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.
224 lines (211 loc) • 8.13 kB
text/typescript
/**
* SDK transport construction for pi-mcp.
*
* Builds the configured MCP SDK transport (stdio, streamable-http, or sse),
* wires silent OAuth providers and static headers, and applies transport-level
* SDK workarounds.
*/
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import { McpError } from "./errors.js";
import type { ServerConfig } from "./config.js";
import { McpOAuthProvider } from "./oauth-provider.js";
import { isAuthorizationHeader, stripAuthorizationHeader } from "./auth-challenge.js";
import { reportDiagnostic } from "./debug-log.js";
import { instrumentStderr } from "./stdio-process.js";
import { sanitizeTerminalText } from "./terminal-text.js";
import { instrumentTransportMessageOrder } from "./transport-order.js";
function resolveEnv(extra?: Record<string, string>): Record<string, string> {
// Build clean env: process.env may contain undefined values,
// child_process.spawn silently drops them, but let's be explicit
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) env[key] = value;
}
if (extra) {
Object.assign(env, extra);
}
return env;
}
function createRequestInit(headers: Record<string, string> | undefined): RequestInit | undefined {
// Build requestInit for static headers (API keys, etc.)
return headers ? { headers } : undefined;
}
function createOAuthProvider(
serverName: string,
config: ServerConfig,
storageDir?: string,
): OAuthClientProvider | undefined {
if (!config.auth || config.transport === "stdio") return undefined;
// Silent transport attempts must not write PKCE or launch a browser.
return new McpOAuthProvider({
serverName,
authConfig: config.auth,
storageDir,
mode: "silent",
serverUrl: config.url,
});
}
/**
* Route a `createTransport(..., onStderr)` caller's stderr through the shared instrumentation.
*
* WHY: this used to keep its own line splitter that only flushed on the stream's 'end'/'close',
* so on this path a partial line written before a close was dropped — the same defect
* `instrumentStderr` fixes for `ServerManager` (#206).
*/
function wireStdioStderr(transport: StdioClientTransport, onStderr: (line: string) => void): void {
instrumentStderr(transport, (rawLine) => {
const clean = sanitizeTerminalText(rawLine);
if (clean) onStderr(clean);
});
}
/** Expand a leading `~` / `~/` / `~\` to the home directory, the way Pi resolves a config `cwd`. */
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
return path;
}
/**
* Resolve the working directory for a stdio server: Pi 1.3.0's per-server `cwd` (relative paths
* resolve against the session working directory, `~` expands to the home directory), else the
* session working directory unchanged.
*/
export function resolveStdioCwd(
configuredCwd: string | undefined,
sessionCwd: string | undefined,
): string | undefined {
if (configuredCwd === undefined) return sessionCwd;
return resolve(sessionCwd ?? process.cwd(), expandHome(configuredCwd));
}
function createStdioTransport(
serverName: string,
config: ServerConfig,
sessionCwd: string | undefined,
onStderr: ((line: string) => void) | undefined,
): Transport {
if (!config.command) {
throw new McpError("stdio transport requires 'command'", serverName, "config");
}
const cwd = resolveStdioCwd(config.cwd, sessionCwd);
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: resolveEnv(config.env),
...(cwd !== undefined ? { cwd } : {}),
stderr: "pipe",
});
if (onStderr) {
wireStdioStderr(transport, onStderr);
}
return transport;
}
function createStreamableHttpTransport(
config: ServerConfig,
requestInit: RequestInit | undefined,
authProvider: OAuthClientProvider | undefined,
): Transport {
return new StreamableHTTPClientTransport(
new URL(config.url!),
{
...(requestInit && { requestInit }),
...(authProvider && { authProvider }),
// WHY: Disable internal SDK SSE stream retries so unexpected disconnects
// immediately surface to ServerManager._recover() for deterministic backoff.
reconnectionOptions: {
maxRetries: 0,
initialReconnectionDelay: 0,
maxReconnectionDelay: 0,
reconnectionDelayGrowFactor: 1,
},
},
);
}
function createSseTransport(
config: ServerConfig,
requestInit: RequestInit | undefined,
authProvider: OAuthClientProvider | undefined,
): Transport {
const transport = new SSEClientTransport(
new URL(config.url!),
{
...(requestInit && { requestInit }),
...(authProvider && { authProvider }),
},
);
// WHY: SSEClientTransport.start() awaits the initial `event: endpoint` SSE frame,
// while SSEClientTransport.close() aborts _abortController and closes _eventSource
// without firing _eventSource.onerror, leaving start() pending forever (which would
// deadlock `await client.close()`). Wrap start() and close() so closing the transport
// immediately rejects any pending start() promise.
const origStart = transport.start.bind(transport);
const origClose = transport.close.bind(transport);
let rejectPendingStart: ((err: Error) => void) | null = null;
transport.start = () =>
new Promise<void>((resolve, reject) => {
rejectPendingStart = reject;
origStart().then(
() => {
rejectPendingStart = null;
resolve();
},
(err: unknown) => {
rejectPendingStart = null;
reject(err);
},
);
});
transport.close = async () => {
if (rejectPendingStart) {
const reject = rejectPendingStart;
rejectPendingStart = null;
reject(new Error("SSE transport closed before endpoint event"));
}
await origClose();
};
return transport;
}
export function createTransport(
serverName: string,
config: ServerConfig,
cwdOrOnStderr?: string | ((line: string) => void),
storageDir?: string,
cwdArg?: string,
): Transport {
const cwd = typeof cwdOrOnStderr === "string" ? cwdOrOnStderr : cwdArg;
const onStderr = typeof cwdOrOnStderr === "function" ? cwdOrOnStderr : undefined;
// WHY: with OAuth configured the SDK owns `Authorization` (see stripAuthorizationHeader);
// a leftover static header is a migration artifact, so drop it and record that we did.
const staticHeaders = config.auth ? stripAuthorizationHeader(config.headers) : config.headers;
if (config.auth && config.headers && Object.keys(config.headers).some(isAuthorizationHeader)) {
reportDiagnostic({
level: "info",
event: "config.static_authorization_ignored",
server: serverName,
message: `Ignoring the static Authorization header for "${serverName}": OAuth is configured, so requests carry the OAuth access token instead.`,
});
}
const requestInit = createRequestInit(staticHeaders);
const authProvider = createOAuthProvider(serverName, config, storageDir);
switch (config.transport) {
case "stdio":
return instrumentTransportMessageOrder(
serverName,
createStdioTransport(serverName, config, cwd, onStderr),
);
case "streamable-http":
return instrumentTransportMessageOrder(
serverName,
createStreamableHttpTransport(config, requestInit, authProvider),
);
case "sse":
return instrumentTransportMessageOrder(
serverName,
createSseTransport(config, requestInit, authProvider),
);
}
}