nuwax-mcp-stdio-proxy 1.0.0 → 1.1.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/dist/index.d.ts CHANGED
@@ -2,17 +2,22 @@
2
2
  /**
3
3
  * nuwax-mcp-stdio-proxy
4
4
  *
5
- * A pure TypeScript stdio MCP proxy that aggregates multiple child MCP servers
6
- * into a single MCP server endpoint. Eliminates HTTP/port management and
7
- * avoids Windows console popup issues when invoked via Node.js directly.
5
+ * A pure TypeScript stdio MCP proxy that aggregates multiple MCP servers
6
+ * into a single MCP server endpoint. Supports two upstream transport types:
7
+ *
8
+ * - **stdio**: Spawns child MCP server processes (StdioClientTransport)
9
+ * - **bridge**: Connects to persistent MCP Bridge servers via StreamableHTTP (StreamableHTTPClientTransport)
8
10
  *
9
11
  * Usage:
10
- * nuwax-mcp-stdio-proxy --config '{"mcpServers":{"name":{"command":"...","args":["..."]}}}'
12
+ * nuwax-mcp-stdio-proxy --config '{"mcpServers":{
13
+ * "local": {"command":"npx","args":["-y","some-mcp"]},
14
+ * "remote": {"url":"http://127.0.0.1:8080/mcp/name"} // bridge to persistent server
15
+ * }}'
11
16
  *
12
17
  * Architecture:
13
18
  * Agent Engine (stdin/stdout) ←→ This Proxy (StdioServerTransport)
14
19
  * ├→ Child MCP Server A (StdioClientTransport)
15
- * ├→ Child MCP Server B (StdioClientTransport)
20
+ * ├→ Bridge MCP Server B (StreamableHTTPClientTransport)
16
21
  * └→ ...
17
22
  */
18
23
  export {};
package/dist/index.js CHANGED
@@ -2,32 +2,31 @@
2
2
  /**
3
3
  * nuwax-mcp-stdio-proxy
4
4
  *
5
- * A pure TypeScript stdio MCP proxy that aggregates multiple child MCP servers
6
- * into a single MCP server endpoint. Eliminates HTTP/port management and
7
- * avoids Windows console popup issues when invoked via Node.js directly.
5
+ * A pure TypeScript stdio MCP proxy that aggregates multiple MCP servers
6
+ * into a single MCP server endpoint. Supports two upstream transport types:
7
+ *
8
+ * - **stdio**: Spawns child MCP server processes (StdioClientTransport)
9
+ * - **bridge**: Connects to persistent MCP Bridge servers via StreamableHTTP (StreamableHTTPClientTransport)
8
10
  *
9
11
  * Usage:
10
- * nuwax-mcp-stdio-proxy --config '{"mcpServers":{"name":{"command":"...","args":["..."]}}}'
12
+ * nuwax-mcp-stdio-proxy --config '{"mcpServers":{
13
+ * "local": {"command":"npx","args":["-y","some-mcp"]},
14
+ * "remote": {"url":"http://127.0.0.1:8080/mcp/name"} // bridge to persistent server
15
+ * }}'
11
16
  *
12
17
  * Architecture:
13
18
  * Agent Engine (stdin/stdout) ←→ This Proxy (StdioServerTransport)
14
19
  * ├→ Child MCP Server A (StdioClientTransport)
15
- * ├→ Child MCP Server B (StdioClientTransport)
20
+ * ├→ Bridge MCP Server B (StreamableHTTPClientTransport)
16
21
  * └→ ...
17
22
  */
