Skip to content

v0.9 — MCP tool visibility + LLM tool stripping

Latest

Choose a tag to compare

@chernomaz chernomaz released this 25 Jul 14:52
· 4 commits to main since this release

[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

  • ToolListFilter proxy addon (services/proxy/tool_list_filter.py).
    Request hook records mcp_pending_list:{sid}:{jsonrpc_id} → agent_id
    (120 s TTL) on every MCP tools/list request. Response hook correlates
    the reply — across POST-inline JSON, POST-SSE frames, and GET-SSE frames
    — and rewrites result.tools down to result.tools ∩ policy.allowed_tools. Runs between stream_blocker and pii_redactor
    in the chain. Fail-closed on Redis error during response filtering
    (503 redis-unavailable, matching enforcer convention).
  • ListChangeNotifier proxy addon
    (services/proxy/list_change_notifier.py). Startup task subscribes to
    Redis Pub/Sub channel policy:changed; tracks live MCP GET-SSE flows in
    an in-process agent_sse_streams registry populated in
    responseheaders and cleared via weakref on flow end. On a message
    whose changed_fields includes allowed_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 after AuditLog in
    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 and PUBLISHes {"agent_id", "changed_fields"} on policy: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 a changed_fields array.
  • GET /policy/{agent_id}/tool-visibility?server=<name> endpoint.
    Orchestrator calls the MCP server via the existing mcp_probe helper,
    applies the same allowed_tools filter, 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_servers React Query cache and the draft's allowed_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_filtered audit field ("N/M" — before/after) written by
    AuditLog from flow.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 empty changed_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.
  • LLMToolStripper proxy addon
    (services/proxy/llm_tool_stripper.py). Framework-agnostic backstop at
    the LLM boundary: parses the tools[] array on every outbound
    chat-completion / messages request and drops entries not in
    policy.allowed_tools, patching tool_choice when it pinned a stripped
    tool. Closes the gap left by ToolListFilter for runtimes that cached
    their tool schema before the policy tightened — every LangChain /
    LangGraph agent has this shape, because create_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 to StreamBlocker since both mutate LLM request bodies.
    Fail-closed on Redis error (503 redis-unavailable).
  • Push-based tool-set signalling in the SDK. PUT /policy/{agent_id}
    now assembles {allowed_tools: [{name, description, inputSchema}, ...]}
    from the approved mcp:{name} schema caches and POSTs it to the
    agent's /_amaze/tools_changed endpoint (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
    ToolListFilter and LLMToolStripper still 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 LangChain wrap_model_call middleware,
    or ignore the signal entirely. Deliberately not a callback: reusing
    on_startup as 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 current allowed_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_token Redis key, written at registration
    so the orchestrator can echo the agent's bearer on the outbound push.

Changed

  • services/proxy/main.py addon 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. ListChangeNotifier is
    registered UNWRAPPED (not inside FailClosed) because the wrapper does
    not forward load/running/done hooks; it needs running to start
    its Pub/Sub task. Its responseheaders is not on the deny path, so the
    missing wrapper has no security impact. Forwarding lifecycle hooks
    through FailClosed is filed as follow-up work — the same gap
    currently prevents PiiRedactor.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_filtered field ("N/M") on tools/list spans, and LLM records
    carry llm_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-level Policy fields
    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 LangChain wrap_model_call middleware for the
    canonical example because it works for any framework.
  • SDK version. sdk/pyproject.toml bumped to 0.9.0.

Fixed

  • ListChangeNotifier crashed on every MCP GET-SSE flow. _FlowSlot
    declared __slots__ without __weakref__, so adding a slot to the
    WeakSet registry raised TypeError: 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 by enforcer.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;
    LLMToolStripper strips them from the wire if the runtime's cache went
    stale; PolicyEnforcer still denies the tools/call itself. 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_changed delivery 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-adapters has no list_changed callback slot. Its
    Callbacks dataclass exposes only on_logging_message, on_progress
    and on_elicitation, and create_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.