@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 +260 -24
- package/dist/core.js +165 -18
- package/dist/core.js.map +1 -1
- package/dist/daemon.d.ts +45 -0
- package/dist/daemon.js +608 -0
- package/dist/daemon.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +30 -8
- package/dist/index.js.map +1 -1
- package/package.json +5 -12
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @luutuankiet/mcp-proxy-shim
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
| `
|
|
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
|
-
//
|
|
186
|
-
{ "
|
|
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/
|
|
210
|
-
cd
|
|
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
|
|
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
|
-
#
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
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 =
|
|
428
|
-
if (
|
|
429
|
-
return
|
|
430
|
-
return
|
|
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
|
-
|
|
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", {
|