18
23
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
21
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
22
25
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
23
- // ========== Logging (stderr only — stdout is MCP JSON-RPC channel) ==========
24
- function log(level, msg) {
25
- process.stderr.write(`[nuwax-mcp-proxy] ${level}: ${msg}\n`);
26
- }
27
- const logInfo = (msg) => log('INFO', msg);
28
- const logWarn = (msg) => log('WARN', msg);
29
- const logError = (msg) => log('ERROR', msg);
30
- // ========== CLI Argument Parsing ==========
26
+ import { isHttpEntry } from './types.js';
27
+ import { logInfo, logWarn, logError } from './logger.js';
28
+ import { buildBaseEnv, connectStdio, connectBridge } from './transport.js';
29
+ // ========== CLI ==========
31
30
  function parseConfig() {
32
31
  const args = process.argv.slice(2);
33
32
  const idx = args.indexOf('--config');
@@ -48,20 +47,6 @@ function parseConfig() {
48
47
  process.exit(1);
49
48
  }
50
49
  }
51
- // ========== Build Clean Environment for Child Processes ==========
52
- function buildBaseEnv() {
53
- const env = {};
54
- // Copy current process.env, filtering out undefined values
55
- for (const [key, value] of Object.entries(process.env)) {
56
- if (value !== undefined) {
57
- env[key] = value;
58
- }
59
- }
60
- // Remove ELECTRON_RUN_AS_NODE — child MCP servers should run normally,
61
- // not as Electron Node.js instances
62
- delete env.ELECTRON_RUN_AS_NODE;
63
- return env;
64
- }
65
50
  // ========== Main ==========
