agentconnect-cli 0.1.0a1__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.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,382 @@
1
+ """``agentconnect mcp``: the stdio MCP server an agent tool starts.
2
+
3
+ It hosts the runtime in this process or attaches to the one that holds the
4
+ data directory (:mod:`agentconnect.mcp.session`), answers ``tools/list``
5
+ and ``tools/call`` with :mod:`agentconnect.mcp.tools`, and stops on stdin
6
+ EOF (the agent tool ended the session) or SIGTERM/SIGINT. Stopping ends the
7
+ tool calls in flight, gives queued sends the runtime's bounded flush and
8
+ releases the lock (:meth:`Runtime.stop`).
9
+
10
+ stdout carries MCP only: the CLI moves the process's stdout to stderr before
11
+ anything runs (:func:`claim_stdio`), so stray output can't corrupt the
12
+ channel. Logs go to stderr (or a file), never a message body.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import io
20
+ import json
21
+ import os
22
+ import re
23
+ import signal
24
+ import tomllib
25
+ from collections.abc import AsyncIterator, Mapping
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any, cast
29
+
30
+ import anyio
31
+ from mcp import types
32
+ from mcp.server import Server, ServerRequestContext
33
+ from mcp.server.stdio import stdio_server
34
+
35
+ from agentconnect import __version__, log
36
+ from agentconnect.mcp.backend import ToolError
37
+ from agentconnect.mcp.session import Session, current_request
38
+ from agentconnect.mcp.tools import BY_NAME, HUMAN_ONLY_NOTE, INSTRUCTIONS, TOOLS, Tool, call
39
+ from agentconnect.runtime import (
40
+ Clock,
41
+ Config,
42
+ ConfigError,
43
+ IsolationError,
44
+ Keystore,
45
+ Options,
46
+ SystemClock,
47
+ load_config,
48
+ )
49
+
50
+ LOG = log.get("agentconnect.mcp")
51
+
52
+ SERVER_NAME = "agentconnect"
53
+ DEFAULT_AGENT = "mcp"
54
+ MAX_LINE = 4 << 20 # bytes of one JSON-RPC message from the client
55
+ SIGNALS = (signal.SIGINT, signal.SIGTERM)
56
+ _NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}") # fullmatch: no newline
57
+ UNENCODABLE = "the result holds text that can't be written as UTF-8"
58
+
59
+
60
+ def default_data_dir(agent: str, environ: Mapping[str, str]) -> Path:
61
+ """``$XDG_DATA_HOME/agentconnect-<agent>`` (``~/.local/share`` without it)."""
62
+ raw = environ.get("XDG_DATA_HOME", "").strip()
63
+ base = Path(raw) if raw else Path(environ.get("HOME", str(Path.home()))) / ".local/share"
64
+ return base / f"agentconnect-{agent}"
65
+
66
+
67
+ def _file_data_dir(config: Path | None) -> Path | None:
68
+ """``daemon.data_dir`` of a config file, if it sets one."""
69
+ if config is None:
70
+ return None
71
+ try:
72
+ data = tomllib.loads(config.expanduser().read_text(encoding="utf-8"))
73
+ except (OSError, tomllib.TOMLDecodeError) as e:
74
+ msg = f"{config}: {e}"
75
+ raise ConfigError(msg) from None
76
+ daemon = data.get("daemon")
77
+ raw = daemon.get("data_dir") if isinstance(daemon, dict) else None
78
+ return Path(raw).expanduser() if isinstance(raw, str) and raw else None
79
+
80
+
81
+ def configure( # noqa: PLR0913
82
+ agent: str | None,
83
+ *,
84
+ data_dir: Path | None = None,
85
+ relay: str | None = None,
86
+ config: Path | None = None,
87
+ display_name: str | None = None,
88
+ environ: Mapping[str, str] = os.environ,
89
+ ) -> Config:
90
+ """The MCP host's :class:`Config` (ADR-005, Decision 2).
91
+
92
+ The data directory is ``data_dir``, else ``AC_DATA_DIR``, else the
93
+ ``config`` file's ``daemon.data_dir`` when no ``agent`` is given (to
94
+ attach to the daemon that file configures), else
95
+ ``$XDG_DATA_HOME/agentconnect-<agent>`` (``agent`` defaults to ``mcp``).
96
+ An ``AC_DATA_DIR`` that disagrees with an explicit ``agent`` is refused,
97
+ so a variable exported for one agent's CLI can't hand this one another
98
+ agent's identity. Only the TOML file ``config`` is read, never the
99
+ separate daemon's default file; ``AC_*`` variables apply on top."""
100
+ name = DEFAULT_AGENT if agent is None else agent
101
+ if not _NAME.fullmatch(name):
102
+ msg = "--agent: 1 to 64 of A-Z a-z 0-9 . _ -, starting with a letter or digit"
103
+ raise ConfigError(msg)
104
+ env_dir = environ.get("AC_DATA_DIR", "")
105
+ if data_dir is not None:
106
+ where = data_dir.expanduser()
107
+ elif env_dir:
108
+ where = Path(env_dir).expanduser()
109
+ if agent is not None and where != default_data_dir(agent, environ):
110
+ msg = (
111
+ f"AC_DATA_DIR is {env_dir}, not the data directory of --agent {agent} "
112
+ f"({default_data_dir(agent, environ)}); unset it, or pass --data-dir"
113
+ )
114
+ raise ConfigError(msg)
115
+ elif agent is None and (file_dir := _file_data_dir(config)) is not None:
116
+ where = file_dir
117
+ else:
118
+ where = default_data_dir(name, environ)
119
+ # Its one adapter, whatever the file lists for the separate daemon.
120
+ overrides: dict[str, Any] = {"data_dir": where, "default_adapter": "mcp", "adapters": ()}
121
+ if relay is not None:
122
+ overrides |= {"relay_url": relay, "relay_host": ""}
123
+ if display_name is not None or (agent is not None and config is None):
124
+ overrides["display_name"] = display_name or agent
125
+ cfg = load_config(config, environ, use_file=config is not None, **overrides)
126
+ if cfg.isolation:
127
+ msg = (
128
+ "isolation.enabled is for a separate daemon only: an MCP server runs the "
129
+ "runtime as the agent tool's own user (docs/isolation.md)"
130
+ )
131
+ raise IsolationError(msg)
132
+ return cfg
133
+
134
+
135
+ def _annotations(tool: Tool) -> types.ToolAnnotations:
136
+ return types.ToolAnnotations(
137
+ read_only_hint=tool.read_only,
138
+ destructive_hint=tool.destructive,
139
+ **tool.hints,
140
+ )
141
+
142
+
143
+ def mcp_tools(session: Session) -> list[types.Tool]:
144
+ """The tools this session offers. A host session whose config keeps
145
+ knocks to the human (``knock.agent_may_knock = false``) doesn't list
146
+ ``agentconnect_knock``; the runtime refuses it either way."""
147
+ hidden = set()
148
+ host = session.backend is not None and session.mode == "host"
149
+ if host and not session.config.knock_agent_may_knock:
150
+ hidden.add("agentconnect_knock")
151
+ return [
152
+ types.Tool(
153
+ name=t.name,
154
+ description=t.description,
155
+ input_schema=t.schema,
156
+ annotations=_annotations(t),
157
+ )
158
+ for t in TOOLS
159
+ if t.name not in hidden
160
+ ]
161
+
162
+
163
+ def _result(data: dict[str, Any], *, error: bool = False) -> types.CallToolResult:
164
+ text = json.dumps(data, sort_keys=True, ensure_ascii=False)
165
+ try:
166
+ text.encode("utf-8")
167
+ except UnicodeEncodeError:
168
+ # A lone surrogate would end the stdio writer, and the server with
169
+ # it: this call fails, the server goes on. (A wait's messages are
170
+ # checked one by one, agentconnect.mcp.tools.writable_view.)
171
+ LOG.warning("mcp_result_unencodable")
172
+ data, error = {"error": {"code": "unencodable", "message": UNENCODABLE}}, True
173
+ text = json.dumps(data, sort_keys=True)
174
+ return types.CallToolResult(
175
+ content=[types.TextContent(type="text", text=text)],
176
+ structured_content=data,
177
+ is_error=error,
178
+ )
179
+
180
+
181
+ async def call_tool(
182
+ session: Session, name: str, args: Mapping[str, Any], request_id: object | None = None
183
+ ) -> types.CallToolResult:
184
+ """One ``tools/call``: the tool's result, or an error result with the
185
+ runtime's code (``human_only`` with a pointer to the CLI). A refused
186
+ call is logged with its code only. ``request_id`` ties a wait's batch
187
+ to this request, to be acknowledged once its result is written."""
188
+ token = current_request.set(request_id)
189
+ finished = False
190
+ try:
191
+ result = _result(await call(session, name, args))
192
+ finished = True
193
+ return result
194
+ except ToolError as e:
195
+ LOG.info("mcp_tool_refused", tool=name if name in BY_NAME else "unknown", code=e.code)
196
+ error: dict[str, Any] = {"code": e.code, "message": str(e)}
197
+ if e.code == "human_only":
198
+ error["hint"] = HUMAN_ONLY_NOTE
199
+ finished = True
200
+ return _result({"error": error}, error=True)
201
+ finally:
202
+ current_request.reset(token)
203
+ if not finished and request_id is not None:
204
+ # Cancelled (or failed) after a take: no result will be written,
205
+ # so the batch goes back at once.
206
+ await session.abandon(request_id)
207
+
208
+
209
+ def build_server(session: Session) -> Server[Any]:
210
+ async def list_tools(
211
+ _ctx: ServerRequestContext[Any], _params: types.PaginatedRequestParams | None
212
+ ) -> types.ListToolsResult:
213
+ return types.ListToolsResult(tools=mcp_tools(session))
214
+
215
+ async def on_call(
216
+ ctx: ServerRequestContext[Any], params: types.CallToolRequestParams
217
+ ) -> types.CallToolResult:
218
+ return await call_tool(session, params.name, params.arguments or {}, ctx.request_id)
219
+
220
+ return Server(
221
+ SERVER_NAME,
222
+ version=__version__,
223
+ instructions=INSTRUCTIONS,
224
+ on_list_tools=list_tools,
225
+ on_call_tool=on_call,
226
+ )
227
+
228
+
229
+ def _msg_ids(message: dict[str, Any]) -> list[str]:
230
+ """The ``msg_id`` values a written tool result returned to the client."""
231
+ result = message.get("result")
232
+ content = result.get("structuredContent") if isinstance(result, dict) else None
233
+ messages = content.get("messages") if isinstance(content, dict) else None
234
+ if not isinstance(messages, list):
235
+ return []
236
+ return [str(m.get("msg_id")) for m in messages if isinstance(m, dict)]
237
+
238
+
239
+ class Written:
240
+ """The server's stdout: once a response line is written and flushed,
241
+ the session hears of it, and acknowledges the batch of a wait whose
242
+ result that was (:meth:`Session.result_written`)."""
243
+
244
+ def __init__(self, out: anyio.AsyncFile[str], session: Session) -> None:
245
+ self._out, self._session = out, session
246
+ self._lines: list[str] = []
247
+
248
+ async def write(self, text: str) -> None:
249
+ await self._out.write(text)
250
+ if self._session.awaiting_write():
251
+ self._lines.append(text)
252
+
253
+ async def flush(self) -> None:
254
+ await self._out.flush()
255
+ lines, self._lines = self._lines, []
256
+ for line in lines:
257
+ with contextlib.suppress(ValueError):
258
+ message = json.loads(line)
259
+ if isinstance(message, dict) and "result" in message:
260
+ self._session.result_written_soon(message.get("id"), _msg_ids(message))
261
+
262
+
263
+ class LineReader:
264
+ """The client's messages, one per line, from a pipe or terminal; closable
265
+ from another task (a signal), which ends the iteration as EOF would."""
266
+
267
+ def __init__(self, fd: int) -> None:
268
+ self._fd = fd
269
+ self._reader = asyncio.StreamReader(limit=MAX_LINE)
270
+ self._transport: asyncio.ReadTransport | None = None
271
+
272
+ async def open(self) -> None:
273
+ loop = asyncio.get_running_loop()
274
+ pipe = os.fdopen(self._fd, "rb", buffering=0, closefd=False)
275
+ protocol = asyncio.StreamReaderProtocol(self._reader)
276
+ self._transport, _ = await loop.connect_read_pipe(lambda: protocol, pipe)
277
+
278
+ def close(self) -> None:
279
+ """End the input now: the server sees EOF and stops."""
280
+ if self._transport is not None:
281
+ self._transport.close()
282
+ self._reader.feed_eof()
283
+
284
+ def __aiter__(self) -> AsyncIterator[str]:
285
+ return self._lines()
286
+
287
+ async def _lines(self) -> AsyncIterator[str]:
288
+ while True:
289
+ try:
290
+ line = await self._reader.readline()
291
+ except (ValueError, asyncio.LimitOverrunError):
292
+ LOG.warning("mcp_line_too_long")
293
+ return
294
+ if not line:
295
+ return
296
+ if line.strip():
297
+ yield line.decode("utf-8", "replace")
298
+
299
+
300
+ @dataclass
301
+ class StdioFds:
302
+ """The MCP channel's file descriptors, after :func:`claim_stdio`."""
303
+
304
+ stdin: int
305
+ stdout: int
306
+
307
+
308
+ def claim_stdio() -> StdioFds:
309
+ """Keep private copies of fd 0 and 1 for MCP, then point fd 0 at the
310
+ null device and fd 1 at stderr, so nothing else in the process (or a
311
+ child) reads the client's input or writes into the channel."""
312
+ fds = StdioFds(os.dup(0), os.dup(1))
313
+ devnull = os.open(os.devnull, os.O_RDONLY)
314
+ try:
315
+ os.dup2(devnull, 0)
316
+ finally:
317
+ os.close(devnull)
318
+ os.dup2(2, 1)
319
+ return fds
320
+
321
+
322
+ class McpServer:
323
+ """One MCP session over two file descriptors (the CLI passes its
324
+ claimed stdin and stdout; tests pass pipes)."""
325
+
326
+ def __init__( # noqa: PLR0913
327
+ self,
328
+ config: Config,
329
+ *,
330
+ clock: Clock | None = None,
331
+ keystore: Keystore | None = None,
332
+ options: Options | None = None,
333
+ stdin: int = 0,
334
+ stdout: int = 1,
335
+ ) -> None:
336
+ self.session = Session(config, clock or SystemClock(), keystore=keystore, options=options)
337
+ self._stdin, self._stdout = stdin, stdout
338
+ self._reader = LineReader(stdin)
339
+
340
+ def stop(self) -> None:
341
+ """Stop as on stdin EOF (signal handlers call this)."""
342
+ self._reader.close()
343
+
344
+ async def run(self, ready: asyncio.Event | None = None) -> None:
345
+ """Open the session (host or attach), serve MCP until stdin EOF or
346
+ :meth:`stop`, then close the session: tool calls in flight are
347
+ cancelled first, then the runtime stops and releases the lock.
348
+ Cancellation-safe: a cancelled run still closes the session."""
349
+ await self.session.open()
350
+ try:
351
+ await self._reader.open()
352
+ out = io.TextIOWrapper(
353
+ os.fdopen(self._stdout, "wb", closefd=False), encoding="utf-8", newline="\n"
354
+ )
355
+ stdout = cast("anyio.AsyncFile[str]", Written(anyio.wrap_file(out), self.session))
356
+ server = build_server(self.session)
357
+ # The SDK reads the client's lines by iterating `stdin` only.
358
+ stdin = cast("anyio.AsyncFile[str]", self._reader)
359
+ async with stdio_server(stdin, stdout) as (read, write):
360
+ if ready is not None:
361
+ ready.set()
362
+ await server.run(read, write, server.create_initialization_options())
363
+ with contextlib.suppress(ValueError):
364
+ out.detach() # the descriptor is the caller's
365
+ finally:
366
+ self._reader.close()
367
+ await self.session.close()
368
+
369
+
370
+ async def serve(config: Config, fds: StdioFds) -> None:
371
+ """The CLI's entry point: run until stdin EOF or a signal."""
372
+ server = McpServer(config, stdin=fds.stdin, stdout=fds.stdout)
373
+ loop = asyncio.get_running_loop()
374
+ for sig in SIGNALS:
375
+ with contextlib.suppress(NotImplementedError):
376
+ loop.add_signal_handler(sig, server.stop)
377
+ try:
378
+ await server.run()
379
+ finally:
380
+ for sig in SIGNALS:
381
+ with contextlib.suppress(NotImplementedError):
382
+ loop.remove_signal_handler(sig)