neutron-framework 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.
- neutron/__init__.py +72 -0
- neutron/__main__.py +5 -0
- neutron/ai/__init__.py +39 -0
- neutron/ai/agent.py +205 -0
- neutron/ai/mcp.py +299 -0
- neutron/ai/memory.py +202 -0
- neutron/ai/providers.py +443 -0
- neutron/ai/rag.py +212 -0
- neutron/ai/structured.py +75 -0
- neutron/ai/tools.py +146 -0
- neutron/ai/workflow.py +187 -0
- neutron/app.py +574 -0
- neutron/auth/__init__.py +37 -0
- neutron/auth/apikey.py +116 -0
- neutron/auth/csrf.py +243 -0
- neutron/auth/jwt.py +268 -0
- neutron/auth/oauth.py +568 -0
- neutron/auth/password.py +106 -0
- neutron/auth/rbac.py +128 -0
- neutron/auth/session.py +192 -0
- neutron/cache/__init__.py +9 -0
- neutron/cache/http.py +221 -0
- neutron/cache/tiered.py +139 -0
- neutron/cli.py +384 -0
- neutron/config.py +88 -0
- neutron/depends.py +30 -0
- neutron/error.py +137 -0
- neutron/handler.py +426 -0
- neutron/jobs/__init__.py +9 -0
- neutron/jobs/queue.py +626 -0
- neutron/middleware.py +548 -0
- neutron/nucleus/__init__.py +46 -0
- neutron/nucleus/_exec.py +58 -0
- neutron/nucleus/blob.py +165 -0
- neutron/nucleus/cdc.py +81 -0
- neutron/nucleus/client.py +229 -0
- neutron/nucleus/columnar.py +82 -0
- neutron/nucleus/datalog.py +73 -0
- neutron/nucleus/document.py +307 -0
- neutron/nucleus/fts.py +111 -0
- neutron/nucleus/geo.py +220 -0
- neutron/nucleus/graph.py +184 -0
- neutron/nucleus/kv.py +304 -0
- neutron/nucleus/migrate.py +172 -0
- neutron/nucleus/pubsub.py +115 -0
- neutron/nucleus/retry.py +137 -0
- neutron/nucleus/sql.py +71 -0
- neutron/nucleus/streams.py +171 -0
- neutron/nucleus/timeseries.py +179 -0
- neutron/nucleus/tx.py +105 -0
- neutron/nucleus/vector.py +179 -0
- neutron/openapi.py +371 -0
- neutron/realtime/__init__.py +10 -0
- neutron/realtime/sse.py +125 -0
- neutron/realtime/websocket.py +136 -0
- neutron/response.py +81 -0
- neutron/router.py +237 -0
- neutron/test/__init__.py +128 -0
- neutron_framework-0.1.0.dist-info/METADATA +144 -0
- neutron_framework-0.1.0.dist-info/RECORD +62 -0
- neutron_framework-0.1.0.dist-info/WHEEL +4 -0
- neutron_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
neutron/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Neutron — The AI application development framework for Python."""
|
|
2
|
+
|
|
3
|
+
from neutron.app import App
|
|
4
|
+
from neutron.config import NeutronConfig
|
|
5
|
+
from neutron.depends import Depends
|
|
6
|
+
from neutron.error import (
|
|
7
|
+
AppError,
|
|
8
|
+
bad_request,
|
|
9
|
+
conflict,
|
|
10
|
+
forbidden,
|
|
11
|
+
internal_error,
|
|
12
|
+
not_found,
|
|
13
|
+
rate_limited,
|
|
14
|
+
unauthorized,
|
|
15
|
+
validation_error,
|
|
16
|
+
)
|
|
17
|
+
from neutron.handler import Form, Header, Query, UploadFile
|
|
18
|
+
from neutron.auth.csrf import CSRFMiddleware
|
|
19
|
+
from neutron.middleware import (
|
|
20
|
+
CORSMiddleware,
|
|
21
|
+
CompressionMiddleware,
|
|
22
|
+
LoggingMiddleware,
|
|
23
|
+
OTelMiddleware,
|
|
24
|
+
RateLimitMiddleware,
|
|
25
|
+
RequestIDMiddleware,
|
|
26
|
+
TimeoutMiddleware,
|
|
27
|
+
TrailingSlashMiddleware,
|
|
28
|
+
default_stack,
|
|
29
|
+
)
|
|
30
|
+
from neutron.openapi import (
|
|
31
|
+
SecurityScheme,
|
|
32
|
+
api_key_scheme,
|
|
33
|
+
bearer_auth_scheme,
|
|
34
|
+
oauth2_scheme,
|
|
35
|
+
)
|
|
36
|
+
from neutron.router import Router
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"App",
|
|
42
|
+
"Router",
|
|
43
|
+
"Depends",
|
|
44
|
+
"Query",
|
|
45
|
+
"Header",
|
|
46
|
+
"Form",
|
|
47
|
+
"UploadFile",
|
|
48
|
+
"NeutronConfig",
|
|
49
|
+
"AppError",
|
|
50
|
+
"bad_request",
|
|
51
|
+
"not_found",
|
|
52
|
+
"unauthorized",
|
|
53
|
+
"forbidden",
|
|
54
|
+
"conflict",
|
|
55
|
+
"validation_error",
|
|
56
|
+
"rate_limited",
|
|
57
|
+
"internal_error",
|
|
58
|
+
"CSRFMiddleware",
|
|
59
|
+
"CORSMiddleware",
|
|
60
|
+
"CompressionMiddleware",
|
|
61
|
+
"LoggingMiddleware",
|
|
62
|
+
"OTelMiddleware",
|
|
63
|
+
"RateLimitMiddleware",
|
|
64
|
+
"RequestIDMiddleware",
|
|
65
|
+
"TimeoutMiddleware",
|
|
66
|
+
"TrailingSlashMiddleware",
|
|
67
|
+
"default_stack",
|
|
68
|
+
"SecurityScheme",
|
|
69
|
+
"bearer_auth_scheme",
|
|
70
|
+
"api_key_scheme",
|
|
71
|
+
"oauth2_scheme",
|
|
72
|
+
]
|
neutron/__main__.py
ADDED
neutron/ai/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Neutron AI — first-class AI primitives for application development."""
|
|
2
|
+
|
|
3
|
+
from neutron.ai.agent import Agent, AgentResult, handoff
|
|
4
|
+
from neutron.ai.mcp import MCPClient, MCPServer
|
|
5
|
+
from neutron.ai.memory import Memory, MemoryMessage
|
|
6
|
+
from neutron.ai.providers import LLM, LLMResponse
|
|
7
|
+
from neutron.ai.rag import RAGAnswer, RAGPipeline, RAGSource
|
|
8
|
+
from neutron.ai.structured import extract_structured
|
|
9
|
+
from neutron.ai.tools import Tool, tool
|
|
10
|
+
from neutron.ai.workflow import Workflow, WorkflowResult, step
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
# Providers
|
|
14
|
+
"LLM",
|
|
15
|
+
"LLMResponse",
|
|
16
|
+
# Tools
|
|
17
|
+
"Tool",
|
|
18
|
+
"tool",
|
|
19
|
+
# Structured output
|
|
20
|
+
"extract_structured",
|
|
21
|
+
# Agents
|
|
22
|
+
"Agent",
|
|
23
|
+
"AgentResult",
|
|
24
|
+
"handoff",
|
|
25
|
+
# RAG
|
|
26
|
+
"RAGPipeline",
|
|
27
|
+
"RAGAnswer",
|
|
28
|
+
"RAGSource",
|
|
29
|
+
# Workflows
|
|
30
|
+
"Workflow",
|
|
31
|
+
"WorkflowResult",
|
|
32
|
+
"step",
|
|
33
|
+
# Memory
|
|
34
|
+
"Memory",
|
|
35
|
+
"MemoryMessage",
|
|
36
|
+
# MCP
|
|
37
|
+
"MCPServer",
|
|
38
|
+
"MCPClient",
|
|
39
|
+
]
|
neutron/ai/agent.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Agent framework — tool loops, multi-turn, and handoffs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from neutron.ai.providers import LLM, LLMResponse, Message
|
|
11
|
+
from neutron.ai.tools import Tool, resolve_tool_call
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentResult(BaseModel):
|
|
15
|
+
content: str
|
|
16
|
+
messages: list[dict[str, Any]] = []
|
|
17
|
+
tool_calls_made: int = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Agent:
|
|
21
|
+
"""Base agent with tool use and multi-turn conversation.
|
|
22
|
+
|
|
23
|
+
Subclass and add tools via the ``@tool`` decorator::
|
|
24
|
+
|
|
25
|
+
class MyAgent(Agent):
|
|
26
|
+
llm = LLM(provider="openai", model="gpt-4o")
|
|
27
|
+
system_prompt = "You are a helpful assistant."
|
|
28
|
+
|
|
29
|
+
@tool
|
|
30
|
+
async def search(self, query: str) -> str:
|
|
31
|
+
\"\"\"Search the knowledge base.\"\"\"
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
agent = MyAgent()
|
|
35
|
+
result = await agent.run("Find info about quantum computing")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
llm: LLM | None = None
|
|
39
|
+
system_prompt: str = "You are a helpful assistant."
|
|
40
|
+
max_turns: int = 20
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
llm: LLM | None = None,
|
|
45
|
+
db: Any = None,
|
|
46
|
+
**kwargs: Any,
|
|
47
|
+
) -> None:
|
|
48
|
+
if llm is not None:
|
|
49
|
+
self.llm = llm
|
|
50
|
+
self.db = db
|
|
51
|
+
for k, v in kwargs.items():
|
|
52
|
+
setattr(self, k, v)
|
|
53
|
+
|
|
54
|
+
# Collect tools from class methods decorated with @tool
|
|
55
|
+
self._tools: list[Tool] = []
|
|
56
|
+
for attr_name in dir(self.__class__):
|
|
57
|
+
attr = getattr(self.__class__, attr_name, None)
|
|
58
|
+
if isinstance(attr, Tool):
|
|
59
|
+
# Bind self to the tool function
|
|
60
|
+
_fn = attr.fn
|
|
61
|
+
_self = self
|
|
62
|
+
bound_fn = lambda _fn=_fn, _self=_self, **kw: _fn(_self, **kw)
|
|
63
|
+
bound_tool = Tool(bound_fn, name=attr.name)
|
|
64
|
+
bound_tool.description = attr.description
|
|
65
|
+
bound_tool.schema = attr.schema
|
|
66
|
+
bound_tool.is_async = attr.is_async
|
|
67
|
+
self._tools.append(bound_tool)
|
|
68
|
+
|
|
69
|
+
# Also collect handoff tools
|
|
70
|
+
for attr_name in dir(self):
|
|
71
|
+
attr = getattr(self, attr_name, None)
|
|
72
|
+
if isinstance(attr, _HandoffTool):
|
|
73
|
+
self._tools.append(attr.as_tool())
|
|
74
|
+
|
|
75
|
+
async def run(
|
|
76
|
+
self,
|
|
77
|
+
prompt: str,
|
|
78
|
+
*,
|
|
79
|
+
messages: list[Message] | None = None,
|
|
80
|
+
tools: list[Tool] | None = None,
|
|
81
|
+
) -> AgentResult:
|
|
82
|
+
"""Run the agent loop until the LLM produces a final response."""
|
|
83
|
+
if self.llm is None:
|
|
84
|
+
raise ValueError("Agent requires an LLM. Set llm class attribute or pass to __init__.")
|
|
85
|
+
|
|
86
|
+
all_tools: list[Tool | Callable[..., Any]] = list(self._tools)
|
|
87
|
+
if tools:
|
|
88
|
+
all_tools.extend(tools)
|
|
89
|
+
|
|
90
|
+
conversation: list[Message] = list(messages or [])
|
|
91
|
+
if self.system_prompt:
|
|
92
|
+
conversation.insert(0, {"role": "system", "content": self.system_prompt})
|
|
93
|
+
conversation.append({"role": "user", "content": prompt})
|
|
94
|
+
|
|
95
|
+
total_tool_calls = 0
|
|
96
|
+
|
|
97
|
+
for _turn in range(self.max_turns):
|
|
98
|
+
response = await self.llm.chat(conversation, tools=all_tools)
|
|
99
|
+
|
|
100
|
+
if not response.tool_calls:
|
|
101
|
+
# Final response — no more tool calls
|
|
102
|
+
conversation.append(
|
|
103
|
+
{"role": "assistant", "content": response.content}
|
|
104
|
+
)
|
|
105
|
+
return AgentResult(
|
|
106
|
+
content=response.content,
|
|
107
|
+
messages=conversation,
|
|
108
|
+
tool_calls_made=total_tool_calls,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Process tool calls
|
|
112
|
+
# Add assistant message with tool calls
|
|
113
|
+
conversation.append(
|
|
114
|
+
{
|
|
115
|
+
"role": "assistant",
|
|
116
|
+
"content": response.content or "",
|
|
117
|
+
"tool_calls": [
|
|
118
|
+
{
|
|
119
|
+
"id": tc["id"],
|
|
120
|
+
"type": "function",
|
|
121
|
+
"function": {
|
|
122
|
+
"name": tc["name"],
|
|
123
|
+
"arguments": json.dumps(tc["arguments"]),
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
for tc in response.tool_calls
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
for tc in response.tool_calls:
|
|
132
|
+
total_tool_calls += 1
|
|
133
|
+
tool_obj = resolve_tool_call(all_tools, tc["name"], tc["arguments"])
|
|
134
|
+
if tool_obj is None:
|
|
135
|
+
tool_result = f"Error: Unknown tool '{tc['name']}'"
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
result = await tool_obj(**tc["arguments"])
|
|
139
|
+
tool_result = (
|
|
140
|
+
json.dumps(result) if not isinstance(result, str) else result
|
|
141
|
+
)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
tool_result = f"Error: {e}"
|
|
144
|
+
|
|
145
|
+
conversation.append(
|
|
146
|
+
{
|
|
147
|
+
"role": "tool",
|
|
148
|
+
"tool_call_id": tc["id"],
|
|
149
|
+
"content": tool_result,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Max turns reached
|
|
154
|
+
return AgentResult(
|
|
155
|
+
content="Max turns reached without a final response.",
|
|
156
|
+
messages=conversation,
|
|
157
|
+
tool_calls_made=total_tool_calls,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Handoffs
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _HandoffTool:
|
|
167
|
+
"""Marker for agent handoff methods."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, target_cls: type[Any], fn: Any) -> None:
|
|
170
|
+
self.target_cls = target_cls
|
|
171
|
+
self.fn = fn
|
|
172
|
+
self.name = fn.__name__
|
|
173
|
+
self.description = (fn.__doc__ or "").strip()
|
|
174
|
+
|
|
175
|
+
def as_tool(self) -> Tool:
|
|
176
|
+
from neutron.ai.tools import _build_parameters_schema
|
|
177
|
+
|
|
178
|
+
async def handoff_fn(**kwargs: Any) -> str:
|
|
179
|
+
target = self.target_cls()
|
|
180
|
+
# The first arg is typically the prompt/topic
|
|
181
|
+
prompt = " ".join(str(v) for v in kwargs.values())
|
|
182
|
+
result = await target.run(prompt)
|
|
183
|
+
return cast(str, result.content)
|
|
184
|
+
|
|
185
|
+
t = Tool(handoff_fn, name=self.name)
|
|
186
|
+
t.description = self.description or f"Hand off to {self.target_cls.__name__}"
|
|
187
|
+
t.schema = _build_parameters_schema(self.fn)
|
|
188
|
+
return t
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def handoff(to: type[Any]) -> Any:
|
|
192
|
+
"""Decorator to create a handoff tool to another agent.
|
|
193
|
+
|
|
194
|
+
Usage::
|
|
195
|
+
|
|
196
|
+
class TriageAgent(Agent):
|
|
197
|
+
@handoff(to=ResearchAgent)
|
|
198
|
+
async def research(self, topic: str) -> str:
|
|
199
|
+
\"\"\"Hand off to research specialist.\"\"\"
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def decorator(fn: Any) -> _HandoffTool:
|
|
203
|
+
return _HandoffTool(to, fn)
|
|
204
|
+
|
|
205
|
+
return decorator
|
neutron/ai/mcp.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) — server and client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Callable, cast
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import JSONResponse
|
|
12
|
+
from starlette.routing import Route
|
|
13
|
+
|
|
14
|
+
from neutron.ai.tools import Tool, _build_parameters_schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# MCP Server
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MCPServer:
|
|
23
|
+
"""MCP-compatible tool + resource server.
|
|
24
|
+
|
|
25
|
+
Exposes tools and resources via a JSON-RPC-style HTTP interface
|
|
26
|
+
that can be consumed by MCP clients (Claude Desktop, other agents).
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
mcp = MCPServer(name="my-tools")
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
async def search(query: str) -> list[dict]:
|
|
34
|
+
\"\"\"Search the database.\"\"\"
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@mcp.resource("users://{user_id}")
|
|
38
|
+
async def get_user(user_id: str) -> dict:
|
|
39
|
+
\"\"\"Get user by ID.\"\"\"
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
app.mount("/mcp", mcp)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, name: str = "neutron-mcp", version: str = "1.0.0") -> None:
|
|
46
|
+
self.name = name
|
|
47
|
+
self.version = version
|
|
48
|
+
self._tools: dict[str, Tool] = {}
|
|
49
|
+
self._resources: dict[str, _ResourceDef] = {}
|
|
50
|
+
self._asgi_app: Any = None
|
|
51
|
+
|
|
52
|
+
def tool(self, name: str | None = None) -> Callable:
|
|
53
|
+
"""Decorator to register a function as an MCP tool."""
|
|
54
|
+
|
|
55
|
+
def decorator(fn: Callable) -> Callable:
|
|
56
|
+
tool_name = name or fn.__name__
|
|
57
|
+
t = Tool(fn, name=tool_name)
|
|
58
|
+
self._tools[tool_name] = t
|
|
59
|
+
return fn
|
|
60
|
+
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
def resource(self, uri_template: str) -> Callable:
|
|
64
|
+
"""Decorator to register a resource with a URI template.
|
|
65
|
+
|
|
66
|
+
URI templates use ``{param}`` placeholders::
|
|
67
|
+
|
|
68
|
+
@mcp.resource("users://{user_id}")
|
|
69
|
+
async def get_user(user_id: str) -> dict: ...
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def decorator(fn: Callable) -> Callable:
|
|
73
|
+
res = _ResourceDef(uri_template, fn)
|
|
74
|
+
self._resources[uri_template] = res
|
|
75
|
+
return fn
|
|
76
|
+
|
|
77
|
+
return decorator
|
|
78
|
+
|
|
79
|
+
# --- ASGI / Starlette integration ---
|
|
80
|
+
|
|
81
|
+
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
|
82
|
+
"""ASGI interface — route MCP requests."""
|
|
83
|
+
# The route table binds to live bound-methods of self (the handlers
|
|
84
|
+
# read self._tools / self._resources at request time), so one cached
|
|
85
|
+
# app serves every request and still sees tools/resources registered
|
|
86
|
+
# after the first request.
|
|
87
|
+
if self._asgi_app is None:
|
|
88
|
+
self._asgi_app = self._build_asgi_app()
|
|
89
|
+
await self._asgi_app(scope, receive, send)
|
|
90
|
+
|
|
91
|
+
def _build_asgi_app(self) -> Any:
|
|
92
|
+
from starlette.applications import Starlette
|
|
93
|
+
from starlette.routing import Route
|
|
94
|
+
|
|
95
|
+
routes = [
|
|
96
|
+
Route("/", self._handle_root, methods=["GET"]),
|
|
97
|
+
Route("/tools", self._handle_list_tools, methods=["GET"]),
|
|
98
|
+
Route("/tools/{tool_name}", self._handle_call_tool, methods=["POST"]),
|
|
99
|
+
Route("/resources", self._handle_list_resources, methods=["GET"]),
|
|
100
|
+
Route(
|
|
101
|
+
"/resources/{resource_path:path}",
|
|
102
|
+
self._handle_read_resource,
|
|
103
|
+
methods=["GET"],
|
|
104
|
+
),
|
|
105
|
+
]
|
|
106
|
+
return Starlette(routes=routes)
|
|
107
|
+
|
|
108
|
+
async def _handle_root(self, request: Request) -> JSONResponse:
|
|
109
|
+
"""Server info / capabilities."""
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
{
|
|
112
|
+
"name": self.name,
|
|
113
|
+
"version": self.version,
|
|
114
|
+
"protocol": "mcp",
|
|
115
|
+
"capabilities": {
|
|
116
|
+
"tools": len(self._tools) > 0,
|
|
117
|
+
"resources": len(self._resources) > 0,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def _handle_list_tools(self, request: Request) -> JSONResponse:
|
|
123
|
+
"""List available tools with their schemas."""
|
|
124
|
+
tools = []
|
|
125
|
+
for t in self._tools.values():
|
|
126
|
+
tools.append(
|
|
127
|
+
{
|
|
128
|
+
"name": t.name,
|
|
129
|
+
"description": t.description,
|
|
130
|
+
"inputSchema": t.schema,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return JSONResponse({"tools": tools})
|
|
134
|
+
|
|
135
|
+
async def _handle_call_tool(self, request: Request) -> JSONResponse:
|
|
136
|
+
"""Call a tool by name."""
|
|
137
|
+
tool_name = request.path_params["tool_name"]
|
|
138
|
+
tool_obj = self._tools.get(tool_name)
|
|
139
|
+
if tool_obj is None:
|
|
140
|
+
return JSONResponse(
|
|
141
|
+
{"error": f"Unknown tool: {tool_name}"}, status_code=404
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
body = await request.json()
|
|
146
|
+
except Exception:
|
|
147
|
+
body = {}
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
result = await tool_obj(**body)
|
|
151
|
+
return JSONResponse({"result": result})
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
154
|
+
|
|
155
|
+
async def _handle_list_resources(self, request: Request) -> JSONResponse:
|
|
156
|
+
"""List available resources."""
|
|
157
|
+
resources = []
|
|
158
|
+
for uri_template, res in self._resources.items():
|
|
159
|
+
resources.append(
|
|
160
|
+
{
|
|
161
|
+
"uri_template": uri_template,
|
|
162
|
+
"name": res.fn.__name__,
|
|
163
|
+
"description": (res.fn.__doc__ or "").strip(),
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
return JSONResponse({"resources": resources})
|
|
167
|
+
|
|
168
|
+
async def _handle_read_resource(self, request: Request) -> JSONResponse:
|
|
169
|
+
"""Read a resource by URI."""
|
|
170
|
+
resource_path = request.path_params["resource_path"]
|
|
171
|
+
|
|
172
|
+
for uri_template, res in self._resources.items():
|
|
173
|
+
params = res.match(resource_path)
|
|
174
|
+
if params is not None:
|
|
175
|
+
try:
|
|
176
|
+
result = await res.call(**params)
|
|
177
|
+
return JSONResponse({"result": result})
|
|
178
|
+
except Exception as e:
|
|
179
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
180
|
+
|
|
181
|
+
return JSONResponse(
|
|
182
|
+
{"error": f"No resource matches: {resource_path}"}, status_code=404
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Resource definition
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _ResourceDef:
|
|
192
|
+
"""A resource with a URI template and handler function."""
|
|
193
|
+
|
|
194
|
+
def __init__(self, uri_template: str, fn: Callable) -> None:
|
|
195
|
+
self.uri_template = uri_template
|
|
196
|
+
self.fn = fn
|
|
197
|
+
self._is_async = _is_coroutine(fn)
|
|
198
|
+
|
|
199
|
+
# Build regex from URI template: "users://{user_id}" → "users://(?P<user_id>[^/]+)"
|
|
200
|
+
pattern = re.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", uri_template)
|
|
201
|
+
self._pattern = re.compile(f"^{pattern}$")
|
|
202
|
+
|
|
203
|
+
def match(self, uri: str) -> dict[str, str] | None:
|
|
204
|
+
"""Try to match a URI against this template. Returns params or None."""
|
|
205
|
+
m = self._pattern.match(uri)
|
|
206
|
+
if m:
|
|
207
|
+
return m.groupdict()
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
async def call(self, **kwargs: Any) -> Any:
|
|
211
|
+
if self._is_async:
|
|
212
|
+
return await self.fn(**kwargs)
|
|
213
|
+
return self.fn(**kwargs)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_coroutine(fn: Callable) -> bool:
|
|
217
|
+
import inspect
|
|
218
|
+
|
|
219
|
+
return inspect.iscoroutinefunction(fn)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# MCP Client
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class MCPClient:
|
|
228
|
+
"""Client for connecting to MCP servers.
|
|
229
|
+
|
|
230
|
+
Usage::
|
|
231
|
+
|
|
232
|
+
async with MCPClient("http://other-service/mcp") as client:
|
|
233
|
+
tools = await client.list_tools()
|
|
234
|
+
result = await client.call_tool("search", {"query": "hello"})
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self, url: str) -> None:
|
|
238
|
+
self.url = url.rstrip("/")
|
|
239
|
+
self._client: httpx.AsyncClient | None = None
|
|
240
|
+
|
|
241
|
+
async def __aenter__(self) -> MCPClient:
|
|
242
|
+
self._client = httpx.AsyncClient(timeout=60.0)
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
246
|
+
if self._client:
|
|
247
|
+
await self._client.aclose()
|
|
248
|
+
self._client = None
|
|
249
|
+
|
|
250
|
+
def _ensure_client(self) -> httpx.AsyncClient:
|
|
251
|
+
if self._client is None:
|
|
252
|
+
raise RuntimeError("MCPClient must be used as an async context manager")
|
|
253
|
+
return self._client
|
|
254
|
+
|
|
255
|
+
async def server_info(self) -> dict[str, Any]:
|
|
256
|
+
"""Get server info and capabilities."""
|
|
257
|
+
client = self._ensure_client()
|
|
258
|
+
resp = await client.get(f"{self.url}/")
|
|
259
|
+
resp.raise_for_status()
|
|
260
|
+
return cast("dict[str, Any]", resp.json())
|
|
261
|
+
|
|
262
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
263
|
+
"""List available tools on the server."""
|
|
264
|
+
client = self._ensure_client()
|
|
265
|
+
resp = await client.get(f"{self.url}/tools")
|
|
266
|
+
resp.raise_for_status()
|
|
267
|
+
return cast("list[dict[str, Any]]", resp.json().get("tools", []))
|
|
268
|
+
|
|
269
|
+
async def call_tool(
|
|
270
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
271
|
+
) -> Any:
|
|
272
|
+
"""Call a tool on the MCP server."""
|
|
273
|
+
client = self._ensure_client()
|
|
274
|
+
resp = await client.post(
|
|
275
|
+
f"{self.url}/tools/{name}",
|
|
276
|
+
json=arguments or {},
|
|
277
|
+
)
|
|
278
|
+
resp.raise_for_status()
|
|
279
|
+
data = resp.json()
|
|
280
|
+
if "error" in data:
|
|
281
|
+
raise RuntimeError(f"MCP tool error: {data['error']}")
|
|
282
|
+
return data.get("result")
|
|
283
|
+
|
|
284
|
+
async def list_resources(self) -> list[dict[str, Any]]:
|
|
285
|
+
"""List available resources on the server."""
|
|
286
|
+
client = self._ensure_client()
|
|
287
|
+
resp = await client.get(f"{self.url}/resources")
|
|
288
|
+
resp.raise_for_status()
|
|
289
|
+
return cast("list[dict[str, Any]]", resp.json().get("resources", []))
|
|
290
|
+
|
|
291
|
+
async def read_resource(self, uri: str) -> Any:
|
|
292
|
+
"""Read a resource by URI."""
|
|
293
|
+
client = self._ensure_client()
|
|
294
|
+
resp = await client.get(f"{self.url}/resources/{uri}")
|
|
295
|
+
resp.raise_for_status()
|
|
296
|
+
data = resp.json()
|
|
297
|
+
if "error" in data:
|
|
298
|
+
raise RuntimeError(f"MCP resource error: {data['error']}")
|
|
299
|
+
return data.get("result")
|