mcpose 2.1.1 → 3.0.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 +457 -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 +285 -32
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +961 -193
- 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 +1 -1
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +1 -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 +86 -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 +1019 -0
- package/src/__tests__/eventStore.test.ts +141 -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 +359 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +638 -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 +985 -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 +2042 -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 +37 -0
- package/src/sanitizeCatalog.ts +120 -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,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,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
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { compose, pipe, type Middleware } from '../middleware.js';
|
|
3
|
+
import type { ProxyContext } from '../proxyContext.js';
|
|
4
|
+
|
|
5
|
+
type Req = { value: number };
|
|
6
|
+
type Res = { result: number };
|
|
7
|
+
|
|
8
|
+
const identity: Middleware<Req, Res> = (req, next) => next(req);
|
|
9
|
+
|
|
10
|
+
describe('compose()', () => {
|
|
11
|
+
it('calls innermost next when middlewares array is empty', async () => {
|
|
12
|
+
const pipeline = compose<Req, Res>([]);
|
|
13
|
+
const result = await pipeline({ value: 1 }, async (r) => ({
|
|
14
|
+
result: r.value * 10,
|
|
15
|
+
}));
|
|
16
|
+
expect(result).toEqual({ result: 10 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('passes through a single identity middleware', async () => {
|
|
20
|
+
const pipeline = compose([identity]);
|
|
21
|
+
const result = await pipeline({ value: 3 }, async (r) => ({
|
|
22
|
+
result: r.value,
|
|
23
|
+
}));
|
|
24
|
+
expect(result).toEqual({ result: 3 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('passes ProxyContext through the entire middleware chain unchanged', async () => {
|
|
28
|
+
const context: ProxyContext = {
|
|
29
|
+
requestId: 'req-1',
|
|
30
|
+
transport: 'http',
|
|
31
|
+
sessionId: 'sess-1',
|
|
32
|
+
};
|
|
33
|
+
const seen: ProxyContext[] = [];
|
|
34
|
+
|
|
35
|
+
const outer: Middleware<Req, Res> = async (req, next, currentContext) => {
|
|
36
|
+
seen.push(currentContext);
|
|
37
|
+
return next(req);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const inner: Middleware<Req, Res> = async (req, next, currentContext) => {
|
|
41
|
+
seen.push(currentContext);
|
|
42
|
+
return next(req);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const pipeline = compose([outer, inner]);
|
|
46
|
+
await pipeline({ value: 1 }, async (r) => ({ result: r.value }), context);
|
|
47
|
+
|
|
48
|
+
expect(seen).toEqual([context, context]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('creates a default ProxyContext when one is omitted', async () => {
|
|
52
|
+
let seenContext: ProxyContext | undefined;
|
|
53
|
+
|
|
54
|
+
const capture: Middleware<Req, Res> = async (req, next, context) => {
|
|
55
|
+
seenContext = context;
|
|
56
|
+
return next(req);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const pipeline = compose([capture]);
|
|
60
|
+
await pipeline({ value: 2 }, async (r) => ({ result: r.value }));
|
|
61
|
+
|
|
62
|
+
expect(seenContext?.transport).toBe('stdio');
|
|
63
|
+
expect(seenContext?.requestId).toEqual(expect.any(String));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('executes middlewares in order — outer first, inner last', async () => {
|
|
67
|
+
const order: string[] = [];
|
|
68
|
+
|
|
69
|
+
const outer: Middleware<Req, Res> = async (req, next) => {
|
|
70
|
+
order.push('outer-enter');
|
|
71
|
+
const res = await next(req);
|
|
72
|
+
order.push('outer-exit');
|
|
73
|
+
return res;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const inner: Middleware<Req, Res> = async (req, next) => {
|
|
77
|
+
order.push('inner-enter');
|
|
78
|
+
const res = await next(req);
|
|
79
|
+
order.push('inner-exit');
|
|
80
|
+
return res;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const pipeline = compose([outer, inner]);
|
|
84
|
+
await pipeline({ value: 0 }, async () => ({ result: 0 }));
|
|
85
|
+
|
|
86
|
+
expect(order).toEqual([
|
|
87
|
+
'outer-enter',
|
|
88
|
+
'inner-enter',
|
|
89
|
+
'inner-exit',
|
|
90
|
+
'outer-exit',
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('allows outer middleware to transform the response', async () => {
|
|
95
|
+
const doubler: Middleware<Req, Res> = async (req, next) => {
|
|
96
|
+
const res = await next(req);
|
|
97
|
+
return { result: res.result * 2 };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const pipeline = compose([doubler]);
|
|
101
|
+
const result = await pipeline({ value: 5 }, async (r) => ({
|
|
102
|
+
result: r.value,
|
|
103
|
+
}));
|
|
104
|
+
expect(result).toEqual({ result: 10 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('allows middleware to transform the request before passing on', async () => {
|
|
108
|
+
const incrementer: Middleware<Req, Res> = (req, next) =>
|
|
109
|
+
next({ value: req.value + 1 });
|
|
110
|
+
|
|
111
|
+
const pipeline = compose([incrementer]);
|
|
112
|
+
const result = await pipeline({ value: 4 }, async (r) => ({
|
|
113
|
+
result: r.value,
|
|
114
|
+
}));
|
|
115
|
+
expect(result).toEqual({ result: 5 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('chains multiple transformations correctly', async () => {
|
|
119
|
+
const add1: Middleware<Req, Res> = (req, next) =>
|
|
120
|
+
next({ value: req.value + 1 });
|
|
121
|
+
const mul2: Middleware<Req, Res> = (req, next) =>
|
|
122
|
+
next({ value: req.value * 2 });
|
|
123
|
+
|
|
124
|
+
// add1 outer: value = (original + 1) * 2
|
|
125
|
+
const pipeline = compose([add1, mul2]);
|
|
126
|
+
const result = await pipeline({ value: 3 }, async (r) => ({
|
|
127
|
+
result: r.value,
|
|
128
|
+
}));
|
|
129
|
+
expect(result).toEqual({ result: 8 }); // (3 + 1) * 2 = 8
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws if next() is called more than once', async () => {
|
|
133
|
+
const doubleNext: Middleware<Req, Res> = async (req, next) => {
|
|
134
|
+
await next(req);
|
|
135
|
+
return next(req); // second call — should throw
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const pipeline = compose([doubleNext]);
|
|
139
|
+
await expect(
|
|
140
|
+
pipeline({ value: 0 }, async () => ({ result: 0 })),
|
|
141
|
+
).rejects.toThrow('next() called multiple times');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('propagates errors from the inner next', async () => {
|
|
145
|
+
const pipeline = compose([identity]);
|
|
146
|
+
await expect(
|
|
147
|
+
pipeline({ value: 0 }, async () => {
|
|
148
|
+
throw new Error('upstream error');
|
|
149
|
+
}),
|
|
150
|
+
).rejects.toThrow('upstream error');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('propagates errors thrown inside a middleware', async () => {
|
|
154
|
+
const failing: Middleware<Req, Res> = async () => {
|
|
155
|
+
throw new Error('middleware error');
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const pipeline = compose([failing]);
|
|
159
|
+
await expect(
|
|
160
|
+
pipeline({ value: 0 }, async () => ({ result: 0 })),
|
|
161
|
+
).rejects.toThrow('middleware error');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('pipe()', () => {
|
|
166
|
+
it('pipe([A, B]) is equivalent to compose([B, A])', async () => {
|
|
167
|
+
const add1: Middleware<Req, Res> = (req, next) =>
|
|
168
|
+
next({ value: req.value + 1 });
|
|
169
|
+
const mul2: Middleware<Req, Res> = (req, next) =>
|
|
170
|
+
next({ value: req.value * 2 });
|
|
171
|
+
|
|
172
|
+
// pipe([add1, mul2]) ≡ compose([mul2, add1])
|
|
173
|
+
// mul2 outer: value = (original * 2) + 1 = 7
|
|
174
|
+
const pipePipeline = pipe([add1, mul2]);
|
|
175
|
+
const composePipeline = compose([mul2, add1]);
|
|
176
|
+
|
|
177
|
+
const baseNext = async (r: Req): Promise<Res> => ({ result: r.value });
|
|
178
|
+
expect(await pipePipeline({ value: 3 }, baseNext)).toEqual({ result: 7 });
|
|
179
|
+
expect(await composePipeline({ value: 3 }, baseNext)).toEqual({
|
|
180
|
+
result: 7,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('executes in data-flow order — first element processes the response first', async () => {
|
|
185
|
+
const order: string[] = [];
|
|
186
|
+
|
|
187
|
+
// first element in pipe() = innermost = processes response first
|
|
188
|
+
const innerMW: Middleware<Req, Res> = async (req, next) => {
|
|
189
|
+
order.push('inner-enter');
|
|
190
|
+
const res = await next(req);
|
|
191
|
+
order.push('inner-exit');
|
|
192
|
+
return res;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const outerMW: Middleware<Req, Res> = async (req, next) => {
|
|
196
|
+
order.push('outer-enter');
|
|
197
|
+
const res = await next(req);
|
|
198
|
+
order.push('outer-exit');
|
|
199
|
+
return res;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// pipe([innerMW, outerMW]) — outerMW wraps innerMW
|
|
203
|
+
const pipeline = pipe([innerMW, outerMW]);
|
|
204
|
+
await pipeline({ value: 0 }, async () => ({ result: 0 }));
|
|
205
|
+
|
|
206
|
+
expect(order).toEqual([
|
|
207
|
+
'outer-enter',
|
|
208
|
+
'inner-enter',
|
|
209
|
+
'inner-exit',
|
|
210
|
+
'outer-exit',
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('keeps the same ProxyContext while reversing response-processing order', async () => {
|
|
215
|
+
const context: ProxyContext = { requestId: 'req-2', transport: 'stdio' };
|
|
216
|
+
const seen: string[] = [];
|
|
217
|
+
|
|
218
|
+
const innerMW: Middleware<Req, Res> = async (req, next, currentContext) => {
|
|
219
|
+
seen.push(`inner:${currentContext.requestId}`);
|
|
220
|
+
return next(req);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const outerMW: Middleware<Req, Res> = async (req, next, currentContext) => {
|
|
224
|
+
seen.push(`outer:${currentContext.requestId}`);
|
|
225
|
+
return next(req);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const pipeline = pipe([innerMW, outerMW]);
|
|
229
|
+
await pipeline({ value: 1 }, async (r) => ({ result: r.value }), context);
|
|
230
|
+
|
|
231
|
+
expect(seen).toEqual(['outer:req-2', 'inner:req-2']);
|
|
232
|
+
});
|
|
233
|
+
});
|