fix(sse): restore MCP/third-party tool names on native Claude path (#4091) (#4153)

Restore MCP/third-party tool names on the native Claude path (#4091): re-attach the non-enumerable _toolNameMap dropped by the request-capture JSON round-trip, so the response-side un-cloak restores MCP/snake_case names. Integrado em release/v3.8.29.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-18 03:48:21 -03:00
committed by GitHub
parent c82462d4ef
commit 464db4b4b3
3 changed files with 179 additions and 1 deletions

View File

@@ -8,6 +8,10 @@
_In development — bullets added per PR; finalized at release._
### 🐛 Fixed
- **fix(sse): restore MCP / third-party tool names on the native Claude path (MCP dispatch broken in Claude Code)** — since 3.8.27, every MCP tool call routed through OmniRoute to a native Claude OAuth provider failed client-side with `Error: No such tool available: <PascalCaseName>`: tool schemas arrived fine but the streamed `tool_use.name` reached Claude Code in its cloaked form (e.g. `McpN8nMcpSearchWorkflows` instead of the registered `mcp__n8n-mcp__search_workflows`). The native-Claude tool-name cloak stashes its per-request alias→original map as a **non-enumerable** `_toolNameMap` on the request body; the request-inspector capture added in 3.8.27 rebuilds the captured body from its serialized form (`JSON.parse(JSON.stringify(...))`), which drops non-enumerable properties, so `finalBody._toolNameMap` was empty and the response-side un-cloak silently fell back to the static built-in map — never restoring dynamic MCP / snake_case names. Built-in tools (Bash/Read/…) were unaffected (static map); cross-format paths were unaffected (they attach the map enumerably). The provider-request capture now re-attaches the per-request map (kept non-enumerable, so it still never re-serializes upstream) when the captured copy lost it, restoring MCP tool dispatch. ([#4091](https://github.com/diegosouzapw/OmniRoute/issues/4091) — thanks @pedrotecinf, @NakHalal)
---
## [3.8.28] — 2026-06-17

View File

@@ -234,7 +234,36 @@ export function createPreparedRequestLogger(
});
},
body(fallback) {
return latest?.body ?? fallback;
const resolved = latest?.body ?? fallback;
// #4091: the captured body is rebuilt from the serialized upstream payload
// (the fetch-capture does `JSON.parse(JSON.stringify(...))`), which drops
// non-enumerable properties. The native-Claude tool-name cloak stashes its
// per-request alias→original map as a NON-ENUMERABLE `_toolNameMap` on the
// real (fallback) transformed body; without it the response-side un-cloak
// (`mergeResponseToolNameMap` → `remapToolNamesInResponse`) can't restore
// MCP / snake_case tool names, so Claude Code receives the cloaked
// PascalCase name and rejects every call with "No such tool available".
// Re-attach the map onto the resolved (captured) body — kept non-enumerable
// so it still never re-serializes into an upstream request.
if (
resolved !== fallback &&
resolved &&
typeof resolved === "object" &&
fallback &&
typeof fallback === "object"
) {
const map = (fallback as Record<string, unknown>)._toolNameMap;
const target = resolved as Record<string, unknown>;
if (map instanceof Map && !(target._toolNameMap instanceof Map)) {
Object.defineProperty(target, "_toolNameMap", {
value: map,
enumerable: false,
configurable: true,
writable: true,
});
}
}
return resolved;
},
latest() {
return latest;

View File

@@ -0,0 +1,145 @@
/**
* #4091 — MCP tool dispatch broken in Claude Code ("No such tool available")
* on native Claude OAuth, regression from v3.8.27.
*
* Root cause: the native-Claude tool-name cloak stores the per-request
* alias→original map as a NON-ENUMERABLE `_toolNameMap` on the request body.
* The request-inspector capture added in 3.8.27 intercepts the upstream fetch,
* takes the serialized body string and rebuilds the object via
* `JSON.parse(JSON.stringify(...))` — which drops non-enumerable properties.
* `finalBody = providerRequestCapture.body(transformedBody)` then resolves to
* that round-tripped copy, so the response-side un-cloak
* (`mergeResponseToolNameMap` → `remapToolNamesInResponse`) sees an empty map
* and the cloaked PascalCase name (`McpN8nMcpSearchWorkflows`) streams verbatim
* to Claude Code, which rejects it client-side.
*
* These tests pin the lossy boundary: `createPreparedRequestLogger().body()`
* must preserve the cloak map from the real (fallback) transformed body even
* when the captured copy lost it to JSON serialization.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
createPreparedRequestLogger,
type ProviderRequestPrepared,
} from "../../open-sse/utils/providerRequestLogging.ts";
import {
cloakThirdPartyToolNames,
remapToolNamesInResponse,
} from "../../open-sse/services/claudeCodeToolRemapper.ts";
function makeCapture() {
const logged: unknown[] = [];
const reqLogger = {
logTargetRequest: (_url: unknown, _headers: Record<string, string>, body: unknown) => {
logged.push(body);
},
};
const scope = { id: null, model: "claude-opus-4-8", provider: "claude", connectionId: null };
return { capture: createPreparedRequestLogger(reqLogger, scope), logged };
}
const ALIAS = "McpN8nMcpSearchWorkflows";
const ORIGINAL = "mcp__n8n-mcp__search_workflows";
function makeCloakedClaudeBody(): Record<string, unknown> {
const body: Record<string, unknown> = {
model: "claude-opus-4-8",
messages: [{ role: "user", content: "hi" }],
tools: [{ name: ORIGINAL, input_schema: { type: "object", properties: {} } }],
};
const map = cloakThirdPartyToolNames(body);
// Sanity: the cloak fired and the alias matches the reporter's observed name.
assert.equal((body.tools as Array<{ name: string }>)[0].name, ALIAS);
assert.ok(map instanceof Map && map.get(ALIAS) === ORIGINAL);
return body;
}
test("#4091 body() preserves the non-enumerable cloak map dropped by the capture round-trip", () => {
const { capture } = makeCapture();
const transformedBody = makeCloakedClaudeBody();
// The fetch-capture serializes the outgoing body and rebuilds it — exactly
// what `captureFetchRequest` does — which strips the non-enumerable map.
const bodyString = JSON.stringify(transformedBody);
const captured = JSON.parse(bodyString);
assert.equal(
(captured as Record<string, unknown>)._toolNameMap,
undefined,
"precondition: JSON round-trip drops the non-enumerable _toolNameMap"
);
const prepared: ProviderRequestPrepared = {
url: "https://api.anthropic.com/v1/messages",
headers: {},
body: captured,
bodyString,
};
capture.capture(prepared);
// finalBody is what chatCore feeds to mergeResponseToolNameMap.
const finalBody = capture.body(transformedBody) as Record<string, unknown>;
const map = finalBody._toolNameMap;
assert.ok(map instanceof Map, "finalBody must still carry the per-request cloak map");
assert.equal((map as Map<string, string>).get(ALIAS), ORIGINAL);
// End-to-end: the streamed tool_use.name is un-cloaked back to the registered
// MCP name, so Claude Code can dispatch it instead of rejecting it.
const chunk = `event: content_block_start\ndata: {"type":"tool_use","name":"${ALIAS}"}\n\n`;
const restored = remapToolNamesInResponse(chunk, true, map as Map<string, string>);
assert.ok(
restored.includes(`"name":"${ORIGINAL}"`),
"cloaked alias must be restored to the registered MCP tool name"
);
assert.ok(!restored.includes(`"name":"${ALIAS}"`), "cloaked alias must not leak to the client");
});
test("#4091 body() preserves the map kept non-enumerable on the captured copy too", () => {
// Defensive: the map must not be re-attached enumerably (it must never
// re-serialize into an upstream request body).
const { capture } = makeCapture();
const transformedBody = makeCloakedClaudeBody();
const bodyString = JSON.stringify(transformedBody);
capture.capture({
url: "https://api.anthropic.com/v1/messages",
headers: {},
body: JSON.parse(bodyString),
bodyString,
});
const finalBody = capture.body(transformedBody) as Record<string, unknown>;
assert.ok(finalBody._toolNameMap instanceof Map);
assert.ok(
!Object.keys(finalBody).includes("_toolNameMap"),
"_toolNameMap must stay non-enumerable so it never re-serializes upstream"
);
assert.ok(
!JSON.stringify(finalBody).includes("_toolNameMap"),
"_toolNameMap must not appear in a serialized provider body"
);
});
test("#4091 body() leaves non-cloaked traffic untouched (no spurious map)", () => {
const { capture } = makeCapture();
const plainBody: Record<string, unknown> = {
model: "claude-opus-4-8",
messages: [{ role: "user", content: "hi" }],
tools: [{ name: "Bash", input_schema: { type: "object" } }],
};
const bodyString = JSON.stringify(plainBody);
capture.capture({
url: "https://api.anthropic.com/v1/messages",
headers: {},
body: JSON.parse(bodyString),
bodyString,
});
const finalBody = capture.body(plainBody) as Record<string, unknown>;
assert.equal(finalBody._toolNameMap, undefined);
});
test("#4091 body() falls back to the original body when nothing was captured", () => {
const { capture } = makeCapture();
const transformedBody = makeCloakedClaudeBody();
// No capture() call — body() returns the fallback, which already has the map.
const finalBody = capture.body(transformedBody) as Record<string, unknown>;
assert.ok(finalBody._toolNameMap instanceof Map);
});