@luutuankiet/mcp-proxy-shim 1.0.8 → 1.1.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @luutuankiet/mcp-proxy-shim
2
2
 
3
- **Stdio MCP shim for [mcpproxy-go](https://github.com/smart-mcp-proxy/mcpproxy-go)** — eliminates `args_json` string escaping overhead for LLM clients.
3
+ **MCP shim for [mcpproxy-go](https://github.com/smart-mcp-proxy/mcpproxy-go)** — eliminates `args_json` string escaping overhead for LLM clients. Supports **stdio** and **HTTP Streamable** transports.
4
4
 
5
5
  ## The Problem
6
6
 
@@ -21,7 +21,7 @@ mcpproxy-go's `/mcp/call` mode uses **generic dispatcher tools** (`call_tool_rea
21
21
  The LLM must escape every quote, every nested object, every bracket. For complex tool calls (file edits with match_text containing code), this becomes:
22
22
 
23
23
  ```json
24
- "args_json": "{\"files\":[{\"path\":\"src/app.ts\",\"edits\":[{\"match_text\":\"function hello() {\\n return \\\"world\\\";\\n}\",\"new_string\":\"function hello() {\\n return \\\"universe\\\";\\n}\"}]}]}"
24
+ "args_json": "{\"files\":[{\"path\":\"src/app.ts\",\"edits\":[{\"match_text\":\"function hello() {\\n return \\\\\"world\\\\\";\\n}\",\"new_string\":\"function hello() {\\n return \\\\\"universe\\\\\";\\n}\"}]}]}"
25
25
  ```
26
26
 
27
27
  This is **~400 tokens of overhead per call**, and LLMs frequently produce malformed payloads (mismatched escaping, missing backslashes).
@@ -56,11 +56,11 @@ Native JSON. No escaping. ~50 tokens. Zero malformed payloads.
56
56
  ```mermaid
57
57
  sequenceDiagram
58
58
  participant Client as MCP Client<br/>Claude Code / Cursor / etc
59
- participant Shim as mcp-proxy-shim<br/>stdio
59
+ participant Shim as mcp-proxy-shim<br/>stdio or HTTP
60
60
  participant Proxy as mcpproxy-go<br/>StreamableHTTP
61
61
 
62
62
  Note over Client,Proxy: Connection Setup
63
- Client->>Shim: initialize (stdio)
63
+ Client->>Shim: initialize (stdio or HTTP)
64
64
  Shim->>Proxy: initialize (HTTP)
65
65
  Proxy-->>Shim: capabilities + session ID
66
66
  Shim-->>Client: capabilities
@@ -97,6 +97,8 @@ Only 3 tools are transformed. **Everything else passes through unchanged:**
97
97
 
98
98
  ## Quick Start
99
99
 
100
+ ### Option A: Stdio (local MCP client)
101
+
100
102
  Add to your `.mcp.json` — no install needed, `npx` fetches on first run:
101
103
 
102
104
  ```json
@@ -119,19 +121,227 @@ Or run directly from the CLI:
119
121
  MCP_URL="https://your-proxy/mcp/?apikey=KEY" npx @luutuankiet/mcp-proxy-shim
120
122
  ```
121
123
 
124
+ ### Option B: HTTP Streamable Server (remote agents)
125
+
126
+ Run as an HTTP server that remote MCP clients connect to over the network:
127
+
128
+ ```bash
129
+ MCP_URL="https://upstream-proxy/mcp/?apikey=KEY" \
130
+ MCP_APIKEY="my-secret" \
131
+ npx @luutuankiet/mcp-proxy-shim serve
132
+ ```
133
+
134
+ Then point your remote MCP client at:
135
+ ```
136
+ http://localhost:3000/mcp?apikey=my-secret
137
+ ```
138
+
139
+ #### Production deployment with Docker
140
+
141
+ ```yaml
142
+ # docker-compose.yml
143
+ services:
144
+ mcp-shim:
145
+ image: node:22-slim
146
+ command: npx -y @luutuankiet/mcp-proxy-shim serve
147
+ environment:
148
+ - MCP_URL=http://mcpproxy:9997/mcp/?apikey=admin
149
+ - MCP_PORT=3000
150
+ - MCP_HOST=0.0.0.0
151
+ - MCP_APIKEY=your-secret-key
152
+ ports:
153
+ - "3000:3000"
154
+ ```
155
+
156
+ Put a reverse proxy (Caddy/nginx/Traefik) in front for TLS:
157
+ ```
158
+ https://shim.yourdomain.com/mcp?apikey=KEY → http://localhost:3000/mcp
159
+ ```
160
+
161
+ #### HTTP Server Architecture
162
+
163
+ ```mermaid
164
+ sequenceDiagram
165
+ participant Agent as Remote Agent
166
+ participant Shim as mcp-proxy-shim<br/>HTTP :3000
167
+ participant Proxy as mcpproxy-go<br/>upstream
168
+
169
+ Note over Agent,Shim: Authentication
170
+ Agent->>Shim: POST /mcp?apikey=KEY
171
+ alt apikey invalid or missing
172
+ Shim-->>Agent: 401 Unauthorized
173
+ else apikey valid
174
+ Note over Shim: Create session transport
175
+ Shim->>Proxy: initialize shared upstream
176
+ Proxy-->>Shim: session ID
177
+ Shim-->>Agent: MCP session + Mcp-Session-Id header
178
+ end
179
+
180
+ Note over Agent,Shim: Subsequent requests
181
+ Agent->>Shim: POST /mcp?apikey=KEY<br/>Mcp-Session-Id: abc-123
182
+ Shim->>Proxy: tool call shared session
183
+ Proxy-->>Shim: result
184
+ Shim-->>Agent: result
185
+
186
+ Note over Shim: Multiple agents share<br/>one upstream connection
187
+ ```
188
+
189
+ Each downstream client gets its own MCP session, but all sessions **share a single upstream connection** to mcpproxy-go. This is efficient — one upstream session, many downstream clients.
190
+
191
+ **Endpoints:**
192
+
193
+ | Endpoint | Method | Auth | Description |
194
+ |----------|--------|------|-------------|
195
+ | `/mcp` | POST | `?apikey=` | MCP JSON-RPC (initialize, tool calls) |
196
+ | `/mcp` | GET | `?apikey=` | SSE stream reconnection |
197
+ | `/mcp` | DELETE | `?apikey=` | Session termination |
198
+ | `/health` | GET | None | Health check (session count, uptime) |
199
+
200
+ ### Option C: Daemon Mode (multi-server MCP gateway)
201
+
202
+ Run a standalone gateway that connects to **multiple** MCP servers (stdio or HTTP) and exposes all their tools through a single HTTP endpoint. Pure passthrough — no schema transformation.
203
+
204
+ **Use case:** Cloud agents (claude.ai/code, Codespaces, etc.) that can't spawn MCP servers on the fly.
205
+
206
+ ```
207
+ Cloud Agent ──HTTP──▶ daemon (:3456) ──┬── stdio ──▶ github MCP
208
+ ├── stdio ──▶ filesystem MCP
209
+ └── HTTP ──▶ remote API (with auth headers)
210
+ ```
211
+
212
+ #### Inline config
213
+
214
+ ```bash
215
+ MCP_SERVERS='{
216
+ "github": {
217
+ "type": "stdio",
218
+ "command": "npx",
219
+ "args": ["-y", "@modelcontextprotocol/server-github"],
220
+ "env": { "GITHUB_TOKEN": "ghp_..." }
221
+ },
222
+ "my-api": {
223
+ "type": "streamableHttp",
224
+ "url": "https://api.example.com/mcp",
225
+ "headers": { "Authorization": "Bearer xxx", "X-Org-Id": "org_123" }
226
+ }
227
+ }' npx @luutuankiet/mcp-proxy-shim daemon
228
+ ```
229
+
230
+ #### Config file
231
+
232
+ ```bash
233
+ MCP_CONFIG=./mcp-servers.json npx @luutuankiet/mcp-proxy-shim daemon
234
+ ```
235
+
236
+ The config file supports three formats:
237
+ - Flat: `{ "server-name": { "type": "stdio", ... } }`
238
+ - Wrapped: `{ "servers": { "server-name": { ... } } }`
239
+ - `.mcp.json` format: `{ "mcpServers": { "server-name": { ... } } }`
240
+
241
+ #### How clients use it
242
+
243
+ All upstream tools are **namespaced** with the server name as prefix:
244
+
245
+ ```
246
+ github__get_file_contents ← from the "github" server
247
+ my-api__query ← from the "my-api" server
248
+ ```
249
+
250
+ A built-in `daemon_help` tool provides a full usage guide:
251
+
252
+ ```json
253
+ { "name": "daemon_help", "arguments": {} }
254
+ ```
255
+
256
+ Returns connected servers, all tools with namespaced names, and calling examples. You can also filter to a specific server for full schemas:
257
+
258
+ ```json
259
+ { "name": "daemon_help", "arguments": { "server": "github" } }
260
+ ```
261
+
262
+ The daemon also returns `instructions` in the MCP `initialize` response, so MCP clients that support server instructions will automatically know how to use it.
263
+
264
+ #### Server config reference
265
+
266
+ **Stdio servers** (spawn a local process):
267
+
268
+ ```json
269
+ {
270
+ "type": "stdio",
271
+ "command": "npx",
272
+ "args": ["-y", "@modelcontextprotocol/server-github"],
273
+ "env": { "GITHUB_TOKEN": "ghp_..." },
274
+ "cwd": "/optional/working/directory"
275
+ }
276
+ ```
277
+
278
+ **HTTP Streamable servers** (connect to remote MCP):
279
+
280
+ ```json
281
+ {
282
+ "type": "streamableHttp",
283
+ "url": "https://api.example.com/mcp",
284
+ "headers": {
285
+ "Authorization": "Bearer your-token",
286
+ "X-Custom-Header": "value"
287
+ }
288
+ }
289
+ ```
290
+
291
+ The `headers` field supports any custom HTTP headers — useful for authentication, org routing, or API versioning.
292
+
293
+ #### Daemon environment variables
294
+
295
+ | Variable | Default | Description |
296
+ |----------|---------|-------------|
297
+ | `MCP_SERVERS` | — | JSON string with server configs (inline) |
298
+ | `MCP_CONFIG` | — | Path to JSON config file (alternative to MCP_SERVERS) |
299
+ | `MCP_PORT` | `3456` | Port to listen on |
300
+ | `MCP_HOST` | `0.0.0.0` | Host to bind to |
301
+ | `MCP_APIKEY` | — (open) | Require `?apikey=KEY` on `/mcp` requests |
302
+ | `https_proxy` | — | HTTPS proxy for HTTP upstream connections |
303
+
304
+ #### Daemon endpoints
305
+
306
+ | Endpoint | Method | Description |
307
+ |----------|--------|-------------|
308
+ | `/mcp` | POST | MCP JSON-RPC (initialize, tools/list, tools/call) |
309
+ | `/mcp` | GET | SSE stream reconnection |
310
+ | `/mcp` | DELETE | Session termination |
311
+ | `/health` | GET | Per-server status, tool counts, uptime |
312
+
313
+ #### Production deployment
314
+
315
+ ```yaml
316
+ # docker-compose.yml
317
+ services:
318
+ mcp-daemon:
319
+ image: node:22-slim
320
+ command: npx -y @luutuankiet/mcp-proxy-shim daemon
321
+ environment:
322
+ - MCP_CONFIG=/config/servers.json
323
+ - MCP_APIKEY=your-secret
324
+ ports:
325
+ - "3456:3456"
326
+ volumes:
327
+ - ./mcp-servers.json:/config/servers.json:ro
328
+ ```
329
+
330
+ ---
331
+
122
332
  ## Why Not `/mcp/all`?
123
333
 
124
334
  mcpproxy-go exposes two routing modes:
125
335
 
126
336
  ```mermaid
127
337
  flowchart LR
128
- subgraph "/mcp/all (direct mode)"
338
+ subgraph direct["/mcp/all - direct mode"]
129
339
  A1[Client] --> B1[myserver__read_files<br/>native schema]
130
340
  A1 --> C1[myserver__edit_files<br/>native schema]
131
341
  A1 --> D1[github__get_user<br/>native schema]
132
342
  end
133
343
 
134
- subgraph "/mcp/call (retrieve_tools mode)"
344
+ subgraph call["/mcp/call - retrieve_tools mode"]
135
345
  A2[Client] --> B2[retrieve_tools<br/>BM25 search]
136
346
  A2 --> C2[call_tool_read<br/>generic dispatcher]
137
347
  A2 --> D2[upstream_servers<br/>add/remove/patch]
@@ -150,41 +360,56 @@ We tested this live: added a YNAB financial tool mid-session → 43 new tools ap
150
360
  # 1. User adds YNAB server to mcpproxy-go (via UI or API)
151
361
 
152
362
  # 2. Client discovers new tools (no reconnect!)
153
- retrieve_tools("ynab accounts balance")
154
- [ynab__getAccounts, ynab__getTransactions, ynab__getPlans, ...]
363
+ # retrieve_tools("ynab accounts balance")
364
+ # => [ynab__getAccounts, ynab__getTransactions, ynab__getPlans, ...]
155
365
 
156
366
  # 3. Client calls with native args (shim handles serialization)
157
- call_tool_read {
158
- name: "utils:ynab__getAccounts",
159
- args: { plan_id: "abc-123" } // native object, not escaped string
160
- }
161
- [{ name: "Checking", balance: 1500000, ... }]
367
+ # call_tool_read {
368
+ # name: "utils:ynab__getAccounts",
369
+ # args: { plan_id: "abc-123" } // native object, not escaped string
370
+ # }
371
+ # => [{ name: "Checking", balance: 1500000, ... }]
162
372
  ```
163
373
 
164
374
  ## Configuration
165
375
 
166
- | Environment variable | Default | Description |
167
- |---------------------|---------|-------------|
168
- | `MCP_URL` | **(required)** | mcpproxy-go StreamableHTTP endpoint |
169
- | `https_proxy` / `HTTPS_PROXY` | | HTTPS proxy (auto-detected via undici ProxyAgent) |
376
+ | Environment variable | Default | Transport | Description |
377
+ |---------------------|---------|-----------|-------------|
378
+ | `MCP_URL` | **(required)** | Both | mcpproxy-go StreamableHTTP endpoint |
379
+ | `MCP_PORT` | `3000` | HTTP only | Port to listen on |
380
+ | `MCP_HOST` | `0.0.0.0` | HTTP only | Host to bind to |
381
+ | `MCP_APIKEY` | — (open) | HTTP only | API key for downstream clients. When set, requests must include `?apikey=KEY`. Unset = no auth. |
382
+ | `https_proxy` / `HTTPS_PROXY` | — | Both | HTTPS proxy (auto-detected via undici ProxyAgent) |
170
383
 
171
384
  ## Architecture Details
172
385
 
386
+ ### Transport Modes
387
+
388
+ | Feature | Stdio | HTTP Streamable (`serve`) | Daemon (`daemon`) |
389
+ |---------|-------|--------------------------|-------------------|
390
+ | Use case | Local MCP client (Claude Code, Cursor) | Remote agents, single upstream | Cloud agents, multi-server gateway |
391
+ | Connection | stdin/stdout | HTTP on `/mcp` | HTTP on `/mcp` |
392
+ | Upstreams | Single (mcpproxy-go) | Single (mcpproxy-go) | Multiple (stdio + HTTP) |
393
+ | Schema transforms | `args_json` → `args` | `args_json` → `args` | None (pure passthrough) |
394
+ | Auth | N/A (local process) | Optional `?apikey=` | Optional `?apikey=` |
395
+ | Multi-client | Single | Multiple sessions | Multiple sessions |
396
+ | Custom headers | N/A | N/A | Per-upstream `headers` config |
397
+
173
398
  ### Session Management
174
399
 
175
400
  - Initializes upstream MCP session on startup via `initialize` + `notifications/initialized`
176
401
  - Auto-reinitializes on session expiry (e.g., upstream restart, 405 responses)
177
402
  - Retries transient failures with exponential backoff (1s, 2s, max 2 retries)
178
403
  - Refreshes tool list on every `tools/list` request (upstream servers may have changed)
404
+ - HTTP mode: each downstream client gets its own `Mcp-Session-Id`, all sharing one upstream session
179
405
 
180
406
  ### Backward Compatibility
181
407
 
182
408
  If a caller sends `args_json` directly (old style), the shim **passes it through unchanged**. You can migrate gradually — no breaking changes.
183
409
 
184
410
  ```json
185
- // Both work:
186
- { "args": { "files": [...] } } // ← new: native object (shim serializes)
187
- { "args_json": "{\"files\":[...]}" } // ← old: pre-serialized (shim passes through)
411
+ { "args": { "files": [...] } } // new: native object (shim serializes)
412
+ { "args_json": "{\"files\":[...]}" } // old: pre-serialized (shim passes through)
188
413
  ```
189
414
 
190
415
  ### HTTPS Proxy Support
@@ -206,19 +431,30 @@ StreamableHTTP responses may arrive as either `application/json` or `text/event-
206
431
  ## Development
207
432
 
208
433
  ```bash
209
- git clone https://github.com/luutuankiet/sandbox-cc
210
- cd sandbox-cc/mcp-shim
434
+ git clone https://github.com/luutuankiet/mcp-proxy-shim
435
+ cd mcp-proxy-shim
211
436
  npm install
212
437
  npm run build
213
- npm start # starts the shim (connects upstream, waits for stdio)
438
+ npm start # stdio mode (connects upstream, waits for stdio)
439
+ npm run start:http # HTTP serve mode
440
+ node dist/index.js daemon # daemon mode (set MCP_SERVERS or MCP_CONFIG)
214
441
  ```
215
442
 
216
443
  ### Testing
217
444
 
218
445
  ```bash
219
- # Send MCP JSON-RPC over stdin:
446
+ # Stdio mode — send MCP JSON-RPC over stdin:
220
447
  echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \
221
448
  | MCP_URL="https://your-proxy/mcp/?apikey=KEY" node dist/index.js
449
+
450
+ # HTTP mode — start server, then test with curl:
451
+ MCP_URL="https://your-proxy/mcp/?apikey=KEY" MCP_APIKEY="test" node dist/index.js serve
452
+
453
+ # In another terminal:
454
+ curl http://localhost:3000/health
455
+ curl -X POST http://localhost:3000/mcp?apikey=test \
456
+ -H "Content-Type: application/json" \
457
+ -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}'
222
458
  ```
223
459
 
224
460
  Logs go to stderr (stdout is the stdio transport):
package/dist/core.js CHANGED
@@ -189,7 +189,11 @@ function transformToolSchema(tool) {
189
189
  delete props.args_json;
190
190
  props.args = {
191
191
  type: "object",
192
- description: "Tool arguments as a native JSON object. The shim serializes this to args_json before forwarding upstream.",
192
+ description: "The upstream tool's arguments as a native JSON object (not a string). " +
193
+ "Use describe_tools to get the upstream tool's inputSchema, then pass " +
194
+ "those fields here directly. Example: if the upstream tool expects " +
195
+ '{owner: string, repo: string}, pass args: {"owner": "foo", "repo": "bar"}. ' +
196
+ "Nested strings containing JSON are fine — only the top-level args must be an object.",
193
197
  additionalProperties: true,
194
198
  };
195
199
  let required = tool.inputSchema.required;
@@ -205,16 +209,91 @@ function transformToolSchema(tool) {
205
209
  },
206
210
  };
207
211
  }
