diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1b3358f63..a5e2586df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added a `description` field for MCP servers (`pi mcp add --description`), shown next to the server in the `codemode` and `tool_search` descriptions, and a `describeNamespace(name)` codemode helper that returns a namespace's instructions and tool names. +- Added an `oauth.clientName` setting for MCP servers (`pi mcp add --oauth-client-name`) to change the client name sent during OAuth client registration, for servers such as Figma that only accept known clients ([#10226](https://github.com/earendil-works/pi/issues/10226)). ### Changed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index 1db46d930..f9636193a 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -318,7 +318,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`, and `--oauth-callback-port` 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`, and `--oauth-client-name` 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 (default 300 seconds); a terminal also accepts the pasted redirect URL | diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md index ceaca02f6..9435f1a1f 100644 --- a/packages/coding-agent/docs/mcp.md +++ b/packages/coding-agent/docs/mcp.md @@ -135,6 +135,18 @@ The redirect URI must match the registered URI. `callbackPort` uses `http://127. Set `scope` to a space-separated list for servers that do not advertise their required scopes. Otherwise, Pi requests the advertised scopes. Later scope requests are added to the configured value. +Pi registers as `pi`. Some servers only accept registrations from known clients. Set `clientName` to send another name: + +```json +{ + "mcpServers": { + "figma": { "url": "https://mcp.figma.com/mcp", "oauth": { "clientName": "Claude Code" } } + } +} +``` + +The name is only sent when Pi registers a client. To register again under a new name, sign out first. + ## Control tool exposure Each server tool is registered as `mcp____`. The server's `exposure` determines how the model reaches it: diff --git a/packages/coding-agent/src/core/mcp-servers.ts b/packages/coding-agent/src/core/mcp-servers.ts index 01a6afb80..fa221f742 100644 --- a/packages/coding-agent/src/core/mcp-servers.ts +++ b/packages/coding-agent/src/core/mcp-servers.ts @@ -71,6 +71,11 @@ export interface McpOAuthConfig { callbackUrl?: string; /** Scopes to request, separated by spaces. Default: the scopes the server advertises. */ scope?: string; + /** + * `client_name` sent with dynamic client registration, for servers that only accept known clients. + * Default: `pi`. + */ + clientName?: string; } const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; @@ -123,6 +128,9 @@ function validateOAuth(value: unknown): string | undefined { } } if (value.scope !== undefined && typeof value.scope !== "string") return "oauth.scope must be a string"; + if (value.clientName !== undefined && (typeof value.clientName !== "string" || !value.clientName.trim())) { + return "oauth.clientName must be a non-empty string"; + } return undefined; } diff --git a/packages/coding-agent/src/extensions/mcp/cli.ts b/packages/coding-agent/src/extensions/mcp/cli.ts index 48aabf4c2..6a8e7ec7a 100644 --- a/packages/coding-agent/src/extensions/mcp/cli.ts +++ b/packages/coding-agent/src/extensions/mcp/cli.ts @@ -63,6 +63,8 @@ Options for add: OAuth client secret (may be \${NAME} or !command) --oauth-callback-port Fixed OAuth callback port + --oauth-client-name + Client name sent when registering with the OAuth server --exposure codemode (default), deferred, direct, or hidden --description What the server offers, shown to the model with its tools @@ -290,6 +292,7 @@ function add( "oauth-client-id": "value", "oauth-client-secret": "value", "oauth-callback-port": "value", + "oauth-client-name": "value", exposure: "value", description: "value", }, @@ -309,7 +312,14 @@ function add( return typeof found === "string" ? found : undefined; }; const exposure = value("exposure"); - const httpOnly = ["header", "bearer-token-env-var", "oauth-client-id", "oauth-client-secret", "oauth-callback-port"]; + const httpOnly = [ + "header", + "bearer-token-env-var", + "oauth-client-id", + "oauth-client-secret", + "oauth-callback-port", + "oauth-client-name", + ]; const stdioOnly = ["env", "cwd"]; const misplaced = (url === undefined ? httpOnly : stdioOnly).find( (option) => values.has(option) || lists.has(option), @@ -330,6 +340,7 @@ function add( ...(value("oauth-client-id") === undefined ? {} : { clientId: value("oauth-client-id") }), ...(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") }), }; config = { url, diff --git a/packages/coding-agent/src/extensions/mcp/oauth.ts b/packages/coding-agent/src/extensions/mcp/oauth.ts index 6c63ca80f..ff4bf7158 100644 --- a/packages/coding-agent/src/extensions/mcp/oauth.ts +++ b/packages/coding-agent/src/extensions/mcp/oauth.ts @@ -53,6 +53,8 @@ export interface McpOAuthSettings { callbackUrl?: string; /** Scopes to request, separated by spaces. */ scope?: string; + /** `client_name` for dynamic client registration. Default: `APP_NAME`. */ + clientName?: string; } /** Where the loopback callback server listens and the redirect URI it serves. */ @@ -199,7 +201,7 @@ function createProvider( return new McpOAuthProvider({ serverUrl, redirectUrl, - clientMetadata: { client_name: APP_NAME }, + clientMetadata: { client_name: settings.clientName ?? APP_NAME }, clientId: settings.clientId, clientSecret: settings.clientSecret, store, diff --git a/packages/coding-agent/src/extensions/mcp/runtime.ts b/packages/coding-agent/src/extensions/mcp/runtime.ts index 0342aaf2e..f848854f1 100644 --- a/packages/coding-agent/src/extensions/mcp/runtime.ts +++ b/packages/coding-agent/src/extensions/mcp/runtime.ts @@ -234,6 +234,7 @@ export class McpServerConnection implements McpToolCaller, McpResourceServer { callbackPort: oauth.callbackPort, callbackUrl: oauth.callbackUrl, scope: oauth.scope, + clientName: oauth.clientName, }; } diff --git a/packages/coding-agent/test/mcp-command.test.ts b/packages/coding-agent/test/mcp-command.test.ts index bfcec881d..7bde1e11b 100644 --- a/packages/coding-agent/test/mcp-command.test.ts +++ b/packages/coding-agent/test/mcp-command.test.ts @@ -129,13 +129,22 @@ describe("pi mcp", () => { }); const oauth = await run( - ["add", "sentry", "--url", "https://mcp.sentry.dev/mcp", "--oauth-client-id", "pi"], + [ + "add", + "sentry", + "--url", + "https://mcp.sentry.dev/mcp", + "--oauth-client-id", + "pi", + "--oauth-client-name", + "Claude Code", + ], undefined, agentDir, ); expect(oauth.output).toContain("If it requires sign-in: pi mcp login sentry"); expect(readConfig(join(agentDir, "mcp.json")).mcpServers).toMatchObject({ - sentry: { url: "https://mcp.sentry.dev/mcp", oauth: { clientId: "pi" } }, + sentry: { url: "https://mcp.sentry.dev/mcp", oauth: { clientId: "pi", clientName: "Claude Code" } }, }); }); diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index 3750f8fd7..32cbafe2b 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -114,7 +114,7 @@ describe("MCP config", () => { expect(trusted.errors).toContainEqual(expect.stringContaining("autoEnableCodemode must be a boolean")); }); - it("validates the OAuth callback URL and scope", () => { + it("validates the OAuth callback URL, scope, and client name", () => { const paths = setup( { mcpServers: { @@ -127,16 +127,19 @@ describe("MCP config", () => { remote: { url: "https://a.example/mcp", oauth: { callbackUrl: "https://example.com/callback" } }, both: { url: "https://a.example/mcp", oauth: { callbackUrl: "http://127.0.0.1:1/cb", callbackPort: 2 } }, scope: { url: "https://a.example/mcp", oauth: { scope: ["a"] } }, + named: { url: "https://a.example/mcp", oauth: { clientName: "Claude Code" } }, + unnamed: { url: "https://a.example/mcp", oauth: { clientName: " " } }, }, }, {}, ); const { servers, errors } = loadMcpConfig({ ...paths, projectTrusted: false }); - expect(servers.map((server) => server.name)).toEqual(["ok", "ipv6", "same"]); + expect(servers.map((server) => server.name)).toEqual(["ok", "ipv6", "same", "named"]); 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'), ]); }); 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 48704146b..9a6763adc 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 @@ -148,6 +148,18 @@ describe("AgentSession MCP OAuth", () => { expect(getMessageText(await callWhoami(harness))).toBe("token access-1"); }); + // #10226 + it("registers with the configured client name", async () => { + const { harness, server, notifications } = await setup("follow", { clientName: "Claude Code" }); + await harness.session.prompt("/mcp login issues"); + expect(notifications.at(-1)).toBe('Signed in to MCP server "issues" (1 tools).'); + expect(server.registrations.map((metadata) => metadata.client_name)).toEqual(["Claude Code"]); + + const fallback = await setup("follow"); + await fallback.harness.session.prompt("/mcp login issues"); + expect(fallback.server.registrations.map((metadata) => metadata.client_name)).toEqual(["pi"]); + }); + it("adds the listening port to a callback URL without one", async () => { const { harness, notifications, opened } = await setup("follow", { callbackUrl: "http://127.0.0.1/oauth/done" }); await harness.session.prompt("/mcp login issues"); diff --git a/packages/coding-agent/test/suite/mcp-oauth-server.ts b/packages/coding-agent/test/suite/mcp-oauth-server.ts index 2f134c8e0..baaf9b525 100644 --- a/packages/coding-agent/test/suite/mcp-oauth-server.ts +++ b/packages/coding-agent/test/suite/mcp-oauth-server.ts @@ -15,6 +15,7 @@ function json(response: ServerResponse, status: number, body: unknown, headers: /** MCP server protected by OAuth, with its own authorization server (discovery, DCR, PKCE, refresh). */ export async function startOAuthMcpServer() { const log: string[] = []; + const registrations: Record[] = []; const validTokens = new Set(); const refreshTokens = new Set(); const challenges = new Map(); @@ -87,6 +88,7 @@ export async function startOAuthMcpServer() { case "/register": { const metadata = JSON.parse(await readBody(request)) as Record; log.push("register"); + registrations.push(metadata); return json(response, 201, { ...metadata, client_id: "client-1" }); } case "/authorize": { @@ -132,6 +134,8 @@ export async function startOAuthMcpServer() { return { url: `${origin}/mcp`, log, + /** Client metadata of dynamic client registrations. */ + registrations, /** Simulates access token expiry. */ expireAccessTokens: () => validTokens.clear(), close: () =>