@ni-c/mcp-hub 0.6.4 → 0.7.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +25 -5
  3. package/dist/auth/headers.js +33 -0
  4. package/dist/auth/headers.js.map +1 -0
  5. package/dist/auth/provider.js +4 -0
  6. package/dist/auth/provider.js.map +1 -1
  7. package/dist/auth/routes.js +4 -1
  8. package/dist/auth/routes.js.map +1 -1
  9. package/dist/auth/store.js +152 -35
  10. package/dist/auth/store.js.map +1 -1
  11. package/dist/config.js +209 -27
  12. package/dist/config.js.map +1 -1
  13. package/dist/docker-proxy/index.js +106 -0
  14. package/dist/docker-proxy/index.js.map +1 -0
  15. package/dist/docker-proxy/policy.js +278 -0
  16. package/dist/docker-proxy/policy.js.map +1 -0
  17. package/dist/docker-proxy/secrets.js +111 -0
  18. package/dist/docker-proxy/secrets.js.map +1 -0
  19. package/dist/docker-proxy/server.js +265 -0
  20. package/dist/docker-proxy/server.js.map +1 -0
  21. package/dist/health.js +11 -1
  22. package/dist/health.js.map +1 -1
  23. package/dist/hub.js +3 -6
  24. package/dist/hub.js.map +1 -1
  25. package/dist/index.js +9 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp-limits.js +69 -0
  28. package/dist/mcp-limits.js.map +1 -0
  29. package/dist/proxy.js +4 -5
  30. package/dist/proxy.js.map +1 -1
  31. package/dist/sandbox/container-spec.js +115 -0
  32. package/dist/sandbox/container-spec.js.map +1 -0
  33. package/dist/sandbox/docker-client.js +291 -0
  34. package/dist/sandbox/docker-client.js.map +1 -0
  35. package/dist/sandbox/policy-protocol.js +11 -0
  36. package/dist/sandbox/policy-protocol.js.map +1 -0
  37. package/dist/supervisor.js +90 -17
  38. package/dist/supervisor.js.map +1 -1
  39. package/dist/transports/docker.js +163 -0
  40. package/dist/transports/docker.js.map +1 -0
  41. package/dist/transports/socket.js +59 -0
  42. package/dist/transports/socket.js.map +1 -0
  43. package/dist/transports/stream.js +102 -0
  44. package/dist/transports/stream.js.map +1 -0
  45. package/package.json +4 -3
