pi-soly 1.9.3 → 1.11.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/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
5
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
6
|
+
import {
|
|
7
|
+
ElicitationCompleteNotificationSchema,
|
|
8
|
+
type CallToolRequest,
|
|
9
|
+
type CallToolResult,
|
|
10
|
+
type ReadResourceResult,
|
|
11
|
+
type UrlElicitationRequiredError,
|
|
12
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import type {
|
|
14
|
+
McpTool,
|
|
15
|
+
McpResource,
|
|
16
|
+
ServerDefinition,
|
|
17
|
+
ServerStreamResultPatchNotification,
|
|
18
|
+
Transport,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
import { serverStreamResultPatchNotificationSchema } from "./types.ts";
|
|
21
|
+
import { resolveNpxBinary } from "./npx-resolver.ts";
|
|
22
|
+
import { logger } from "./logger.ts";
|
|
23
|
+
import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
|
|
24
|
+
import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts";
|
|
25
|
+
import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
|
|
26
|
+
import {
|
|
27
|
+
handleUrlElicitation,
|
|
28
|
+
registerElicitationHandler,
|
|
29
|
+
type ServerElicitationConfig,
|
|
30
|
+
} from "./elicitation-handler.ts";
|
|
31
|
+
import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
|
|
32
|
+
|
|
33
|
+
interface ServerConnection {
|
|
34
|
+
client: Client;
|
|
35
|
+
transport: Transport;
|
|
36
|
+
definition: ServerDefinition;
|
|
37
|
+
tools: McpTool[];
|
|
38
|
+
resources: McpResource[];
|
|
39
|
+
lastUsedAt: number;
|
|
40
|
+
inFlight: number;
|
|
41
|
+
status: "connected" | "closed" | "needs-auth";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
|
|
45
|
+
|
|
46
|
+
export class McpServerManager {
|
|
47
|
+
private connections = new Map<string, ServerConnection>();
|
|
48
|
+
private connectPromises = new Map<string, Promise<ServerConnection>>();
|
|
49
|
+
private uiStreamListeners = new Map<string, UiStreamListener>();
|
|
50
|
+
private samplingConfig: ServerSamplingConfig | undefined;
|
|
51
|
+
private elicitationConfig: ServerElicitationConfig | undefined;
|
|
52
|
+
private acceptedUrlElicitations = new Map<string, Set<string>>();
|
|
53
|
+
/** Called after an auto-recovery attempt: args are (serverName, ok, errorMsg).
|
|
54
|
+
* Fired from `callTool()` when a session-expired error triggered close+reconnect.
|
|
55
|
+
* Use to surface notifications ("session auto-recovered" / "recovery failed"). */
|
|
56
|
+
onSessionRecover?: (serverName: string, ok: boolean, errorMsg: string) => void;
|
|
57
|
+
|
|
58
|
+
setSamplingConfig(config: ServerSamplingConfig | undefined): void {
|
|
59
|
+
this.samplingConfig = config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setElicitationConfig(config: ServerElicitationConfig | undefined): void {
|
|
63
|
+
this.elicitationConfig = config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
|
|
67
|
+
// Dedupe concurrent connection attempts
|
|
68
|
+
if (this.connectPromises.has(name)) {
|
|
69
|
+
return this.connectPromises.get(name)!;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Reuse existing connection if healthy
|
|
73
|
+
const existing = this.connections.get(name);
|
|
74
|
+
if (existing?.status === "connected") {
|
|
75
|
+
existing.lastUsedAt = Date.now();
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const promise = this.createConnection(name, definition);
|
|
80
|
+
this.connectPromises.set(name, promise);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const connection = await promise;
|
|
84
|
+
this.connections.set(name, connection);
|
|
85
|
+
return connection;
|
|
86
|
+
} finally {
|
|
87
|
+
this.connectPromises.delete(name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async createConnection(
|
|
92
|
+
name: string,
|
|
93
|
+
definition: ServerDefinition
|
|
94
|
+
): Promise<ServerConnection> {
|
|
95
|
+
const client = this.createClient(name);
|
|
96
|
+
|
|
97
|
+
let transport: Transport;
|
|
98
|
+
|
|
99
|
+
if (definition.command) {
|
|
100
|
+
let command = definition.command;
|
|
101
|
+
let args = definition.args ?? [];
|
|
102
|
+
|
|
103
|
+
if (command === "npx" || command === "npm") {
|
|
104
|
+
const resolved = await resolveNpxBinary(command, args);
|
|
105
|
+
if (resolved) {
|
|
106
|
+
command = resolved.isJs ? "node" : resolved.binPath;
|
|
107
|
+
args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
|
|
108
|
+
logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
transport = new StdioClientTransport({
|
|
113
|
+
command,
|
|
114
|
+
args,
|
|
115
|
+
env: resolveEnv(definition.env),
|
|
116
|
+
cwd: resolveConfigPath(definition.cwd),
|
|
117
|
+
stderr: definition.debug ? "inherit" : "ignore",
|
|
118
|
+
});
|
|
119
|
+
} else if (definition.url) {
|
|
120
|
+
// HTTP transport with fallback
|
|
121
|
+
transport = await this.createHttpTransport(definition, name);
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error(`Server ${name} has no command or url`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await client.connect(transport);
|
|
128
|
+
this.attachAdapterNotificationHandlers(name, client);
|
|
129
|
+
|
|
130
|
+
// Discover tools and resources
|
|
131
|
+
const [tools, resources] = await Promise.all([
|
|
132
|
+
this.fetchAllTools(client),
|
|
133
|
+
this.fetchAllResources(client),
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
client,
|
|
138
|
+
transport,
|
|
139
|
+
definition,
|
|
140
|
+
tools,
|
|
141
|
+
resources,
|
|
142
|
+
lastUsedAt: Date.now(),
|
|
143
|
+
inFlight: 0,
|
|
144
|
+
status: "connected",
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Check for UnauthorizedError - server requires OAuth
|
|
148
|
+
if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
|
|
149
|
+
// Clean up both client and transport before reporting needs-auth.
|
|
150
|
+
await client.close().catch(() => {});
|
|
151
|
+
await transport.close().catch(() => {});
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
client,
|
|
155
|
+
transport,
|
|
156
|
+
definition,
|
|
157
|
+
tools: [],
|
|
158
|
+
resources: [],
|
|
159
|
+
lastUsedAt: Date.now(),
|
|
160
|
+
inFlight: 0,
|
|
161
|
+
status: "needs-auth",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Clean up both client and transport on any error
|
|
166
|
+
await client.close().catch(() => {});
|
|
167
|
+
await transport.close().catch(() => {});
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private buildClientCapabilities() {
|
|
173
|
+
return {
|
|
174
|
+
...(this.samplingConfig ? { sampling: {} } : {}),
|
|
175
|
+
...(this.elicitationConfig
|
|
176
|
+
? {
|
|
177
|
+
elicitation: {
|
|
178
|
+
form: {},
|
|
179
|
+
...(this.elicitationConfig.allowUrl ? { url: {} } : {}),
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private createClient(serverName: string): Client {
|
|
187
|
+
const capabilities = this.buildClientCapabilities();
|
|
188
|
+
const client = new Client(
|
|
189
|
+
{ name: `pi-mcp-${serverName}`, version: "1.0.0" },
|
|
190
|
+
Object.keys(capabilities).length > 0 ? { capabilities } : undefined,
|
|
191
|
+
);
|
|
192
|
+
if (this.samplingConfig) {
|
|
193
|
+
registerSamplingHandler(client, { ...this.samplingConfig, serverName });
|
|
194
|
+
}
|
|
195
|
+
if (this.elicitationConfig) {
|
|
196
|
+
registerElicitationHandler(client, {
|
|
197
|
+
...this.elicitationConfig,
|
|
198
|
+
serverName,
|
|
199
|
+
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
|
200
|
+
});
|
|
201
|
+
if (this.elicitationConfig.allowUrl) {
|
|
202
|
+
client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
|
|
203
|
+
const accepted = this.acceptedUrlElicitations.get(serverName);
|
|
204
|
+
if (!accepted?.delete(notification.params.elicitationId)) return;
|
|
205
|
+
this.elicitationConfig?.ui.notify(
|
|
206
|
+
`MCP browser interaction for ${serverName} completed. You can retry the tool now.`,
|
|
207
|
+
"info",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return client;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async handleUrlElicitationRequired(
|
|
216
|
+
serverName: string,
|
|
217
|
+
error: UrlElicitationRequiredError,
|
|
218
|
+
): Promise<"accept" | "decline" | "cancel"> {
|
|
219
|
+
if (!this.elicitationConfig?.allowUrl) return "cancel";
|
|
220
|
+
for (const params of error.elicitations) {
|
|
221
|
+
const result = await handleUrlElicitation({
|
|
222
|
+
...this.elicitationConfig,
|
|
223
|
+
serverName,
|
|
224
|
+
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
|
225
|
+
}, params);
|
|
226
|
+
if (result.action !== "accept") return result.action;
|
|
227
|
+
}
|
|
228
|
+
return "accept";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private rememberUrlElicitation(serverName: string, elicitationId: string): void {
|
|
232
|
+
let accepted = this.acceptedUrlElicitations.get(serverName);
|
|
233
|
+
if (!accepted) {
|
|
234
|
+
accepted = new Set();
|
|
235
|
+
this.acceptedUrlElicitations.set(serverName, accepted);
|
|
236
|
+
}
|
|
237
|
+
accepted.add(elicitationId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async createHttpTransport(
|
|
241
|
+
definition: ServerDefinition,
|
|
242
|
+
serverName: string
|
|
243
|
+
): Promise<Transport> {
|
|
244
|
+
const url = new URL(definition.url!);
|
|
245
|
+
|
|
246
|
+
// Build headers first (including any bearer token)
|
|
247
|
+
const headers = resolveHeaders(definition.headers) ?? {};
|
|
248
|
+
|
|
249
|
+
// For bearer auth, add the token to headers BEFORE creating requestInit
|
|
250
|
+
if (definition.auth === "bearer") {
|
|
251
|
+
const token = resolveBearerToken(definition);
|
|
252
|
+
if (token) {
|
|
253
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Create request init with headers (Authorization now included for bearer auth)
|
|
258
|
+
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
259
|
+
|
|
260
|
+
// For OAuth servers, create an auth provider
|
|
261
|
+
let authProvider: McpOAuthProvider | undefined;
|
|
262
|
+
if (supportsOAuth(definition)) {
|
|
263
|
+
const oauthConfig = extractOAuthConfig(definition);
|
|
264
|
+
authProvider = new McpOAuthProvider(
|
|
265
|
+
serverName,
|
|
266
|
+
definition.url!,
|
|
267
|
+
oauthConfig,
|
|
268
|
+
{
|
|
269
|
+
onRedirect: async (_authUrl) => {
|
|
270
|
+
// URL is captured by startAuth, no need to log
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Try StreamableHTTP first (modern MCP servers)
|
|
277
|
+
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
|
278
|
+
requestInit,
|
|
279
|
+
authProvider,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
// Create a test client to verify the transport works
|
|
284
|
+
const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" });
|
|
285
|
+
await testClient.connect(streamableTransport);
|
|
286
|
+
await testClient.close().catch(() => {});
|
|
287
|
+
// Close probe transport before creating fresh one
|
|
288
|
+
await streamableTransport.close().catch(() => {});
|
|
289
|
+
|
|
290
|
+
// StreamableHTTP works - create fresh transport for actual use
|
|
291
|
+
return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
|
292
|
+
} catch (error) {
|
|
293
|
+
// StreamableHTTP failed, close and try SSE fallback
|
|
294
|
+
await streamableTransport.close().catch(() => {});
|
|
295
|
+
|
|
296
|
+
// If this was an UnauthorizedError, don't try SSE - the server needs auth
|
|
297
|
+
if (error instanceof UnauthorizedError) {
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// SSE is the legacy transport
|
|
302
|
+
return new SSEClientTransport(url, { requestInit, authProvider });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async fetchAllTools(client: Client): Promise<McpTool[]> {
|
|
307
|
+
const allTools: McpTool[] = [];
|
|
308
|
+
let cursor: string | undefined;
|
|
309
|
+
|
|
310
|
+
do {
|
|
311
|
+
const result = await client.listTools(cursor ? { cursor } : undefined);
|
|
312
|
+
allTools.push(...(result.tools ?? []));
|
|
313
|
+
cursor = result.nextCursor;
|
|
314
|
+
} while (cursor);
|
|
315
|
+
|
|
316
|
+
return allTools;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async fetchAllResources(client: Client): Promise<McpResource[]> {
|
|
320
|
+
try {
|
|
321
|
+
const allResources: McpResource[] = [];
|
|
322
|
+
let cursor: string | undefined;
|
|
323
|
+
|
|
324
|
+
do {
|
|
325
|
+
const result = await client.listResources(cursor ? { cursor } : undefined);
|
|
326
|
+
allResources.push(...(result.resources ?? []));
|
|
327
|
+
cursor = result.nextCursor;
|
|
328
|
+
} while (cursor);
|
|
329
|
+
|
|
330
|
+
return allResources;
|
|
331
|
+
} catch {
|
|
332
|
+
// Server may not support resources
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
|
|
338
|
+
client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => {
|
|
339
|
+
const listener = this.uiStreamListeners.get(notification.params.streamToken);
|
|
340
|
+
if (!listener) return;
|
|
341
|
+
listener(serverName, notification.params);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
|
|
346
|
+
this.uiStreamListeners.set(streamToken, listener);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
removeUiStreamListener(streamToken: string): void {
|
|
350
|
+
this.uiStreamListeners.delete(streamToken);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async readResource(name: string, uri: string): Promise<ReadResourceResult> {
|
|
354
|
+
const connection = this.connections.get(name);
|
|
355
|
+
if (!connection || connection.status !== "connected") {
|
|
356
|
+
throw new Error(`Server "${name}" is not connected`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
this.touch(name);
|
|
361
|
+
this.incrementInFlight(name);
|
|
362
|
+
return await connection.client.readResource({ uri });
|
|
363
|
+
} finally {
|
|
364
|
+
this.decrementInFlight(name);
|
|
365
|
+
this.touch(name);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Call a tool with automatic session-recovery.
|
|
371
|
+
*
|
|
372
|
+
* Some MCP servers (notably UE5) return a session id in the `Mcp-Session-Id`
|
|
373
|
+
* response header on `initialize`, then immediately forget it. The next
|
|
374
|
+
* `tools/call` fails with "Unknown session id ... client should reinitialize".
|
|
375
|
+
*
|
|
376
|
+
* This wrapper catches that specific error pattern, closes + reconnects the
|
|
377
|
+
* server (which triggers a fresh `initialize` and new session id), and
|
|
378
|
+
* retries the call once. Other errors are propagated unchanged.
|
|
379
|
+
*
|
|
380
|
+
* Note: this is a per-call recovery. The MCP SDK's transport caches the
|
|
381
|
+
* session id internally; our fix works because `close()` + `connect()`
|
|
382
|
+
* creates a brand new transport + client, so the SDK starts fresh.
|
|
383
|
+
*/
|
|
384
|
+
async callTool(
|
|
385
|
+
name: string,
|
|
386
|
+
params: CallToolRequest["params"],
|
|
387
|
+
options?: { signal?: AbortSignal; _bookkeep?: boolean },
|
|
388
|
+
): Promise<CallToolResult> {
|
|
389
|
+
const doCall = async (): Promise<CallToolResult> => {
|
|
390
|
+
const connection = this.connections.get(name);
|
|
391
|
+
if (!connection || connection.status !== "connected") {
|
|
392
|
+
throw new Error(`Server "${name}" is not connected`);
|
|
393
|
+
}
|
|
394
|
+
// Skip bookkeeping if the caller already did it (legacy path).
|
|
395
|
+
const bookkeeping = options?._bookkeep !== false;
|
|
396
|
+
if (bookkeeping) {
|
|
397
|
+
this.touch(name);
|
|
398
|
+
this.incrementInFlight(name);
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
// The SDK's callTool signature changed in 1.28 to accept an optional
|
|
402
|
+
// resultSchema; we still target 1.27.1. Cast through unknown to
|
|
403
|
+
// bridge the type skew without losing type safety elsewhere.
|
|
404
|
+
const result = await (connection.client.callTool as (
|
|
405
|
+
p: CallToolRequest["params"],
|
|
406
|
+
o?: unknown,
|
|
407
|
+
) => Promise<CallToolResult>)(
|
|
408
|
+
params as CallToolRequest["params"],
|
|
409
|
+
options,
|
|
410
|
+
);
|
|
411
|
+
return result;
|
|
412
|
+
} finally {
|
|
413
|
+
if (bookkeeping) {
|
|
414
|
+
this.decrementInFlight(name);
|
|
415
|
+
this.touch(name);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
return await doCall();
|
|
422
|
+
} catch (firstError) {
|
|
423
|
+
if (!this.isSessionExpiredError(firstError)) throw firstError;
|
|
424
|
+
// Session expired — reconnect and retry once. Caller can listen via
|
|
425
|
+
// the optional onSessionRecover callback to show a notification.
|
|
426
|
+
const connection = this.connections.get(name);
|
|
427
|
+
if (!connection) throw firstError;
|
|
428
|
+
const errMsg = firstError instanceof Error ? firstError.message : String(firstError);
|
|
429
|
+
try {
|
|
430
|
+
await this.close(name);
|
|
431
|
+
} catch {
|
|
432
|
+
// ignore close errors during recovery
|
|
433
|
+
}
|
|
434
|
+
await this.connect(name, connection.definition);
|
|
435
|
+
try {
|
|
436
|
+
const result = await (connection.client.callTool as (
|
|
437
|
+
p: CallToolRequest["params"],
|
|
438
|
+
o?: unknown,
|
|
439
|
+
) => Promise<CallToolResult>)(
|
|
440
|
+
params as CallToolRequest["params"],
|
|
441
|
+
options,
|
|
442
|
+
);
|
|
443
|
+
this.onSessionRecover?.(name, true, errMsg);
|
|
444
|
+
return result;
|
|
445
|
+
} catch (retryErr) {
|
|
446
|
+
this.onSessionRecover?.(name, false, errMsg);
|
|
447
|
+
throw retryErr;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Detect the UE5-style "session expired" error pattern. */
|
|
453
|
+
private isSessionExpiredError(error: unknown): boolean {
|
|
454
|
+
if (!error) return false;
|
|
455
|
+
const msg =
|
|
456
|
+
error instanceof Error
|
|
457
|
+
? error.message
|
|
458
|
+
: typeof error === "object" && error !== null && "message" in error
|
|
459
|
+
? String((error as { message: unknown }).message)
|
|
460
|
+
: String(error);
|
|
461
|
+
return /unknown session id|client should reinitialize|session.*expired|session.*invalid/i.test(
|
|
462
|
+
msg,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async close(name: string): Promise<void> {
|
|
467
|
+
const connection = this.connections.get(name);
|
|
468
|
+
if (!connection) return;
|
|
469
|
+
|
|
470
|
+
// Delete from map BEFORE async cleanup to prevent a race where a
|
|
471
|
+
// concurrent connect() creates a new connection that our deferred
|
|
472
|
+
// delete() would then remove, orphaning the new server process.
|
|
473
|
+
connection.status = "closed";
|
|
474
|
+
this.connections.delete(name);
|
|
475
|
+
this.acceptedUrlElicitations.delete(name);
|
|
476
|
+
await connection.client.close().catch(() => {});
|
|
477
|
+
await connection.transport.close().catch(() => {});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async closeAll(): Promise<void> {
|
|
481
|
+
const names = [...this.connections.keys()];
|
|
482
|
+
await Promise.all(names.map(name => this.close(name)));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
getConnection(name: string): ServerConnection | undefined {
|
|
486
|
+
return this.connections.get(name);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getAllConnections(): Map<string, ServerConnection> {
|
|
490
|
+
return new Map(this.connections);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
touch(name: string): void {
|
|
494
|
+
const connection = this.connections.get(name);
|
|
495
|
+
if (connection) {
|
|
496
|
+
connection.lastUsedAt = Date.now();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
incrementInFlight(name: string): void {
|
|
501
|
+
const connection = this.connections.get(name);
|
|
502
|
+
if (connection) {
|
|
503
|
+
connection.inFlight = (connection.inFlight ?? 0) + 1;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
decrementInFlight(name: string): void {
|
|
508
|
+
const connection = this.connections.get(name);
|
|
509
|
+
if (connection && connection.inFlight) {
|
|
510
|
+
connection.inFlight--;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
isIdle(name: string, timeoutMs: number): boolean {
|
|
515
|
+
const connection = this.connections.get(name);
|
|
516
|
+
if (!connection || connection.status !== "connected") return false;
|
|
517
|
+
if (connection.inFlight > 0) return false;
|
|
518
|
+
return (Date.now() - connection.lastUsedAt) > timeoutMs;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Resolve environment variables with interpolation.
|
|
524
|
+
*/
|
|
525
|
+
function resolveEnv(env?: Record<string, string>): Record<string, string> {
|
|
526
|
+
// Copy process.env, filtering out undefined values
|
|
527
|
+
const resolved: Record<string, string> = {};
|
|
528
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
529
|
+
if (value !== undefined) {
|
|
530
|
+
resolved[key] = value;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!env) return resolved;
|
|
535
|
+
|
|
536
|
+
const overrides = interpolateEnvRecord(env);
|
|
537
|
+
return overrides ? { ...resolved, ...overrides } : resolved;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Resolve headers with environment variable interpolation.
|
|
542
|
+
*/
|
|
543
|
+
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
|
544
|
+
return interpolateEnvRecord(headers);
|
|
545
|
+
}
|
package/mcp/state.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ConsentManager } from "./consent-manager.ts";
|
|
3
|
+
import type { McpLifecycleManager } from "./lifecycle.ts";
|
|
4
|
+
import type { McpServerManager } from "./server-manager.ts";
|
|
5
|
+
import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.ts";
|
|
6
|
+
import type { UiResourceHandler } from "./ui-resource-handler.ts";
|
|
7
|
+
import type { UiServerHandle } from "./ui-server.ts";
|
|
8
|
+
|
|
9
|
+
export interface CompletedUiSession {
|
|
10
|
+
serverName: string;
|
|
11
|
+
toolName: string;
|
|
12
|
+
completedAt: Date;
|
|
13
|
+
reason: string;
|
|
14
|
+
messages: UiSessionMessages;
|
|
15
|
+
stream?: UiStreamSummary;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SendMessageFn = (
|
|
19
|
+
message: {
|
|
20
|
+
customType: string;
|
|
21
|
+
content: Array<{ type: "text"; text: string }>;
|
|
22
|
+
display?: string;
|
|
23
|
+
details?: unknown;
|
|
24
|
+
},
|
|
25
|
+
options?: { triggerTurn?: boolean }
|
|
26
|
+
) => void;
|
|
27
|
+
|
|
28
|
+
export interface McpExtensionState {
|
|
29
|
+
manager: McpServerManager;
|
|
30
|
+
lifecycle: McpLifecycleManager;
|
|
31
|
+
toolMetadata: Map<string, ToolMetadata[]>;
|
|
32
|
+
config: McpConfig;
|
|
33
|
+
failureTracker: Map<string, number>;
|
|
34
|
+
uiResourceHandler: UiResourceHandler;
|
|
35
|
+
consentManager: ConsentManager;
|
|
36
|
+
uiServer: UiServerHandle | null;
|
|
37
|
+
completedUiSessions: CompletedUiSession[];
|
|
38
|
+
openBrowser: (url: string) => Promise<void>;
|
|
39
|
+
ui?: ExtensionContext["ui"];
|
|
40
|
+
sendMessage?: SendMessageFn;
|
|
41
|
+
}
|