@@ -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 )])
9621113async 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