deepcrew-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepcrew/__init__.py +88 -0
- deepcrew/agent.py +70 -0
- deepcrew/exceptions.py +22 -0
- deepcrew/mcp/__init__.py +7 -0
- deepcrew/mcp/base.py +55 -0
- deepcrew/mcp/http.py +168 -0
- deepcrew/mcp/manager.py +87 -0
- deepcrew/mcp/sse.py +155 -0
- deepcrew/mcp/stdio.py +138 -0
- deepcrew/orchestrator.py +277 -0
- deepcrew/runner.py +236 -0
- deepcrew/stream.py +33 -0
- deepcrew/tools.py +144 -0
- deepcrew/types.py +92 -0
- deepcrew/workflow.py +271 -0
- deepcrew_ai-0.1.0.dist-info/METADATA +314 -0
- deepcrew_ai-0.1.0.dist-info/RECORD +19 -0
- deepcrew_ai-0.1.0.dist-info/WHEEL +4 -0
- deepcrew_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
deepcrew/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
deepcrew-ai — Multi-agent spawning library.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from deepcrew import Agent, run_agent, Orchestrator, WorkflowBuilder, tool
|
|
7
|
+
from deepcrew.mcp import StdioMCP, SSEMCP, HTTPMCP, MCPManager
|
|
8
|
+
|
|
9
|
+
Single agent::
|
|
10
|
+
|
|
11
|
+
agent = Agent(name="assistant", model="openai/gpt-4o",
|
|
12
|
+
system_prompt="You are helpful.")
|
|
13
|
+
result = await run_agent(agent, [{"role": "user", "content": "Hello!"}])
|
|
14
|
+
print(result.text)
|
|
15
|
+
|
|
16
|
+
Automated orchestration (router → parallel agents → synthesizer)::
|
|
17
|
+
|
|
18
|
+
orch = Orchestrator(
|
|
19
|
+
agents=[researcher, analyst, writer],
|
|
20
|
+
router_model="openai/gpt-4o-mini",
|
|
21
|
+
synthesizer_model="openai/gpt-4o",
|
|
22
|
+
)
|
|
23
|
+
result = await orch.run("Explain quantum entanglement")
|
|
24
|
+
|
|
25
|
+
DAG workflow::
|
|
26
|
+
|
|
27
|
+
workflow = (
|
|
28
|
+
WorkflowBuilder()
|
|
29
|
+
.add_agent("research", researcher, task="{input}")
|
|
30
|
+
.add_agent("write", writer, task="Write about:\\n{research}")
|
|
31
|
+
.then("research", "write")
|
|
32
|
+
)
|
|
33
|
+
result = await workflow.run("Quantum computing trends")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from .agent import Agent
|
|
37
|
+
from .exceptions import (
|
|
38
|
+
DeepCrewError,
|
|
39
|
+
MCPError,
|
|
40
|
+
MaxTurnsError,
|
|
41
|
+
RouterError,
|
|
42
|
+
ToolError,
|
|
43
|
+
WorkflowError,
|
|
44
|
+
)
|
|
45
|
+
from .orchestrator import Orchestrator
|
|
46
|
+
from .runner import run_agent
|
|
47
|
+
from .stream import make_done_event, make_error_event, queue_to_stream
|
|
48
|
+
from .tools import fn_to_tool_def, tool
|
|
49
|
+
from .types import (
|
|
50
|
+
AgentResult,
|
|
51
|
+
EventType,
|
|
52
|
+
OrchestratorResult,
|
|
53
|
+
StreamEvent,
|
|
54
|
+
ToolDef,
|
|
55
|
+
WorkflowResult,
|
|
56
|
+
)
|
|
57
|
+
from .workflow import WorkflowBuilder
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
# Core
|
|
63
|
+
"Agent",
|
|
64
|
+
"run_agent",
|
|
65
|
+
"Orchestrator",
|
|
66
|
+
"WorkflowBuilder",
|
|
67
|
+
# Tools
|
|
68
|
+
"tool",
|
|
69
|
+
"fn_to_tool_def",
|
|
70
|
+
# Types
|
|
71
|
+
"StreamEvent",
|
|
72
|
+
"EventType",
|
|
73
|
+
"AgentResult",
|
|
74
|
+
"WorkflowResult",
|
|
75
|
+
"OrchestratorResult",
|
|
76
|
+
"ToolDef",
|
|
77
|
+
# Streaming
|
|
78
|
+
"queue_to_stream",
|
|
79
|
+
"make_done_event",
|
|
80
|
+
"make_error_event",
|
|
81
|
+
# Exceptions
|
|
82
|
+
"DeepCrewError",
|
|
83
|
+
"MCPError",
|
|
84
|
+
"ToolError",
|
|
85
|
+
"MaxTurnsError",
|
|
86
|
+
"RouterError",
|
|
87
|
+
"WorkflowError",
|
|
88
|
+
]
|
deepcrew/agent.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
6
|
+
|
|
7
|
+
from .tools import fn_to_tool_def
|
|
8
|
+
from .types import ToolDef
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .mcp.base import MCPClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Agent:
|
|
16
|
+
"""
|
|
17
|
+
Defines a single AI agent: model, system prompt, and its tools.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
name:
|
|
22
|
+
Unique identifier used in logs and result objects.
|
|
23
|
+
model:
|
|
24
|
+
LiteLLM model string, e.g. ``"openai/gpt-4o"``,
|
|
25
|
+
``"anthropic/claude-opus-4-8"``, ``"gemini/gemini-2.0-flash"``.
|
|
26
|
+
system_prompt:
|
|
27
|
+
The agent's system/instruction prompt.
|
|
28
|
+
mcps:
|
|
29
|
+
MCP clients (StdioMCP, SSEMCP, or HTTPMCP) whose tools this agent
|
|
30
|
+
can use.
|
|
31
|
+
tools:
|
|
32
|
+
Plain Python callables decorated with @tool (or undecorated).
|
|
33
|
+
Schemas are auto-generated from type hints.
|
|
34
|
+
max_turns:
|
|
35
|
+
Maximum LLM→tool→LLM cycles before raising MaxTurnsError.
|
|
36
|
+
temperature:
|
|
37
|
+
Sampling temperature forwarded to the LLM. None uses provider default.
|
|
38
|
+
max_tokens:
|
|
39
|
+
Maximum output tokens. None uses provider default.
|
|
40
|
+
extra_params:
|
|
41
|
+
Any additional kwargs forwarded verbatim to ``litellm.acompletion``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
model: str
|
|
46
|
+
system_prompt: str = ""
|
|
47
|
+
mcps: list[MCPClient] = field(default_factory=list)
|
|
48
|
+
tools: list[Callable[..., Any]] = field(default_factory=list)
|
|
49
|
+
max_turns: int = 10
|
|
50
|
+
temperature: float | None = None
|
|
51
|
+
max_tokens: int | None = None
|
|
52
|
+
extra_params: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
async def get_tool_defs(self) -> list[ToolDef]:
|
|
55
|
+
"""Discover and merge tools from all attached MCP servers + Python functions."""
|
|
56
|
+
defs: list[ToolDef] = []
|
|
57
|
+
|
|
58
|
+
if self.mcps:
|
|
59
|
+
results = await asyncio.gather(
|
|
60
|
+
*[mcp.list_tools() for mcp in self.mcps],
|
|
61
|
+
return_exceptions=True,
|
|
62
|
+
)
|
|
63
|
+
for r in results:
|
|
64
|
+
if isinstance(r, list):
|
|
65
|
+
defs.extend(r)
|
|
66
|
+
|
|
67
|
+
for fn in self.tools:
|
|
68
|
+
defs.append(fn_to_tool_def(fn))
|
|
69
|
+
|
|
70
|
+
return defs
|
deepcrew/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class DeepCrewError(Exception):
|
|
2
|
+
"""Base exception for all deepcrew errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MCPError(DeepCrewError):
|
|
6
|
+
"""Raised when an MCP server operation fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolError(DeepCrewError):
|
|
10
|
+
"""Raised when a tool call fails or a tool is not found."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MaxTurnsError(DeepCrewError):
|
|
14
|
+
"""Raised when an agent exceeds its max_turns limit without finishing."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RouterError(DeepCrewError):
|
|
18
|
+
"""Raised when the orchestrator router fails to parse a routing decision."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WorkflowError(DeepCrewError):
|
|
22
|
+
"""Raised for invalid workflow configurations (e.g. cycles in the DAG)."""
|
deepcrew/mcp/__init__.py
ADDED
deepcrew/mcp/base.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from ..types import ToolDef
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPClient(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract base class for all MCP transport implementations.
|
|
11
|
+
|
|
12
|
+
Subclasses implement the three transport types defined by the MCP spec:
|
|
13
|
+
- :class:`StdioMCP` — subprocess JSON-RPC over stdin/stdout
|
|
14
|
+
- :class:`SSEMCP` — legacy HTTP/SSE protocol
|
|
15
|
+
- :class:`HTTPMCP` — modern streamable-HTTP protocol
|
|
16
|
+
|
|
17
|
+
All clients support the async context manager protocol::
|
|
18
|
+
|
|
19
|
+
async with HTTPMCP("https://example.com/mcp") as mcp:
|
|
20
|
+
tools = await mcp.list_tools()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def connect(self) -> None:
|
|
25
|
+
"""Establish the connection and run the MCP initialize handshake."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def list_tools(self) -> list[ToolDef]:
|
|
29
|
+
"""Return all tools exposed by this MCP server."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def call_tool(self, name: str, args: dict) -> dict | str:
|
|
33
|
+
"""Invoke a tool and return its result."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def close(self) -> None:
|
|
37
|
+
"""Tear down the connection and release resources."""
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> MCPClient:
|
|
40
|
+
await self.connect()
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
async def __aexit__(self, *_: object) -> None:
|
|
44
|
+
await self.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_mcp_tool(raw: dict, client: MCPClient) -> ToolDef:
|
|
48
|
+
"""Convert the raw tools/list entry from an MCP server into a ToolDef."""
|
|
49
|
+
schema = raw.get("inputSchema") or raw.get("input_schema") or {}
|
|
50
|
+
return ToolDef(
|
|
51
|
+
name=raw["name"],
|
|
52
|
+
description=raw.get("description", ""),
|
|
53
|
+
parameters=schema,
|
|
54
|
+
_mcp_client=client,
|
|
55
|
+
)
|
deepcrew/mcp/http.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ..exceptions import MCPError
|
|
10
|
+
from ..types import ToolDef
|
|
11
|
+
from .base import MCPClient, _parse_mcp_tool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTTPMCP(MCPClient):
|
|
15
|
+
"""
|
|
16
|
+
MCP client using the modern streamable-HTTP transport (MCP spec 2024-11-05+).
|
|
17
|
+
|
|
18
|
+
Performs an initialize handshake to obtain a ``Mcp-Session-Id`` header,
|
|
19
|
+
then uses that session ID for all subsequent requests. Supports automatic
|
|
20
|
+
session re-initialization on 400/401 responses and exponential-backoff
|
|
21
|
+
retries.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
url:
|
|
26
|
+
Full URL of the MCP server endpoint, e.g. ``"https://example.com/mcp"``.
|
|
27
|
+
headers:
|
|
28
|
+
Extra HTTP headers (e.g. ``{"Authorization": "Bearer token"}``).
|
|
29
|
+
timeout:
|
|
30
|
+
Request timeout in seconds.
|
|
31
|
+
retries:
|
|
32
|
+
Number of retry attempts on transient errors.
|
|
33
|
+
|
|
34
|
+
Example
|
|
35
|
+
-------
|
|
36
|
+
::
|
|
37
|
+
|
|
38
|
+
async with HTTPMCP("https://my-mcp.example.com/mcp",
|
|
39
|
+
headers={"Authorization": "Bearer sk-..."}) as mcp:
|
|
40
|
+
tools = await mcp.list_tools()
|
|
41
|
+
result = await mcp.call_tool("search", {"query": "hello"})
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
url: str,
|
|
47
|
+
headers: dict[str, str] | None = None,
|
|
48
|
+
timeout: int = 30,
|
|
49
|
+
retries: int = 2,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.url = url.rstrip("/")
|
|
52
|
+
self._extra_headers: dict[str, str] = headers or {}
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._retries = retries
|
|
55
|
+
self._session_id: str | None = None
|
|
56
|
+
self._http: httpx.AsyncClient | None = None
|
|
57
|
+
self._tool_cache: list[ToolDef] | None = None
|
|
58
|
+
self._msg_id = 0
|
|
59
|
+
|
|
60
|
+
def _headers(self) -> dict[str, str]:
|
|
61
|
+
h = {"Content-Type": "application/json", **self._extra_headers}
|
|
62
|
+
if self._session_id:
|
|
63
|
+
h["Mcp-Session-Id"] = self._session_id
|
|
64
|
+
return h
|
|
65
|
+
|
|
66
|
+
async def connect(self) -> None:
|
|
67
|
+
self._http = httpx.AsyncClient(timeout=self._timeout)
|
|
68
|
+
await self._initialize()
|
|
69
|
+
|
|
70
|
+
async def _initialize(self) -> None:
|
|
71
|
+
self._msg_id += 1
|
|
72
|
+
resp = await self._raw_post(
|
|
73
|
+
{
|
|
74
|
+
"jsonrpc": "2.0",
|
|
75
|
+
"id": self._msg_id,
|
|
76
|
+
"method": "initialize",
|
|
77
|
+
"params": {
|
|
78
|
+
"protocolVersion": "2024-11-05",
|
|
79
|
+
"capabilities": {},
|
|
80
|
+
"clientInfo": {"name": "deepcrew", "version": "0.1.0"},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
skip_session=True,
|
|
84
|
+
)
|
|
85
|
+
self._session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
|
|
86
|
+
body = resp.json()
|
|
87
|
+
if "error" in body:
|
|
88
|
+
raise MCPError(f"MCP initialize error: {body['error']}")
|
|
89
|
+
|
|
90
|
+
# Send notifications/initialized (fire-and-forget)
|
|
91
|
+
if self._http:
|
|
92
|
+
try:
|
|
93
|
+
await self._http.post(
|
|
94
|
+
self.url,
|
|
95
|
+
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
96
|
+
headers=self._headers(),
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
async def _raw_post(
|
|
102
|
+
self, payload: dict[str, Any], skip_session: bool = False
|
|
103
|
+
) -> httpx.Response:
|
|
104
|
+
if self._http is None:
|
|
105
|
+
raise MCPError("HTTPMCP is not connected. Call connect() first.")
|
|
106
|
+
headers = {"Content-Type": "application/json", **self._extra_headers}
|
|
107
|
+
if not skip_session and self._session_id:
|
|
108
|
+
headers["Mcp-Session-Id"] = self._session_id
|
|
109
|
+
resp = await self._http.post(self.url, json=payload, headers=headers)
|
|
110
|
+
return resp
|
|
111
|
+
|
|
112
|
+
async def _post(self, payload: dict[str, Any], attempt: int = 0) -> dict[str, Any]:
|
|
113
|
+
try:
|
|
114
|
+
resp = await self._raw_post(payload)
|
|
115
|
+
if resp.status_code in (400, 401):
|
|
116
|
+
# Session expired — re-initialize and retry once
|
|
117
|
+
self._session_id = None
|
|
118
|
+
self._tool_cache = None
|
|
119
|
+
await self._initialize()
|
|
120
|
+
if attempt < self._retries:
|
|
121
|
+
return await self._post(payload, attempt + 1)
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
return resp.json()
|
|
124
|
+
except httpx.HTTPStatusError as exc:
|
|
125
|
+
if attempt < self._retries:
|
|
126
|
+
await asyncio.sleep(2**attempt)
|
|
127
|
+
return await self._post(payload, attempt + 1)
|
|
128
|
+
raise MCPError(f"HTTPMCP request failed: {exc}") from exc
|
|
129
|
+
except httpx.RequestError as exc:
|
|
130
|
+
if attempt < self._retries:
|
|
131
|
+
await asyncio.sleep(2**attempt)
|
|
132
|
+
return await self._post(payload, attempt + 1)
|
|
133
|
+
raise MCPError(f"HTTPMCP connection error: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
async def list_tools(self) -> list[ToolDef]:
|
|
136
|
+
if self._tool_cache is not None:
|
|
137
|
+
return self._tool_cache
|
|
138
|
+
self._msg_id += 1
|
|
139
|
+
body = await self._post({"jsonrpc": "2.0", "id": self._msg_id, "method": "tools/list", "params": {}})
|
|
140
|
+
if "error" in body:
|
|
141
|
+
raise MCPError(f"tools/list failed: {body['error']}")
|
|
142
|
+
raw_tools: list[dict] = body.get("result", {}).get("tools", [])
|
|
143
|
+
self._tool_cache = [_parse_mcp_tool(t, self) for t in raw_tools]
|
|
144
|
+
return self._tool_cache
|
|
145
|
+
|
|
146
|
+
async def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any] | str:
|
|
147
|
+
self._msg_id += 1
|
|
148
|
+
body = await self._post(
|
|
149
|
+
{
|
|
150
|
+
"jsonrpc": "2.0",
|
|
151
|
+
"id": self._msg_id,
|
|
152
|
+
"method": "tools/call",
|
|
153
|
+
"params": {"name": name, "arguments": args},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
if "error" in body:
|
|
157
|
+
raise MCPError(f"Tool {name!r} call failed: {body['error']}")
|
|
158
|
+
result = body.get("result", {})
|
|
159
|
+
if isinstance(result, dict) and "content" in result:
|
|
160
|
+
contents = result["content"]
|
|
161
|
+
if contents and isinstance(contents[0], dict):
|
|
162
|
+
return contents[0].get("text", json.dumps(result))
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
async def close(self) -> None:
|
|
166
|
+
if self._http:
|
|
167
|
+
await self._http.aclose()
|
|
168
|
+
self._http = None
|
deepcrew/mcp/manager.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..exceptions import MCPError
|
|
7
|
+
from ..types import ToolDef
|
|
8
|
+
from .base import MCPClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MCPManager:
|
|
12
|
+
"""
|
|
13
|
+
Aggregates multiple MCP clients into a single interface.
|
|
14
|
+
|
|
15
|
+
Discovers tools from all attached clients in parallel and maintains a
|
|
16
|
+
routing table so that tool calls are automatically dispatched to the
|
|
17
|
+
correct server.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
clients:
|
|
22
|
+
Any combination of StdioMCP, SSEMCP, and HTTPMCP instances.
|
|
23
|
+
|
|
24
|
+
Example
|
|
25
|
+
-------
|
|
26
|
+
::
|
|
27
|
+
|
|
28
|
+
manager = MCPManager([
|
|
29
|
+
StdioMCP("npx", ["-y", "@modelcontextprotocol/server-filesystem", "."]),
|
|
30
|
+
HTTPMCP("https://my-search-mcp.example.com/mcp"),
|
|
31
|
+
])
|
|
32
|
+
await manager.connect_all()
|
|
33
|
+
tools = await manager.discover_tools()
|
|
34
|
+
|
|
35
|
+
# Pass manager as a single MCP to an Agent
|
|
36
|
+
agent = Agent("assistant", model="openai/gpt-4o", mcps=[manager])
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, clients: list[MCPClient]) -> None:
|
|
40
|
+
self._clients = clients
|
|
41
|
+
self._routing: dict[str, MCPClient] = {}
|
|
42
|
+
|
|
43
|
+
async def connect_all(self) -> None:
|
|
44
|
+
"""Connect all clients in parallel."""
|
|
45
|
+
await asyncio.gather(*[c.connect() for c in self._clients], return_exceptions=True)
|
|
46
|
+
|
|
47
|
+
async def discover_tools(self) -> list[ToolDef]:
|
|
48
|
+
"""
|
|
49
|
+
Fetch tools from all servers in parallel and build the routing table.
|
|
50
|
+
Tools from earlier clients take precedence if names collide.
|
|
51
|
+
"""
|
|
52
|
+
all_tools: list[ToolDef] = []
|
|
53
|
+
results = await asyncio.gather(
|
|
54
|
+
*[c.list_tools() for c in self._clients],
|
|
55
|
+
return_exceptions=True,
|
|
56
|
+
)
|
|
57
|
+
for r in results:
|
|
58
|
+
if isinstance(r, list):
|
|
59
|
+
for td in r:
|
|
60
|
+
if td.name not in self._routing:
|
|
61
|
+
self._routing[td.name] = td._mcp_client # type: ignore[assignment]
|
|
62
|
+
all_tools.append(td)
|
|
63
|
+
return all_tools
|
|
64
|
+
|
|
65
|
+
async def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any] | str:
|
|
66
|
+
client = self._routing.get(name)
|
|
67
|
+
if not client:
|
|
68
|
+
raise MCPError(
|
|
69
|
+
f"No MCP server owns tool {name!r}. "
|
|
70
|
+
"Did you call discover_tools() before calling call_tool()?"
|
|
71
|
+
)
|
|
72
|
+
return await client.call_tool(name, args)
|
|
73
|
+
|
|
74
|
+
async def list_tools(self) -> list[ToolDef]:
|
|
75
|
+
"""Implement the MCPClient interface so MCPManager can be passed as an MCP."""
|
|
76
|
+
return await self.discover_tools()
|
|
77
|
+
|
|
78
|
+
async def close_all(self) -> None:
|
|
79
|
+
"""Close all clients, ignoring individual errors."""
|
|
80
|
+
await asyncio.gather(*[c.close() for c in self._clients], return_exceptions=True)
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> MCPManager:
|
|
83
|
+
await self.connect_all()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *_: object) -> None:
|
|
87
|
+
await self.close_all()
|
deepcrew/mcp/sse.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..exceptions import MCPError
|
|
11
|
+
from ..types import ToolDef
|
|
12
|
+
from .base import MCPClient, _parse_mcp_tool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SSEMCP(MCPClient):
|
|
16
|
+
"""
|
|
17
|
+
MCP client for the legacy Server-Sent Events transport.
|
|
18
|
+
|
|
19
|
+
This transport was used by early MCP servers (pre-2024-11-05). It
|
|
20
|
+
connects to a ``/sse`` endpoint to receive an event that tells it which
|
|
21
|
+
URL to POST requests to.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
base_url:
|
|
26
|
+
Base URL of the MCP server, e.g. ``"http://localhost:3000"``.
|
|
27
|
+
The client appends ``/sse`` for the event stream and uses the
|
|
28
|
+
``endpoint`` event to discover the POST URL.
|
|
29
|
+
headers:
|
|
30
|
+
Extra HTTP headers forwarded with every request.
|
|
31
|
+
|
|
32
|
+
Example
|
|
33
|
+
-------
|
|
34
|
+
::
|
|
35
|
+
|
|
36
|
+
async with SSEMCP("http://localhost:3000") as mcp:
|
|
37
|
+
tools = await mcp.list_tools()
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, base_url: str, headers: dict[str, str] | None = None) -> None:
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self._extra_headers: dict[str, str] = headers or {}
|
|
43
|
+
self._post_url: str | None = None
|
|
44
|
+
self._http: httpx.AsyncClient | None = None
|
|
45
|
+
self._tool_cache: list[ToolDef] | None = None
|
|
46
|
+
self._msg_id = 0
|
|
47
|
+
|
|
48
|
+
async def connect(self) -> None:
|
|
49
|
+
self._http = httpx.AsyncClient(timeout=60.0)
|
|
50
|
+
await self._sse_handshake()
|
|
51
|
+
await self._initialize()
|
|
52
|
+
|
|
53
|
+
async def _sse_handshake(self) -> None:
|
|
54
|
+
"""Subscribe to /sse until we receive the 'endpoint' event."""
|
|
55
|
+
try:
|
|
56
|
+
from httpx_sse import aconnect_sse
|
|
57
|
+
except ImportError as exc:
|
|
58
|
+
raise MCPError(
|
|
59
|
+
"httpx-sse is required for SSEMCP. Install it with: pip install httpx-sse"
|
|
60
|
+
) from exc
|
|
61
|
+
|
|
62
|
+
if self._http is None:
|
|
63
|
+
raise MCPError("SSEMCP not connected")
|
|
64
|
+
|
|
65
|
+
sse_url = f"{self.base_url}/sse"
|
|
66
|
+
async with aconnect_sse(
|
|
67
|
+
self._http, "GET", sse_url, headers=self._extra_headers
|
|
68
|
+
) as event_source:
|
|
69
|
+
async for event in event_source.aiter_sse():
|
|
70
|
+
if event.event == "endpoint":
|
|
71
|
+
data = event.data.strip()
|
|
72
|
+
if data.startswith("http"):
|
|
73
|
+
self._post_url = data
|
|
74
|
+
else:
|
|
75
|
+
self._post_url = urljoin(self.base_url, data)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
raise MCPError("SSE stream closed before receiving 'endpoint' event")
|
|
79
|
+
|
|
80
|
+
async def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
if self._http is None or self._post_url is None:
|
|
82
|
+
raise MCPError("SSEMCP is not connected. Call connect() first.")
|
|
83
|
+
resp = await self._http.post(
|
|
84
|
+
self._post_url, json=payload, headers=self._extra_headers
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
try:
|
|
88
|
+
return resp.json()
|
|
89
|
+
except Exception:
|
|
90
|
+
return {"result": resp.text}
|
|
91
|
+
|
|
92
|
+
async def _initialize(self) -> None:
|
|
93
|
+
self._msg_id += 1
|
|
94
|
+
body = await self._post(
|
|
95
|
+
{
|
|
96
|
+
"jsonrpc": "2.0",
|
|
97
|
+
"id": self._msg_id,
|
|
98
|
+
"method": "initialize",
|
|
99
|
+
"params": {
|
|
100
|
+
"protocolVersion": "2024-11-05",
|
|
101
|
+
"capabilities": {},
|
|
102
|
+
"clientInfo": {"name": "deepcrew", "version": "0.1.0"},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
if "error" in body:
|
|
107
|
+
raise MCPError(f"SSEMCP initialize error: {body['error']}")
|
|
108
|
+
|
|
109
|
+
# Fire-and-forget notification
|
|
110
|
+
try:
|
|
111
|
+
if self._http and self._post_url:
|
|
112
|
+
await self._http.post(
|
|
113
|
+
self._post_url,
|
|
114
|
+
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
115
|
+
headers=self._extra_headers,
|
|
116
|
+
)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
async def list_tools(self) -> list[ToolDef]:
|
|
121
|
+
if self._tool_cache is not None:
|
|
122
|
+
return self._tool_cache
|
|
123
|
+
self._msg_id += 1
|
|
124
|
+
body = await self._post(
|
|
125
|
+
{"jsonrpc": "2.0", "id": self._msg_id, "method": "tools/list", "params": {}}
|
|
126
|
+
)
|
|
127
|
+
if "error" in body:
|
|
128
|
+
raise MCPError(f"tools/list failed: {body['error']}")
|
|
129
|
+
raw_tools: list[dict] = body.get("result", {}).get("tools", [])
|
|
130
|
+
self._tool_cache = [_parse_mcp_tool(t, self) for t in raw_tools]
|
|
131
|
+
return self._tool_cache
|
|
132
|
+
|
|
133
|
+
async def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any] | str:
|
|
134
|
+
self._msg_id += 1
|
|
135
|
+
body = await self._post(
|
|
136
|
+
{
|
|
137
|
+
"jsonrpc": "2.0",
|
|
138
|
+
"id": self._msg_id,
|
|
139
|
+
"method": "tools/call",
|
|
140
|
+
"params": {"name": name, "arguments": args},
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
if "error" in body:
|
|
144
|
+
raise MCPError(f"Tool {name!r} call failed: {body['error']}")
|
|
145
|
+
result = body.get("result", {})
|
|
146
|
+
if isinstance(result, dict) and "content" in result:
|
|
147
|
+
contents = result["content"]
|
|
148
|
+
if contents and isinstance(contents[0], dict):
|
|
149
|
+
return contents[0].get("text", json.dumps(result))
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
async def close(self) -> None:
|
|
153
|
+
if self._http:
|
|
154
|
+
await self._http.aclose()
|
|
155
|
+
self._http = None
|