Skip to content

Commit 5c8bbe4

Browse files
fix: make MCP endpoint JSON-RPC compatible
1 parent 0289536 commit 5c8bbe4

4 files changed

Lines changed: 333 additions & 35 deletions

File tree

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,29 @@ non-loopback deployment must set `KYROZEN_SERVER_TOKEN` and send it as
481481
and dynamic tools. Authentication, capability tokens, and command safety checks
482482
still apply.
483483

484+
The MCP endpoint supports `initialize`, `notifications/initialized`, `ping`,
485+
`server/discover`, `tools/list`, `tools/call`, and the legacy `chat/send`
486+
method. Responses echo the JSON-RPC request `id`. `tools/list` returns an
487+
`inputSchema` for every exposed tool. Tools retain their internal plain-string
488+
contracts while MCP object arguments are mapped explicitly; for example:
489+
490+
```bash
491+
curl -sS http://127.0.0.1:8000/mcp \
492+
-H 'Content-Type: application/json' \
493+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
494+
curl -sS http://127.0.0.1:8000/mcp \
495+
-H 'Content-Type: application/json' \
496+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
497+
curl -sS http://127.0.0.1:8000/mcp \
498+
-H 'Content-Type: application/json' \
499+
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"README.md"}}}'
500+
```
501+
502+
Protocol errors use JSON-RPC error objects. Tool failures use a successful
503+
JSON-RPC response whose result contains `isError: true`; capability-denied
504+
and unknown tools remain protocol errors. Write, shell, network, destructive,
505+
and dynamic tools continue to require their configured MCP capabilities.
506+
484507
The Web/MCP server is intentionally a **single-user deployment**. One
485508
`KYROZEN_SERVER_TOKEN` represents the one owner of that server's private
486509
memories, tasks, events, schedules, and learning state; request JSON cannot

‎server.py‎

Lines changed: 216 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -958,64 +958,245 @@ async def api_health():
958958
# MCP (Model Context Protocol) endpoint
959959
# ---------------------------------------------------------------------------
960960

