sunholo 0.142.0__py3-none-any.whl → 0.143.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@ import random
6
6
  from functools import partial
7
7
  import inspect
8
8
  import asyncio
9
+ from typing import Dict, List, Optional, Callable, Any
10
+
9
11
 
10
12
  from ..chat_history import extract_chat_history_with_cache, extract_chat_history_async_cached
11
13
  from ...qna.parsers import parse_output
@@ -29,6 +31,26 @@ try:
29
31
  except ImportError:
30
32
  PubSubManager = None
31
33
 
34
+ try:
35
+ from ...mcp.mcp_manager import MCPClientManager
36
+ except ImportError:
37
+ MCPClientManager = None
38
+
39
+ try:
40
+ from ...mcp.vac_mcp_server import VACMCPServer
41
+ from mcp.server.models import InitializationOptions
42
+ from mcp import JSONRPCMessage, ErrorData, INTERNAL_ERROR
43
+ except ImportError:
44
+ VACMCPServer = None
45
+ InitializationOptions = None
46
+ JSONRPCMessage = None
47
+
48
+ try:
49
+ from ...a2a.vac_a2a_agent import VACA2AAgent
50
+ except (ImportError, SyntaxError):
51
+ VACA2AAgent = None
52
+
53
+
32
54
  # Cache dictionary to store validated API keys
33
55
  api_key_cache = {}
34
56
  cache_duration = timedelta(minutes=5) # Cache duration
@@ -61,11 +83,41 @@ if __name__ == "__main__":
61
83
  stream_interpreter: callable,
62
84
  vac_interpreter:callable=None,
63
85
  additional_routes:dict=None,
86
+ mcp_servers: List[Dict[str, Any]] = None,
64
87
  async_stream:bool=False,
65
- add_langfuse_eval:bool=True):
88
+ add_langfuse_eval:bool=True,
89
+ enable_mcp_server:bool=False,
90
+ enable_a2a_agent:bool=False,
91
+ a2a_vac_names: List[str] = None):
66
92
  self.app = app
67
93
  self.stream_interpreter = stream_interpreter
68
94
  self.vac_interpreter = vac_interpreter or partial(self.vac_interpreter_default)
95
+
96
+ # MCP client initialization
97
+ self.mcp_servers = mcp_servers or []
98
+ self.mcp_client_manager = MCPClientManager()
99
+ # Initialize MCP connections
100
+ if self.mcp_servers and self.mcp_client_manager:
101
+ asyncio.create_task(self._initialize_mcp_servers())
102
+
103
+ # MCP server initialization
104
+ self.enable_mcp_server = enable_mcp_server
105
+ self.vac_mcp_server = None
106
+ if self.enable_mcp_server and VACMCPServer:
107
+ self.vac_mcp_server = VACMCPServer(
108
+ stream_interpreter=self.stream_interpreter,
109
+ vac_interpreter=self.vac_interpreter
110
+ )
111
+
112
+ # A2A agent initialization
113
+ self.enable_a2a_agent = enable_a2a_agent
114
+ self.vac_a2a_agent = None
115
+ self.a2a_vac_names = a2a_vac_names
116
+ if self.enable_a2a_agent and VACA2AAgent:
117
+ # Extract base URL from request context during route handling
118
+ # For now, initialize with placeholder - will be updated in route handlers
119
+ self.vac_a2a_agent = None # Initialized lazily in route handlers
120
+
69
121
  self.additional_routes = additional_routes if additional_routes is not None else []
70
122
  self.async_stream = async_stream
71
123
  self.add_langfuse_eval = add_langfuse_eval
@@ -129,6 +181,27 @@ if __name__ == "__main__":
129
181
  # OpenAI compatible endpoint
130
182
  self.app.route('/openai/v1/chat/completions', methods=['POST'])(self.handle_openai_compatible_endpoint)
131
183
  self.app.route('/openai/v1/chat/completions/<vector_name>', methods=['POST'])(self.handle_openai_compatible_endpoint)
