fix(translator): flatten MCP namespace tools to functions on Responses->Chat path (#4340)

When a Codex CLI client routes a Responses-API request to a non-Codex
backend (e.g. kr/claude-opus-4.7), each MCP server is declared as a
`namespace` tool: { type:"namespace", name, tools:[{name, description,
parameters}] }. The Responses->Chat translator had no namespace branch, so
the whole group collapsed into one empty-schema function named
`mcp__<server>__` and every MCP call failed with
`unsupported call: mcp__<server>__`, breaking all MCP workflows for that
combination. The translator now expands a namespace into one Chat function
per sub-tool (name + parameters preserved); an empty namespace yields no
tools. The native Codex passthrough path was already correct.

Reported-by: V13t4nh (https://github.com/decolua/9router/issues/1534)

Co-authored-by: V13t4nh <201110185+V13t4nh@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 23:23:41 -03:00
committed by GitHub
parent 7a7b437b61
commit 8c96cdeade
3 changed files with 97 additions and 1 deletions

View File

@@ -36,6 +36,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd)
- **fix(translator): clamp Responses API `call_id` to 64 characters** — the OpenAI Responses API rejects `call_id` values longer than 64 characters with a 400. Long upstream tool-call ids (some clients emit ids well over the limit) are now clamped deterministically on both the `function_call` item and its matching `function_call_output`, so the pair stays matched through the orphaned-output filter and the request is accepted. (thanks @anuragg-saxenaa, @ngapngap)
- **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr)
- **fix(translator): MCP `namespace` tools flatten to individual functions on the Responses→Chat path** — when a Codex CLI client routes a Responses-API request to a non-Codex backend (e.g. `kr/claude-opus-4.7`), each MCP server is declared as a `namespace` tool (`{ type:"namespace", name, tools:[…] }`). The Responses→Chat translator had no `namespace` branch, so the whole group collapsed into a single empty-schema function named `mcp__<server>__` and every MCP call returned `unsupported call: mcp__<server>__`, breaking all MCP-based workflows (context7, codegraph, custom MCPs) for that combination. The translator now expands a namespace into one Chat function per sub-tool (preserving each sub-tool's name and parameters); an empty namespace yields no tools instead of a broken placeholder. The native Codex passthrough path was already correct. (thanks @V13t4nh)
---

View File

@@ -315,10 +315,33 @@ export function openaiResponsesToOpenAIRequest(
!TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType)
);
})
.map((toolValue) => {
.flatMap((toolValue) => {
const tool = toRecord(toolValue);
if (tool.function) return toolValue;
const toolType = toString(tool.type);
// MCP tool groups: Codex/OpenAI Responses clients declare each MCP server as a
// `namespace` tool — { type:"namespace", name, tools:[{name, description, parameters}] }.
// Non-Codex backends (Kiro/Claude) have no `namespace` type, so flatten each sub-tool
// into a standalone Chat function (#1534). Without this the whole group collapsed into
// one empty-schema function named `mcp__<server>__` and every MCP call failed with
// `unsupported call: mcp__<server>__`.
if (toolType === "namespace") {
const subTools = Array.isArray(tool.tools) ? tool.tools : [];
return subTools
.map((subValue) => toRecord(subValue))
.filter((sub) => toString(sub.name))
.map((sub) => ({
type: "function",
function: {
name: toString(sub.name),
description: toString(sub.description),
parameters: sub.parameters ?? sub.input_schema ?? {
type: "object",
properties: {},
},
},
}));
}
// Pass web_search server tools through with their original type (versioned or plain).
// These have no Chat Completions equivalent; preserve as-is so upstreams that understand
// Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect.

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
// Regression for port-from-9router#1534: when a Codex CLI client routes a
// Responses-API request to a non-Codex backend (e.g. kr/claude-opus-4.7), MCP
// servers are declared as `namespace` tools — { type:"namespace", name, tools:[...] }.
// The Responses→Chat translator had no namespace branch, so each namespace
// collapsed into a single empty-schema function named `mcp__<server>__`, dropping
// every sub-tool. Any MCP call then failed with `unsupported call: mcp__<server>__`.
const { openaiResponsesToOpenAIRequest } = await import(
"../../open-sse/translator/request/openai-responses.ts"
);
test("#1534: namespace MCP tools flatten into one Chat function per sub-tool", () => {
const body = {
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
tools: [
{
type: "namespace",
name: "mcp__ctx7__",
tools: [
{
name: "mcp__ctx7__get_docs",
description: "Get library docs",
parameters: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
},
},
{
name: "mcp__ctx7__search",
description: "Search docs",
parameters: { type: "object", properties: { q: { type: "string" } } },
},
],
},
],
};
const out = openaiResponsesToOpenAIRequest("kr/claude-opus-4.7", body, false, {}) as {
tools: { type: string; function: { name: string; parameters?: unknown } }[];
};
const names = out.tools.map((t) => t.function?.name).sort();
assert.deepEqual(names, ["mcp__ctx7__get_docs", "mcp__ctx7__search"]);
// The empty namespace placeholder must NOT survive.
assert.equal(
out.tools.some((t) => t.function?.name === "mcp__ctx7__"),
false,
"the empty `mcp__ctx7__` namespace placeholder must not be emitted"
);
// Each flattened function keeps its own parameters.
const getDocs = out.tools.find((t) => t.function.name === "mcp__ctx7__get_docs");
assert.ok(getDocs?.function.parameters, "sub-tool parameters must be preserved");
assert.deepEqual((getDocs!.function.parameters as { required?: string[] }).required, ["id"]);
});
test("#1534: an empty namespace (no sub-tools) is dropped, not turned into a broken function", () => {
const body = {
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
tools: [{ type: "namespace", name: "mcp__empty__", tools: [] }],
};
const out = openaiResponsesToOpenAIRequest("kr/claude-opus-4.7", body, false, {}) as {
tools: unknown[];
};
assert.equal(out.tools.length, 0, "an empty namespace yields no tools");
});