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
This commit is contained in:
Armin Ronacher 2026-10-30 10:36:09 +01:00
parent 4b535d57ea
commit 908aa7b74b
11 changed files with 125 additions and 43 deletions

View file

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

View file

@ -322,7 +322,7 @@ These commands work outside a session, so agents can run them through `bash`. Se
| Command | Description |
|---|---|
| `pi mcp add <server> [options] -- <command> [args...]` | Add or replace a stdio server in `mcp.json`; `--env KEY=VALUE` (repeatable) and `--cwd <dir>` set its environment and working directory. Arguments after the command are passed to it |
| `pi mcp add <server> [options] --url <url>` | Add or replace a streamable HTTP server; `--header KEY=VALUE` (repeatable), `--bearer-token-env-var <NAME>` (sends `Authorization: Bearer ${NAME}`), `--oauth-client-id`, `--oauth-client-secret`, `--oauth-callback-port`, and `--oauth-client-name` configure authentication |
| `pi mcp add <server> [options] --url <url>` | Add or replace a streamable HTTP server; `--header KEY=VALUE` (repeatable), `--bearer-token-env-var <NAME>` (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 <server>` | 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 <server> [--timeout <seconds>]` | 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) |

View file

@ -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:<port>/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/<id>/client.json` with `http://127.0.0.1:<port>/callback/<id>`.
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:<port>/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/<id>/client.json` with `http://127.0.0.1:<port>/callback/<id>`. 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 <mode>` 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:

View file

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

View file

@ -65,6 +65,8 @@ Options for add:
Fixed OAuth callback port
--oauth-client-name <name>
Client name sent when registering with the OAuth server
--oauth-client-registration <mode>
auto (default), cimd, or dcr
--exposure <mode> codemode (default), deferred, direct, or hidden
--description <text> 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,

View file

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

View file

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

View file

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

View file

@ -214,7 +214,6 @@
"passing": [
"authorization-request",
"authorization-server-metadata",
"client-registration",
"pi-client",
"pkce-code-challenge-sent",
"pkce-code-verifier-sent",

View file

@ -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'),
]);

View file

@ -164,6 +164,7 @@ describe("MCP OAuth sign-in", () => {
describe("MCP OAuth client ID metadata documents", () => {
const cleanups: (() => Promise<void>)[] = [];
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.