Skip to content

fix(mcp): preserve SSE frame terminator when filtering tools/list - #484

Open
mprokopov wants to merge 3 commits into
LiteLLM-Labs:mainfrom
mprokopov:fix/mcp-sse-frame-terminator
Open

fix(mcp): preserve SSE frame terminator when filtering tools/list#484
mprokopov wants to merge 3 commits into
LiteLLM-Labs:mainfrom
mprokopov:fix/mcp-sse-frame-terminator

Conversation

@mprokopov

Copy link
Copy Markdown

Fixes #483.

Two related fixes; together they make an MCP server that LAP itself proxies usable from a runtime. They are independent commits and can be split if you'd prefer separate PRs.


1. fix(mcp): preserve SSE frame terminator when filtering tools/list

filter_event_stream_tools rebuilt the event-stream body with text.lines() + join("\n"), which is not round-trip safe for trailing newlines: "a\nb\n\n".lines() yields ["a","b",""], so joining gives "a\nb\n" and drops one newline — the blank line that terminates an SSE event.

Any MCP server with a non-empty allowed_tools therefore returned a tools/list frame that never closed. Real clients hang and report a generic failure (opencode 1.18.9: {"status":"failed","error":"Failed to get tools"}). Servers with an empty allowed_tools were fine, because handle_response streams upstream bytes verbatim on that path — which made the bug look server-specific.

Switched to split_inclusive('\n'), which keeps each line's terminator so blank lines and the trailing newline survive; CRLF is preserved too.

Verified against GitHub's remote MCP server through the proxy with 41 allowed_tools:

bytes last bytes opencode
before 109722 }}\n failed
after 109723 }}\n\n connected

An agent then successfully called github_get_file_contents end to end.

Adds two regression tests: one asserting the terminator survives filtering, one asserting the newline count is unchanged when nothing is filtered out.

2. fix(templates/opencode): let agents reach LAP-hosted MCP servers

Two things blocked an opencode agent from using an MCP server proxied at /{alias}/mcp:

  1. writeMcpConfig emitted only {type,url,enabled} for remote servers, dropping any headers. opencode's remote transport supports a headers object, so there was no way to authenticate.
  2. normalize_mcp_servers retains only {type,name,url} per server entry, so headers set on the agent never reach the runtime — meaning (1) alone can't fix it.

Since the proxy requires a gateway key, the net effect was a guaranteed 401.

This forwards headers when present, and otherwise attaches the gateway key the runtime already holds for model calls — but only when the MCP URL's origin matches LITELLM_BASE_URL, so the key is never sent to a third-party MCP host.

Happy to drop this commit if you'd rather solve it in normalize_mcp_servers by allowing headers through to runtimes; that would be the more general fix, but it changes the SDK contract.


Testing

  • cargo test --lib → 139 passed, 0 failed
  • Manual end-to-end against GitHub's remote MCP server via the proxy (GitHub App installation token), opencode 1.18.9 runtime, model routed through the gateway: MCP status connected, agent invoked a real MCP tool and returned the correct result.

`filter_event_stream_tools` rebuilt the event-stream body with
`text.lines()` + `join("\n")`. That is not round-trip safe for trailing
newlines: `"a\nb\n\n".lines()` yields `["a","b",""]`, so joining produces
`"a\nb\n"` and silently drops one newline — exactly the blank line that
terminates an SSE event.

Effect: any MCP server registered WITH a non-empty `allowed_tools` returned a
`tools/list` frame that never closed. Real MCP clients (opencode 1.18.9, and any
client using a spec-compliant SSE parser) wait forever for the frame and report a
generic failure — opencode surfaces `{"status":"failed","error":"Failed to get
tools"}`. Servers with an empty `allowed_tools` were unaffected, because that
path streams the upstream bytes verbatim.

This was easy to misdiagnose: `curl` appears to work fine, since it just prints
bytes until the connection closes and never needs the terminator. The bug is only
visible byte-wise (`}}\n` instead of `}}\n\n`) or via a real client.

Switch to `split_inclusive('\n')`, which keeps each line's terminator, so blank
lines and the trailing newline survive. CRLF is preserved too.

Verified against GitHub's remote MCP server through LAP's proxy with 41
`allowed_tools` configured:
  - before: 109722 bytes, tail `0a`,   opencode -> "failed"
  - after:  109723 bytes, tail `0a0a`, opencode -> "connected"
and an agent then successfully called `github_get_file_contents` end to end.

Adds two regression tests: one asserting the terminator survives filtering, one
asserting newline count is unchanged when nothing is filtered out.
Two problems stopped an opencode agent from using an MCP server that LAP itself
proxies at `/{alias}/mcp`:

