mirror of
https://github.com/earendil-works/pi.git
synced 2026-09-28 05:54:43 +08:00
feat(coding-agent): let extensions register MCP servers
pi.registerMcpServer(name, config) adds a server for the session with the mcp.json config shape; pi.unregisterMcpServer() removes it and pi.getMcpServers() lists registrations. Servers registered while loading connect on session_start, later ones right away. A server in mcp.json with the same name takes precedence and /mcp shows the override. Changes to registered servers in /mcp apply to the session only. The mcp_servers_change event lets any MCP extension connect registered servers; when none handles it, registrations are reported as extension errors.
This commit is contained in:
@@ -80,6 +80,7 @@ Automatic retries, recovery, compaction, or queued work can continue afterward.
|
||||
| Persist non-context session data | `pi.appendEntry()` |
|
||||
| Change active tools, model, or thinking level | Session control methods on `pi` |
|
||||
| Add a model provider | `pi.registerProvider()` |
|
||||
| Add an MCP server | `pi.registerMcpServer()` |
|
||||
| Add terminal rendering | Renderer registration and `ctx.ui` |
|
||||
| Communicate with another extension | `pi.events` |
|
||||
|
||||
@@ -169,6 +170,19 @@ Register every tool first, keep optional tools inactive, and use `pi.setActiveTo
|
||||
|
||||
Pi records the initial prompt and tool set in the transcript's first system message, then appends tool and prompt changes before the next model request. Providers that cannot represent the transition receive a complete transcript checkpoint, which can invalidate the cached prefix.
|
||||
|
||||
### MCP servers
|
||||
|
||||
`pi.registerMcpServer(name, config)` adds an MCP server for the current session. `config` has the shape of an `mcpServers` entry in [`mcp.json`](mcp.md): `command`, `args`, `env`, and `cwd` for stdio servers, `url`, `headers`, and `oauth` for HTTP servers, plus `exposure`, `enabled`, and `timeout`.
|
||||
|
||||
```typescript
|
||||
pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" });
|
||||
pi.unregisterMcpServer("jira");
|
||||
```
|
||||
|
||||
Servers registered while the extension loads connect when the session starts, together with the `mcp.json` servers; servers registered later connect right away, and `pi.unregisterMcpServer()` closes the connection and makes the server's tools unreachable. Registrations are not saved: register again on every load, for example based on the extension's own settings. A server in `mcp.json` with the same name takes precedence, and `/mcp` shows the override. Registering the same name again replaces the extension's earlier registration; names registered by another extension, invalid names, and invalid configs throw.
|
||||
|
||||
The built-in MCP support connects registered servers. When nothing does, because another extension replaced it (see [MCP](mcp.md#other-mcp-extensions)), each registration is reported as an extension error. Other MCP extensions can connect registered servers too: read them with `pi.getMcpServers()` on `session_start` and handle the `mcp_servers_change` event for later changes.
|
||||
|
||||
<a id="extensioncontext"></a>
|
||||
<a id="extensioncommandcontext"></a>
|
||||
<a id="use-extension-context"></a>
|
||||
|
||||
@@ -121,6 +121,10 @@ Codemode scripts receive an MCP tool's whole `CallToolResult` (`content` blocks
|
||||
|
||||
Every MCP call goes through pi's tool pipeline, so `tool_call` and `tool_result` extension handlers, including permission gates, apply to MCP tools. Calls made from codemode scripts carry the `codemode` call's id as `parentToolCallId`.
|
||||
|
||||
## Servers from extensions
|
||||
|
||||
Extensions can add servers for the current session with `pi.registerMcpServer(name, config)`, using the same config shape as `mcp.json` (see [Extensions](extensions.md#mcp-servers)). They connect like configured servers and appear in `/mcp` with the extension as their source. Enabling, disabling, and exposure changes for them apply to the current session only. A server in `mcp.json` with the same name takes precedence; `/mcp` lists the overridden registration. `pi mcp` shell commands do not load extensions and only see `mcp.json` servers.
|
||||
|
||||
## Other MCP extensions
|
||||
|
||||
An installed extension that registers the `/mcp` command, such as `pi-mcp-adapter`, replaces the built-in MCP support: pi then neither reads `mcp.json` in sessions nor connects servers, and `/mcp` belongs to that extension. Remove the extension to use the built-in support. Likewise, an extension that registers a tool named `codemode` or `tool_search` replaces the built-in tool of that name. `pi mcp` shell commands always use the built-in support.
|
||||
|
||||
@@ -3108,6 +3108,7 @@ export class AgentSession {
|
||||
|
||||
this._applyExtensionBindings(this._extensionRunner);
|
||||
await this._extensionRunner.emit(this._sessionStartEvent);
|
||||
this._extensionRunner.reportUnhandledMcpServers();
|
||||
await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
|
||||
}
|
||||
|
||||
@@ -3500,6 +3501,7 @@ export class AgentSession {
|
||||
if (hasBindings) {
|
||||
await options?.beforeSessionStart?.();
|
||||
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
|
||||
this._extensionRunner.reportUnhandledMcpServers();
|
||||
await this.extendResourcesFromExtensions("reload");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export type {
|
||||
LsToolResultEvent,
|
||||
MarkdownTransformContext,
|
||||
MarkdownTransformer,
|
||||
McpServersChangeEvent,
|
||||
// Events - Message
|
||||
MessageEndEvent,
|
||||
MessageEndEventResult,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolvePath } from "../../utils/paths.ts";
|
||||
import { createEventBus, type EventBus } from "../event-bus.ts";
|
||||
import type { ExecOptions } from "../exec.ts";
|
||||
import { execCommand } from "../exec.ts";
|
||||
import { type McpServerConfig, McpServerRegistry, validateMcpServerConfig } from "../mcp-servers.ts";
|
||||
import { readPiManifest } from "../pi-manifest.ts";
|
||||
import { createSyntheticSourceInfo } from "../source-info.ts";
|
||||
import { time } from "../timings.ts";
|
||||
@@ -182,6 +183,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
|
||||
flagValues: new Map(),
|
||||
pendingProviderRegistrations: [],
|
||||
pendingNativeProviderRegistrations: [],
|
||||
mcpServers: new McpServerRegistry(),
|
||||
assertActive,
|
||||
invalidate: (message) => {
|
||||
if (state.staleMessage) return;
|
||||
@@ -439,6 +441,30 @@ function createExtensionAPI(
|
||||
applyRuntimeChange(() => runtime.unregisterProvider(name, extension.path));
|
||||
},
|
||||
|
||||
registerMcpServer(name: string, config: McpServerConfig) {
|
||||
assertActive();
|
||||
const validated = validateMcpServerConfig(name, config);
|
||||
if (typeof validated === "string") {
|
||||
throw new Error(`Invalid MCP server registered by extension "${extension.path}": ${validated}`);
|
||||
}
|
||||
const owner = runtime.mcpServers.get(name)?.extensionPath;
|
||||
if (owner !== undefined && owner !== extension.path) {
|
||||
throw new Error(`MCP server "${name}" is already registered by extension "${owner}"`);
|
||||
}
|
||||
const server = { name, config: structuredClone(validated), extensionPath: extension.path };
|
||||
applyRuntimeChange(() => runtime.mcpServers.register(server));
|
||||
},
|
||||
|
||||
unregisterMcpServer(name: string) {
|
||||
assertActive();
|
||||
applyRuntimeChange(() => runtime.mcpServers.unregister(name, extension.path));
|
||||
},
|
||||
|
||||
getMcpServers() {
|
||||
assertActive();
|
||||
return runtime.mcpServers.list();
|
||||
},
|
||||
|
||||
events: {
|
||||
emit(channel, data) {
|
||||
assertActive();
|
||||
|
||||
@@ -376,6 +376,8 @@ export class ExtensionRunner {
|
||||
normalizeBuildSystemPromptOptions({ cwd: this.cwd });
|
||||
private executeToolFn: ExtensionContextActions["executeTool"];
|
||||
private getCallableToolsFn: () => readonly AgentTool[] = () => [];
|
||||
/** Registered MCP servers already reported as unhandled. */
|
||||
private readonly reportedMcpServers = new Set<string>();
|
||||
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
|
||||
private forkHandler: ForkHandler = async () => ({ cancelled: false });
|
||||
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
||||
@@ -446,6 +448,13 @@ export class ExtensionRunner {
|
||||
this.executeToolFn = contextActions.executeTool;
|
||||
this.getCallableToolsFn = contextActions.getCallableTools ?? (() => []);
|
||||
|
||||
// Servers registered from now on reach the extension that connects them right away. Servers
|
||||
// registered during loading are read on session_start.
|
||||
this.runtime.mcpServers.setChangeListener(() => {
|
||||
void this.emit({ type: "mcp_servers_change", servers: this.runtime.mcpServers.list() });
|
||||
this.reportUnhandledMcpServers();
|
||||
});
|
||||
|
||||
// Flush provider registrations queued during extension loading
|
||||
for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) {
|
||||
try {
|
||||
@@ -709,6 +718,23 @@ export class ExtensionRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report registered MCP servers when no extension handles `mcp_servers_change`, which means
|
||||
* nothing connects them (for example when another MCP extension replaced the built-in one).
|
||||
*/
|
||||
reportUnhandledMcpServers(): void {
|
||||
if (this.hasHandlers("mcp_servers_change")) return;
|
||||
for (const server of this.runtime.mcpServers.list()) {
|
||||
if (this.reportedMcpServers.has(server.name)) continue;
|
||||
this.reportedMcpServers.add(server.name);
|
||||
this.emitError({
|
||||
extensionPath: server.extensionPath,
|
||||
event: "register_mcp_server",
|
||||
error: `MCP server "${server.name}" is registered, but no loaded extension connects MCP servers; another extension may have replaced the built-in MCP support`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hasHandlers(eventType: string): boolean {
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get(eventType);
|
||||
|
||||
@@ -63,6 +63,7 @@ import type { EventBus } from "../event-bus.ts";
|
||||
import type { ExecOptions, ExecResult } from "../exec.ts";
|
||||
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.ts";
|
||||
import type { KeybindingsManager } from "../keybindings.ts";
|
||||
import type { McpServerConfig, McpServerRegistry, RegisteredMcpServer } from "../mcp-servers.ts";
|
||||
import type { CustomMessage } from "../messages.ts";
|
||||
import type { ModelRegistry } from "../model-registry.ts";
|
||||
import type { ScopedModel } from "../model-resolver.ts";
|
||||
@@ -676,6 +677,18 @@ export interface ResourcesDiscoverResult {
|
||||
themePaths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when an extension registers or unregisters an MCP server after the extensions are bound
|
||||
* (see {@link ExtensionAPI.registerMcpServer}). Servers registered while extensions load are read
|
||||
* with `pi.getMcpServers()` on `session_start`. Handling this event marks an extension as the one
|
||||
* that connects registered servers.
|
||||
*/
|
||||
export interface McpServersChangeEvent {
|
||||
type: "mcp_servers_change";
|
||||
/** Every registered server after the change. */
|
||||
servers: RegisteredMcpServer[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
@@ -1316,6 +1329,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
|
||||
export type ExtensionEvent =
|
||||
| ProjectTrustEvent
|
||||
| ResourcesDiscoverEvent
|
||||
| McpServersChangeEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| ContextWithSystemEvent
|
||||
@@ -1526,6 +1540,7 @@ export interface ExtensionAPI {
|
||||
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): () => void;
|
||||
on(event: "session_compact_failed", handler: ExtensionHandler<SessionCompactFailedEvent>): () => void;
|
||||
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): () => void;
|
||||
on(event: "mcp_servers_change", handler: ExtensionHandler<McpServersChangeEvent>): () => void;
|
||||
on(
|
||||
event: "session_before_tree",
|
||||
handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>,
|
||||
@@ -1779,6 +1794,32 @@ export interface ExtensionAPI {
|
||||
*/
|
||||
unregisterProvider(name: string): void;
|
||||
|
||||
// =========================================================================
|
||||
// MCP Servers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Register an MCP server for this session, with the same config as an `mcpServers` entry in
|
||||
* `mcp.json`. The server connects next to the configured servers: on `session_start` when
|
||||
* registered during extension load, right away when registered later. Registering a name again
|
||||
* replaces the extension's earlier registration.
|
||||
*
|
||||
* The registration is not saved; register again on every load. A server of the same name in
|
||||
* `mcp.json` takes precedence. Throws for invalid configs and for names another extension
|
||||
* registered. When no loaded extension handles MCP servers (for example because another MCP
|
||||
* extension replaced the built-in one), the registration is reported as an extension error.
|
||||
*
|
||||
* @example
|
||||
* pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira" });
|
||||
*/
|
||||
registerMcpServer(name: string, config: McpServerConfig): void;
|
||||
|
||||
/** Remove an MCP server this extension registered and close its connection. */
|
||||
unregisterMcpServer(name: string): void;
|
||||
|
||||
/** Every MCP server registered by extensions. For extensions that connect MCP servers. */
|
||||
getMcpServers(): RegisteredMcpServer[];
|
||||
|
||||
/** Shared event bus for extension communication. */
|
||||
events: EventBus;
|
||||
}
|
||||
@@ -2016,6 +2057,8 @@ export interface ExtensionRuntimeState {
|
||||
registerProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void;
|
||||
registerNativeProvider: (provider: Provider, extensionPath?: string) => void;
|
||||
unregisterProvider: (name: string, extensionPath?: string) => void;
|
||||
/** Servers registered with `pi.registerMcpServer()`. */
|
||||
mcpServers: McpServerRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* MCP server configuration and the servers extensions register with `pi.registerMcpServer()`.
|
||||
*
|
||||
* The core only validates and stores registrations. The MCP extension (built in, or another
|
||||
* extension that handles `mcp_servers_change`) connects them next to the servers from `mcp.json`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* - `codemode`: tools are callable from codemode scripts and listed in its description, but not
|
||||
* declared to the model.
|
||||
* - `deferred`: like `codemode`, but not listed in the codemode description.
|
||||
* - `direct`: tools are declared to the model like any other tool (and callable from codemode).
|
||||
* - `hidden`: tools are registered but unreachable.
|
||||
*/
|
||||
export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";
|
||||
|
||||
const MCP_EXPOSURES: readonly string[] = ["codemode", "deferred", "direct", "hidden"] satisfies McpExposure[];
|
||||
|
||||
interface McpServerConfigBase {
|
||||
/** Default: `codemode`. */
|
||||
exposure?: McpExposure;
|
||||
/** Set to false to keep the entry without connecting. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface McpStdioServerConfig extends McpServerConfigBase {
|
||||
type?: "stdio";
|
||||
command: string;
|
||||
args?: string[];
|
||||
/** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
env?: Record<string, string>;
|
||||
/** Relative paths resolve against the session working directory. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** OAuth client settings for servers that do not support dynamic client registration. */
|
||||
export interface McpOAuthConfig {
|
||||
/** Pre-registered client id. Without it, pi registers a client with the authorization server. */
|
||||
clientId?: string;
|
||||
/** May reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
clientSecret?: string;
|
||||
/** Fixed loopback callback port, for clients registered with an exact redirect URI. */
|
||||
callbackPort?: number;
|
||||
}
|
||||
|
||||
export interface McpHttpServerConfig extends McpServerConfigBase {
|
||||
type?: "http";
|
||||
url: string;
|
||||
/** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
headers?: Record<string, string>;
|
||||
oauth?: McpOAuthConfig;
|
||||
}
|
||||
|
||||
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
|
||||
|
||||
const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function validateOAuth(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) return "oauth must be an object";
|
||||
if (value.clientId !== undefined && typeof value.clientId !== "string") return "oauth.clientId must be a string";
|
||||
if (value.clientSecret !== undefined && typeof value.clientSecret !== "string") {
|
||||
return "oauth.clientSecret must be a string";
|
||||
}
|
||||
const port = value.callbackPort;
|
||||
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) {
|
||||
return "oauth.callbackPort must be a port number";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Validate one server entry of the `mcpServers` shape. Returns the config or an error message. */
|
||||
export function validateMcpServerConfig(name: string, value: unknown): McpServerConfig | string {
|
||||
if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`;
|
||||
if (!isRecord(value)) return `server "${name}" must be an object`;
|
||||
const { type, exposure, enabled, timeout } = value;
|
||||
if (exposure !== undefined && (typeof exposure !== "string" || !MCP_EXPOSURES.includes(exposure))) {
|
||||
return `server "${name}": exposure must be one of ${MCP_EXPOSURES.map((value) => `"${value}"`).join(", ")}`;
|
||||
}
|
||||
if (enabled !== undefined && typeof enabled !== "boolean") return `server "${name}": enabled must be a boolean`;
|
||||
if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) {
|
||||
return `server "${name}": timeout must be a positive number of seconds`;
|
||||
}
|
||||
if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`;
|
||||
|
||||
if (typeof value.url === "string" && (type === undefined || type === "http" || type === "streamable-http")) {
|
||||
if (!URL.canParse(value.url) || !/^https?:$/.test(new URL(value.url).protocol)) {
|
||||
return `server "${name}": url must be an http or https URL`;
|
||||
}
|
||||
if (value.headers !== undefined && !isStringRecord(value.headers)) {
|
||||
return `server "${name}": headers must map names to strings`;
|
||||
}
|
||||
const oauthError = validateOAuth(value.oauth);
|
||||
if (oauthError) return `server "${name}": ${oauthError}`;
|
||||
return value as unknown as McpHttpServerConfig;
|
||||
}
|
||||
if (typeof value.command === "string" && (type === undefined || type === "stdio")) {
|
||||
if (
|
||||
value.args !== undefined &&
|
||||
!(Array.isArray(value.args) && value.args.every((arg) => typeof arg === "string"))
|
||||
) {
|
||||
return `server "${name}": args must be an array of strings`;
|
||||
}
|
||||
if (value.env !== undefined && !isStringRecord(value.env))
|
||||
return `server "${name}": env must map names to strings`;
|
||||
if (value.cwd !== undefined && typeof value.cwd !== "string") return `server "${name}": cwd must be a string`;
|
||||
return value as unknown as McpStdioServerConfig;
|
||||
}
|
||||
return `server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`;
|
||||
}
|
||||
|
||||
/** A server an extension registered with `pi.registerMcpServer()`. */
|
||||
export interface RegisteredMcpServer {
|
||||
name: string;
|
||||
config: McpServerConfig;
|
||||
/** Path of the extension that registered the server. */
|
||||
extensionPath: string;
|
||||
}
|
||||
|
||||
/** Servers registered by the extensions of one runtime. */
|
||||
export class McpServerRegistry {
|
||||
private readonly servers = new Map<string, RegisteredMcpServer>();
|
||||
private changeListener: (() => void) | undefined;
|
||||
|
||||
/** Register or replace a server. The caller checks ownership. */
|
||||
register(server: RegisteredMcpServer): void {
|
||||
this.servers.set(server.name, server);
|
||||
this.changeListener?.();
|
||||
}
|
||||
|
||||
/** Remove a server registered by `extensionPath`. Servers of other extensions are left alone. */
|
||||
unregister(name: string, extensionPath: string): void {
|
||||
if (this.servers.get(name)?.extensionPath !== extensionPath) return;
|
||||
this.servers.delete(name);
|
||||
this.changeListener?.();
|
||||
}
|
||||
|
||||
get(name: string): RegisteredMcpServer | undefined {
|
||||
return this.servers.get(name);
|
||||
}
|
||||
|
||||
/** Copies of the registered servers, in registration order. */
|
||||
list(): RegisteredMcpServer[] {
|
||||
return [...this.servers.values()].map((server) => ({ ...server, config: structuredClone(server.config) }));
|
||||
}
|
||||
|
||||
/** Called after every change. The runner sets it when it binds, to emit `mcp_servers_change`. */
|
||||
setChangeListener(listener: (() => void) | undefined): void {
|
||||
this.changeListener = listener;
|
||||
}
|
||||
}
|
||||
@@ -25,64 +25,26 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { CONFIG_DIR_NAME } from "../../config.ts";
|
||||
import { type McpExposure, type McpServerConfig, validateMcpServerConfig } from "../../core/mcp-servers.ts";
|
||||
|
||||
/**
|
||||
* - `codemode`: tools are callable from codemode scripts and listed in its description, but not
|
||||
* declared to the model.
|
||||
* - `deferred`: like `codemode`, but not listed in the codemode description.
|
||||
* - `direct`: tools are declared to the model like any other tool (and callable from codemode).
|
||||
* - `hidden`: tools are registered but unreachable.
|
||||
*/
|
||||
export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";
|
||||
|
||||
const MCP_EXPOSURES: readonly string[] = ["codemode", "deferred", "direct", "hidden"] satisfies McpExposure[];
|
||||
|
||||
interface McpServerConfigBase {
|
||||
/** Default: `codemode`. */
|
||||
exposure?: McpExposure;
|
||||
/** Set to false to keep the entry without connecting. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface McpStdioServerConfig extends McpServerConfigBase {
|
||||
type?: "stdio";
|
||||
command: string;
|
||||
args?: string[];
|
||||
/** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
env?: Record<string, string>;
|
||||
/** Relative paths resolve against the session working directory. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** OAuth client settings for servers that do not support dynamic client registration. */
|
||||
export interface McpOAuthConfig {
|
||||
/** Pre-registered client id. Without it, pi registers a client with the authorization server. */
|
||||
clientId?: string;
|
||||
/** May reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
clientSecret?: string;
|
||||
/** Fixed loopback callback port, for clients registered with an exact redirect URI. */
|
||||
callbackPort?: number;
|
||||
}
|
||||
|
||||
export interface McpHttpServerConfig extends McpServerConfigBase {
|
||||
type?: "http";
|
||||
url: string;
|
||||
/** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
headers?: Record<string, string>;
|
||||
oauth?: McpOAuthConfig;
|
||||
}
|
||||
|
||||
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
|
||||
export type {
|
||||
McpExposure,
|
||||
McpHttpServerConfig,
|
||||
McpOAuthConfig,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
} from "../../core/mcp-servers.ts";
|
||||
|
||||
export interface McpServerEntry {
|
||||
name: string;
|
||||
config: McpServerConfig;
|
||||
/** Config file that defined the entry. */
|
||||
/** Config file that defined the entry, or the path of the extension that registered it. */
|
||||
source: string;
|
||||
/** Whether the entry comes from the global or the project `mcp.json`. */
|
||||
scope?: "global" | "project";
|
||||
/**
|
||||
* The global or the project `mcp.json`, or `extension` for servers registered with
|
||||
* `pi.registerMcpServer()`. Changes to extension servers are not saved.
|
||||
*/
|
||||
scope?: "global" | "project" | "extension";
|
||||
}
|
||||
|
||||
export interface LoadedMcpConfig {
|
||||
@@ -92,69 +54,10 @@ export interface LoadedMcpConfig {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function validateOAuth(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) return "oauth must be an object";
|
||||
if (value.clientId !== undefined && typeof value.clientId !== "string") return "oauth.clientId must be a string";
|
||||
if (value.clientSecret !== undefined && typeof value.clientSecret !== "string") {
|
||||
return "oauth.clientSecret must be a string";
|
||||
}
|
||||
const port = value.callbackPort;
|
||||
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) {
|
||||
return "oauth.callbackPort must be a port number";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateServer(name: string, value: unknown): McpServerConfig | string {
|
||||
if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`;
|
||||
if (!isRecord(value)) return `server "${name}" must be an object`;
|
||||
const { type, exposure, enabled, timeout } = value;
|
||||
if (exposure !== undefined && (typeof exposure !== "string" || !MCP_EXPOSURES.includes(exposure))) {
|
||||
return `server "${name}": exposure must be one of ${MCP_EXPOSURES.map((value) => `"${value}"`).join(", ")}`;
|
||||
}
|
||||
if (enabled !== undefined && typeof enabled !== "boolean") return `server "${name}": enabled must be a boolean`;
|
||||
if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) {
|
||||
return `server "${name}": timeout must be a positive number of seconds`;
|
||||
}
|
||||
if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`;
|
||||
|
||||
if (typeof value.url === "string" && (type === undefined || type === "http" || type === "streamable-http")) {
|
||||
if (!URL.canParse(value.url) || !/^https?:$/.test(new URL(value.url).protocol)) {
|
||||
return `server "${name}": url must be an http or https URL`;
|
||||
}
|
||||
if (value.headers !== undefined && !isStringRecord(value.headers)) {
|
||||
return `server "${name}": headers must map names to strings`;
|
||||
}
|
||||
const oauthError = validateOAuth(value.oauth);
|
||||
if (oauthError) return `server "${name}": ${oauthError}`;
|
||||
return value as unknown as McpHttpServerConfig;
|
||||
}
|
||||
if (typeof value.command === "string" && (type === undefined || type === "stdio")) {
|
||||
if (
|
||||
value.args !== undefined &&
|
||||
!(Array.isArray(value.args) && value.args.every((arg) => typeof arg === "string"))
|
||||
) {
|
||||
return `server "${name}": args must be an array of strings`;
|
||||
}
|
||||
if (value.env !== undefined && !isStringRecord(value.env))
|
||||
return `server "${name}": env must map names to strings`;
|
||||
if (value.cwd !== undefined && typeof value.cwd !== "string") return `server "${name}": cwd must be a string`;
|
||||
return value as unknown as McpStdioServerConfig;
|
||||
}
|
||||
return `server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`;
|
||||
}
|
||||
|
||||
interface McpConfigState {
|
||||
servers: Map<string, McpServerEntry>;
|
||||
autoEnableCodemode?: boolean;
|
||||
@@ -178,7 +81,7 @@ function readConfigFile(path: string, scope: "global" | "project", state: McpCon
|
||||
if (typeof parsed.autoEnableCodemode === "boolean") state.autoEnableCodemode = parsed.autoEnableCodemode;
|
||||
else if (parsed.autoEnableCodemode !== undefined) errors.push(`${path}: autoEnableCodemode must be a boolean`);
|
||||
for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) {
|
||||
const config = validateServer(name, value);
|
||||
const config = validateMcpServerConfig(name, value);
|
||||
if (typeof config === "string") {
|
||||
errors.push(`${path}: ${config}`);
|
||||
continue;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Built-in MCP integration.
|
||||
*
|
||||
* Connects the servers from `mcp.json` when a session starts and registers their tools as
|
||||
* Connects the servers from `mcp.json` and the servers extensions register with
|
||||
* `pi.registerMcpServer()` when a session starts, and servers registered later right away. A server
|
||||
* in `mcp.json` takes precedence over a registered server of the same name. Tools are registered as
|
||||
* `mcp__<server>__<tool>`. By default (`"exposure": "codemode"`) the tools are only callable from
|
||||
* codemode scripts, which keeps large MCP tool lists out of the model's tool declarations; the
|
||||
* codemode tool is activated for that unless `autoEnableCodemode` is false. `"exposure": "direct"`
|
||||
@@ -13,7 +15,8 @@
|
||||
*
|
||||
* Problems found at startup (config errors, failed connections, servers that need a sign-in) are
|
||||
* reported once. `/mcp` opens a manager to sign in, reconnect, enable or disable servers, and change
|
||||
* their exposure; the last two are saved to the `mcp.json` that defines the server.
|
||||
* their exposure; the last two are saved to the `mcp.json` that defines the server, or apply to the
|
||||
* current session for registered servers.
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
@@ -70,6 +73,8 @@ const DEFAULT_STARTUP_WAIT_MS = 10_000;
|
||||
interface McpServer {
|
||||
entry: McpServerEntry;
|
||||
connection?: McpServerConnection;
|
||||
/** For servers extensions registered: the config as registered, to detect re-registrations. */
|
||||
registeredConfig?: string;
|
||||
/** Result of the last `/mcp` action that failed, shown in the manager. */
|
||||
message?: string;
|
||||
}
|
||||
@@ -143,7 +148,13 @@ const MCP_USAGE = "Usage: /mcp, /mcp login [server], /mcp logout [server], /mcp
|
||||
export function createMcpExtension(options: McpExtensionOptions = {}): ExtensionFactory {
|
||||
return (pi: ExtensionAPI) => {
|
||||
let servers: McpServer[] = [];
|
||||
/** Servers from `mcp.json`, which take precedence over registered servers of the same name. */
|
||||
let configuredEntries: McpServerEntry[] = [];
|
||||
let configErrors: string[] = [];
|
||||
/** Registered servers that `mcp.json` overrides, shown in `/mcp`. */
|
||||
let overridden: string[] = [];
|
||||
/** Between session_start and session_shutdown. Registrations before that are read on session_start. */
|
||||
let sessionActive = false;
|
||||
let autoEnableCodemode = true;
|
||||
/** Whether the "codemode tools unreachable" warning was shown since the session started. */
|
||||
let warnedUnreachable = false;
|
||||
@@ -174,6 +185,24 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
const connections = () => servers.flatMap((server) => (server.connection ? [server.connection] : []));
|
||||
const findServer = (name: string) => servers.find((server) => server.entry.name === name);
|
||||
|
||||
/** Servers extensions registered, except names `mcp.json` defines, which take precedence. */
|
||||
const registeredServers = (): { servers: McpServer[]; overridden: string[] } => {
|
||||
const registered: McpServer[] = [];
|
||||
const overriddenNames: string[] = [];
|
||||
for (const { name, config, extensionPath } of pi.getMcpServers()) {
|
||||
const configured = configuredEntries.find((entry) => entry.name === name);
|
||||
if (configured) {
|
||||
overriddenNames.push(`"${name}" registered by ${extensionPath} is overridden by ${configured.source}`);
|
||||
continue;
|
||||
}
|
||||
registered.push({
|
||||
entry: { name, config, source: extensionPath, scope: "extension" },
|
||||
registeredConfig: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
return { servers: registered, overridden: overriddenNames };
|
||||
};
|
||||
|
||||
const getCredentials = (runtime: typeof McpRuntime): McpOAuthCredentialStore => {
|
||||
credentials ??= new runtime.McpOAuthCredentialStore();
|
||||
return credentials;
|
||||
@@ -298,10 +327,13 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
return connection;
|
||||
};
|
||||
|
||||
/** One message for everything that needs the user after startup. */
|
||||
const reportProblems = (ctx: ExtensionContext) => {
|
||||
const lines = configErrors.map((error) => `config: ${error}`);
|
||||
for (const server of servers) {
|
||||
/**
|
||||
* One message for everything that needs the user after startup, or only for `only`, servers
|
||||
* that connected later.
|
||||
*/
|
||||
const reportProblems = (ctx: ExtensionContext, only?: readonly McpServer[]) => {
|
||||
const lines = only ? [] : configErrors.map((error) => `config: ${error}`);
|
||||
for (const server of only ?? servers) {
|
||||
const state = server.connection?.state;
|
||||
if (state === "needs-auth" || state === "failed")
|
||||
lines.push(`${server.entry.name}: ${describeState(server)}`);
|
||||
@@ -313,12 +345,17 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
);
|
||||
};
|
||||
|
||||
/** Save a config change; returns an error message when the file could not be updated. */
|
||||
/**
|
||||
* Save a config change; returns an error message when the file could not be updated. Changes to
|
||||
* registered servers only apply to the current session.
|
||||
*/
|
||||
const saveConfig = (server: McpServer, patch: McpServerConfigPatch): string | undefined => {
|
||||
try {
|
||||
updateConfig(server.entry, patch);
|
||||
} catch (error) {
|
||||
return `Could not update ${server.entry.source}: ${errorMessage(error)}`;
|
||||
if (server.entry.scope !== "extension") {
|
||||
try {
|
||||
updateConfig(server.entry, patch);
|
||||
} catch (error) {
|
||||
return `Could not update ${server.entry.source}: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
server.entry = { ...server.entry, config: { ...server.entry.config, ...patch } };
|
||||
return undefined;
|
||||
@@ -403,9 +440,14 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
// Manager (`/mcp` in the TUI)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
const notices = () => [
|
||||
...configErrors.map((error) => `config: ${error}`),
|
||||
...overridden.map((line) => `overridden: ${line}`),
|
||||
];
|
||||
|
||||
const serversMenu = (): McpMenu => ({
|
||||
title: "MCP servers",
|
||||
error: configErrors.length > 0 ? configErrors.map((error) => `config: ${error}`).join("\n") : undefined,
|
||||
error: notices().join("\n") || undefined,
|
||||
items: [...servers]
|
||||
.sort((a, b) => attentionRank(a) - attentionRank(b) || a.entry.name.localeCompare(b.entry.name))
|
||||
.map((server) => ({
|
||||
@@ -430,7 +472,12 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
};
|
||||
}
|
||||
const { entry, connection } = server;
|
||||
const saved = entry.scope ? `saved to the ${entry.scope} mcp.json` : "saved to mcp.json";
|
||||
const saved =
|
||||
entry.scope === "extension"
|
||||
? "for this session"
|
||||
: entry.scope
|
||||
? `saved to the ${entry.scope} mcp.json`
|
||||
: "saved to mcp.json";
|
||||
const items: SelectItem[] = [];
|
||||
if (!isEnabled(server)) {
|
||||
items.push({ value: "enable", label: "Enable", description: saved });
|
||||
@@ -489,7 +536,10 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
const current = exposureOf(server.entry);
|
||||
const choice = await ui.menu(() => ({
|
||||
title: `Exposure of ${server.entry.name}`,
|
||||
details: `Saved to ${server.entry.source}.`,
|
||||
details:
|
||||
server.entry.scope === "extension"
|
||||
? `Applies to this session; the server is registered by ${server.entry.source}.`
|
||||
: `Saved to ${server.entry.source}.`,
|
||||
items: (Object.keys(EXPOSURE_DESCRIPTIONS) as (keyof typeof EXPOSURE_DESCRIPTIONS)[]).map((exposure) => ({
|
||||
value: exposure,
|
||||
label: exposure === current ? `${exposure} (current)` : exposure,
|
||||
@@ -566,7 +616,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
const formatStatus = (): string => {
|
||||
if (servers.length === 0 && configErrors.length === 0) {
|
||||
if (servers.length === 0 && configErrors.length === 0 && overridden.length === 0) {
|
||||
return `No MCP servers configured. Add them to ${resolve(getAgentDir(), "mcp.json")} or .pi/mcp.json.`;
|
||||
}
|
||||
const lines = servers.map((server) => {
|
||||
@@ -588,6 +638,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
return `${name}: ${state}${tools} (${exposure})${error}`;
|
||||
});
|
||||
for (const error of configErrors) lines.push(`config error: ${error}`);
|
||||
for (const line of overridden) lines.push(`overridden: ${line}`);
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
@@ -659,7 +710,11 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
waitedForStartup = false;
|
||||
sessionCwd = ctx.cwd;
|
||||
const current = ++generation;
|
||||
servers = loaded.servers.map((entry) => ({ entry }));
|
||||
sessionActive = true;
|
||||
configuredEntries = loaded.servers;
|
||||
const registered = registeredServers();
|
||||
overridden = registered.overridden;
|
||||
servers = [...loaded.servers.map((entry) => ({ entry })), ...registered.servers];
|
||||
emitChange();
|
||||
const enabled = servers.filter(isEnabled);
|
||||
if (enabled.length === 0) {
|
||||
@@ -679,7 +734,12 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
ensureCodemodeActive(ctx);
|
||||
reportProblems(ctx);
|
||||
})
|
||||
.catch((error: unknown) => ctx.ui.notify(`MCP failed to load: ${errorMessage(error)}`, "error"));
|
||||
.catch((error: unknown) => {
|
||||
// The session may have been disposed meanwhile, which makes ctx stale.
|
||||
try {
|
||||
ctx.ui.notify(`MCP failed to load: ${errorMessage(error)}`, "error");
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
|
||||
// The first prompt waits for startup connections so their tools are available to it, but not
|
||||
@@ -706,7 +766,45 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
|
||||
if (tokensAtSignIn.size > 0) await reconnectSignedIn(ctx);
|
||||
});
|
||||
|
||||
// Servers registered or unregistered during the session connect or disconnect right away.
|
||||
pi.on("mcp_servers_change", async (_event, ctx) => {
|
||||
if (!sessionActive) return;
|
||||
const current = generation;
|
||||
const registered = registeredServers();
|
||||
overridden = registered.overridden;
|
||||
const next = new Map(registered.servers.map((server) => [server.entry.name, server]));
|
||||
// Unregistered servers and re-registered ones with a new config are dropped; the latter come back below.
|
||||
const removed = servers.filter(
|
||||
(server) =>
|
||||
server.entry.scope === "extension" &&
|
||||
next.get(server.entry.name)?.registeredConfig !== server.registeredConfig,
|
||||
);
|
||||
servers = servers.filter((server) => !removed.includes(server));
|
||||
for (const server of removed) hideTools(server.entry.name);
|
||||
const added = registered.servers.filter((server) => !findServer(server.entry.name));
|
||||
servers.push(...added);
|
||||
emitChange();
|
||||
await Promise.all(removed.map((server) => server.connection?.close()));
|
||||
const connecting = added.filter(isEnabled);
|
||||
if (current !== generation || connecting.length === 0) return;
|
||||
try {
|
||||
const started = await Promise.all(connecting.map((server) => createConnection(server)));
|
||||
if (current !== generation) {
|
||||
await Promise.all(started.map((connection) => connection.close()));
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled(started.map((connection) => connection.getClient()));
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`MCP failed to load: ${errorMessage(error)}`, "error");
|
||||
return;
|
||||
}
|
||||
if (current !== generation) return;
|
||||
ensureCodemodeActive(ctx);
|
||||
reportProblems(ctx, connecting);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
sessionActive = false;
|
||||
generation++;
|
||||
const closing = connections();
|
||||
servers = [];
|
||||
|
||||
@@ -120,6 +120,7 @@ export type {
|
||||
LsToolCallEvent,
|
||||
MarkdownTransformContext,
|
||||
MarkdownTransformer,
|
||||
McpServersChangeEvent,
|
||||
MessageEndEvent,
|
||||
MessageEndEventResult,
|
||||
MessageRenderer,
|
||||
@@ -210,6 +211,7 @@ export {
|
||||
} from "./core/extensions/index.ts";
|
||||
// Footer data provider (git branch + extension statuses - data not otherwise available to extensions)
|
||||
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts";
|
||||
export type { RegisteredMcpServer } from "./core/mcp-servers.ts";
|
||||
export { convertToLlm } from "./core/messages.ts";
|
||||
export { ModelRegistry } from "./core/model-registry.ts";
|
||||
export {
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { SystemMessage, ToolResultMessage } from "@earendil-works/pi-ai/com
|
||||
import { type JsonRpcRequest, LATEST_PROTOCOL_VERSION } from "@earendil-works/pi-mcp";
|
||||
import { createInMemoryTransportPair } from "@earendil-works/pi-mcp/testing";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionFactory } from "../../src/core/extensions/types.ts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExtensionAPI, ExtensionFactory } from "../../src/core/extensions/types.ts";
|
||||
import { createCodemodeExtension } from "../../src/extensions/codemode/index.ts";
|
||||
import type { McpExposure, McpServerEntry } from "../../src/extensions/mcp/config.ts";
|
||||
import { createMcpExtension } from "../../src/extensions/mcp/index.ts";
|
||||
@@ -530,3 +530,115 @@ describe("AgentSession MCP integration", () => {
|
||||
expect(text(toolResult(harness, "tool_search"))).toBe("No matching tools found.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSession MCP servers registered by extensions", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) harnesses.pop()?.cleanup();
|
||||
});
|
||||
|
||||
/** `configured` are the mcp.json servers; `plugins` register servers through the extension API. */
|
||||
async function setup(plugins: ExtensionFactory | ExtensionFactory[], configured: McpServerEntry[] = []) {
|
||||
const connected: McpServerEntry[] = [];
|
||||
const harness = await createHarness({
|
||||
initialActiveToolNames: [],
|
||||
extensionFactories: [
|
||||
...(Array.isArray(plugins) ? plugins : [plugins]),
|
||||
createCodemodeExtension(),
|
||||
createMcpExtension({
|
||||
loadConfig: () => ({ servers: configured, errors: [] }),
|
||||
createTransport: (entry) => {
|
||||
connected.push(entry);
|
||||
const pair = createFakeServer([]);
|
||||
void pair.server.start();
|
||||
return pair.client;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
await harness.session.bindExtensions({});
|
||||
return { harness, connected };
|
||||
}
|
||||
|
||||
it("connects servers registered while extensions load", async () => {
|
||||
const { harness, connected } = await setup((pi) => {
|
||||
pi.registerMcpServer("plugin", { url: "http://plugin.invalid", exposure: "direct" });
|
||||
});
|
||||
harness.setResponses([fauxAssistantMessage("ready")]);
|
||||
await harness.session.prompt("start");
|
||||
|
||||
expect(connected.map((entry) => [entry.name, entry.scope])).toEqual([["plugin", "extension"]]);
|
||||
expect(harness.session.getActiveToolNames()).toContain("mcp__plugin__search");
|
||||
});
|
||||
|
||||
it("connects and disconnects servers registered during the session", async () => {
|
||||
let api: ExtensionAPI | undefined;
|
||||
const { harness, connected } = await setup((pi) => {
|
||||
api = pi;
|
||||
});
|
||||
if (!api) throw new Error("No extension API");
|
||||
const pi = api;
|
||||
|
||||
pi.registerMcpServer("late", { url: "http://late.invalid" });
|
||||
await vi.waitFor(() => expect(harness.session.getCallableToolNames()).toContain("mcp__late__search"));
|
||||
expect(connected.map((entry) => entry.name)).toEqual(["late"]);
|
||||
// Codemode-exposed tools need the codemode tool, which is activated for them.
|
||||
expect(harness.session.getActiveToolNames()).toContain("codemode");
|
||||
|
||||
pi.unregisterMcpServer("late");
|
||||
await vi.waitFor(() => expect(harness.session.getCallableToolNames()).not.toContain("mcp__late__search"));
|
||||
});
|
||||
|
||||
it("prefers the mcp.json server over a registered server of the same name", async () => {
|
||||
const configured: McpServerEntry = { name: "docs", config: { url: "http://config.invalid" }, source: "mcp.json" };
|
||||
const { harness, connected } = await setup(
|
||||
(pi) => {
|
||||
pi.registerMcpServer("docs", { url: "http://plugin.invalid" });
|
||||
},
|
||||
[configured],
|
||||
);
|
||||
harness.setResponses([fauxAssistantMessage("ready")]);
|
||||
await harness.session.prompt("start");
|
||||
|
||||
expect(connected).toEqual([configured]);
|
||||
});
|
||||
|
||||
it("rejects invalid configs and names another extension registered", async () => {
|
||||
const errors: string[] = [];
|
||||
const record = (fn: () => void) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
await setup([
|
||||
(pi) => {
|
||||
record(() => pi.registerMcpServer("bad name", { url: "http://x.invalid" }));
|
||||
record(() => pi.registerMcpServer("ftp", { url: "ftp://x.invalid" }));
|
||||
pi.registerMcpServer("taken", { url: "http://x.invalid" });
|
||||
// Registering again replaces the extension's own registration.
|
||||
pi.registerMcpServer("taken", { url: "http://y.invalid" });
|
||||
},
|
||||
(pi) => record(() => pi.registerMcpServer("taken", { url: "http://z.invalid" })),
|
||||
]);
|
||||
expect(errors).toEqual([
|
||||
expect.stringContaining('invalid server name "bad name"'),
|
||||
expect.stringContaining('server "ftp": url must be an http or https URL'),
|
||||
expect.stringMatching(/MCP server "taken" is already registered by extension/),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports registered servers when no extension connects them", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [(pi) => pi.registerMcpServer("orphan", { url: "http://orphan.invalid" })],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const errors: string[] = [];
|
||||
await harness.session.bindExtensions({ onError: (error) => errors.push(error.error) });
|
||||
|
||||
expect(errors).toEqual([expect.stringContaining('MCP server "orphan" is registered, but no loaded extension')]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user