66
51
  async function main() {
67
52
  const config = parseConfig();
@@ -70,38 +55,22 @@ async function main() {
70
55
  logError('No MCP servers configured in mcpServers');
71
56
  process.exit(1);
72
57
  }
73
- logInfo(`Starting proxy with ${entries.length} child server(s): ${entries.map(([id]) => id).join(', ')}`);
74
- // ---- Phase 1: Connect to all child MCP servers ----
75
- // Build base env once — per-server env is merged on top
58
+ logInfo(`Starting proxy with ${entries.length} server(s): ${entries.map(([id]) => id).join(', ')}`);
59
+ // ---- Phase 1: Connect to all MCP servers (stdio + bridge) ----
76
60
  const baseEnv = buildBaseEnv();
77
61
  const clients = new Map();
62
+ const cleanups = new Map();
78
63
  const toolToClient = new Map();
79
64
  const toolToServer = new Map();
80
65
  const toolsByName = new Map();
81
66
  for (const [id, entry] of entries) {
82
- let transport = null;
83
67
  try {
84
- logInfo(`Connecting to "${id}": ${entry.command} ${(entry.args || []).join(' ')}`);
85
- transport = new StdioClientTransport({
86
- command: entry.command,
87
- args: entry.args || [],
88
- env: { ...baseEnv, ...(entry.env || {}) },
89
- // stderr defaults to 'pipe' in the SDK; child errors captured via transport.stderr
90
- });
91
- // Attach stderr listener BEFORE connect to catch early child errors
92
- // (SDK returns a PassThrough stream immediately for this purpose)
93
- if (transport.stderr) {
94
- transport.stderr.on('data', (chunk) => {
95
- const text = chunk.toString().trim();
96
- if (text) {
97
- process.stderr.write(`[child:${id}] ${text}\n`);
98
- }
99
- });
100
- }
101
- const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
102
- await client.connect(transport);
68
+ const { client, cleanup } = isHttpEntry(entry)
69
+ ? await connectBridge(id, entry)
70
+ : await connectStdio(id, entry, baseEnv);
103
71
  clients.set(id, client);
104
- // Discover tools from this child server (handle pagination)
72
+ cleanups.set(id, cleanup);
73
+ // Discover tools (handle pagination)
105
74
  const allServerTools = [];
106
75
  let cursor;
107
76
  do {
@@ -121,29 +90,20 @@ async function main() {
121
90
  }
122
91
  catch (e) {
123
92
  logError(`Failed to connect to server "${id}": ${e}`);
124
- // Clean up transport to prevent child process leak
125
- if (transport) {
126
- try {
127
- await transport.close();
128
- }
129
- catch { /* ignore */ }
130
- }
131
93
  // Continue with remaining servers — partial startup is acceptable
132
94
  }
133
95
  }
134
96
  if (clients.size === 0) {
135
- logError('Failed to connect to any child MCP server');
97
+ logError('Failed to connect to any MCP server');
136
98
  process.exit(1);
137
99
  }
138
100
  const aggregatedTools = Array.from(toolsByName.values());
139
101
  logInfo(`Aggregated ${aggregatedTools.length} unique tool(s) from ${clients.size} server(s)`);
140
102
  // ---- Phase 2: Create the aggregating MCP server ----
141
103
  const server = new Server({ name: 'nuwax-mcp-stdio-proxy', version: '1.0.0' }, { capabilities: { tools: {} } });
142
- // tools/list → return all aggregated tools
143
104
  server.setRequestHandler(ListToolsRequestSchema, async () => {
144
105
  return { tools: aggregatedTools };
145
106
  });
146
- // tools/call → route to the correct child server
147
107
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
108
  const { name, arguments: toolArgs } = request.params;
149
109
  const client = toolToClient.get(name);
@@ -180,10 +140,18 @@ async function main() {
180
140
  for (const [id, client] of clients) {
181
141
  try {
182
142
  await client.close();
183
- logInfo(`Closed child client "${id}"`);
143
+ logInfo(`Closed client "${id}"`);
144
+ }
145
+ catch (e) {
146
+ logError(`Failed to close client "${id}": ${e}`);
147
+ }
148
+ }
149
+ for (const [id, cleanup] of cleanups) {
150
+ try {
151
+ await cleanup();
184
152
  }
185
153
  catch (e) {
186
- logError(`Failed to close child client "${id}": ${e}`);
154
+ logError(`Failed cleanup for "${id}": ${e}`);
187
155
  }
188
156
  }
189
157
  try {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Logging utilities — stderr only, stdout is the MCP JSON-RPC channel
3
+ */
4
+ export declare function log(level: string, msg: string): void;
5
+ export declare const logInfo: (msg: string) => void;
6
+ export declare const logWarn: (msg: string) => void;
7
+ export declare const logError: (msg: string) => void;
package/dist/logger.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Logging utilities — stderr only, stdout is the MCP JSON-RPC channel
3
+ */
4
+ export function log(level, msg) {
5
+ process.stderr.write(`[nuwax-mcp-proxy] ${level}: ${msg}\n`);
6
+ }
7
+ export const logInfo = (msg) => log('INFO', msg);
8
+ export const logWarn = (msg) => log('WARN', msg);
9
+ export const logError = (msg) => log('ERROR', msg);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Transport layer — connect to upstream MCP servers via stdio or bridge (HTTP)
3
+ */
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import type { StdioServerEntry, BridgeServerEntry } from './types.js';
6
+ export interface ConnectedClient {
7
+ client: Client;
8
+ cleanup: () => Promise<void>;
9
+ }
10
+ /**
11
+ * Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
12
+ */
13
+ export declare function buildBaseEnv(): Record<string, string>;
14
+ /**
15
+ * Connect to a stdio MCP server (spawn child process)
16
+ */
17
+ export declare function connectStdio(id: string, entry: StdioServerEntry, baseEnv: Record<string, string>): Promise<ConnectedClient>;
18
+ /**
19
+ * Connect to a bridge MCP server (StreamableHTTP → PersistentMcpBridge)
20
+ *
21
+ * Bridge connections target long-lived MCP servers managed by PersistentMcpBridge
22
+ * in the Electron main process, accessed via HTTP endpoints.
23
+ */
24
+ export declare function connectBridge(id: string, entry: BridgeServerEntry): Promise<ConnectedClient>;
25
+ /** @deprecated Use connectBridge instead */
26
+ export declare const connectHttp: typeof connectBridge;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Transport layer — connect to upstream MCP servers via stdio or bridge (HTTP)
3
+ */
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
+ import { logInfo } from './logger.js';
8
+ /**
9
+ * Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
10
+ */
11
+ export function buildBaseEnv() {
12
+ const env = {};
13
+ for (const [key, value] of Object.entries(process.env)) {
14
+ if (value !== undefined) {
15
+ env[key] = value;
16
+ }
17
+ }
18
+ // Child MCP servers should run normally, not as Electron Node.js instances
19
+ delete env.ELECTRON_RUN_AS_NODE;
20
+ return env;
21
+ }
22
+ /**
23
+ * Connect to a stdio MCP server (spawn child process)
24
+ */
25
+ export async function connectStdio(id, entry, baseEnv) {
26
+ logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(' ')}`);
27
+ const transport = new StdioClientTransport({
28
+ command: entry.command,
29
+ args: entry.args || [],
30
+ env: { ...baseEnv, ...(entry.env || {}) },
31
+ });
32
+ // Attach stderr listener BEFORE connect to catch early child errors
33
+ if (transport.stderr) {
34
+ transport.stderr.on('data', (chunk) => {
35
+ const text = chunk.toString().trim();
36
+ if (text) {
37
+ process.stderr.write(`[child:${id}] ${text}\n`);
38
+ }
39
+ });
40
+ }
41
+ const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
42
+ await client.connect(transport);
43
+ return {
44
+ client,
45
+ cleanup: async () => {
46
+ try {
47
+ await transport.close();
48
+ }
49
+ catch { /* ignore */ }
50
+ },
51
+ };
52
+ }
53
+ /**
54
+ * Connect to a bridge MCP server (StreamableHTTP → PersistentMcpBridge)
55
+ *
56
+ * Bridge connections target long-lived MCP servers managed by PersistentMcpBridge
57
+ * in the Electron main process, accessed via HTTP endpoints.
58
+ */
59
+ export async function connectBridge(id, entry) {
60
+ logInfo(`Connecting to "${id}" (bridge): ${entry.url}`);
61
+ const transport = new StreamableHTTPClientTransport(new URL(entry.url));
62
+ const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
63
+ await client.connect(transport);
64
+ return {
65
+ client,
66
+ cleanup: async () => {
67
+ try {
68
+ await transport.close();
69
+ }
70
+ catch { /* ignore */ }
71
+ },
72
+ };
73
+ }
74
+ /** @deprecated Use connectBridge instead */
75
+ export const connectHttp = connectBridge;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Types for MCP server configuration
3
+ */
4
+ /** stdio 类型: spawn 子进程 */
5
+ export interface StdioServerEntry {
6
+ command: string;
7
+ args?: string[];
8
+ env?: Record<string, string>;
9
+ }
10
+ /**
11
+ * Bridge 类型: 连接持久化 MCP Bridge (HTTP)
12
+ *
13
+ * 用于连接 PersistentMcpBridge 等长生命周期的 MCP server。
14
+ * Bridge server 通过 StreamableHTTPClientTransport 访问,
15
+ * 其生命周期独立于 proxy 进程。
16
+ */
17
+ export interface BridgeServerEntry {
18
+ url: string;
19
+ }
20
+ /** @deprecated Use BridgeServerEntry instead */
21
+ export type HttpServerEntry = BridgeServerEntry;
22
+ export type McpServerEntry = StdioServerEntry | BridgeServerEntry;
23
+ export declare function isBridgeEntry(entry: McpServerEntry): entry is BridgeServerEntry;
24
+ /** @deprecated Use isBridgeEntry instead */
25
+ export declare const isHttpEntry: typeof isBridgeEntry;
26
+ export interface McpServersConfig {
27
+ mcpServers: Record<string, McpServerEntry>;
28
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Types for MCP server configuration
3
+ */
4
+ export function isBridgeEntry(entry) {
5
+ return 'url' in entry && typeof entry.url === 'string';
6
+ }
7
+ /** @deprecated Use isBridgeEntry instead */
8
+ export const isHttpEntry = isBridgeEntry;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuwax-mcp-stdio-proxy",
3
- "version": "1.0.0",
4
- "description": "TypeScript stdio MCP proxy — aggregates multiple child MCP servers into one",
3
+ "version": "1.1.0",
4
+ "description": "TypeScript stdio MCP proxy — aggregates multiple MCP servers (stdio + bridge) into one",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nuwax-mcp-stdio-proxy": "./dist/index.js"