184
+
185
+ # MCP client routes
186
+ if self.mcp_servers:
187
+ self.app.route('/mcp/tools', methods=['GET'])(self.handle_mcp_list_tools)
188
+ self.app.route('/mcp/tools/<server_name>', methods=['GET'])(self.handle_mcp_list_tools)
189
+ self.app.route('/mcp/call', methods=['POST'])(self.handle_mcp_call_tool)
190
+ self.app.route('/mcp/resources', methods=['GET'])(self.handle_mcp_list_resources)
191
+ self.app.route('/mcp/resources/read', methods=['POST'])(self.handle_mcp_read_resource)
192
+
193
+ # MCP server endpoint
194
+ if self.enable_mcp_server and self.vac_mcp_server:
195
+ self.app.route('/mcp', methods=['POST', 'GET'])(self.handle_mcp_server)
196
+
197
+ # A2A agent endpoints
198
+ if self.enable_a2a_agent:
199
+ self.app.route('/.well-known/agent.json', methods=['GET'])(self.handle_a2a_agent_card)
200
+ self.app.route('/a2a/tasks/send', methods=['POST'])(self.handle_a2a_task_send)
201
+ self.app.route('/a2a/tasks/sendSubscribe', methods=['POST'])(self.handle_a2a_task_send_subscribe)
202
+ self.app.route('/a2a/tasks/get', methods=['POST'])(self.handle_a2a_task_get)
203
+ self.app.route('/a2a/tasks/cancel', methods=['POST'])(self.handle_a2a_task_cancel)
204
+ self.app.route('/a2a/tasks/pushNotification/set', methods=['POST'])(self.handle_a2a_push_notification)
132
205
 
133
206
  self.register_additional_routes()
134
207
 
@@ -803,3 +876,474 @@ if __name__ == "__main__":
803
876
  except Exception as e:
804
877
  raise Exception(f'File upload failed: {str(e)}')
805
878
 
