@luutuankiet/mcp-proxy-shim 1.1.1 → 1.1.2

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/daemon.js CHANGED
@@ -1,426 +1,326 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * MCP Proxy Shim — Daemon Mode
3
+ * MCP Proxy Shim — Daemon Mode (REST + MCP Gateway)
4
4
  *
5
- * Multi-server MCP gateway: connects to N upstream MCP servers (stdio or HTTP)
6
- * and exposes all their tools through a single HTTP Streamable endpoint.
7
- *
8
- * Pure passthrough — no schema transformation. Designed for cloud agents that
9
- * can't spawn MCP servers on the fly.
5
+ * Connects to a single upstream mcpproxy-go via MCP_URL (same as stdio/serve modes)
6
+ * and exposes both REST endpoints for curl-based subagents AND a Streamable HTTP
7
+ * /mcp endpoint for backward compatibility.
10
8
  *
11
9
  * Architecture:
12
- * Cloud Agent ──HTTP──▶ daemon (:3456) ──┬── stdio ──▶ spawned process
13
- * ├── HTTP ──▶ remote MCP server
14
- * └── stdio ──▶ another process
10
+ * Subagent ──curl──▶ daemon (:3456) ──HTTP──▶ mcpproxy-go (upstream)
11
+ * MCP client ──HTTP──▶ daemon (:3456/mcp) ──HTTP──▶ mcpproxy-go (upstream)
15
12
  *
16
- * Configuration via MCP_SERVERS env var (JSON) or MCP_CONFIG file path:
13
+ * REST endpoints (clean JSON, no MCP wrappers):
14
+ * GET /health Health check + session info
15
+ * POST /retrieve_tools { query, compact?, limit? }
16
+ * POST /describe_tools { names: [...] }
17
+ * POST /call { method, name, args, reason?, sensitivity? }
18
+ * POST /exec { code }
19
+ * POST /reinit Force new upstream session
17
20
  *
18
- * {
19
- * "github": {
20
- * "type": "stdio",
21
- * "command": "npx",
22
- * "args": ["-y", "@modelcontextprotocol/server-github"],
23
- * "env": { "GITHUB_TOKEN": "ghp_..." }
24
- * },
25
- * "my-api": {
26
- * "type": "streamableHttp",
27
- * "url": "https://api.example.com/mcp",
28
- * "headers": { "Authorization": "Bearer xxx", "X-Custom": "value" }
29
- * }
30
- * }
21
+ * MCP endpoint (Streamable HTTP, backward compat):
22
+ * POST/GET/DELETE /mcp Standard MCP Streamable HTTP protocol
31
23
  *
32
24
  * Environment variables:
33
- * MCP_SERVERS JSON string with server configs (inline)
34
- * MCP_CONFIG Path to JSON config file (alternative to MCP_SERVERS)
35
- * MCP_PORT Port to listen on (default: 3456)
36
- * MCP_HOST Host to bind to (default: 0.0.0.0)
37
- * MCP_APIKEY Require ?apikey=KEY on /mcp requests (optional)
25
+ * MCP_URL (required) Upstream mcpproxy-go StreamableHTTP endpoint
26
+ * MCP_PORT (optional) Port to listen on (default: 3456)
27
+ * MCP_HOST (optional) Host to bind to (default: 0.0.0.0)
28
+ * MCP_APIKEY (optional) Require ?apikey=KEY on requests
29
+ * https_proxy (optional) HTTPS proxy for upstream connection
38
30
  *
39
31
  * Usage:
40
- * MCP_SERVERS='{"github":{"type":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-github"]}}' \
41
- * npx @luutuankiet/mcp-proxy-shim daemon
42
- *
43
- * MCP_CONFIG=./mcp-servers.json npx @luutuankiet/mcp-proxy-shim daemon
32
+ * MCP_URL="https://proxy.example.com/mcp/?apikey=KEY" npx @luutuankiet/mcp-proxy-shim daemon
44
33
  */
45
- import { readFileSync } from "node:fs";
34
+ import { readFileSync, existsSync } from "node:fs";
35
+ import { join } from "node:path";
46
36
  import { randomUUID } from "node:crypto";
47
37
  import http from "node:http";
