@ni-c/mcp-hub 0.11.2 → 0.11.4
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/CHANGELOG.md +110 -0
- package/README.md +6 -5
- package/dist/admin.js +8 -3
- package/dist/auth/cimd.js +8 -2
- package/dist/auth/oidc/interactions.js +14 -3
- package/dist/auth/oidc/mount.js +6 -1
- package/dist/auth/oidc/provider.js +23 -2
- package/dist/auth/oidc/verifier.js +35 -4
- package/dist/auth/page.js +9 -3
- package/dist/auth/redirect-uri.js +40 -0
- package/dist/auth/resource.js +16 -0
- package/dist/auth/store.js +35 -4
- package/dist/auth/text.js +9 -0
- package/dist/index.js +4 -1
- package/dist/subscriptions.js +23 -0
- package/dist/supervisor.js +58 -8
- package/dist/transports/docker.js +62 -18
- package/dist/transports/stream.js +35 -9
- package/dist/upstream/auth.js +26 -6
- package/dist/upstream/provider.js +57 -3
- package/dist/upstream/redirects.js +181 -0
- package/package.json +2 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/server';
|
|
2
|
+
import { logSafe } from '../auth/text.js';
|
|
3
|
+
/**
|
|
4
|
+
* Redirects on the data plane of a remote upstream, followed only within the
|
|
5
|
+
* origin the operator configured.
|
|
6
|
+
*
|
|
7
|
+
* `fetch` follows a 3xx by default, and neither the SDK's transports nor the
|
|
8
|
+
* hub's own wrappers said otherwise — so a remote MCP server could answer a
|
|
9
|
+
* `tools/call`, the SSE stream or a `subscriptions/listen` with a `Location`
|
|
10
|
+
* pointing at an internal address, and the hub would connect there, send the
|
|
11
|
+
* JSON-RPC body and every configured header except `Authorization` and
|
|
12
|
+
* `Cookie` (the two the platform strips across origins), and parse whatever
|
|
13
|
+
* came back as MCP. The control plane — discovery, token, registration — has
|
|
14
|
+
* refused redirects since the guard for the authorization server was written;
|
|
15
|
+
* this closes the same door on the other side.
|
|
16
|
+
*
|
|
17
|
+
* Same origin is the line, not same host: a different port on the same name
|
|
18
|
+
* is a different service, and a plain-http twin of an https upstream is not
|
|
19
|
+
* the upstream. Within that line a hop is followed because servers really do
|
|
20
|
+
* redirect `/mcp` to `/mcp/`, and refusing it would break upstreams that were
|
|
21
|
+
* never a problem.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_REDIRECT_HOPS = 3;
|
|
24
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
25
|
+
/**
|
|
26
|
+
* Ceiling on one message from a remote upstream: the same limit the
|
|
27
|
+
* byte-stream transports apply (`src/transports/stream.ts`), because a remote
|
|
28
|
+
* server is no more trusted than a sandboxed one. It bounds a JSON reply as a
|
|
29
|
+
* whole and an event stream per event, so a long-lived stream is never cut
|
|
30
|
+
* off for its length.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_UPSTREAM_RESPONSE_BYTES = STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
33
|
+
function isEventStream(response) {
|
|
34
|
+
const contentType = response.headers.get('content-type');
|
|
35
|
+
return contentType !== null && contentType.split(';')[0].trim().toLowerCase() === 'text/event-stream';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Passes bytes through until more than `maxBytes` have gone by, then errors
|
|
39
|
+
* the stream, so `.json()` or the SDK's SSE reader fails instead of buffering
|
|
40
|
+
* without end. For an event stream the count restarts at every blank line —
|
|
41
|
+
* the event separator, in any mix of CR, LF and CRLF. A CR at the end of a
|
|
42
|
+
* chunk may be the first half of a CRLF, so `sawCR` carries it into the next.
|
|
43
|
+
*/
|
|
44
|
+
function budgetedStream(maxBytes, sse) {
|
|
45
|
+
let sinceBoundary = 0;
|
|
46
|
+
let sawCR = false;
|
|
47
|
+
let terminatorRun = 0;
|
|
48
|
+
// One line terminator (`\r`, `\n` or `\r\n`) completed. Two in a row with no
|
|
49
|
+
// content byte between them is a blank line — the event boundary.
|
|
50
|
+
const closeTerminator = () => {
|
|
51
|
+
if (++terminatorRun < 2)
|
|
52
|
+
return;
|
|
53
|
+
sinceBoundary = 0;
|
|
54
|
+
terminatorRun = 0;
|
|
55
|
+
};
|
|
56
|
+
return new TransformStream({
|
|
57
|
+
transform(chunk, controller) {
|
|
58
|
+
if (!sse) {
|
|
59
|
+
sinceBoundary += chunk.byteLength;
|
|
60
|
+
if (sinceBoundary > maxBytes) {
|
|
61
|
+
controller.error(new Error(`upstream response exceeds the ${maxBytes} byte limit`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
controller.enqueue(chunk);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (let i = 0; i < chunk.byteLength; i++) {
|
|
68
|
+
sinceBoundary++;
|
|
69
|
+
if (sinceBoundary > maxBytes) {
|
|
70
|
+
controller.error(new Error(`upstream SSE event exceeds the ${maxBytes} byte limit`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const byte = chunk[i];
|
|
74
|
+
if (byte === 0x0d) {
|
|
75
|
+
if (sawCR)
|
|
76
|
+
closeTerminator();
|
|
77
|
+
sawCR = true;
|
|
78
|
+
}
|
|
79
|
+
else if (byte === 0x0a) {
|
|
80
|
+
closeTerminator();
|
|
81
|
+
sawCR = false;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (sawCR)
|
|
85
|
+
closeTerminator();
|
|
86
|
+
sawCR = false;
|
|
87
|
+
terminatorRun = 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
controller.enqueue(chunk);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Puts the final response's body under the budget above. A JSON reply that
|
|
96
|
+
* declares more than the limit in `content-length` is refused before reading;
|
|
97
|
+
* an event stream's length describes the connection, not one event, so it is
|
|
98
|
+
* not consulted there.
|
|
99
|
+
*/
|
|
100
|
+
function capResponseBody(response, maxBytes) {
|
|
101
|
+
if (!response.body)
|
|
102
|
+
return response;
|
|
103
|
+
const sse = isEventStream(response);
|
|
104
|
+
if (!sse) {
|
|
105
|
+
const declared = Number(response.headers.get('content-length'));
|
|
106
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
107
|
+
void response.body.cancel().catch(() => { });
|
|
108
|
+
throw new Error(`upstream declared a ${declared} byte response, exceeding the ${maxBytes} byte limit`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return new Response(response.body.pipeThrough(budgetedStream(maxBytes, sse)), {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers: response.headers
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Wraps a fetch so that every redirect it would follow is checked first and
|
|
119
|
+
* the response it returns is byte-budgeted.
|
|
120
|
+
*
|
|
121
|
+
* `origin` is the configured upstream's origin (`new URL(config.url).origin`).
|
|
122
|
+
* The returned function has the platform's shape and can be handed to the SDK
|
|
123
|
+
* transports as their `fetch`; every hop goes through `fetchImpl`, so a wrapper
|
|
124
|
+
* that adds headers still adds them on each hop. `maxBytes` is a parameter so
|
|
125
|
+
* tests can exercise the budget without megabytes of fixtures.
|
|
126
|
+
*/
|
|
127
|
+
export function boundedRedirectFetch(origin, fetchImpl = fetch, maxBytes = MAX_UPSTREAM_RESPONSE_BYTES) {
|
|
128
|
+
return async (input, init) => {
|
|
129
|
+
let url = new URL(input instanceof Request ? input.url : String(input));
|
|
130
|
+
let request = { ...init, redirect: 'manual' };
|
|
131
|
+
for (let hop = 0;; hop++) {
|
|
132
|
+
// A caller that built a Request keeps it on the first hop — its body
|
|
133
|
+
// lives there. Nothing in the hub does, but the shape is the platform's.
|
|
134
|
+
const response = await fetchImpl(hop === 0 && input instanceof Request ? new Request(input, request) : url, request);
|
|
135
|
+
if (!REDIRECT_STATUSES.has(response.status))
|
|
136
|
+
return capResponseBody(response, maxBytes);
|
|
137
|
+
const location = response.headers.get('location');
|
|
138
|
+
if (location === null)
|
|
139
|
+
return capResponseBody(response, maxBytes);
|
|
140
|
+
// Nothing of the redirect's body is wanted, and holding the stream open
|
|
141
|
+
// would keep the connection with it.
|
|
142
|
+
await response.body?.cancel().catch(() => { });
|
|
143
|
+
let next;
|
|
144
|
+
try {
|
|
145
|
+
next = new URL(location, url);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new Error(`upstream at ${logSafe(url.origin)} redirected to an unparseable location`);
|
|
149
|
+
}
|
|
150
|
+
if (next.origin !== origin) {
|
|
151
|
+
throw new Error(`upstream at ${logSafe(url.origin)} redirected to ${logSafe(next.origin)} — refused, redirects are followed only within the configured origin`);
|
|
152
|
+
}
|
|
153
|
+
if (hop + 1 >= MAX_REDIRECT_HOPS) {
|
|
154
|
+
throw new Error(`upstream at ${logSafe(url.origin)} redirected more than ${MAX_REDIRECT_HOPS} times`);
|
|
155
|
+
}
|
|
156
|
+
// A fragment on the request URL survives a redirect that has none.
|
|
157
|
+
if (!next.hash && url.hash)
|
|
158
|
+
next.hash = url.hash;
|
|
159
|
+
url = next;
|
|
160
|
+
request = nextRequest(request, response.status);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* What the platform would do to the method and body on this hop (Fetch
|
|
166
|
+
* standard, "HTTP-redirect fetch"): a 303 always becomes a GET, a 301 or 302
|
|
167
|
+
* turns a POST into a GET, and a 307 or 308 keeps both. The body is dropped
|
|
168
|
+
* whenever the method changes, with the headers that only described it.
|
|
169
|
+
*/
|
|
170
|
+
function nextRequest(request, status) {
|
|
171
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
172
|
+
const becomesGet = status === 303 ? method !== 'GET' && method !== 'HEAD' : (status === 301 || status === 302) && method === 'POST';
|
|
173
|
+
if (!becomesGet)
|
|
174
|
+
return request;
|
|
175
|
+
const headers = new Headers(request.headers);
|
|
176
|
+
for (const name of ['content-encoding', 'content-language', 'content-location', 'content-type', 'content-length']) {
|
|
177
|
+
headers.delete(name);
|
|
178
|
+
}
|
|
179
|
+
return { ...request, method: 'GET', body: undefined, headers };
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=redirects.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ni-c/mcp-hub",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Serve multiple stdio MCP servers from one container: Claude-Code-style mcpServers config, path-based routing, hub meta-tools, and CIMD-first OAuth 2.1 + API tokens for ChatGPT, Claude and any Streamable-HTTP MCP client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@types/node": "^26.2.0",
|
|
79
79
|
"@types/oidc-provider": "^9.11.1",
|
|
80
80
|
"@types/supertest": "^7.2.1",
|
|
81
|
-
"@vitest/coverage-v8": "5.0.
|
|
81
|
+
"@vitest/coverage-v8": "5.0.1",
|
|
82
82
|
"fast-check": "^4.9.0",
|
|
83
83
|
"oxlint": "^1.80.0",
|
|
84
84
|
"supertest": "^7.0.0",
|