961+
_MCP_PROTOCOL_VERSION = "2025-06-18"
962+
_MCP_SUPPORTED_PROTOCOL_VERSIONS = {"2024-11-05", "2025-06-18"}
963+
_MCP_SERVER_INFO = {"name": "openkyrozen", "version": app.version}
964+
965+
966+
def _mcp_response(request_id: Any, *, result: Any = None, error: dict[str, Any] | None = None) -> dict[str, Any]:
967+
response: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
968+
if error is not None:
969+
response["error"] = error
970+
else:
971+
response["result"] = result
972+
return response
973+
974+
975+
def _mcp_error(request_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]:
976+
error: dict[str, Any] = {"code": code, "message": message}
977+
if data is not None:
978+
error["data"] = data
979+
return _mcp_response(request_id, error=error)
980+
981+
982+
def _mcp_tool_schema(name: str, fn: Any) -> dict[str, Any]:
983+
"""Return the object schema for a tool's existing string contract."""
984+
schemas: dict[str, dict[str, Any]] = {
985+
"read_file": {
986+
"properties": {"path": {"type": "string"}}, "required": ["path"],
987+
},
988+
"write_file": {
989+
"properties": {
990+
"path": {"type": "string"},
991+
"content": {"type": "string"},
992+
}, "required": ["path", "content"],
993+
},
994+
"list_dir": {"properties": {"path": {"type": "string"}}},
995+
"list_tree": {"properties": {"path": {"type": "string"}}},
996+
"find_files": {
997+
"properties": {
998+
"pattern": {"type": "string"},
999+
"directory": {"type": "string"},
1000+
}, "required": ["pattern"],
1001+
},
1002+
"run_cmd": {"properties": {"command": {"type": "string"}}, "required": ["command"]},
1003+
"execute_terminal_command": {
1004+
"properties": {"command": {"type": "string"}}, "required": ["command"],
1005+
},
1006+
"search_web": {"properties": {"query": {"type": "string"}}, "required": ["query"]},
1007+
"read_webpage": {"properties": {"url": {"type": "string"}}, "required": ["url"]},
1008+
"git_clone": {
1009+
"properties": {
1010+
"url": {"type": "string"},
1011+
"destination": {"type": "string"},
1012+
}, "required": ["url"],
1013+
},
1014+
"analyze_remote_repo": {"properties": {"url": {"type": "string"}}, "required": ["url"]},
1015+
"browser_open": {"properties": {"url": {"type": "string"}}, "required": ["url"]},
1016+
"browser_snapshot": {"properties": {"session_id": {"type": "string"}}, "required": ["session_id"]},
1017+
"browser_close": {"properties": {"session_id": {"type": "string"}}, "required": ["session_id"]},
1018+
"browser_click": {
1019+
"properties": {
1020+
"session_id": {"type": "string"},
1021+
"selector": {"type": "string"},
1022+
}, "required": ["session_id", "selector"],
1023+
},
1024+
"browser_type": {
1025+
"properties": {
1026+
"session_id": {"type": "string"},
1027+
"selector": {"type": "string"},
1028+
"text": {"type": "string"},
1029+
}, "required": ["session_id", "selector", "text"],
1030+
},
1031+
}
1032+
schema = copy.deepcopy(schemas.get(name, {
1033+
"properties": {"args": {"type": "string"}},
1034+
}))
1035+
schema["type"] = "object"
1036+
schema["additionalProperties"] = False
1037+
if name not in schemas:
1038+
schema["description"] = "Plain-string arguments can be supplied as the `args` property."
1039+
return schema
1040+
1041+
1042+
def _mcp_tool_descriptors(allowed: set[str]) -> list[dict[str, Any]]:
1043+
return [
1044+
{
1045+
"name": name,
1046+
"description": (getattr(fn, "__doc__", "") or "").strip().split("\n")[0],
1047+
"inputSchema": _mcp_tool_schema(name, fn),
1048+
}
1049+
for name, fn in _agent.AVAILABLE_TOOLS.items()
1050+
if name in allowed
1051+
]
1052+
1053+
1054+
def _mcp_string_arguments(tool_name: str, arguments: Any) -> str:
1055+
"""Map MCP object arguments to the tool's documented pipe/string format."""
1056+
if arguments is None:
1057+
return ""
1058+
if isinstance(arguments, str):
1059+
return arguments
1060+
if not isinstance(arguments, dict):
1061+
raise ValueError("arguments must be an object or plain string")
1062+
if not arguments:
1063+
return ""
1064+
if set(arguments) == {"args"}:
1065+
if not isinstance(arguments["args"], str):
1066+
raise ValueError("arguments.args must be a string")
1067+
return arguments["args"]
1068+
1069+
def value(*names: str, required: bool = True) -> str:
1070+
present = next((name for name in names if name in arguments), None)
1071+
if present is None:
1072+
if required:
1073+
raise ValueError(f"missing required argument: {names[0]}")
1074+
return ""
1075+
if not isinstance(arguments[present], str):
1076+
raise ValueError(f"argument '{present}' must be a string")
1077+
return arguments[present]
1078+
1079+
if tool_name == "read_file":
1080+
return value("path", "file_path")
1081+
if tool_name == "write_file":
1082+
return f"{value('path', 'file_path')}|{value('content', 'text')}"
1083+
if tool_name in {"list_dir", "list_tree"}:
1084+
return value("path", required=False)
1085+
if tool_name == "find_files":
1086+
pattern = value("pattern")
1087+
directory = value("directory", required=False)
1088+
return f"{pattern}|{directory}" if directory else pattern
1089+
if tool_name in {"run_cmd", "execute_terminal_command"}:
1090+
return value("command", "cmd")
1091+
if tool_name == "search_web":
1092+
return value("query")
1093+
if tool_name in {"read_webpage", "browser_open", "analyze_remote_repo"}:
1094+
return value("url")
1095+
if tool_name == "git_clone":
1096+
url = value("url")
1097+
destination = value("destination", required=False)
1098+
return f"{url}|{destination}" if destination else url
1099+
if tool_name in {"browser_snapshot", "browser_close"}:
1100+
return value("session_id", "sessionId")
1101+
if tool_name == "browser_click":
1102+
return f"{value('session_id', 'sessionId')}|{value('selector')}"
1103+
if tool_name == "browser_type":
1104+
return f"{value('session_id', 'sessionId')}|{value('selector')}|{value('text')}"
1105+
1106+
# Every legacy tool has a plain-string contract. Requiring the explicit
1107+
# `args` wrapper keeps ambiguous object shapes from silently changing the
1108+
# command sent to a tool.
1109+
raise ValueError(f"tool '{tool_name}' accepts object arguments only as {{'args': '<string>'}}")
1110+
1111+
9611112
@app.post("/mcp", dependencies=[Depends(require_api_access)])
9621113
async def mcp_endpoint(request: Request):
9631114
"""MCP-compatible endpoint for AI tool interoperability."""
9641115
try:
9651116
body = await request.json()
9661117
except Exception:
967-
raise HTTPException(400, "Invalid JSON")
1118+
return _mcp_error(None, -32700, "Parse error")
9681119
if not isinstance(body, dict):
969-
raise HTTPException(400, "JSON object required")
970-
method = body.get("method", "")
1120+
return _mcp_error(None, -32600, "Invalid Request")
1121+
request_id = body.get("id")
1122+
if "id" in body and isinstance(request_id, (dict, list, bool)):
1123+
request_id = None
1124+
if body.get("jsonrpc") not in (None, "2.0") or not isinstance(body.get("method"), str):
1125+
return _mcp_error(request_id, -32600, "Invalid Request")
1126+
method = body["method"]
9711127
params = body.get("params", {})
9721128
if not isinstance(params, dict):
973-
raise HTTPException(400, "params object required")
1129+
return _mcp_error(request_id, -32602, "Invalid params: params must be an object")
9741130

