Skip to content

AgentCat v2 — explicit handles for sessionless MCP, and MCP Python SDK v2 support - #42

Merged
naji247 merged 13 commits into
mainfrom
feat/explicit-handles-v2
Aug 5, 2026
Merged

AgentCat v2 — explicit handles for sessionless MCP, and MCP Python SDK v2 support#42
naji247 merged 13 commits into
mainfrom
feat/explicit-handles-v2

Conversation

@naji247

@naji247 naji247 commented Aug 3, 2026

Copy link
Copy Markdown
Member

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 initialize handshake and the Mcp-Session-Id header, 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:

"If your server needs to carry state across calls, mint an explicit handle from a tool and have the model pass it back as an argument. We found this works better than session state hidden in the transport — the model can see the handle and thread it between tools."

AgentCat v2 rebuilds correlation on exactly this pattern. Instead of inferring sessions from the transport, track() mints an explicit session_id handle and delivers it to the agent, which threads it back on every subsequent call — the same create_*() → handle → threaded argument shape SEP-2567 describes.

What it costs you

session_id adds 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_id is 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.
  • Issued IDs are delivered via _mcp_instructions and, for tools declaring an output schema, mirrored into structuredContent so clients that only read structured results still receive them.
  • Injected parameters are stripped before your handler runs — your tool code never sees them — and mint-back instructions never appear in published events.

Agent attribution (opt-in)

  • agent_id — enable with enable_agent_tracking=True. Each agent self-generates a model|harness|nonce ID, 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-level FastMCP (v1) / MCPServer (v2) and low-level Server flavors on both majors, plus community FastMCP 3.x and 4.x.
  • Full 2026-07-28 protocol support: envelope-first client identity, the protocol version ladder, fully-qualified reserved keys, and MRTR tagging.
  • Per-request serving is first-class: call track() inside your server factory; queues, exporters, and diagnostics initialize once and are shared across instances.
  • get_more_tools is annotated read-only via MCP tool annotations.

Reliability

  • Full Python exception detail on tool errors — stack frames, exception type, context lines, chained causes — is recovered on every era through a per-call inner tap, isolated by ContextVar so parallel calls can never read each other's exception.
  • Opt-in real-HTTP e2e matrix across four deployment topologies, behavior-parity suites twinning all four server flavors, and a 25-way concurrency proof per flavor.

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.

  • A failure while decorating or recording a completed tool call can no longer replace your tool's response: capture_exception is 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.
  • A failed on-demand schema rebuild can no longer strip customer-declared parameters: the fallback is shape- and config-aware, so a session_id / agent_id / context parameter you declared yourself rides through to your handler untouched.
  • Customer hooks — 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 raising SystemExit — reaches your request path. A slow, throwing, or even permanently hanging hook costs analytics data for that one event, never your server.
  • The SDK never touches your process lifecycle: no SIGINT/SIGTERM handlers, no os._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-thread SIGABRT on Linux.
  • import agentcat is 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-id machinery, identity caches, and identify events are removed. Correlation now flows through explicit session_id handles. See the migration guide.
  • Agent tracking is opt-inenable_agent_tracking defaults to False.
  • identify, event_tags, event_properties and resolve_session_id now receive the tool call's request params, not the enclosing request. This one fails silently if you skip it.
  • publish_custom_event takes verbatim session ids; CustomEventData.session_id replaces session references.
  • Community FastMCP 2.x is no longer supported — pin agentcat<2 if you need it.

Compatibility

MCP Python SDK 1.x MCP Python SDK 2.x
Low-level Server
Official facade mcp.server.fastmcp mcp.server.mcpserver
Community FastMCP ✅ 3.x ✅ 4.x
Stateless 2026-07-28 transports
Python 3.10 / 3.11 / 3.12
Community FastMCP 2.x

Release

This branch ships as stable v2.0.0 — the beta line (2.0.0b12.0.0b3) is superseded.

Install: pip install agentcat (2.0.0)

naji247 added 11 commits August 3, 2026 12:49
…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
naji247 force-pushed the feat/explicit-handles-v2 branch from 619d418 to 1cccfda Compare August 5, 2026 01:45
naji247 added 2 commits August 4, 2026 22:34
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.
@naji247
naji247 merged commit 21df778 into main Aug 5, 2026
40 checks passed
@naji247
naji247 deleted the feat/explicit-handles-v2 branch August 5, 2026 03:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants