Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/junior-plugin-api/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,20 @@ export interface PluginRuntimePostinstallCommand {
sudo?: boolean;
}

export interface PluginMcpAuthConfig {
/** Issuer string the MCP server trusts for this bot. */
issuer: string;
/** JWKS key id of the published public key that pairs with the private key. */
keyId: string;
/** Env var holding the PEM (PKCS#8) private signing key. */
privateKeyEnv: string;
}

export interface PluginMcpConfig {
/** Provider tools exposed directly to the model. */
allowedTools?: string[];
/** Bot auth: sign short-lived JWT assertions instead of per-actor OAuth. */
auth?: PluginMcpAuthConfig;
Comment thread
mchen-sentry marked this conversation as resolved.
headers?: Record<string, string>;
transport: "http";
url: string;
Expand Down
97 changes: 97 additions & 0 deletions packages/junior/src/chat/mcp/jwt-bearer-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Non-interactive MCP auth for bot plugins.
*
* When a manifest declares `mcp.auth`, this provider signs a short-lived RFC 7523 jwt-bearer
* assertion with a plugin-held private key and the SDK exchanges it at the server token endpoint.
* No user, no browser redirect; expired access tokens re-mint automatically on the next 401.
* The absent redirectUrl is what routes the SDK into its non-interactive token flow, the
* `oauth-id-jag+jwt` typ header is what identity-assertion servers require, and the placeholder
* redirect_uri exists only because dynamic client registration rejects an empty list.
*/
import { randomUUID } from "node:crypto";
import type {
OAuthClientProvider,
OAuthDiscoveryState,
} from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformationMixed,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { importPKCS8, SignJWT } from "jose";
import type { PluginMcpConfig } from "@sentry/junior-plugin-api";

const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
const ALGORITHM = "RS256";

function nonInteractive(): never {
throw new Error("jwt-bearer MCP auth is non-interactive");
}

/** Build the OAuth client provider that signs jwt-bearer assertions for one bot MCP plugin. */
export function createJwtBearerMcpClientProvider(
subject: string,
mcpUrl: string,
auth: NonNullable<PluginMcpConfig["auth"]>,
): OAuthClientProvider {
let discovery: OAuthDiscoveryState | undefined;
let clientInfo: OAuthClientInformationMixed | undefined;
let tokens: OAuthTokens | undefined;

return {
redirectUrl: undefined,
clientMetadata: {
client_name: subject,
redirect_uris: ["http://localhost"],
token_endpoint_auth_method: "none",
},
Comment thread
mchen-sentry marked this conversation as resolved.
discoveryState: () => discovery,
saveDiscoveryState: (state) => {
discovery = state;
},
clientInformation: () => clientInfo,
saveClientInformation: (info) => {
clientInfo = info;
},
tokens: () => tokens,
saveTokens: (next) => {
tokens = next;
},
Comment thread
mchen-sentry marked this conversation as resolved.
redirectToAuthorization: nonInteractive,
saveCodeVerifier: nonInteractive,
codeVerifier: nonInteractive,
prepareTokenRequest: async () => {
if (!discovery) {
throw new Error("jwt-bearer token request requires discovered server metadata");
}
const privateKeyPem = process.env[auth.privateKeyEnv];
if (!privateKeyPem) {
throw new Error(
`jwt-bearer MCP auth env var ${auth.privateKeyEnv} is unset`,
);
}
const assertion = await new SignJWT({
client_id: clientInfo?.client_id,
resource: mcpUrl,
})
.setProtectedHeader({
alg: ALGORITHM,
kid: auth.keyId,
typ: "oauth-id-jag+jwt",
})
.setIssuer(auth.issuer)
.setSubject(subject)
.setAudience(
discovery.authorizationServerMetadata?.issuer ??
discovery.authorizationServerUrl,
)
.setIssuedAt()
.setExpirationTime("5m")
.setJti(randomUUID())
.sign(await importPKCS8(privateKeyPem, ALGORITHM));
return new URLSearchParams({
grant_type: JWT_BEARER_GRANT_TYPE,
assertion,
});
},
};
}
14 changes: 13 additions & 1 deletion packages/junior/src/chat/plugins/inline-manifest-source.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PluginManifest } from "./types";
import type { PluginManifest, PluginMcpConfig } from "./types";

type ManifestSource = Record<string, unknown>;

