From 908aa7b74baa7502321293797313f64c6fec22ee Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 30 Oct 2026 10:36:09 +0100 Subject: [PATCH] feat(coding-agent): prefer the client ID metadata document for MCP OAuth oauth.clientRegistration defaults to auto: pi's Client ID Metadata Document when the authorization server supports it for public clients, otherwise dynamic client registration. Registered clients stay in use. pi mcp add gains --oauth-client-registration. closes #11426 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/cli.md | 2 +- packages/coding-agent/docs/mcp.md | 10 ++-- packages/coding-agent/src/core/mcp-servers.ts | 28 ++++++--- .../coding-agent/src/extensions/mcp/cli.ts | 7 +++ .../coding-agent/src/extensions/mcp/oauth.ts | 59 +++++++++++++------ .../coding-agent/test/mcp-command.test.ts | 10 ++++ .../test/mcp-conformance/README.md | 2 +- .../test/mcp-conformance/baseline.json | 1 - .../coding-agent/test/mcp-extension.test.ts | 7 ++- .../test/mcp-oauth-refresh.test.ts | 41 +++++++++++-- 11 files changed, 125 insertions(+), 43 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0af338b96..17a9683fd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262)) - `/model` and `/scoped-models` now show a model's name next to its ID when the name adds words the ID lacks, such as a renamed `models.json` override ([#5916](https://github.com/earendil-works/pi/issues/5916), [#11347](https://github.com/earendil-works/pi/pull/11347)) +- MCP OAuth sign-in now uses pi's Client ID Metadata Document by default when the authorization server supports it for public clients, and registers a client dynamically otherwise, like Codex, Claude Code, and opencode. Clients registered before stay in use while their redirect URI matches. The new default `oauth.clientRegistration: "auto"` registers when `clientName` or a non-default `callbackUrl` is set; `"dcr"` always registers. `pi mcp add --oauth-client-registration` sets the mode ([#11426](https://github.com/earendil-works/pi/issues/11426)) ### Fixed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index c471f7576..376a83ab2 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -322,7 +322,7 @@ These commands work outside a session, so agents can run them through `bash`. Se | Command | Description | |---|---| | `pi mcp add [options] -- [args...]` | Add or replace a stdio server in `mcp.json`; `--env KEY=VALUE` (repeatable) and `--cwd ` set its environment and working directory. Arguments after the command are passed to it | -| `pi mcp add [options] --url ` | Add or replace a streamable HTTP server; `--header KEY=VALUE` (repeatable), `--bearer-token-env-var ` (sends `Authorization: Bearer ${NAME}`), `--oauth-client-id`, `--oauth-client-secret`, `--oauth-callback-port`, and `--oauth-client-name` configure authentication | +| `pi mcp add [options] --url ` | Add or replace a streamable HTTP server; `--header KEY=VALUE` (repeatable), `--bearer-token-env-var ` (sends `Authorization: Bearer ${NAME}`), `--oauth-client-id`, `--oauth-client-secret`, `--oauth-callback-port`, `--oauth-client-name`, and `--oauth-client-registration` (`auto`, `cimd`, or `dcr`) configure authentication | | `pi mcp remove ` | Remove a server from `mcp.json`; stored OAuth credentials are kept | | `pi mcp list [--json]` | Connect to every enabled server and print its state, tools, and errors; exit with `1` when a config entry is invalid or an enabled server is not connected | | `pi mcp login [--timeout ]` | Sign in to an OAuth server: open the authorization page and wait for the browser; a terminal also accepts the pasted redirect URL. `--timeout` limits the whole sign-in, including requests to the authorization server (default 300 seconds) | diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md index 5d5eb338e..5c6aa7d86 100644 --- a/packages/coding-agent/docs/mcp.md +++ b/packages/coding-agent/docs/mcp.md @@ -126,7 +126,7 @@ Remote servers that use OAuth, such as Sentry, need no credentials in `mcp.json` When the server rejects an unauthenticated connection, `/mcp` shows that it needs sign-in. Select "Sign in", run `/mcp login sentry`, or run `pi mcp login sentry`. Pi opens the authorization page and waits for approval. If the browser runs on another machine, such as over SSH, paste its redirected URL into the sign-in screen. A running session uses the new credentials on its next turn. -Pi registers itself with the authorization server, stores tokens in `~/.pi/agent/mcp-auth.json`, and refreshes access tokens when they expire or the server rejects them. If a server later requests additional scope, Pi asks for sign-in again. Signing out deletes the stored credentials. +Pi identifies itself to the authorization server with its Client ID Metadata Document or by registering a client, stores tokens in `~/.pi/agent/mcp-auth.json`, and refreshes access tokens when they expire or the server rejects them. If a server later requests additional scope, Pi asks for sign-in again. Signing out deletes the stored credentials. Credentials belong to a server name and URL. Servers with the same URL under different names, such as one per account, sign in separately; servers with the same name and URL in different `mcp.json` files share one sign-in. @@ -159,17 +159,19 @@ Pi registers as `pi`. Some servers only accept registrations from known clients. The name is only sent when Pi registers a client. To register again under a new name, sign out first. -Some authorization servers allow clients by their Client ID Metadata Document URL instead of registering them. Set `clientRegistration` to `cimd` to identify as Pi's document on pi.dev instead of registering: +Without `clientId`, Pi identifies itself with its Client ID Metadata Document on pi.dev when the authorization server advertises support for documents and public clients. Otherwise it registers a client dynamically. A client registered before stays in use while its redirect URI matches, until you sign out. With the document, the client ID is `https://pi.dev/oauth/client.json` with the redirect URI `http://127.0.0.1:/callback`. If the authorization server does not send the `iss` parameter in authorization responses (RFC 9207), Pi uses a document and redirect path specific to the MCP server instead: `https://pi.dev/oauth//client.json` with `http://127.0.0.1:/callback/`. + +Pi registers instead of using the document when `clientName` is set or `callbackUrl` is not `/callback` on `localhost` or `127.0.0.1`. Set `clientRegistration` to choose the method: `auto` (default) as described, `dcr` to always register, or `cimd` to always use the document. With `cimd`, sign-in fails if the authorization server does not support documents for public clients. Use `dcr` when the authorization page reports an invalid client: ```json { "mcpServers": { - "example": { "url": "https://mcp.example.com/mcp", "oauth": { "clientRegistration": "cimd" } } + "example": { "url": "https://mcp.example.com/mcp", "oauth": { "clientRegistration": "dcr" } } } } ``` -The client ID is `https://pi.dev/oauth/client.json` with the redirect URI `http://127.0.0.1:/callback`. If the authorization server does not send the `iss` parameter in authorization responses (RFC 9207), Pi uses a document and redirect path specific to the MCP server instead: `https://pi.dev/oauth//client.json` with `http://127.0.0.1:/callback/`. The authorization server must advertise Client ID Metadata Document support and public clients, or sign-in fails. `cimd` cannot be combined with `clientId` or `clientName`, and a `callbackUrl` must use `localhost` or `127.0.0.1` with the path `/callback`. +`pi mcp add --oauth-client-registration ` sets it when adding a server. `cimd` cannot be combined with `clientId` or `clientName`, and a `callbackUrl` must use `localhost` or `127.0.0.1` with the path `/callback`. Pi finds the authorization server through the server's protected resource metadata (RFC 9728) and checks that the authorization server's metadata names the expected issuer (RFC 8414). Some servers advertise the wrong authorization server or none, so sign-in opens a page that does not exist. Set `authServerMetadataUrl` to the metadata document of the right authorization server: diff --git a/packages/coding-agent/src/core/mcp-servers.ts b/packages/coding-agent/src/core/mcp-servers.ts index 1f6414928..46b4241d5 100644 --- a/packages/coding-agent/src/core/mcp-servers.ts +++ b/packages/coding-agent/src/core/mcp-servers.ts @@ -112,11 +112,13 @@ export interface McpOAuthConfig { */ clientName?: string; /** - * How pi identifies itself without `clientId`. `dcr` (default): dynamic client registration. `cimd`: - * pi's Client ID Metadata Document on pi.dev, for authorization servers that allow pi by that URL. The - * server must support it for public clients, and the callback must use the default path `/callback`. + * How pi identifies itself without `clientId`. `auto` (default): pi's Client ID Metadata Document on + * pi.dev when the authorization server supports it for public clients, otherwise dynamic client + * registration; with `clientName` or a `callbackUrl` other than `/callback` on localhost or 127.0.0.1, + * always dynamic client registration. `cimd`: always the document, and sign-in fails without support. + * `dcr`: always dynamic client registration. */ - clientRegistration?: "dcr" | "cimd"; + clientRegistration?: "auto" | "dcr" | "cimd"; /** * Authorization server metadata document (RFC 8414 or OpenID Connect discovery) to use instead of * discovery through the server, for servers that advertise a wrong authorization server or none. @@ -134,6 +136,16 @@ export function isLoopbackRedirectUri(value: string): boolean { return url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === ""; } +/** + * Whether pi's Client ID Metadata Documents list a redirect URI for the loopback `callbackUrl`: they list + * `/callback` on localhost and 127.0.0.1, on any port. Without `callbackUrl`, pi uses such a URI. + */ +export function isClientMetadataDocumentCallback(callbackUrl: string | undefined): boolean { + if (callbackUrl === undefined) return true; + const url = new URL(callbackUrl); + return url.hostname !== "[::1]" && url.pathname === "/callback"; +} + export interface McpHttpServerConfig extends McpServerConfigBase { type?: "http"; url: string; @@ -188,13 +200,13 @@ function validateOAuth(value: unknown): string | undefined { if (value.clientName !== undefined && (typeof value.clientName !== "string" || !value.clientName.trim())) { return "oauth.clientName must be a non-empty string"; } - if (value.clientRegistration !== undefined && value.clientRegistration !== "dcr") { - if (value.clientRegistration !== "cimd") return 'oauth.clientRegistration must be "dcr" or "cimd"'; + const registration = value.clientRegistration; + if (registration !== undefined && registration !== "auto" && registration !== "dcr") { + if (registration !== "cimd") return 'oauth.clientRegistration must be "auto", "dcr", or "cimd"'; if (value.clientId !== undefined || value.clientName !== undefined) { return 'oauth.clientRegistration "cimd" cannot be combined with oauth.clientId or oauth.clientName'; } - const callback = typeof value.callbackUrl === "string" ? new URL(value.callbackUrl) : undefined; - if (callback && (callback.hostname === "[::1]" || callback.pathname !== "/callback")) { + if (!isClientMetadataDocumentCallback(typeof value.callbackUrl === "string" ? value.callbackUrl : undefined)) { return 'oauth.clientRegistration "cimd" requires oauth.callbackUrl on localhost or 127.0.0.1 with path /callback'; } } diff --git a/packages/coding-agent/src/extensions/mcp/cli.ts b/packages/coding-agent/src/extensions/mcp/cli.ts index 75cdf1f08..2a55f0b6a 100644 --- a/packages/coding-agent/src/extensions/mcp/cli.ts +++ b/packages/coding-agent/src/extensions/mcp/cli.ts @@ -65,6 +65,8 @@ Options for add: Fixed OAuth callback port --oauth-client-name Client name sent when registering with the OAuth server + --oauth-client-registration + auto (default), cimd, or dcr --exposure codemode (default), deferred, direct, or hidden --description What the server offers, shown in the system prompt @@ -295,6 +297,7 @@ function add( "oauth-client-secret": "value", "oauth-callback-port": "value", "oauth-client-name": "value", + "oauth-client-registration": "value", exposure: "value", description: "value", }, @@ -321,6 +324,7 @@ function add( "oauth-client-secret", "oauth-callback-port", "oauth-client-name", + "oauth-client-registration", ]; const stdioOnly = ["env", "cwd"]; const misplaced = (url === undefined ? httpOnly : stdioOnly).find( @@ -343,6 +347,9 @@ function add( ...(value("oauth-client-secret") === undefined ? {} : { clientSecret: value("oauth-client-secret") }), ...(port === undefined ? {} : { callbackPort: Number(port) }), ...(value("oauth-client-name") === undefined ? {} : { clientName: value("oauth-client-name") }), + ...(value("oauth-client-registration") === undefined + ? {} + : { clientRegistration: value("oauth-client-registration") }), }; config = { url, diff --git a/packages/coding-agent/src/extensions/mcp/oauth.ts b/packages/coding-agent/src/extensions/mcp/oauth.ts index b1ca5601a..97fb56793 100644 --- a/packages/coding-agent/src/extensions/mcp/oauth.ts +++ b/packages/coding-agent/src/extensions/mcp/oauth.ts @@ -34,7 +34,7 @@ import { import lockfile from "proper-lockfile"; import { APP_NAME, getAgentDir } from "../../config.ts"; import { type AuthStorageBackend, FileAuthStorageBackend } from "../../core/auth-storage.ts"; -import { mcpNamespace } from "../../core/mcp-servers.ts"; +import { isClientMetadataDocumentCallback, mcpNamespace } from "../../core/mcp-servers.ts"; const CALLBACK_HOST = "127.0.0.1"; const CALLBACK_PATH = "/callback"; @@ -67,7 +67,7 @@ export interface McpOAuthSettings { /** `client_name` for dynamic client registration. Default: `APP_NAME`. */ clientName?: string; /** See `McpOAuthConfig.clientRegistration`. */ - clientRegistration?: "dcr" | "cimd"; + clientRegistration?: "auto" | "dcr" | "cimd"; /** See `McpOAuthConfig.authServerMetadataUrl`. */ authServerMetadataUrl?: URL; } @@ -243,23 +243,33 @@ function callbackId(serverUrl: string): string { } /** - * pi's Client ID Metadata Document, for `clientRegistration: "cimd"`, chosen like Codex chooses its own. - * The configuration ensures the default callback path. Without the `iss` parameter in authorization - * responses (RFC 9207), the redirect URI and the document are specific to the MCP server, so a response - * cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). + * Whether sign-in may identify with pi's Client ID Metadata Document instead of registering. `auto` + * registers when the user asked for a client name, the redirect URI is not one the documents list, or + * a rebranded build would show up as pi. + */ +function offersClientMetadataDocument(settings: McpOAuthSettings): boolean { + const registration = settings.clientRegistration ?? "auto"; + if (registration !== "auto") return registration === "cimd"; + if (settings.clientId || settings.clientName || APP_NAME !== "pi") return false; + return isClientMetadataDocumentCallback(settings.callbackUrl); +} + +/** + * pi's Client ID Metadata Document, chosen like Codex chooses its own, or `undefined` when the + * authorization server does not support documents for public clients. Without the `iss` parameter in + * authorization responses (RFC 9207), the redirect URI and the document are specific to the MCP server, + * so a response cannot be mixed up with one from another authorization server (RFC 9700 section 4.4.2.2). */ function clientMetadataDocument( serverUrl: string, redirectUrl: string, metadata: AuthorizationServerMetadata | undefined, -): OAuthClientMetadataDocument { +): OAuthClientMetadataDocument | undefined { if ( !metadata?.client_id_metadata_document_supported || !metadata.token_endpoint_auth_methods_supported?.includes("none") ) { - throw new Error( - 'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"', - ); + return undefined; } if (metadata.authorization_response_iss_parameter_supported) { return { url: `${CLIENT_METADATA_BASE_URL}/client.json`, redirectUrl }; @@ -281,10 +291,17 @@ function createProvider( serverUrl, redirectUrl, clientMetadata: { client_name: settings.clientName ?? APP_NAME }, - clientMetadataDocument: - settings.clientRegistration === "cimd" - ? (metadata) => clientMetadataDocument(serverUrl, redirectUrl, metadata) - : undefined, + clientMetadataDocument: offersClientMetadataDocument(settings) + ? (metadata) => { + const document = clientMetadataDocument(serverUrl, redirectUrl, metadata); + if (!document && settings.clientRegistration === "cimd") { + throw new Error( + 'The authorization server does not support Client ID Metadata Documents for public clients; remove oauth.clientRegistration "cimd"', + ); + } + return document; + } + : undefined, clientId: settings.clientId, clientSecret: settings.clientSecret, store, @@ -487,11 +504,11 @@ export async function signInMcpServer(options: { const registered = registeredRedirectUrls(stored?.clientInformation)[0]; const preferredPort = callbackOptions.port ?? (registered ? Number(new URL(registered).port) || undefined : undefined); - const cimd = settings.clientRegistration === "cimd"; + const offersDocument = offersClientMetadataDocument(settings); const callback = await listenForCallback( callbackOptions, // The redirect URI of a server-specific Client ID Metadata Document. - cimd ? [`${CALLBACK_PATH}/${callbackId(serverUrl)}`] : [], + offersDocument ? [`${CALLBACK_PATH}/${callbackId(serverUrl)}`] : [], preferredPort, callbackOptions.port !== undefined, ); @@ -501,11 +518,15 @@ export async function signInMcpServer(options: { const next: McpOAuthState = { ...stored }; // Every sign-in gets a fresh `state` parameter. delete next.oauthState; - // A registered client cannot use another redirect URI, and its tokens belong to it. A Client ID - // Metadata Document is not stored, so with one, a stored client was registered before and is replaced. + // A registered client cannot use another redirect URI, and its tokens belong to it. `cimd` replaces a + // registered client; `auto` keeps it. A Client ID Metadata Document is not stored, so tokens without + // a stored client belong to the document. const keepClient = settings.clientId || - (cimd ? !stored.clientInformation : registeredRedirectUrls(stored.clientInformation).includes(redirectUrl)); + (stored.clientInformation + ? settings.clientRegistration !== "cimd" && + registeredRedirectUrls(stored.clientInformation).includes(redirectUrl) + : offersDocument); if (!keepClient) { delete next.clientInformation; delete next.tokens; diff --git a/packages/coding-agent/test/mcp-command.test.ts b/packages/coding-agent/test/mcp-command.test.ts index 7bde1e11b..d4cef236b 100644 --- a/packages/coding-agent/test/mcp-command.test.ts +++ b/packages/coding-agent/test/mcp-command.test.ts @@ -146,6 +146,15 @@ describe("pi mcp", () => { expect(readConfig(join(agentDir, "mcp.json")).mcpServers).toMatchObject({ sentry: { url: "https://mcp.sentry.dev/mcp", oauth: { clientId: "pi", clientName: "Claude Code" } }, }); + + await run( + ["add", "linear", "--url", "https://mcp.linear.app/mcp", "--oauth-client-registration", "dcr"], + undefined, + agentDir, + ); + expect(readConfig(join(agentDir, "mcp.json")).mcpServers).toMatchObject({ + linear: { url: "https://mcp.linear.app/mcp", oauth: { clientRegistration: "dcr" } }, + }); }); it("rejects invalid add invocations without writing", async () => { @@ -158,6 +167,7 @@ describe("pi mcp", () => { ["add", "x", "--header", "A=1", "--", "cmd"], ["add", "x", "--env", "NOVALUE", "--", "cmd"], ["add", "x", "--exposure", "loud", "--", "cmd"], + ["add", "x", "--url", "https://example.com", "--oauth-client-registration", "maybe"], ]; for (const args of cases) { const result = await run(args, undefined); diff --git a/packages/coding-agent/test/mcp-conformance/README.md b/packages/coding-agent/test/mcp-conformance/README.md index 8aaa36690..dbdbc5bf1 100644 --- a/packages/coding-agent/test/mcp-conformance/README.md +++ b/packages/coding-agent/test/mcp-conformance/README.md @@ -39,7 +39,7 @@ node packages/coding-agent/test/mcp-conformance/run.ts --update-baseline ## Known failures - `elicitation-sep1034-client-defaults`: pi does not support elicitation. -- `auth/basic-cimd`: pi registers clients dynamically instead of using a Client ID Metadata Document. +- `auth/basic-cimd`: pi uses its Client ID Metadata Document on pi.dev, not the suite's `https://conformance-test.local/client-metadata.json`. - `auth/scope-retry-limit`: `pi-client` fails by design; the server never accepts the granted scope. 2026-07-28 is not covered: it is a stateless protocol pi does not implement. diff --git a/packages/coding-agent/test/mcp-conformance/baseline.json b/packages/coding-agent/test/mcp-conformance/baseline.json index 0401d64b0..8e7f205ae 100644 --- a/packages/coding-agent/test/mcp-conformance/baseline.json +++ b/packages/coding-agent/test/mcp-conformance/baseline.json @@ -214,7 +214,6 @@ "passing": [ "authorization-request", "authorization-server-metadata", - "client-registration", "pi-client", "pkce-code-challenge-sent", "pkce-code-verifier-sent", diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index 0948434fd..7bc18ca43 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -208,7 +208,8 @@ describe("MCP config", () => { url: "https://a.example/mcp", oauth: { clientRegistration: "cimd", callbackUrl: "http://localhost/callback" }, }, - badRegistration: { url: "https://a.example/mcp", oauth: { clientRegistration: "auto" } }, + auto: { url: "https://a.example/mcp", oauth: { clientRegistration: "auto", clientName: "x" } }, + badRegistration: { url: "https://a.example/mcp", oauth: { clientRegistration: "oidc" } }, cimdClient: { url: "https://a.example/mcp", oauth: { clientRegistration: "cimd", clientId: "x" } }, cimdPath: { url: "https://a.example/mcp", @@ -219,14 +220,14 @@ describe("MCP config", () => { {}, ); const { servers, errors } = loadMcpConfig({ ...paths, projectTrusted: false }); - expect(servers.map((server) => server.name)).toEqual(["ok", "ipv6", "same", "named", "metadata", "cimd"]); + expect(servers.map((server) => server.name)).toEqual(["ok", "ipv6", "same", "named", "metadata", "cimd", "auto"]); expect(errors).toEqual([ expect.stringContaining('server "remote": oauth.callbackUrl must be an http URI on localhost'), expect.stringContaining('server "both": oauth.callbackUrl and oauth.callbackPort name different ports'), expect.stringContaining('server "scope": oauth.scope must be a string'), expect.stringContaining('server "unnamed": oauth.clientName must be a non-empty string'), expect.stringContaining('server "plainMetadata": oauth.authServerMetadataUrl must be an https URL'), - expect.stringContaining('server "badRegistration": oauth.clientRegistration must be "dcr" or "cimd"'), + expect.stringContaining('server "badRegistration": oauth.clientRegistration must be "auto", "dcr", or "cimd"'), expect.stringContaining('server "cimdClient": oauth.clientRegistration "cimd" cannot be combined'), expect.stringContaining('server "cimdPath": oauth.clientRegistration "cimd" requires oauth.callbackUrl'), ]); diff --git a/packages/coding-agent/test/mcp-oauth-refresh.test.ts b/packages/coding-agent/test/mcp-oauth-refresh.test.ts index a901ec35a..aed142a87 100644 --- a/packages/coding-agent/test/mcp-oauth-refresh.test.ts +++ b/packages/coding-agent/test/mcp-oauth-refresh.test.ts @@ -164,6 +164,7 @@ describe("MCP OAuth sign-in", () => { describe("MCP OAuth client ID metadata documents", () => { const cleanups: (() => Promise)[] = []; const cimd: McpOAuthSettings = { clientRegistration: "cimd" }; + const dcr: McpOAuthSettings = { clientRegistration: "dcr" }; afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()?.(); @@ -183,16 +184,17 @@ describe("MCP OAuth client ID metadata documents", () => { return { server, store, signIn }; } - it("registers dynamically by default, even when the server supports documents", async () => { + it("registers dynamically with dcr, even when the server supports documents", async () => { const { server, signIn } = await startServer({ cimd: true, issParameter: true }); - await signIn({}); + await signIn(dcr); expect(server.registrations).toHaveLength(1); expect(server.authorizations[0].get("client_id")).toBe("client-1"); }); - it("uses pi's document when authorization responses name their issuer", async () => { + // #11426 + it("uses pi's document by default when authorization responses name their issuer", async () => { const { server, store, signIn } = await startServer({ cimd: true, issParameter: true }); - await signIn(cimd); + await signIn({}); const [authorization] = server.authorizations; expect(authorization.get("client_id")).toBe("https://pi.dev/oauth/client.json"); const redirect = new URL(authorization.get("redirect_uri") ?? ""); @@ -203,12 +205,39 @@ describe("MCP OAuth client ID metadata documents", () => { expect(server.tokenRequests[0].get("redirect_uri")).toBe(redirect.href); // The document is not stored, so signing in again refreshes the tokens instead of discarding them. expect((await store.load())?.clientInformation).toBeUndefined(); - await signIn(cimd); + await signIn({}); expect(server.authorizations).toHaveLength(1); expect(server.tokenRequests[1].get("grant_type")).toBe("refresh_token"); expect(server.tokenRequests[1].get("client_id")).toBe("https://pi.dev/oauth/client.json"); }); + // #11426 + it("registers dynamically by default when the server does not support documents", async () => { + const { server, signIn } = await startServer({ issParameter: true }); + await signIn({}); + expect(server.registrations).toHaveLength(1); + expect(server.authorizations[0].get("client_id")).toBe("client-1"); + }); + + // #11426 + it("registers dynamically by default with a client name", async () => { + const { server, signIn } = await startServer({ cimd: true, issParameter: true }); + await signIn({ clientName: "Claude Code" }); + expect(server.registrations).toMatchObject([{ client_name: "Claude Code" }]); + }); + + // #11426 + it("keeps a registered client by default", async () => { + const { server, store, signIn } = await startServer({ cimd: true, issParameter: true }); + await signIn(dcr); + await signIn({}); + // The registered client's tokens are refreshed instead of signing in with the document. + expect(server.authorizations).toHaveLength(1); + expect(server.tokenRequests[1].get("grant_type")).toBe("refresh_token"); + expect(server.tokenRequests[1].get("client_id")).toBe("client-1"); + expect((await store.load())?.clientInformation?.client_id).toBe("client-1"); + }); + it("uses a document and callback path specific to the MCP server without the iss parameter", async () => { const { server, signIn } = await startServer({ cimd: true }); await signIn(cimd); @@ -224,7 +253,7 @@ describe("MCP OAuth client ID metadata documents", () => { it("replaces a registered client when switching to the document", async () => { const { server, store, signIn } = await startServer({ cimd: true, issParameter: true }); - await signIn({}); + await signIn(dcr); expect((await store.load())?.clientInformation?.client_id).toBe("client-1"); await signIn(cimd); // The registered client's tokens are not refreshed with another client.