Skip to content

Commit f2ea7e3

Browse files
author
staradigm
committed
Test MCP wire protocol compliance
1 parent e9c089a commit f2ea7e3

2 files changed

Lines changed: 215 additions & 28 deletions

File tree

config/includes.chroot/usr/local/bin/neuros-mcp

Lines changed: 110 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,47 +21,105 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
2121

2222
DEFAULT_PORT = 11436
2323
OLLAMA_URL = "http://localhost:11434/api/generate"
24+
SUPPORTED_PROTOCOL_VERSION = "2024-11-05"
2425

2526

2627
class MCPServer(BaseHTTPRequestHandler):
2728
"""MCP protocol server for NeurOS."""
2829

30+
def _send_error(self, code, message, req_id=None, data=None, status=200):
31+
error = {"code": code, "message": message}
32+
if data is not None:
33+
error["data"] = data
34+
self.send_json({"jsonrpc": "2.0", "error": error, "id": req_id}, status=status)
35+
36+
def _send_no_content(self):
37+
self.send_response(202)
38+
self.send_header("Access-Control-Allow-Origin", "*")
39+
self.send_header("Content-Length", "0")
40+
self.end_headers()
41+
2942
def do_POST(self):
30-
content_length = int(self.headers.get('Content-Length', 0))
43+
content_type = self.headers.get("Content-Type", "")
44+
accept = {value.strip().split(";", 1)[0].lower()
45+
for value in self.headers.get("Accept", "").split(",")}
46+
media_type = content_type.split(";", 1)[0].strip().lower()
47+
if media_type != "application/json":
48+
self._send_error(-32600, "Content-Type must be application/json", status=400)
49+
return
50+
if not {"application/json", "text/event-stream"}.issubset(accept):
51+
self._send_error(-32600,
52+
"Accept must include application/json and text/event-stream",
53+
status=400)
54+
return
55+
56+
try:
57+
content_length = int(self.headers.get("Content-Length", ""))
58+
except ValueError:
59+
self._send_error(-32600, "Content-Length must be a valid integer", status=400)
60+
return
61+
if content_length < 0:
62+
self._send_error(-32600, "Content-Length must not be negative", status=400)
63+
return
3164
body = self.rfile.read(content_length)
3265

3366
try:
3467
request = json.loads(body)
3568
except json.JSONDecodeError:
36-
self.send_error(400, "Invalid JSON")
69+
self._send_error(-32700, "Parse error", status=400)
70+
return
71+
72+
if not isinstance(request, dict):
73+
self._send_error(-32600, "Invalid Request", status=400)
3774
return
3875

39-
method = request.get("method", "")
40-
params = request.get("params", {})
4176
req_id = request.get("id")
77+
if request.get("jsonrpc") != "2.0" or not isinstance(request.get("method"), str):
78+
self._send_error(-32600, "Invalid Request", status=400)
79+
return
80+
if "id" in request and (req_id is None or not isinstance(req_id, (str, int))
81+
or isinstance(req_id, bool)):
82+
self._send_error(-32600, "Invalid Request", status=400)
83+
return
84+
params = request.get("params", {})
85+
if not isinstance(params, dict):
86+
self._send_error(-32602, "Invalid params", req_id=req_id)
87+
return
88+
89+
method = request["method"]
90+
is_notification = "id" not in request
4291