Expand Down Expand Up @@ -54,6 +54,17 @@ function inlineCredentialsSource(
return result;
}

/** Convert a camelCase `mcp.auth` block to its plugin.yaml source keys. */
export function mcpAuthSource(
auth: NonNullable<PluginMcpConfig["auth"]>,
): ManifestSource {
return {
issuer: auth.issuer,
"key-id": auth.keyId,
"private-key-env": auth.privateKeyEnv,
};
}

function inlineMcpSource(mcp: PluginManifest["mcp"]): unknown {
if (mcp === undefined || !isRecord(mcp)) {
return mcp;
Expand All @@ -63,6 +74,7 @@ function inlineMcpSource(mcp: PluginManifest["mcp"]): unknown {
setDefined(result, "transport", mcp.transport);
setDefined(result, "url", mcp.url);
setDefined(result, "headers", mcp.headers);
setDefined(result, "auth", mcp.auth && mcpAuthSource(mcp.auth));
setDefined(result, "allowed-tools", mcp.allowedTools);
setDefined(result, "wrapped-tools", mcp.wrappedTools);
return result;
Expand Down
29 changes: 28 additions & 1 deletion packages/junior/src/chat/plugins/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import type {
PluginSystemRuntimeDependency,
PluginSystemRuntimeDependencyFromUrl,
} from "./types";
import { inlineManifestSource } from "./inline-manifest-source";
import {
inlineManifestSource,
mcpAuthSource,
} from "./inline-manifest-source";

const PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/;
const SHORT_CONFIG_KEY_RE = /^[a-z0-9]+(\.[a-z0-9-]+)*$/;
Expand Down Expand Up @@ -227,6 +230,14 @@ const oauthSourceSchema = z
})
.passthrough();

const mcpAuthSourceSchema = z
.object({
issuer: nonEmptyTrimmedString,
"key-id": nonEmptyTrimmedString,
"private-key-env": envVarString,
})
.passthrough();

const mcpSourceSchema = z
.object({
transport: nonEmptyTrimmedString
Expand All @@ -235,6 +246,7 @@ const mcpSourceSchema = z
})
.optional(),
url: httpsUrlString,
auth: mcpAuthSourceSchema.optional(),
headers: stringMapSchema.optional(),
"allowed-tools": nonEmptyStringArraySchema("allowed-tools").optional(),
"wrapped-tools": nonEmptyStringArraySchema("wrapped-tools").optional(),
Expand Down Expand Up @@ -360,6 +372,7 @@ function manifestConfigPatch(
setDefined(mcp, "transport", config.mcp.transport);
setDefined(mcp, "url", config.mcp.url);
setDefined(mcp, "headers", config.mcp.headers);
setDefined(mcp, "auth", config.mcp.auth && mcpAuthSource(config.mcp.auth));
setDefined(mcp, "allowed-tools", config.mcp.allowedTools);
setDefined(mcp, "wrapped-tools", config.mcp.wrappedTools);
result.mcp = mcp;
Expand Down Expand Up @@ -588,13 +601,17 @@ function assertCommandEnvDoesNotExposeHostSecretRefs(
apiHeaders: Record<string, string> | undefined,
credentials: PluginCredentials | undefined,
oauth: PluginOAuthConfig | undefined,
mcp: PluginMcpConfig | undefined,
pluginName: string,
): void {
if (!commandEnv) {
return;
}

const hostOnlyRefs = new Set<string>();
if (mcp?.auth) {
hostOnlyRefs.add(mcp.auth.privateKeyEnv);
}
for (const value of Object.values(apiHeaders ?? {})) {
for (const name of envReferences(value)) {
hostOnlyRefs.add(name);
Expand Down Expand Up @@ -945,9 +962,18 @@ function normalizeMcp(
})
: undefined;

const auth = result.data.auth
? {
issuer: result.data.auth.issuer,
keyId: result.data.auth["key-id"],
privateKeyEnv: result.data.auth["private-key-env"],
}
: undefined;

return {
transport: "http",
url: result.data.url,
...(auth ? { auth } : undefined),
...(headers ? { headers } : undefined),
...(result.data["allowed-tools"]
? { allowedTools: result.data["allowed-tools"] }
Expand Down Expand Up @@ -1150,6 +1176,7 @@ function parseManifestSource(
apiHeaders,
credentials,
manifest.oauth,
mcp,
data.name,
);
assertCommandEnvHostRefsAreExplicitlyExposed(
Expand Down
1 change: 1 addition & 0 deletions packages/junior/src/chat/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export interface PluginManifestConfig {
transport?: "http";
url?: string;
headers?: Record<string, string | null> | null;
auth?: PluginMcpConfig["auth"];
allowedTools?: string[] | null;
wrappedTools?: string[] | null;
} | null;
Expand Down
9 changes: 9 additions & 0 deletions packages/junior/src/chat/services/mcp-auth-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type { Destination, Source } from "@sentry/junior-plugin-api";
import { createJwtBearerMcpClientProvider } from "@/chat/mcp/jwt-bearer-provider";
import { createMcpOAuthClientProvider } from "@/chat/mcp/oauth";
import {
deleteMcpAuthSession,
Expand Down Expand Up @@ -84,6 +85,14 @@ export function createMcpAuthOrchestration(
const authProviderFactory = async (
plugin: PluginDefinition,
): Promise<OAuthClientProvider | undefined> => {
const mcp = plugin.manifest.mcp;
if (mcp?.auth) {
return createJwtBearerMcpClientProvider(
plugin.manifest.name,
mcp.url,
mcp.auth,
);
}
Comment thread
mchen-sentry marked this conversation as resolved.
if (!input.conversationId || !input.sessionId || !input.actorId) {
return undefined;
}
Expand Down
60 changes: 60 additions & 0 deletions packages/junior/tests/unit/mcp/jwt-bearer-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { generateKeyPairSync } from "node:crypto";
import { decodeProtectedHeader, jwtVerify } from "jose";
import { afterEach, describe, expect, it } from "vitest";
import { createJwtBearerMcpClientProvider } from "@/chat/mcp/jwt-bearer-provider";

describe("createJwtBearerMcpClientProvider", () => {
afterEach(() => {
delete process.env.TEST_MCP_PRIVATE_KEY;
});

it("prepares a jwt-bearer grant with a verifiable signed assertion", async () => {
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
process.env.TEST_MCP_PRIVATE_KEY = privateKey
.export({ type: "pkcs8", format: "pem" })
.toString();
const provider = createJwtBearerMcpClientProvider(
"gocd-mcp",
"https://mcp.example.test/mcp",
{
issuer: "https://junior.example.test",
keyId: "junior-1",
privateKeyEnv: "TEST_MCP_PRIVATE_KEY",
},
);
await provider.saveDiscoveryState?.({
authorizationServerUrl: "https://mcp.example.test",
authorizationServerMetadata: {
issuer: "https://mcp.example.test/",
authorization_endpoint: "https://mcp.example.test/authorize",
token_endpoint: "https://mcp.example.test/token",
response_types_supported: ["code"],
},
});
await provider.saveClientInformation?.({ client_id: "client-123" });

const params = await provider.prepareTokenRequest?.();

expect(params?.get("grant_type")).toBe(
"urn:ietf:params:oauth:grant-type:jwt-bearer",
);
const assertion = params?.get("assertion") ?? "";
expect(decodeProtectedHeader(assertion)).toMatchObject({
alg: "RS256",
kid: "junior-1",
typ: "oauth-id-jag+jwt",
});
const { payload } = await jwtVerify(assertion, publicKey, {
issuer: "https://junior.example.test",
audience: "https://mcp.example.test/",
});
expect(payload).toMatchObject({
sub: "gocd-mcp",
client_id: "client-123",
resource: "https://mcp.example.test/mcp",
});
expect(payload.jti).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,25 @@ describe("inline plugin manifests", () => {
});
});

it("preserves wrapped MCP tool declarations", () => {
it("preserves wrapped MCP tool and bot auth declarations", () => {
const auth = {
issuer: "https://junior.example.test",
keyId: "junior-1",
privateKeyEnv: "LINEAR_MCP_PRIVATE_KEY",
};
const manifest = parse({
name: "linear",
displayName: "Linear",
description: "Linear issue tracking",
mcp: {
transport: "http",
url: "https://mcp.linear.app/mcp",
auth,
wrappedTools: ["create_issue"],
},
});

expect(manifest.mcp?.wrappedTools).toEqual(["create_issue"]);
expect(manifest.mcp?.auth).toEqual(auth);
});
});
Loading