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
package/dist/core.js
CHANGED
|
@@ -3,54 +3,98 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
3
3
|
import * as http from 'node:http';
|
|
4
4
|
import * as https from 'node:https';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { createRequire } from 'node:module';
|
|
7
6
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
8
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
10
|
-
import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, PromptListChangedNotificationSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
-
import { pipe } from './middleware.js';
|
|
12
|
-
import { createProxyContext } from './proxyContext.js';
|
|
13
|
-
import {
|
|
9
|
+
import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, PromptListChangedNotificationSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { pipe, isPassThroughObserver } from './middleware.js';
|
|
11
|
+
import { createProxyContext, } from './proxyContext.js';
|
|
12
|
+
import { listAcrossMesh, namespaceName, namespaceUri, normalizeBackends, routeNamespaced, routeResourceUri, } from './mesh.js';
|
|
13
|
+
import { attachDelegationMeta, detectDelegationLoop, readInboundDelegation, } from './delegation.js';
|
|
14
|
+
import { createInMemoryEventStore, scopeEventStore } from './eventStore.js';
|
|
15
|
+
import { initializeParamsOf, replayInitialize, } from './sessionRegistry.js';
|
|
14
16
|
import { rejectionMcpError } from './rejection.js';
|
|
15
|
-
|
|
16
|
-
// ESM on Node 18+ without the `--experimental-json-modules` flag and avoids the
|
|
17
|
-
// tsconfig and runtime caveats of a static JSON import.
|
|
18
|
-
const readPackageJson = createRequire(import.meta.url);
|
|
19
|
-
const pkg = readPackageJson('../package.json');
|
|
20
|
-
/** Name the proxy advertises when `ProxyOptions.name` is omitted. */
|
|
21
|
-
const DEFAULT_PROXY_NAME = 'mcpose';
|
|
17
|
+
import { VERSION } from './version.js';
|
|
22
18
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* The result channels of a `CallToolResult` — everything a redaction or
|
|
20
|
+
* transform middleware must account for:
|
|
21
|
+
*
|
|
22
|
+
* 1. `content` blocks (text, image, audio, resource link, embedded
|
|
23
|
+
* resource), each carrying its own block-level `_meta`,
|
|
24
|
+
* 2. `structuredContent`, the machine-readable mirror of the content,
|
|
25
|
+
* 3. result-level `_meta`,
|
|
26
|
+
* 4. `isError`,
|
|
27
|
+
* 5. unknown extra keys via the index signature.
|
|
28
|
+
*
|
|
29
|
+
* Redaction that maps only `.content` misses 2, 3, and the non-text
|
|
30
|
+
* members of 1. Use {@link mapToolResult} to cover every payload channel
|
|
31
|
+
* by construction; channel 3 is stripped at the proxy boundary by default
|
|
32
|
+
* via `ProxyOptions.stripResultMeta` (ADR-0009).
|
|
33
|
+
*
|
|
34
|
+
* This guard narrows `CompatibilityCallToolResult` to `CallToolResult`
|
|
35
|
+
* (has `.content` array). Both union members carry `[x: string]: unknown`,
|
|
36
|
+
* so this avoids unsafe casts.
|
|
25
37
|
*/
|
|
26
38
|
export function hasToolContent(r) {
|
|
27
39
|
return Array.isArray(r.content);
|
|
28
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Maps every payload channel of a tool result through the given handlers.
|
|
43
|
+
*
|
|
44
|
+
* The legacy `{ toolResult }` shape (protocol 2024-10-07) is returned
|
|
45
|
+
* untouched. `isError`, result-level `_meta`, and unknown extra keys are
|
|
46
|
+
* preserved by spread — result-level `_meta` needs no handler because the
|
|
47
|
+
* proxy strips it by default (`stripResultMeta`, ADR-0009); with
|
|
48
|
+
* `stripResultMeta: false` forwarding it is the consumer's explicit choice.
|
|
49
|
+
*/
|
|
50
|
+
export function mapToolResult(result, handlers) {
|
|
51
|
+
if (!hasToolContent(result))
|
|
52
|
+
return result;
|
|
53
|
+
const content = result.content
|
|
54
|
+
.map((block) => block.type === 'text' ? handlers.onText(block) : handlers.onOther(block))
|
|
55
|
+
.filter((block) => block !== null);
|
|
56
|
+
const structured = result.structuredContent === undefined
|
|
57
|
+
? undefined
|
|
58
|
+
: handlers.onStructured(result.structuredContent);
|
|
59
|
+
const { structuredContent: _dropped, ...rest } = result;
|
|
60
|
+
return {
|
|
61
|
+
...rest,
|
|
62
|
+
content,
|
|
63
|
+
...(structured === undefined ? {} : { structuredContent: structured }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
29
66
|
const listChangedBuses = new WeakMap();
|
|
30
67
|
const httpProxyContext = new AsyncLocalStorage();
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Union of every backend's capabilities, plus the ADR-0007 rule that a
|
|
70
|
+
* non-empty `localTools` advertises `tools` on its own.
|
|
71
|
+
*/
|
|
72
|
+
function createProxyCapabilities(entries, hasLocalTools) {
|
|
73
|
+
const upstreams = entries.map((entry) => entry.client.getServerCapabilities());
|
|
74
|
+
const listChanged = (supported) => supported ? { listChanged: true } : {};
|
|
33
75
|
return {
|
|
34
|
-
...(
|
|
35
|
-
? { tools:
|
|
76
|
+
...(upstreams.some((u) => u?.tools) || hasLocalTools
|
|
77
|
+
? { tools: listChanged(upstreams.some((u) => u?.tools?.listChanged)) }
|
|
36
78
|
: {}),
|
|
37
|
-
...(
|
|
79
|
+
...(upstreams.some((u) => u?.resources)
|
|
38
80
|
? {
|
|
39
|
-
resources:
|
|
40
|
-
? { listChanged: true }
|
|
41
|
-
: {},
|
|
81
|
+
resources: listChanged(upstreams.some((u) => u?.resources?.listChanged)),
|
|
42
82
|
}
|
|
43
83
|
: {}),
|
|
44
|
-
...(
|
|
45
|
-
? { prompts:
|
|
84
|
+
...(upstreams.some((u) => u?.prompts)
|
|
85
|
+
? { prompts: listChanged(upstreams.some((u) => u?.prompts?.listChanged)) }
|
|
46
86
|
: {}),
|
|
47
87
|
};
|
|
48
88
|
}
|
|
49
89
|
function createRequestOptions(extra = {}) {
|
|
50
90
|
const progressToken = extra._meta?.progressToken;
|
|
51
|
-
|
|
91
|
+
// `0` and `''` are legitimate progress tokens — only undefined disables.
|
|
92
|
+
const onprogress = progressToken !== undefined && extra.sendNotification
|
|
52
93
|
? ({ progress, total, message, }) => {
|
|
53
|
-
|
|
94
|
+
// A client that disconnected mid-call makes sendNotification
|
|
95
|
+
// reject; dropping the progress tick is the correct outcome.
|
|
96
|
+
void extra
|
|
97
|
+
.sendNotification?.({
|
|
54
98
|
method: 'notifications/progress',
|
|
55
99
|
params: {
|
|
56
100
|
progressToken,
|
|
@@ -58,7 +102,8 @@ function createRequestOptions(extra = {}) {
|
|
|
58
102
|
...(total === undefined ? {} : { total }),
|
|
59
103
|
...(message === undefined ? {} : { message }),
|
|
60
104
|
},
|
|
61
|
-
})
|
|
105
|
+
})
|
|
106
|
+
.catch(() => { });
|
|
62
107
|
}
|
|
63
108
|
: undefined;
|
|
64
109
|
if (!extra.signal && !onprogress)
|
|
@@ -68,8 +113,22 @@ function createRequestOptions(extra = {}) {
|
|
|
68
113
|
...(onprogress ? { onprogress } : {}),
|
|
69
114
|
};
|
|
70
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Credential-bearing headers are stripped before headers reach
|
|
118
|
+
* `ProxyContext` (and through it, audit logs). Identity resolution reads
|
|
119
|
+
* the raw `http.IncomingMessage`, so `resolveIdentity` still sees them.
|
|
120
|
+
*/
|
|
121
|
+
const SENSITIVE_HEADERS = new Set([
|
|
122
|
+
'authorization',
|
|
123
|
+
'proxy-authorization',
|
|
124
|
+
'cookie',
|
|
125
|
+
'set-cookie',
|
|
126
|
+
'x-api-key',
|
|
127
|
+
]);
|
|
71
128
|
function normalizeHeaders(headers) {
|
|
72
129
|
const normalized = Object.entries(headers).reduce((acc, [key, value]) => {
|
|
130
|
+
if (SENSITIVE_HEADERS.has(key))
|
|
131
|
+
return acc;
|
|
73
132
|
if (typeof value === 'string') {
|
|
74
133
|
acc[key] = value;
|
|
75
134
|
return acc;
|
|
@@ -81,143 +140,421 @@ function normalizeHeaders(headers) {
|
|
|
81
140
|
}, {});
|
|
82
141
|
return Object.keys(normalized).length ? normalized : undefined;
|
|
83
142
|
}
|
|
84
|
-
function getMiddlewareContext(signal) {
|
|
143
|
+
function getMiddlewareContext(proxy, signal, delegatedFrom) {
|
|
144
|
+
const store = httpProxyContext.getStore();
|
|
85
145
|
return createProxyContext({
|
|
86
|
-
...
|
|
146
|
+
...store,
|
|
87
147
|
...(signal === undefined ? {} : { signal }),
|
|
148
|
+
// A host-stamped chain wins; an extracted one fills the field only when
|
|
149
|
+
// the host left it unset, which keeps the extraction additive (ADR-0016).
|
|
150
|
+
delegatedFrom: store?.delegatedFrom ?? delegatedFrom,
|
|
151
|
+
proxy,
|
|
88
152
|
});
|
|
89
153
|
}
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
tools: result.tools.filter((tool) => !hiddenToolSet.has(tool.name)),
|
|
96
|
-
};
|
|
154
|
+
function getRejectionReason(err) {
|
|
155
|
+
const data = err?.data;
|
|
156
|
+
return typeof data?.rejectionReason === 'string'
|
|
157
|
+
? data.rejectionReason
|
|
158
|
+
: undefined;
|
|
97
159
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Removes `params._meta` at the proxy boundary, before the pipeline runs,
|
|
162
|
+
* so middleware sees the stripped request, can still add its own `_meta`
|
|
163
|
+
* deliberately, and audit hashes cover what is actually forwarded.
|
|
164
|
+
* Identity function when there is nothing to strip.
|
|
165
|
+
*/
|
|
166
|
+
function stripParamsMeta(req) {
|
|
167
|
+
if (req.params === undefined || !('_meta' in req.params))
|
|
168
|
+
return req;
|
|
169
|
+
const { _meta: _stripped, ...params } = req.params;
|
|
170
|
+
return { ...req, params };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Removes top-level `_meta` from an upstream result at the proxy boundary,
|
|
174
|
+
* inside the innermost `next`, so middleware sees the stripped result and
|
|
175
|
+
* middleware-added `_meta` still reaches the client. Nested `_meta`
|
|
176
|
+
* (per-tool in `tools[]`, per-block in `content`) is untouched.
|
|
177
|
+
* Identity function when there is nothing to strip.
|
|
178
|
+
*/
|
|
179
|
+
function stripTopLevelMeta(res) {
|
|
180
|
+
if (!('_meta' in res))
|
|
181
|
+
return res;
|
|
182
|
+
const { _meta: _stripped, ...rest } = res;
|
|
183
|
+
// Omit<Res, '_meta'> still satisfies Res: `_meta` is optional.
|
|
184
|
+
return rest;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Validates and indexes `localTools`. Throws on a duplicate name: there is
|
|
188
|
+
* no correct way to pick one, and silently keeping the last would route
|
|
189
|
+
* calls to a handler the configuration does not obviously name.
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* Rejects a blank proxy name at construction, like a duplicate local tool name.
|
|
193
|
+
*
|
|
194
|
+
* The name is recorded as provenance on every `ProxyContext`, audit event, and
|
|
195
|
+
* `ReplayManifest` (ADR-0012), so a blank one produces a trail that looks
|
|
196
|
+
* attributed and is not (#122). The negated check covers all three bad values
|
|
197
|
+
* at once: whitespace-only trims to `''`, and `''` and an omitted name (from a
|
|
198
|
+
* JS caller the type does not bind) are falsy already.
|
|
199
|
+
*/
|
|
200
|
+
function assertProxyName(name) {
|
|
201
|
+
if (!name?.trim()) {
|
|
202
|
+
throw new Error(`mcpose: name must be a non-empty string, got ${JSON.stringify(name)}`);
|
|
103
203
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
backend.setNotificationHandler(ToolListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendToolListChanged()));
|
|
204
|
+
}
|
|
205
|
+
function buildLocalToolMap(localTools) {
|
|
206
|
+
const localToolMap = new Map();
|
|
207
|
+
for (const localTool of localTools ?? []) {
|
|
208
|
+
const toolName = localTool.tool.name;
|
|
209
|
+
if (localToolMap.has(toolName)) {
|
|
210
|
+
throw new Error(`mcpose: duplicate local tool name "${toolName}"`);
|
|
112
211
|
}
|
|
113
|
-
|
|
114
|
-
|
|
212
|
+
localToolMap.set(toolName, localTool);
|
|
213
|
+
}
|
|
214
|
+
return localToolMap;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The backends advertising one surface, ready to list across and to route
|
|
218
|
+
* by backend key. Only these are queried or routed to, so an otherwise-valid
|
|
219
|
+
* prefix naming a backend without the surface is unroutable rather than a
|
|
220
|
+
* call the upstream cannot serve.
|
|
221
|
+
*/
|
|
222
|
+
function surfaceBackends(entries, surface) {
|
|
223
|
+
const selected = entries.filter((entry) => entry.client.getServerCapabilities()?.[surface] !== undefined);
|
|
224
|
+
return {
|
|
225
|
+
entries: selected,
|
|
226
|
+
byKey: new Map(selected.map((entry) => [entry.key, entry.client])),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Normalizes the `hiddenTools` option to its predicate form. */
|
|
230
|
+
function toHiddenToolPredicate(hiddenTools) {
|
|
231
|
+
if (typeof hiddenTools === 'function')
|
|
232
|
+
return hiddenTools;
|
|
233
|
+
const hiddenToolSet = new Set(hiddenTools ?? []);
|
|
234
|
+
return (name) => hiddenToolSet.has(name);
|
|
235
|
+
}
|
|
236
|
+
function filterHiddenTools(result, isHiddenTool) {
|
|
237
|
+
// List phase: a listed tool has no arguments, so args is undefined.
|
|
238
|
+
const tools = result.tools.filter((tool) => !isHiddenTool(tool.name, undefined));
|
|
239
|
+
return tools.length === result.tools.length ? result : { ...result, tools };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Subscribes `server` to every backend's list-changed notifications, so a
|
|
243
|
+
* mesh fans them in through the same per-backend bus a 1:1 proxy uses.
|
|
244
|
+
*
|
|
245
|
+
* Every subscriber advertises the union of its backends' surfaces, so a
|
|
246
|
+
* notification a bus can emit is always one its subscribers accept, and the
|
|
247
|
+
* fan-out needs no per-surface filter.
|
|
248
|
+
*/
|
|
249
|
+
function registerListChangedForwarders(entries, server, capabilities) {
|
|
250
|
+
const leaveBuses = [];
|
|
251
|
+
for (const { client } of entries) {
|
|
252
|
+
const upstream = client.getServerCapabilities() ?? {};
|
|
253
|
+
const surfaces = [
|
|
254
|
+
'tools',
|
|
255
|
+
'prompts',
|
|
256
|
+
'resources',
|
|
257
|
+
];
|
|
258
|
+
if (!surfaces.some((surface) => capabilities[surface]?.listChanged === true &&
|
|
259
|
+
upstream[surface]?.listChanged === true)) {
|
|
260
|
+
continue;
|
|
115
261
|
}
|
|
116
|
-
|
|
117
|
-
|
|
262
|
+
let bus = listChangedBuses.get(client);
|
|
263
|
+
if (!bus) {
|
|
264
|
+
const servers = new Set();
|
|
265
|
+
const fanOut = async (notify) => {
|
|
266
|
+
await Promise.allSettled([...servers].map(notify));
|
|
267
|
+
};
|
|
268
|
+
if (upstream.tools?.listChanged) {
|
|
269
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendToolListChanged()));
|
|
270
|
+
}
|
|
271
|
+
if (upstream.prompts?.listChanged) {
|
|
272
|
+
client.setNotificationHandler(PromptListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendPromptListChanged()));
|
|
273
|
+
}
|
|
274
|
+
if (upstream.resources?.listChanged) {
|
|
275
|
+
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendResourceListChanged()));
|
|
276
|
+
}
|
|
277
|
+
bus = { servers };
|
|
278
|
+
listChangedBuses.set(client, bus);
|
|
118
279
|
}
|
|
119
|
-
|
|
120
|
-
|
|
280
|
+
const joined = bus;
|
|
281
|
+
joined.servers.add(server);
|
|
282
|
+
let active = true;
|
|
283
|
+
leaveBuses.push(() => {
|
|
284
|
+
if (!active)
|
|
285
|
+
return;
|
|
286
|
+
active = false;
|
|
287
|
+
joined.servers.delete(server);
|
|
288
|
+
if (!joined.servers.size)
|
|
289
|
+
listChangedBuses.delete(client);
|
|
290
|
+
});
|
|
121
291
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
292
|
+
if (leaveBuses.length === 0)
|
|
293
|
+
return;
|
|
294
|
+
// Accessor instead of plain assignment: a consumer setting `server.onclose`
|
|
295
|
+
// after createProxyServer() must not clobber the bus cleanup (which would
|
|
296
|
+
// leak this server into the fan-out set forever).
|
|
297
|
+
let consumerOnClose;
|
|
298
|
+
Object.defineProperty(server, 'onclose', {
|
|
299
|
+
configurable: true,
|
|
300
|
+
get: () => () => {
|
|
301
|
+
for (const leaveBus of leaveBuses)
|
|
302
|
+
leaveBus();
|
|
303
|
+
consumerOnClose?.();
|
|
304
|
+
},
|
|
305
|
+
set: (fn) => {
|
|
306
|
+
consumerOnClose = fn;
|
|
307
|
+
},
|
|
308
|
+
});
|
|
134
309
|
}
|
|
135
310
|
/**
|
|
136
311
|
* Creates a proxy MCP server without connecting it to a transport.
|
|
137
312
|
*
|
|
138
313
|
* Mirrors upstream tool/resource/prompt lists and routes requests through
|
|
139
|
-
* middleware pipelines.
|
|
314
|
+
* middleware pipelines. `prompts/list` is forwarded as-is; `prompts/get`
|
|
315
|
+
* runs `promptMiddleware` (ADR-0014).
|
|
140
316
|
*
|
|
141
317
|
* Uses low-level `Server` (not `McpServer`) — transparent proxying requires
|
|
142
318
|
* generic list interception; `McpServer.tool()` needs names upfront.
|
|
143
319
|
*
|
|
144
|
-
* @param
|
|
320
|
+
* @param backends - Connected (or mock) upstream MCP client for a 1:1 proxy,
|
|
321
|
+
* or a record of named backends for mesh mode (ADR-0013), where every
|
|
322
|
+
* upstream tool and prompt is exposed as `<backendKey>__<name>`.
|
|
145
323
|
* @param options - Middleware stacks, hidden/passthrough sets.
|
|
146
324
|
* @returns Configured {@link Server} ready to connect.
|
|
147
325
|
*/
|
|
148
|
-
export function createProxyServer(
|
|
149
|
-
|
|
326
|
+
export function createProxyServer(backends, options) {
|
|
327
|
+
assertProxyName(options.name);
|
|
328
|
+
const { mesh, entries } = normalizeBackends(backends);
|
|
329
|
+
for (const { key, client } of entries) {
|
|
330
|
+
if (client.getServerCapabilities() === undefined) {
|
|
331
|
+
throw new Error(`mcpose: backend${mesh ? ` "${key}"` : ''} is not connected (getServerCapabilities() returned undefined). Connect the backend before calling createProxyServer().`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const backend = entries[0].client;
|
|
335
|
+
const localToolMap = buildLocalToolMap(options.localTools);
|
|
336
|
+
const toolBackends = surfaceBackends(entries, 'tools');
|
|
337
|
+
const promptBackends = surfaceBackends(entries, 'prompts');
|
|
338
|
+
const resourceBackends = surfaceBackends(entries, 'resources');
|
|
339
|
+
const upstreamHasTools = toolBackends.entries.length > 0;
|
|
340
|
+
const capabilities = createProxyCapabilities(entries, localToolMap.size > 0);
|
|
150
341
|
const toolPipeline = pipe(options.toolMiddleware ?? []);
|
|
342
|
+
// Pass-through tools skip transforming middleware but are still seen by
|
|
343
|
+
// observers (audit, telemetry) marked via markPassThroughObserver().
|
|
344
|
+
const passThroughToolPipeline = pipe((options.toolMiddleware ?? []).filter(isPassThroughObserver));
|
|
151
345
|
const resourcePipeline = pipe(options.resourceMiddleware ?? []);
|
|
346
|
+
const promptPipeline = pipe(options.promptMiddleware ?? []);
|
|
152
347
|
const listToolsPipeline = pipe(options.listToolsMiddleware ?? []);
|
|
153
|
-
const
|
|
348
|
+
const isHiddenTool = toHiddenToolPredicate(options.hiddenTools);
|
|
349
|
+
// Applied uniformly at every handler entry, pass-through included: a
|
|
350
|
+
// privacy control a per-tool option could silently switch off would be
|
|
351
|
+
// the same false-confidence failure as the dispatcher bypass (ADR-0006).
|
|
352
|
+
const stripMeta = options.stripRequestMeta !== false;
|
|
353
|
+
const stripRequest = (req) => (stripMeta ? stripParamsMeta(req) : req);
|
|
354
|
+
// The response-direction mirror: applied to every upstream result at the
|
|
355
|
+
// innermost `next`, never to local tool results (they have no upstream).
|
|
356
|
+
const stripResMeta = options.stripResultMeta !== false;
|
|
357
|
+
const stripResult = (res) => stripResMeta ? stripTopLevelMeta(res) : res;
|
|
154
358
|
const passThroughToolSet = new Set(options.passThroughTools ?? []);
|
|
155
359
|
const hiddenResourceSet = new Set(options.hiddenResources ?? []);
|
|
156
360
|
const passThroughResourceSet = new Set(options.passThroughResources ?? []);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
361
|
+
// Stamped onto every ProxyContext so middleware and the audit trail can
|
|
362
|
+
// attribute events to this proxy instance (#85, ADR-0012). Frozen because
|
|
363
|
+
// the one object is aliased into the SDK Server, every context, and every
|
|
364
|
+
// audit event: a middleware mutating it would rewrite provenance already
|
|
365
|
+
// recorded elsewhere.
|
|
366
|
+
const proxyIdentity = Object.freeze({
|
|
367
|
+
name: options.name,
|
|
368
|
+
version: options.version ?? VERSION,
|
|
369
|
+
});
|
|
370
|
+
const server = new Server(proxyIdentity, { capabilities });
|
|
371
|
+
registerListChangedForwarders(entries, server, capabilities);
|
|
372
|
+
/**
|
|
373
|
+
* Reports a backend that dropped out of a mesh list. Failure isolation is
|
|
374
|
+
* only safe if the gap is visible, and a throwing sink must not turn a
|
|
375
|
+
* degraded list into a failed one.
|
|
376
|
+
*/
|
|
377
|
+
const reportDegraded = (context, backendKey, method, error) => {
|
|
378
|
+
try {
|
|
379
|
+
options.onTelemetry?.({
|
|
380
|
+
type: 'backend_degraded',
|
|
381
|
+
requestId: context.requestId,
|
|
382
|
+
...(context.sessionId === undefined
|
|
383
|
+
? {}
|
|
384
|
+
: { sessionId: context.sessionId }),
|
|
385
|
+
backend: backendKey,
|
|
386
|
+
method,
|
|
387
|
+
error,
|
|
388
|
+
...(context.identity === undefined
|
|
389
|
+
? {}
|
|
390
|
+
: { identity: context.identity }),
|
|
391
|
+
...(context.proxy === undefined ? {} : { proxy: context.proxy }),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
console.error(err);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
162
398
|
// ── Tool handlers ──────────────────────────────────────────────────────────
|
|
163
399
|
if (capabilities.tools) {
|
|
164
|
-
server.setRequestHandler(ListToolsRequestSchema, async (
|
|
400
|
+
server.setRequestHandler(ListToolsRequestSchema, async (rawReq, extra) => {
|
|
401
|
+
// Read the chain before the strip, or it never survives a chained
|
|
402
|
+
// proxy (ADR-0016, the ADR-0011 boundary-ordering constraint).
|
|
403
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
404
|
+
const req = stripRequest(rawReq);
|
|
165
405
|
const requestOptions = createRequestOptions(extra);
|
|
166
|
-
const context = getMiddlewareContext(extra.signal);
|
|
167
|
-
const
|
|
168
|
-
|
|
406
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
407
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
408
|
+
// Local tools are overlaid inside the innermost `next`, so
|
|
409
|
+
// listToolsMiddleware sees them exactly as it sees upstream tools.
|
|
410
|
+
// They are added to the first page only (no cursor), so a paginating
|
|
411
|
+
// client sees each local tool once; the shadow filter runs on every
|
|
412
|
+
// page, so the client sees exactly one entry per name.
|
|
413
|
+
const result = await listToolsPipeline(req, async (currentReq) => {
|
|
414
|
+
// Thrown inside the pipeline, so observing middleware records the
|
|
415
|
+
// attempt rather than seeing a call that never happened.
|
|
416
|
+
if (delegationError !== undefined)
|
|
417
|
+
throw delegationError;
|
|
418
|
+
const upstreamResult = mesh
|
|
419
|
+
? {
|
|
420
|
+
// A mesh has no cursor that means "page 3 of the union", so
|
|
421
|
+
// every backend is drained into one complete page.
|
|
422
|
+
tools: await listAcrossMesh(toolBackends.entries, async (client, cursor) => {
|
|
423
|
+
const page = stripResult(await client.listTools(attachDelegationMeta(cursor === undefined ? {} : { cursor }, context), requestOptions));
|
|
424
|
+
return { items: page.tools, nextCursor: page.nextCursor };
|
|
425
|
+
}, (key, error) => reportDegraded(context, key, 'tools/list', error), namespaceName),
|
|
426
|
+
}
|
|
427
|
+
: upstreamHasTools
|
|
428
|
+
? stripResult(await backend.listTools(attachDelegationMeta(currentReq.params, context), requestOptions))
|
|
429
|
+
: { tools: [] };
|
|
430
|
+
if (!localToolMap.size) {
|
|
431
|
+
return filterHiddenTools(upstreamResult, isHiddenTool);
|
|
432
|
+
}
|
|
433
|
+
const tools = [
|
|
434
|
+
...upstreamResult.tools.filter((t) => !localToolMap.has(t.name)),
|
|
435
|
+
// A mesh response is always the only page, so local tools belong
|
|
436
|
+
// on it whatever cursor the client sent.
|
|
437
|
+
...(mesh || currentReq.params?.cursor === undefined
|
|
438
|
+
? [...localToolMap.values()].map((lt) => lt.tool)
|
|
439
|
+
: []),
|
|
440
|
+
];
|
|
441
|
+
return filterHiddenTools({ ...upstreamResult, tools }, isHiddenTool);
|
|
442
|
+
}, context);
|
|
443
|
+
return filterHiddenTools(result, isHiddenTool);
|
|
169
444
|
});
|
|
170
|
-
server.setRequestHandler(CallToolRequestSchema, async (
|
|
445
|
+
server.setRequestHandler(CallToolRequestSchema, async (rawReq, extra) => {
|
|
446
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
447
|
+
const req = stripRequest(rawReq);
|
|
171
448
|
const name = req.params.name;
|
|
172
449
|
const requestOptions = createRequestOptions(extra);
|
|
173
|
-
const context = getMiddlewareContext(extra.signal);
|
|
174
|
-
const
|
|
450
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
451
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
452
|
+
const start = performance.now();
|
|
175
453
|
const emitTelemetry = (outcome, rejectionReason) => {
|
|
176
|
-
options.onTelemetry?.({
|
|
177
|
-
type: 'tool_call',
|
|
178
|
-
requestId: context.requestId,
|
|
179
|
-
...(context.sessionId === undefined
|
|
180
|
-
? {}
|
|
181
|
-
: { sessionId: context.sessionId }),
|
|
182
|
-
tool: name,
|
|
183
|
-
duration_ms: Date.now() - start,
|
|
184
|
-
outcome,
|
|
185
|
-
...(rejectionReason === undefined ? {} : { rejectionReason }),
|
|
186
|
-
...(context.identity === undefined
|
|
187
|
-
? {}
|
|
188
|
-
: { identity: context.identity }),
|
|
189
|
-
});
|
|
190
|
-
};
|
|
191
|
-
if (hiddenToolSet.has(name)) {
|
|
192
|
-
emitTelemetry('rejected', 'TOOL_HIDDEN');
|
|
193
|
-
throw rejectionMcpError('TOOL_HIDDEN', ErrorCode.MethodNotFound, `Tool not found: ${name}`);
|
|
194
|
-
}
|
|
195
|
-
if (passThroughToolSet.has(name)) {
|
|
196
454
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
455
|
+
options.onTelemetry?.({
|
|
456
|
+
type: 'tool_call',
|
|
457
|
+
requestId: context.requestId,
|
|
458
|
+
...(context.sessionId === undefined
|
|
459
|
+
? {}
|
|
460
|
+
: { sessionId: context.sessionId }),
|
|
461
|
+
tool: name,
|
|
462
|
+
duration_ms: Math.round(performance.now() - start),
|
|
463
|
+
outcome,
|
|
464
|
+
...(rejectionReason === undefined ? {} : { rejectionReason }),
|
|
465
|
+
...(context.identity === undefined
|
|
466
|
+
? {}
|
|
467
|
+
: { identity: context.identity }),
|
|
468
|
+
...(context.proxy === undefined ? {} : { proxy: context.proxy }),
|
|
469
|
+
});
|
|
200
470
|
}
|
|
201
471
|
catch (err) {
|
|
202
|
-
|
|
203
|
-
|
|
472
|
+
// A throwing telemetry sink must never fail the tool call.
|
|
473
|
+
console.error(err);
|
|
204
474
|
}
|
|
205
|
-
}
|
|
475
|
+
};
|
|
476
|
+
// Hidden beats pass-through. The rejection is thrown by the innermost
|
|
477
|
+
// `next` INSIDE the pipeline so middleware (audit) observes it in-chain;
|
|
478
|
+
// the backend is never called for hidden tools.
|
|
479
|
+
// Call phase: args is always an object, empty when the client sent
|
|
480
|
+
// none, so a dispatcher-aware predicate can fail closed on it.
|
|
481
|
+
const isHidden = isHiddenTool(name, req.params.arguments ?? {});
|
|
482
|
+
// Hidden beats local; local beats upstream; pass-through never
|
|
483
|
+
// applies to a local tool (it has no upstream response to forward).
|
|
484
|
+
const localTool = isHidden ? undefined : localToolMap.get(name);
|
|
485
|
+
const pipeline = !isHidden && localTool === undefined && passThroughToolSet.has(name)
|
|
486
|
+
? passThroughToolPipeline
|
|
487
|
+
: toolPipeline;
|
|
488
|
+
// The return type is annotated on the binding rather than on the arrow:
|
|
489
|
+
// Babel, which Stryker uses to instrument this file for mutation testing,
|
|
490
|
+
// cannot parse an async arrow carrying a return type inside a conditional.
|
|
491
|
+
const callBackend = isHidden
|
|
492
|
+
? async () => {
|
|
493
|
+
throw rejectionMcpError('TOOL_HIDDEN', ErrorCode.MethodNotFound, `Tool not found: ${name}`);
|
|
494
|
+
}
|
|
495
|
+
: localTool !== undefined
|
|
496
|
+
? (r) => localTool.handler(r.params, context)
|
|
497
|
+
: mesh
|
|
498
|
+
? async (r) => {
|
|
499
|
+
// Routed at the innermost `next` from the post-pipeline name,
|
|
500
|
+
// so middleware can re-route a call deliberately.
|
|
501
|
+
const routed = routeNamespaced(r.params.name, toolBackends.byKey);
|
|
502
|
+
if (routed === undefined) {
|
|
503
|
+
throw rejectionMcpError('BACKEND_UNROUTABLE', ErrorCode.MethodNotFound, `Tool not found: ${r.params.name} — a mesh exposes tools as "<backendKey>__<tool>"`);
|
|
504
|
+
}
|
|
505
|
+
return stripResult(await routed.client.callTool(attachDelegationMeta({ ...r.params, name: routed.name }, context), undefined, requestOptions));
|
|
506
|
+
}
|
|
507
|
+
: upstreamHasTools
|
|
508
|
+
? async (r) => stripResult(await backend.callTool(attachDelegationMeta(r.params, context), undefined, requestOptions))
|
|
509
|
+
: async () => {
|
|
510
|
+
// Tools-less upstream: reject inside the pipeline rather
|
|
511
|
+
// than forwarding to an upstream that cannot serve it.
|
|
512
|
+
throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`);
|
|
513
|
+
};
|
|
206
514
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
515
|
+
// A malformed chain replaces the innermost `next` rather than
|
|
516
|
+
// rejecting at the boundary, so the pipeline observes it, and it is
|
|
517
|
+
// decided before the tool is: the answer says nothing about the tool.
|
|
518
|
+
const result = await pipeline(req, delegationError === undefined
|
|
519
|
+
? callBackend
|
|
520
|
+
: () => Promise.reject(delegationError), context);
|
|
521
|
+
// MCP signals tool-level failures in-band via isError, not by throwing.
|
|
522
|
+
emitTelemetry(hasToolContent(result) && result.isError === true
|
|
523
|
+
? 'error'
|
|
524
|
+
: 'success');
|
|
209
525
|
return result;
|
|
210
526
|
}
|
|
211
527
|
catch (err) {
|
|
212
|
-
|
|
528
|
+
const reason = getRejectionReason(err);
|
|
529
|
+
if (reason === undefined)
|
|
530
|
+
emitTelemetry('error');
|
|
531
|
+
else
|
|
532
|
+
emitTelemetry('rejected', reason);
|
|
213
533
|
throw err;
|
|
214
534
|
}
|
|
215
535
|
});
|
|
216
536
|
}
|
|
217
537
|
// ── Resource handlers ──────────────────────────────────────────────────────
|
|
218
538
|
if (capabilities.resources) {
|
|
219
|
-
server.setRequestHandler(ListResourcesRequestSchema, async (
|
|
220
|
-
const
|
|
539
|
+
server.setRequestHandler(ListResourcesRequestSchema, async (rawReq, extra) => {
|
|
540
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
541
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
542
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
543
|
+
// `resources/list` runs no pipeline, so there is no observer to
|
|
544
|
+
// record the attempt: the rejection is thrown here instead.
|
|
545
|
+
if (delegationError !== undefined)
|
|
546
|
+
throw delegationError;
|
|
547
|
+
const requestOptions = createRequestOptions(extra);
|
|
548
|
+
const result = mesh
|
|
549
|
+
? {
|
|
550
|
+
// Each URI is exposed as `mcpose://<key>/<uri>` (ADR-0022), so
|
|
551
|
+
// two backends serving the same URI never collide.
|
|
552
|
+
resources: await listAcrossMesh(resourceBackends.entries, async (client, cursor) => {
|
|
553
|
+
const page = stripResult(await client.listResources(attachDelegationMeta(cursor === undefined ? {} : { cursor }, context), requestOptions));
|
|
554
|
+
return { items: page.resources, nextCursor: page.nextCursor };
|
|
555
|
+
}, (key, error) => reportDegraded(context, key, 'resources/list', error), namespaceUri),
|
|
556
|
+
}
|
|
557
|
+
: stripResult(await backend.listResources(attachDelegationMeta(stripRequest(rawReq).params, context), requestOptions));
|
|
221
558
|
if (!hiddenResourceSet.size)
|
|
222
559
|
return result;
|
|
223
560
|
return {
|
|
@@ -225,23 +562,107 @@ export function createProxyServer(backend, options = {}) {
|
|
|
225
562
|
resources: result.resources.filter((r) => !hiddenResourceSet.has(r.uri)),
|
|
226
563
|
};
|
|
227
564
|
});
|
|
228
|
-
server.setRequestHandler(ReadResourceRequestSchema, (
|
|
565
|
+
server.setRequestHandler(ReadResourceRequestSchema, (rawReq, extra) => {
|
|
566
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
567
|
+
const req = stripRequest(rawReq);
|
|
229
568
|
const uri = req.params.uri;
|
|
230
569
|
const requestOptions = createRequestOptions(extra);
|
|
231
|
-
const context = getMiddlewareContext(extra.signal);
|
|
570
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
571
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
232
572
|
if (hiddenResourceSet.has(uri)) {
|
|
233
573
|
throw rejectionMcpError('RESOURCE_HIDDEN', ErrorCode.InvalidRequest, `Resource not found: ${uri}`);
|
|
234
574
|
}
|
|
575
|
+
// In mesh mode the exposed URI is unwrapped to a backend and its own
|
|
576
|
+
// URI; an unwrappable one is rejected loudly, with no lookup of which
|
|
577
|
+
// backend listed it (ADR-0022). Resolved from the post-pipeline params
|
|
578
|
+
// so middleware can re-route a read deliberately.
|
|
579
|
+
const readUpstream = (params) => {
|
|
580
|
+
if (delegationError !== undefined)
|
|
581
|
+
throw delegationError;
|
|
582
|
+
if (!mesh) {
|
|
583
|
+
return backend
|
|
584
|
+
.readResource(attachDelegationMeta(params, context), requestOptions)
|
|
585
|
+
.then(stripResult);
|
|
586
|
+
}
|
|
587
|
+
const routed = routeResourceUri(params.uri, resourceBackends.byKey);
|
|
588
|
+
if (routed === undefined) {
|
|
589
|
+
throw rejectionMcpError('BACKEND_UNROUTABLE', ErrorCode.InvalidRequest, `Resource not found: ${params.uri} — a mesh exposes resources as "mcpose://<backendKey>/<uri>"`);
|
|
590
|
+
}
|
|
591
|
+
return routed.client
|
|
592
|
+
.readResource(attachDelegationMeta({ ...params, uri: routed.uri }, context), requestOptions)
|
|
593
|
+
.then(stripResult);
|
|
594
|
+
};
|
|
235
595
|
if (passThroughResourceSet.has(uri)) {
|
|
236
|
-
|
|
596
|
+
// A pass-through resource runs no pipeline at all, so as with
|
|
597
|
+
// `resources/list` the rejection is thrown here.
|
|
598
|
+
return readUpstream(req.params);
|
|
237
599
|
}
|
|
238
|
-
return resourcePipeline(req, (r) =>
|
|
600
|
+
return resourcePipeline(req, (r) => readUpstream(r.params), context);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// ── Prompt handlers ───────────────────────────────────────────────────────
|
|
604
|
+
// `prompts/get` runs the promptMiddleware pipeline; `prompts/list` is still
|
|
605
|
+
// pass-through (ADR-0014).
|
|
606
|
+
if (capabilities.prompts && mesh) {
|
|
607
|
+
// Prompt names are plain strings, so they namespace and route exactly
|
|
608
|
+
// like tool names (ADR-0013).
|
|
609
|
+
server.setRequestHandler(ListPromptsRequestSchema, async (rawReq, extra) => {
|
|
610
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
611
|
+
const requestOptions = createRequestOptions(extra);
|
|
612
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
613
|
+
// No pipeline here either, so the rejection is thrown at the handler.
|
|
614
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
615
|
+
if (delegationError !== undefined)
|
|
616
|
+
throw delegationError;
|
|
617
|
+
return {
|
|
618
|
+
prompts: await listAcrossMesh(promptBackends.entries, async (client, cursor) => {
|
|
619
|
+
const page = stripResult(await client.listPrompts(attachDelegationMeta(cursor === undefined ? {} : { cursor }, context), requestOptions));
|
|
620
|
+
return { items: page.prompts, nextCursor: page.nextCursor };
|
|
621
|
+
}, (key, error) => reportDegraded(context, key, 'prompts/list', error), namespaceName),
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
server.setRequestHandler(GetPromptRequestSchema, (rawReq, extra) => {
|
|
625
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
626
|
+
const req = stripRequest(rawReq);
|
|
627
|
+
const requestOptions = createRequestOptions(extra);
|
|
628
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
629
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
630
|
+
// Routed at the innermost `next` from the post-pipeline name, so the
|
|
631
|
+
// rejection is thrown in-chain and middleware (audit) observes it.
|
|
632
|
+
return promptPipeline(req, async (r) => {
|
|
633
|
+
if (delegationError !== undefined)
|
|
634
|
+
throw delegationError;
|
|
635
|
+
const routed = routeNamespaced(r.params.name, promptBackends.byKey);
|
|
636
|
+
if (routed === undefined) {
|
|
637
|
+
throw rejectionMcpError('BACKEND_UNROUTABLE', ErrorCode.MethodNotFound, `Prompt not found: ${r.params.name} — a mesh exposes prompts as "<backendKey>__<prompt>"`);
|
|
638
|
+
}
|
|
639
|
+
return stripResult(await routed.client.getPrompt(attachDelegationMeta({ ...r.params, name: routed.name }, context), requestOptions));
|
|
640
|
+
}, context);
|
|
239
641
|
});
|
|
240
642
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
643
|
+
else if (capabilities.prompts) {
|
|
644
|
+
server.setRequestHandler(ListPromptsRequestSchema, (rawReq, extra) => {
|
|
645
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
646
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
647
|
+
// No pipeline here either, so the rejection is thrown at the handler.
|
|
648
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
649
|
+
if (delegationError !== undefined)
|
|
650
|
+
throw delegationError;
|
|
651
|
+
return backend
|
|
652
|
+
.listPrompts(attachDelegationMeta(stripRequest(rawReq).params, context), createRequestOptions(extra))
|
|
653
|
+
.then(stripResult);
|
|
654
|
+
});
|
|
655
|
+
server.setRequestHandler(GetPromptRequestSchema, (rawReq, extra) => {
|
|
656
|
+
const { chain, error: wireError } = readInboundDelegation(rawReq);
|
|
657
|
+
const requestOptions = createRequestOptions(extra);
|
|
658
|
+
const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
|
|
659
|
+
const delegationError = wireError ?? detectDelegationLoop(context);
|
|
660
|
+
return promptPipeline(stripRequest(rawReq), async (r) => {
|
|
661
|
+
if (delegationError !== undefined)
|
|
662
|
+
throw delegationError;
|
|
663
|
+
return stripResult(await backend.getPrompt(attachDelegationMeta(r.params, context), requestOptions));
|
|
664
|
+
}, context);
|
|
665
|
+
});
|
|
245
666
|
}
|
|
246
667
|
return server;
|
|
247
668
|
}
|
|
@@ -250,31 +671,77 @@ export function createProxyServer(backend, options = {}) {
|
|
|
250
671
|
* Calls {@link createProxyServer} then connects to `StdioServerTransport`.
|
|
251
672
|
* Use `createProxyServer` directly for testable access to the server.
|
|
252
673
|
*/
|
|
253
|
-
export async function startProxy(
|
|
254
|
-
const server = createProxyServer(
|
|
674
|
+
export async function startProxy(backends, options) {
|
|
675
|
+
const server = createProxyServer(backends, options);
|
|
255
676
|
await server.connect(new StdioServerTransport());
|
|
256
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* Enforces `maxBodyBytes`.
|
|
680
|
+
*
|
|
681
|
+
* Fast path: a declared `Content-Length` over the limit is rejected before
|
|
682
|
+
* any body is read. Chunked/streamed bodies are counted as they arrive.
|
|
683
|
+
*
|
|
684
|
+
* @returns `true` when the request was already rejected (caller must stop).
|
|
685
|
+
*/
|
|
257
686
|
function applyBodySizeLimit(req, res, maxBodyBytes) {
|
|
687
|
+
const reject413 = () => {
|
|
688
|
+
if (!res.headersSent) {
|
|
689
|
+
res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify({
|
|
690
|
+
error: {
|
|
691
|
+
message: 'Request body too large',
|
|
692
|
+
data: { rejectionReason: 'BODY_LIMIT' },
|
|
693
|
+
},
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
const declared = Number(req.headers['content-length']);
|
|
698
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes) {
|
|
699
|
+
reject413();
|
|
700
|
+
req.destroy();
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
258
703
|
let total = 0;
|
|
259
704
|
const originalPush = req.push.bind(req);
|
|
260
705
|
req.push = (chunk, enc) => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
706
|
+
const chunkBytes = chunk === null
|
|
707
|
+
? 0
|
|
708
|
+
: typeof chunk === 'string'
|
|
709
|
+
? Buffer.byteLength(chunk, enc)
|
|
710
|
+
: chunk.length;
|
|
711
|
+
if (chunk !== null && total + chunkBytes > maxBodyBytes) {
|
|
712
|
+
reject413();
|
|
713
|
+
if (res.headersSent) {
|
|
714
|
+
// Too late for a clean 413 — sever the connection instead of
|
|
715
|
+
// leaving the client on a hung socket.
|
|
716
|
+
res.destroy();
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
// Mute the response for downstream writers (the SDK transport will
|
|
720
|
+
// observe the destroyed request and try to send its own error).
|
|
721
|
+
const muted = res;
|
|
722
|
+
muted.writeHead = () => res;
|
|
723
|
+
muted.write = () => true;
|
|
724
|
+
muted.end = () => res;
|
|
725
|
+
}
|
|
270
726
|
req.destroy(new Error('Request body too large'));
|
|
271
727
|
return false;
|
|
272
728
|
}
|
|
273
|
-
|
|
274
|
-
total += chunk.length;
|
|
729
|
+
total += chunkBytes;
|
|
275
730
|
return originalPush(chunk, enc);
|
|
276
731
|
};
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* True for bind addresses only reachable from the local machine.
|
|
736
|
+
* `127.` catches the whole 127.0.0.0/8 block Node can bind.
|
|
737
|
+
*/
|
|
738
|
+
function isLoopbackHost(host) {
|
|
739
|
+
return (host === 'localhost' ||
|
|
740
|
+
host === '::1' ||
|
|
741
|
+
host === '::ffff:127.0.0.1' ||
|
|
742
|
+
host.startsWith('127.'));
|
|
277
743
|
}
|
|
744
|
+
const MAX_NODE_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
278
745
|
/**
|
|
279
746
|
* Starts the proxy over Streamable HTTP with stateful sessions.
|
|
280
747
|
*
|
|
@@ -286,15 +753,184 @@ function applyBodySizeLimit(req, res, maxBodyBytes) {
|
|
|
286
753
|
* @returns Promise resolving to the listening `http.Server` (or `https.Server`
|
|
287
754
|
* when `tlsOptions` is supplied).
|
|
288
755
|
*/
|
|
289
|
-
export function startHttpProxy(
|
|
756
|
+
export function startHttpProxy(backends, options, httpOptions = {}) {
|
|
757
|
+
// Sessions build their proxy server lazily, so surface configuration
|
|
758
|
+
// errors (a blank name, duplicate local tool names, invalid backend keys)
|
|
759
|
+
// at startup, not on first initialize.
|
|
760
|
+
assertProxyName(options.name);
|
|
761
|
+
buildLocalToolMap(options.localTools);
|
|
762
|
+
normalizeBackends(backends);
|
|
290
763
|
const mcpPath = httpOptions.path ?? '/mcp';
|
|
291
764
|
const port = httpOptions.port ?? 3000;
|
|
292
|
-
const host = httpOptions.host;
|
|
765
|
+
const host = httpOptions.host ?? '127.0.0.1';
|
|
766
|
+
const loopback = isLoopbackHost(host);
|
|
767
|
+
const dnsRebindingProtection = httpOptions.enableDnsRebindingProtection ?? loopback;
|
|
768
|
+
// Bounded by default (#107): unlimited sessions that never expire are a
|
|
769
|
+
// memory exhaustion path for any client that can initialize. `Infinity`
|
|
770
|
+
// is the opt-out for both; `maxSessions: 0` already means "reject every
|
|
771
|
+
// session", so it cannot double as one. The `!(x >= n)` form also
|
|
772
|
+
// rejects NaN, which a plain `x < n` would let through.
|
|
773
|
+
const maxSessions = httpOptions.maxSessions ?? 1000;
|
|
774
|
+
const sessionTtlMs = httpOptions.sessionTtlMs ?? 30 * 60 * 1000;
|
|
775
|
+
if (!(maxSessions >= 0)) {
|
|
776
|
+
throw new Error(`mcpose: maxSessions must be >= 0 or Infinity, got ${String(httpOptions.maxSessions)}`);
|
|
777
|
+
}
|
|
778
|
+
if (!(sessionTtlMs > 0)) {
|
|
779
|
+
throw new Error(`mcpose: sessionTtlMs must be > 0 or Infinity, got ${String(httpOptions.sessionTtlMs)}`);
|
|
780
|
+
}
|
|
781
|
+
if (Number.isFinite(sessionTtlMs) && sessionTtlMs > MAX_NODE_TIMER_DELAY_MS) {
|
|
782
|
+
throw new Error(`mcpose: sessionTtlMs must be <= ${MAX_NODE_TIMER_DELAY_MS} or Infinity, got ${String(httpOptions.sessionTtlMs)}`);
|
|
783
|
+
}
|
|
784
|
+
// Filled in once the server is listening (the real port is only known
|
|
785
|
+
// then); sessions are only created after that. The SDK transport
|
|
786
|
+
// validates Host/Origin only against a non-empty list, so an enabled flag
|
|
787
|
+
// without a list would be an inert control — see #74.
|
|
788
|
+
let derivedAllowedHosts;
|
|
789
|
+
let derivedAllowedOrigins;
|
|
293
790
|
const eventStore = httpOptions.eventStore === null
|
|
294
791
|
? undefined
|
|
295
792
|
: (httpOptions.eventStore ?? createInMemoryEventStore());
|
|
296
|
-
|
|
793
|
+
const registry = httpOptions.sessionRegistry;
|
|
794
|
+
// session ID → live session held by this instance
|
|
297
795
|
const sessions = new Map();
|
|
796
|
+
// Sessions being initialized or resumed (identity resolution or registry
|
|
797
|
+
// lookup in flight) — counted so concurrent admissions cannot overshoot
|
|
798
|
+
// maxSessions.
|
|
799
|
+
let pendingSessions = 0;
|
|
800
|
+
const reportError = (err) => {
|
|
801
|
+
(httpOptions.onError ?? console.error)(err);
|
|
802
|
+
};
|
|
803
|
+
/**
|
|
804
|
+
* Single teardown path for every way a session can end: client DELETE,
|
|
805
|
+
* TTL expiry, and server shutdown. Clears the TTL timer, drops the shared
|
|
806
|
+
* record unless the session is meant to outlive this process, fires and
|
|
807
|
+
* awaits `onSessionClosed` (guarded — a throwing or rejecting hook must not
|
|
808
|
+
* break teardown), and closes the proxy server so it leaves the listChanged
|
|
809
|
+
* fan-out bus. Idempotent: a second call for the same id is a no-op.
|
|
810
|
+
*/
|
|
811
|
+
const destroySession = async (id, keepRecord = false) => {
|
|
812
|
+
const session = sessions.get(id);
|
|
813
|
+
if (!session)
|
|
814
|
+
return;
|
|
815
|
+
sessions.delete(id);
|
|
816
|
+
if (session.ttlTimer !== undefined)
|
|
817
|
+
clearTimeout(session.ttlTimer);
|
|
818
|
+
if (registry && !keepRecord)
|
|
819
|
+
await registry.delete(id).catch(reportError);
|
|
820
|
+
try {
|
|
821
|
+
await httpOptions.onSessionClosed?.(id);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
reportError(err);
|
|
825
|
+
}
|
|
826
|
+
await session.proxyServer.close().catch(reportError);
|
|
827
|
+
};
|
|
828
|
+
/**
|
|
829
|
+
* Takes a session live on this instance. `expiresAt` is the session's
|
|
830
|
+
* deadline fixed at creation, so a resumed session gets the remaining
|
|
831
|
+
* lifetime rather than a fresh TTL; `undefined` means it never expires.
|
|
832
|
+
*/
|
|
833
|
+
const admitSession = (id, transport, proxyServer, identity, expiresAt) => {
|
|
834
|
+
let ttlTimer;
|
|
835
|
+
if (expiresAt !== undefined) {
|
|
836
|
+
// A record from a proxy with a longer TTL may still exceed Node's
|
|
837
|
+
// timer ceiling; clamping only makes that timer fire early and
|
|
838
|
+
// re-check nothing, since the session is then simply closed.
|
|
839
|
+
const remaining = Math.min(Math.max(expiresAt - Date.now(), 0), MAX_NODE_TIMER_DELAY_MS);
|
|
840
|
+
ttlTimer = setTimeout(() => {
|
|
841
|
+
void destroySession(id);
|
|
842
|
+
}, remaining);
|
|
843
|
+
ttlTimer.unref();
|
|
844
|
+
}
|
|
845
|
+
sessions.set(id, {
|
|
846
|
+
transport,
|
|
847
|
+
proxyServer,
|
|
848
|
+
...(identity === undefined ? {} : { identity }),
|
|
849
|
+
...(ttlTimer === undefined ? {} : { ttlTimer }),
|
|
850
|
+
});
|
|
851
|
+
};
|
|
852
|
+
const sessionLimitBody = JSON.stringify({
|
|
853
|
+
error: {
|
|
854
|
+
message: 'Session limit reached',
|
|
855
|
+
data: { rejectionReason: 'SESSION_LIMIT' },
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
// Builds the transport for one session. The id is fixed before the
|
|
859
|
+
// transport exists so the event store can be scoped to it: stream ids are
|
|
860
|
+
// namespaced per session (#154).
|
|
861
|
+
const createTransport = (sessionId, onsessioninitialized) => {
|
|
862
|
+
// An explicit list is used verbatim; the derived loopback list only
|
|
863
|
+
// fills the gap so the default actually validates something.
|
|
864
|
+
const allowedHosts = httpOptions.allowedHosts ?? derivedAllowedHosts;
|
|
865
|
+
const allowedOrigins = httpOptions.allowedOrigins ?? derivedAllowedOrigins;
|
|
866
|
+
return new StreamableHTTPServerTransport({
|
|
867
|
+
sessionIdGenerator: () => sessionId,
|
|
868
|
+
...(eventStore
|
|
869
|
+
? { eventStore: scopeEventStore(eventStore, sessionId) }
|
|
870
|
+
: {}),
|
|
871
|
+
...(allowedHosts ? { allowedHosts } : {}),
|
|
872
|
+
...(allowedOrigins ? { allowedOrigins } : {}),
|
|
873
|
+
enableDnsRebindingProtection: dnsRebindingProtection,
|
|
874
|
+
onsessioninitialized,
|
|
875
|
+
onsessionclosed: (id) => {
|
|
876
|
+
void destroySession(id);
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
};
|
|
880
|
+
// Resumes in flight, so concurrent requests for one unknown id build one
|
|
881
|
+
// session between them instead of one each.
|
|
882
|
+
const resuming = new Map();
|
|
883
|
+
/**
|
|
884
|
+
* Rebuilds a session this instance does not hold from its shared record:
|
|
885
|
+
* `undefined` when the registry has no live record for it, or the SDK's
|
|
886
|
+
* own rejection when the replayed initialize did not bring the transport
|
|
887
|
+
* up (a failed Host or Origin check, which the real request would fail
|
|
888
|
+
* identically).
|
|
889
|
+
*/
|
|
890
|
+
const resumeSession = (id, sharedRegistry, req) => {
|
|
891
|
+
const inflight = resuming.get(id);
|
|
892
|
+
if (inflight)
|
|
893
|
+
return inflight;
|
|
894
|
+
pendingSessions += 1;
|
|
895
|
+
const attempt = (async () => {
|
|
896
|
+
const record = await sharedRegistry.get(id);
|
|
897
|
+
if (record === undefined ||
|
|
898
|
+
(record.expiresAt !== undefined && record.expiresAt <= Date.now())) {
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
// The pending slot is already counted, so this instance can be full
|
|
902
|
+
// by exactly one.
|
|
903
|
+
if (sessions.size + pendingSessions > maxSessions) {
|
|
904
|
+
return { status: 503, body: sessionLimitBody };
|
|
905
|
+
}
|
|
906
|
+
const proxyServer = createProxyServer(backends, options);
|
|
907
|
+
const transport = createTransport(id, (sid) => {
|
|
908
|
+
admitSession(sid, transport, proxyServer, record.identity, record.expiresAt);
|
|
909
|
+
});
|
|
910
|
+
try {
|
|
911
|
+
await proxyServer.connect(transport);
|
|
912
|
+
const answer = await replayInitialize(transport, record.initialize, {
|
|
913
|
+
...(req.headers.host === undefined ? {} : { host: req.headers.host }),
|
|
914
|
+
...(req.headers.origin === undefined
|
|
915
|
+
? {}
|
|
916
|
+
: { origin: req.headers.origin }),
|
|
917
|
+
});
|
|
918
|
+
return sessions.get(id) ?? answer;
|
|
919
|
+
}
|
|
920
|
+
finally {
|
|
921
|
+
// Not admitted, whether the SDK refused the replay or threw: close
|
|
922
|
+
// the orphaned proxy server before it leaks (memory + listChanged
|
|
923
|
+
// fan-out), as the fresh-session path does.
|
|
924
|
+
if (!sessions.has(id))
|
|
925
|
+
void proxyServer.close().catch(reportError);
|
|
926
|
+
}
|
|
927
|
+
})().finally(() => {
|
|
928
|
+
resuming.delete(id);
|
|
929
|
+
pendingSessions -= 1;
|
|
930
|
+
});
|
|
931
|
+
resuming.set(id, attempt);
|
|
932
|
+
return attempt;
|
|
933
|
+
};
|
|
298
934
|
const requestHandler = (req, res) => {
|
|
299
935
|
const handle = async () => {
|
|
300
936
|
if (httpOptions.onRequest !== undefined) {
|
|
@@ -302,7 +938,12 @@ export function startHttpProxy(backend, options = {}, httpOptions = {}) {
|
|
|
302
938
|
try {
|
|
303
939
|
allowed = await httpOptions.onRequest(req, res);
|
|
304
940
|
}
|
|
305
|
-
catch {
|
|
941
|
+
catch (err) {
|
|
942
|
+
// Failing closed is the right answer for the caller; it is not an
|
|
943
|
+
// answer for the operator, whose only signal would otherwise be a
|
|
944
|
+
// stream of 401s. Same containment the session teardown path
|
|
945
|
+
// already uses for a throwing `onSessionClosed`.
|
|
946
|
+
reportError(err);
|
|
306
947
|
if (!res.headersSent)
|
|
307
948
|
res.writeHead(401).end();
|
|
308
949
|
return;
|
|
@@ -318,80 +959,168 @@ export function startHttpProxy(backend, options = {}, httpOptions = {}) {
|
|
|
318
959
|
return;
|
|
319
960
|
}
|
|
320
961
|
if (method === 'POST') {
|
|
321
|
-
applyBodySizeLimit(req, res, httpOptions.maxBodyBytes ?? 4 * 1024 * 1024);
|
|
962
|
+
const rejected = applyBodySizeLimit(req, res, httpOptions.maxBodyBytes ?? 4 * 1024 * 1024);
|
|
963
|
+
if (rejected)
|
|
964
|
+
return;
|
|
322
965
|
}
|
|
323
966
|
const sessionId = req.headers['mcp-session-id'];
|
|
324
967
|
const headers = normalizeHeaders(req.headers);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
968
|
+
if (typeof sessionId === 'string') {
|
|
969
|
+
// Route to an existing session, resuming it from the shared registry
|
|
970
|
+
// if another process created it — stamp its resolved identity into
|
|
971
|
+
// context.
|
|
972
|
+
const session = sessions.get(sessionId) ??
|
|
973
|
+
(registry
|
|
974
|
+
? await resumeSession(sessionId, registry, req)
|
|
975
|
+
: undefined);
|
|
976
|
+
if (!session) {
|
|
977
|
+
res.writeHead(404).end();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if ('status' in session) {
|
|
981
|
+
res
|
|
982
|
+
.writeHead(session.status, { 'content-type': 'application/json' })
|
|
983
|
+
.end(session.body);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (httpOptions.validateSession !== undefined) {
|
|
987
|
+
let valid;
|
|
988
|
+
try {
|
|
989
|
+
valid = await httpOptions.validateSession(req, {
|
|
990
|
+
sessionId,
|
|
991
|
+
...(session.identity === undefined
|
|
992
|
+
? {}
|
|
993
|
+
: { identity: session.identity }),
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
reportError(err);
|
|
998
|
+
valid = false;
|
|
999
|
+
}
|
|
1000
|
+
if (!valid) {
|
|
1001
|
+
if (!res.headersSent)
|
|
1002
|
+
res.writeHead(401).end();
|
|
333
1003
|
return;
|
|
334
1004
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
1005
|
+
}
|
|
1006
|
+
const requestContext = {
|
|
1007
|
+
transport: 'http',
|
|
1008
|
+
sessionId,
|
|
1009
|
+
...(headers === undefined ? {} : { headers }),
|
|
1010
|
+
...(session.identity === undefined
|
|
1011
|
+
? {}
|
|
1012
|
+
: { identity: session.identity }),
|
|
1013
|
+
};
|
|
1014
|
+
await httpProxyContext.run(requestContext, () => session.transport.handleRequest(req, res));
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
// New session — only an initialize POST may create one. A
|
|
1018
|
+
// session-less GET/DELETE can never initialize, so reject it
|
|
1019
|
+
// before constructing a Server/transport (which would otherwise
|
|
1020
|
+
// leak into the listChanged fan-out bus).
|
|
1021
|
+
if (method !== 'POST') {
|
|
1022
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({
|
|
1023
|
+
jsonrpc: '2.0',
|
|
1024
|
+
error: {
|
|
1025
|
+
code: -32000,
|
|
1026
|
+
message: 'Bad Request: no valid session ID provided',
|
|
1027
|
+
},
|
|
1028
|
+
id: null,
|
|
1029
|
+
}));
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
// Count this initialize as pending so concurrent initializes
|
|
1033
|
+
// cannot overshoot maxSessions while identity resolution awaits.
|
|
1034
|
+
if (sessions.size + pendingSessions >= maxSessions) {
|
|
1035
|
+
res
|
|
1036
|
+
.writeHead(503, { 'content-type': 'application/json' })
|
|
1037
|
+
.end(sessionLimitBody);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
pendingSessions += 1;
|
|
1041
|
+
// The shared record carries the client's initialize params, and the
|
|
1042
|
+
// SDK owns parsing the body, so tap the bytes as they arrive rather
|
|
1043
|
+
// than reading the stream out from under it. The same seam the body
|
|
1044
|
+
// limit uses.
|
|
1045
|
+
const bodyChunks = [];
|
|
1046
|
+
if (registry) {
|
|
1047
|
+
const push = req.push.bind(req);
|
|
1048
|
+
req.push = (chunk, enc) => {
|
|
1049
|
+
if (chunk !== null) {
|
|
1050
|
+
bodyChunks.push(typeof chunk === 'string' ? Buffer.from(chunk, enc) : chunk);
|
|
1051
|
+
}
|
|
1052
|
+
return push(chunk, enc);
|
|
342
1053
|
};
|
|
343
|
-
await httpProxyContext.run(requestContext, () => session.transport.handleRequest(req, res));
|
|
344
1054
|
}
|
|
345
|
-
|
|
346
|
-
// New session (initialize request)
|
|
347
|
-
if (httpOptions.maxSessions !== undefined &&
|
|
348
|
-
sessions.size >= httpOptions.maxSessions) {
|
|
349
|
-
res.writeHead(503).end();
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
1055
|
+
try {
|
|
352
1056
|
// Resolve identity once for the lifetime of this session
|
|
353
1057
|
let identity;
|
|
354
1058
|
if (httpOptions.resolveIdentity !== undefined) {
|
|
355
1059
|
try {
|
|
356
1060
|
identity = await httpOptions.resolveIdentity(req);
|
|
357
1061
|
}
|
|
358
|
-
catch {
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
reportError(err);
|
|
359
1064
|
if (!res.headersSent)
|
|
360
1065
|
res.writeHead(401).end();
|
|
361
1066
|
return;
|
|
362
1067
|
}
|
|
363
1068
|
}
|
|
364
|
-
const proxyServer = createProxyServer(
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1069
|
+
const proxyServer = createProxyServer(backends, options);
|
|
1070
|
+
const sessionId = randomUUID();
|
|
1071
|
+
const transport = createTransport(sessionId, async (id) => {
|
|
1072
|
+
// The deadline is fixed here and travels with the record, so a
|
|
1073
|
+
// resume elsewhere inherits what is left of it. Infinity opts
|
|
1074
|
+
// out; setTimeout would clamp it to 1ms.
|
|
1075
|
+
const expiresAt = Number.isFinite(sessionTtlMs)
|
|
1076
|
+
? Date.now() + sessionTtlMs
|
|
1077
|
+
: undefined;
|
|
1078
|
+
// The SDK awaits this hook before it answers, so the record is
|
|
1079
|
+
// durable before the client can present the id anywhere. A
|
|
1080
|
+
// throw here becomes the SDK's 400 with the error text in the
|
|
1081
|
+
// body, so the real cause goes to onError and the client gets a
|
|
1082
|
+
// message that names nothing about the backing store.
|
|
1083
|
+
if (registry) {
|
|
1084
|
+
try {
|
|
1085
|
+
await registry.set(id, {
|
|
1086
|
+
initialize: initializeParamsOf(Buffer.concat(bodyChunks).toString()),
|
|
1087
|
+
...(identity === undefined ? {} : { identity }),
|
|
1088
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
1089
|
+
});
|
|
379
1090
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
reportError(err);
|
|
1093
|
+
throw new Error('mcpose: session registry unavailable');
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
admitSession(id, transport, proxyServer, identity, expiresAt);
|
|
385
1097
|
});
|
|
386
1098
|
const requestContext = {
|
|
387
1099
|
transport: 'http',
|
|
388
1100
|
...(headers === undefined ? {} : { headers }),
|
|
389
1101
|
...(identity === undefined ? {} : { identity }),
|
|
390
1102
|
};
|
|
1103
|
+
// See backendClient.ts: the SDK's transports are not assignable to
|
|
1104
|
+
// its own `Transport` interface under `exactOptionalPropertyTypes`
|
|
1105
|
+
// (here `onclose?: () => void` vs `onclose: (() => void) | undefined`).
|
|
391
1106
|
await proxyServer.connect(transport);
|
|
392
|
-
|
|
1107
|
+
try {
|
|
1108
|
+
await httpProxyContext.run(requestContext, () => transport.handleRequest(req, res));
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
// No session came up — a non-initialize body the transport
|
|
1112
|
+
// rejected, or a registry that refused the record — so close the
|
|
1113
|
+
// orphaned proxy server before it leaks (memory + listChanged
|
|
1114
|
+
// fan-out).
|
|
1115
|
+
if (!sessions.has(sessionId)) {
|
|
1116
|
+
void proxyServer.close().catch(reportError);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
393
1119
|
}
|
|
394
|
-
|
|
1120
|
+
finally {
|
|
1121
|
+
pendingSessions -= 1;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
395
1124
|
};
|
|
396
1125
|
handle().catch((err) => {
|
|
397
1126
|
if (!res.headersSent)
|
|
@@ -404,21 +1133,60 @@ export function startHttpProxy(backend, options = {}, httpOptions = {}) {
|
|
|
404
1133
|
: http.createServer(requestHandler);
|
|
405
1134
|
const rawClose = server.close.bind(server);
|
|
406
1135
|
let shuttingDown = false;
|
|
407
|
-
server.close = (
|
|
1136
|
+
server.close = (callback) => {
|
|
408
1137
|
if (shuttingDown)
|
|
409
1138
|
return rawClose(callback);
|
|
410
1139
|
shuttingDown = true;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1140
|
+
// Tear down every session through the single teardown path (clears TTL
|
|
1141
|
+
// timers, fires and awaits onSessionClosed so audit manifests flush
|
|
1142
|
+
// before the close callback runs).
|
|
1143
|
+
// Shared records are kept: the sessions are meant to resume on the next
|
|
1144
|
+
// process, and their deadlines expire them on their own.
|
|
1145
|
+
void Promise.allSettled([...sessions.keys()].map((id) => destroySession(id, true))).finally(() => {
|
|
414
1146
|
rawClose(callback);
|
|
1147
|
+
// Idle keep-alive and lingering SSE sockets would otherwise keep the
|
|
1148
|
+
// close callback pending indefinitely.
|
|
1149
|
+
server.closeAllConnections();
|
|
415
1150
|
});
|
|
416
1151
|
return server;
|
|
417
|
-
}
|
|
1152
|
+
};
|
|
1153
|
+
if (!loopback && httpOptions.resolveIdentity === undefined) {
|
|
1154
|
+
reportError(new Error(`mcpose: startHttpProxy is binding non-loopback address "${host}" without resolveIdentity — ` +
|
|
1155
|
+
'anything that can route to this host can call the upstream with ' +
|
|
1156
|
+
"the proxy's credentials. Pass resolveIdentity, or bind 127.0.0.1."));
|
|
1157
|
+
}
|
|
1158
|
+
// The SDK transport validates Host/Origin only against a non-empty list,
|
|
1159
|
+
// and no list is derived for a non-loopback bind, so this combination is
|
|
1160
|
+
// an inert flag — the false-confidence case the derived defaults exist
|
|
1161
|
+
// to remove.
|
|
1162
|
+
if (dnsRebindingProtection &&
|
|
1163
|
+
!loopback &&
|
|
1164
|
+
httpOptions.allowedHosts === undefined &&
|
|
1165
|
+
httpOptions.allowedOrigins === undefined) {
|
|
1166
|
+
reportError(new Error('mcpose: enableDnsRebindingProtection is on, but no allowedHosts or ' +
|
|
1167
|
+
'allowedOrigins are set and none are derived for a non-loopback ' +
|
|
1168
|
+
'bind, so the transport validates nothing. Pass allowedHosts ' +
|
|
1169
|
+
'and/or allowedOrigins.'));
|
|
1170
|
+
}
|
|
418
1171
|
return new Promise((resolve, reject) => {
|
|
419
1172
|
server.once('error', reject);
|
|
420
|
-
server.listen(port,
|
|
1173
|
+
server.listen(port, host, () => {
|
|
421
1174
|
server.off('error', reject);
|
|
1175
|
+
// Route post-listen server errors (e.g. EMFILE) to onError instead of
|
|
1176
|
+
// crashing the process with an unhandled 'error' event.
|
|
1177
|
+
server.on('error', reportError);
|
|
1178
|
+
if (dnsRebindingProtection && loopback) {
|
|
1179
|
+
const addr = server.address();
|
|
1180
|
+
const actualPort = addr !== null && typeof addr === 'object' ? addr.port : port;
|
|
1181
|
+
const hosts = [
|
|
1182
|
+
`127.0.0.1:${actualPort}`,
|
|
1183
|
+
`localhost:${actualPort}`,
|
|
1184
|
+
`[::1]:${actualPort}`,
|
|
1185
|
+
];
|
|
1186
|
+
const scheme = httpOptions.tlsOptions ? 'https' : 'http';
|
|
1187
|
+
derivedAllowedHosts = hosts;
|
|
1188
|
+
derivedAllowedOrigins = hosts.map((h) => `${scheme}://${h}`);
|
|
1189
|
+
}
|
|
422
1190
|
resolve(server);
|
|
423
1191
|
});
|
|
424
1192
|
});
|