@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/core.d.ts +34 -0
- package/dist/core.js +13 -5
- package/dist/core.js.map +1 -1
- package/dist/daemon.d.ts +21 -32
- package/dist/daemon.js +329 -461
- package/dist/daemon.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.js +13 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|
-
*
|
|
6
|
-
* and exposes
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
102
|
+
// REST response helpers
|
|
105
103
|
// ---------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
return null;
|
|
154
|
+
return jsonResponse(res, 500, { error: err.message });
|
|
185
155
|
}
|
|
186
156
|
}
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
192
|
+
return jsonResponse(res, 500, { error: err.message });
|
|
196
193
|
}
|
|
197
194
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
"
|
|
208
|
-
"
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
//
|
|
301
|
+
// Streamable HTTP /mcp endpoint (backward compat)
|
|
281
302
|
// ---------------------------------------------------------------------------
|
|
282
|
-
const
|
|
283
|
-
async function
|
|
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(`
|
|
288
|
-
|
|
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 &&
|
|
294
|
-
log(`
|
|
295
|
-
|
|
314
|
+
if (sid && mcpTransports.has(sid)) {
|
|
315
|
+
log(`MCP session closed: ${sid.slice(0, 12)}...`);
|
|
316
|
+
mcpTransports.delete(sid);
|
|
296
317
|
}
|
|
297
318
|
};
|
|
298
|
-
|
|
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 &&
|
|
438
|
-
const transport =
|
|
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
|
|
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: {
|
|
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 || !
|
|
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 =
|
|
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("
|
|
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
|
-
//
|
|
384
|
+
// HTTP Server + routing
|
|
491
385
|
// ---------------------------------------------------------------------------
|
|
492
386
|
async function main() {
|
|
493
|
-
log("Starting MCP
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
log("
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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}
|
|
567
|
-
log(
|
|
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
|
-
|
|
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
|
-
|
|
462
|
+
mcpTransports.clear();
|
|
595
463
|
httpServer.close(() => {
|
|
596
464
|
log("Daemon stopped");
|
|
597
465
|
process.exit(0);
|