mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
c82462d4ef
commit
464db4b4b3
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
145
tests/unit/provider-request-capture-toolname-map-4091.test.ts
Normal file
145
tests/unit/provider-request-capture-toolname-map-4091.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user