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
This commit is contained in:
Armin Ronacher 2026-10-05 17:11:00 +02:00
parent 021eae60a7
commit 147b502816
6 changed files with 53 additions and 1 deletions

View file

@ -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

View file

@ -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 () => {

View file

@ -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

View file

@ -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());

View file

@ -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;

View file

@ -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<string, unknown>[] = [];
const origin = await listen(async (request, response) => {
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
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);