maverick-mcp-server 0.1.2__tar.gz

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,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: maverick-mcp-server
3
+ Version: 0.1.2
4
+ Summary: Model Context Protocol server for Maverick (exposes the swarm to MCP clients)
5
+ Author: cdayAI
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cdayAI/maverick
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maverick-agent>=0.1
11
+
12
+ # maverick-mcp-server
13
+
14
+ A Model Context Protocol (MCP) server that exposes Maverick's agent
15
+ loop as a set of MCP tools. Any MCP-compatible client (Claude Code,
16
+ Claude Desktop, Cursor, etc.) can drive Maverick over stdio JSON-RPC.
17
+
18
+ ## Why
19
+
20
+ Maverick is a swarm of agents with persistent memory, budget caps,
21
+ and verifier loops. Most MCP clients are single-turn. By plugging
22
+ Maverick in via MCP, you get:
23
+
24
+ - A "think hard for an hour" tool from inside Claude Code
25
+ - Persistent goals that survive editor restarts
26
+ - Per-role model routing controlled by your `~/.maverick/config.toml`
27
+ - Auto-distilled skills that compound across sessions
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install -e ./packages/maverick-mcp
33
+ ```
34
+
35
+ ## Wire into Claude Code
36
+
37
+ Add to your Claude Code MCP config (typically
38
+ `~/.config/claude-code/mcp.json` or similar):
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "maverick": {
44
+ "command": "maverick-mcp",
45
+ "args": []
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Then restart Claude Code. The Maverick tools should appear under the
52
+ MCP servers menu.
53
+
54
+ ## Tools exposed
55
+
56
+ | Tool | What it does |
57
+ |---|---|
58
+ | `maverick_start` | Start a new goal, run the swarm, return the final answer |
59
+ | `maverick_status` | List recent goals + open questions |
60
+ | `maverick_resume` | Resume a paused goal |
61
+ | `maverick_answer` | Answer a queued question |
62
+ | `maverick_skill_install` | Install a SKILL.md from URL / gh:org/repo / local path |
63
+ | `maverick_skills_list` | List installed / distilled skills |
64
+ | `maverick_fact_set` | Set a fact in the world model |
65
+ | `maverick_facts_get` | Get all known facts |
66
+
67
+ All calls go through `maverick.orchestrator.run_goal` -- same Shield
68
+ chokepoints, same budget caps, same per-role model routing.
69
+
70
+ ## Protocol
71
+
72
+ Minimal JSON-RPC 2.0 over stdio, matching the MCP 2024-11-05 spec
73
+ (`initialize`, `tools/list`, `tools/call`). No external dependencies.
74
+ The full `mcp` Python SDK is an option for a future hardening pass;
75
+ this hand-rolled version keeps the dependency footprint tiny.
@@ -0,0 +1,64 @@
1
+ # maverick-mcp-server
2
+
3
+ A Model Context Protocol (MCP) server that exposes Maverick's agent
4
+ loop as a set of MCP tools. Any MCP-compatible client (Claude Code,
5
+ Claude Desktop, Cursor, etc.) can drive Maverick over stdio JSON-RPC.
6
+
7
+ ## Why
8
+
9
+ Maverick is a swarm of agents with persistent memory, budget caps,
10
+ and verifier loops. Most MCP clients are single-turn. By plugging
11
+ Maverick in via MCP, you get:
12
+
13
+ - A "think hard for an hour" tool from inside Claude Code
14
+ - Persistent goals that survive editor restarts
15
+ - Per-role model routing controlled by your `~/.maverick/config.toml`
16
+ - Auto-distilled skills that compound across sessions
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install -e ./packages/maverick-mcp
22
+ ```
23
+
24
+ ## Wire into Claude Code
25
+
26
+ Add to your Claude Code MCP config (typically
27
+ `~/.config/claude-code/mcp.json` or similar):
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "maverick": {
33
+ "command": "maverick-mcp",
34
+ "args": []
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Then restart Claude Code. The Maverick tools should appear under the
41
+ MCP servers menu.
42
+
43
+ ## Tools exposed
44
+
45
+ | Tool | What it does |
46
+ |---|---|
47
+ | `maverick_start` | Start a new goal, run the swarm, return the final answer |
48
+ | `maverick_status` | List recent goals + open questions |
49
+ | `maverick_resume` | Resume a paused goal |
50
+ | `maverick_answer` | Answer a queued question |
51
+ | `maverick_skill_install` | Install a SKILL.md from URL / gh:org/repo / local path |
52
+ | `maverick_skills_list` | List installed / distilled skills |
53
+ | `maverick_fact_set` | Set a fact in the world model |
54
+ | `maverick_facts_get` | Get all known facts |
55
+
56
+ All calls go through `maverick.orchestrator.run_goal` -- same Shield
57
+ chokepoints, same budget caps, same per-role model routing.
58
+
59
+ ## Protocol
60
+
61
+ Minimal JSON-RPC 2.0 over stdio, matching the MCP 2024-11-05 spec
62
+ (`initialize`, `tools/list`, `tools/call`). No external dependencies.
63
+ The full `mcp` Python SDK is an option for a future hardening pass;
64
+ this hand-rolled version keeps the dependency footprint tiny.
@@ -0,0 +1,3 @@
1
+ """MCP server for Maverick."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,164 @@
1
+ """Streamable HTTP transport for Maverick's MCP server (spec 2025-11-25).
2
+
3
+ The stdio JSON-RPC transport in `server.py` works great for desktop
4
+ clients (Claude Desktop, Cursor) that spawn Maverick as a subprocess.
5
+ For hosted Maverick — VPS deployments, multi-tenant setups, MCP
6
+ gateways like Composio / MintMCP / Cloudflare — clients need an HTTP
7
+ endpoint.
8
+
9
+ This module ships the Streamable HTTP transport per MCP 2025-11-25:
10
+ single POST endpoint that accepts JSON-RPC requests and returns
11
+ JSON-RPC responses, with optional Server-Sent Events for streaming
12
+ results (long-running tools, sampling).
13
+
14
+ Usage::
15
+
16
+ maverick mcp --http --port 8771 --token $MAVERICK_MCP_TOKEN
17
+
18
+ Security:
19
+ - Bearer-token auth required when MAVERICK_MCP_TOKEN is set.
20
+ - Per the 2025-11-25 spec, server runs as an OAuth resource server;
21
+ full OAuth flow is a v0.3 follow-up. Bearer is the simpler path
22
+ that works today.
23
+ - All requests are routed through the same MCPServer.handle_*
24
+ dispatch as stdio, so the security audit you do on the stdio
25
+ side covers HTTP too.
26
+
27
+ Spec deprecation note: the older SSE-only transport is EOL mid-2026
28
+ across major clients; we ship Streamable HTTP as the GA transport.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import hmac
33
+ import logging
34
+ import os
35
+ from typing import Optional
36
+
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+
41
+ try:
42
+ from fastapi import FastAPI, Header, HTTPException, Request
43
+ from fastapi.responses import JSONResponse, StreamingResponse
44
+ _HAVE_FASTAPI = True
45
+ except ImportError:
46
+ _HAVE_FASTAPI = False
47
+ FastAPI = Header = HTTPException = Request = None # type: ignore
48
+ JSONResponse = StreamingResponse = None # type: ignore
49
+
50
+
51
+ def _check_bearer(authorization: Optional[str]) -> bool:
52
+ """Bearer-token gate for network HTTP transport.
53
+
54
+ Unlike stdio, HTTP requests are network-reachable; token auth is
55
+ therefore mandatory and a missing MAVERICK_MCP_TOKEN rejects all
56
+ requests.
57
+ """
58
+ expected = os.environ.get("MAVERICK_MCP_TOKEN")
59
+ if not expected:
60
+ return False
61
+ if not authorization or not authorization.startswith("Bearer "):
62
+ return False
63
+ given = authorization[len("Bearer "):].strip()
64
+ return hmac.compare_digest(expected, given)
65
+
66
+
67
+ def build_app(server) -> "FastAPI":
68
+ """Wrap an MCPServer instance in a Streamable HTTP transport.
69
+
70
+ `server` is an instance of `maverick_mcp.server.MCPServer`. We
71
+ reuse its handle_* methods 1:1; this module is just the transport.
72
+ """
73
+ if not _HAVE_FASTAPI:
74
+ raise ImportError(
75
+ "fastapi not installed; install maverick-mcp-server[http] to enable "
76
+ "the streamable HTTP transport"
77
+ )
78
+
79
+ app = FastAPI(
80
+ title="Maverick MCP HTTP",
81
+ description=(
82
+ "MCP 2025-11-25 streamable HTTP transport. POST a JSON-RPC "
83
+ "request; receive a JSON-RPC response or an SSE stream."
84
+ ),
85
+ version="0.2.0",
86
+ )
87
+
88
+ @app.post("/mcp")
89
+ async def mcp_endpoint(
90
+ request: "Request",
91
+ authorization: Optional[str] = Header(None),
92
+ ):
93
+ if not _check_bearer(authorization):
94
+ raise HTTPException(status_code=401, detail="invalid bearer")
95
+ body = await request.json()
96
+ is_notification = "id" not in body
97
+ request_id = body.get("id")
98
+ method = body.get("method", "")
99
+ params = body.get("params", {}) or {}
100
+
101
+ # Route via the existing MCPServer dispatcher. We piggyback on
102
+ # its method table by calling the appropriate handle_* directly.
103
+ try:
104
+ result = _dispatch(server, method, params)
105
+ except Exception as e:
106
+ from .server import _ProtocolError
107
+ if isinstance(e, _ProtocolError):
108
+ code, message = e.code, e.message
109
+ else:
110
+ code, message = -32603, f"internal error: {e}"
111
+ if is_notification:
112
+ return JSONResponse({}, status_code=204)
113
+ return JSONResponse({
114
+ "jsonrpc": "2.0", "id": request_id,
115
+ "error": {"code": code, "message": message},
116
+ })
117
+
118
+ if is_notification:
119
+ return JSONResponse({}, status_code=204)
120
+ return JSONResponse({
121
+ "jsonrpc": "2.0", "id": request_id, "result": result,
122
+ })
123
+
124
+ @app.get("/healthz")
125
+ async def healthz():
126
+ return {"status": "ok", "transport": "http"}
127
+
128
+ return app
129
+
130
+
131
+ _METHOD_MAP = {
132
+ "initialize": "handle_initialize",
133
+ "tools/list": "handle_tools_list",
134
+ "tools/call": "handle_tools_call",
135
+ "resources/list": "handle_resources_list",
136
+ "resources/read": "handle_resources_read",
137
+ "prompts/list": "handle_prompts_list",
138
+ "prompts/get": "handle_prompts_get",
139
+ }
140
+
141
+
142
+ def _dispatch(server, method: str, params: dict) -> dict:
143
+ """Route a JSON-RPC method to the corresponding handle_* method."""
144
+ if method == "notifications/initialized":
145
+ return {}
146
+ if method == "ping":
147
+ return {}
148
+ handler_name = _METHOD_MAP.get(method)
149
+ if not handler_name:
150
+ from .server import _ProtocolError
151
+ raise _ProtocolError(-32601, f"method not found: {method}")
152
+ handler = getattr(server, handler_name)
153
+ return handler(params)
154
+
155
+
156
+ def serve(host: str = "127.0.0.1", port: int = 8771) -> None:
157
+ """Run the HTTP transport on host:port. Blocking."""
158
+ import uvicorn
159
+ from .server import MCPServer
160
+
161
+ server = MCPServer()
162
+ app = build_app(server)
163
+ log.info("MCP Streamable HTTP transport on http://%s:%d/mcp", host, port)
164
+ uvicorn.run(app, host=host, port=port, log_level="info")
@@ -0,0 +1,506 @@
1
+ """MCP server for Maverick."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import sys
7
+ import traceback
8
+ from typing import Any
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ stream=sys.stderr,
15
+ format="%(asctime)s [%(levelname)s] mcp: %(message)s",
16
+ )
17
+
18
+ # Protocol version. MCP 2025-11-25 ships Tasks / Resources / Elicitation /
19
+ # Sampling / MCP Apps; we negotiate down to the older spec when a client
20
+ # advertises that, but our initialize response is on the current one.
21
+ PROTOCOL_VERSION = "2025-11-25"
22
+ PROTOCOL_VERSION_FALLBACK = "2024-11-05"
23
+ SERVER_NAME = "maverick"
24
+ SERVER_VERSION = "0.2.0"
25
+
26
+
27
+ class _ProtocolError(Exception):
28
+ """Raised for JSON-RPC protocol-level errors (unknown method/tool, bad params).
29
+
30
+ The `run()` loop catches this and emits a structured JSON-RPC error
31
+ response (per MCP 2024-11-05 spec). Surface in tests via
32
+ pytest.raises -- it deliberately does NOT collapse into an isError
33
+ envelope because Claude Desktop / Cursor treat those differently.
34
+ """
35
+ def __init__(self, code: int, message: str):
36
+ super().__init__(message)
37
+ self.code = code
38
+ self.message = message
39
+
40
+
41
+ TOOLS: list[dict[str, Any]] = [
42
+ {
43
+ "name": "maverick_start",
44
+ "description": (
45
+ "Start a new goal in Maverick's recursive multi-agent swarm. "
46
+ "Returns the final answer after the swarm completes. Long-running."
47
+ ),
48
+ "inputSchema": {
49
+ "type": "object",
50
+ "properties": {
51
+ "title": {"type": "string"},
52
+ "description": {"type": "string"},
53
+ "max_dollars": {"type": "number", "default": 5.0},
54
+ "max_wall_seconds": {"type": "number", "default": 3600},
55
+ "max_depth": {"type": "integer", "default": 3},
56
+ },
57
+ "required": ["title"],
58
+ },
59
+ },
60
+ {
61
+ "name": "maverick_status",
62
+ "description": "List recent goals and any open questions.",
63
+ "inputSchema": {"type": "object", "properties": {}},
64
+ },
65
+ {
66
+ "name": "maverick_resume",
67
+ "description": "Resume a paused goal by id.",
68
+ "inputSchema": {
69
+ "type": "object",
70
+ "properties": {"goal_id": {"type": "integer"}},
71
+ },
72
+ },
73
+ {
74
+ "name": "maverick_answer",
75
+ "description": "Answer a queued question.",
76
+ "inputSchema": {
77
+ "type": "object",
78
+ "properties": {
79
+ "question_id": {"type": "integer"},
80
+ "answer": {"type": "string"},
81
+ },
82
+ "required": ["question_id", "answer"],
83
+ },
84
+ },
85
+ {
86
+ "name": "maverick_skill_install",
87
+ "description": "Install a SKILL.md from a URL or gh:org/repo[:path].",
88
+ "inputSchema": {
89
+ "type": "object",
90
+ "properties": {"source": {"type": "string"}},
91
+ "required": ["source"],
92
+ },
93
+ },
94
+ {
95
+ "name": "maverick_skills_list",
96
+ "description": "List installed / distilled skills.",
97
+ "inputSchema": {"type": "object", "properties": {}},
98
+ },
99
+ {
100
+ "name": "maverick_fact_set",
101
+ "description": "Store a fact in the persistent world model.",
102
+ "inputSchema": {
103
+ "type": "object",
104
+ "properties": {
105
+ "key": {"type": "string"},
106
+ "value": {"type": "string"},
107
+ },
108
+ "required": ["key", "value"],
109
+ },
110
+ },
111
+ {
112
+ "name": "maverick_facts_get",
113
+ "description": "Get all known facts.",
114
+ "inputSchema": {"type": "object", "properties": {}},
115
+ },
116
+ ]
117
+
118
+ _TOOL_NAMES = {t["name"] for t in TOOLS}
119
+
120
+
121
+ class MCPServer:
122
+ def __init__(self):
123
+ self._initialized = False
124
+ self._shield = self._build_shield()
125
+
126
+ @staticmethod
127
+ def _build_shield():
128
+ try:
129
+ from maverick_shield import Shield
130
+ return Shield.from_config()
131
+ except Exception:
132
+ return None
133
+
134
+ def handle_initialize(self, params: dict) -> dict:
135
+ self._initialized = True
136
+ # Negotiate down if the client only speaks the older spec.
137
+ client_ver = params.get("protocolVersion", "")
138
+ version = PROTOCOL_VERSION
139
+ if client_ver and client_ver < "2025-11-25":
140
+ version = PROTOCOL_VERSION_FALLBACK
141
+ return {
142
+ "protocolVersion": version,
143
+ "capabilities": {
144
+ "tools": {"listChanged": False},
145
+ # Resources: goals/skills/facts exposed as URI-addressable
146
+ # objects for clients (Claude Desktop, Cursor) that support
147
+ # the 2025-11-25 spec.
148
+ "resources": {"subscribe": False, "listChanged": False},
149
+ # Prompts: ship templated goal patterns so clients can
150
+ # surface "start a research run" / "plan a trip" without
151
+ # the user typing the prompt themselves.
152
+ "prompts": {"listChanged": False},
153
+ # Elicitation: server can ask the user a follow-up question
154
+ # (replaces our ask_user tool in 2025-11-25-aware clients).
155
+ # We declare it; the actual call falls back to tool-result
156
+ # when the client doesn't support it.
157
+ "elicitation": {},
158
+ },
159
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
160
+ }
161
+
162
+ def handle_tools_list(self, params: dict) -> dict:
163
+ return {"tools": TOOLS}
164
+
165
+ # ---- 2025-11-25 Resources -----------------------------------------
166
+
167
+ def handle_resources_list(self, params: dict) -> dict:
168
+ """Expose Maverick state as MCP Resources.
169
+
170
+ - maverick://goals — list of active/recent goals
171
+ - maverick://skills — installed skills
172
+ """
173
+ resources = [
174
+ {
175
+ "uri": "maverick://goals",
176
+ "name": "All recent goals",
177
+ "mimeType": "application/json",
178
+ },
179
+ {
180
+ "uri": "maverick://skills",
181
+ "name": "Installed skills",
182
+ "mimeType": "application/json",
183
+ },
184
+ ]
185
+ return {"resources": resources}
186
+
187
+ def handle_resources_read(self, params: dict) -> dict:
188
+ uri = params.get("uri", "")
189
+ if not uri.startswith("maverick://"):
190
+ raise _ProtocolError(-32602, f"unsupported uri scheme: {uri}")
191
+ path = uri[len("maverick://"):]
192
+ from maverick.world_model import DEFAULT_DB, WorldModel
193
+ wm = WorldModel(DEFAULT_DB)
194
+
195
+ if path == "goals":
196
+ data = [
197
+ {"id": g.id, "status": g.status, "title": g.title}
198
+ for g in wm.list_goals()[-20:]
199
+ ]
200
+ elif path == "skills":
201
+ try:
202
+ from maverick.skills import load_skills
203
+ data = [
204
+ {"name": s.name, "triggers": s.triggers,
205
+ "tools_needed": s.tools_needed}
206
+ for s in load_skills()
207
+ ]
208
+ except Exception:
209
+ data = []
210
+ else:
211
+ raise _ProtocolError(-32602, f"unknown resource path: {uri}")
212
+
213
+ return {
214
+ "contents": [{
215
+ "uri": uri,
216
+ "mimeType": "application/json",
217
+ "text": json.dumps(data, indent=2, default=str),
218
+ }],
219
+ }
220
+
221
+ # ---- 2025-11-25 Prompts -------------------------------------------
222
+
223
+ def handle_prompts_list(self, params: dict) -> dict:
224
+ return {"prompts": [
225
+ {
226
+ "name": "research_topic",
227
+ "description": "Spawn a research swarm to investigate a topic.",
228
+ "arguments": [
229
+ {"name": "topic", "description": "What to research",
230
+ "required": True},
231
+ {"name": "depth", "description": "shallow / medium / deep",
232
+ "required": False},
233
+ ],
234
+ },
235
+ {
236
+ "name": "draft_message",
237
+ "description": "Draft an email / message in a given tone.",
238
+ "arguments": [
239
+ {"name": "recipient", "required": True},
240
+ {"name": "intent", "required": True},
241
+ {"name": "tone", "required": False},
242
+ ],
243
+ },
244
+ {
245
+ "name": "compare_options",
246
+ "description": "Compare 2-N options against a criterion list.",
247
+ "arguments": [
248
+ {"name": "options", "required": True},
249
+ {"name": "criteria", "required": True},
250
+ ],
251
+ },
252
+ ]}
253
+
254
+ def handle_prompts_get(self, params: dict) -> dict:
255
+ name = params.get("name", "")
256
+ args = params.get("arguments", {}) or {}
257
+ templates = {
258
+ "research_topic": (
259
+ "Spawn a research swarm to investigate: {topic}. "
260
+ "Depth: {depth}. Verify findings before FINAL."
261
+ ),
262
+ "draft_message": (
263
+ "Draft a message to {recipient} with intent: {intent}. "
264
+ "Tone: {tone}. Keep it concise."
265
+ ),
266
+ "compare_options": (
267
+ "Compare these options: {options}. Use criteria: {criteria}. "
268
+ "Build a table; recommend one."
269
+ ),
270
+ }
271
+ if name not in templates:
272
+ raise _ProtocolError(-32602, f"unknown prompt: {name}")
273
+ try:
274
+ text = templates[name].format(**{
275
+ "topic": args.get("topic", ""),
276
+ "depth": args.get("depth", "medium"),
277
+ "recipient": args.get("recipient", ""),
278
+ "intent": args.get("intent", ""),
279
+ "tone": args.get("tone", "professional"),
280
+ "options": args.get("options", ""),
281
+ "criteria": args.get("criteria", ""),
282
+ })
283
+ except KeyError as e:
284
+ raise _ProtocolError(-32602, f"missing argument: {e}") from e
285
+ return {
286
+ "description": f"Maverick prompt: {name}",
287
+ "messages": [{
288
+ "role": "user",
289
+ "content": {"type": "text", "text": text},
290
+ }],
291
+ }
292
+
293
+ def handle_tools_call(self, params: dict) -> dict:
294
+ name = params.get("name")
295
+ if name not in _TOOL_NAMES:
296
+ raise _ProtocolError(-32602, f"unknown tool: {name!r}")
297
+ arguments = params.get("arguments", {}) or {}
298
+ tool_spec = next(t for t in TOOLS if t["name"] == name)
299
+ required = tool_spec.get("inputSchema", {}).get("required", []) or []
300
+ missing = [r for r in required if r not in arguments]
301
+ if missing:
302
+ raise _ProtocolError(-32602, f"missing required argument(s) for {name}: {missing}")
303
+ try:
304
+ result = self._dispatch_tool(name, arguments)
305
+ except Exception as e:
306
+ return {
307
+ "isError": True,
308
+ "content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}],
309
+ }
310
+ if self._shield is not None:
311
+ verdict = self._shield.scan_output(result)
312
+ if not verdict.allowed:
313
+ return {
314
+ "isError": True,
315
+ "content": [{"type": "text", "text": f"⚠ Output blocked: {'; '.join(verdict.reasons)}"}],
316
+ }
317
+ return {
318
+ "isError": False,
319
+ "content": [{"type": "text", "text": result}],
320
+ }
321
+
322
+ def _dispatch_tool(self, name: str, args: dict) -> str:
323
+ if name == "maverick_start":
324
+ return self._tool_start(args)
325
+ if name == "maverick_status":
326
+ return self._tool_status()
327
+ if name == "maverick_resume":
328
+ return self._tool_resume(args)
329
+ if name == "maverick_answer":
330
+ return self._tool_answer(args)
331
+ if name == "maverick_skill_install":
332
+ return self._tool_skill_install(args)
333
+ if name == "maverick_skills_list":
334
+ return self._tool_skills_list()
335
+ if name == "maverick_fact_set":
336
+ return self._tool_fact_set(args)
337
+ if name == "maverick_facts_get":
338
+ return self._tool_facts_get()
339
+ raise _ProtocolError(-32602, f"unknown tool {name!r}")
340
+
341
+ def _tool_start(self, args: dict) -> str:
342
+ from maverick.budget import Budget
343
+ from maverick.llm import LLM
344
+ from maverick.orchestrator import run_goal_sync
345
+ from maverick.sandbox import build_sandbox
346
+ from maverick.world_model import WorldModel
347
+ title = args["title"]
348
+ description = args.get("description", "")
349
+ if self._shield is not None:
350
+ verdict = self._shield.scan_input(f"{title}\n{description}")
351
+ if not verdict.allowed:
352
+ return f"⚠ Blocked: {'; '.join(verdict.reasons)}"
353
+ budget = Budget(
354
+ max_dollars=float(args.get("max_dollars", 5.0)),
355
+ max_wall_seconds=float(args.get("max_wall_seconds", 3600)),
356
+ )
357
+ world = WorldModel()
358
+ goal_id = world.create_goal(title, description)
359
+ llm = LLM()
360
+ sandbox = build_sandbox()
361
+ return run_goal_sync(
362
+ llm, world, budget, goal_id, sandbox=sandbox,
363
+ max_depth=int(args.get("max_depth", 3)),
364
+ )
365
+
366
+ def _tool_status(self) -> str:
367
+ from maverick.world_model import WorldModel
368
+ w = WorldModel()
369
+ goals = w.list_goals()
370
+ if not goals:
371
+ return "no goals yet"
372
+ lines = [f"#{g.id} [{g.status}] {g.title}" for g in goals[-10:]]
373
+ for q in w.open_questions():
374
+ lines.append(f" open question #{q.id} (goal {q.goal_id}): {q.question}")
375
+ return "\n".join(lines)
376
+
377
+ def _tool_resume(self, args: dict) -> str:
378
+ from maverick.budget import Budget
379
+ from maverick.llm import LLM
380
+ from maverick.orchestrator import run_goal_sync
381
+ from maverick.world_model import WorldModel
382
+ w = WorldModel()
383
+ goal_id = args.get("goal_id")
384
+ if goal_id is None:
385
+ g = w.active_goal()
386
+ if not g:
387
+ return "no active or blocked goal to resume"
388
+ goal_id = g.id
389
+ return run_goal_sync(LLM(), w, Budget(), int(goal_id))
390
+
391
+ def _tool_answer(self, args: dict) -> str:
392
+ from maverick.world_model import WorldModel
393
+ w = WorldModel()
394
+ w.answer(int(args["question_id"]), str(args["answer"]))
395
+ return f"answered #{args['question_id']}"
396
+
397
+ def _tool_skill_install(self, args: dict) -> str:
398
+ from maverick.skills import install_skill
399
+ s = install_skill(args["source"], trusted_local=True)
400
+ return f"installed: {s.name} -> {s.path}"
401
+
402
+ def _tool_skills_list(self) -> str:
403
+ from maverick.skills import load_skills
404
+ items = load_skills()
405
+ if not items:
406
+ return "no skills installed"
407
+ return "\n".join(f"{s.name}: {', '.join(s.triggers[:3])}" for s in items)
408
+
409
+ def _tool_fact_set(self, args: dict) -> str:
410
+ from maverick.world_model import WorldModel
411
+ w = WorldModel()
412
+ w.upsert_fact(args["key"], args["value"])
413
+ return f"set {args['key']}"
414
+
415
+ def _tool_facts_get(self) -> str:
416
+ from maverick.world_model import WorldModel
417
+ w = WorldModel()
418
+ facts = w.get_facts()
419
+ if not facts:
420
+ return "no facts known"
421
+ return "\n".join(f"{k}: {v}" for k, v in facts.items())
422
+
423
+ def _send(self, message: dict) -> None:
424
+ sys.stdout.write(json.dumps(message) + "\n")
425
+ sys.stdout.flush()
426
+
427
+ def _send_error(self, request_id: Any, code: int, message: str) -> None:
428
+ self._send({
429
+ "jsonrpc": "2.0",
430
+ "id": request_id,
431
+ "error": {"code": code, "message": message},
432
+ })
433
+
434
+ def _send_result(self, request_id: Any, result: dict) -> None:
435
+ self._send({"jsonrpc": "2.0", "id": request_id, "result": result})
436
+
437
+ def run(self) -> None:
438
+ log.info("Maverick MCP server starting (protocol %s)", PROTOCOL_VERSION)
439
+ for line in sys.stdin:
440
+ line = line.strip()
441
+ if not line:
442
+ continue
443
+ try:
444
+ msg = json.loads(line)
445
+ except json.JSONDecodeError as e:
446
+ log.warning("bad JSON: %s", e)
447
+ continue
448
+ method = msg.get("method")
449
+ request_id = msg.get("id")
450
+ params = msg.get("params", {}) or {}
451
+ is_notification = request_id is None
452
+ try:
453
+ if method == "initialize":
454
+ self._send_result(request_id, self.handle_initialize(params))
455
+ elif method == "tools/list":
456
+ self._send_result(request_id, self.handle_tools_list(params))
457
+ elif method == "tools/call":
458
+ self._send_result(request_id, self.handle_tools_call(params))
459
+ elif method == "resources/list":
460
+ self._send_result(request_id, self.handle_resources_list(params))
461
+ elif method == "resources/read":
462
+ self._send_result(request_id, self.handle_resources_read(params))
463
+ elif method == "prompts/list":
464
+ self._send_result(request_id, self.handle_prompts_list(params))
465
+ elif method == "prompts/get":
466
+ self._send_result(request_id, self.handle_prompts_get(params))
467
+ elif method == "notifications/initialized":
468
+ pass
469
+ elif method == "ping":
470
+ if not is_notification:
471
+ self._send_result(request_id, {})
472
+ else:
473
+ if not is_notification:
474
+ self._send_error(request_id, -32601, f"method not found: {method}")
475
+ except _ProtocolError as e:
476
+ if not is_notification:
477
+ self._send_error(request_id, e.code, e.message)
478
+ except Exception as e:
479
+ log.exception("handler error")
480
+ if not is_notification:
481
+ self._send_error(
482
+ request_id, -32603,
483
+ f"internal error: {type(e).__name__}: {e}\n{traceback.format_exc()}",
484
+ )
485
+
486
+
487
+ def main() -> None:
488
+ """Entry point. Defaults to stdio transport (Claude Desktop /
489
+ Cursor compatible). Pass `--http` for the Streamable HTTP
490
+ transport (hosted Maverick, MCP gateways)."""
491
+ import argparse
492
+ ap = argparse.ArgumentParser(prog="maverick-mcp")
493
+ ap.add_argument("--http", action="store_true",
494
+ help="Serve over Streamable HTTP instead of stdio")
495
+ ap.add_argument("--host", default="127.0.0.1")
496
+ ap.add_argument("--port", type=int, default=8771)
497
+ args = ap.parse_args()
498
+ if args.http:
499
+ from .http_transport import serve
500
+ serve(host=args.host, port=args.port)
501
+ else:
502
+ MCPServer().run()
503
+
504
+
505
+ if __name__ == "__main__":
506
+ main()
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: maverick-mcp-server
3
+ Version: 0.1.2
4
+ Summary: Model Context Protocol server for Maverick (exposes the swarm to MCP clients)
5
+ Author: cdayAI
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cdayAI/maverick
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maverick-agent>=0.1
11
+
12
+ # maverick-mcp-server
13
+
14
+ A Model Context Protocol (MCP) server that exposes Maverick's agent
15
+ loop as a set of MCP tools. Any MCP-compatible client (Claude Code,
16
+ Claude Desktop, Cursor, etc.) can drive Maverick over stdio JSON-RPC.
17
+
18
+ ## Why
19
+
20
+ Maverick is a swarm of agents with persistent memory, budget caps,
21
+ and verifier loops. Most MCP clients are single-turn. By plugging
22
+ Maverick in via MCP, you get:
23
+
24
+ - A "think hard for an hour" tool from inside Claude Code
25
+ - Persistent goals that survive editor restarts
26
+ - Per-role model routing controlled by your `~/.maverick/config.toml`
27
+ - Auto-distilled skills that compound across sessions
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install -e ./packages/maverick-mcp
33
+ ```
34
+
35
+ ## Wire into Claude Code
36
+
37
+ Add to your Claude Code MCP config (typically
38
+ `~/.config/claude-code/mcp.json` or similar):
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "maverick": {
44
+ "command": "maverick-mcp",
45
+ "args": []
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Then restart Claude Code. The Maverick tools should appear under the
52
+ MCP servers menu.
53
+
54
+ ## Tools exposed
55
+
56
+ | Tool | What it does |
57
+ |---|---|
58
+ | `maverick_start` | Start a new goal, run the swarm, return the final answer |
59
+ | `maverick_status` | List recent goals + open questions |
60
+ | `maverick_resume` | Resume a paused goal |
61
+ | `maverick_answer` | Answer a queued question |
62
+ | `maverick_skill_install` | Install a SKILL.md from URL / gh:org/repo / local path |
63
+ | `maverick_skills_list` | List installed / distilled skills |
64
+ | `maverick_fact_set` | Set a fact in the world model |
65
+ | `maverick_facts_get` | Get all known facts |
66
+
67
+ All calls go through `maverick.orchestrator.run_goal` -- same Shield
68
+ chokepoints, same budget caps, same per-role model routing.
69
+
70
+ ## Protocol
71
+
72
+ Minimal JSON-RPC 2.0 over stdio, matching the MCP 2024-11-05 spec
73
+ (`initialize`, `tools/list`, `tools/call`). No external dependencies.
74
+ The full `mcp` Python SDK is an option for a future hardening pass;
75
+ this hand-rolled version keeps the dependency footprint tiny.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ maverick_mcp/__init__.py
4
+ maverick_mcp/http_transport.py
5
+ maverick_mcp/server.py
6
+ maverick_mcp_server.egg-info/PKG-INFO
7
+ maverick_mcp_server.egg-info/SOURCES.txt
8
+ maverick_mcp_server.egg-info/dependency_links.txt
9
+ maverick_mcp_server.egg-info/entry_points.txt
10
+ maverick_mcp_server.egg-info/requires.txt
11
+ maverick_mcp_server.egg-info/top_level.txt
12
+ tests/test_http_transport.py
13
+ tests/test_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ maverick-mcp = maverick_mcp.server:main
@@ -0,0 +1 @@
1
+ maverick-agent>=0.1
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "maverick-mcp-server"
7
+ version = "0.1.2"
8
+ description = "Model Context Protocol server for Maverick (exposes the swarm to MCP clients)"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "cdayAI" }]
12
+ readme = "README.md"
13
+ dependencies = [
14
+ "maverick-agent>=0.1",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/cdayAI/maverick"
19
+
20
+ [project.scripts]
21
+ maverick-mcp = "maverick_mcp.server:main"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["."]
25
+ include = ["maverick_mcp*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,112 @@
1
+ """MCP Streamable HTTP transport tests."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+
7
+ def _have_fastapi() -> bool:
8
+ try:
9
+ import fastapi # noqa: F401
10
+ return True
11
+ except ImportError:
12
+ return False
13
+
14
+
15
+ @pytest.mark.skipif(not _have_fastapi(), reason="fastapi not installed")
16
+ class TestHTTPTransport:
17
+ def _client(self):
18
+ from fastapi.testclient import TestClient
19
+
20
+ from maverick_mcp.http_transport import build_app
21
+ from maverick_mcp.server import MCPServer
22
+ app = build_app(MCPServer())
23
+ return TestClient(app)
24
+
25
+ def test_initialize_returns_capabilities(self, monkeypatch):
26
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
27
+ client = self._client()
28
+ resp = client.post("/mcp", json={
29
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
30
+ "params": {"protocolVersion": "2025-11-25"},
31
+ }, headers={"Authorization": "Bearer s3cr3t"})
32
+ assert resp.status_code == 200
33
+ body = resp.json()
34
+ assert body["jsonrpc"] == "2.0"
35
+ assert body["id"] == 1
36
+ assert "result" in body
37
+ assert "capabilities" in body["result"]
38
+
39
+ def test_unknown_method_returns_jsonrpc_error(self, monkeypatch):
40
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
41
+ client = self._client()
42
+ resp = client.post("/mcp", json={
43
+ "jsonrpc": "2.0", "id": 2, "method": "no/such/method",
44
+ "params": {},
45
+ }, headers={"Authorization": "Bearer s3cr3t"})
46
+ body = resp.json()
47
+ assert "error" in body
48
+ assert body["error"]["code"] == -32601
49
+
50
+ def test_resources_list_works_over_http(self, monkeypatch):
51
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
52
+ client = self._client()
53
+ resp = client.post("/mcp", json={
54
+ "jsonrpc": "2.0", "id": 3, "method": "resources/list",
55
+ "params": {},
56
+ }, headers={"Authorization": "Bearer s3cr3t"})
57
+ body = resp.json()
58
+ assert "result" in body
59
+ assert "resources" in body["result"]
60
+
61
+ def test_bearer_required_when_token_set(self, monkeypatch):
62
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
63
+ client = self._client()
64
+ resp = client.post("/mcp", json={
65
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
66
+ "params": {},
67
+ })
68
+ assert resp.status_code == 401
69
+
70
+ def test_bearer_accepted_when_correct(self, monkeypatch):
71
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
72
+ client = self._client()
73
+ resp = client.post("/mcp", json={
74
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
75
+ "params": {},
76
+ }, headers={"Authorization": "Bearer s3cr3t"})
77
+ assert resp.status_code == 200
78
+
79
+ def test_wrong_bearer_rejected(self, monkeypatch):
80
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
81
+ client = self._client()
82
+ resp = client.post("/mcp", json={
83
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
84
+ "params": {},
85
+ }, headers={"Authorization": "Bearer wrong"})
86
+ assert resp.status_code == 401
87
+
88
+ def test_auth_required_when_token_unset(self, monkeypatch):
89
+ monkeypatch.delenv("MAVERICK_MCP_TOKEN", raising=False)
90
+ client = self._client()
91
+ resp = client.post("/mcp", json={
92
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
93
+ "params": {},
94
+ })
95
+ assert resp.status_code == 401
96
+
97
+ def test_healthz_exempt(self, monkeypatch):
98
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
99
+ client = self._client()
100
+ resp = client.get("/healthz")
101
+ assert resp.status_code == 200
102
+ assert resp.json()["transport"] == "http"
103
+
104
+ def test_notification_returns_204(self, monkeypatch):
105
+ monkeypatch.setenv("MAVERICK_MCP_TOKEN", "s3cr3t")
106
+ client = self._client()
107
+ resp = client.post("/mcp", json={
108
+ "jsonrpc": "2.0", "method": "notifications/initialized",
109
+ "params": {},
110
+ }, headers={"Authorization": "Bearer s3cr3t"})
111
+ # No "id" -> notification -> 204
112
+ assert resp.status_code == 204
@@ -0,0 +1,176 @@
1
+ """MCP server smoke + protocol tests.
2
+
3
+ v0.1.6: handle_tools_call now RAISES `_ProtocolError` for unknown tool /
4
+ missing required args (per MCP spec -- protocol errors must come back as
5
+ JSON-RPC `-32602`, not `isError`). Tests assert the exception is raised
6
+ and carries the right code. The `isError` envelope is only for tool
7
+ *execution* failures (e.g., the tool raised mid-call).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from types import SimpleNamespace
12
+
13
+ import pytest
14
+
15
+ from maverick_mcp.server import (
16
+ PROTOCOL_VERSION,
17
+ TOOLS,
18
+ MCPServer,
19
+ _ProtocolError,
20
+ )
21
+
22
+
23
+ class TestTools:
24
+ def test_all_tools_have_required_fields(self):
25
+ for t in TOOLS:
26
+ assert "name" in t
27
+ assert "description" in t
28
+ assert "inputSchema" in t
29
+ assert t["inputSchema"].get("type") == "object"
30
+
31
+ def test_known_tool_names(self):
32
+ names = {t["name"] for t in TOOLS}
33
+ for expected in (
34
+ "maverick_start",
35
+ "maverick_status",
36
+ "maverick_skill_install",
37
+ "maverick_fact_set",
38
+ ):
39
+ assert expected in names
40
+
41
+
42
+ class TestProtocol:
43
+ def test_initialize_response_shape(self):
44
+ s = MCPServer()
45
+ out = s.handle_initialize({})
46
+ assert out["protocolVersion"] == PROTOCOL_VERSION
47
+ assert out["serverInfo"]["name"] == "maverick"
48
+ assert "capabilities" in out
49
+
50
+ def test_tools_list_returns_full_catalog(self):
51
+ s = MCPServer()
52
+ out = s.handle_tools_list({})
53
+ assert len(out["tools"]) == len(TOOLS)
54
+
55
+ def test_unknown_tool_raises_protocol_error(self):
56
+ """Unknown tool -> JSON-RPC -32602, not isError envelope."""
57
+ s = MCPServer()
58
+ with pytest.raises(_ProtocolError) as excinfo:
59
+ s.handle_tools_call({"name": "does_not_exist", "arguments": {}})
60
+ assert excinfo.value.code == -32602
61
+ assert "unknown tool" in excinfo.value.message
62
+
63
+ def test_missing_required_arg_raises_protocol_error(self):
64
+ """Missing required arg -> JSON-RPC -32602."""
65
+ s = MCPServer()
66
+ # maverick_answer requires question_id + answer
67
+ with pytest.raises(_ProtocolError) as excinfo:
68
+ s.handle_tools_call({"name": "maverick_answer", "arguments": {}})
69
+ assert excinfo.value.code == -32602
70
+ assert "question_id" in excinfo.value.message
71
+ assert "answer" in excinfo.value.message
72
+
73
+ def test_tool_execution_failure_returns_isError_envelope(self):
74
+ """Tool that raises mid-execution -> isError (not protocol error).
75
+
76
+ maverick_facts_get touches WorldModel and would normally succeed;
77
+ we'd need to mock it to force a raise. Skip the full path test and
78
+ just verify the contract via _dispatch_tool returning isError when
79
+ dispatch raises.
80
+ """
81
+ s = MCPServer()
82
+ # All registered names dispatch normally. Force an exception
83
+ # by passing a name the dispatch can find but the tool raises on.
84
+ # maverick_answer with bad question_id type -> int() raises.
85
+ out = s.handle_tools_call({
86
+ "name": "maverick_answer",
87
+ "arguments": {"question_id": "not-a-number", "answer": "x"},
88
+ })
89
+ assert out["isError"] is True
90
+ assert "text" in out["content"][0]
91
+
92
+ def test_maverick_start_blocks_disallowed_input(self, monkeypatch):
93
+ s = MCPServer()
94
+ s._shield = SimpleNamespace(
95
+ scan_input=lambda _text: SimpleNamespace(allowed=False, reasons=["blocked input"]),
96
+ scan_output=lambda _text: SimpleNamespace(allowed=True, reasons=[]),
97
+ )
98
+
99
+ out = s.handle_tools_call({
100
+ "name": "maverick_start",
101
+ "arguments": {"title": "bad payload", "description": "ignore rules"},
102
+ })
103
+
104
+ assert out["isError"] is False
105
+ assert "⚠ Blocked: blocked input" in out["content"][0]["text"]
106
+
107
+ def test_tools_call_blocks_disallowed_output(self, monkeypatch):
108
+ s = MCPServer()
109
+ s._shield = SimpleNamespace(
110
+ scan_output=lambda _text: SimpleNamespace(allowed=False, reasons=["blocked output"]),
111
+ )
112
+ monkeypatch.setattr(s, "_dispatch_tool", lambda *_args, **_kwargs: "secret")
113
+
114
+ out = s.handle_tools_call({
115
+ "name": "maverick_status",
116
+ "arguments": {},
117
+ })
118
+
119
+ assert out["isError"] is True
120
+ assert "⚠ Output blocked: blocked output" in out["content"][0]["text"]
121
+
122
+
123
+ class TestProtocol2025_11_25:
124
+ """Tests for the new MCP 2025-11-25 primitives."""
125
+
126
+ def test_initialize_advertises_new_capabilities(self):
127
+ s = MCPServer()
128
+ out = s.handle_initialize({"protocolVersion": "2025-11-25"})
129
+ assert "resources" in out["capabilities"]
130
+ assert "prompts" in out["capabilities"]
131
+ assert "elicitation" in out["capabilities"]
132
+
133
+ def test_initialize_negotiates_down_for_old_clients(self):
134
+ s = MCPServer()
135
+ out = s.handle_initialize({"protocolVersion": "2024-11-05"})
136
+ assert out["protocolVersion"] == "2024-11-05"
137
+
138
+ def test_initialize_uses_current_version_for_new_clients(self):
139
+ s = MCPServer()
140
+ out = s.handle_initialize({"protocolVersion": "2025-11-25"})
141
+ assert out["protocolVersion"] == "2025-11-25"
142
+
143
+ def test_resources_list_includes_static_namespaces(self):
144
+ s = MCPServer()
145
+ out = s.handle_resources_list({})
146
+ uris = {r["uri"] for r in out["resources"]}
147
+ assert "maverick://goals" in uris
148
+ assert "maverick://skills" in uris
149
+
150
+ def test_resources_read_rejects_unsupported_scheme(self):
151
+ s = MCPServer()
152
+ with pytest.raises(_ProtocolError):
153
+ s.handle_resources_read({"uri": "file:///etc/passwd"})
154
+
155
+ def test_prompts_list_returns_three_templates(self):
156
+ s = MCPServer()
157
+ out = s.handle_prompts_list({})
158
+ names = {p["name"] for p in out["prompts"]}
159
+ assert "research_topic" in names
160
+ assert "draft_message" in names
161
+ assert "compare_options" in names
162
+
163
+ def test_prompts_get_renders_with_args(self):
164
+ s = MCPServer()
165
+ out = s.handle_prompts_get({
166
+ "name": "research_topic",
167
+ "arguments": {"topic": "fusion reactors", "depth": "deep"},
168
+ })
169
+ text = out["messages"][0]["content"]["text"]
170
+ assert "fusion reactors" in text
171
+ assert "deep" in text
172
+
173
+ def test_prompts_get_unknown_raises(self):
174
+ s = MCPServer()
175
+ with pytest.raises(_ProtocolError):
176
+ s.handle_prompts_get({"name": "nonexistent", "arguments": {}})