feat(coding-agent): allow a custom OAuth client name for MCP servers

Adds oauth.clientName (and pi mcp add --oauth-client-name) to set the
client_name sent during dynamic client registration. Defaults to pi.

closes #10226
This commit is contained in:
Armin Ronacher
2026-09-30 13:59:24 +02:00
parent 1c7e7df765
commit 7c9fe66452
11 changed files with 70 additions and 7 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -318,7 +318,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`, and `--oauth-callback-port` 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`, and `--oauth-client-name` 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 (default 300 seconds); a terminal also accepts the pasted redirect URL |
+12
View File
@@ -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__<server>__<tool>`. The server's `exposure` determines how the model reaches it:
@@ -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;
}
@@ -63,6 +63,8 @@ Options for add:
OAuth client secret (may be \${NAME} or !command)
--oauth-callback-port <port>
Fixed OAuth callback port
--oauth-client-name <name>
Client name sent when registering with the OAuth server
--exposure <mode> codemode (default), deferred, direct, or hidden
--description <text> 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,
@@ -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,
@@ -234,6 +234,7 @@ export class McpServerConnection implements McpToolCaller, McpResourceServer {
callbackPort: oauth.callbackPort,
callbackUrl: oauth.callbackUrl,
scope: oauth.scope,
clientName: oauth.clientName,
};
}
+11 -2
View File
@@ -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" } },
});
});
@@ -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'),
]);
});
@@ -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");
@@ -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<string, unknown>[] = [];
const validTokens = new Set<string>();
const refreshTokens = new Set<string>();
const challenges = new Map<string, string>();
@@ -87,6 +88,7 @@ export async function startOAuthMcpServer() {
case "/register": {
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
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: () =>