Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/code-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ jobs:
run: pnpm check-types
- name: Model-check task lifecycle protocols
run: pnpm lifecycle:model-check
- name: Validate MCP OAuth integration
run: pnpm mcp:integration-check

build-vsix:
name: Build test VSIX
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts",
"cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts",
"parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs",
"mcp:integration-check": "tsx scripts/check-mcp-oauth-integration.ts",
"test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only",
"format": "turbo format --log-order grouped --output-logs new-only",
"build": "turbo build --log-order grouped --output-logs new-only",
Expand Down
77 changes: 77 additions & 0 deletions scripts/check-mcp-oauth-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from "node:assert/strict"

import {
AUTHORIZATION_CODE_GRANT_TYPE,
buildMcpOAuthClientMetadata,
MCP_OAUTH_GRANT_TYPES,
REFRESH_TOKEN_GRANT_TYPE,
selectMcpOAuthGrantTypes,
} from "../src/services/mcp/oauthMetadata"

const advertisedGrantTypes = [
AUTHORIZATION_CODE_GRANT_TYPE,
REFRESH_TOKEN_GRANT_TYPE,
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:example:grant-type:extension",
] as const

let checkedCases = 0

// Repository policy: Zoo Code implements only these two token-endpoint grants.
// Keeping this assertion literal prevents an allowlist expansion from silently
// broadening dynamic registration.
assert.deepEqual(MCP_OAUTH_GRANT_TYPES, ["authorization_code", "refresh_token"])

for (let mask = 0; mask < 1 << advertisedGrantTypes.length; mask++) {
const advertised = advertisedGrantTypes.filter((_, index) => mask & (1 << index))
const selected = selectMcpOAuthGrantTypes(advertised)
const expected = MCP_OAUTH_GRANT_TYPES.filter((grantType) => advertised.includes(grantType))

// Normative MUST: RFC 7591 section 2 says grant_types describes grants the
// client can use, and each token-endpoint grant_type must match its registered
// value. https://www.rfc-editor.org/rfc/rfc7591.html#section-2
// Repository policy: intersect server metadata with Zoo Code's implemented
// grants, canonicalize order, and never propagate unknown extension values.
assert.deepEqual(selected, expected, `unexpected grant selection for ${JSON.stringify(advertised)}`)
assert.equal(new Set(selected).size, selected.length, "registration grant types must be unique")

const buildMetadata = () =>
buildMcpOAuthClientMetadata({
clientName: "Zoo Code",
redirectUrl: "http://localhost:12345/callback",
grantTypes: selected,
tokenEndpointAuthMethod: "none",
})

if (!selected.includes(AUTHORIZATION_CODE_GRANT_TYPE)) {
assert.throws(buildMetadata, /requires authorization_code support/)
checkedCases++
continue
}

const metadata = buildMetadata()

assert.deepEqual(metadata.grant_types, selected)
// Normative SHOULD: RFC 7591 section 2.1 recommends consistent
// authorization_code/code metadata.
// https://www.rfc-editor.org/rfc/rfc7591.html#section-2.1
assert.deepEqual(metadata.response_types, ["code"])
// Normative MUST/SHOULD: MCP 2026-07-28 requires DCR clients to declare
// application_type; desktop clients using localhost should identify as native.
// https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints
assert.equal(metadata.application_type, "native")
assert.match(metadata.redirect_uris[0], /^http:\/\/localhost:/)

checkedCases++
}

// RFC 8414 defaults omitted grant_types_supported to authorization_code and
// implicit. Zoo Code implements only authorization_code from that default.
// https://www.rfc-editor.org/rfc/rfc8414.html#section-2
assert.deepEqual(selectMcpOAuthGrantTypes(), [AUTHORIZATION_CODE_GRANT_TYPE])
assert.deepEqual(
selectMcpOAuthGrantTypes([REFRESH_TOKEN_GRANT_TYPE, AUTHORIZATION_CODE_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE]),
[...MCP_OAUTH_GRANT_TYPES],
)