1. `writeMcpConfig` wrote only `{type,url,enabled}` for remote servers, dropping
   any `headers`. opencode's remote transport supports a `headers` object, so
   there was no way to authenticate at all.

2. LAP's `normalize_mcp_servers` retains ONLY `{type,name,url}` on each server
   entry, so headers configured on the agent never survive the trip to the
   runtime — meaning (1) alone cannot fix it.

LAP's MCP proxy requires a gateway key, so the net effect was a guaranteed 401.

Forward `headers` when present, and otherwise attach the gateway key we already
hold for model calls — but ONLY when the MCP URL's origin matches
`LITELLM_BASE_URL`, so the key is never sent to a third-party MCP host.

Verified: agent on gemini-3.6-flash called `github_get_file_contents` through
LAP's proxy against GitHub's remote MCP server.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fede2a01ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread templates/opencode/src/opencode.mjs Outdated
Comment thread templates/opencode/src/opencode.mjs Outdated
…routes

Addresses both Codex review findings on LiteLLM-Labs#484.

**P1 — origin-only check was too permissive.** Comparing `URL.origin` ignores the
path, so a gateway mounted under a prefix on a shared host
(`LITELLM_BASE_URL=https://co.example/litellm/v1`) made every other path on that
host look LAP-owned — including an unrelated `https://co.example/customer-mcp`,
which would then receive the gateway key (possibly the master key).

Now the URL must sit under LAP's own root *and* match one of LAP's actual MCP
routes:

  {root}/{alias}/mcp              — dynamic MCP proxy
  {root}/mcp/platform/{agent_id}  — platform MCPs

Both enforce `require_any_gateway_key`, so both legitimately need the credential;
anything else on the origin no longer gets it. The `/v1` model-API suffix is
stripped from the base to derive the root, so path-mounted gateways still work.

Note: scoping to only `/{alias}/mcp` was initially too narrow — it silently
dropped auth for platform MCPs (`request_human_approval`, `agent_memory`, …),
which live at `/mcp/platform/{agent_id}`. Caught by live verification, hence both
shapes are allowlisted and covered by tests.

**P2 — a non-auth header suppressed the fallback.** The guard tested whether
`headers` was non-empty, so an entry carrying only e.g. `x-tenant` skipped the
credential and LAP answered 401. It now checks specifically for an existing
`Authorization` or `x-api-key` header (case-insensitive), and an explicit caller
auth header is still never overwritten.

Adds 9 tests to templates/opencode/test/opencode.test.mjs covering: LAP proxy and
platform routes get the key; same-origin non-LAP path does not; other origins
never do; path-mounted gateway still matches; query strings are ignored; non-auth
headers coexist with the injected credential; explicit `Authorization` and
lowercase `x-api-key` are preserved.

Verified: `node --test test/opencode.test.mjs` → 11 passed, 0 failed; and live
against a rebuilt runtime both `github` and `platform` MCP entries receive the
credential while non-LAP URLs receive none.
@mprokopov

Copy link
Copy Markdown
Author

Both Codex findings addressed in ffcfa79d — thanks, the P1 one was a genuine credential-leak vector.

P1 (origin too permissive): the gateway-key fallback is now scoped to LAP's actual MCP routes rather than its origin — {root}/{alias}/mcp and {root}/mcp/platform/{agent_id}, both of which enforce require_any_gateway_key. A same-host but unrelated path such as /customer-mcp no longer receives the key.

P2 (non-auth header suppressed the fallback): the guard now looks for an existing Authorization/x-api-key header specifically, instead of treating any header as sufficient.

One thing worth surfacing, because it argues for the tests: scoping to only /{alias}/mcp was too narrow and silently dropped auth for platform MCPs at /mcp/platform/{agent_id} (request_human_approval, agent_memory, …). Live verification caught it; both route shapes are now allowlisted with coverage.

Verification

  • node --test test/opencode.test.mjs11 passed, 0 failed (9 new)
  • cargo test --lib139 passed, 0 failed (SSE fix unchanged)
  • Live, rebuilt runtime: both github and platform MCP entries receive the credential; non-LAP URLs receive none

Note on unrelated CI

templates/opencode/test/app.test.mjs has 5 failing tests. These are pre-existing on main and not from this PR — verified by checking out origin/main's opencode.mjs/opencode.test.mjs and re-running: same 5 failures. Happy to open a separate issue if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP proxy drops the SSE frame terminator when allowed_tools is set, breaking tools/list for real clients

1 participant