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,405 @@
1
+ """Where the MCP server's tools go: a runtime of its own, or one it attaches to.
2
+
3
+ * :class:`HostBackend` runs a :class:`~agentconnect.runtime.Runtime` in this
4
+ process with the delivery queue as its adapter, and calls the runtime's
5
+ :class:`~agentconnect.runtime.AgentApi` and the queue directly. It holds
6
+ the data directory's lock.
7
+ * :class:`AttachBackend` talks to the runtime that holds the lock (another
8
+ MCP session, a daemon or an embedded agent) over its local API with the
9
+ agent token (``api-token``), and pulls deliveries with
10
+ ``GET /v0/deliveries``. It opens no key and no database, and never reads
11
+ ``human-token``.
12
+
13
+ Both answer in the shapes of the local API's routes (``docs/api.md``), and
14
+ both turn a refused operation into :class:`ToolError` with the API's code.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ import re
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass, replace
23
+ from typing import Any, Literal, Protocol
24
+ from urllib.parse import quote
25
+
26
+ import httpx
27
+
28
+ from agentconnect.runtime import (
29
+ AgentApi,
30
+ Clock,
31
+ Config,
32
+ DeliveryQueue,
33
+ Keystore,
34
+ Options,
35
+ Runtime,
36
+ error_reply,
37
+ )
38
+ from agentconnect.sockets import bind_path
39
+ from agentconnect.tokens import API_TOKEN_FILE
40
+
41
+ POLL_SECS = 60 # one GET /v0/deliveries waits at most this long
42
+ HTTP_SLACK_SECS = 15.0 # on top of the poll, before the HTTP client gives up
43
+
44
+
45
+ class ToolError(Exception):
46
+ """A refused tool call: a short ``code`` (the local API's) and a message."""
47
+
48
+ def __init__(self, code: str, message: str, status: int = 400) -> None:
49
+ super().__init__(message)
50
+ self.code, self.status = code, status
51
+
52
+
53
+ class OwnerGoneError(Exception):
54
+ """The runtime this session attached to doesn't answer. ``sent``: the
55
+ request may have reached it before it went (so it may have happened)."""
56
+
57
+ def __init__(self, sent: bool) -> None:
58
+ super().__init__("the runtime that owns the data directory isn't answering")
59
+ self.sent = sent
60
+
61
+
62
+ ID_RE = re.compile(r"(?=[A-Za-z0-9_-]*[A-Za-z0-9])[A-Za-z0-9_-]+") # base64url, not all - _
63
+
64
+
65
+ def segment(value: str) -> str:
66
+ """An id as one path segment of the local API: group and message ids
67
+ are base64url, so anything else (`/`, `?`, `%`, `:`, `.`) is refused;
68
+ quoted anyway."""
69
+ if ID_RE.fullmatch(value) is None:
70
+ raise ToolError("bad_request", "not an id")
71
+ return quote(value, safe="")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Pulled:
76
+ """One leased delivery: ``delivery_id`` acknowledges it."""
77
+
78
+ delivery_id: str
79
+ payload: dict[str, Any]
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Pulls:
84
+ """One take's batch: ``lease`` acknowledges it."""
85
+
86
+ lease: str
87
+ items: list[Pulled]
88
+ more: bool
89
+
90
+
91
+ class Backend(Protocol):
92
+ """The agent-scope operations a tool may call, and the delivery queue."""
93
+
94
+ @property
95
+ def mode(self) -> Literal["host", "attach"]: ...
96
+
97
+ async def create_group(self, name: str, purpose: str) -> dict[str, Any]: ...
98
+ async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]: ...
99
+ async def accept(self, url: str, display_name: str | None) -> dict[str, Any]: ...
100
+ async def send(
101
+ self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
102
+ ) -> dict[str, Any]: ...
103
+ async def groups(self) -> dict[str, Any]: ...
104
+ async def group_info(self, group: str) -> dict[str, Any]: ...
105
+ async def leave(self, group: str) -> dict[str, Any]: ...
106
+ async def events(self, after: int, group: str | None) -> dict[str, Any]: ...
107
+ async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]: ...
108
+ async def knock(
109
+ self,
110
+ listing: str,
111
+ *,
112
+ listing_sha256: str | None,
113
+ display_name: str | None,
114
+ note: str | None,
115
+ domain_check: bool | None,
116
+ ) -> dict[str, Any]: ...
117
+ async def knocks(self) -> dict[str, Any]: ...
118
+ async def listing_status(self) -> dict[str, Any]: ...
119
+ async def take(self, group: str | None, addressed_only: bool, wait_secs: float) -> Pulls: ...
120
+ async def ack(self, lease: str, delivery_ids: Sequence[str]) -> list[str]: ...
121
+ async def release(self, lease: str, delivery_ids: Sequence[str]) -> None: ...
122
+ async def close(self) -> None: ...
123
+
124
+
125
+ def _refused(e: Exception) -> ToolError | None:
126
+ reply = error_reply(e)
127
+ if reply is None:
128
+ return None
129
+ status, code, message = reply
130
+ return ToolError(code, message, status)
131
+
132
+
133
+ class HostBackend:
134
+ """A runtime in this process (the session that holds the lock)."""
135
+
136
+ mode: Literal["host", "attach"] = "host"
137
+
138
+ def __init__(self, runtime: Runtime, queue: DeliveryQueue) -> None:
139
+ self.runtime, self.queue = runtime, queue
140
+ self._agent: AgentApi = runtime.agent
141
+
142
+ @classmethod
143
+ async def start(
144
+ cls,
145
+ config: Config,
146
+ clock: Clock,
147
+ *,
148
+ keystore: Keystore | None = None,
149
+ options: Options | None = None,
150
+ ) -> HostBackend:
151
+ """Take the lock (``DataDirLockedError`` if another runtime has it),
152
+ open the keys and databases and start the runtime, its local API
153
+ included. Nothing stays open if this fails or is cancelled."""
154
+ queue = DeliveryQueue(clock)
155
+ runtime = Runtime(
156
+ config,
157
+ adapters=[queue],
158
+ clock=clock,
159
+ keystore=keystore,
160
+ options=replace(options or Options(), lock_owner="mcp"),
161
+ )
162
+ await runtime.start() # stops it again itself if this fails
163
+ return cls(runtime, queue)
164
+
165
+ async def _run(self, call: Any) -> dict[str, Any]:
166
+ try:
167
+ result: dict[str, Any] = await call
168
+ except Exception as e:
169
+ refused = _refused(e)
170
+ if refused is None:
171
+ raise
172
+ raise refused from None
173
+ return result
174
+
175
+ async def create_group(self, name: str, purpose: str) -> dict[str, Any]:
176
+ return await self._run(self._agent.create_group(name, purpose))
177
+
178
+ async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]:
179
+ return await self._run(
180
+ self._agent.create_invite(group, ttl_secs=ttl_secs, max_uses=max_uses)
181
+ )
182
+
183
+ async def accept(self, url: str, display_name: str | None) -> dict[str, Any]:
184
+ return await self._run(self._agent.accept_invite(url, display_name))
185
+
186
+ async def send(
187
+ self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
188
+ ) -> dict[str, Any]:
189
+ return await self._run(self._agent.send(group, text=text, to=to, reply_to=reply_to))
190
+
191
+ async def groups(self) -> dict[str, Any]:
192
+ return await self._run(self._agent.list_groups())
193
+
194
+ async def group_info(self, group: str) -> dict[str, Any]:
195
+ return await self._run(self._agent.group_info(group))
196
+
197
+ async def leave(self, group: str) -> dict[str, Any]:
198
+ return await self._run(self._agent.leave(group))
199
+
200
+ async def events(self, after: int, group: str | None) -> dict[str, Any]:
201
+ return await self._run(self._agent.events(after, group))
202
+
203
+ async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]:
204
+ return await self._run(self._agent.knock_inspect(listing, domain_check=domain_check))
205
+
206
+ async def knock(
207
+ self,
208
+ listing: str,
209
+ *,
210
+ listing_sha256: str | None,
211
+ display_name: str | None,
212
+ note: str | None,
213
+ domain_check: bool | None,
214
+ ) -> dict[str, Any]:
215
+ return await self._run(
216
+ self._agent.knock(
217
+ listing,
218
+ listing_sha256=listing_sha256,
219
+ display_name=display_name,
220
+ note=note,
221
+ domain_check=domain_check,
222
+ )
223
+ )
224
+
225
+ async def knocks(self) -> dict[str, Any]:
226
+ return await self._run(self._agent.knocks())
227
+
228
+ async def listing_status(self) -> dict[str, Any]:
229
+ return await self._run(self._agent.listing_status())
230
+
231
+ async def take(self, group: str | None, addressed_only: bool, wait_secs: float) -> Pulls:
232
+ if group is not None:
233
+ await self.group_info(group) # an unknown group is not_found, not silence
234
+ batch = await self.queue.take(group, addressed_only=addressed_only, wait_secs=wait_secs)
235
+ items = [Pulled(str(t.delivery_id), t.payload) for t in batch.items]
236
+ return Pulls(batch.lease, items, batch.more)
237
+
238
+ async def ack(self, lease: str, delivery_ids: Sequence[str]) -> list[str]:
239
+ return [str(d) for d in self.queue.ack(lease, [int(d) for d in delivery_ids])]
240
+
241
+ async def release(self, lease: str, delivery_ids: Sequence[str]) -> None:
242
+ self.queue.release(lease, [int(d) for d in delivery_ids])
243
+
244
+ async def close(self) -> None:
245
+ """Stop the runtime: queued sends get the bounded flush, the lock is
246
+ released (cancellation-safe: :meth:`Runtime.stop`)."""
247
+ await self.runtime.stop()
248
+
249
+
250
+ class AttachBackend:
251
+ """A client of the runtime that holds the lock, with the agent token."""
252
+
253
+ mode: Literal["host", "attach"] = "attach"
254
+
255
+ def __init__(
256
+ self,
257
+ config: Config,
258
+ holder: str | None,
259
+ transport: httpx.AsyncBaseTransport | None = None,
260
+ ) -> None:
261
+ self.holder = holder # what owns the directory: "mcp", "daemon", "embedded"
262
+ try:
263
+ token = (config.data_dir / API_TOKEN_FILE).read_text(encoding="ascii").strip()
264
+ except FileNotFoundError:
265
+ msg = f"no {API_TOKEN_FILE} in {config.data_dir}: is its runtime running?"
266
+ raise ToolError("not_running", msg, 503) from None
267
+ if transport is None:
268
+ transport = (
269
+ httpx.AsyncHTTPTransport()
270
+ if config.api_tcp_port
271
+ else httpx.AsyncHTTPTransport(uds=str(bind_path(config.socket_path)))
272
+ )
273
+ base = (
274
+ f"http://127.0.0.1:{config.api_tcp_port}"
275
+ if config.api_tcp_port
276
+ else "http://agentconnectd"
277
+ )
278
+ self._http = httpx.AsyncClient(
279
+ transport=transport,
280
+ base_url=base,
281
+ timeout=POLL_SECS + HTTP_SLACK_SECS,
282
+ trust_env=False,
283
+ headers={"authorization": f"Bearer {token}"},
284
+ )
285
+
286
+ async def _request(
287
+ self, method: str, path: str, body: dict[str, Any] | None = None, **params: Any
288
+ ) -> dict[str, Any]:
289
+ try:
290
+ resp = await self._http.request(method, path, json=body, params=params or None)
291
+ except (httpx.ConnectError, httpx.ConnectTimeout) as e:
292
+ raise OwnerGoneError(sent=False) from e
293
+ except httpx.TransportError as e:
294
+ raise OwnerGoneError(sent=True) from e
295
+ try:
296
+ data = resp.json()
297
+ except ValueError:
298
+ data = {}
299
+ if not isinstance(data, dict):
300
+ data = {}
301
+ if resp.status_code != 200: # noqa: PLR2004
302
+ code = str(data.get("code", "error"))
303
+ raise ToolError(code, str(data.get("message", "")), resp.status_code)
304
+ return data
305
+
306
+ async def create_group(self, name: str, purpose: str) -> dict[str, Any]:
307
+ return await self._request("POST", "/v0/groups", {"name": name, "purpose": purpose})
308
+
309
+ async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]:
310
+ body = {"max_uses": max_uses, "ttl_secs": ttl_secs}
311
+ return await self._request("POST", f"/v0/groups/{segment(group)}/invites", body)
312
+
313
+ async def accept(self, url: str, display_name: str | None) -> dict[str, Any]:
314
+ body: dict[str, Any] = {"url": url}
315
+ if display_name is not None:
316
+ body["display_name"] = display_name
317
+ return await self._request("POST", "/v0/invites/accept", body)
318
+
319
+ async def send(
320
+ self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
321
+ ) -> dict[str, Any]:
322
+ body: dict[str, Any] = {"text": text, "to": list(to)}
323
+ if reply_to is not None:
324
+ body["reply_to"] = list(reply_to)
325
+ return await self._request("POST", f"/v0/groups/{segment(group)}/messages", body)
326
+
327
+ async def groups(self) -> dict[str, Any]:
328
+ return await self._request("GET", "/v0/groups")
329
+
330
+ async def group_info(self, group: str) -> dict[str, Any]:
331
+ return await self._request("GET", f"/v0/groups/{segment(group)}")
332
+
333
+ async def leave(self, group: str) -> dict[str, Any]:
334
+ return await self._request("POST", f"/v0/groups/{segment(group)}/leave")
335
+
336
+ async def events(self, after: int, group: str | None) -> dict[str, Any]:
337
+ params: dict[str, Any] = {"after": after}
338
+ if group is not None:
339
+ params["group"] = group
340
+ return await self._request("GET", "/v0/events", **params)
341
+
342
+ async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]:
343
+ body: dict[str, Any] = {"listing": listing}
344
+ if domain_check is not None:
345
+ body["domain_check"] = domain_check
346
+ return await self._request("POST", "/v0/knocks/inspect", body)
347
+
348
+ async def knock(
349
+ self,
350
+ listing: str,
351
+ *,
352
+ listing_sha256: str | None,
353
+ display_name: str | None,
354
+ note: str | None,
355
+ domain_check: bool | None,
356
+ ) -> dict[str, Any]:
357
+ fields = {
358
+ "listing_sha256": listing_sha256,
359
+ "display_name": display_name,
360
+ "note": note,
361
+ "domain_check": domain_check,
362
+ }
363
+ body = {"listing": listing} | {k: v for k, v in fields.items() if v is not None}
364
+ return await self._request("POST", "/v0/knocks", body)
365
+
366
+ async def knocks(self) -> dict[str, Any]:
367
+ return await self._request("GET", "/v0/knocks")
368
+
369
+ async def listing_status(self) -> dict[str, Any]:
370
+ return await self._request("GET", "/v0/listing")
371
+
372
+ async def take(self, group: str | None, addressed_only: bool, wait_secs: float) -> Pulls:
373
+ """Long-poll ``GET /v0/deliveries`` in steps of :data:`POLL_SECS`
374
+ until something comes or ``wait_secs`` is used up."""
375
+ if group is not None:
376
+ await self.group_info(group)
377
+ steps = max(1, math.ceil(wait_secs / POLL_SECS))
378
+ left = wait_secs
379
+ for _ in range(steps):
380
+ wait = min(POLL_SECS, math.ceil(left))
381
+ left -= wait
382
+ params: dict[str, Any] = {"addressed_only": int(addressed_only), "timeout": wait}
383
+ if group is not None:
384
+ params["group"] = group
385
+ data = await self._request("GET", "/v0/deliveries", **params)
386
+ rows = data.get("deliveries") or []
387
+ if rows:
388
+ pulled = [Pulled(str(r["delivery_id"]), dict(r["message"])) for r in rows]
389
+ return Pulls(str(data.get("lease", "")), pulled, bool(data.get("more")))
390
+ return Pulls("", [], more=False)
391
+
392
+ async def ack(self, lease: str, delivery_ids: Sequence[str]) -> list[str]:
393
+ if not delivery_ids:
394
+ return []
395
+ body = {"lease": lease, "ids": list(delivery_ids)}
396
+ refused = (await self._request("POST", "/v0/deliveries/ack", body)).get("refused") or []
397
+ return [d for d in delivery_ids if d not in refused]
398
+
399
+ async def release(self, lease: str, delivery_ids: Sequence[str]) -> None:
400
+ if delivery_ids:
401
+ body = {"lease": lease, "ids": list(delivery_ids)}
402
+ await self._request("POST", "/v0/deliveries/release", body)
403
+
404
+ async def close(self) -> None:
405
+ await self._http.aclose()