212
+ /**
213
+ * Validate and re-serialize a JSON string to canonical form.
214
+ * The upstream Go server expects args_json to unmarshal into map[string]interface{}.
215
+ *
216
+ * CRITICAL: Always re-serializes from the parsed object via JSON.stringify()
217
+ * rather than passing through the raw input string. This prevents failures when
218
+ * args arrives as a pre-serialized string from the LLM with non-canonical
219
+ * escaping, encoding quirks, or formatting that Go's json.Unmarshal rejects.
220
+ * See: https://github.com/luutuankiet/mcp-proxy-shim/issues/1
221
+ *
222
+ * Throws ArgsValidationError on invalid input — never silently drops data.
223
+ */
224
+ class ArgsValidationError extends Error {
225
+ constructor(message) {
226
+ super(message);
227
+ this.name = "ArgsValidationError";
228
+ }
229
+ }
230
+ function validateAndSerializeArgs(jsonStr) {
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(jsonStr);
234
+ }
235
+ catch (err) {
236
+ throw new ArgsValidationError(`args is a string but not valid JSON. The "args" field must be a native JSON object, ` +
237
+ `not a pre-serialized string. Pass args as {"key": "value"}, not as '{"key": "value"}'. ` +
238
+ `Parse error: ${err.message}. Input (first 200 chars): ${jsonStr.slice(0, 200)}`);
239
+ }
240
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
241
+ const actualType = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
242
+ throw new ArgsValidationError(`args must be a JSON object, got ${actualType}. ` +
243
+ `Pass args as {"key": "value"}, not as a primitive or array. ` +
244
+ `Input: ${jsonStr.slice(0, 200)}`);
245
+ }
246
+ // Re-serialize from parsed object for canonical JSON.
247
+ // Do NOT return jsonStr directly — the raw string from the LLM may have
248
+ // non-canonical escaping (e.g., \\/ vs /, unicode escapes, whitespace)
249
+ // that Go's json.Unmarshal handles differently than Node's JSON.parse.
250
+ return JSON.stringify(parsed);
251
+ }
252
+ /**
253
+ * Transform call_tool_* arguments: args:object → args_json:string.
254
+ * Returns the transformed args, or throws ArgsValidationError if
255
+ * the client sends malformed args.
256
+ */
208
257
  function transformToolCallArgs(toolName, args) {
209
258
  if (!CALL_TOOL_NAMES.has(toolName))
210
259
  return args;
211
- if ("args_json" in args)
212
- return args;
260
+ if ("args_json" in args) {
261
+ // Backward compat: if args_json is already present, ensure it's a string.
262
+ const existing = args.args_json;
263
+ if (typeof existing === "string") {
264
+ return { ...args, args_json: validateAndSerializeArgs(existing) };
265
+ }
266
+ // If it's an object, stringify it.
267
+ if (existing !== null && existing !== undefined && typeof existing === "object") {
268
+ return { ...args, args_json: JSON.stringify(existing) };
269
+ }
270
+ throw new ArgsValidationError(`args_json must be a JSON string or object, got ${existing === null ? "null" : typeof existing}. ` +
271
+ `Pass args_json as '{"key": "value"}' (string) or use the "args" field with a native object instead.`);
272
+ }
213
273
  if ("args" in args) {
214
274
  const { args: argsObj, ...rest } = args;
275
+ let argsJson;
276
+ if (argsObj === null || argsObj === undefined) {
277
+ // Nullish args — treat as empty object (common for tools with no required params)
278
+ argsJson = "{}";
279
+ }
280
+ else if (typeof argsObj === "string") {
281
+ // LLM sent args as a pre-serialized string — validate and re-serialize.
282
+ argsJson = validateAndSerializeArgs(argsObj);
283
+ }
284
+ else if (typeof argsObj === "object" && !Array.isArray(argsObj)) {
285
+ // Happy path: native object → serialize.
286
+ // Nested strings containing JSON are fine — JSON.stringify handles them correctly.
287
+ argsJson = JSON.stringify(argsObj);
288
+ }
289
+ else {
290
+ const actualType = Array.isArray(argsObj) ? "array" : typeof argsObj;
291
+ throw new ArgsValidationError(`args must be a JSON object, got ${actualType}. ` +
292
+ `Pass args as {"key": "value"}. Use describe_tools to get the upstream tool's inputSchema.`);
293
+ }
215
294
  return {
216
295
  ...rest,
217
- args_json: JSON.stringify(argsObj),
296
+ args_json: argsJson,
218
297
  };
219
298
  }
220
299
  return args;
@@ -331,6 +410,45 @@ async function fetchUpstreamTools() {
331
410
  log(`Fetched ${tools.length} upstream tools`);
332
411
  return tools;
333
412
  }
413
+ // ---------------------------------------------------------------------------
414
+ // Tool name resolution helpers
415
+ // ---------------------------------------------------------------------------
416
+ /**
417
+ * Resolve a tool name against an index map, handling server prefixes and
418
+ * suffix matching for mount-path variations.
419
+ */
420
+ function resolveToolFromIndex(name, index) {
421
+ // 1. Exact match
422
+ let tool = index.get(name);
423
+ if (tool)
424
+ return tool;
425
+ // 2. Try without server prefix (e.g., "utils:bi-platform__query" → "bi-platform__query")
426
+ if (name.includes(":")) {
427
+ const withoutPrefix = name.split(":").slice(1).join(":");
428
+ tool = index.get(withoutPrefix);
429
+ if (tool)
430
+ return tool;
431
+ }
432
+ // 3. Try suffix/prefix matching — check all indexed tools
433
+ for (const [key, candidate] of index) {
434
+ if (key.endsWith(name) || name.endsWith(key)) {
435
+ return candidate;
436
+ }
437
+ }
438
+ // 4. Fuzzy suffix match — handle mount-path variations
439
+ const nParts = name.includes(":") ? name.split(":").slice(1).join(":").split("__") : name.split("__");
440
+ const nSuffix = nParts[nParts.length - 1];
441
+ if (nSuffix) {
442
+ for (const [, candidate] of index) {
443
+ const cParts = candidate.name.split("__");
444
+ const cSuffix = cParts[cParts.length - 1];
445
+ if (cSuffix === nSuffix && candidate.name.includes(nParts[0])) {
446
+ return candidate;
447
+ }
448
+ }
449
+ }
450
+ return undefined;
451
+ }
334
452
  /**
335
453
  * Create and wire up an MCP Server with all shim handlers.
336
454
  * Caller connects their chosen transport (stdio or HTTP).
@@ -384,11 +502,32 @@ export async function createShimServer(options = {}) {
384
502
  }
385
503
  await ensureSession();
386
504
  const nameSet = new Set(names);
505
+ // Always query live BM25 — no caching. Generate multiple search
506
+ // queries per tool name for broader coverage:
507
+ // 1. Raw name as-is (exact match in BM25 — most targeted)
508
+ // 2. Full name with separators → spaces (e.g., "bi-platform query")
509
+ // 3. Last __ segment only (e.g., "query") as a broader fallback
510
+ // 4. First __ segment (e.g., "bi-platform") for server/mount prefix matching
387
511
  const queries = new Set();
388
512
  for (const n of nameSet) {
389
- const parts = n.split("__");
390
- const toolPart = parts[parts.length - 1] || n;
391
- queries.add(toolPart.replace(/_/g, " "));
513
+ // Strip optional "server:" prefix for searching
514
+ const withoutServer = n.includes(":") ? n.split(":").slice(1).join(":") : n;
515
+ // Raw name as-is — BM25 often matches exact tool names better than transformed queries
516
+ queries.add(withoutServer);
517
+ // Full name → spaces (primary query, most specific)
518
+ queries.add(withoutServer.replace(/__/g, " ").replace(/[-_]/g, " "));
519
+ // Segment-based queries for compound names
520
+ const parts = withoutServer.split("__");
521
+ if (parts.length > 1) {
522
+ // Last segment: tool action (e.g., "query", "edit_files")
523
+ const toolPart = parts[parts.length - 1] || withoutServer;
524
+ queries.add(toolPart.replace(/[-_]/g, " "));
525
+ // First segment: server/mount prefix (e.g., "looker-da", "hetzner_at_slash")
526
+ const prefixPart = parts[0];
527
+ if (prefixPart && prefixPart !== toolPart) {
528
+ queries.add(prefixPart.replace(/[-_]/g, " ") + " " + toolPart.replace(/[-_]/g, " "));
529
+ }
530
+ }
392
531
  }
393
532
  const index = new Map();
394
533
  for (const query of queries) {
@@ -398,18 +537,13 @@ export async function createShimServer(options = {}) {
398
537
  name: "retrieve_tools",
399
538
  arguments: { query },
400
539
  });
401
- log("describe_tools: resp exists:", !!resp, "resp.result exists:", !!resp?.result);
402
540
  if (resp?.result) {
403
541
  const unwrapped = deepUnwrapResult(resp.result);
404
- log("describe_tools: unwrapped type:", typeof unwrapped, "isArray:", Array.isArray(unwrapped));
405
- if (unwrapped && typeof unwrapped === "object" && !Array.isArray(unwrapped)) {
406
- log("describe_tools: unwrapped keys:", Object.keys(unwrapped).join(", "));
407
- }
408
542
  const result = unwrapped;
409
543
  const tools = Array.isArray(result)
410
544
  ? result
411
545
  : (result && Array.isArray(result.tools) ? result.tools : []);
412
- log("describe_tools: found", tools.length, "tools from query");
546
+ log("describe_tools: found", tools.length, "tools from query:", query);
413
547
  for (const t of tools) {
414
548
  const tool = t;
415
549
  if (tool.name && !index.has(tool.name)) {
@@ -424,16 +558,29 @@ export async function createShimServer(options = {}) {
424
558
  }
425
559
  log("describe_tools: index has", index.size, "tools, looking up:", names.join(", "));
426
560
  const results = names.map((n) => {
427
- const tool = index.get(n);
428
- if (!tool)
429
- return { name: n, error: "not found" };
430
- return transformToolSchema(tool);
561
+ const tool = resolveToolFromIndex(n, index);
562
+ if (tool)
563
+ return transformToolSchema(tool);
564
+ return { name: n, error: "not found" };
431
565
  });
432
566
  return {
433
567
  content: [{ type: "text", text: JSON.stringify(results) }],
434
568
  };
435
569
  }
436
- const forwardArgs = transformToolCallArgs(name, args || {});
570
+ let forwardArgs;
571
+ try {
572
+ forwardArgs = transformToolCallArgs(name, args || {});
573
+ }
574
+ catch (err) {
575
+ if (err instanceof ArgsValidationError) {
576
+ log("Args validation rejected:", err.message);
577
+ return {
578
+ content: [{ type: "text", text: err.message }],
579
+ isError: true,
580
+ };
581
+ }
582
+ throw err;
583
+ }
437
584
  try {
438
585
  await ensureSession();
439
586
  const resp = await mcpRequest("tools/call", {