console.log(`MCP OAuth integration check passed (${checkedCases} advertised-grant combinations)`)
28 changes: 17 additions & 11 deletions src/services/mcp/McpOAuthClientProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import type {
} from "@modelcontextprotocol/sdk/shared/auth.js"

import { TOKEN_EXPIRY_BUFFER_MS } from "./constants"
import {
AUTHORIZATION_CODE_GRANT_TYPE,
buildMcpOAuthClientMetadata,
REFRESH_TOKEN_GRANT_TYPE,
selectMcpOAuthGrantTypes,
type McpOAuthGrantType,
} from "./oauthMetadata"
import { SecretStorageService } from "./SecretStorageService"
import { startCallbackServer, stopCallbackServer } from "./utils/callbackServer"
import { fetchOAuthAuthServerMetadata } from "./utils/oauth"
Expand Down Expand Up @@ -80,7 +87,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
private _authCodePromise: Promise<string> | null,
private _cancelCallbackServer: (() => void) | null,
private readonly _tokenEndpointAuthMethod: string,
private readonly _grantTypes: string[],
private readonly _grantTypes: McpOAuthGrantType[],
private readonly _scopes: string[],
private readonly _state: string,
private readonly _authServerMeta: Record<string, any> | null,
Expand Down Expand Up @@ -126,7 +133,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
// Only pick methods we actually implement: "none" or "client_secret_post".
const authMethods: string[] = authServerMeta?.token_endpoint_auth_methods_supported ?? []
const tokenEndpointAuthMethod = authMethods.includes("none") ? "none" : "client_secret_post"
const grantTypes: string[] = authServerMeta?.grant_types_supported ?? ["authorization_code", "refresh_token"]
const grantTypes = selectMcpOAuthGrantTypes(authServerMeta?.grant_types_supported)
const scopes: string[] = authServerMeta?.scopes_supported ?? []

// Generate a CSRF state token for the OAuth flow.
Expand Down Expand Up @@ -196,13 +203,12 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
}

get clientMetadata(): OAuthClientMetadata {
return {
client_name: this._clientName,
redirect_uris: [this.redirectUrl],
grant_types: this._grantTypes,
response_types: ["code"],
token_endpoint_auth_method: this._tokenEndpointAuthMethod,
}
return buildMcpOAuthClientMetadata({
clientName: this._clientName,
redirectUrl: this.redirectUrl,
grantTypes: this._grantTypes,
tokenEndpointAuthMethod: this._tokenEndpointAuthMethod,
})
}

async clientInformation(): Promise<OAuthClientInformation | undefined> {
Expand Down Expand Up @@ -438,7 +444,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {

// Build the token request body per RFC 6749 §4.1.3 + RFC 7636 §4.5.
const params: Record<string, string> = {
grant_type: "authorization_code",
grant_type: AUTHORIZATION_CODE_GRANT_TYPE,
code: authorizationCode,
redirect_uri: this.redirectUrl,
client_id: this._clientInfo.client_id,
Expand Down Expand Up @@ -493,7 +499,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
}

const params: Record<string, string> = {
grant_type: "refresh_token",
grant_type: REFRESH_TOKEN_GRANT_TYPE,
refresh_token: refreshToken,
client_id: clientId,
}
Expand Down
115 changes: 114 additions & 1 deletion src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,26 @@ describe("McpOAuthClientProvider", () => {

expect(metadata.client_name).toBe("Roo Code")
expect(metadata.redirect_uris).toEqual(["http://localhost:0/callback"])
expect(metadata.grant_types).toContain("authorization_code")
expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"])
expect(metadata.response_types).toContain("code")
expect(metadata.token_endpoint_auth_method).toBe("none")
expect(metadata).toMatchObject({ application_type: "native" })
await provider.close()
})

it("should default to authorization code when server grant metadata is omitted", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
token_endpoint_auth_methods_supported: ["none"],
}),
})

