diff --git a/src/app/api/mcp/oauth/__tests__/register.test.ts b/src/app/api/mcp/oauth/__tests__/register.test.ts index 9bacbea67..e64105985 100644 --- a/src/app/api/mcp/oauth/__tests__/register.test.ts +++ b/src/app/api/mcp/oauth/__tests__/register.test.ts @@ -77,6 +77,39 @@ describe("POST /register", () => { expect(res.status).toBe(400); }); + it("honours a valid OIDC application_type and echoes it back", async () => { + const res = await POST( + jsonReq({ + client_name: "Native app", + application_type: "native", + redirect_uris: ["http://127.0.0.1:8976/callback"], + }) as never, + ); + expect(res.status).toBe(201); + expect((await res.json()).application_type).toBe("native"); + }); + + it("defaults application_type to web when omitted", async () => { + const res = await POST( + jsonReq({ + redirect_uris: ["https://chatgpt.com/cb"], + }) as never, + ); + expect(res.status).toBe(201); + expect((await res.json()).application_type).toBe("web"); + }); + + it("rejects an invalid application_type with invalid_client_metadata", async () => { + const res = await POST( + jsonReq({ + application_type: "robot", + redirect_uris: ["https://chatgpt.com/cb"], + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_client_metadata"); + }); + it("returns 503 when the API is globally disabled (M4)", async () => { vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); const res = await POST( diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts index 513bf1b85..046453158 100644 --- a/src/app/api/mcp/oauth/register/route.ts +++ b/src/app/api/mcp/oauth/register/route.ts @@ -41,6 +41,12 @@ const registrationSchema = z.object({ message: "redirect_uris must be HTTPS or http loopback URLs", }), client_name: z.string().min(1).max(200).optional(), + // OIDC dynamic-registration `application_type` (forward-compat with OIDC-style + // clients): honoured by echoing it back, validated to the closed set so a + // malformed value is rejected cleanly rather than silently dropped. Absent ⇒ + // the OIDC default `web`. It does not alter the redirect-URI rules — those are + // already shape-validated below and authoritatively matched at `/authorize`. + application_type: z.enum(["web", "native"]).optional(), // Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them). grant_types: z.array(z.string()).optional(), response_types: z.array(z.string()).optional(), @@ -111,6 +117,8 @@ export async function POST(request: NextRequest): Promise { ); } + const applicationType = parsed.data.application_type ?? "web"; + const client = registerDcrClient({ clientName: parsed.data.client_name ?? "MCP client", redirectUris: parsed.data.redirect_uris, @@ -128,6 +136,7 @@ export async function POST(request: NextRequest): Promise { client_id_issued_at: Math.floor(Date.now() / 1000), redirect_uris: client.redirectUris, client_name: client.clientName, + application_type: applicationType, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],