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/ARCHITECTURE.md +572 -0
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/config.ts +132 -0
- package/index.ts +907 -0
- package/lifecycle.ts +59 -0
- package/oauth-handler.ts +57 -0
- package/package.json +51 -0
- package/resource-tools.ts +45 -0
- package/server-manager.ts +242 -0
- package/tool-registrar.ts +77 -0
- package/types.ts +112 -0
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
|
+
}
|
package/oauth-handler.ts
ADDED
|
@@ -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
|
+
}
|