Skip to content

Commit e5248e5

Browse files
[Fix] MCP OAuth registration fails for servers advertising unsupported grants (#1532)
* fix(oauth): filter dynamic registration grant types * test(mcp): validate OAuth registration integration * fix(oauth): honor default server grant support --------- Co-authored-by: Roomote <roomote@roomote.dev>
1 parent 4e7f7de commit e5248e5

6 files changed

Lines changed: 249 additions & 12 deletions

File tree

‎.github/workflows/code-qa.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ jobs:
9292
run: pnpm check-types
9393
- name: Model-check task lifecycle protocols
9494
run: pnpm lifecycle:model-check
95+
- name: Validate MCP OAuth integration
96+
run: pnpm mcp:integration-check
9597

9698
build-vsix:
9799
name: Build test VSIX

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"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",
1717
"cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts",
1818
"parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs",
19+
"mcp:integration-check": "tsx scripts/check-mcp-oauth-integration.ts",
1920
"test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only",
2021
"format": "turbo format --log-order grouped --output-logs new-only",
2122
"build": "turbo build --log-order grouped --output-logs new-only",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import assert from "node:assert/strict"
2+
3+
import {
4+
AUTHORIZATION_CODE_GRANT_TYPE,
5+
buildMcpOAuthClientMetadata,
6+
MCP_OAUTH_GRANT_TYPES,
7+
REFRESH_TOKEN_GRANT_TYPE,
8+
selectMcpOAuthGrantTypes,
9+
} from "../src/services/mcp/oauthMetadata"
10+
11+
const advertisedGrantTypes = [
12+
AUTHORIZATION_CODE_GRANT_TYPE,
13+
REFRESH_TOKEN_GRANT_TYPE,
14+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
15+
"urn:example:grant-type:extension",
16+
] as const
17+
18+
let checkedCases = 0
19+
20+
// Repository policy: Zoo Code implements only these two token-endpoint grants.
21+
// Keeping this assertion literal prevents an allowlist expansion from silently
22+
// broadening dynamic registration.
23+
assert.deepEqual(MCP_OAUTH_GRANT_TYPES, ["authorization_code", "refresh_token"])
24+
25+
for (let mask = 0; mask < 1 << advertisedGrantTypes.length; mask++) {
26+
const advertised = advertisedGrantTypes.filter((_, index) => mask & (1 << index))
27+
const selected = selectMcpOAuthGrantTypes(advertised)
28+
const expected = MCP_OAUTH_GRANT_TYPES.filter((grantType) => advertised.includes(grantType))
29+
30+
// Normative MUST: RFC 7591 section 2 says grant_types describes grants the
31+
// client can use, and each token-endpoint grant_type must match its registered
32+
// value. https://www.rfc-editor.org/rfc/rfc7591.html#section-2
33+
// Repository policy: intersect server metadata with Zoo Code's implemented
34+
// grants, canonicalize order, and never propagate unknown extension values.
35+
assert.deepEqual(selected, expected, `unexpected grant selection for ${JSON.stringify(advertised)}`)
36+
assert.equal(new Set(selected).size, selected.length, "registration grant types must be unique")
37+
38+
const buildMetadata = () =>
39+
buildMcpOAuthClientMetadata({
40+
clientName: "Zoo Code",
41+
redirectUrl: "http://localhost:12345/callback",
42+
grantTypes: selected,
43+
tokenEndpointAuthMethod: "none",
44+
})
45+
46+
if (!selected.includes(AUTHORIZATION_CODE_GRANT_TYPE)) {
47+
assert.throws(buildMetadata, /requires authorization_code support/)
48+
checkedCases++
49+
continue
50+
}
51+
52+
const metadata = buildMetadata()
53+
54+
assert.deepEqual(metadata.grant_types, selected)
55+
// Normative SHOULD: RFC 7591 section 2.1 recommends consistent
56+
// authorization_code/code metadata.
57+
// https://www.rfc-editor.org/rfc/rfc7591.html#section-2.1
58+
assert.deepEqual(metadata.response_types, ["code"])
59+
// Normative MUST/SHOULD: MCP 2026-07-28 requires DCR clients to declare
60+
// application_type; desktop clients using localhost should identify as native.
61+
// https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints
62+
assert.equal(metadata.application_type, "native")
63+
assert.match(metadata.redirect_uris[0], /^http:\/\/localhost:/)
64+
65+
checkedCases++
66+
}
67+
68+
// RFC 8414 defaults omitted grant_types_supported to authorization_code and
69+
// implicit. Zoo Code implements only authorization_code from that default.
70+
// https://www.rfc-editor.org/rfc/rfc8414.html#section-2
71+
assert.deepEqual(selectMcpOAuthGrantTypes(), [AUTHORIZATION_CODE_GRANT_TYPE])
72+
assert.deepEqual(
73+
selectMcpOAuthGrantTypes([REFRESH_TOKEN_GRANT_TYPE, AUTHORIZATION_CODE_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE]),
74+
[...MCP_OAUTH_GRANT_TYPES],
75+
)
76+
77+
console.log(`MCP OAuth integration check passed (${checkedCases} advertised-grant combinations)`)

‎src/services/mcp/McpOAuthClientProvider.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import type {
1010
} from "@modelcontextprotocol/sdk/shared/auth.js"
1111

1212
import { TOKEN_EXPIRY_BUFFER_MS } from "./constants"
13+
import {
14+
AUTHORIZATION_CODE_GRANT_TYPE,
15+
buildMcpOAuthClientMetadata,
16+
REFRESH_TOKEN_GRANT_TYPE,
17+
selectMcpOAuthGrantTypes,
18+
type McpOAuthGrantType,
19+
} from "./oauthMetadata"
1320
import { SecretStorageService } from "./SecretStorageService"
1421
import { startCallbackServer, stopCallbackServer } from "./utils/callbackServer"
1522
import { fetchOAuthAuthServerMetadata } from "./utils/oauth"
@@ -80,7 +87,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
8087
private _authCodePromise: Promise<string> | null,
8188
private _cancelCallbackServer: (() => void) | null,
8289
private readonly _tokenEndpointAuthMethod: string,
83-
private readonly _grantTypes: string[],
90+
private readonly _grantTypes: McpOAuthGrantType[],
8491
private readonly _scopes: string[],
8592
private readonly _state: string,
8693
private readonly _authServerMeta: Record<string, any> | null,
@@ -126,7 +133,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
126133
// Only pick methods we actually implement: "none" or "client_secret_post".
127134
const authMethods: string[] = authServerMeta?.token_endpoint_auth_methods_supported ?? []
128135
const tokenEndpointAuthMethod = authMethods.includes("none") ? "none" : "client_secret_post"
129-
const grantTypes: string[] = authServerMeta?.grant_types_supported ?? ["authorization_code", "refresh_token"]
136+
const grantTypes = selectMcpOAuthGrantTypes(authServerMeta?.grant_types_supported)
130137
const scopes: string[] = authServerMeta?.scopes_supported ?? []
131138

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

198205
get clientMetadata(): OAuthClientMetadata {
199-
return {
200-
client_name: this._clientName,
201-
redirect_uris: [this.redirectUrl],
202-
grant_types: this._grantTypes,
203-
response_types: ["code"],
204-
token_endpoint_auth_method: this._tokenEndpointAuthMethod,
205-
}
206+
return buildMcpOAuthClientMetadata({
207+
clientName: this._clientName,
208+
redirectUrl: this.redirectUrl,
209+
grantTypes: this._grantTypes,
210+
tokenEndpointAuthMethod: this._tokenEndpointAuthMethod,
211+
})
206212
}
207213

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

439445
// Build the token request body per RFC 6749 §4.1.3 + RFC 7636 §4.5.
440446
const params: Record<string, string> = {
441-
grant_type: "authorization_code",
447+
grant_type: AUTHORIZATION_CODE_GRANT_TYPE,
442448
code: authorizationCode,
443449
redirect_uri: this.redirectUrl,
444450
client_id: this._clientInfo.client_id,
@@ -493,7 +499,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
493499
}
494500

495501
const params: Record<string, string> = {
496-
grant_type: "refresh_token",
502+
grant_type: REFRESH_TOKEN_GRANT_TYPE,
497503
refresh_token: refreshToken,
498504
client_id: clientId,
499505
}

‎src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts‎

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,26 @@ describe("McpOAuthClientProvider", () => {
190190

191191
expect(metadata.client_name).toBe("Roo Code")
192192
expect(metadata.redirect_uris).toEqual(["http://localhost:0/callback"])
193-
expect(metadata.grant_types).toContain("authorization_code")
193+
expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"])
194194
expect(metadata.response_types).toContain("code")
195195
expect(metadata.token_endpoint_auth_method).toBe("none")
196+
expect(metadata).toMatchObject({ application_type: "native" })
197+
await provider.close()
198+
})
199+
200+
it("should default to authorization code when server grant metadata is omitted", async () => {
201+
mockFetch.mockResolvedValueOnce({
202+
ok: true,
203+
json: () =>
204+
Promise.resolve({
205+
issuer: "https://auth.example.com",
206+
token_endpoint_auth_methods_supported: ["none"],
207+
}),
208+
})
209+
210+
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
211+
212+
expect(provider.clientMetadata.grant_types).toEqual(["authorization_code"])
196213
await provider.close()
197214
})
198215

@@ -207,6 +224,61 @@ describe("McpOAuthClientProvider", () => {
207224
expect(provider.clientMetadata.client_name).toBe("figma")
208225
await provider.close()
209226
})
227+
228+
it("should exclude jwt-bearer from advertised grant types", async () => {
229+
mockFetch.mockResolvedValueOnce({
230+
ok: true,
231+
json: () =>
232+
Promise.resolve({
233+
issuer: "https://auth.example.com",
234+
token_endpoint_auth_methods_supported: ["none"],
235+
grant_types_supported: [
236+
"authorization_code",
237+
"refresh_token",
238+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
239+
],
240+
}),
241+
})
242+
243+
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
244+
245+
expect(provider.clientMetadata.grant_types).toEqual(["authorization_code", "refresh_token"])
246+
await provider.close()
247+
})
248+
249+
it("should exclude unknown advertised grant types", async () => {
250+
mockFetch.mockResolvedValueOnce({
251+
ok: true,
252+
json: () =>
253+
Promise.resolve({
254+
issuer: "https://auth.example.com",
255+
token_endpoint_auth_methods_supported: ["none"],
256+
grant_types_supported: ["authorization_code", "urn:example:grant-type:foo"],
257+
}),
258+
})
259+
260+
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
261+
262+
expect(provider.clientMetadata.grant_types).toEqual(["authorization_code"])
263+
await provider.close()
264+
})
265+
266+
it("should reject registration metadata when authorization code is unsupported", async () => {
267+
mockFetch.mockResolvedValueOnce({
268+
ok: true,
269+
json: () =>
270+
Promise.resolve({
271+
issuer: "https://auth.example.com",
272+
token_endpoint_auth_methods_supported: ["none"],
273+
grant_types_supported: ["refresh_token"],
274+
}),
275+
})
276+
277+
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
278+
279+
expect(() => provider.clientMetadata).toThrow("authorization_code")
280+
await provider.close()
281+
})
210282
})
211283

