pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
424 lines (384 loc) • 14.7 kB
text/typescript
/**
* Stateless MCP client protocol wiring for pi-mcp.
*
* Owns everything about a single SDK `Client` connection that does not touch
* `ManagedServer` lifecycle state:
* - handshake race guard + timeout normalization (`createConnectRaceGuard`,
* `rethrowNormalizedConnectError`, `connectClientWithTimeout`)
* - client construction and protocol notifications (`createMcpClient`) —
* `roots/list`, `notifications/tools/list_changed`,
* `notifications/resources/list_changed`, `notifications/message`
* - pure formatting helpers (`buildWorkspaceRoot`, `formatServerLogNotification`)
* - time-bounded, non-overlapping health-check pings (`pingClientWithTimeout`,
* `startHealthCheck`)
*
* Every lifecycle decision (is this server still `ready`, is this client still the
* current one, should a failed ping trigger recovery) stays in `ServerManager`, which
* supplies it through the callbacks in {@link CreateMcpClientOptions} and
* {@link HealthCheckOptions}.
*/
import { basename } from "node:path";
import { pathToFileURL } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import {
ListRootsRequestSchema,
ToolListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
LoggingMessageNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { McpError, AuthCancelledError } from "./errors.js";
import { isSdkTimeoutError } from "./connect-policy.js";
import { sanitizeTerminalText } from "./terminal-text.js";
// ─── Timer injection ──────────────────────────────────────────────────────────
/**
* Timer functions used by the connect race guard, ping deadline, and health-check
* scheduler.
*
* WHY: tests drive every deadline through a fake clock instead of wall-clock sleeps, so
* the modules that own timers accept this deps object with the production globals as
* defaults (same pattern as `AuthLockDeps` / `StdioProcessDeps`).
*/
export interface McpClientTimerDeps {
setTimeout: typeof setTimeout;
clearTimeout: typeof clearTimeout;
setInterval: typeof setInterval;
clearInterval: typeof clearInterval;
}
function resolveTimerDeps(deps?: Partial<McpClientTimerDeps> | undefined): McpClientTimerDeps {
// WHY: merge the production globals per call instead of returning `defaultTimerDeps`, so a
// replaced clock (node:test `mock.timers`) is the one the caller's deadlines actually use.
return {
setTimeout: deps?.setTimeout ?? setTimeout,
clearTimeout: deps?.clearTimeout ?? clearTimeout,
setInterval: deps?.setInterval ?? setInterval,
clearInterval: deps?.clearInterval ?? clearInterval,
};
}
// ─── Handshake race & timeout normalization ───────────────────────────────────
export interface ConnectRaceGuard {
outcome: Promise<never>;
dispose: () => void;
}
/**
* Race a connection attempt against its deadline and its abort signal.
*
* `outcome` rejects with {@link McpError} (`timeout`) when `timeoutMs` elapses and with
* {@link AuthCancelledError} when `signal` aborts; `dispose()` releases both the timer and
* the abort listener, and is safe to call after either of them fired.
*/
export function createConnectRaceGuard(
timeoutMs: number,
signal: AbortSignal,
serverName: string,
deps?: Partial<McpClientTimerDeps> | undefined,
): ConnectRaceGuard {
const timers = resolveTimerDeps(deps);
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const outcome = new Promise<never>((_resolve, reject) => {
if (signal.aborted) {
reject(new AuthCancelledError(serverName));
return;
}
onAbort = () => {
reject(new AuthCancelledError(serverName));
};
signal.addEventListener("abort", onAbort, { once: true });
timer = timers.setTimeout(() => {
reject(
new McpError(
`Connection timed out after ${timeoutMs}ms`,
serverName,
"timeout",
),
);
}, timeoutMs);
});
const dispose = (): void => {
if (timer !== undefined) {
timers.clearTimeout(timer);
}
if (onAbort) {
signal.removeEventListener("abort", onAbort);
}
};
return { outcome, dispose };
}
/**
* Re-throw a failed handshake with the error class the connect classifier downstream reads.
*
* WHY: a caller abort and an SDK request timeout must stay distinguishable — the SDK rejects
* an aborted in-flight request with its own timeout code, so checking `signal.aborted` first
* keeps `/mcp:stop` from being reported as a server timeout.
*/
export function rethrowNormalizedConnectError(
err: unknown,
signal: AbortSignal,
serverName: string,
timeoutMs: number,
): never {
if (signal.aborted || err instanceof AuthCancelledError) {
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName);
}
if (isSdkTimeoutError(err)) {
throw new McpError(
`Connection timed out after ${timeoutMs}ms`,
serverName,
"timeout",
err,
);
}
throw err;
}
export interface ConnectClientOptions {
client: Client;
transport: Transport;
timeoutMs: number;
signal: AbortSignal;
serverName: string;
}
/**
* Connect a client to its transport, bounded by `timeoutMs`, cancellable by `signal`.
*
* `client.connect` is awaited against the race guard rather than trusted to honour both,
* because the SDK applies its own `timeout` per request and a transport whose `start()` hangs
* never reaches a request at all.
*/
export async function connectClientWithTimeout(
options: ConnectClientOptions,
deps?: Partial<McpClientTimerDeps> | undefined,
): Promise<void> {
const { client, transport, timeoutMs, signal, serverName } = options;
if (signal.aborted) {
throw new AuthCancelledError(serverName);
}
const connectPromise = client.connect(transport, { timeout: timeoutMs, signal });
// WHY: Attach a no-op catch handler so if the outer timeout or abort wins the race
// while transport.start() is still in flight, a late rejection from connect() never
// triggers an unhandledRejection.
connectPromise.catch(() => {
// WHY: Handled via Promise.race below.
});
const guard = createConnectRaceGuard(timeoutMs, signal, serverName, deps);
try {
await Promise.race([connectPromise, guard.outcome]);
} catch (err) {
rethrowNormalizedConnectError(err, signal, serverName, timeoutMs);
} finally {
guard.dispose();
}
}
// ─── Client construction & protocol notifications ─────────────────────────────
export interface WorkspaceRoot {
uri: string;
name: string;
}
/**
* The single `roots/list` entry exposing the workspace to the server.
*
* The `"workspace"` fallback is kept as-is: the extracted helper must not change what a server
* sees for a cwd whose basename is empty (a filesystem root), even though returning the cwd
* itself would read more naturally.
*/
export function buildWorkspaceRoot(cwd: string): WorkspaceRoot {
return {
uri: pathToFileURL(cwd).href,
name: basename(cwd) || "workspace",
};
}
export interface ServerLogNotificationParams {
level?: unknown;
logger?: unknown;
data?: unknown;
}
export interface FormattedServerLog {
safeLevel: string;
safeLogger: string;
formatted: string;
}
/**
* Turn a `notifications/message` payload into sanitized log fields.
*
* Every field is server-controlled, so it passes through `sanitizeTerminalText` before it can
* reach the stderr ring buffer, the debug log, or a notification.
*/
export function formatServerLogNotification(
serverName: string,
params: ServerLogNotificationParams | undefined,
): FormattedServerLog {
const { level = "info", logger = serverName, data } = params ?? {};
const rawMsg = typeof data === "string" ? data : JSON.stringify(data);
const safeLevel = sanitizeTerminalText(String(level), { maxLength: 32 });
const safeLogger = sanitizeTerminalText(String(logger), { maxLength: 100 });
const safeMsg = sanitizeTerminalText(rawMsg ?? "");
return {
safeLevel,
safeLogger,
formatted: `[${safeLevel}] ${safeLogger}: ${safeMsg}`,
};
}
export interface CreateMcpClientOptions {
/** Server name, used as the default logger name for `notifications/message`. */
serverName: string;
/** Current workspace root exposed to the server (`server.cwd` when it is set). */
getCwd: () => string;
/** `notifications/tools/list_changed` — the caller decides whether it still applies. */
onToolsChanged: (client: Client) => Promise<void>;
/** `notifications/resources/list_changed` — the caller decides whether it still applies. */
onResourcesChanged: (client: Client) => void;
/** `notifications/message` — already sanitized and formatted. */
onLogLine: (entry: FormattedServerLog) => void;
}
/**
* Build the pi-mcp MCP client and wire its protocol surface.
*
* The handlers are registered on the client but hold no lifecycle state: each one forwards
* the *current* client back to its owner so a superseded client's late notification cannot
* mutate a server it no longer belongs to.
*/
export function createMcpClient(options: CreateMcpClientOptions): Client {
const client = new Client(
{ name: "pi-mcp", version: "1.0.0" },
{
capabilities: {
// Expose workspace root to MCP servers
roots: { listChanged: true },
// Sampling: explicitly NOT declared — not supported in v1
},
},
);
// Handle roots/list requests from the server
client.setRequestHandler(ListRootsRequestSchema, async () => ({
roots: [buildWorkspaceRoot(options.getCwd())],
}));
// tools/list_changed: re-discover tools and update Pi registrations
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
await options.onToolsChanged(client);
});
// resources/list_changed: drop the cached resource catalog so the next
// `mcp_list_resources` / `/mcp:resources` call re-reads it.
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
options.onResourcesChanged(client);
});
// notifications/message: structured logging from MCP servers
client.setNotificationHandler(LoggingMessageNotificationSchema, async (notification) => {
options.onLogLine(formatServerLogNotification(options.serverName, notification.params));
});
return client;
}
// ─── Health checks ────────────────────────────────────────────────────────────
export interface PingClientOptions {
client: Client;
serverName: string;
timeoutMs: number;
signal: AbortSignal;
}
/**
* Ping a server with a hard deadline that is independent of the SDK's own request timeout.
*
* WHY: the ping is raced against a local timer because a wedged transport can leave the SDK's
* per-request timeout unarmed — the point of a health check is to notice that.
*/
export async function pingClientWithTimeout(
options: PingClientOptions,
deps?: Partial<McpClientTimerDeps> | undefined,
): Promise<void> {
const { client, serverName, timeoutMs, signal } = options;
const timers = resolveTimerDeps(deps);
let pingTimer: ReturnType<typeof setTimeout> | undefined;
const pingOptions = { timeout: timeoutMs, signal };
const pingCall = (
client.ping as (
opts?: { timeout?: number; signal?: AbortSignal },
extraOpts?: { timeout?: number; signal?: AbortSignal },
) => Promise<unknown>
)(pingOptions, pingOptions);
// WHY: Handled via Promise.race below — a ping that loses the race must not surface as
// an unhandled rejection.
pingCall.catch(() => {
// WHY: Handled via Promise.race below.
});
try {
await Promise.race([
pingCall,
new Promise<never>((_resolve, reject) => {
pingTimer = timers.setTimeout(
() =>
reject(
new McpError(
`Health check ping timed out after ${timeoutMs}ms`,
serverName,
"timeout",
),
),
timeoutMs,
);
}),
]);
} finally {
if (pingTimer !== undefined) {
timers.clearTimeout(pingTimer);
}
}
}
export interface HealthCheckOptions {
serverName: string;
/** The connection to ping, or `null` when the server has no live client. */
getClient: () => Client | null;
/** Whether the owning server is currently `ready` (guards the interval and recovery). */
isReady: () => boolean;
intervalMs: number;
timeoutMs: number;
signal: AbortSignal;
/** Called after a failed, non-cancelled ping on a still-ready server. */
onPingFailure: () => void;
}
/**
* Start the periodic health check, or return `null` when the server has none configured.
*
* The interval is deliberately non-overlapping (`pingInFlight`) and bounded per tick. The
* caller keeps sole ownership of the timer handle (including whether it is referenced) and of
* what a failure means.
*/
export function startHealthCheck(
options: HealthCheckOptions,
deps?: Partial<McpClientTimerDeps> | undefined,
): ReturnType<typeof setInterval> | null {
if (!options.intervalMs) {
return null;
}
const timers = resolveTimerDeps(deps);
let pingInFlight = false;
const runHealthCheck = async (): Promise<void> => {
const client = options.getClient();
if (pingInFlight || !client || !options.isReady() || options.signal.aborted) {
return;
}
pingInFlight = true;
try {
await pingClientWithTimeout(
{
client,
serverName: options.serverName,
timeoutMs: options.timeoutMs,
signal: options.signal,
},
deps,
);
} catch {
// WHY: Ping failure triggers background recovery if the server is still ready.
// The ping error is intentionally not kept on the server's lastError because
// recovery tears the server down synchronously, which resets lastError before any
// reader observes it; if reconnection fails, recovery records that failure instead.
if (options.isReady() && !options.signal.aborted) {
options.onPingFailure();
}
} finally {
pingInFlight = false;
}
};
return timers.setInterval(() => {
void runHealthCheck();
}, options.intervalMs);
}