Visitar URL original
fix(agent/x/agentmcp): disable standalone SSE for HTTP MCP servers by jeremyruppel · Pull Request #30484 · coder/coder · GitHub
Skip to content

fix(agent/x/agentmcp): disable standalone SSE for HTTP MCP servers - #30484

Open
jeremyruppel wants to merge 2 commits into
mainfrom
fix-agentmcp-http-standalone-sse
Open

jeremyruppel wants to merge 2 commits into
mainfrom
fix-agentmcp-http-standalone-sse

Conversation

@jeremyruppel

@jeremyruppel jeremyruppel commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

tl;dr is this should fix the storybook mcp server in Coder Agents. the storybook mcp never responds to the SSE GET, so it never connects, and reload fails. this disables that SSE GET and should let storybook reconnect on config reload


HTTP workspace MCP servers whose /mcp endpoint never answers the standalone SSE GET (for example, the Storybook dev server) hang the agent's MCP reload indefinitely. The go-sdk opens that GET inside Connect on a context the agent's 30s connect timeout does not bound, and because reloads are singleflighted, every later .mcp.json change waits behind the hung one. The server never shows up as connected or failed, and no MCP config change is picked up until the agent restarts.

Set DisableStandaloneSSE: true on the streamable HTTP transport. The agent's client registers no handlers for server-initiated messages, so the stream is unused; tool call responses and in-request notifications still arrive on the POST.

Investigation and decision log

Symptom: a storybook HTTP server in .mcp.json never produced storybook__* tools in chats, while stdio servers worked.

Findings:

  • Agent log showed the startup connect failing with connection refused (Storybook was not up yet), and a later reload failing with standalone SSE request failed (session ID: ...), meaning initialize had succeeded and the GET stream was the failure point.
  • After touching .mcp.json with Storybook running, no reload result was ever logged (success or warning).
  • A standalone probe using go-sdk v1.8.0 and the same transport config hung in Connect past its 30s timeout. Goroutine dump: Client.Connect -> sessionUpdated -> connectStandaloneSSE -> connectSSE, which uses the connection's lifetime context, not the connect context.
  • curl against Storybook: POST initialize returns a session ID; GET /mcp with that session sends no response headers at all. The spec requires either text/event-stream or 405, so Storybook is non-compliant, but the agent should not wedge on it.
  • With DisableStandaloneSSE: true, the same probe connected in ~7ms and listed all 8 Storybook tools.

Why disable instead of alternatives:

  • mcp.NewClient(..., nil) in manager.go registers no tools/list_changed, sampling, elicitation, roots, or logging handlers, so nothing on the standalone stream is consumed today.
  • The spec makes the standalone GET optional for clients; responses to client requests are never sent on it.
  • Bounding Connect with our own timeout would require abandoning a stuck goroutine and session per bad server, since the hang is inside the SDK call on a context we do not control.
  • Tradeoff: live tools/list_changed refresh would need the stream back, opened after Connect and off the reload path.

Out of scope, noted for follow-up:

  • A server that is down when the agent first connects is not retried until .mcp.json changes.
  • A server that restarts invalidates the session ID, so the next POST fails and nothing reconnects the session. Disabling the standalone stream does not change this.

Generated by Coder Agents on behalf of @jeremyruppel.

