dex-agents-cli-core 0.5.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.
- dex_agents_cli_core/__init__.py +101 -0
- dex_agents_cli_core/chat.py +538 -0
- dex_agents_cli_core/cli.py +671 -0
- dex_agents_cli_core/config.py +650 -0
- dex_agents_cli_core/config_file.py +210 -0
- dex_agents_cli_core/connectors.py +531 -0
- dex_agents_cli_core/dex_server.py +345 -0
- dex_agents_cli_core/dexcli_install.py +314 -0
- dex_agents_cli_core/environment.py +136 -0
- dex_agents_cli_core/harness.py +445 -0
- dex_agents_cli_core/local_tools/__init__.py +26 -0
- dex_agents_cli_core/local_tools/browser.py +235 -0
- dex_agents_cli_core/local_tools/files.py +512 -0
- dex_agents_cli_core/local_tools/shell.py +85 -0
- dex_agents_cli_core/local_tools/source.py +458 -0
- dex_agents_cli_core/local_tools/web.py +98 -0
- dex_agents_cli_core/py.typed +0 -0
- dex_agents_cli_core/schemas/__init__.py +9 -0
- dex_agents_cli_core/schemas/config.schema.json +315 -0
- dex_agents_cli_core/schemas/session-settings.schema.json +153 -0
- dex_agents_cli_core/tool_policy.py +179 -0
- dex_agents_cli_core/turns.py +235 -0
- dex_agents_cli_core-0.5.0.dist-info/METADATA +479 -0
- dex_agents_cli_core-0.5.0.dist-info/RECORD +26 -0
- dex_agents_cli_core-0.5.0.dist-info/WHEEL +4 -0
- dex_agents_cli_core-0.5.0.dist-info/licenses/LICENSE +105 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Connector tools from the connectors MCP server, filtered by the configuration."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx2
|
|
19
|
+
from mcp import Client, Implementation
|
|
20
|
+
|
|
21
|
+
from dex_agents import (
|
|
22
|
+
AgentToolCall,
|
|
23
|
+
AgentTurnContext,
|
|
24
|
+
DeferredToolCall,
|
|
25
|
+
DeferredToolExecution,
|
|
26
|
+
MediaStore,
|
|
27
|
+
ToolExecutionError,
|
|
28
|
+
ToolExecutionResult,
|
|
29
|
+
ToolPolicy,
|
|
30
|
+
ToolTaskCancellation,
|
|
31
|
+
ToolTaskPoll,
|
|
32
|
+
ToolTaskPollOutcome,
|
|
33
|
+
ToolTaskStarted,
|
|
34
|
+
)
|
|
35
|
+
from dex_agents.builtin_loop import SourceToolDefinition
|
|
36
|
+
from dex_agents.mcp import (
|
|
37
|
+
McpStreamableHttpServerConfig,
|
|
38
|
+
McpTool,
|
|
39
|
+
McpToolListingError,
|
|
40
|
+
McpToolSource,
|
|
41
|
+
)
|
|
42
|
+
from dex_agents_cli_core.config import ConnectorsConfig, EffectiveConfig
|
|
43
|
+
from dex_agents_cli_core.tool_policy import disabled_tool_call, raise_if_disabled
|
|
44
|
+
from dex_agents_cli_core.turns import (
|
|
45
|
+
TurnConfigResolver,
|
|
46
|
+
current_turn_config,
|
|
47
|
+
recorded_turn_settings,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: The connector source's ``source_name``; stored calls are routed by it.
|
|
51
|
+
CONNECTORS_TOOL_SOURCE_NAME = "dex_connectors"
|
|
52
|
+
|
|
53
|
+
_MCP_SERVER_NAME = "connectors"
|
|
54
|
+
_MCP_REFERENCE_KEY = "mcp"
|
|
55
|
+
_IDENTITY_REFERENCE_KEY = "connector_tool"
|
|
56
|
+
_CLIENT_NAME = "dex-agents-cli"
|
|
57
|
+
_CATALOG_TIMEOUT_SECONDS = 15.0
|
|
58
|
+
_CATALOG_REFRESH_SECONDS = 30.0
|
|
59
|
+
_LISTING_PAGE_LIMIT = 100
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ConnectorToolMetaKeys:
|
|
64
|
+
"""The ``_meta`` keys that name what one connectors MCP server tool runs.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
connector: Key of the connector ID, such as ``slack``.
|
|
68
|
+
connection: Key of the Dex Web connection name, such as ``work``.
|
|
69
|
+
operation: Key of the operationId, such as ``getMessage``.
|
|
70
|
+
operation_name: Key of the snake-case operation name, such as ``get_message``.
|
|
71
|
+
expected_connector: ``tools/call`` request key of the connector the call was approved for.
|
|
72
|
+
expected_connection: ``tools/call`` request key of the connection it was approved for.
|
|
73
|
+
expected_operation: ``tools/call`` request key of the operation it was approved for.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
connector: str
|
|
77
|
+
connection: str
|
|
78
|
+
operation: str
|
|
79
|
+
operation_name: str
|
|
80
|
+
expected_connector: str
|
|
81
|
+
expected_connection: str
|
|
82
|
+
expected_operation: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: The keys of the connectors MCP server: the first four in the top-level ``_meta`` of every
|
|
86
|
+
#: tool, the ``expected_*`` keys in the ``_meta`` of a ``tools/call`` request.
|
|
87
|
+
CONNECTOR_TOOL_META_KEYS = ConnectorToolMetaKeys(
|
|
88
|
+
connector="io.superdurable.connectors/connector",
|
|
89
|
+
connection="io.superdurable.connectors/connection",
|
|
90
|
+
operation="io.superdurable.connectors/operation",
|
|
91
|
+
operation_name="io.superdurable.connectors/operationName",
|
|
92
|
+
expected_connector="io.superdurable.connectors/expectedConnector",
|
|
93
|
+
expected_connection="io.superdurable.connectors/expectedConnection",
|
|
94
|
+
expected_operation="io.superdurable.connectors/expectedOperation",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class ConnectorToolIdentity:
|
|
100
|
+
"""Which connector operation on which connection one MCP tool runs.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
connector: Connector ID, such as ``slack``.
|
|
104
|
+
connection: Connection name.
|
|
105
|
+
operation: The operationId, such as ``getMessage``, or ``None`` when
|
|
106
|
+
the server did not send it.
|
|
107
|
+
operation_name: The snake-case operation name, such as ``get_message``,
|
|
108
|
+
or ``None`` when the server did not send it.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
connector: str
|
|
112
|
+
connection: str
|
|
113
|
+
operation: str | None = None
|
|
114
|
+
operation_name: str | None = None
|
|
115
|
+
|
|
116
|
+
def is_enabled_by(self, connectors: ConnectorsConfig) -> bool:
|
|
117
|
+
"""Report whether a configuration enables this operation on this connection.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
connectors: The ``connectors`` section of a turn.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
``True`` when an entry covers the connection and either lists no
|
|
124
|
+
operations or lists this one, by its snake-case name or its
|
|
125
|
+
operationId. An entry that lists operations needs both
|
|
126
|
+
``operation`` and ``operationName`` in the tool's ``_meta``.
|
|
127
|
+
"""
|
|
128
|
+
if connectors.enabled is None:
|
|
129
|
+
return True
|
|
130
|
+
for entry in connectors.enabled:
|
|
131
|
+
if not entry.covers_connection(self.connector, self.connection):
|
|
132
|
+
continue
|
|
133
|
+
if entry.tools is None:
|
|
134
|
+
return True
|
|
135
|
+
if (
|
|
136
|
+
self.operation is not None
|
|
137
|
+
and self.operation_name is not None
|
|
138
|
+
and (
|
|
139
|
+
entry.allows_operation(self.operation_name)
|
|
140
|
+
or entry.allows_operation(self.operation)
|
|
141
|
+
)
|
|
142
|
+
):
|
|
143
|
+
return True
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class ConnectorCatalogTool:
|
|
149
|
+
"""One tool of the connectors MCP server.
|
|
150
|
+
|
|
151
|
+
Attributes:
|
|
152
|
+
name: The tool name the agent sees.
|
|
153
|
+
description: Its description.
|
|
154
|
+
identity: Its connector operation, or ``None`` when the server did not say.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
name: str
|
|
158
|
+
description: str
|
|
159
|
+
identity: ConnectorToolIdentity | None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ConnectorsMcpUnavailableError(Exception):
|
|
163
|
+
"""Report that the connectors MCP server cannot be listed, with the reason."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ConnectorToolDefinition:
|
|
168
|
+
"""A connector tool as the model sees it: the MCP tool name without a server prefix.
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
name: The MCP tool name, such as ``slack_post_thread_reply``.
|
|
172
|
+
description: Tool description for the model.
|
|
173
|
+
input_schema: JSON Schema of the arguments.
|
|
174
|
+
mcp_tool: The ``McpTool`` that runs the call.
|
|
175
|
+
identity: Its connector operation, or ``None`` when the server did not say.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
name: str
|
|
179
|
+
description: str
|
|
180
|
+
input_schema: Mapping[str, Any] = field(repr=False)
|
|
181
|
+
mcp_tool: McpTool = field(repr=False)
|
|
182
|
+
identity: ConnectorToolIdentity | None = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def read_connector_catalog(url: str) -> tuple[ConnectorCatalogTool, ...]:
|
|
186
|
+
"""List the connectors MCP server's tools and the connector operation each one runs.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
url: The server's MCP endpoint.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Every listed tool.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ConnectorsMcpUnavailableError: If the server cannot be listed. A server
|
|
196
|
+
that answers 503 gives its reason, such as that it is still starting.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
async with Client(
|
|
200
|
+
url,
|
|
201
|
+
read_timeout_seconds=_CATALOG_TIMEOUT_SECONDS,
|
|
202
|
+
client_info=_client_info(),
|
|
203
|
+
cache=None,
|
|
204
|
+
) as client:
|
|
205
|
+
tools: list[Any] = []
|
|
206
|
+
cursor: str | None = None
|
|
207
|
+
for _ in range(_LISTING_PAGE_LIMIT):
|
|
208
|
+
page = await client.list_tools(cursor=cursor)
|
|
209
|
+
tools.extend(page.tools)
|
|
210
|
+
cursor = page.next_cursor
|
|
211
|
+
if not cursor:
|
|
212
|
+
break
|
|
213
|
+
except Exception as error:
|
|
214
|
+
raise ConnectorsMcpUnavailableError(await _describe_unavailable(url, error)) from error
|
|
215
|
+
return tuple(
|
|
216
|
+
ConnectorCatalogTool(
|
|
217
|
+
name=tool.name, description=tool.description or "", identity=_identity(tool.meta)
|
|
218
|
+
)
|
|
219
|
+
for tool in tools
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ConnectorsMcpToolSource:
|
|
224
|
+
"""Offer the connectors MCP server's tools that the running turn enables.
|
|
225
|
+
|
|
226
|
+
Calls run through ``McpToolSource``, so each one is a Dex tool Step with
|
|
227
|
+
the server's effect class, approval, idempotency key, and tasks. The tool
|
|
228
|
+
names are the server's own, without a server prefix, so
|
|
229
|
+
``approval.always`` names them as the agent sees them. A tool whose
|
|
230
|
+
connector, connection, and operation the server does not report is
|
|
231
|
+
offered only while ``connectors.enabled`` is omitted.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
url: str,
|
|
237
|
+
resolver: TurnConfigResolver,
|
|
238
|
+
*,
|
|
239
|
+
is_available: bool = True,
|
|
240
|
+
report: Callable[[str], None] | None = None,
|
|
241
|
+
media_store: MediaStore | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Connect to one connectors MCP server.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
url: The server's MCP endpoint, on a loopback host.
|
|
247
|
+
resolver: The turn configuration resolver.
|
|
248
|
+
is_available: Whether the server answered when the command started.
|
|
249
|
+
report: Prints a line when the server stops or starts answering;
|
|
250
|
+
``None`` prints nothing.
|
|
251
|
+
media_store: The store for the images that tool results carry.
|
|
252
|
+
With a store, each image of a successful result is uploaded
|
|
253
|
+
in the tool Step and reaches the model as an image reference.
|
|
254
|
+
``None``, the default, keeps images as JSON in the output text.
|
|
255
|
+
|
|
256
|
+
.. versionadded:: 0.5.0
|
|
257
|
+
"""
|
|
258
|
+
self._url = url
|
|
259
|
+
self._resolver = resolver
|
|
260
|
+
self._is_available = is_available
|
|
261
|
+
self._report = report
|
|
262
|
+
self._mcp = McpToolSource(
|
|
263
|
+
[McpStreamableHttpServerConfig(name=_MCP_SERVER_NAME, url=url)],
|
|
264
|
+
client_name=_CLIENT_NAME,
|
|
265
|
+
media_store=media_store,
|
|
266
|
+
)
|
|
267
|
+
self._last_listing: tuple[ConnectorToolDefinition, ...] = ()
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def source_name(self) -> str:
|
|
271
|
+
"""Return ``dex_connectors``."""
|
|
272
|
+
return CONNECTORS_TOOL_SOURCE_NAME
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def url(self) -> str:
|
|
276
|
+
"""Return the server's MCP endpoint."""
|
|
277
|
+
return self._url
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def is_available(self) -> bool:
|
|
281
|
+
"""Return whether the server answered its latest listing or catalog read."""
|
|
282
|
+
return self._is_available
|
|
283
|
+
|
|
284
|
+
def cached_tools(self) -> tuple[ConnectorToolDefinition, ...]:
|
|
285
|
+
"""Return the last listing, or the startup listing of ``list_file_enabled_tools``."""
|
|
286
|
+
return self._last_listing
|
|
287
|
+
|
|
288
|
+
async def list_tools(self) -> Sequence[SourceToolDefinition]:
|
|
289
|
+
"""Return the tools the running turn enables; none outside a model call.
|
|
290
|
+
|
|
291
|
+
The server is listed again in every model call. While it does not
|
|
292
|
+
answer, no connector tools are offered, and a line is printed when it
|
|
293
|
+
stops and when it starts answering again.
|
|
294
|
+
"""
|
|
295
|
+
turn_config = current_turn_config()
|
|
296
|
+
if turn_config is None:
|
|
297
|
+
return ()
|
|
298
|
+
return await self._list_enabled_tools(turn_config)
|
|
299
|
+
|
|
300
|
+
async def list_file_enabled_tools(self) -> Sequence[SourceToolDefinition]:
|
|
301
|
+
"""List the tools the configuration file enables, before any turn runs.
|
|
302
|
+
|
|
303
|
+
The command calls it at startup, so ``cached_tools`` has the tools
|
|
304
|
+
before the harness builds its Flow. A runner that fixes its tools when
|
|
305
|
+
it is built, such as the OpenAI Agents SDK runner, can offer only these.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The listed tools; none while the server does not answer.
|
|
309
|
+
"""
|
|
310
|
+
return await self._list_enabled_tools(self._resolver.file_config)
|
|
311
|
+
|
|
312
|
+
async def _list_enabled_tools(
|
|
313
|
+
self, turn_config: EffectiveConfig
|
|
314
|
+
) -> Sequence[SourceToolDefinition]:
|
|
315
|
+
try:
|
|
316
|
+
mcp_tools = await self._mcp.list_tools(refresh=True)
|
|
317
|
+
catalog = await read_connector_catalog(self._url)
|
|
318
|
+
except (McpToolListingError, ConnectorsMcpUnavailableError) as error:
|
|
319
|
+
if self._is_available and self._report is not None:
|
|
320
|
+
self._report(f"Connector tools are unavailable for now: {error}")
|
|
321
|
+
self._is_available = False
|
|
322
|
+
return ()
|
|
323
|
+
if not self._is_available and self._report is not None:
|
|
324
|
+
self._report("Connector tools are available again.")
|
|
325
|
+
self._is_available = True
|
|
326
|
+
identities = {tool.name: tool.identity for tool in catalog}
|
|
327
|
+
self._last_listing = tuple(
|
|
328
|
+
ConnectorToolDefinition(
|
|
329
|
+
name=tool.mcp_tool_name,
|
|
330
|
+
description=tool.description,
|
|
331
|
+
input_schema=tool.input_schema,
|
|
332
|
+
mcp_tool=tool,
|
|
333
|
+
identity=identities.get(tool.mcp_tool_name),
|
|
334
|
+
)
|
|
335
|
+
for tool in mcp_tools
|
|
336
|
+
if _is_enabled(turn_config.connectors, identities.get(tool.mcp_tool_name))
|
|
337
|
+
)
|
|
338
|
+
return self._last_listing
|
|
339
|
+
|
|
340
|
+
def deferred_tool_call(
|
|
341
|
+
self,
|
|
342
|
+
turn: AgentTurnContext,
|
|
343
|
+
*,
|
|
344
|
+
tool_call_id: str,
|
|
345
|
+
tool_name: str,
|
|
346
|
+
arguments: str,
|
|
347
|
+
listed_tool: SourceToolDefinition,
|
|
348
|
+
) -> DeferredToolCall:
|
|
349
|
+
"""Build the MCP call under the agent's tool name, adding ``approval.always``.
|
|
350
|
+
|
|
351
|
+
A tool whose connection or operation the turn does not enable becomes
|
|
352
|
+
a call that its tool Step refuses.
|
|
353
|
+
"""
|
|
354
|
+
if not isinstance(listed_tool, ConnectorToolDefinition):
|
|
355
|
+
raise LookupError(f"{tool_name} was not listed by the connectors MCP server")
|
|
356
|
+
turn_config = self._resolver.resolve_turn(turn)
|
|
357
|
+
if not _is_enabled(turn_config.connectors, listed_tool.identity):
|
|
358
|
+
return disabled_tool_call(tool_call_id, tool_name, arguments)
|
|
359
|
+
mcp_tool = listed_tool.mcp_tool
|
|
360
|
+
built = self._mcp.deferred_tool_call(
|
|
361
|
+
turn,
|
|
362
|
+
tool_call_id=tool_call_id,
|
|
363
|
+
tool_name=mcp_tool.name,
|
|
364
|
+
arguments=arguments,
|
|
365
|
+
listed_tool=mcp_tool,
|
|
366
|
+
)
|
|
367
|
+
policy = built.policy
|
|
368
|
+
if tool_name in turn_config.approval.always:
|
|
369
|
+
policy = ToolPolicy(policy.effect, requires_approval=True)
|
|
370
|
+
identity = listed_tool.identity
|
|
371
|
+
reference = {
|
|
372
|
+
_MCP_REFERENCE_KEY: built.tool_source_reference,
|
|
373
|
+
_IDENTITY_REFERENCE_KEY: None if identity is None else asdict(identity),
|
|
374
|
+
}
|
|
375
|
+
return replace(
|
|
376
|
+
built, tool_name=tool_name, policy=policy, tool_source_reference=json.dumps(reference)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def execute_deferred_tool_call(
|
|
380
|
+
self, call: DeferredToolExecution
|
|
381
|
+
) -> ToolExecutionResult | ToolTaskStarted:
|
|
382
|
+
"""Run one call through ``McpToolSource``, after authorizing it again on this Worker.
|
|
383
|
+
|
|
384
|
+
The call may run on another Worker than the one that built it, with
|
|
385
|
+
its own configuration file. Its connection and operation must be
|
|
386
|
+
enabled for the turn here too; otherwise nothing runs.
|
|
387
|
+
"""
|
|
388
|
+
raise_if_disabled(call)
|
|
389
|
+
record, identity = _unwrap_reference(call.tool_call)
|
|
390
|
+
turn = current_turn_config() or self._resolver.resolve(recorded_turn_settings(call))
|
|
391
|
+
if not _is_enabled(turn.connectors, identity):
|
|
392
|
+
raise ToolExecutionError(f"tool {record.tool_name} is switched off for this session")
|
|
393
|
+
unwrapped = replace(call, tool_call=record)
|
|
394
|
+
if identity is None:
|
|
395
|
+
return await self._mcp.execute_deferred_tool_call(unwrapped)
|
|
396
|
+
# Only a resume that can never send tools/call again skips the listing check.
|
|
397
|
+
if not self._mcp.will_resume_tool_task_without_sending(unwrapped):
|
|
398
|
+
await self._check_binding(record, identity)
|
|
399
|
+
return await self._mcp.execute_deferred_tool_call(
|
|
400
|
+
unwrapped, request_meta=_expected_identity_meta(identity)
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def _check_binding(self, record: AgentToolCall, approved: ConnectorToolIdentity) -> None:
|
|
404
|
+
"""Refuse a call whose tool name now runs another connection or operation.
|
|
405
|
+
|
|
406
|
+
A name can move to another connection between the model call and the
|
|
407
|
+
tool Step, such as when a second connection of the connector is
|
|
408
|
+
authorized. Nothing is sent then.
|
|
409
|
+
"""
|
|
410
|
+
mcp_tool_name = _mcp_tool_name(record)
|
|
411
|
+
try:
|
|
412
|
+
catalog = await read_connector_catalog(self._url)
|
|
413
|
+
except ConnectorsMcpUnavailableError as error:
|
|
414
|
+
raise ToolExecutionError(
|
|
415
|
+
f"cannot check what {record.tool_name} runs now, so it was not sent: {error}"
|
|
416
|
+
) from error
|
|
417
|
+
current = next((tool.identity for tool in catalog if tool.name == mcp_tool_name), None)
|
|
418
|
+
if current is None or (current.connector, current.connection, current.operation) != (
|
|
419
|
+
approved.connector,
|
|
420
|
+
approved.connection,
|
|
421
|
+
approved.operation,
|
|
422
|
+
):
|
|
423
|
+
now = (
|
|
424
|
+
"is no longer offered"
|
|
425
|
+
if current is None
|
|
426
|
+
else f"now runs {_describe_identity(current)}"
|
|
427
|
+
)
|
|
428
|
+
raise ToolExecutionError(
|
|
429
|
+
f"{record.tool_name} {now}, but the call was made for "
|
|
430
|
+
f"{_describe_identity(approved)}; it was not sent"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def poll_tool_task(self, poll: ToolTaskPoll) -> ToolTaskPollOutcome:
|
|
434
|
+
"""Poll one MCP task through ``McpToolSource``."""
|
|
435
|
+
record, _ = _unwrap_reference(poll.tool_call)
|
|
436
|
+
return await self._mcp.poll_tool_task(replace(poll, tool_call=record))
|
|
437
|
+
|
|
438
|
+
async def cancel_tool_task(self, cancellation: ToolTaskCancellation) -> None:
|
|
439
|
+
"""Cancel one abandoned MCP task through ``McpToolSource``."""
|
|
440
|
+
record, _ = _unwrap_reference(cancellation.tool_call)
|
|
441
|
+
await self._mcp.cancel_tool_task(replace(cancellation, tool_call=record))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _expected_identity_meta(identity: ConnectorToolIdentity) -> dict[str, str]:
|
|
445
|
+
keys = CONNECTOR_TOOL_META_KEYS
|
|
446
|
+
meta = {
|
|
447
|
+
keys.expected_connector: identity.connector,
|
|
448
|
+
keys.expected_connection: identity.connection,
|
|
449
|
+
}
|
|
450
|
+
if identity.operation is not None:
|
|
451
|
+
meta[keys.expected_operation] = identity.operation
|
|
452
|
+
return meta
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _describe_identity(identity: ConnectorToolIdentity) -> str:
|
|
456
|
+
operation = identity.operation or "an unnamed operation"
|
|
457
|
+
return f"{identity.connector}/{identity.connection} {operation}"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _mcp_tool_name(record: AgentToolCall) -> str:
|
|
461
|
+
"""Return the tool's name on the MCP server from ``McpToolSource``'s own reference."""
|
|
462
|
+
try:
|
|
463
|
+
parsed = json.loads(record.tool_source_reference or "null")
|
|
464
|
+
except json.JSONDecodeError:
|
|
465
|
+
parsed = None
|
|
466
|
+
name = parsed.get("mcp_tool") if isinstance(parsed, dict) else None
|
|
467
|
+
return name if isinstance(name, str) else record.tool_name
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _unwrap_reference(
|
|
471
|
+
record: AgentToolCall,
|
|
472
|
+
) -> tuple[AgentToolCall, ConnectorToolIdentity | None]:
|
|
473
|
+
"""Return the record with ``McpToolSource``'s own reference, and the tool's identity."""
|
|
474
|
+
try:
|
|
475
|
+
parsed = json.loads(record.tool_source_reference or "null")
|
|
476
|
+
except json.JSONDecodeError:
|
|
477
|
+
return record, None
|
|
478
|
+
if not isinstance(parsed, dict) or _MCP_REFERENCE_KEY not in parsed:
|
|
479
|
+
return record, None
|
|
480
|
+
stored = parsed.get(_IDENTITY_REFERENCE_KEY)
|
|
481
|
+
identity = ConnectorToolIdentity(**stored) if isinstance(stored, dict) else None
|
|
482
|
+
return replace(record, tool_source_reference=parsed[_MCP_REFERENCE_KEY]), identity
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _is_enabled(connectors: ConnectorsConfig, identity: ConnectorToolIdentity | None) -> bool:
|
|
486
|
+
if connectors.enabled is None:
|
|
487
|
+
return True
|
|
488
|
+
return identity is not None and identity.is_enabled_by(connectors)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _identity(meta: Mapping[str, Any] | None) -> ConnectorToolIdentity | None:
|
|
492
|
+
if not meta:
|
|
493
|
+
return None
|
|
494
|
+
keys = CONNECTOR_TOOL_META_KEYS
|
|
495
|
+
connector = meta.get(keys.connector)
|
|
496
|
+
connection = meta.get(keys.connection)
|
|
497
|
+
operation = meta.get(keys.operation)
|
|
498
|
+
operation_name = meta.get(keys.operation_name)
|
|
499
|
+
if not all(isinstance(value, str) and value for value in (connector, connection)):
|
|
500
|
+
return None
|
|
501
|
+
assert isinstance(connector, str) and isinstance(connection, str)
|
|
502
|
+
return ConnectorToolIdentity(
|
|
503
|
+
connector=connector,
|
|
504
|
+
connection=connection,
|
|
505
|
+
operation=operation if isinstance(operation, str) and operation else None,
|
|
506
|
+
operation_name=operation_name
|
|
507
|
+
if isinstance(operation_name, str) and operation_name
|
|
508
|
+
else None,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def _describe_unavailable(url: str, error: Exception) -> str:
|
|
513
|
+
"""Ask the endpoint directly, so a 503 shows the server's own reason."""
|
|
514
|
+
try:
|
|
515
|
+
async with httpx2.AsyncClient(timeout=5.0) as http:
|
|
516
|
+
response = await http.get(url, headers={"Accept": "text/event-stream"})
|
|
517
|
+
except httpx2.HTTPError as http_error:
|
|
518
|
+
return f"cannot reach {url}: {http_error}"
|
|
519
|
+
if response.status_code == 503:
|
|
520
|
+
reason = response.text.strip() or "the server is not ready"
|
|
521
|
+
return f"{url} is not ready: {reason}"
|
|
522
|
+
if response.status_code == 404:
|
|
523
|
+
return (
|
|
524
|
+
f"{url} does not serve MCP (404); this dexcli dev predates the connectors MCP "
|
|
525
|
+
"server or runs with --connectors-mcp=false"
|
|
526
|
+
)
|
|
527
|
+
return f"cannot list the tools of {url}: {error}"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _client_info() -> Implementation:
|
|
531
|
+
return Implementation(name=_CLIENT_NAME, version="0.1.0")
|