3"""JSON-RPC 2.0 framing and the MCP method handlers.
5The transport-and-envelope half of the server: build a result, build an error,
6and route one request to the handler for its method. Kept apart from the
7tools, resources and prompts it dispatches to so that a protocol change and a
8capability change cannot be made in the same edit by accident.
11from __future__
import annotations
15from mcp_content
import (
21from mcp_tools
import TOOL_INDEX, TOOLS
22from mcp_util
import log
24SERVER_NAME =
"ra8-firmware"
25SERVER_VERSION =
"1.0.0"
30PROTOCOL_VERSION_DEFAULT =
"2024-11-05"
34ERR_INVALID_REQUEST = -32600
35ERR_METHOD_NOT_FOUND = -32601
36ERR_INVALID_PARAMS = -32602
43def _result(request_id: str | int |
None, payload: dict[str, Any]) -> dict[str, Any]:
44 """Wrap `payload` as a JSON-RPC 2.0 success response.
47 request_id: The originating request's id, echoed verbatim -- the client
48 matches responses by it, so it must not be normalised. JSON-RPC 2.0
49 permits a string, a number or null, and nothing else.
50 payload: The method's result object.
53 A response envelope carrying `result`.
55 return {
"jsonrpc":
"2.0",
"id": request_id,
"result": payload}
58def _error(request_id: str | int |
None, code: int, message: str) -> dict[str, Any]:
59 """Wrap a failure as a JSON-RPC 2.0 error response.
61 This is protocol-level failure -- an unknown method, malformed JSON. A tool
62 that runs and fails is NOT this: `handle_tools_call` returns a successful
63 response carrying `isError: true`, so the model sees the failure text
64 instead of the transport swallowing it.
67 request_id: The originating request's id, echoed verbatim. None when
68 the request was unparseable enough to have no id.
69 code: JSON-RPC error code (-32601 unknown method, -32603 internal).
70 message: Human-readable text; reaches the client as-is.
73 A response envelope carrying `error`.
75 return {
"jsonrpc":
"2.0",
"id": request_id,
"error": {
"code": code,
"message": message}}
78def handle_initialize(params: dict[str, Any]) -> dict[str, Any]:
79 """Answer the MCP `initialize` handshake and declare server capabilities.
81 Echoes the client's requested `protocolVersion` back rather than asserting
82 the server's own, which keeps a client on a newer spec revision from being
83 refused over a version string alone. The fallback applies only when the
84 client omits the field or sends an empty one.
86 All three capability objects are advertised empty: this server supports
87 tools, resources and prompts but none of their optional sub-features (no
88 list-changed notifications, no subscriptions).
91 params: JSON-RPC params from the client's initialize request.
94 Protocol version, capability map, and server name/version.
96 requested = str(params.get(
"protocolVersion")
or PROTOCOL_VERSION_DEFAULT)
98 "protocolVersion": requested,
99 "capabilities": {
"tools": {},
"resources": {},
"prompts": {}},
100 "serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION},
104def handle_tools_list() -> dict[str, Any]:
105 """Answer MCP `tools/list` with the catalogue's client-visible fields.
107 Projects each entry to name, description and inputSchema; `handler` is a
108 Python callable and is neither serialisable nor the client's business.
111 `{"tools": [...]}` in TOOLS order.
114 {
"name": t[
"name"],
"description": t[
"description"],
"inputSchema": t[
"inputSchema"]}
117 return {
"tools": listed}
120def handle_tools_call(params: dict[str, Any]) -> dict[str, Any]:
121 """Answer MCP `tools/call` by running the named handler.
123 Every failure here is reported as a SUCCESSFUL JSON-RPC response carrying
124 `isError: true`, never as a protocol error. That is the MCP contract for
125 tool failure and it is what puts the message in front of the model: a
126 transport-level error would be handled by the client and the model would
127 only see that something went wrong, not what.
129 Unknown tool, argument rejection (ValueError) and any other handler
130 exception are therefore all caught. The catch-all is a deliberate RPC
131 boundary -- an unhandled exception would otherwise kill the stdio loop and
132 take the whole session down over one bad tool call -- and it logs the repr
133 to stderr so the traceback-worthy detail is not lost.
136 params: JSON-RPC params; "name" selects the tool, "arguments" is
137 forwarded to the handler unvalidated (schema enforcement is the
141 `{"content": [{"type": "text", ...}], "isError": bool}`.
143 name = str(params.get(
"name",
""))
144 tool = TOOL_INDEX.get(name)
146 return {
"content": [{
"type":
"text",
"text": f
"unknown tool: {name}"}],
"isError":
True}
147 arguments = params.get(
"arguments")
or {}
149 text = tool[
"handler"](arguments)
150 except ValueError
as exc:
151 return {
"content": [{
"type":
"text",
"text": f
"invalid request: {exc}"}],
"isError":
True}
152 except Exception
as exc:
153 log(f
"tool '{name}' raised: {exc!r}")
154 return {
"content": [{
"type":
"text",
"text": f
"tool error: {exc}"}],
"isError":
True}
156 return {
"content": [{
"type":
"text",
"text": text}],
"isError":
False}
159def handle_resources_list() -> dict[str, Any]:
160 """Answer MCP `resources/list` with the catalogue's descriptive fields.
162 Projects out `reader`, the callable that actually loads each resource's
163 text. Listing is therefore cheap and touches no files -- content is only
164 read when the client asks for a specific URI.
167 `{"resources": [...]}` with uri, name, description and mimeType, in
174 "description": r[
"description"],
175 "mimeType": r[
"mimeType"],
179 return {
"resources": listed}
182def handle_resources_read(params: dict[str, Any]) -> dict[str, Any]:
183 """Answer MCP `resources/read` by invoking the URI's reader.
185 Lookup is exact-match against the registered URI; there is no prefix or
186 glob matching, so a client cannot reach a file the catalogue does not name.
187 That is the read boundary for this server.
189 The reader runs on every call with no caching, so the client always sees
190 the file's current contents rather than a snapshot from server start.
193 params: JSON-RPC params; "uri" selects the resource.
196 `{"contents": [{"uri", "mimeType", "text"}]}` -- a single-element list,
197 as this server has no multi-part resources.
200 ValueError: URI is not in the catalogue; `dispatch` turns it into a
202 OSError: The reader could not read its backing file -- a registered
203 resource whose file has been moved or deleted.
205 uri = str(params.get(
"uri",
""))
206 resource = RESOURCE_INDEX.get(uri)
208 msg = f
"unknown resource uri: {uri}"
209 raise ValueError(msg)
210 text = resource[
"reader"]()
211 return {
"contents": [{
"uri": uri,
"mimeType": resource[
"mimeType"],
"text": text}]}
214def dispatch(request: dict[str, Any]) -> dict[str, Any] |
None:
215 """Route one JSON-RPC request. Returns a response, or None for notifications."""
216 method = request.get(
"method")
217 request_id = request.get(
"id")
218 params = request.get(
"params")
or {}
221 if request_id
is None and isinstance(method, str)
and method.startswith(
"notifications/"):
225 if method ==
"initialize":
226 return _result(request_id, handle_initialize(params))
228 return _result(request_id, {})
229 if method ==
"tools/list":
230 return _result(request_id, handle_tools_list())
231 if method ==
"tools/call":
232 return _result(request_id, handle_tools_call(params))
233 if method ==
"resources/list":
234 return _result(request_id, handle_resources_list())
235 if method ==
"resources/read":
236 return _result(request_id, handle_resources_read(params))
237 if method ==
"prompts/list":
238 return _result(request_id, handle_prompts_list())
239 if method ==
"prompts/get":
240 return _result(request_id, handle_prompts_get(params))
241 if method ==
"resources/templates/list":
242 return _result(request_id, {
"resourceTemplates": []})
243 return _error(request_id, ERR_METHOD_NOT_FOUND, f
"method not found: {method}")
244 except ValueError
as exc:
245 return _error(request_id, ERR_INVALID_PARAMS, str(exc))
246 except Exception
as exc:
247 log(f
"dispatch error for {method}: {exc!r}")
248 return _error(request_id, ERR_INTERNAL, str(exc))