@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,689 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { registerMcp, setIndexSeamsForTest } from "./index.js";
|
|
9
|
+
import { ServerManager } from "./server-manager.js";
|
|
10
|
+
import { ServerClient } from "./server-client.js";
|
|
11
|
+
import {
|
|
12
|
+
clearRegisteredForTest,
|
|
13
|
+
getRegisteredNamesForTest,
|
|
14
|
+
} from "./direct-tools.js";
|
|
15
|
+
import { setCachePathForTest, saveServerCache } from "./metadata-cache.js";
|
|
16
|
+
import { DEFAULT_MCP_CONFIG } from "./types.js";
|
|
17
|
+
import type { McpConfig, ServerDef } from "./types.js";
|
|
18
|
+
|
|
19
|
+
// ── mocks ──────────────────────────────────────────────────────────────────
|
|
20
|
+
// The real BorderedLoader needs a live TUI; a stub with the same surface
|
|
21
|
+
// (constructor message + onAbort) is enough to drive runAuthWithLoader.
|
|
22
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
23
|
+
BorderedLoader: class {
|
|
24
|
+
message: string;
|
|
25
|
+
onAbort?: () => void;
|
|
26
|
+
constructor(_tui: unknown, _theme: unknown, message: string) {
|
|
27
|
+
this.message = message;
|
|
28
|
+
}
|
|
29
|
+
dispose() {}
|
|
30
|
+
},
|
|
31
|
+
// core/settings-io builds its settings path at module load
|
|
32
|
+
getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// ── fakes ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface CapturedTool {
|
|
38
|
+
name: string;
|
|
39
|
+
execute?: (
|
|
40
|
+
toolCallId: string,
|
|
41
|
+
params: unknown,
|
|
42
|
+
signal?: AbortSignal,
|
|
43
|
+
onUpdate?: unknown,
|
|
44
|
+
ctx?: unknown,
|
|
45
|
+
) => Promise<ExecuteResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ExecuteResult {
|
|
49
|
+
content: Array<{ type: string; text?: string }>;
|
|
50
|
+
details?: Record<string, unknown>;
|
|
51
|
+
isError?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fake ExtensionAPI capturing event handlers, registered tools, and commands */
|
|
55
|
+
function makeFakePi(): {
|
|
56
|
+
pi: ExtensionAPI;
|
|
57
|
+
handlers: Record<string, Array<(...args: unknown[]) => unknown>>;
|
|
58
|
+
tools: CapturedTool[];
|
|
59
|
+
commands: Record<string, { description?: string; handler: unknown }>;
|
|
60
|
+
} {
|
|
61
|
+
const handlers: Record<string, Array<(...args: unknown[]) => unknown>> = {};
|
|
62
|
+
const tools: CapturedTool[] = [];
|
|
63
|
+
const commands: Record<string, { description?: string; handler: unknown }> = {};
|
|
64
|
+
const pi = {
|
|
65
|
+
on: (name: string, fn: (...args: unknown[]) => unknown) => {
|
|
66
|
+
(handlers[name] ??= []).push(fn);
|
|
67
|
+
},
|
|
68
|
+
registerTool: (def: CapturedTool) => {
|
|
69
|
+
tools.push(def);
|
|
70
|
+
},
|
|
71
|
+
registerCommand: (name: string, def: { description?: string; handler: unknown }) => {
|
|
72
|
+
commands[name] = def;
|
|
73
|
+
},
|
|
74
|
+
} as unknown as ExtensionAPI;
|
|
75
|
+
return { pi, handlers, tools, commands };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fake SDK Client. Only the surface ServerClient touches is implemented.
|
|
80
|
+
* `callLog` records the RAW tool names passed to callTool so tests can assert
|
|
81
|
+
* on the name-resolution behaviour of the proxy.
|
|
82
|
+
*/
|
|
83
|
+
function makeFakeSdkClient(opts: {
|
|
84
|
+
tools?: Array<{ name: string; description?: string; inputSchema: unknown }>;
|
|
85
|
+
onConnect?: (transport: unknown) => Promise<void> | void;
|
|
86
|
+
} = {}) {
|
|
87
|
+
const fake = {
|
|
88
|
+
callLog: [] as Array<{ name: string; args: Record<string, unknown> }>,
|
|
89
|
+
async connect(transport: unknown) {
|
|
90
|
+
await opts.onConnect?.(transport);
|
|
91
|
+
},
|
|
92
|
+
async listTools() {
|
|
93
|
+
return { tools: opts.tools ?? [] };
|
|
94
|
+
},
|
|
95
|
+
async listResources() {
|
|
96
|
+
return { resources: [] };
|
|
97
|
+
},
|
|
98
|
+
async listPrompts() {
|
|
99
|
+
return { prompts: [] };
|
|
100
|
+
},
|
|
101
|
+
getServerCapabilities() {
|
|
102
|
+
return undefined;
|
|
103
|
+
},
|
|
104
|
+
getInstructions() {
|
|
105
|
+
return undefined;
|
|
106
|
+
},
|
|
107
|
+
async callTool(params: { name: string; arguments: Record<string, unknown> }) {
|
|
108
|
+
fake.callLog.push({ name: params.name, args: params.arguments });
|
|
109
|
+
return { content: [{ type: "text", text: `result:${params.name}` }] };
|
|
110
|
+
},
|
|
111
|
+
async close() {},
|
|
112
|
+
};
|
|
113
|
+
return fake;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let tmp: string;
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
tmp = mkdtempSync(join(tmpdir(), "mcp-index-test-"));
|
|
119
|
+
setCachePathForTest(join(tmp, "cache.json"));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
setIndexSeamsForTest(null);
|
|
124
|
+
setCachePathForTest(null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** Wire the index module to a fake manager + config loaders, return the proxy execute fn */
|
|
128
|
+
function setupProxy(
|
|
129
|
+
defs: Record<string, ServerDef>,
|
|
130
|
+
sdkClient: unknown,
|
|
131
|
+
config: McpConfig = DEFAULT_MCP_CONFIG,
|
|
132
|
+
): (params: Record<string, unknown>) => Promise<ExecuteResult> {
|
|
133
|
+
const manager = new ServerManager({
|
|
134
|
+
clientFactory: () => sdkClient as unknown as Client,
|
|
135
|
+
});
|
|
136
|
+
setIndexSeamsForTest({
|
|
137
|
+
manager,
|
|
138
|
+
loadServerDefs: () => defs,
|
|
139
|
+
loadMcpConfig: () => config,
|
|
140
|
+
});
|
|
141
|
+
const { pi, tools } = makeFakePi();
|
|
142
|
+
registerMcp(pi);
|
|
143
|
+
const mcpTool = tools.find((t) => t.name === "mcp");
|
|
144
|
+
if (!mcpTool?.execute) throw new Error("mcp proxy tool not registered");
|
|
145
|
+
const execute = mcpTool.execute;
|
|
146
|
+
return (params) => execute!("call", params, undefined, undefined, undefined);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── call: raw tool-name resolution ───────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe("mcp proxy — call tool", () => {
|
|
152
|
+
it("calls the RAW dotted tool name when given its sanitized prefixed name", async () => {
|
|
153
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
154
|
+
const sdk = makeFakeSdkClient({
|
|
155
|
+
tools: [
|
|
156
|
+
{ name: "a.b", description: "dotted tool", inputSchema: {} },
|
|
157
|
+
{ name: "plain", inputSchema: {} },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
// Seed the metadata cache so getToolsForServer has the raw names offline
|
|
161
|
+
saveServerCache("srv", def, {
|
|
162
|
+
tools: [
|
|
163
|
+
{ name: "a.b", description: "dotted tool", inputSchema: {} },
|
|
164
|
+
{ name: "plain", inputSchema: {} },
|
|
165
|
+
],
|
|
166
|
+
resources: [],
|
|
167
|
+
});
|
|
168
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
169
|
+
|
|
170
|
+
// "srv_a_b" is the SANITIZED form of the raw tool "a.b" — the proxy must
|
|
171
|
+
// call the server with "a.b", not "a_b".
|
|
172
|
+
const result = await run({ tool: "srv_a_b" });
|
|
173
|
+
expect(sdk.callLog).toEqual([{ name: "a.b", args: {} }]);
|
|
174
|
+
expect(result.content).toEqual([{ type: "text", text: "result:a.b" }]);
|
|
175
|
+
expect(result.details).toEqual({ server: "srv", tool: "a.b" });
|
|
176
|
+
|
|
177
|
+
// Dot-free names keep working via the same path
|
|
178
|
+
await run({ tool: "srv_plain" });
|
|
179
|
+
expect(sdk.callLog[1]).toEqual({ name: "plain", args: {} });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("still accepts the raw (unprefixed) tool name", async () => {
|
|
183
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
184
|
+
const sdk = makeFakeSdkClient({ tools: [{ name: "a.b", inputSchema: {} }] });
|
|
185
|
+
saveServerCache("srv", def, {
|
|
186
|
+
tools: [{ name: "a.b", inputSchema: {} }],
|
|
187
|
+
resources: [],
|
|
188
|
+
});
|
|
189
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
190
|
+
|
|
191
|
+
await run({ tool: "a.b" });
|
|
192
|
+
expect(sdk.callLog).toEqual([{ name: "a.b", args: {} }]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("resolves a prefixed tool name when an explicit server is also given", async () => {
|
|
196
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
197
|
+
const sdk = makeFakeSdkClient({
|
|
198
|
+
tools: [
|
|
199
|
+
{ name: "a.b", description: "dotted tool", inputSchema: {} },
|
|
200
|
+
{ name: "plain", inputSchema: {} },
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
saveServerCache("srv", def, {
|
|
204
|
+
tools: [
|
|
205
|
+
{ name: "a.b", description: "dotted tool", inputSchema: {} },
|
|
206
|
+
{ name: "plain", inputSchema: {} },
|
|
207
|
+
],
|
|
208
|
+
resources: [],
|
|
209
|
+
});
|
|
210
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
211
|
+
|
|
212
|
+
// mcp({ tool: "srv_a_b", server: "srv" }) — the prefixed name must be
|
|
213
|
+
// resolved to the raw dotted name, not passed through as-is.
|
|
214
|
+
const result = await run({ server: "srv", tool: "srv_a_b" });
|
|
215
|
+
expect(sdk.callLog).toEqual([{ name: "a.b", args: {} }]);
|
|
216
|
+
expect(result.details).toEqual({ server: "srv", tool: "a.b" });
|
|
217
|
+
|
|
218
|
+
// A dot-free prefixed name resolves too
|
|
219
|
+
await run({ server: "srv", tool: "srv_plain" });
|
|
220
|
+
expect(sdk.callLog[1]).toEqual({ name: "plain", args: {} });
|
|
221
|
+
|
|
222
|
+
// A raw name with an explicit server still passes through unchanged
|
|
223
|
+
await run({ server: "srv", tool: "a.b" });
|
|
224
|
+
expect(sdk.callLog[2]).toEqual({ name: "a.b", args: {} });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("passes string args through JSON parsing to the raw tool", async () => {
|
|
228
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
229
|
+
const sdk = makeFakeSdkClient({ tools: [{ name: "a.b", inputSchema: {} }] });
|
|
230
|
+
saveServerCache("srv", def, {
|
|
231
|
+
tools: [{ name: "a.b", inputSchema: {} }],
|
|
232
|
+
resources: [],
|
|
233
|
+
});
|
|
234
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
235
|
+
|
|
236
|
+
await run({ tool: "srv_a_b", args: '{"q": 42}' });
|
|
237
|
+
expect(sdk.callLog).toEqual([{ name: "a.b", args: { q: 42 } }]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── status action ────────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe("mcp proxy — status action", () => {
|
|
244
|
+
it("treats action:'status' the same as a no-parameter call", async () => {
|
|
245
|
+
const run = setupProxy({}, makeFakeSdkClient());
|
|
246
|
+
const result = await run({ action: "status" });
|
|
247
|
+
expect(result.content[0]?.text).toBe("No MCP servers configured.");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("lists server statuses for action:'status' with configured servers", async () => {
|
|
251
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
252
|
+
const run = setupProxy({ srv: def }, makeFakeSdkClient());
|
|
253
|
+
const result = await run({ action: "status" });
|
|
254
|
+
expect(result.content[0]?.text).toContain("srv: disconnected");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("still reports unknown actions as unknown", async () => {
|
|
258
|
+
const run = setupProxy({}, makeFakeSdkClient());
|
|
259
|
+
const result = await run({ action: "bogus" });
|
|
260
|
+
expect(result.content[0]?.text).toBe("Unknown action");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ── search / describe / list accept prefixed names ──────────────────────────────
|
|
265
|
+
|
|
266
|
+
describe("mcp proxy — prefixed-name support in discovery actions", () => {
|
|
267
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
268
|
+
const seedCache = (): unknown => {
|
|
269
|
+
const sdk = makeFakeSdkClient({
|
|
270
|
+
tools: [{ name: "a.b", description: "dotted tool", inputSchema: {} }],
|
|
271
|
+
});
|
|
272
|
+
saveServerCache("srv", def, {
|
|
273
|
+
tools: [{ name: "a.b", description: "dotted tool", inputSchema: {} }],
|
|
274
|
+
resources: [],
|
|
275
|
+
});
|
|
276
|
+
return sdk;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
it("describe resolves a sanitized prefixed name to the raw tool", async () => {
|
|
280
|
+
const sdk = seedCache();
|
|
281
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
282
|
+
const result = await run({ describe: "srv_a_b" });
|
|
283
|
+
expect(result.content[0]?.text).toContain("a.b (srv)");
|
|
284
|
+
expect(result.content[0]?.text).toContain("dotted tool");
|
|
285
|
+
expect(result.content[0]?.text).toContain("Schema:");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("describe still resolves the raw name", async () => {
|
|
289
|
+
const sdk = seedCache();
|
|
290
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
291
|
+
const result = await run({ describe: "a.b" });
|
|
292
|
+
expect(result.content[0]?.text).toContain("a.b (srv)");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("search retries with the raw name when the query is a prefixed name", async () => {
|
|
296
|
+
const sdk = seedCache();
|
|
297
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
298
|
+
const result = await run({ search: "srv_a_b" });
|
|
299
|
+
expect(result.content[0]?.text).toContain("a.b (srv)");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("search by plain keyword is unchanged", async () => {
|
|
303
|
+
const sdk = seedCache();
|
|
304
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
305
|
+
const result = await run({ search: "dotted" });
|
|
306
|
+
expect(result.content[0]?.text).toContain("a.b (srv)");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("list accepts a bare server prefix (short mode) as the server reference", async () => {
|
|
310
|
+
const shortDef: ServerDef = { type: "stdio", command: "true", toolPrefix: "short" };
|
|
311
|
+
const sdk = makeFakeSdkClient({
|
|
312
|
+
tools: [{ name: "search", description: "find things", inputSchema: {} }],
|
|
313
|
+
});
|
|
314
|
+
saveServerCache("github-mcp", shortDef, {
|
|
315
|
+
tools: [{ name: "search", description: "find things", inputSchema: {} }],
|
|
316
|
+
resources: [],
|
|
317
|
+
});
|
|
318
|
+
const run = setupProxy({ "github-mcp": shortDef }, sdk);
|
|
319
|
+
// "github" is the SHORT prefix of server "github-mcp" — not a server name
|
|
320
|
+
const result = await run({ server: "github" });
|
|
321
|
+
expect(result.content[0]?.text).toContain("search");
|
|
322
|
+
expect(result.details).toEqual({ server: "github-mcp", toolCount: 1 });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("list still rejects unknown server references", async () => {
|
|
326
|
+
const sdk = seedCache();
|
|
327
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
328
|
+
const result = await run({ server: "nope" });
|
|
329
|
+
expect(result.content[0]?.text).toBe("Unknown server: nope");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ── connect action: needs-auth must not be reported as success ────────────
|
|
334
|
+
|
|
335
|
+
describe("mcp proxy — connect action with needs-auth", () => {
|
|
336
|
+
const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
|
|
337
|
+
|
|
338
|
+
it("reports the needs-auth error instead of 'Connected to X'", async () => {
|
|
339
|
+
const sdk = makeFakeSdkClient({
|
|
340
|
+
onConnect: () => {
|
|
341
|
+
throw new StreamableHTTPError(401, "Unauthorized");
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
const run = setupProxy({ "auth-srv": httpDef }, sdk);
|
|
345
|
+
const result = await run({ connect: "auth-srv" });
|
|
346
|
+
const text = result.content[0]?.text ?? "";
|
|
347
|
+
expect(text).toContain("requires authentication");
|
|
348
|
+
expect(text).toContain("OAuth");
|
|
349
|
+
expect(text).not.toContain("tools available");
|
|
350
|
+
expect(result.details).toEqual({ server: "auth-srv", status: "needs-auth" });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("still reports a successful connect with the tool count", async () => {
|
|
354
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
355
|
+
const sdk = makeFakeSdkClient({ tools: [{ name: "t1", inputSchema: {} }] });
|
|
356
|
+
const run = setupProxy({ srv: def }, sdk);
|
|
357
|
+
const result = await run({ connect: "srv" });
|
|
358
|
+
expect(result.content[0]?.text).toBe("Connected to srv. 1 tools available.");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ── call action: needs-auth at call time (autoAuth) ──────────────────────
|
|
363
|
+
|
|
364
|
+
describe("mcp proxy — call with needs-auth server", () => {
|
|
365
|
+
const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp", auth: "oauth" };
|
|
366
|
+
|
|
367
|
+
/** SDK fake that 401s on connect until `approved` flips (post-auth reconnect succeeds). */
|
|
368
|
+
function makeOauthSdk(approved: { value: boolean }) {
|
|
369
|
+
return makeFakeSdkClient({
|
|
370
|
+
tools: [{ name: "t1", inputSchema: {} }],
|
|
371
|
+
onConnect: () => {
|
|
372
|
+
if (!approved.value) throw new StreamableHTTPError(401, "Unauthorized");
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
it("returns /mcp auth guidance (isError false) and never authenticates when autoAuth is off (default)", async () => {
|
|
378
|
+
const approved = { value: false };
|
|
379
|
+
const sdk = makeOauthSdk(approved);
|
|
380
|
+
saveServerCache("auth-srv", httpDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
|
|
381
|
+
const run = setupProxy({ "auth-srv": httpDef }, sdk);
|
|
382
|
+
const authSpy = vi.spyOn(ServerClient.prototype, "authenticate");
|
|
383
|
+
try {
|
|
384
|
+
const result = await run({ tool: "t1", server: "auth-srv" });
|
|
385
|
+
const text = result.content[0]?.text ?? "";
|
|
386
|
+
expect(text).toContain("requires authentication");
|
|
387
|
+
expect(text).toContain("/mcp auth auth-srv");
|
|
388
|
+
expect(result.isError).toBeFalsy();
|
|
389
|
+
expect(authSpy).not.toHaveBeenCalled();
|
|
390
|
+
// The tool was never dispatched to the server
|
|
391
|
+
expect(sdk.callLog).toEqual([]);
|
|
392
|
+
} finally {
|
|
393
|
+
authSpy.mockRestore();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("auto-authenticates and retries the call once when autoAuth is on and the flow succeeds", async () => {
|
|
398
|
+
const approved = { value: false };
|
|
399
|
+
const sdk = makeOauthSdk(approved);
|
|
400
|
+
saveServerCache("auth-srv", httpDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
|
|
401
|
+
const run = setupProxy(
|
|
402
|
+
{ "auth-srv": httpDef },
|
|
403
|
+
sdk,
|
|
404
|
+
{ ...DEFAULT_MCP_CONFIG, autoAuth: true },
|
|
405
|
+
);
|
|
406
|
+
const authSpy = vi.spyOn(ServerClient.prototype, "authenticate").mockImplementation(
|
|
407
|
+
async function (this: ServerClient) {
|
|
408
|
+
// Simulate a completed browser flow (fresh tokens stored)
|
|
409
|
+
approved.value = true;
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
try {
|
|
413
|
+
const result = await run({ tool: "t1", server: "auth-srv" });
|
|
414
|
+
expect(authSpy).toHaveBeenCalledTimes(1);
|
|
415
|
+
// The single retry reached the server with the raw tool name
|
|
416
|
+
expect(sdk.callLog).toEqual([{ name: "t1", args: {} }]);
|
|
417
|
+
expect(result.content).toEqual([{ type: "text", text: "result:t1" }]);
|
|
418
|
+
expect(result.isError).toBe(false);
|
|
419
|
+
} finally {
|
|
420
|
+
authSpy.mockRestore();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("returns guidance with the error when autoAuth is on but the flow is cancelled", async () => {
|
|
425
|
+
const approved = { value: false };
|
|
426
|
+
const sdk = makeOauthSdk(approved);
|
|
427
|
+
saveServerCache("auth-srv", httpDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
|
|
428
|
+
const run = setupProxy(
|
|
429
|
+
{ "auth-srv": httpDef },
|
|
430
|
+
sdk,
|
|
431
|
+
{ ...DEFAULT_MCP_CONFIG, autoAuth: true },
|
|
432
|
+
);
|
|
433
|
+
const authSpy = vi.spyOn(ServerClient.prototype, "authenticate").mockRejectedValue(
|
|
434
|
+
new Error("OAuth cancelled"),
|
|
435
|
+
);
|
|
436
|
+
try {
|
|
437
|
+
const result = await run({ tool: "t1", server: "auth-srv" });
|
|
438
|
+
const text = result.content[0]?.text ?? "";
|
|
439
|
+
expect(text).toContain("OAuth cancelled");
|
|
440
|
+
expect(text).toContain("/mcp auth auth-srv");
|
|
441
|
+
expect(result.isError).toBeFalsy();
|
|
442
|
+
expect(approved.value).toBe(false);
|
|
443
|
+
// No retry was attempted
|
|
444
|
+
expect(sdk.callLog).toEqual([]);
|
|
445
|
+
} finally {
|
|
446
|
+
authSpy.mockRestore();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ── command wiring: index registers ONLY /mcp; auth flows through it ──────
|
|
452
|
+
|
|
453
|
+
describe("mcp proxy — command wiring", () => {
|
|
454
|
+
it("registers only the mcp command (standalone OAuth commands retired)", async () => {
|
|
455
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
456
|
+
const manager = new ServerManager({
|
|
457
|
+
clientFactory: () => makeFakeSdkClient() as unknown as Client,
|
|
458
|
+
});
|
|
459
|
+
setIndexSeamsForTest({
|
|
460
|
+
manager,
|
|
461
|
+
loadServerDefs: () => ({ srv: def }),
|
|
462
|
+
loadAllServerDefs: () => ({ srv: def }),
|
|
463
|
+
loadMcpConfig: () => DEFAULT_MCP_CONFIG,
|
|
464
|
+
});
|
|
465
|
+
const { pi, commands } = makeFakePi();
|
|
466
|
+
registerMcp(pi);
|
|
467
|
+
// The command registry contains ONLY "mcp" — /mcp auth and /mcp logout
|
|
468
|
+
// dispatch through it; the standalone commands are gone.
|
|
469
|
+
expect(Object.keys(commands).sort()).toEqual(["mcp"]);
|
|
470
|
+
|
|
471
|
+
// A stdio server is not OAuth-capable: /mcp auth must surface it as an
|
|
472
|
+
// unknown (non-http) server, not attempt auth.
|
|
473
|
+
const notify = vi.fn();
|
|
474
|
+
const ctx = { hasUI: true, ui: { notify, custom: vi.fn() } } as never;
|
|
475
|
+
const handler = commands["mcp"]!.handler as (
|
|
476
|
+
args: string,
|
|
477
|
+
ctx: unknown,
|
|
478
|
+
) => Promise<void>;
|
|
479
|
+
await handler("auth srv", ctx);
|
|
480
|
+
expect(notify).toHaveBeenCalledWith("Unknown server: srv", "error");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("/mcp auth finds a typeless url server configured for oauth (shape-based classification)", async () => {
|
|
484
|
+
const def: ServerDef = { url: "https://mcp.example.com/mcp", auth: "oauth" };
|
|
485
|
+
const manager = new ServerManager({
|
|
486
|
+
clientFactory: () => makeFakeSdkClient() as unknown as Client,
|
|
487
|
+
});
|
|
488
|
+
setIndexSeamsForTest({
|
|
489
|
+
manager,
|
|
490
|
+
loadServerDefs: () => ({ srv: def }),
|
|
491
|
+
loadAllServerDefs: () => ({ srv: def }),
|
|
492
|
+
loadMcpConfig: () => DEFAULT_MCP_CONFIG,
|
|
493
|
+
});
|
|
494
|
+
const { pi, commands } = makeFakePi();
|
|
495
|
+
registerMcp(pi);
|
|
496
|
+
|
|
497
|
+
// The auth flow needs a managed client: session_start syncs in
|
|
498
|
+
// production; mirror that here.
|
|
499
|
+
manager.sync({ srv: def });
|
|
500
|
+
|
|
501
|
+
const authSpy = vi.spyOn(ServerClient.prototype, "authenticate").mockResolvedValue(
|
|
502
|
+
undefined,
|
|
503
|
+
);
|
|
504
|
+
try {
|
|
505
|
+
const notify = vi.fn();
|
|
506
|
+
// ui.custom runs the loader factory and resolves when done() is called
|
|
507
|
+
const custom = vi.fn(
|
|
508
|
+
(factory: (
|
|
509
|
+
tui: unknown,
|
|
510
|
+
theme: unknown,
|
|
511
|
+
keybindings: unknown,
|
|
512
|
+
done: (result: unknown) => void,
|
|
513
|
+
) => unknown) => {
|
|
514
|
+
let resolve!: (result: unknown) => void;
|
|
515
|
+
const pending = new Promise<unknown>((r) => (resolve = r));
|
|
516
|
+
let settled = false;
|
|
517
|
+
const done = (result: unknown) => {
|
|
518
|
+
if (!settled) {
|
|
519
|
+
settled = true;
|
|
520
|
+
resolve(result);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
factory({}, {}, {}, done);
|
|
524
|
+
return pending;
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
const ctx = { hasUI: true, ui: { notify, custom } } as never;
|
|
528
|
+
const handler = commands["mcp"]!.handler as (
|
|
529
|
+
args: string,
|
|
530
|
+
ctx: unknown,
|
|
531
|
+
) => Promise<void>;
|
|
532
|
+
await handler("auth srv", ctx);
|
|
533
|
+
|
|
534
|
+
// Shape (url) classified it as HTTP: the OAuth entry point ran and the
|
|
535
|
+
// full flow completed — NOT "Unknown server".
|
|
536
|
+
expect(authSpy).toHaveBeenCalledTimes(1);
|
|
537
|
+
expect(notify).not.toHaveBeenCalledWith("Unknown server: srv", "error");
|
|
538
|
+
expect(notify).toHaveBeenCalledWith("✓ srv authenticated — 0 tools available", "info");
|
|
539
|
+
} finally {
|
|
540
|
+
authSpy.mockRestore();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ── test seam reset ───────────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
describe("mcp proxy — test seam reset", () => {
|
|
548
|
+
it("setIndexSeamsForTest(null) discards the swapped-in manager", async () => {
|
|
549
|
+
const def: ServerDef = { type: "stdio", command: "true" };
|
|
550
|
+
const sdk = makeFakeSdkClient({ tools: [{ name: "t1", inputSchema: {} }] });
|
|
551
|
+
const manager = new ServerManager({
|
|
552
|
+
clientFactory: () => sdk as unknown as Client,
|
|
553
|
+
});
|
|
554
|
+
manager.sync({ srv: def });
|
|
555
|
+
await manager.getClient("srv")!.connect();
|
|
556
|
+
expect(manager.getClient("srv")!.status).toBe("connected");
|
|
557
|
+
|
|
558
|
+
setIndexSeamsForTest({
|
|
559
|
+
manager,
|
|
560
|
+
loadServerDefs: () => ({ srv: def }),
|
|
561
|
+
loadMcpConfig: () => DEFAULT_MCP_CONFIG,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Reset the seam — the swapped-in (fake, connected) manager must be
|
|
565
|
+
// discarded, not left in place.
|
|
566
|
+
setIndexSeamsForTest(null);
|
|
567
|
+
|
|
568
|
+
// Re-arm only the config loaders: the module-level manager should now be
|
|
569
|
+
// a FRESH one, so the same server must be reported as a brand-new
|
|
570
|
+
// (disconnected) client rather than the stale fake (connected).
|
|
571
|
+
setIndexSeamsForTest({
|
|
572
|
+
loadServerDefs: () => ({ srv: def }),
|
|
573
|
+
loadMcpConfig: () => DEFAULT_MCP_CONFIG,
|
|
574
|
+
});
|
|
575
|
+
const { pi, tools } = makeFakePi();
|
|
576
|
+
registerMcp(pi);
|
|
577
|
+
const mcpTool = tools.find((t) => t.name === "mcp");
|
|
578
|
+
if (!mcpTool?.execute) throw new Error("mcp proxy tool not registered");
|
|
579
|
+
const result = await mcpTool.execute!("call", {}, undefined, undefined, undefined);
|
|
580
|
+
expect(result.content[0]?.text).toBe("srv: disconnected");
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// ── session_start probe: superseded client must not evict registered names ──
|
|
585
|
+
|
|
586
|
+
describe("mcp proxy — session_start probe fenced by a superseded client", () => {
|
|
587
|
+
it("does not re-register when the probe's client is replaced mid-connect", async () => {
|
|
588
|
+
clearRegisteredForTest();
|
|
589
|
+
const def1: ServerDef = { type: "stdio", command: "cmd-v1" };
|
|
590
|
+
const def2: ServerDef = { type: "stdio", command: "cmd-v2" };
|
|
591
|
+
let defs: Record<string, ServerDef> = { srv: def1 };
|
|
592
|
+
|
|
593
|
+
// sdk1 backs the FIRST (stale) client. Its connect holds the probe at the
|
|
594
|
+
// gate after a concurrent session_start (a /reload with an updated def)
|
|
595
|
+
// has already sync()'d — closing (generation-fencing) and replacing the
|
|
596
|
+
// stale client. Releasing the gate lets the fenced connect resolve with
|
|
597
|
+
// EMPTY tools, exactly like the real fence path.
|
|
598
|
+
let releaseStale!: () => void;
|
|
599
|
+
const staleGate = new Promise<void>((r) => (releaseStale = r));
|
|
600
|
+
const sdk1 = makeFakeSdkClient({
|
|
601
|
+
tools: [{ name: "t1", inputSchema: {} }],
|
|
602
|
+
onConnect: async () => {
|
|
603
|
+
defs = { srv: def2 };
|
|
604
|
+
manager.sync(defs);
|
|
605
|
+
await staleGate;
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
const sdk2 = makeFakeSdkClient({
|
|
609
|
+
tools: [{ name: "t1", inputSchema: {} }],
|
|
610
|
+
});
|
|
611
|
+
let created = 0;
|
|
612
|
+
const manager = new ServerManager({
|
|
613
|
+
clientFactory: () => (created++ === 0 ? sdk1 : sdk2) as unknown as Client,
|
|
614
|
+
});
|
|
615
|
+
setIndexSeamsForTest({
|
|
616
|
+
manager,
|
|
617
|
+
loadServerDefs: () => defs,
|
|
618
|
+
loadMcpConfig: () => DEFAULT_MCP_CONFIG,
|
|
619
|
+
});
|
|
620
|
+
const { pi, handlers, tools } = makeFakePi();
|
|
621
|
+
registerMcp(pi);
|
|
622
|
+
const start = handlers["session_start"]?.[0];
|
|
623
|
+
if (!start) throw new Error("session_start handler not registered");
|
|
624
|
+
|
|
625
|
+
// 1st session_start: no valid cache → background probe starts connecting
|
|
626
|
+
// the stale client (blocked at the gate after the sync above).
|
|
627
|
+
await start();
|
|
628
|
+
|
|
629
|
+
// 2nd session_start (the /reload): the client has been replaced, its
|
|
630
|
+
// probe connects the fresh client and registers "srv_t1".
|
|
631
|
+
await start();
|
|
632
|
+
await vi.waitFor(() => {
|
|
633
|
+
expect(getRegisteredNamesForTest().get("srv")?.has("srv_t1")).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// The stale connect now completes. Its probe must detect that it was
|
|
637
|
+
// superseded and NOT run a zero-tool registration pass — that would
|
|
638
|
+
// replace the server's tracked set with an empty one, evicting the
|
|
639
|
+
// names the current session just registered.
|
|
640
|
+
releaseStale();
|
|
641
|
+
await new Promise((r) => setTimeout(r, 10)); // drain the probe's microtasks
|
|
642
|
+
|
|
643
|
+
expect(getRegisteredNamesForTest().get("srv")?.has("srv_t1")).toBe(true);
|
|
644
|
+
|
|
645
|
+
// A later session_start must therefore NOT call pi.registerTool again
|
|
646
|
+
// for a name pi already holds (duplicate registration).
|
|
647
|
+
await start();
|
|
648
|
+
expect(tools.filter((t) => t.name === "srv_t1")).toHaveLength(1);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// ── session_start probe: needs-auth servers warn instead of registering 0 tools ──
|
|
653
|
+
|
|
654
|
+
describe("mcp proxy — session_start background probe with needs-auth", () => {
|
|
655
|
+
const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
|
|
656
|
+
|
|
657
|
+
it("warns and skips registration when a server 401s during the probe", async () => {
|
|
658
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
659
|
+
const sdk = makeFakeSdkClient({
|
|
660
|
+
onConnect: () => {
|
|
661
|
+
throw new StreamableHTTPError(401, "Unauthorized");
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
const manager = new ServerManager({
|
|
665
|
+
clientFactory: () => sdk as unknown as Client,
|
|
666
|
+
});
|
|
667
|
+
setIndexSeamsForTest({
|
|
668
|
+
manager,
|
|
669
|
+
loadServerDefs: () => ({ "auth-srv": httpDef }),
|
|
670
|
+
loadMcpConfig: () => DEFAULT_MCP_CONFIG,
|
|
671
|
+
});
|
|
672
|
+
const { pi, handlers, tools } = makeFakePi();
|
|
673
|
+
registerMcp(pi);
|
|
674
|
+
const start = handlers["session_start"]?.[0];
|
|
675
|
+
if (!start) throw new Error("session_start handler not registered");
|
|
676
|
+
|
|
677
|
+
await start();
|
|
678
|
+
// The probe is fire-and-forget — wait for it to settle
|
|
679
|
+
await vi.waitFor(() =>
|
|
680
|
+
expect(warn).toHaveBeenCalledWith(
|
|
681
|
+
expect.stringContaining('server "auth-srv" requires authentication'),
|
|
682
|
+
),
|
|
683
|
+
);
|
|
684
|
+
// No direct tools registered for the needs-auth server (proxy only)
|
|
685
|
+
expect(tools).toHaveLength(1);
|
|
686
|
+
expect(tools[0]?.name).toBe("mcp");
|
|
687
|
+
warn.mockRestore();
|
|
688
|
+
});
|
|
689
|
+
});
|