1131+
if method == "initialize":
1132+
requested_version = params.get("protocolVersion")
1133+
selected_version = (
1134+
requested_version if requested_version in _MCP_SUPPORTED_PROTOCOL_VERSIONS
1135+
else _MCP_PROTOCOL_VERSION
1136+
)
1137+
return _mcp_response(request_id, result={
1138+
"protocolVersion": selected_version,
1139+
"capabilities": {"tools": {"listChanged": False}},
1140+
"serverInfo": _MCP_SERVER_INFO,
1141+
})
1142+
if method in {"notifications/initialized", "ping"}:
1143+
return _mcp_response(request_id, result={})
1144+
if method == "server/discover":
1145+
allowed = _allowed_server_tools("mcp")
1146+
return _mcp_response(request_id, result={
1147+
"protocolVersion": _MCP_PROTOCOL_VERSION,
1148+
"serverInfo": _MCP_SERVER_INFO,
1149+
"capabilities": {"tools": {"listChanged": False}},
1150+
"tools": _mcp_tool_descriptors(allowed),
1151+
})
9751152
if method == "tools/list":
9761153
allowed = _allowed_server_tools("mcp")
977-
return {
978-
"jsonrpc": "2.0",
979-
"result": {
980-
"tools": [
981-
{"name": name, "description": (fn.__doc__ or "").strip().split("\n")[0]}
982-
for name, fn in _agent.AVAILABLE_TOOLS.items()
983-
if name in allowed
984-
]
985-
}
986-
}
987-
elif method == "tools/call":
1154+
return _mcp_response(request_id, result={"tools": _mcp_tool_descriptors(allowed)})
1155+
if method == "tools/call":
9881156
tool_name = params.get("name", "")
9891157
tool_args = params.get("arguments", "")
1158+
if not isinstance(tool_name, str) or not tool_name:
1159+
return _mcp_error(request_id, -32602, "Invalid params: tool name is required")
9901160
fn = _agent.AVAILABLE_TOOLS.get(tool_name)
9911161
if fn is None:
992-
return {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Tool '{tool_name}' not found"}}
1162+
return _mcp_error(request_id, -32601, f"Tool '{tool_name}' not found")
9931163
if tool_name not in _allowed_server_tools("mcp"):
994-
return {
995-
"jsonrpc": "2.0",
996-
"error": {
997-
"code": -32001,
998-
"message": (
999-
f"Tool requires '{tool_capability(tool_name)}' capability; "
1000-
"set KYROZEN_MCP_CAPABILITIES or use the full profile to enable it"
1001-
),
1002-
},
1003-
}
1164+
return _mcp_error(request_id, -32001, (
1165+
f"Tool requires '{tool_capability(tool_name)}' capability; "
1166+
"set KYROZEN_MCP_CAPABILITIES or use the full profile to enable it"
1167+
))
1168+
try:
1169+
string_args = _mcp_string_arguments(tool_name, tool_args)
1170+
except (TypeError, ValueError) as e:
1171+
return _mcp_error(request_id, -32602, f"Invalid params: {e}")
10041172
try:
1005-
result = fn(str(tool_args))
1006-
return {"jsonrpc": "2.0", "result": {"content": [{"type": "text", "text": str(result)}]}}
1173+
previous_token = _agent._execution_capability_token
1174+
_agent._execution_capability_token = issue_capability_token(
1175+
"surface:mcp", _server_capabilities("mcp"), ttl_seconds=300,
1176+
)
1177+
try:
1178+
result = _agent._run_tool(tool_name, string_args)
1179+
finally:
1180+
_agent._execution_capability_token = previous_token
1181+
result_text = str(result)
10071182
except Exception as e:
1008-
return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}}
1009-
elif method == "chat/send":
1010-
msg = _validate_message(_sanitize_api_message(str(params.get("message", "")).strip()))
1183+
result_text = f"Error: {e}"
1184+
return _mcp_response(request_id, result={
1185+
"content": [{"type": "text", "text": result_text}],
1186+
"isError": _agent._is_tool_error(result_text),
1187+
})
1188+
if method == "chat/send":
1189+
try:
1190+
msg = _validate_message(_sanitize_api_message(str(params.get("message", "")).strip()))
1191+
except HTTPException as exc:
1192+
return _mcp_error(request_id, -32602, str(exc.detail))
10111193
if not msg:
1012-
raise HTTPException(400, "Empty message")
1194+
return _mcp_error(request_id, -32602, "Empty message")
10131195
session_id = _normalise_session_id(params.get("session_id"))
10141196
session = _get_or_create_session(session_id, _SERVER_ACTOR_ID)
10151197
reply = _run_session_chat(session, msg)
1016-
return {"jsonrpc": "2.0", "result": {"content": reply, "session_id": session_id}}
1017-
else:
1018-
return {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Unknown method: {method}"}}
1198+
return _mcp_response(request_id, result={"content": reply, "session_id": session_id})
1199+
return _mcp_error(request_id, -32601, f"Unknown method: {method}")
10191200

10201201

10211202
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)