Skip to content

vMCP does not follow standard MCP Streamable HTTP behavior#3624

Open
juancarlosm wants to merge 2 commits intostacklok:mainfrom
juancarlosm:main
Open

vMCP does not follow standard MCP Streamable HTTP behavior#3624
juancarlosm wants to merge 2 commits intostacklok:mainfrom
juancarlosm:main

Conversation

@juancarlosm
Copy link

@juancarlosm juancarlosm commented Feb 5, 2026

Added:

  • GET without Accept: text/event-stream returns HTTP 406 with a JSON-RPC error
  • GET with Accept: text/event-stream opens a streaming connection with periodic keepalive pings

fixes: #3623

Tested:

curl http://vmcp-search.mcp.svc.cluster.local:4483
{"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Not Acceptable: Client must accept text/event-stream"}}

curl http://vmcp-search.mcp.svc.cluster.local:4483 -H "Accept: text/event-stream"
event: message
data: {"jsonrpc":"2.0","id":1,"method":"ping"}

juancarlosm and others added 2 commits February 5, 2026 15:48
- Reject GET requests missing Accept: text/event-stream with HTTP 406
  and JSON-RPC error, avoiding the 15s discovery timeout on invalid requests
- Enable SDK heartbeat pings (30s interval) to prevent proxies and load
  balancers from closing idle streaming connections
- Remove WriteTimeout from http.Server since it kills long-lived
  streaming connections after 30s

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions github-actions bot added the size/XS Extra small PR: < 100 lines changed label Feb 5, 2026
@codecov
Copy link

codecov bot commented Feb 5, 2026

Codecov Report

❌ Patch coverage is 41.66667% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.70%. Comparing base (c3549f6) to head (1ce7d3f).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/server/server.go 41.66% 6 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3624      +/-   ##
==========================================
- Coverage   65.71%   65.70%   -0.02%     
==========================================
  Files         410      410              
  Lines       40624    40632       +8     
==========================================
- Hits        26697    26696       -1     
- Misses      11846    11853       +7     
- Partials     2081     2083       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@jerm-dro jerm-dro left a comment

Choose a reason for hiding this comment

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

Thanks @juancarlosm for clearly documenting this issue and contributing the fix 🙏

I left a few blocking comments in the review. Two high level comments:

  1. It'd be great to add a test for this. This can be tested on a server with pretty minimal config (e.g. like here).
  2. The streamableServer which acts as our MCP server comes from this repo: https://github.com/mark3labs/mcp-go. Do you think it would make more sense to make the change there? I imagine other servers that build upon the sdk also have bugs like this. From my perspective, an MCP server sdk ought to validate headers conform to the spec, but I'm curious what you think.

Thanks again for contributing 😄

Comment on lines +444 to +445
//nolint:errcheck // Best-effort write of error response body
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Not Acceptable: Client must accept text/event-stream"}}`))
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: let's log the error instead of ignoring it entirely.

Comment on lines +438 to +439
// Accept header validation is the innermost wrapper so invalid GETs are rejected
// immediately without triggering discovery timeout or middleware overhead.
Copy link
Contributor

Choose a reason for hiding this comment

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

Blocker: The comment contradicts the code. As the code is written, this validation will actually execute last, because the middlewares below wrap it.

The comment is correct this is the innermost, but we actually want it to be the outermost, so it can fail requests immediately.

Copy link
Contributor

Choose a reason for hiding this comment

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

The comment above re: code wrapping and execution order also appears incorrect. The actual execution order is:

// Execution order: recovery → auth → audit → discovery → backend enrichment → telemetry → handler

Let's remove the statement about "Code wraps," since the ordering of wrapping is clearly documented below. The statement about execution order is good to keep, since it documents our intention.

//
// Accept header validation is the innermost wrapper so invalid GETs are rejected
// immediately without triggering discovery timeout or middleware overhead.
var mcpHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: can you pull this out into a headerValidatingMiddleware function?

This server.Start code already has a lot going on. It's fine to keep the new function in this file.

}

// acceptsEventStream checks whether the request's Accept header includes text/event-stream.
func acceptsEventStream(r *http.Request) bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: delete this thin function and inline it in the proposed headerValidatingMiddleware.

WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
// WriteTimeout is intentionally omitted. SSE connections are long-lived
Copy link
Contributor

Choose a reason for hiding this comment

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

Question, Blocking: can you write a test to confirm this needs to be removed? From my reading of the WriteTimeout, this is actually how long it takes to respond to each request on the stream.

// WriteTimeout is the maximum duration before timing out
// writes of the response. It is reset whenever a new
// request's header is read. Like ReadTimeout, it does not
// let Handlers make decisions on a per-request basis.
// A zero or negative value means there will be no timeout.

In other words, I expect a session can live for more than 30s if each request on the stream is responded to in less than 30s.

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

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] vMCP does not follow standard MCP Streamable HTTP behavior

2 participants