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.
- agent_core_busproxy/__init__.py +1 -0
- agent_core_busproxy/__main__.py +37 -0
- agent_core_busproxy/proxy.py +117 -0
- agent_core_busproxy/transient.py +148 -0
- agent_core_busproxy-0.8.1.dist-info/METADATA +11 -0
- agent_core_busproxy-0.8.1.dist-info/RECORD +8 -0
- agent_core_busproxy-0.8.1.dist-info/WHEEL +4 -0
- agent_core_busproxy-0.8.1.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|