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.
@@ -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")