mcpose 3.0.0 → 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 +9 -5
- package/dist/core.d.ts +26 -5
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +120 -51
- package/dist/core.js.map +1 -1
- package/dist/rejection.d.ts +6 -0
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +23 -0
- package/dist/rejection.js.map +1 -1
- package/dist/sanitizeCatalog.d.ts.map +1 -1
- package/dist/sanitizeCatalog.js +8 -2
- package/dist/sanitizeCatalog.js.map +1 -1
- package/dist/sessionRegistry.d.ts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/src/__tests__/core.test.ts +86 -0
- package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
- package/src/__tests__/hasToolContent.test.ts +63 -0
- package/src/__tests__/sanitizeCatalog.test.ts +21 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +37 -0
- package/src/__tests__/startHttpProxy.test.ts +80 -0
- package/src/core.ts +145 -58
- package/src/rejection.ts +25 -0
- package/src/sanitizeCatalog.ts +8 -2
- package/src/sessionRegistry.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitizeCatalog.d.ts","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"sanitizeCatalog.d.ts","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAWrD,oDAAoD;AACpD,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IAC1C,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA8BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,+BAAoC,GAC5C,mBAAmB,CAgDrB"}
|
package/dist/sanitizeCatalog.js
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
* descriptions verbatim into model context, and real upstreams leak org
|
|
4
4
|
* slugs and internal hostnames there. See ADR-0010.
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Matches http(s) URLs; always stripped regardless of `patterns`.
|
|
8
|
+
* The `i` flag is load-bearing: URI schemes are case-insensitive
|
|
9
|
+
* (RFC 3986 §6.2.2), so an uppercase `HTTPS://` is the same URL, and a
|
|
10
|
+
* case-sensitive strip would let an upstream leak a hostname simply by
|
|
11
|
+
* capitalizing the scheme.
|
|
12
|
+
*/
|
|
13
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>()[\]]+/gi;
|
|
8
14
|
/**
|
|
9
15
|
* Rebuilds a schema node with every string-valued `description` property
|
|
10
16
|
* sanitized, at any depth through plain objects and arrays. Returns the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitizeCatalog.js","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH
|
|
1
|
+
{"version":3,"file":"sanitizeCatalog.js","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"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"}
|
|
@@ -37,7 +37,7 @@ export interface SessionRecord {
|
|
|
37
37
|
export interface SessionRegistry {
|
|
38
38
|
/**
|
|
39
39
|
* Persists a record. Awaited before the client learns its session id, so a
|
|
40
|
-
* rejection fails the initialize with a
|
|
40
|
+
* rejection fails the initialize with a 400 rather than handing out an id
|
|
41
41
|
* that no other instance could honour.
|
|
42
42
|
*/
|
|
43
43
|
set(sessionId: string, record: SessionRecord): Promise<void>;
|
package/dist/version.d.ts
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* `createRequire('../package.json')` lookup breaks when consumers bundle
|
|
4
4
|
* the package). Kept in sync with package.json by `version.test.ts`.
|
|
5
5
|
*/
|
|
6
|
-
export declare const VERSION = "3.
|
|
6
|
+
export declare const VERSION = "3.1.0";
|
|
7
7
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpose",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "The audit and governance layer for MCP",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.17.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@modelcontextprotocol/sdk": "1.
|
|
54
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
55
55
|
"@stryker-mutator/core": "10.0.0",
|
|
56
56
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
57
|
-
"@types/node": "^26.5
|
|
58
|
-
"@vitest/coverage-v8": "5.0.
|
|
57
|
+
"@types/node": "^26.6.5",
|
|
58
|
+
"@vitest/coverage-v8": "5.0.4",
|
|
59
59
|
"typescript": "6.0.3",
|
|
60
|
-
"vitest": "5.0.
|
|
60
|
+
"vitest": "5.0.4"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build": "tsc -p tsconfig.build.json",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { runInNewContext } from 'node:vm';
|
|
4
5
|
import {
|
|
5
6
|
createProxyServer,
|
|
6
7
|
type ListToolsMiddleware,
|
|
@@ -721,6 +722,33 @@ describe('createProxyServer() — onTelemetry', () => {
|
|
|
721
722
|
outcome: 'error',
|
|
722
723
|
});
|
|
723
724
|
});
|
|
725
|
+
|
|
726
|
+
it.each(['LEDGER_POOL_RESET', 'constructor'])(
|
|
727
|
+
'emits an error event, not a rejection, when the upstream throws with the unknown reason %s (#238)',
|
|
728
|
+
async (reason) => {
|
|
729
|
+
const backend = makeMockBackend();
|
|
730
|
+
vi.mocked(backend.callTool).mockRejectedValueOnce(
|
|
731
|
+
Object.assign(new Error('upstream failure'), {
|
|
732
|
+
data: { rejectionReason: reason },
|
|
733
|
+
}),
|
|
734
|
+
);
|
|
735
|
+
const events: ToolCallTelemetryEvent[] = [];
|
|
736
|
+
const server = createProxyServer(backend, {
|
|
737
|
+
onTelemetry: (e) => {
|
|
738
|
+
if (e.type === 'tool_call') events.push(e);
|
|
739
|
+
},
|
|
740
|
+
name: 'test-server',
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
await expect(
|
|
744
|
+
invokeHandler(server, 'tools/call', { name: 'echo', arguments: {} }),
|
|
745
|
+
).rejects.toBeDefined();
|
|
746
|
+
|
|
747
|
+
expect(events).toHaveLength(1);
|
|
748
|
+
expect(events[0]?.outcome).toBe('error');
|
|
749
|
+
expect(Object.hasOwn(events[0]!, 'rejectionReason')).toBe(false);
|
|
750
|
+
},
|
|
751
|
+
);
|
|
724
752
|
});
|
|
725
753
|
|
|
726
754
|
describe('createProxyServer() — server identity', () => {
|
|
@@ -929,6 +957,64 @@ describe('createProxyServer() — telemetry outcomes', () => {
|
|
|
929
957
|
expect(consoleSpy).toHaveBeenCalled();
|
|
930
958
|
consoleSpy.mockRestore();
|
|
931
959
|
});
|
|
960
|
+
|
|
961
|
+
it('does not fail the call when an async onTelemetry rejects', async () => {
|
|
962
|
+
const backend = makeMockBackend();
|
|
963
|
+
const consoleSpy = vi
|
|
964
|
+
.spyOn(console, 'error')
|
|
965
|
+
.mockImplementation(() => undefined);
|
|
966
|
+
const rejection = new Error('exporter down');
|
|
967
|
+
const server = createProxyServer(backend, {
|
|
968
|
+
onTelemetry: async () => {
|
|
969
|
+
await Promise.resolve();
|
|
970
|
+
throw rejection;
|
|
971
|
+
},
|
|
972
|
+
name: 'test-server',
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
const result = await invokeHandler(server, 'tools/call', {
|
|
976
|
+
name: 'normal_tool',
|
|
977
|
+
arguments: {},
|
|
978
|
+
});
|
|
979
|
+
expect(result).toMatchObject({
|
|
980
|
+
content: [{ type: 'text', text: 'raw upstream response' }],
|
|
981
|
+
});
|
|
982
|
+
// A rejection nobody observes would surface as an unhandled rejection and
|
|
983
|
+
// fail the run, so reaching here at all is part of the assertion; the spy
|
|
984
|
+
// pins that it was also logged rather than dropped.
|
|
985
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
986
|
+
expect(consoleSpy).toHaveBeenCalledWith(rejection);
|
|
987
|
+
consoleSpy.mockRestore();
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('does not fail the call when onTelemetry returns a cross-realm promise that rejects', async () => {
|
|
991
|
+
const backend = makeMockBackend();
|
|
992
|
+
const consoleSpy = vi
|
|
993
|
+
.spyOn(console, 'error')
|
|
994
|
+
.mockImplementation(() => undefined);
|
|
995
|
+
const rejection = new Error('exporter down');
|
|
996
|
+
// A sink loaded from a `node:vm` sandbox hands back a promise built by that
|
|
997
|
+
// realm's `Promise`, so it is not `instanceof Promise` here; the guard has
|
|
998
|
+
// to adopt it rather than test its constructor.
|
|
999
|
+
const rejectFromOtherRealm = runInNewContext(
|
|
1000
|
+
'(reason) => Promise.reject(reason)',
|
|
1001
|
+
) as (reason: unknown) => unknown;
|
|
1002
|
+
const server = createProxyServer(backend, {
|
|
1003
|
+
onTelemetry: () => rejectFromOtherRealm(rejection),
|
|
1004
|
+
name: 'test-server',
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
const result = await invokeHandler(server, 'tools/call', {
|
|
1008
|
+
name: 'normal_tool',
|
|
1009
|
+
arguments: {},
|
|
1010
|
+
});
|
|
1011
|
+
expect(result).toMatchObject({
|
|
1012
|
+
content: [{ type: 'text', text: 'raw upstream response' }],
|
|
1013
|
+
});
|
|
1014
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1015
|
+
expect(consoleSpy).toHaveBeenCalledWith(rejection);
|
|
1016
|
+
consoleSpy.mockRestore();
|
|
1017
|
+
});
|
|
932
1018
|
});
|
|
933
1019
|
|
|
934
1020
|
describe('createProxyServer() — promptMiddleware', () => {
|
|
@@ -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,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
|
+
});
|
|
@@ -147,6 +147,27 @@ describe('sanitizeToolDescriptions()', () => {
|
|
|
147
147
|
expect(result.tools[0]?.description).toBe('see for details');
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it.each([
|
|
151
|
+
['uppercase', 'HTTPS'],
|
|
152
|
+
['mixed case', 'HtTp'],
|
|
153
|
+
['mixed-case with s', 'HTtpS'],
|
|
154
|
+
])(
|
|
155
|
+
'strips a %s scheme, which RFC 3986 treats as case-insensitive',
|
|
156
|
+
async (_label, scheme) => {
|
|
157
|
+
const upstream: ListToolsResult = {
|
|
158
|
+
tools: [
|
|
159
|
+
{
|
|
160
|
+
name: 't',
|
|
161
|
+
description: `see ${scheme}://internal.host/a for details`,
|
|
162
|
+
inputSchema: { type: 'object' },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const result = await run(sanitizeToolDescriptions(), upstream);
|
|
167
|
+
expect(result.tools[0]?.description).toBe('see for details');
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
150
171
|
it('accepts a pattern that already carries the global flag', async () => {
|
|
151
172
|
const upstream: ListToolsResult = {
|
|
152
173
|
tools: [
|
|
@@ -221,6 +221,8 @@ describe('startHttpProxy() session lifecycle', () => {
|
|
|
221
221
|
['sessionTtlMs', 0],
|
|
222
222
|
['sessionTtlMs', -1],
|
|
223
223
|
['sessionTtlMs', Number.NaN],
|
|
224
|
+
['maxBodyBytes', Number.NaN],
|
|
225
|
+
['maxBodyBytes', -1],
|
|
224
226
|
])('rejects %s: %s at startup', (option, value) => {
|
|
225
227
|
expect(() =>
|
|
226
228
|
startHttpProxy(
|
|
@@ -270,6 +272,41 @@ describe('startHttpProxy() session lifecycle', () => {
|
|
|
270
272
|
await closeServer(server);
|
|
271
273
|
}
|
|
272
274
|
});
|
|
275
|
+
|
|
276
|
+
it('accepts maxBodyBytes: Infinity as the opt-out from the cap', async () => {
|
|
277
|
+
const server = await startHttpProxy(
|
|
278
|
+
makeMockBackend(),
|
|
279
|
+
{ name: 'test-server' },
|
|
280
|
+
{ port: 0, path: '/mcp', maxBodyBytes: Infinity },
|
|
281
|
+
);
|
|
282
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
283
|
+
try {
|
|
284
|
+
await initSession(baseUrl);
|
|
285
|
+
} finally {
|
|
286
|
+
await closeServer(server);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('keeps maxBodyBytes: 0 a cap that rejects every POST body', async () => {
|
|
291
|
+
// `0` is not the opt-out and not a typo to be forgiven: like
|
|
292
|
+
// `maxSessions: 0`, it keeps its literal meaning.
|
|
293
|
+
const server = await startHttpProxy(
|
|
294
|
+
makeMockBackend(),
|
|
295
|
+
{ name: 'test-server' },
|
|
296
|
+
{ port: 0, path: '/mcp', maxBodyBytes: 0 },
|
|
297
|
+
);
|
|
298
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
299
|
+
try {
|
|
300
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: MCP_HEADERS,
|
|
303
|
+
body: INIT_BODY,
|
|
304
|
+
});
|
|
305
|
+
expect(res.status).toBe(413);
|
|
306
|
+
} finally {
|
|
307
|
+
await closeServer(server);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
273
310
|
});
|
|
274
311
|
});
|
|
275
312
|
|
|
@@ -452,6 +452,86 @@ describe('startHttpProxy()', () => {
|
|
|
452
452
|
}
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
+
it('still answers 401 when onError itself throws (#238 sibling)', async () => {
|
|
456
|
+
// `onError` is observability, so its own failure must change nothing.
|
|
457
|
+
// Unguarded it replaces the 401 documented for a throwing
|
|
458
|
+
// `resolveIdentity` with a 500, and rejects out of the request handler.
|
|
459
|
+
const server = await startHttpProxy(
|
|
460
|
+
makeMockBackend(),
|
|
461
|
+
{ name: 'test-server' },
|
|
462
|
+
{
|
|
463
|
+
port: 0,
|
|
464
|
+
path: '/mcp',
|
|
465
|
+
onError: () => {
|
|
466
|
+
throw new Error('logging backend down');
|
|
467
|
+
},
|
|
468
|
+
resolveIdentity: () => {
|
|
469
|
+
throw new Error('jwks fetch failed');
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
headers: { 'Content-Type': 'application/json' },
|
|
479
|
+
body: initializeBody,
|
|
480
|
+
});
|
|
481
|
+
expect(res.status).toBe(401);
|
|
482
|
+
} finally {
|
|
483
|
+
await closeServer(server);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('still answers 401 when onError rejects asynchronously (#245)', async () => {
|
|
488
|
+
// `onError` is observability, so its own failure must change nothing.
|
|
489
|
+
// An async reporter that rejects after its first await escapes the
|
|
490
|
+
// synchronous guard: nothing observes the rejection, so Node's default
|
|
491
|
+
// --unhandled-rejections=throw ends the process.
|
|
492
|
+
const rejections: unknown[] = [];
|
|
493
|
+
const onUnhandled = (reason: unknown): void => {
|
|
494
|
+
rejections.push(reason);
|
|
495
|
+
};
|
|
496
|
+
process.on('unhandledRejection', onUnhandled);
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const server = await startHttpProxy(
|
|
500
|
+
makeMockBackend(),
|
|
501
|
+
{ name: 'test-server' },
|
|
502
|
+
{
|
|
503
|
+
port: 0,
|
|
504
|
+
path: '/mcp',
|
|
505
|
+
onError: async () => {
|
|
506
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
507
|
+
throw new Error('logging backend down');
|
|
508
|
+
},
|
|
509
|
+
resolveIdentity: () => {
|
|
510
|
+
throw new Error('jwks fetch failed');
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
);
|
|
514
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
headers: { 'Content-Type': 'application/json' },
|
|
520
|
+
body: initializeBody,
|
|
521
|
+
});
|
|
522
|
+
expect(res.status).toBe(401);
|
|
523
|
+
// One macrotask turn, so a rejection nobody adopted has surfaced as
|
|
524
|
+
// an unhandledRejection event by the time this asserts.
|
|
525
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
526
|
+
expect(rejections).toHaveLength(0);
|
|
527
|
+
} finally {
|
|
528
|
+
await closeServer(server);
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
process.removeListener('unhandledRejection', onUnhandled);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
455
535
|
it('reports an error thrown by validateSession instead of only discarding it', async () => {
|
|
456
536
|
const errors: unknown[] = [];
|
|
457
537
|
const server = await startHttpProxy(
|