pi-mcp-adapter 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lifecycle.ts ADDED
@@ -0,0 +1,59 @@
1
+ // lifecycle.ts - Connection health checks and reconnection
2
+ import type { ServerDefinition } from "./types.js";
3
+ import type { McpServerManager } from "./server-manager.js";
4
+
5
+ export type ReconnectCallback = (serverName: string) => void;
6
+
7
+ export class McpLifecycleManager {
8
+ private manager: McpServerManager;
9
+ private keepAliveServers = new Map<string, ServerDefinition>();
10
+ private healthCheckInterval?: NodeJS.Timeout;
11
+ private onReconnect?: ReconnectCallback;
12
+
13
+ constructor(manager: McpServerManager) {
14
+ this.manager = manager;
15
+ }
16
+
17
+ /**
18
+ * Set callback to be invoked after a successful auto-reconnect.
19
+ * Use this to update tool metadata when a server reconnects.
20
+ */
21
+ setReconnectCallback(callback: ReconnectCallback): void {
22
+ this.onReconnect = callback;
23
+ }
24
+
25
+ markKeepAlive(name: string, definition: ServerDefinition): void {
26
+ this.keepAliveServers.set(name, definition);
27
+ }
28
+
29
+ startHealthChecks(intervalMs = 30000): void {
30
+ this.healthCheckInterval = setInterval(() => {
31
+ this.checkConnections();
32
+ }, intervalMs);
33
+ this.healthCheckInterval.unref();
34
+ }
35
+
36
+ private async checkConnections(): Promise<void> {
37
+ for (const [name, definition] of this.keepAliveServers) {
38
+ const connection = this.manager.getConnection(name);
39
+
40
+ if (!connection || connection.status !== "connected") {
41
+ try {
42
+ await this.manager.connect(name, definition);
43
+ console.log(`MCP: Reconnected to ${name}`);
44
+ // Notify extension to update metadata
45
+ this.onReconnect?.(name);
46
+ } catch (error) {
47
+ console.error(`MCP: Failed to reconnect to ${name}:`, error);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ async gracefulShutdown(): Promise<void> {
54
+ if (this.healthCheckInterval) {
55
+ clearInterval(this.healthCheckInterval);
56
+ }
57
+ await this.manager.closeAll();
58
+ }
59
+ }
@@ -0,0 +1,57 @@
1
+ // oauth-handler.ts - OAuth token management for MCP servers
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
6
+
7
+ // Token storage path for a server
8
+ function getTokensPath(serverName: string): string {
9
+ return join(homedir(), ".pi", "agent", "mcp-oauth", serverName, "tokens.json");
10
+ }
11
+
12
+ /**
13
+ * Get stored OAuth tokens for a server (if any).
14
+ * Returns undefined if no tokens or tokens are expired.
15
+ *
16
+ * Token file location: ~/.pi/agent/mcp-oauth/<server>/tokens.json
17
+ *
18
+ * Expected format:
19
+ * {
20
+ * "access_token": "...",
21
+ * "token_type": "bearer",
22
+ * "refresh_token": "...", // optional
23
+ * "expires_in": 3600, // optional, seconds
24
+ * "expiresAt": 1234567890 // optional, absolute timestamp ms
25
+ * }
26
+ */
27
+ export function getStoredTokens(serverName: string): OAuthTokens | undefined {
28
+ const tokensPath = getTokensPath(serverName);
29
+
30
+ if (!existsSync(tokensPath)) return undefined;
31
+
32
+ try {
33
+ const stored = JSON.parse(readFileSync(tokensPath, "utf-8"));
34
+
35
+ // Validate required field
36
+ if (!stored.access_token || typeof stored.access_token !== "string") {
37
+ return undefined;
38
+ }
39
+
40
+ // Check expiration if expiresAt is set
41
+ if (stored.expiresAt && typeof stored.expiresAt === "number") {
42
+ if (Date.now() > stored.expiresAt) {
43
+ // Token expired
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ return {
49
+ access_token: stored.access_token,
50
+ token_type: stored.token_type ?? "bearer",
51
+ refresh_token: stored.refresh_token,
52
+ expires_in: stored.expires_in,
53
+ };
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "pi-mcp-adapter",
3
+ "version": "1.1.0",
4
+ "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Nico Bailon",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/nicobailon/pi-mcp-adapter"
11
+ },
12
+ "keywords": [
13
+ "pi",
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "ai",
17
+ "coding-agent",
18
+ "extension",
19
+ "claude",
20
+ "llm"
21
+ ],
22
+ "pi": {
23
+ "extensions": ["./index.ts"]
24
+ },
25
+ "files": [
26
+ "index.ts",
27
+ "types.ts",
28
+ "config.ts",
29
+ "server-manager.ts",
30
+ "tool-registrar.ts",
31
+ "resource-tools.ts",
32
+ "lifecycle.ts",
33
+ "oauth-handler.ts",
34
+ "README.md",
35
+ "CHANGELOG.md",
36
+ "ARCHITECTURE.md",
37
+ "LICENSE"
38
+ ],
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.25.1",
41
+ "@sinclair/typebox": "^0.32.0"
42
+ },
43
+ "peerDependencies": {
44
+ "zod": "^3.25.0 || ^4.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^20.0.0",
48
+ "typescript": "^5.0.0",
49
+ "zod": "^3.25.0"
50
+ }
51
+ }
@@ -0,0 +1,45 @@
1
+ // resource-tools.ts - MCP resource tool name collection
2
+ // NOTE: Resources are NOT registered as Pi tools - they're called via the `mcp` proxy.
3
+
4
+ import { formatToolName, type McpResource } from "./types.js";
5
+
6
+ interface ResourceCollectionOptions {
7
+ serverName: string;
8
+ prefix: "server" | "none" | "short";
9
+ }
10
+
11
+ /**
12
+ * Collect tool names for MCP resources.
13
+ * Does NOT register with Pi - resources are called via the `mcp` proxy.
14
+ */
15
+ export function collectResourceToolNames(
16
+ resources: McpResource[],
17
+ options: ResourceCollectionOptions
18
+ ): string[] {
19
+ const collected: string[] = [];
20
+ const { serverName, prefix } = options;
21
+
22
+ for (const resource of resources) {
23
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
24
+ const toolName = formatToolName(baseName, serverName, prefix);
25
+ collected.push(toolName);
26
+ }
27
+
28
+ return collected;
29
+ }
30
+
31
+ export function resourceNameToToolName(name: string): string {
32
+ let result = name
33
+ .replace(/[^a-zA-Z0-9]/g, "_")
34
+ .replace(/_+/g, "_")
35
+ .replace(/^_+/, "") // Remove leading underscores
36
+ .replace(/_+$/, "") // Remove trailing underscores
37
+ .toLowerCase();
38
+
39
+ // Ensure we have a valid name
40
+ if (!result || /^\d/.test(result)) {
41
+ result = "resource" + (result ? "_" + result : "");
42
+ }
43
+
44
+ return result;
45
+ }
@@ -0,0 +1,242 @@
1
+ // server-manager.ts - MCP connection management (stdio + HTTP)
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
6
+ import type { McpTool, McpResource, ServerDefinition, Transport } from "./types.js";
7
+ import { getStoredTokens } from "./oauth-handler.js";
8
+
9
+ interface ServerConnection {
10
+ client: Client;
11
+ transport: Transport;
12
+ definition: ServerDefinition;
13
+ tools: McpTool[];
14
+ resources: McpResource[];
15
+ lastUsedAt: number;
16
+ status: "connected" | "closed";
17
+ }
18
+
19
+ export class McpServerManager {
20
+ private connections = new Map<string, ServerConnection>();
21
+ private connectPromises = new Map<string, Promise<ServerConnection>>();
22
+
23
+ async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
24
+ // Dedupe concurrent connection attempts
25
+ if (this.connectPromises.has(name)) {
26
+ return this.connectPromises.get(name)!;
27
+ }
28
+
29
+ // Reuse existing connection if healthy
30
+ const existing = this.connections.get(name);
31
+ if (existing?.status === "connected") {
32
+ existing.lastUsedAt = Date.now();
33
+ return existing;
34
+ }
35
+
36
+ const promise = this.createConnection(name, definition);
37
+ this.connectPromises.set(name, promise);
38
+
39
+ try {
40
+ const connection = await promise;
41
+ this.connections.set(name, connection);
42
+ return connection;
43
+ } finally {
44
+ this.connectPromises.delete(name);
45
+ }
46
+ }
47
+
48
+ private async createConnection(
49
+ name: string,
50
+ definition: ServerDefinition
51
+ ): Promise<ServerConnection> {
52
+ const client = new Client({ name: `pi-mcp-${name}`, version: "1.0.0" });
53
+
54
+ let transport: Transport;
55
+
56
+ if (definition.command) {
57
+ // Stdio transport
58
+ transport = new StdioClientTransport({
59
+ command: definition.command,
60
+ args: definition.args ?? [],
61
+ env: resolveEnv(definition.env),
62
+ cwd: definition.cwd,
63
+ stderr: definition.debug ? "inherit" : "ignore",
64
+ });
65
+ } else if (definition.url) {
66
+ // HTTP transport with fallback
67
+ transport = await this.createHttpTransport(definition, name);
68
+ } else {
69
+ throw new Error(`Server ${name} has no command or url`);
70
+ }
71
+
72
+ try {
73
+ await client.connect(transport);
74
+
75
+ // Discover tools and resources
76
+ const [tools, resources] = await Promise.all([
77
+ this.fetchAllTools(client),
78
+ this.fetchAllResources(client),
79
+ ]);
80
+
81
+ return {
82
+ client,
83
+ transport,
84
+ definition,
85
+ tools,
86
+ resources,
87
+ lastUsedAt: Date.now(),
88
+ status: "connected",
89
+ };
90
+ } catch (error) {
91
+ // Clean up both client and transport on any error
92
+ await client.close().catch(() => {});
93
+ await transport.close().catch(() => {});
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ private async createHttpTransport(definition: ServerDefinition, serverName?: string): Promise<Transport> {
99
+ const url = new URL(definition.url!);
100
+ const headers = resolveHeaders(definition.headers) ?? {};
101
+
102
+ // Add bearer token if configured
103
+ if (definition.auth === "bearer") {
104
+ const token = definition.bearerToken
105
+ ?? (definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined);
106
+ if (token) {
107
+ headers["Authorization"] = `Bearer ${token}`;
108
+ }
109
+ }
110
+
111
+ // Handle OAuth auth - use stored tokens
112
+ if (definition.auth === "oauth") {
113
+ if (!serverName) {
114
+ throw new Error("Server name required for OAuth authentication");
115
+ }
116
+ const tokens = getStoredTokens(serverName);
117
+ if (!tokens) {
118
+ throw new Error(
119
+ `No OAuth tokens found for "${serverName}". Run /mcp-auth ${serverName} to authenticate.`
120
+ );
121
+ }
122
+ headers["Authorization"] = `Bearer ${tokens.access_token}`;
123
+ }
124
+
125
+ const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
126
+
127
+ // Try StreamableHTTP first (modern MCP servers)
128
+ const streamableTransport = new StreamableHTTPClientTransport(url, { requestInit });
129
+
130
+ try {
131
+ // Create a test client to verify the transport works
132
+ const testClient = new Client({ name: "pi-mcp-probe", version: "1.0.0" });
133
+ await testClient.connect(streamableTransport);
134
+ await testClient.close().catch(() => {});
135
+ // Close probe transport before creating fresh one
136
+ await streamableTransport.close().catch(() => {});
137
+
138
+ // StreamableHTTP works - create fresh transport for actual use
139
+ return new StreamableHTTPClientTransport(url, { requestInit });
140
+ } catch {
141
+ // StreamableHTTP failed, close and try SSE fallback
142
+ await streamableTransport.close().catch(() => {});
143
+
144
+ // SSE is the legacy transport
145
+ return new SSEClientTransport(url, { requestInit });
146
+ }
147
+ }
148
+
149
+ private async fetchAllTools(client: Client): Promise<McpTool[]> {
150
+ const allTools: McpTool[] = [];
151
+ let cursor: string | undefined;
152
+
153
+ do {
154
+ const result = await client.listTools(cursor ? { cursor } : undefined);
155
+ allTools.push(...(result.tools ?? []));
156
+ cursor = result.nextCursor;
157
+ } while (cursor);
158
+
159
+ return allTools;
160
+ }
161
+
162
+ private async fetchAllResources(client: Client): Promise<McpResource[]> {
163
+ try {
164
+ const allResources: McpResource[] = [];
165
+ let cursor: string | undefined;
166
+
167
+ do {
168
+ const result = await client.listResources(cursor ? { cursor } : undefined);
169
+ allResources.push(...(result.resources ?? []));
170
+ cursor = result.nextCursor;
171
+ } while (cursor);
172
+
173
+ return allResources;
174
+ } catch {
175
+ // Server may not support resources
176
+ return [];
177
+ }
178
+ }
179
+
180
+ async close(name: string): Promise<void> {
181
+ const connection = this.connections.get(name);
182
+ if (!connection) return;
183
+
184
+ connection.status = "closed";
185
+ // Close both independently - don't let one failure prevent the other
186
+ await connection.client.close().catch(() => {});
187
+ await connection.transport.close().catch(() => {});
188
+ this.connections.delete(name);
189
+ }
190
+
191
+ async closeAll(): Promise<void> {
192
+ const names = [...this.connections.keys()];
193
+ await Promise.all(names.map(name => this.close(name)));
194
+ }
195
+
196
+ getConnection(name: string): ServerConnection | undefined {
197
+ return this.connections.get(name);
198
+ }
199
+
200
+ getAllConnections(): Map<string, ServerConnection> {
201
+ return new Map(this.connections);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Resolve environment variables with interpolation.
207
+ */
208
+ function resolveEnv(env?: Record<string, string>): Record<string, string> {
209
+ // Copy process.env, filtering out undefined values
210
+ const resolved: Record<string, string> = {};
211
+ for (const [key, value] of Object.entries(process.env)) {
212
+ if (value !== undefined) {
213
+ resolved[key] = value;
214
+ }
215
+ }
216
+
217
+ if (!env) return resolved;
218
+
219
+ for (const [key, value] of Object.entries(env)) {
220
+ // Support ${VAR} and $env:VAR interpolation
221
+ resolved[key] = value
222
+ .replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
223
+ .replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
224
+ }
225
+
226
+ return resolved;
227
+ }
228
+
229
+ /**
230
+ * Resolve headers with environment variable interpolation.
231
+ */
232
+ function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
233
+ if (!headers) return undefined;
234
+
235
+ const resolved: Record<string, string> = {};
236
+ for (const [key, value] of Object.entries(headers)) {
237
+ resolved[key] = value
238
+ .replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
239
+ .replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
240
+ }
241
+ return resolved;
242
+ }
@@ -0,0 +1,77 @@
1
+ // tool-registrar.ts - MCP tool metadata and content transformation
2
+ // NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered.
3
+ // This keeps the LLM context small (1 tool instead of 100s).
4
+
5
+ import type { McpTool, McpContent, ContentBlock } from "./types.js";
6
+ import { formatToolName } from "./types.js";
7
+
8
+ interface ToolCollectionOptions {
9
+ serverName: string;
10
+ prefix: "server" | "none" | "short";
11
+ }
12
+
13
+ /**
14
+ * Collect tool names from MCP server tools.
15
+ * Does NOT register with Pi - tools are called via the `mcp` proxy.
16
+ */
17
+ export function collectToolNames(
18
+ tools: McpTool[],
19
+ options: ToolCollectionOptions
20
+ ): { collected: string[]; failed: string[] } {
21
+ const collected: string[] = [];
22
+ const failed: string[] = [];
23
+
24
+ for (const tool of tools) {
25
+ // Basic validation - tool must have a name
26
+ if (!tool.name) {
27
+ failed.push("(unnamed)");
28
+ continue;
29
+ }
30
+
31
+ const toolName = formatToolName(tool.name, options.serverName, options.prefix);
32
+ collected.push(toolName);
33
+ }
34
+
35
+ return { collected, failed };
36
+ }
37
+
38
+ /**
39
+ * Transform MCP content types to Pi content blocks.
40
+ */
41
+ export function transformMcpContent(content: McpContent[]): ContentBlock[] {
42
+ return content.map(c => {
43
+ if (c.type === "text") {
44
+ return { type: "text" as const, text: c.text ?? "" };
45
+ }
46
+ if (c.type === "image") {
47
+ return {
48
+ type: "image" as const,
49
+ data: c.data ?? "",
50
+ mimeType: c.mimeType ?? "image/png",
51
+ };
52
+ }
53
+ if (c.type === "resource") {
54
+ const resourceUri = c.resource?.uri ?? "(no URI)";
55
+ const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
56
+ return {
57
+ type: "text" as const,
58
+ text: `[Resource: ${resourceUri}]\n${resourceContent}`,
59
+ };
60
+ }
61
+ if (c.type === "resource_link") {
62
+ const linkName = c.name ?? c.uri ?? "unknown";
63
+ const linkUri = c.uri ?? "(no URI)";
64
+ return {
65
+ type: "text" as const,
66
+ text: `[Resource Link: ${linkName}]\nURI: ${linkUri}`,
67
+ };
68
+ }
69
+ if (c.type === "audio") {
70
+ return {
71
+ type: "text" as const,
72
+ text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
73
+ };
74
+ }
75
+ return { type: "text" as const, text: JSON.stringify(c) };
76
+ });
77
+ }
package/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ // types.ts - Core type definitions
2
+ import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import type { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import type { TextContent, ImageContent } from "@mariozechner/pi-ai";
6
+
7
+ // Transport type (stdio + HTTP)
8
+ export type Transport =
9
+ | StdioClientTransport
10
+ | SSEClientTransport
11
+ | StreamableHTTPClientTransport;
12
+
13
+ // Import sources for config
14
+ export type ImportKind =
15
+ | "cursor"
16
+ | "claude-code"
17
+ | "claude-desktop"
18
+ | "codex"
19
+ | "windsurf"
20
+ | "vscode";
21
+
22
+ // Tool definition from MCP server
23
+ export interface McpTool {
24
+ name: string;
25
+ title?: string;
26
+ description?: string;
27
+ inputSchema?: unknown; // JSON Schema
28
+ }
29
+
30
+ // Resource definition from MCP server
31
+ export interface McpResource {
32
+ uri: string;
33
+ name: string;
34
+ description?: string;
35
+ mimeType?: string;
36
+ }
37
+
38
+ // Content types from MCP
39
+ export interface McpContent {
40
+ type: "text" | "image" | "audio" | "resource" | "resource_link";
41
+ text?: string;
42
+ data?: string;
43
+ mimeType?: string;
44
+ resource?: {
45
+ uri: string;
46
+ text?: string;
47
+ blob?: string;
48
+ };
49
+ uri?: string;
50
+ name?: string;
51
+ description?: string;
52
+ }
53
+
54
+ // Pi content block type
55
+ export type ContentBlock = TextContent | ImageContent;
56
+
57
+ // Server configuration
58
+ export interface ServerEntry {
59
+ command?: string;
60
+ args?: string[];
61
+ env?: Record<string, string>;
62
+ cwd?: string;
63
+ // HTTP fields
64
+ url?: string;
65
+ headers?: Record<string, string>;
66
+ auth?: "oauth" | "bearer";
67
+ bearerToken?: string;
68
+ bearerTokenEnv?: string;
69
+ lifecycle?: "keep-alive" | "ephemeral";
70
+ // Resource handling
71
+ exposeResources?: boolean;
72
+ // Debug
73
+ debug?: boolean; // Show server stderr (default: false)
74
+ }
75
+
76
+ // Settings
77
+ export interface McpSettings {
78
+ toolPrefix?: "server" | "none" | "short";
79
+ }
80
+
81
+ // Root config
82
+ export interface McpConfig {
83
+ mcpServers: Record<string, ServerEntry>;
84
+ imports?: ImportKind[];
85
+ settings?: McpSettings;
86
+ }
87
+
88
+ // Alias for clarity
89
+ export type ServerDefinition = ServerEntry;
90
+
91
+ /**
92
+ * Format a tool name with server prefix.
93
+ */
94
+ export function formatToolName(
95
+ toolName: string,
96
+ serverName: string,
97
+ prefix: "server" | "none" | "short"
98
+ ): string {
99
+ switch (prefix) {
100
+ case "none":
101
+ return toolName;
102
+ case "short":
103
+ let short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
104
+ // Fallback if server name was just "mcp" or similar
105
+ if (!short) short = "mcp";
106
+ return `${short}_${toolName}`;
107
+ case "server":
108
+ default:
109
+ const normalized = serverName.replace(/-/g, "_");
110
+ return `${normalized}_${toolName}`;
111
+ }
112
+ }