212284
describe("clientInformation / saveClientInformation", () => {
@@ -806,6 +878,47 @@ describe("McpOAuthClientProvider", () => {
806878
await provider.close()
807879
})
808880

881+
it("should register when the endpoint rejects unsupported grant types", async () => {
882+
setupCallbackServerMock()
883+
const secretStorage = createMockSecretStorage()
884+
885+
mockFetch.mockClear()
886+
mockFetch.mockResolvedValueOnce({
887+
ok: true,
888+
json: () =>
889+
Promise.resolve({
890+
issuer: "https://auth.example.com",
891+
authorization_endpoint: "https://auth.example.com/authorize",
892+
token_endpoint: "https://auth.example.com/token",
893+
registration_endpoint: "https://auth.example.com/register",
894+
token_endpoint_auth_methods_supported: ["none"],
895+
grant_types_supported: [
896+
"authorization_code",
897+
"refresh_token",
898+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
899+
],
900+
}),
901+
})
902+
mockFetch.mockImplementationOnce((_url, init) => {
903+
const body = JSON.parse(init?.body as string)
904+
const hasUnsupportedGrant = body.grant_types.some(
905+
(grantType: string) => !["authorization_code", "refresh_token"].includes(grantType),
906+
)
907+
908+
return Promise.resolve({
909+
ok: !hasUnsupportedGrant,
910+
status: hasUnsupportedGrant ? 400 : 200,
911+
json: () => Promise.resolve({ client_id: "registered-client-id" }),
912+
})
913+
})
914+
915+
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
916+
917+
await expect(provider.registerClientIfNeeded()).resolves.toBeUndefined()
918+
expect((await provider.clientInformation())?.client_id).toBe("registered-client-id")
919+
await provider.close()
920+
})
921+
809922
it("should use the same redirect URI in DCR and authorization flow", async () => {
810923
setupCallbackServerMock()
811924
const secretStorage = createMockSecretStorage()

‎src/services/mcp/oauthMetadata.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"
2+
3+
export const MCP_OAUTH_GRANT_TYPES = ["authorization_code", "refresh_token"] as const
4+
export type McpOAuthGrantType = (typeof MCP_OAUTH_GRANT_TYPES)[number]
5+
6+
export const AUTHORIZATION_CODE_GRANT_TYPE = MCP_OAUTH_GRANT_TYPES[0]
7+
export const REFRESH_TOKEN_GRANT_TYPE = MCP_OAUTH_GRANT_TYPES[1]
8+
9+
export interface McpOAuthClientMetadata extends OAuthClientMetadata {
10+
application_type: "native"
11+
}
12+
13+
/** Selects the grants Zoo Code implements from authorization-server metadata. */
14+
export function selectMcpOAuthGrantTypes(supportedGrantTypes?: readonly string[]): McpOAuthGrantType[] {
15+
const supported = new Set(supportedGrantTypes ?? [AUTHORIZATION_CODE_GRANT_TYPE])
16+
return MCP_OAUTH_GRANT_TYPES.filter((grantType) => supported.has(grantType))
17+
}
18+
19+
/** Builds dynamic-registration metadata for Zoo Code's native authorization-code client. */
20+
export function buildMcpOAuthClientMetadata(options: {
21+
clientName: string
22+
redirectUrl: string
23+
grantTypes: readonly McpOAuthGrantType[]
24+
tokenEndpointAuthMethod: string
25+
}): McpOAuthClientMetadata {
26+
if (!options.grantTypes.includes(AUTHORIZATION_CODE_GRANT_TYPE)) {
27+
throw new Error("MCP OAuth registration requires authorization_code support")
28+
}
29+
30+
return {
31+
application_type: "native",
32+
client_name: options.clientName,
33+
redirect_uris: [options.redirectUrl],
34+
grant_types: [...options.grantTypes],
35+
response_types: ["code"],
36+
token_endpoint_auth_method: options.tokenEndpointAuthMethod,
37+
}
38+
}

0 commit comments

Comments
 (0)