|
| 1 | +"""The Model Context Protocol over Streamable HTTP, as /mcp speaks it: |
| 2 | +JSON-RPC, one message per POST, every answer a single JSON object |
| 3 | +(never an SSE stream), and no sessions. Tools are all it offers. |
| 4 | +
|
| 5 | +Written here rather than with the MCP Python SDK: the SDK is ASGI and |
| 6 | +this server is WSGI (Flask on waitress), and without streams or |
| 7 | +sessions the protocol is small. |
| 8 | +
|
| 9 | +Two eras of the protocol are served on the one endpoint (the spec's |
| 10 | +"dual-era" server): |
| 11 | +
|
| 12 | +- Modern (2026-07-28): no handshake. Each request names its version in |
| 13 | + params._meta and in the MCP-Protocol-Version header, which must agree, |
| 14 | + as must the Mcp-Method and Mcp-Name headers with the body. Results |
| 15 | + carry resultType and the server's identity in _meta. |
| 16 | +- Legacy (2025-03-26 to 2025-11-25): an initialize request picks the |
| 17 | + version, and later requests name it in the MCP-Protocol-Version header. |
| 18 | + As no session is kept, a request is served by its header alone. |
| 19 | +
|
| 20 | +handle() takes the parsed body and the headers and returns (HTTP |
| 21 | +status, JSON body or None); routes/mcp.py does the HTTP and the auth.""" |
| 22 | + |
| 23 | +import base64 |
| 24 | +import binascii |
| 25 | +import json |
| 26 | + |
| 27 | +from gcm import config |
| 28 | +from gcm.mcp import tools |
| 29 | + |
| 30 | +MODERN_VERSIONS = ["2026-07-28"] |
| 31 | +LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"] |
| 32 | +SUPPORTED_VERSIONS = MODERN_VERSIONS + LEGACY_VERSIONS |
| 33 | + |
| 34 | +_META_VERSION = "io.modelcontextprotocol/protocolVersion" |
| 35 | +_META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" |
| 36 | + |
| 37 | +PARSE_ERROR = -32700 |
| 38 | +INVALID_REQUEST = -32600 |
| 39 | +METHOD_NOT_FOUND = -32601 |
| 40 | +INVALID_PARAMS = -32602 |
| 41 | +HEADER_MISMATCH = -32020 |
| 42 | +UNSUPPORTED_VERSION = -32022 |
| 43 | + |
| 44 | +INSTRUCTIONS = ( |
| 45 | + "This server is a Gregtech: New Horizons (Minecraft modpack) base's monitor. It reads the base's " |
| 46 | + "AE2 ME network, crafting CPUs and power storage, as reported by OpenComputers scripts in the game. " |
| 47 | + "Start with search_items to find an item's key; the other item tools take that key, never a name. " |
| 48 | + "Amounts are items, or mB (millibuckets, the game's litres) for fluids. Data is as fresh as the " |
| 49 | + "game's last report: say when it is old." |
| 50 | +) |
| 51 | + |
| 52 | + |
| 53 | +def server_info(): |
| 54 | + return {"name": "gtnh-craft-monitor", "title": "GTNH Craft Monitor", "version": config.SERVER_VERSION or "dev"} |
| 55 | + |
| 56 | + |
| 57 | +def error(msg_id, code, message, data=None): |
| 58 | + error = {"code": code, "message": message} |
| 59 | + if data is not None: |
| 60 | + error["data"] = data |
| 61 | + return {"jsonrpc": "2.0", "id": msg_id, "error": error} |
| 62 | + |
| 63 | + |
| 64 | +def _result(msg_id, result): |
| 65 | + return {"jsonrpc": "2.0", "id": msg_id, "result": result} |
| 66 | + |
| 67 | + |
| 68 | +def _decode_header(value): |
| 69 | + """A header value with the spec's =?base64?...?= encoding undone.""" |
| 70 | + if value is not None and value.startswith("=?base64?") and value.endswith("?="): |
| 71 | + try: |
| 72 | + return base64.b64decode(value[9:-2], validate=True).decode("utf-8") |
| 73 | + except (binascii.Error, UnicodeDecodeError): |
| 74 | + return None |
| 75 | + return value |
| 76 | + |
| 77 | + |
| 78 | +def _unsupported(msg_id, requested): |
| 79 | + return 400, error( |
| 80 | + msg_id, UNSUPPORTED_VERSION, "Unsupported protocol version", |
| 81 | + {"supported": SUPPORTED_VERSIONS, "requested": requested}, |
| 82 | + ) |
| 83 | + |
| 84 | + |
| 85 | +def handle(message, headers, user): |
| 86 | + """Answers one JSON-RPC message for a signed-in user.""" |
| 87 | + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str): |
| 88 | + if isinstance(message, list): |
| 89 | + return 400, error(None, INVALID_REQUEST, "Batches aren't supported: send one message per request") |
| 90 | + return 400, error(None, INVALID_REQUEST, "Not a JSON-RPC 2.0 request") |
| 91 | + method = message["method"] |
| 92 | + params = message.get("params") |
| 93 | + if params is None: |
| 94 | + params = {} |
| 95 | + if not isinstance(params, dict): |
| 96 | + return 400, error(message.get("id"), INVALID_PARAMS, "params must be an object") |
| 97 | + |
| 98 | + if "id" not in message: |
| 99 | + # Notifications (the legacy notifications/initialized and |
| 100 | + # notifications/cancelled): nothing to do for any of them. |
| 101 | + return 202, None |
| 102 | + msg_id = message["id"] |
| 103 | + |
| 104 | + meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} |
| 105 | + header_version = headers.get("MCP-Protocol-Version") |
| 106 | + |
| 107 | + if method == "initialize": |
| 108 | + requested = params.get("protocolVersion") |
| 109 | + version = requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0] |
| 110 | + return 200, _result(msg_id, { |
| 111 | + "protocolVersion": version, |
| 112 | + "capabilities": {"tools": {"listChanged": False}}, |
| 113 | + "serverInfo": server_info(), |
| 114 | + "instructions": INSTRUCTIONS, |
| 115 | + }) |
| 116 | + |
| 117 | + if _META_VERSION in meta: |
| 118 | + version = meta[_META_VERSION] |
| 119 | + if header_version is None: |
| 120 | + return 400, error(msg_id, HEADER_MISMATCH, "Header mismatch: MCP-Protocol-Version header is missing") |
| 121 | + if header_version != version: |
| 122 | + return 400, error( |
| 123 | + msg_id, HEADER_MISMATCH, |
| 124 | + f"Header mismatch: MCP-Protocol-Version header value '{header_version}' does not match body value '{version}'", |
| 125 | + ) |
| 126 | + if version not in MODERN_VERSIONS: |
| 127 | + return _unsupported(msg_id, version) |
| 128 | + mismatch = _header_mismatch(method, params, headers) |
| 129 | + if mismatch: |
| 130 | + return 400, error(msg_id, HEADER_MISMATCH, mismatch) |
| 131 | + modern = True |
| 132 | + else: |
| 133 | + # Legacy: the version initialize settled on, in the header; a |
| 134 | + # 2025-03-26 client didn't send one. |
| 135 | + version = header_version or "2025-03-26" |
| 136 | + if version not in LEGACY_VERSIONS: |
| 137 | + return _unsupported(msg_id, version) |
| 138 | + modern = False |
| 139 | + |
| 140 | + if method == "server/discover": |
| 141 | + return 200, _modern_result(msg_id, { |
| 142 | + "supportedVersions": SUPPORTED_VERSIONS, |
| 143 | + "capabilities": {"tools": {}}, |
| 144 | + "instructions": INSTRUCTIONS, |
| 145 | + "ttlMs": 3600000, |
| 146 | + "cacheScope": "public", |
| 147 | + }) |
| 148 | + if method == "ping": |
| 149 | + result = {} |
| 150 | + elif method == "tools/list": |
| 151 | + result = {"tools": tools.listing()} |
| 152 | + if modern: |
| 153 | + # The same list for everyone, but per-user once there are |
| 154 | + # tools only some roles may use. |
| 155 | + result.update({"ttlMs": 3600000, "cacheScope": "private"}) |
| 156 | + elif method == "tools/call": |
| 157 | + name = params.get("name") |
| 158 | + if not isinstance(name, str): |
| 159 | + return 200, error(msg_id, INVALID_PARAMS, "params.name must be a tool name") |
| 160 | + if not tools.exists(name): |
| 161 | + return 200, error(msg_id, INVALID_PARAMS, f"Unknown tool: {name}") |
| 162 | + answer, is_error = tools.call(name, params.get("arguments"), user) |
| 163 | + result = {"content": [{"type": "text", "text": _compact_json(answer)}], "isError": is_error} |
| 164 | + else: |
| 165 | + return (404 if modern else 200), error(msg_id, METHOD_NOT_FOUND, f"Method not found: {method}") |
| 166 | + return 200, (_modern_result(msg_id, result) if modern else _result(msg_id, result)) |
| 167 | + |
| 168 | + |
| 169 | +def _modern_result(msg_id, result): |
| 170 | + return _result(msg_id, {"resultType": "complete", **result, "_meta": {_META_SERVER_INFO: server_info()}}) |
| 171 | + |
| 172 | + |
| 173 | +def _header_mismatch(method, params, headers): |
| 174 | + """Why the modern request headers don't agree with the body, or None.""" |
| 175 | + header_method = headers.get("Mcp-Method") |
| 176 | + if header_method is None: |
| 177 | + return "Header mismatch: Mcp-Method header is missing" |
| 178 | + if header_method != method: |
| 179 | + return f"Header mismatch: Mcp-Method header value '{header_method}' does not match body value '{method}'" |
| 180 | + if method == "tools/call": |
| 181 | + raw = headers.get("Mcp-Name") |
| 182 | + if raw is None: |
| 183 | + return "Header mismatch: Mcp-Name header is missing" |
| 184 | + name = _decode_header(raw) |
| 185 | + if name != params.get("name"): |
| 186 | + return f"Header mismatch: Mcp-Name header value '{raw}' does not match body value '{params.get('name')}'" |
| 187 | + return None |
| 188 | + |
| 189 | + |
| 190 | +def _compact_json(value): |
| 191 | + if isinstance(value, str): |
| 192 | + return value |
| 193 | + return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str) |
0 commit comments