Skip to content

Commit 04f7b7d

Browse files
authored
Merge pull request #50 from nestm-dev/codex/oauth-dcr-response-compatibility
fix(client): accept compatible DCR success responses
2 parents 391df98 + 7346f94 commit 04f7b7d

3 files changed

Lines changed: 60 additions & 22 deletions

File tree

.changeset/soft-mice-register.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nestm/mcp-client": patch
3+
---
4+
5+
Accept successful legacy dynamic-registration responses that follow the MCP SDK schema while still rejecting conflicting security-sensitive metadata.

packages/mcp-client/src/oauth/dynamic-registration.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,6 @@ export class McpClientOAuthDynamicRegistration {
173173
true,
174174
);
175175
}
176-
if (response.status !== 201) {
177-
await discardResponse(response);
178-
throw registrationError(McpClientOAuthDynamicRegistrationErrorCode.ResponseInvalid, true);
179-
}
180176

181177
try {
182178
const body = await readBoundedJson(response);
@@ -329,22 +325,25 @@ function normalizeRegistrationResult(
329325
if (!sameStrings(redirectUris, input.redirectUris)) {
330326
throw registrationError(McpClientOAuthDynamicRegistrationErrorCode.ResponseInvalid);
331327
}
332-
if (value.application_type !== input.applicationType) {
328+
if (value.application_type !== undefined && value.application_type !== input.applicationType) {
333329
throw registrationError(McpClientOAuthDynamicRegistrationErrorCode.ResponseInvalid);
334330
}
335-
if (value.token_endpoint_auth_method !== "none") {
331+
if (
332+
value.token_endpoint_auth_method !== undefined &&
333+
value.token_endpoint_auth_method !== "none"
334+
) {
336335
throw registrationError(McpClientOAuthDynamicRegistrationErrorCode.ResponseInvalid);
337336
}
338-
const responseTypes = normalizeRemoteStringList(value.response_types, 16);
339-
const grantTypes = normalizeRemoteStringList(value.grant_types, 16);
340-
if (!responseTypes.includes("code") || !grantTypes.includes("authorization_code")) {
337+
const responseTypes = normalizeRemoteOptionalStringList(value.response_types, 16);
338+
const grantTypes = normalizeRemoteOptionalStringList(value.grant_types, 16);
339+
if (
340+
(responseTypes !== undefined && !responseTypes.includes("code")) ||
341+
(grantTypes !== undefined && !grantTypes.includes("authorization_code"))
342+
) {
341343
throw registrationError(McpClientOAuthDynamicRegistrationErrorCode.ResponseInvalid);
342344
}
343345
const clientIdIssuedAt = normalizeRemoteOptionalTimestamp(value.client_id_issued_at);
344346
const clientSecretExpiresAt = normalizeRemoteOptionalTimestamp(value.client_secret_expires_at);
345-
if ((clientSecret === undefined) !== (clientSecretExpiresAt === undefined)) {
346-
throw registrationError(McpClientOAuthDynamicRegistrationErrorCode.ResponseInvalid);
347-
}
348347
const registeredScopes = normalizeRemoteOptionalScope(value.scope);
349348
return Object.freeze({
350349
issuer: input.issuer,
@@ -513,6 +512,13 @@ function normalizeRemoteStringList(value: unknown, maximumCount: number): readon
513512
return Object.freeze(result);
514513
}
515514

515+
function normalizeRemoteOptionalStringList(
516+
value: unknown,
517+
maximumCount: number,
518+
): readonly string[] | undefined {
519+
return value === undefined ? undefined : normalizeRemoteStringList(value, maximumCount);
520+
}
521+
516522
function normalizeRemoteOptionalSecret(value: unknown): string | undefined {
517523
if (value === undefined) return undefined;
518524
return requireRemoteBoundedText(value, MAX_CLIENT_SECRET_LENGTH);

packages/mcp-client/tests/oauth-dynamic-registration.test.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@ describe("McpClientOAuthDynamicRegistration", () => {
8787
expect(Object.isFrozen(result.client.authentication)).toBe(true);
8888
});
8989

90+
it("accepts an SDK-compatible 2xx response with only required echoed metadata", async () => {
91+
const registration = new McpClientOAuthDynamicRegistration({
92+
fetch: async () =>
93+
jsonResponse(
94+
{
95+
client_id: "minimally-registered-public-client",
96+
client_secret: "optional-non-confidential-secret",
97+
redirect_uris: [REDIRECT_URI],
98+
},
99+
200,
100+
),
101+
endpointPolicy: allowEndpoint,
102+
});
103+
104+
await expect(registerDefault(registration)).resolves.toEqual({
105+
issuer: ISSUER_URL,
106+
client: {
107+
clientId: "minimally-registered-public-client",
108+
authentication: { method: "none" },
109+
},
110+
clientSecret: "optional-non-confidential-secret",
111+
});
112+
});
113+
90114
it.each([
91115
{ name: "claimed HTTPS", redirectUri: "https://native.example.test/oauth/callback" },
92116
{ name: "loopback HTTP", redirectUri: "http://127.0.0.1:9876/oauth/callback" },
@@ -252,10 +276,6 @@ describe("McpClientOAuthDynamicRegistration", () => {
252276
});
253277

254278
it.each([
255-
{
256-
name: "non-201 successful status",
257-
response: () => jsonResponse(registrationResponse(), 200),
258-
},
259279
{
260280
name: "declared oversized body",
261281
response: () =>
@@ -290,13 +310,12 @@ describe("McpClientOAuthDynamicRegistration", () => {
290310
response: () => jsonResponse(registrationResponse({ client_secret: "x".repeat(8_193) }), 201),
291311
},
292312
{
293-
name: "client secret without an expiry",
313+
name: "redirect URI substitution",
294314
response: () =>
295-
jsonResponse(registrationResponse({ client_secret_expires_at: undefined }), 201),
296-
},
297-
{
298-
name: "client secret expiry without a secret",
299-
response: () => jsonResponse(registrationResponse({ client_secret: undefined }), 201),
315+
jsonResponse(
316+
registrationResponse({ redirect_uris: ["https://evil.example.test/callback"] }),
317+
201,
318+
),
300319
},
301320
{
302321
name: "confidential authentication substitution",
@@ -310,6 +329,14 @@ describe("McpClientOAuthDynamicRegistration", () => {
310329
name: "application type substitution",
311330
response: () => jsonResponse(registrationResponse({ application_type: "native" }), 201),
312331
},
332+
{
333+
name: "authorization-code response type removal",
334+
response: () => jsonResponse(registrationResponse({ response_types: ["token"] }), 201),
335+
},
336+
{
337+
name: "authorization-code grant removal",
338+
response: () => jsonResponse(registrationResponse({ grant_types: ["refresh_token"] }), 201),
339+
},
313340
{
314341
name: "non-NQCHAR registered scope",
315342
response: () => jsonResponse(registrationResponse({ scope: 'tools"read' }), 201),

0 commit comments

Comments
 (0)