nexus-coding-agent 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.
- nexus/__init__.py +4 -0
- nexus/application/__init__.py +2 -0
- nexus/application/approval_service.py +130 -0
- nexus/application/diff_service.py +177 -0
- nexus/application/event_publisher.py +180 -0
- nexus/application/execution_context.py +29 -0
- nexus/application/execution_ledger.py +126 -0
- nexus/application/model_call_context.py +29 -0
- nexus/application/observed_model_gateway.py +194 -0
- nexus/application/plan_approval_service.py +250 -0
- nexus/application/planning.py +668 -0
- nexus/application/runtime.py +624 -0
- nexus/application/session_service.py +411 -0
- nexus/application/structured_output.py +96 -0
- nexus/application/telemetry.py +396 -0
- nexus/application/telemetry_subscriber.py +216 -0
- nexus/application/tool_runtime.py +496 -0
- nexus/application/tracing_dispatcher.py +283 -0
- nexus/application/validation.py +293 -0
- nexus/config/__init__.py +17 -0
- nexus/config/loader.py +253 -0
- nexus/config/models.py +310 -0
- nexus/context/__init__.py +6 -0
- nexus/context/budget.py +24 -0
- nexus/context/builder.py +92 -0
- nexus/context/chunking.py +65 -0
- nexus/context/explorer.py +246 -0
- nexus/context/integration.py +63 -0
- nexus/context/manager.py +328 -0
- nexus/context/retrieval.py +261 -0
- nexus/domain/__init__.py +2 -0
- nexus/domain/agent_decision.py +113 -0
- nexus/domain/agent_state.py +49 -0
- nexus/domain/approvals.py +64 -0
- nexus/domain/context.py +108 -0
- nexus/domain/exploration.py +86 -0
- nexus/domain/mcp.py +51 -0
- nexus/domain/model.py +173 -0
- nexus/domain/observability.py +326 -0
- nexus/domain/persistence.py +122 -0
- nexus/domain/planning.py +329 -0
- nexus/domain/ports/__init__.py +61 -0
- nexus/domain/ports/agent_decision.py +9 -0
- nexus/domain/ports/approval_unit_of_work.py +27 -0
- nexus/domain/ports/checkpoint_provider.py +11 -0
- nexus/domain/ports/context.py +71 -0
- nexus/domain/ports/events.py +21 -0
- nexus/domain/ports/graph_runtime.py +30 -0
- nexus/domain/ports/mcp.py +23 -0
- nexus/domain/ports/model_gateway.py +21 -0
- nexus/domain/ports/model_input_budget.py +15 -0
- nexus/domain/ports/planning.py +62 -0
- nexus/domain/ports/repositories.py +54 -0
- nexus/domain/ports/repository_context.py +18 -0
- nexus/domain/ports/session_unit_of_work.py +33 -0
- nexus/domain/ports/skills.py +41 -0
- nexus/domain/ports/tooling.py +39 -0
- nexus/domain/ports/tracer.py +13 -0
- nexus/domain/ports/validation.py +32 -0
- nexus/domain/runtime_events.py +445 -0
- nexus/domain/skills.py +129 -0
- nexus/domain/tooling.py +163 -0
- nexus/domain/validation.py +161 -0
- nexus/errors/__init__.py +82 -0
- nexus/evaluation/__init__.py +7 -0
- nexus/evaluation/assertions.py +4 -0
- nexus/evaluation/evaluator.py +263 -0
- nexus/evaluation/fixtures.py +36 -0
- nexus/evaluation/harness.py +606 -0
- nexus/evaluation/loader.py +415 -0
- nexus/evaluation/metrics.py +27 -0
- nexus/evaluation/models.py +362 -0
- nexus/evaluation/report.py +43 -0
- nexus/evaluation/runner.py +480 -0
- nexus/infrastructure/__init__.py +2 -0
- nexus/infrastructure/asyncio_compat.py +13 -0
- nexus/infrastructure/bootstrap/__init__.py +17 -0
- nexus/infrastructure/bootstrap/composition.py +663 -0
- nexus/infrastructure/checkpoint/__init__.py +5 -0
- nexus/infrastructure/checkpoint/postgres.py +62 -0
- nexus/infrastructure/database.py +39 -0
- nexus/infrastructure/embedding.py +61 -0
- nexus/infrastructure/graph/__init__.py +6 -0
- nexus/infrastructure/graph/day4_runtime.py +966 -0
- nexus/infrastructure/graph/langgraph_runtime.py +172 -0
- nexus/infrastructure/mcp/__init__.py +5 -0
- nexus/infrastructure/mcp/manager.py +380 -0
- nexus/infrastructure/model_gateway/__init__.py +6 -0
- nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
- nexus/infrastructure/observability/__init__.py +7 -0
- nexus/infrastructure/observability/console.py +31 -0
- nexus/infrastructure/observability/langsmith.py +236 -0
- nexus/infrastructure/observability/structured_logger.py +70 -0
- nexus/infrastructure/persistence/__init__.py +13 -0
- nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
- nexus/infrastructure/persistence/models.py +146 -0
- nexus/infrastructure/persistence/repositories.py +326 -0
- nexus/infrastructure/persistence/unit_of_work.py +82 -0
- nexus/infrastructure/repository_files.py +117 -0
- nexus/infrastructure/sandbox/__init__.py +5 -0
- nexus/infrastructure/sandbox/local_process.py +353 -0
- nexus/infrastructure/semantic.py +276 -0
- nexus/infrastructure/semantic_models.py +56 -0
- nexus/interfaces/__init__.py +2 -0
- nexus/interfaces/cli/__init__.py +2 -0
- nexus/interfaces/cli/__main__.py +6 -0
- nexus/interfaces/cli/app.py +266 -0
- nexus/interfaces/cli/renderer.py +118 -0
- nexus/security/__init__.py +14 -0
- nexus/security/approval_policies.py +73 -0
- nexus/security/command_policy.py +117 -0
- nexus/security/executables.py +107 -0
- nexus/security/git_commands.py +65 -0
- nexus/security/workspace.py +93 -0
- nexus/skills/__init__.py +7 -0
- nexus/skills/builtin/__init__.py +1 -0
- nexus/skills/builtin/debug-python/SKILL.md +26 -0
- nexus/skills/builtin/review-repository/SKILL.md +26 -0
- nexus/skills/builtin/write-tests/SKILL.md +26 -0
- nexus/skills/loader.py +439 -0
- nexus/skills/registry.py +146 -0
- nexus/skills/selector.py +152 -0
- nexus/tools/__init__.py +6 -0
- nexus/tools/editing.py +312 -0
- nexus/tools/mcp.py +160 -0
- nexus/tools/native.py +654 -0
- nexus/tools/registry.py +27 -0
- nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
- nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
- nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Minimal Day 1 LangGraph implementation behind GraphRuntime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypedDict, cast
|
|
6
|
+
|
|
7
|
+
from langchain_core.runnables import RunnableConfig
|
|
8
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
9
|
+
from langgraph.graph import END, START, StateGraph
|
|
10
|
+
|
|
11
|
+
from nexus.application.model_call_context import bind_model_call_phase
|
|
12
|
+
from nexus.domain.agent_state import AgentState
|
|
13
|
+
from nexus.domain.model import ModelCallPhase, ModelMessage
|
|
14
|
+
from nexus.domain.planning import PlanApprovalResumeInput
|
|
15
|
+
from nexus.domain.ports.model_gateway import ModelGateway
|
|
16
|
+
from nexus.domain.runtime_events import RuntimeStatus
|
|
17
|
+
from nexus.errors import NexusError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _GraphState(TypedDict):
|
|
21
|
+
task: str
|
|
22
|
+
messages: list[ModelMessage]
|
|
23
|
+
run_id: str
|
|
24
|
+
session_id: str | None
|
|
25
|
+
status: RuntimeStatus
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LangGraphRuntime:
|
|
29
|
+
"""Compile and execute START -> model_response -> END."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
model_gateway: ModelGateway,
|
|
34
|
+
*,
|
|
35
|
+
checkpointer: BaseCheckpointSaver[Any] | None = None,
|
|
36
|
+
interrupt_before_model_response: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
async def model_response(state: _GraphState) -> dict[str, object]:
|
|
39
|
+
with bind_model_call_phase(ModelCallPhase.DIRECT_RESPONSE):
|
|
40
|
+
response = await model_gateway.complete(state["messages"])
|
|
41
|
+
assistant = ModelMessage(role="assistant", content=response.content)
|
|
42
|
+
return {
|
|
43
|
+
"messages": [*state["messages"], assistant],
|
|
44
|
+
"status": RuntimeStatus.COMPLETED,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
builder = StateGraph(_GraphState)
|
|
48
|
+
builder.add_node("model_response", model_response)
|
|
49
|
+
builder.add_edge(START, "model_response")
|
|
50
|
+
builder.add_edge("model_response", END)
|
|
51
|
+
self._graph = builder.compile(
|
|
52
|
+
checkpointer=checkpointer,
|
|
53
|
+
interrupt_before=["model_response"] if interrupt_before_model_response else None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def run(
|
|
57
|
+
self,
|
|
58
|
+
state: AgentState,
|
|
59
|
+
*,
|
|
60
|
+
thread_id: str | None = None,
|
|
61
|
+
) -> AgentState:
|
|
62
|
+
"""Translate Nexus state into/out of the hidden LangGraph implementation."""
|
|
63
|
+
|
|
64
|
+
graph_state: _GraphState = {
|
|
65
|
+
"task": state.task,
|
|
66
|
+
"messages": state.messages,
|
|
67
|
+
"run_id": state.run_id,
|
|
68
|
+
"session_id": state.session_id,
|
|
69
|
+
"status": state.status,
|
|
70
|
+
}
|
|
71
|
+
config = _thread_config(
|
|
72
|
+
thread_id,
|
|
73
|
+
run_id=state.run_id,
|
|
74
|
+
session_id=state.session_id,
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
result = await self._graph.ainvoke(graph_state, config=config)
|
|
78
|
+
except NexusError:
|
|
79
|
+
raise
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise NexusError(
|
|
82
|
+
"The graph execution failed.",
|
|
83
|
+
code="GRAPH_EXECUTION_ERROR",
|
|
84
|
+
retryable=True,
|
|
85
|
+
) from exc
|
|
86
|
+
status_override = (
|
|
87
|
+
RuntimeStatus.INTERRUPTED
|
|
88
|
+
if thread_id is not None and result.get("status") == RuntimeStatus.STARTED
|
|
89
|
+
else None
|
|
90
|
+
)
|
|
91
|
+
return self._to_agent_state(
|
|
92
|
+
cast(_GraphState, result), state, status_override=status_override
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def resume(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
thread_id: str,
|
|
99
|
+
resume_input: PlanApprovalResumeInput | None = None,
|
|
100
|
+
) -> AgentState:
|
|
101
|
+
"""Resume exclusively from the checkpointer-owned latest thread snapshot."""
|
|
102
|
+
|
|
103
|
+
if resume_input is not None:
|
|
104
|
+
raise NexusError(
|
|
105
|
+
"This checkpoint is not waiting for Plan approval.",
|
|
106
|
+
code="INVALID_APPROVAL_TRANSITION",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = await self._graph.ainvoke(None, config=_thread_config(thread_id))
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
raise NexusError(
|
|
113
|
+
"Nexus could not resume the persisted graph execution.",
|
|
114
|
+
code="GRAPH_RESUME_ERROR",
|
|
115
|
+
retryable=True,
|
|
116
|
+
) from exc
|
|
117
|
+
if not isinstance(result, dict):
|
|
118
|
+
raise NexusError(
|
|
119
|
+
"The resumed graph returned invalid state.",
|
|
120
|
+
code="GRAPH_INVALID_STATE",
|
|
121
|
+
)
|
|
122
|
+
return self._to_agent_state(cast(_GraphState, result), None)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _to_agent_state(
|
|
126
|
+
result: _GraphState,
|
|
127
|
+
original: AgentState | None,
|
|
128
|
+
*,
|
|
129
|
+
status_override: RuntimeStatus | None = None,
|
|
130
|
+
) -> AgentState:
|
|
131
|
+
messages = result.get("messages")
|
|
132
|
+
if not isinstance(messages, list) or not all(
|
|
133
|
+
isinstance(message, ModelMessage) for message in messages
|
|
134
|
+
):
|
|
135
|
+
raise NexusError("The graph returned invalid messages.", code="GRAPH_INVALID_STATE")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
status = status_override or RuntimeStatus(result["status"])
|
|
139
|
+
except (KeyError, ValueError) as exc:
|
|
140
|
+
raise NexusError(
|
|
141
|
+
"The graph returned an invalid status.",
|
|
142
|
+
code="GRAPH_INVALID_STATE",
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
return AgentState(
|
|
146
|
+
task=str(result.get("task", original.task if original is not None else "")),
|
|
147
|
+
messages=messages,
|
|
148
|
+
run_id=str(result.get("run_id", original.run_id if original is not None else "")),
|
|
149
|
+
session_id=result.get(
|
|
150
|
+
"session_id", original.session_id if original is not None else None
|
|
151
|
+
),
|
|
152
|
+
status=status,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _thread_config(
|
|
157
|
+
thread_id: str | None,
|
|
158
|
+
*,
|
|
159
|
+
run_id: str | None = None,
|
|
160
|
+
session_id: str | None = None,
|
|
161
|
+
) -> RunnableConfig | None:
|
|
162
|
+
if thread_id is None:
|
|
163
|
+
return None
|
|
164
|
+
metadata = {
|
|
165
|
+
key: value
|
|
166
|
+
for key, value in {"run_id": run_id, "session_id": session_id}.items()
|
|
167
|
+
if value is not None
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
"configurable": {"thread_id": thread_id},
|
|
171
|
+
"metadata": metadata,
|
|
172
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Sequential stdio MCP lifecycle implemented behind Nexus-owned values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol, cast
|
|
11
|
+
|
|
12
|
+
from mcp import Client, StdioServerParameters
|
|
13
|
+
from mcp.shared.exceptions import MCPError as SDKMCPError
|
|
14
|
+
from mcp_types import REQUEST_TIMEOUT
|
|
15
|
+
|
|
16
|
+
from nexus.config.models import MCPServerConfig
|
|
17
|
+
from nexus.domain.mcp import MCPConnectionInfo, MCPToolDescriptor
|
|
18
|
+
from nexus.domain.tooling import JsonObject
|
|
19
|
+
from nexus.errors import MCPError
|
|
20
|
+
|
|
21
|
+
_MAX_DESCRIPTION_CHARS = 2_000
|
|
22
|
+
_MAX_SCHEMA_BYTES = 64_000
|
|
23
|
+
_MAX_TEXT_CHARS = 16_000
|
|
24
|
+
_MAX_BINARY_CHARS = 4_096
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _SessionLike(Protocol):
|
|
28
|
+
async def call_tool(
|
|
29
|
+
self,
|
|
30
|
+
name: str,
|
|
31
|
+
arguments: dict[str, Any] | None = None,
|
|
32
|
+
read_timeout_seconds: float | None = None,
|
|
33
|
+
progress_callback: object | None = None,
|
|
34
|
+
*,
|
|
35
|
+
input_responses: object | None = None,
|
|
36
|
+
request_state: str | None = None,
|
|
37
|
+
meta: object | None = None,
|
|
38
|
+
allow_input_required: bool = False,
|
|
39
|
+
allow_claimed: bool = False,
|
|
40
|
+
) -> object: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _ClientLike(Protocol):
|
|
44
|
+
server_info: object | None
|
|
45
|
+
protocol_version: str | None
|
|
46
|
+
session: _SessionLike
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> _ClientLike: ...
|
|
49
|
+
|
|
50
|
+
async def __aexit__(
|
|
51
|
+
self,
|
|
52
|
+
exc_type: type[BaseException] | None,
|
|
53
|
+
exc_val: BaseException | None,
|
|
54
|
+
exc_tb: object,
|
|
55
|
+
) -> None: ...
|
|
56
|
+
|
|
57
|
+
async def list_tools(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
cursor: str | None = None,
|
|
61
|
+
meta: object | None = None,
|
|
62
|
+
cache_mode: str = "use",
|
|
63
|
+
) -> object: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ClientFactory = Callable[[MCPServerConfig], _ClientLike]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class _Connection:
|
|
71
|
+
config: MCPServerConfig
|
|
72
|
+
client: _ClientLike
|
|
73
|
+
remote_names: frozenset[str] = frozenset()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SDKMCPManager:
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
servers: tuple[MCPServerConfig, ...],
|
|
80
|
+
*,
|
|
81
|
+
client_factory: ClientFactory | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._servers = tuple(server for server in servers if server.enabled)
|
|
84
|
+
self._client_factory = client_factory or _sdk_client
|
|
85
|
+
self._connections: list[_Connection] = []
|
|
86
|
+
self._descriptors: tuple[MCPToolDescriptor, ...] | None = None
|
|
87
|
+
self._connection_info: tuple[MCPConnectionInfo, ...] | None = None
|
|
88
|
+
|
|
89
|
+
async def connect(self) -> tuple[MCPConnectionInfo, ...]:
|
|
90
|
+
if self._connection_info is not None:
|
|
91
|
+
return self._connection_info
|
|
92
|
+
try:
|
|
93
|
+
for config in self._servers:
|
|
94
|
+
self._connections.append(await self._connect_one(config))
|
|
95
|
+
except asyncio.CancelledError:
|
|
96
|
+
await self.close()
|
|
97
|
+
raise
|
|
98
|
+
except Exception:
|
|
99
|
+
await self.close()
|
|
100
|
+
raise
|
|
101
|
+
self._connection_info = tuple(
|
|
102
|
+
MCPConnectionInfo(
|
|
103
|
+
connection.config.server_id,
|
|
104
|
+
True,
|
|
105
|
+
_optional_text(connection.client.server_info, "name"),
|
|
106
|
+
_optional_text(connection.client.server_info, "version"),
|
|
107
|
+
connection.client.protocol_version,
|
|
108
|
+
)
|
|
109
|
+
for connection in self._connections
|
|
110
|
+
)
|
|
111
|
+
return self._connection_info
|
|
112
|
+
|
|
113
|
+
async def _connect_one(self, config: MCPServerConfig) -> _Connection:
|
|
114
|
+
for attempt in range(1, config.max_connect_attempts + 1):
|
|
115
|
+
client: _ClientLike | None = None
|
|
116
|
+
try:
|
|
117
|
+
client = self._client_factory(config)
|
|
118
|
+
async with asyncio.timeout(config.connect_timeout_seconds):
|
|
119
|
+
await client.__aenter__()
|
|
120
|
+
return _Connection(config, client)
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
if client is not None:
|
|
123
|
+
await _close_client(client)
|
|
124
|
+
raise
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
if client is not None:
|
|
127
|
+
await _close_client(client)
|
|
128
|
+
retryable = _is_retryable_connect_failure(exc)
|
|
129
|
+
if not retryable or attempt == config.max_connect_attempts:
|
|
130
|
+
raise MCPError(
|
|
131
|
+
f"MCP server {config.server_id!r} failed during connect.",
|
|
132
|
+
code="MCP_CONNECT_FAILED",
|
|
133
|
+
retryable=False,
|
|
134
|
+
) from exc
|
|
135
|
+
await asyncio.sleep(0)
|
|
136
|
+
raise AssertionError("MCP connect attempt loop did not terminate.")
|
|
137
|
+
|
|
138
|
+
async def list_tools(self) -> tuple[MCPToolDescriptor, ...]:
|
|
139
|
+
if self._connection_info is None:
|
|
140
|
+
raise MCPError(
|
|
141
|
+
"MCP discovery requires a successful connection.",
|
|
142
|
+
code="MCP_DISCOVERY_FAILED",
|
|
143
|
+
)
|
|
144
|
+
if self._descriptors is not None:
|
|
145
|
+
return self._descriptors
|
|
146
|
+
descriptors: list[MCPToolDescriptor] = []
|
|
147
|
+
try:
|
|
148
|
+
for connection in self._connections:
|
|
149
|
+
names: set[str] = set()
|
|
150
|
+
cursor: str | None = None
|
|
151
|
+
while True:
|
|
152
|
+
async with asyncio.timeout(
|
|
153
|
+
connection.config.connect_timeout_seconds
|
|
154
|
+
):
|
|
155
|
+
page = await connection.client.list_tools(cursor=cursor)
|
|
156
|
+
tools = getattr(page, "tools", None)
|
|
157
|
+
if not isinstance(tools, list):
|
|
158
|
+
raise MCPError(
|
|
159
|
+
f"MCP server {connection.config.server_id!r} returned "
|
|
160
|
+
"an invalid discovery result.",
|
|
161
|
+
code="MCP_DISCOVERY_FAILED",
|
|
162
|
+
)
|
|
163
|
+
for tool in tools:
|
|
164
|
+
descriptor = _descriptor(connection.config.server_id, tool)
|
|
165
|
+
if descriptor.remote_name in names:
|
|
166
|
+
raise MCPError(
|
|
167
|
+
f"MCP server {connection.config.server_id!r} advertised "
|
|
168
|
+
"a duplicate Tool name.",
|
|
169
|
+
code="MCP_DISCOVERY_FAILED",
|
|
170
|
+
)
|
|
171
|
+
names.add(descriptor.remote_name)
|
|
172
|
+
descriptors.append(descriptor)
|
|
173
|
+
next_cursor = getattr(page, "next_cursor", None)
|
|
174
|
+
if next_cursor is None:
|
|
175
|
+
break
|
|
176
|
+
if not isinstance(next_cursor, str) or not next_cursor:
|
|
177
|
+
raise MCPError(
|
|
178
|
+
f"MCP server {connection.config.server_id!r} returned "
|
|
179
|
+
"an invalid discovery cursor.",
|
|
180
|
+
code="MCP_DISCOVERY_FAILED",
|
|
181
|
+
)
|
|
182
|
+
cursor = next_cursor
|
|
183
|
+
connection.remote_names = frozenset(names)
|
|
184
|
+
except asyncio.CancelledError:
|
|
185
|
+
raise
|
|
186
|
+
except MCPError:
|
|
187
|
+
raise
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise MCPError(
|
|
190
|
+
"MCP Tool discovery failed.",
|
|
191
|
+
code="MCP_DISCOVERY_FAILED",
|
|
192
|
+
retryable=False,
|
|
193
|
+
) from exc
|
|
194
|
+
registry_names = [item.registry_name for item in descriptors]
|
|
195
|
+
if len(registry_names) != len(set(registry_names)):
|
|
196
|
+
raise MCPError(
|
|
197
|
+
"MCP Tool discovery produced duplicate registry names.",
|
|
198
|
+
code="MCP_DISCOVERY_FAILED",
|
|
199
|
+
)
|
|
200
|
+
self._descriptors = tuple(descriptors)
|
|
201
|
+
return self._descriptors
|
|
202
|
+
|
|
203
|
+
async def call_tool(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
server_id: str,
|
|
207
|
+
remote_name: str,
|
|
208
|
+
arguments: JsonObject,
|
|
209
|
+
timeout_seconds: float,
|
|
210
|
+
) -> JsonObject:
|
|
211
|
+
connection = next(
|
|
212
|
+
(item for item in self._connections if item.config.server_id == server_id),
|
|
213
|
+
None,
|
|
214
|
+
)
|
|
215
|
+
if connection is None or remote_name not in connection.remote_names:
|
|
216
|
+
raise MCPError(
|
|
217
|
+
f"MCP Tool {server_id!r}/{remote_name!r} is not connected and discovered.",
|
|
218
|
+
code="MCP_CALL_FAILED",
|
|
219
|
+
)
|
|
220
|
+
timeout = min(timeout_seconds, connection.config.tool_timeout_seconds)
|
|
221
|
+
try:
|
|
222
|
+
async with asyncio.timeout(timeout):
|
|
223
|
+
result = await connection.client.session.call_tool(
|
|
224
|
+
remote_name,
|
|
225
|
+
dict(arguments),
|
|
226
|
+
read_timeout_seconds=timeout,
|
|
227
|
+
allow_input_required=True,
|
|
228
|
+
)
|
|
229
|
+
return _normalize_result(result)
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
raise
|
|
232
|
+
except TimeoutError as exc:
|
|
233
|
+
raise MCPError(
|
|
234
|
+
f"MCP Tool {server_id!r}/{remote_name!r} timed out.",
|
|
235
|
+
code="MCP_TOOL_TIMEOUT",
|
|
236
|
+
retryable=True,
|
|
237
|
+
) from exc
|
|
238
|
+
except SDKMCPError as exc:
|
|
239
|
+
if exc.code == REQUEST_TIMEOUT:
|
|
240
|
+
raise MCPError(
|
|
241
|
+
f"MCP Tool {server_id!r}/{remote_name!r} timed out.",
|
|
242
|
+
code="MCP_TOOL_TIMEOUT",
|
|
243
|
+
retryable=True,
|
|
244
|
+
) from exc
|
|
245
|
+
raise MCPError(
|
|
246
|
+
f"MCP Tool {server_id!r}/{remote_name!r} failed during call.",
|
|
247
|
+
code="MCP_CALL_FAILED",
|
|
248
|
+
retryable=False,
|
|
249
|
+
) from exc
|
|
250
|
+
except MCPError:
|
|
251
|
+
raise
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
raise MCPError(
|
|
254
|
+
f"MCP Tool {server_id!r}/{remote_name!r} failed during call.",
|
|
255
|
+
code="MCP_CALL_FAILED",
|
|
256
|
+
retryable=False,
|
|
257
|
+
) from exc
|
|
258
|
+
|
|
259
|
+
async def close(self) -> None:
|
|
260
|
+
connections = tuple(reversed(self._connections))
|
|
261
|
+
self._connections.clear()
|
|
262
|
+
self._connection_info = None
|
|
263
|
+
self._descriptors = None
|
|
264
|
+
for connection in connections:
|
|
265
|
+
await _close_client(connection.client)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _sdk_client(config: MCPServerConfig) -> _ClientLike:
|
|
269
|
+
parameters = StdioServerParameters(
|
|
270
|
+
command=config.command,
|
|
271
|
+
args=list(config.args),
|
|
272
|
+
env=dict(os.environ) if config.inherit_environment else {},
|
|
273
|
+
)
|
|
274
|
+
return cast(_ClientLike, Client(parameters, input_required_max_rounds=0))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _is_retryable_connect_failure(exc: Exception) -> bool:
|
|
278
|
+
"""Return whether a connect failure is an explicit transient transport signal."""
|
|
279
|
+
|
|
280
|
+
if isinstance(exc, ExceptionGroup):
|
|
281
|
+
return bool(exc.exceptions) and all(
|
|
282
|
+
_is_retryable_connect_failure(item) for item in exc.exceptions
|
|
283
|
+
)
|
|
284
|
+
return isinstance(exc, (TimeoutError, ConnectionError))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def _close_client(client: _ClientLike) -> None:
|
|
288
|
+
try:
|
|
289
|
+
await client.__aexit__(None, None, None)
|
|
290
|
+
except asyncio.CancelledError:
|
|
291
|
+
raise
|
|
292
|
+
except Exception:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _descriptor(server_id: str, tool: object) -> MCPToolDescriptor:
|
|
297
|
+
remote_name = getattr(tool, "name", None)
|
|
298
|
+
description = getattr(tool, "description", None)
|
|
299
|
+
schema = getattr(tool, "input_schema", None)
|
|
300
|
+
if not isinstance(remote_name, str) or not remote_name:
|
|
301
|
+
raise MCPError(
|
|
302
|
+
f"MCP server {server_id!r} advertised an invalid Tool name.",
|
|
303
|
+
code="MCP_INVALID_SCHEMA",
|
|
304
|
+
)
|
|
305
|
+
if description is not None and not isinstance(description, str):
|
|
306
|
+
raise MCPError(
|
|
307
|
+
f"MCP Tool {server_id!r}/{remote_name!r} has an invalid description.",
|
|
308
|
+
code="MCP_INVALID_SCHEMA",
|
|
309
|
+
)
|
|
310
|
+
if not isinstance(schema, dict) or any(not isinstance(key, str) for key in schema):
|
|
311
|
+
raise MCPError(
|
|
312
|
+
f"MCP Tool {server_id!r}/{remote_name!r} has an invalid input schema.",
|
|
313
|
+
code="MCP_INVALID_SCHEMA",
|
|
314
|
+
)
|
|
315
|
+
try:
|
|
316
|
+
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":"))
|
|
317
|
+
except (TypeError, ValueError) as exc:
|
|
318
|
+
raise MCPError(
|
|
319
|
+
f"MCP Tool {server_id!r}/{remote_name!r} has a non-JSON input schema.",
|
|
320
|
+
code="MCP_INVALID_SCHEMA",
|
|
321
|
+
) from exc
|
|
322
|
+
if len(encoded.encode("utf-8")) > _MAX_SCHEMA_BYTES:
|
|
323
|
+
raise MCPError(
|
|
324
|
+
f"MCP Tool {server_id!r}/{remote_name!r} input schema is too large.",
|
|
325
|
+
code="MCP_INVALID_SCHEMA",
|
|
326
|
+
)
|
|
327
|
+
return MCPToolDescriptor(
|
|
328
|
+
server_id,
|
|
329
|
+
remote_name,
|
|
330
|
+
f"mcp.{server_id}.{remote_name}",
|
|
331
|
+
None if description is None else description[:_MAX_DESCRIPTION_CHARS],
|
|
332
|
+
cast(JsonObject, schema),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _normalize_result(result: object) -> JsonObject:
|
|
337
|
+
content = getattr(result, "content", None)
|
|
338
|
+
structured = getattr(result, "structured_content", None)
|
|
339
|
+
is_error = getattr(result, "is_error", None)
|
|
340
|
+
if not isinstance(content, list) or not isinstance(is_error, bool):
|
|
341
|
+
raise MCPError("MCP returned an invalid Tool result.", code="MCP_INVALID_RESULT")
|
|
342
|
+
if structured is not None and not isinstance(structured, dict):
|
|
343
|
+
raise MCPError("MCP returned invalid structured content.", code="MCP_INVALID_RESULT")
|
|
344
|
+
return {
|
|
345
|
+
"content": [_safe_content(block) for block in content],
|
|
346
|
+
"structured_content": None if structured is None else _safe_json(structured),
|
|
347
|
+
"is_error": is_error,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _safe_content(block: object) -> object:
|
|
352
|
+
dump = getattr(block, "model_dump", None)
|
|
353
|
+
if callable(dump):
|
|
354
|
+
value = dump(mode="json", by_alias=False)
|
|
355
|
+
else:
|
|
356
|
+
value = {"type": type(block).__name__, "text": str(block)[:_MAX_TEXT_CHARS]}
|
|
357
|
+
return _safe_json(value)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _safe_json(value: object, *, key: str | None = None) -> object:
|
|
361
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
362
|
+
return value
|
|
363
|
+
if isinstance(value, str):
|
|
364
|
+
limit = _MAX_BINARY_CHARS if key in {"data", "blob"} else _MAX_TEXT_CHARS
|
|
365
|
+
if len(value) <= limit:
|
|
366
|
+
return value
|
|
367
|
+
return value[:limit] + "...[truncated]"
|
|
368
|
+
if isinstance(value, list):
|
|
369
|
+
return [_safe_json(item) for item in value[:100]]
|
|
370
|
+
if isinstance(value, dict):
|
|
371
|
+
return {
|
|
372
|
+
str(item_key): _safe_json(item, key=str(item_key))
|
|
373
|
+
for item_key, item in list(value.items())[:100]
|
|
374
|
+
}
|
|
375
|
+
raise MCPError("MCP returned non-JSON content.", code="MCP_INVALID_RESULT")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _optional_text(value: object | None, attribute: str) -> str | None:
|
|
379
|
+
result = None if value is None else getattr(value, attribute, None)
|
|
380
|
+
return result if isinstance(result, str) else None
|