Coverage for src / osiris_cli / mcp.py: 0%
80 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-27 17:41 +0200
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-27 17:41 +0200
1"""
2Minimal MCP (Model Context Protocol) client scaffold.
3Refs for parity: Goose MCP (stdio/http/sse) and Crush MCP support.
4Currently disabled by default; enable via settings.mcp_enabled and settings.mcp_servers.
5"""
7import asyncio
8import json
9import os
10from typing import Any, Dict
12import httpx
14from .config import settings
17class MCPClient:
18 def __init__(self):
19 self.reload()
21 def reload(self):
22 self.servers = settings.mcp_servers or {}
24 async def health(self) -> Dict[str, Any]:
25 """Return basic health info for configured servers."""
26 results = {}
27 for name, cfg in self.servers.items():
28 try:
29 t = cfg.get("type")
30 if t == "http":
31 url = cfg.get("url")
32 async with httpx.AsyncClient(timeout=10) as client:
33 resp = await client.get(url)
34 results[name] = {"status": "ok", "http_status": resp.status_code}
35 elif t == "stdio":
36 # try to run command --version or just spawn
37 cmd = cfg.get("command")
38 if cmd:
39 proc = await asyncio.create_subprocess_exec(
40 cmd, *cfg.get("args", []),
41 stdin=asyncio.subprocess.PIPE,
42 stdout=asyncio.subprocess.PIPE,
43 stderr=asyncio.subprocess.PIPE
44 )
45 proc.kill()
46 results[name] = {"status": "ok", "note": "spawnable"}
47 else:
48 results[name] = {"status": "error", "error": "no command"}
49 else:
50 results[name] = {"status": "configured", "note": "health not implemented"}
51 except Exception as e:
52 results[name] = {"status": "error", "error": str(e)}
53 return results
55 async def call(self, server: str, payload: Dict[str, Any]) -> Dict[str, Any]:
56 if not settings.mcp_enabled:
57 return {"error": "MCP disabled"}
58 cfg = self.servers.get(server)
59 if not cfg:
60 return {"error": "Unknown MCP server"}
61 t = cfg.get("type")
62 if t == "http":
63 return await self._call_http(cfg, payload)
64 elif t == "stdio":
65 return await self._call_stdio(cfg, payload)
66 elif t == "sse":
67 return {"error": "SSE not implemented"}
68 return {"error": f"Unsupported MCP type: {t}"}
70 async def _call_http(self, cfg: dict, payload: Dict[str, Any]) -> Dict[str, Any]:
71 url = cfg.get("url")
72 headers_raw = cfg.get("headers", {})
73 # Expand environment placeholders like "${GITHUB_TOKEN}" in header values.
74 headers: Dict[str, str] = {}
75 for key, value in headers_raw.items():
76 if isinstance(value, str):
77 headers[key] = os.path.expandvars(value)
78 else:
79 headers[key] = str(value)
80 timeout = cfg.get("timeout", 20)
81 method = (cfg.get("method") or "POST").upper()
82 if not url:
83 return {"error": "Missing url"}
84 try:
85 async with httpx.AsyncClient(timeout=timeout) as client:
86 if method == "GET":
87 resp = await client.get(url, params=payload, headers=headers)
88 else:
89 resp = await client.post(url, json=payload, headers=headers)
90 return {
91 "status": resp.status_code,
92 "body": resp.text[:8000]
93 }
94 except Exception as e:
95 return {"error": str(e)}
97 async def _call_stdio(self, cfg: dict, payload: Dict[str, Any]) -> Dict[str, Any]:
98 cmd = cfg.get("command")
99 args = cfg.get("args", [])
100 env = cfg.get("env", {})
101 timeout = cfg.get("timeout", 20)
102 if not cmd:
103 return {"error": "No command provided"}
104 try:
105 proc = await asyncio.create_subprocess_exec(
106 cmd, *args,
107 stdin=asyncio.subprocess.PIPE,
108 stdout=asyncio.subprocess.PIPE,
109 stderr=asyncio.subprocess.PIPE,
110 env={**env, **dict(**{k: str(v) for k, v in env.items()})} if env else None
111 )
112 out, err = await asyncio.wait_for(proc.communicate(json.dumps(payload).encode()), timeout=timeout)
113 return {
114 "status": proc.returncode,
115 "stdout": out.decode(errors="ignore")[:8000],
116 "stderr": err.decode(errors="ignore")[:2000],
117 }
118 except Exception as e:
119 return {"error": str(e)}
122mcp_client = MCPClient()