const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())

expect(provider.clientMetadata.grant_types).toEqual(["authorization_code"])
await provider.close()
})

Expand All @@ -207,6 +224,61 @@ describe("McpOAuthClientProvider", () => {
expect(provider.clientMetadata.client_name).toBe("figma")
await provider.close()
})

it("should exclude jwt-bearer from advertised grant types", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
],
}),
})

const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())

expect(provider.clientMetadata.grant_types).toEqual(["authorization_code", "refresh_token"])
await provider.close()
})

it("should exclude unknown advertised grant types", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: ["authorization_code", "urn:example:grant-type:foo"],
}),
})

const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())

expect(provider.clientMetadata.grant_types).toEqual(["authorization_code"])
await provider.close()
})

it("should reject registration metadata when authorization code is unsupported", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: ["refresh_token"],
}),
})

const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())

expect(() => provider.clientMetadata).toThrow("authorization_code")
await provider.close()
})
})

describe("clientInformation / saveClientInformation", () => {
Expand Down Expand Up @@ -806,6 +878,47 @@ describe("McpOAuthClientProvider", () => {
await provider.close()
})

it("should register when the endpoint rejects unsupported grant types", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()

mockFetch.mockClear()
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
],
}),
})
mockFetch.mockImplementationOnce((_url, init) => {
const body = JSON.parse(init?.body as string)
const hasUnsupportedGrant = body.grant_types.some(
(grantType: string) => !["authorization_code", "refresh_token"].includes(grantType),
)

return Promise.resolve({
ok: !hasUnsupportedGrant,
status: hasUnsupportedGrant ? 400 : 200,
json: () => Promise.resolve({ client_id: "registered-client-id" }),
})
})

const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)

await expect(provider.registerClientIfNeeded()).resolves.toBeUndefined()
expect((await provider.clientInformation())?.client_id).toBe("registered-client-id")
await provider.close()
})

it("should use the same redirect URI in DCR and authorization flow", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
Expand Down
38 changes: 38 additions & 0 deletions src/services/mcp/oauthMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"

export const MCP_OAUTH_GRANT_TYPES = ["authorization_code", "refresh_token"] as const
export type McpOAuthGrantType = (typeof MCP_OAUTH_GRANT_TYPES)[number]

export const AUTHORIZATION_CODE_GRANT_TYPE = MCP_OAUTH_GRANT_TYPES[0]
export const REFRESH_TOKEN_GRANT_TYPE = MCP_OAUTH_GRANT_TYPES[1]

export interface McpOAuthClientMetadata extends OAuthClientMetadata {
application_type: "native"
}

/** Selects the grants Zoo Code implements from authorization-server metadata. */
export function selectMcpOAuthGrantTypes(supportedGrantTypes?: readonly string[]): McpOAuthGrantType[] {
const supported = new Set(supportedGrantTypes ?? [AUTHORIZATION_CODE_GRANT_TYPE])
return MCP_OAUTH_GRANT_TYPES.filter((grantType) => supported.has(grantType))
}

/** Builds dynamic-registration metadata for Zoo Code's native authorization-code client. */
export function buildMcpOAuthClientMetadata(options: {
clientName: string
redirectUrl: string
grantTypes: readonly McpOAuthGrantType[]
tokenEndpointAuthMethod: string
}): McpOAuthClientMetadata {
if (!options.grantTypes.includes(AUTHORIZATION_CODE_GRANT_TYPE)) {
throw new Error("MCP OAuth registration requires authorization_code support")
}

return {
application_type: "native",
client_name: options.clientName,
redirect_uris: [options.redirectUrl],
grant_types: [...options.grantTypes],
response_types: ["code"],
token_endpoint_auth_method: options.tokenEndpointAuthMethod,
}
}
Loading