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.
215 lines (196 loc) • 9.02 kB
text/typescript
/**
* Per-server status snapshots shared by `/mcp <name>` and the `mcp_status` tool.
*
* `collectServerStatus` gathers connection state, tool activation counts, the OAuth
* credential summary, server instructions, and config provenance in one place; `formatServerDetail` renders
* the `/mcp <name>` view and `toStatusRow` renders an `mcp_status` row from the same
* snapshot, so the command and the tool can never drift apart.
*/
import type { ServerConfig } from "./config.js";
import { describeError, sanitizeLogText } from "./debug-log-format.js";
import { reportDiagnostic } from "./debug-log.js";
import { AuthLockError, AuthRequiredError, McpError } from "./errors.js";
import type { ManagedServer } from "./server-manager.js";
import { redactSecrets } from "./secret-redaction.js";
import {
formatProjectConfigNote,
formatServerSource,
type ConfigProvenance,
type ServerConfigSource,
} from "./server-provenance.js";
import { sanitizeTerminalText } from "./terminal-text.js";
/** Stored-credentials summary as far as a status view needs it. */
export interface StoredAuthStatus {
hasTokens: boolean;
}
export interface ServerStatusSnapshot {
name: string;
state: ManagedServer["state"];
transport: ServerConfig["transport"];
lifecycle: ServerConfig["lifecycle"];
retryCount: number;
source: ServerConfigSource | undefined;
tools: { discovered: number; active: number };
/** OAuth credential summary, or undefined when the server has no `auth` config. */
auth: string | undefined;
lastError: Error | null;
/** Recent sanitized stderr output, or the caller's "(no stderr output)" note. */
recentOutput: string;
/** Sanitized initialize-handshake guidance, only while the server is ready. */
instructions?: string | undefined;
}
export interface ServerStatusRow {
name: string;
lifecycle: string;
state: string;
tools: { discovered: number; active: number };
auth: string;
error: string | undefined;
source: ServerConfigSource | "unknown";
instructions?: string | undefined;
}
export interface CollectServerStatusParams {
server: ManagedServer;
tools: { discovered: number; active: number };
source: ServerConfigSource | undefined;
recentOutput?: string | undefined;
/** Reads stored OAuth state for a server; injected so tests avoid disk I/O. */
readAuthStatus: (serverName: string) => Promise<StoredAuthStatus | null>;
}
/** How a failed server should be steered back to a working state. */
export type FailureKind = "auth-required" | "auth-lock" | "connection";
const ISSUE_TRACKER = "https://github.com/irahardianto/pi-mcp-extension/issues";
export function formatStatusAuth(
authConfig: ServerConfig["auth"] | undefined,
authCorrupt: boolean,
hasTokens: boolean | undefined,
): string {
if (!authConfig) return "not configured";
if (authCorrupt) return "corrupt state file backed up (re-auth required)";
if (hasTokens) return "credentials stored";
return "authorization may be required";
}
/** Short action hint for the `mcp_status` tool, or undefined when the server is healthy. */
export function formatStatusError(lastError: unknown): string | undefined {
if (!lastError) return undefined;
if (lastError instanceof AuthRequiredError) {
return "Authorization required: use mcp_connect in an interactive Pi session";
}
const code = lastError instanceof McpError ? lastError.code : "failed";
return `Connection ${code}; use mcp_connect to retry`;
}
export function classifyFailure(error: Error | null): FailureKind | undefined {
if (error === null) return undefined;
// WHY: AuthLockError extends AuthRequiredError, so the narrower lock case must be
// tested first or every lock contention would be reported as missing credentials.
if (error instanceof AuthLockError) return "auth-lock";
if (error instanceof AuthRequiredError) return "auth-required";
return "connection";
}
/** Troubleshooting hint for `/mcp <name>`, or undefined when there is no failure. */
export function formatFailureHint(kind: FailureKind | undefined, serverName: string): string | undefined {
if (kind === undefined) return undefined;
const name = sanitizeTerminalText(serverName);
if (kind === "auth-required") {
return `Hint: run /mcp:auth ${name} (or /mcp:auth ${name} --reset if stored credentials expired)`;
}
if (kind === "auth-lock") {
return `Hint: another Pi process is authorizing ${name}; retry once it finishes`;
}
return `Hint: run with PI_MCP_DEBUG=1 pi to log redacted diagnostics, or report server issues at ${ISSUE_TRACKER}`;
}
function isDefined(value: string | undefined): value is string {
return value !== undefined;
}
/** `/mcp <name>` detail view: config provenance, tool counts, credentials, and next steps. */
export function formatServerDetail(snapshot: ServerStatusSnapshot, provenance: ConfigProvenance): string {
const instructions = sanitizeServerInstructions(snapshot.instructions);
const lines: Array<string | undefined> = [
`Server: ${sanitizeTerminalText(snapshot.name)}`,
`State: ${snapshot.state}`,
`Retries: ${snapshot.retryCount}`,
`Transport: ${snapshot.transport}`,
`Lifecycle: ${snapshot.lifecycle}`,
`Source: ${formatServerSource(snapshot.source)}`,
`Tools: ${snapshot.tools.active}/${snapshot.tools.discovered} active`,
instructions === undefined ? undefined : `Instructions: ${instructions}`,
snapshot.auth === undefined ? undefined : `OAuth: ${snapshot.auth}`,
formatProjectConfigNote(provenance),
// WHY: a connect/stream failure usually quotes the request URL, and that URL can carry a
// credential. This view is what users paste into an issue, so mask it like the log does.
snapshot.lastError === null ? undefined : `Last error: ${sanitizeLogText(snapshot.lastError.message, 500)}`,
formatFailureHint(classifyFailure(snapshot.lastError), snapshot.name),
];
return `${lines.filter(isDefined).join("\n")}\n\nRecent output:\n${snapshot.recentOutput}`;
}
/** `mcp_status` row for one server. */
export function toStatusRow(snapshot: ServerStatusSnapshot): ServerStatusRow {
const instructions = sanitizeServerInstructions(snapshot.instructions);
return {
name: snapshot.name,
lifecycle: snapshot.lifecycle,
state: snapshot.state,
tools: snapshot.tools,
auth: snapshot.auth ?? "not configured",
error: formatStatusError(snapshot.lastError),
source: formatServerSource(snapshot.source),
...(instructions === undefined ? {} : { instructions }),
};
}
/**
* OAuth summary for one server. A corrupt stored-state file is reported as a
* diagnostic and rendered as "corrupt state file backed up", never thrown: one
* broken server must not take down `/mcp <name>` or the whole `mcp_status` list.
*/
async function summarizeAuth(params: CollectServerStatusParams): Promise<string | undefined> {
const authConfig = params.server.config.auth;
if (authConfig === undefined) return undefined;
try {
const status = await params.readAuthStatus(params.server.name);
return formatStatusAuth(authConfig, false, status?.hasTokens);
} catch (err) {
// WHY: getAuthStatus() already backed up and deleted the corrupt file before throwing.
const { kind, message } = describeError(err);
reportDiagnostic({
level: "error",
event: "oauth.state_corrupt",
server: params.server.name,
message,
fields: { kind },
});
return formatStatusAuth(authConfig, true, undefined);
}
}
/**
* Sanitize raw MCP initialize-handshake instructions: strip terminal escapes,
* redact credential-shaped substrings, and bound to 500 UTF-16 code units.
*/
export function sanitizeServerInstructions(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
// WHY: remove escapes before redaction so they cannot hide a credential key;
// bound the redacted result so masking cannot grow it beyond the UI budget.
const cleaned = sanitizeTerminalText(raw, { maxLength: raw.length }).trim();
return sanitizeTerminalText(redactSecrets(cleaned), { maxLength: 500 }).trim() || undefined;
}
/** Read and sanitize handshake instructions from a ready server. */
export function getServerInstructions(server: ManagedServer | undefined): string | undefined {
if (server?.state !== "ready") return undefined;
return sanitizeServerInstructions(server.client?.getInstructions());
}
export async function collectServerStatus(params: CollectServerStatusParams): Promise<ServerStatusSnapshot> {
const { server } = params;
const instructions = getServerInstructions(server);
return {
name: server.name,
state: server.state,
transport: server.config.transport,
lifecycle: server.config.lifecycle,
retryCount: server.retryCount,
source: params.source,
tools: params.tools,
auth: await summarizeAuth(params),
lastError: server.lastError,
recentOutput: params.recentOutput ?? "",
...(instructions === undefined ? {} : { instructions }),
};
}