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,157 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createProxyServer, type LocalTool } from '../core.js';
|
|
3
|
+
import type { BackendClient } from '../backendClient.js';
|
|
4
|
+
import type { TelemetryEvent } from '../telemetry.js';
|
|
5
|
+
|
|
6
|
+
/** Invokes a registered handler directly via `_requestHandlers` — no transport needed. */
|
|
7
|
+
async function invokeHandler(
|
|
8
|
+
server: ReturnType<typeof createProxyServer>,
|
|
9
|
+
method: string,
|
|
10
|
+
params: Record<string, unknown> = {},
|
|
11
|
+
) {
|
|
12
|
+
const { _requestHandlers: handlers } = server as unknown as {
|
|
13
|
+
_requestHandlers: Map<
|
|
14
|
+
string,
|
|
15
|
+
(
|
|
16
|
+
req: { method: string; params: Record<string, unknown> },
|
|
17
|
+
extra: object,
|
|
18
|
+
) => Promise<unknown>
|
|
19
|
+
>;
|
|
20
|
+
};
|
|
21
|
+
const handler = handlers.get(method);
|
|
22
|
+
if (!handler) throw new Error(`No handler registered for method: ${method}`);
|
|
23
|
+
return handler({ method, params }, {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeBackend(): BackendClient {
|
|
27
|
+
return {
|
|
28
|
+
getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }),
|
|
29
|
+
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
30
|
+
callTool: vi.fn(),
|
|
31
|
+
setNotificationHandler: vi.fn(),
|
|
32
|
+
} as unknown as BackendClient;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `hasToolContent` answers false for a value that is not a tool result instead
|
|
37
|
+
* of throwing on it. The `tools/call` telemetry decision reads that guard, so a
|
|
38
|
+
* pipeline that resolved to nothing must not be recorded as a clean call: the
|
|
39
|
+
* result never reached the client, and there is no `isError` to judge it by.
|
|
40
|
+
*/
|
|
41
|
+
describe('tools/call telemetry — a result that is not a tool result', () => {
|
|
42
|
+
it('reports error when a local tool handler resolves to nothing', async () => {
|
|
43
|
+
const events: TelemetryEvent[] = [];
|
|
44
|
+
const noReturn: LocalTool = {
|
|
45
|
+
tool: {
|
|
46
|
+
name: 'silent_tool',
|
|
47
|
+
description: 'returns undefined',
|
|
48
|
+
inputSchema: { type: 'object', properties: {} },
|
|
49
|
+
},
|
|
50
|
+
// A local tool with a missing `return`: the pipeline resolves to undefined.
|
|
51
|
+
handler: (async () => undefined) as unknown as LocalTool['handler'],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const server = createProxyServer(
|
|
55
|
+
{ crm: makeBackend() },
|
|
56
|
+
{
|
|
57
|
+
name: 'test-server',
|
|
58
|
+
localTools: [noReturn],
|
|
59
|
+
onTelemetry: (e) => events.push(e),
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// The SDK rejects the malformed result on the way out, which is correct and
|
|
64
|
+
// unchanged by this PR. What matters is what telemetry recorded first.
|
|
65
|
+
await expect(
|
|
66
|
+
invokeHandler(server, 'tools/call', {
|
|
67
|
+
name: 'silent_tool',
|
|
68
|
+
arguments: {},
|
|
69
|
+
}),
|
|
70
|
+
).rejects.toBeDefined();
|
|
71
|
+
|
|
72
|
+
expect(events).toHaveLength(1);
|
|
73
|
+
expect(events[0]).toMatchObject({
|
|
74
|
+
type: 'tool_call',
|
|
75
|
+
tool: 'silent_tool',
|
|
76
|
+
outcome: 'error',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('still reports success for a real result and error for an isError one', async () => {
|
|
81
|
+
const events: TelemetryEvent[] = [];
|
|
82
|
+
const ok: LocalTool = {
|
|
83
|
+
tool: {
|
|
84
|
+
name: 'ok_tool',
|
|
85
|
+
description: 'ok',
|
|
86
|
+
inputSchema: { type: 'object', properties: {} },
|
|
87
|
+
},
|
|
88
|
+
handler: async () => ({ content: [{ type: 'text', text: 'fine' }] }),
|
|
89
|
+
};
|
|
90
|
+
const failing: LocalTool = {
|
|
91
|
+
tool: {
|
|
92
|
+
name: 'bad_tool',
|
|
93
|
+
description: 'bad',
|
|
94
|
+
inputSchema: { type: 'object', properties: {} },
|
|
95
|
+
},
|
|
96
|
+
handler: async () => ({
|
|
97
|
+
content: [{ type: 'text', text: 'nope' }],
|
|
98
|
+
isError: true,
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const server = createProxyServer(
|
|
103
|
+
{ crm: makeBackend() },
|
|
104
|
+
{
|
|
105
|
+
name: 'test-server',
|
|
106
|
+
localTools: [ok, failing],
|
|
107
|
+
onTelemetry: (e) => events.push(e),
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await invokeHandler(server, 'tools/call', {
|
|
112
|
+
name: 'ok_tool',
|
|
113
|
+
arguments: {},
|
|
114
|
+
});
|
|
115
|
+
await invokeHandler(server, 'tools/call', {
|
|
116
|
+
name: 'bad_tool',
|
|
117
|
+
arguments: {},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(events.map((e) => (e as { outcome?: string }).outcome)).toEqual([
|
|
121
|
+
'success',
|
|
122
|
+
'error',
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The legacy `{ toolResult }` shape (protocol 2024-10-07) is a valid, documented
|
|
129
|
+
* result — `mapToolResult` returns it untouched and the guard answers `false` for
|
|
130
|
+
* it because it has no `.content`. It is not a failure, so it must not be
|
|
131
|
+
* telemetered as one.
|
|
132
|
+
*/
|
|
133
|
+
describe('tools/call telemetry — the legacy { toolResult } shape', () => {
|
|
134
|
+
it('reports success for a legacy-shape result', async () => {
|
|
135
|
+
const events: TelemetryEvent[] = [];
|
|
136
|
+
const backend = makeBackend();
|
|
137
|
+
backend.callTool = vi.fn().mockResolvedValue({
|
|
138
|
+
toolResult: { raw: true },
|
|
139
|
+
}) as BackendClient['callTool'];
|
|
140
|
+
|
|
141
|
+
const server = createProxyServer(
|
|
142
|
+
{ crm: backend },
|
|
143
|
+
{ name: 'test-server', onTelemetry: (e) => events.push(e) },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await invokeHandler(server, 'tools/call', {
|
|
147
|
+
name: 'crm__legacy_tool',
|
|
148
|
+
arguments: {},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(events).toHaveLength(1);
|
|
152
|
+
expect(events[0]).toMatchObject({
|
|
153
|
+
type: 'tool_call',
|
|
154
|
+
outcome: 'success',
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { createInMemoryEventStore, scopeEventStore } from '../eventStore.js';
|
|
4
|
+
|
|
5
|
+
function msg(id: number): JSONRPCMessage {
|
|
6
|
+
return { jsonrpc: '2.0', method: 'notifications/test', params: { id } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('createInMemoryEventStore()', () => {
|
|
10
|
+
it('assigns monotonically increasing event ids', async () => {
|
|
11
|
+
const store = createInMemoryEventStore();
|
|
12
|
+
const a = await store.storeEvent('stream-a', msg(1));
|
|
13
|
+
const b = await store.storeEvent('stream-a', msg(2));
|
|
14
|
+
expect(parseInt(b, 10)).toBeGreaterThan(parseInt(a, 10));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('resolves the stream id for a stored event', async () => {
|
|
18
|
+
const store = createInMemoryEventStore();
|
|
19
|
+
const id = await store.storeEvent('stream-a', msg(1));
|
|
20
|
+
expect(await store.getStreamIdForEventId?.(id)).toBe('stream-a');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('replayEventsAfter', () => {
|
|
24
|
+
it('replays only events belonging to the same stream', async () => {
|
|
25
|
+
const store = createInMemoryEventStore();
|
|
26
|
+
const a1 = await store.storeEvent('stream-a', msg(1));
|
|
27
|
+
await store.storeEvent('stream-b', msg(2));
|
|
28
|
+
const a2 = await store.storeEvent('stream-a', msg(3));
|
|
29
|
+
await store.storeEvent('stream-b', msg(4));
|
|
30
|
+
|
|
31
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
32
|
+
const streamId = await store.replayEventsAfter(a1, { send });
|
|
33
|
+
|
|
34
|
+
expect(streamId).toBe('stream-a');
|
|
35
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
36
|
+
expect(send).toHaveBeenCalledWith(a2, msg(3));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('replays nothing for an unknown Last-Event-ID', async () => {
|
|
40
|
+
const store = createInMemoryEventStore();
|
|
41
|
+
await store.storeEvent('stream-a', msg(1));
|
|
42
|
+
await store.storeEvent('stream-a', msg(2));
|
|
43
|
+
|
|
44
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
const streamId = await store.replayEventsAfter('does-not-exist', {
|
|
46
|
+
send,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(streamId).toBe('');
|
|
50
|
+
expect(send).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('replays nothing for a malformed Last-Event-ID', async () => {
|
|
54
|
+
const store = createInMemoryEventStore();
|
|
55
|
+
await store.storeEvent('stream-a', msg(1));
|
|
56
|
+
|
|
57
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
58
|
+
const streamId = await store.replayEventsAfter('not-a-number', { send });
|
|
59
|
+
|
|
60
|
+
expect(streamId).toBe('');
|
|
61
|
+
expect(send).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('replays nothing for an evicted Last-Event-ID', async () => {
|
|
65
|
+
const store = createInMemoryEventStore(2);
|
|
66
|
+
const first = await store.storeEvent('stream-a', msg(1));
|
|
67
|
+
await store.storeEvent('stream-a', msg(2));
|
|
68
|
+
await store.storeEvent('stream-a', msg(3)); // evicts `first`
|
|
69
|
+
|
|
70
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
71
|
+
const streamId = await store.replayEventsAfter(first, { send });
|
|
72
|
+
|
|
73
|
+
expect(streamId).toBe('');
|
|
74
|
+
expect(send).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('evicts FIFO once maxEvents is reached', async () => {
|
|
79
|
+
const store = createInMemoryEventStore(2);
|
|
80
|
+
const first = await store.storeEvent('stream-a', msg(1));
|
|
81
|
+
const second = await store.storeEvent('stream-a', msg(2));
|
|
82
|
+
const third = await store.storeEvent('stream-a', msg(3));
|
|
83
|
+
|
|
84
|
+
expect(await store.getStreamIdForEventId?.(first)).toBeUndefined();
|
|
85
|
+
expect(await store.getStreamIdForEventId?.(second)).toBe('stream-a');
|
|
86
|
+
expect(await store.getStreamIdForEventId?.(third)).toBe('stream-a');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('scopeEventStore()', () => {
|
|
91
|
+
it('namespaces stream ids by session and strips the prefix on the way out', async () => {
|
|
92
|
+
const inner = createInMemoryEventStore();
|
|
93
|
+
const scoped = scopeEventStore(inner, 'session-a');
|
|
94
|
+
const id = await scoped.storeEvent('_GET_stream', msg(1));
|
|
95
|
+
const id2 = await scoped.storeEvent('_GET_stream', msg(2));
|
|
96
|
+
|
|
97
|
+
expect(await inner.getStreamIdForEventId?.(id)).toBe(
|
|
98
|
+
'session-a:_GET_stream',
|
|
99
|
+
);
|
|
100
|
+
expect(await scoped.getStreamIdForEventId?.(id)).toBe('_GET_stream');
|
|
101
|
+
|
|
102
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
103
|
+
expect(await scoped.replayEventsAfter(id, { send })).toBe('_GET_stream');
|
|
104
|
+
expect(send).toHaveBeenCalledWith(id2, msg(2));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("treats another session's cursor as unknown and replays nothing (#154)", async () => {
|
|
108
|
+
const inner = createInMemoryEventStore();
|
|
109
|
+
const a = scopeEventStore(inner, 'session-a');
|
|
110
|
+
const b = scopeEventStore(inner, 'session-b');
|
|
111
|
+
const cursor = await a.storeEvent('_GET_stream', msg(1));
|
|
112
|
+
await a.storeEvent('_GET_stream', msg(2));
|
|
113
|
+
|
|
114
|
+
expect(await b.getStreamIdForEventId?.(cursor)).toBeUndefined();
|
|
115
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
116
|
+
expect(await b.replayEventsAfter(cursor, { send })).toBe('');
|
|
117
|
+
expect(send).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('learns the owner by a silent replay when the store has no getStreamIdForEventId', async () => {
|
|
121
|
+
const inner = createInMemoryEventStore();
|
|
122
|
+
const bare = {
|
|
123
|
+
storeEvent: inner.storeEvent,
|
|
124
|
+
replayEventsAfter: inner.replayEventsAfter,
|
|
125
|
+
};
|
|
126
|
+
const a = scopeEventStore(bare, 'session-a');
|
|
127
|
+
const b = scopeEventStore(bare, 'session-b');
|
|
128
|
+
const cursor = await a.storeEvent('_GET_stream', msg(1));
|
|
129
|
+
const next = await a.storeEvent('_GET_stream', msg(2));
|
|
130
|
+
|
|
131
|
+
expect(await a.getStreamIdForEventId?.(cursor)).toBe('_GET_stream');
|
|
132
|
+
expect(await b.getStreamIdForEventId?.(cursor)).toBeUndefined();
|
|
133
|
+
expect(await a.getStreamIdForEventId?.('unknown')).toBeUndefined();
|
|
134
|
+
|
|
135
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
136
|
+
expect(await b.replayEventsAfter(cursor, { send })).toBe('');
|
|
137
|
+
expect(send).not.toHaveBeenCalled();
|
|
138
|
+
expect(await a.replayEventsAfter(cursor, { send })).toBe('_GET_stream');
|
|
139
|
+
expect(send).toHaveBeenCalledWith(next, msg(2));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hasToolContent, mapToolResult } from '../core.js';
|
|
3
|
+
import type {
|
|
4
|
+
CallToolResult,
|
|
5
|
+
CompatibilityCallToolResult,
|
|
6
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `hasToolContent` narrows by reading `.content`, and a value that is not a
|
|
10
|
+
* tool result has none: the legacy `{ toolResult }` shape, and a pipeline that
|
|
11
|
+
* resolved to nothing at all (a local tool handler with a missing `return`, or
|
|
12
|
+
* a middleware that dropped the result). It is a guard, so the answer to
|
|
13
|
+
* "is this a CallToolResult?" has to be `false`, not a thrown TypeError.
|
|
14
|
+
*
|
|
15
|
+
* Both in-repo call sites depend on that answer rather than on a throw:
|
|
16
|
+
* `mapToolResult` returns the value untouched when the guard says `false`, and
|
|
17
|
+
* the `tools/call` handler reads `result.isError` only behind it. A throw here
|
|
18
|
+
* instead replaces whatever the pipeline produced with
|
|
19
|
+
* `TypeError: Cannot read properties of undefined (reading 'content')`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Callers reach this through a pipeline result, which the types do not pin. */
|
|
23
|
+
const untyped = (value: unknown): CompatibilityCallToolResult =>
|
|
24
|
+
value as CompatibilityCallToolResult;
|
|
25
|
+
|
|
26
|
+
describe('hasToolContent()', () => {
|
|
27
|
+
it('is true for a CallToolResult, even an empty content array', () => {
|
|
28
|
+
const result: CallToolResult = { content: [] };
|
|
29
|
+
expect(hasToolContent(result)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is false for the legacy { toolResult } shape, which has no content', () => {
|
|
33
|
+
const legacy: CompatibilityCallToolResult = {
|
|
34
|
+
toolResult: { raw: true },
|
|
35
|
+
};
|
|
36
|
+
expect(hasToolContent(legacy)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is false for a non-object, rather than throwing', () => {
|
|
40
|
+
expect(hasToolContent(untyped('a string'))).toBe(false);
|
|
41
|
+
expect(hasToolContent(untyped(42))).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (const [label, value] of [
|
|
45
|
+
['undefined', undefined],
|
|
46
|
+
['null', null],
|
|
47
|
+
] as const) {
|
|
48
|
+
it(`is false for a ${label} result rather than throwing`, () => {
|
|
49
|
+
expect(hasToolContent(untyped(value))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it(`mapToolResult returns a ${label} result untouched`, () => {
|
|
53
|
+
const result = untyped(value);
|
|
54
|
+
expect(
|
|
55
|
+
mapToolResult(result, {
|
|
56
|
+
onText: () => null,
|
|
57
|
+
onOther: () => null,
|
|
58
|
+
onStructured: () => undefined,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(result);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mapToolResult } from '../core.js';
|
|
3
|
+
import type { ToolResultHandlers } from '../core.js';
|
|
4
|
+
import type {
|
|
5
|
+
CallToolResult,
|
|
6
|
+
CompatibilityCallToolResult,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
|
|
9
|
+
/** Handlers that pass every channel through unchanged. */
|
|
10
|
+
const identity: ToolResultHandlers = {
|
|
11
|
+
onText: (block) => block,
|
|
12
|
+
onOther: (block) => block,
|
|
13
|
+
onStructured: (structured) => structured,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('mapToolResult()', () => {
|
|
17
|
+
it('maps text blocks through onText', () => {
|
|
18
|
+
const result: CallToolResult = {
|
|
19
|
+
content: [{ type: 'text', text: 'ssn 123456789' }],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mapped = mapToolResult(result, {
|
|
23
|
+
...identity,
|
|
24
|
+
onText: (block) => ({ ...block, text: '[REDACTED]' }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(mapped.content).toEqual([{ type: 'text', text: '[REDACTED]' }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('routes non-text blocks through onOther, and null drops the block', () => {
|
|
31
|
+
const result: CallToolResult = {
|
|
32
|
+
content: [
|
|
33
|
+
{ type: 'text', text: 'keep' },
|
|
34
|
+
{ type: 'image', data: 'aGk=', mimeType: 'image/png' },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const onOther = vi.fn().mockReturnValue(null);
|
|
38
|
+
|
|
39
|
+
const mapped = mapToolResult(result, { ...identity, onOther });
|
|
40
|
+
|
|
41
|
+
expect(onOther).toHaveBeenCalledWith(
|
|
42
|
+
expect.objectContaining({ type: 'image' }),
|
|
43
|
+
);
|
|
44
|
+
expect(mapped.content).toEqual([{ type: 'text', text: 'keep' }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('null from onText drops the block too', () => {
|
|
48
|
+
const result: CallToolResult = {
|
|
49
|
+
content: [{ type: 'text', text: 'drop me' }],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const mapped = mapToolResult(result, { ...identity, onText: () => null });
|
|
53
|
+
expect(mapped.content).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('calls onStructured only when structuredContent is present', () => {
|
|
57
|
+
const onStructured = vi.fn().mockReturnValue({ ok: true });
|
|
58
|
+
|
|
59
|
+
mapToolResult({ content: [] }, { ...identity, onStructured });
|
|
60
|
+
expect(onStructured).not.toHaveBeenCalled();
|
|
61
|
+
|
|
62
|
+
const mapped = mapToolResult(
|
|
63
|
+
{ content: [], structuredContent: { secret: 'x' } },
|
|
64
|
+
{ ...identity, onStructured },
|
|
65
|
+
);
|
|
66
|
+
expect(onStructured).toHaveBeenCalledWith({ secret: 'x' });
|
|
67
|
+
expect(mapped.structuredContent).toEqual({ ok: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('undefined from onStructured removes the field', () => {
|
|
71
|
+
const mapped = mapToolResult(
|
|
72
|
+
{ content: [], structuredContent: { secret: 'x' } },
|
|
73
|
+
{ ...identity, onStructured: () => undefined },
|
|
74
|
+
);
|
|
75
|
+
expect(mapped).not.toHaveProperty('structuredContent');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns the legacy { toolResult } shape untouched', () => {
|
|
79
|
+
const legacy: CompatibilityCallToolResult = { toolResult: { raw: true } };
|
|
80
|
+
const onText = vi.fn();
|
|
81
|
+
|
|
82
|
+
expect(mapToolResult(legacy, { ...identity, onText })).toBe(legacy);
|
|
83
|
+
expect(onText).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('preserves isError, result _meta, and unknown extra keys', () => {
|
|
87
|
+
const result: CallToolResult = {
|
|
88
|
+
content: [],
|
|
89
|
+
isError: true,
|
|
90
|
+
_meta: { forwarded: 'deliberately' },
|
|
91
|
+
vendorKey: 'kept',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const mapped = mapToolResult(result, identity);
|
|
95
|
+
expect(mapped).toMatchObject({
|
|
96
|
+
isError: true,
|
|
97
|
+
_meta: { forwarded: 'deliberately' },
|
|
98
|
+
vendorKey: 'kept',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { compose, pipe, type Middleware } from '../middleware.js';
|
|
3
|
+
import { createProxyContext, type ProxyContext } from '../proxyContext.js';
|
|
4
|
+
|
|
5
|
+
type Req = { value: number };
|
|
6
|
+
type Res = { result: number };
|
|
7
|
+
|
|
8
|
+
const passthroughNext = async (r: Req): Promise<Res> => ({ result: r.value });
|
|
9
|
+
|
|
10
|
+
describe('compose() — edge cases', () => {
|
|
11
|
+
it('rejects (not throws synchronously) when next() is called twice in the same middleware', async () => {
|
|
12
|
+
let secondCallReturn: unknown;
|
|
13
|
+
const dbl: Middleware<Req, Res> = async (req, next) => {
|
|
14
|
+
const first = next(req);
|
|
15
|
+
secondCallReturn = next(req);
|
|
16
|
+
await first;
|
|
17
|
+
return { result: 0 };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pipeline = compose([dbl]);
|
|
21
|
+
const promise = pipeline({ value: 1 }, passthroughNext);
|
|
22
|
+
|
|
23
|
+
expect(secondCallReturn).toBeInstanceOf(Promise);
|
|
24
|
+
await expect(secondCallReturn as Promise<Res>).rejects.toThrow(
|
|
25
|
+
'next() called multiple times',
|
|
26
|
+
);
|
|
27
|
+
await expect(promise).resolves.toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects on a second next() call even after the first has settled', async () => {
|
|
31
|
+
let captured: Promise<Res> | undefined;
|
|
32
|
+
const mw: Middleware<Req, Res> = async (req, next) => {
|
|
33
|
+
const first = await next(req);
|
|
34
|
+
captured = next(req);
|
|
35
|
+
return first;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const pipeline = compose([mw]);
|
|
39
|
+
await pipeline({ value: 1 }, passthroughNext);
|
|
40
|
+
|
|
41
|
+
await expect(captured).rejects.toThrow('next() called multiple times');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not mutate a frozen middleware array', async () => {
|
|
45
|
+
const mw: Middleware<Req, Res> = (req, next) =>
|
|
46
|
+
next({ value: req.value + 1 });
|
|
47
|
+
const arr = Object.freeze([mw]);
|
|
48
|
+
|
|
49
|
+
const pipeline = compose(arr);
|
|
50
|
+
const result = await pipeline({ value: 2 }, passthroughNext);
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ result: 3 });
|
|
53
|
+
expect(arr).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('generates a fresh requestId for each invocation when no context is provided', async () => {
|
|
57
|
+
const seen: string[] = [];
|
|
58
|
+
const capture: Middleware<Req, Res> = async (req, next, context) => {
|
|
59
|
+
seen.push(context.requestId);
|
|
60
|
+
return next(req);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const pipeline = compose([capture]);
|
|
64
|
+
await pipeline({ value: 1 }, passthroughNext);
|
|
65
|
+
await pipeline({ value: 1 }, passthroughNext);
|
|
66
|
+
|
|
67
|
+
expect(seen).toHaveLength(2);
|
|
68
|
+
expect(seen[0]).not.toEqual(seen[1]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('propagates errors thrown from inner-most next through wrappers', async () => {
|
|
72
|
+
const wrap: Middleware<Req, Res> = async (req, next) => next(req);
|
|
73
|
+
const pipeline = compose([wrap, wrap, wrap]);
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
pipeline({ value: 0 }, async () => {
|
|
77
|
+
throw new Error('inner boom');
|
|
78
|
+
}),
|
|
79
|
+
).rejects.toThrow('inner boom');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('passes the same context reference to every middleware', async () => {
|
|
83
|
+
const refs: ProxyContext[] = [];
|
|
84
|
+
const capture: Middleware<Req, Res> = async (req, next, context) => {
|
|
85
|
+
refs.push(context);
|
|
86
|
+
return next(req);
|
|
87
|
+
};
|
|
88
|
+
const ctx = createProxyContext({ requestId: 'fixed-id' });
|
|
89
|
+
|
|
90
|
+
await compose([capture, capture, capture])(
|
|
91
|
+
{ value: 1 },
|
|
92
|
+
passthroughNext,
|
|
93
|
+
ctx,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(refs).toHaveLength(3);
|
|
97
|
+
expect(refs[0]).toBe(ctx);
|
|
98
|
+
expect(refs[1]).toBe(ctx);
|
|
99
|
+
expect(refs[2]).toBe(ctx);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('pipe() — edge cases', () => {
|
|
104
|
+
it('does not mutate the input array (reverse on a copy)', async () => {
|
|
105
|
+
const a: Middleware<Req, Res> = (req, next) =>
|
|
106
|
+
next({ value: req.value + 1 });
|
|
107
|
+
const b: Middleware<Req, Res> = (req, next) =>
|
|
108
|
+
next({ value: req.value * 2 });
|
|
109
|
+
const arr = [a, b];
|
|
110
|
+
const snapshot = [...arr];
|
|
111
|
+
|
|
112
|
+
await pipe(arr)({ value: 1 }, passthroughNext);
|
|
113
|
+
|
|
114
|
+
expect(arr).toEqual(snapshot);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts a frozen input array', async () => {
|
|
118
|
+
const a: Middleware<Req, Res> = (req, next) =>
|
|
119
|
+
next({ value: req.value + 1 });
|
|
120
|
+
const arr = Object.freeze([a]);
|
|
121
|
+
|
|
122
|
+
const result = await pipe(arr)({ value: 1 }, passthroughNext);
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual({ result: 2 });
|
|
125
|
+
});
|
|
126
|
+
});
|