nuwax-mcp-stdio-proxy 1.1.2 → 1.1.5

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,70 @@
1
+ # nuwax-mcp-stdio-proxy
2
+
3
+ TypeScript stdio MCP proxy — aggregates multiple MCP servers into a single stdio endpoint. Supports **stdio** (spawn child processes) and **bridge** (Streamable HTTP to a persistent MCP bridge).
4
+
5
+ ## Requirements
6
+
7
+ - **Node.js** >= 22.0.0
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ nuwax-mcp-stdio-proxy --config '{"mcpServers":{...}}'
13
+ ```
14
+
15
+ The proxy reads JSON from `--config` and runs as a stdio MCP server. Upstream servers can be:
16
+
17
+ - **stdio**: `{ "command", "args?", "env?" }` — proxy spawns a child process and talks MCP over stdin/stdout.
18
+ - **bridge**: `{ "url" }` — proxy connects via HTTP (Streamable HTTP) to a long-lived MCP bridge (e.g. Electron app’s PersistentMcpBridge).
19
+
20
+ ## Config format
21
+
22
+ | Entry type | Shape | Description |
23
+ |------------|--------|-------------|
24
+ | **stdio** | `{ "command": string, "args"?: string[], "env"?: Record<string, string> }` | Spawn subprocess; MCP over stdio. |
25
+ | **bridge** | `{ "url": string }` | Connect to MCP over HTTP (e.g. `http://127.0.0.1:PORT/mcp/<serverId>`). |
26
+
27
+ Example:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "filesystem": {
33
+ "command": "npx",
34
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
35
+ },
36
+ "chrome-devtools": {
37
+ "url": "http://127.0.0.1:57278/mcp/chrome-devtools"
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ - `filesystem` → stdio (child process).
44
+ - `chrome-devtools` → bridge (HTTP to a persistent bridge).
45
+
46
+ ## Architecture
47
+
48
+ ```
49
+ Agent / ACP engine (stdin/stdout)
50
+
51
+ nuwax-mcp-stdio-proxy (StdioServerTransport)
52
+ ├→ stdio upstream → child process (StdioClientTransport)
53
+ └→ bridge upstream → StreamableHTTPClientTransport → PersistentMcpBridge HTTP
54
+ ```
55
+
56
+ - **Downstream**: one stdio MCP server; the agent talks to the proxy over stdin/stdout.
57
+ - **Upstream**: multiple backends — some are child processes (stdio), some are HTTP bridge endpoints. Tools from all upstreams are aggregated and exposed as one tool list.
58
+
59
+ ## Scripts
60
+
61
+ | Command | Description |
62
+ |---------|-------------|
63
+ | `npm run build` | Compile TypeScript to `dist/`. |
64
+ | `npm run test` | Run tests (Vitest). |
65
+ | `npm run test:run` | Run tests once (no watch). |
66
+ | `npm run test:coverage` | Run tests with coverage. |
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Custom Stdio Transport — fixes Windows console popup issue
3
+ *
4
+ * The MCP SDK's StdioClientTransport sets:
5
+ * windowsHide: process.platform === 'win32' && isElectron()
6
+ *
7
+ * But when running via ELECTRON_RUN_AS_NODE=1, isElectron() returns false,
8
+ * causing console popup windows on Windows.
9
+ *
10
+ * This custom transport always sets windowsHide: true on Windows.
11
+ */
12
+ import { Readable } from 'stream';
13
+ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
14
+ import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
15
+ export interface CustomStdioServerParameters {
16
+ command: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ stderr?: 'inherit' | 'pipe' | 'overlapped';
20
+ cwd?: string;
21
+ }
22
+ export declare class CustomStdioClientTransport implements Transport {
23
+ private _process;
24
+ private _readBuffer;
25
+ private _stderrStream;
26
+ private _serverParams;
27
+ onclose?: () => void;
28
+ onerror?: (error: Error) => void;
29
+ onmessage?: (message: JSONRPCMessage) => void;
30
+ constructor(server: CustomStdioServerParameters);
31
+ start(): Promise<void>;
32
+ get stderr(): Readable | null;
33
+ get pid(): number | null;
34
+ private processReadBuffer;
35
+ close(): Promise<void>;
36
+ send(message: JSONRPCMessage): Promise<void>;
37
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Custom Stdio Transport — fixes Windows console popup issue
3
+ *
4
+ * The MCP SDK's StdioClientTransport sets:
5
+ * windowsHide: process.platform === 'win32' && isElectron()
6
+ *
7
+ * But when running via ELECTRON_RUN_AS_NODE=1, isElectron() returns false,
8
+ * causing console popup windows on Windows.
9
+ *
10
+ * This custom transport always sets windowsHide: true on Windows.
11
+ */
12
+ import { spawn } from 'child_process';
13
+ import { PassThrough } from 'stream';
14
+ import { serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
15
+ export class CustomStdioClientTransport {
16
+ _process;
17
+ _readBuffer = new ReadBuffer();
18
+ _stderrStream = null;
19
+ _serverParams;
20
+ onclose;
21
+ onerror;
22
+ onmessage;
23
+ constructor(server) {
24
+ this._serverParams = server;
25
+ if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
26
+ this._stderrStream = new PassThrough();
27
+ }
28
+ }
29
+ async start() {
30
+ if (this._process) {
31
+ throw new Error('StdioClientTransport already started!');
32
+ }
33
+ return new Promise((resolve, reject) => {
34
+ this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
35
+ env: {
36
+ ...getDefaultEnvironment(),
37
+ ...this._serverParams.env,
38
+ },
39
+ stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
40
+ shell: false,
41
+ windowsHide: true,
42
+ cwd: this._serverParams.cwd,
43
+ });
44
+ this._process.on('error', (error) => {
45
+ reject(error);
46
+ this.onerror?.(error);
47
+ });
48
+ this._process.on('spawn', () => {
49
+ resolve();
50
+ });
51
+ this._process.on('close', (_code) => {
52
+ this._process = undefined;
53
+ this.onclose?.();
54
+ });
55
+ this._process.stdin?.on('error', (error) => {
56
+ this.onerror?.(error);
57
+ });
58
+ this._process.stdout?.on('data', (chunk) => {
59
+ this._readBuffer.append(chunk);
60
+ this.processReadBuffer();
61
+ });
62
+ this._process.stdout?.on('error', (error) => {
63
+ this.onerror?.(error);
64
+ });
65
+ if (this._stderrStream && this._process.stderr) {
66
+ this._process.stderr.pipe(this._stderrStream);
67
+ }
68
+ });
69
+ }
70
+ get stderr() {
71
+ if (this._stderrStream) {
72
+ return this._stderrStream;
73
+ }
74
+ return this._process?.stderr ?? null;
75
+ }
76
+ get pid() {
77
+ return this._process?.pid ?? null;
78
+ }
79
+ processReadBuffer() {
80
+ while (true) {
81
+ try {
82
+ const message = this._readBuffer.readMessage();
83
+ if (message === null) {
84
+ break;
85
+ }
86
+ this.onmessage?.(message);
87
+ }
88
+ catch (error) {
89
+ this.onerror?.(error);
90
+ }
91
+ }
92
+ }
93
+ async close() {
94
+ if (this._process) {
95
+ const processToClose = this._process;
96
+ this._process = undefined;
97
+ const closePromise = new Promise((resolve) => {
98
+ processToClose.once('close', () => {
99
+ resolve();
100
+ });
101
+ });
102
+ try {
103
+ processToClose.stdin?.end();
104
+ }
105
+ catch {
106
+ // ignore
107
+ }
108
+ await Promise.race([closePromise, new Promise((resolve) => setTimeout(resolve, 2000).unref())]);
109
+ if (processToClose.exitCode === null) {
110
+ try {
111
+ processToClose.kill('SIGTERM');
112
+ }
113
+ catch {
114
+ // ignore
115
+ }
116
+ await Promise.race([closePromise, new Promise((resolve) => setTimeout(resolve, 2000).unref())]);
117
+ }
118
+ if (processToClose.exitCode === null) {
119
+ try {
120
+ processToClose.kill('SIGKILL');
121
+ }
122
+ catch {
123
+ // ignore
124
+ }
125
+ }
126
+ }
127
+ this._readBuffer.clear();
128
+ }
129
+ async send(message) {
130
+ return new Promise((resolve) => {
131
+ if (!this._process?.stdin) {
132
+ throw new Error('Not connected');
133
+ }
134
+ const json = serializeMessage(message);
135
+ if (this._process.stdin.write(json)) {
136
+ resolve();
137
+ }
138
+ else {
139
+ this._process.stdin.once('drain', resolve);
140
+ }
141
+ });
142
+ }
143
+ }
144
+ class ReadBuffer {
145
+ _buffer;
146
+ append(chunk) {
147
+ this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
148
+ }
149
+ readMessage() {
150
+ if (!this._buffer) {
151
+ return null;
152
+ }
153
+ const separatorIndex = this._buffer.indexOf('\n');
154
+ if (separatorIndex === -1) {
155
+ return null;
156
+ }
157
+ const line = this._buffer.subarray(0, separatorIndex);
158
+ this._buffer = this._buffer.subarray(separatorIndex + 1);
159
+ try {
160
+ const obj = JSON.parse(line.toString('utf-8'));
161
+ return obj;
162
+ }
163
+ catch {
164
+ throw new Error('Failed to parse JSON-RPC message');
165
+ }
166
+ }
167
+ clear() {
168
+ this._buffer = undefined;
169
+ }
170
+ }
171
+ const DEFAULT_INHERITED_ENV_VARS = process.platform === 'win32'
172
+ ? [
173
+ 'APPDATA',
174
+ 'HOMEDRIVE',
175
+ 'HOMEPATH',
176
+ 'LOCALAPPDATA',
177
+ 'PATH',
178
+ 'PROCESSOR_ARCHITECTURE',
179
+ 'SYSTEMDRIVE',
180
+ 'SYSTEMROOT',
181
+ 'TEMP',
182
+ 'USERNAME',
183
+ 'USERPROFILE',
184
+ 'PROGRAMFILES',
185
+ ]
186
+ : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
187
+ function getDefaultEnvironment() {
188
+ const env = {};
189
+ for (const key of DEFAULT_INHERITED_ENV_VARS) {
190
+ const value = process.env[key];
191
+ if (value === undefined) {
192
+ continue;
193
+ }
194
+ if (value.startsWith('()')) {
195
+ continue;
196
+ }
197
+ env[key] = value;
198
+ }
199
+ return env;
200
+ }
package/dist/transport.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * Transport layer — connect to upstream MCP servers via stdio or bridge (HTTP)
3
3
  */
4
4
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
5
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6
+ import { CustomStdioClientTransport } from './customStdio.js';
7
7
  import { logInfo } from './logger.js';
8
8
  /**
9
9
  * Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
@@ -24,10 +24,11 @@ export function buildBaseEnv() {
24
24
  */
25
25
  export async function connectStdio(id, entry, baseEnv) {
26
26
  logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(' ')}`);
27
- const transport = new StdioClientTransport({
27
+ const transport = new CustomStdioClientTransport({
28
28
  command: entry.command,
29
29
  args: entry.args || [],
30
30
  env: { ...baseEnv, ...(entry.env || {}) },
31
+ stderr: 'pipe',
31
32
  });
32
33
  // Attach stderr listener BEFORE connect to catch early child errors
33
34
  if (transport.stderr) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuwax-mcp-stdio-proxy",
3
- "version": "1.1.2",
3
+ "version": "1.1.5",
4
4
  "description": "TypeScript stdio MCP proxy — aggregates multiple MCP servers (stdio + bridge) into one",
5
5
  "type": "module",
6
6
  "bin": {