Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(api): LAN/Tailscale dashboard access — `ws:` CSP scheme, GET-exempt version route, surface combo field errors** — three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` allowed the `ws:` scheme only for loopback origins, blocking the dashboard's `ws://<lan-host>:*` Live WebSocket from LAN/Tailscale clients; the bare `ws:` scheme is now permitted (symmetric with the bare `wss:` already allowed), kept declarative in `next.config.mjs` with no global middleware (the project has none by design); (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) — a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message — `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) — thanks @KooshaPari for the diagnosis and original PR #5084)
- **fix(sse): defer `</think>` close so it never leaks before `tool_calls` in Claude→OpenAI streaming** — when a Claude thinking block was followed by a tool_use block, the translator unconditionally emitted a `content: "</think>"` chunk at `content_block_stop`, injecting a spurious assistant text chunk immediately before the `tool_calls` delta and corrupting OpenAI-compatible clients (e.g. Kimi Coding). The close marker is now deferred: it is flushed at the first `text_delta` that follows the thinking block (preserving the #4633 / decolua/9router#454 behavior for Claude Code / Cursor) or at stream finish when no tool_calls were collected. Tool-use streams never get a `text_delta` after the thinking block, so `</think>` is never emitted into content before `tool_calls`. ([#5123](https://github.com/diegosouzapw/OmniRoute/issues/5123))
- **fix(sse): normalize array user-message content in the Command Code executor to prevent upstream 400** — when a client sends a user turn whose `content` is an array of content parts (e.g. `[{type:"text",text:"…"}, …]`), the raw array was forwarded verbatim to the Command Code upstream, which requires `messages[N].content` for the `user` role to be a plain string — resulting in `expected string, received array` / HTTP 400 on DeepSeek V4-Pro and other Command Code models. The user branch of `convertMessages` now calls `normalizeContentText()` (already used by system, assistant, and tool branches) so multi-part user content is joined to a string before dispatch. Partially addresses ([#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166)); the 0-output-token symptom on reasoning-only models is tracked separately.
- **fix(mcp): return HTTP 404 (not 400) for an unknown/expired Streamable HTTP session id** — when an MCP session is terminated or idles out and the client reuses the stale `Mcp-Session-Id` header, the Streamable HTTP transport replied with HTTP 400. The MCP spec (2025-03-26 and 2025-11-25, Session Management) mandates HTTP 404 Not Found in that case, and spec-compliant clients only re-initialize a session on 404 — so the 400 was non-recoverable. The handler now returns 404 for a present-but-unknown session id, while a *missing* session id on a non-initialize request correctly stays 400. ([#5169](https://github.com/diegosouzapw/OmniRoute/issues/5169) — thanks @czer323)

---

Expand Down
6 changes: 5 additions & 1 deletion open-sse/mcp-server/httpTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
if (sessionId) {
const session = _streamableSessions.get(sessionId);
if (!session) {
return errorResponse("Bad Request: Unknown Mcp-Session-Id header", -32000);
// MCP spec (2025-03-26 / 2025-11-25, Session Management): once a session is
// terminated/unknown, the server MUST respond with HTTP 404 Not Found so the
// client re-initializes. A 400 here is non-recoverable for spec-compliant
// clients (they only re-init on 404). See issue #5169.
return errorResponse("Not Found: Unknown Mcp-Session-Id header", -32000, 404);
}

try {
Expand Down
44 changes: 42 additions & 2 deletions tests/unit/mcp-session-sweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,9 @@ test("shutdownMcpHttp removes sessions created via handleMcpStreamableHTTP", asy
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
});
const staleRes = await mod.handleMcpStreamableHTTP(staleReq);
assert.equal(staleRes.status, 400);
// MCP spec (2025-03-26 / 2025-11-25, Session Management): a terminated/unknown
// session id MUST return 404 Not Found so the client re-initializes (issue #5169).
assert.equal(staleRes.status, 404);
}
});

Expand All @@ -302,8 +304,46 @@ test("handleMcpStreamableHTTP rejects request with unknown session id", async ()
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
});
const res = await mod.handleMcpStreamableHTTP(req);
assert.equal(res.status, 400);
// Per MCP spec, a present-but-unknown session id MUST yield 404 (not 400), so
// the client knows to start a fresh session rather than hard-fail (issue #5169).
assert.equal(res.status, 404);
const body = await res.json();
assert.ok(body.error);
assert.ok(body.error.message.includes("Unknown"));
});

// ── Regression #5169: unknown/expired session → HTTP 404 (not 400) ───────────

test("handleMcpStreamableHTTP returns 404 (not 400) for an unknown session id", async () => {
mod.shutdownMcpHttp();
const req = new Request("http://localhost/api/mcp/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"mcp-session-id": "expired-or-unknown-session",
},
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 7 }),
});
const res = await mod.handleMcpStreamableHTTP(req);
// The 400-vs-404 distinction is the whole bug: clients only re-initialize on 404.
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.jsonrpc, "2.0");
assert.equal(body.error.code, -32000);
assert.ok(body.error.message.includes("Mcp-Session-Id"));
});

// ── Guard: a *missing* session id on a non-initialize request stays 400 ───────

test("handleMcpStreamableHTTP keeps 400 for a missing session id (non-initialize)", async () => {
mod.shutdownMcpHttp();
const req = new Request("http://localhost/api/mcp/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 8 }),
});
const res = await mod.handleMcpStreamableHTTP(req);
// Spec reserves 400 for a *missing* session id on non-initialize requests —
// only the *present-but-unknown* case changed to 404. This must NOT regress.
assert.equal(res.status, 400);
});
Loading