@moxxy/plugin-mcp 0.26.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/dist/admin/config-io.d.ts +46 -0
- package/dist/admin/config-io.d.ts.map +1 -0
- package/dist/admin/config-io.js +108 -0
- package/dist/admin/config-io.js.map +1 -0
- package/dist/admin/config-schema.d.ts +65 -0
- package/dist/admin/config-schema.d.ts.map +1 -0
- package/dist/admin/config-schema.js +36 -0
- package/dist/admin/config-schema.js.map +1 -0
- package/dist/admin/index.d.ts +21 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +189 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/runtime.d.ts +25 -0
- package/dist/admin/runtime.d.ts.map +1 -0
- package/dist/admin/runtime.js +196 -0
- package/dist/admin/runtime.js.map +1 -0
- package/dist/admin/schema.d.ts +37 -0
- package/dist/admin/schema.d.ts.map +1 -0
- package/dist/admin/schema.js +97 -0
- package/dist/admin/schema.js.map +1 -0
- package/dist/admin/secrets.d.ts +24 -0
- package/dist/admin/secrets.d.ts.map +1 -0
- package/dist/admin/secrets.js +35 -0
- package/dist/admin/secrets.js.map +1 -0
- package/dist/admin/skill.d.ts +18 -0
- package/dist/admin/skill.d.ts.map +1 -0
- package/dist/admin/skill.js +70 -0
- package/dist/admin/skill.js.map +1 -0
- package/dist/admin/tools/add.d.ts +19 -0
- package/dist/admin/tools/add.d.ts.map +1 -0
- package/dist/admin/tools/add.js +97 -0
- package/dist/admin/tools/add.js.map +1 -0
- package/dist/admin/tools/list.d.ts +3 -0
- package/dist/admin/tools/list.d.ts.map +1 -0
- package/dist/admin/tools/list.js +16 -0
- package/dist/admin/tools/list.js.map +1 -0
- package/dist/admin/tools/remove.d.ts +6 -0
- package/dist/admin/tools/remove.d.ts.map +1 -0
- package/dist/admin/tools/remove.js +41 -0
- package/dist/admin/tools/remove.js.map +1 -0
- package/dist/admin/tools/test.d.ts +8 -0
- package/dist/admin/tools/test.d.ts.map +1 -0
- package/dist/admin/tools/test.js +73 -0
- package/dist/admin/tools/test.js.map +1 -0
- package/dist/admin/types.d.ts +107 -0
- package/dist/admin/types.d.ts.map +1 -0
- package/dist/admin/types.js +2 -0
- package/dist/admin/types.js.map +1 -0
- package/dist/admin.d.ts +7 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +7 -0
- package/dist/admin.js.map +1 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +73 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/timeout.d.ts +23 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +35 -0
- package/dist/timeout.js.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/wrap.d.ts +23 -0
- package/dist/wrap.d.ts.map +1 -0
- package/dist/wrap.js +193 -0
- package/dist/wrap.js.map +1 -0
- package/package.json +63 -0
- package/src/admin/config-io.test.ts +188 -0
- package/src/admin/config-io.ts +112 -0
- package/src/admin/config-schema.ts +39 -0
- package/src/admin/index.ts +225 -0
- package/src/admin/runtime.test.ts +470 -0
- package/src/admin/runtime.ts +244 -0
- package/src/admin/schema.test.ts +70 -0
- package/src/admin/schema.ts +110 -0
- package/src/admin/secrets.test.ts +60 -0
- package/src/admin/secrets.ts +55 -0
- package/src/admin/skill.test.ts +143 -0
- package/src/admin/skill.ts +83 -0
- package/src/admin/tools/add.test.ts +193 -0
- package/src/admin/tools/add.ts +118 -0
- package/src/admin/tools/list.ts +19 -0
- package/src/admin/tools/remove.test.ts +74 -0
- package/src/admin/tools/remove.ts +46 -0
- package/src/admin/tools/test.test.ts +123 -0
- package/src/admin/tools/test.ts +84 -0
- package/src/admin/types.ts +112 -0
- package/src/admin.ts +25 -0
- package/src/client.ts +120 -0
- package/src/discovery.test.ts +45 -0
- package/src/index.test.ts +95 -0
- package/src/index.ts +101 -0
- package/src/timeout.ts +37 -0
- package/src/types.ts +57 -0
- package/src/wrap.test.ts +261 -0
- package/src/wrap.ts +236 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { McpClientLike, McpServerConfig, McpToolDescriptor } from '../types.js';
|
|
6
|
+
import type { AdminToolRegistryLike, McpStoredServer } from './types.js';
|
|
7
|
+
|
|
8
|
+
// The runtime connects through `defaultClientFactory` (from ../client.js),
|
|
9
|
+
// which would spawn a real subprocess / open a real socket. Replace it with
|
|
10
|
+
// an injectable fake whose behavior each test controls via `connectImpl`.
|
|
11
|
+
const hoisted = vi.hoisted(() => {
|
|
12
|
+
return {
|
|
13
|
+
connectCalls: [] as McpServerConfig[],
|
|
14
|
+
connectImpl: null as ((server: McpServerConfig) => Promise<McpClientLike>) | null,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock('../client.js', () => ({
|
|
19
|
+
defaultClientFactory: async (server: McpServerConfig): Promise<McpClientLike> => {
|
|
20
|
+
hoisted.connectCalls.push(server);
|
|
21
|
+
if (!hoisted.connectImpl) throw new Error('connectImpl not set by test');
|
|
22
|
+
return hoisted.connectImpl(server);
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// Imported AFTER the mock is registered (vi.mock is hoisted above imports).
|
|
27
|
+
const { createMcpRuntime } = await import('./runtime.js');
|
|
28
|
+
const { readMcpConfig, writeMcpConfig } = await import('./config-io.js');
|
|
29
|
+
|
|
30
|
+
const PING: McpToolDescriptor = { name: 'ping', description: 'pong', inputSchema: { type: 'object' } };
|
|
31
|
+
|
|
32
|
+
const makeClient = (over: Partial<McpClientLike> = {}): McpClientLike & { closed: number } => {
|
|
33
|
+
const state = { closed: 0 };
|
|
34
|
+
return {
|
|
35
|
+
closed: 0,
|
|
36
|
+
async listTools() {
|
|
37
|
+
return { tools: [PING] };
|
|
38
|
+
},
|
|
39
|
+
async callTool({ name }) {
|
|
40
|
+
return { content: [{ type: 'text', text: `pong ${name}` }] };
|
|
41
|
+
},
|
|
42
|
+
async close() {
|
|
43
|
+
state.closed++;
|
|
44
|
+
(this as { closed: number }).closed = state.closed;
|
|
45
|
+
},
|
|
46
|
+
...over,
|
|
47
|
+
} as McpClientLike & { closed: number };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const makeRegistry = (): AdminToolRegistryLike & { tools: Map<string, unknown> } => {
|
|
51
|
+
const tools = new Map<string, unknown>();
|
|
52
|
+
return {
|
|
53
|
+
tools,
|
|
54
|
+
has: (n) => tools.has(n),
|
|
55
|
+
register: (t) => {
|
|
56
|
+
if (tools.has(t.name)) throw new Error(`dup register ${t.name}`);
|
|
57
|
+
tools.set(t.name, t);
|
|
58
|
+
},
|
|
59
|
+
unregister: (n) => void tools.delete(n),
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const stored = (over: Partial<McpStoredServer> = {}): McpStoredServer =>
|
|
64
|
+
({ kind: 'stdio', name: 'demo', command: 'noop', cachedTools: [PING], ...over }) as McpStoredServer;
|
|
65
|
+
|
|
66
|
+
const baseCtx = () => ({
|
|
67
|
+
sessionId: 's' as never,
|
|
68
|
+
turnId: 't' as never,
|
|
69
|
+
callId: 'c' as never,
|
|
70
|
+
cwd: '/tmp',
|
|
71
|
+
signal: new AbortController().signal,
|
|
72
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
73
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('admin/runtime', () => {
|
|
77
|
+
let home: string;
|
|
78
|
+
const original = process.env.MOXXY_HOME;
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
home = await mkdtemp(join(tmpdir(), 'moxxy-mcp-rt-'));
|
|
82
|
+
process.env.MOXXY_HOME = home;
|
|
83
|
+
hoisted.connectCalls.length = 0;
|
|
84
|
+
hoisted.connectImpl = null;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
if (original === undefined) delete process.env.MOXXY_HOME;
|
|
89
|
+
else process.env.MOXXY_HOME = original;
|
|
90
|
+
await rm(home, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('attachServer (eager)', () => {
|
|
94
|
+
it('throws on a tool-name collision and closes the freshly-opened client', async () => {
|
|
95
|
+
const registry = makeRegistry();
|
|
96
|
+
// Pre-register the name the server would produce.
|
|
97
|
+
registry.register({ name: 'mcp__demo__ping' } as never);
|
|
98
|
+
const client = makeClient();
|
|
99
|
+
hoisted.connectImpl = async () => client;
|
|
100
|
+
const rt = createMcpRuntime(registry);
|
|
101
|
+
await expect(rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' })).rejects.toThrow(
|
|
102
|
+
/tool name collision/,
|
|
103
|
+
);
|
|
104
|
+
// Client must not leak when we bail on collision.
|
|
105
|
+
expect(client.closed).toBe(1);
|
|
106
|
+
// No runtime entry recorded for a failed attach.
|
|
107
|
+
expect(rt.runtimes.has('demo')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('registers wrapped tools and records a runtime handle on success', async () => {
|
|
111
|
+
const registry = makeRegistry();
|
|
112
|
+
const client = makeClient();
|
|
113
|
+
hoisted.connectImpl = async () => client;
|
|
114
|
+
const rt = createMcpRuntime(registry);
|
|
115
|
+
const { toolNames, descriptors } = await rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' });
|
|
116
|
+
expect(toolNames).toEqual(['mcp__demo__ping']);
|
|
117
|
+
expect(descriptors).toEqual([PING]);
|
|
118
|
+
expect(registry.has('mcp__demo__ping')).toBe(true);
|
|
119
|
+
expect(rt.runtimes.get('demo')?.toolNames).toEqual(['mcp__demo__ping']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('closes the client (no registration) when there is no registry', async () => {
|
|
123
|
+
const client = makeClient();
|
|
124
|
+
hoisted.connectImpl = async () => client;
|
|
125
|
+
const rt = createMcpRuntime(null);
|
|
126
|
+
const { toolNames } = await rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' });
|
|
127
|
+
expect(toolNames).toEqual(['mcp__demo__ping']);
|
|
128
|
+
expect(client.closed).toBe(1);
|
|
129
|
+
expect(rt.runtimes.has('demo')).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('attachServerLazy', () => {
|
|
134
|
+
it('registers stubs WITHOUT connecting', () => {
|
|
135
|
+
const registry = makeRegistry();
|
|
136
|
+
const rt = createMcpRuntime(registry);
|
|
137
|
+
const { toolNames } = rt.attachServerLazy(stored());
|
|
138
|
+
expect(toolNames).toEqual(['mcp__demo__ping']);
|
|
139
|
+
expect(registry.has('mcp__demo__ping')).toBe(true);
|
|
140
|
+
// No connection until a tool actually runs.
|
|
141
|
+
expect(hoisted.connectCalls).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('skips servers with no cached tools', () => {
|
|
145
|
+
const registry = makeRegistry();
|
|
146
|
+
const rt = createMcpRuntime(registry);
|
|
147
|
+
const { toolNames } = rt.attachServerLazy(stored({ cachedTools: [] }));
|
|
148
|
+
expect(toolNames).toEqual([]);
|
|
149
|
+
expect(rt.runtimes.has('demo')).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('is idempotent when the server is already attached', () => {
|
|
153
|
+
const registry = makeRegistry();
|
|
154
|
+
const rt = createMcpRuntime(registry);
|
|
155
|
+
rt.attachServerLazy(stored());
|
|
156
|
+
// Second call must not throw a collision against its own prior stubs.
|
|
157
|
+
const { toolNames } = rt.attachServerLazy(stored());
|
|
158
|
+
expect(toolNames).toEqual(['mcp__demo__ping']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('lazy sentinel close() is a no-op until the first call connects', async () => {
|
|
162
|
+
const registry = makeRegistry();
|
|
163
|
+
const rt = createMcpRuntime(registry);
|
|
164
|
+
rt.attachServerLazy(stored());
|
|
165
|
+
const handle = rt.runtimes.get('demo')!;
|
|
166
|
+
// Closing before any tool runs must not connect or throw.
|
|
167
|
+
await handle.client.close();
|
|
168
|
+
expect(hoisted.connectCalls).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('first tool call triggers one shared connection; subsequent calls reuse it', async () => {
|
|
172
|
+
const registry = makeRegistry();
|
|
173
|
+
const client = makeClient();
|
|
174
|
+
hoisted.connectImpl = async () => client;
|
|
175
|
+
const rt = createMcpRuntime(registry);
|
|
176
|
+
rt.attachServerLazy(stored());
|
|
177
|
+
const tool = registry.tools.get('mcp__demo__ping') as {
|
|
178
|
+
handler: (i: unknown, c: unknown) => Promise<unknown>;
|
|
179
|
+
};
|
|
180
|
+
await tool.handler({}, baseCtx());
|
|
181
|
+
await tool.handler({}, baseCtx());
|
|
182
|
+
expect(hoisted.connectCalls).toHaveLength(1);
|
|
183
|
+
// The live client replaced the sentinel on the runtime handle.
|
|
184
|
+
expect(rt.runtimes.get('demo')?.client).toBe(client);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('getOrConnect retries after a failed connect instead of caching the rejection', async () => {
|
|
188
|
+
const registry = makeRegistry();
|
|
189
|
+
const good = makeClient();
|
|
190
|
+
let attempt = 0;
|
|
191
|
+
hoisted.connectImpl = async () => {
|
|
192
|
+
attempt++;
|
|
193
|
+
if (attempt === 1) throw new Error('connect boom');
|
|
194
|
+
return good;
|
|
195
|
+
};
|
|
196
|
+
const rt = createMcpRuntime(registry);
|
|
197
|
+
rt.attachServerLazy(stored());
|
|
198
|
+
const tool = registry.tools.get('mcp__demo__ping') as {
|
|
199
|
+
handler: (i: unknown, c: unknown) => Promise<unknown>;
|
|
200
|
+
};
|
|
201
|
+
// First call fails...
|
|
202
|
+
await expect(tool.handler({}, baseCtx())).rejects.toThrow(/connect boom/);
|
|
203
|
+
// ...but the connect promise was reset, so a retry succeeds.
|
|
204
|
+
const out = await tool.handler({}, baseCtx());
|
|
205
|
+
expect(out).toBe('pong ping');
|
|
206
|
+
expect(attempt).toBe(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('throws and does not register on a cross-server name collision', () => {
|
|
210
|
+
const registry = makeRegistry();
|
|
211
|
+
registry.register({ name: 'mcp__demo__ping' } as never);
|
|
212
|
+
const rt = createMcpRuntime(registry);
|
|
213
|
+
expect(() => rt.attachServerLazy(stored())).toThrow(/tool name collision/);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('refreshServerCache', () => {
|
|
218
|
+
it('connects, persists discovered descriptors, and closes the client', async () => {
|
|
219
|
+
await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'demo', command: 'noop' }] });
|
|
220
|
+
const client = makeClient();
|
|
221
|
+
hoisted.connectImpl = async () => client;
|
|
222
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
223
|
+
const refreshed = await rt.refreshServerCache(stored({ cachedTools: undefined }));
|
|
224
|
+
expect(refreshed.cachedTools).toEqual([PING]);
|
|
225
|
+
expect(client.closed).toBe(1);
|
|
226
|
+
const persisted = await readMcpConfig();
|
|
227
|
+
expect(persisted.servers[0]?.cachedTools).toEqual([PING]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('rolls back (writes nothing) and still closes the client when listTools fails', async () => {
|
|
231
|
+
await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'demo', command: 'noop' }] });
|
|
232
|
+
const client = makeClient({
|
|
233
|
+
listTools: async () => {
|
|
234
|
+
throw new Error('list boom');
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
hoisted.connectImpl = async () => client;
|
|
238
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
239
|
+
await expect(rt.refreshServerCache(stored({ cachedTools: undefined }))).rejects.toThrow(/list boom/);
|
|
240
|
+
// finally{} must have closed the client even though listTools threw.
|
|
241
|
+
expect(client.closed).toBe(1);
|
|
242
|
+
// No cache written — the on-disk entry is untouched.
|
|
243
|
+
const persisted = await readMcpConfig();
|
|
244
|
+
expect(persisted.servers[0]?.cachedTools).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('detachServer', () => {
|
|
249
|
+
it('unregisters tools, closes the client, and forgets the runtime', async () => {
|
|
250
|
+
const registry = makeRegistry();
|
|
251
|
+
const client = makeClient();
|
|
252
|
+
hoisted.connectImpl = async () => client;
|
|
253
|
+
const rt = createMcpRuntime(registry);
|
|
254
|
+
await rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' });
|
|
255
|
+
expect(await rt.detachServer('demo')).toBe(true);
|
|
256
|
+
expect(registry.has('mcp__demo__ping')).toBe(false);
|
|
257
|
+
expect(rt.runtimes.has('demo')).toBe(false);
|
|
258
|
+
expect(client.closed).toBe(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns false for an unknown server', async () => {
|
|
262
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
263
|
+
expect(await rt.detachServer('ghost')).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('secretResolver (vault placeholders, A43)', () => {
|
|
268
|
+
const fakeResolver = async (value: string): Promise<string> =>
|
|
269
|
+
value.replace(/\$\{vault:([A-Za-z0-9_.-]+)\}/g, (_m, name: string) => `resolved-${name}`);
|
|
270
|
+
|
|
271
|
+
it('attachServer connects with resolved env values but keeps the input untouched', async () => {
|
|
272
|
+
hoisted.connectImpl = async () => makeClient();
|
|
273
|
+
const rt = createMcpRuntime(makeRegistry(), { secretResolver: fakeResolver });
|
|
274
|
+
const server: McpServerConfig = {
|
|
275
|
+
kind: 'stdio',
|
|
276
|
+
name: 'demo',
|
|
277
|
+
command: 'x',
|
|
278
|
+
env: { API_KEY: '${vault:demo_key}', PLAIN: 'literal' },
|
|
279
|
+
};
|
|
280
|
+
await rt.attachServer(server);
|
|
281
|
+
const connected = hoisted.connectCalls[0] as Extract<McpServerConfig, { command: string }>;
|
|
282
|
+
// Connect path sees the plaintext…
|
|
283
|
+
expect(connected.env).toEqual({ API_KEY: 'resolved-demo_key', PLAIN: 'literal' });
|
|
284
|
+
// …while the caller-held config object keeps the placeholder.
|
|
285
|
+
expect(server.env).toEqual({ API_KEY: '${vault:demo_key}', PLAIN: 'literal' });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('refreshServerCache persists the PLACEHOLDER, never the resolved plaintext', async () => {
|
|
289
|
+
await writeMcpConfig({
|
|
290
|
+
servers: [
|
|
291
|
+
{ kind: 'http', name: 'demo', url: 'https://mcp.example.com', headers: { authorization: 'Bearer ${vault:demo_token}' } },
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
hoisted.connectImpl = async () => makeClient();
|
|
295
|
+
const rt = createMcpRuntime(makeRegistry(), { secretResolver: fakeResolver });
|
|
296
|
+
await rt.refreshServerCache(
|
|
297
|
+
{ kind: 'http', name: 'demo', url: 'https://mcp.example.com', headers: { authorization: 'Bearer ${vault:demo_token}' } },
|
|
298
|
+
);
|
|
299
|
+
// Connect saw the resolved header.
|
|
300
|
+
const connected = hoisted.connectCalls[0] as Extract<McpServerConfig, { url: string }>;
|
|
301
|
+
expect(connected.headers).toEqual({ authorization: 'Bearer resolved-demo_token' });
|
|
302
|
+
// Disk keeps the placeholder.
|
|
303
|
+
const raw = JSON.stringify(await readMcpConfig());
|
|
304
|
+
expect(raw).toContain('${vault:demo_token}');
|
|
305
|
+
expect(raw).not.toContain('resolved-demo_token');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('lazy attach resolves at first-call connect time', async () => {
|
|
309
|
+
hoisted.connectImpl = async () => makeClient();
|
|
310
|
+
const registry = makeRegistry();
|
|
311
|
+
const rt = createMcpRuntime(registry, { secretResolver: fakeResolver });
|
|
312
|
+
rt.attachServerLazy(stored({ env: { TOKEN: '${vault:t}' } } as Partial<McpStoredServer>));
|
|
313
|
+
// No connection yet — lazy stubs only.
|
|
314
|
+
expect(hoisted.connectCalls).toHaveLength(0);
|
|
315
|
+
const tool = registry.tools.get('mcp__demo__ping') as { handler: (i: unknown, c: unknown) => Promise<unknown> };
|
|
316
|
+
await tool.handler({}, baseCtx());
|
|
317
|
+
const connected = hoisted.connectCalls[0] as Extract<McpServerConfig, { command: string }>;
|
|
318
|
+
expect(connected.env).toEqual({ TOKEN: 'resolved-t' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('passes literals through unchanged when no resolver is wired (back-compat)', async () => {
|
|
322
|
+
hoisted.connectImpl = async () => makeClient();
|
|
323
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
324
|
+
const server: McpServerConfig = { kind: 'stdio', name: 'demo', command: 'x', env: { KEY: 'plain-secret' } };
|
|
325
|
+
await rt.attachServer(server);
|
|
326
|
+
const connected = hoisted.connectCalls[0] as Extract<McpServerConfig, { command: string }>;
|
|
327
|
+
expect(connected.env).toEqual({ KEY: 'plain-secret' });
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('eager attach lists tools exactly once (no double round-trip)', () => {
|
|
332
|
+
it('calls listTools a single time even though it also wraps the tools', async () => {
|
|
333
|
+
const registry = makeRegistry();
|
|
334
|
+
let listCalls = 0;
|
|
335
|
+
const client = makeClient({
|
|
336
|
+
listTools: async () => {
|
|
337
|
+
listCalls++;
|
|
338
|
+
return { tools: [PING] };
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
hoisted.connectImpl = async () => client;
|
|
342
|
+
const rt = createMcpRuntime(registry);
|
|
343
|
+
await rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' });
|
|
344
|
+
expect(listCalls).toBe(1);
|
|
345
|
+
expect(registry.has('mcp__demo__ping')).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('boot-time connect is bounded by a timeout', () => {
|
|
350
|
+
afterEach(() => {
|
|
351
|
+
vi.useRealTimers();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('attachServer (eager add path) rejects when connect never resolves', async () => {
|
|
355
|
+
vi.useFakeTimers();
|
|
356
|
+
// The eager add path drives `mcp_add_server` / `enableAndAttach`; a
|
|
357
|
+
// wedged spawn here would hang the tool call forever without a bound.
|
|
358
|
+
hoisted.connectImpl = () => new Promise<McpClientLike>(() => {});
|
|
359
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
360
|
+
const promise = rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' });
|
|
361
|
+
const assertion = expect(promise).rejects.toThrow(/timed out/);
|
|
362
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
363
|
+
await assertion;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('attachServer rejects when listTools never resolves and closes the opened client', async () => {
|
|
367
|
+
vi.useFakeTimers();
|
|
368
|
+
const client = makeClient({
|
|
369
|
+
listTools: () => new Promise<{ tools: ReadonlyArray<McpToolDescriptor> }>(() => {}),
|
|
370
|
+
});
|
|
371
|
+
hoisted.connectImpl = async () => client;
|
|
372
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
373
|
+
const promise = rt.attachServer({ kind: 'stdio', name: 'demo', command: 'x' });
|
|
374
|
+
const assertion = expect(promise).rejects.toThrow(/timed out/);
|
|
375
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
376
|
+
await assertion;
|
|
377
|
+
// A connected-but-mute server must not leak its child process / socket
|
|
378
|
+
// when discovery times out.
|
|
379
|
+
expect(client.closed).toBe(1);
|
|
380
|
+
// Nothing got registered for the failed attach.
|
|
381
|
+
expect(rt.runtimes.has('demo')).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('refreshServerCache rejects (does not hang) when connect never resolves', async () => {
|
|
385
|
+
vi.useFakeTimers();
|
|
386
|
+
// connect hangs forever — without the bounded timeout this would wedge
|
|
387
|
+
// session boot (core awaits onInit serially).
|
|
388
|
+
hoisted.connectImpl = () => new Promise<McpClientLike>(() => {});
|
|
389
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
390
|
+
const promise = rt.refreshServerCache(stored({ cachedTools: undefined }));
|
|
391
|
+
const assertion = expect(promise).rejects.toThrow(/timed out/);
|
|
392
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
393
|
+
await assertion;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('refreshServerCache rejects when listTools never resolves but still closes the client', async () => {
|
|
397
|
+
vi.useFakeTimers();
|
|
398
|
+
const client = makeClient({
|
|
399
|
+
listTools: () => new Promise<{ tools: ReadonlyArray<McpToolDescriptor> }>(() => {}),
|
|
400
|
+
});
|
|
401
|
+
hoisted.connectImpl = async () => client;
|
|
402
|
+
const rt = createMcpRuntime(makeRegistry());
|
|
403
|
+
const promise = rt.refreshServerCache(stored({ cachedTools: undefined }));
|
|
404
|
+
const assertion = expect(promise).rejects.toThrow(/timed out/);
|
|
405
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
406
|
+
await assertion;
|
|
407
|
+
expect(client.closed).toBe(1);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('lazy first-call connect rejects on timeout and retries cleanly afterward', async () => {
|
|
411
|
+
vi.useFakeTimers();
|
|
412
|
+
let attempt = 0;
|
|
413
|
+
const good = makeClient();
|
|
414
|
+
hoisted.connectImpl = () => {
|
|
415
|
+
attempt++;
|
|
416
|
+
if (attempt === 1) return new Promise<McpClientLike>(() => {}); // hangs
|
|
417
|
+
return Promise.resolve(good);
|
|
418
|
+
};
|
|
419
|
+
const registry = makeRegistry();
|
|
420
|
+
const rt = createMcpRuntime(registry);
|
|
421
|
+
rt.attachServerLazy(stored());
|
|
422
|
+
const tool = registry.tools.get('mcp__demo__ping') as {
|
|
423
|
+
handler: (i: unknown, c: unknown) => Promise<unknown>;
|
|
424
|
+
};
|
|
425
|
+
const first = tool.handler({}, baseCtx());
|
|
426
|
+
const assertion = expect(first).rejects.toThrow(/timed out/);
|
|
427
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
428
|
+
await assertion;
|
|
429
|
+
// The rejected connect promise was reset; a retry connects to the good client.
|
|
430
|
+
vi.useRealTimers();
|
|
431
|
+
const out = await tool.handler({}, baseCtx());
|
|
432
|
+
expect(out).toBe('pong ping');
|
|
433
|
+
expect(attempt).toBe(2);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe('lazy connect racing detachServer does not leak the client', () => {
|
|
438
|
+
it('closes the freshly-opened client when the server was detached mid-connect', async () => {
|
|
439
|
+
const registry = makeRegistry();
|
|
440
|
+
const client = makeClient();
|
|
441
|
+
let releaseConnect: (() => void) | null = null;
|
|
442
|
+
// Hold the connect open until the test releases it, so we can detach
|
|
443
|
+
// the server while the connect is still in flight.
|
|
444
|
+
hoisted.connectImpl = () =>
|
|
445
|
+
new Promise<McpClientLike>((resolve) => {
|
|
446
|
+
releaseConnect = () => resolve(client);
|
|
447
|
+
});
|
|
448
|
+
const rt = createMcpRuntime(registry);
|
|
449
|
+
rt.attachServerLazy(stored());
|
|
450
|
+
const tool = registry.tools.get('mcp__demo__ping') as {
|
|
451
|
+
handler: (i: unknown, c: unknown) => Promise<unknown>;
|
|
452
|
+
};
|
|
453
|
+
const callPromise = tool.handler({}, baseCtx());
|
|
454
|
+
// Wait a tick so getOrConnect has entered the connect.
|
|
455
|
+
await Promise.resolve();
|
|
456
|
+
// Detach while the connect is in flight — the runtime entry is removed.
|
|
457
|
+
// detachServer awaits the (lazy sentinel) client.close(), which itself
|
|
458
|
+
// waits on the in-flight connectPromise, so don't await it before the
|
|
459
|
+
// connect is released or the test deadlocks.
|
|
460
|
+
const detachPromise = rt.detachServer('demo');
|
|
461
|
+
// Now let the connect resolve. The freshly-opened client has nowhere to
|
|
462
|
+
// live (entry gone, shutdown loop won't see it) so it must be closed here.
|
|
463
|
+
releaseConnect!();
|
|
464
|
+
await expect(callPromise).rejects.toThrow(/detached during connect/);
|
|
465
|
+
await detachPromise;
|
|
466
|
+
expect(client.closed).toBe(1);
|
|
467
|
+
expect(rt.runtimes.has('demo')).toBe(false);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
});
|