Coverage for little_loops / mcp_call.py: 0%
131 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""mcp-call — thin CLI wrapper for direct MCP tool invocation.
3Usage:
4 mcp-call server/tool-name '{"param": "value"}'
6Reads .mcp.json from the current working directory, spawns the MCP server
7subprocess, performs the JSON-RPC initialize handshake, calls tools/call,
8and writes the MCP response envelope to stdout.
10Exit codes:
11 0 → success (isError: false)
12 1 → tool_error (isError: true)
13 124 → timeout (transport-level)
14 127 → not_found (server or tool missing from .mcp.json)
15 2 → usage/config error
16"""
18from __future__ import annotations
20import json
21import subprocess
22import sys
23import threading
24import time
25from pathlib import Path
26from typing import Any
28MCP_PROTOCOL_VERSION = "2024-11-05"
29_DEFAULT_TIMEOUT = 30 # seconds
32def _load_mcp_config(cwd: Path) -> dict[str, Any]:
33 """Load .mcp.json from the current working directory.
35 Args:
36 cwd: Directory to search for .mcp.json
38 Returns:
39 Parsed .mcp.json content
41 Raises:
42 FileNotFoundError: If .mcp.json not found
43 json.JSONDecodeError: If .mcp.json is invalid JSON
44 """
45 mcp_path = cwd / ".mcp.json"
46 if not mcp_path.exists():
47 raise FileNotFoundError(f".mcp.json not found in {cwd}")
48 result: dict[str, Any] = json.loads(mcp_path.read_text())
49 return result
52def _find_server_config(mcp_config: dict[str, Any], server_name: str) -> dict[str, Any] | None:
53 """Find server configuration by name in .mcp.json.
55 Args:
56 mcp_config: Parsed .mcp.json content
57 server_name: Server name to find
59 Returns:
60 Server config dict, or None if not found
61 """
62 servers: dict[str, Any] = mcp_config.get("mcpServers", {})
63 found: dict[str, Any] | None = servers.get(server_name)
64 return found
67def _send_jsonrpc(
68 proc: subprocess.Popen[str],
69 request: dict[str, Any],
70 request_id: int | None,
71 timeout: float,
72) -> dict[str, Any] | None:
73 """Send a JSON-RPC request and optionally wait for a response.
75 Args:
76 proc: Running MCP server process
77 request: JSON-RPC request dict
78 request_id: Expected response id (None for notifications)
79 timeout: Seconds to wait for response
81 Returns:
82 Parsed response dict, or None for notifications
83 """
84 assert proc.stdin is not None
85 line = json.dumps(request) + "\n"
86 proc.stdin.write(line)
87 proc.stdin.flush()
89 if request_id is None:
90 # Notification — no response expected
91 return None
93 # Read response lines until we find the matching id or timeout
94 assert proc.stdout is not None
95 deadline = time.monotonic() + timeout
96 while time.monotonic() < deadline:
97 proc.stdout.readable()
98 try:
99 response_line = proc.stdout.readline()
100 except (OSError, ValueError):
101 break
102 if not response_line:
103 break
104 response_line = response_line.strip()
105 if not response_line:
106 continue
107 try:
108 response: dict[str, Any] = json.loads(response_line)
109 if response.get("id") == request_id:
110 return response
111 except json.JSONDecodeError:
112 continue
114 return None
117def call_mcp_tool(
118 server_name: str,
119 tool_name: str,
120 params: dict[str, Any],
121 timeout: int = _DEFAULT_TIMEOUT,
122 cwd: Path | None = None,
123) -> tuple[dict[str, Any], int]:
124 """Call an MCP tool via JSON-RPC and return the response envelope + exit code.
126 Args:
127 server_name: MCP server name (from .mcp.json mcpServers key)
128 tool_name: Tool name to call
129 params: Tool arguments
130 timeout: Timeout in seconds
131 cwd: Directory containing .mcp.json (defaults to Path.cwd())
133 Returns:
134 Tuple of (MCP response envelope dict, exit code)
135 Exit codes:
136 0 → success
137 1 → tool_error
138 124 → timeout
139 127 → not_found
140 2 → config/usage error
141 """
142 if cwd is None:
143 cwd = Path.cwd()
145 # Load .mcp.json
146 try:
147 mcp_config = _load_mcp_config(cwd)
148 except FileNotFoundError as e:
149 return {"isError": True, "content": [{"type": "text", "text": str(e)}]}, 127
150 except json.JSONDecodeError as e:
151 return {
152 "isError": True,
153 "content": [{"type": "text", "text": f"Invalid .mcp.json: {e}"}],
154 }, 2
156 # Find server config
157 server_config = _find_server_config(mcp_config, server_name)
158 if server_config is None:
159 return {
160 "isError": True,
161 "content": [{"type": "text", "text": f"Server '{server_name}' not found in .mcp.json"}],
162 }, 127
164 # Build server command
165 command = server_config.get("command")
166 if not command:
167 return {
168 "isError": True,
169 "content": [
170 {"type": "text", "text": f"Server '{server_name}' has no 'command' in .mcp.json"}
171 ],
172 }, 2
174 args = server_config.get("args", [])
175 env_overrides = server_config.get("env", {})
177 import os
179 env = os.environ.copy()
180 env.update({k: str(v) for k, v in env_overrides.items()})
182 cmd = [command] + args
184 # Spawn server
185 try:
186 proc = subprocess.Popen(
187 cmd,
188 stdin=subprocess.PIPE,
189 stdout=subprocess.PIPE,
190 stderr=subprocess.PIPE,
191 text=True,
192 env=env,
193 )
194 except FileNotFoundError:
195 return {
196 "isError": True,
197 "content": [{"type": "text", "text": f"Server command not found: {command}"}],
198 }, 127
200 stderr_chunks: list[str] = []
202 def _drain_stderr() -> None:
203 assert proc.stderr is not None
204 for line in proc.stderr:
205 stderr_chunks.append(line)
207 stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
208 stderr_thread.start()
210 try:
211 # Step 1: Send initialize request
212 init_response = _send_jsonrpc(
213 proc,
214 {
215 "jsonrpc": "2.0",
216 "id": 1,
217 "method": "initialize",
218 "params": {
219 "protocolVersion": MCP_PROTOCOL_VERSION,
220 "capabilities": {},
221 "clientInfo": {"name": "mcp-call", "version": "1.0"},
222 },
223 },
224 request_id=1,
225 timeout=timeout,
226 )
228 if init_response is None:
229 proc.kill()
230 return {
231 "isError": True,
232 "content": [
233 {"type": "text", "text": "No response to initialize request (timeout)"}
234 ],
235 }, 124
237 if "error" in init_response:
238 proc.kill()
239 return {
240 "isError": True,
241 "content": [
242 {"type": "text", "text": f"Initialize failed: {init_response['error']}"}
243 ],
244 }, 1
246 # Step 2: Send initialized notification
247 _send_jsonrpc(
248 proc,
249 {"jsonrpc": "2.0", "method": "notifications/initialized"},
250 request_id=None,
251 timeout=0,
252 )
254 # Step 3: Call tools/call
255 call_response = _send_jsonrpc(
256 proc,
257 {
258 "jsonrpc": "2.0",
259 "id": 2,
260 "method": "tools/call",
261 "params": {"name": tool_name, "arguments": params},
262 },
263 request_id=2,
264 timeout=timeout,
265 )
267 if call_response is None:
268 proc.kill()
269 return {
270 "isError": True,
271 "content": [
272 {"type": "text", "text": "No response to tools/call request (timeout)"}
273 ],
274 }, 124
276 if "error" in call_response:
277 err = call_response["error"]
278 # JSON-RPC method not found → tool not found
279 if isinstance(err, dict) and err.get("code") == -32601:
280 return {
281 "isError": True,
282 "content": [
283 {
284 "type": "text",
285 "text": f"Tool '{tool_name}' not found on server '{server_name}'",
286 }
287 ],
288 }, 127
289 return {
290 "isError": True,
291 "content": [{"type": "text", "text": f"tools/call error: {err}"}],
292 }, 1
294 # Unwrap result from JSON-RPC envelope
295 result = call_response.get("result", {})
296 is_error = result.get("isError", False)
297 exit_code = 1 if is_error else 0
298 return result, exit_code
300 except subprocess.TimeoutExpired:
301 proc.kill()
302 return {
303 "isError": True,
304 "content": [{"type": "text", "text": "MCP tool call timed out"}],
305 }, 124
306 finally:
307 try:
308 proc.terminate()
309 proc.wait(timeout=5)
310 except Exception:
311 proc.kill()
312 stderr_thread.join(timeout=5)
315def main() -> None:
316 """Entry point for mcp-call CLI."""
317 if len(sys.argv) < 3:
318 print(
319 'Usage: mcp-call server/tool-name \'{"param": "value"}\'',
320 file=sys.stderr,
321 )
322 sys.exit(2)
324 spec = sys.argv[1]
325 params_json = sys.argv[2]
327 # Parse server/tool-name
328 if "/" not in spec:
329 print(
330 f"Error: spec must be 'server/tool-name', got: {spec!r}",
331 file=sys.stderr,
332 )
333 sys.exit(2)
335 server_name, tool_name = spec.split("/", 1)
337 # Parse params JSON
338 try:
339 params = json.loads(params_json)
340 except json.JSONDecodeError as e:
341 print(f"Error: invalid params JSON: {e}", file=sys.stderr)
342 sys.exit(2)
344 if not isinstance(params, dict):
345 print("Error: params must be a JSON object", file=sys.stderr)
346 sys.exit(2)
348 envelope, exit_code = call_mcp_tool(
349 server_name=server_name,
350 tool_name=tool_name,
351 params=params,
352 )
354 print(json.dumps(envelope))
355 sys.exit(exit_code)
358if __name__ == "__main__":
359 main()