48
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
49
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
50
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
51
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
52
38
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
53
- import { ListToolsRequestSchema, CallToolRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
54
- // Proxy support same pattern as core.ts
55
- import { createRequire } from "node:module";
56
- const _require = createRequire(import.meta.url);
57
- const { ProxyAgent } = _require("undici");
58
- const PROXY_URL = process.env.https_proxy || process.env.HTTPS_PROXY || "";
59
- const proxyDispatcher = PROXY_URL ? new ProxyAgent(PROXY_URL) : undefined;
39
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
40
+ import { createShimServer, log, maskUrl, UPSTREAM_URL, ensureSession, mcpRequest, deepUnwrapResult, compactRetrieveTools, transformToolCallArgs, reinitOnExpiry, getSessionId, resetSessionId, } from "./core.js";
41
+ // ---------------------------------------------------------------------------
42
+ // Auto-load .cloud.env from CWD if MCP_URL not in environment
43
+ // ---------------------------------------------------------------------------
44
+ function autoLoadCloudEnv() {
45
+ if (process.env.MCP_URL)
46
+ return;
47
+ const envPath = join(process.cwd(), ".cloud.env");
48
+ if (!existsSync(envPath))
49
+ return;
50
+ try {
51
+ const content = readFileSync(envPath, "utf-8");
52
+ for (const line of content.split("\n")) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith("#"))
55
+ continue;
56
+ const eqIdx = trimmed.indexOf("=");
57
+ if (eqIdx < 1)
58
+ continue;
59
+ const key = trimmed.slice(0, eqIdx).trim();
60
+ let val = trimmed.slice(eqIdx + 1).trim();
61
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
62
+ val = val.slice(1, -1);
63
+ }
64
+ if (!process.env[key]) {
65
+ process.env[key] = val;
66
+ }
67
+ }
68
+ log("Loaded environment from .cloud.env");
69
+ }
70
+ catch (err) {
71
+ log("Warning: failed to load .cloud.env:", err.message);
72
+ }
73
+ }
60
74
  // ---------------------------------------------------------------------------
61
75
  // Config
62
76
  // ---------------------------------------------------------------------------
63
77
  const PORT = parseInt(process.env.MCP_PORT || "3456", 10);
64
78
  const HOST = process.env.MCP_HOST || "0.0.0.0";
65
79
  const APIKEY = process.env.MCP_APIKEY || null;
