From 147b5028160ce87586a311ef9d48550d17079cdd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 5 Oct 2026 17:11:00 +0200 Subject: [PATCH] fix(mcp): send application_type in OAuth dynamic client registration OpenID Connect authorization servers treat a registration without application_type as a web client and reject http loopback redirect URIs. Derive it from redirect_uris when unset (MCP SEP-837): native for loopback hosts and custom schemes, otherwise web. closes #10493 --- packages/coding-agent/CHANGELOG.md | 1 + .../suite/agent-session-mcp-oauth.test.ts | 2 ++ packages/mcp/CHANGELOG.md | 4 ++++ packages/mcp/src/oauth/flow.ts | 20 ++++++++++++++++- packages/mcp/src/oauth/types.ts | 5 +++++ packages/mcp/test/oauth.test.ts | 22 +++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b8abc0270..17f446908 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed syntax highlighting losing colors after the first line of multiline strings and comments in fenced code blocks ([#10143](https://github.com/earendil-works/pi/issues/10143)) - Fixed codemode scripts not receiving images from `read`: `tools.read()` now resolves to an image block for image files, which `image()` shows ([#10251](https://github.com/earendil-works/pi/issues/10251)) +- Fixed MCP OAuth sign-in failing with `invalid_redirect_uri` on servers with OpenID Connect client registration, such as `mcp.modem.dev`: pi now registers as a native client ([#10493](https://github.com/earendil-works/pi/issues/10493)) ## [1.0.3] - 2026-10-05 diff --git a/packages/coding-agent/test/suite/agent-session-mcp-oauth.test.ts b/packages/coding-agent/test/suite/agent-session-mcp-oauth.test.ts index 9a6763adc..cb5b89a83 100644 --- a/packages/coding-agent/test/suite/agent-session-mcp-oauth.test.ts +++ b/packages/coding-agent/test/suite/agent-session-mcp-oauth.test.ts @@ -158,6 +158,8 @@ describe("AgentSession MCP OAuth", () => { const fallback = await setup("follow"); await fallback.harness.session.prompt("/mcp login issues"); expect(fallback.server.registrations.map((metadata) => metadata.client_name)).toEqual(["pi"]); + // OpenID Connect servers reject the loopback redirect URI of a `web` client (#10493). + expect(fallback.server.registrations.map((metadata) => metadata.application_type)).toEqual(["native"]); }); it("adds the listening port to a callback URL without one", async () => { diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 18e7c970e..25f88c764 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed dynamic client registration failing on OpenID Connect authorization servers with `invalid_redirect_uri` for loopback redirect URIs. `registerClient()` now sends `application_type` (MCP SEP-837), derived from `redirect_uris` when `OAuthClientMetadata.application_type` is not set: `native` for loopback hosts and custom schemes, otherwise `web` ([#10493](https://github.com/earendil-works/pi/issues/10493)) + ## [1.0.3] - 2026-10-05 ## [1.0.2] - 2026-10-04 diff --git a/packages/mcp/src/oauth/flow.ts b/packages/mcp/src/oauth/flow.ts index 778a003ff..d8711a34b 100644 --- a/packages/mcp/src/oauth/flow.ts +++ b/packages/mcp/src/oauth/flow.ts @@ -103,6 +103,19 @@ function loopback(hostname: string): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; } +/** + * The OpenID Connect `application_type` for `redirect_uris` (MCP SEP-837). Without one, OpenID Connect servers + * assume `web`, which rejects http loopback redirect URIs. Loopback hosts and custom schemes are native (RFC 8252). + */ +function applicationType(redirectUris: readonly string[]): "native" | "web" { + const native = redirectUris.some((uri) => { + if (!URL.canParse(uri)) return false; + const url = new URL(uri); + return (url.protocol !== "http:" && url.protocol !== "https:") || loopback(url.hostname); + }); + return native ? "native" : "web"; +} + function secureEndpoint(value: string | URL): URL { const url = new URL(value); if (url.protocol !== "https:" && !loopback(url.hostname)) throw new OAuthInsecureEndpointError(url.href); @@ -239,7 +252,12 @@ export async function registerClient( { method: "POST", headers: { Accept: "application/json", "content-type": "application/json" }, - body: JSON.stringify({ ...options.clientMetadata, ...(options.scope ? { scope: options.scope } : {}) }), + body: JSON.stringify({ + ...options.clientMetadata, + application_type: + options.clientMetadata.application_type ?? applicationType(options.clientMetadata.redirect_uris), + ...(options.scope ? { scope: options.scope } : {}), + }), }, ); if (!response.ok) throw new OAuthRegistrationError(response.status, await response.text()); diff --git a/packages/mcp/src/oauth/types.ts b/packages/mcp/src/oauth/types.ts index d7d83da56..df5552564 100644 --- a/packages/mcp/src/oauth/types.ts +++ b/packages/mcp/src/oauth/types.ts @@ -43,6 +43,11 @@ export interface OAuthClientMetadata { token_endpoint_auth_method?: string; grant_types?: string[]; response_types?: string[]; + /** + * OpenID Connect client type, `native` or `web`. Dynamic client registration derives it from + * `redirect_uris` when absent (MCP SEP-837). + */ + application_type?: string; client_name?: string; client_uri?: string; logo_uri?: string; diff --git a/packages/mcp/test/oauth.test.ts b/packages/mcp/test/oauth.test.ts index fdf4714c8..2415c7346 100644 --- a/packages/mcp/test/oauth.test.ts +++ b/packages/mcp/test/oauth.test.ts @@ -16,6 +16,7 @@ import { OAuthInsecureEndpointError, OAuthIssuerMismatchError, type OAuthTokens, + registerClient, } from "../src/oauth/index.ts"; import { closeServers, listen, readBody } from "./helpers.ts"; @@ -407,6 +408,27 @@ describe("MCP OAuth", () => { expect(await second.tokens()).toBeUndefined(); }); + // #10493 + it("registers with an application_type derived from the redirect URIs unless one is set", async () => { + const bodies: Record[] = []; + const origin = await listen(async (request, response) => { + const metadata = JSON.parse(await readBody(request)) as Record; + bodies.push(metadata); + response.writeHead(201, { "content-type": "application/json" }); + response.end(JSON.stringify({ ...metadata, client_id: "client" })); + }); + const register = (redirect_uris: string[], application_type?: string) => + registerClient(origin, { + clientMetadata: { redirect_uris, ...(application_type ? { application_type } : {}) }, + }); + await register(["http://127.0.0.1:1234/callback"]); + await register(["http://[::1]/callback"]); + await register(["com.example.app:/callback"]); + await register(["https://app.example/callback"]); + await register(["http://localhost/callback"], "web"); + expect(bodies.map((body) => body.application_type)).toEqual(["native", "native", "native", "web", "web"]); + }); + it("rejects authorization metadata whose issuer does not match discovery", async () => { const origin = await listen(async (request, response, serverOrigin) => { const url = new URL(request.url ?? "/", serverOrigin);