879
+ async def _initialize_mcp_servers(self):
880
+ """Initialize connections to configured MCP servers."""
881
+ for server_config in self.mcp_servers:
882
+ try:
883
+ await self.mcp_client_manager.connect_to_server(
884
+ server_name=server_config["name"],
885
+ command=server_config["command"],
886
+ args=server_config.get("args", [])
887
+ )
888
+ log.info(f"Connected to MCP server: {server_config['name']}")
889
+ except Exception as e:
890
+ log.error(f"Failed to connect to MCP server {server_config['name']}: {e}")
891
+
892
+
893
+ def handle_mcp_list_tools(self, server_name: Optional[str] = None):
894
+ """List available MCP tools."""
895
+ async def get_tools():
896
+ tools = await self.mcp_client_manager.list_tools(server_name)
897
+ return [
898
+ {
899
+ "name": tool.name,
900
+ "description": tool.description,
901
+ "inputSchema": tool.inputSchema,
902
+ "server": tool.metadata.get("server") if tool.metadata else server_name
903
+ }
904
+ for tool in tools
905
+ ]
906
+
907
+ # Run async in sync context
908
+ loop = asyncio.new_event_loop()
909
+ asyncio.set_event_loop(loop)
910
+ try:
911
+ tools = loop.run_until_complete(get_tools())
912
+ return jsonify({"tools": tools})
913
+ finally:
914
+ loop.close()
915
+
916
+ def handle_mcp_call_tool(self):
917
+ """Call an MCP tool."""
918
+ data = request.get_json()
919
+ server_name = data.get("server")
920
+ tool_name = data.get("tool")
921
+ arguments = data.get("arguments", {})
922
+
923
+ if not server_name or not tool_name:
924
+ return jsonify({"error": "Missing 'server' or 'tool' parameter"}), 400
925
+
926
+ async def call_tool():
927
+ try:
928
+ result = await self.mcp_client_manager.call_tool(server_name, tool_name, arguments)
929
+
930
+ # Convert result to JSON-serializable format
931
+ if hasattr(result, 'content'):
932
+ # Handle different content types
933
+ if hasattr(result.content, 'text'):
934
+ return {"result": result.content.text}
935
+ elif hasattr(result.content, 'data'):
936
+ return {"result": result.content.data}
937
+ else:
938
+ return {"result": str(result.content)}
939
+ else:
940
+ return {"result": str(result)}
941
+
942
+ except Exception as e:
943
+ return {"error": str(e)}
944
+
945
+ loop = asyncio.new_event_loop()
946
+ asyncio.set_event_loop(loop)
947
+ try:
948
+ result = loop.run_until_complete(call_tool())
949
+ if "error" in result:
950
+ return jsonify(result), 500
951
+ return jsonify(result)
952
+ finally:
953
+ loop.close()
954
+
955
+ def handle_mcp_list_resources(self):
956
+ """List available MCP resources."""
957
+ server_name = request.args.get("server")
958
+
959
+ async def get_resources():
960
+ resources = await self.mcp_client_manager.list_resources(server_name)
961
+ return [
962
+ {
963
+ "uri": resource.uri,
964
+ "name": resource.name,
965
+ "description": resource.description,
966
+ "mimeType": resource.mimeType,
967
+ "server": resource.metadata.get("server") if resource.metadata else server_name
968
+ }
969
+ for resource in resources
970
+ ]
971
+
972
+ loop = asyncio.new_event_loop()
973
+ asyncio.set_event_loop(loop)
974
+ try:
975
+ resources = loop.run_until_complete(get_resources())
976
+ return jsonify({"resources": resources})
977
+ finally:
978
+ loop.close()
979
+
980
+ def handle_mcp_read_resource(self):
981
+ """Read an MCP resource."""
982
+ data = request.get_json()
983
+ server_name = data.get("server")
984
+ uri = data.get("uri")
985
+
986
+ if not server_name or not uri:
987
+ return jsonify({"error": "Missing 'server' or 'uri' parameter"}), 400
988
+
989
+ async def read_resource():
990
+ try:
991
+ contents = await self.mcp_client_manager.read_resource(server_name, uri)
992
+ return {
993
+ "contents": [
994
+ {"text": content.text} if hasattr(content, 'text') else {"data": str(content)}
995
+ for content in contents
996
+ ]
997
+ }
998
+ except Exception as e:
999
+ return {"error": str(e)}
1000
+
1001
+ loop = asyncio.new_event_loop()
1002
+ asyncio.set_event_loop(loop)
1003
+ try:
1004
+ result = loop.run_until_complete(read_resource())
1005
+ if "error" in result:
1006
+ return jsonify(result), 500
1007
+ return jsonify(result)
1008
+ finally:
1009
+ loop.close()
1010
+
1011
+ def handle_mcp_server(self):
1012
+ """Handle MCP server requests using HTTP transport."""
1013
+ if not self.vac_mcp_server:
1014
+ return jsonify({"error": "MCP server not enabled"}), 501
1015
+
1016
+ import json as json_module
1017
+
1018
+ # Handle streaming for HTTP transport
1019
+ if request.method == 'POST':
1020
+ try:
1021
+ # Get the JSON-RPC request
1022
+ data = request.get_json()
1023
+ log.info(f"MCP server received: {data}")
1024
+
1025
+ # Create an async handler for the request
1026
+ async def process_request():
1027
+ # Create mock read/write streams for the server
1028
+ from io import StringIO
1029
+ import asyncio
1030
+
1031
+ # Convert request to proper format
1032
+ request_str = json_module.dumps(data) + '\n'
1033
+
1034
+ # Create read queue with the request
1035
+ read_queue = asyncio.Queue()
1036
+ await read_queue.put(request_str.encode())
1037
+ await read_queue.put(None) # EOF signal
1038
+
1039
+ # Create write queue for response
1040
+ write_queue = asyncio.Queue()
1041
+
1042
+ # Create async iterators
1043
+ async def read_messages():
1044
+ while True:
1045
+ msg = await read_queue.get()
1046
+ if msg is None:
1047
+ break
1048
+ yield msg
1049
+
1050
+ responses = []
1051
+ async def write_messages():
1052
+ async for msg in write_queue:
1053
+ if msg is None:
1054
+ break
1055
+ responses.append(msg.decode())
1056
+
1057
+ # Run the server with these streams
1058
+ server = self.vac_mcp_server.get_server()
1059
+
1060
+ # Start write handler
1061
+ write_task = asyncio.create_task(write_messages())
1062
+
1063
+ try:
1064
+ # Process the request through the server
1065
+ await server.run(
1066
+ read_messages(),
1067
+ write_queue,
1068
+ InitializationOptions() if InitializationOptions else None
1069
+ )
1070
+ except Exception as e:
1071
+ log.error(f"Error processing MCP request: {e}")
1072
+ await write_queue.put(None)
1073
+ await write_task
1074
+ raise
1075
+
1076
+ # Signal end and wait for write task
1077
+ await write_queue.put(None)
1078
+ await write_task
1079
+
1080
+ # Return collected responses
1081
+ return responses
1082
+
1083
+ # Run the async handler
1084
+ loop = asyncio.new_event_loop()
1085
+ asyncio.set_event_loop(loop)
1086
+ try:
1087
+ responses = loop.run_until_complete(process_request())
1088
+
1089
+ # Parse and return the response
1090
+ if responses:
1091
+ # The response should be a single JSON-RPC response
1092
+ response_data = json_module.loads(responses[0])
1093
+ return jsonify(response_data)
1094
+ else:
1095
+ return jsonify({"error": "No response from MCP server"}), 500
1096
+
1097
+ except Exception as e:
1098
+ log.error(f"MCP server error: {str(e)}")
1099
+ return jsonify({
1100
+ "jsonrpc": "2.0",
1101
+ "error": {
1102
+ "code": -32603,
1103
+ "message": f"Internal error: {str(e)}"
1104
+ },
1105
+ "id": data.get("id") if isinstance(data, dict) else None
1106
+ }), 500
1107
+ finally:
1108
+ loop.close()
1109
+
1110
+ except Exception as e:
1111
+ log.error(f"MCP server error: {str(e)}")
1112
+ return jsonify({
1113
+ "jsonrpc": "2.0",
1114
+ "error": {
1115
+ "code": -32603,
1116
+ "message": f"Internal error: {str(e)}"
1117
+ },
1118
+ "id": data.get("id") if isinstance(data, dict) else None
1119
+ }), 500
1120
+
1121
+ else:
1122
+ # GET request - return server information
1123
+ return jsonify({
1124
+ "name": "sunholo-vac-server",
1125
+ "version": "1.0.0",
1126
+ "transport": "http",
1127
+ "endpoint": "/mcp",
1128
+ "tools": ["vac_stream", "vac_query"] if self.vac_interpreter else ["vac_stream"]
1129
+ })
1130
+
1131
+ def _get_or_create_a2a_agent(self):
1132
+ """Get or create the A2A agent instance with current request context."""
1133
+ if not self.enable_a2a_agent or not VACA2AAgent:
1134
+ return None
1135
+
1136
+ if self.vac_a2a_agent is None:
1137
+ # Extract base URL from current request
1138
+ base_url = request.url_root.rstrip('/')
1139
+
1140
+ self.vac_a2a_agent = VACA2AAgent(
1141
+ base_url=base_url,
1142
+ stream_interpreter=self.stream_interpreter,
1143
+ vac_interpreter=self.vac_interpreter,
1144
+ vac_names=self.a2a_vac_names
1145
+ )
1146
+
1147
+ return self.vac_a2a_agent
1148
+
1149
+ def handle_a2a_agent_card(self):
1150
+ """Handle A2A agent card discovery request."""
1151
+ agent = self._get_or_create_a2a_agent()
1152
+ if not agent:
1153
+ return jsonify({"error": "A2A agent not enabled"}), 501
1154
+
1155
+ return jsonify(agent.get_agent_card())
1156
+
1157
+ def handle_a2a_task_send(self):
1158
+ """Handle A2A task send request."""
1159
+ agent = self._get_or_create_a2a_agent()
1160
+ if not agent:
1161
+ return jsonify({"error": "A2A agent not enabled"}), 501
1162
+
1163
+ try:
1164
+ data = request.get_json()
1165
+ if not data:
1166
+ return jsonify({
1167
+ "jsonrpc": "2.0",
1168
+ "error": {
1169
+ "code": -32700,
1170
+ "message": "Parse error: Invalid JSON"
1171
+ },
1172
+ "id": None
1173
+ }), 400
1174
+
1175
+ # Run async handler
1176
+ loop = asyncio.new_event_loop()
1177
+ asyncio.set_event_loop(loop)
1178
+ try:
1179
+ response = loop.run_until_complete(agent.handle_task_send(data))
1180
+ return jsonify(response)
1181
+ finally:
1182
+ loop.close()
1183
+
1184
+ except Exception as e:
1185
+ log.error(f"A2A task send error: {e}")
1186
+ return jsonify({
1187
+ "jsonrpc": "2.0",
1188
+ "error": {
1189
+ "code": -32603,
1190
+ "message": f"Internal error: {str(e)}"
1191
+ },
1192
+ "id": data.get("id") if 'data' in locals() else None
1193
+ }), 500
1194
+
1195
+ def handle_a2a_task_send_subscribe(self):
1196
+ """Handle A2A task send with subscription (SSE)."""
1197
+ agent = self._get_or_create_a2a_agent()
1198
+ if not agent:
1199
+ return jsonify({"error": "A2A agent not enabled"}), 501
1200
+
1201
+ try:
1202
+ data = request.get_json()
1203
+ if not data:
1204
+ def error_generator():
1205
+ yield "data: {\"error\": \"Parse error: Invalid JSON\"}\n\n"
1206
+
1207
+ return Response(error_generator(), content_type='text/event-stream')
1208
+
1209
+ # Create async generator for SSE
1210
+ async def sse_generator():
1211
+ async for chunk in agent.handle_task_send_subscribe(data):
1212
+ yield chunk
1213
+
1214
+ def sync_generator():
1215
+ loop = asyncio.new_event_loop()
1216
+ asyncio.set_event_loop(loop)
1217
+ try:
1218
+ async_gen = sse_generator()
1219
+ while True:
1220
+ try:
1221
+ chunk = loop.run_until_complete(async_gen.__anext__())
1222
+ yield chunk
1223
+ except StopAsyncIteration:
1224
+ break
1225
+ finally:
1226
+ loop.close()
1227
+
1228
+ return Response(sync_generator(), content_type='text/event-stream')
1229
+
1230
+ except Exception as e:
1231
+ log.error(f"A2A task send subscribe error: {e}")
1232
+ def error_generator(err):
1233
+ yield f"data: {{\"error\": \"Internal error: {str(err)}\"}}\n\n"
1234
+
1235
+ return Response(error_generator(e), content_type='text/event-stream')
1236
+
1237
+ def handle_a2a_task_get(self):
1238
+ """Handle A2A task get request."""
1239
+ agent = self._get_or_create_a2a_agent()
1240
+ if not agent:
1241
+ return jsonify({"error": "A2A agent not enabled"}), 501
1242
+
1243
+ try:
1244
+ data = request.get_json()
1245
+ if not data:
1246
+ return jsonify({
1247
+ "jsonrpc": "2.0",
1248
+ "error": {
1249
+ "code": -32700,
1250
+ "message": "Parse error: Invalid JSON"
1251
+ },
1252
+ "id": None
1253
+ }), 400
1254
+
1255
+ # Run async handler
1256
+ loop = asyncio.new_event_loop()
1257
+ asyncio.set_event_loop(loop)
1258
+ try:
1259
+ response = loop.run_until_complete(agent.handle_task_get(data))
1260
+ return jsonify(response)
1261
+ finally:
1262
+ loop.close()
1263
+
1264
+ except Exception as e:
1265
+ log.error(f"A2A task get error: {e}")
1266
+ return jsonify({
1267
+ "jsonrpc": "2.0",
1268
+ "error": {
1269
+ "code": -32603,
1270
+ "message": f"Internal error: {str(e)}"
1271
+ },
1272
+ "id": data.get("id") if 'data' in locals() else None
1273
+ }), 500
1274
+
1275
+ def handle_a2a_task_cancel(self):
1276
+ """Handle A2A task cancel request."""
1277
+ agent = self._get_or_create_a2a_agent()
1278
+ if not agent:
1279
+ return jsonify({"error": "A2A agent not enabled"}), 501
1280
+
1281
+ try:
1282
+ data = request.get_json()
1283
+ if not data:
1284
+ return jsonify({
1285
+ "jsonrpc": "2.0",
1286
+ "error": {
1287
+ "code": -32700,
1288
+ "message": "Parse error: Invalid JSON"
1289
+ },
1290
+ "id": None
1291
+ }), 400
1292
+
1293
+ # Run async handler
1294
+ loop = asyncio.new_event_loop()
1295
+ asyncio.set_event_loop(loop)
1296
+ try:
1297
+ response = loop.run_until_complete(agent.handle_task_cancel(data))
1298
+ return jsonify(response)
1299
+ finally:
1300
+ loop.close()
1301
+
1302
+ except Exception as e:
1303
+ log.error(f"A2A task cancel error: {e}")
1304
+ return jsonify({
1305
+ "jsonrpc": "2.0",
1306
+ "error": {
1307
+ "code": -32603,
1308
+ "message": f"Internal error: {str(e)}"
1309
+ },
1310
+ "id": data.get("id") if 'data' in locals() else None
1311
+ }), 500
1312
+
1313
+ def handle_a2a_push_notification(self):
1314
+ """Handle A2A push notification settings."""
1315
+ agent = self._get_or_create_a2a_agent()
1316
+ if not agent:
1317
+ return jsonify({"error": "A2A agent not enabled"}), 501
1318
+
1319
+ try:
1320
+ data = request.get_json()
1321
+ if not data:
1322
+ return jsonify({
1323
+ "jsonrpc": "2.0",
1324
+ "error": {
1325
+ "code": -32700,
1326
+ "message": "Parse error: Invalid JSON"
1327
+ },
1328
+ "id": None
1329
+ }), 400
1330
+
1331
+ # Run async handler
1332
+ loop = asyncio.new_event_loop()
1333
+ asyncio.set_event_loop(loop)
1334
+ try:
1335
+ response = loop.run_until_complete(agent.handle_push_notification_set(data))
1336
+ return jsonify(response)
1337
+ finally:
1338
+ loop.close()
1339
+
1340
+ except Exception as e:
1341
+ log.error(f"A2A push notification error: {e}")
1342
+ return jsonify({
1343
+ "jsonrpc": "2.0",
1344
+ "error": {
1345
+ "code": -32603,
1346
+ "message": f"Internal error: {str(e)}"
1347
+ },
1348
+ "id": data.get("id") if 'data' in locals() else None
1349
+ }), 500
sunholo/mcp/__init__.py CHANGED
@@ -0,0 +1,20 @@
1
+ # Copyright [2024] [Holosun ApS]
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """MCP (Model Context Protocol) integration for Sunholo."""
16
+
17
+ from .mcp_manager import MCPClientManager
18
+ from .vac_mcp_server import VACMCPServer
19
+
20
+ __all__ = ['MCPClientManager', 'VACMCPServer']