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.
@@ -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()