@luutuankiet/mcp-proxy-shim 1.1.1 → 1.1.3

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,395 @@
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`);
136
+ try {
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
+ });
148
+ }
149
+ const compacted = compactRetrieveTools(deepUnwrapResult(resp.result), body);
150
+ const unwrapped = unwrapForRest(compacted);
151
+ return jsonResponse(res, 200, unwrapped);
155
152
  }
156
- const opts = {};
157
- if (Object.keys(requestInit).length > 0) {
158
- opts.requestInit = requestInit;
153
+ catch (err) {
154
+ return jsonResponse(res, 500, { error: err.message });
159
155
  }
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
156
  }
169
- async function connectServer(name, config) {
157
+ /**
158
+ * describe_tools — batch-hydrate tool schemas.
159
+ *
160
+ * Strategy (mirrors core.ts shim-local describe_tools):
161
+ * 1. Derive multiple BM25 search queries from each requested name
162
+ * 2. Query upstream retrieve_tools for each (gets FULL schemas, not compacted)
163
+ * 3. Build an index keyed by raw name + server:name composite
164
+ * 4. Resolve each requested name with flexible matching
165
+ */
166
+ async function handleDescribeTools(req, res) {
167
+ const body = await parseBody(req);
168
+ const names = body.names;
169
+ if (!Array.isArray(names) || names.length === 0) {
170
+ return jsonResponse(res, 400, { error: "names array is required" });
171
+ }
170
172
  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);
173
+ await ensureSession();
174
+ callCount++;
175
+ // 1. Derive search queries from tool names (same strategy as core.ts)
176
+ const queries = new Set();
177
+ for (const n of names) {
178
+ const raw = n.includes(":") ? n.split(":").slice(1).join(":") : n;
179
+ // Raw name as-is — BM25 often matches exact tool names well
180
+ queries.add(raw);
181
+ // Full name with separators → spaces
182
+ queries.add(raw.replace(/__/g, " ").replace(/[-_]/g, " "));
183
+ // Segment-based queries for compound names
184
+ const parts = raw.split("__");
185
+ if (parts.length > 1) {
186
+ const toolPart = parts[parts.length - 1] || raw;
187
+ queries.add(toolPart.replace(/[-_]/g, " "));
188
+ const prefixPart = parts[0];
189
+ if (prefixPart && prefixPart !== toolPart) {
190
+ queries.add(prefixPart.replace(/[-_]/g, " ") + " " + toolPart.replace(/[-_]/g, " "));
191
+ }
192
+ }
176
193
  }
177
- else {
178
- log(`[${name}] Unknown server type: ${config.type}`);
179
- return null;
194
+ // 2. Query upstream retrieve_tools for each (full schemas, no compaction)
195
+ const index = new Map();
196
+ for (const query of queries) {
197
+ try {
198
+ const resp = await mcpRequest("tools/call", {
199
+ name: "retrieve_tools",
200
+ arguments: { query },
201
+ });
202
+ if (resp?.result) {
203
+ const unwrapped = deepUnwrapResult(resp.result);
204
+ const tools = Array.isArray(unwrapped)
205
+ ? unwrapped
206
+ : (Array.isArray(unwrapped?.tools) ? unwrapped.tools : []);
207
+ for (const t of tools) {
208
+ const tool = t;
209
+ const tName = tool.name;
210
+ if (tName && !index.has(tName)) {
211
+ index.set(tName, tool);
212
+ }
213
+ // Also key by server:name composite for flexible lookup
214
+ if (tool.server) {
215
+ const composite = `${tool.server}:${tName}`;
216
+ if (!index.has(composite))
217
+ index.set(composite, tool);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ catch (err) {
223
+ log("describe_tools query failed:", query, err.message);
224
+ }
180
225
  }
226
+ // 3. Resolve each requested name with flexible matching
227
+ const results = names.map((n) => {
228
+ // Exact match
229
+ let tool = index.get(n);
230
+ if (tool)
231
+ return tool;
232
+ // Without server: prefix (e.g., "utils:read_files" → "read_files")
233
+ if (n.includes(":")) {
234
+ const withoutPrefix = n.split(":").slice(1).join(":");
235
+ tool = index.get(withoutPrefix);
236
+ if (tool)
237
+ return tool;
238
+ }
239
+ // Suffix/prefix match
240
+ for (const [key, candidate] of index) {
241
+ if (key.endsWith(n) || n.endsWith(key))
242
+ return candidate;
243
+ }
244
+ // Fuzzy suffix match — handle mount-path variations
245
+ const nParts = n.includes(":") ? n.split(":").slice(1).join(":").split("__") : n.split("__");
246
+ const nSuffix = nParts[nParts.length - 1];
247
+ if (nSuffix) {
248
+ for (const [, candidate] of index) {
249
+ const cName = candidate.name;
250
+ const cParts = cName.split("__");
251
+ const cSuffix = cParts[cParts.length - 1];
252
+ if (cSuffix === nSuffix && cName.includes(nParts[0]))
253
+ return candidate;
254
+ }
255
+ }
256
+ return { name: n, error: "not found" };
257
+ });
258
+ return jsonResponse(res, 200, results);
181
259
  }
182
260
  catch (err) {
183
- log(`[${name}] Connection failed:`, err.message);
184
- return null;
261
+ return jsonResponse(res, 500, { error: err.message });
185
262
  }
186
263
  }
187
- /** Refresh tools from a specific upstream */
188
- async function refreshTools(server) {
264
+ async function handleCall(req, res) {
265
+ const body = await parseBody(req);
266
+ const method = body.method;
267
+ const name = body.name;
268
+ const args = (body.args || {});
269
+ if (!name) {
270
+ return jsonResponse(res, 400, { error: "name is required" });
271
+ }
272
+ const validMethods = [
273
+ "call_tool_read",
274
+ "call_tool_write",
275
+ "call_tool_destructive",
276
+ ];
277
+ if (method && !validMethods.includes(method)) {
278
+ return jsonResponse(res, 400, {
279
+ error: `Invalid method: ${method}. Must be one of: ${validMethods.join(", ")}`,
280
+ });
281
+ }
189
282
  try {
190
- const result = await server.client.listTools();
191
- server.tools = (result.tools || []);
192
- log(`[${server.name}] Refreshed: ${server.tools.length} tools`);
283
+ await ensureSession();
284
+ callCount++;
285
+ const toolName = method || "call_tool_read";
286
+ const callArgs = { name, args };
287
+ if (body.reason)
288
+ callArgs.intent_reason = body.reason;
289
+ if (body.sensitivity)
290
+ callArgs.intent_data_sensitivity = body.sensitivity;
291
+ const forwardArgs = transformToolCallArgs(toolName, callArgs);
292
+ const resp = await mcpRequest("tools/call", {
293
+ name: toolName,
294
+ arguments: forwardArgs,
295
+ });
296
+ if (!resp) {
297
+ return jsonResponse(res, 502, { error: "No response from upstream" });
298
+ }
299
+ if (resp.error) {
300
+ if (resp.error.message?.includes("session") ||
301
+ resp.error.message?.includes("Session") ||
302
+ resp.error.code === -32001) {
303
+ const ok = await reinitOnExpiry();
304
+ if (ok) {
305
+ const retry = await mcpRequest("tools/call", {
306
+ name: toolName,
307
+ arguments: forwardArgs,
308
+ });
309
+ if (retry && !retry.error) {
310
+ return jsonResponse(res, 200, unwrapForRest(retry.result));
311
+ }
312
+ }
313
+ }
314
+ return jsonResponse(res, 502, {
315
+ error: "upstream error",
316
+ detail: resp.error,
317
+ });
318
+ }
319
+ return jsonResponse(res, 200, unwrapForRest(resp.result));
193
320
  }
194
321
  catch (err) {
195
- log(`[${server.name}] Tool refresh failed:`, err.message);
322
+ return jsonResponse(res, 500, { error: err.message });
196
323
  }
197
324
  }
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
- "",
212
- ];
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}`);
325
+ async function handleExec(req, res) {
326
+ const body = await parseBody(req);
327
+ const code = body.code;
328
+ if (!code) {
329
+ return jsonResponse(res, 400, { error: "code is required" });
330
+ }
331
+ try {
332
+ await ensureSession();
333
+ callCount++;
334
+ const resp = await mcpRequest("tools/call", {
335
+ name: "code_execution",
336
+ arguments: { code },
337
+ });
338
+ if (!resp) {
339
+ return jsonResponse(res, 502, { error: "No response from upstream" });
340
+ }
341
+ if (resp.error) {
342
+ return jsonResponse(res, 502, {
343
+ error: "upstream error",
344
+ detail: resp.error,
345
+ });
221
346
  }
222
- lines.push("");
347
+ return jsonResponse(res, 200, unwrapForRest(resp.result));
223
348
  }
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("```");
349
+ catch (err) {
350
+ return jsonResponse(res, 500, { error: err.message });
244
351
  }
245
- lines.push("");
246
- return lines.join("\n");
247
352
  }
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(),
274
- });
275
- }
353
+ async function handleReinit(_req, res) {
354
+ try {
355
+ resetSessionId();
356
+ await ensureSession();
357
+ return jsonResponse(res, 200, {
358
+ ok: true,
359
+ sessionId: (getSessionId() || "").slice(0, 12) + "...",
360
+ });
361
+ }
362
+ catch (err) {
363
+ return jsonResponse(res, 500, {
364
+ ok: false,
365
+ error: err.message,
366
+ });
276
367
  }
277
- return allTools;
278
368
  }
