agent-core-busproxy 0.8.1__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 @@
1
+ """agent-core-busproxy — stdio MCP proxy to the daemon bus tool surface."""
@@ -0,0 +1,37 @@
1
+ """Typer CLI for agent-core-busproxy.
2
+
3
+ Runs a FastMCP proxy over stdio, backed by the daemon's per-agent HTTP
4
+ endpoint. Spawned by Claude Code as a stdio MCP server; its lifetime is
5
+ the Claude Code session, decoupled from the daemon's lifetime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import anyio
11
+ import typer
12
+
13
+ app = typer.Typer(add_completion=False)
14
+
15
+
16
+ @app.command()
17
+ def main(
18
+ agent: str = typer.Option(..., "--agent", help="Agent name on the bus."),
19
+ daemon_url: str = typer.Option(
20
+ "http://127.0.0.1:8789",
21
+ "--daemon-url",
22
+ help="agent-core daemon URL (default: http://127.0.0.1:8789).",
23
+ ),
24
+ ) -> None:
25
+ """Run the agent-core stdio bus proxy."""
26
+ from agent_core_busproxy.proxy import build_busproxy
27
+
28
+ proxy = build_busproxy(agent=agent, daemon_url=daemon_url)
29
+
30
+ async def _run() -> None:
31
+ await proxy.run_async(transport="stdio")
32
+
33
+ anyio.run(_run)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ app()
@@ -0,0 +1,117 @@
1
+ """Assemble the stdio bus proxy.
2
+
3
+ The proxy forwards the daemon's per-agent MCP tool surface. Each tool
4
+ call mints a FRESH backend session (ProxyClient.new()), so a restarted
5
+ daemon is never presented a stale mcp-session-id — issue #91 is removed
6
+ by construction, not by recovery logic.
7
+
8
+ Cache hardening (spec Amendment 2026-05-16): FastMCP's ProxyProvider
9
+ caches the backend tool list and swallows a backend-down list into an
10
+ EMPTY registry. We build the proxy via the documented lower-level
11
+ pattern — FastMCP + add_provider(ProxyProvider(client_factory,
12
+ cache_ttl=<long>)) — with a 24h cache_ttl so the tool palette survives
13
+ any realistic daemon bounce. (FastMCPProxy hardcodes ProxyProvider with
14
+ no cache_ttl passthrough, so it cannot be used here.) No active
15
+ keepalive: a keepalive cannot help the cold-start-while-down edge, and a
16
+ long TTL covers expiry (YAGNI).
17
+
18
+ `init_timeout` keeps a down/bouncing daemon from hanging the call: the
19
+ backend connect fails fast and the TransientErrorMiddleware (attached in
20
+ Task 5) turns that into a structured retryable tool result.
21
+
22
+ Output-schema stripping (spec Amendment 2): a FastMCP ProxyProvider
23
+ mirrors each backend tool's output_schema and the server then
24
+ re-validates every result against it. That would reject the
25
+ TransientErrorMiddleware's {transient:true} payload (it cannot match an
26
+ arbitrary tool's schema) and mangle it into an opaque output-validation
27
+ error. A transparent passthrough proxy must not re-validate the
28
+ backend's own outputs anyway, so _NoOutputSchemaProxyProvider drops the
29
+ output_schema on every proxied tool.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any
35
+
36
+ from fastmcp import FastMCP
37
+ from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
38
+
39
+ from agent_core_busproxy.transient import TransientErrorMiddleware
40
+
41
+
42
+ class _NoOutputSchemaProxyProvider(ProxyProvider):
43
+ """ProxyProvider that drops backend tool output schemas.
44
+
45
+ See module docstring (spec Amendment 2). Overrides both the list and
46
+ single-tool resolution paths so FastMCP performs no output validation
47
+ on proxied results — letting the transient payload AND normal
48
+ passthrough results through unchanged.
49
+ """
50
+
51
+ @staticmethod
52
+ def _strip(tool: Any) -> Any:
53
+ # Any: avoid annotating with fastmcp's non-public Tool internals.
54
+ if tool is None:
55
+ return None
56
+ return tool.model_copy(update={"output_schema": None})
57
+
58
+ async def _list_tools(self) -> Any:
59
+ return [self._strip(t) for t in await super()._list_tools()]
60
+
61
+ async def _get_tool(self, name: str, version: Any = None) -> Any:
62
+ return self._strip(await super()._get_tool(name, version))
63
+
64
+ # Fail-fast: a down daemon must not hang a tool call. Small connect/init
65
+ # budget; the agent owns the retry decision (spec: fail-fast retryable).
66
+ _BACKEND_INIT_TIMEOUT_SECONDS = 5.0
67
+ # Per-call request budget once connected (a healthy daemon answers in ms;
68
+ # this only bounds a half-open connection).
69
+ _BACKEND_REQUEST_TIMEOUT_SECONDS = 60.0
70
+ # Tool-list cache lifetime. Long enough that the palette survives any
71
+ # realistic daemon bounce/refresh within a session (spec Amendment).
72
+ _TOOL_CACHE_TTL_SECONDS = 86400.0
73
+
74
+
75
+ def build_busproxy(
76
+ *,
77
+ agent: str,
78
+ daemon_url: str | None,
79
+ _backend: Any | None = None,
80
+ ) -> FastMCP:
81
+ """Return a FastMCP proxy over the daemon's per-agent endpoint.
82
+
83
+ Args:
84
+ agent: bus agent name (URL path segment).
85
+ daemon_url: e.g. ``http://127.0.0.1:8789``. Ignored when
86
+ ``_backend`` is supplied.
87
+ _backend: test seam — an in-process FastMCP server to proxy
88
+ instead of an HTTP URL. Production always passes a URL.
89
+ """
90
+ if _backend is not None:
91
+ base_client = ProxyClient(_backend)
92
+ else:
93
+ if not daemon_url:
94
+ raise ValueError("daemon_url is required when no _backend is given")
95
+ url = f"{daemon_url.rstrip('/')}/mcp/{agent}"
96
+ base_client = ProxyClient(
97
+ url,
98
+ init_timeout=_BACKEND_INIT_TIMEOUT_SECONDS,
99
+ timeout=_BACKEND_REQUEST_TIMEOUT_SECONDS,
100
+ )
101
+
102
+ # -> Any: ProxyClient.new() returns an httpx-backed client (URL path)
103
+ # or an in-process FastMCP client (_backend path) — undifferentiated.
104
+ def client_factory() -> Any:
105
+ # Fresh session per request — the #91 fix.
106
+ return base_client.new()
107
+
108
+ proxy = FastMCP(name=f"agent-core[{agent}]")
109
+ proxy.add_provider(
110
+ _NoOutputSchemaProxyProvider(
111
+ client_factory, cache_ttl=_TOOL_CACHE_TTL_SECONDS
112
+ )
113
+ )
114
+ # daemon_url is the middleware's liveness-probe target. None on the
115
+ # in-process test path => probing disabled (ambiguous => genuine).
116
+ proxy.add_middleware(TransientErrorMiddleware(daemon_url=daemon_url))
117
+ return proxy
@@ -0,0 +1,148 @@
1
+ """Translate backend-unreachable failures into a structured, retryable
2
+ tool result. Genuine backend tool errors pass through verbatim so the
3
+ agent never retry-loops on a real failure.
4
+
5
+ Two backend-down shapes (spec Amendment 2026-05-16):
6
+ 1. Transport error — warm tool cache, per-request connect to the dead
7
+ daemon fails. Unambiguously transient.
8
+ 2. NotFoundError("Unknown tool: ...") — the tool-list fetch failed and
9
+ FastMCP's ProxyProvider/AggregateProvider swallowed it into an empty
10
+ registry. AMBIGUOUS: identical shape to a genuine unknown-tool / tool
11
+ exception from a HEALTHY daemon. Disambiguated with a fast TCP
12
+ liveness probe to the daemon: unreachable => transient; reachable =>
13
+ genuine, re-raised unchanged.
14
+
15
+ NOTE: fastmcp raises `NotFoundError` server-side from
16
+ `FastMCP.call_tool` when the resolved tool list is empty (backend-down
17
+ → swallowed list → empty registry). ToolError is also classified
18
+ AMBIGUOUS for forward-compatibility with versions that may change this
19
+ behaviour.
20
+
21
+ Unknown exception types are treated as genuine — a real bug is never
22
+ masked as retryable.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ import re
30
+ from enum import Enum
31
+ from typing import Any
32
+ from urllib.parse import urlparse
33
+
34
+ import httpx
35
+ from fastmcp.exceptions import NotFoundError, ToolError
36
+ from fastmcp.server.middleware import Middleware
37
+ from fastmcp.tools.base import ToolResult
38
+ from mcp.shared.exceptions import McpError
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+ TRANSIENT_ERROR_CODE = "bus_unavailable"
43
+ _RETRY_AFTER_SECONDS = 5
44
+ _PROBE_TIMEOUT_SECONDS = 2.0
45
+
46
+ # Reuse the #76 redaction shape: drop the entire query string (signed CDN
47
+ # tokens / session ids live there).
48
+ _URL_QS_RE = re.compile(r"(https?://[^\s?]+)\?\S*")
49
+
50
+
51
+ def redact(text: str) -> str:
52
+ """Strip query strings from any URLs in a human string."""
53
+ return _URL_QS_RE.sub(r"\1?<redacted>", text)
54
+
55
+
56
+ class Disposition(Enum):
57
+ """How a backend exception should be handled."""
58
+
59
+ TRANSIENT = "transient" # definitely daemon unreachable
60
+ GENUINE = "genuine" # definitely a real error — re-raise
61
+ AMBIGUOUS = "ambiguous" # could be either — probe the daemon to decide
62
+
63
+
64
+ # Transport-class: could not reach / handshake the daemon. Definitely
65
+ # transient. (NotFoundError/ToolError are NOT in here — ambiguous, handled below.)
66
+ _TRANSIENT_TYPES: tuple[type[BaseException], ...] = (
67
+ httpx.HTTPError,
68
+ ConnectionError,
69
+ TimeoutError,
70
+ OSError,
71
+ McpError,
72
+ )
73
+
74
+
75
+ def classify_backend_error(exc: BaseException) -> Disposition:
76
+ """Triage a backend exception (see module docstring)."""
77
+ if isinstance(exc, _TRANSIENT_TYPES):
78
+ return Disposition.TRANSIENT
79
+ # NotFoundError: fastmcp raises this (not ToolError) from FastMCP.call_tool
80
+ # when the resolved tool list is empty (backend-down → swallowed list →
81
+ # empty registry). ToolError: kept for forward-compatibility.
82
+ if isinstance(exc, (NotFoundError, ToolError)):
83
+ return Disposition.AMBIGUOUS
84
+ return Disposition.GENUINE
85
+
86
+
87
+ async def daemon_reachable(daemon_url: str) -> bool:
88
+ """Fast TCP liveness probe — can we open a socket to the daemon?
89
+
90
+ A bounced/down daemon has nothing listening on its port, so a refused
91
+ or timed-out connect means 'down'. Bounded by _PROBE_TIMEOUT_SECONDS
92
+ so it cannot itself hang a tool call.
93
+ """
94
+ parsed = urlparse(daemon_url)
95
+ host = parsed.hostname or "127.0.0.1"
96
+ port = parsed.port or 80
97
+ try:
98
+ fut = asyncio.open_connection(host, port)
99
+ _reader, writer = await asyncio.wait_for(
100
+ fut, timeout=_PROBE_TIMEOUT_SECONDS
101
+ )
102
+ except (TimeoutError, OSError):
103
+ return False
104
+ writer.close()
105
+ try:
106
+ await writer.wait_closed()
107
+ except Exception: # close best-effort
108
+ pass
109
+ return True
110
+
111
+
112
+ def _transient_result(exc: BaseException) -> ToolResult:
113
+ detail = redact(f"{type(exc).__name__}: {exc}")
114
+ log.warning("busproxy: backend unavailable (transient): %s", detail)
115
+ return ToolResult(
116
+ structured_content={
117
+ "error": TRANSIENT_ERROR_CODE,
118
+ "transient": True,
119
+ "retry_after_seconds": _RETRY_AFTER_SECONDS,
120
+ "detail": detail,
121
+ }
122
+ )
123
+
124
+
125
+ class TransientErrorMiddleware(Middleware):
126
+ """Map daemon-unreachable to a retryable result; pass real errors through.
127
+
128
+ ``daemon_url`` is the liveness-probe target. ``None`` (the in-process
129
+ test path) disables probing — an AMBIGUOUS error is then treated as
130
+ genuine so a real error is never masked when we cannot verify.
131
+ """
132
+
133
+ def __init__(self, daemon_url: str | None = None) -> None:
134
+ self._daemon_url = daemon_url
135
+
136
+ async def on_call_tool(self, context: Any, call_next: Any) -> ToolResult:
137
+ try:
138
+ return await call_next(context)
139
+ except BaseException as exc: # triage then re-raise (never swallow)
140
+ disposition = classify_backend_error(exc)
141
+ if disposition is Disposition.TRANSIENT:
142
+ return _transient_result(exc)
143
+ if disposition is Disposition.AMBIGUOUS and (
144
+ self._daemon_url is not None
145
+ and not await daemon_reachable(self._daemon_url)
146
+ ):
147
+ return _transient_result(exc)
148
+ raise
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-core-busproxy
3
+ Version: 0.8.1
4
+ Summary: Stdio MCP proxy — bridges Claude Code to the agent-core daemon bus tool surface with per-request backend sessions so a daemon bounce never strands the session.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: anyio>=4.0
8
+ Requires-Dist: fastmcp>=3.2
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: mcp>=1.0
11
+ Requires-Dist: typer>=0.12
@@ -0,0 +1,8 @@
1
+ agent_core_busproxy/__init__.py,sha256=HZ3dTcOuviHe3H81tZ29Zv8CgvTDgEo86PptF_VJdls,78
2
+ agent_core_busproxy/__main__.py,sha256=tuHNcvQ1A7xwoLVFijJ8jIYc2UD17cR0hn6HiswwOgU,948
3
+ agent_core_busproxy/proxy.py,sha256=lWgGwbQRKhzk611TJLwuTCWRmoOZ_AJU4dMyDEHWYWo,4809
4
+ agent_core_busproxy/transient.py,sha256=jrU1MdcxcyFrIHonEOiu-cMJLCze_IVSM2aIty8DNtA,5355
5
+ agent_core_busproxy-0.8.1.dist-info/METADATA,sha256=bs2MNEad-xEJC-WbzvxNBow5j2vVXROFVJBcsc51A4o,414
6
+ agent_core_busproxy-0.8.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ agent_core_busproxy-0.8.1.dist-info/entry_points.txt,sha256=7KUtxYhTxHwQ7hVfPpYZdY4D_bRg7MIrQZjv0J2xLz4,73
8
+ agent_core_busproxy-0.8.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-core-busproxy = agent_core_busproxy.__main__:app