arch-ops-server 3.0.1__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.
- arch_ops_server/__init__.py +176 -0
- arch_ops_server/aur.py +1190 -0
- arch_ops_server/config.py +361 -0
- arch_ops_server/http_server.py +829 -0
- arch_ops_server/logs.py +345 -0
- arch_ops_server/mirrors.py +397 -0
- arch_ops_server/news.py +288 -0
- arch_ops_server/pacman.py +1305 -0
- arch_ops_server/py.typed +0 -0
- arch_ops_server/server.py +1869 -0
- arch_ops_server/system.py +307 -0
- arch_ops_server/utils.py +313 -0
- arch_ops_server/wiki.py +245 -0
- arch_ops_server-3.0.1.dist-info/METADATA +253 -0
- arch_ops_server-3.0.1.dist-info/RECORD +17 -0
- arch_ops_server-3.0.1.dist-info/WHEEL +4 -0
- arch_ops_server-3.0.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only OR MIT
|
|
2
|
+
"""
|
|
3
|
+
HTTP Server for MCP using SSE (Server-Sent Events) transport.
|
|
4
|
+
|
|
5
|
+
This module provides HTTP transport support for Smithery and other
|
|
6
|
+
HTTP-based MCP clients, while keeping STDIO transport for Docker MCP Catalog.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.routing import Route
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
20
|
+
import uvicorn
|
|
21
|
+
STARLETTE_AVAILABLE = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
STARLETTE_AVAILABLE = False
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from mcp.server.sse import SseServerTransport
|
|
27
|
+
SSE_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
SSE_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
from .server import server
|
|
32
|
+
from . import __version__
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Import handlers - try to get them from server if direct import fails
|
|
37
|
+
try:
|
|
38
|
+
from .server import list_tools, list_resources, list_prompts, call_tool, read_resource, get_prompt
|
|
39
|
+
logger.info("Successfully imported all handlers")
|
|
40
|
+
except ImportError as e:
|
|
41
|
+
logger.warning(f"Could not import handlers directly: {e}")
|
|
42
|
+
# Fallback: we'll access them through server handlers if needed
|
|
43
|
+
list_tools = None
|
|
44
|
+
list_resources = None
|
|
45
|
+
list_prompts = None
|
|
46
|
+
call_tool = None
|
|
47
|
+
read_resource = None
|
|
48
|
+
get_prompt = None
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(f"Unexpected error importing handlers: {e}", exc_info=True)
|
|
51
|
+
# Set to None to prevent crashes
|
|
52
|
+
list_tools = None
|
|
53
|
+
list_resources = None
|
|
54
|
+
list_prompts = None
|
|
55
|
+
call_tool = None
|
|
56
|
+
read_resource = None
|
|
57
|
+
get_prompt = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _handle_direct_mcp_request(request_data: dict) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Handle MCP request directly without SSE session.
|
|
63
|
+
|
|
64
|
+
This is used when Smithery POSTs directly without establishing SSE connection.
|
|
65
|
+
Processes requests using the server's registered handlers.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
request_data: JSON-RPC request data
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
JSON-RPC response data
|
|
72
|
+
"""
|
|
73
|
+
import json
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
method = request_data.get("method", "")
|
|
77
|
+
params = request_data.get("params", {})
|
|
78
|
+
request_id = request_data.get("id")
|
|
79
|
+
|
|
80
|
+
if method == "initialize":
|
|
81
|
+
# Handle initialize request - return proper capabilities
|
|
82
|
+
# Indicate that we support tools, resources, and prompts
|
|
83
|
+
result = {
|
|
84
|
+
"jsonrpc": "2.0",
|
|
85
|
+
"id": request_id,
|
|
86
|
+
"result": {
|
|
87
|
+
"protocolVersion": params.get("protocolVersion", "2025-06-18"),
|
|
88
|
+
"capabilities": {
|
|
89
|
+
"tools": {
|
|
90
|
+
"listChanged": False
|
|
91
|
+
},
|
|
92
|
+
"resources": {
|
|
93
|
+
"subscribe": False,
|
|
94
|
+
"listChanged": False
|
|
95
|
+
},
|
|
96
|
+
"prompts": {
|
|
97
|
+
"listChanged": False
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"serverInfo": {
|
|
101
|
+
"name": "arch-ops-server",
|
|
102
|
+
"version": __version__
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result
|
|
107
|
+
elif method == "tools/list":
|
|
108
|
+
# Call the server's list_tools handler directly
|
|
109
|
+
logger.info("Handling tools/list request")
|
|
110
|
+
try:
|
|
111
|
+
tools = await list_tools()
|
|
112
|
+
logger.info(f"Got {len(tools)} tools")
|
|
113
|
+
# Convert Tool objects to dicts
|
|
114
|
+
tools_list = []
|
|
115
|
+
for tool in tools:
|
|
116
|
+
tools_list.append({
|
|
117
|
+
"name": tool.name,
|
|
118
|
+
"description": tool.description,
|
|
119
|
+
"inputSchema": tool.inputSchema
|
|
120
|
+
})
|
|
121
|
+
logger.info(f"Returning {len(tools_list)} tools")
|
|
122
|
+
return {
|
|
123
|
+
"jsonrpc": "2.0",
|
|
124
|
+
"id": request_id,
|
|
125
|
+
"result": {
|
|
126
|
+
"tools": tools_list
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Error in tools/list: {e}", exc_info=True)
|
|
131
|
+
return {
|
|
132
|
+
"jsonrpc": "2.0",
|
|
133
|
+
"error": {
|
|
134
|
+
"code": -32603,
|
|
135
|
+
"message": f"Failed to list tools: {str(e)}"
|
|
136
|
+
},
|
|
137
|
+
"id": request_id
|
|
138
|
+
}
|
|
139
|
+
elif method == "resources/list":
|
|
140
|
+
# Call the server's list_resources handler directly
|
|
141
|
+
logger.info("Handling resources/list request")
|
|
142
|
+
try:
|
|
143
|
+
# Ensure we await the async function properly
|
|
144
|
+
resources = await list_resources()
|
|
145
|
+
logger.info(f"Got {len(resources)} resources")
|
|
146
|
+
# Convert Resource objects to dicts
|
|
147
|
+
resources_list = []
|
|
148
|
+
for resource in resources:
|
|
149
|
+
try:
|
|
150
|
+
resources_list.append({
|
|
151
|
+
"uri": str(resource.uri),
|
|
152
|
+
"name": str(resource.name) if resource.name else "",
|
|
153
|
+
"mimeType": str(resource.mimeType) if resource.mimeType else "text/plain",
|
|
154
|
+
"description": str(resource.description) if resource.description else ""
|
|
155
|
+
})
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"Error converting resource to dict: {e}", exc_info=True)
|
|
158
|
+
# Skip this resource but continue with others
|
|
159
|
+
continue
|
|
160
|
+
logger.info(f"Returning {len(resources_list)} resources")
|
|
161
|
+
return {
|
|
162
|
+
"jsonrpc": "2.0",
|
|
163
|
+
"id": request_id,
|
|
164
|
+
"result": {
|
|
165
|
+
"resources": resources_list
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Error in resources/list: {e}", exc_info=True)
|
|
170
|
+
import traceback
|
|
171
|
+
logger.error(traceback.format_exc())
|
|
172
|
+
return {
|
|
173
|
+
"jsonrpc": "2.0",
|
|
174
|
+
"error": {
|
|
175
|
+
"code": -32603,
|
|
176
|
+
"message": f"Failed to list resources: {str(e)}"
|
|
177
|
+
},
|
|
178
|
+
"id": request_id
|
|
179
|
+
}
|
|
180
|
+
elif method == "prompts/list":
|
|
181
|
+
# Call the server's list_prompts handler directly
|
|
182
|
+
logger.info("Handling prompts/list request - starting")
|
|
183
|
+
try:
|
|
184
|
+
# Try to get the handler function
|
|
185
|
+
if list_prompts is None:
|
|
186
|
+
# Fallback: try to get it from server's registered handlers
|
|
187
|
+
logger.warning("list_prompts not imported, trying to access via server")
|
|
188
|
+
# The server object should have the handler registered
|
|
189
|
+
# We can't easily access it, so return an error
|
|
190
|
+
return {
|
|
191
|
+
"jsonrpc": "2.0",
|
|
192
|
+
"error": {
|
|
193
|
+
"code": -32603,
|
|
194
|
+
"message": "list_prompts handler not available"
|
|
195
|
+
},
|
|
196
|
+
"id": request_id
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Verify list_prompts is callable
|
|
200
|
+
if not callable(list_prompts):
|
|
201
|
+
logger.error("list_prompts is not callable!")
|
|
202
|
+
return {
|
|
203
|
+
"jsonrpc": "2.0",
|
|
204
|
+
"error": {
|
|
205
|
+
"code": -32603,
|
|
206
|
+
"message": "list_prompts function is not callable"
|
|
207
|
+
},
|
|
208
|
+
"id": request_id
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Log before calling the function
|
|
212
|
+
logger.info("About to call list_prompts()")
|
|
213
|
+
# Ensure we await the async function properly
|
|
214
|
+
prompts = await list_prompts()
|
|
215
|
+
logger.info(f"Got {len(prompts)} prompts from list_prompts()")
|
|
216
|
+
# Convert Prompt objects to dicts
|
|
217
|
+
prompts_list = []
|
|
218
|
+
for idx, prompt in enumerate(prompts):
|
|
219
|
+
try:
|
|
220
|
+
logger.debug(f"Processing prompt {idx+1}/{len(prompts)}: {getattr(prompt, 'name', 'unknown')}")
|
|
221
|
+
# Safely extract prompt fields
|
|
222
|
+
prompt_dict = {
|
|
223
|
+
"name": str(prompt.name) if hasattr(prompt, 'name') and prompt.name else "",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Handle description (may be None)
|
|
227
|
+
if hasattr(prompt, 'description'):
|
|
228
|
+
prompt_dict["description"] = str(prompt.description) if prompt.description else ""
|
|
229
|
+
else:
|
|
230
|
+
prompt_dict["description"] = ""
|
|
231
|
+
|
|
232
|
+
# Handle arguments (may be None or empty list)
|
|
233
|
+
if hasattr(prompt, 'arguments') and prompt.arguments:
|
|
234
|
+
# Ensure arguments is a list
|
|
235
|
+
if isinstance(prompt.arguments, list):
|
|
236
|
+
prompt_dict["arguments"] = prompt.arguments
|
|
237
|
+
else:
|
|
238
|
+
# Try to convert to list if it's not
|
|
239
|
+
prompt_dict["arguments"] = list(prompt.arguments) if prompt.arguments else []
|
|
240
|
+
else:
|
|
241
|
+
prompt_dict["arguments"] = []
|
|
242
|
+
|
|
243
|
+
prompts_list.append(prompt_dict)
|
|
244
|
+
logger.debug(f"Added prompt: {prompt_dict['name']}")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"Error converting prompt {idx+1} to dict: {e}", exc_info=True)
|
|
247
|
+
import traceback
|
|
248
|
+
logger.error(traceback.format_exc())
|
|
249
|
+
# Skip this prompt but continue with others
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
logger.info(f"Returning {len(prompts_list)} prompts")
|
|
253
|
+
result = {
|
|
254
|
+
"jsonrpc": "2.0",
|
|
255
|
+
"id": request_id,
|
|
256
|
+
"result": {
|
|
257
|
+
"prompts": prompts_list
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
logger.info("Successfully prepared prompts/list response")
|
|
261
|
+
return result
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.error(f"Error in prompts/list: {e}", exc_info=True)
|
|
264
|
+
import traceback
|
|
265
|
+
logger.error(traceback.format_exc())
|
|
266
|
+
return {
|
|
267
|
+
"jsonrpc": "2.0",
|
|
268
|
+
"error": {
|
|
269
|
+
"code": -32603,
|
|
270
|
+
"message": f"Failed to list prompts: {str(e)}"
|
|
271
|
+
},
|
|
272
|
+
"id": request_id
|
|
273
|
+
}
|
|
274
|
+
elif method == "tools/call":
|
|
275
|
+
# Call tool execution
|
|
276
|
+
logger.info(f"Direct HTTP tools/call: {params.get('name')}")
|
|
277
|
+
tool_name = params.get("name", "")
|
|
278
|
+
tool_arguments = params.get("arguments", {})
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
# Execute the tool
|
|
282
|
+
result_content = await call_tool(tool_name, tool_arguments)
|
|
283
|
+
|
|
284
|
+
# Convert content objects to dicts
|
|
285
|
+
content_list = []
|
|
286
|
+
for content in result_content:
|
|
287
|
+
if hasattr(content, 'type') and content.type == "text":
|
|
288
|
+
content_list.append({
|
|
289
|
+
"type": "text",
|
|
290
|
+
"text": content.text
|
|
291
|
+
})
|
|
292
|
+
elif hasattr(content, 'type') and content.type == "image":
|
|
293
|
+
content_list.append({
|
|
294
|
+
"type": "image",
|
|
295
|
+
"data": content.data,
|
|
296
|
+
"mimeType": content.mimeType
|
|
297
|
+
})
|
|
298
|
+
elif hasattr(content, 'type') and content.type == "resource":
|
|
299
|
+
content_list.append({
|
|
300
|
+
"type": "resource",
|
|
301
|
+
"resource": {
|
|
302
|
+
"uri": content.resource.uri,
|
|
303
|
+
"mimeType": content.resource.mimeType,
|
|
304
|
+
"text": content.resource.text if hasattr(content.resource, 'text') else None,
|
|
305
|
+
"blob": content.resource.blob if hasattr(content.resource, 'blob') else None,
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"jsonrpc": "2.0",
|
|
311
|
+
"id": request_id,
|
|
312
|
+
"result": {
|
|
313
|
+
"content": content_list
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
except ValueError as e:
|
|
317
|
+
# Tool not found or invalid arguments
|
|
318
|
+
logger.error(f"Tool error: {e}")
|
|
319
|
+
return {
|
|
320
|
+
"jsonrpc": "2.0",
|
|
321
|
+
"error": {
|
|
322
|
+
"code": -32602,
|
|
323
|
+
"message": str(e)
|
|
324
|
+
},
|
|
325
|
+
"id": request_id
|
|
326
|
+
}
|
|
327
|
+
except Exception as e:
|
|
328
|
+
# Other errors during tool execution
|
|
329
|
+
logger.error(f"Tool execution error: {e}", exc_info=True)
|
|
330
|
+
return {
|
|
331
|
+
"jsonrpc": "2.0",
|
|
332
|
+
"error": {
|
|
333
|
+
"code": -32603,
|
|
334
|
+
"message": f"Tool execution failed: {str(e)}"
|
|
335
|
+
},
|
|
336
|
+
"id": request_id
|
|
337
|
+
}
|
|
338
|
+
elif method == "resources/read":
|
|
339
|
+
# Read resource
|
|
340
|
+
logger.info(f"Direct HTTP resources/read: {params.get('uri')}")
|
|
341
|
+
uri = params.get("uri", "")
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
# Read the resource
|
|
345
|
+
resource_content = await read_resource(uri)
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"jsonrpc": "2.0",
|
|
349
|
+
"id": request_id,
|
|
350
|
+
"result": {
|
|
351
|
+
"contents": [
|
|
352
|
+
{
|
|
353
|
+
"uri": uri,
|
|
354
|
+
"mimeType": "text/plain",
|
|
355
|
+
"text": resource_content
|
|
356
|
+
}
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
except ValueError as e:
|
|
361
|
+
# Resource not found or invalid URI
|
|
362
|
+
logger.error(f"Resource error: {e}")
|
|
363
|
+
return {
|
|
364
|
+
"jsonrpc": "2.0",
|
|
365
|
+
"error": {
|
|
366
|
+
"code": -32602,
|
|
367
|
+
"message": str(e)
|
|
368
|
+
},
|
|
369
|
+
"id": request_id
|
|
370
|
+
}
|
|
371
|
+
except Exception as e:
|
|
372
|
+
# Other errors during resource read
|
|
373
|
+
logger.error(f"Resource read error: {e}", exc_info=True)
|
|
374
|
+
return {
|
|
375
|
+
"jsonrpc": "2.0",
|
|
376
|
+
"error": {
|
|
377
|
+
"code": -32603,
|
|
378
|
+
"message": f"Resource read failed: {str(e)}"
|
|
379
|
+
},
|
|
380
|
+
"id": request_id
|
|
381
|
+
}
|
|
382
|
+
elif method == "prompts/get":
|
|
383
|
+
# Get prompt
|
|
384
|
+
logger.info(f"Direct HTTP prompts/get: {params.get('name')}")
|
|
385
|
+
prompt_name = params.get("name", "")
|
|
386
|
+
prompt_arguments = params.get("arguments", {})
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
# Get the prompt
|
|
390
|
+
prompt_result = await get_prompt(prompt_name, prompt_arguments)
|
|
391
|
+
|
|
392
|
+
# Convert PromptMessage objects to dicts
|
|
393
|
+
messages_list = []
|
|
394
|
+
for message in prompt_result.messages:
|
|
395
|
+
msg_dict = {
|
|
396
|
+
"role": message.role,
|
|
397
|
+
"content": {
|
|
398
|
+
"type": "text",
|
|
399
|
+
"text": message.content.text
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
messages_list.append(msg_dict)
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
"jsonrpc": "2.0",
|
|
406
|
+
"id": request_id,
|
|
407
|
+
"result": {
|
|
408
|
+
"description": prompt_result.description,
|
|
409
|
+
"messages": messages_list
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
except ValueError as e:
|
|
413
|
+
# Prompt not found or invalid arguments
|
|
414
|
+
logger.error(f"Prompt error: {e}")
|
|
415
|
+
return {
|
|
416
|
+
"jsonrpc": "2.0",
|
|
417
|
+
"error": {
|
|
418
|
+
"code": -32602,
|
|
419
|
+
"message": str(e)
|
|
420
|
+
},
|
|
421
|
+
"id": request_id
|
|
422
|
+
}
|
|
423
|
+
except Exception as e:
|
|
424
|
+
# Other errors during prompt generation
|
|
425
|
+
logger.error(f"Prompt generation error: {e}", exc_info=True)
|
|
426
|
+
return {
|
|
427
|
+
"jsonrpc": "2.0",
|
|
428
|
+
"error": {
|
|
429
|
+
"code": -32603,
|
|
430
|
+
"message": f"Prompt generation failed: {str(e)}"
|
|
431
|
+
},
|
|
432
|
+
"id": request_id
|
|
433
|
+
}
|
|
434
|
+
else:
|
|
435
|
+
# For other methods, return method not found
|
|
436
|
+
return {
|
|
437
|
+
"jsonrpc": "2.0",
|
|
438
|
+
"error": {
|
|
439
|
+
"code": -32601,
|
|
440
|
+
"message": f"Method '{method}' not supported in direct HTTP mode. Please use SSE connection."
|
|
441
|
+
},
|
|
442
|
+
"id": request_id
|
|
443
|
+
}
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error(f"Error handling direct MCP request: {e}", exc_info=True)
|
|
446
|
+
import traceback
|
|
447
|
+
logger.error(traceback.format_exc())
|
|
448
|
+
return {
|
|
449
|
+
"jsonrpc": "2.0",
|
|
450
|
+
"error": {
|
|
451
|
+
"code": -32603,
|
|
452
|
+
"message": f"Internal error: {str(e)}"
|
|
453
|
+
},
|
|
454
|
+
"id": request_data.get("id")
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
# Initialize SSE transport at module level
|
|
458
|
+
sse: Any = None
|
|
459
|
+
if SSE_AVAILABLE:
|
|
460
|
+
sse = SseServerTransport("/messages")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def handle_sse_raw(scope: dict, receive: Any, send: Any) -> None:
|
|
464
|
+
"""
|
|
465
|
+
Raw ASGI handler for Server-Sent Events (SSE) endpoint for MCP.
|
|
466
|
+
|
|
467
|
+
This is the main MCP endpoint that Smithery will connect to.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
scope: ASGI scope dictionary
|
|
471
|
+
receive: ASGI receive callable
|
|
472
|
+
send: ASGI send callable
|
|
473
|
+
"""
|
|
474
|
+
if not SSE_AVAILABLE or sse is None:
|
|
475
|
+
logger.error("SSE transport not available - MCP package needs SSE support")
|
|
476
|
+
await send({
|
|
477
|
+
"type": "http.response.start",
|
|
478
|
+
"status": 500,
|
|
479
|
+
"headers": [[b"content-type", b"text/plain"]],
|
|
480
|
+
})
|
|
481
|
+
await send({
|
|
482
|
+
"type": "http.response.body",
|
|
483
|
+
"body": b"SSE transport not available. Install mcp package with SSE support.",
|
|
484
|
+
})
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
logger.info("New SSE connection established")
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
async with sse.connect_sse(scope, receive, send) as streams:
|
|
491
|
+
await server.run(
|
|
492
|
+
streams[0],
|
|
493
|
+
streams[1],
|
|
494
|
+
server.create_initialization_options()
|
|
495
|
+
)
|
|
496
|
+
except Exception as e:
|
|
497
|
+
logger.error(f"SSE connection error: {e}", exc_info=True)
|
|
498
|
+
raise
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
async def handle_sse(request: Request) -> None:
|
|
502
|
+
"""
|
|
503
|
+
Starlette request handler wrapper for SSE endpoint.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
request: Starlette Request object
|
|
507
|
+
"""
|
|
508
|
+
await handle_sse_raw(request.scope, request.receive, request._send)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def handle_messages_raw(scope: dict, receive: Any, send: Any) -> None:
|
|
512
|
+
"""
|
|
513
|
+
Raw ASGI handler for POST requests to /messages endpoint for SSE transport.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
scope: ASGI scope dictionary
|
|
517
|
+
receive: ASGI receive callable
|
|
518
|
+
send: ASGI send callable
|
|
519
|
+
"""
|
|
520
|
+
if not SSE_AVAILABLE or sse is None:
|
|
521
|
+
await send({
|
|
522
|
+
"type": "http.response.start",
|
|
523
|
+
"status": 500,
|
|
524
|
+
"headers": [[b"content-type", b"text/plain"]],
|
|
525
|
+
})
|
|
526
|
+
await send({
|
|
527
|
+
"type": "http.response.body",
|
|
528
|
+
"body": b"SSE transport not available.",
|
|
529
|
+
})
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
await sse.handle_post_message(scope, receive, send)
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.error(f"Message handling error: {e}", exc_info=True)
|
|
536
|
+
await send({
|
|
537
|
+
"type": "http.response.start",
|
|
538
|
+
"status": 500,
|
|
539
|
+
"headers": [[b"content-type", b"application/json"]],
|
|
540
|
+
})
|
|
541
|
+
await send({
|
|
542
|
+
"type": "http.response.body",
|
|
543
|
+
"body": f'{{"jsonrpc": "2.0", "error": {{"code": -32603, "message": "Internal error: {str(e)}"}}, "id": null}}'.encode(),
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def handle_messages(request: Request) -> None:
|
|
548
|
+
"""
|
|
549
|
+
Starlette request handler wrapper for messages endpoint.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
request: Starlette Request object
|
|
553
|
+
"""
|
|
554
|
+
await handle_messages_raw(request.scope, request.receive, request._send)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
async def handle_mcp_raw(scope: dict, receive: Any, send: Any) -> None:
|
|
558
|
+
"""
|
|
559
|
+
Raw ASGI handler for /mcp endpoint (Smithery requirement).
|
|
560
|
+
|
|
561
|
+
Smithery expects a single /mcp endpoint that handles:
|
|
562
|
+
- GET: Establish SSE connection (streamable HTTP)
|
|
563
|
+
- POST: Send messages
|
|
564
|
+
- DELETE: Close connection
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
scope: ASGI scope dictionary
|
|
568
|
+
receive: ASGI receive callable
|
|
569
|
+
send: ASGI send callable
|
|
570
|
+
"""
|
|
571
|
+
async def handle_mcp_raw(scope: dict, receive: Any, send: Any) -> None:
|
|
572
|
+
"""
|
|
573
|
+
Raw ASGI handler for /mcp endpoint (Smithery requirement).
|
|
574
|
+
|
|
575
|
+
Smithery expects a single /mcp endpoint that handles:
|
|
576
|
+
- GET: Establish SSE connection (streamable HTTP)
|
|
577
|
+
- POST: Send messages
|
|
578
|
+
- DELETE: Close connection
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
scope: ASGI scope dictionary
|
|
582
|
+
receive: ASGI receive callable
|
|
583
|
+
send: ASGI send callable
|
|
584
|
+
"""
|
|
585
|
+
method = scope.get("method", "")
|
|
586
|
+
|
|
587
|
+
# Wrap everything in a try-except to catch any unhandled exceptions
|
|
588
|
+
try:
|
|
589
|
+
if method == "GET":
|
|
590
|
+
# GET /mcp establishes SSE connection
|
|
591
|
+
# The SSE transport might check the path, so we ensure compatibility
|
|
592
|
+
logger.info("GET /mcp - Establishing SSE connection")
|
|
593
|
+
# For GET, the path doesn't matter for connect_sse, but we keep original
|
|
594
|
+
await handle_sse_raw(scope, receive, send)
|
|
595
|
+
elif method == "POST":
|
|
596
|
+
# POST /mcp sends messages
|
|
597
|
+
# Check if session_id exists in query string
|
|
598
|
+
query_string = scope.get("query_string", b"").decode("utf-8")
|
|
599
|
+
has_session_id = "session_id" in query_string
|
|
600
|
+
|
|
601
|
+
if not has_session_id:
|
|
602
|
+
# Smithery POSTs directly without establishing SSE connection first
|
|
603
|
+
# Handle as regular HTTP request-response (non-SSE)
|
|
604
|
+
logger.info("POST /mcp without session_id - handling as regular HTTP request")
|
|
605
|
+
request_data = None
|
|
606
|
+
try:
|
|
607
|
+
# Read request body
|
|
608
|
+
body = b""
|
|
609
|
+
more_body = True
|
|
610
|
+
while more_body:
|
|
611
|
+
message = await receive()
|
|
612
|
+
if message["type"] == "http.request":
|
|
613
|
+
body += message.get("body", b"")
|
|
614
|
+
more_body = message.get("more_body", False)
|
|
615
|
+
|
|
616
|
+
# Parse JSON-RPC request
|
|
617
|
+
import json
|
|
618
|
+
request_data = json.loads(body.decode("utf-8"))
|
|
619
|
+
logger.info(f"Processing MCP request: {request_data.get('method', 'unknown')}")
|
|
620
|
+
|
|
621
|
+
# Handle it as a direct HTTP request-response
|
|
622
|
+
response = await _handle_direct_mcp_request(request_data)
|
|
623
|
+
|
|
624
|
+
await send({
|
|
625
|
+
"type": "http.response.start",
|
|
626
|
+
"status": 200,
|
|
627
|
+
"headers": [[b"content-type", b"application/json"], [b"access-control-allow-origin", b"*"]],
|
|
628
|
+
})
|
|
629
|
+
await send({
|
|
630
|
+
"type": "http.response.body",
|
|
631
|
+
"body": json.dumps(response).encode("utf-8"),
|
|
632
|
+
})
|
|
633
|
+
return
|
|
634
|
+
except json.JSONDecodeError as e:
|
|
635
|
+
logger.error(f"JSON decode error: {e}", exc_info=True)
|
|
636
|
+
import json
|
|
637
|
+
await send({
|
|
638
|
+
"type": "http.response.start",
|
|
639
|
+
"status": 400,
|
|
640
|
+
"headers": [[b"content-type", b"application/json"]],
|
|
641
|
+
})
|
|
642
|
+
await send({
|
|
643
|
+
"type": "http.response.body",
|
|
644
|
+
"body": json.dumps({
|
|
645
|
+
"jsonrpc": "2.0",
|
|
646
|
+
"error": {"code": -32700, "message": f"Parse error: {str(e)}"},
|
|
647
|
+
"id": None
|
|
648
|
+
}).encode("utf-8"),
|
|
649
|
+
})
|
|
650
|
+
return
|
|
651
|
+
except Exception as e:
|
|
652
|
+
logger.error(f"Error handling direct POST request: {e}", exc_info=True)
|
|
653
|
+
import traceback
|
|
654
|
+
logger.error(traceback.format_exc())
|
|
655
|
+
import json
|
|
656
|
+
await send({
|
|
657
|
+
"type": "http.response.start",
|
|
658
|
+
"status": 500,
|
|
659
|
+
"headers": [[b"content-type", b"application/json"]],
|
|
660
|
+
})
|
|
661
|
+
error_response = {
|
|
662
|
+
"jsonrpc": "2.0",
|
|
663
|
+
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
|
|
664
|
+
"id": request_data.get("id") if request_data else None
|
|
665
|
+
}
|
|
666
|
+
await send({
|
|
667
|
+
"type": "http.response.body",
|
|
668
|
+
"body": json.dumps(error_response).encode("utf-8"),
|
|
669
|
+
})
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
# The SSE transport expects /messages path, so we modify the scope
|
|
673
|
+
logger.info("POST /mcp - Handling message with session_id")
|
|
674
|
+
# Create a modified scope with /messages path for SSE transport compatibility
|
|
675
|
+
modified_scope = dict(scope)
|
|
676
|
+
modified_scope["path"] = "/messages"
|
|
677
|
+
# Preserve query string (includes session_id)
|
|
678
|
+
modified_scope["query_string"] = scope.get("query_string", b"")
|
|
679
|
+
await handle_messages_raw(modified_scope, receive, send)
|
|
680
|
+
elif method == "DELETE":
|
|
681
|
+
# DELETE /mcp closes connection
|
|
682
|
+
logger.info("DELETE /mcp - Closing connection")
|
|
683
|
+
# SSE connections are closed when the stream ends, so just return 200
|
|
684
|
+
await send({
|
|
685
|
+
"type": "http.response.start",
|
|
686
|
+
"status": 200,
|
|
687
|
+
"headers": [[b"content-type", b"text/plain"]],
|
|
688
|
+
})
|
|
689
|
+
await send({
|
|
690
|
+
"type": "http.response.body",
|
|
691
|
+
"body": b"Connection closed",
|
|
692
|
+
})
|
|
693
|
+
else:
|
|
694
|
+
await send({
|
|
695
|
+
"type": "http.response.start",
|
|
696
|
+
"status": 405,
|
|
697
|
+
"headers": [[b"content-type", b"text/plain"]],
|
|
698
|
+
})
|
|
699
|
+
await send({
|
|
700
|
+
"type": "http.response.body",
|
|
701
|
+
"body": f"Method {method} not allowed".encode(),
|
|
702
|
+
})
|
|
703
|
+
except Exception as e:
|
|
704
|
+
# Catch any unhandled exceptions at the top level
|
|
705
|
+
logger.error(f"Unhandled exception in handle_mcp_raw: {e}", exc_info=True)
|
|
706
|
+
import traceback
|
|
707
|
+
logger.error(traceback.format_exc())
|
|
708
|
+
import json
|
|
709
|
+
try:
|
|
710
|
+
await send({
|
|
711
|
+
"type": "http.response.start",
|
|
712
|
+
"status": 500,
|
|
713
|
+
"headers": [[b"content-type", b"application/json"]],
|
|
714
|
+
})
|
|
715
|
+
await send({
|
|
716
|
+
"type": "http.response.body",
|
|
717
|
+
"body": json.dumps({
|
|
718
|
+
"jsonrpc": "2.0",
|
|
719
|
+
"error": {"code": -32603, "message": f"Internal server error: {str(e)}"},
|
|
720
|
+
"id": None
|
|
721
|
+
}).encode("utf-8"),
|
|
722
|
+
})
|
|
723
|
+
except Exception as send_error:
|
|
724
|
+
logger.error(f"Failed to send error response: {send_error}", exc_info=True)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
async def handle_mcp(request: Request) -> None:
|
|
728
|
+
"""
|
|
729
|
+
Starlette request handler wrapper for /mcp endpoint.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
request: Starlette Request object
|
|
733
|
+
"""
|
|
734
|
+
await handle_mcp_raw(request.scope, request.receive, request._send)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def create_app() -> Any:
|
|
738
|
+
"""
|
|
739
|
+
Create Starlette application with MCP SSE endpoints.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
Starlette application instance
|
|
743
|
+
|
|
744
|
+
Raises:
|
|
745
|
+
ImportError: If starlette is not installed
|
|
746
|
+
"""
|
|
747
|
+
if not STARLETTE_AVAILABLE:
|
|
748
|
+
raise ImportError(
|
|
749
|
+
"Starlette and uvicorn are required for HTTP transport. "
|
|
750
|
+
"Install with: pip install 'arch-ops-server[http]'"
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
if not SSE_AVAILABLE or sse is None:
|
|
754
|
+
raise ImportError(
|
|
755
|
+
"MCP SSE transport not available. Install mcp package with SSE support."
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Create routes
|
|
759
|
+
# - /mcp: Required by Smithery (handles GET/POST/DELETE for streamable HTTP)
|
|
760
|
+
# - /sse and /messages: Alternative endpoints for other clients
|
|
761
|
+
routes = [
|
|
762
|
+
Route("/mcp", endpoint=handle_mcp, methods=["GET", "POST", "DELETE"]),
|
|
763
|
+
Route("/sse", endpoint=handle_sse),
|
|
764
|
+
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
# Create app
|
|
768
|
+
app = Starlette(debug=False, routes=routes)
|
|
769
|
+
|
|
770
|
+
# Add CORS middleware for browser-based clients
|
|
771
|
+
app.add_middleware(
|
|
772
|
+
CORSMiddleware,
|
|
773
|
+
allow_origins=["*"],
|
|
774
|
+
allow_credentials=True,
|
|
775
|
+
allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
|
|
776
|
+
allow_headers=["*"],
|
|
777
|
+
expose_headers=["*"],
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
logger.info("MCP HTTP Server initialized with SSE transport")
|
|
781
|
+
logger.info("Endpoints: GET/POST/DELETE /mcp (Smithery), GET /sse, POST /messages")
|
|
782
|
+
|
|
783
|
+
return app
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
async def run_http_server(host: str = "0.0.0.0", port: int = 8080) -> None:
|
|
787
|
+
"""
|
|
788
|
+
Run MCP server with HTTP transport.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
host: Host to bind to (default: 0.0.0.0)
|
|
792
|
+
port: Port to listen on (default: 8080, or PORT env var)
|
|
793
|
+
"""
|
|
794
|
+
if not STARLETTE_AVAILABLE:
|
|
795
|
+
logger.error("HTTP transport requires starlette and uvicorn packages")
|
|
796
|
+
logger.error("Install with: pip install starlette uvicorn")
|
|
797
|
+
raise ImportError("starlette not available")
|
|
798
|
+
|
|
799
|
+
# Get port from environment if specified (Smithery sets this)
|
|
800
|
+
port = int(os.getenv("PORT", port))
|
|
801
|
+
|
|
802
|
+
logger.info(f"Starting Arch Linux MCP HTTP Server on {host}:{port}")
|
|
803
|
+
logger.info("Transport: Server-Sent Events (SSE)")
|
|
804
|
+
logger.info("Endpoints: GET/POST/DELETE /mcp (Smithery), GET /sse, POST /messages")
|
|
805
|
+
|
|
806
|
+
# Create app
|
|
807
|
+
app = create_app()
|
|
808
|
+
|
|
809
|
+
# Configure uvicorn
|
|
810
|
+
config = uvicorn.Config(
|
|
811
|
+
app=app,
|
|
812
|
+
host=host,
|
|
813
|
+
port=port,
|
|
814
|
+
log_level="info",
|
|
815
|
+
access_log=True,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Run server
|
|
819
|
+
server_instance = uvicorn.Server(config)
|
|
820
|
+
await server_instance.serve()
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def main_http():
|
|
824
|
+
"""Synchronous wrapper for HTTP server."""
|
|
825
|
+
asyncio.run(run_http_server())
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
if __name__ == "__main__":
|
|
829
|
+
main_http()
|