279
369
  // ---------------------------------------------------------------------------
280
- // Downstream MCP server (exposed to cloud agents)
370
+ // Streamable HTTP /mcp endpoint (backward compat)
281
371
  // ---------------------------------------------------------------------------
282
- const downstreamTransports = new Map();
283
- async function createDownstreamSession() {
372
+ const mcpTransports = new Map();
373
+ async function createMcpSessionTransport() {
284
374
  const transport = new StreamableHTTPServerTransport({
285
375
  sessionIdGenerator: () => randomUUID(),
286
376
  onsessioninitialized: (sessionId) => {
287
- log(`Session initialized: ${sessionId.slice(0, 12)}...`);
288
- downstreamTransports.set(sessionId, transport);
377
+ log(`MCP session initialized: ${sessionId.slice(0, 12)}...`);
378
+ mcpTransports.set(sessionId, transport);
289
379
  },
290
380
  });
291
381
  transport.onclose = () => {
292
382
  const sid = transport.sessionId;
293
- if (sid && downstreamTransports.has(sid)) {
294
- log(`Session closed: ${sid.slice(0, 12)}...`);
295
- downstreamTransports.delete(sid);
383
+ if (sid && mcpTransports.has(sid)) {
384
+ log(`MCP session closed: ${sid.slice(0, 12)}...`);
385
+ mcpTransports.delete(sid);
296
386
  }
297
387
  };
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
- });
388
+ const server = await createShimServer({ lazyInit: true });
400
389
  await server.connect(transport);
401
390
  return transport;
402
391
  }
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
392
  async function handleMcpRequest(req, res) {
423
- // CORS
424
393
  res.setHeader("Access-Control-Allow-Origin", "*");
425
394
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
426
395
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID");
@@ -434,39 +403,33 @@ async function handleMcpRequest(req, res) {
434
403
  try {
435
404
  if (req.method === "POST") {
436
405
  const body = await parseBody(req);
437
- if (sessionId && downstreamTransports.has(sessionId)) {
438
- const transport = downstreamTransports.get(sessionId);
406
+ if (sessionId && mcpTransports.has(sessionId)) {
407
+ const transport = mcpTransports.get(sessionId);
439
408
  await transport.handleRequest(req, res, body);
440
409
  }
441
410
  else if (!sessionId && isInitializeRequest(body)) {
442
- const transport = await createDownstreamSession();
411
+ const transport = await createMcpSessionTransport();
443
412
  await transport.handleRequest(req, res, body);
444
413
  }
445
414
  else {
446
415
  res.writeHead(400, { "Content-Type": "application/json" });
447
416
  res.end(JSON.stringify({
448
417
  jsonrpc: "2.0",
449
- error: { code: -32000, message: "Bad Request: No valid session ID provided" },
418
+ error: {
419
+ code: -32000,
420
+ message: "Bad Request: No valid session ID provided",
421
+ },
450
422
  id: null,
451
423
  }));
452
424
  }
453
425
  }
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)) {
426
+ else if (req.method === "GET" || req.method === "DELETE") {
427
+ if (!sessionId || !mcpTransports.has(sessionId)) {
465
428
  res.writeHead(400, { "Content-Type": "text/plain" });
466
429
  res.end("Invalid or missing session ID");
467
430
  return;
468
431
  }
469
- const transport = downstreamTransports.get(sessionId);
432
+ const transport = mcpTransports.get(sessionId);
470
433
  await transport.handleRequest(req, res);
471
434
  }
472
435
  else {
@@ -475,7 +438,7 @@ async function handleMcpRequest(req, res) {
475
438
  }
476
439
  }
477
440
  catch (error) {
478
- log("HTTP handler error:", error.message);
441
+ log("MCP handler error:", error.message);
479
442
  if (!res.headersSent) {
480
443
  res.writeHead(500, { "Content-Type": "application/json" });
481
444
  res.end(JSON.stringify({
@@ -487,111 +450,85 @@ async function handleMcpRequest(req, res) {
487
450
  }
488
451
  }
489
452
  // ---------------------------------------------------------------------------
490
- // Main
453
+ // HTTP Server + routing
491
454
  // ---------------------------------------------------------------------------
492
455
  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);
456
+ log("Starting daemon (REST + MCP gateway)...");
457
+ log(`Upstream: ${maskUrl(UPSTREAM_URL)}`);
458
+ try {
459
+ await ensureSession();
460
+ log("Upstream session established");
514
461
  }
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 ? "..." : ""}`);
462
+ catch (err) {
463
+ log("Warning: initial upstream connection failed:", err.message);
464
+ log("Will retry on first request");
519
465
  }
520
- // Start HTTP server
521
466
  const httpServer = http.createServer((req, res) => {
522
467
  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
- });
541
- }
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
- }));
468
+ const pathname = url.pathname;
469
+ if (APIKEY && url.searchParams.get("apikey") !== APIKEY) {
470
+ res.writeHead(401, { "Content-Type": "application/json" });
471
+ res.end(JSON.stringify({ error: "Unauthorized: invalid or missing apikey" }));
472
+ return;
559
473
  }
560
- else {
561
- res.writeHead(404, { "Content-Type": "text/plain" });
562
- res.end("Not Found MCP endpoint is at /mcp");
474
+ if (req.method === "OPTIONS") {
475
+ res.setHeader("Access-Control-Allow-Origin", "*");
476
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
477
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID");
478
+ res.writeHead(204);
479
+ res.end();
480
+ return;
563
481
  }
482
+ const handler = async () => {
483
+ if (pathname === "/health" || pathname === "/healthz") {
484
+ return handleHealth(req, res);
485
+ }
486
+ if (pathname === "/retrieve_tools" && req.method === "POST") {
487
+ return handleRetrieveTools(req, res);
488
+ }
489
+ if (pathname === "/describe_tools" && req.method === "POST") {
490
+ return handleDescribeTools(req, res);
491
+ }
492
+ if (pathname === "/call" && req.method === "POST") {
493
+ return handleCall(req, res);
494
+ }
495
+ if (pathname === "/exec" && req.method === "POST") {
496
+ return handleExec(req, res);
497
+ }
498
+ if (pathname === "/reinit" && req.method === "POST") {
499
+ return handleReinit(req, res);
500
+ }
501
+ if (pathname === "/mcp" || pathname === "/mcp/") {
502
+ return handleMcpRequest(req, res);
503
+ }
504
+ res.writeHead(404, { "Content-Type": "application/json" });
505
+ res.end(JSON.stringify({ error: "Not Found" }));
506
+ };
507
+ handler().catch((err) => {
508
+ log("Unhandled error:", err.message);
509
+ if (!res.headersSent) {
510
+ res.writeHead(500, { "Content-Type": "application/json" });
511
+ res.end(JSON.stringify({ error: "Internal Server Error" }));
512
+ }
513
+ });
564
514
  });
565
515
  httpServer.listen(PORT, HOST, () => {
566
- log(`Daemon listening on http://${HOST}:${PORT}/mcp`);
567
- log(`Health check: http://${HOST}:${PORT}/health`);
516
+ log(`Daemon listening on http://${HOST}:${PORT}`);
517
+ log("REST endpoints: /health, /retrieve_tools, /describe_tools, /call, /exec, /reinit");
518
+ log("MCP endpoint: /mcp");
568
519
  log(`Auth: ${APIKEY ? "apikey required (?apikey=...)" : "OPEN (no MCP_APIKEY set)"}`);
569
- log("Waiting for MCP client connections...");
570
520
  });
571
- // Graceful shutdown
572
521
  const shutdown = async () => {
573
522
  log("Shutting down daemon...");
574
- // Close downstream sessions
575
- for (const [sid, transport] of downstreamTransports) {
523
+ for (const [sid, transport] of mcpTransports) {
576
524
  try {
577
525
  await transport.close();
578
526
  }
579
527
  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);
528
+ log(`Error closing MCP session ${sid.slice(0, 12)}:`, err.message);
592
529
  }
593
530
  }
594
- upstreams.clear();
531
+ mcpTransports.clear();
595
532
  httpServer.close(() => {
596
533
  log("Daemon stopped");
597
534
  process.exit(0);