Skip to content

Commit c7a15f9

Browse files
authored
fix(mcp): make the SSE transport usable (#1121)
1 parent d38cd96 commit c7a15f9

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)