AgentCat v2 — explicit handles for sessionless MCP, and MCP Python SDK v2 support - #42
Merged
Conversation
…thon SDK v2 support MCP 2026-07-28 (SEP-2567) removed protocol-level sessions: no `initialize` handshake, no `Mcp-Session-Id` header, and list endpoints are now connection-independent. Every request is self-contained. For analytics that is a tectonic shift — sessions were the built-in thread tying related tool calls together, and without them a stateless deployment sees each call in isolation. The MCP core team's guidance for cross-call state is to mint an explicit handle from a tool and have the model pass it back as an argument. AgentCat v2 rebuilds correlation on exactly that pattern: `track()` mints a `session_id`, delivers it to the agent, and the agent threads it back on every later call. Session correlation via explicit handles - `session_id` is injected into every tracked tool's input schema. Agents echo it back, grouping related calls into one session across stateless HTTP, load balancers, and per-request server instances. - Issued IDs are delivered via `_mcp_instructions` and mirrored into `structuredContent` for tools declaring an output schema. - Injected parameters are stripped before the customer's handler runs, and mint-back instructions never appear in published events. - AgentCat honors only handles it issued. A `session_id` that is not a `ses_` KSUID from this server is rejected rather than adopted — the call publishes without a session and the agent is told to re-send the ID it was given. `Event.session_id` is exempt from both redaction hooks, so a value AgentCat did not mint could not be redacted after the fact. - A tool whose own schema declares `session_id` keeps it. Their value reaches their handler untouched and is never read as a handle; those calls publish without a session and log an error naming the tool. `resolve_session_id` is the documented remedy — it reads no arguments at all. Agent attribution (beta, opt-in) - `agent_id` — enable with `enable_agent_tracking=True`. Each agent self-generates a `model|harness|nonce` ID, so parallel agents on one session stay individually attributable. Off by default; no server-side minting. MCP Python SDK v2 and community FastMCP 4 support - `track()` supports four server flavors through one call, dispatched by per-object detection: official SDK 1.x (low-level `Server` and `mcp.server.fastmcp.FastMCP`), official SDK 2.x (low-level `Server` and `mcp.server.mcpserver.MCPServer`), and community FastMCP 3.x and 4.x. - Full 2026-07-28 protocol support: envelope-first client identity, the protocol-version ladder, fully-qualified reserved metadata keys, and MRTR tagging for multi-round tool responses. - Per-request serving is first-class: call `track()` inside your server factory. Queues, exporters and diagnostics initialize once and are shared across instances. - Architecture is a shared pure engine plus thin per-era adapters, replacing the v1 monkey-patch overrides. Reliability - Full exception detail on tool errors is recovered on every era through a per-call inner tap, isolated by `ContextVar` so parallel calls can never read each other's exception. - `redact_sensitive_information` now actually runs on the publish path. - Real-HTTP e2e suites across four deployment topologies, cross-flavor behavior-parity suites, and a 25-way concurrency proof per flavor. Breaking changes - Session machinery, identity caches and identify events are removed. Correlation flows through explicit `session_id` handles. See MIGRATION.md. - `AgentCatOptions.stateless` is gone; agent tracking is opt-in. - `identify`, `event_tags`, `event_properties` and `resolve_session_id` receive the tool call's request PARAMS, not the enclosing request. - `publish_custom_event` takes verbatim session IDs; `CustomEventData.session_id` replaces session references. - Community FastMCP 2.x is no longer supported.
The `--no-sync` this branch added to the compatibility workflow made its 29 legs report honestly for the first time. Main runs `uv run pytest` without it, so uv re-resolved the venv from the lockfile immediately before every test session and discarded the `uv pip install "mcp==X"` pin the step above had just made — main's "1.2.1" leg passes six modules that import `mcp.client.streamable_http`, which does not exist in mcp 1.2.1. Twenty-two legs then failed here, and one of them was a real bug. `install_lowlevel_v1` imported `ToolAnnotations` as its first statement. That model only exists from mcp 1.7, and `track()`'s blanket except swallowed the ImportError, so on mcp 1.2-1.6 AgentCat installed no adapter and published nothing while `set_server_tracking_data` had already run and the server still looked tracked. Both lowlevel adapters now pass the hint as a plain mapping, the way `adapters/community.py` already did, and pydantic coerces it wherever the model exists. Everything else was the harness reaching for era-specific spellings. Fixtures now build through `request_handlers`, whose contract has been stable since mcp 1.0, so one fixture serves every 1.x instead of one that needs the 1.10 tuple return or the 1.19 result passthrough. `tests/conftest.py` gains the two collection axes it was missing — fastmcp presence, and the mcp minor that brought Streamable HTTP — because a module-scope import of an absent symbol fails before any skipif can run. That is what left the mcp 2.0.0 leg running zero tests. Where a capability is genuinely absent upstream rather than spelled differently, the suite gates on a probe and names the missing seam: `Server._make_error_result` and structured output (mcp 1.10), the HTTP request object (1.9.2), concurrent message handling (1.3), `ToolResult.is_error` (fastmcp 3.4). AgentCat runs below all of them; it just records less, and README and MIGRATION now say exactly what goes quiet where. Measured, all with zero failures: mcp 1.2.1 648 passed, mcp 1.9.4 679, mcp 2.0.0 without fastmcp 574 (was a collection error), fastmcp 3.3.1 781 (was 8 failed), legacy 781, modern 625. Ruff 258 and mypy 48-in-13 both unchanged.
The env var was read at import but unconditionally overwritten by track() with the options default (False), so the documented debug switch never worked (broken since it shipped in #11). debug_mode is now tri-state: explicit option > AGENTCAT_DEBUG_MODE > off.
…tics
Every write_to_log entry (local file and diagnostics sink) now carries a
once-per-process cached "agentcat=… python=… mcp=… fastmcp=…" suffix, so
a shared log excerpt never leaves the reader guessing which environment
produced it; an uninstalled MCP distribution reads `absent`, which is
itself a diagnostic. The OTLP exporter stamps process.runtime.* and
agentcat.{mcp,fastmcp}_sdk.version resource attributes on every exported
event, and the diagnostics beacon now reports fastmcp alongside mcp
(community servers previously reported only the transitive mcp version).
Graduate the 2.0.0b prerelease train to stable: Development Status classifier moves to Production/Stable, and the --pre install guidance in README and MIGRATION is gone since pip now resolves 2.0.0 directly.
…ndings An adversarially-verified audit found the SDK violating its core guarantee in 13 ways. This closes all of them: - event_queue: no signal handlers, no os._exit, no exit-time event drain. Lazy daemon workers consume the bounded queue directly (the executor's unbounded backlog is gone), destroy() is bounded, publish HTTP calls carry a 10s timeout. Import is side-effect free and thread-safe. - diagnostics: the one remaining atexit hook skips when empty and caps its POST at 2s. - __init__: the import-time version lookup no longer crashes metadata-less installs. - exceptions: capture_exception is never-raise, with per-step guards so hostile __str__, deleted-cwd abspath, raising content properties, and poisoned cause chains degrade instead of replacing the customer's result on the wire. - injection/callpath: the failed-rebuild strip fallback is shape+config aware — customer-declared session_id/agent_id/context parameters ride through, and the misleading invalid-session correction is suppressed when the registry is unknown. Rebuild root causes fixed (params_type fallback, per-tool copy guards in all three adapters), and the v1 tools/list copy preserves _meta and extra fields. - hooks: customer hooks run contained via run_hook — sync hooks offload to a worker thread (the loop never stalls), everything is capped at 5s, and SystemExit/spontaneous CancelledError are converted to a plain HookExecutionError while genuine task cancellation propagates. New regression suites: tests/test_process_safety.py (subprocess-based lifecycle checks) and tests/test_hook_offload.py, plus per-finding tests across the existing files. Both dependency legs green (776 modern / 919 legacy); ruff and mypy ratchets both improved.
CI's compatibility matrix caught the new process-safety tests failing on Linux: a daemon worker waking from queue.get(timeout=0.1) while the interpreter finalizes is killed via pthread_exit(), and glibc's forced unwind aborts the whole process (CPython gh-87135, fixed in 3.14) — replacing the customer's exit code with SIGABRT. Workers now block in an untimed get() and never wake on their own; destroy() wakes parked workers with a _Stop marker. Wake-up markers are excluded from the unprocessed-events count. MIGRATION.md documents the one remaining narrow window (timed retry backoff while the API is unreachable at exit).
…worker The compatibility matrix kept aborting (SIGABRT, 'FATAL: exception not rethrown') after the untimed-get fix: parking idle workers was not enough. Container bisection on Linux showed the real mechanism — a daemon thread still actively executing when finalization sets the finalizing flag is killed via pthread_exit(), and glibc's forced unwind through its C frames aborts the whole process (CPython gh-87135, fixed in 3.14). Pure-threading repros never abort (0/520); the SDK's real workload aborts at ~4% per exit. The fix is lifecycle, not waiting-strategy: first publish now registers a bounded atexit hook that stops the worker — sets the shutdown flag, wakes a parked or backing-off worker, joins with a ~1s budget, sends nothing. atexit runs before the finalizing flag is set, so the worker is gone before the dangerous window opens. Queued events still drop at exit by design. The pool is also simplified to a single worker thread; the bounded queue absorbs bursts and add() drops on overflow. Validated on python:3.12-slim: the two previously-aborting subprocess bodies ran 210 times with zero failures (baseline ~4%) and prompt exits.
naji247
force-pushed
the
feat/explicit-handles-v2
branch
from
August 5, 2026 01:45
619d418 to
1cccfda
Compare
Match the TS README section-for-section; code samples stay Python. Deviations: no redactEvent or PostHog (not in this SDK), and the identify paragraph reflects that hooks run ahead of the handler.
kashishhora
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AgentCat v2 — explicit handles for sessionless MCP, and MCP Python SDK v2 support
MCP 2026-07-28 Compatibility
The MCP 2026-07-28 specification removed protocol-level sessions. SEP-2567: Sessionless MCP via Explicit State Handles (merged) eliminates the
initializehandshake and theMcp-Session-Idheader, and the changelog makes list endpoints connection-independent. Every request is now self-contained.For analytics, that's a tectonic shift: sessions were the built-in thread tying related tool calls together. Without them, a stateless deployment sees each call in isolation.
The MCP core team's guidance for cross-call state is explicit — from the 2026-07-28 announcement:
AgentCat v2 rebuilds correlation on exactly this pattern. Instead of inferring sessions from the transport,
track()mints an explicitsession_idhandle and delivers it to the agent, which threads it back on every subsequent call — the samecreate_*() → handle → threaded argumentshape SEP-2567 describes.What it costs you
session_idadds 323 tokens to a tool's schema — 0.03% of a 1M-token context window.Tokens scale with the tools loaded into a given request, not with the size of your catalog: with progressive tool disclosure, clients pull in the tools a request needs rather than your entire surface.
The correlation you get in exchange holds across stateless HTTP, load balancers, and per-request server instances — where transport-session inference gives you nothing at all.
What's new
Task correlation via explicit handles
session_idis injected into every tracked tool's input schema. Agents echo it back on each call, grouping related calls into one task — even across stateless HTTP, load-balanced deployments, and per-request server instances._mcp_instructionsand, for tools declaring an output schema, mirrored intostructuredContentso clients that only read structured results still receive them.Agent attribution (opt-in)
agent_id— enable withenable_agent_tracking=True. Each agent self-generates amodel|harness|nonceID, so parallel agents collaborating on one task stay individually attributable. Off by default; no server-side minting.MCP Python SDK v2 support
track()now supports both SDK v1 and v2 servers through a unified engine with per-object SDK detection — high-levelFastMCP(v1) /MCPServer(v2) and low-levelServerflavors on both majors, plus community FastMCP 3.x and 4.x.track()inside your server factory; queues, exporters, and diagnostics initialize once and are shared across instances.get_more_toolsis annotated read-only via MCP tool annotations.Reliability
ContextVarso parallel calls can never read each other's exception.Fault containment
An adversarial audit of every failure path between AgentCat and the host server hardened v2 around one guarantee: an AgentCat failure never takes your server down with it. Each fix landed with a reproducing test.
capture_exceptionis never-raise, with per-step guards, so a hostile__str__, a deleted working directory, or a poisoned cause chain forfeits that call's analytics instead of your result on the wire.session_id/agent_id/contextparameter you declared yourself rides through to your handler untouched.identify,resolve_session_id,event_tags,event_properties— now run contained: sync hooks offload to a worker thread so your event loop never stalls, everything is capped at 5 seconds, and nothing a hook does — including raisingSystemExit— reaches your request path. A slow, throwing, or even permanently hanging hook costs analytics data for that one event, never your server.SIGINT/SIGTERMhandlers, noos._exit, no exit-time event drain — every worker is a daemon thread and shutdown is entirely yours. The one bounded exit hook stops the publish worker before interpreter finalization, closing a CPython ≤ 3.13 daemon-threadSIGABRTon Linux.import agentcatis side-effect free, thread-safe, and survives metadata-less installs; the event queue is bounded, and publish HTTP calls carry a 10-second timeout.Known limitation, accepted for 2.0.0: telemetry still queued when the process exits is dropped — the deliberate price of never delaying or hijacking your shutdown.
Breaking changes
session_idhandles. See the migration guide.enable_agent_trackingdefaults toFalse.identify,event_tags,event_propertiesandresolve_session_idnow receive the tool call's request params, not the enclosing request. This one fails silently if you skip it.publish_custom_eventtakes verbatim session ids;CustomEventData.session_idreplaces session references.agentcat<2if you need it.Compatibility
Servermcp.server.fastmcpmcp.server.mcpserverRelease
This branch ships as stable v2.0.0 — the beta line (
2.0.0b1–2.0.0b3) is superseded.Install:
pip install agentcat(2.0.0)