@pi-archimedes/mcp 2.3.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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import {
|
|
5
|
+
StreamableHTTPClientTransport,
|
|
6
|
+
StreamableHTTPError,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import type { StdioServerDef, HttpServerDef, ServerDef, ToolPrefix, CachedTool } from "./types.js";
|
|
12
|
+
import {
|
|
13
|
+
authenticate as runOAuthFlow,
|
|
14
|
+
extractOAuthConfig,
|
|
15
|
+
getValidToken,
|
|
16
|
+
type AuthenticateOptions,
|
|
17
|
+
} from "./auth-flow.js";
|
|
18
|
+
import { resolveNpxBinary } from "./npx-resolver.js";
|
|
19
|
+
import { saveServerCache } from "./metadata-cache.js";
|
|
20
|
+
import { isHttpDef } from "./config.js";
|
|
21
|
+
import { TolerantJsonSchemaValidator } from "./schema-validator.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the request headers for an HTTP server from its full definition.
|
|
25
|
+
* Merges def.headers (shallow copy), then adds Authorization in this
|
|
26
|
+
* precedence: auth.token wins over any user-supplied Authorization header;
|
|
27
|
+
* otherwise bearerTokenEnv is read from the process environment and used
|
|
28
|
+
* only when non-empty. Returns an empty record when nothing is configured.
|
|
29
|
+
* Exported for unit tests.
|
|
30
|
+
*/
|
|
31
|
+
export function buildAuthHeaders(def: HttpServerDef): Record<string, string> {
|
|
32
|
+
const headers = { ...(def.headers ?? {}) };
|
|
33
|
+
const token =
|
|
34
|
+
typeof def.auth === "object" && def.auth !== null && "token" in def.auth
|
|
35
|
+
? def.auth.token
|
|
36
|
+
: undefined;
|
|
37
|
+
if (token !== undefined) {
|
|
38
|
+
headers.Authorization = `Bearer ${token}`;
|
|
39
|
+
} else {
|
|
40
|
+
const envName = def.bearerTokenEnv;
|
|
41
|
+
if (envName) {
|
|
42
|
+
const envToken = process.env[envName];
|
|
43
|
+
if (envToken) headers.Authorization = `Bearer ${envToken}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return headers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Cap for the captured stderr tail (last 8 KiB of output). */
|
|
50
|
+
const MAX_STDERR_TAIL_BYTES = 8 * 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format the bounded stderr tail: at most the last 3 lines of the captured
|
|
54
|
+
* (already size-bounded) output. Returns "" when there is nothing to show.
|
|
55
|
+
*/
|
|
56
|
+
function formatStderrTail(chunks: Buffer[]): string {
|
|
57
|
+
if (chunks.length === 0) return "";
|
|
58
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
59
|
+
if (!text) return "";
|
|
60
|
+
return text.split("\n").slice(-3).join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ServerStatus =
|
|
64
|
+
| "disconnected"
|
|
65
|
+
| "connecting"
|
|
66
|
+
| "connected"
|
|
67
|
+
| "error"
|
|
68
|
+
| "needs-auth";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Error surfaced when a server requires credentials we cannot provide yet.
|
|
72
|
+
* Guides to the two ways to fix it: a static bearer token in the server
|
|
73
|
+
* definition, or the OAuth flow via /mcp auth (plan-026).
|
|
74
|
+
*/
|
|
75
|
+
const NEEDS_AUTH_MESSAGE =
|
|
76
|
+
"authentication required or token rejected — configure a static bearer token or authenticate via OAuth (/mcp auth <server>)";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Options for ServerClient. `clientFactory` is a testability seam: replace the
|
|
80
|
+
* SDK Client with a fake in tests (no real network/stdio is touched).
|
|
81
|
+
*/
|
|
82
|
+
export interface ServerClientOptions {
|
|
83
|
+
clientFactory?: () => Client;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface McpTool extends Tool {
|
|
87
|
+
serverName: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A discovered resource (shaped like the cache entry in types.ts) */
|
|
91
|
+
export type DiscoveredResource = {
|
|
92
|
+
uri: string;
|
|
93
|
+
name?: string;
|
|
94
|
+
description?: string;
|
|
95
|
+
mimeType?: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A discovered prompt (shaped like the cache entry in types.ts) */
|
|
99
|
+
export type DiscoveredPrompt = { name: string; description?: string };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Project an SDK resource into the discovered shape, dropping optional keys
|
|
103
|
+
* that are undefined (exactOptionalPropertyTypes — the SDK types declare
|
|
104
|
+
* `prop?: string | undefined`, which is not assignable to `prop?: string`).
|
|
105
|
+
*/
|
|
106
|
+
function toDiscoveredResource(res: {
|
|
107
|
+
uri: string;
|
|
108
|
+
name?: string | undefined;
|
|
109
|
+
description?: string | undefined;
|
|
110
|
+
mimeType?: string | undefined;
|
|
111
|
+
}): DiscoveredResource {
|
|
112
|
+
const out: DiscoveredResource = { uri: res.uri };
|
|
113
|
+
if (res.name !== undefined) out.name = res.name;
|
|
114
|
+
if (res.description !== undefined) out.description = res.description;
|
|
115
|
+
if (res.mimeType !== undefined) out.mimeType = res.mimeType;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Same projection for SDK prompts (see toDiscoveredResource). */
|
|
120
|
+
function toDiscoveredPrompt(p: { name: string; description?: string | undefined }): DiscoveredPrompt {
|
|
121
|
+
const out: DiscoveredPrompt = { name: p.name };
|
|
122
|
+
if (p.description !== undefined) out.description = p.description;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A simplified content block as returned by callTool */
|
|
127
|
+
export type ContentBlock =
|
|
128
|
+
| { type: "text"; text: string }
|
|
129
|
+
| { type: "image"; data: string; mimeType: string }
|
|
130
|
+
| { type: string; [key: string]: unknown };
|
|
131
|
+
|
|
132
|
+
export class ServerClient {
|
|
133
|
+
readonly name: string;
|
|
134
|
+
private _def: ServerDef;
|
|
135
|
+
private clientFactory: () => Client;
|
|
136
|
+
private client: Client | null = null;
|
|
137
|
+
private _status: ServerStatus = "disconnected";
|
|
138
|
+
private _tools: McpTool[] = [];
|
|
139
|
+
private _resources: DiscoveredResource[] = [];
|
|
140
|
+
private _prompts: DiscoveredPrompt[] = [];
|
|
141
|
+
private _instructions: string | undefined;
|
|
142
|
+
private _error: string | null = null;
|
|
143
|
+
private connectPromise: Promise<void> | null = null;
|
|
144
|
+
/**
|
|
145
|
+
* Generation counter for fencing: bumped on every close(). A connect that
|
|
146
|
+
* started under an older generation (and only finishes after the close)
|
|
147
|
+
* tears itself down instead of leaking a live connection.
|
|
148
|
+
*/
|
|
149
|
+
private generation = 0;
|
|
150
|
+
/** Epoch ms of the last tool call dispatch; 0 = never used. */
|
|
151
|
+
lastUsedAt = 0;
|
|
152
|
+
/** Number of tool calls currently in flight. */
|
|
153
|
+
inFlight = 0;
|
|
154
|
+
|
|
155
|
+
constructor(name: string, def: ServerDef, options?: ServerClientOptions) {
|
|
156
|
+
this.name = name;
|
|
157
|
+
this._def = def;
|
|
158
|
+
this.clientFactory = options?.clientFactory ?? (() => {
|
|
159
|
+
// A single unsound server schema must not take down the whole tools list —
|
|
160
|
+
// see TolerantJsonSchemaValidator for the reason.
|
|
161
|
+
return new Client(
|
|
162
|
+
{ name: "pi-archimedes-mcp", version: "1.0.0" },
|
|
163
|
+
{ jsonSchemaValidator: new TolerantJsonSchemaValidator() },
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get status(): ServerStatus {
|
|
169
|
+
return this._status;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The server definition this client was constructed from */
|
|
173
|
+
get def(): ServerDef {
|
|
174
|
+
return this._def;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get tools(): McpTool[] {
|
|
178
|
+
return this._tools;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get resources(): DiscoveredResource[] {
|
|
182
|
+
return this._resources;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get prompts(): DiscoveredPrompt[] {
|
|
186
|
+
return this._prompts;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get instructions(): string | undefined {
|
|
190
|
+
return this._instructions;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get error(): string | null {
|
|
194
|
+
return this._error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Tool-name prefix mode for this server (per-server setting, defaults to "server") */
|
|
198
|
+
get toolPrefix(): ToolPrefix {
|
|
199
|
+
return this._def.toolPrefix ?? "server";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* True when the client is connected, has no in-flight calls, and the last
|
|
204
|
+
* call finished more than `timeoutMs` ago. A never-used connection is idle
|
|
205
|
+
* by any timeout; a disconnected one is never idle.
|
|
206
|
+
*/
|
|
207
|
+
isIdle(timeoutMs: number): boolean {
|
|
208
|
+
return (
|
|
209
|
+
this._status === "connected" &&
|
|
210
|
+
this.inFlight === 0 &&
|
|
211
|
+
Date.now() - this.lastUsedAt > timeoutMs
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Authenticate via OAuth — the SINGLE auth entry point for this server
|
|
217
|
+
* (the `/mcp auth` command and auto-auth both call this; neither calls
|
|
218
|
+
* the auth-flow module directly, so url/config always come from the def).
|
|
219
|
+
*
|
|
220
|
+
* Rejects when the server isn't configured for OAuth (missing auth,
|
|
221
|
+
* static bearer, or stdio). Resolves when the flow finishes
|
|
222
|
+
* authenticated; rejects with a clear error for failed flows (with the
|
|
223
|
+
* underlying cause appended) and needs-manual-interaction, and rethrows a
|
|
224
|
+
* cancelled flow's "OAuth cancelled" error untouched so the caller can
|
|
225
|
+
* distinguish cancel from failure.
|
|
226
|
+
*/
|
|
227
|
+
async authenticate(options?: AuthenticateOptions): Promise<void> {
|
|
228
|
+
const def = this._def;
|
|
229
|
+
if (!isHttpDef(def)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Server ${this.name} is not configured for OAuth (auth must be "oauth" or an oauth config object)`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const httpDef = def as HttpServerDef;
|
|
235
|
+
const cfg = extractOAuthConfig(httpDef.auth);
|
|
236
|
+
if (!cfg) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Server ${this.name} is not configured for OAuth (auth must be "oauth" or an oauth config object)`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const status = await runOAuthFlow(this.name, httpDef.url, cfg, options);
|
|
242
|
+
if (status.status === "failed") {
|
|
243
|
+
// `status.error` is the underlying cause (network error, token-endpoint
|
|
244
|
+
// rejection, …) — carry it up so /mcp auth and auto-auth can show the
|
|
245
|
+
// user the real reason instead of a generic message.
|
|
246
|
+
throw new Error(`Authentication failed for ${this.name}: ${status.error}`);
|
|
247
|
+
}
|
|
248
|
+
if (status.status === "needs-interaction") {
|
|
249
|
+
throw new Error(`Authentication requires manual interaction for ${this.name}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Lazily connect — idempotent, safe to call multiple times */
|
|
254
|
+
async connect(): Promise<void> {
|
|
255
|
+
if (this._status === "connected") return;
|
|
256
|
+
// A needs-auth server 401'd on connect; retrying without OAuth would just
|
|
257
|
+
// 401 again. A close() (e.g. config change) resets the status and allows
|
|
258
|
+
// a fresh attempt.
|
|
259
|
+
if (this._status === "needs-auth") return;
|
|
260
|
+
if (this.connectPromise) return this.connectPromise;
|
|
261
|
+
|
|
262
|
+
this.connectPromise = this._doConnect().finally(() => {
|
|
263
|
+
this.connectPromise = null;
|
|
264
|
+
});
|
|
265
|
+
return this.connectPromise;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async _doConnect(): Promise<void> {
|
|
269
|
+
const gen = this.generation;
|
|
270
|
+
this._status = "connecting";
|
|
271
|
+
this._error = null;
|
|
272
|
+
|
|
273
|
+
// Bounded stderr capture for the stdio child, populated only when the
|
|
274
|
+
// transport pipes stderr (non-debug mode). Kept outside the try so the
|
|
275
|
+
// catch block can surface the tail on connection failure.
|
|
276
|
+
const stderrChunks: Buffer[] = [];
|
|
277
|
+
let stderrSize = 0;
|
|
278
|
+
const pushStderr = (chunk: Buffer) => {
|
|
279
|
+
stderrChunks.push(chunk);
|
|
280
|
+
stderrSize += chunk.length;
|
|
281
|
+
while (stderrSize > MAX_STDERR_TAIL_BYTES && stderrChunks.length > 1) {
|
|
282
|
+
const dropped = stderrChunks.shift()!;
|
|
283
|
+
stderrSize -= dropped.length;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
this.client = this.clientFactory();
|
|
289
|
+
const primaryClient = this.client;
|
|
290
|
+
|
|
291
|
+
// Guarded by generation AND client identity: a stale transport's close
|
|
292
|
+
// can arrive (a) after a close()+reconnect under a new generation, or
|
|
293
|
+
// (b) from an ABANDONED transport whose client was replaced within the
|
|
294
|
+
// same generation — in the HTTP branch the SSE fallback creates a
|
|
295
|
+
// second client under the same generation, so a late onclose from the
|
|
296
|
+
// abandoned StreamableHTTP transport must not clobber the live client.
|
|
297
|
+
const oncloseFor = (c: Client) => () => {
|
|
298
|
+
if (this.generation === gen && this.client === c) {
|
|
299
|
+
this._status = "disconnected";
|
|
300
|
+
this.client = null;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (!isHttpDef(this._def)) {
|
|
305
|
+
const def = this._def as StdioServerDef;
|
|
306
|
+
// Resolve npx/npm wrappers to the actual binary so we spawn it
|
|
307
|
+
// directly; null means "not an npx/npm command" → use the original.
|
|
308
|
+
const resolved = await resolveNpxBinary(def.command, def.args ?? []);
|
|
309
|
+
const command = resolved?.command ?? def.command;
|
|
310
|
+
const args = resolved?.args ?? (def.args ?? []);
|
|
311
|
+
|
|
312
|
+
const transport = new StdioClientTransport({
|
|
313
|
+
command,
|
|
314
|
+
args,
|
|
315
|
+
env: { ...process.env, ...(def.env ?? {}) } as Record<string, string>,
|
|
316
|
+
// Pipe stderr in non-debug mode so we can capture the tail; in debug
|
|
317
|
+
// mode inherit so the user sees raw output in the foreground.
|
|
318
|
+
stderr: def.debug ? "inherit" : "pipe",
|
|
319
|
+
});
|
|
320
|
+
transport.onclose = oncloseFor(primaryClient);
|
|
321
|
+
// Attach the stderr listener BEFORE connecting so early crash output
|
|
322
|
+
// (e.g. the server failing to start) is not lost. Guard against null.
|
|
323
|
+
if (transport.stderr) {
|
|
324
|
+
transport.stderr.on("data", (chunk: Buffer) => pushStderr(chunk));
|
|
325
|
+
}
|
|
326
|
+
await this.client.connect(transport);
|
|
327
|
+
} else {
|
|
328
|
+
const def = this._def as HttpServerDef;
|
|
329
|
+
let authHeaders = buildAuthHeaders(def);
|
|
330
|
+
// OAuth servers: attach a valid stored token (the helper refreshes
|
|
331
|
+
// via the SDK when the stored token is expired and may return null
|
|
332
|
+
// — no stored token, or the ADR 0001 config-stub guard — in which
|
|
333
|
+
// case the headers are left untouched and the 401 → needs-auth path
|
|
334
|
+
// guides the user to /mcp auth). Static `{ token }` servers are
|
|
335
|
+
// unaffected (extractOAuthConfig returns null for them).
|
|
336
|
+
const oauthConfig = extractOAuthConfig(def.auth);
|
|
337
|
+
if (oauthConfig) {
|
|
338
|
+
const token = await getValidToken(this.name, def.url, oauthConfig);
|
|
339
|
+
if (token !== null) {
|
|
340
|
+
authHeaders = { ...authHeaders, Authorization: `Bearer ${token}` };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// buildAuthHeaders merges arbitrary def.headers, so this flag really
|
|
344
|
+
// means "has ANY headers" (not just auth).
|
|
345
|
+
const hasHeaders = Object.keys(authHeaders).length > 0;
|
|
346
|
+
|
|
347
|
+
// Try StreamableHTTP first (modern standard), fall back to SSE for legacy servers
|
|
348
|
+
let connected = false;
|
|
349
|
+
try {
|
|
350
|
+
const transport = new StreamableHTTPClientTransport(
|
|
351
|
+
new URL(def.url),
|
|
352
|
+
hasHeaders ? { requestInit: { headers: authHeaders } } : undefined,
|
|
353
|
+
);
|
|
354
|
+
transport.onclose = oncloseFor(primaryClient);
|
|
355
|
+
// StreamableHTTPClientTransport.sessionId is `string | undefined` which conflicts
|
|
356
|
+
// with the Transport interface's `sessionId?: string` under exactOptionalPropertyTypes
|
|
357
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
358
|
+
await (this.client as any).connect(transport);
|
|
359
|
+
connected = true;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
// 401 with no OAuth provider configured surfaces as StreamableHTTPError.
|
|
362
|
+
// We cannot resolve it in this plan — record needs-auth and stop
|
|
363
|
+
// (no SSE fallback, no throw).
|
|
364
|
+
if (e instanceof StreamableHTTPError && e.code === 401) {
|
|
365
|
+
const c = this.client;
|
|
366
|
+
this.client = null;
|
|
367
|
+
await c.close().catch(() => {});
|
|
368
|
+
this._status = "needs-auth";
|
|
369
|
+
this._error = NEEDS_AUTH_MESSAGE;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Fall through to SSE fallback
|
|
373
|
+
}
|
|
374
|
+
if (!connected) {
|
|
375
|
+
// Create a fresh Client for the SSE fallback — the previous instance may be in a
|
|
376
|
+
// partially-initialised or broken state after the failed StreamableHTTP attempt.
|
|
377
|
+
const sseClient = this.clientFactory();
|
|
378
|
+
this.client = sseClient;
|
|
379
|
+
const transport = new SSEClientTransport(
|
|
380
|
+
new URL(def.url),
|
|
381
|
+
hasHeaders ? { requestInit: { headers: authHeaders } } : undefined,
|
|
382
|
+
);
|
|
383
|
+
transport.onclose = oncloseFor(sseClient);
|
|
384
|
+
await sseClient.connect(transport);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Generation fence: a close() raced ahead of this connect finishing.
|
|
389
|
+
// Tear down the just-created client without ever marking it connected.
|
|
390
|
+
if (this.generation !== gen) {
|
|
391
|
+
await this.tearDownClient();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this._status = "connected";
|
|
396
|
+
|
|
397
|
+
// Discover tools, resources, and prompts immediately after connect.
|
|
398
|
+
// All three list calls are paginated via nextCursor — a server with
|
|
399
|
+
// more than one page is truncated otherwise. Resources and prompts
|
|
400
|
+
// are only called when the server advertises the capability; the SDK
|
|
401
|
+
// throws assertCapabilityForMethod otherwise.
|
|
402
|
+
const tools: Tool[] = [];
|
|
403
|
+
let toolCursor: string | undefined;
|
|
404
|
+
do {
|
|
405
|
+
const r = await this.client.listTools(toolCursor ? { cursor: toolCursor } : undefined);
|
|
406
|
+
tools.push(...r.tools);
|
|
407
|
+
toolCursor = r.nextCursor;
|
|
408
|
+
} while (toolCursor);
|
|
409
|
+
this._tools = tools.map((t) => ({ ...t, serverName: this.name }));
|
|
410
|
+
|
|
411
|
+
const caps = this.client.getServerCapabilities();
|
|
412
|
+
const resources: DiscoveredResource[] = [];
|
|
413
|
+
let resourceCursor: string | undefined;
|
|
414
|
+
if (caps?.resources) {
|
|
415
|
+
try {
|
|
416
|
+
do {
|
|
417
|
+
const r = await this.client.listResources(resourceCursor ? { cursor: resourceCursor } : undefined);
|
|
418
|
+
resources.push(...r.resources.map(toDiscoveredResource));
|
|
419
|
+
resourceCursor = r.nextCursor;
|
|
420
|
+
} while (resourceCursor);
|
|
421
|
+
} catch (e) {
|
|
422
|
+
// Some servers advertise the resources capability but don't implement
|
|
423
|
+
// resources/list (e.g. Atlassian MCP returns -32601). Treat this as
|
|
424
|
+
// "no resources" rather than a fatal connection error.
|
|
425
|
+
if (!(e instanceof McpError && e.code === -32601)) throw e;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const prompts: DiscoveredPrompt[] = [];
|
|
429
|
+
let promptCursor: string | undefined;
|
|
430
|
+
if (caps?.prompts) {
|
|
431
|
+
try {
|
|
432
|
+
do {
|
|
433
|
+
const r = await this.client.listPrompts(promptCursor ? { cursor: promptCursor } : undefined);
|
|
434
|
+
prompts.push(...r.prompts.map(toDiscoveredPrompt));
|
|
435
|
+
promptCursor = r.nextCursor;
|
|
436
|
+
} while (promptCursor);
|
|
437
|
+
} catch (e) {
|
|
438
|
+
// Same defensive pattern: ignore -32601 for prompts/list.
|
|
439
|
+
if (!(e instanceof McpError && e.code === -32601)) throw e;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this._resources = resources;
|
|
443
|
+
this._prompts = prompts;
|
|
444
|
+
// Server-level instructions come from the SDK accessor (which reads
|
|
445
|
+
// the initialize result), not from the raw response.
|
|
446
|
+
this._instructions = this.client.getInstructions();
|
|
447
|
+
|
|
448
|
+
// Persist tool/resource/prompt metadata so search/describe can work
|
|
449
|
+
// offline. Cache writes are best-effort — a failure must never break
|
|
450
|
+
// the connection.
|
|
451
|
+
try {
|
|
452
|
+
const cacheEntry: {
|
|
453
|
+
tools: CachedTool[];
|
|
454
|
+
resources: DiscoveredResource[];
|
|
455
|
+
prompts?: DiscoveredPrompt[];
|
|
456
|
+
instructions?: string;
|
|
457
|
+
} = {
|
|
458
|
+
tools: tools.map((t) => {
|
|
459
|
+
const cached: CachedTool = { name: t.name, inputSchema: t.inputSchema };
|
|
460
|
+
if (t.description !== undefined) cached.description = t.description;
|
|
461
|
+
return cached;
|
|
462
|
+
}),
|
|
463
|
+
resources,
|
|
464
|
+
};
|
|
465
|
+
// exactOptionalPropertyTypes: omit optional keys rather than
|
|
466
|
+
// assigning undefined
|
|
467
|
+
if (prompts.length > 0) cacheEntry.prompts = prompts;
|
|
468
|
+
if (this._instructions !== undefined) cacheEntry.instructions = this._instructions;
|
|
469
|
+
saveServerCache(this.name, this._def, cacheEntry);
|
|
470
|
+
} catch {
|
|
471
|
+
// Best-effort cache write — ignore
|
|
472
|
+
}
|
|
473
|
+
} catch (e) {
|
|
474
|
+
// A close() won the race: the failure belongs to a superseded attempt.
|
|
475
|
+
if (this.generation !== gen) {
|
|
476
|
+
await this.tearDownClient();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
this._status = "error";
|
|
480
|
+
const baseMessage = e instanceof Error ? e.message : String(e);
|
|
481
|
+
const tail = formatStderrTail(stderrChunks);
|
|
482
|
+
this._error = tail ? `${baseMessage}\n--- stderr ---\n${tail}` : baseMessage;
|
|
483
|
+
this.client = null;
|
|
484
|
+
throw e;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Null out and close our current client (if any), marking the client
|
|
490
|
+
* disconnected. Used by the generation fence to clean up a connect that
|
|
491
|
+
* finished after a close().
|
|
492
|
+
*/
|
|
493
|
+
private async tearDownClient(): Promise<void> {
|
|
494
|
+
const c = this.client;
|
|
495
|
+
this.client = null;
|
|
496
|
+
if (this._status !== "needs-auth") this._status = "disconnected";
|
|
497
|
+
if (c) await c.close().catch(() => {});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Call a tool by name with arguments */
|
|
501
|
+
async callTool(
|
|
502
|
+
toolName: string,
|
|
503
|
+
args: Record<string, unknown>,
|
|
504
|
+
signal?: AbortSignal,
|
|
505
|
+
): Promise<{ content: ContentBlock[]; isError: boolean }> {
|
|
506
|
+
await this.connect();
|
|
507
|
+
this.assertCallable();
|
|
508
|
+
|
|
509
|
+
// Check abort before calling
|
|
510
|
+
signal?.throwIfAborted();
|
|
511
|
+
|
|
512
|
+
this.lastUsedAt = Date.now();
|
|
513
|
+
this.inFlight++;
|
|
514
|
+
try {
|
|
515
|
+
try {
|
|
516
|
+
return await this.invokeTool(toolName, args);
|
|
517
|
+
} catch (e) {
|
|
518
|
+
// Expired HTTP session (server restarted, etc.): reconnect exactly
|
|
519
|
+
// once and retry the call. Any further failure surfaces as-is.
|
|
520
|
+
if (!(e instanceof StreamableHTTPError) || e.code !== 404) throw e;
|
|
521
|
+
await this.close();
|
|
522
|
+
await this.connect();
|
|
523
|
+
this.assertCallable();
|
|
524
|
+
signal?.throwIfAborted();
|
|
525
|
+
this.lastUsedAt = Date.now();
|
|
526
|
+
return await this.invokeTool(toolName, args);
|
|
527
|
+
}
|
|
528
|
+
} finally {
|
|
529
|
+
this.inFlight--;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Throw a clear error when no usable connection exists */
|
|
534
|
+
private assertCallable(): void {
|
|
535
|
+
if (this._status === "needs-auth") {
|
|
536
|
+
throw new Error(`Server ${this.name}: ${this._error ?? NEEDS_AUTH_MESSAGE}`);
|
|
537
|
+
}
|
|
538
|
+
if (!this.client) throw new Error(`Server ${this.name} not connected`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Perform the SDK call and normalise the result shape */
|
|
542
|
+
private async invokeTool(
|
|
543
|
+
toolName: string,
|
|
544
|
+
args: Record<string, unknown>,
|
|
545
|
+
): Promise<{ content: ContentBlock[]; isError: boolean }> {
|
|
546
|
+
const client = this.client;
|
|
547
|
+
if (!client) throw new Error(`Server ${this.name} not connected`);
|
|
548
|
+
|
|
549
|
+
const result = await client.callTool({ name: toolName, arguments: args });
|
|
550
|
+
|
|
551
|
+
// The SDK returns a union: one branch has `content`, the other has `toolResult` (legacy)
|
|
552
|
+
if ("content" in result) {
|
|
553
|
+
return {
|
|
554
|
+
content: result.content as ContentBlock[],
|
|
555
|
+
isError: result.isError === true,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Legacy CompatibilityCallToolResult — wrap toolResult as text
|
|
560
|
+
return {
|
|
561
|
+
content: [{ type: "text", text: JSON.stringify(result.toolResult, null, 2) }],
|
|
562
|
+
isError: false,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async close(): Promise<void> {
|
|
567
|
+
// Bump the generation so any in-flight connect is fenced out on completion.
|
|
568
|
+
// Set the state synchronously BEFORE awaiting the SDK close so a
|
|
569
|
+
// concurrent callTool cannot observe a null client with a stale status.
|
|
570
|
+
this.generation++;
|
|
571
|
+
this._status = "disconnected";
|
|
572
|
+
const c = this.client;
|
|
573
|
+
this.client = null;
|
|
574
|
+
if (c) await c.close().catch(() => {});
|
|
575
|
+
}
|
|
576
|
+
}
|