Repository navigation
[0.9] - 2026-07-17
Per-agent MCP tool visibility with live cache invalidation. Two new proxy
addons and a small orchestrator publisher: agents now see only tool
descriptions their policy allows, and live sessions are pushed a
notifications/tools/list_changed frame when allowed_tools is edited.
See docs/adr/001-mcp-tool-visibility.md.
Added
ToolListFilterproxy addon (services/proxy/tool_list_filter.py).
Request hook recordsmcp_pending_list:{sid}:{jsonrpc_id}→agent_id
(120 s TTL) on every MCPtools/listrequest. Response hook correlates
the reply — across POST-inline JSON, POST-SSE frames, and GET-SSE frames
— and rewritesresult.toolsdown toresult.tools ∩ policy.allowed_tools. Runs betweenstream_blockerandpii_redactor
in the chain. Fail-closed on Redis error during response filtering
(503redis-unavailable, matching enforcer convention).ListChangeNotifierproxy addon
(services/proxy/list_change_notifier.py). Startup task subscribes to
Redis Pub/Sub channelpolicy:changed; tracks live MCP GET-SSE flows in
an in-processagent_sse_streamsregistry populated in
responseheadersand cleared via weakref on flow end. On a message
whosechanged_fieldsincludesallowed_tools, queues a
data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}\n\n
frame into each affected flow. Delivery is coupled to the MCP server's
SSE keepalive cadence (typically 15-30 s) — trade-off documented in
ADR-001 vs the forced-reconnect alternative. Runs afterAuditLogin
the chain so it wraps AuditLog's existing GET-SSE stream handler
instead of overriding it.- Orchestrator publisher.
PUT /policy/{agent_id}now diffs old vs
new andPUBLISHes{"agent_id", "changed_fields"}onpolicy:changed
when any field differs. Publish is best-effort — Redis errors do not
fail the PUT (the policy is already persisted; the per-request refetch
picks it up). Response body gains achanged_fieldsarray. GET /policy/{agent_id}/tool-visibility?server=<name>endpoint.
Orchestrator calls the MCP server via the existingmcp_probehelper,
applies the sameallowed_toolsfilter, returns
{server, visible: MCPTool[], hidden: MCPTool[]}. Owner-gated. Powers
the AgentPolicy Tool Visibility panel and the ST-TV.7 system test.- Tool Visibility preview panel (
services/ui/src/pages/AgentPolicy.tsx).
New card in flexible mode below Allowed Capabilities. MCP server
dropdown; two live-updating columns (visible / hidden) driven from the
mcp_serversReact Query cache and the draft'sallowed_tools. No
extra round-trip per toggle. - "N tools hidden" chip on tools/list spans in the Trace Detail table
(services/ui/src/pages/TraceDetail.tsx). Backed by a new
tools_filteredaudit field ("N/M" — before/after) written by
AuditLogfromflow.metadata["amaze_tool_list_filtered"]. - System tests (
tests/test_s9_tool_visibility.py). Endpoint slice
of ST-TV.*: ST-TV.1 filter applies, ST-TV.2 no-policy pass-through,
ST-TV.4 no-op PUT reports emptychanged_fields, ST-TV.6 rapid
successive PUTs leave persisted state matching the final write,
ST-TV.7 endpoint shape + 404 on unknown server, ST-TV.8 cross-user
403. Real stack, no mocks. LLMToolStripperproxy addon
(services/proxy/llm_tool_stripper.py). Framework-agnostic backstop at
the LLM boundary: parses thetools[]array on every outbound
chat-completion / messages request and drops entries not in
policy.allowed_tools, patchingtool_choicewhen it pinned a stripped
tool. Closes the gap left byToolListFilterfor runtimes that cached
their tool schema before the policy tightened — every LangChain /
LangGraph agent has this shape, becausecreate_agent(tools=...)bakes
the binding at construction. Wire formats live in a_PROVIDER_SHAPES
registry keyed by canonical provider name (openai,anthropic
today); adding a provider is one entry, not a new filter function.
Runs adjacent toStreamBlockersince both mutate LLM request bodies.
Fail-closed on Redis error (503redis-unavailable).- Push-based tool-set signalling in the SDK.
PUT /policy/{agent_id}
now assembles{allowed_tools: [{name, description, inputSchema}, ...]}
from the approvedmcp:{name}schema caches and POSTs it to the
agent's/_amaze/tools_changedendpoint (mounted on both the chat and
A2A apps, so A2A-only agents are covered). Authenticated by bearer echo
— the orchestrator replays the agent's own token, which only it and the
agent hold. Best-effort: a failed push never fails the PUT, because
ToolListFilterandLLMToolStripperstill enforce at the wire. amaze.is_tools_changed()/amaze.current_tools(). Two sync
functions (threading-locked, callable from sync or async handlers) that
surface the pushed payload to author code.is_tools_changed()is an
atomic read-and-clear;current_tools()returns a defensive copy of
the current authoritative set with schemas. The SDK holds no opinion
about what happens next — the author decides whether to rebuild their
agent, swap a filter set behind LangChainwrap_model_callmiddleware,
or ignore the signal entirely. Deliberately not a callback: reusing
on_startupas an implicit rebuild hook would silently re-run
one-shot author code.GET /agents/self/allowed-tools. Agent-bearer-authenticated
self-lookup returning the caller's currentallowed_tools. Resolves
session_token:{token}→ agent_id the same way the proxy does.
Available as a poll-based fallback when a push is missed.agent:{agent_id}:bearer_tokenRedis key, written at registration
so the orchestrator can echo the agent's bearer on the outbound push.
Changed
services/proxy/main.pyaddon chain. Three new addons inserted;
chain-order docstring updated. Chain is now
session → tracer → enforcer → graph → debug_pauser → stream_blocker → llm_tool_stripper → tool_list_filter → pii_redactor → counters → audit_log → list_change_notifier → router.ListChangeNotifieris
registered UNWRAPPED (not insideFailClosed) because the wrapper does
not forwardload/running/donehooks; it needsrunningto start
its Pub/Sub task. Itsresponseheadersis not on the deny path, so the
missing wrapper has no security impact. Forwarding lifecycle hooks
throughFailClosedis filed as follow-up work — the same gap
currently preventsPiiRedactor.load()from firing, so the spaCy
preload it was meant to do happens lazily on first request instead.services/proxy/audit_log.py. MCP audit records now carry a
tools_filteredfield ("N/M") on tools/list spans, and LLM records
carryllm_tools_stripped("N/M") when the stripper reduced the
outbound tool array. Both absent on records where they don't apply.- Trace detail UI. Two chips: cyan "N tools hidden" on
tools/list
spans (MCP-boundary filtering), amber "N tools stripped" on LLM spans
(LLM-boundary stripping). The colour split is deliberate — hidden means
the filter worked at build time, stripped means the runtime's cache was
stale and the wire-level backstop caught it. PUT /policy/{agent_id}response body. Now includes
"changed_fields": [...]reporting which top-levelPolicyfields
diverged from the previous value. Empty on no-op writes.- Demo agents (
examples/agents/agent_sdk{,1,2,3}.py) check
amaze.is_tools_changed()at the top of their message handlers and
rebuild via their existing_build_agent(). ~5 lines each; the rebuild
pattern was chosen over LangChainwrap_model_callmiddleware for the
canonical example because it works for any framework. SDK version.sdk/pyproject.tomlbumped to0.9.0.
Fixed
ListChangeNotifiercrashed on every MCP GET-SSE flow._FlowSlot
declared__slots__without__weakref__, so adding a slot to the
WeakSetregistry raisedTypeError: cannot create weak reference.
The registry stayed empty and no notification could ever be delivered.
Notes
- The addon adopts the discovery pass-through invariant already
established byenforcer.py: an agent with no policy row sees the
full tool list. - Three enforcement layers, deliberately redundant.
ToolListFilter
keeps disallowed descriptions out of the agent at MCP discovery time;
LLMToolStripperstrips them from the wire if the runtime's cache went
stale;PolicyEnforcerstill denies thetools/callitself. The SDK
push is a UX layer on top — it lets a cooperating agent converge fast,
but an agent that ignores it is still correctly constrained. notifications/tools/list_changeddelivery is best-effort.
mitmproxy's stream handler is a per-upstream-chunk callback and cannot
push independently, so an injected frame only reaches the agent when
the MCP server next writes to the SSE stream. Servers with sparse
keepalives may never flush it. This is why the SDK push exists — the
MCP frame is kept for non-SDK clients that honour it, but it is not the
mechanism the demo agents rely on. Full detail in ADR-001
§"Delivery model".langchain-mcp-adaptershas nolist_changedcallback slot. Its
Callbacksdataclass exposes onlyon_logging_message,on_progress
andon_elicitation, andcreate_agent()binds tools at construction
— so even a perfectly delivered MCP notification has nowhere to go in a
LangChain runtime. This is the concrete reason the design landed on a
push to the SDK plus an author-controlled reaction, rather than relying
on the MCP-native path.- Traffic-level ST-TV.3 (mid-session notification within 500 ms) and
ST-TV.5 (Redis-down fail-closed observed through the real proxy) need
a live-agent + mock-MCP harness with a way to kill Redis mid-flight;
deferred to a follow-up harness, mirroring the S8 endpoint-slice split.