ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcp_protocol.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""JSON-RPC 2.0 framing and the MCP method handlers.
4
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.
9"""
10
11from __future__ import annotations
12
13from typing import Any
14
15from mcp_content import (
16 RESOURCE_INDEX,
17 RESOURCES,
18 handle_prompts_get,
19 handle_prompts_list,
20)
21from mcp_tools import TOOL_INDEX, TOOLS
22from mcp_util import log
23
24SERVER_NAME = "ra8-firmware"
25SERVER_VERSION = "1.0.0"
26
27# Newest spec revision this server is known to interoperate with. When a client
28# asks for a specific revision in ``initialize`` we echo theirs back (the spec
29# lets the server propose its own, but echoing avoids needless renegotiation).
30PROTOCOL_VERSION_DEFAULT = "2024-11-05"
31
32# JSON-RPC 2.0 standard error codes used by this server.
33ERR_PARSE = -32700
34ERR_INVALID_REQUEST = -32600
35ERR_METHOD_NOT_FOUND = -32601
36ERR_INVALID_PARAMS = -32602
37ERR_INTERNAL = -32603
38
39
40# ---------------------------------------------------------------------------
41# JSON-RPC dispatch
42# ---------------------------------------------------------------------------
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.
45
46 Args:
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.
51
52 Returns:
53 A response envelope carrying `result`.
54 """
55 return {"jsonrpc": "2.0", "id": request_id, "result": payload}
56
57
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.
60
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.
65
66 Args:
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.
71
72 Returns:
73 A response envelope carrying `error`.
74 """
75 return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
76
77
78def handle_initialize(params: dict[str, Any]) -> dict[str, Any]:
79 """Answer the MCP `initialize` handshake and declare server capabilities.
80
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.
85
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).
89
90 Args:
91 params: JSON-RPC params from the client's initialize request.
92
93 Returns:
94 Protocol version, capability map, and server name/version.
95 """
96 requested = str(params.get("protocolVersion") or PROTOCOL_VERSION_DEFAULT)
97 return {
98 "protocolVersion": requested,
99 "capabilities": {"tools": {}, "resources": {}, "prompts": {}},
100 "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
101 }
102
103
104def handle_tools_list() -> dict[str, Any]:
105 """Answer MCP `tools/list` with the catalogue's client-visible fields.
106
107 Projects each entry to name, description and inputSchema; `handler` is a
108 Python callable and is neither serialisable nor the client's business.
109
110 Returns:
111 `{"tools": [...]}` in TOOLS order.
112 """
113 listed = [
114 {"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
115 for t in TOOLS
116 ]
117 return {"tools": listed}
118
119
120def handle_tools_call(params: dict[str, Any]) -> dict[str, Any]:
121 """Answer MCP `tools/call` by running the named handler.
122
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.
128
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.
134
135 Args:
136 params: JSON-RPC params; "name" selects the tool, "arguments" is
137 forwarded to the handler unvalidated (schema enforcement is the
138 handler's job).
139
140 Returns:
141 `{"content": [{"type": "text", ...}], "isError": bool}`.
142 """
143 name = str(params.get("name", ""))
144 tool = TOOL_INDEX.get(name)
145 if tool is None:
146 return {"content": [{"type": "text", "text": f"unknown tool: {name}"}], "isError": True}
147 arguments = params.get("arguments") or {}
148 try:
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: # noqa: BLE001 -- RPC boundary: every handler error becomes a protocol error
153 log(f"tool '{name}' raised: {exc!r}")
154 return {"content": [{"type": "text", "text": f"tool error: {exc}"}], "isError": True}
155 else:
156 return {"content": [{"type": "text", "text": text}], "isError": False}
157
158
159def handle_resources_list() -> dict[str, Any]:
160 """Answer MCP `resources/list` with the catalogue's descriptive fields.
161
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.
165
166 Returns:
167 `{"resources": [...]}` with uri, name, description and mimeType, in
168 RESOURCES order.
169 """
170 listed = [
171 {
172 "uri": r["uri"],
173 "name": r["name"],
174 "description": r["description"],
175 "mimeType": r["mimeType"],
176 }
177 for r in RESOURCES
178 ]
179 return {"resources": listed}
180
181
182def handle_resources_read(params: dict[str, Any]) -> dict[str, Any]:
183 """Answer MCP `resources/read` by invoking the URI's reader.
184
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.
188
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.
191
192 Args:
193 params: JSON-RPC params; "uri" selects the resource.
194
195 Returns:
196 `{"contents": [{"uri", "mimeType", "text"}]}` -- a single-element list,
197 as this server has no multi-part resources.
198
199 Raises:
200 ValueError: URI is not in the catalogue; `dispatch` turns it into a
201 JSON-RPC error.
202 OSError: The reader could not read its backing file -- a registered
203 resource whose file has been moved or deleted.
204 """
205 uri = str(params.get("uri", ""))
206 resource = RESOURCE_INDEX.get(uri)
207 if resource is None:
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}]}
212
213
214def dispatch(request: dict[str, Any]) -> dict[str, Any] | None: # noqa: PLR0911 # JSON-RPC method router, splitting hurts readability
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 {}
219
220 # Notifications (no id) get no response.
221 if request_id is None and isinstance(method, str) and method.startswith("notifications/"):
222 return None
223
224 try:
225 if method == "initialize":
226 return _result(request_id, handle_initialize(params))
227 if method == "ping":
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: # noqa: BLE001 -- RPC boundary: every handler error becomes a protocol error
247 log(f"dispatch error for {method}: {exc!r}")
248 return _error(request_id, ERR_INTERNAL, str(exc))