Phase 2F-D2a — MCP JSON/SSE Content Negotiation Implementation Report (2026-05-14)
Phase 2F-D2a — MCP JSON/SSE Content Negotiation — Implementation Report
Date: 2026-05-14
Status: PASS — deployed to VPS, committed.
Commit: 34ca724 on main of /opt/incomex/docker/agent-data-repo.
Design reference: knowledge/current-state/reports/phase-2f-c-rev2-mcp-production-patch-design-2026-05-14.md S3
Prior phase: D1 19315ce (list_documents SQL pushdown) — regression intact.
1. Code changes summary
Single-file change: agent_data/server.py (+207 / -28 lines).
New helpers
| Function | Purpose |
|---|---|
_parse_accept(header) |
Parses an Accept header into (wants_json, wants_sse, q_json, q_sse, any_valid_media). Tolerant of */*, application/*, text/*, bare *, missing q-values, malformed input. Never raises. |
_wants_sse(request) |
Returns True iff the client explicitly prefers SSE over JSON for this response. Tie-breaks to JSON when q-values are equal. |
_accept_is_unsupported(request) |
Returns True iff the Accept advertises one or more real media types yet none are application/json or text/event-stream. Malformed Accept that yields no valid media at all is NOT treated as unsupported — it falls back to JSON. |
_mcp_respond(request, payload, status_code=200) |
Serialises a JSON-RPC envelope as either JSONResponse (default) or a single-event SSE Response with Content-Type: text/event-stream; charset=utf-8, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no. |
Endpoints touched
mcp_jsonrpc (/mcp) and _mcp_filtered_handler (used by /mcp-readonly, /mcp-gpt, /mcp-gpt-full): every return site that delivers a JSON-RPC envelope now routes through _mcp_respond(request, payload). The two exceptions kept JSON:
- API-key 401 — kept as plain
JSONResponseto avoid leaking SSE framing to misconfigured clients before authentication succeeds. notifications/*— kept asResponse(status_code=204)regardless of Accept (no body to deliver under either media type, per spec).
GET /mcp (info endpoint) — added an Accept guard: if the client sends Accept: text/event-stream and does NOT also accept JSON, return 405 Method Not Allowed with Allow: POST and a JSON-RPC error envelope. Otherwise unchanged.
Unsupported-media 406
Each POST handler additionally returns 406 Not Acceptable if Accept advertises any real media type yet none are JSON or SSE (e.g. application/xml). Malformed Accept like ;;;malformed falls back to JSON, not 406.
2. Exact Accept negotiation rule
parse Accept
if absent / empty -> wants_json=true (default)
if no valid media token at all -> wants_json=true (default; treat as malformed)
for each "type/subtype" token with q > 0:
application/json | */* | application/* -> wants_json, q_json
text/event-stream | text/* -> wants_sse, q_sse
decision
if neither wanted, but any valid media token seen -> HTTP 406
if not wants_sse -> JSON
if wants_sse and not wants_json -> SSE
if both, q_sse > q_json -> SSE
if both, q_json >= q_sse -> JSON (tie-break)
This rule keeps every existing client byte-identical:
curl -H 'Accept: application/json'-> JSON OK- FastMCP / Claude.ai sending
Accept: application/json, text/event-stream-> JSON (tie-break) OK - A client that strictly wants SSE (
Accept: text/event-stream) -> SSE OK - A client weighting SSE higher (
Accept: application/json;q=0.5, text/event-stream;q=1) -> SSE OK
3. SSE response format
A request that resolves to SSE receives exactly one event, then the connection closes:
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
X-Accel-Buffering: no
event: message
data: <single-line JSON-RPC envelope>
\n
No keep-alive pings, no session-id (Mcp-Session-Id), no Last-Event-ID. Those are D2b scope. For request/response operations, one event is sufficient.
4. Internal Accept matrix — test results
24 / 24 PASS, run from inside incomex-agent-data against http://127.0.0.1:8000.
| # | Request | Expected | Result |
|---|---|---|---|
| U1 | Accept: application/json POST /mcp-gpt tools/list |
200 JSON | PASS |
| U2 | Accept: application/json, text/event-stream |
200 JSON (tie-break) | PASS |
| U3 | Accept: text/event-stream |
200 SSE w/ event: message frame |
PASS |
| U4 | Accept: application/json;q=0.5, text/event-stream;q=1 |
200 SSE | PASS |
| U5 | Accept: application/json;q=1, text/event-stream;q=0.5 |
200 JSON | PASS |
| U6 | no Accept | 200 JSON | PASS |
| U7 | Accept: ;;;malformed |
200 JSON fallback | PASS |
| U8 | Accept: */* |
200 JSON | PASS |
| U9 | Accept: application/xml |
406 | PASS |
| U10a-d | tools/list count: /mcp=11, /mcp-readonly=5, /mcp-gpt=8, /mcp-gpt-full=11 |
exact counts | PASS x4 |
| U11 | notifications/initialized w/ JSON Accept |
204, empty body | PASS |
| U12 | notifications/initialized w/ SSE Accept |
204, empty body | PASS |
| U13 | malformed JSON body w/ JSON Accept | JSON-RPC -32700 parse error |
PASS |
| U14 | malformed JSON body w/ SSE Accept | SSE-framed parse error | PASS |
| U15 | tools/call list_documents over SSE | valid frame, items returned | PASS |
| U16 | GET /mcp w/ SSE Accept | 405 + Allow: POST |
PASS |
| U17 | GET /mcp w/ JSON Accept | 200 JSON info | PASS |
| U18 | GET /mcp no Accept | 200 JSON info | PASS |
| U19 | /mcp-gpt-full SSE Accept | 200 SSE | PASS |
| U20 | /mcp internal SSE Accept | 200 SSE | PASS |
| U21 | SSE response headers | Cache-Control: no-cache, no-transform, X-Accel-Buffering: no |
PASS |
5. Public route sanity (HTTPS via nginx)
Tested through the existing nginx /gpt-mcp/{token}/mcp location (which proxies to /mcp-gpt). The location already had proxy_buffering off; chunked_transfer_encoding on; proxy_cache off; — no nginx change required.
| Case | Result |
|---|---|
HTTPS POST Accept: application/json |
200 application/json; charset=utf-8, 8 tools |
HTTPS POST Accept: text/event-stream |
200 text/event-stream; charset=utf-8, valid event: message + data: frame, 8 tools inside |
HTTPS POST Accept: application/json, text/event-stream |
200 application/json; charset=utf-8 (tie-break) |
Secret token never printed.
6. FastMCP client — before / after
FastMCP 2.10.5 was already present in the container. The client uses Streamable HTTP transport and sends Accept: application/json, text/event-stream (tie-break case -> JSON in our rule).
| Result | |
|---|---|
| initialize | OK |
| list_tools | OK — 8 tools returned |
call_tool list_documents |
OK — items returned |
call_tool get_document |
OK |
| "Unexpected content type" warnings | One warning remains, but only on the 204 No Content reply to notifications/initialized. The empty Content-Type for a 204 is per HTTP spec; the FastMCP/mcp-python client logs an ERROR-level message for it but continues normally — the subsequent JSON-RPC calls succeed. |
Before D2a, the same FastMCP version produced "Unexpected content type" for every JSON-RPC response when the client expected SSE in Streamable HTTP mode (per Rev2 S3.1 framing). After D2a, the warning is reduced to the single 204-without-body case, which is benign and outside our control. Functional impact: zero — full conversation works. Any future fix to that quirk lives in the mcp-python library, not in our server.
7. Regression — D1 still passes
After the D2a deploy, re-ran the D1 functional + perf harness against the new binary:
- 16 / 16 D1 functional checks PASS (tools/list counts, prefix variants, escape, pagination, caps, synonym, soft-delete exclusion, get/batch/search regression).
list_documents path=knowledge/ limit=50 c=5 n=50: p50 = 40.2 ms, p95 = 102.6 ms, max = 114.8 ms — within the 250 ms D1 target.
No tool latency degraded.
8. Commit
- Hash:
34ca724 - Branch:
main - Message:
phase2f-d2a: add MCP JSON/SSE content negotiation - Co-author trailer:
Claude Opus 4.7 (1M context) - Repo:
/opt/incomex/docker/agent-data-repo(still 5 ahead, 112 behind origin/main per project convention).
9. Logs / resource snapshot
- No ERROR / Exception / Traceback in container logs since restart.
- CPU 1.0 %, memory 1.4 GiB / 2.5 GiB on
incomex-agent-data. /healthreports all three dependencies (qdrant,postgres,openai) OK.- No nginx reload performed.
10. Rollback
ssh root@38.242.240.89
cd /opt/incomex/docker/agent-data-repo
git revert 34ca724
cd /opt/incomex/docker
docker compose build agent-data && docker compose up -d agent-data
No DB rollback. No nginx rollback.
11. Risks / notes
- R1 (acknowledged). D2a is production-compliance, NOT proven to fix ChatGPT MCP App hang. Per Rev2 S3.1 we ship it for spec correctness and SSE-strict clients; we explicitly do not advertise it as the ChatGPT unblocker. Whether ChatGPT now works is a separate test to be run only after a deliberate decision (see S12).
- R2 (benign). FastMCP/mcp-python
streamable_httpclient still logs ONE "Unexpected content type" error per session on the 204 notifications/initialized reply. Non-fatal; the conversation completes normally. Library quirk, not a server bug. Documented in S6. - R3 (auth always JSON). API-key 401 stays plain JSON; no SSE for unauthenticated requests. Intentional.
- R4 (q parsing). Quality factors outside
[0, 1]are clamped to 1.0 if parse fails, but float values outside that range are accepted as-is. Edge case for adversarial clients; not a security concern. - R5 (no streaming Tools/call yet). Long-running tools still produce a single SSE frame at the end. Progress events / server-pushed notifications are D2b.
12. Recommendation — D3 first or ChatGPT retry?
Run D3 first, then retry ChatGPT.
D3 (remove move_document from MCP_TOOLS / MCP_GPT_FULL_TOOLS / dispatcher) is a 4-line patch with zero functional risk. Doing it before the ChatGPT retest gives the LLM a clean tools list and prevents Tool not allowed audit noise during the test.
Concrete sequence:
- D3 — schema cleanup, ~5 minute change + 6 tests, separate commit.
- ChatGPT MCP App retest — fresh conversation, hit
/gpt-mcp/{token}/mcp, attempt search + list + patch onknowledge/test/.... Observe whether the patch UI hang persists. - If hang persists -> file a separate diagnosis ticket; D2a was production-compliance, not the silver bullet.
- If hang resolves -> ship D4 canary (still gated separately per Rev2 S5).
D4 canary remains parked until C1..C7 evidence is collected.
Appendix — Diff stat
agent_data/server.py | 235 +++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 207 insertions(+), 28 deletions(-)
End of report. No nginx change. No DB change. No restart of postgres/qdrant. No secret rotated.