The go-sdk opens the standalone SSE GET inside Connect on a context the
connect timeout does not bound. A server that never answers that GET
(for example, the Storybook dev server's /mcp endpoint) hangs Connect
indefinitely, which wedges the shared reload so no workspace MCP server
config change is picked up until the agent restarts.

The manager registers no handlers for server-initiated messages, so the
stream is unused. Tool call responses still arrive on the POST.

Generated by Coder Agents.
@jeremyruppel
jeremyruppel marked this pull request as ready for review October 7, 2026 21:38
@jeremyruppel
jeremyruppel added this pull request to stack #30490 October 7, 2026 22:23
@mafredri

This comment was marked as resolved.

@coder-agents-review

coder-agents-review Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-10-08 10:07 UTC by @mafredri

Review history
  • R1 (2026-10-08): 10 reviewers, 1 Nit, 1 P3, 2 P4, COMMENT. Review

deep-review v0.13.0 | Round 1 | 6db5a36..560e94b

Last posted: Round 1, 4 findings (1 P3, 2 P4, 1 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open manager.go:918 DisableStandaloneSSE safety depends on mcp.NewClient(..., nil) options with no back-reference at the NewClient call R1 Ryosuke, Pariston, Mafuuu, Gon, Zoro (all Note) Yes
CRF-2 P4 Open manager.go:918 Comment does not say connectTimeout fails to bound the hang R1 Gon P4, Leorio P4 Yes
CRF-3 P4 Open manager_internal_test.go:277 Test doc comment restates the name and omits the bug it guards R1 Gon P2 Yes
CRF-4 Nit Open manager_internal_test.go:280 Test name describes a GET the client no longer sends R1 Gon Yes
CRF-5 Nit Dropped by orchestrator (res.client vs result.Tools differ in type; misuse does not compile) manager_internal_test.go:324 res/result naming R1 Gon No
CRF-6 Nit Dropped by orchestrator (subject is accurate and follows type(scope) convention; no repo rule requires symptom framing) 560e94b Commit subject names mechanism, not symptom R1 Leorio No
CRF-7 Note Note (body) manager_internal_test.go:276 Test reaches the GET only because the SDK test server negotiates a protocol below 2026-07-28 R1 Takumi No
CRF-8 Note Note (body) PR description Storybook-restart example: the session still dies on the next POST and nothing reconnects it R1 Pariston No
CRF-9 OOS Out of scope (body) manager.go:922 "sse" transport GET opened on connectCtx is canceled when connectServer returns R1 Melody, Pariston, Mafuuu No
CRF-10 OOS Out of scope (body) coderd/x/chatd/mcpclient/mcpclient.go:692 chatd streamable transport still opens standalone GET R1 Netero, Melody, Pariston, Zoro No
CRF-11 OOS Out of scope (body) aibridge/mcp/proxy_streamable_http.go:43 aibridge streamable transport still opens standalone GET with no outer bound R1 Netero, Melody, Pariston, Zoro No
CRF-12 OOS Out of scope (body) manager.go:574 Server down at first connect is not retried until .mcp.json changes R1 Ryosuke, Pariston, Leorio No
CRF-13 OOS Out of scope (body) manager.go:449 doReload has no deadline of its own R1 Ryosuke No
CRF-14 OOS Out of scope (body) manager.go:853 Manager.Close holds m.mu across per-server Close calls R1 Takumi No
CRF-15 OOS Out of scope (body) go-sdk mcp/streamable.go:2168 SDK opens standalone GET synchronously on the detached connection context R1 Zoro No

Contested and acknowledged

Round log

Round 1

Full panel (round 1). Netero: no findings; Law not run (62 effective additions). Panel: ging-go, ryosuke, takumi, melody (specialists), pariston, mafuuu (core), bisky (tests changed), gon, leorio (once-per-PR floor), zoro (wildcard). 9 panel reviewers plus wildcard exceeds the focused target of 6-8 because the four Go/context/SSE specialists, core, Bisky, and the Gon/Leorio floor are all required. 1 P3, 2 P4, 1 Nit posted; 2 Nits dropped; 2 Notes and 7 out-of-scope items in body. Gon's P2 on the test doc comment downgraded to P4: keep-argument was that a reader of a failing test cannot see why the GET matters; downgraded because the reason belongs in the production comment (CRF-2) and Leorio judged the test comment adequate. Reviewed against 6db5a36..560e94b.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T10:10:14.774594Z 560e94b Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

This comment was marked as resolved.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR sets DisableStandaloneSSE: true on the agent's streamable HTTP MCP transport so a server that never answers the standalone GET cannot hang Connect and the singleflighted reload. The regression test fails with the option set to false (10s RequireReceive: context expired) and passes with it. 1 P3, 2 P4, 1 Nit.

Out of scope (needs a ticket or explicit acceptance by a human):

  • agent/x/agentmcp/manager.go:922: SSEClientTransport.Connect opens its event-stream GET on connectCtx, and defer cancel() in connectServer cancels it on return. A scratch test against mcp.NewSSEHandler connected, then ListTools returned EOF, so every "type": "sse" server in .mcp.json connects and then exposes no working tools.
  • coderd/x/chatd/mcpclient/mcpclient.go:692: chatd's StreamableClientTransport still opens the standalone GET with no handlers registered, so each connect to a Storybook-like server leaves a goroutine and session blocked in Connect.
  • aibridge/mcp/proxy_streamable_http.go:43: same transport setup, and Init has no outer budget around Connect, so a server that never answers the GET can block Init without limit (inferred from the SDK code, not reproduced).
  • agent/x/agentmcp/manager.go:574: a server that is down at the first connect is not retried until .mcp.json changes. The PR description names this as follow-up; no ticket is linked.
  • agent/x/agentmcp/manager.go:449: doReload has no deadline of its own, so any other SDK call that ignores its context hangs every later reload.
  • agent/x/agentmcp/manager.go:853: Manager.Close holds m.mu while closing each server; an HTTP server that ignores the session DELETE stalls Catalog() for up to 5s per server.
  • go-sdk v1.8.0 mcp/streamable.go:2168: connectStandaloneSSE runs synchronously inside Client.Connect on the detached connection context, so Connect ignores the caller's deadline. This is the upstream root cause.

Notes:

  • agent/x/agentmcp/manager_internal_test.go:276: the test reaches the standalone GET only because the go-sdk test server negotiates a protocol below 2026-07-28 (sessionUpdated skips the GET at or above it). After an SDK upgrade that changes the negotiated version, the test would pass with the fix reverted.
  • PR description: the Storybook-restart example for the removed failure mode is incomplete. After a restart the old session ID is gone, so I expect the next POST to fail, and nothing in the agent reconnects a dead session (not verified against Storybook).

🤖 This review was automatically generated with Coder Agents.

Comment thread agent/x/agentmcp/manager.go Outdated
return &mcp.StreamableClientTransport{
Endpoint: cfg.URL,
HTTPClient: httpClientWithHeaders(cfg.Headers),
// Server-initiated messages are unused, and a server that never

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-1] "Server-initiated messages are unused" is true only because connectServer passes nil options to mcp.NewClient (line 890), and nothing at that call points back to this field. (Ryosuke, Pariston, Mafuuu, Gon, Zoro)

If someone adds a ToolListChangedHandler, logging, sampling, or elicitation handler there, it compiles and passes tests, runs for stdio servers, and never fires for HTTP servers. Add a one-line comment at the mcp.NewClient call naming DisableStandaloneSSE as the reason server-initiated handlers will not work over HTTP.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 93b6ea3: added a comment at the mcp.NewClient call pointing at DisableStandaloneSSE.$\n\n> 🤖 Generated by Coder Agents on behalf of @jeremyruppel.

Comment thread agent/x/agentmcp/manager.go Outdated
return &mcp.StreamableClientTransport{
Endpoint: cfg.URL,
HTTPClient: httpClientWithHeaders(cfg.Headers),
// Server-initiated messages are unused, and a server that never

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P4 [CRF-2] "would block Connect" does not say that connectTimeout fails to bound the block, so a reader will assume the worst case is 30s. (Gon, Leorio)

go-sdk v1.8.0 issues the GET with c.connectSSE(c.ctx, ...), where c.ctx is detached from the connect context, so the hang has no limit and stalls every later reload. A maintainer who thinks it costs 30s may remove this option to get tools/list_changed. State that the SDK opens the GET inside Connect on a context connectTimeout does not cancel.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 93b6ea3: the comment now states the GET runs on a context connectTimeout does not cancel, so the hang is unbounded and stalls later reloads.$\n\n> 🤖 Generated by Coder Agents on behalf of @jeremyruppel.

assert.Equal(t, "echo", result.Tools[0].Name)
}

// TestConnectServer_HTTPIgnoresUnansweredStandaloneSSE verifies that an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P4 [CRF-3] The doc comment restates the test name and does not name the bug the test guards against. (Gon)

The sibling TestConnectServer_StdioProcessSurvivesConnect states its bug. Someone seeing this test fail with RequireReceive: context expired needs to know that the SDK's standalone GET is not bounded by connectTimeout. One clause pointing at DisableStandaloneSSE in createTransport is enough.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 93b6ea3: the doc comment names the unbounded standalone GET and points at DisableStandaloneSSE in createTransport.$\n\n> 🤖 Generated by Coder Agents on behalf of @jeremyruppel.

// TestConnectServer_HTTPIgnoresUnansweredStandaloneSSE verifies that an
// HTTP server which never answers the standalone SSE GET does not block
// connect or tool listing.
func TestConnectServer_HTTPIgnoresUnansweredStandaloneSSE(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit [CRF-4] HTTPIgnoresUnansweredStandaloneSSE implies the client sends the GET and tolerates no answer, but with the fix the client never sends it. (Gon)

A name for the asserted behavior, such as TestConnectServer_HTTPConnectsWhenStandaloneSSEHangs, matches what the test checks.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 93b6ea3: renamed to TestConnectServer_HTTPConnectsWhenStandaloneSSEHangs.$\n\n> 🤖 Generated by Coder Agents on behalf of @jeremyruppel.

@jeremyruppel

Copy link
Copy Markdown
Contributor Author

Addressed the R1 inline findings in 93b6ea3.

Notes:

  • CRF-7: the test doc comment now says the GET is sent only below protocol 2026-07-28, so an SDK upgrade could make the test vacuous. I didn't add an assertion for this.
  • CRF-8: dropped the Storybook-restart claim from the description and listed session reconnect after a server restart as a follow-up.

Out of scope (CRF-9 to CRF-15): acknowledged, no changes in this PR.$\n\n> 🤖 Generated by Coder Agents on behalf of @jeremyruppel.

@jeremyruppel

Copy link
Copy Markdown
Contributor Author

my agent also called out CRF-9 as what could be an actual bug. I will keep the chat around so say the word and I'll have the agent implement a fix

This branch has not been deployed

No deployments
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.

2 participants