Skip to content

Commit 665f589

Browse files
committed
fix(mcp): Make SSE transport spec-compliant and add Streamable HTTP
The HTTP+SSE transport never worked. The stream emitted only ping events and never `event: message`, while /mcp/messages returned JSON-RPC replies in the POST body. Under the 2024-11-05 transport the client reads replies off the stream, so any conforming client hung after initialize. The generated session id was never stored or looked up either. Sessions are now tracked per worker, replies are delivered over the stream, and POSTs return 202. A session may only be written to by the user who opened it. The announced endpoint is relative so it stays correct behind a reverse proxy. Adds Streamable HTTP on POST /mcp, implemented statelessly so it works across multiple workers, with GET/DELETE answering 405 as the spec allows. protocolVersion is now negotiated against the versions actually supported rather than hardcoded to 2024-11-05, and notifications and JSON-RPC batches are handled properly. Verified against Claude Code over both transports.
1 parent 09aaf5f commit 665f589

2 files changed

Lines changed: 329 additions & 57 deletions

File tree

backend/app/controller/mcp_controller.py

Lines changed: 143 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

33
import json
4-
import time
54
import uuid
5+
from dataclasses import dataclass, field
66
from datetime import datetime
7+
from queue import Empty, Queue
78
from typing import Any, Callable
89

9-
from flask import Blueprint, Response, jsonify, request, stream_with_context
10+
from flask import Blueprint, Response, jsonify, request, url_for
1011
from flask_jwt_extended import current_user, jwt_required
1112

1213
from app import db
@@ -32,14 +33,6 @@
3233
mcp = Blueprint("mcp", __name__)
3334

3435

35-
def _jsonrpc_ok(id_value: Any, result: Any):
36-
return jsonify({"jsonrpc": "2.0", "id": id_value, "result": result})
37-
38-
39-
def _jsonrpc_err(id_value: Any, code: int, message: str):
40-
return jsonify({"jsonrpc": "2.0", "id": id_value, "error": {"code": code, "message": message}})
41-
42-
4336
def _as_tool_result(payload: Any):
4437
text = json.dumps(payload, ensure_ascii=False, default=str)
4538
return {
@@ -839,86 +832,179 @@ def _tool_scrape_recipe(args: dict[str, Any]) -> Any:
839832
}
840833

841834

842-
def _handle_jsonrpc(body: dict[str, Any]):
835+
SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
836+
LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
837+
838+
SERVER_INSTRUCTIONS = (
839+
"KitchenOwl manages households, each of which owns shopping lists, items, "
840+
"recipes, tags, meal plans and expenses. Nearly every tool is scoped to a "
841+
"household, so call list_households first and reuse the id you get back."
842+
)
843+
844+
SSE_KEEPALIVE_SECONDS = 15
845+
846+
847+
def _dispatch(body: Any) -> Any:
848+
# Returns the response to send back, or None for notifications, which the
849+
# protocol requires be left unanswered.
850+
if isinstance(body, list):
851+
responses = [r for r in (_dispatch(item) for item in body) if r is not None]
852+
return responses or None
853+
854+
if not isinstance(body, dict):
855+
return {
856+
"jsonrpc": "2.0",
857+
"id": None,
858+
"error": {"code": -32600, "message": "Invalid Request"},
859+
}
860+
843861
id_value = body.get("id")
844862
method = body.get("method")
845863
params = body.get("params") or {}
864+
is_notification = "id" not in body
846865

