mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user