mcpose 2.1.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +461 -62
- package/dist/backendClient.d.ts +1 -1
- package/dist/backendClient.d.ts.map +1 -1
- package/dist/backendClient.js +6 -1
- package/dist/backendClient.js.map +1 -1
- package/dist/core.d.ts +309 -35
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +1023 -186
- package/dist/core.js.map +1 -1
- package/dist/delegation.d.ts +89 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +175 -0
- package/dist/delegation.js.map +1 -0
- package/dist/eventStore.d.ts +12 -0
- package/dist/eventStore.d.ts.map +1 -1
- package/dist/eventStore.js +43 -5
- package/dist/eventStore.js.map +1 -1
- package/dist/hiddenTools.d.ts +42 -0
- package/dist/hiddenTools.d.ts.map +1 -0
- package/dist/hiddenTools.js +53 -0
- package/dist/hiddenTools.js.map +1 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/mesh.d.ts +82 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +141 -0
- package/dist/mesh.js.map +1 -0
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +27 -3
- package/dist/middleware.js.map +1 -1
- package/dist/proxyContext.d.ts +65 -5
- package/dist/proxyContext.d.ts.map +1 -1
- package/dist/proxyContext.js +39 -4
- package/dist/proxyContext.js.map +1 -1
- package/dist/rejection.d.ts +7 -1
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +24 -1
- package/dist/rejection.js.map +1 -1
- package/dist/sanitizeCatalog.d.ts +37 -0
- package/dist/sanitizeCatalog.d.ts.map +1 -0
- package/dist/sanitizeCatalog.js +92 -0
- package/dist/sanitizeCatalog.js.map +1 -0
- package/dist/sessionRegistry.d.ts +79 -0
- package/dist/sessionRegistry.d.ts.map +1 -0
- package/dist/sessionRegistry.js +83 -0
- package/dist/sessionRegistry.js.map +1 -0
- package/dist/telemetry.d.ts +29 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/testing.d.ts +18 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +30 -8
- package/dist/testing.js.map +1 -1
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +22 -8
- package/src/__tests__/_helpers.ts +64 -0
- package/src/__tests__/backendClient.test.ts +198 -0
- package/src/__tests__/core.delegation.test.ts +964 -0
- package/src/__tests__/core.edge.test.ts +314 -0
- package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
- package/src/__tests__/core.localTools.test.ts +314 -0
- package/src/__tests__/core.mesh.test.ts +1182 -0
- package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
- package/src/__tests__/core.stripResultMeta.test.ts +217 -0
- package/src/__tests__/core.test.ts +1105 -0
- package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
- package/src/__tests__/eventStore.test.ts +141 -0
- package/src/__tests__/hasToolContent.test.ts +63 -0
- package/src/__tests__/mapToolResult.test.ts +101 -0
- package/src/__tests__/middleware.edge.test.ts +126 -0
- package/src/__tests__/middleware.test.ts +233 -0
- package/src/__tests__/proxyContext.test.ts +140 -0
- package/src/__tests__/sanitizeCatalog.test.ts +380 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +675 -0
- package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
- package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
- package/src/__tests__/startHttpProxy.security.test.ts +288 -0
- package/src/__tests__/startHttpProxy.test.ts +1065 -0
- package/src/__tests__/testing.test.ts +250 -0
- package/src/__tests__/version.test.ts +12 -0
- package/src/backendClient.ts +83 -0
- package/src/core.ts +2129 -0
- package/src/delegation.ts +224 -0
- package/src/eventStore.ts +106 -0
- package/src/hiddenTools.ts +84 -0
- package/src/identity.ts +15 -0
- package/src/index.ts +63 -0
- package/src/mesh.ts +204 -0
- package/src/middleware.ts +101 -0
- package/src/proxyContext.ts +108 -0
- package/src/rejection.ts +62 -0
- package/src/sanitizeCatalog.ts +126 -0
- package/src/sessionRegistry.ts +142 -0
- package/src/telemetry.ts +46 -0
- package/src/testing.ts +174 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
/** Multi-backend (mesh) composition — ADR-0013. */
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
createProxyServer,
|
|
5
|
+
startHttpProxy,
|
|
6
|
+
type PromptMiddleware,
|
|
7
|
+
type ToolMiddleware,
|
|
8
|
+
} from '../core.js';
|
|
9
|
+
import type { BackendClient } from '../backendClient.js';
|
|
10
|
+
import type { TelemetryEvent } from '../telemetry.js';
|
|
11
|
+
import { markPassThroughObserver } from '../middleware.js';
|
|
12
|
+
import {
|
|
13
|
+
ErrorCode,
|
|
14
|
+
PromptListChangedNotificationSchema,
|
|
15
|
+
ToolListChangedNotificationSchema,
|
|
16
|
+
type ServerCapabilities,
|
|
17
|
+
type Tool,
|
|
18
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
19
|
+
|
|
20
|
+
// ── Test helpers ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
type MockBackend = BackendClient & {
|
|
23
|
+
__notificationHandlers: Map<string, () => Promise<void>>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const tool = (name: string): Tool => ({
|
|
27
|
+
name,
|
|
28
|
+
description: name,
|
|
29
|
+
inputSchema: { type: 'object', properties: {} },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function makeMeshBackend(
|
|
33
|
+
toolNames: ReadonlyArray<string>,
|
|
34
|
+
overrides: {
|
|
35
|
+
capabilities?: ServerCapabilities;
|
|
36
|
+
prompts?: ReadonlyArray<string>;
|
|
37
|
+
resources?: ReadonlyArray<string>;
|
|
38
|
+
} = {},
|
|
39
|
+
): MockBackend {
|
|
40
|
+
const notificationHandlers = new Map<string, () => Promise<void>>();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
getServerCapabilities: vi
|
|
44
|
+
.fn<() => ServerCapabilities>()
|
|
45
|
+
.mockReturnValue(overrides.capabilities ?? { tools: {}, prompts: {} }),
|
|
46
|
+
listTools: vi.fn().mockResolvedValue({ tools: toolNames.map(tool) }),
|
|
47
|
+
callTool: vi
|
|
48
|
+
.fn()
|
|
49
|
+
.mockImplementation((params: { name: string }) =>
|
|
50
|
+
Promise.resolve({ content: [{ type: 'text', text: params.name }] }),
|
|
51
|
+
),
|
|
52
|
+
listResources: vi.fn().mockResolvedValue({
|
|
53
|
+
resources: (overrides.resources ?? []).map((uri) => ({
|
|
54
|
+
uri,
|
|
55
|
+
name: uri,
|
|
56
|
+
})),
|
|
57
|
+
}),
|
|
58
|
+
readResource: vi.fn().mockImplementation((params: { uri: string }) =>
|
|
59
|
+
Promise.resolve({
|
|
60
|
+
contents: [{ uri: params.uri, text: params.uri }],
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
listPrompts: vi.fn().mockResolvedValue({
|
|
64
|
+
prompts: (overrides.prompts ?? []).map((name) => ({ name })),
|
|
65
|
+
}),
|
|
66
|
+
getPrompt: vi
|
|
67
|
+
.fn()
|
|
68
|
+
.mockImplementation((params: { name: string }) =>
|
|
69
|
+
Promise.resolve({ messages: [], description: params.name }),
|
|
70
|
+
),
|
|
71
|
+
setNotificationHandler: vi.fn((schema, handler) => {
|
|
72
|
+
const method =
|
|
73
|
+
schema === ToolListChangedNotificationSchema
|
|
74
|
+
? 'notifications/tools/list_changed'
|
|
75
|
+
: schema === PromptListChangedNotificationSchema
|
|
76
|
+
? 'notifications/prompts/list_changed'
|
|
77
|
+
: 'notifications/resources/list_changed';
|
|
78
|
+
notificationHandlers.set(method, handler as () => Promise<void>);
|
|
79
|
+
}),
|
|
80
|
+
removeNotificationHandler: vi.fn(),
|
|
81
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
__notificationHandlers: notificationHandlers,
|
|
83
|
+
} as unknown as MockBackend;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function invokeHandler(
|
|
87
|
+
server: ReturnType<typeof createProxyServer>,
|
|
88
|
+
method: string,
|
|
89
|
+
params: Record<string, unknown> = {},
|
|
90
|
+
extra: object = {},
|
|
91
|
+
) {
|
|
92
|
+
const { _requestHandlers: handlers } = server as unknown as {
|
|
93
|
+
_requestHandlers: Map<
|
|
94
|
+
string,
|
|
95
|
+
(
|
|
96
|
+
req: { method: string; params: Record<string, unknown> },
|
|
97
|
+
extra: object,
|
|
98
|
+
) => Promise<unknown>
|
|
99
|
+
>;
|
|
100
|
+
};
|
|
101
|
+
const handler = handlers.get(method);
|
|
102
|
+
if (!handler) throw new Error(`No handler registered for method: ${method}`);
|
|
103
|
+
return handler({ method, params }, extra);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const listToolNames = async (
|
|
107
|
+
server: ReturnType<typeof createProxyServer>,
|
|
108
|
+
params: Record<string, unknown> = {},
|
|
109
|
+
): Promise<string[]> => {
|
|
110
|
+
const result = (await invokeHandler(server, 'tools/list', params)) as {
|
|
111
|
+
tools: { name: string }[];
|
|
112
|
+
};
|
|
113
|
+
return result.tools.map((t) => t.name);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const advertisedCapabilities = (
|
|
117
|
+
server: ReturnType<typeof createProxyServer>,
|
|
118
|
+
): ServerCapabilities =>
|
|
119
|
+
(server as unknown as { _capabilities: ServerCapabilities })._capabilities;
|
|
120
|
+
|
|
121
|
+
// ── Backend keys ────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('createProxyServer() — backend key validation', () => {
|
|
124
|
+
it('throws on an empty backends record', () => {
|
|
125
|
+
expect(() => createProxyServer({}, { name: 'test-server' })).toThrow(
|
|
126
|
+
/backends record is empty/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws on a key that is not an identifier', () => {
|
|
131
|
+
for (const key of ['crm cache', 'crm/eu', 'crm:1', '-crm', 'crm#']) {
|
|
132
|
+
expect(() =>
|
|
133
|
+
createProxyServer(
|
|
134
|
+
{ [key]: makeMeshBackend(['lookup']) },
|
|
135
|
+
{ name: 'test-server' },
|
|
136
|
+
),
|
|
137
|
+
).toThrow(/must be an identifier/);
|
|
138
|
+
}
|
|
139
|
+
expect(() =>
|
|
140
|
+
createProxyServer(
|
|
141
|
+
{ 'crm_v2.eu-west': makeMeshBackend(['lookup']) },
|
|
142
|
+
{ name: 'test-server' },
|
|
143
|
+
),
|
|
144
|
+
).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('throws on an empty backend key', () => {
|
|
148
|
+
expect(() =>
|
|
149
|
+
createProxyServer({ '': makeMeshBackend([]) }, { name: 'test-server' }),
|
|
150
|
+
).toThrow(/backend key must not be empty/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('throws on a backend key containing the namespace separator', () => {
|
|
154
|
+
expect(() =>
|
|
155
|
+
createProxyServer(
|
|
156
|
+
{ my__crm: makeMeshBackend([]) },
|
|
157
|
+
{ name: 'test-server' },
|
|
158
|
+
),
|
|
159
|
+
).toThrow(/must not contain "__"/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('names the offending backend when one is not connected', () => {
|
|
163
|
+
const disconnected = makeMeshBackend([]);
|
|
164
|
+
vi.mocked(disconnected.getServerCapabilities).mockReturnValue(undefined);
|
|
165
|
+
expect(() =>
|
|
166
|
+
createProxyServer(
|
|
167
|
+
{ crm: makeMeshBackend([]), docs: disconnected },
|
|
168
|
+
{ name: 'test-server' },
|
|
169
|
+
),
|
|
170
|
+
).toThrow(/backend "docs" is not connected/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('validates backend keys at startHttpProxy, before the first session', () => {
|
|
174
|
+
expect(() =>
|
|
175
|
+
startHttpProxy(
|
|
176
|
+
{ a__b: makeMeshBackend([]) },
|
|
177
|
+
{ name: 'test-server' },
|
|
178
|
+
{ port: 0 },
|
|
179
|
+
),
|
|
180
|
+
).toThrow(/must not contain "__"/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('validates the proxy name at startHttpProxy, before the first session', () => {
|
|
184
|
+
// Sessions build their proxy server lazily, so without the startup check
|
|
185
|
+
// a blank name would only surface on the first initialize (#122).
|
|
186
|
+
expect(() =>
|
|
187
|
+
startHttpProxy({ crm: makeMeshBackend([]) }, { name: ' ' }, { port: 0 }),
|
|
188
|
+
).toThrow(/name must be a non-empty string/);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Namespacing and routing ────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe('createProxyServer() — mesh namespacing', () => {
|
|
195
|
+
it('exposes every backend tool under its backend key', async () => {
|
|
196
|
+
const server = createProxyServer(
|
|
197
|
+
{
|
|
198
|
+
crm: makeMeshBackend(['lookup', 'delete_account']),
|
|
199
|
+
docs: makeMeshBackend(['search']),
|
|
200
|
+
},
|
|
201
|
+
{ name: 'test-server' },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(await listToolNames(server)).toEqual([
|
|
205
|
+
'crm__lookup',
|
|
206
|
+
'crm__delete_account',
|
|
207
|
+
'docs__search',
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('routes a call to its backend with the un-namespaced name', async () => {
|
|
212
|
+
const crm = makeMeshBackend(['lookup']);
|
|
213
|
+
const docs = makeMeshBackend(['search']);
|
|
214
|
+
const server = createProxyServer({ crm, docs }, { name: 'test-server' });
|
|
215
|
+
|
|
216
|
+
const result = await invokeHandler(server, 'tools/call', {
|
|
217
|
+
name: 'crm__lookup',
|
|
218
|
+
arguments: { q: 'acme' },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(crm.callTool).toHaveBeenCalledWith(
|
|
222
|
+
{ name: 'lookup', arguments: { q: 'acme' } },
|
|
223
|
+
undefined,
|
|
224
|
+
undefined,
|
|
225
|
+
);
|
|
226
|
+
expect(docs.callTool).not.toHaveBeenCalled();
|
|
227
|
+
expect(result).toMatchObject({ content: [{ text: 'lookup' }] });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('splits on the first separator, so an upstream name may contain one', async () => {
|
|
231
|
+
const crm = makeMeshBackend(['find__by__email']);
|
|
232
|
+
const server = createProxyServer({ crm }, { name: 'test-server' });
|
|
233
|
+
|
|
234
|
+
expect(await listToolNames(server)).toEqual(['crm__find__by__email']);
|
|
235
|
+
await invokeHandler(server, 'tools/call', {
|
|
236
|
+
name: 'crm__find__by__email',
|
|
237
|
+
arguments: {},
|
|
238
|
+
});
|
|
239
|
+
expect(crm.callTool).toHaveBeenCalledWith(
|
|
240
|
+
{ name: 'find__by__email', arguments: {} },
|
|
241
|
+
undefined,
|
|
242
|
+
undefined,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('createProxyServer() — mesh unroutable calls', () => {
|
|
248
|
+
const cases: ReadonlyArray<[string, string]> = [
|
|
249
|
+
['an un-namespaced name', 'lookup'],
|
|
250
|
+
['an unknown backend key', 'billing__lookup'],
|
|
251
|
+
['an empty upstream name', 'crm__'],
|
|
252
|
+
['a leading separator', '__lookup'],
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
for (const [label, name] of cases) {
|
|
256
|
+
it(`rejects ${label} with BACKEND_UNROUTABLE`, async () => {
|
|
257
|
+
const crm = makeMeshBackend(['lookup']);
|
|
258
|
+
const server = createProxyServer({ crm }, { name: 'test-server' });
|
|
259
|
+
|
|
260
|
+
await expect(
|
|
261
|
+
invokeHandler(server, 'tools/call', { name, arguments: {} }),
|
|
262
|
+
).rejects.toMatchObject({
|
|
263
|
+
code: ErrorCode.MethodNotFound,
|
|
264
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
265
|
+
});
|
|
266
|
+
expect(crm.callTool).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
it('rejects a prefix naming a backend without a tools capability', async () => {
|
|
271
|
+
const promptsOnly = makeMeshBackend([], {
|
|
272
|
+
capabilities: { prompts: {} },
|
|
273
|
+
prompts: ['brief'],
|
|
274
|
+
});
|
|
275
|
+
const server = createProxyServer(
|
|
276
|
+
{
|
|
277
|
+
crm: makeMeshBackend(['lookup']),
|
|
278
|
+
wiki: promptsOnly,
|
|
279
|
+
},
|
|
280
|
+
{ name: 'test-server' },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await expect(
|
|
284
|
+
invokeHandler(server, 'tools/call', {
|
|
285
|
+
name: 'wiki__brief',
|
|
286
|
+
arguments: {},
|
|
287
|
+
}),
|
|
288
|
+
).rejects.toMatchObject({
|
|
289
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('lets audit middleware observe the rejection in-chain', async () => {
|
|
294
|
+
const seen: string[] = [];
|
|
295
|
+
const auditMW: ToolMiddleware = async (req, next) => {
|
|
296
|
+
try {
|
|
297
|
+
return await next(req);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
seen.push(`${req.params.name}:${String((err as Error).message)}`);
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const server = createProxyServer(
|
|
304
|
+
{ crm: makeMeshBackend(['lookup']) },
|
|
305
|
+
{ name: 'test-server', toolMiddleware: [auditMW] },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
await expect(
|
|
309
|
+
invokeHandler(server, 'tools/call', { name: 'lookup', arguments: {} }),
|
|
310
|
+
).rejects.toBeDefined();
|
|
311
|
+
expect(seen).toHaveLength(1);
|
|
312
|
+
expect(seen[0]).toContain('lookup:');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('emits rejected telemetry naming the namespaced tool', async () => {
|
|
316
|
+
const events: TelemetryEvent[] = [];
|
|
317
|
+
const server = createProxyServer(
|
|
318
|
+
{ crm: makeMeshBackend(['lookup']) },
|
|
319
|
+
{ name: 'test-server', onTelemetry: (e) => events.push(e) },
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await expect(
|
|
323
|
+
invokeHandler(server, 'tools/call', {
|
|
324
|
+
name: 'billing__charge',
|
|
325
|
+
arguments: {},
|
|
326
|
+
}),
|
|
327
|
+
).rejects.toBeDefined();
|
|
328
|
+
expect(events).toHaveLength(1);
|
|
329
|
+
expect(events[0]).toMatchObject({
|
|
330
|
+
type: 'tool_call',
|
|
331
|
+
tool: 'billing__charge',
|
|
332
|
+
outcome: 'rejected',
|
|
333
|
+
rejectionReason: 'BACKEND_UNROUTABLE',
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── Global config against namespaced names ─────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe('createProxyServer() — mesh configuration is global and namespaced', () => {
|
|
341
|
+
it('hides one tool on one backend by its namespaced name', async () => {
|
|
342
|
+
const server = createProxyServer(
|
|
343
|
+
{
|
|
344
|
+
crm: makeMeshBackend(['lookup', 'delete_account']),
|
|
345
|
+
docs: makeMeshBackend(['delete_account']),
|
|
346
|
+
},
|
|
347
|
+
{ name: 'test-server', hiddenTools: ['crm__delete_account'] },
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
expect(await listToolNames(server)).toEqual([
|
|
351
|
+
'crm__lookup',
|
|
352
|
+
'docs__delete_account',
|
|
353
|
+
]);
|
|
354
|
+
await expect(
|
|
355
|
+
invokeHandler(server, 'tools/call', {
|
|
356
|
+
name: 'crm__delete_account',
|
|
357
|
+
arguments: {},
|
|
358
|
+
}),
|
|
359
|
+
).rejects.toMatchObject({ data: { rejectionReason: 'TOOL_HIDDEN' } });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('gives a hidden-tool predicate the namespaced name, so a prefix hides a backend', async () => {
|
|
363
|
+
const seen: string[] = [];
|
|
364
|
+
const server = createProxyServer(
|
|
365
|
+
{ crm: makeMeshBackend(['lookup']), docs: makeMeshBackend(['search']) },
|
|
366
|
+
{
|
|
367
|
+
name: 'test-server',
|
|
368
|
+
hiddenTools: (name) => {
|
|
369
|
+
seen.push(name);
|
|
370
|
+
return name.startsWith('crm__');
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(await listToolNames(server)).toEqual(['docs__search']);
|
|
376
|
+
expect(seen).toContain('crm__lookup');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('matches passThroughTools against the namespaced name', async () => {
|
|
380
|
+
const transform: ToolMiddleware = async (req, next) => {
|
|
381
|
+
const result = await next(req);
|
|
382
|
+
return { ...result, structuredContent: { transformed: true } };
|
|
383
|
+
};
|
|
384
|
+
const observed: string[] = [];
|
|
385
|
+
const observer = markPassThroughObserver<
|
|
386
|
+
Parameters<ToolMiddleware>[0],
|
|
387
|
+
Awaited<ReturnType<ToolMiddleware>>
|
|
388
|
+
>(async (req, next) => {
|
|
389
|
+
observed.push(req.params.name);
|
|
390
|
+
return next(req);
|
|
391
|
+
});
|
|
392
|
+
const server = createProxyServer(
|
|
393
|
+
{ crm: makeMeshBackend(['lookup']) },
|
|
394
|
+
{
|
|
395
|
+
name: 'test-server',
|
|
396
|
+
toolMiddleware: [transform, observer],
|
|
397
|
+
passThroughTools: ['crm__lookup'],
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const result = await invokeHandler(server, 'tools/call', {
|
|
402
|
+
name: 'crm__lookup',
|
|
403
|
+
arguments: {},
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(result).not.toHaveProperty('structuredContent');
|
|
407
|
+
expect(observed).toEqual(['crm__lookup']);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('shows middleware the namespaced name and runs the pipeline once per call', async () => {
|
|
411
|
+
const seen: string[] = [];
|
|
412
|
+
const mw: ToolMiddleware = async (req, next) => {
|
|
413
|
+
seen.push(req.params.name);
|
|
414
|
+
return next(req);
|
|
415
|
+
};
|
|
416
|
+
const server = createProxyServer(
|
|
417
|
+
{ crm: makeMeshBackend(['lookup']), docs: makeMeshBackend(['search']) },
|
|
418
|
+
{ name: 'test-server', toolMiddleware: [mw] },
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await invokeHandler(server, 'tools/call', {
|
|
422
|
+
name: 'docs__search',
|
|
423
|
+
arguments: {},
|
|
424
|
+
});
|
|
425
|
+
expect(seen).toEqual(['docs__search']);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ── Partial availability ───────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
describe('createProxyServer() — mesh degradation', () => {
|
|
432
|
+
it('lists the live backends and reports the failed one', async () => {
|
|
433
|
+
const crm = makeMeshBackend(['lookup']);
|
|
434
|
+
const docs = makeMeshBackend(['search']);
|
|
435
|
+
vi.mocked(docs.listTools).mockRejectedValue(new Error('upstream down'));
|
|
436
|
+
const events: TelemetryEvent[] = [];
|
|
437
|
+
const server = createProxyServer(
|
|
438
|
+
{ crm, docs },
|
|
439
|
+
{ name: 'test-server', onTelemetry: (e) => events.push(e) },
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
expect(await listToolNames(server)).toEqual(['crm__lookup']);
|
|
443
|
+
expect(events).toHaveLength(1);
|
|
444
|
+
expect(events[0]).toMatchObject({
|
|
445
|
+
type: 'backend_degraded',
|
|
446
|
+
backend: 'docs',
|
|
447
|
+
method: 'tools/list',
|
|
448
|
+
});
|
|
449
|
+
expect((events[0] as { error: Error }).error.message).toBe('upstream down');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('stamps the configured proxy identity onto a backend_degraded event', async () => {
|
|
453
|
+
const crm = makeMeshBackend(['lookup']);
|
|
454
|
+
const docs = makeMeshBackend(['search']);
|
|
455
|
+
vi.mocked(docs.listTools).mockRejectedValue(new Error('upstream down'));
|
|
456
|
+
const events: TelemetryEvent[] = [];
|
|
457
|
+
const server = createProxyServer(
|
|
458
|
+
{ crm, docs },
|
|
459
|
+
{
|
|
460
|
+
onTelemetry: (e) => events.push(e),
|
|
461
|
+
name: 'payments-proxy',
|
|
462
|
+
version: '9.9.9',
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
await listToolNames(server);
|
|
467
|
+
|
|
468
|
+
expect(events).toHaveLength(1);
|
|
469
|
+
expect(events[0]).toMatchObject({
|
|
470
|
+
type: 'backend_degraded',
|
|
471
|
+
proxy: { name: 'payments-proxy', version: '9.9.9' },
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('fails only the call routed to a down backend', async () => {
|
|
476
|
+
const crm = makeMeshBackend(['lookup']);
|
|
477
|
+
const docs = makeMeshBackend(['search']);
|
|
478
|
+
vi.mocked(docs.callTool).mockRejectedValue(new Error('upstream down'));
|
|
479
|
+
const server = createProxyServer({ crm, docs }, { name: 'test-server' });
|
|
480
|
+
|
|
481
|
+
await expect(
|
|
482
|
+
invokeHandler(server, 'tools/call', {
|
|
483
|
+
name: 'docs__search',
|
|
484
|
+
arguments: {},
|
|
485
|
+
}),
|
|
486
|
+
).rejects.toThrow('upstream down');
|
|
487
|
+
await expect(
|
|
488
|
+
invokeHandler(server, 'tools/call', {
|
|
489
|
+
name: 'crm__lookup',
|
|
490
|
+
arguments: {},
|
|
491
|
+
}),
|
|
492
|
+
).resolves.toMatchObject({ content: [{ text: 'lookup' }] });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('degrades silently but completely without an onTelemetry sink', async () => {
|
|
496
|
+
const docs = makeMeshBackend(['search']);
|
|
497
|
+
vi.mocked(docs.listTools).mockRejectedValue(new Error('upstream down'));
|
|
498
|
+
const server = createProxyServer(
|
|
499
|
+
{
|
|
500
|
+
crm: makeMeshBackend(['lookup']),
|
|
501
|
+
docs,
|
|
502
|
+
},
|
|
503
|
+
{ name: 'test-server' },
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
expect(await listToolNames(server)).toEqual(['crm__lookup']);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('never fails the list when the telemetry sink throws', async () => {
|
|
510
|
+
const consoleSpy = vi
|
|
511
|
+
.spyOn(console, 'error')
|
|
512
|
+
.mockImplementation(() => undefined);
|
|
513
|
+
const docs = makeMeshBackend(['search']);
|
|
514
|
+
vi.mocked(docs.listTools).mockRejectedValue(new Error('upstream down'));
|
|
515
|
+
const server = createProxyServer(
|
|
516
|
+
{ crm: makeMeshBackend(['lookup']), docs },
|
|
517
|
+
{
|
|
518
|
+
name: 'test-server',
|
|
519
|
+
onTelemetry: () => {
|
|
520
|
+
throw new Error('sink down');
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(await listToolNames(server)).toEqual(['crm__lookup']);
|
|
526
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
527
|
+
consoleSpy.mockRestore();
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ── Pagination ──────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
describe('createProxyServer() — mesh listing is unpaginated', () => {
|
|
534
|
+
it('drains every backend page into one complete response', async () => {
|
|
535
|
+
const crm = makeMeshBackend([]);
|
|
536
|
+
vi.mocked(crm.listTools).mockImplementation((params) =>
|
|
537
|
+
Promise.resolve(
|
|
538
|
+
params?.cursor === undefined
|
|
539
|
+
? { tools: [tool('a')], nextCursor: 'p2' }
|
|
540
|
+
: { tools: [tool('b')] },
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
const server = createProxyServer(
|
|
544
|
+
{ crm, docs: makeMeshBackend(['c']) },
|
|
545
|
+
{ name: 'test-server' },
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const result = (await invokeHandler(server, 'tools/list')) as {
|
|
549
|
+
tools: { name: string }[];
|
|
550
|
+
nextCursor?: string;
|
|
551
|
+
};
|
|
552
|
+
expect(result.tools.map((t) => t.name)).toEqual([
|
|
553
|
+
'crm__a',
|
|
554
|
+
'crm__b',
|
|
555
|
+
'docs__c',
|
|
556
|
+
]);
|
|
557
|
+
expect(result.nextCursor).toBeUndefined();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('drops and reports a backend that never finishes paginating', async () => {
|
|
561
|
+
const crm = makeMeshBackend([]);
|
|
562
|
+
vi.mocked(crm.listTools).mockResolvedValue({
|
|
563
|
+
tools: [tool('a')],
|
|
564
|
+
nextCursor: 'always-more',
|
|
565
|
+
});
|
|
566
|
+
const events: TelemetryEvent[] = [];
|
|
567
|
+
const server = createProxyServer(
|
|
568
|
+
{ crm, docs: makeMeshBackend(['c']) },
|
|
569
|
+
{ name: 'test-server', onTelemetry: (e) => events.push(e) },
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
expect(await listToolNames(server)).toEqual(['docs__c']);
|
|
573
|
+
expect(events[0]).toMatchObject({
|
|
574
|
+
type: 'backend_degraded',
|
|
575
|
+
backend: 'crm',
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('keeps local tools on the response whatever cursor the client sent', async () => {
|
|
580
|
+
const server = createProxyServer(
|
|
581
|
+
{ crm: makeMeshBackend(['lookup']) },
|
|
582
|
+
{
|
|
583
|
+
name: 'test-server',
|
|
584
|
+
localTools: [
|
|
585
|
+
{
|
|
586
|
+
tool: tool('why_blocked'),
|
|
587
|
+
handler: () => Promise.resolve({ content: [] }),
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
expect(await listToolNames(server, { cursor: 'stale' })).toEqual([
|
|
594
|
+
'crm__lookup',
|
|
595
|
+
'why_blocked',
|
|
596
|
+
]);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ── Capability union ────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
describe('createProxyServer() — mesh capability union', () => {
|
|
603
|
+
it('advertises a capability when any backend has it', () => {
|
|
604
|
+
const server = createProxyServer(
|
|
605
|
+
{
|
|
606
|
+
crm: makeMeshBackend(['lookup'], { capabilities: { tools: {} } }),
|
|
607
|
+
wiki: makeMeshBackend([], { capabilities: { prompts: {} } }),
|
|
608
|
+
},
|
|
609
|
+
{ name: 'test-server' },
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
expect(advertisedCapabilities(server)).toEqual({ tools: {}, prompts: {} });
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('advertises listChanged when any backend supports it', () => {
|
|
616
|
+
const server = createProxyServer(
|
|
617
|
+
{
|
|
618
|
+
crm: makeMeshBackend(['lookup'], { capabilities: { tools: {} } }),
|
|
619
|
+
docs: makeMeshBackend(['search'], {
|
|
620
|
+
capabilities: { tools: { listChanged: true } },
|
|
621
|
+
}),
|
|
622
|
+
},
|
|
623
|
+
{ name: 'test-server' },
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
expect(advertisedCapabilities(server)).toEqual({
|
|
627
|
+
tools: { listChanged: true },
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('advertises tools for localTools alone even when no backend has any', async () => {
|
|
632
|
+
const server = createProxyServer(
|
|
633
|
+
{ wiki: makeMeshBackend([], { capabilities: { prompts: {} } }) },
|
|
634
|
+
{
|
|
635
|
+
name: 'test-server',
|
|
636
|
+
localTools: [
|
|
637
|
+
{
|
|
638
|
+
tool: tool('why_blocked'),
|
|
639
|
+
handler: () => Promise.resolve({ content: [] }),
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
expect(advertisedCapabilities(server)).toMatchObject({ tools: {} });
|
|
646
|
+
expect(await listToolNames(server)).toEqual(['why_blocked']);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('advertises resources when any backend serves them', async () => {
|
|
650
|
+
const server = createProxyServer(
|
|
651
|
+
{
|
|
652
|
+
crm: makeMeshBackend(['lookup'], {
|
|
653
|
+
capabilities: { tools: {}, resources: { listChanged: true } },
|
|
654
|
+
}),
|
|
655
|
+
wiki: makeMeshBackend(['search']),
|
|
656
|
+
},
|
|
657
|
+
{ name: 'test-server' },
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
expect(advertisedCapabilities(server).resources).toEqual({
|
|
661
|
+
listChanged: true,
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// ── localTools composition ──────────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
describe('createProxyServer() — mesh localTools composition', () => {
|
|
669
|
+
it('exposes local tools un-namespaced alongside namespaced upstream tools', async () => {
|
|
670
|
+
const server = createProxyServer(
|
|
671
|
+
{ crm: makeMeshBackend(['lookup']) },
|
|
672
|
+
{
|
|
673
|
+
name: 'test-server',
|
|
674
|
+
localTools: [
|
|
675
|
+
{
|
|
676
|
+
tool: tool('why_blocked'),
|
|
677
|
+
handler: () => Promise.resolve({ content: [] }),
|
|
678
|
+
},
|
|
679
|
+
],
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
expect(await listToolNames(server)).toEqual(['crm__lookup', 'why_blocked']);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('shadows a namespaced upstream tool with a local tool of that name', async () => {
|
|
687
|
+
const crm = makeMeshBackend(['lookup']);
|
|
688
|
+
const server = createProxyServer(
|
|
689
|
+
{ crm },
|
|
690
|
+
{
|
|
691
|
+
name: 'test-server',
|
|
692
|
+
localTools: [
|
|
693
|
+
{
|
|
694
|
+
tool: tool('crm__lookup'),
|
|
695
|
+
handler: () =>
|
|
696
|
+
Promise.resolve({ content: [{ type: 'text', text: 'local' }] }),
|
|
697
|
+
},
|
|
698
|
+
],
|
|
699
|
+
},
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
expect(await listToolNames(server)).toEqual(['crm__lookup']);
|
|
703
|
+
await expect(
|
|
704
|
+
invokeHandler(server, 'tools/call', {
|
|
705
|
+
name: 'crm__lookup',
|
|
706
|
+
arguments: {},
|
|
707
|
+
}),
|
|
708
|
+
).resolves.toMatchObject({ content: [{ text: 'local' }] });
|
|
709
|
+
expect(crm.callTool).not.toHaveBeenCalled();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('keeps hiddenTools ahead of a local tool in a mesh', async () => {
|
|
713
|
+
const server = createProxyServer(
|
|
714
|
+
{ crm: makeMeshBackend(['lookup']) },
|
|
715
|
+
{
|
|
716
|
+
name: 'test-server',
|
|
717
|
+
hiddenTools: ['why_blocked'],
|
|
718
|
+
localTools: [
|
|
719
|
+
{
|
|
720
|
+
tool: tool('why_blocked'),
|
|
721
|
+
handler: () => Promise.resolve({ content: [] }),
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
expect(await listToolNames(server)).toEqual(['crm__lookup']);
|
|
728
|
+
await expect(
|
|
729
|
+
invokeHandler(server, 'tools/call', {
|
|
730
|
+
name: 'why_blocked',
|
|
731
|
+
arguments: {},
|
|
732
|
+
}),
|
|
733
|
+
).rejects.toMatchObject({ data: { rejectionReason: 'TOOL_HIDDEN' } });
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ── list-changed fan-in ─────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
describe('createProxyServer() — mesh list-changed fan-in', () => {
|
|
740
|
+
it('forwards a notification from any backend, and unsubscribes from all on close', async () => {
|
|
741
|
+
const crm = makeMeshBackend(['lookup'], {
|
|
742
|
+
capabilities: { tools: { listChanged: true } },
|
|
743
|
+
});
|
|
744
|
+
const docs = makeMeshBackend(['search'], {
|
|
745
|
+
capabilities: { tools: { listChanged: true } },
|
|
746
|
+
});
|
|
747
|
+
const server = createProxyServer({ crm, docs }, { name: 'test-server' });
|
|
748
|
+
const send = vi
|
|
749
|
+
.spyOn(server, 'sendToolListChanged')
|
|
750
|
+
.mockResolvedValue(undefined);
|
|
751
|
+
|
|
752
|
+
await crm.__notificationHandlers.get(
|
|
753
|
+
'notifications/tools/list_changed',
|
|
754
|
+
)?.();
|
|
755
|
+
await docs.__notificationHandlers.get(
|
|
756
|
+
'notifications/tools/list_changed',
|
|
757
|
+
)?.();
|
|
758
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
759
|
+
|
|
760
|
+
server.onclose?.();
|
|
761
|
+
send.mockClear();
|
|
762
|
+
await crm.__notificationHandlers.get(
|
|
763
|
+
'notifications/tools/list_changed',
|
|
764
|
+
)?.();
|
|
765
|
+
await docs.__notificationHandlers.get(
|
|
766
|
+
'notifications/tools/list_changed',
|
|
767
|
+
)?.();
|
|
768
|
+
expect(send).not.toHaveBeenCalled();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('forwards a resource list change from any backend', async () => {
|
|
772
|
+
const crm = makeMeshBackend(['lookup'], {
|
|
773
|
+
capabilities: {
|
|
774
|
+
tools: { listChanged: true },
|
|
775
|
+
resources: { listChanged: true },
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
const mesh = createProxyServer({ crm }, { name: 'test-server' });
|
|
779
|
+
const send = vi
|
|
780
|
+
.spyOn(mesh, 'sendResourceListChanged')
|
|
781
|
+
.mockResolvedValue(undefined);
|
|
782
|
+
|
|
783
|
+
await crm.__notificationHandlers.get(
|
|
784
|
+
'notifications/resources/list_changed',
|
|
785
|
+
)?.();
|
|
786
|
+
|
|
787
|
+
expect(send).toHaveBeenCalledOnce();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('keeps a backend shared with a 1:1 proxy forwarding to both', async () => {
|
|
791
|
+
const shared = makeMeshBackend(['lookup'], {
|
|
792
|
+
capabilities: {
|
|
793
|
+
tools: { listChanged: true },
|
|
794
|
+
resources: { listChanged: true },
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
const mesh = createProxyServer({ crm: shared }, { name: 'test-server' });
|
|
798
|
+
const direct = createProxyServer(shared, { name: 'test-server' });
|
|
799
|
+
const meshSend = vi
|
|
800
|
+
.spyOn(mesh, 'sendResourceListChanged')
|
|
801
|
+
.mockResolvedValue(undefined);
|
|
802
|
+
const directSend = vi
|
|
803
|
+
.spyOn(direct, 'sendResourceListChanged')
|
|
804
|
+
.mockResolvedValue(undefined);
|
|
805
|
+
|
|
806
|
+
await shared.__notificationHandlers.get(
|
|
807
|
+
'notifications/resources/list_changed',
|
|
808
|
+
)?.();
|
|
809
|
+
|
|
810
|
+
expect(directSend).toHaveBeenCalledOnce();
|
|
811
|
+
expect(meshSend).toHaveBeenCalledOnce();
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// ── Resources ───────────────────────────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
describe('createProxyServer() — mesh resources (ADR-0022)', () => {
|
|
818
|
+
const withResources = (uris: ReadonlyArray<string>) =>
|
|
819
|
+
makeMeshBackend([], {
|
|
820
|
+
capabilities: { resources: {} },
|
|
821
|
+
resources: uris,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('exposes every backend resource as mcpose://<key>/<uri>, collisions included', async () => {
|
|
825
|
+
const server = createProxyServer(
|
|
826
|
+
{
|
|
827
|
+
crm: withResources(['file:///notes.md', 'crm://accounts/1']),
|
|
828
|
+
wiki: withResources(['file:///notes.md']),
|
|
829
|
+
},
|
|
830
|
+
{ name: 'test-server' },
|
|
831
|
+
);
|
|
832
|
+
const { resources } = (await invokeHandler(server, 'resources/list')) as {
|
|
833
|
+
resources: { uri: string; name: string }[];
|
|
834
|
+
};
|
|
835
|
+
expect(resources).toEqual([
|
|
836
|
+
{ uri: 'mcpose://crm/file:///notes.md', name: 'file:///notes.md' },
|
|
837
|
+
{ uri: 'mcpose://crm/crm://accounts/1', name: 'crm://accounts/1' },
|
|
838
|
+
{ uri: 'mcpose://wiki/file:///notes.md', name: 'file:///notes.md' },
|
|
839
|
+
]);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('routes resources/read by the mcpose:// prefix and forwards the upstream URI', async () => {
|
|
843
|
+
const crm = withResources(['file:///notes.md']);
|
|
844
|
+
const wiki = withResources(['file:///notes.md']);
|
|
845
|
+
const server = createProxyServer({ crm, wiki }, { name: 'test-server' });
|
|
846
|
+
const result = await invokeHandler(server, 'resources/read', {
|
|
847
|
+
uri: 'mcpose://wiki/file:///notes.md?rev=2#top',
|
|
848
|
+
});
|
|
849
|
+
expect(result).toEqual({
|
|
850
|
+
contents: [
|
|
851
|
+
{
|
|
852
|
+
uri: 'file:///notes.md?rev=2#top',
|
|
853
|
+
text: 'file:///notes.md?rev=2#top',
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
});
|
|
857
|
+
expect(wiki.readResource).toHaveBeenCalledWith(
|
|
858
|
+
{ uri: 'file:///notes.md?rev=2#top' },
|
|
859
|
+
undefined,
|
|
860
|
+
);
|
|
861
|
+
expect(crm.readResource).not.toHaveBeenCalled();
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('rejects an unwrapped, unknown-key, or empty URI with BACKEND_UNROUTABLE', async () => {
|
|
865
|
+
const server = createProxyServer(
|
|
866
|
+
{ crm: withResources(['file:///notes.md']) },
|
|
867
|
+
{ name: 'test-server' },
|
|
868
|
+
);
|
|
869
|
+
for (const uri of [
|
|
870
|
+
'file:///notes.md',
|
|
871
|
+
'mcpose://hr/file:///notes.md',
|
|
872
|
+
'mcpose://crm/',
|
|
873
|
+
'mcpose://crm',
|
|
874
|
+
'mcpose:///file:///notes.md',
|
|
875
|
+
]) {
|
|
876
|
+
const error = await invokeHandler(server, 'resources/read', {
|
|
877
|
+
uri,
|
|
878
|
+
}).catch((err: unknown) => err);
|
|
879
|
+
expect(error).toMatchObject({
|
|
880
|
+
code: ErrorCode.InvalidRequest,
|
|
881
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('rejects a key naming a backend without resources', async () => {
|
|
887
|
+
const server = createProxyServer(
|
|
888
|
+
{
|
|
889
|
+
crm: withResources(['file:///notes.md']),
|
|
890
|
+
tools: makeMeshBackend(['lookup']),
|
|
891
|
+
},
|
|
892
|
+
{ name: 'test-server' },
|
|
893
|
+
);
|
|
894
|
+
await expect(
|
|
895
|
+
invokeHandler(server, 'resources/read', {
|
|
896
|
+
uri: 'mcpose://tools/file:///notes.md',
|
|
897
|
+
}),
|
|
898
|
+
).rejects.toMatchObject({
|
|
899
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('matches hiddenResources and passThroughResources on the exposed URI', async () => {
|
|
904
|
+
const crm = withResources(['file:///a.md', 'file:///b.md']);
|
|
905
|
+
const seen: string[] = [];
|
|
906
|
+
const server = createProxyServer(
|
|
907
|
+
{ crm },
|
|
908
|
+
{
|
|
909
|
+
name: 'test-server',
|
|
910
|
+
hiddenResources: ['mcpose://crm/file:///a.md'],
|
|
911
|
+
passThroughResources: ['mcpose://crm/file:///b.md'],
|
|
912
|
+
resourceMiddleware: [
|
|
913
|
+
(req, next) => {
|
|
914
|
+
seen.push(req.params.uri);
|
|
915
|
+
return next(req);
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
},
|
|
919
|
+
);
|
|
920
|
+
const { resources } = (await invokeHandler(server, 'resources/list')) as {
|
|
921
|
+
resources: { uri: string }[];
|
|
922
|
+
};
|
|
923
|
+
expect(resources.map((r) => r.uri)).toEqual(['mcpose://crm/file:///b.md']);
|
|
924
|
+
|
|
925
|
+
await expect(
|
|
926
|
+
invokeHandler(server, 'resources/read', {
|
|
927
|
+
uri: 'mcpose://crm/file:///a.md',
|
|
928
|
+
}),
|
|
929
|
+
).rejects.toMatchObject({ data: { rejectionReason: 'RESOURCE_HIDDEN' } });
|
|
930
|
+
|
|
931
|
+
await invokeHandler(server, 'resources/read', {
|
|
932
|
+
uri: 'mcpose://crm/file:///b.md',
|
|
933
|
+
});
|
|
934
|
+
expect(seen).toEqual([]);
|
|
935
|
+
expect(crm.readResource).toHaveBeenCalledWith(
|
|
936
|
+
{ uri: 'file:///b.md' },
|
|
937
|
+
undefined,
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('lets middleware re-route a read, and audits the unroutable rejection inside the pipeline', async () => {
|
|
942
|
+
const crm = withResources(['file:///a.md']);
|
|
943
|
+
const wiki = withResources(['file:///a.md']);
|
|
944
|
+
const outcomes: string[] = [];
|
|
945
|
+
const server = createProxyServer(
|
|
946
|
+
{ crm, wiki },
|
|
947
|
+
{
|
|
948
|
+
name: 'test-server',
|
|
949
|
+
resourceMiddleware: [
|
|
950
|
+
async (req, next) => {
|
|
951
|
+
try {
|
|
952
|
+
const result = await next({
|
|
953
|
+
...req,
|
|
954
|
+
params: {
|
|
955
|
+
...req.params,
|
|
956
|
+
uri: req.params.uri.replace(
|
|
957
|
+
'mcpose://crm/',
|
|
958
|
+
'mcpose://wiki/',
|
|
959
|
+
),
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
outcomes.push('ok');
|
|
963
|
+
return result;
|
|
964
|
+
} catch (err) {
|
|
965
|
+
outcomes.push(
|
|
966
|
+
String(
|
|
967
|
+
(err as { data?: { rejectionReason?: string } }).data
|
|
968
|
+
?.rejectionReason,
|
|
969
|
+
),
|
|
970
|
+
);
|
|
971
|
+
throw err;
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
],
|
|
975
|
+
},
|
|
976
|
+
);
|
|
977
|
+
await invokeHandler(server, 'resources/read', {
|
|
978
|
+
uri: 'mcpose://crm/file:///a.md',
|
|
979
|
+
});
|
|
980
|
+
expect(wiki.readResource).toHaveBeenCalledOnce();
|
|
981
|
+
expect(crm.readResource).not.toHaveBeenCalled();
|
|
982
|
+
|
|
983
|
+
await invokeHandler(server, 'resources/read', {
|
|
984
|
+
uri: 'file:///a.md',
|
|
985
|
+
}).catch(() => undefined);
|
|
986
|
+
expect(outcomes).toEqual(['ok', 'BACKEND_UNROUTABLE']);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('degrades resources/list when one backend fails and reports it', async () => {
|
|
990
|
+
const down = withResources([]);
|
|
991
|
+
(down.listResources as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
992
|
+
new Error('boom'),
|
|
993
|
+
);
|
|
994
|
+
const events: TelemetryEvent[] = [];
|
|
995
|
+
const server = createProxyServer(
|
|
996
|
+
{ crm: withResources(['file:///a.md']), down },
|
|
997
|
+
{ name: 'test-server', onTelemetry: (e) => events.push(e) },
|
|
998
|
+
);
|
|
999
|
+
const { resources } = (await invokeHandler(server, 'resources/list')) as {
|
|
1000
|
+
resources: { uri: string }[];
|
|
1001
|
+
};
|
|
1002
|
+
expect(resources.map((r) => r.uri)).toEqual(['mcpose://crm/file:///a.md']);
|
|
1003
|
+
expect(events).toMatchObject([
|
|
1004
|
+
{ type: 'backend_degraded', backend: 'down', method: 'resources/list' },
|
|
1005
|
+
]);
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// ── Prompts ─────────────────────────────────────────────────────────────────
|
|
1010
|
+
|
|
1011
|
+
describe('createProxyServer() — mesh prompts', () => {
|
|
1012
|
+
it('namespaces prompt names and routes prompts/get by prefix', async () => {
|
|
1013
|
+
const wiki = makeMeshBackend([], {
|
|
1014
|
+
capabilities: { prompts: {} },
|
|
1015
|
+
prompts: ['brief'],
|
|
1016
|
+
});
|
|
1017
|
+
const server = createProxyServer(
|
|
1018
|
+
{
|
|
1019
|
+
crm: makeMeshBackend(['lookup']),
|
|
1020
|
+
wiki,
|
|
1021
|
+
},
|
|
1022
|
+
{ name: 'test-server' },
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
const list = (await invokeHandler(server, 'prompts/list')) as {
|
|
1026
|
+
prompts: { name: string }[];
|
|
1027
|
+
};
|
|
1028
|
+
expect(list.prompts.map((p) => p.name)).toEqual(['wiki__brief']);
|
|
1029
|
+
|
|
1030
|
+
await invokeHandler(server, 'prompts/get', { name: 'wiki__brief' });
|
|
1031
|
+
expect(wiki.getPrompt).toHaveBeenCalledWith({ name: 'brief' }, undefined);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('rejects an unroutable prompt name', async () => {
|
|
1035
|
+
const server = createProxyServer(
|
|
1036
|
+
{
|
|
1037
|
+
wiki: makeMeshBackend([], {
|
|
1038
|
+
capabilities: { prompts: {} },
|
|
1039
|
+
prompts: ['brief'],
|
|
1040
|
+
}),
|
|
1041
|
+
},
|
|
1042
|
+
{ name: 'test-server' },
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
await expect(
|
|
1046
|
+
invokeHandler(server, 'prompts/get', { name: 'brief' }),
|
|
1047
|
+
).rejects.toMatchObject({
|
|
1048
|
+
code: ErrorCode.MethodNotFound,
|
|
1049
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('runs promptMiddleware around a routed prompts/get', async () => {
|
|
1054
|
+
const wiki = makeMeshBackend([], {
|
|
1055
|
+
capabilities: { prompts: {} },
|
|
1056
|
+
prompts: ['brief'],
|
|
1057
|
+
});
|
|
1058
|
+
const seen: string[] = [];
|
|
1059
|
+
const mw: PromptMiddleware = async (req, next) => {
|
|
1060
|
+
seen.push(req.params.name);
|
|
1061
|
+
return next(req);
|
|
1062
|
+
};
|
|
1063
|
+
const server = createProxyServer(
|
|
1064
|
+
{ wiki },
|
|
1065
|
+
{ name: 'test-server', promptMiddleware: [mw] },
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
await invokeHandler(server, 'prompts/get', { name: 'wiki__brief' });
|
|
1069
|
+
|
|
1070
|
+
// The middleware sees the namespaced name; routing happens inside the
|
|
1071
|
+
// innermost next, so the backend still receives the bare one.
|
|
1072
|
+
expect(seen).toEqual(['wiki__brief']);
|
|
1073
|
+
expect(wiki.getPrompt).toHaveBeenCalledWith({ name: 'brief' }, undefined);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('forwards a middleware edit to params.arguments to the backend', async () => {
|
|
1077
|
+
const wiki = makeMeshBackend([], {
|
|
1078
|
+
capabilities: { prompts: {} },
|
|
1079
|
+
prompts: ['brief'],
|
|
1080
|
+
});
|
|
1081
|
+
const rewrite: PromptMiddleware = (req, next) =>
|
|
1082
|
+
next({ ...req, params: { ...req.params, arguments: { topic: 'q4' } } });
|
|
1083
|
+
const server = createProxyServer(
|
|
1084
|
+
{ wiki },
|
|
1085
|
+
{ name: 'test-server', promptMiddleware: [rewrite] },
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
await invokeHandler(server, 'prompts/get', {
|
|
1089
|
+
name: 'wiki__brief',
|
|
1090
|
+
arguments: { topic: 'q3' },
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// Routing rebuilds params from the post-pipeline request, so the edit
|
|
1094
|
+
// survives the name rewrite instead of being clobbered by it.
|
|
1095
|
+
expect(wiki.getPrompt).toHaveBeenCalledWith(
|
|
1096
|
+
{ name: 'brief', arguments: { topic: 'q4' } },
|
|
1097
|
+
undefined,
|
|
1098
|
+
);
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it('throws BACKEND_UNROUTABLE inside the pipeline, so middleware observes it', async () => {
|
|
1102
|
+
const observed: unknown[] = [];
|
|
1103
|
+
const observer: PromptMiddleware = async (req, next) => {
|
|
1104
|
+
try {
|
|
1105
|
+
return await next(req);
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
observed.push(err);
|
|
1108
|
+
throw err;
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
const server = createProxyServer(
|
|
1112
|
+
{
|
|
1113
|
+
wiki: makeMeshBackend([], {
|
|
1114
|
+
capabilities: { prompts: {} },
|
|
1115
|
+
prompts: ['brief'],
|
|
1116
|
+
}),
|
|
1117
|
+
},
|
|
1118
|
+
{ name: 'test-server', promptMiddleware: [observer] },
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
await expect(
|
|
1122
|
+
invokeHandler(server, 'prompts/get', { name: 'brief' }),
|
|
1123
|
+
).rejects.toMatchObject({
|
|
1124
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
1125
|
+
});
|
|
1126
|
+
expect(observed).toHaveLength(1);
|
|
1127
|
+
expect(observed[0]).toMatchObject({
|
|
1128
|
+
code: ErrorCode.MethodNotFound,
|
|
1129
|
+
data: { rejectionReason: 'BACKEND_UNROUTABLE' },
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('degrades prompts/list when one backend fails', async () => {
|
|
1134
|
+
const wiki = makeMeshBackend([], {
|
|
1135
|
+
capabilities: { prompts: {} },
|
|
1136
|
+
prompts: ['brief'],
|
|
1137
|
+
});
|
|
1138
|
+
const kb = makeMeshBackend([], { capabilities: { prompts: {} } });
|
|
1139
|
+
vi.mocked(kb.listPrompts).mockRejectedValue(new Error('down'));
|
|
1140
|
+
const events: TelemetryEvent[] = [];
|
|
1141
|
+
const server = createProxyServer(
|
|
1142
|
+
{ wiki, kb },
|
|
1143
|
+
{ name: 'test-server', onTelemetry: (e) => events.push(e) },
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
const list = (await invokeHandler(server, 'prompts/list')) as {
|
|
1147
|
+
prompts: { name: string }[];
|
|
1148
|
+
};
|
|
1149
|
+
expect(list.prompts.map((p) => p.name)).toEqual(['wiki__brief']);
|
|
1150
|
+
expect(events[0]).toMatchObject({
|
|
1151
|
+
type: 'backend_degraded',
|
|
1152
|
+
backend: 'kb',
|
|
1153
|
+
method: 'prompts/list',
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// ── Boundary behaviour carried into mesh mode ──────────────────────────────
|
|
1159
|
+
|
|
1160
|
+
describe('createProxyServer() — mesh boundary behaviour', () => {
|
|
1161
|
+
it('strips request _meta before forwarding and result _meta before returning', async () => {
|
|
1162
|
+
const crm = makeMeshBackend(['lookup']);
|
|
1163
|
+
vi.mocked(crm.callTool).mockResolvedValue({
|
|
1164
|
+
content: [],
|
|
1165
|
+
_meta: { upstream: 'trace' },
|
|
1166
|
+
});
|
|
1167
|
+
const server = createProxyServer({ crm }, { name: 'test-server' });
|
|
1168
|
+
|
|
1169
|
+
const result = await invokeHandler(server, 'tools/call', {
|
|
1170
|
+
name: 'crm__lookup',
|
|
1171
|
+
arguments: {},
|
|
1172
|
+
_meta: { progressToken: 1 },
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
expect(crm.callTool).toHaveBeenCalledWith(
|
|
1176
|
+
{ name: 'lookup', arguments: {} },
|
|
1177
|
+
undefined,
|
|
1178
|
+
undefined,
|
|
1179
|
+
);
|
|
1180
|
+
expect(result).not.toHaveProperty('_meta');
|
|
1181
|
+
});
|
|
1182
|
+
});
|