nuwax-mcp-stdio-proxy 1.0.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.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * nuwax-mcp-stdio-proxy
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.
8
+ *
9
+ * Usage:
10
+ * nuwax-mcp-stdio-proxy --config '{"mcpServers":{"name":{"command":"...","args":["..."]}}}'
11
+ *
12
+ * Architecture:
13
+ * Agent Engine (stdin/stdout) ←→ This Proxy (StdioServerTransport)
14
+ * ├→ Child MCP Server A (StdioClientTransport)
15
+ * ├→ Child MCP Server B (StdioClientTransport)
16
+ * └→ ...
17
+ */
18
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * nuwax-mcp-stdio-proxy
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.
8
+ *
9
+ * Usage:
10
+ * nuwax-mcp-stdio-proxy --config '{"mcpServers":{"name":{"command":"...","args":["..."]}}}'
11
+ *
12
+ * Architecture:
13
+ * Agent Engine (stdin/stdout) ←→ This Proxy (StdioServerTransport)
14
+ * ├→ Child MCP Server A (StdioClientTransport)
15
+ * ├→ Child MCP Server B (StdioClientTransport)
16
+ * └→ ...
17
+ */
18
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
+ 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
+ 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 ==========
31
+ function parseConfig() {
32
+ const args = process.argv.slice(2);
33
+ const idx = args.indexOf('--config');
34
+ if (idx === -1 || idx + 1 >= args.length) {
35
+ logError('Missing --config argument');
36
+ logError('Usage: nuwax-mcp-stdio-proxy --config \'{"mcpServers":{...}}\'');
37
+ process.exit(1);
38
+ }
39
+ try {
40
+ const config = JSON.parse(args[idx + 1]);
41
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
42
+ throw new Error('config must contain a "mcpServers" object');
43
+ }
44
+ return config;
45
+ }
46
+ catch (e) {
47
+ logError(`Failed to parse --config JSON: ${e}`);
48
+ process.exit(1);
49
+ }
50
+ }
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
+ // ========== Main ==========
66
+ async function main() {
67
+ const config = parseConfig();
68
+ const entries = Object.entries(config.mcpServers);
69
+ if (entries.length === 0) {
70
+ logError('No MCP servers configured in mcpServers');
71
+ process.exit(1);
72
+ }
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
76
+ const baseEnv = buildBaseEnv();
77
+ const clients = new Map();
78
+ const toolToClient = new Map();
79
+ const toolToServer = new Map();
80
+ const toolsByName = new Map();
81
+ for (const [id, entry] of entries) {
82
+ let transport = null;
83
+ 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);
103
+ clients.set(id, client);
104
+ // Discover tools from this child server (handle pagination)
105
+ const allServerTools = [];
106
+ let cursor;
107
+ do {
108
+ const page = await client.listTools(cursor ? { cursor } : undefined);
109
+ allServerTools.push(...page.tools);
110
+ cursor = page.nextCursor;
111
+ } while (cursor);
112
+ logInfo(`Server "${id}": ${allServerTools.length} tool(s)${allServerTools.length > 0 ? ' — ' + allServerTools.map((t) => t.name).join(', ') : ''}`);
113
+ for (const tool of allServerTools) {
114
+ if (toolToClient.has(tool.name)) {
115
+ logWarn(`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`);
116
+ }
117
+ toolToClient.set(tool.name, client);
118
+ toolToServer.set(tool.name, id);
119
+ toolsByName.set(tool.name, tool);
120
+ }
121
+ }
122
+ catch (e) {
123
+ 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
+ // Continue with remaining servers — partial startup is acceptable
132
+ }
133
+ }
134
+ if (clients.size === 0) {
135
+ logError('Failed to connect to any child MCP server');
136
+ process.exit(1);
137
+ }
138
+ const aggregatedTools = Array.from(toolsByName.values());
139
+ logInfo(`Aggregated ${aggregatedTools.length} unique tool(s) from ${clients.size} server(s)`);
140
+ // ---- Phase 2: Create the aggregating MCP server ----
141
+ const server = new Server({ name: 'nuwax-mcp-stdio-proxy', version: '1.0.0' }, { capabilities: { tools: {} } });
142
+ // tools/list → return all aggregated tools
143
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
144
+ return { tools: aggregatedTools };
145
+ });
146
+ // tools/call → route to the correct child server
147
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
+ const { name, arguments: toolArgs } = request.params;
149
+ const client = toolToClient.get(name);
150
+ if (!client) {
151
+ return {
152
+ content: [{ type: 'text', text: `Unknown tool: "${name}"` }],
153
+ isError: true,
154
+ };
155
+ }
156
+ try {
157
+ const result = await client.callTool({ name, arguments: toolArgs });
158
+ return result;
159
+ }
160
+ catch (e) {
161
+ const serverName = toolToServer.get(name) || 'unknown';
162
+ logError(`Tool "${name}" (server: "${serverName}") call failed: ${e}`);
163
+ return {
164
+ content: [{ type: 'text', text: `Tool call failed: ${e}` }],
165
+ isError: true,
166
+ };
167
+ }
168
+ });
169
+ // ---- Phase 3: Start stdio transport ----
170
+ const transport = new StdioServerTransport();
171
+ await server.connect(transport);
172
+ logInfo('Proxy server running on stdio');
173
+ // ---- Graceful shutdown ----
174
+ let isShuttingDown = false;
175
+ const shutdown = async (signal) => {
176
+ if (isShuttingDown)
177
+ return;
178
+ isShuttingDown = true;
179
+ logInfo(`Received ${signal}, shutting down...`);
180
+ for (const [id, client] of clients) {
181
+ try {
182
+ await client.close();
183
+ logInfo(`Closed child client "${id}"`);
184
+ }
185
+ catch (e) {
186
+ logError(`Failed to close child client "${id}": ${e}`);
187
+ }
188
+ }
189
+ try {
190
+ await server.close();
191
+ }
192
+ catch {
193
+ // Ignore close errors during shutdown
194
+ }
195
+ process.exit(0);
196
+ };
197
+ process.on('SIGINT', () => void shutdown('SIGINT'));
198
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
199
+ }
200
+ process.on('unhandledRejection', (reason) => {
201
+ logError(`Unhandled rejection: ${reason}`);
202
+ process.exit(1);
203
+ });
204
+ main().catch((error) => {
205
+ logError(`Fatal error: ${error}`);
206
+ process.exit(1);
207
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "nuwax-mcp-stdio-proxy",
3
+ "version": "1.0.0",
4
+ "description": "TypeScript stdio MCP proxy — aggregates multiple child MCP servers into one",
5
+ "type": "module",
6
+ "bin": {
7
+ "nuwax-mcp-stdio-proxy": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "vitest",
15
+ "test:run": "vitest run",
16
+ "test:coverage": "vitest run --coverage",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.27.0"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.7.0",
24
+ "@types/node": "^22.0.0",
25
+ "vitest": "^2.1.8"
26
+ },
27
+ "engines": {
28
+ "node": ">=22.0.0"
29
+ },
30
+ "license": "MIT"
31
+ }