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.
- maverick_mcp_server-0.1.2/PKG-INFO +75 -0
- maverick_mcp_server-0.1.2/README.md +64 -0
- maverick_mcp_server-0.1.2/maverick_mcp/__init__.py +3 -0
- maverick_mcp_server-0.1.2/maverick_mcp/http_transport.py +164 -0
- maverick_mcp_server-0.1.2/maverick_mcp/server.py +506 -0
- maverick_mcp_server-0.1.2/maverick_mcp_server.egg-info/PKG-INFO +75 -0
- maverick_mcp_server-0.1.2/maverick_mcp_server.egg-info/SOURCES.txt +13 -0
- maverick_mcp_server-0.1.2/maverick_mcp_server.egg-info/dependency_links.txt +1 -0
- maverick_mcp_server-0.1.2/maverick_mcp_server.egg-info/entry_points.txt +2 -0
- maverick_mcp_server-0.1.2/maverick_mcp_server.egg-info/requires.txt +1 -0
- maverick_mcp_server-0.1.2/maverick_mcp_server.egg-info/top_level.txt +1 -0
- maverick_mcp_server-0.1.2/pyproject.toml +25 -0
- maverick_mcp_server-0.1.2/setup.cfg +4 -0
- maverick_mcp_server-0.1.2/tests/test_http_transport.py +112 -0
- maverick_mcp_server-0.1.2/tests/test_server.py +176 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
maverick-agent>=0.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
maverick_mcp
|
|
@@ -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,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": {}})
|