@@ -0,0 +1,163 @@
1
+ import { buildCreateRequest, containerName } from '../sandbox/container-spec.js';
2
+ import { StreamTransport } from './stream.js';
3
+ /** Guards against a corrupt header turning into a multi-gigabyte allocation. */
4
+ const MAX_FRAME_BYTES = 16 * 1024 * 1024;
5
+ const STDOUT = 1;
6
+ const STDERR = 2;
7
+ /**
8
+ * Demultiplexes Docker's attach stream.
9
+ *
10
+ * Without a TTY the daemon frames every chunk with an 8-byte header —
11
+ * `[stream, 0, 0, 0, size:uint32be]` — so one connection can carry stdout and
12
+ * stderr. That is exactly what a sandboxed MCP server needs: stdout stays a
13
+ * clean protocol channel while the server's log lines still reach the operator.
14
+ */
15
+ export class DockerFrameDecoder {
16
+ onFrame;
17
+ onError;
18
+ buffer = Buffer.alloc(0);
19
+ failed = false;
20
+ constructor(onFrame, onError) {
21
+ this.onFrame = onFrame;
22
+ this.onError = onError;
23
+ }
24
+ push(chunk) {
25
+ if (this.failed)
26
+ return;
27
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
28
+ for (;;) {
29
+ if (this.buffer.length < 8)
30
+ return;
31
+ const size = this.buffer.readUInt32BE(4);
32
+ if (size > MAX_FRAME_BYTES) {
33
+ this.failed = true;
34
+ this.onError(new Error(`docker frame of ${size} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`));
35
+ this.buffer = Buffer.alloc(0);
36
+ return;
37
+ }
38
+ if (this.buffer.length < 8 + size)
39
+ return;
40
+ const stream = this.buffer[0];
41
+ const payload = this.buffer.subarray(8, 8 + size);
42
+ this.buffer = this.buffer.subarray(8 + size);
43
+ this.onFrame(stream, payload);
44
+ }
45
+ }
46
+ }
47
+ /**
48
+ * An MCP server running in its own container, spoken to over the Docker API.
49
+ *
50
+ * The isolation is the container's (own filesystem, own credentials, own
51
+ * network policy, own memory limit); the protocol is plain stdio across the
52
+ * container boundary. No HTTP listener, no bridge process inside the image, no
53
+ * shared secret — the things an HTTP upstream forces on a server that only
54
+ * speaks stdio.
55
+ *
56
+ * Order matters: create, then attach, then start. Starting before the attach
57
+ * is in place loses whatever the server writes in its first milliseconds.
58
+ */
59
+ export class DockerTransport {
60
+ server;
61
+ config;
62
+ client;
63
+ writeStderr;
64
+ onclose;
65
+ onerror;
66
+ onmessage;
67
+ inner;
68
+ stream;
69
+ closing = false;
70
+ stderrTail = '';
71
+ constructor(server, config, client, writeStderr = line => process.stderr.write(line)) {
72
+ this.server = server;
73
+ this.config = config;
74
+ this.client = client;
75
+ this.writeStderr = writeStderr;
76
+ }
77
+ async start() {
78
+ const { name, body } = buildCreateRequest(this.server, this.config);
79
+ await this.ensureImage();
80
+ // A container of that name can survive an unclean hub exit (AutoRemove
81
+ // only fires when the container itself stops), and create would then fail
82
+ // with a name conflict forever.
83
+ await this.client.removeContainer(name);
84
+ await this.client.createContainer(name, body);
85
+ let stream;
86
+ try {
87
+ stream = await this.client.attach(name);
88
+ }
89
+ catch (error) {
90
+ await this.client.removeContainer(name).catch(() => { });
91
+ throw error;
92
+ }
93
+ this.stream = stream;
94
+ const inner = new StreamTransport(stream, false);
95
+ inner.onmessage = message => this.onmessage?.(message);
96
+ inner.onerror = error => this.onerror?.(error);
97
+ inner.onclose = () => {
98
+ this.onclose?.();
99
+ // Best effort: with AutoRemove the daemon usually got there first.
100
+ if (!this.closing)
101
+ void this.client.removeContainer(name).catch(() => { });
102
+ };
103
+ await inner.start();
104
+ this.inner = inner;
105
+ const decoder = new DockerFrameDecoder((streamType, payload) => {
106
+ if (streamType === STDOUT)
107
+ inner.receive(payload);
108
+ else if (streamType === STDERR)
109
+ this.logStderr(payload);
110
+ }, error => {
111
+ this.onerror?.(error);
112
+ // A corrupt length makes frame boundaries unknowable. Closing the
113
+ // attach stream triggers container cleanup and the supervisor's normal
114
+ // restart backoff instead of leaving a poisoned stream alive.
115
+ void inner.close();
116
+ });
117
+ stream.on('data', chunk => decoder.push(chunk));
118
+ try {
119
+ await this.client.startContainer(name);
120
+ }
121
+ catch (error) {
122
+ await this.client.removeContainer(name).catch(() => { });
123
+ throw error;
124
+ }
125
+ }
126
+ async send(message) {
127
+ if (!this.inner)
128
+ throw new Error(`Server "${this.server}" is not attached`);
129
+ await this.inner.send(message);
130
+ }
131
+ async close() {
132
+ this.closing = true;
133
+ await this.inner?.close();
134
+ this.stream?.destroy();
135
+ await this.client.removeContainer(containerName(this.server)).catch(() => { });
136
+ }
137
+ async ensureImage() {
138
+ if (await this.client.imageExists(this.config.image))
139
+ return;
140
+ if (this.config.pull !== 'missing') {
141
+ throw new Error(`image "${this.config.image}" is not present and "pull" is "never" — build or pull it first`);
142
+ }
143
+ await this.client.pullImage(this.config.image);
144
+ }
145
+ /**
146
+ * Prefix the container's stderr like a stdio child's, and write it straight
147
+ * to the process's stderr rather than through console: stdio children use
148
+ * `stderr: 'inherit'` and bypass console too, which is what keeps LOG_FILE
149
+ * (read by fail2ban) free of server chatter.
150
+ */
151
+ logStderr(payload) {
152
+ this.stderrTail += payload.toString('utf8');
153
+ const lines = this.stderrTail.split('\n');
154
+ this.stderrTail = lines.pop() ?? '';
155
+ for (const line of lines)
156
+ this.writeStderr(`[${this.server}] ${line}\n`);
157
+ if (this.stderrTail.length > 64 * 1024) {
158
+ this.writeStderr(`[${this.server}] ${this.stderrTail}\n`);
159
+ this.stderrTail = '';
160
+ }
161
+ }
162
+ }
163
+ //# sourceMappingURL=docker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docker.js","sourceRoot":"","sources":["../../src/transports/docker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ import net from 'node:net';
2
+ import { StreamTransport } from './stream.js';
3
+ const CONNECT_TIMEOUT_MS = 10_000;
4
+ /**
5
+ * Connects to a server that already listens on a Unix socket or a TCP port and
6
+ * speaks stdio-framed JSON-RPC there.
7
+ *
8
+ * This is the sandboxing route that costs the hub no privileges at all: the
9
+ * container is started by whoever owns the Compose file, and a Unix socket in
10
+ * a shared volume reaches it even with `network_mode: none` — something an
11
+ * HTTP upstream can never offer, because HTTP needs an interface to listen on.
12
+ */
13
+ export class SocketTransport extends StreamTransport {
14
+ config;
15
+ constructor(config) {
16
+ // The socket is created here but connected in start(); an unconnected
17
+ // net.Socket is a perfectly ordinary Duplex until then.
18
+ super(new net.Socket());
19
+ this.config = config;
20
+ }
21
+ async start() {
22
+ const socket = this.stream;
23
+ await new Promise((resolve, reject) => {
24
+ const onError = (error) => {
25
+ socket.destroy();
26
+ reject(new Error(`cannot connect to ${this.describe()}: ${error.message}`));
27
+ };
28
+ const onTimeout = () => {
29
+ socket.destroy();
30
+ reject(new Error(`timed out connecting to ${this.describe()}`));
31
+ };
32
+ socket.once('error', onError);
33
+ socket.once('timeout', onTimeout);
34
+ socket.setTimeout(CONNECT_TIMEOUT_MS);
35
+ socket.once('connect', () => {
36
+ // Hand the socket over cleanly: the connect-phase handlers must not
37
+ // survive, or a later error would destroy the socket behind the
38
+ // transport's back instead of being reported through onerror. And the
39
+ // connect timeout must not linger as an idle timeout — an MCP session
40
+ // is idle most of the time and would be torn down mid-use.
41
+ socket.off('error', onError);
42
+ socket.off('timeout', onTimeout);
43
+ socket.setTimeout(0);
44
+ if (this.config.transport === 'tcp')
45
+ socket.setNoDelay(true);
46
+ resolve();
47
+ });
48
+ if (this.config.transport === 'unix')
49
+ socket.connect({ path: this.config.socketPath });
50
+ else
51
+ socket.connect({ host: this.config.host, port: this.config.port });
52
+ });
53
+ await super.start();
54
+ }
55
+ describe() {
56
+ return this.config.transport === 'unix' ? this.config.socketPath : `${this.config.host}:${this.config.port}`;
57
+ }
58
+ }
59
+ //# sourceMappingURL=socket.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket.js","sourceRoot":"","sources":["../../src/transports/socket.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,UAAU,CAAC;AAE3B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,OAAO,eAAgB,SAAQ,eAAe;IACrB,MAAM;IAAnC,YAA6B,MAA0B;QACrD,sEAAsE;QACtE,wDAAwD;QACxD,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;sBAHG,MAAM;IAInC,CAAC;IAEQ,KAAK,CAAC,KAAK;QAClB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAoB,CAAC;QACzC,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC1C,MAAM,OAAO,GAAG,CAAC,KAAY,EAAE,EAAE;gBAC/B,MAAM,CAAC,OAAO,EAAE,CAAC;gBACjB,MAAM,CAAC,IAAI,KAAK,CAAC,qBAAqB,IAAI,CAAC,QAAQ,EAAE,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YAC9E,CAAC,CAAC;YACF,MAAM,SAAS,GAAG,GAAG,EAAE;gBACrB,MAAM,CAAC,OAAO,EAAE,CAAC;gBACjB,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,IAAI,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC;YAClE,CAAC,CAAC;YACF,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;YAClC,MAAM,CAAC,UAAU,CAAC,kBAAkB,CAAC,CAAC;YACtC,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,EAAE;gBAC1B,oEAAoE;gBACpE,gEAAgE;gBAChE,sEAAsE;gBACtE,sEAAsE;gBACtE,2DAA2D;gBAC3D,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;gBAC7B,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;gBACjC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;gBACrB,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,KAAK,KAAK;oBAAE,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;gBAC7D,OAAO,EAAE,CAAC;YACZ,CAAC,CAAC,CAAC;YACH,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,KAAK,MAAM;gBAAE,MAAM,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,UAAW,EAAE,CAAC,CAAC;;gBACnF,MAAM,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,IAAK,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,IAAK,EAAE,CAAC,CAAC;QAC5E,CAAC,CAAC,CAAC;QACH,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;IACtB,CAAC;IAEO,QAAQ;QACd,OAAO,IAAI,CAAC,MAAM,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,UAAW,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,IAAK,IAAI,IAAI,CAAC,MAAM,CAAC,IAAK,EAAE,CAAC;IAClH,CAAC;CACF"}
@@ -0,0 +1,102 @@
1
+ import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
2
+ /**
3
+ * MCP over any reliable bidirectional byte stream, using the stdio framing.
4
+ *
5
+ * The specification says exactly this about custom transports: they may run
6
+ * over other channels (a Unix socket, a TCP connection, a container's attached
7
+ * stdio) and SHOULD reuse the stdio binding's newline-delimited JSON rather
8
+ * than invent a framing. So this class is only plumbing — ReadBuffer and
9
+ * serializeMessage are the SDK's own stdio codec, byte for byte.
10
+ *
11
+ * Reading is separated from the stream on purpose: an attached container
12
+ * multiplexes stdout and stderr over one connection, so the caller decodes the
13
+ * frames and feeds only the stdout payload in via `receive()`.
14
+ */
15
+ export class StreamTransport {
16
+ stream;
17
+ feedFromStream;
18
+ onclose;
19
+ onerror;
20
+ onmessage;
21
+ readBuffer = new ReadBuffer();
22
+ started = false;
23
+ closed = false;
24
+ reportedClose = false;
25
+ /**
26
+ * @param stream the duplex carrying the protocol
27
+ * @param feedFromStream whether stream data is plain protocol bytes. False
28
+ * when the caller demultiplexes first (Docker attach).
29
+ */
30
+ constructor(stream, feedFromStream = true) {
31
+ this.stream = stream;
32
+ this.feedFromStream = feedFromStream;
33
+ }
34
+ async start() {
35
+ if (this.started)
36
+ throw new Error('StreamTransport already started');
37
+ this.started = true;
38
+ if (this.feedFromStream)
39
+ this.stream.on('data', chunk => this.receive(chunk));
40
+ this.stream.on('error', error => this.onerror?.(error));
41
+ // 'close' rather than 'end': a half-open socket whose peer vanished never
42
+ // emits 'end', and the supervisor must learn about the death either way.
43
+ this.stream.on('close', () => this.reportClosed());
44
+ }
45
+ /** Feed protocol bytes that were read out of band (demultiplexed stdout). */
46
+ receive(chunk) {
47
+ try {
48
+ this.readBuffer.append(chunk);
49
+ }
50
+ catch (error) {
51
+ // The SDK caps the buffer at 10 MB and throws when a peer keeps sending
52
+ // without ever writing a newline. This runs inside a 'data' handler, so
53
+ // an escaping throw would reach process.on('uncaughtException') and take
54
+ // the entire hub down — every other server with it — because one
55
+ // sandboxed server misbehaved. The stream is desynchronised anyway:
56
+ // report it, end this connection, let the supervisor restart it.
57
+ this.onerror?.(error);
58
+ void this.close();
59
+ return;
60
+ }
61
+ for (;;) {
62
+ let message;
63
+ try {
64
+ message = this.readBuffer.readMessage();
65
+ }
66
+ catch (error) {
67
+ // One malformed line must not kill the connection: report it and let
68
+ // the buffer continue with the bytes after the newline.
69
+ this.onerror?.(error);
70
+ continue;
71
+ }
72
+ if (message === null)
73
+ return;
74
+ this.onmessage?.(message);
75
+ }
76
+ }
77
+ async send(message) {
78
+ if (this.closed)
79
+ throw new Error('Transport is closed');
80
+ const payload = serializeMessage(message);
81
+ // The write callback fires once the chunk has left the buffer, so awaiting
82
+ // it respects backpressure: a server that stops reading slows us down
83
+ // instead of letting the socket buffer grow without bound.
84
+ await new Promise((resolve, reject) => {
85
+ this.stream.write(payload, error => (error ? reject(error) : resolve()));
86
+ });
87
+ }
88
+ async close() {
89
+ this.closed = true;
90
+ this.stream.destroy();
91
+ this.reportClosed();
92
+ }
93
+ reportClosed() {
94
+ if (this.reportedClose)
95
+ return;
96
+ this.reportedClose = true;
97
+ this.closed = true;
98
+ this.readBuffer.clear();
99
+ this.onclose?.();
100
+ }
101
+ }
102
+ //# sourceMappingURL=stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/transports/stream.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ni-c/mcp-hub",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Serve multiple stdio MCP servers from one container: Claude-Code-style mcpServers config, path-based routing, hub meta-tools, and OAuth 2.1 + API tokens for ChatGPT, Claude and any Streamable-HTTP MCP client.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -22,12 +22,13 @@
22
22
  },
23
23
  "homepage": "https://mcp-hub.ni-c.de",
24
24
  "engines": {
25
- "node": ">=20"
25
+ "node": ">=22"
26
26
  },
27
27
  "main": "dist/index.js",
28
28
  "bin": {
29
29
  "mcp-hub": "dist/index.js",
30
- "mcp-hub-admin": "dist/admin.js"
30
+ "mcp-hub-admin": "dist/admin.js",
31
+ "mcp-hub-docker-proxy": "dist/docker-proxy/index.js"
31
32
  },
32
33
  "files": [
33
34
  "dist",