66
- function log(...args) {
67
- console.error("[mcp-daemon]", ...args);
68
- }
69
- function loadConfig() {
70
- // Try inline JSON first
71
- if (process.env.MCP_SERVERS) {
72
- try {
73
- return JSON.parse(process.env.MCP_SERVERS);
74
- }
75
- catch (err) {
76
- log("Fatal: MCP_SERVERS is not valid JSON:", err.message);
77
- process.exit(1);
78
- }
79
- }
80
- // Try config file
81
- if (process.env.MCP_CONFIG) {
82
- try {
83
- const raw = readFileSync(process.env.MCP_CONFIG, "utf-8");
84
- const parsed = JSON.parse(raw);
85
- // Support both { servers: {...} } and flat { name: config } format
86
- // Also support .mcp.json format: { mcpServers: {...} }
87
- return parsed.mcpServers || parsed.servers || parsed;
88
- }
89
- catch (err) {
90
- log("Fatal: Cannot read MCP_CONFIG:", err.message);
91
- process.exit(1);
92
- }
93
- }
94
- log("Fatal: No server configuration provided.");
95
- log("Set MCP_SERVERS (JSON) or MCP_CONFIG (file path).");
96
- log("");
97
- log("Example:");
98
- log(` MCP_SERVERS='{"my-server":{"type":"stdio","command":"npx","args":["-y","some-mcp-server"]}}' \\`);
99
- log(" npx @luutuankiet/mcp-proxy-shim daemon");
100
- process.exit(1);
101
- return {};
80
+ const startTime = Date.now();
81
+ let callCount = 0;
82
+ // ---------------------------------------------------------------------------
83
+ // JSON body parser
84
+ // ---------------------------------------------------------------------------
85
+ function parseBody(req) {
86
+ return new Promise((resolve) => {
87
+ const chunks = [];
88
+ req.on("data", (chunk) => chunks.push(chunk));
89
+ req.on("end", () => {
90
+ try {
91
+ const raw = Buffer.concat(chunks).toString("utf-8");
92
+ resolve(raw ? JSON.parse(raw) : {});
93
+ }
94
+ catch {
95
+ resolve({});
96
+ }
97
+ });
98
+ req.on("error", () => resolve({}));
99
+ });
102
100
  }
103
101
  // ---------------------------------------------------------------------------
104
- // Upstream connection management
102
+ // REST response helpers
105
103
  // ---------------------------------------------------------------------------
106
- const upstreams = new Map();
107
- /** Namespace a tool name with server prefix to avoid collisions */
108
- function namespaceTool(serverName, toolName) {
109
- return `${serverName}__${toolName}`;
104
+ function jsonResponse(res, status, body) {
105
+ res.writeHead(status, {
106
+ "Content-Type": "application/json",
107
+ "Access-Control-Allow-Origin": "*",
108
+ });
109
+ res.end(JSON.stringify(body));
110
110
  }
111
- /** Extract server name and original tool name from namespaced name */
112
- function parseNamespacedTool(namespacedName) {
113
- for (const [name] of upstreams) {
114
- const prefix = `${name}__`;
115
- if (namespacedName.startsWith(prefix)) {
116
- return { serverName: name, toolName: namespacedName.slice(prefix.length) };
117
- }
118
- }
119
- return null;
111
+ /**
112
+ * Unwrap an MCP tools/call response to clean JSON for REST consumers.
113
+ * Parses content[0].text and JSON.parse if possible.
114
+ */
115
+ function unwrapForRest(result) {
116
+ return deepUnwrapResult(result);
120
117
  }
121
- async function connectStdio(name, config) {
122
- log(`[${name}] Connecting via stdio: ${config.command} ${(config.args || []).join(" ")}`);
123
- const client = new Client({ name: `mcp-daemon/${name}`, version: "1.0.0" }, { capabilities: {} });
124
- const transport = new StdioClientTransport({
125
- command: config.command,
126
- args: config.args,
127
- env: { ...process.env, ...(config.env || {}) },
128
- cwd: config.cwd,
118
+ // ---------------------------------------------------------------------------
119
+ // REST endpoint handlers
120
+ // ---------------------------------------------------------------------------
121
+ async function handleHealth(_req, res) {
122
+ const sid = getSessionId();
123
+ jsonResponse(res, 200, {
124
+ ok: !!sid,
125
+ sessionId: sid ? sid.slice(0, 12) + "..." : null,
126
+ uptime: Math.round((Date.now() - startTime) / 1000),
127
+ callCount,
129
128
  });
130
- await client.connect(transport);
131
- log(`[${name}] Connected (pid: ${transport.pid ?? "unknown"})`);
132
- // Fetch tools
133
- const toolsResult = await client.listTools();
134
- const tools = (toolsResult.tools || []);
135
- log(`[${name}] ${tools.length} tools available`);
136
- return { name, config, client, tools, connected: true };
137
129
  }
138
- async function connectHttp(name, config) {
139
- const maskedUrl = config.url.replace(/apikey=[^&\s]+/gi, "apikey=***");
140
- log(`[${name}] Connecting via HTTP: ${maskedUrl}`);
141
- if (config.headers) {
142
- const headerNames = Object.keys(config.headers);
143
- log(`[${name}] Custom headers: ${headerNames.join(", ")}`);
144
- }
145
- const client = new Client({ name: `mcp-daemon/${name}`, version: "1.0.0" }, { capabilities: {} });
146
- const url = new URL(config.url);
147
- // Build requestInit with custom headers and proxy support
148
- const requestInit = {};
149
- if (config.headers && Object.keys(config.headers).length > 0) {
150
- requestInit.headers = config.headers;
130
+ async function handleRetrieveTools(req, res) {
131
+ const body = await parseBody(req);
132
+ const query = body.query;
133
+ if (!query) {
134
+ return jsonResponse(res, 400, { error: "query is required" });
151
135
  }
152
- if (proxyDispatcher) {
153
- requestInit.dispatcher = proxyDispatcher;
154
- log(`[${name}] Using HTTPS proxy`);
155
- }
156
- const opts = {};
157
- if (Object.keys(requestInit).length > 0) {
158
- opts.requestInit = requestInit;
159
- }
160
- const transport = new StreamableHTTPClientTransport(url, opts);
161
- await client.connect(transport);
162
- log(`[${name}] Connected`);
163
- // Fetch tools
164
- const toolsResult = await client.listTools();
165
- const tools = (toolsResult.tools || []);
166
- log(`[${name}] ${tools.length} tools available`);
167
- return { name, config, client, tools, connected: true };
168
- }
169
- async function connectServer(name, config) {
170
136
  try {
171
- if (config.type === "stdio") {
172
- return await connectStdio(name, config);
173
- }
174
- else if (config.type === "streamableHttp" || config.type === "http" || config.type === "sse") {
175
- return await connectHttp(name, config);
176
- }
177
- else {
178
- log(`[${name}] Unknown server type: ${config.type}`);
179
- return null;
137
+ await ensureSession();
138
+ callCount++;
139
+ const resp = await mcpRequest("tools/call", {
140
+ name: "retrieve_tools",
141
+ arguments: body,
142
+ });
143
+ if (!resp || resp.error) {
144
+ return jsonResponse(res, 502, {
145
+ error: "upstream error",
146
+ detail: resp?.error || "no response",
147
+ });
180
148
  }
149
+ const compacted = compactRetrieveTools(deepUnwrapResult(resp.result), body);
150
+ const unwrapped = unwrapForRest(compacted);
151
+ return jsonResponse(res, 200, unwrapped);
181
152
  }
182
153
  catch (err) {
183
- log(`[${name}] Connection failed:`, err.message);
184
- return null;
154
+ return jsonResponse(res, 500, { error: err.message });
185
155
  }
186
156
  }
187
- /** Refresh tools from a specific upstream */
188
- async function refreshTools(server) {
157
+ async function handleDescribeTools(req, res) {
158
+ const body = await parseBody(req);
159
+ const names = body.names;
160
+ if (!Array.isArray(names) || names.length === 0) {
161
+ return jsonResponse(res, 400, { error: "names array is required" });
162
+ }
189
163
  try {
190
- const result = await server.client.listTools();
191
- server.tools = (result.tools || []);
192
- log(`[${server.name}] Refreshed: ${server.tools.length} tools`);
164
+ await ensureSession();
165
+ callCount++;
166
+ const results = [];
167
+ for (const name of names) {
168
+ const resp = await mcpRequest("tools/call", {
169
+ name: "retrieve_tools",
170
+ arguments: { query: name },
171
+ });
172
+ if (resp?.result) {
173
+ const unwrapped = deepUnwrapResult(resp.result);
174
+ const arr = Array.isArray(unwrapped)
175
+ ? unwrapped
176
+ : unwrapped?.tools;
177
+ if (Array.isArray(arr)) {
178
+ const match = arr.find((t) => t.name === name);
179
+ results.push(match || { name, error: "not found" });
180
+ }
181
+ else {
182
+ results.push({ name, error: "not found" });
183
+ }
184
+ }
185
+ else {
186
+ results.push({ name, error: "upstream error" });
187
+ }
188
+ }
189
+ return jsonResponse(res, 200, results);
193
190
  }
194
191
  catch (err) {
195
- log(`[${server.name}] Tool refresh failed:`, err.message);
192
+ return jsonResponse(res, 500, { error: err.message });
196
193
  }
197
194
  }
198
- // ---------------------------------------------------------------------------
199
- // Aggregated tool list + helper tools
200
- // ---------------------------------------------------------------------------
201
- /** Build a usage guide for the current daemon state */
202
- function buildUsageGuide() {
203
- const servers = [...upstreams.values()].filter((s) => s.connected);
204
- const lines = [
205
- "# MCP Daemon — Usage Guide",
206
- "",
207
- "This is an MCP gateway that aggregates tools from multiple upstream servers.",
208
- "All tools are namespaced: `<server>__<tool>` (double underscore separator).",
209
- "",
210
- "## Connected Servers",
211
- "",
195
+ async function handleCall(req, res) {
196
+ const body = await parseBody(req);
197
+ const method = body.method;
198
+ const name = body.name;
199
+ const args = (body.args || {});
200
+ if (!name) {
201
+ return jsonResponse(res, 400, { error: "name is required" });
202
+ }
203
+ const validMethods = [
204
+ "call_tool_read",
205
+ "call_tool_write",
206
+ "call_tool_destructive",
212
207
  ];
213
- for (const server of servers) {
214
- const type = server.config.type;
215
- lines.push(`### \`${server.name}\` (${type})`);
216
- lines.push("");
217
- lines.push(`Tools (${server.tools.length}):`);
218
- for (const tool of server.tools) {
219
- const desc = tool.description ? ` — ${tool.description.slice(0, 80)}${tool.description.length > 80 ? "..." : ""}` : "";
220
- lines.push(`- \`${namespaceTool(server.name, tool.name)}\`${desc}`);
208
+ if (method && !validMethods.includes(method)) {
209
+ return jsonResponse(res, 400, {
210
+ error: `Invalid method: ${method}. Must be one of: ${validMethods.join(", ")}`,
211
+ });
212
+ }
213
+ try {
214
+ await ensureSession();
215
+ callCount++;
216
+ const toolName = method || "call_tool_read";
217
+ const callArgs = { name, args };
218
+ if (body.reason)
219
+ callArgs.intent_reason = body.reason;
220
+ if (body.sensitivity)
221
+ callArgs.intent_data_sensitivity = body.sensitivity;
222
+ const forwardArgs = transformToolCallArgs(toolName, callArgs);
223
+ const resp = await mcpRequest("tools/call", {
224
+ name: toolName,
225
+ arguments: forwardArgs,
226
+ });
227
+ if (!resp) {
228
+ return jsonResponse(res, 502, { error: "No response from upstream" });
221
229
  }
222
- lines.push("");
230
+ if (resp.error) {
231
+ if (resp.error.message?.includes("session") ||
232
+ resp.error.message?.includes("Session") ||
233
+ resp.error.code === -32001) {
234
+ const ok = await reinitOnExpiry();
235
+ if (ok) {
236
+ const retry = await mcpRequest("tools/call", {
237
+ name: toolName,
238
+ arguments: forwardArgs,
239
+ });
240
+ if (retry && !retry.error) {
241
+ return jsonResponse(res, 200, unwrapForRest(retry.result));
242
+ }
243
+ }
244
+ }
245
+ return jsonResponse(res, 502, {
246
+ error: "upstream error",
247
+ detail: resp.error,
248
+ });
249
+ }
250
+ return jsonResponse(res, 200, unwrapForRest(resp.result));
223
251
  }
224
- lines.push("## How to Call Tools");
225
- lines.push("");
226
- lines.push("1. Find the tool you need from the list above");
227
- lines.push("2. Call it using the full namespaced name (e.g., `github__get_file_contents`)");
228
- lines.push("3. Pass arguments as a native JSON object — no special serialization needed");
229
- lines.push("");
230
- lines.push("### Example");
231
- lines.push("");
232
- // Generate a real example from the first server's first tool
233
- if (servers.length > 0 && servers[0].tools.length > 0) {
234
- const example = servers[0].tools[0];
235
- const exampleName = namespaceTool(servers[0].name, example.name);
236
- const exampleArgs = example.inputSchema?.properties
237
- ? Object.fromEntries(Object.entries(example.inputSchema.properties)
238
- .slice(0, 3)
239
- .map(([k, v]) => [k, v.type === "number" ? 1 : v.type === "boolean" ? true : "..."]))
240
- : {};
241
- lines.push("```json");
242
- lines.push(`{ "name": "${exampleName}", "arguments": ${JSON.stringify(exampleArgs)} }`);
243
- lines.push("```");
252
+ catch (err) {
253
+ return jsonResponse(res, 500, { error: err.message });
244
254
  }
245
- lines.push("");
246
- return lines.join("\n");
247
255
  }
248
- /** Shim-local tool schemas injected into the aggregated tool list */
249
- const DAEMON_HELP_TOOL = {
250
- name: "daemon_help",
251
- description: "Get usage guide for this MCP daemon — lists all connected servers, " +
252
- "their tools (with namespaced names), and how to call them. " +
253
- "Call this FIRST if you're unsure how to use this gateway.",
254
- inputSchema: {
255
- type: "object",
256
- properties: {
257
- server: {
258
- type: "string",
259
- description: "Optional: filter help to a specific server name",
260
- },
261
- },
262
- },
263
- };
264
- function getAggregatedTools() {
265
- const allTools = [DAEMON_HELP_TOOL];
266
- for (const [, server] of upstreams) {
267
- if (!server.connected)
268
- continue;
269
- for (const tool of server.tools) {
270
- allTools.push({
271
- ...tool,
272
- name: namespaceTool(server.name, tool.name),
273
- description: `[${server.name}] ${tool.description || ""}`.trim(),
256
+ async function handleExec(req, res) {
257
+ const body = await parseBody(req);
258
+ const code = body.code;
259
+ if (!code) {
260
+ return jsonResponse(res, 400, { error: "code is required" });
261
+ }
262
+ try {
263
+ await ensureSession();
264
+ callCount++;
265
+ const resp = await mcpRequest("tools/call", {
266
+ name: "code_execution",
267
+ arguments: { code },
268
+ });
269
+ if (!resp) {
270
+ return jsonResponse(res, 502, { error: "No response from upstream" });
271
+ }
272
+ if (resp.error) {
273
+ return jsonResponse(res, 502, {
274
+ error: "upstream error",
275
+ detail: resp.error,
274
276
  });
275
277
  }
278
+ return jsonResponse(res, 200, unwrapForRest(resp.result));
279
+ }
280
+ catch (err) {
281
+ return jsonResponse(res, 500, { error: err.message });
282
+ }
283
+ }
284
+ async function handleReinit(_req, res) {
285
+ try {
286
+ resetSessionId();
287
+ await ensureSession();
288
+ return jsonResponse(res, 200, {
289
+ ok: true,
290
+ sessionId: (getSessionId() || "").slice(0, 12) + "...",
291
+ });
292
+ }
293
+ catch (err) {
294
+ return jsonResponse(res, 500, {
295
+ ok: false,
296
+ error: err.message,
297
+ });
276
298
  }
277
- return allTools;
278
299
  }
279
300
  // ---------------------------------------------------------------------------
280
- // Downstream MCP server (exposed to cloud agents)
301
+ // Streamable HTTP /mcp endpoint (backward compat)
281
302
  // ---------------------------------------------------------------------------
282
- const downstreamTransports = new Map();
283
- async function createDownstreamSession() {
303
+ const mcpTransports = new Map();
304
+ async function createMcpSessionTransport() {
284
305
  const transport = new StreamableHTTPServerTransport({
285
306
  sessionIdGenerator: () => randomUUID(),
286
307
  onsessioninitialized: (sessionId) => {
287
- log(`Session initialized: ${sessionId.slice(0, 12)}...`);
288
- downstreamTransports.set(sessionId, transport);
308
+ log(`MCP session initialized: ${sessionId.slice(0, 12)}...`);
309
+ mcpTransports.set(sessionId, transport);
289
310
  },
290
311
  });
291
312
  transport.onclose = () => {
292
313
  const sid = transport.sessionId;
293
- if (sid && downstreamTransports.has(sid)) {
294
- log(`Session closed: ${sid.slice(0, 12)}...`);
295
- downstreamTransports.delete(sid);
314
+ if (sid && mcpTransports.has(sid)) {
315
+ log(`MCP session closed: ${sid.slice(0, 12)}...`);
316
+ mcpTransports.delete(sid);
296
317
  }
297
318
  };
298
- // Build server instructions so clients know how to use the daemon
299
- const connectedServers = [...upstreams.values()].filter((s) => s.connected);
300
- const serverList = connectedServers.map((s) => `${s.name} (${s.tools.length} tools)`).join(", ");
301
- const instructions = [
302
- "MCP Daemon — Multi-server gateway.",
303
- `Connected servers: ${serverList}.`,
304
- "All tools are namespaced as <server>__<tool> (double underscore).",
305
- "Call daemon_help for a full usage guide with all available tools.",
306
- ].join(" ");
307
- // Create MCP server for this session
308
- const server = new Server({ name: "mcp-daemon", version: "1.0.0" }, {
309
- capabilities: { tools: {} },
310
- instructions,
311
- });
312
- // Handle tools/list — aggregate from all upstreams
313
- server.setRequestHandler(ListToolsRequestSchema, async () => {
314
- // Refresh tools from all connected upstreams
315
- await Promise.allSettled([...upstreams.values()]
316
- .filter((s) => s.connected)
317
- .map((s) => refreshTools(s)));
318
- return { tools: getAggregatedTools() };
319
- });
320
- // Handle tools/call — route to correct upstream
321
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
322
- const { name, arguments: args } = request.params;
323
- // --- Shim-local: daemon_help ---
324
- if (name === "daemon_help") {
325
- const filterServer = args?.server || "";
326
- if (filterServer) {
327
- const upstream = upstreams.get(filterServer);
328
- if (!upstream || !upstream.connected) {
329
- return {
330
- content: [{ type: "text", text: `Server "${filterServer}" not found. Connected servers: ${[...upstreams.keys()].join(", ")}` }],
331
- isError: true,
332
- };
333
- }
334
- // Server-specific help
335
- const lines = [
336
- `# Server: ${filterServer} (${upstream.config.type})`,
337
- "",
338
- `## Tools (${upstream.tools.length})`,
339
- "",
340
- ];
341
- for (const tool of upstream.tools) {
342
- lines.push(`### \`${namespaceTool(filterServer, tool.name)}\``);
343
- if (tool.description)
344
- lines.push(tool.description);
345
- if (tool.inputSchema) {
346
- lines.push("");
347
- lines.push("```json");
348
- lines.push(JSON.stringify(tool.inputSchema, null, 2));
349
- lines.push("```");
350
- }
351
- lines.push("");
352
- }
353
- return {
354
- content: [{ type: "text", text: lines.join("\n") }],
355
- };
356
- }
357
- return {
358
- content: [{ type: "text", text: buildUsageGuide() }],
359
- };
360
- }
361
- const parsed = parseNamespacedTool(name);
362
- if (!parsed) {
363
- return {
364
- content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}. Tool names are prefixed with server name (e.g., "myserver__toolname"). Call daemon_help for a full list.` }) }],
365
- isError: true,
366
- };
367
- }
368
- const server = upstreams.get(parsed.serverName);
369
- if (!server || !server.connected) {
370
- return {
371
- content: [{ type: "text", text: JSON.stringify({ error: `Server "${parsed.serverName}" is not connected` }) }],
372
- isError: true,
373
- };
374
- }
375
- try {
376
- const result = await server.client.callTool({
377
- name: parsed.toolName,
378
- arguments: args || {},
379
- });
380
- // Passthrough the result as-is
381
- if (result.content && Array.isArray(result.content)) {
382
- return {
383
- content: result.content,
384
- isError: result.isError === true ? true : undefined,
385
- };
386
- }
387
- // Wrap non-standard results
388
- const text = typeof result === "string" ? result : JSON.stringify(result);
389
- return { content: [{ type: "text", text }] };
390
- }
391
- catch (err) {
392
- const msg = err.message;
393
- log(`[${parsed.serverName}] Tool call error (${parsed.toolName}):`, msg);
394
- return {
395
- content: [{ type: "text", text: `Error calling ${parsed.serverName}/${parsed.toolName}: ${msg}` }],
396
- isError: true,
397
- };
398
- }
399
- });
319
+ const server = await createShimServer({ lazyInit: true });
400
320
  await server.connect(transport);
401
321
  return transport;
402
322
  }
403
- // ---------------------------------------------------------------------------
404
- // HTTP Server
405
- // ---------------------------------------------------------------------------
406
- function parseBody(req) {
407
- return new Promise((resolve) => {
408
- const chunks = [];
409
- req.on("data", (chunk) => chunks.push(chunk));
410
- req.on("end", () => {
411
- try {
412
- const raw = Buffer.concat(chunks).toString("utf-8");
413
- resolve(raw ? JSON.parse(raw) : null);
414
- }
415
- catch {
416
- resolve(null);
417
- }
418
- });
419
- req.on("error", () => resolve(null));
420
- });
421
- }
422
323
  async function handleMcpRequest(req, res) {
423
- // CORS
424
324
  res.setHeader("Access-Control-Allow-Origin", "*");
425
325
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
426
326
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID");
@@ -434,39 +334,33 @@ async function handleMcpRequest(req, res) {
434
334
  try {
435
335
  if (req.method === "POST") {
436
336
  const body = await parseBody(req);
437
- if (sessionId && downstreamTransports.has(sessionId)) {
438
- const transport = downstreamTransports.get(sessionId);
337
+ if (sessionId && mcpTransports.has(sessionId)) {
338
+ const transport = mcpTransports.get(sessionId);
439
339
  await transport.handleRequest(req, res, body);
440
340
  }
441
341
  else if (!sessionId && isInitializeRequest(body)) {
442
- const transport = await createDownstreamSession();
342
+ const transport = await createMcpSessionTransport();
443
343
  await transport.handleRequest(req, res, body);
444
344
  }
445
345
  else {
446
346
  res.writeHead(400, { "Content-Type": "application/json" });
447
347
  res.end(JSON.stringify({
448
348
  jsonrpc: "2.0",
449
- error: { code: -32000, message: "Bad Request: No valid session ID provided" },
349
+ error: {
350
+ code: -32000,
351
+ message: "Bad Request: No valid session ID provided",
352
+ },
450
353
  id: null,
451
354
  }));
452
355
  }
453
356
  }
454
- else if (req.method === "GET") {
455
- if (!sessionId || !downstreamTransports.has(sessionId)) {
456
- res.writeHead(400, { "Content-Type": "text/plain" });
457
- res.end("Invalid or missing session ID");
458
- return;
459
- }
460
- const transport = downstreamTransports.get(sessionId);
461
- await transport.handleRequest(req, res);
462
- }
463
- else if (req.method === "DELETE") {
464
- if (!sessionId || !downstreamTransports.has(sessionId)) {
357
+ else if (req.method === "GET" || req.method === "DELETE") {
358
+ if (!sessionId || !mcpTransports.has(sessionId)) {
465
359
  res.writeHead(400, { "Content-Type": "text/plain" });
466
360
  res.end("Invalid or missing session ID");
467
361
  return;
468
362
  }
469
- const transport = downstreamTransports.get(sessionId);
363
+ const transport = mcpTransports.get(sessionId);
470
364
  await transport.handleRequest(req, res);
471
365
  }
472
366
  else {
@@ -475,7 +369,7 @@ async function handleMcpRequest(req, res) {
475
369
  }
476
370
  }
477
371
  catch (error) {
478
- log("HTTP handler error:", error.message);
372
+ log("MCP handler error:", error.message);
479
373
  if (!res.headersSent) {
480
374
  res.writeHead(500, { "Content-Type": "application/json" });
481
375
  res.end(JSON.stringify({
@@ -487,111 +381,85 @@ async function handleMcpRequest(req, res) {
487
381
  }
488
382
  }
489
383
  // ---------------------------------------------------------------------------
490
- // Main
384
+ // HTTP Server + routing
491
385
  // ---------------------------------------------------------------------------
492
386
  async function main() {
493
- log("Starting MCP Daemon...");
494
- const configs = loadConfig();
495
- const serverNames = Object.keys(configs);
496
- if (serverNames.length === 0) {
497
- log("Fatal: No servers defined in configuration.");
498
- process.exit(1);
499
- }
500
- log(`Configured servers: ${serverNames.join(", ")}`);
501
- // Connect to all upstream servers in parallel
502
- const results = await Promise.allSettled(serverNames.map(async (name) => {
503
- const server = await connectServer(name, configs[name]);
504
- if (server) {
505
- upstreams.set(name, server);
506
- }
507
- return server;
508
- }));
509
- const connected = [...upstreams.values()].filter((s) => s.connected);
510
- const totalTools = connected.reduce((sum, s) => sum + s.tools.length, 0);
511
- if (connected.length === 0) {
512
- log("Fatal: No upstream servers connected successfully.");
513
- process.exit(1);
387
+ log("Starting daemon (REST + MCP gateway)...");
388
+ log(`Upstream: ${maskUrl(UPSTREAM_URL)}`);
389
+ try {
390
+ await ensureSession();
391
+ log("Upstream session established");
514
392
  }
515
- log(`Connected: ${connected.length}/${serverNames.length} servers, ${totalTools} total tools`);
516
- // Print tool summary
517
- for (const server of connected) {
518
- log(` [${server.name}] ${server.tools.length} tools: ${server.tools.slice(0, 5).map((t) => t.name).join(", ")}${server.tools.length > 5 ? "..." : ""}`);
393
+ catch (err) {
394
+ log("Warning: initial upstream connection failed:", err.message);
395
+ log("Will retry on first request");
519
396
  }
520
- // Start HTTP server
521
397
  const httpServer = http.createServer((req, res) => {
522
398
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
523
- if (url.pathname === "/mcp" || url.pathname === "/mcp/") {
524
- // Apikey gate
525
- if (APIKEY && url.searchParams.get("apikey") !== APIKEY) {
526
- res.writeHead(401, { "Content-Type": "application/json" });
527
- res.end(JSON.stringify({
528
- jsonrpc: "2.0",
529
- error: { code: -32001, message: "Unauthorized: invalid or missing apikey" },
530
- id: null,
531
- }));
532
- return;
533
- }
534
- handleMcpRequest(req, res).catch((err) => {
535
- log("Unhandled error:", err.message);
536
- if (!res.headersSent) {
537
- res.writeHead(500);
538
- res.end("Internal Server Error");
539
- }
540
- });
399
+ const pathname = url.pathname;
400
+ if (APIKEY && url.searchParams.get("apikey") !== APIKEY) {
401
+ res.writeHead(401, { "Content-Type": "application/json" });
402
+ res.end(JSON.stringify({ error: "Unauthorized: invalid or missing apikey" }));
403
+ return;
541
404
  }
542
- else if (url.pathname === "/health" || url.pathname === "/healthz") {
543
- const serverStatus = {};
544
- for (const [name, server] of upstreams) {
545
- serverStatus[name] = {
546
- connected: server.connected,
547
- tools: server.tools.length,
548
- type: server.config.type,
549
- };
550
- }
551
- res.writeHead(200, { "Content-Type": "application/json" });
552
- res.end(JSON.stringify({
553
- status: "ok",
554
- sessions: downstreamTransports.size,
555
- uptime: process.uptime(),
556
- servers: serverStatus,
557
- totalTools,
558
- }));
559
- }
560
- else {
561
- res.writeHead(404, { "Content-Type": "text/plain" });
562
- res.end("Not Found — MCP endpoint is at /mcp");
405
+ if (req.method === "OPTIONS") {
406
+ res.setHeader("Access-Control-Allow-Origin", "*");
407
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
408
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID");
409
+ res.writeHead(204);
410
+ res.end();
411
+ return;
563
412
  }
413
+ const handler = async () => {
414
+ if (pathname === "/health" || pathname === "/healthz") {
415
+ return handleHealth(req, res);
416
+ }
417
+ if (pathname === "/retrieve_tools" && req.method === "POST") {
418
+ return handleRetrieveTools(req, res);
419
+ }
420
+ if (pathname === "/describe_tools" && req.method === "POST") {
421
+ return handleDescribeTools(req, res);
422
+ }
423
+ if (pathname === "/call" && req.method === "POST") {
424
+ return handleCall(req, res);
425
+ }
426
+ if (pathname === "/exec" && req.method === "POST") {
427
+ return handleExec(req, res);
428
+ }
429
+ if (pathname === "/reinit" && req.method === "POST") {
430
+ return handleReinit(req, res);
431
+ }
432
+ if (pathname === "/mcp" || pathname === "/mcp/") {
433
+ return handleMcpRequest(req, res);
434
+ }
435
+ res.writeHead(404, { "Content-Type": "application/json" });
436
+ res.end(JSON.stringify({ error: "Not Found" }));
437
+ };
438
+ handler().catch((err) => {
439
+ log("Unhandled error:", err.message);
440
+ if (!res.headersSent) {
441
+ res.writeHead(500, { "Content-Type": "application/json" });
442
+ res.end(JSON.stringify({ error: "Internal Server Error" }));
443
+ }
444
+ });
564
445
  });
565
446
  httpServer.listen(PORT, HOST, () => {
566
- log(`Daemon listening on http://${HOST}:${PORT}/mcp`);
567
- log(`Health check: http://${HOST}:${PORT}/health`);
447
+ log(`Daemon listening on http://${HOST}:${PORT}`);
448
+ log("REST endpoints: /health, /retrieve_tools, /describe_tools, /call, /exec, /reinit");
449
+ log("MCP endpoint: /mcp");
568
450
  log(`Auth: ${APIKEY ? "apikey required (?apikey=...)" : "OPEN (no MCP_APIKEY set)"}`);
569
- log("Waiting for MCP client connections...");
570
451
  });
571
- // Graceful shutdown
572
452
  const shutdown = async () => {
573
453
  log("Shutting down daemon...");
574
- // Close downstream sessions
575
- for (const [sid, transport] of downstreamTransports) {
454
+ for (const [sid, transport] of mcpTransports) {
576
455
  try {
577
456
  await transport.close();
578
457
  }
579
458
  catch (err) {
580
- log(`Error closing session ${sid.slice(0, 12)}:`, err.message);
581
- }
582
- }
583
- downstreamTransports.clear();
584
- // Disconnect upstream servers
585
- for (const [name, server] of upstreams) {
586
- try {
587
- log(`Disconnecting [${name}]...`);
588
- await server.client.close();
589
- }
590
- catch (err) {
591
- log(`Error disconnecting [${name}]:`, err.message);
459
+ log(`Error closing MCP session ${sid.slice(0, 12)}:`, err.message);
592
460
  }
593
461
  }
594
- upstreams.clear();
462
+ mcpTransports.clear();
595
463
  httpServer.close(() => {
596
464
  log("Daemon stopped");
597
465
  process.exit(0);