@almyty/mcp-mux 1.2.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 ADDED
@@ -0,0 +1,99 @@
1
+ # @almyty/mcp-mux
2
+
3
+ Southbound stdio-MCP **id-rewrite multiplexer**: fan N client sessions into one
4
+ downstream stdio MCP child over a Unix socket, with safe id remapping, framing
5
+ serialization, response routing, and crash-respawn.
6
+
7
+ > Scope: **southbound MCP only** (almyty as the client/proxy to a downstream MCP
8
+ > server). This package does not touch northbound ACP/A2A serving.
9
+
10
+ ## Why
11
+
12
+ A stdio MCP server is a single process with one stdin/stdout pair. If several
13
+ sessions write to it concurrently you get interleaved framing and JSON-RPC `id`
14
+ collisions (two clients both pick `id: 1`; responses can't be told apart). This
15
+ package puts the child behind a proxy that:
16
+
17
+ - **rewrites** every client request `id` to a global monotonic proxy id and
18
+ reverse-maps `{proxyId -> (sessionId, originalId)}`, so colliding client ids
19
+ never alias and each response routes home with its original id restored;
20
+ - **serializes** stdin writes through a single async queue, so concurrent
21
+ sessions can never interleave a frame;
22
+ - **routes** each response to its issuing session; load-and-delete on the id-map
23
+ means a duplicate/late response is dropped, never double-routed;
24
+ - **isolates teardown**: closing one session drops only that session's mappings,
25
+ leaving every other session's in-flight requests intact;
26
+ - **survives downstream death**: on child exit, all in-flight requests are
27
+ errored (`-32011`), the id-map is cleared, and the child is respawned with
28
+ exponential backoff (3s → 30s).
29
+
30
+ ## Architecture
31
+
32
+ ```
33
+ clients ──unix socket──► SocketListener ──Session──┐
34
+ ├──► McpStdioMux ──Downstream──► child stdio MCP
35
+ Supervisor ──spawn/respawn/reap──► NodeDownstream ─┘ (id remap, write queue,
36
+ (FSM + error-path resource ledger) response routing, TTL sweep)
37
+ ```
38
+
39
+ - `McpStdioMux` — pure logic, owns NO process or socket. Fully unit-testable
40
+ with in-process fakes (`Downstream` / `Session` are the test seams).
41
+ - `Supervisor` — owns the child lifecycle + respawn/teardown FSM, and enforces
42
+ the **error-path resource-ownership invariant** (see below).
43
+ - `NodeDownstream` / `SocketListener` — the only files that touch
44
+ `child_process` / `net`.
45
+
46
+ ### The FD-leak invariant
47
+
48
+ The reference implementation this was specced against leaked a log fd: `Start()`
49
+ opened it, then errored *before* registering for `Stop()`, so the fd was never
50
+ closed — repeated failing attaches drained the fd budget over hours.
51
+
52
+ `Supervisor.start()` pre-empts that bug class with an **acquire ledger**: every
53
+ resource acquired is pushed onto a release list *before* the next acquisition.
54
+ If anything throws before the ownership-transfer point, the ledger is unwound in
55
+ reverse. The `FD-LEAK HAMMER` test runs 500 failing starts and asserts the net
56
+ open-handle count returns to zero.
57
+
58
+ ## Usage
59
+
60
+ ```ts
61
+ import { createStdioMux } from '@almyty/mcp-mux';
62
+
63
+ const handle = await createStdioMux({
64
+ socketPath: '/tmp/my-mcp.sock',
65
+ downstream: { command: 'npx', args: ['-y', 'some-mcp-server'] },
66
+ supervisor: { backoffBaseMs: 3000, backoffCapMs: 30000 },
67
+ });
68
+
69
+ // ... clients connect to the socket and speak newline-delimited JSON-RPC ...
70
+
71
+ await handle.close(); // stop accepting, reap child, release resources
72
+ ```
73
+
74
+ ## Design note
75
+
76
+ Full design — id-map lifecycle, framing serialization, response routing, the
77
+ respawn/teardown state machine, and error-path resource ownership — lives in
78
+ [`DESIGN.md`](./DESIGN.md).
79
+
80
+ ## Develop
81
+
82
+ ```bash
83
+ npm install
84
+ npm run typecheck # tsc --noEmit (includes tests)
85
+ npm test # vitest run
86
+ npm run build # emits dist/ (tests excluded)
87
+ ```
88
+
89
+ ## About almyty
90
+
91
+ almyty is the full-stack platform for AI agents, agnostic by design: any LLM, any
92
+ API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
93
+ no lock-in.
94
+
95
+ - Website — https://almyty.com
96
+ - Docs — https://docs.almyty.com
97
+ - Source — https://github.com/almyty-inc/almyty
98
+
99
+ Apache-2.0 © Almyty Inc.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * createStdioMux — convenience wiring for the common case: serve one stdio MCP
3
+ * child to many clients over a Unix socket. Builds the McpStdioMux, a
4
+ * NodeDownstreamFactory + Supervisor (which owns spawn/respawn), and a
5
+ * SocketListener, then starts both.
6
+ *
7
+ * Returns a handle whose `close()` tears everything down in dependency order:
8
+ * stop accepting connections, stop the supervisor (reap child + release log),
9
+ * then close the mux. Southbound only — this never touches northbound serving.
10
+ */
11
+ import { McpStdioMux } from './mux.js';
12
+ import { Supervisor, type SupervisorOptions } from './supervisor.js';
13
+ import { type NodeDownstreamSpec } from './node-downstream.js';
14
+ export interface StdioMuxConfig {
15
+ socketPath: string;
16
+ downstream: NodeDownstreamSpec;
17
+ supervisor?: SupervisorOptions;
18
+ }
19
+ export interface StdioMuxHandle {
20
+ readonly mux: McpStdioMux;
21
+ readonly supervisor: Supervisor;
22
+ close(): Promise<void>;
23
+ }
24
+ export declare function createStdioMux(config: StdioMuxConfig): Promise<StdioMuxHandle>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * createStdioMux — convenience wiring for the common case: serve one stdio MCP
3
+ * child to many clients over a Unix socket. Builds the McpStdioMux, a
4
+ * NodeDownstreamFactory + Supervisor (which owns spawn/respawn), and a
5
+ * SocketListener, then starts both.
6
+ *
7
+ * Returns a handle whose `close()` tears everything down in dependency order:
8
+ * stop accepting connections, stop the supervisor (reap child + release log),
9
+ * then close the mux. Southbound only — this never touches northbound serving.
10
+ */
11
+ import { McpStdioMux } from './mux.js';
12
+ import { Supervisor } from './supervisor.js';
13
+ import { NodeDownstreamFactory } from './node-downstream.js';
14
+ import { SocketListener } from './socket-listener.js';
15
+ export async function createStdioMux(config) {
16
+ const mux = new McpStdioMux(config.supervisor);
17
+ const factory = new NodeDownstreamFactory(config.downstream);
18
+ const supervisor = new Supervisor(factory, mux, config.supervisor);
19
+ const listener = new SocketListener(mux, { socketPath: config.socketPath });
20
+ await supervisor.start(); // spawn the child first so early clients have a target
21
+ try {
22
+ await listener.listen();
23
+ }
24
+ catch (e) {
25
+ // Listener failed to bind — don't leak the running child.
26
+ await supervisor.stop();
27
+ mux.close();
28
+ throw e;
29
+ }
30
+ return {
31
+ mux,
32
+ supervisor,
33
+ async close() {
34
+ await listener.close();
35
+ await supervisor.stop();
36
+ mux.close();
37
+ },
38
+ };
39
+ }
40
+ //# sourceMappingURL=create-stdio-mux.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-stdio-mux.js","sourceRoot":"","sources":["../src/create-stdio-mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,UAAU,EAA0B,MAAM,iBAAiB,CAAC;AACrE,OAAO,EAAE,qBAAqB,EAA2B,MAAM,sBAAsB,CAAC;AACtF,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AActD,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,MAAsB;IACzD,MAAM,GAAG,GAAG,IAAI,WAAW,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC/C,MAAM,OAAO,GAAG,IAAI,qBAAqB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC7D,MAAM,UAAU,GAAG,IAAI,UAAU,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IACnE,MAAM,QAAQ,GAAG,IAAI,cAAc,CAAC,GAAG,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;IAE5E,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC,CAAC,uDAAuD;IACjF,IAAI,CAAC;QACH,MAAM,QAAQ,CAAC,MAAM,EAAE,CAAC;IAC1B,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,0DAA0D;QAC1D,MAAM,UAAU,CAAC,IAAI,EAAE,CAAC;QACxB,GAAG,CAAC,KAAK,EAAE,CAAC;QACZ,MAAM,CAAC,CAAC;IACV,CAAC;IAED,OAAO;QACL,GAAG;QACH,UAAU;QACV,KAAK,CAAC,KAAK;YACT,MAAM,QAAQ,CAAC,KAAK,EAAE,CAAC;YACvB,MAAM,UAAU,CAAC,IAAI,EAAE,CAAC;YACxB,GAAG,CAAC,KAAK,EAAE,CAAC;QACd,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,12 @@
1
+ export { McpStdioMux } from './mux.js';
2
+ export { Supervisor } from './supervisor.js';
3
+ export type { SupervisorOptions, SupervisorState, Closable } from './supervisor.js';
4
+ export { LineReader } from './line-reader.js';
5
+ export { NodeDownstream, NodeDownstreamFactory } from './node-downstream.js';
6
+ export type { NodeDownstreamSpec } from './node-downstream.js';
7
+ export { SocketListener } from './socket-listener.js';
8
+ export type { SocketListenerOptions } from './socket-listener.js';
9
+ export { createStdioMux } from './create-stdio-mux.js';
10
+ export type { StdioMuxConfig, StdioMuxHandle } from './create-stdio-mux.js';
11
+ export { RPC } from './types.js';
12
+ export type { Downstream, DownstreamFactory, DownstreamExit, Session, JsonRpcFrame, JsonRpcId, IdMapping, MuxOptions, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { McpStdioMux } from './mux.js';
2
+ export { Supervisor } from './supervisor.js';
3
+ export { LineReader } from './line-reader.js';
4
+ export { NodeDownstream, NodeDownstreamFactory } from './node-downstream.js';
5
+ export { SocketListener } from './socket-listener.js';
6
+ export { createStdioMux } from './create-stdio-mux.js';
7
+ export { RPC } from './types.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE7E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Newline-delimited frame reader. Downstream stdout arrives in arbitrary
3
+ * chunks; a frame may span chunks or several frames may share one chunk.
4
+ * NEVER JSON.parse a raw chunk — buffer until '\n'.
5
+ */
6
+ export declare class LineReader {
7
+ private readonly onLine;
8
+ private buf;
9
+ constructor(onLine: (line: string) => void);
10
+ push(chunk: string | Buffer): void;
11
+ /** Any trailing bytes with no terminating newline (e.g. at EOF). */
12
+ flushRemainder(): string;
13
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Newline-delimited frame reader. Downstream stdout arrives in arbitrary
3
+ * chunks; a frame may span chunks or several frames may share one chunk.
4
+ * NEVER JSON.parse a raw chunk — buffer until '\n'.
5
+ */
6
+ export class LineReader {
7
+ onLine;
8
+ buf = '';
9
+ constructor(onLine) {
10
+ this.onLine = onLine;
11
+ }
12
+ push(chunk) {
13
+ this.buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
14
+ let nl;
15
+ while ((nl = this.buf.indexOf('\n')) !== -1) {
16
+ const line = this.buf.slice(0, nl);
17
+ this.buf = this.buf.slice(nl + 1);
18
+ // Tolerate \r\n and skip blank keep-alive lines.
19
+ const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line;
20
+ if (trimmed.length > 0)
21
+ this.onLine(trimmed);
22
+ }
23
+ }
24
+ /** Any trailing bytes with no terminating newline (e.g. at EOF). */
25
+ flushRemainder() {
26
+ const rem = this.buf;
27
+ this.buf = '';
28
+ return rem;
29
+ }
30
+ }
31
+ //# sourceMappingURL=line-reader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"line-reader.js","sourceRoot":"","sources":["../src/line-reader.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,OAAO,UAAU;IAGQ;IAFrB,GAAG,GAAG,EAAE,CAAC;IAEjB,YAA6B,MAA8B;QAA9B,WAAM,GAAN,MAAM,CAAwB;IAAG,CAAC;IAE/D,IAAI,CAAC,KAAsB;QACzB,IAAI,CAAC,GAAG,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACvE,IAAI,EAAU,CAAC;QACf,OAAO,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;YAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACnC,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;YAClC,iDAAiD;YACjD,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAC/D,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;gBAAE,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;IAED,oEAAoE;IACpE,cAAc;QACZ,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC;QACrB,IAAI,CAAC,GAAG,GAAG,EAAE,CAAC;QACd,OAAO,GAAG,CAAC;IACb,CAAC;CACF"}
package/dist/mux.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * McpStdioMux — the multiplexer proper. Fans N sessions into ONE downstream
3
+ * stdio MCP child:
4
+ * - rewrites each client request `id` to a global monotonic proxy id and
5
+ * reverse-maps {proxyId -> (sessionId, originalId)} so responses route back;
6
+ * - serializes stdin frames (write queue) so concurrent sessions can't
7
+ * interleave framing;
8
+ * - restores the original id on the way back and routes to the issuing session;
9
+ * - on downstream loss, errors every in-flight request and clears the map;
10
+ * - per-session teardown removes only that session's mappings.
11
+ *
12
+ * It owns NO process and NO socket — the supervisor hands it a Downstream and
13
+ * the listener hands it Sessions. That keeps it fully unit-testable with fakes.
14
+ *
15
+ * Direction scope (v1): client->downstream requests/notifications and
16
+ * downstream->client responses are fully multiplexed. downstream->client
17
+ * messages that carry a `method` (server-initiated notifications/requests, e.g.
18
+ * progress, sampling) are BROADCAST to all sessions — server-initiated requests
19
+ * cannot be cleanly fanned to one client, so they are surfaced, not routed.
20
+ */
21
+ import type { Downstream, Session, MuxOptions } from './types.js';
22
+ export declare class McpStdioMux {
23
+ private readonly idMap;
24
+ private proxyIdSeq;
25
+ private readonly sessions;
26
+ private downstream;
27
+ private writeChain;
28
+ private sweepTimer;
29
+ private closed;
30
+ private readonly ttlMs;
31
+ private readonly warn;
32
+ constructor(opts?: MuxOptions);
33
+ /** In-flight request count (test/observability). */
34
+ get inFlight(): number;
35
+ get sessionCount(): number;
36
+ setDownstream(downstream: Downstream): void;
37
+ /** Called by the supervisor when the child is lost; errors all in-flight. */
38
+ onDownstreamGone(reason: string): void;
39
+ addSession(session: Session): void;
40
+ /** Per-session teardown: drop ONLY this session's mappings; never the child. */
41
+ teardownSession(sessionId: string): void;
42
+ private onClientFrame;
43
+ private nextProxyId;
44
+ /** Serialize frames to the downstream; resolves after the frame is flushed. */
45
+ private enqueueFrame;
46
+ private onDownstreamLine;
47
+ private normalizeId;
48
+ private sweep;
49
+ private errorSession;
50
+ /** Stop sweeping + forget everything. Does not touch the downstream/sessions' sockets. */
51
+ close(): void;
52
+ }
package/dist/mux.js ADDED
@@ -0,0 +1,180 @@
1
+ import { RPC } from './types.js';
2
+ const DEFAULT_TTL_MS = 300_000;
3
+ const DEFAULT_SWEEP_MS = 30_000;
4
+ export class McpStdioMux {
5
+ idMap = new Map();
6
+ proxyIdSeq = 0; // monotonic across the mux's life (incl. respawns)
7
+ sessions = new Map();
8
+ downstream = null;
9
+ writeChain = Promise.resolve();
10
+ sweepTimer = null;
11
+ closed = false;
12
+ ttlMs;
13
+ warn;
14
+ constructor(opts = {}) {
15
+ this.ttlMs = opts.requestTtlMs ?? DEFAULT_TTL_MS;
16
+ this.warn = opts.warn ?? ((m) => process.stderr.write(`[mcp-mux] ${m}\n`));
17
+ const sweepMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_MS;
18
+ this.sweepTimer = setInterval(() => this.sweep(), sweepMs);
19
+ this.sweepTimer.unref?.();
20
+ }
21
+ /** In-flight request count (test/observability). */
22
+ get inFlight() {
23
+ return this.idMap.size;
24
+ }
25
+ get sessionCount() {
26
+ return this.sessions.size;
27
+ }
28
+ // ── downstream wiring (called by the supervisor on spawn/respawn) ──
29
+ setDownstream(downstream) {
30
+ this.downstream = downstream;
31
+ downstream.on('line', (line) => this.onDownstreamLine(line));
32
+ downstream.once('exit', () => this.onDownstreamGone('downstream exited'));
33
+ downstream.on('error', (e) => this.warn(`downstream error: ${e?.message ?? e}`));
34
+ }
35
+ /** Called by the supervisor when the child is lost; errors all in-flight. */
36
+ onDownstreamGone(reason) {
37
+ this.downstream = null;
38
+ const affected = new Set();
39
+ for (const m of this.idMap.values())
40
+ affected.add(m.sessionId);
41
+ this.idMap.clear();
42
+ for (const sid of affected) {
43
+ this.errorSession(sid, null, RPC.DOWNSTREAM_GONE, `downstream unavailable: ${reason}`);
44
+ }
45
+ }
46
+ // ── session wiring (called by the listener) ──
47
+ addSession(session) {
48
+ this.sessions.set(session.id, session);
49
+ session.on('frame', (frame) => this.onClientFrame(session.id, frame));
50
+ session.once('close', () => this.teardownSession(session.id));
51
+ }
52
+ /** Per-session teardown: drop ONLY this session's mappings; never the child. */
53
+ teardownSession(sessionId) {
54
+ for (const [proxyId, m] of this.idMap) {
55
+ if (m.sessionId === sessionId)
56
+ this.idMap.delete(proxyId);
57
+ }
58
+ this.sessions.delete(sessionId);
59
+ }
60
+ // ── client -> downstream ──
61
+ onClientFrame(sessionId, raw) {
62
+ if (this.closed)
63
+ return;
64
+ let frame;
65
+ try {
66
+ frame = JSON.parse(raw);
67
+ }
68
+ catch {
69
+ this.errorSession(sessionId, null, RPC.PARSE_ERROR, 'invalid JSON frame');
70
+ return;
71
+ }
72
+ // Notification (request without an id): forward verbatim, allocate nothing —
73
+ // a mapping for something that never gets a response would leak forever.
74
+ if (frame.id === undefined || frame.id === null) {
75
+ void this.enqueueFrame(raw);
76
+ return;
77
+ }
78
+ const proxyId = this.nextProxyId();
79
+ this.idMap.set(proxyId, { sessionId, originalId: frame.id, sentAt: Date.now() });
80
+ frame.id = proxyId;
81
+ void this.enqueueFrame(JSON.stringify(frame)).catch(() => {
82
+ // Write failed (downstream gone / EPIPE): free the mapping and tell the
83
+ // client now rather than leaving it hung until the TTL sweep.
84
+ if (this.idMap.delete(proxyId)) {
85
+ this.errorSession(sessionId, frame.id, RPC.DOWNSTREAM_GONE, 'downstream write failed');
86
+ }
87
+ });
88
+ }
89
+ nextProxyId() {
90
+ if (this.proxyIdSeq >= Number.MAX_SAFE_INTEGER) {
91
+ // ~9e15 ids into a single process life — practically unreachable, but a
92
+ // wrap would alias live mappings, so refuse loudly instead.
93
+ throw new Error('mcp-mux: proxy id space exhausted');
94
+ }
95
+ return ++this.proxyIdSeq;
96
+ }
97
+ /** Serialize frames to the downstream; resolves after the frame is flushed. */
98
+ enqueueFrame(frame) {
99
+ const run = async () => {
100
+ const ds = this.downstream;
101
+ if (!ds)
102
+ throw new Error('no downstream');
103
+ await ds.write(frame);
104
+ };
105
+ // Chain so frames never interleave; isolate failures so the chain survives.
106
+ this.writeChain = this.writeChain.then(run, run);
107
+ return this.writeChain;
108
+ }
109
+ // ── downstream -> client ──
110
+ onDownstreamLine(line) {
111
+ let frame;
112
+ try {
113
+ frame = JSON.parse(line);
114
+ }
115
+ catch {
116
+ this.warn(`unparseable downstream line dropped: ${line.slice(0, 120)}`);
117
+ return;
118
+ }
119
+ // Server-initiated (has a method): notification or request. Can't be routed
120
+ // to one session — broadcast and move on.
121
+ if (typeof frame.method === 'string') {
122
+ for (const s of this.sessions.values())
123
+ s.send(line);
124
+ return;
125
+ }
126
+ // Otherwise it's a response: route by the proxy id.
127
+ const proxyId = this.normalizeId(frame.id);
128
+ if (proxyId === null) {
129
+ this.warn('downstream response with non-numeric id dropped');
130
+ return;
131
+ }
132
+ const mapping = this.idMap.get(proxyId);
133
+ if (!mapping) {
134
+ // Unknown or already-freed (duplicate/late) — drop, never double-route.
135
+ this.warn(`downstream response for unknown id ${proxyId} dropped`);
136
+ return;
137
+ }
138
+ this.idMap.delete(proxyId); // load-and-delete: a second response can't re-route
139
+ frame.id = mapping.originalId; // restore the client's original id
140
+ const session = this.sessions.get(mapping.sessionId);
141
+ if (session)
142
+ session.send(JSON.stringify(frame));
143
+ // If the session is already gone, silently drop — its mappings were cleared
144
+ // at teardown; this guards the late-arrival race.
145
+ }
146
+ normalizeId(id) {
147
+ if (typeof id === 'number' && Number.isSafeInteger(id))
148
+ return id;
149
+ if (typeof id === 'string' && /^\d+$/.test(id))
150
+ return Number(id); // tolerant of stringified ids
151
+ return null;
152
+ }
153
+ // ── TTL sweep ──
154
+ sweep() {
155
+ const cutoff = Date.now() - this.ttlMs;
156
+ for (const [proxyId, m] of this.idMap) {
157
+ if (m.sentAt <= cutoff) {
158
+ this.idMap.delete(proxyId);
159
+ this.errorSession(m.sessionId, m.originalId, RPC.DOWNSTREAM_TIMEOUT, 'downstream did not respond in time');
160
+ }
161
+ }
162
+ }
163
+ errorSession(sessionId, id, code, message) {
164
+ const session = this.sessions.get(sessionId);
165
+ if (!session)
166
+ return;
167
+ session.send(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }));
168
+ }
169
+ /** Stop sweeping + forget everything. Does not touch the downstream/sessions' sockets. */
170
+ close() {
171
+ this.closed = true;
172
+ if (this.sweepTimer)
173
+ clearInterval(this.sweepTimer);
174
+ this.sweepTimer = null;
175
+ this.idMap.clear();
176
+ this.sessions.clear();
177
+ this.downstream = null;
178
+ }
179
+ }
180
+ //# sourceMappingURL=mux.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mux.js","sourceRoot":"","sources":["../src/mux.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,MAAM,cAAc,GAAG,OAAO,CAAC;AAC/B,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC,MAAM,OAAO,WAAW;IACL,KAAK,GAAG,IAAI,GAAG,EAAqB,CAAC;IAC9C,UAAU,GAAG,CAAC,CAAC,CAAC,mDAAmD;IAC1D,QAAQ,GAAG,IAAI,GAAG,EAAmB,CAAC;IAC/C,UAAU,GAAsB,IAAI,CAAC;IACrC,UAAU,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAC;IAC9C,UAAU,GAA0C,IAAI,CAAC;IACzD,MAAM,GAAG,KAAK,CAAC;IAEN,KAAK,CAAS;IACd,IAAI,CAAsB;IAE3C,YAAY,OAAmB,EAAE;QAC/B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,YAAY,IAAI,cAAc,CAAC;QACjD,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3E,MAAM,OAAO,GAAG,IAAI,CAAC,eAAe,IAAI,gBAAgB,CAAC;QACzD,IAAI,CAAC,UAAU,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,OAAO,CAAC,CAAC;QAC3D,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,CAAC;IAC5B,CAAC;IAED,oDAAoD;IACpD,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;IACzB,CAAC;IACD,IAAI,YAAY;QACd,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC5B,CAAC;IAED,sEAAsE;IAEtE,aAAa,CAAC,UAAsB;QAClC,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC;QACrE,UAAU,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,mBAAmB,CAAC,CAAC,CAAC;QAC1E,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAQ,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,qBAAqB,CAAC,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1F,CAAC;IAED,6EAA6E;IAC7E,gBAAgB,CAAC,MAAc;QAC7B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;QACvB,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAC;QACnC,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE;YAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QAC/D,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACnB,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;YAC3B,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,eAAe,EAAE,2BAA2B,MAAM,EAAE,CAAC,CAAC;QACzF,CAAC;IACH,CAAC;IAED,gDAAgD;IAEhD,UAAU,CAAC,OAAgB;QACzB,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QACvC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC;QAC9E,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;IAChE,CAAC;IAED,gFAAgF;IAChF,eAAe,CAAC,SAAiB;QAC/B,KAAK,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACtC,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS;gBAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAC5D,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAClC,CAAC;IAED,6BAA6B;IAErB,aAAa,CAAC,SAAiB,EAAE,GAAW;QAClD,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO;QACxB,IAAI,KAAmB,CAAC;QACxB,IAAI,CAAC;YACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC1B,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,IAAI,EAAE,GAAG,CAAC,WAAW,EAAE,oBAAoB,CAAC,CAAC;YAC1E,OAAO;QACT,CAAC;QAED,6EAA6E;QAC7E,yEAAyE;QACzE,IAAI,KAAK,CAAC,EAAE,KAAK,SAAS,IAAI,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC;YAChD,KAAK,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;YAC5B,OAAO;QACT,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACnC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACjF,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC;QAEnB,KAAK,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE;YACvD,wEAAwE;YACxE,8DAA8D;YAC9D,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/B,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,KAAK,CAAC,EAAe,EAAE,GAAG,CAAC,eAAe,EAAE,yBAAyB,CAAC,CAAC;YACtG,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,WAAW;QACjB,IAAI,IAAI,CAAC,UAAU,IAAI,MAAM,CAAC,gBAAgB,EAAE,CAAC;YAC/C,wEAAwE;YACxE,4DAA4D;YAC5D,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;QACvD,CAAC;QACD,OAAO,EAAE,IAAI,CAAC,UAAU,CAAC;IAC3B,CAAC;IAED,+EAA+E;IACvE,YAAY,CAAC,KAAa;QAChC,MAAM,GAAG,GAAG,KAAK,IAAI,EAAE;YACrB,MAAM,EAAE,GAAG,IAAI,CAAC,UAAU,CAAC;YAC3B,IAAI,CAAC,EAAE;gBAAE,MAAM,IAAI,KAAK,CAAC,eAAe,CAAC,CAAC;YAC1C,MAAM,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACxB,CAAC,CAAC;QACF,4EAA4E;QAC5E,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACjD,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,6BAA6B;IAErB,gBAAgB,CAAC,IAAY;QACnC,IAAI,KAAmB,CAAC;QACxB,IAAI,CAAC;YACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3B,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,IAAI,CAAC,wCAAwC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;YACxE,OAAO;QACT,CAAC;QAED,4EAA4E;QAC5E,0CAA0C;QAC1C,IAAI,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACrC,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE;gBAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACrD,OAAO;QACT,CAAC;QAED,oDAAoD;QACpD,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC3C,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,IAAI,CAAC,IAAI,CAAC,iDAAiD,CAAC,CAAC;YAC7D,OAAO;QACT,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACxC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,wEAAwE;YACxE,IAAI,CAAC,IAAI,CAAC,sCAAsC,OAAO,UAAU,CAAC,CAAC;YACnE,OAAO;QACT,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,oDAAoD;QAEhF,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,mCAAmC;QAClE,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACrD,IAAI,OAAO;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;QACjD,4EAA4E;QAC5E,kDAAkD;IACpD,CAAC;IAEO,WAAW,CAAC,EAAyB;QAC3C,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,MAAM,CAAC,aAAa,CAAC,EAAE,CAAC;YAAE,OAAO,EAAE,CAAC;QAClE,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAAE,OAAO,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,8BAA8B;QACjG,OAAO,IAAI,CAAC;IACd,CAAC;IAED,kBAAkB;IAEV,KAAK;QACX,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC;QACvC,KAAK,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACtC,IAAI,CAAC,CAAC,MAAM,IAAI,MAAM,EAAE,CAAC;gBACvB,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;gBAC3B,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,UAAU,EAAE,GAAG,CAAC,kBAAkB,EAAE,oCAAoC,CAAC,CAAC;YAC7G,CAAC;QACH,CAAC;IACH,CAAC;IAEO,YAAY,CAAC,SAAiB,EAAE,EAAa,EAAE,IAAY,EAAE,OAAe;QAClF,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO;YAAE,OAAO;QACrB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;IACjF,CAAC;IAED,0FAA0F;IAC1F,KAAK;QACH,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,IAAI,CAAC,UAAU;YAAE,aAAa,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACpD,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;QACvB,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACnB,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;QACtB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;IACzB,CAAC;CACF"}
@@ -0,0 +1,30 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { Downstream, DownstreamFactory } from './types.js';
3
+ export interface NodeDownstreamSpec {
4
+ command: string;
5
+ args?: string[];
6
+ cwd?: string;
7
+ env?: NodeJS.ProcessEnv;
8
+ /** Where to forward the child's stderr. Default: inherit to this process' stderr. */
9
+ stderr?: 'inherit' | 'ignore' | ((chunk: Buffer) => void);
10
+ }
11
+ export declare class NodeDownstream extends EventEmitter implements Downstream {
12
+ private readonly child;
13
+ private readonly reader;
14
+ private exited;
15
+ constructor(spec: NodeDownstreamSpec);
16
+ get pid(): number | undefined;
17
+ /**
18
+ * Write one frame + newline. Resolves once the data is accepted (or flushed
19
+ * past a full buffer). The mux awaits this to serialize framing, so a write
20
+ * that can't complete must reject rather than silently drop.
21
+ */
22
+ write(frame: string): Promise<void>;
23
+ kill(signal?: NodeJS.Signals): void;
24
+ }
25
+ /** Factory the Supervisor uses to (re)spawn the child on demand. */
26
+ export declare class NodeDownstreamFactory implements DownstreamFactory {
27
+ private readonly spec;
28
+ constructor(spec: NodeDownstreamSpec);
29
+ spawn(): Promise<Downstream>;
30
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * NodeDownstream — the real child_process implementation of the `Downstream`
3
+ * seam. Spawns one stdio MCP server, parses its stdout into newline-delimited
4
+ * JSON frames (via LineReader, so partial lines never reach the mux), and
5
+ * serializes writes to stdin with back-pressure-aware flushing.
6
+ *
7
+ * This is the ONLY file in the package that touches child_process; everything
8
+ * else is driven through the `Downstream` interface so it stays unit-testable.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { EventEmitter } from 'node:events';
12
+ import { LineReader } from './line-reader.js';
13
+ export class NodeDownstream extends EventEmitter {
14
+ child;
15
+ reader = new LineReader((line) => this.emit('line', line));
16
+ exited = false;
17
+ constructor(spec) {
18
+ super();
19
+ const stderrMode = spec.stderr === undefined ? 'inherit' : spec.stderr;
20
+ this.child = spawn(spec.command, spec.args ?? [], {
21
+ cwd: spec.cwd,
22
+ env: spec.env,
23
+ stdio: ['pipe', 'pipe', typeof stderrMode === 'function' ? 'pipe' : stderrMode],
24
+ });
25
+ this.child.stdout.on('data', (chunk) => this.reader.push(chunk));
26
+ if (typeof stderrMode === 'function' && this.child.stderr) {
27
+ this.child.stderr.on('data', stderrMode);
28
+ }
29
+ this.child.on('error', (err) => this.emit('error', err));
30
+ this.child.once('exit', (code, signal) => {
31
+ this.exited = true;
32
+ const tail = this.reader.flushRemainder();
33
+ if (tail)
34
+ this.emit('line', tail);
35
+ this.emit('exit', { code, signal });
36
+ });
37
+ }
38
+ get pid() {
39
+ return this.child.pid;
40
+ }
41
+ /**
42
+ * Write one frame + newline. Resolves once the data is accepted (or flushed
43
+ * past a full buffer). The mux awaits this to serialize framing, so a write
44
+ * that can't complete must reject rather than silently drop.
45
+ */
46
+ write(frame) {
47
+ return new Promise((resolve, reject) => {
48
+ if (this.exited || !this.child.stdin.writable) {
49
+ reject(new Error('downstream stdin not writable'));
50
+ return;
51
+ }
52
+ this.child.stdin.write(frame + '\n', (err) => (err ? reject(err) : resolve()));
53
+ });
54
+ }
55
+ kill(signal = 'SIGTERM') {
56
+ if (this.exited)
57
+ return;
58
+ try {
59
+ this.child.kill(signal);
60
+ }
61
+ catch {
62
+ /* already gone */
63
+ }
64
+ }
65
+ }
66
+ /** Factory the Supervisor uses to (re)spawn the child on demand. */
67
+ export class NodeDownstreamFactory {
68
+ spec;
69
+ constructor(spec) {
70
+ this.spec = spec;
71
+ }
72
+ async spawn() {
73
+ return new NodeDownstream(this.spec);
74
+ }
75
+ }
76
+ //# sourceMappingURL=node-downstream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-downstream.js","sourceRoot":"","sources":["../src/node-downstream.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import type { McpStdioMux } from './mux.js';
2
+ export interface SocketListenerOptions {
3
+ /** Filesystem path for the Unix domain socket. */
4
+ socketPath: string;
5
+ }
6
+ export declare class SocketListener {
7
+ private readonly mux;
8
+ private readonly opts;
9
+ private readonly server;
10
+ private seq;
11
+ constructor(mux: McpStdioMux, opts: SocketListenerOptions);
12
+ /** Register the connection with the mux SYNCHRONOUSLY — no await before this. */
13
+ private onConnection;
14
+ listen(): Promise<void>;
15
+ close(): Promise<void>;
16
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * SocketListener — the real Unix-socket implementation of the inbound side.
3
+ * Each accepted connection becomes one `Session`: its stream is framed by a
4
+ * LineReader (partial lines never reach the mux), `send` writes frame+newline,
5
+ * and `close` destroys the socket. The listener registers each session with the
6
+ * mux; the mux tears the session down on the socket's 'close' event.
7
+ *
8
+ * One process leak class to avoid here too: a socket accepted but never handed
9
+ * to the mux would leak. We register synchronously on 'connection', before any
10
+ * await, so every accepted fd has an owner.
11
+ */
12
+ import { createServer } from 'node:net';
13
+ import { EventEmitter } from 'node:events';
14
+ import { LineReader } from './line-reader.js';
15
+ class SocketSession extends EventEmitter {
16
+ id;
17
+ socket;
18
+ reader;
19
+ closed = false;
20
+ constructor(id, socket) {
21
+ super();
22
+ this.id = id;
23
+ this.socket = socket;
24
+ this.reader = new LineReader((line) => this.emit('frame', line));
25
+ socket.setEncoding('utf8');
26
+ socket.on('data', (chunk) => this.reader.push(chunk));
27
+ socket.once('close', () => this.markClosed());
28
+ socket.on('error', () => this.markClosed());
29
+ }
30
+ send(frame) {
31
+ if (this.closed || !this.socket.writable)
32
+ return;
33
+ this.socket.write(frame + '\n');
34
+ }
35
+ close() {
36
+ if (this.closed)
37
+ return;
38
+ this.socket.destroy();
39
+ // 'close' will fire markClosed(); guard in case destroy is synchronous.
40
+ this.markClosed();
41
+ }
42
+ markClosed() {
43
+ if (this.closed)
44
+ return;
45
+ this.closed = true;
46
+ this.emit('close');
47
+ }
48
+ }
49
+ export class SocketListener {
50
+ mux;
51
+ opts;
52
+ server;
53
+ seq = 0;
54
+ constructor(mux, opts) {
55
+ this.mux = mux;
56
+ this.opts = opts;
57
+ this.server = createServer((socket) => this.onConnection(socket));
58
+ }
59
+ /** Register the connection with the mux SYNCHRONOUSLY — no await before this. */
60
+ onConnection(socket) {
61
+ const session = new SocketSession(`s${++this.seq}`, socket);
62
+ this.mux.addSession(session); // mux wires 'frame'/'close' and owns teardown
63
+ }
64
+ listen() {
65
+ return new Promise((resolve, reject) => {
66
+ this.server.once('error', reject);
67
+ this.server.listen(this.opts.socketPath, () => {
68
+ this.server.removeListener('error', reject);
69
+ resolve();
70
+ });
71
+ });
72
+ }
73
+ close() {
74
+ return new Promise((resolve) => this.server.close(() => resolve()));
75
+ }
76
+ }
77
+ //# sourceMappingURL=socket-listener.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-listener.js","sourceRoot":"","sources":["../src/socket-listener.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Supervisor — owns the downstream child's lifecycle and the respawn/teardown
3
+ * state machine, and enforces the error-path resource-ownership invariant that
4
+ * pre-empts the reference's FD leak.
5
+ *
6
+ * idle ──start()ok──► running ──exit/error──► failed ──reap+sweep──► respawning ──backoff──► (start again)
7
+ * ▲ │
8
+ * └──── stop() ────────┴──────────────────────────────────────────► stopped (terminal)
9
+ *
10
+ * THE INVARIANT (their bug class): every resource acquired in start() is pushed
11
+ * onto an `acquired` ledger BEFORE the next acquisition. If anything throws
12
+ * before ownership is transferred (registerForStop), the ledger is unwound in
13
+ * reverse — so a half-built start can never leak a log fd / socket / child.
14
+ * Their leak: Start() opened a log fd, then errored before registering for
15
+ * Stop(); the fd was never closed; repeated failing attaches drained the budget.
16
+ */
17
+ import type { DownstreamFactory, MuxOptions } from './types.js';
18
+ import type { McpStdioMux } from './mux.js';
19
+ export interface Closable {
20
+ close(): void;
21
+ }
22
+ export type SupervisorState = 'idle' | 'running' | 'failed' | 'respawning' | 'stopped';
23
+ export interface SupervisorOptions extends MuxOptions {
24
+ /** Acquire the leak-prone pre-spawn resource (e.g. a log fd). Optional. */
25
+ openLog?: () => Closable;
26
+ backoffBaseMs?: number;
27
+ backoffCapMs?: number;
28
+ /** A run lasting at least this long resets the backoff. */
29
+ backoffResetAfterMs?: number;
30
+ }
31
+ export declare class Supervisor {
32
+ private readonly factory;
33
+ private readonly mux;
34
+ private readonly opts;
35
+ private state;
36
+ private downstream;
37
+ private log;
38
+ private childExited;
39
+ private reaped;
40
+ private respawnTimer;
41
+ private backoffMs;
42
+ private runStartedAt;
43
+ private readonly warn;
44
+ private readonly backoffBaseMs;
45
+ private readonly backoffCapMs;
46
+ private readonly backoffResetAfterMs;
47
+ constructor(factory: DownstreamFactory, mux: McpStdioMux, opts?: SupervisorOptions);
48
+ get current(): SupervisorState;
49
+ /**
50
+ * Acquire resources + spawn the downstream. The ledger guarantees that any
51
+ * failure before ownership transfer releases everything acquired so far.
52
+ */
53
+ start(): Promise<void>;
54
+ /** Child exited unexpectedly (or via our kill during reap). */
55
+ private onChildExit;
56
+ /** Kill (if alive) + mark reaped EXACTLY once — pre-empts double-kill/zombie. */
57
+ private reap;
58
+ private releaseLog;
59
+ private scheduleRespawn;
60
+ /** Terminal shutdown. Idempotent. Cancels respawn, reaps, releases resources. */
61
+ stop(): Promise<void>;
62
+ }
@@ -0,0 +1,160 @@
1
+ export class Supervisor {
2
+ factory;
3
+ mux;
4
+ opts;
5
+ state = 'idle';
6
+ downstream = null;
7
+ log = null;
8
+ childExited = false;
9
+ reaped = false;
10
+ respawnTimer = null;
11
+ backoffMs;
12
+ runStartedAt = 0;
13
+ warn;
14
+ backoffBaseMs;
15
+ backoffCapMs;
16
+ backoffResetAfterMs;
17
+ constructor(factory, mux, opts = {}) {
18
+ this.factory = factory;
19
+ this.mux = mux;
20
+ this.opts = opts;
21
+ this.warn = opts.warn ?? ((m) => process.stderr.write(`[mcp-mux:sup] ${m}\n`));
22
+ this.backoffBaseMs = opts.backoffBaseMs ?? 3_000;
23
+ this.backoffCapMs = opts.backoffCapMs ?? 30_000;
24
+ this.backoffResetAfterMs = opts.backoffResetAfterMs ?? 30_000;
25
+ this.backoffMs = this.backoffBaseMs;
26
+ }
27
+ get current() {
28
+ return this.state;
29
+ }
30
+ /**
31
+ * Acquire resources + spawn the downstream. The ledger guarantees that any
32
+ * failure before ownership transfer releases everything acquired so far.
33
+ */
34
+ async start() {
35
+ if (this.state === 'stopped')
36
+ throw new Error('supervisor stopped');
37
+ const acquired = [];
38
+ const unwind = () => {
39
+ while (acquired.length) {
40
+ try {
41
+ acquired.pop()();
42
+ }
43
+ catch (e) {
44
+ this.warn(`cleanup error: ${e?.message ?? e}`);
45
+ }
46
+ }
47
+ };
48
+ try {
49
+ // (1) leak-prone pre-spawn resource
50
+ if (this.opts.openLog) {
51
+ const log = this.opts.openLog();
52
+ acquired.push(() => log.close());
53
+ this.log = log;
54
+ }
55
+ // (2) the child + its pipes
56
+ const ds = await this.factory.spawn();
57
+ acquired.push(() => {
58
+ try {
59
+ ds.kill('SIGKILL');
60
+ }
61
+ catch {
62
+ /* */
63
+ }
64
+ });
65
+ // (3) wire it up — still inside the guarded region
66
+ this.childExited = false;
67
+ this.reaped = false;
68
+ ds.once('exit', () => this.onChildExit());
69
+ this.mux.setDownstream(ds);
70
+ // ── ownership transfer point ──
71
+ // Past here the Supervisor (via stop()) owns these; do NOT unwind them.
72
+ this.downstream = ds;
73
+ acquired.length = 0;
74
+ this.state = 'running';
75
+ this.runStartedAt = Date.now();
76
+ }
77
+ catch (e) {
78
+ unwind(); // releases log + child if acquired
79
+ this.log = null;
80
+ this.downstream = null;
81
+ this.state = 'failed';
82
+ throw e;
83
+ }
84
+ }
85
+ /** Child exited unexpectedly (or via our kill during reap). */
86
+ onChildExit() {
87
+ this.childExited = true;
88
+ if (this.state === 'stopped')
89
+ return; // intentional shutdown; nothing to do
90
+ // Reset backoff if the run was healthy for long enough.
91
+ if (Date.now() - this.runStartedAt >= this.backoffResetAfterMs) {
92
+ this.backoffMs = this.backoffBaseMs;
93
+ }
94
+ this.state = 'failed';
95
+ this.reap();
96
+ // NOTE: we do NOT call mux.onDownstreamGone() here. The mux self-subscribes
97
+ // to its downstream's 'exit' in setDownstream() and clears its own in-flight
98
+ // state. Single ownership: the mux owns its id-map; the supervisor owns the
99
+ // process (reap) and the respawn FSM. Calling it here would double-fire.
100
+ this.releaseLog();
101
+ this.scheduleRespawn();
102
+ }
103
+ /** Kill (if alive) + mark reaped EXACTLY once — pre-empts double-kill/zombie. */
104
+ reap() {
105
+ if (this.reaped)
106
+ return;
107
+ this.reaped = true;
108
+ const ds = this.downstream;
109
+ this.downstream = null;
110
+ if (ds && !this.childExited) {
111
+ try {
112
+ ds.kill('SIGKILL');
113
+ }
114
+ catch {
115
+ /* */
116
+ }
117
+ }
118
+ }
119
+ releaseLog() {
120
+ if (this.log) {
121
+ try {
122
+ this.log.close();
123
+ }
124
+ catch {
125
+ /* */
126
+ }
127
+ this.log = null;
128
+ }
129
+ }
130
+ scheduleRespawn() {
131
+ if (this.respawnTimer || this.state === 'stopped')
132
+ return; // never two in flight
133
+ this.state = 'respawning';
134
+ const delay = this.backoffMs;
135
+ this.backoffMs = Math.min(this.backoffCapMs, this.backoffMs * 2);
136
+ this.respawnTimer = setTimeout(() => {
137
+ this.respawnTimer = null;
138
+ if (this.state === 'stopped')
139
+ return;
140
+ this.start().catch((e) => {
141
+ this.warn(`respawn failed: ${e?.message ?? e}`);
142
+ this.scheduleRespawn(); // start() already set state=failed; back off again
143
+ });
144
+ }, delay);
145
+ this.respawnTimer.unref?.();
146
+ }
147
+ /** Terminal shutdown. Idempotent. Cancels respawn, reaps, releases resources. */
148
+ async stop() {
149
+ if (this.state === 'stopped')
150
+ return;
151
+ this.state = 'stopped';
152
+ if (this.respawnTimer) {
153
+ clearTimeout(this.respawnTimer);
154
+ this.respawnTimer = null;
155
+ }
156
+ this.reap();
157
+ this.releaseLog();
158
+ }
159
+ }
160
+ //# sourceMappingURL=supervisor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervisor.js","sourceRoot":"","sources":["../src/supervisor.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Wire + seam types for the southbound stdio-MCP multiplexer.
3
+ *
4
+ * The `Downstream` and `Session` interfaces are the test seams (mirroring the
5
+ * runner's AdapterFactory idiom): the multiplexer never spawns a process or
6
+ * opens a socket itself — it's handed these, so tests inject in-process fakes.
7
+ */
8
+ import type { EventEmitter } from 'node:events';
9
+ /** A JSON-RPC id per the spec: string | number | null (null only on errors). */
10
+ export type JsonRpcId = string | number | null;
11
+ /** Minimal JSON-RPC frame. We only care about `id`; everything else is opaque. */
12
+ export interface JsonRpcFrame {
13
+ jsonrpc?: string;
14
+ id?: JsonRpcId;
15
+ method?: string;
16
+ [k: string]: unknown;
17
+ }
18
+ /** One proxy-id -> origin reverse-map entry. */
19
+ export interface IdMapping {
20
+ sessionId: string;
21
+ originalId: JsonRpcId;
22
+ /** Epoch ms the request was forwarded — drives TTL eviction. */
23
+ sentAt: number;
24
+ }
25
+ /**
26
+ * One downstream MCP child as seen by the multiplexer. Emits exactly one
27
+ * `'line'` per complete newline-delimited JSON frame from the child's stdout
28
+ * (partial-line buffering is the implementation's job, never the consumer's),
29
+ * and `'exit'` once when the child is gone.
30
+ *
31
+ * `write` resolves only after the frame (including its trailing newline) is
32
+ * flushed past back-pressure, so the caller can serialize frames by awaiting.
33
+ */
34
+ export interface Downstream extends EventEmitter {
35
+ readonly pid: number | undefined;
36
+ /** Write one already-serialized JSON frame (no trailing newline; we add it). */
37
+ write(frame: string): Promise<void>;
38
+ /** Best-effort terminate. */
39
+ kill(signal?: NodeJS.Signals): void;
40
+ }
41
+ export interface DownstreamExit {
42
+ code: number | null;
43
+ signal: NodeJS.Signals | null;
44
+ }
45
+ /** Factory the supervisor uses to (re)spawn the downstream. */
46
+ export interface DownstreamFactory {
47
+ spawn(): Promise<Downstream>;
48
+ }
49
+ /**
50
+ * One client connection == one session. The multiplexer reads `'frame'`
51
+ * events (incoming client frames) and calls `send` to push responses back.
52
+ */
53
+ export interface Session extends EventEmitter {
54
+ readonly id: string;
55
+ /** Push one serialized JSON frame to the client (newline added by impl). */
56
+ send(frame: string): void;
57
+ close(): void;
58
+ }
59
+ export interface MuxOptions {
60
+ /** Evict id-map entries older than this (ms) and error their sessions. Default 300_000. */
61
+ requestTtlMs?: number;
62
+ /** Sweep cadence (ms). Default 30_000. */
63
+ sweepIntervalMs?: number;
64
+ /** Non-fatal logger. Defaults to stderr. */
65
+ warn?: (msg: string) => void;
66
+ }
67
+ /** JSON-RPC error codes we synthesize. */
68
+ export declare const RPC: {
69
+ readonly PARSE_ERROR: -32700;
70
+ readonly INVALID_REQUEST: -32600;
71
+ readonly INTERNAL: -32603;
72
+ readonly DOWNSTREAM_TIMEOUT: -32010;
73
+ readonly DOWNSTREAM_GONE: -32011;
74
+ };
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ /** JSON-RPC error codes we synthesize. */
2
+ export const RPC = {
3
+ PARSE_ERROR: -32700,
4
+ INVALID_REQUEST: -32600,
5
+ INTERNAL: -32603,
6
+ DOWNSTREAM_TIMEOUT: -32010,
7
+ DOWNSTREAM_GONE: -32011,
8
+ };
9
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA6EA,0CAA0C;AAC1C,MAAM,CAAC,MAAM,GAAG,GAAG;IACjB,WAAW,EAAE,CAAC,KAAK;IACnB,eAAe,EAAE,CAAC,KAAK;IACvB,QAAQ,EAAE,CAAC,KAAK;IAChB,kBAAkB,EAAE,CAAC,KAAK;IAC1B,eAAe,EAAE,CAAC,KAAK;CACf,CAAC"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@almyty/mcp-mux",
3
+ "version": "1.2.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "stdio-MCP multiplexer \u2014 fans many client sessions into one downstream MCP child over a Unix socket, rewriting ids. Internal almyty plumbing.",
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^25.4.0",
26
+ "typescript": "^5.3.0",
27
+ "vitest": "^4.1.0"
28
+ },
29
+ "keywords": [
30
+ "almyty",
31
+ "mcp",
32
+ "multiplexer",
33
+ "stdio",
34
+ "proxy"
35
+ ],
36
+ "author": "almyty",
37
+ "license": "Apache-2.0",
38
+ "homepage": "https://almyty.com",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/almyty-inc/almyty.git",
42
+ "directory": "packages/mcp-mux"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/almyty-inc/almyty/issues"
46
+ }
47
+ }