4392
if method == "initialize":
44-
result = self.handle_initialize(params)
93+
result, error = self.handle_initialize(params)
4594
elif method == "tools/list":
46-
result = self.handle_list_tools()
95+
result, error = self.handle_list_tools(), None
4796
elif method == "tools/call":
48-
result = self.handle_call_tool(params)
97+
result, error = self.handle_call_tool(params)
4998
elif method == "resources/list":
50-
result = self.handle_list_resources()
99+
result, error = self.handle_list_resources(), None
51100
elif method == "resources/read":
52-
result = self.handle_read_resource(params)
101+
result, error = self.handle_read_resource(params)
53102
else:
54-
self.send_json({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": req_id})
103+
error = {"code": -32601, "message": "Method not found"}
104+
result = None
105+
106+
if is_notification:
107+
self._send_no_content()
55108
return
56109

57-
self.send_json({"jsonrpc": "2.0", "result": result, "id": req_id})
110+
if error is not None:
111+
self.send_json({"jsonrpc": "2.0", "error": error, "id": req_id})
112+
else:
113+
self.send_json({"jsonrpc": "2.0", "result": result, "id": req_id})
58114

59-
def send_json(self, data):
60-
self.send_response(200)
115+
def send_json(self, data, status=200):
116+
body = json.dumps(data).encode("utf-8")
117+
self.send_response(status)
61118
self.send_header("Content-Type", "application/json")
62119
self.send_header("Access-Control-Allow-Origin", "*")
120+
self.send_header("Content-Length", str(len(body)))
63121
self.end_headers()
64-
self.wfile.write(json.dumps(data).encode('utf-8'))
122+
self.wfile.write(body)
65123

66124
def do_GET(self):
67125
if self.path == "/health":
@@ -81,8 +139,25 @@ class MCPServer(BaseHTTPRequestHandler):
81139
self.end_headers()
82140

83141
def handle_initialize(self, params):
142+
if not isinstance(params.get("protocolVersion"), str):
143+
return None, {"code": -32602, "message": "Invalid params"}
144+
client_info = params.get("clientInfo")
145+
if not isinstance(params.get("capabilities"), dict) or not isinstance(client_info, dict):
146+
return None, {"code": -32602, "message": "Invalid params"}
147+
if not isinstance(client_info.get("name"), str) or not isinstance(client_info.get("version"), str):
148+
return None, {"code": -32602, "message": "Invalid params"}
149+
requested_version = params["protocolVersion"]
150+
if requested_version != SUPPORTED_PROTOCOL_VERSION:
151+
return None, {
152+
"code": -32602,
153+
"message": "Unsupported protocol version",
154+
"data": {
155+
"supported": [SUPPORTED_PROTOCOL_VERSION],
156+
"requested": requested_version,
157+
},
158+
}
84159
return {
85-
"protocolVersion": "2024-11-05",
160+
"protocolVersion": SUPPORTED_PROTOCOL_VERSION,
86161
"serverInfo": {
87162
"name": "NeurOS MCP Server",
88163
"version": "1.0.0"
@@ -91,7 +166,7 @@ class MCPServer(BaseHTTPRequestHandler):
91166
"tools": {},
92167
"resources": {}
93168
}
94-
}
169+
}, None
95170

96171
def handle_list_tools(self):
97172
return {
@@ -163,21 +238,23 @@ class MCPServer(BaseHTTPRequestHandler):
163238
def handle_call_tool(self, params):
164239
tool_name = params.get("name", "")
165240
arguments = params.get("arguments", {})
241+
if not isinstance(tool_name, str) or not isinstance(arguments, dict):
242+
return None, {"code": -32602, "message": "Invalid params"}
166243

167244
if tool_name == "read_file":
168-
return self.tool_read_file(arguments)
245+
return self.tool_read_file(arguments), None
169246
elif tool_name == "list_directory":
170-
return self.tool_list_directory(arguments)
247+
return self.tool_list_directory(arguments), None
171248
elif tool_name == "run_command":
172-
return self.tool_run_command(arguments)
249+
return self.tool_run_command(arguments), None
173250
elif tool_name == "ask_llm":
174-
return self.tool_ask_llm(arguments)
251+
return self.tool_ask_llm(arguments), None
175252
elif tool_name == "get_system_info":
176-
return self.tool_get_system_info(arguments)
253+
return self.tool_get_system_info(arguments), None
177254
elif tool_name == "git_status":
178-
return self.tool_git_status(arguments)
255+
return self.tool_git_status(arguments), None
179256
else:
180-
return {"error": f"Unknown tool: {tool_name}"}
257+
return None, {"code": -32602, "message": f"Unknown tool: {tool_name}"}
181258

182259
def tool_read_file(self, args):
183260
path = os.path.expanduser(args.get("path", ""))
@@ -316,15 +393,21 @@ class MCPServer(BaseHTTPRequestHandler):
316393

317394
def handle_read_resource(self, params):
318395
uri = params.get("uri", "")
396+
if not isinstance(uri, str) or not uri:
397+
return None, {"code": -32602, "message": "Invalid params"}
319398

320399
if uri == "neuros://system/info":
321-
return self.tool_get_system_info({})
400+
return self.tool_get_system_info({}), None
322401
elif uri == "neuros://ollama/models":
323-
return self.tool_ollama_models()
402+
return self.tool_ollama_models(), None
324403
elif uri == "neuros://git/status":
325-
return self.tool_git_status({})
404+
return self.tool_git_status({}), None
326405
else:
327-
return {"contents": [{"uri": uri, "mimeType": "text/plain", "text": "Resource not found"}]}
406+
return None, {
407+
"code": -32002,
408+
"message": "Resource not found",
409+
"data": {"uri": uri},
410+
}
328411

329412
def tool_ollama_models(self):
330413
try:

tests/test_mcp.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"""
66

77
import os
8+
import http.client
9+
import json
10+
import threading
811
import tempfile
912
import unittest
1013
import importlib.util
@@ -66,7 +69,108 @@ def test_run_command_missing_binary(self):
6669

6770
def test_call_tool_unknown_name(self):
6871
result = self.handler.handle_call_tool({"name": "no_such_tool", "arguments": {}})
69-
self.assertIn("error", result)
72+
value, error = result
73+
self.assertIsNone(value)
74+
self.assertEqual(error["code"], -32602)
75+
76+
77+
class TestMCPWireProtocol(unittest.TestCase):
78+
def setUp(self):
79+
self.mcp = load_neuros_mcp()
80+
self.server = self.mcp.HTTPServer(("127.0.0.1", 0), self.mcp.MCPServer)
81+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
82+
self.thread.start()
83+
self.host, self.port = self.server.server_address
84+
85+
def tearDown(self):
86+
self.server.shutdown()
87+
self.server.server_close()
88+
self.thread.join(timeout=2)
89+
90+
def post(self, message, *, accept="application/json, text/event-stream",
91+
content_type="application/json"):
92+
body = json.dumps(message).encode("utf-8") if not isinstance(message, bytes) else message
93+
connection = http.client.HTTPConnection(self.host, self.port, timeout=2)
94+
connection.request("POST", "/", body=body,
95+
headers={"Accept": accept, "Content-Type": content_type})
96+
response = connection.getresponse()
97+
raw_body = response.read()
98+
connection.close()
99+
return response, raw_body
100+
101+
def test_initialize_handshake_and_capabilities_are_wire_valid(self):
102+
response, body = self.post({
103+
"jsonrpc": "2.0",
104+
"id": 7,
105+
"method": "initialize",
106+
"params": {
107+
"protocolVersion": "2024-11-05",
108+
"capabilities": {"roots": {"listChanged": True}},
109+
"clientInfo": {"name": "wire-test", "version": "1.0"},
110+
},
111+
})
112+
message = json.loads(body)
113+
self.assertEqual(response.status, 200)
114+
self.assertEqual(response.getheader("Content-Type"), "application/json")
115+
self.assertEqual(int(response.getheader("Content-Length")), len(body))
116+
self.assertEqual(message["jsonrpc"], "2.0")
117+
self.assertEqual(message["id"], 7)
118+
self.assertEqual(message["result"]["protocolVersion"], "2024-11-05")
119+
self.assertEqual(message["result"]["capabilities"], {"tools": {}, "resources": {}})
120+
self.assertEqual(set(message["result"]["serverInfo"]), {"name", "version"})
121+
122+
def test_initialize_rejects_unsupported_version_with_invalid_params(self):
123+
response, body = self.post({
124+
"jsonrpc": "2.0", "id": "version", "method": "initialize",
125+
"params": {
126+
"protocolVersion": "2099-01-01", "capabilities": {},
127+
"clientInfo": {"name": "wire-test", "version": "1.0"},
128+
},
129+
})
130+
message = json.loads(body)
131+
self.assertEqual(response.status, 200)
132+
self.assertEqual(message["error"]["code"], -32602)
133+
self.assertEqual(message["error"]["data"], {
134+
"supported": ["2024-11-05"], "requested": "2099-01-01"})
135+
136+
def test_json_rpc_error_code_mapping_is_wire_valid(self):
137+
cases = [
138+
(b"not-json", -32700, 400),
139+
({"jsonrpc": "2.0", "id": 1}, -32600, 400),
140+
({"jsonrpc": "2.0", "id": {"bad": "id"}, "method": "tools/list"}, -32600, 400),
141+
({"jsonrpc": "2.0", "id": 2, "method": "no/such"}, -32601, 200),
142+
({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
143+
"params": {"name": "no_such_tool", "arguments": {}}}, -32602, 200),
144+
]
145+
for message, expected_code, expected_status in cases:
146+
with self.subTest(expected_code=expected_code):
147+
response, body = self.post(message)
148+
payload = json.loads(body)
149+
self.assertEqual(response.status, expected_status)
150+
self.assertEqual(payload["jsonrpc"], "2.0")
151+
self.assertEqual(payload["error"]["code"], expected_code)
152+
if expected_code == -32600:
153+
self.assertIsNone(payload["id"])
154+
155+
def test_notification_returns_202_without_json_rpc_body(self):
156+
response, body = self.post({"jsonrpc": "2.0", "method": "notifications/initialized"})
157+
self.assertEqual(response.status, 202)
158+
self.assertEqual(body, b"")
159+
self.assertEqual(response.getheader("Content-Length"), "0")
160+
161+
def test_streamable_http_accept_header_is_required(self):
162+
response, body = self.post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
163+
accept="application/json")
164+
message = json.loads(body)
165+
self.assertEqual(response.status, 400)
166+
self.assertEqual(message["error"]["code"], -32600)
167+
168+
def test_json_content_type_must_be_exact_media_type(self):
169+
response, body = self.post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
170+
content_type="application/jsonp")
171+
message = json.loads(body)
172+
self.assertEqual(response.status, 400)
173+
self.assertEqual(message["error"]["code"], -32600)
70174

71175

72176
if __name__ == "__main__":

0 commit comments

Comments
 (0)