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,964 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createProxyServer, startHttpProxy } from '../core.js';
|
|
3
|
+
import type {
|
|
4
|
+
PromptMiddleware,
|
|
5
|
+
ResourceMiddleware,
|
|
6
|
+
ToolMiddleware,
|
|
7
|
+
} from '../core.js';
|
|
8
|
+
import {
|
|
9
|
+
DELEGATION_META_KEY,
|
|
10
|
+
detectDelegationLoop,
|
|
11
|
+
serializeDelegationChain,
|
|
12
|
+
} from '../delegation.js';
|
|
13
|
+
import type { Identity } from '../identity.js';
|
|
14
|
+
import { markPassThroughObserver } from '../middleware.js';
|
|
15
|
+
import { createProxyContext, type ProxyContext } from '../proxyContext.js';
|
|
16
|
+
import type { BackendClient } from '../backendClient.js';
|
|
17
|
+
import { getPort, closeServer } from './_helpers.js';
|
|
18
|
+
|
|
19
|
+
// ── Test helpers ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const AGENT_A = { sub: 'agent-a', type: 'agent' } as const;
|
|
22
|
+
|
|
23
|
+
function wire(chain: ReadonlyArray<Record<string, unknown>>): {
|
|
24
|
+
v: number;
|
|
25
|
+
chain: ReadonlyArray<Record<string, unknown>>;
|
|
26
|
+
} {
|
|
27
|
+
return { v: 1, chain };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function metaWith(payload: unknown): Record<string, unknown> {
|
|
31
|
+
return { progressToken: 7, [DELEGATION_META_KEY]: payload };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeMockBackend(): BackendClient {
|
|
35
|
+
return {
|
|
36
|
+
getServerCapabilities: vi
|
|
37
|
+
.fn()
|
|
38
|
+
.mockReturnValue({ tools: {}, resources: {}, prompts: {} }),
|
|
39
|
+
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
40
|
+
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
|
41
|
+
listResources: vi.fn().mockResolvedValue({ resources: [] }),
|
|
42
|
+
readResource: vi.fn().mockResolvedValue({ contents: [] }),
|
|
43
|
+
listPrompts: vi.fn().mockResolvedValue({ prompts: [] }),
|
|
44
|
+
getPrompt: vi.fn().mockResolvedValue({ messages: [] }),
|
|
45
|
+
setNotificationHandler: vi.fn(),
|
|
46
|
+
removeNotificationHandler: vi.fn(),
|
|
47
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
} as unknown as BackendClient;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Invokes a registered handler directly — the stdio path, no transport. */
|
|
52
|
+
async function invokeHandler(
|
|
53
|
+
server: ReturnType<typeof createProxyServer>,
|
|
54
|
+
method: string,
|
|
55
|
+
params: Record<string, unknown> = {},
|
|
56
|
+
): Promise<unknown> {
|
|
57
|
+
const { _requestHandlers: handlers } = server as unknown as {
|
|
58
|
+
_requestHandlers: Map<
|
|
59
|
+
string,
|
|
60
|
+
(
|
|
61
|
+
req: { method: string; params: Record<string, unknown> },
|
|
62
|
+
extra: object,
|
|
63
|
+
) => Promise<unknown>
|
|
64
|
+
>;
|
|
65
|
+
};
|
|
66
|
+
const handler = handlers.get(method);
|
|
67
|
+
if (!handler) throw new Error(`No handler registered for method: ${method}`);
|
|
68
|
+
return handler({ method, params }, {});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function forwardedParams(mock: unknown): Record<string, unknown> {
|
|
72
|
+
return (mock as { mock: { calls: [Record<string, unknown>][] } }).mock
|
|
73
|
+
.calls[0]![0];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function forwardedChain(mock: unknown): unknown {
|
|
77
|
+
const meta = forwardedParams(mock)._meta as
|
|
78
|
+
Record<string, unknown> | undefined;
|
|
79
|
+
return meta?.[DELEGATION_META_KEY];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PROXY_B_CALLER: Identity = {
|
|
83
|
+
sub: 'proxy-b-caller',
|
|
84
|
+
type: 'agent',
|
|
85
|
+
roles: ['reader'],
|
|
86
|
+
claims: { tenant: 'bank' },
|
|
87
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
88
|
+
source: 'jwt',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface JsonRpcResponse {
|
|
92
|
+
result?: unknown;
|
|
93
|
+
error?: {
|
|
94
|
+
code: number;
|
|
95
|
+
message: string;
|
|
96
|
+
data?: { rejectionReason?: string };
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Starts an HTTP proxy resolving `identity` for every session, initializes one
|
|
102
|
+
* session, and returns a `call` for JSON-RPC requests on it. The HTTP path is
|
|
103
|
+
* the only one that resolves an identity, which the loop check needs.
|
|
104
|
+
*/
|
|
105
|
+
async function startProxyWithIdentity(
|
|
106
|
+
backend: BackendClient,
|
|
107
|
+
identity: Identity | undefined,
|
|
108
|
+
options: Partial<Parameters<typeof createProxyServer>[1]> = {},
|
|
109
|
+
): Promise<{
|
|
110
|
+
call: (
|
|
111
|
+
method: string,
|
|
112
|
+
params: Record<string, unknown>,
|
|
113
|
+
) => Promise<JsonRpcResponse>;
|
|
114
|
+
close: () => Promise<void>;
|
|
115
|
+
}> {
|
|
116
|
+
const server = await startHttpProxy(
|
|
117
|
+
backend,
|
|
118
|
+
{ name: 'test-server', ...options },
|
|
119
|
+
{
|
|
120
|
+
port: 0,
|
|
121
|
+
path: '/mcp',
|
|
122
|
+
...(identity === undefined ? {} : { resolveIdentity: () => identity }),
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
const url = `http://localhost:${getPort(server)}/mcp`;
|
|
126
|
+
const headers = {
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
Accept: 'application/json, text/event-stream',
|
|
129
|
+
};
|
|
130
|
+
const post = (body: unknown, extra: Record<string, string> = {}) =>
|
|
131
|
+
fetch(url, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { ...headers, ...extra },
|
|
134
|
+
body: JSON.stringify(body),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const init = await post({
|
|
138
|
+
jsonrpc: '2.0',
|
|
139
|
+
id: 1,
|
|
140
|
+
method: 'initialize',
|
|
141
|
+
params: {
|
|
142
|
+
protocolVersion: '2024-11-05',
|
|
143
|
+
capabilities: {},
|
|
144
|
+
clientInfo: { name: 'proxy-a', version: '0.0.1' },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const sessionHeaders = {
|
|
148
|
+
'mcp-session-id': init.headers.get('mcp-session-id')!,
|
|
149
|
+
};
|
|
150
|
+
await init.text();
|
|
151
|
+
await post(
|
|
152
|
+
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
153
|
+
sessionHeaders,
|
|
154
|
+
).then((r) => r.text());
|
|
155
|
+
|
|
156
|
+
let id = 1;
|
|
157
|
+
return {
|
|
158
|
+
call: async (method, params) => {
|
|
159
|
+
id += 1;
|
|
160
|
+
const res = await post(
|
|
161
|
+
{ jsonrpc: '2.0', id, method, params },
|
|
162
|
+
sessionHeaders,
|
|
163
|
+
);
|
|
164
|
+
const body = await res.text();
|
|
165
|
+
// The transport answers on an SSE stream: one `data:` line per message.
|
|
166
|
+
const payload = body.startsWith('{')
|
|
167
|
+
? body
|
|
168
|
+
: (/^data: (.*)$/m.exec(body)?.[1] ?? '{}');
|
|
169
|
+
return JSON.parse(payload) as JsonRpcResponse;
|
|
170
|
+
},
|
|
171
|
+
close: () => closeServer(server),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Captures the context the innermost middleware saw. */
|
|
176
|
+
function observer(): { middleware: ToolMiddleware; seen: ProxyContext[] } {
|
|
177
|
+
const seen: ProxyContext[] = [];
|
|
178
|
+
const middleware: ToolMiddleware = async (req, next, context) => {
|
|
179
|
+
seen.push(context);
|
|
180
|
+
return next(req);
|
|
181
|
+
};
|
|
182
|
+
return { middleware, seen };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Extraction ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe('delegation extraction', () => {
|
|
188
|
+
it('populates delegatedFrom from params._meta before the strip runs', async () => {
|
|
189
|
+
const backend = makeMockBackend();
|
|
190
|
+
const { middleware, seen } = observer();
|
|
191
|
+
const server = createProxyServer(backend, {
|
|
192
|
+
name: 'test-server',
|
|
193
|
+
toolMiddleware: [middleware],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await invokeHandler(server, 'tools/call', {
|
|
197
|
+
name: 't',
|
|
198
|
+
arguments: {},
|
|
199
|
+
_meta: metaWith(wire([AGENT_A, { sub: 'agent-b', type: 'agent' }])),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(seen[0]?.delegatedFrom?.map((i) => i.sub)).toEqual([
|
|
203
|
+
'agent-a',
|
|
204
|
+
'agent-b',
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('leaves delegatedFrom unset when no payload is present', async () => {
|
|
209
|
+
const backend = makeMockBackend();
|
|
210
|
+
const { middleware, seen } = observer();
|
|
211
|
+
const server = createProxyServer(backend, {
|
|
212
|
+
name: 'test-server',
|
|
213
|
+
toolMiddleware: [middleware],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await invokeHandler(server, 'tools/call', { name: 't', arguments: {} });
|
|
217
|
+
|
|
218
|
+
expect(seen[0]?.delegatedFrom).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('leaves delegatedFrom unset when the presented chain is empty', async () => {
|
|
222
|
+
const backend = makeMockBackend();
|
|
223
|
+
const { middleware, seen } = observer();
|
|
224
|
+
const server = createProxyServer(backend, {
|
|
225
|
+
name: 'test-server',
|
|
226
|
+
toolMiddleware: [middleware],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await invokeHandler(server, 'tools/call', {
|
|
230
|
+
name: 't',
|
|
231
|
+
arguments: {},
|
|
232
|
+
_meta: metaWith(wire([])),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(seen[0]?.delegatedFrom).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('drops roles and claims asserted on the wire', async () => {
|
|
239
|
+
const backend = makeMockBackend();
|
|
240
|
+
const { middleware, seen } = observer();
|
|
241
|
+
const server = createProxyServer(backend, {
|
|
242
|
+
name: 'test-server',
|
|
243
|
+
toolMiddleware: [middleware],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await invokeHandler(server, 'tools/call', {
|
|
247
|
+
name: 't',
|
|
248
|
+
arguments: {},
|
|
249
|
+
_meta: metaWith(
|
|
250
|
+
wire([
|
|
251
|
+
{
|
|
252
|
+
sub: 'agent-a',
|
|
253
|
+
type: 'agent',
|
|
254
|
+
roles: ['admin'],
|
|
255
|
+
claims: { tenant: 'other-bank' },
|
|
256
|
+
},
|
|
257
|
+
]),
|
|
258
|
+
),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const extracted = seen[0]?.delegatedFrom?.[0];
|
|
262
|
+
expect(extracted?.sub).toBe('agent-a');
|
|
263
|
+
expect(extracted?.roles).toEqual([]);
|
|
264
|
+
expect(extracted?.claims).toEqual({});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('keeps a valid source, displayName, and resolvedAt', async () => {
|
|
268
|
+
const backend = makeMockBackend();
|
|
269
|
+
const { middleware, seen } = observer();
|
|
270
|
+
const server = createProxyServer(backend, {
|
|
271
|
+
name: 'test-server',
|
|
272
|
+
toolMiddleware: [middleware],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await invokeHandler(server, 'tools/call', {
|
|
276
|
+
name: 't',
|
|
277
|
+
arguments: {},
|
|
278
|
+
_meta: metaWith(
|
|
279
|
+
wire([
|
|
280
|
+
{
|
|
281
|
+
sub: 'agent-a',
|
|
282
|
+
type: 'agent',
|
|
283
|
+
displayName: 'Agent A',
|
|
284
|
+
source: 'jwt',
|
|
285
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
286
|
+
},
|
|
287
|
+
]),
|
|
288
|
+
),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(seen[0]?.delegatedFrom?.[0]).toMatchObject({
|
|
292
|
+
displayName: 'Agent A',
|
|
293
|
+
source: 'jwt',
|
|
294
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('replaces an unknown source and an unparseable resolvedAt', async () => {
|
|
299
|
+
const backend = makeMockBackend();
|
|
300
|
+
const { middleware, seen } = observer();
|
|
301
|
+
const server = createProxyServer(backend, {
|
|
302
|
+
name: 'test-server',
|
|
303
|
+
toolMiddleware: [middleware],
|
|
304
|
+
});
|
|
305
|
+
const before = new Date().toISOString();
|
|
306
|
+
|
|
307
|
+
await invokeHandler(server, 'tools/call', {
|
|
308
|
+
name: 't',
|
|
309
|
+
arguments: {},
|
|
310
|
+
_meta: metaWith(
|
|
311
|
+
wire([
|
|
312
|
+
{
|
|
313
|
+
sub: 'agent-a',
|
|
314
|
+
type: 'agent',
|
|
315
|
+
displayName: 42,
|
|
316
|
+
source: 'homegrown',
|
|
317
|
+
resolvedAt: 'last tuesday',
|
|
318
|
+
},
|
|
319
|
+
]),
|
|
320
|
+
),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const extracted = seen[0]?.delegatedFrom?.[0];
|
|
324
|
+
expect(extracted?.source).toBe('custom');
|
|
325
|
+
expect(extracted?.displayName).toBeUndefined();
|
|
326
|
+
// The receipt time stands in for a timestamp the proxy cannot read.
|
|
327
|
+
expect(extracted?.resolvedAt.localeCompare(before)).toBeGreaterThanOrEqual(
|
|
328
|
+
0,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it.each([
|
|
333
|
+
['2026-08-29T10:00:00.000Z', true],
|
|
334
|
+
['2026-08-29T10:00:00Z', true],
|
|
335
|
+
['2026-08-29T12:00:00+02:00', true],
|
|
336
|
+
// ISO-shaped but not a real instant, so it is not a timestamp.
|
|
337
|
+
['2026-13-45T10:00:00Z', false],
|
|
338
|
+
['2026-08-29', false],
|
|
339
|
+
['29 August 2026', false],
|
|
340
|
+
] as const)('keeps resolvedAt %s: %s', async (resolvedAt, kept) => {
|
|
341
|
+
const backend = makeMockBackend();
|
|
342
|
+
const { middleware, seen } = observer();
|
|
343
|
+
const server = createProxyServer(backend, {
|
|
344
|
+
name: 'test-server',
|
|
345
|
+
toolMiddleware: [middleware],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await invokeHandler(server, 'tools/call', {
|
|
349
|
+
name: 't',
|
|
350
|
+
arguments: {},
|
|
351
|
+
_meta: metaWith(wire([{ sub: 'agent-a', type: 'agent', resolvedAt }])),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const extracted = seen[0]?.delegatedFrom?.[0];
|
|
355
|
+
expect(extracted?.resolvedAt === resolvedAt).toBe(kept);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('extracts on a resource read and on a prompt get', async () => {
|
|
359
|
+
const backend = makeMockBackend();
|
|
360
|
+
const promptSeen: ProxyContext[] = [];
|
|
361
|
+
const resourceSeen: ProxyContext[] = [];
|
|
362
|
+
const promptObserver: PromptMiddleware = async (req, next, context) => {
|
|
363
|
+
promptSeen.push(context);
|
|
364
|
+
return next(req);
|
|
365
|
+
};
|
|
366
|
+
const resourceObserver: ResourceMiddleware = async (req, next, context) => {
|
|
367
|
+
resourceSeen.push(context);
|
|
368
|
+
return next(req);
|
|
369
|
+
};
|
|
370
|
+
const server = createProxyServer(backend, {
|
|
371
|
+
name: 'test-server',
|
|
372
|
+
resourceMiddleware: [resourceObserver],
|
|
373
|
+
promptMiddleware: [promptObserver],
|
|
374
|
+
});
|
|
375
|
+
const _meta = metaWith(wire([AGENT_A]));
|
|
376
|
+
|
|
377
|
+
await invokeHandler(server, 'resources/read', { uri: 'res://a', _meta });
|
|
378
|
+
await invokeHandler(server, 'prompts/get', { name: 'p', _meta });
|
|
379
|
+
|
|
380
|
+
expect(resourceSeen[0]?.delegatedFrom?.[0]?.sub).toBe('agent-a');
|
|
381
|
+
expect(promptSeen[0]?.delegatedFrom?.[0]?.sub).toBe('agent-a');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('extracts on the mesh tool-call path', async () => {
|
|
385
|
+
const backend = makeMockBackend();
|
|
386
|
+
const { middleware, seen } = observer();
|
|
387
|
+
const server = createProxyServer(
|
|
388
|
+
{ a: backend },
|
|
389
|
+
{ name: 'test-server', toolMiddleware: [middleware] },
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
await invokeHandler(server, 'tools/call', {
|
|
393
|
+
name: 'a__t',
|
|
394
|
+
arguments: {},
|
|
395
|
+
_meta: metaWith(wire([AGENT_A])),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(seen[0]?.delegatedFrom?.[0]?.sub).toBe('agent-a');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('lets a host chain stamped in middleware win downstream', async () => {
|
|
402
|
+
const backend = makeMockBackend();
|
|
403
|
+
const hostChain: Identity[] = [
|
|
404
|
+
{
|
|
405
|
+
sub: 'host-stamped',
|
|
406
|
+
type: 'service',
|
|
407
|
+
roles: [],
|
|
408
|
+
claims: {},
|
|
409
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
410
|
+
source: 'custom',
|
|
411
|
+
},
|
|
412
|
+
];
|
|
413
|
+
const stamp: ToolMiddleware = async (req, next, context) => {
|
|
414
|
+
context.delegatedFrom = hostChain;
|
|
415
|
+
return next(req);
|
|
416
|
+
};
|
|
417
|
+
const { middleware, seen } = observer();
|
|
418
|
+
const server = createProxyServer(backend, {
|
|
419
|
+
name: 'test-server',
|
|
420
|
+
toolMiddleware: [stamp, middleware],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await invokeHandler(server, 'tools/call', {
|
|
424
|
+
name: 't',
|
|
425
|
+
arguments: {},
|
|
426
|
+
_meta: metaWith(wire([AGENT_A])),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(seen[0]?.delegatedFrom).toBe(hostChain);
|
|
430
|
+
expect(forwardedChain(backend.callTool)).toEqual({
|
|
431
|
+
v: 1,
|
|
432
|
+
chain: [
|
|
433
|
+
{
|
|
434
|
+
sub: 'host-stamped',
|
|
435
|
+
type: 'service',
|
|
436
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
437
|
+
source: 'custom',
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ── Malformed payloads ──────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
describe('delegation rejection', () => {
|
|
447
|
+
const malformed: ReadonlyArray<readonly [string, unknown, string]> = [
|
|
448
|
+
['a payload that is not an object', 'agent-a', 'payload is not an object'],
|
|
449
|
+
['an unknown version', { v: 2, chain: [AGENT_A] }, 'unsupported version 2'],
|
|
450
|
+
[
|
|
451
|
+
'a chain that is not an array',
|
|
452
|
+
{ v: 1, chain: { sub: 'agent-a' } },
|
|
453
|
+
'chain is not an array',
|
|
454
|
+
],
|
|
455
|
+
[
|
|
456
|
+
'more than 32 entries',
|
|
457
|
+
wire(
|
|
458
|
+
Array.from({ length: 33 }, (_, i) => ({
|
|
459
|
+
sub: `agent-${i}`,
|
|
460
|
+
type: 'agent',
|
|
461
|
+
})),
|
|
462
|
+
),
|
|
463
|
+
'chain exceeds 32 entries',
|
|
464
|
+
],
|
|
465
|
+
[
|
|
466
|
+
'an entry that is not an object',
|
|
467
|
+
wire(['agent-a' as never]),
|
|
468
|
+
'an entry is not an object',
|
|
469
|
+
],
|
|
470
|
+
['an entry with no sub', wire([{ type: 'agent' }]), 'an entry has no sub'],
|
|
471
|
+
[
|
|
472
|
+
'an entry with an empty sub',
|
|
473
|
+
wire([{ sub: '', type: 'agent' }]),
|
|
474
|
+
'an entry has no sub',
|
|
475
|
+
],
|
|
476
|
+
[
|
|
477
|
+
'an entry with a non-string sub',
|
|
478
|
+
wire([{ sub: 7, type: 'agent' }]),
|
|
479
|
+
'an entry has no sub',
|
|
480
|
+
],
|
|
481
|
+
[
|
|
482
|
+
'an entry with a type outside the union',
|
|
483
|
+
wire([{ sub: 'a', type: 'bot' }]),
|
|
484
|
+
'entry "a" has an unknown type',
|
|
485
|
+
],
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
it.each(malformed)(
|
|
489
|
+
'rejects %s inside the pipeline',
|
|
490
|
+
async (_case, payload, detail) => {
|
|
491
|
+
const backend = makeMockBackend();
|
|
492
|
+
const { middleware, seen } = observer();
|
|
493
|
+
const server = createProxyServer(backend, {
|
|
494
|
+
name: 'test-server',
|
|
495
|
+
toolMiddleware: [middleware],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
await expect(
|
|
499
|
+
invokeHandler(server, 'tools/call', {
|
|
500
|
+
name: 't',
|
|
501
|
+
arguments: {},
|
|
502
|
+
_meta: metaWith(payload),
|
|
503
|
+
}),
|
|
504
|
+
).rejects.toMatchObject({
|
|
505
|
+
code: -32600, // ErrorCode.InvalidRequest
|
|
506
|
+
message: `MCP error -32600: Invalid delegation chain: ${detail}`,
|
|
507
|
+
data: { rejectionReason: 'DELEGATION_INVALID' },
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Observed in-chain, so an audit middleware records the attempt, and
|
|
511
|
+
// the backend never saw the call.
|
|
512
|
+
expect(seen).toHaveLength(1);
|
|
513
|
+
expect(backend.callTool).not.toHaveBeenCalled();
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
it('accepts exactly 32 entries', async () => {
|
|
518
|
+
const backend = makeMockBackend();
|
|
519
|
+
const server = createProxyServer(backend, { name: 'test-server' });
|
|
520
|
+
|
|
521
|
+
await invokeHandler(server, 'tools/call', {
|
|
522
|
+
name: 't',
|
|
523
|
+
arguments: {},
|
|
524
|
+
_meta: metaWith(
|
|
525
|
+
wire(
|
|
526
|
+
Array.from({ length: 32 }, (_, i) => ({
|
|
527
|
+
sub: `agent-${i}`,
|
|
528
|
+
type: 'agent',
|
|
529
|
+
})),
|
|
530
|
+
),
|
|
531
|
+
),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(backend.callTool).toHaveBeenCalledTimes(1);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('rejects a hidden tool call on the malformed chain, not on the tool', async () => {
|
|
538
|
+
const backend = makeMockBackend();
|
|
539
|
+
const server = createProxyServer(backend, {
|
|
540
|
+
name: 'test-server',
|
|
541
|
+
hiddenTools: ['t'],
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
await expect(
|
|
545
|
+
invokeHandler(server, 'tools/call', {
|
|
546
|
+
name: 't',
|
|
547
|
+
arguments: {},
|
|
548
|
+
_meta: metaWith({ v: 9 }),
|
|
549
|
+
}),
|
|
550
|
+
).rejects.toMatchObject({
|
|
551
|
+
data: { rejectionReason: 'DELEGATION_INVALID' },
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it.each([
|
|
556
|
+
['resources/read', { uri: 'res://a' }],
|
|
557
|
+
['prompts/get', { name: 'p' }],
|
|
558
|
+
['tools/list', {}],
|
|
559
|
+
['resources/list', {}],
|
|
560
|
+
['prompts/list', {}],
|
|
561
|
+
] as const)('rejects a malformed chain on %s', async (method, params) => {
|
|
562
|
+
const backend = makeMockBackend();
|
|
563
|
+
const server = createProxyServer(backend, { name: 'test-server' });
|
|
564
|
+
|
|
565
|
+
await expect(
|
|
566
|
+
invokeHandler(server, method, {
|
|
567
|
+
...params,
|
|
568
|
+
_meta: metaWith({ v: 2, chain: [] }),
|
|
569
|
+
}),
|
|
570
|
+
).rejects.toMatchObject({
|
|
571
|
+
data: { rejectionReason: 'DELEGATION_INVALID' },
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('rejects a malformed chain on a pass-through resource', async () => {
|
|
576
|
+
const backend = makeMockBackend();
|
|
577
|
+
const server = createProxyServer(backend, {
|
|
578
|
+
name: 'test-server',
|
|
579
|
+
passThroughResources: ['res://a'],
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await expect(
|
|
583
|
+
invokeHandler(server, 'resources/read', {
|
|
584
|
+
uri: 'res://a',
|
|
585
|
+
_meta: metaWith({ v: 2, chain: [] }),
|
|
586
|
+
}),
|
|
587
|
+
).rejects.toMatchObject({
|
|
588
|
+
data: { rejectionReason: 'DELEGATION_INVALID' },
|
|
589
|
+
});
|
|
590
|
+
expect(backend.readResource).not.toHaveBeenCalled();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('still observes the rejection for a pass-through tool', async () => {
|
|
594
|
+
const backend = makeMockBackend();
|
|
595
|
+
const { middleware, seen } = observer();
|
|
596
|
+
const server = createProxyServer(backend, {
|
|
597
|
+
name: 'test-server',
|
|
598
|
+
passThroughTools: ['t'],
|
|
599
|
+
toolMiddleware: [markPassThroughObserver(middleware)],
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await expect(
|
|
603
|
+
invokeHandler(server, 'tools/call', {
|
|
604
|
+
name: 't',
|
|
605
|
+
arguments: {},
|
|
606
|
+
_meta: metaWith({ v: 2, chain: [] }),
|
|
607
|
+
}),
|
|
608
|
+
).rejects.toMatchObject({
|
|
609
|
+
data: { rejectionReason: 'DELEGATION_INVALID' },
|
|
610
|
+
});
|
|
611
|
+
expect(seen).toHaveLength(1);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ── Outbound re-attachment ──────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
describe('outbound delegation', () => {
|
|
618
|
+
it('re-attaches the chain core can vouch for, not the caller copy', async () => {
|
|
619
|
+
const backend = makeMockBackend();
|
|
620
|
+
const server = createProxyServer(backend, { name: 'test-server' });
|
|
621
|
+
|
|
622
|
+
await invokeHandler(server, 'tools/call', {
|
|
623
|
+
name: 't',
|
|
624
|
+
arguments: {},
|
|
625
|
+
_meta: metaWith(
|
|
626
|
+
wire([{ sub: 'agent-a', type: 'agent', roles: ['admin'] }]),
|
|
627
|
+
),
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const meta = forwardedParams(backend.callTool)._meta as Record<
|
|
631
|
+
string,
|
|
632
|
+
unknown
|
|
633
|
+
>;
|
|
634
|
+
// The inbound strip removed the caller's `_meta`; what the backend sees
|
|
635
|
+
// is core's rebuilt copy and nothing else.
|
|
636
|
+
expect(Object.keys(meta)).toEqual([DELEGATION_META_KEY]);
|
|
637
|
+
expect(meta[DELEGATION_META_KEY]).toMatchObject({
|
|
638
|
+
v: 1,
|
|
639
|
+
chain: [{ sub: 'agent-a', type: 'agent' }],
|
|
640
|
+
});
|
|
641
|
+
expect(
|
|
642
|
+
(meta[DELEGATION_META_KEY] as { chain: Record<string, unknown>[] })
|
|
643
|
+
.chain[0],
|
|
644
|
+
).not.toHaveProperty('roles');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('attaches nothing when there is no chain and no identity', async () => {
|
|
648
|
+
const backend = makeMockBackend();
|
|
649
|
+
const server = createProxyServer(backend, { name: 'test-server' });
|
|
650
|
+
|
|
651
|
+
await invokeHandler(server, 'tools/call', { name: 't', arguments: {} });
|
|
652
|
+
|
|
653
|
+
expect(forwardedParams(backend.callTool)).not.toHaveProperty('_meta');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it.each([
|
|
657
|
+
['resources/read', { uri: 'res://a' }, 'readResource'],
|
|
658
|
+
['prompts/get', { name: 'p' }, 'getPrompt'],
|
|
659
|
+
['tools/list', {}, 'listTools'],
|
|
660
|
+
['resources/list', {}, 'listResources'],
|
|
661
|
+
['prompts/list', {}, 'listPrompts'],
|
|
662
|
+
] as const)('attaches on %s', async (method, params, backendMethod) => {
|
|
663
|
+
const backend = makeMockBackend();
|
|
664
|
+
const server = createProxyServer(backend, { name: 'test-server' });
|
|
665
|
+
|
|
666
|
+
await invokeHandler(server, method, {
|
|
667
|
+
...params,
|
|
668
|
+
_meta: metaWith(wire([AGENT_A])),
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
expect(forwardedChain(backend[backendMethod])).toMatchObject({
|
|
672
|
+
v: 1,
|
|
673
|
+
chain: [{ sub: 'agent-a' }],
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('attaches on both mesh routes', async () => {
|
|
678
|
+
const backend = makeMockBackend();
|
|
679
|
+
const server = createProxyServer({ a: backend }, { name: 'test-server' });
|
|
680
|
+
const _meta = metaWith(wire([AGENT_A]));
|
|
681
|
+
|
|
682
|
+
await invokeHandler(server, 'tools/call', {
|
|
683
|
+
name: 'a__t',
|
|
684
|
+
arguments: {},
|
|
685
|
+
_meta,
|
|
686
|
+
});
|
|
687
|
+
await invokeHandler(server, 'prompts/get', { name: 'a__p', _meta });
|
|
688
|
+
await invokeHandler(server, 'tools/list', { _meta });
|
|
689
|
+
|
|
690
|
+
expect(forwardedChain(backend.callTool)).toMatchObject({
|
|
691
|
+
chain: [{ sub: 'agent-a' }],
|
|
692
|
+
});
|
|
693
|
+
expect(forwardedChain(backend.getPrompt)).toMatchObject({
|
|
694
|
+
chain: [{ sub: 'agent-a' }],
|
|
695
|
+
});
|
|
696
|
+
expect(forwardedChain(backend.listTools)).toMatchObject({
|
|
697
|
+
chain: [{ sub: 'agent-a' }],
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('leaves a local tool handler to attach its own chain', async () => {
|
|
702
|
+
const backend = makeMockBackend();
|
|
703
|
+
const handler = vi.fn().mockResolvedValue({ content: [] });
|
|
704
|
+
const server = createProxyServer(backend, {
|
|
705
|
+
name: 'test-server',
|
|
706
|
+
localTools: [
|
|
707
|
+
{
|
|
708
|
+
tool: { name: 'local', inputSchema: { type: 'object' as const } },
|
|
709
|
+
handler,
|
|
710
|
+
},
|
|
711
|
+
],
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
await invokeHandler(server, 'tools/call', {
|
|
715
|
+
name: 'local',
|
|
716
|
+
arguments: {},
|
|
717
|
+
_meta: metaWith(wire([AGENT_A])),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// The handler receives the chain on the context (ADR-0011); core does
|
|
721
|
+
// not rewrite params it is not forwarding.
|
|
722
|
+
const [params, context] = handler.mock.calls[0] as [
|
|
723
|
+
Record<string, unknown>,
|
|
724
|
+
ProxyContext,
|
|
725
|
+
];
|
|
726
|
+
expect(params).not.toHaveProperty('_meta');
|
|
727
|
+
expect(context.delegatedFrom?.[0]?.sub).toBe('agent-a');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('preserves a _meta key middleware added deliberately', async () => {
|
|
731
|
+
const backend = makeMockBackend();
|
|
732
|
+
const addMeta: ToolMiddleware = async (req, next) =>
|
|
733
|
+
next({
|
|
734
|
+
...req,
|
|
735
|
+
params: { ...req.params, _meta: { tenant: 'bank-pilot' } },
|
|
736
|
+
});
|
|
737
|
+
const server = createProxyServer(backend, {
|
|
738
|
+
name: 'test-server',
|
|
739
|
+
toolMiddleware: [addMeta],
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
await invokeHandler(server, 'tools/call', {
|
|
743
|
+
name: 't',
|
|
744
|
+
arguments: {},
|
|
745
|
+
_meta: metaWith(wire([AGENT_A])),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
expect(forwardedParams(backend.callTool)._meta).toMatchObject({
|
|
749
|
+
tenant: 'bank-pilot',
|
|
750
|
+
[DELEGATION_META_KEY]: { v: 1 },
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// ── The chained-proxy round trip ────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
describe('a proxy chained behind another proxy', () => {
|
|
758
|
+
it('forwards the presented chain extended with the identity it resolved', async () => {
|
|
759
|
+
const backend = makeMockBackend();
|
|
760
|
+
const proxy = await startProxyWithIdentity(backend, PROXY_B_CALLER);
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
await proxy.call('tools/call', {
|
|
764
|
+
name: 't',
|
|
765
|
+
arguments: {},
|
|
766
|
+
_meta: metaWith(wire([AGENT_A])),
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Oldest-first: the chain proxy A presented, extended with the
|
|
770
|
+
// identity this proxy resolved for A itself.
|
|
771
|
+
expect(forwardedChain(backend.callTool)).toMatchObject({
|
|
772
|
+
v: 1,
|
|
773
|
+
chain: [
|
|
774
|
+
{ sub: 'agent-a', type: 'agent', source: 'custom' },
|
|
775
|
+
{
|
|
776
|
+
sub: 'proxy-b-caller',
|
|
777
|
+
type: 'agent',
|
|
778
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
779
|
+
source: 'jwt',
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
});
|
|
783
|
+
// The resolved identity's roles and claims stay local to this proxy.
|
|
784
|
+
const forwarded = forwardedChain(backend.callTool) as {
|
|
785
|
+
chain: Record<string, unknown>[];
|
|
786
|
+
};
|
|
787
|
+
expect(forwarded.chain[1]).not.toHaveProperty('roles');
|
|
788
|
+
expect(forwarded.chain[1]).not.toHaveProperty('claims');
|
|
789
|
+
} finally {
|
|
790
|
+
await proxy.close();
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// ── Loops ───────────────────────────────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
describe('delegation loop rejection', () => {
|
|
798
|
+
it('rejects a chain the resolved caller already appears in, in-pipeline', async () => {
|
|
799
|
+
const backend = makeMockBackend();
|
|
800
|
+
const { middleware, seen } = observer();
|
|
801
|
+
const proxy = await startProxyWithIdentity(backend, PROXY_B_CALLER, {
|
|
802
|
+
toolMiddleware: [middleware],
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const res = await proxy.call('tools/call', {
|
|
807
|
+
name: 't',
|
|
808
|
+
arguments: {},
|
|
809
|
+
_meta: metaWith(
|
|
810
|
+
wire([{ sub: 'proxy-b-caller', type: 'agent' }, AGENT_A]),
|
|
811
|
+
),
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
expect(res.error?.code).toBe(-32600);
|
|
815
|
+
expect(res.error?.message).toContain(
|
|
816
|
+
'Invalid delegation chain: the caller "proxy-b-caller" is already in the chain',
|
|
817
|
+
);
|
|
818
|
+
expect(res.error?.data?.rejectionReason).toBe('DELEGATION_INVALID');
|
|
819
|
+
// Observed in-chain with the identity that tripped it, so the audit
|
|
820
|
+
// trail records the attempt.
|
|
821
|
+
expect(seen).toHaveLength(1);
|
|
822
|
+
expect(seen[0]?.identity?.sub).toBe('proxy-b-caller');
|
|
823
|
+
expect(backend.callTool).not.toHaveBeenCalled();
|
|
824
|
+
} finally {
|
|
825
|
+
await proxy.close();
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('lets a chain the resolved caller is absent from through', async () => {
|
|
830
|
+
const backend = makeMockBackend();
|
|
831
|
+
const proxy = await startProxyWithIdentity(backend, PROXY_B_CALLER);
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
const res = await proxy.call('tools/call', {
|
|
835
|
+
name: 't',
|
|
836
|
+
arguments: {},
|
|
837
|
+
_meta: metaWith(wire([AGENT_A, { sub: 'agent-c', type: 'agent' }])),
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(res.error).toBeUndefined();
|
|
841
|
+
expect(backend.callTool).toHaveBeenCalledTimes(1);
|
|
842
|
+
} finally {
|
|
843
|
+
await proxy.close();
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('rejects a looping chain on a resource read too', async () => {
|
|
848
|
+
const backend = makeMockBackend();
|
|
849
|
+
const proxy = await startProxyWithIdentity(backend, PROXY_B_CALLER);
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
const res = await proxy.call('resources/read', {
|
|
853
|
+
uri: 'res://a',
|
|
854
|
+
_meta: metaWith(wire([{ sub: 'proxy-b-caller', type: 'agent' }])),
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
expect(res.error).toMatchObject({
|
|
858
|
+
data: { rejectionReason: 'DELEGATION_INVALID' },
|
|
859
|
+
});
|
|
860
|
+
expect(backend.readResource).not.toHaveBeenCalled();
|
|
861
|
+
} finally {
|
|
862
|
+
await proxy.close();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('skips the loop check when no identity was resolved', async () => {
|
|
867
|
+
const backend = makeMockBackend();
|
|
868
|
+
const proxy = await startProxyWithIdentity(backend, undefined);
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
// The same chain that loops above: with nothing resolved there is no
|
|
872
|
+
// sub to look for, so only structural validation applies (ADR-0016).
|
|
873
|
+
const res = await proxy.call('tools/call', {
|
|
874
|
+
name: 't',
|
|
875
|
+
arguments: {},
|
|
876
|
+
_meta: metaWith(wire([{ sub: 'proxy-b-caller', type: 'agent' }])),
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
expect(res.error).toBeUndefined();
|
|
880
|
+
expect(backend.callTool).toHaveBeenCalledTimes(1);
|
|
881
|
+
} finally {
|
|
882
|
+
await proxy.close();
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('skips the loop check on stdio', async () => {
|
|
887
|
+
const backend = makeMockBackend();
|
|
888
|
+
const server = createProxyServer(backend, { name: 'test-server' });
|
|
889
|
+
|
|
890
|
+
await invokeHandler(server, 'tools/call', {
|
|
891
|
+
name: 't',
|
|
892
|
+
arguments: {},
|
|
893
|
+
_meta: metaWith(wire([{ sub: 'proxy-b-caller', type: 'agent' }])),
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
expect(backend.callTool).toHaveBeenCalledTimes(1);
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
describe('detectDelegationLoop()', () => {
|
|
901
|
+
const identity: Identity = { ...PROXY_B_CALLER };
|
|
902
|
+
const link = (sub: string): Identity => ({ ...PROXY_B_CALLER, sub });
|
|
903
|
+
|
|
904
|
+
it.each([
|
|
905
|
+
['the caller is in the chain', [link('a'), link('proxy-b-caller')], true],
|
|
906
|
+
['the caller is absent', [link('a'), link('b')], false],
|
|
907
|
+
['the chain is empty', [], false],
|
|
908
|
+
] as const)('%s: %s', (_case, delegatedFrom, looped) => {
|
|
909
|
+
const error = detectDelegationLoop(
|
|
910
|
+
createProxyContext({ identity, delegatedFrom: [...delegatedFrom] }),
|
|
911
|
+
);
|
|
912
|
+
expect(error === undefined).toBe(!looped);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('returns nothing without a resolved identity, chain or not', () => {
|
|
916
|
+
expect(
|
|
917
|
+
detectDelegationLoop(
|
|
918
|
+
createProxyContext({ delegatedFrom: [link('proxy-b-caller')] }),
|
|
919
|
+
),
|
|
920
|
+
).toBeUndefined();
|
|
921
|
+
expect(detectDelegationLoop(createProxyContext())).toBeUndefined();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('returns nothing for an identity with no chain at all', () => {
|
|
925
|
+
expect(
|
|
926
|
+
detectDelegationLoop(createProxyContext({ identity })),
|
|
927
|
+
).toBeUndefined();
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// ── The serializer, for hosts ───────────────────────────────────────────────
|
|
932
|
+
|
|
933
|
+
describe('serializeDelegationChain()', () => {
|
|
934
|
+
it('drops roles and claims', () => {
|
|
935
|
+
expect(
|
|
936
|
+
serializeDelegationChain([
|
|
937
|
+
{
|
|
938
|
+
sub: 'agent-a',
|
|
939
|
+
type: 'agent',
|
|
940
|
+
displayName: 'Agent A',
|
|
941
|
+
roles: ['admin'],
|
|
942
|
+
claims: { tenant: 'bank' },
|
|
943
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
944
|
+
source: 'jwt',
|
|
945
|
+
},
|
|
946
|
+
]),
|
|
947
|
+
).toEqual({
|
|
948
|
+
v: 1,
|
|
949
|
+
chain: [
|
|
950
|
+
{
|
|
951
|
+
sub: 'agent-a',
|
|
952
|
+
type: 'agent',
|
|
953
|
+
displayName: 'Agent A',
|
|
954
|
+
resolvedAt: '2026-08-29T10:00:00.000Z',
|
|
955
|
+
source: 'jwt',
|
|
956
|
+
},
|
|
957
|
+
],
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('serializes an empty chain to an empty wire chain', () => {
|
|
962
|
+
expect(serializeDelegationChain([])).toEqual({ v: 1, chain: [] });
|
|
963
|
+
});
|
|
964
|
+
});
|