847866
try:
848867
if method == "initialize":
849-
return _jsonrpc_ok(
850-
id_value,
851-
{
852-
"protocolVersion": "2024-11-05",
853-
"capabilities": {"tools": {}},
854-
"serverInfo": {"name": "kitchenowl-mcp", "version": str(BACKEND_VERSION)},
855-
},
868+
requested = params.get("protocolVersion")
869+
version = (
870+
requested
871+
if requested in SUPPORTED_PROTOCOL_VERSIONS
872+
else LATEST_PROTOCOL_VERSION
856873
)
857-
858-
if method == "notifications/initialized":
859-
return ("", 204)
860-
861-
if method == "ping":
862-
return _jsonrpc_ok(id_value, {})
863-
864-
if method == "tools/list":
865-
tools = []
866-
for name, (schema, _) in TOOLS.items():
867-
tools.append(
874+
result = {
875+
"protocolVersion": version,
876+
"capabilities": {"tools": {"listChanged": False}},
877+
"serverInfo": {
878+
"name": "kitchenowl-mcp",
879+
"version": str(BACKEND_VERSION),
880+
},
881+
"instructions": SERVER_INSTRUCTIONS,
882+
}
883+
elif method is not None and method.startswith("notifications/"):
884+
return None
885+
elif method == "ping":
886+
result = {}
887+
elif method == "tools/list":
888+
result = {
889+
"tools": [
868890
{
869891
"name": name,
870892
"description": f"KitchenOwl tool: {name}",
871893
"inputSchema": schema,
872894
}
873-
)
874-
return _jsonrpc_ok(id_value, {"tools": tools})
875-
876-
if method == "tools/call":
895+
for name, (schema, _) in TOOLS.items()
896+
]
897+
}
898+
elif method == "tools/call":
877899
name = params.get("name")
878900
args = params.get("arguments") or {}
879901
if name not in TOOLS:
880-
return _jsonrpc_err(id_value, -32601, f"Unknown tool: {name}")
902+
return None if is_notification else _rpc_error(
903+
id_value, -32602, f"Unknown tool: {name}"
904+
)
881905
_, handler = TOOLS[name]
882-
result = handler(args)
906+
result = _as_tool_result(handler(args))
883907
db.session.commit()
884-
return _jsonrpc_ok(id_value, _as_tool_result(result))
885-
886-
return _jsonrpc_err(id_value, -32601, f"Method not found: {method}")
908+
else:
909+
return None if is_notification else _rpc_error(
910+
id_value, -32601, f"Method not found: {method}"
911+
)
887912
except Exception as e:
888913
db.session.rollback()
889-
return _jsonrpc_err(id_value, -32000, str(e))
914+
return None if is_notification else _rpc_error(id_value, -32000, str(e))
915+
916+
return None if is_notification else {"jsonrpc": "2.0", "id": id_value, "result": result}
917+
918+
919+
def _rpc_error(id_value: Any, code: int, message: str) -> dict[str, Any]:
920+
return {"jsonrpc": "2.0", "id": id_value, "error": {"code": code, "message": message}}
921+
922+
923+
# Streamable HTTP. Stateless: no session id is issued, so this transport keeps
924+
# working across multiple uWSGI workers.
925+
926+
927+
@mcp.route("", methods=["POST"])
928+
@jwt_required()
929+
def mcp_post():
930+
response = _dispatch(request.get_json(silent=True))
931+
if response is None:
932+
return "", 202
933+
return jsonify(response)
934+
935+
936+
@mcp.route("", methods=["GET", "DELETE"])
937+
@jwt_required()
938+
def mcp_stream_unsupported():
939+
# Nothing to push outside a request and no session to tear down; the spec
940+
# allows 405 for both.
941+
return Response(status=405, headers={"Allow": "POST"})
942+
943+
944+
# HTTP+SSE. Both halves of a session must be served by the same process, so this
945+
# registry is deliberately per-worker.
946+
947+
948+
@dataclass
949+
class _SseSession:
950+
user_id: int
951+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
952+
outbox: Queue = field(default_factory=Queue)
953+
954+
955+
_sse_sessions: dict[str, _SseSession] = {}
890956

891957

892-
@mcp.route("", methods=["GET"])
893958
@mcp.route("/sse", methods=["GET"])
894959
@jwt_required()
895960
def mcp_sse():
896-
session_id = str(uuid.uuid4())
961+
session = _SseSession(user_id=current_user.id)
962+
_sse_sessions[session.id] = session
963+
964+
# Relative, so it survives a reverse proxy that request.url_root would not.
965+
endpoint = f"{url_for('mcp.mcp_messages')}?session_id={session.id}"
897966

898967
def generate():
899-
endpoint = request.url_root.rstrip("/") + f"/mcp/messages/{session_id}"
900-
yield f"event: endpoint\ndata: {endpoint}\n\n"
901-
while True:
902-
yield "event: ping\ndata: {}\n\n"
903-
time.sleep(15)
968+
try:
969+
yield f"event: endpoint\ndata: {endpoint}\n\n"
970+
while True:
971+
try:
972+
message = session.outbox.get(timeout=SSE_KEEPALIVE_SECONDS)
973+
except Empty:
974+
yield ": keep-alive\n\n"
975+
continue
976+
yield "event: message\ndata: {}\n\n".format(
977+
json.dumps(message, ensure_ascii=False, default=str)
978+
)
979+
finally:
980+
_sse_sessions.pop(session.id, None)
904981

905982
return Response(
906-
stream_with_context(generate()),
983+
generate(),
907984
mimetype="text/event-stream",
908-
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
985+
headers={
986+
"Cache-Control": "no-cache, no-transform",
987+
"Connection": "keep-alive",
988+
"X-Accel-Buffering": "no",
989+
},
909990
)
910991

911992

912-
@mcp.route("", methods=["POST"])
913-
@jwt_required()
914-
def mcp_post():
915-
body = request.get_json(silent=True) or {}
916-
return _handle_jsonrpc(body)
917-
918-
919993
@mcp.route("/messages", methods=["POST"])
920994
@mcp.route("/messages/<session_id>", methods=["POST"])
921995
@jwt_required()
922996
def mcp_messages(session_id: str | None = None):
923-
body = request.get_json(silent=True) or {}
924-
return _handle_jsonrpc(body)
997+
session_id = session_id or request.args.get("session_id")
998+
session = _sse_sessions.get(session_id) if session_id else None
999+
1000+
if session is None:
1001+
return jsonify({"error": "Unknown or expired session"}), 404
1002+
if session.user_id != current_user.id:
1003+
return jsonify({"error": "Session belongs to a different user"}), 403
1004+
1005+
response = _dispatch(request.get_json(silent=True))
1006+
if response is not None:
1007+
session.outbox.put(response)
1008+
1009+
# The reply travels over the SSE stream, not this response.
1010+
return "", 202

0 commit comments

Comments
 (0)