|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import json |
4 | | -import time |
5 | 4 | import uuid |
| 5 | +from dataclasses import dataclass, field |
6 | 6 | from datetime import datetime |
| 7 | +from queue import Empty, Queue |
7 | 8 | from typing import Any, Callable |
8 | 9 |
|
9 | | -from flask import Blueprint, Response, jsonify, request, stream_with_context |
| 10 | +from flask import Blueprint, Response, jsonify, request, url_for |
10 | 11 | from flask_jwt_extended import current_user, jwt_required |
11 | 12 |
|
12 | 13 | from app import db |
|
32 | 33 | mcp = Blueprint("mcp", __name__) |
33 | 34 |
|
34 | 35 |
|
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 | | - |
43 | 36 | def _as_tool_result(payload: Any): |
44 | 37 | text = json.dumps(payload, ensure_ascii=False, default=str) |
45 | 38 | return { |
@@ -839,86 +832,179 @@ def _tool_scrape_recipe(args: dict[str, Any]) -> Any: |
839 | 832 | } |
840 | 833 |
|
841 | 834 |
|
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 | + |
843 | 861 | id_value = body.get("id") |
844 | 862 | method = body.get("method") |
845 | 863 | params = body.get("params") or {} |
| 864 | + is_notification = "id" not in body |
846 | 865 |
|
847 | 866 | try: |
848 | 867 | 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 |
856 | 873 | ) |
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": [ |
868 | 890 | { |
869 | 891 | "name": name, |
870 | 892 | "description": f"KitchenOwl tool: {name}", |
871 | 893 | "inputSchema": schema, |
872 | 894 | } |
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": |
877 | 899 | name = params.get("name") |
878 | 900 | args = params.get("arguments") or {} |
879 | 901 | 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 | + ) |
881 | 905 | _, handler = TOOLS[name] |
882 | | - result = handler(args) |
| 906 | + result = _as_tool_result(handler(args)) |
883 | 907 | 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 | + ) |
887 | 912 | except Exception as e: |
888 | 913 | 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] = {} |
890 | 956 |
|
891 | 957 |
|
892 | | -@mcp.route("", methods=["GET"]) |
893 | 958 | @mcp.route("/sse", methods=["GET"]) |
894 | 959 | @jwt_required() |
895 | 960 | 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}" |
897 | 966 |
|
898 | 967 | 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) |
904 | 981 |
|
905 | 982 | return Response( |
906 | | - stream_with_context(generate()), |
| 983 | + generate(), |
907 | 984 | 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 | + }, |
909 | 990 | ) |
910 | 991 |
|
911 | 992 |
|
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 | | - |
919 | 993 | @mcp.route("/messages", methods=["POST"]) |
920 | 994 | @mcp.route("/messages/<session_id>", methods=["POST"]) |
921 | 995 | @jwt_required() |
922 | 996 | 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