diff --git a/.github/scripts/discover_versions.py b/.github/scripts/discover_versions.py
new file mode 100644
index 0000000..be9e176
--- /dev/null
+++ b/.github/scripts/discover_versions.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Pick the upstream versions a compatibility matrix should test.
+
+Replaces the `pip index versions | grep | sort -V` pipelines the two
+compatibility workflows used to carry. Three reasons it is Python:
+
+- `sort -V` is not PEP 440. Measured: it sorts `4.0.0b1` AFTER `4.0.0`, where
+ PEP 440 puts a prerelease BEFORE the release it leads to. So "latest" off
+ `sort -V | tail -1` could name a superseded prerelease as the newest version.
+- `pip index versions` is an experimental command that prints a deprecation
+ banner to stderr and has no stable output contract.
+- A range like `>=1.2,<3` is one specifier here instead of hand-rolled
+ major/minor arithmetic that has to be re-derived every time a bound moves.
+
+Usage:
+ discover_versions.py # stable releases
+ discover_versions.py --pre # prereleases only
+
+Specifier gotcha, and why the prerelease callers pass `a0`: PEP 440 orders
+`4.0.0b1 < 4.0.0`, so `>=4.0` EXCLUDES every 4.0.0 prerelease and a `--pre`
+run against it silently finds nothing. `>=4.0.0a0` is the lowest bound that
+admits the whole 4.0.0 prerelease series.
+
+Stable mode emits the newest patch of every minor in range — one matrix leg per
+minor, which is the granularity upstream breaks things at.
+
+Prerelease mode emits at most one version: the newest prerelease in range that
+no stable release has superseded. A prerelease older than the latest stable is
+not an early warning about anything, it is history, so `mcp 2.0.0rc1` drops out
+the day `mcp 2.0.0` ships and that job goes back to skipping itself. This is
+what keeps the prerelease workflow pointed at the NEXT generation without a
+hardcoded version number to bump.
+
+Writes GitHub Actions step outputs (`versions=[...]`, `has-versions=...`) to
+stdout, for appending to $GITHUB_OUTPUT.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import urllib.error
+import urllib.request
+
+from packaging.specifiers import SpecifierSet
+from packaging.version import InvalidVersion, Version
+
+PYPI_JSON = "https://pypi.org/pypi/{package}/json"
+TIMEOUT_SECONDS = 30
+
+
+def fetch_releases(package: str) -> dict[str, list[dict[str, object]]]:
+ """Every release of `package` from PyPI's JSON API, mapped to its files."""
+ url = PYPI_JSON.format(package=package)
+ try:
+ with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response:
+ payload = json.load(response)
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
+ print(f"::error::Could not read {url}: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
+ releases = payload.get("releases")
+ if not isinstance(releases, dict):
+ print(f"::error::{url} returned no releases map", file=sys.stderr)
+ raise SystemExit(1)
+ return releases
+
+
+def installable(files: object) -> bool:
+ """True when a release still has at least one file nobody has yanked.
+
+ PyPI keeps an entry for deleted and fully-yanked releases, and pinning one
+ fails the install rather than reporting an incompatibility — so a matrix
+ leg for it would be noise that looks exactly like a real failure.
+ """
+ if not isinstance(files, list) or not files:
+ return False
+ return any(not entry.get("yanked", False) for entry in files)
+
+
+def parse_versions(releases: dict[str, list[dict[str, object]]]) -> list[Version]:
+ parsed = []
+ for raw, files in releases.items():
+ if not installable(files):
+ continue
+ try:
+ parsed.append(Version(raw))
+ except InvalidVersion:
+ continue
+ return parsed
+
+
+def latest_per_minor(versions: list[Version]) -> list[Version]:
+ newest: dict[tuple[int, int], Version] = {}
+ for version in versions:
+ key = (version.major, version.minor)
+ if key not in newest or version > newest[key]:
+ newest[key] = version
+ return [newest[key] for key in sorted(newest)]
+
+
+def select(package: str, specifier: str, prerelease: bool) -> list[Version]:
+ all_versions = parse_versions(fetch_releases(package))
+ # `prereleases=True` on both sides: the default would silently drop every
+ # prerelease from an in-range check, which is exactly what we filter on.
+ in_range = [v for v in all_versions if SpecifierSet(specifier).contains(v, True)]
+
+ if not prerelease:
+ return latest_per_minor([v for v in in_range if not v.is_prerelease])
+
+ candidates = [v for v in in_range if v.is_prerelease]
+ stable = [v for v in all_versions if not v.is_prerelease]
+ if stable:
+ newest_stable = max(stable)
+ candidates = [v for v in candidates if v > newest_stable]
+ return [max(candidates)] if candidates else []
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("package")
+ parser.add_argument("specifier", help="PEP 440 range, e.g. '>=1.2,<3'")
+ parser.add_argument(
+ "--pre",
+ action="store_true",
+ dest="prerelease",
+ help="emit the newest un-superseded prerelease instead of stables",
+ )
+ args = parser.parse_args()
+
+ selected = select(args.package, args.specifier, args.prerelease)
+ rendered = json.dumps([str(v) for v in selected], separators=(",", ":"))
+
+ channel = "prerelease" if args.prerelease else "stable"
+ print(
+ f"Selected {len(selected)} {args.package} {channel} version(s) "
+ f"for {args.specifier}: {rendered}",
+ file=sys.stderr,
+ )
+ print(f"versions={rendered}")
+ print(f"has-versions={'true' if selected else 'false'}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/workflows/mcp-compatibility.yml b/.github/workflows/mcp-compatibility.yml
index f6ae2df..5095e37 100644
--- a/.github/workflows/mcp-compatibility.yml
+++ b/.github/workflows/mcp-compatibility.yml
@@ -10,6 +10,31 @@ on:
- cron: '0 0 * * *'
workflow_dispatch:
+# The two generations agentcat 2.x supports, as PEP 440 ranges. `mcp` spans
+# both majors from one dependency (1.x lowlevel/FastMCP and 2.x
+# lowlevel/MCPServer are four adapters behind one `track()`); `fastmcp` starts
+# at 3.0 because agentcat>=2 dropped community FastMCP 2.x outright — a 2.x
+# server is classified, refused and returned untracked on purpose, so there is
+# nothing for a compatibility leg to assert. That is also why the old `2.9.*`
+# carve-out is gone rather than moved: the whole 2.x line is out of range now.
+#
+# These ranges only started meaning anything when the test steps below gained
+# `uv run --no-sync`. Without it `uv run` re-resolves from the lockfile
+# immediately before pytest, silently discarding the `uv pip install
+# "mcp==X"` pin the step above just made — so every leg ran the lockfile's mcp
+# and the matrix was green by construction. Never drop `--no-sync`; the
+# "Show installed versions" steps are what prove the pin survived.
+#
+# Three capabilities are genuinely absent on the oldest legs rather than
+# spelled differently, and the suite gates on each rather than pretending:
+# concurrent message handling (mcp 1.3), Streamable HTTP plus the request
+# object `extra.requestInfo` reads (1.8 / 1.9.2, see `tests/conftest.py`), and
+# `Server._make_error_result` — the seam the inner tap hooks — with structured
+# tool output (1.10). AgentCat runs below all of them; it just records less.
+env:
+ MCP_RANGE: ">=1.2,<3"
+ FASTMCP_RANGE: ">=3.0,<5"
+
jobs:
discover-mcp-versions:
runs-on: ubuntu-latest
@@ -17,35 +42,18 @@ jobs:
mcp-versions: ${{ steps.get-mcp-versions.outputs.versions }}
steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
- name: Get available MCP versions
id: get-mcp-versions
run: |
- # Get all available versions from PyPI
- versions=$(pip index versions mcp 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | sort -V)
-
- # Filter to versions >= 1.2.0 and get only latest patch version for each minor
- declare -A latest_minor
- for version in $versions; do
- major=$(echo $version | cut -d. -f1)
- minor=$(echo $version | cut -d. -f2)
- patch=$(echo $version | cut -d. -f3)
-
- # Include if version >= 1.2.0
- if [ "$major" -gt 1 ] || ([ "$major" -eq 1 ] && [ "$minor" -ge 2 ]); then
- minor_key="$major.$minor"
- latest_minor[$minor_key]="$version"
- fi
- done
-
- # Create JSON array from latest versions
- filtered_versions=()
- for version in "${latest_minor[@]}"; do
- filtered_versions+=(\"$version\")
- done
-
- json_array="[$(IFS=,; echo "${filtered_versions[*]}")]"
- echo "Found MCP versions: $json_array"
- echo "versions=$json_array" >> $GITHUB_OUTPUT
+ python -m pip install --quiet --disable-pip-version-check packaging
+ python .github/scripts/discover_versions.py mcp "$MCP_RANGE" >> "$GITHUB_OUTPUT"
discover-fastmcp-versions:
runs-on: ubuntu-latest
@@ -53,41 +61,84 @@ jobs:
fastmcp-versions: ${{ steps.get-fastmcp-versions.outputs.versions }}
steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
- name: Get available FastMCP versions
id: get-fastmcp-versions
run: |
- # Get all available versions from PyPI
- versions=$(pip index versions fastmcp 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | sort -V)
-
- # Filter to versions >= 2.7.0, exclude 2.9.*, and get only latest patch version for each minor
- declare -A latest_minor
- for version in $versions; do
- major=$(echo $version | cut -d. -f1)
- minor=$(echo $version | cut -d. -f2)
- patch=$(echo $version | cut -d. -f3)
-
- # Skip 2.9.* versions (known compatibility issues)
- if [ "$major" -eq 2 ] && [ "$minor" -eq 9 ]; then
- echo "Skipping FastMCP $version (2.9.* versions have known issues)"
- continue
- fi
-
- # Include if version >= 2.7.0
- if [ "$major" -gt 2 ] || ([ "$major" -eq 2 ] && [ "$minor" -ge 7 ]); then
- minor_key="$major.$minor"
- latest_minor[$minor_key]="$version"
- fi
- done
-
- # Create JSON array from latest versions
- filtered_versions=()
- for version in "${latest_minor[@]}"; do
- filtered_versions+=("\"$version\"")
- done
-
- json_array="[$(IFS=,; echo "${filtered_versions[*]}")]"
- echo "Found FastMCP versions: $json_array"
- echo "versions=$json_array" >> $GITHUB_OUTPUT
+ python -m pip install --quiet --disable-pip-version-check packaging
+ python .github/scripts/discover_versions.py fastmcp "$FASTMCP_RANGE" >> "$GITHUB_OUTPUT"
+
+ # The shipped dual-generation configuration, exactly as a customer gets it:
+ # no version pinning, just the two mutually exclusive dependency groups from
+ # pyproject. The discovery matrices below sweep what upstream has PUBLISHED;
+ # this job is the one that proves what we RESOLVE TO still works, and it is
+ # the only place the modern generation's full suite runs on a schedule.
+ #
+ # Both legs run the whole suite. `tests/conftest.py` gates collection on the
+ # installed `mcp` major, so each leg selects its own subset — there is no
+ # per-leg test path to keep in sync here.
+ test-dependency-groups:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - generation: legacy
+ group: mcp-legacy
+ excluded: mcp-modern
+ - generation: modern
+ group: mcp-modern
+ excluded: mcp-legacy
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+ with:
+ version: "latest"
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install the ${{ matrix.generation }} generation
+ run: |
+ uv sync --extra community \
+ --no-group ${{ matrix.excluded }} --group ${{ matrix.group }}
+
+ - name: Show installed versions
+ run: |
+ uv pip show mcp | grep "Version:" || echo "MCP not found"
+ uv pip show fastmcp | grep "Version:" || echo "FastMCP not found"
+
+ - name: Run the full suite (${{ matrix.generation }})
+ run: |
+ set -o pipefail
+ uv run --no-sync pytest -q 2>&1 | tee pytest.log
+
+ - name: Collect failure details
+ if: failure()
+ env:
+ PACKAGE_UNDER_TEST: dependency-group
+ PACKAGE_VERSION: ${{ matrix.group }}
+ run: |
+ python .github/scripts/collect_failure.py pytest.log
+
+ - name: Upload failure artifact
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: compat-failure-group-${{ matrix.group }}
+ path: failure.json
+ retention-days: 7
test-fastmcp-compatibility:
runs-on: ubuntu-latest
@@ -110,27 +161,50 @@ jobs:
with:
python-version: '3.12'
- - name: Update pyproject.toml with FastMCP ${{ matrix.fastmcp-version }}
+ - name: Pin the community extra to FastMCP ${{ matrix.fastmcp-version }}
run: |
- # Update the community extras section with the specific FastMCP version
- sed -i -E 's/"fastmcp[>=]+[^"]+"/\"fastmcp==${{ matrix.fastmcp-version }}\"/' pyproject.toml
-
- # Also update the MCP dependency to be flexible so FastMCP can choose its version
- # Remove the strict MCP version constraint and use >=1.2.0
- sed -i -E 's/"mcp[>=]+[^"]+"/\"mcp>=1.2.0\"/' pyproject.toml
-
- # Show the updated dependencies for verification
- echo "Updated dependencies:"
- grep -A 5 "^dependencies = \[" pyproject.toml
+ # Both edits are ADDRESS-SCOPED to one table. Since Task 1 there are
+ # three `fastmcp` pins and three `mcp` pins in this file — the community
+ # extra plus the `mcp-legacy` / `mcp-modern` dependency groups — and an
+ # unscoped `s/"fastmcp[>=]+[^"]+"/.../` rewrites all of them. That
+ # breaks resolution outright: the group pins are what make the two
+ # generations coherent, and `fastmcp-slim` (4.x's runtime package, and
+ # not matched by that pattern) would be left demanding 4.x while
+ # `fastmcp` was pinned to 3.x.
+ #
+ # This job installs no dependency group at all (see the install step),
+ # so the groups are left exactly as committed and only the two tables
+ # that actually drive this resolution are touched.
+ sed -i -E '/^community = \[/,/^\]/ s/"fastmcp[^"]*"/"fastmcp==${{ matrix.fastmcp-version }}"/' pyproject.toml
+
+ # Widen the base `mcp` floor so FastMCP picks whichever generation it
+ # needs. The `<3` ceiling stays: outside it we have no adapter.
+ sed -i -E '/^dependencies = \[/,/^\]/ s/"mcp[<>=!~][^"]*"/"mcp>=1.2.0,<3"/' pyproject.toml
+
+ echo "Updated [project] dependencies:"
+ sed -n '/^dependencies = \[/,/^\]/p' pyproject.toml
+ echo ""
+ echo "Updated community extra:"
+ sed -n '/^community = \[/,/^\]/p' pyproject.toml
echo ""
- echo "Updated community extras:"
- grep -A 2 "^\[project.optional-dependencies\]" pyproject.toml | grep -A 2 "community"
+ echo "Dependency groups (must be UNCHANGED):"
+ sed -n '/^\[dependency-groups\]/,/^\[/p' pyproject.toml
- name: Install dependencies with FastMCP ${{ matrix.fastmcp-version }}
run: |
- # Install with both dev and community extras
- # Let FastMCP determine which MCP version it needs
- uv sync --extra dev --extra community
+ # `uv pip install`, NOT `uv sync`. `uv sync` locks before it installs,
+ # and locking is universal: it has to satisfy every declared dependency
+ # group even for groups it will not install, so `--no-default-groups`
+ # does not save it. Measured — pinning `fastmcp==3.4.5` and syncing
+ # fails with "agentcat:mcp-modern depends on fastmcp>=4.0.0b1,<5 and
+ # agentcat[community] depends on fastmcp==3.4.5 ... unsatisfiable".
+ #
+ # A pip-style install reads only `[project]` and its extras. PEP 735
+ # groups are opt-in there, so the pin above is the only thing choosing
+ # a FastMCP and FastMCP is the only thing choosing an mcp — which is
+ # exactly this job's premise.
+ uv venv --python 3.12
+ uv pip install -e ".[dev,community]"
- name: Show installed versions
run: |
@@ -142,7 +216,9 @@ jobs:
run: |
echo "Running community FastMCP tests with version ${{ matrix.fastmcp-version }}"
set -o pipefail
- uv run pytest -v 2>&1 | tee pytest.log
+ # `--no-sync`: plain `uv run` would re-sync the env from the lockfile
+ # and undo the pinned install above.
+ uv run --no-sync pytest -v 2>&1 | tee pytest.log
- name: Collect failure details
if: failure()
@@ -183,24 +259,21 @@ jobs:
- name: Install dependencies with MCP ${{ matrix.mcp-version }} (no FastMCP)
run: |
- # Create virtual environment
- uv venv
+ uv venv --python 3.12
- # Install base dependencies first
- uv pip install pydantic>=2.0.0 requests>=2.31.0 agentcat-api==1.0.0
+ # The project's own declared deps, rather than a hand-copied list that
+ # silently goes stale every time `[project] dependencies` moves. The
+ # `community` extra is deliberately omitted: this job's whole point is
+ # that agentcat works on official mcp alone.
+ uv pip install -e ".[dev]"
- # Install the specific MCP version
+ # Then force the exact generation under test, overriding whatever the
+ # `>=1.2.0,<3` floor resolved to a moment ago.
uv pip install "mcp==${{ matrix.mcp-version }}"
- # Install dev dependencies for testing
- uv pip install pytest>=7.0.0 pytest-asyncio>=0.21.0 mypy>=1.0.0 ruff>=0.1.0 freezegun>=1.2.0
-
- # Explicitly uninstall fastmcp if it somehow got installed
+ # Belt and braces: nothing above should pull fastmcp in.
uv pip uninstall fastmcp -y 2>/dev/null || true
- # Install the package itself in editable mode
- uv pip install -e .
-
- name: Show installed MCP version
run: |
echo "Installed MCP version:"
@@ -218,9 +291,12 @@ jobs:
- name: Run tests without community FastMCP
run: |
echo "Running tests with MCP ${{ matrix.mcp-version }} (no community FastMCP)"
- # Run all tests except community tests
+ # `conftest.py` gates collection on the installed mcp major, so the
+ # right subset is selected for whichever generation this leg pinned.
+ # `--no-sync` so `uv run` cannot re-resolve from the lockfile and undo
+ # both the pin and the fastmcp-free premise this job just verified.
set -o pipefail
- uv run pytest -v 2>&1 | tee pytest.log
+ uv run --no-sync pytest -v 2>&1 | tee pytest.log
- name: Collect failure details
if: failure()
@@ -240,7 +316,7 @@ jobs:
send-failure-notification:
runs-on: ubuntu-latest
- needs: [discover-mcp-versions, discover-fastmcp-versions, test-fastmcp-compatibility, test-without-fastmcp]
+ needs: [discover-mcp-versions, discover-fastmcp-versions, test-dependency-groups, test-fastmcp-compatibility, test-without-fastmcp]
if: failure()
steps:
@@ -482,7 +558,7 @@ jobs:
report-compatibility:
runs-on: ubuntu-latest
- needs: [discover-mcp-versions, discover-fastmcp-versions, test-fastmcp-compatibility, test-without-fastmcp, send-failure-notification]
+ needs: [discover-mcp-versions, discover-fastmcp-versions, test-dependency-groups, test-fastmcp-compatibility, test-without-fastmcp, send-failure-notification]
if: always()
steps:
@@ -498,30 +574,53 @@ jobs:
echo ""
echo "Generated on: $(date)"
echo ""
+ echo "📋 SHIPPED DEPENDENCY GROUPS (the configuration customers get)"
+ echo "─────────────────────────────────────────"
+ echo "Legs: mcp-legacy (mcp 1.x + fastmcp 3.x), mcp-modern (mcp 2.x + fastmcp 4.x)"
+ echo "Tests run: the full suite on each leg; tests/conftest.py gates"
+ echo " collection on the installed mcp major, so each leg"
+ echo " automatically selects its own era's subset."
+ echo ""
echo "📋 OFFICIAL MCP COMPATIBILITY (without community FastMCP)"
echo "─────────────────────────────────────────"
+ echo "Range swept: ${MCP_RANGE}"
echo "MCP versions tested: ${{ needs.discover-mcp-versions.outputs.mcp-versions }}"
- echo "Tests run: All tests except community FastMCP tests"
+ echo "Tests run: the full suite, minus whatever conftest gates out"
echo "Python version: 3.12"
echo ""
echo "📋 COMMUNITY FASTMCP COMPATIBILITY TESTING"
echo "─────────────────────────────────────────"
+ echo "Range swept: ${FASTMCP_RANGE} (2.x is unsupported by agentcat>=2)"
echo "FastMCP versions tested: ${{ needs.discover-fastmcp-versions.outputs.fastmcp-versions }}"
echo "MCP version: Determined by each FastMCP version's requirements"
- echo "Tests run: Community FastMCP tests only"
echo "Python version: 3.12"
echo ""
- echo "🧪 TEST COVERAGE"
+ echo "🧪 SERVER SHAPES COVERED"
+ echo "─────────────────────────────────────────"
+ echo "✓ Official mcp 1.x — low-level Server and mcp.server.fastmcp.FastMCP"
+ echo "✓ Official mcp 2.x — low-level server and MCPServer"
+ echo "✓ Community FastMCP 3.x and 4.x (the agentcat[community] extra)"
+ echo "✓ Community FastMCP 2.x — refused and returned untracked, by design"
+ echo "✓ Package works without community FastMCP (official MCP alone)"
+ echo ""
+ echo "📉 WHAT DEGRADES ON THE OLDEST LEGS (AgentCat still runs)"
echo "─────────────────────────────────────────"
- echo "✓ Official FastMCP (mcp.server.fastmcp) compatibility"
- echo "✓ Community FastMCP (fastmcp package) compatibility - optional dependency"
- echo "✓ Low-level Server compatibility"
- echo "✓ All implementations tested with is_compatible_server function"
- echo "✓ Package works without community FastMCP (using only official MCP)"
+ echo "mcp < 1.10 no Server._make_error_result, so a bare lowlevel"
+ echo " handler's exception type/frames cannot be recovered;"
+ echo " the surfaced message is still published. No declared"
+ echo " structured tool output, so no structured mint-back."
+ echo "mcp < 1.9.2 no RequestContext.request: no header/requestInfo capture."
+ echo "mcp < 1.8 no Streamable HTTP transport at all."
+ echo "mcp < 1.3 messages are handled serially, so nothing overlaps."
+ echo "fastmcp<3.4 no ToolResult.is_error: a proxied upstream error"
+ echo " arrives as a raised ToolError instead of a result."
echo ""
echo "📦 INSTALLATION OPTIONS"
echo "─────────────────────────────────────────"
- echo "• pip install agentcat - Official MCP only (includes mcp.server.fastmcp)"
+ echo "• pip install agentcat - Official MCP only. Resolves the"
+ echo " newest mcp in >=1.2.0,<3, i.e. 2.x,"
+ echo " which has NO mcp.server.fastmcp;"
+ echo " pin 'mcp<2' if you need that module."
echo "• pip install agentcat[community] - Adds community FastMCP support"
echo ""
echo "📊 RESULTS"
diff --git a/.github/workflows/mcp-prerelease-compatibility.yml b/.github/workflows/mcp-prerelease-compatibility.yml
index 3919561..d55bb60 100644
--- a/.github/workflows/mcp-prerelease-compatibility.yml
+++ b/.github/workflows/mcp-prerelease-compatibility.yml
@@ -11,58 +11,65 @@ on:
required: false
type: string
+# The prerelease CHANNELS this workflow watches — the generation after each
+# package's current stable line, which is where a breaking change lands first.
+#
+# The `a0` floors are load-bearing, not decoration. PEP 440 orders
+# `4.0.0b1 < 4.0.0`, so a bound of `>=4.0` excludes every 4.0.0 prerelease and
+# this workflow would find nothing while reporting success. `>=4.0.0a0` is the
+# lowest bound that admits the whole prerelease series.
+#
+# Discovery emits nothing once a stable release supersedes the channel (see
+# `discover_versions.py --pre`), and the jobs below are already gated on
+# `has-prereleases`, so a generation that ships simply stops being tested here
+# instead of pinning CI to a version that no longer matters. Move these floors
+# forward when the NEXT generation opens.
+env:
+ MCP_PRERELEASE_RANGE: ">=2.0.0a0,<3"
+ FASTMCP_PRERELEASE_RANGE: ">=4.0.0a0,<5"
+
jobs:
discover-mcp-prereleases:
runs-on: ubuntu-latest
outputs:
mcp-prereleases: ${{ steps.get-mcp-prereleases.outputs.versions }}
- has-prereleases: ${{ steps.get-mcp-prereleases.outputs.has-prereleases }}
+ has-prereleases: ${{ steps.get-mcp-prereleases.outputs.has-versions }}
steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
- name: Get available MCP prerelease versions
id: get-mcp-prereleases
run: |
- # Get all available versions from PyPI including prereleases
- all_versions=$(pip index versions mcp --pre 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+[ab][0-9]\+\|[0-9]\+\.[0-9]\+\.[0-9]\+rc[0-9]\+' | sort -V || echo "")
-
- if [ -z "$all_versions" ]; then
- echo "No MCP prereleases found"
- echo "versions=[]" >> $GITHUB_OUTPUT
- echo "has-prereleases=false" >> $GITHUB_OUTPUT
- else
- # Get only the latest prerelease
- latest=$(echo "$all_versions" | tail -n 1)
- json_array="[\"$latest\"]"
- echo "Found MCP prerelease: $json_array"
- echo "versions=$json_array" >> $GITHUB_OUTPUT
- echo "has-prereleases=true" >> $GITHUB_OUTPUT
- fi
+ python -m pip install --quiet --disable-pip-version-check packaging
+ python .github/scripts/discover_versions.py --pre \
+ mcp "$MCP_PRERELEASE_RANGE" >> "$GITHUB_OUTPUT"
discover-fastmcp-prereleases:
runs-on: ubuntu-latest
outputs:
fastmcp-prereleases: ${{ steps.get-fastmcp-prereleases.outputs.versions }}
- has-prereleases: ${{ steps.get-fastmcp-prereleases.outputs.has-prereleases }}
+ has-prereleases: ${{ steps.get-fastmcp-prereleases.outputs.has-versions }}
steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
- name: Get available FastMCP prerelease versions
id: get-fastmcp-prereleases
run: |
- # Get all available versions from PyPI including prereleases
- all_versions=$(pip index versions fastmcp --pre 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+[ab][0-9]\+\|[0-9]\+\.[0-9]\+\.[0-9]\+rc[0-9]\+' | sort -V || echo "")
-
- if [ -z "$all_versions" ]; then
- echo "No FastMCP prereleases found"
- echo "versions=[]" >> $GITHUB_OUTPUT
- echo "has-prereleases=false" >> $GITHUB_OUTPUT
- else
- # Get only the latest prerelease
- latest=$(echo "$all_versions" | tail -n 1)
- json_array="[\"$latest\"]"
- echo "Found FastMCP prerelease: $json_array"
- echo "versions=$json_array" >> $GITHUB_OUTPUT
- echo "has-prereleases=true" >> $GITHUB_OUTPUT
- fi
+ python -m pip install --quiet --disable-pip-version-check packaging
+ python .github/scripts/discover_versions.py --pre \
+ fastmcp "$FASTMCP_PRERELEASE_RANGE" >> "$GITHUB_OUTPUT"
test-fastmcp-prerelease:
runs-on: ubuntu-latest
@@ -86,31 +93,41 @@ jobs:
with:
python-version: '3.12'
- - name: Update pyproject.toml with prerelease FastMCP ${{ matrix.fastmcp-version }}
+ - name: Pin the community extra to FastMCP ${{ matrix.fastmcp-version }}
run: |
- # Update the community extras section with the specific FastMCP prerelease version
- sed -i -E 's/"fastmcp[>=]+[^"]+"/\"fastmcp==${{ matrix.fastmcp-version }}\"/' pyproject.toml
-
- # Keep MCP flexible so FastMCP can choose its version
- sed -i -E 's/"mcp[>=]+[^"]+"/\"mcp>=1.2.0\"/' pyproject.toml
-
- # Show the updated dependencies for verification
- echo "Updated dependencies:"
- grep -A 5 "^dependencies = \[" pyproject.toml
+ # Address-scoped to one table each. Since Task 1 there are three
+ # `fastmcp` pins and three `mcp` pins in this file — the community
+ # extra plus the `mcp-legacy` / `mcp-modern` dependency groups — and an
+ # unscoped substitution rewrites all of them, leaving `fastmcp-slim`
+ # (4.x's runtime package, which the old pattern did not match) pinned
+ # to a generation the rewritten `fastmcp` no longer agrees with.
+ sed -i -E '/^community = \[/,/^\]/ s/"fastmcp[^"]*"/"fastmcp==${{ matrix.fastmcp-version }}"/' pyproject.toml
+
+ # Widen the base `mcp` floor so the prerelease FastMCP picks whichever
+ # generation it needs; the `<3` ceiling stays.
+ sed -i -E '/^dependencies = \[/,/^\]/ s/"mcp[<>=!~][^"]*"/"mcp>=1.2.0,<3"/' pyproject.toml
+
+ echo "Updated [project] dependencies:"
+ sed -n '/^dependencies = \[/,/^\]/p' pyproject.toml
echo ""
- echo "Updated community extras:"
- grep -A 2 "^\[project.optional-dependencies\]" pyproject.toml | grep -A 2 "community"
+ echo "Updated community extra:"
+ sed -n '/^community = \[/,/^\]/p' pyproject.toml
- name: Install dependencies with prerelease FastMCP ${{ matrix.fastmcp-version }}
run: |
- # Install with both dev and community extras, allow prereleases.
- # FastMCP will determine which MCP version it needs.
- # Use --prerelease=allow (not if-necessary-or-explicit): FastMCP 3.4+
+ # `uv pip install`, NOT `uv sync`: syncing locks first, and locking is
+ # universal — it must satisfy every declared dependency group, so the
+ # committed `mcp-modern` pin collides with whatever this leg pinned and
+ # resolution fails before a single test runs. A pip-style install reads
+ # only `[project]` and its extras (PEP 735 groups are opt-in there).
+ #
+ # `--prerelease=allow`, not if-necessary-or-explicit: FastMCP 3.4+
# splits into fastmcp + a transitive fastmcp-slim package. Pinning a
# prerelease fastmcp pulls a matching prerelease fastmcp-slim that is
# not named in our pyproject, so if-necessary-or-explicit refuses it
# and resolution fails. "allow" is correct for a prerelease-testing job.
- uv sync --extra dev --extra community --prerelease allow
+ uv venv --python 3.12
+ uv pip install -e ".[dev,community]" --prerelease allow
- name: Show installed versions
run: |
@@ -125,7 +142,9 @@ jobs:
run: |
echo "Running community FastMCP tests with prerelease version ${{ matrix.fastmcp-version }}"
set -o pipefail
- uv run pytest -v 2>&1 | tee pytest.log
+ # `--no-sync`: plain `uv run` re-resolves from the lockfile and would
+ # undo the pinned prerelease install above.
+ uv run --no-sync pytest -v 2>&1 | tee pytest.log
- name: Collect failure details
if: failure()
@@ -167,24 +186,19 @@ jobs:
- name: Install dependencies with prerelease MCP ${{ matrix.mcp-version }} (no FastMCP)
run: |
- # Create virtual environment
- uv venv
+ uv venv --python 3.12
- # Install base dependencies first
- uv pip install pydantic>=2.0.0 requests>=2.31.0 agentcat-api==1.0.0
+ # The project's own declared deps rather than a hand-copied list that
+ # goes stale whenever `[project] dependencies` moves. No `community`
+ # extra: this job proves agentcat works on official mcp alone.
+ uv pip install -e ".[dev]"
- # Install the specific MCP prerelease version
+ # Then force the exact prerelease under test.
uv pip install "mcp==${{ matrix.mcp-version }}" --prerelease=allow
- # Install dev dependencies for testing
- uv pip install pytest>=7.0.0 pytest-asyncio>=0.21.0 mypy>=1.0.0 ruff>=0.1.0 freezegun>=1.2.0
-
- # Explicitly uninstall fastmcp if it somehow got installed
+ # Belt and braces: nothing above should pull fastmcp in.
uv pip uninstall fastmcp -y 2>/dev/null || true
- # Install the package itself in editable mode
- uv pip install -e .
-
- name: Verify FastMCP is not installed
run: |
if uv pip show fastmcp 2>/dev/null; then
@@ -207,7 +221,9 @@ jobs:
echo "Running tests with prerelease MCP version ${{ matrix.mcp-version }} (no FastMCP)"
# Run all tests except community tests
set -o pipefail
- uv run pytest -v 2>&1 | tee pytest.log
+ # `--no-sync`: plain `uv run` re-resolves from the lockfile and would
+ # undo the pinned prerelease install above.
+ uv run --no-sync pytest -v 2>&1 | tee pytest.log
- name: Collect failure details
if: failure()
diff --git a/.gitignore b/.gitignore
index b8bbe6f..cb44375 100644
--- a/.gitignore
+++ b/.gitignore
@@ -149,3 +149,4 @@ CLAUDE.md
.claude/
.serena/
AGENTS.md
+docs/superpowers/
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..d957aea
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,36 @@
+{
+ "mcpServers": {
+ "officialsdk-factory": {
+ "type": "http",
+ "url": "http://localhost:8090/mcp"
+ },
+ "officialsdk-basic": {
+ "type": "http",
+ "url": "http://localhost:8091/mcp"
+ },
+ "officialsdk-advanced": {
+ "type": "http",
+ "url": "http://localhost:8092/mcp"
+ },
+ "officialsdk-legacy": {
+ "type": "http",
+ "url": "http://localhost:8093/mcp"
+ },
+ "fastmcp-basic": {
+ "type": "http",
+ "url": "http://localhost:8094/mcp"
+ },
+ "fastmcp-advanced": {
+ "type": "http",
+ "url": "http://localhost:8095/mcp"
+ },
+ "fastmcp-v3": {
+ "type": "http",
+ "url": "http://localhost:8096/mcp"
+ },
+ "agentcat-mcp": {
+ "type": "http",
+ "url": "https://mcp.agentcat.com/mcp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/2026-07-28-cross-sdk-changelog.md b/2026-07-28-cross-sdk-changelog.md
new file mode 100644
index 0000000..f15dfe0
--- /dev/null
+++ b/2026-07-28-cross-sdk-changelog.md
@@ -0,0 +1,495 @@
+# AgentCat × MCP 2026-07-28 — Cross-SDK Changelog & Implementation Brief
+
+**Audience:** maintainers of the AgentCat Python and Go SDKs.
+**Reference implementation:** `agentcat` (TypeScript) `2.0.0-beta.4`, branch `feat/explicit-handles-v2`.
+**Spec context:** [MCP 2026-07-28 release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) (SEP-2567).
+
+This document is language-agnostic: it describes the *behavior* every AgentCat SDK
+must converge on, with the TypeScript implementation as the worked example. Where
+exact bytes matter (agent-facing copy, tag names, wire keys, ID derivation), they
+are specified verbatim in the appendices — **do not reword or re-derive them.**
+
+---
+
+## 1. What the protocol changed and why we care
+
+MCP 2026-07-28 makes the protocol **stateless by default**:
+
+- The `initialize`/`initialized` handshake and the `Mcp-Session-Id` header are
+ gone. There is no protocol-level session to attach analytics to.
+- Every request is self-describing: client identity and protocol version travel
+ per-request under reserved `io.modelcontextprotocol/*` metadata keys.
+- Servers are commonly built from **per-request factories** (a fresh server
+ object per HTTP request), landing behind round-robin load balancers with no
+ shared storage. Any instance may serve `tools/call` without ever having
+ served `tools/list`.
+- **Multi Round-Trip Requests (MRTR)** replace held-open elicitation streams: a
+ single logical tool call can span several HTTP rounds (`input_required`
+ intermediate results, then a continuation carrying the client's input
+ responses).
+- Tasks moved to the formal `io.modelcontextprotocol/tasks` extension
+ (poll-based). *Note: AgentCat's `task_id` handle is unrelated to this
+ extension — see §7 Non-goals.*
+
+Everything AgentCat previously leaned on for correlation — the initialize
+handshake, transport session IDs, inactivity-based session rollover, cached
+client identity — is either gone or unreliable in this world. The 2.0 response
+replaces all of it with **explicit, stateless, per-request mechanisms**.
+
+---
+
+## 2. TL;DR — old model vs. new model
+
+| Concern | Pre-2026 (1.x) | 2026-era (2.0) |
+| --- | --- | --- |
+| Correlation | Transport `sessionId` + inactivity rollover | Explicit `task_id` handle, echoed by the agent as a tool parameter |
+| Session events | `mcp:initialize`, `mcp:tools/list`, `agentcat:identify` published | **Removed.** Only tool-call and custom events remain |
+| Actor identity | `identify` hook + per-session identity cache | `identify` runs on **every** tool call; result stamped on that event; **no cache** |
+| Client name/version | Captured once at initialize, cached | Resolved **per request** from the envelope/`_meta`, stamped on every event |
+| Agent identity | (none) | Opt-in self-chosen `agent_id`, carried as event tags |
+| Server state | Per-session state | **Stateless resolution** — nothing stored between requests; per-server state only in weak/ephemeral maps keyed by the server object |
+
+Backward compatibility on the wire: **the task ID is stored in the existing
+`sessionId` event field with the existing `ses_` prefix.** Dashboards, queries,
+the ingestion API, and exporters are unaffected. No backend changes required.
+
+---
+
+## 3. Task handles replace session maintenance
+
+### 3.1 What is removed
+
+- All transport-session logic: reading `extra.sessionId` (or your language's
+ equivalent), session caches, inactivity-based session rollover/timeouts.
+ `extra.sessionId` is **ignored entirely**, even when the transport still
+ provides one.
+- The `mcp:initialize` and `mcp:tools/list` event types are no longer
+ published. (`tools/list` is still *intercepted* — for schema injection — it
+ just doesn't emit an event.)
+- The `agentcat:identify` event type and the identity cache are gone. Actor
+ fields ride on every event instead.
+
+### 3.2 The `task_id` handle
+
+- One `task_id` covers one goal, start to finish. Subagents share their
+ parent's `task_id`.
+- Format: a KSUID with the `ses` prefix (e.g. `ses_2a4F...`), deliberately
+ reusing the session prefix and the `Event.sessionId` field.
+- Injected into **every** tool's input schema as an **optional** string
+ parameter named `task_id` — including AgentCat's own `get_more_tools` tool
+ (its calls publish events, so it must be able to carry handles). It is never
+ added to the schema's `required` list: **omission is the minting signal.**
+
+### 3.3 Per-call resolution algorithm (prompted mode — the default)
+
+For each `tools/call`, statelessly:
+
+```
+supplied = args["task_id"] if it is a string whose trimmed value is non-empty
+if supplied:
+ task_id = supplied # trusted VERBATIM — no shape validation
+ task_source = "supplied"
+else:
+ task_id = new ses_ KSUID # random mint
+ task_source = "minted"
+```
+
+Nothing is stored on the server between requests, so concurrent requests can
+never clobber each other. A supplied value is trusted verbatim (agents echo
+what we minted; a strict format check would sever tasks over cosmetic
+differences).
+
+### 3.4 Mint-back: telling the agent its handle
+
+Two delivery channels, both applied to the wire response only:
+
+**(a) Trailing text content block** — appended **only on the call that minted
+a new task** (i.e. `task_source == "minted"`, and never in hook mode):
+
+```
+[MCP INSTRUCTIONS]: task_id issued.
+ task_id= — required on every subsequent tool call
+Without task_id, this server does not function as intended.
+```
+
+Append it to error results too (`isError: true`) — the retry after an error
+must carry the same task. Only requirement to append: the result has an array
+`content`. Never mutate the customer's result object — copy.
+
+**(b) Structured mirror in `structuredContent`** — unlike (a), this is
+persistent handle state present on **every** response (supplied handles are
+re-confirmed so an agent can re-read its own handles mid-conversation). The
+SDK adds a `_mcp_instructions` object:
+
+```jsonc
+"_mcp_instructions": {
+ "task_id": "ses_...", // omitted in hook mode
+ "agent_id": "opus-...|...", // only when the agent supplied one
+ "instructions": ""
+}
+```
+
+Rules:
+- Never name a handle the agent cannot echo: no `task_id` key in hook mode, no
+ `agent_id` key when the agent didn't supply one. If neither is present,
+ mirror nothing.
+- Only mirror into a **plain-object** `structuredContent`. If the customer's
+ result already contains a `_mcp_instructions` key, it is customer data — it
+ wins, skip the mirror.
+- Gate the mirror on the output-injection registry (§5.3): only mirror for
+ tools whose declared `outputSchema` we successfully extended. Exception: if
+ no registry exists at all (rebuild failed), mirror anyway — the client
+ cannot be validating against a schema we know about.
+
+**Why the schema declaration matters:** 2026-era clients ajv/schema-validate
+`structuredContent` against the tool's advertised `outputSchema`, and common
+schema generators emit `additionalProperties: false`. An undeclared extra key
+would fail the customer's *entire* result. So for every tool that declares a
+plain-object `outputSchema`, inject an **optional** `_mcp_instructions`
+property (object, with the sub-property descriptions from Appendix A) at
+list-time. Composed schemas (`oneOf`/`allOf`/`anyOf`) have no single
+properties bag — skip them (log a warning; mint-back stays content-only for
+that tool).
+
+**Mint-back is wire-only.** The recorded event's `response` is the customer's
+original result — no `[MCP INSTRUCTIONS]` block, no `_mcp_instructions` field.
+Same for error messages.
+
+### 3.5 Hook mode — customers who bring their own correlation IDs
+
+New option `resolveTaskId(request, extra) -> string | null` (name it
+idiomatically per language). When configured:
+
+- **No `task_id` parameter is injected anywhere** and no task instructions are
+ ever shown to the agent. The customer owns task state.
+- The returned string is combined with the project ID and **deterministically
+ derived** into a `ses_` KSUID (§3.6), so the same customer ID always maps to
+ the same task across processes, restarts — and, once you implement this,
+ across *languages*. `task_source = "hook"`.
+- A nullish return or a thrown error mints a random task silently
+ (`task_source = "minted"`, still no agent-facing instructions — the agent
+ has no parameter to echo, so announcing an ID it can never send would be
+ noise). Log the hook error. A configured hook should answer every request.
+
+### 3.6 Deterministic derivation — MUST match across SDKs
+
+```
+input = project_id ? f"{customer_id}:{project_id}" : customer_id
+hash = SHA-256(input) # 32 bytes
+ts_ms = 1704067200000 # 2024-01-01T00:00:00Z, fixed epoch
+ + (uint32_be(hash[0:4]) % 31536000000) # offset < 365 days, keeps KSUID valid
+payload = hash[4:20] # 16 bytes
+ksuid = KSUID(timestamp = floor((ts_ms - 1400000000000) / 1000) as uint32-BE,
+ payload = payload) # standard 20-byte KSUID, base62 → 27 chars
+result = "ses_" + base62(ksuid)
+```
+
+(`1400000000000` is the standard KSUID epoch, 2017-05-13T16:53:20Z. The
+customer ID is trimmed before hashing.) Add a cross-language golden-vector
+test: pick a few `(customer_id, project_id)` pairs and assert the exact
+`ses_…` output matches the TypeScript SDK.
+
+### 3.7 Custom events
+
+`publishCustomEvent` no longer derives a task from its session-id string
+argument — the string is used **verbatim** as the task ID. Prefer an explicit
+`task_id` field on the custom-event data (TS: `CustomEventData.taskId`), which
+takes precedence. The tracked-server form publishes **without** a task unless
+one is explicitly given.
+
+---
+
+## 4. Optional self-chosen `agent_id`
+
+Distinguishes parallel agents working the same task. **Off by default** —
+opt-in via `enableAgentTracking: true` (breaking-change posture: quiet by
+default).
+
+- Injected into every tool's input schema as a string parameter named
+ `agent_id`, **marked `required` in the schema**.
+- The value is **self-chosen by the agent** — there is no server-side agent
+ minting (an earlier design minted `agt_` KSUIDs server-side; it was removed,
+ the `agt` prefix stays reserved). The parameter description instructs the
+ agent to generate `model|harness|nonce`, e.g. `opus-4.80-1m|claude-code|k3n9x`,
+ and to keep it stable for the whole task. Subagents MUST generate their own
+ (opposite of `task_id`, which subagents share).
+- **Enforcement is client-side, soft server-side.** A strict schema-validating
+ MCP client refuses to send a call omitting a required parameter — that is
+ the actual enforcement mechanism. Server-side, an omitted `agent_id` NEVER
+ rejects the call: the event is simply published without agent identity, and
+ no mint-back/echo mentions `agent_id`.
+- Extraction mirrors `task_id`: trimmed non-empty string, trusted verbatim,
+ else treated as omitted.
+- Carried on events **as tags**, not as a first-class event field:
+ - `agentcat_agent_id` — the supplied value, clamped for the tag channel:
+ CR/LF replaced with spaces, truncated to 200 chars. (The tag channel
+ bypasses customer tag validation/redaction/truncation, hence the clamp.
+ The un-clamped value still appears in the recorded raw request.)
+ - `agentcat_agent_id_source` — always `"supplied"` today.
+- Echoed back in `_mcp_instructions.agent_id` (with the "keep sending this
+ exact value" instructions) only when supplied. Never announced in the text
+ mint-back block.
+- Works in hook mode too: `enableAgentTracking` and `resolveTaskId` compose.
+ In hook mode use the hook-mode variant of the parameter description
+ (Appendix A) — it must not reference a `task_id` parameter the agent cannot
+ see.
+
+---
+
+## 5. Per-request client identity, protocol version, and actor identity
+
+### 5.1 Client name/version — resolved on every request, never cached
+
+Resolution ladder (first hit wins):
+
+1. **2026 envelope** — `extra.mcpReq.envelope["io.modelcontextprotocol/clientInfo"]`
+ (or your SDK's equivalent). 2026-era server SDKs lift the reserved
+ `io.modelcontextprotocol/*` keys out of `_meta` before dispatch and expose
+ them under their **fully-qualified** names on the request envelope; the
+ envelope is the only place they exist there.
+2. **`_meta` passthrough** — `request.params._meta["io.modelcontextprotocol/clientInfo"]`
+ (pre-2026 server SDKs pass the key through untouched).
+3. **Legacy initialize capture** — the server's cached client info from the
+ old handshake (`getClientVersion()` in TS). Keeps identity working for
+ pre-2026 clients; absent on 2026-pinned stdio.
+
+Narrow defensively: accept `name`/`version` only if each is individually a
+string; a non-string field must not reach the event payload.
+
+The resolved `clientName`/`clientVersion` are stamped **directly on every
+event** at publish time, alongside server name/version, SDK language, and
+AgentCat SDK version. There is no cache and no session-info object shared
+between requests.
+
+### 5.2 Protocol version
+
+Same ladder (envelope → `_meta`) for
+`io.modelcontextprotocol/protocolVersion`. When present, stamp it as the
+`agentcat_protocol_version` tag on the event. This gives the platform
+fleet-level visibility into protocol adoption.
+
+### 5.3 Actor identity
+
+The `identify` hook now runs on **every tool call**, and its result is stamped
+directly onto that call's event (`identifyActorGivenId` / `identifyActorName`
+/ `identifyActorData`). There is no identity cache and no separate identify
+event. Hooks should be cheap; document that for customers.
+
+**Divergence — Python awaits `identify`, TypeScript's status unverified.** The
+Python SDK originally shipped `identify` as the only one of the five customer
+hooks that was never awaited, so an `async def` hook built a coroutine, ran none
+of its body, and published the call anonymously with no error the customer could
+see. Python now resolves all five hooks through one contract
+(`src/agentcat/modules/hooks.py`): sync or async, narrowed on `isawaitable` — so
+an `asyncio.Task` or any `__await__` implementer works too, not just native
+coroutines. A parity sweep should confirm the TypeScript side accepts the same
+range before this row is called settled.
+
+---
+
+## 6. Mechanics you must replicate (the parts nobody puts in the headline)
+
+### 6.1 Schema injection pipeline (at `tools/list` time)
+
+- Parameter order in each tool's schema: **customer params, `task_id`,
+ `agent_id`, `context`** (the intent-capture param — its injector runs after
+ the handle injector).
+- Deep-copy every schema before modifying; never mutate the customer's
+ registered tool in place on the list path.
+- If the input schema has `additionalProperties: false`, remove it (injected
+ params must not fail validation).
+- Skip injection entirely for composed input schemas (`oneOf`/`allOf`/`anyOf`)
+ — log a warning.
+- **Name collisions:** if a tool already defines `task_id` (or `agent_id`),
+ skip injecting that parameter for that tool, log a warning, and — critically
+ — make sure the customer's own parameter **reaches their handler untouched**
+ (it must not be stripped).
+- Record every (tool → params actually injected) pair in an
+ **injected-params registry**, and every tool whose `outputSchema` you
+ extended in an **output-injection registry**. These drive stripping and
+ mirroring.
+- The whole pipeline — config + listed tools in → advertised tools +
+ registries out — must be **pure and deterministic**. §6.3 depends on it.
+
+### 6.2 Argument stripping (at `tools/call` time)
+
+- Before invoking the customer's handler, strip **only** the params the
+ registry says were injected for that tool, on a **cloned** request.
+- The published event records the **raw, unstripped** request (handles and
+ `context` included) — the event shows exactly what the agent sent.
+- Fallback when a call arrives with no registry (see §6.3 for why): strip all
+ three names (`task_id`, `agent_id`, `context`) heuristically — except
+ `get_more_tools`, whose `context` is a real parameter and must survive.
+
+### 6.3 Per-request topology: rebuild-on-demand
+
+2026-era factories create a fresh server per request, so a `tools/call` can
+land on an instance that never served `tools/list`. On the first call, if the
+registries are missing, **rebuild them** by invoking the original (unwrapped)
+`tools/list` handler and running its result through the same pure injection
+pipeline. Because the pipeline is deterministic, the rebuilt registries match
+what any listing instance advertised. Only if the rebuild fails do you fall
+back to the §6.2 heuristic strip.
+
+Related invariants for factory topologies:
+
+- Document `track()`-inside-the-factory as the integration pattern.
+- Module-level state (event queue, telemetry manager, diagnostics) initializes
+ once, first-wins, and is reused across `track()` calls. Per-server state
+ lives in maps that don't outlive the server object (WeakMap in TS; use your
+ language's equivalent or explicit lifecycle).
+- `track()` **never throws** — any failure logs a warning and returns the
+ untracked server.
+
+### 6.4 MRTR (multi round-trip requests) tagging
+
+A 2026-era tool call can return an intermediate result with
+`resultType: "input_required"` and later complete on a continuation round
+carrying the client's input responses.
+
+- **Intermediate round** (`resultType == "input_required"`): tag the event
+ `agentcat_mrtr = "input_required"`, and **do not decorate the result** — no
+ text mint-back, no structured mirror. The completing round carries the
+ mint-back.
+- **Continuation round** (the request envelope carries `inputResponses`): tag
+ the event `agentcat_mrtr = "continuation"`.
+- Each round publishes its own event; they correlate through the shared
+ `task_id` like everything else.
+
+### 6.5 SDK tag namespace
+
+All SDK-owned tags (Appendix B) are merged **after** customer tags (SDK wins
+on collision) and are **exempt from the customer 50-tag cap**. Every event
+gets `agentcat_task_id_source`; the others are conditional.
+
+### 6.6 `get_more_tools`
+
+- **Not** exempt from handle injection (it publishes events), but its bespoke
+ `context` parameter is its own — only `task_id`/`agent_id` are stripped.
+- Carries the read-only MCP tool annotation (`readOnlyHint`).
+- Still answers when tracing is disabled.
+
+### 6.7 Dual-generation SDK support
+
+The TS SDK supports both MCP SDK majors through one `track()` call, using
+**per-object feature detection** (never importing either SDK), per-major
+adapters for the one property that differs (which field holds the dispatched
+function), and a unified interception engine. Mirror the *approach* if your
+language has two coexisting SDK generations: detection by probing the object
+in hand, single-sourced probe list logged as a shape fingerprint (with a
+diagnostics beacon on unrecognized shapes, for fleet-level drift detection),
+and version-specific knowledge confined to tiny adapters.
+
+---
+
+## 7. Non-goals — deliberately NOT part of this design
+
+- **No use of the `io.modelcontextprotocol/tasks` extension** for correlation.
+ AgentCat's `task_id` is an analytics handle echoed as a tool argument; it is
+ unrelated to the poll-based tasks extension despite the name.
+- **No reliance on the `Mcp-Method` / `Mcp-Name` routing headers.**
+- **No server-side session or handle storage of any kind.** Resolution is
+ fully stateless per request.
+- **No server-side minting of `agent_id`** (removed during design — a
+ server-minted agent ID can't survive the agent's own context boundaries,
+ and only the agent knows who it is).
+- **No hard rejection of calls missing `agent_id`.** Required-in-schema +
+ strict clients is the enforcement; the server never breaks a customer's
+ tool over analytics.
+
+---
+
+## 8. Suggested rollout order (mirrors the TS commit history)
+
+1. Handle primitives: minting, deterministic derivation (+ golden vectors),
+ extraction, mint-back text builder, tag builder.
+2. Per-request resolution (prompted + hook modes) and per-request client
+ identity/protocol version.
+3. Schema injection + injected-params registry + safe stripping.
+4. Call-path wiring: resolve → strip → dispatch → mint-back → publish; then
+ remove session machinery, identity caches, and the retired event types.
+5. Structured mint-back: outputSchema declaration + `structuredContent`
+ mirror.
+6. `agent_id`: opt-in flag, required-in-schema, self-chosen copy, tags.
+7. 2026-era topology: rebuild-on-demand, MRTR tagging, factory-pattern docs,
+ concurrency regression tests (parallel calls with distinct handles must
+ not cross-attribute).
+
+---
+
+## Appendix A — Canonical agent-facing copy (byte-identical across SDKs)
+
+This copy was iterated for LLM compliance and is the single source of truth
+(TS: `src/modules/constants.ts`, spec:
+`docs/superpowers/specs/2026-07-29-explicit-handles-copy.md`). Reuse it
+verbatim; fleet-wide consistency of the prompt surface is the point.
+
+**`task_id` parameter description:**
+
+> REQUIRED on every call after your first. This MCP server associates all of your tool calls for a given task using task_id; a call that arrives without it cannot be associated with your earlier work and is treated as the start of an unrelated task, disconnected from the original goal. Omit it on your first call only — the server will issue one in the _mcp_instructions field of the result (or a trailing [MCP INSTRUCTIONS] text block) — then echo that exact value on every later call. Never invent a value, and do not issue parallel tool calls until the server has issued your task_id. One task_id covers the whole goal from start to finish: when you spawn subagents you MUST give them this same task_id, or their work is severed from yours. Without task_id, this server does not function as intended.
+
+**`agent_id` parameter description (default mode):**
+
+> REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent working this task using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended.
+
+**`agent_id` parameter description (hook mode):** identical except the first
+sentence's task framing — it says "distinguishes you from every other agent"
+(no "working this task") so it never references a `task_id` parameter the
+agent cannot see. See `AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE` in the TS
+constants for the exact string.
+
+**Text mint-back block (task minted this call):**
+
+```
+[MCP INSTRUCTIONS]: task_id issued.
+ task_id= — required on every subsequent tool call
+Without task_id, this server does not function as intended.
+```
+
+**Structured-mirror `instructions` when nothing was minted this call:**
+
+```
+[MCP INSTRUCTIONS]: confirmed. Keep sending this exact value on every call.
+```
+
+where `` is `task_id`, `agent_id`, or `task_id and agent_id`, and the
+tail pluralizes to "these exact values" when both are present.
+
+**`_mcp_instructions` outputSchema field descriptions:**
+
+- field: "Your handles for this task, confirmed by this MCP server on every response, and the instructions for echoing them on later calls. Read and follow."
+- `task_id` sub-property: "Echo this exact value as the task_id argument on every subsequent tool call."
+- `agent_id` sub-property: "Your agent_id as this server received it. Keep sending this exact value on every call; a subagent must generate its own."
+
+---
+
+## Appendix B — Wire keys, tags, and ID prefixes
+
+**Reserved metadata keys (read-only, defined by MCP):**
+
+| Key | Purpose |
+| --- | --- |
+| `io.modelcontextprotocol/clientInfo` | Per-request client `{name, version}` |
+| `io.modelcontextprotocol/protocolVersion` | Per-request protocol version |
+
+**Injected parameter names:** `task_id`, `agent_id`, `context` (pre-existing).
+
+**Structured mint-back key:** `_mcp_instructions`.
+
+**SDK-owned event tags (post-customer merge, exempt from the 50-tag cap):**
+
+| Tag | Values |
+| --- | --- |
+| `agentcat_task_id_source` | `supplied` \| `minted` \| `hook` (always present) |
+| `agentcat_agent_id` | the supplied agent_id, newlines→space, max 200 chars |
+| `agentcat_agent_id_source` | `supplied` |
+| `agentcat_protocol_version` | e.g. `2026-07-28` (when the request carries one) |
+| `agentcat_mrtr` | `input_required` \| `continuation` |
+
+**ID prefixes:** `ses_` (tasks — deliberately the session prefix, keeps
+`Event.sessionId` compatible), `evt_` (events), `agt_` (reserved; server-side
+agent minting was removed).
+
+**Event field mapping:** task ID → `Event.sessionId`. Client identity →
+`Event.clientName` / `Event.clientVersion` on every event. Actor →
+`Event.identifyActor*` on every event.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0ad3f50..028d789 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,9 +10,9 @@ Thank you for your interest in contributing to AgentCat! We're excited to have y
git clone https://github.com/YOUR-USERNAME/agentcat-python-sdk.git
cd agentcat-python-sdk
```
-3. **Install dependencies** using uv:
+3. **Install dependencies** using uv (see [Dev environments](#dev-environments) for the legacy variant):
```bash
- uv sync
+ uv sync --extra community
```
4. **Create a branch** for your feature or fix:
```bash
@@ -21,21 +21,63 @@ Thank you for your interest in contributing to AgentCat! We're excited to have y
git checkout -b fix/your-bug-fix
```
+## Dev Environments
+
+AgentCat v2 supports two generations of the MCP ecosystem: the official `mcp` package 1.x and 2.x, and community `fastmcp` 3.x and 4.x. The two `mcp` majors cannot be installed side by side, so development uses two mutually exclusive uv dependency groups, declared as conflicting in `pyproject.toml`:
+
+- `mcp-modern` — `mcp` 2.x + `fastmcp` 4.x (the default group)
+- `mcp-legacy` — `mcp` 1.x + `fastmcp` 3.x
+
+**Modern environment (default):**
+
+```bash
+uv sync --extra community
+```
+
+**Legacy environment** (turn off the default modern group, turn on legacy):
+
+```bash
+uv sync --extra community --no-group mcp-modern --group mcp-legacy
+```
+
+Note that a bare `uv run` re-syncs to the default (modern) environment, which would silently undo a legacy sync. Once you have synced the generation you want, run against it with `--no-sync`:
+
+```bash
+uv run --no-sync pytest
+```
+
+**Both generations are expected to pass.** `tests/conftest.py` reads the installed `mcp` major at collection time and skips the modules that target the other generation, so the same `pytest` invocation selects the right subset in either environment — there is nothing to pass by hand. At the time of writing that is 576 passed / 26 skipped on modern and 732 passed / 10 skipped on legacy. CI runs both legs in the `test-dependency-groups` job of `mcp-compatibility.yml`, so a change that only works in the generation you developed in will be caught there.
+
## Development Process
### Making Changes
1. **Write your code** following our Python standards
2. **Add tests** for new features (required for feature additions)
-3. **Run the test suite** to ensure everything passes:
+3. **Run the test suite** to ensure everything passes, in the generation you
+ synced:
```bash
- uv run pytest
+ uv run --no-sync pytest
```
+ `--no-sync`: a bare `uv run` re-syncs to the default (modern) groups and
+ silently undoes a legacy sync. Both generations are expected to pass — see
+ [Dev Environments](#dev-environments).
4. **Check your code** meets our standards:
```bash
- uv run ruff check . # Run linting checks
- uv run ruff format . # Format code
+ uvx ruff check . # lint the whole repo
+ git diff --name-only main -- '*.py' | xargs -r uvx ruff format --check
```
+ `uvx`, not `uv run` — see [Code Quality](#code-quality) for why. And
+ `--check`, scoped to the files you touched: 46 files in this repo are
+ unformatted, so a bare `uvx ruff format .` rewrites all of them and buries
+ your change in an unrelated 46-file diff. Reformatting debt you did not
+ create is welcome as [its own PR](#the-linttype-debt-and-the-ratchet).
+
+ **`xargs -r`, not `$(…)`.** With no path arguments `ruff format` defaults to
+ `.` — so on a branch that has touched no Python (a docs change, or before
+ you have made any), the substitution expands to nothing and the command
+ becomes exactly the whole-repo rewrite this step exists to avoid. `-r` skips
+ the run instead.
### Commit Conventions
@@ -97,29 +139,98 @@ Looking for a place to start? Check out issues labeled [`good first issue`](http
## Testing
- New features **should include tests** to ensure reliability
-- Run tests locally with `uv run pytest`
+- Run tests locally with `uv run --no-sync pytest`, in the generation you synced
- We use [pytest](https://docs.pytest.org/) for our test suite
- Test files should be placed in the `tests/` directory with `test_*.py` naming convention
## Code Quality
-Before submitting your PR, ensure your code passes all checks:
-
```bash
-# Run tests
-uv run pytest
+# Run tests — this IS gated in CI. `--no-sync` keeps the generation you synced.
+uv run --no-sync pytest
# Check code style and linting
-uv run ruff check .
+uvx ruff check .
+
+# Check formatting (the whole repo; 46 files already fail — see the ratchet)
+uvx ruff format --check .
-# Format code
-uv run ruff format .
+# Format only what you changed. `xargs -r`, never `$(…)`: with no path
+# arguments ruff formats `.`, so an empty expansion silently rewrites all 46.
+git diff --name-only main -- '*.py' | xargs -r uvx ruff format
-# Type checking (if applicable)
-uv run mypy src/agentcat --ignore-missing-imports
+# Type checking
+uvx mypy src/agentcat
```
-Our CI will run these same checks on your PR.
+**`uvx`, not `uv run`, for the last three.** `ruff` and `mypy` are declared only
+in the `dev` *extra*, and no default sync installs an extra —
+`[tool.uv] default-groups` selects the `dev` dependency *group*, which holds
+`freezegun`, `pytest-asyncio` and `pytest-cov`. So `uv run ruff check .` fails
+with `ruff: command not found` in a normally-synced checkout. `uvx` fetches the
+tool on demand and needs no environment change, which also keeps these commands
+safe to run in either dependency generation.
+
+One consequence worth knowing: `uvx mypy` runs *without* the project's
+dependencies, so a handful of its findings are import-resolution noise rather
+than real type errors. `uvx --with pydantic mypy src/agentcat` removes the
+largest chunk of it. The table below reports the plain `uvx mypy` number, so
+the commands above reproduce it exactly.
+
+Note that a bare `uv run pytest` syncs the environment to the **default**
+groups first, which is the modern generation (`mcp` 2.x + `fastmcp` 4.x). To
+run the legacy generation's suite, sync it explicitly — see
+[Dev Environments](#dev-environments) — and then use `uv run --no-sync`.
+
+**Only the tests are gated.** Neither workflow in `.github/workflows/` mentions
+ruff or mypy at all, so a lint or type finding will not fail your PR today.
+That is what makes the rule below a convention rather than a check.
+
+### The lint/type debt, and the ratchet
+
+Neither tool has ever been clean on this repo, and turning either into a
+blocking gate would fail every PR on pre-existing findings. Measured on
+`feat/explicit-handles-v2` (`uvx ruff check .`, `uvx mypy src/agentcat`, which
+reads the `strict = true` in `pyproject.toml`):
+
+| Check | `main` | this branch |
+| --- | --- | --- |
+| `ruff check .` | 515 | 259 |
+| `ruff format --check .` | 57 files | 46 files |
+| `mypy src/agentcat` | 80 in 23 files | 53 in 15 files |
+
+Reproduce them with the three `uvx` commands under [Code Quality](#code-quality),
+`ruff format --check .` included — that row counts files the formatter *would*
+rewrite, which is why it is the `--check` form and not the rewriting one.
+
+The standing rule is a **ratchet, not a gate: a change may not add findings.**
+Compare rule-by-rule against `main` rather than eyeballing the totals — a patch
+that fixes ten `E501`s and introduces one `B904` has gone backwards even though
+the count fell. Cleaning up debt you did not create is welcome as its own PR,
+where it can be reviewed as such.
+
+Most of what remains is mechanical: `UP006`/`UP045` (pre-PEP-585/604 typing
+spellings), `E501`, `I001` import ordering, and untyped test helpers.
+
+Measure both sides the same way. Roughly 3 of the 53 mypy findings are the
+import-resolution noise described above, so a `uvx mypy` number and a
+`uvx --with pydantic mypy` number are not comparable to each other — mixing
+them invents an improvement, or hides a regression, that is purely an artifact
+of the invocation.
+
+## Release dependencies outside this repo
+
+Some changes here are only half-shipped until another repo moves. Check these
+before cutting a release:
+
+- **`agentcat-go-api/api/openapi.yaml` is missing the `agentcat:custom`
+ `event_type` enum entry.** `publish_custom_event` emits that type, and the
+ backend has accepted it since TS 2.0, but the generated `agentcat-api` client
+ still validates against the older enum. This SDK works around it by
+ overriding the validator (`Event.event_type_validate_enum` in
+ `src/agentcat/types.py`) — **any other consumer of that spec stays broken
+ until the enum entry lands.** Adding it upstream lets the override be
+ deleted.
## Dependencies
@@ -141,7 +252,6 @@ agentcat-python-sdk/
│ ├── types.py # Type definitions
│ └── utils.py # Utility functions
├── tests/ # Test files
-├── examples/ # Example usage
├── docs/ # Documentation
└── dist/ # Built distributions (generated)
```
diff --git a/MIGRATION.md b/MIGRATION.md
index 3f85276..0531798 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,3 +1,189 @@
+# Migrating `agentcat` 1.x to 2.0 — Explicit handles replace MCP session correlation
+
+MCP protocol 2026-07-28 (SEP-2567) removed protocol-level sessions, so AgentCat now correlates work with two explicit, server-minted handles that agents echo back as tool parameters:
+
+- `session_id` — one goal, start to finish. Subagents share their parent's session_id. It is stored in the existing `session_id` event field with the same `ses_` prefix, so dashboards, queries, saved filters, and exporters are unaffected. **No backend migration.**
+- `agent_id` — one per agent; subagents get their own. Rides on events as the `agentcat_agent_id` tag. Off by default — opt in with `enable_agent_tracking=True`.
+
+### This changes your tools' public interface
+
+Upgrading takes no configuration, but it does change what your MCP server publishes to its callers. These are your schemas and your responses — review them before you roll out.
+
+**Every tracked tool's input schema gains** `session_id` — type `string`, optional. Agents echo it back on later calls, and AgentCat strips it before your handler runs.
+
+```diff
+ {
+ "name": "search_orders",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": { "type": "string" },
++ "session_id": { "type": "string", "description": "REQUIRED on every call after your first…" }
+ }
+ }
+ }
+```
+
+`additionalProperties: false` **is removed** from tracked input schemas. If you declared it deliberately to reject unknown parameters, that constraint no longer appears in the schema AgentCat publishes.
+
+**With** `enable_agent_tracking=True`**,** `agent_id` **is added to the schema's** `required` **array.** This is the one addition a strict client will enforce — a schema-validating MCP client refuses to send a call that omits it. Server-side enforcement is soft: a call without `agent_id` still succeeds, and the event is simply published without agent identity. This is why agent tracking is off by default.
+
+**Tools with a plain-object** `outputSchema` **gain an optional** `_mcp_instructions` **property**, so validating clients accept the handle mirrored into `structuredContent`. Schemas built from `oneOf` / `allOf` / `anyOf` have no single properties bag to extend and are skipped — mint-back stays content-only there.
+
+**Responses that mint a handle gain a trailing** `[MCP INSTRUCTIONS]:` **text block.** It is wire-only: recorded event responses and error messages contain only your tool's own output. The same block appears when an agent sends a `session_id` this server never issued, correcting it without handing out a replacement.
+
+**Tools that already declare** `session_id`**,** `agent_id`**, or** `context` **keep their own parameter.** No injection happens for that name on that tool, and the value reaches your handler untouched. `agent_id` and `context` log a warning; `session_id` logs an **error**, once per tool, because it costs you correlation on that tool.
+
+AgentCat also stops treating that name as its own there, which is what you want and worth stating plainly: a tool whose `session_id` is *yours* — a ticket ID, a job ID, a row key — never becomes the analytics handle, and is never confirmed back to the agent. Those calls publish **without a session** rather than with a minted one: a fresh handle per call on a tool that can never carry it manufactures a phantom session per call, which looks like data and is not. The practical consequence: **calls to a tool with its own `session_id` are not correlated with each other.** If you want them correlated, either rename your parameter or supply the handle yourself with `resolve_session_id`, which injects nothing anywhere and reads no arguments at all — your parameter stays entirely yours.
+
+**AgentCat honors only handles it issued.** A supplied `session_id` that is not a `ses_` KSUID from this server is rejected rather than adopted: the call publishes without a session, tagged `agentcat_session_id_source=invalid`, and the agent is told to re-send the ID it was given (or to omit the parameter and be issued one). `Event.session_id` is exempt from `redact_sensitive_information` and from `redact_event`, so a value AgentCat did not mint could not be redacted after the fact — which is exactly why it is never written there.
+
+One caveat on all of the above: it depends on a `tools/list` having run. A call arriving at an instance that never served a listing rebuilds the registry from your list source; only if *that* fails does it fall back to treating all three names as AgentCat's.
+
+### Most integrations need no code changes
+
+`track(server, project_id, options)` keeps its signature, and `AgentCatOptions` is additive apart from one removal (`stateless`, below). Every 1.x option keeps its name: `identify`, `redact_sensitive_information`, `exporters`, `enable_report_missing`, `enable_tracing`, `enable_tool_call_context`, `custom_context_description`, `event_tags`, `event_properties`, `debug_mode`, `api_base_url`, and `disable_diagnostics`.
+
+Three of them changed *behavior*, and all three are covered below: `identify` and the other request hooks receive a different object ([the hook argument](#the-hook-argument-changed)), `identify` / `event_tags` / `event_properties` now run at different points in the call ([behavior changes](#behavior-changes-worth-knowing)), and `redact_sensitive_information` now actually runs.
+
+If your integration is a bare `track(server, "proj_...")` with no callbacks, upgrading is a version bump. Handles are injected and stripped inside the SDK, so your tool handlers never see the extra parameters, and handles keep landing in the `session_id` field with the `ses_` prefix — your existing dashboards, queries, and exporter pipelines keep working untouched. If you pass callbacks, read the two sections above first: both changes are one-line edits, but both are silent if you skip them.
+
+```bash
+pip install --upgrade "agentcat>=2"
+# or, for Jlowin's/Prefect's FastMCP support:
+pip install --upgrade "agentcat[community]>=2"
+```
+
+### Update your code only if…
+
+**You pass** `AgentCatOptions(stateless=...)`**.** The option is gone, along with its auto-detection. Every handle, actor, and client identity is now resolved per request from the request itself, so a stateless server and a stateful one take exactly the same code path — there is nothing left to configure. Passing it raises `TypeError` from the dataclass constructor; delete the argument.
+
+```diff
+- agentcat.track(server, "proj_abc", AgentCatOptions(stateless=True))
++ agentcat.track(server, "proj_abc")
+```
+
+If you set it because you run stateless HTTP, also read the note on header-derived `client_name` under [Behavior changes worth knowing](#behavior-changes-worth-knowing) — that is the one place where stateless deployments see a visible difference.
+
+**You run community FastMCP 2.x.** `agentcat>=2` supports FastMCP 3.x and 4.x. On a 2.x server, `track()` logs a warning to `~/agentcat.log` and returns your server **untracked** — it does not raise, and your server keeps serving. Either upgrade FastMCP or pin `agentcat<2`, which stays published and keeps working.
+
+```bash
+pip install "agentcat<2" # staying on FastMCP 2.x
+```
+
+**You built dashboards on** `mcp:initialize`**,** `mcp:tools/list`**, or** `agentcat:identify` **events.** None of the three is published anymore. `tools/list` is still intercepted — that is how schema injection happens — it just emits no event. The actor your `identify` hook returns now rides on **every** tool-call event (`identify_actor_given_id`, `identify_actor_name`, `identify_data`), so requery against the tool-call events themselves.
+
+**You import** `EventType`**.** It has two members: `MCP_TOOLS_CALL` (`"mcp:tools/call"`) and `AGENTCAT_CUSTOM` (`"agentcat:custom"`). Everything else was removed.
+
+**You import** `AgentCatData`**,** `SessionInfo` **or** `ToolRegistration`**.** `SessionInfo` and `ToolRegistration` are gone. `AgentCatData` keeps `project_id` and `options`; its session and patching bookkeeping — `session_id`, `session_info`, `last_activity`, `is_stateless`, `tool_registry`, `wrapped_tools`, `monkey_patched`, `tracker_initialized` — is gone, replaced by per-request resolution. Most integrations never reference these types.
+
+**You import from** `agentcat.modules.session`**,** `agentcat.modules.compatibility`**,** `agentcat.modules.version_detection`**,** `agentcat.modules.context_parameters`**, or anything under** `agentcat.modules.overrides`**.** All of them were deleted, the whole `overrides` package included — monkey-patching is gone, replaced by the adapters in `agentcat.modules.adapters`. The top-level names those modules exported went with them: `override_lowlevel_mcp_server`, `get_session_info`, `new_session_id`, `COMPATIBILITY_ERROR_MESSAGE`, `is_compatible_server`, `is_community_fastmcp_v2`, `is_community_fastmcp_v3`, `is_official_fastmcp_server`, `add_context_parameter_to_schema` and `add_context_parameter_to_tools`. Server classification lives in `agentcat.modules.detection` (`detect_server(server).flavor`), and there is no session module because there are no sessions.
+
+**Your** `identify`**,** `event_tags`**,** `event_properties` **or** `resolve_session_id` **hook reads** `request.params`**.** Drop the hop — it is `request.arguments` and `request.name` now. This one fails silently; see [The hook argument changed](#the-hook-argument-changed).
+
+**You set** `redact_sensitive_information`**.** The hook never actually ran in 1.x. It does now — check that yours is narrow enough before you upgrade. See [Behavior changes worth knowing](#behavior-changes-worth-knowing).
+
+**You depend on** `track()` **raising.** It no longer does — see below.
+
+**You snapshot tool schemas in tests.** The schema additions above will fail exact-match assertions. Parameter order is: your params, `session_id`, `agent_id`, `context`.
+
+### The hook argument changed
+
+**Your** `identify`**,** `event_tags`**,** `event_properties` **and** `resolve_session_id` **callbacks now receive the tool call's request PARAMS, not the enclosing request.** 1.x built a synthetic request object with a `.params` attribute; 2.0 hands over the params model itself, which is the one shape all four adapters can produce — the official 2.x SDK gives its handler `(ctx, params)` with no request object anywhere, and community FastMCP's message is params too.
+
+```diff
+ def identify_user(request, extra):
+- token = request.params.arguments["token"]
++ token = request.arguments["token"]
+ return UserIdentity(user_id=token, user_name=None, user_data=None)
+```
+
+**This fails silently if you miss it.** A hook that raises is caught, logged to `~/agentcat.log`, and treated as "no identity" — so an un-migrated `identify` does not break your server, it just publishes every event anonymously. Grep your hooks for `.params` before you roll out, or run once with `AgentCatOptions(debug_mode=True)` and check the log.
+
+`extra` is unchanged in spirit — it is the request context your framework exposes — but it is your adapter's own object, so keep reading it defensively (`getattr(getattr(extra, "request", None), "headers", {}) or {}`) as the examples here do.
+
+### Behavior changes worth knowing
+
+- **`track()` never raises.** A missing `project_id` with no exporters, an unsupported server generation, or an object AgentCat cannot recognize is logged to `~/agentcat.log` and returns your server untracked. 1.x raised `ValueError`/`TypeError` from `track()`; if you wrapped the call in `try`/`except` to keep a bad config from taking your server down, that guard is now redundant. If you *relied* on the exception to fail your startup loudly, check `~/agentcat.log` (or run with `AgentCatOptions(debug_mode=True)`) instead.
+- **`identify` now runs on every tool call**, and its result is stamped directly on that call's event. There is no identity cache and no `agentcat:identify` event. If your hook does a database or API lookup, it is now on the hot path for every call — add your own caching if that matters for your latency budget.
+- **`event_tags` and `event_properties` now resolve _after_ your tool handler returns.** They receive the same `(request, extra)` pair as before, but they see a later snapshot: 1.x resolved them before invoking the tool, 2.0 resolves them while building the event that reports the finished call. If your callback reads request-scoped context that your framework tears down when the handler exits (a scoped DB session, a context-local that a middleware closes, a request object your ASGI stack recycles), it may now observe a closed or mutated context. Failures degrade to "no tags/properties on that event" — the callback's exception is logged and swallowed, never raised into your tool. If your hooks read anything request-scoped, capture what you need eagerly rather than lazily.
+- **`client_name` / `client_version` are no longer derived from HTTP headers.** 1.x fell back to parsing the `user-agent` header and reading `x-mcp-client-name` / `x-mcp-client-version`. 2.0 resolves client identity from three per-request sources only: the reserved `io.modelcontextprotocol/clientInfo` metadata key (2026-era clients), the same key passed through `_meta`, and the `clientInfo` your client sent at `initialize`. **If your callers are identified only by headers — the common case for pre-2026 clients on stateless HTTP, where each request gets a fresh session that never handshakes — `client_name` will start arriving empty on your events.** Nothing else about those events changes, and tool calls are unaffected. Two ways forward: have your clients send `clientInfo` at `initialize` (any stateful HTTP or stdio transport), or attach the header yourself with `event_tags`, which puts it on every event and is filterable in the dashboard:
+
+ ```python
+ from agentcat import AgentCatOptions
+
+ def client_tag(request, extra):
+ headers = getattr(getattr(extra, "request", None), "headers", {}) or {}
+ name = headers.get("x-mcp-client-name") or headers.get("user-agent")
+ return {"client": name} if name else None
+
+ agentcat.track(server, "proj_abc", AgentCatOptions(event_tags=client_tag))
+ ```
+
+- **`redact_sensitive_information` now actually runs.** In 1.x the hook was configured, documented, and never invoked on a published event: the redactor walked `str`/`list`/`dict` and was handed the event *model*, so it returned it untouched. 2.0 fixes that, which means a hook you set in 1.x starts taking effect the moment you upgrade. It runs on every string in the event — `parameters`, `response`, `user_intent`, `client_name`, `server_name` and the rest — except the fields AgentCat needs to attribute the event: `session_id`, `id`, `project_id`, `event_type`, `resource_name`, `actor_id`, the three `identify_*` fields, and your own `tags` and `properties`. **If your hook is an aggressive catch-all** (`lambda s: "[REDACTED]"`), it will now blank fields your dashboards read, such as `client_name`. Narrow it to the patterns you actually care about before upgrading. A hook that raises drops the event rather than publishing it unredacted, and an async hook is supported.
+- **MCP `extra.sessionId` is ignored entirely**, and inactivity-based session rollover is gone. The transport's `mcp-session-id` still rides along untouched under `parameters.extra.sessionId` on each event if you need it.
+- **Request-path hooks now run contained: sync hooks on a worker thread, everything under a 5-second cap.** A sync `identify` (or `event_tags` / `event_properties` / `resolve_session_id`) that blocks — a database read, an HTTP call — no longer suspends your server's event loop; it suspends only its own call, exactly how your framework runs a sync tool body. Two consequences. First, a *sync* hook can no longer call asyncio APIs (`asyncio.ensure_future`, reading a loop-bound future): worker threads have no running loop, so make that hook `async def` — its body then runs on the loop as before. Second, a hook slower than 5 seconds has its result discarded and the call proceeds as if the hook had raised (anonymous / untagged / freshly minted, per that hook's documented degradation); the timeout is logged to `~/agentcat.log`. A hook that raises `SystemExit` or `CancelledError` is contained the same way — nothing a hook does reaches your request path.
+- **The SDK never touches your process lifecycle.** 2.0.0 beta builds replaced `SIGINT`/`SIGTERM` handlers at import and force-exited via `os._exit(0)` after a drain delay — clobbering `KeyboardInterrupt`, your own handlers, `finally` blocks and `atexit` hooks. Stable 2.0 installs no signal handlers, registers no exit-time event drain, and runs every worker as a daemon thread: your shutdown is entirely yours, and exit is never delayed by AgentCat. The trade is deliberate: telemetry still queued when the process exits is dropped. Exactly two bounded exit hooks exist, and neither sends events: a worker-stop hook (registered on first publish) that tells the single publish worker to exit — instant when the worker is idle, capped by a ~1-second join otherwise — and the internal-diagnostics beacon, skipped when empty and capped at ~2 seconds. The worker stop is itself a process-safety measure: on Linux with CPython ≤3.13, a daemon thread still running when interpreter finalization begins can abort the whole process with SIGABRT (CPython gh-87135, fixed in 3.14). Stopping the worker before finalization — atexit hooks run first — closes that path, including for a publish that is mid-retry against an unreachable API.
+
+### Bringing your own session IDs
+
+If you already track your own session or correlation IDs, plug yours in and AgentCat will not prompt the agent about `session_id` at all — no parameter is injected and no instructions are added to your tool descriptions:
+
+```python
+import agentcat
+from agentcat import AgentCatOptions
+
+def session_from_header(request, extra):
+ headers = getattr(getattr(extra, "request", None), "headers", {}) or {}
+ return headers.get("x-correlation-id")
+
+agentcat.track(server, "proj_abc", AgentCatOptions(resolve_session_id=session_from_header))
+```
+
+The returned string is combined with your project ID into a deterministic `ses_` handle, so the same correlation ID always maps to the same session. The hook may be sync or async and receives the same `(request, extra)` pair as `identify`. Returning `None` or raising mints a fresh handle silently — a configured hook should answer every request.
+
+### Publishing your own events
+
+`publish_custom_event` records work that is not a tool call — a background job, a webhook, a checkout step — against a session:
+
+```python
+import agentcat
+
+agentcat.publish_custom_event(server, "proj_abc", {
+ "session_id": current_session_id,
+ "resource_name": "checkout",
+ "message": "order confirmed",
+})
+```
+
+The session ID is used **verbatim** — never validated, derived or reformatted, because this is your deliberate server-side call and not an agent's guess — and events published without one land without a session. The first argument may also be a session-ID string instead of a tracked server. Events are typed `agentcat:custom`, are fire-and-forget, and never raise.
+
+**A server tracked with `enable_tracing=False` publishes no custom events.** Turning tracing off silences this entry point exactly as it silences tool-call events, so a server you deliberately muted does not start emitting a new event type. The session-ID-string form has no options to consult and always publishes.
+
+### Known limitations in 2.0
+
+- **Multi-round tool calls that mint their own handle land on separate sessions.** If a tool call spans several round trips and the *first* round is what mints the handle, each round is attributed to its own session instead of one shared session. The other two modes correlate correctly and are protocol-enforced: supplying `session_id` yourself, or deriving it with a `resolve_session_id` hook. If your server relies on multi-round tool calls, prefer one of those two.
+- **Errors forwarded from a proxied community tool carry no stack detail.** When a community FastMCP server proxies a tool to an upstream server and the upstream returns an error result, no Python exception is raised locally, so the event records the message without a stack trace. Errors raised by your own tool code are unaffected. This applies from fastmcp 3.4, which taught the proxy provider to pass an upstream error result through; on 3.0–3.3 the proxy collapsed it into a raised `ToolError` instead, so those versions do record full detail.
+
+### Supported versions
+
+| Runtime | Supported | Notes |
+| --- | --- | --- |
+| Official MCP SDK (`mcp`) 1.x | ✅ | Low-level `Server` and `mcp.server.fastmcp.FastMCP` |
+| Official MCP SDK (`mcp`) 2.x | ✅ | Low-level server and `MCPServer` |
+| Community FastMCP (`fastmcp`) 3.x | ✅ | Requires the `agentcat[community]` extra |
+| Community FastMCP (`fastmcp`) 4.x | ✅ | Requires the `agentcat[community]` extra |
+| Community FastMCP (`fastmcp`) 2.x | ❌ | Logged and returned untracked — pin `agentcat<2` |
+| Python | 3.10+ | Unchanged |
+
+One `track()` call handles every supported shape; AgentCat classifies the server it is handed and installs the matching adapter. A shape it does not recognize is logged with a diagnostic fingerprint and returned untracked.
+
+The declared floors are `mcp>=1.2.0,<3` and `fastmcp>=3.0.0,<5`, and every minor in both ranges runs the suite on each change. AgentCat works across the whole range, but the oldest MCP releases lack SDK seams that some features are built on — on `mcp<1.10` a bare low-level handler's exception type and stack frames cannot be recovered (the surfaced message is still published) and there is no structured mint-back; `mcp<1.9.2` captures no request headers; `mcp<1.8` has no Streamable HTTP at all. Nothing breaks on those versions; the affected features simply go quiet. See the table in [README.md](./README.md).
+
+> **Installing into a fresh environment resolves `mcp` 2.x**, which removed `mcp.server.fastmcp`. AgentCat's dependency is `mcp>=1.2.0,<3` and both generations are supported, so an existing project that pins `mcp<2` is unaffected — but a `pip install agentcat` into an empty environment will give you the 2.x line, where the 1.x `from mcp.server.fastmcp import FastMCP` import does not exist. Pin `mcp<2` if you need it.
+
+---
+
# Migrating from `mcpcat` to `agentcat`
MCPcat is now **AgentCat** — same team, same product, new name. The PyPI package has been renamed from `mcpcat` to [`agentcat`](https://pypi.org/project/agentcat/), starting fresh at `v1.0.0`.
@@ -25,7 +211,7 @@ If you never touch your integration, nothing stops working. Migrate on your own
| Debug logging | `MCPCAT_DEBUG_MODE` | `AGENTCAT_DEBUG_MODE` (no fallback) |
| Local log file | `~/mcpcat.log` | `~/agentcat.log` |
-There are no other API changes — `track()`, its options, the `identify` and redaction hooks, and the telemetry exporters all work exactly as before.
+There are no other API changes in the rename itself — `track()`, its options, the `identify` and redaction hooks, and the telemetry exporters all work exactly as before. (Going from 1.x to 2.0 is a separate step, covered at the top of this document.)
> **Note:** `agentcat` does not install a `mcpcat` compatibility module — a shim would collide with the real `mcpcat` distribution when both are installed. The import rename is required.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6192ffb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,50 @@
+# AgentCat Python SDK — example server orchestration.
+#
+# Mirrors the Go SDK's run-examples / stop-examples targets. Each example is a
+# self-contained PEP 723 script: `uv run` resolves it into an isolated, cached
+# environment (the first run resolves four distinct dependency sets and can
+# take a minute; cached thereafter) and never touches the project's .venv —
+# safe to use from a legacy-synced checkout (see CONTRIBUTING.md).
+# `--no-project` makes that guarantee explicit.
+
+.PHONY: help run-examples stop-examples smoke-examples
+
+EXAMPLE_PORTS := 8090 8091 8092 8093 8094 8095 8096
+
+# Project ID fallthrough: AGENTCAT_PROJECT_ID > MCPCAT_PROJECT_ID > placeholder.
+PROJECT_ID_ENV = AGENTCAT_PROJECT_ID=$${AGENTCAT_PROJECT_ID:-$${MCPCAT_PROJECT_ID:-proj_YOUR_PROJECT_ID}}
+
+help:
+ @echo "Available targets:"
+ @echo " make run-examples - Start all example MCP servers in the background"
+ @echo " make stop-examples - Stop all example servers (by port)"
+ @echo " make smoke-examples - POST an initialize request to every server"
+
+# Start all example servers in the background.
+run-examples:
+ @echo "Starting all example servers (first run resolves each script's env; may take a minute)..."
+ @$(PROJECT_ID_ENV) uv run --no-project examples/officialsdk/factory/main.py & echo " officialsdk-factory (pid $$!) → http://localhost:8090/mcp"
+ @$(PROJECT_ID_ENV) uv run --no-project examples/officialsdk/basic/main.py & echo " officialsdk-basic (pid $$!) → http://localhost:8091/mcp"
+ @$(PROJECT_ID_ENV) uv run --no-project examples/officialsdk/advanced/main.py & echo " officialsdk-advanced (pid $$!) → http://localhost:8092/mcp"
+ @$(PROJECT_ID_ENV) uv run --no-project examples/officialsdk/legacy/main.py & echo " officialsdk-legacy (pid $$!) → http://localhost:8093/mcp"
+ @$(PROJECT_ID_ENV) uv run --no-project examples/fastmcp/basic/main.py & echo " fastmcp-basic (pid $$!) → http://localhost:8094/mcp"
+ @$(PROJECT_ID_ENV) uv run --no-project examples/fastmcp/advanced/main.py & echo " fastmcp-advanced (pid $$!) → http://localhost:8095/mcp"
+ @$(PROJECT_ID_ENV) uv run --no-project examples/fastmcp/v3/main.py & echo " fastmcp-v3 (pid $$!) → http://localhost:8096/mcp"
+ @echo "All servers started. Use 'make stop-examples' to stop them."
+
+# Stop all example servers (by the ports they listen on).
+stop-examples:
+ @echo "Stopping example servers..."
+ @for port in $(EXAMPLE_PORTS); do kill $$(lsof -ti:$$port) 2>/dev/null || true; done
+ @echo "Done."
+
+# Prove every server answers an MCP initialize over Streamable HTTP.
+smoke-examples:
+ @fail=0; for port in $(EXAMPLE_PORTS); do \
+ code=$$(curl -s -o /dev/null -w "%{http_code}" -m 5 -X POST "http://localhost:$$port/mcp" \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.0"}}}'); \
+ if [ "$$code" = "200" ]; then echo " port $$port OK"; \
+ else echo " port $$port FAIL (HTTP $$code)"; fail=1; fi; \
+ done; exit $$fail
diff --git a/README.md b/README.md
index cab7954..93ffc5c 100644
--- a/README.md
+++ b/README.md
@@ -23,12 +23,14 @@
+> [!NOTE]
+> AgentCat v2 introduces compatibility with the [MCP Protocol "Stateless" 2026-07-28 Update](https://blog.modelcontextprotocol.io/posts/2026-07-28/) and the coinciding [MCP Python SDK v2](https://github.com/modelcontextprotocol/python-sdk/releases) release that puts it into effect. The stateless transition has a massive impact on analytics, as sessions were a built-in concept tying related tool calls together. AgentCat has now migrated its session tracking under guidance of the MCP core team's recommendations of using [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567).
+>
+> As a result AgentCat now injects a `session_id` on every MCP tool call to associate them under the same task umbrella. Our evals show much higher tool correlation accuracy at the cost of < 1% additional context pollution.
+
> [!IMPORTANT]
> **MCPcat is now AgentCat** 🐱 — same team, same product, new name. This package was previously published as [`mcpcat`](https://pypi.org/project/mcpcat/), which keeps working forever, but new features land here. Upgrading takes a few minutes — see the [migration guide](./MIGRATION.md).
-> [!NOTE]
-> Looking for the TypeScript SDK? Check it out here [agentcat-typescript](https://github.com/agentcathq/agentcat-typescript-sdk).
-
AgentCat is an analytics platform for MCP server owners 🐱. It captures user intentions and behavior patterns to help you understand what AI users actually need from your tools — eliminating guesswork and accelerating product development all with one-line of code.
This SDK also provides a free and simple way to forward telemetry like logs, traces, and errors to any Open Telemetry collector or popular tools like Datadog and Sentry.
@@ -37,24 +39,35 @@ This SDK also provides a free and simple way to forward telemetry like logs, tra
# Basic installation (includes official MCP SDK)
pip install agentcat
-# With Jlowin's/Prefect's FastMCP support
+# With community FastMCP support
pip install "agentcat[community]"
```
-To learn more about us, check us out [here](https://agentcat.com)
+To learn more about us, check us out [here](https://agentcat.com). For detailed guides visit our [documentation](https://docs.agentcat.com).
## Why use AgentCat? 🤔
-AgentCat helps developers and product owners build, improve, and monitor their MCP servers by capturing user analytics and tracing tool calls.
+AgentCat helps builders of MCP servers, Claude Connectors, and ChatGPT Plugins learn how to improve them by capturing any agents goals and detecting when they get stuck.
Use AgentCat for:
-- **User session replay** 🎬. Follow alongside your users to understand why they're using your MCP servers, what functionality you're missing, and what clients they're coming from.
+- **Agent session replay** 🎬. Follow alongside your users and their agents to understand why they're using your MCP servers, what functionality you're missing, and what clients they're coming from.
- **Trace debugging** 🔍. See where your users are getting stuck, track and find when LLMs get confused by your API, and debug sessions across all deployments of your MCP server.
- **Existing platform support** 📊. Get logging and tracing out of the box for your existing observability platforms (OpenTelemetry, Datadog, Sentry) — eliminating the tedious work of implementing telemetry yourself.
+## How it works
+
+AgentCat works as a lightweight middleware inside your MCP server. When you call `track()`, it seamlessly modifies your registered tool schemas in place, following the MCP core team's [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567) guidelines. Concretely, AgentCat adds the following to your server:
+
+- **`session_id`** — a parameter injected into each tool's input schema. Agents echo it back on every call, letting AgentCat group related tool calls into one task even over stateless transports. Values are validated: anything AgentCat did not issue is rejected rather than adopted, and the agent is told to re-send the ID it was given.
+- **`agent_id`** _(off by default)_ — enabled with `enable_agent_tracking=True`. Each agent self-generates its own ID, keeping parallel agents working the same task individually attributable.
+- **`context`** — a parameter asking the agent to explain, in one sentence, why it is making this call. This is where intent data comes from.
+- **`get_more_tools`** — an additional tool, prompt-engineered so that agents readily report the features and tools they looked for but couldn't find — surfacing your missing functionality directly from real usage.
+
+Injected parameters are stripped from arguments before your tool handler runs, so your code never sees them. For tools that declare an output schema, issued IDs are also mirrored into `structuredContent` (as `_mcp_instructions`), so clients that only read structured results still receive them.
+
## Getting Started
To get started with AgentCat, first create an account and obtain your project ID by signing up at [agentcat.com](https://agentcat.com). For detailed setup instructions visit our [documentation](https://docs.agentcat.com).
@@ -63,31 +76,57 @@ Once you have your project ID, integrate AgentCat into your MCP server:
```python
import agentcat
-from mcp.server import FastMCP
+from mcp.server.mcpserver import MCPServer
-server = FastMCP(name="echo-mcp", version="1.0.0")
+server = MCPServer("echo-mcp", version="0.1.0")
+@server.tool(description="Echo a message")
+def echo(msg: str) -> str:
+ return msg
+
+# Track the server with AgentCat
agentcat.track(server, "proj_0000000")
```
+Stateless servers built on [MCP 2026-07-28](https://blog.modelcontextprotocol.io/posts/2026-07-28/) create a fresh server instance per worker or per tenant, serving each request with `stateless_http=True`. Call `track()` inside the factory so every instance is tracked:
+
+```python
+import agentcat
+from mcp.server.mcpserver import MCPServer
+
+def create_server() -> MCPServer:
+ server = MCPServer("echo-mcp", version="0.1.0")
+ # register tools...
+ agentcat.track(server, "proj_0000000")
+ return server
+
+server = create_server()
+server.run(transport="streamable-http", stateless_http=True)
+```
+
+Calling `track()` per instance is cheap — the event queue, telemetry exporters, and diagnostics are initialized once and shared across instances.
+
### Identifying users
-You can identify your user sessions with a simple callback AgentCat exposes, called `identify`.
+We strongly encourage identifying every actor. If you can't resolve a real user, return a stable anonymized ID instead — for example, a hash of the auth token or API key — so that all events from the same end user still roll up to one actor in your dashboard rather than scattering into anonymous one-off sessions.
+
+`identify` (like every AgentCat hook) may be sync or async and runs on every tool call, ahead of your handler: a hook that fails outright — or returns anything that is not a `UserIdentity` — costs analytics data for that event, never the call itself. Every hook runs under a 5-second cap, and a slow lookup delays that call's response — so keep it cheap, and add your own caching if it does a database or API lookup.
+
+The callback receives the tool call's `request` params (`.name` and `.arguments`, a plain dict) and the request context the SDK hands to handlers — the same `(request, extra)` shape on every supported server flavor. On HTTP transports, identity signals like headers and auth live on `extra.request`:
```python
from agentcat import AgentCatOptions, UserIdentity
-def identify_user(request, extra):
- user = myapi.get_user(request.params.arguments.token)
- return UserIdentity(
- user_id=user.id,
- user_name=user.name,
- user_data={
- "favorite_color": user.favorite_color,
- },
- )
+async def identify(request, extra):
+ http = getattr(extra, "request", None) # incoming HTTP request, when present
+ token = http.headers.get("authorization") if http else None
+ org_id = http.headers.get("x-org-id") if http else None
+ user = await myapi.get_user(token)
+ if not user:
+ return None
+ return UserIdentity(user_id=user.id, user_name=user.name, user_data={"org_id": org_id})
-agentcat.track(server, "proj_0000000", AgentCatOptions(identify=identify_user))
+agentcat.track(server, "proj_0000000", AgentCatOptions(identify=identify))
```
### Redacting sensitive data
@@ -97,14 +136,14 @@ AgentCat redacts all data sent to its servers and encrypts at rest, but for addi
```python
from agentcat import AgentCatOptions
-# Sync version
-def redact_sync(text):
- return custom_redact(text)
+async def redact(text: str) -> str:
+ return await redactor(text)
+# or a plain sync function — both are supported
-agentcat.track(server, "proj_0000000", AgentCatOptions(redact_sensitive_information=redact_sync))
+agentcat.track(server, "proj_0000000", AgentCatOptions(redact_sensitive_information=redact))
```
-### Forwarding data to existing observability platforms
+### Vendor Support
AgentCat seamlessly integrates with your existing observability stack, providing automatic logging and tracing without the tedious setup typically required. Export telemetry data to multiple platforms simultaneously:
@@ -115,29 +154,26 @@ from agentcat import AgentCatOptions
agentcat.track(
server,
- "proj_0000000", # Or None if you just want to use the SDK to forward telemetry
+ "proj_0000", # Project ID can optionally be None if you just want to forward telemetry
AgentCatOptions(
exporters={
- # OpenTelemetry - works with Jaeger, Tempo, New Relic, etc.
"otlp": {
"type": "otlp",
"endpoint": "http://localhost:4318/v1/traces",
},
- # Datadog
"datadog": {
"type": "datadog",
- "api_key": os.getenv("DD_API_KEY"),
+ "api_key": os.environ["DD_API_KEY"],
"site": "datadoghq.com",
"service": "my-mcp-server",
},
- # Sentry
"sentry": {
"type": "sentry",
- "dsn": os.getenv("SENTRY_DSN"),
+ "dsn": os.environ["SENTRY_DSN"],
"environment": "production",
},
}
- )
+ ),
)
```
@@ -160,7 +196,7 @@ Diagnostics are on by default and can be turned off completely with either:
AgentCat is free for qualified open source projects. We believe in supporting the ecosystem that makes MCP possible. If you maintain an open source MCP server, you can access our full analytics platform at no cost.
-**How to apply**: Email hi@agentcat.com with your repository link
+**How to apply**: Email [hi@agentcat.com](mailto:hi@agentcat.com) with your repository link
_Already using AgentCat? We'll upgrade your account immediately._
@@ -171,6 +207,7 @@ Meet the cats behind AgentCat! Add your cat to our community by submitting a PR
-_Want to add your cat? Create a PR adding your cat's photo to `docs/cats/` and update this section!_
+_Want to add your cat? Create a PR adding your cat's photo to_ `docs/cats/` _and update this section!_
diff --git a/docs/cats/void.jpg b/docs/cats/void.jpg
new file mode 100644
index 0000000..11486d1
Binary files /dev/null and b/docs/cats/void.jpg differ
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..d1189e1
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,112 @@
+# AgentCat Python SDK Examples
+
+These examples show how to integrate AgentCat into MCP servers built with both generations of the official MCP Python SDK and the community FastMCP framework.
+
+Each example is a standalone echo server that runs over Streamable HTTP, self-contained in a single file via a [PEP 723](https://peps.python.org/pep-0723/) inline-metadata header.
+
+## Examples
+
+| Example | Port | Description |
+|---------|------|-------------|
+| [officialsdk/factory](officialsdk/factory) | 8090 | Stateless per-request serving — `track()` inside `create_server()`, the expected 2026 deployment shape (official MCP SDK 2.x) |
+| [officialsdk/basic](officialsdk/basic) | 8091 | Minimal 3-line AgentCat integration with the official MCP SDK 2.x (`MCPServer`) |
+| [officialsdk/advanced](officialsdk/advanced) | 8092 | Full AgentCat v2 options (per-call `identify`, `enable_agent_tracking`, hook mode, redaction, debug) with the official MCP SDK 2.x |
+| [officialsdk/legacy](officialsdk/legacy) | 8093 | The official MCP SDK **1.x** shape (`mcp.server.fastmcp.FastMCP`) — same `track()` call, prior generation |
+| [fastmcp/basic](fastmcp/basic) | 8094 | Minimal 3-line AgentCat integration with community [FastMCP](https://github.com/jlowin/fastmcp) v4 |
+| [fastmcp/advanced](fastmcp/advanced) | 8095 | Full AgentCat v2 options with community FastMCP v4 |
+| [fastmcp/v3](fastmcp/v3) | 8096 | Community FastMCP **v3** — same `track()` call, prior generation |
+
+## Running an Example
+
+Each example is a self-contained script. To run one:
+
+```bash
+uv run --no-project examples/officialsdk/basic/main.py
+```
+
+The PEP 723 header at the top of each file pins that example's MCP generation and pulls `agentcat` from this checkout as an editable install. `uv run` resolves it into an isolated, cached environment — the first run takes a moment, later runs start instantly — and **never touches the project's `.venv`**. That matters here: the repo's `mcp-legacy` and `mcp-modern` dependency groups conflict, and a bare `uv run` inside a legacy-synced checkout would re-sync it to modern (see [CONTRIBUTING.md](../CONTRIBUTING.md)). Script environments are exempt from all of that, so the legacy examples run from a modern checkout and vice versa.
+
+The server starts on its configured port (see table above) and accepts Streamable HTTP connections at `/mcp`.
+
+## Running All of Them
+
+```bash
+make run-examples # start all seven in the background
+make smoke-examples # POST an MCP initialize to every port
+make stop-examples # stop them all (by port)
+```
+
+The committed [`.mcp.json`](../.mcp.json) at the repo root points at all seven servers, so Claude Code opened in this repo sees them automatically once they're running — check with `/mcp`.
+
+To use one with any other MCP client, point the client at the URL. For instance, in a Claude Desktop `claude_desktop_config.json`:
+
+```json
+{
+ "mcpServers": {
+ "echo": {
+ "url": "http://localhost:8091/mcp"
+ }
+ }
+}
+```
+
+Or with the Claude Code CLI outside this repo:
+
+```bash
+claude mcp add echo-server http://localhost:8091/mcp
+```
+
+## What the Examples Demonstrate
+
+### Basic
+
+The basic examples show that AgentCat integration is just 3 lines of code added to a normal MCP server:
+
+```python
+project_id = os.environ.get("AGENTCAT_PROJECT_ID") or "proj_YOUR_PROJECT_ID"
+agentcat.track(server, project_id)
+```
+
+Every tool call is captured automatically. MCP 2026-07-28 has no protocol sessions, so AgentCat correlates the calls belonging to one task through an explicit `session_id` parameter it adds to each tool schema, mints back to the agent on its first call, and strips out again before your handler runs. `track()` never raises — a shape AgentCat does not support is logged to `~/agentcat.log` and your server comes back untracked rather than failing to start.
+
+### Advanced
+
+The advanced examples show the v2 options:
+
+- **`identify`** — attach actor identity (ID, name, metadata) to a call's event. It runs on **every tool call**, uncached, and stamps only that event; keep it cheap and make no network calls in it
+- **`enable_agent_tracking`** — also inject a required `agent_id` parameter so parallel agents on one task can be told apart (off by default)
+- **`resolve_session_id` (hook mode)** — shown in a comment block: return your own correlation ID and AgentCat derives the session from it deterministically. In hook mode no `session_id` is injected anywhere and no session instructions are shown to the agent
+- **`redact_sensitive_information`** — strip sensitive data (e.g. emails) before it leaves the process
+- **`debug_mode`** — enable debug logging to `~/agentcat.log`
+- **`enable_tool_call_context`** / **`enable_report_missing`** — shown in a comment block: opt out of the injected `context` parameter and the `get_more_tools` tool (both enabled by default)
+
+AgentCat trusts only a `session_id` it issued (`ses_` plus a 27-character KSUID). Anything else publishes without a session and the agent is told to re-send the real one — so a tool that declares its own `session_id` parameter cannot be correlated. Use `resolve_session_id` if you already manage sessions yourself.
+
+### Factory
+
+`officialsdk/factory` is the stateless deployment shape: the server is built in a `create_server()` factory that calls `track()` on the instance it is about to return, then served with `stateless_http=True` so nothing survives between requests. It demonstrates that:
+
+- module-level state (publisher, logger, diagnostics) initializes once no matter how many servers you track, and per-server state is weakly keyed and released when a server goes away — a factory does not leak;
+- correlation survives statelessness, because the `session_id` handle travels on the wire rather than in server memory;
+- **rebuild on demand** works: a stateless client can send `tools/call` to an instance that never served a `tools/list`, and AgentCat rebuilds its injection registries from that server's own tool list on the first call;
+- shutdown is process-wide: there is no handle to hold, the event queue drains itself at exit.
+
+### Legacy generations
+
+`officialsdk/legacy` (official SDK 1.x) and `fastmcp/v3` (community FastMCP v3) are the prior-generation shapes a large installed base still runs. agentcat supports all four from one install — `track()` classifies whatever object you hand it and installs the matching adapter — so these files differ from their modern siblings only in the server class and its serving API, never in the AgentCat integration.
+
+## Configuration
+
+All examples read the project ID from the `AGENTCAT_PROJECT_ID` environment variable, falling back to `MCPCAT_PROJECT_ID`, then to `"proj_YOUR_PROJECT_ID"` — the same precedence the SDK itself uses for `AGENTCAT_API_URL` / `MCPCAT_API_URL`.
+
+```bash
+export AGENTCAT_PROJECT_ID="proj_abc123"
+uv run --no-project examples/officialsdk/basic/main.py
+```
+
+Set `AGENTCAT_DEBUG_MODE=true` to get verbose SDK logging in `~/agentcat.log` (the advanced examples turn this on via `debug_mode=True`).
+
+## Prerequisites
+
+- [uv](https://docs.astral.sh/uv/) — resolves and runs the self-contained scripts (Python ≥3.10 is fetched automatically if needed)
+- An AgentCat project ID from [agentcat.com](https://agentcat.com) — set via `AGENTCAT_PROJECT_ID` or edit the fallback in the code
diff --git a/examples/fastmcp/advanced/main.py b/examples/fastmcp/advanced/main.py
new file mode 100644
index 0000000..becd51c
--- /dev/null
+++ b/examples/fastmcp/advanced/main.py
@@ -0,0 +1,153 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "fastmcp>=4.0.0b1,<5",
+# "fastmcp-slim>=4.0.0b1,<5",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: Full AgentCat v2 options with community FastMCP (v4).
+
+Demonstrates the options beyond the basic 3-line integration:
+
+- ``identify`` — attach actor identity to every captured event
+- ``enable_agent_tracking`` — inject a required agent_id parameter so
+ parallel agents working one session can be told apart
+- ``redact_sensitive_information`` — strip sensitive data (here: emails)
+ before it leaves the process
+- ``debug_mode`` — verbose logging to ~/agentcat.log
+- commented out: ``resolve_session_id`` hook mode, and opt-outs for the
+ injected context parameter and the get_more_tools tool
+
+Usage:
+
+ uv run --no-project examples/fastmcp/advanced/main.py
+
+Serves Streamable HTTP on http://localhost:8095/mcp.
+"""
+
+import os
+import re
+
+from fastmcp import FastMCP
+
+import agentcat
+from agentcat import AgentCatOptions, UserIdentity
+
+PORT = 8095
+
+EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
+
+
+def identify_user(request, extra):
+ """Attribute this call's event to an actor.
+
+ Runs on EVERY tool call, uncached, and stamps only that call's event —
+ keep it cheap and make no network calls. ``request`` carries the call's
+ params (``request.name``, ``request.arguments``); a real implementation
+ would derive the actor from auth data rather than hardcoding one. If this
+ raises or returns None, the event publishes anonymously.
+ """
+ return UserIdentity(
+ user_id="user-123",
+ user_name="John Doe",
+ user_data={"plan": "pro"},
+ )
+
+
+def redact_emails(text: str) -> str:
+ """Strip email addresses from all captured data before it leaves the process."""
+ return EMAIL_RE.sub("[REDACTED_EMAIL]", text)
+
+
+# A three-level call chain so error_test produces a realistic chained
+# stack trace for AgentCat's exception capture.
+def process_data(data: str) -> str:
+ if not data:
+ raise ValueError("input must not be empty")
+ raise ValueError(f"data processing failed for {data!r}: invalid payload structure")
+
+
+def validate_input(data: str) -> str:
+ try:
+ return process_data(data)
+ except ValueError as e:
+ raise ValueError("validation error") from e
+
+
+def dangerous_operation(data: str) -> str:
+ try:
+ return validate_input(data)
+ except ValueError as e:
+ raise RuntimeError("dangerous operation aborted") from e
+
+
+def main() -> None:
+ server = FastMCP("fastmcp-advanced-example", version="1.0.0")
+
+ project_id = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+ )
+ agentcat.track(
+ server,
+ project_id,
+ AgentCatOptions(
+ # Write debug logs to ~/agentcat.log. The default (None) defers to
+ # the AGENTCAT_DEBUG_MODE env var; an explicit True/False wins.
+ debug_mode=True,
+ # Also inject a required agent_id parameter into every tool so
+ # parallel agents on one session can be told apart. Off by
+ # default; a call that omits it is never rejected server-side.
+ enable_agent_tracking=True,
+ identify=identify_user,
+ redact_sensitive_information=redact_emails,
+ # Both injected extras are ON by default; uncomment to opt out.
+ # The "context" parameter powers user-intent analytics:
+ # enable_tool_call_context=False,
+ # get_more_tools lets an agent report capabilities you don't
+ # offer yet:
+ # enable_report_missing=False,
+ #
+ # Hook mode — you own correlation; no session_id parameter is
+ # injected anywhere and no session instructions are shown to the
+ # agent. Return your own ID (trace ID, workflow ID, a header) and
+ # AgentCat derives the same ses_ session from it
+ # deterministically. `extra` is the request context; on HTTP
+ # transports `extra.request` is the incoming HTTP request:
+ # resolve_session_id=lambda request, extra: (
+ # extra.request.headers.get("x-correlation-id")
+ # ),
+ ),
+ )
+
+ @server.tool
+ def echo(text: str) -> str:
+ """Echo back the input text."""
+ return text
+
+ @server.tool
+ def reverse(text: str) -> str:
+ """Reverse the input text."""
+ return text[::-1]
+
+ @server.tool
+ def count_chars(text: str) -> str:
+ """Count the number of characters in the input text."""
+ return str(len(text))
+
+ @server.tool
+ def error_test(text: str) -> str:
+ """Always errors — use this to test stack trace capture."""
+ return dangerous_operation(text)
+
+ print(f"MCP server listening on http://localhost:{PORT}/mcp")
+ server.run(transport="http", host="127.0.0.1", port=PORT, show_banner=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/fastmcp/basic/main.py b/examples/fastmcp/basic/main.py
new file mode 100644
index 0000000..b3839cd
--- /dev/null
+++ b/examples/fastmcp/basic/main.py
@@ -0,0 +1,99 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "fastmcp>=4.0.0b1,<5",
+# "fastmcp-slim>=4.0.0b1,<5",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: Minimal AgentCat integration with community FastMCP (v4).
+
+This shows the simplest possible AgentCat setup — one track() call — on the
+community FastMCP framework. Every tool call is captured automatically, and
+AgentCat correlates the calls belonging to one session through the session_id
+parameter it adds to each tool schema and mints back to the agent on its
+first call.
+
+The explicit fastmcp-slim pin matters: fastmcp 4 is a prerelease, and uv only
+honors prerelease versions named on DIRECT dependencies — the transitive
+fastmcp-slim==4.0.0b1 pin would be rejected without it.
+
+Usage:
+
+ uv run --no-project examples/fastmcp/basic/main.py
+
+Serves Streamable HTTP on http://localhost:8094/mcp.
+"""
+
+import os
+
+from fastmcp import FastMCP
+
+import agentcat
+
+PORT = 8094
+
+
+# A three-level call chain so error_test produces a realistic chained
+# stack trace for AgentCat's exception capture.
+def process_data(data: str) -> str:
+ if not data:
+ raise ValueError("input must not be empty")
+ raise ValueError(f"data processing failed for {data!r}: invalid payload structure")
+
+
+def validate_input(data: str) -> str:
+ try:
+ return process_data(data)
+ except ValueError as e:
+ raise ValueError("validation error") from e
+
+
+def dangerous_operation(data: str) -> str:
+ try:
+ return validate_input(data)
+ except ValueError as e:
+ raise RuntimeError("dangerous operation aborted") from e
+
+
+def main() -> None:
+ server = FastMCP("fastmcp-basic-example", version="1.0.0")
+
+ # --- AgentCat: 3 lines to add analytics ---
+ project_id = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+ )
+ agentcat.track(server, project_id)
+ # --- end AgentCat ---
+
+ @server.tool
+ def echo(text: str) -> str:
+ """Echo back the input text."""
+ return text
+
+ @server.tool
+ def reverse(text: str) -> str:
+ """Reverse the input text."""
+ return text[::-1]
+
+ @server.tool
+ def count_chars(text: str) -> str:
+ """Count the number of characters in the input text."""
+ return str(len(text))
+
+ @server.tool
+ def error_test(text: str) -> str:
+ """Always errors — use this to test stack trace capture."""
+ return dangerous_operation(text)
+
+ print(f"MCP server listening on http://localhost:{PORT}/mcp")
+ server.run(transport="http", host="127.0.0.1", port=PORT, show_banner=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/fastmcp/v3/main.py b/examples/fastmcp/v3/main.py
new file mode 100644
index 0000000..35a9abf
--- /dev/null
+++ b/examples/fastmcp/v3/main.py
@@ -0,0 +1,97 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "fastmcp>=3,<4",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: AgentCat with community FastMCP v3.
+
+agentcat supports both community FastMCP generations from one install; this
+is the v3 shape (which itself pins the official mcp SDK to 1.x). The
+integration is identical to the v4 example — the same track() call — the
+framework generation is the only difference.
+
+The PEP 723 header pins fastmcp to the v3 major. `uv run` resolves this into
+its own isolated environment, so running it never re-syncs or destroys the
+project's .venv.
+
+Usage:
+
+ uv run --no-project examples/fastmcp/v3/main.py
+
+Serves Streamable HTTP on http://localhost:8096/mcp.
+"""
+
+import os
+
+from fastmcp import FastMCP
+
+import agentcat
+
+PORT = 8096
+
+
+# A three-level call chain so error_test produces a realistic chained
+# stack trace for AgentCat's exception capture.
+def process_data(data: str) -> str:
+ if not data:
+ raise ValueError("input must not be empty")
+ raise ValueError(f"data processing failed for {data!r}: invalid payload structure")
+
+
+def validate_input(data: str) -> str:
+ try:
+ return process_data(data)
+ except ValueError as e:
+ raise ValueError("validation error") from e
+
+
+def dangerous_operation(data: str) -> str:
+ try:
+ return validate_input(data)
+ except ValueError as e:
+ raise RuntimeError("dangerous operation aborted") from e
+
+
+def main() -> None:
+ server = FastMCP("fastmcp-v3-example", version="1.0.0")
+
+ # --- AgentCat: 3 lines to add analytics ---
+ project_id = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+ )
+ agentcat.track(server, project_id)
+ # --- end AgentCat ---
+
+ @server.tool
+ def echo(text: str) -> str:
+ """Echo back the input text."""
+ return text
+
+ @server.tool
+ def reverse(text: str) -> str:
+ """Reverse the input text."""
+ return text[::-1]
+
+ @server.tool
+ def count_chars(text: str) -> str:
+ """Count the number of characters in the input text."""
+ return str(len(text))
+
+ @server.tool
+ def error_test(text: str) -> str:
+ """Always errors — use this to test stack trace capture."""
+ return dangerous_operation(text)
+
+ print(f"MCP server listening on http://localhost:{PORT}/mcp")
+ server.run(transport="http", host="127.0.0.1", port=PORT, show_banner=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/officialsdk/advanced/main.py b/examples/officialsdk/advanced/main.py
new file mode 100644
index 0000000..1152940
--- /dev/null
+++ b/examples/officialsdk/advanced/main.py
@@ -0,0 +1,148 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "mcp>=2,<3",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: Full AgentCat v2 options with the official MCP SDK (mcp 2.x).
+
+Demonstrates the options beyond the basic 3-line integration:
+
+- ``identify`` — attach actor identity to every captured event
+- ``enable_agent_tracking`` — inject a required agent_id parameter so
+ parallel agents working one session can be told apart
+- ``redact_sensitive_information`` — strip sensitive data (here: emails)
+ before it leaves the process
+- ``debug_mode`` — verbose logging to ~/agentcat.log
+- commented out: ``resolve_session_id`` hook mode, and opt-outs for the
+ injected context parameter and the get_more_tools tool
+
+Usage:
+
+ uv run --no-project examples/officialsdk/advanced/main.py
+
+Serves Streamable HTTP on http://localhost:8092/mcp.
+"""
+
+import os
+import re
+
+from mcp.server.mcpserver import MCPServer
+
+import agentcat
+from agentcat import AgentCatOptions, UserIdentity
+
+PORT = 8092
+
+EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
+
+
+def identify_user(request, extra):
+ """Attribute this call's event to an actor.
+
+ Runs on EVERY tool call, uncached, and stamps only that call's event —
+ keep it cheap and make no network calls. ``request`` carries the call's
+ params (``request.name``, ``request.arguments``); a real implementation
+ would derive the actor from auth data rather than hardcoding one. If this
+ raises or returns None, the event publishes anonymously.
+ """
+ return UserIdentity(
+ user_id="user-123",
+ user_name="John Doe",
+ user_data={"plan": "pro"},
+ )
+
+
+def redact_emails(text: str) -> str:
+ """Strip email addresses from all captured data before it leaves the process."""
+ return EMAIL_RE.sub("[REDACTED_EMAIL]", text)
+
+
+# A three-level call chain so error_test produces a realistic chained
+# stack trace for AgentCat's exception capture.
+def process_data(data: str) -> str:
+ if not data:
+ raise ValueError("input must not be empty")
+ raise ValueError(f"data processing failed for {data!r}: invalid payload structure")
+
+
+def validate_input(data: str) -> str:
+ try:
+ return process_data(data)
+ except ValueError as e:
+ raise ValueError("validation error") from e
+
+
+def dangerous_operation(data: str) -> str:
+ try:
+ return validate_input(data)
+ except ValueError as e:
+ raise RuntimeError("dangerous operation aborted") from e
+
+
+def main() -> None:
+ server = MCPServer("officialsdk-advanced-example", version="1.0.0")
+
+ project_id = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+ )
+ agentcat.track(
+ server,
+ project_id,
+ AgentCatOptions(
+ # Write debug logs to ~/agentcat.log. The default (None) defers to
+ # the AGENTCAT_DEBUG_MODE env var; an explicit True/False wins.
+ debug_mode=True,
+ # Also inject a required agent_id parameter into every tool so
+ # parallel agents on one session can be told apart. Off by
+ # default; a call that omits it is never rejected server-side.
+ enable_agent_tracking=True,
+ identify=identify_user,
+ redact_sensitive_information=redact_emails,
+ # Both injected extras are ON by default; uncomment to opt out.
+ # The "context" parameter powers user-intent analytics:
+ # enable_tool_call_context=False,
+ # get_more_tools lets an agent report capabilities you don't
+ # offer yet:
+ # enable_report_missing=False,
+ #
+ # Hook mode — you own correlation; no session_id parameter is
+ # injected anywhere and no session instructions are shown to the
+ # agent. Return your own ID (trace ID, workflow ID, a header) and
+ # AgentCat derives the same ses_ session from it
+ # deterministically. `extra` is the request context; on HTTP
+ # transports `extra.request` is the incoming HTTP request:
+ # resolve_session_id=lambda request, extra: (
+ # extra.request.headers.get("x-correlation-id")
+ # ),
+ ),
+ )
+
+ @server.tool(description="Echo back the input text")
+ def echo(text: str) -> str:
+ return text
+
+ @server.tool(description="Reverse the input text")
+ def reverse(text: str) -> str:
+ return text[::-1]
+
+ @server.tool(description="Count the number of characters in the input text")
+ def count_chars(text: str) -> str:
+ return str(len(text))
+
+ @server.tool(description="Always errors — use this to test stack trace capture")
+ def error_test(text: str) -> str:
+ return dangerous_operation(text)
+
+ print(f"MCP server listening on http://localhost:{PORT}/mcp")
+ server.run(transport="streamable-http", host="127.0.0.1", port=PORT)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/officialsdk/basic/main.py b/examples/officialsdk/basic/main.py
new file mode 100644
index 0000000..5a2a1e5
--- /dev/null
+++ b/examples/officialsdk/basic/main.py
@@ -0,0 +1,99 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "mcp>=2,<3",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: Minimal AgentCat integration with the official MCP SDK (mcp 2.x).
+
+This shows the simplest possible AgentCat setup — one track() call. Every tool
+call is captured automatically, and AgentCat correlates the calls belonging to
+one session through the session_id parameter it adds to each tool schema and
+mints back to the agent on its first call.
+
+The PEP 723 header above makes this file self-contained: `uv run` resolves it
+into an isolated, cached environment (mcp 2.x plus agentcat from this checkout)
+without ever touching the project's .venv — safe to run even from a
+legacy-synced checkout (see CONTRIBUTING.md).
+
+Usage:
+
+ uv run --no-project examples/officialsdk/basic/main.py
+
+Serves Streamable HTTP on http://localhost:8091/mcp.
+"""
+
+import os
+
+from mcp.server.mcpserver import MCPServer
+
+import agentcat
+
+PORT = 8091
+
+
+# A three-level call chain so error_test produces a realistic chained
+# stack trace for AgentCat's exception capture.
+def process_data(data: str) -> str:
+ if not data:
+ raise ValueError("input must not be empty")
+ raise ValueError(f"data processing failed for {data!r}: invalid payload structure")
+
+
+def validate_input(data: str) -> str:
+ try:
+ return process_data(data)
+ except ValueError as e:
+ raise ValueError("validation error") from e
+
+
+def dangerous_operation(data: str) -> str:
+ try:
+ return validate_input(data)
+ except ValueError as e:
+ raise RuntimeError("dangerous operation aborted") from e
+
+
+def main() -> None:
+ server = MCPServer("officialsdk-basic-example", version="1.0.0")
+
+ # --- AgentCat: 3 lines to add analytics ---
+ project_id = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+ )
+ agentcat.track(server, project_id)
+ # --- end AgentCat ---
+ #
+ # track() never raises — a misconfiguration logs to ~/agentcat.log and the
+ # server comes back untracked rather than down. There is no shutdown handle
+ # to hold: the event queue is process-wide and drains itself at exit. Tools
+ # registered after track() are picked up automatically.
+
+ @server.tool(description="Echo back the input text")
+ def echo(text: str) -> str:
+ return text
+
+ @server.tool(description="Reverse the input text")
+ def reverse(text: str) -> str:
+ return text[::-1]
+
+ @server.tool(description="Count the number of characters in the input text")
+ def count_chars(text: str) -> str:
+ return str(len(text))
+
+ @server.tool(description="Always errors — use this to test stack trace capture")
+ def error_test(text: str) -> str:
+ return dangerous_operation(text)
+
+ print(f"MCP server listening on http://localhost:{PORT}/mcp")
+ server.run(transport="streamable-http", host="127.0.0.1", port=PORT)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/officialsdk/factory/main.py b/examples/officialsdk/factory/main.py
new file mode 100644
index 0000000..7712e69
--- /dev/null
+++ b/examples/officialsdk/factory/main.py
@@ -0,0 +1,89 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "mcp>=2,<3",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: Stateless server factory with the official MCP SDK (mcp 2.x).
+
+This is the expected 2026 deployment shape: the server is built in a factory
+and served with stateless HTTP, where nothing survives between requests.
+track() mutates the instance it is given, so it runs INSIDE the factory, on
+the instance about to serve traffic (see the "Track inside your server
+factory" section of the repo README).
+
+It demonstrates that:
+
+- module-level AgentCat state (publisher, logger, diagnostics) initializes
+ once no matter how many servers you track; per-server state is weakly keyed
+ and released when a server goes away — a factory does not leak;
+- correlation survives statelessness, because the session_id handle travels
+ on the wire (echoed back by the agent) rather than in server memory;
+- shutdown is process-wide: there is no handle to hold, the event queue
+ drains itself at exit.
+
+Usage:
+
+ uv run --no-project examples/officialsdk/factory/main.py
+
+Serves stateless Streamable HTTP on http://localhost:8090/mcp.
+"""
+
+import os
+
+from mcp.server.mcpserver import MCPServer
+
+import agentcat
+
+PORT = 8090
+
+PROJECT_ID = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+)
+
+
+def create_server() -> MCPServer:
+ """Build and track a server instance.
+
+ In a per-worker or multi-tenant deployment this factory runs once per
+ instance. track() never raises — analytics must never take a customer
+ server down — so there is no error path to handle here.
+ """
+ server = MCPServer("officialsdk-factory-example", version="1.0.0")
+
+ @server.tool(description="Echo back the input text")
+ def echo(text: str) -> str:
+ return text
+
+ @server.tool(description="Count the number of characters in the input text")
+ def count_chars(text: str) -> str:
+ return str(len(text))
+
+ agentcat.track(server, PROJECT_ID)
+ return server
+
+
+def main() -> None:
+ server = create_server()
+ print(f"Stateless MCP server listening on http://localhost:{PORT}/mcp")
+ # stateless_http=True builds a fresh transport per REQUEST — no session
+ # state survives between calls. AgentCat still correlates them: the
+ # session_id handle rides on the wire, and its injection registries are
+ # rebuilt on demand from the server's own tool list if a call arrives at
+ # an instance that never served a tools/list.
+ server.run(
+ transport="streamable-http",
+ host="127.0.0.1",
+ port=PORT,
+ stateless_http=True,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/officialsdk/legacy/main.py b/examples/officialsdk/legacy/main.py
new file mode 100644
index 0000000..caac6d6
--- /dev/null
+++ b/examples/officialsdk/legacy/main.py
@@ -0,0 +1,96 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agentcat",
+# "mcp>=1.8,<2",
+# ]
+#
+# [tool.uv.sources]
+# agentcat = { path = "../../..", editable = true }
+# ///
+"""Example: AgentCat with the official MCP SDK 1.x (mcp.server.fastmcp.FastMCP).
+
+agentcat supports both official-SDK generations from one install; this is the
+1.x shape a large installed base still runs. The integration is identical to
+the modern example — the same track() call — only the server class and its
+serving API differ. (FastMCP 1.x takes no version parameter, so this server
+reports no version.)
+
+The PEP 723 header pins mcp to the 1.x major (>=1.8 for Streamable HTTP).
+`uv run` resolves this into its own isolated environment, so running it never
+re-syncs or destroys the project's .venv — the mcp-legacy / mcp-modern group
+conflict in pyproject.toml does not apply to script environments.
+
+Usage:
+
+ uv run --no-project examples/officialsdk/legacy/main.py
+
+Serves Streamable HTTP on http://localhost:8093/mcp.
+"""
+
+import os
+
+from mcp.server.fastmcp import FastMCP
+
+import agentcat
+
+PORT = 8093
+
+
+# A three-level call chain so error_test produces a realistic chained
+# stack trace for AgentCat's exception capture.
+def process_data(data: str) -> str:
+ if not data:
+ raise ValueError("input must not be empty")
+ raise ValueError(f"data processing failed for {data!r}: invalid payload structure")
+
+
+def validate_input(data: str) -> str:
+ try:
+ return process_data(data)
+ except ValueError as e:
+ raise ValueError("validation error") from e
+
+
+def dangerous_operation(data: str) -> str:
+ try:
+ return validate_input(data)
+ except ValueError as e:
+ raise RuntimeError("dangerous operation aborted") from e
+
+
+def main() -> None:
+ # Host and port ride on FastMCP settings kwargs in 1.x; run() reads them.
+ server = FastMCP("officialsdk-legacy-example", host="127.0.0.1", port=PORT)
+
+ # --- AgentCat: 3 lines to add analytics ---
+ project_id = (
+ os.environ.get("AGENTCAT_PROJECT_ID")
+ or os.environ.get("MCPCAT_PROJECT_ID")
+ or "proj_YOUR_PROJECT_ID"
+ )
+ agentcat.track(server, project_id)
+ # --- end AgentCat ---
+
+ @server.tool(description="Echo back the input text")
+ def echo(text: str) -> str:
+ return text
+
+ @server.tool(description="Reverse the input text")
+ def reverse(text: str) -> str:
+ return text[::-1]
+
+ @server.tool(description="Count the number of characters in the input text")
+ def count_chars(text: str) -> str:
+ return str(len(text))
+
+ @server.tool(description="Always errors — use this to test stack trace capture")
+ def error_test(text: str) -> str:
+ return dangerous_operation(text)
+
+ print(f"MCP server listening on http://localhost:{PORT}/mcp")
+ server.run(transport="streamable-http")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 48d8aad..cf92891 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "agentcat"
-version = "1.0.2"
-description = "Analytics tool for MCP (Model Context Protocol) servers and AI agents - tracks tool usage patterns and provides insights"
+version = "2.0.0"
+description = "Analytics tool for MCP (Model Context Protocol) servers, Claude Connectors, and ChatGPT Plugins - tracks tool usage patterns and provides insights"
authors = [
{ name = "AgentCat, Inc.", email = "support@agentcat.com" },
]
@@ -9,7 +9,7 @@ readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
classifiers = [
- "Development Status :: 4 - Beta",
+ "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
@@ -18,9 +18,9 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
- "mcp>=1.2.0",
+ "mcp>=1.2.0,<3",
"agentcat-api==1.0.0",
- "pydantic>=2.0.0,<2.12",
+ "pydantic>=2.0.0,<3",
"requests>=2.31.0",
]
@@ -31,7 +31,7 @@ dependencies = [
[project.optional-dependencies]
community = [
- "fastmcp>=2.7.0,!=2.9.*",
+ "fastmcp>=3.0.0,<5",
]
dev = [
"pytest>=7.0.0",
@@ -117,6 +117,11 @@ exclude = [
"dist",
]
+# The agent-facing copy in constants.py must stay byte-identical to the TS SDK
+# (tests/test_constants_copy.py), so its long literals cannot be wrapped.
+[tool.ruff.per-file-ignores]
+"src/agentcat/modules/constants.py" = ["E501"]
+
[tool.ruff.isort]
known-first-party = ["agentcat"]
@@ -126,3 +131,13 @@ dev = [
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
]
+mcp-legacy = ["mcp>=1.2.0,<2", "fastmcp>=3.0.0,<4"]
+# fastmcp-slim is fastmcp 4's own runtime package; it is listed explicitly so
+# its prerelease is permitted under prerelease = "if-necessary-or-explicit"
+# (uv only honors prerelease markers on direct dependencies).
+mcp-modern = ["mcp>=2.0.0,<3", "fastmcp>=4.0.0b1,<5", "fastmcp-slim>=4.0.0b1,<5"]
+
+[tool.uv]
+conflicts = [[{ group = "mcp-legacy" }, { group = "mcp-modern" }]]
+default-groups = ["dev", "mcp-modern"]
+prerelease = "if-necessary-or-explicit"
diff --git a/src/agentcat/__init__.py b/src/agentcat/__init__.py
index 933c5f3..97ce075 100644
--- a/src/agentcat/__init__.py
+++ b/src/agentcat/__init__.py
@@ -1,57 +1,56 @@
"""AgentCat - Analytics Tool for MCP Servers."""
import os
-import warnings
+from collections.abc import Mapping
from datetime import datetime, timezone
-from importlib.metadata import version
from typing import Any
-__version__ = version("agentcat")
+from .utils import get_agentcat_version
-from agentcat.modules.overrides.mcp_server import override_lowlevel_mcp_server
-from agentcat.modules.session import get_session_info, new_session_id
+# Guarded: on installs without distribution metadata (vendored source trees,
+# PYTHONPATH, PyInstaller bundles) the lookup fails — that must degrade the
+# version string, never crash the customer's import.
+__version__ = get_agentcat_version() or "0.0.0"
-from .modules.compatibility import (
- COMPATIBILITY_ERROR_MESSAGE,
- is_community_fastmcp_v2,
- is_community_fastmcp_v3,
- is_compatible_server,
- is_official_fastmcp_server,
-)
+from .modules.constants import AGENTCAT_CUSTOM_EVENT_TYPE
+from .modules.detection import Detection, ServerFlavor, detect_server
from .modules.diagnostics import init_diagnostics
-from .modules.internal import set_server_tracking_data
+from .modules.internal import get_server_tracking_data, set_server_tracking_data
from .modules.logging import set_debug_mode, write_to_log
+from .modules.validation import validate_tags
from .types import (
+ AgentCatData,
+ AgentCatOptions,
+ CustomEventData,
EventPropertiesFunction,
EventTagsFunction,
IdentifyFunction,
- AgentCatData,
- AgentCatOptions,
RedactionFunction,
+ ResolveSessionIdFunction,
+ UnredactedEvent,
UserIdentity,
)
-
-def _detect_stateless(server) -> bool:
- """Auto-detect stateless mode from FastMCP server settings.
-
- Best-effort: community FastMCP v3 deprecated per-instance .settings
- in favor of global fastmcp.settings, but the global isn't per-server.
- The deprecated shim is the only per-instance API available.
- AgentCatOptions(stateless=True) is the recommended explicit path.
- """
- try:
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", DeprecationWarning)
- result = server.settings.stateless_http
- if result:
- write_to_log(
- "Auto-detected stateless HTTP mode from your FastMCP server's .settings. "
- "If this is incorrect, please pass stateless=False to AgentCatOptions and file a bug report."
- )
- return result
- except (AttributeError, RuntimeError):
- return False
+# Flavors an adapter already exists for. Everything else is logged and returned
+# untracked — track() never raises, so an unsupported server degrades to a
+# no-op rather than breaking the customer's process at import/startup time.
+_LOWLEVEL_V1_FLAVORS = (ServerFlavor.LOWLEVEL_V1, ServerFlavor.OFFICIAL_FASTMCP_V1)
+_LOWLEVEL_V2_FLAVORS = (ServerFlavor.LOWLEVEL_V2, ServerFlavor.MCPSERVER_V2)
+# Both community generations share one middleware; the era selects its field
+# bridge and names the generation an installed middleware was built for. This
+# map is also the single answer to "is this a community flavor", so a later
+# generation is one entry here rather than a branch in two places.
+#
+# The values are `adapters.community.ERA_V3` / `ERA_V4`, spelled out rather
+# than imported: importing that module at module scope would pull the event
+# queue — and its worker thread, executor and signal handlers — into every
+# `import agentcat`. Every adapter import in this file is deferred for the same
+# reason. Only the V4 pairing is pinned by a test that runs
+# (`tests/test_community_v4_handles.py`); the V3 one is not, because the suite
+# that could assert it is collected under the OTHER mcp major, where `ERA_V3`
+# is unreachable from a modern-env test. The era is a log/diagnostic label, so
+# a wrong pairing would misreport rather than misbehave.
+_COMMUNITY_ERAS = {ServerFlavor.COMMUNITY_V3: 3, ServerFlavor.COMMUNITY_V4: 4}
def track(
@@ -66,11 +65,13 @@ def track(
options: Configuration options including telemetry exporters
Returns:
- The server instance with tracking enabled
+ The server instance, tracked when its shape is one AgentCat supports.
- Raises:
- ValueError: If neither project_id nor exporters are provided
- TypeError: If server is not a compatible MCP server instance
+ Never raises. A misconfiguration (no project_id and no exporters), an
+ unsupported server generation, or an unrecognized object is logged to
+ ~/agentcat.log and mirrored to SDK diagnostics; the server comes back
+ untracked so an analytics problem can never take a customer's MCP server
+ down. This is a 2.0 breaking change — 1.x raised ValueError/TypeError.
Example:
Attach custom metadata to every auto-captured event using
@@ -84,175 +85,367 @@ def track(
... event_properties=lambda req, ctx: {"feature_flags": ["dark_mode"]},
... ))
"""
- if options is None:
- options = AgentCatOptions()
+ try:
+ if options is None:
+ options = AgentCatOptions()
- set_debug_mode(options.debug_mode)
+ if options.debug_mode is not None:
+ set_debug_mode(options.debug_mode)
- # Initialize internal diagnostics before anything can fail, so even an
- # invalid setup still emits a failure beacon. Never throws into the host.
- init_diagnostics(project_id, disabled=options.disable_diagnostics)
+ # Initialize internal diagnostics before anything can fail, so even an
+ # invalid setup still emits a failure beacon. Never throws into the host.
+ init_diagnostics(project_id, disabled=options.disable_diagnostics)
+
+ _apply_tracking(server, project_id, options)
+ except Exception as e:
+ write_to_log(f"Error initializing AgentCat: {e}")
+
+ return server
- # Wrap the whole setup so any failure emits a diagnostic. Config-contract
- # errors (ValueError/TypeError) still propagate; tracking-application errors
- # are logged but never break the host (server is still returned).
- try:
- if not project_id and not options.exporters:
- raise ValueError(
- "Either project_id or exporters must be provided. "
- "Use project_id for AgentCat, exporters for telemetry-only mode, or both."
- )
- if not is_compatible_server(server):
- raise TypeError(COMPATIBILITY_ERROR_MESSAGE)
-
- is_community_v3 = is_community_fastmcp_v3(server)
- is_community_v2 = is_community_fastmcp_v2(server)
- is_official_fastmcp = is_official_fastmcp_server(server)
- is_fastmcp_v2 = is_official_fastmcp or is_community_v2
-
- # Determine where to store tracking data:
- # - v2 FastMCP servers use server._mcp_server
- # - v3 and low-level servers use the server itself
- if is_fastmcp_v2:
- lowlevel_server = server._mcp_server
- else:
- lowlevel_server = server
-
- # Metadata-only setup-started beacon (INFO — no fail/error/Warning).
- server_kind = (
- "fastmcp-v2"
- if is_fastmcp_v2
- else "fastmcp-v3"
- if is_community_v3
- else "lowlevel"
+def _apply_tracking(
+ server: Any, project_id: str | None, options: AgentCatOptions
+) -> None:
+ """Detect the server's shape and install the matching adapter."""
+ if not project_id and not options.exporters:
+ write_to_log(
+ "Warning: Failed to track server - neither project_id nor exporters "
+ "were provided. Use project_id for AgentCat, exporters for "
+ "telemetry-only mode, or both. Server returned untracked."
)
+ return
+
+ detection = detect_server(server)
+ is_lowlevel_v1 = detection.flavor in _LOWLEVEL_V1_FLAVORS
+ is_lowlevel_v2 = detection.flavor in _LOWLEVEL_V2_FLAVORS
+
+ community_era = _COMMUNITY_ERAS.get(detection.flavor)
+
+ if is_lowlevel_v1 or is_lowlevel_v2:
+ # The adapter wraps the lowlevel object, which for official FastMCP is
+ # its `_mcp_server` and for MCPServer its `_lowlevel_server` — that is
+ # also where the tracking data is keyed.
+ target = detection.lowlevel
+ elif community_era is not None:
+ # Community flavors are adapted via middleware on the server itself.
+ target = server
+ else:
+ _log_unsupported(detection)
+ return
+
+ write_to_log(
+ f"AgentCat setup started | project {project_id or '(telemetry-only)'} | "
+ f"server {detection.flavor.value}"
+ )
+
+ if options.exporters:
+ from agentcat.modules.event_queue import set_telemetry_manager
+ from agentcat.modules.telemetry import TelemetryManager
+
+ set_telemetry_manager(TelemetryManager(options.exporters))
+ write_to_log(f"Telemetry initialized with {len(options.exporters)} exporter(s)")
+
+ # Server identity is captured once, here: it is static for the life of the
+ # server and there is no session cache left to re-read it from at publish
+ # time.
+ data = AgentCatData(
+ project_id=project_id,
+ options=options,
+ server_name=getattr(target, "name", None),
+ server_version=getattr(target, "version", None),
+ )
+ set_server_tracking_data(target, data)
+
+ # Resolve API base URL: option > new env var > legacy env var > default
+ api_base_url = (
+ options.api_base_url
+ or os.environ.get("AGENTCAT_API_URL")
+ or os.environ.get("MCPCAT_API_URL")
+ )
+ if api_base_url:
+ from agentcat.modules.event_queue import event_queue
+
+ event_queue.configure(api_base_url)
+
+ # The high-level object that owns the lowlevel one, when the two differ.
+ # Only the inner tap needs it: on an official FastMCP or an MCPServer the
+ # customer's tool sits below a tool manager the lowlevel object cannot
+ # reach, and that is where a raised exception has to be recorded before the
+ # SDK flattens it into an isError result.
+ facade = server if server is not target else None
+
+ if is_lowlevel_v1:
+ from agentcat.modules.adapters.lowlevel_v1 import install_lowlevel_v1
+
+ install_lowlevel_v1(target, data, facade)
+ elif is_lowlevel_v2:
+ from agentcat.modules.adapters.lowlevel_v2 import install_lowlevel_v2
+
+ install_lowlevel_v2(target, data, facade)
+ elif community_era is not None:
+ from agentcat.modules.adapters.community import install_community
+
+ install_community(server, data, era=community_era)
+
+ # Metadata-only setup-complete beacon (INFO). A start-without-complete
+ # signals a failed setup.
+ write_to_log(
+ f"AgentCat setup complete | project {project_id or '(telemetry-only)'} | "
+ f"tracing={options.enable_tracing} "
+ f"context={options.enable_tool_call_context} "
+ f"report_missing={options.enable_report_missing} "
+ f"agent_tracking={options.enable_agent_tracking} "
+ f"hook_mode={options.resolve_session_id is not None} "
+ f"exporters={len(options.exporters) if options.exporters else 0}"
+ )
+
+
+def _log_unsupported(detection: Detection) -> None:
+ """Explain why a server was not tracked, loudly enough to reach diagnostics.
+
+ write_to_log tees every entry to the SDK diagnostics sink, so the
+ unrecognized-shape line below IS the fleet-drift beacon: the full probe
+ fingerprint travels with it, which is how a new upstream server shape gets
+ noticed across the installed base (cross-SDK changelog 6.7).
+
+ Every flavor the classifier can name now has an adapter, so the only two
+ outcomes left are the one shape that is deliberately refused (FastMCP 2.x)
+ and the fingerprint beacon. A generation newer than this build classifies
+ as UNKNOWN rather than as itself, so it reaches the beacon — which carries
+ more than a "not supported yet" line could.
+ """
+ if detection.flavor is ServerFlavor.COMMUNITY_V2_UNSUPPORTED:
write_to_log(
- f"AgentCat setup started | project {project_id or '(telemetry-only)'} | "
- f"server {server_kind}"
+ "Warning: Failed to track server - FastMCP 2.x is not supported by "
+ "agentcat>=2. Pin agentcat<2 or upgrade to FastMCP 3.x or 4.x. "
+ "Server returned untracked."
+ )
+ else:
+ write_to_log(
+ "Warning: Failed to track server - Unrecognized MCP server shape; "
+ f"server returned untracked | fingerprint={detection.fingerprint}"
)
- if options.exporters:
- from agentcat.modules.event_queue import set_telemetry_manager
- from agentcat.modules.telemetry import TelemetryManager
- telemetry_manager = TelemetryManager(options.exporters)
- set_telemetry_manager(telemetry_manager)
- write_to_log(
- f"Telemetry initialized with {len(options.exporters)} exporter(s)"
- )
+def publish_custom_event(
+ server_or_session_id: Any,
+ project_id: str,
+ event_data: CustomEventData | None = None,
+) -> None:
+ """Publish one `agentcat:custom` event for work that is not a tool call.
- session_id = new_session_id()
- session_info = get_session_info(lowlevel_server)
- data = AgentCatData(
- session_id=session_id,
- project_id=project_id,
- last_activity=datetime.now(timezone.utc),
- session_info=session_info,
- options=options,
- is_stateless=options.stateless if options.stateless is not None else _detect_stateless(server),
- )
- set_server_tracking_data(lowlevel_server, data)
+ A background job, a webhook, a checkout step — anything the customer wants
+ on the same timeline as the tool calls AgentCat captures automatically.
- # Resolve API base URL: option > new env var > legacy env var > default
- api_base_url = (
- options.api_base_url
- or os.environ.get("AGENTCAT_API_URL")
- or os.environ.get("MCPCAT_API_URL")
- )
- if api_base_url:
- from agentcat.modules.event_queue import event_queue
- event_queue.configure(api_base_url)
+ Args:
+ server_or_session_id: a tracked MCP server, or a session ID string. A server
+ tracked with `enable_tracing=False` publishes nothing, the same
+ silence its tool calls keep; a session ID string carries no options to
+ consult, so that form always publishes.
+ project_id: the AgentCat project. Used as-is in the session-ID-string
+ form; in the tracked-server form the project captured at `track()`
+ time wins, so a telemetry-only server still publishes.
+ event_data: optional `CustomEventData`. `session_id` attributes the event
+ to a session **verbatim** — never validated, trimmed, prefixed or
+ derived, since it is a deliberate server-side call rather than an
+ agent's guess, so a
+ caller's own correlation ID arrives exactly as given. It takes
+ precedence over a session ID string. Without one the event publishes
+ without a session: handles are per-request in v2, so a tracked server
+ has no ambient session to fall back on.
+
+ Fire-and-forget: never raises, and never lets one unusable field cost the
+ whole event. Two different rules get it there. A `parameters`, `response`
+ or `properties` value that is not a dict is WRAPPED — `response="shipped"`
+ is recorded as `{"value": "shipped"}` — because the wire field holds an
+ object and the payload is the caller's to keep (a non-dict `error` becomes
+ `{"message": ...}`, the shape its consumers read). A mistyped
+ `resource_name`, `message`, `duration`, `is_error` or `tags` is DROPPED,
+ because those wire fields are strictly typed and a near miss carries no
+ payload worth preserving. Both cases log; the event publishes either way.
+
+ TS parity — `publishCustomEvent`, design §3.4.
- if not data.tracker_initialized:
- data.tracker_initialized = True
- write_to_log(
- f"Dynamic tracking initialized for server {id(lowlevel_server)}"
- )
+ Example:
+ >>> import agentcat
+ >>> agentcat.publish_custom_event(server, "proj_abc123", {
+ ... "session_id": "ses_2cOHEO0LYGADMzRvWTXXVbbgxgm",
+ ... "resource_name": "checkout",
+ ... "message": "order confirmed",
+ ... })
+ """
+ try:
+ _publish_custom_event(server_or_session_id, project_id, event_data)
+ except Exception as e:
+ write_to_log(f"Warning: failed to publish custom event - {e}")
- _apply_server_tracking(
- server, lowlevel_server, data,
- is_community_v3, is_official_fastmcp, is_community_v2
+
+def _publish_custom_event(
+ server_or_session_id: Any, project_id: str, event_data: CustomEventData | None
+) -> None:
+ """The body of `publish_custom_event`, minus the never-raises wrapper."""
+ # Imported here, not at module scope: importing the event queue constructs
+ # the global queue (worker thread, executor, signal handlers), which must
+ # not happen merely because someone imported `agentcat`.
+ from agentcat.modules import event_queue as queue_module
+ from agentcat.utils import get_agentcat_version
+
+ data: Mapping[str, Any] = event_data if isinstance(event_data, dict) else {}
+ if event_data is not None and not isinstance(event_data, dict):
+ write_to_log(
+ "publish_custom_event: event_data is not a dict "
+ f"(got {type(event_data).__name__}); publishing without it"
)
- if project_id:
+ session_id = _verbatim_session_id(data.get("session_id"))
+ project = _wire_scalar("project_id", project_id, str)
+ server: Any = None
+ if isinstance(server_or_session_id, str):
+ session_id = session_id or server_or_session_id
+ if not project:
+ write_to_log(
+ "publish_custom_event: project_id is required when the first "
+ "argument is a session ID string; event dropped"
+ )
+ return
+ else:
+ server = server_or_session_id
+ tracking = _tracking_data(server)
+ if tracking is None:
write_to_log(
- f"AgentCat initialized with dynamic tracking for session "
- f"{session_id} on project {project_id}"
+ "publish_custom_event: first argument is neither a tracked "
+ f"server nor a session ID string (got {type(server).__name__}); "
+ "event dropped. Call agentcat.track() first, or pass a session ID."
)
- else:
+ return
+ if not tracking.options.enable_tracing:
+ # Every other event type is gated on this inside the adapters, and
+ # TS gates it inside publishEvent itself; Python's publish_event
+ # has no gate, so this entry point carries its own. A customer who
+ # turned tracing off does not start emitting a new event type.
write_to_log(
- f"AgentCat initialized in telemetry-only mode for session {session_id}"
+ "publish_custom_event: tracing is disabled for this server "
+ "(enable_tracing=False); event dropped"
)
+ return
- # Metadata-only setup-complete beacon (INFO). A start-without-complete
- # (or the ERROR diagnostics below) signals a failed setup.
+ tags = data.get("tags")
+ if tags is not None and not isinstance(tags, dict):
write_to_log(
- f"AgentCat setup complete | project {project_id or '(telemetry-only)'} | "
- f"tracing={options.enable_tracing} "
- f"context={options.enable_tool_call_context} "
- f"report_missing={options.enable_report_missing} "
- f"exporters={len(options.exporters) if options.exporters else 0}"
+ "publish_custom_event: dropping 'tags' - expected dict, "
+ f"got {type(tags).__name__}"
)
+ tags = None
+
+ error = data.get("error")
+ if error is not None and not isinstance(error, dict):
+ # `error` has a known shape (`ErrorData`) and consumers read its
+ # message, so a bare value becomes one rather than a nested payload.
+ error = {"message": str(error)}
+
+ event = UnredactedEvent(
+ session_id=session_id or None,
+ project_id=project or None,
+ event_type=AGENTCAT_CUSTOM_EVENT_TYPE,
+ timestamp=datetime.now(timezone.utc),
+ resource_name=_wire_scalar("resource_name", data.get("resource_name"), str),
+ user_intent=_wire_scalar("message", data.get("message"), str),
+ duration=_wire_scalar("duration", data.get("duration"), int),
+ is_error=_wire_scalar("is_error", data.get("is_error"), bool),
+ parameters=_wire_payload("parameters", data.get("parameters")),
+ response=_wire_payload("response", data.get("response")),
+ properties=_wire_payload("properties", data.get("properties")),
+ error=error,
+ tags=validate_tags(tags) if isinstance(tags, dict) else None,
+ sdk_language=queue_module.SDK_LANGUAGE,
+ agentcat_version=get_agentcat_version(),
+ )
+
+ if server is not None:
+ # The tracked path stamps the project, the server identity captured at
+ # track() time and the customer's redaction hook — the same publish
+ # every tool-call event goes through.
+ queue_module.publish_event(server, event)
+ else:
+ queue_module.event_queue.add(event)
- except (ValueError, TypeError) as e:
- # Config-contract failures: emit a failure diagnostic, then propagate so
- # callers still see the error (preserves existing public behavior).
- write_to_log(f"Warning: Failed to track server - {e}")
- raise
- except Exception as e:
- write_to_log(f"Error initializing AgentCat: {e}")
+ where = (
+ f"for session {event.session_id}" if event.session_id else "without a session"
+ )
+ write_to_log(f"Published custom event {where} | {AGENTCAT_CUSTOM_EVENT_TYPE}")
- return server
+def _tracking_data(server: Any) -> AgentCatData | None:
+ """This server's tracking data, or None if it is not a server at all.
-def _apply_server_tracking(
- server: Any,
- lowlevel_server: Any,
- data: AgentCatData,
- is_community_v3: bool,
- is_official_fastmcp: bool,
- is_community_v2: bool,
-) -> None:
- """Apply the appropriate tracking method based on server type."""
- if is_community_v3:
- from agentcat.modules.overrides.community_v3.integration import (
- apply_community_v3_integration,
- )
+ The lookup is keyed by weak reference, so an int, a string or a dict raises
+ rather than missing — all three mean the same thing here.
+ """
+ try:
+ return get_server_tracking_data(server)
+ except Exception:
+ return None
- apply_community_v3_integration(server, data)
- write_to_log(
- f"Applied Community FastMCP v3 middleware for server {id(server)}"
- )
- elif is_official_fastmcp:
- from agentcat.modules.overrides.mcp_server import (
- override_lowlevel_mcp_server_minimal,
- )
- from agentcat.modules.overrides.official.monkey_patch import (
- apply_official_fastmcp_patches,
- )
+def _verbatim_session_id(value: Any) -> str | None:
+ """`event_data["session_id"]` if it can be sent as-is, else nothing.
- apply_official_fastmcp_patches(server, data)
- override_lowlevel_mcp_server_minimal(lowlevel_server, data)
+ Verbatim cuts both ways: a non-string is not stringified into a session ID the
+ caller never chose, it is dropped and the event lands untethered.
+ """
+ if value is None or isinstance(value, str):
+ return value
+ write_to_log(
+ f"publish_custom_event: ignoring non-string session_id ({type(value).__name__})"
+ )
+ return None
- elif is_community_v2:
- from agentcat.modules.overrides.community.monkey_patch import (
- patch_community_fastmcp,
- )
- patch_community_fastmcp(server)
- write_to_log(f"Applied Community FastMCP v2 patches for server {id(server)}")
+def _wire_scalar(field: str, value: Any, kind: type) -> Any:
+ """A value the strict wire field can hold, or None.
- else:
- override_lowlevel_mcp_server(lowlevel_server, data)
+ `PublishEventRequest` types these `StrictStr`/`StrictInt`/`StrictBool`, so a
+ near miss (`1` for a bool, `12.5` for an int) fails construction and takes
+ the entire event with it. One unusable field is not worth an event.
+ """
+ if value is None or (
+ isinstance(value, kind) and (kind is bool or not isinstance(value, bool))
+ ):
+ return value
+ write_to_log(
+ f"publish_custom_event: dropping '{field}' - expected {kind.__name__}, "
+ f"got {type(value).__name__}"
+ )
+ return None
+
+
+def _wire_payload(field: str, value: Any) -> dict[str, Any] | None:
+ """A dict for the dict-shaped wire fields, wrapping whatever is not one.
+
+ `parameters`/`response`/`properties` are `Optional[Dict[str, Any]]` while
+ `CustomEventData` types them `Any`, so a string or a list is a plausible
+ input rather than a bug. The adapters discard a non-dict `response`
+ (`adapters/_common.response_payload`) because there it is a serialization
+ anomaly with no author; here it is exactly what the caller asked to record,
+ so it is kept.
+ """
+ if value is None or isinstance(value, dict):
+ return value
+ write_to_log(
+ f"publish_custom_event: wrapping non-dict '{field}' "
+ f"({type(value).__name__}) as {{'value': ...}}"
+ )
+ return {"value": value}
__all__ = [
# Main API
"track",
+ "publish_custom_event",
# Configuration
"AgentCatOptions",
+ # Type for publish_custom_event payloads
+ "CustomEventData",
# Types for identify functionality
"UserIdentity",
"IdentifyFunction",
@@ -261,4 +454,5 @@ def _apply_server_tracking(
# Types for event metadata callbacks
"EventTagsFunction",
"EventPropertiesFunction",
+ "ResolveSessionIdFunction",
]
diff --git a/src/agentcat/modules/__init__.py b/src/agentcat/modules/__init__.py
index 4a0d2a2..ae27987 100644
--- a/src/agentcat/modules/__init__.py
+++ b/src/agentcat/modules/__init__.py
@@ -1,28 +1,15 @@
"""AgentCat modules."""
-from .compatibility import is_compatible_server, is_official_fastmcp_server
-from .context_parameters import (
- add_context_parameter_to_schema,
- add_context_parameter_to_tools,
-)
from .internal import get_server_tracking_data, set_server_tracking_data
from .logging import write_to_log
from .tools import handle_report_missing
__all__ = [
- # Compatibility
- "is_compatible_server",
- "is_official_fastmcp_server",
- # Context parameters
- "add_context_parameter_to_schema",
- "add_context_parameter_to_tools",
# Internal
"get_server_tracking_data",
"set_server_tracking_data",
# Logging
"write_to_log",
- # Redaction
- # Session
# Tools
"handle_report_missing",
]
diff --git a/src/agentcat/modules/adapters/__init__.py b/src/agentcat/modules/adapters/__init__.py
new file mode 100644
index 0000000..a8d9bb6
--- /dev/null
+++ b/src/agentcat/modules/adapters/__init__.py
@@ -0,0 +1,11 @@
+"""Era-specific wiring between a customer's MCP server and the v2 engine.
+
+One adapter per server generation. Each knows only the mechanics of its era —
+which field holds the dispatched handler, how results are wrapped, whether the
+model fields are camelCase or snake_case — and delegates every orchestration
+decision (resolve / strip / decorate / publish) to
+`agentcat.modules.callpath`.
+
+Adapters import `mcp` / `fastmcp` symbols inside their install function, never
+at module scope, so `import agentcat` succeeds under either SDK major.
+"""
diff --git a/src/agentcat/modules/adapters/_common.py b/src/agentcat/modules/adapters/_common.py
new file mode 100644
index 0000000..4bc5384
--- /dev/null
+++ b/src/agentcat/modules/adapters/_common.py
@@ -0,0 +1,123 @@
+"""Helpers every adapter needs, in one place so they cannot drift apart.
+
+A few small things, extracted because four adapters (lowlevel v1/v2, community
+v3/v4) each want them and two of them encode rules that must never differ
+between eras: a non-dict ``response`` silently drops the whole event, and
+per-server install state must not outlive the server.
+
+Nothing version-specific from ``mcp``/``fastmcp`` is imported here, so this
+module loads under either SDK major.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from agentcat.modules.internal import get_server_tracking_data
+from agentcat.modules.logging import write_to_log
+from agentcat.types import AgentCatData
+
+# Where an adapter's per-server install state is kept: on the server, in one
+# private attribute. See `install_state`.
+STATE_ATTR = "_agentcat_install_state"
+
+
+def now_ms() -> int:
+ """A monotonic millisecond stamp for measuring one call's duration."""
+ return int(time.monotonic() * 1000)
+
+
+def install_state(server: Any) -> dict[str, Any] | None:
+ """This server's install state, created on first use. None if unstorable.
+
+ Every value an adapter puts in here closes over the server — the handler
+ wrappers do, and so does a customer handler bound to a
+ ``FastMCP``/``MCPServer`` facade that owns the lowlevel object — so a
+ module-level ``WeakKeyDictionary`` would be kept alive by its own values
+ and the weak key would never die.
+
+ That is not a bounded cost on the topology this generation documents: 2026
+ factories build a fresh server per request and ``track()`` runs inside the
+ factory, with the explicit requirement that "per-server state lives in maps
+ that don't outlive the server object" (cross-SDK changelog §6.8). A
+ module-level map means one immortal server — plus its handler table, its
+ deep-copied tool schemas, its ``AgentCatData`` and its injection registries
+ — PER REQUEST, which is a linear leak.
+
+ Held on the server, the state's lifetime is exactly the server's. The
+ reference cycle it forms with the wrappers is the same one the server's own
+ handler table already holds, and the cyclic collector takes both.
+
+ What the state is FOR is idempotence: a repeated ``track()`` finds its
+ predecessor's wrapper in the handler table and takes the customer's handler
+ from the recorded original instead of wrapping the wrapper. A stacked pass
+ would inject session_id on the inside, find it already present on the outside,
+ never record it as strippable, and hand the customer's tool a parameter it
+ never declared.
+ """
+ existing = getattr(server, STATE_ATTR, None)
+ if isinstance(existing, dict):
+ return existing
+ state: dict[str, Any] = {}
+ try:
+ setattr(server, STATE_ATTR, state)
+ except Exception as e:
+ # Nothing that reaches an adapter can refuse an attribute — re-arming
+ # sets one too — but a server that did would leave no way to recognize
+ # our own wrapper on a second track(), and a stacked wrapper is worse
+ # than no tracking.
+ write_to_log(
+ "Warning: could not store AgentCat state on this server, so a "
+ f"repeated track() could not stay idempotent; not tracking it - {e}"
+ )
+ return None
+ return state
+
+
+def current_tracking_data(server: Any, fallback: AgentCatData) -> AgentCatData:
+ """The tracking data as of this request, so a re-``track()`` takes effect.
+
+ Adapters must never capture the data at install time: a second
+ ``track(server, ...)`` stores a fresh ``AgentCatData``, and a handler still
+ reading the first one would serve stale options forever. ``fallback`` is
+ the install-time data, used only if the lookup finds nothing.
+ """
+ try:
+ live = get_server_tracking_data(server)
+ except Exception:
+ live = None
+ return live if live is not None else fallback
+
+
+def response_payload(result: Any) -> dict[str, Any] | None:
+ """A JSON-safe dict for the event's ``response`` field, or None.
+
+ ``PublishEventRequest.response`` is ``Optional[Dict[str, Any]]``: handing
+ it anything else fails pydantic construction and silently drops the whole
+ event, so a non-dict dump is discarded here instead.
+
+ **The dump is era-native, deliberately.** No ``by_alias``, so the keys are
+ whatever the result model this generation produced calls them: official
+ mcp 1.x publishes ``isError`` / ``structuredContent``, while mcp 2.x and
+ both community eras publish ``is_error`` / ``structured_content``. The
+ divergence is RULED IN, not an oversight:
+
+ - it is not a regression — v1 community already published FastMCP's
+ snake_case dump, so normalizing would CHANGE data the backend has been
+ receiving rather than fix it;
+ - ``to_mcp_result()`` is not a drop-in normalizer: it returns a bare list
+ or a tuple for a non-error result, and this field must be a dict;
+ - the wire result the agent receives is untouched either way. This is the
+ analytics payload, not the tool's answer.
+
+ ``tests/test_response_shape.py`` pins the actual spelling per flavor.
+ Adding ``by_alias`` here is a breaking change to published data; change
+ this ruling first if you mean to make it.
+ """
+ try:
+ dumped = result.model_dump(mode="json")
+ except Exception as e:
+ write_to_log(f"Warning: could not serialize tool result for the event - {e}")
+ return None
+ return dumped if isinstance(dumped, dict) else None
diff --git a/src/agentcat/modules/adapters/_inner_tap.py b/src/agentcat/modules/adapters/_inner_tap.py
new file mode 100644
index 0000000..b6ee951
--- /dev/null
+++ b/src/agentcat/modules/adapters/_inner_tap.py
@@ -0,0 +1,390 @@
+"""The inner tap: full Python exception detail on tool-error events.
+
+v2 intercepts at the protocol boundary, and by the time an adapter sees a
+failed ``tools/call`` most SDK generations have already caught the exception
+and flattened it into an ``isError`` result. `mcp/server/lowlevel/server.py`
+does it in the handler its ``@call_tool()`` decorator builds; `MCPServer`
+does it in ``_handle_call_tool``. Either way the exception object dies inside
+that ``except`` — nothing on the returned result retains its type, traceback
+or ``__cause__``. Without a tap the only honest payload left is the three-key
+fallback ``capture_call_tool_result_error`` produces.
+
+This module is the tap. It is the Python answer to the TypeScript engine's
+``src/engine/innerTap.ts``: something *below* the conversion records the live
+exception, and the adapter's publish path picks it up for that same call.
+
+**The contract** — the same on every era, however the tap is placed:
+
+- `inner_tap()` opens a capture slot for exactly one tool call. Enter it
+ before the customer's handler runs; the slot closes on every exit path,
+ including the error path, because a context manager cannot skip ``__exit__``.
+- `capture()` / `capture_in_flight()` record into whatever slot is open for
+ the *calling* call, and are no-ops when none is.
+- `InnerTap.error()` is the adapter's read: the tapped exception when there
+ was one, the flattened result when there was not (a proxied upstream error
+ and a tool that simply returned ``is_error`` have no local exception to
+ find, and never will).
+
+**Which exception, when a tool composes another tool.** A tool that calls a
+tool re-enters a tapped seam, so one slot can see more than one failure — and
+the event must describe the one the AGENT was told about, never a sub-call's
+that the caller handled. Two rules settle it, and both are needed:
+
+- **The last write wins.** A sub-call's failure is recorded, then the caller's
+ own failure is recorded after it and replaces it. (The caller re-raising the
+ same object, or a wrapper around it, is the same rule.) An earlier design
+ kept the FIRST write and published a swallowed sub-call's exception against
+ the caller's message.
+- **A capture that never escaped is discarded.** A seam that returns *normally*
+ drops any capture recorded inside its own dynamic extent: the exception was
+ handled in there, so it did not produce this result. That is what covers the
+ caller who suppresses a sub-call and then answers with an ``is_error`` result
+ of its own rather than raising. A capture the seam re-raised is untouched,
+ which is what keeps the community case — a middleware BELOW us converting a
+ real tool failure into an ``is_error`` result — working.
+
+ "Inside its own extent" is answered per SEAM, never per nesting level. Each
+ entry into a seam takes a fresh marker and pushes it onto a per-task chain,
+ and a capture remembers the chain it was recorded under; a normal return
+ discards only a capture whose chain names that entry. A depth counter cannot
+ answer the same question, because the counter has to live on the cell — and
+ the cell is shared with every task that inherits it, so a sub-call the tool
+ left running inflated the count and then erased a genuine capture on its own
+ normal return. The chain is a ``ContextVar``, so a child task's pushes are
+ invisible to its parent and to its siblings, which is exactly the isolation
+ the question needs.
+
+**Why a cell and not a "last error" variable.** The slot is an object created
+inside the adapter's own frame; the `ContextVar` only carries a *reference* to
+it downward, and the adapter reads its own local cell rather than the
+variable. Two properties follow, and both matter:
+
+1. *Cross-attribution is structurally impossible.* Two tool calls can only
+ interleave if they are separate asyncio tasks, and a task runs in its own
+ copy of the context — so `set()` in one call is invisible to the other, and
+ each tap writes to the cell its own call installed. There is no shared slot
+ to race for.
+2. *A capture from a child task or worker thread still lands.* Contexts are
+ copied downward, so a `ContextVar.set()` inside a child would be invisible
+ to the parent that must read it — which is exactly what the retired
+ ``store_captured_error`` did. Writing to the cell OBJECT is visible to
+ whoever holds it, and `anyio.to_thread` (how both FastMCP generations run a
+ sync tool body) copies the context into the worker thread.
+
+ The same direction has a bounded cost worth naming: a background task the
+ customer's tool spawns and does not await inherits a context that still
+ references this call's cell, so a failure in it can be recorded after the
+ slot closed — retaining that exception and its traceback for as long as the
+ child lives. Nothing reads the cell after `__exit__`, so it cannot reach an
+ event; it is a lifetime footnote, not a correctness one.
+
+**Never alters what the customer's server does.** The wrapping form catches,
+records and re-raises the same exception object with a bare ``raise``; the
+probing form only reads `sys.exc_info()` and returns the original's own
+result. A tap that fails is logged and degrades to the no-tap payload; it
+never raises into the customer's server.
+
+Nothing version-specific from ``mcp``/``fastmcp`` is imported here, so this
+module loads under either SDK major. WHERE the tap is placed is era-specific
+and belongs to each adapter; WHAT it records does not, and belongs here.
+"""
+
+from __future__ import annotations
+
+import contextvars
+import inspect
+import sys
+from collections.abc import Awaitable, Callable
+from types import TracebackType
+from typing import Any
+
+from agentcat.modules.exceptions import capture_exception
+from agentcat.modules.logging import write_to_log
+from agentcat.types import ErrorData
+
+
+class _Cell:
+ """One tool call's exception slot.
+
+ ``baseline`` is whatever exception was already being handled when the slot
+ opened. `capture_in_flight` reads `sys.exc_info`, which answers for the
+ whole stack rather than one frame, so a conversion site reached with no
+ exception of its own would otherwise record an older frame's — the SDK
+ calls ``_make_error_result`` for a bad return type too, outside any
+ ``except``. Remembering the baseline is how that reads as "nothing to
+ record" instead of as someone else's failure.
+
+ ``exc_seams`` is the chain of seam entries that were open, in this task,
+ when the stored exception was recorded. It is what answers "did this
+ failure actually escape?" — see the module docstring. Nothing here counts:
+ the cell is shared with every task that inherits it, so a count would be
+ everyone's and an identity is only its own.
+ """
+
+ __slots__ = ("exc", "exc_seams", "baseline")
+
+ def __init__(self, baseline: BaseException | None) -> None:
+ self.exc: BaseException | None = None
+ self.exc_seams: tuple[object, ...] = ()
+ self.baseline = baseline
+
+ def record(self, exc: BaseException) -> None:
+ """Store ``exc`` as this call's failure so far. Last write wins."""
+ self.exc = exc
+ self.exc_seams = _open_seams.get()
+
+ def discard_unescaped(self, seam: object) -> None:
+ """Drop a capture that ``seam`` handled inside itself.
+
+ Called when that seam entry returns NORMALLY: a capture whose chain
+ names it was raised within it and did not come back out, so it did not
+ produce the result the agent is about to be handed. A capture from a
+ sibling — sequential or concurrent — does not name it, and survives to
+ be settled by whichever seam actually encloses them both.
+ """
+ if self.exc is not None and seam in self.exc_seams:
+ self.exc = None
+ self.exc_seams = ()
+
+
+# Holds a reference to the innermost open cell. Never the exception itself:
+# see the module docstring for why that distinction is the whole design.
+_open_cell: contextvars.ContextVar[_Cell | None] = contextvars.ContextVar(
+ "agentcat_inner_tap", default=None
+)
+
+# The seam entries currently open in THIS task, outermost first. A ContextVar
+# rather than a field on the cell, because the question it answers is about one
+# task's call stack and the cell is shared with every task that inherits it.
+_open_seams: contextvars.ContextVar[tuple[object, ...]] = contextvars.ContextVar(
+ "agentcat_inner_tap_seams", default=()
+)
+
+
+class InnerTap:
+ """The capture slot for one ``tools/call``. Use via `inner_tap`."""
+
+ __slots__ = ("_cell", "_token")
+
+ def __init__(self) -> None:
+ self._cell = _Cell(_in_flight())
+ self._token: contextvars.Token[_Cell | None] | None = None
+
+ def __enter__(self) -> InnerTap:
+ self._token = _open_cell.set(self._cell)
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ tb: TracebackType | None,
+ ) -> None:
+ # Every exit path, the raise included: a token reset restores exactly
+ # the cell that was open before, so a nested call (a tool that calls
+ # its own server) hands the slot back to its caller rather than
+ # clearing it.
+ if self._token is not None:
+ _open_cell.reset(self._token)
+ self._token = None
+
+ @property
+ def captured(self) -> BaseException | None:
+ """The exception a tap recorded for this call, if one did."""
+ return self._cell.exc
+
+ def error(self, flattened: Any) -> ErrorData:
+ """The event's ``error`` payload for a call that failed.
+
+ The tapped exception when there is one — real type, formatted stack,
+ stack frames and the ``__cause__`` chain the SDK wrapped it in.
+ Otherwise ``flattened``, whatever the adapter could make of the result:
+ an upstream error a proxy passed through, or a tool that returned
+ ``is_error`` without anything having been raised at all, both of which
+ have no local exception and never will.
+ """
+ # `is not None`, not truthiness: an exception class that defines
+ # `__len__` or `__bool__` can be falsy, and losing its traceback to
+ # that would be a very quiet bug.
+ exc = self._cell.exc
+ return capture_exception(exc if exc is not None else flattened)
+
+
+def inner_tap() -> InnerTap:
+ """Open a capture slot around one tool call.
+
+ >>> with inner_tap() as tap: # doctest: +SKIP
+ ... result = await run_the_customers_handler()
+ ... error = tap.error(result) if is_error(result) else None
+ """
+ return InnerTap()
+
+
+def capture(exc: BaseException) -> None:
+ """Record ``exc`` as the failure behind the call currently in flight.
+
+ Last write wins: a tool that composes another tool sees the sub-call's
+ failure first and its own second, and the event has to describe the one the
+ agent was told about. A capture that turns out never to have escaped is
+ discarded separately, by `tapped`.
+ """
+ try:
+ cell = _open_cell.get()
+ if cell is not None:
+ cell.record(exc)
+ except Exception as e: # pragma: no cover - defensive
+ write_to_log(f"Warning: inner tap could not record an exception - {e}")
+
+
+def capture_in_flight(surfaced_message: str) -> None:
+ """Record the exception being handled right now, if the SDK is surfacing it.
+
+ For a conversion site that keeps only ``str(e)``: called from inside the
+ SDK's own ``except`` block, `sys.exc_info` still holds the live exception
+ with its traceback. Costs the customer nothing — no wrapper frame, no
+ re-raise — so it is preferred wherever a conversion site can be observed
+ directly.
+
+ ``surfaced_message`` is the text the site is about to put on the wire, and
+ the capture is taken **only when it is exactly ``str(exc)``**. One site on
+ lowlevel v1 hands over the exception's own message
+ (``_make_error_result(str(e))``); the schema-validation sites hand over a
+ sentence the SDK composed instead (``f"Input validation error: …"``).
+ Recording those would replace the one-line message the agent saw with a
+ multi-line ``jsonschema`` dump that embeds the schema and the offending
+ argument value — a payload change, on a common error class, that no one
+ asked for. Equality is the exact test that separates the two.
+ """
+ try:
+ exc = _in_flight()
+ cell = _open_cell.get()
+ if exc is None or cell is None or exc is cell.baseline:
+ return
+ if str(exc) == surfaced_message:
+ cell.record(exc)
+ except Exception as e: # pragma: no cover - defensive
+ write_to_log(f"Warning: inner tap could not record an exception - {e}")
+
+
+def _in_flight() -> BaseException | None:
+ try:
+ return sys.exc_info()[1]
+ except Exception: # pragma: no cover - defensive
+ return None
+
+
+def tapped(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
+ """``fn`` with a catch-record-re-raise around it, and nothing else changed.
+
+ The re-raise is bare, so the SDK above receives the same exception object
+ it would have received untracked and the wire result is byte-identical. The
+ one observable difference is the tap's own frame in the traceback, which is
+ what a middleware seam costs on every era that has one.
+
+ The seam also carries the bookkeeping a composing tool needs: each entry
+ takes a marker of its own and pushes it onto this task's chain of open
+ seams, and on a NORMAL return it drops any capture recorded under that
+ marker — that failure was handled in here and is not what the agent is
+ being told. A capture it re-raises is left alone, which is the whole point
+ of the tap, and so is a capture from a seam that merely ran beside it.
+ """
+
+ async def tap(*args: Any, **kwargs: Any) -> Any:
+ cell = _open_cell.get()
+ seam = object()
+ token = _open_seams.set((*_open_seams.get(), seam))
+ try:
+ result = await fn(*args, **kwargs)
+ except Exception as exc:
+ capture(exc)
+ raise
+ else:
+ if cell is not None:
+ cell.discard_unescaped(seam)
+ return result
+ finally:
+ _open_seams.reset(token)
+
+ return tap
+
+
+def probing(fn: Callable[..., Any]) -> Callable[..., Any]:
+ """``fn`` with a `capture_in_flight` read in front of it, and nothing else.
+
+ For a *conversion* site rather than an invocation one: something the SDK
+ calls from inside its own ``except`` to build the error result. The
+ customer's exception never passes through here, so there is nothing to
+ catch and re-raise — and nothing of ours ends up in their traceback.
+
+ The message the site was handed is what gates the read (see
+ `capture_in_flight`). It is read defensively — first positional argument,
+ then the keyword lowlevel v1 names it by — because a call shape this does
+ not recognize must cost a capture, never the customer's error path.
+ """
+
+ def probe(*args: Any, **kwargs: Any) -> Any:
+ message = args[0] if args else kwargs.get("error_message")
+ if isinstance(message, str):
+ capture_in_flight(message)
+ return fn(*args, **kwargs)
+
+ return probe
+
+
+def tap_method(
+ owner: Any,
+ name: str,
+ state: dict[str, Any],
+ key: str,
+ wrap: Callable[[Any], Any] = tapped,
+ accepts: Callable[[Any], bool] = inspect.iscoroutinefunction,
+) -> bool:
+ """Put a tap over ``owner.name``, in place. Never raises.
+
+ ``state`` is the adapter's per-server install state and ``key`` the slot to
+ record under, so a repeated ``track()`` re-wraps the customer's original
+ rather than stacking a second tap on top of the first. ``wrap`` selects the
+ form — `tapped` around an invocation, `probing` in front of a conversion.
+
+ ``accepts`` is the precondition of that form, checked against the callable
+ about to be wrapped. `tapped` awaits what it wraps, so a target that is not
+ a coroutine function would turn every tool call into
+ ``TypeError: … can't be used in 'await' expression`` — a break the customer
+ sees on every call, and one the install-time ``try`` cannot catch because it
+ happens later. Every seam this ships with is ``async def`` on both majors;
+ a customer subclass that overrode one synchronously is refused the tap
+ instead, and keeps the previous no-tap payload.
+ """
+ original_key = f"orig_tap_{key}"
+ installed_key = f"tap_{key}"
+ try:
+ current = getattr(owner, name, None)
+ if not callable(current):
+ return False
+ # Ours from an earlier pass? Then the original is the one we recorded.
+ original = (
+ state.get(original_key) if current is state.get(installed_key) else current
+ )
+ if original is None:
+ return False
+ if not accepts(original):
+ write_to_log(
+ f"Warning: '{name}' on this server is not the shape the inner "
+ "tap wraps, so it is left alone; tool errors keep the surfaced "
+ "message but lose the stack"
+ )
+ return False
+ wrapper = wrap(original)
+ # Installed first, recorded second: a `setattr` a server refuses would
+ # otherwise leave the state claiming a tap that is not there, and the
+ # next `track()` would unwrap an original nothing ever wrapped.
+ setattr(owner, name, wrapper)
+ state[original_key] = original
+ state[installed_key] = wrapper
+ return True
+ except Exception as e:
+ write_to_log(
+ f"Warning: could not install the inner tap on '{name}'; tool errors "
+ f"on this server keep the surfaced message but lose the stack - {e}"
+ )
+ return False
diff --git a/src/agentcat/modules/adapters/community.py b/src/agentcat/modules/adapters/community.py
new file mode 100644
index 0000000..d3cded4
--- /dev/null
+++ b/src/agentcat/modules/adapters/community.py
@@ -0,0 +1,831 @@
+"""Adapter for community FastMCP (the 3.x and 4.x eras).
+
+Community FastMCP has a first-class middleware seam, so unlike the official
+SDKs there is nothing to monkey-patch: one object inserted at index 0 of
+``server.middleware`` sees every ``tools/list`` and ``tools/call`` before any
+other layer does.
+
+Era-specific knowledge only: FastMCP's snake_case ``Tool.parameters`` /
+``output_schema``, its ``MiddlewareContext``/``ToolResult`` shapes, and the
+provider registration used for ``get_more_tools``. Every decision about what to
+resolve, strip, decorate or publish belongs to
+:mod:`agentcat.modules.callpath` and is not re-derived here — the v1 middleware
+this replaces reimplemented all of it inline.
+
+Three invariants the middleware exists to protect:
+
+- **Nothing customer-owned is mutated.** Tool schemas are copied before the
+ injection pipeline rewrites them in place, and the request message is cloned
+ via ``model_copy`` rather than rebuilt — a rebuilt ``CallToolRequestParams``
+ silently drops ``_meta`` (and, from the 2026 era, ``input_responses`` /
+ ``request_state``), which severs multi-round-trip continuations.
+- **Index 0 is outermost.** FastMCP builds its chain with
+ ``for mw in reversed(self.middleware)``, so element 0 runs first. A caching or
+ dereferencing middleware below us therefore keys on the STRIPPED arguments and
+ never caches our injected schemas.
+- **The event records the call as the agent made it.** Raw (unstripped)
+ arguments and the customer's undecorated result; the mint-back is wire-only.
+
+``AgentCatMiddleware`` deliberately does not subclass ``fastmcp``'s
+``Middleware``: that would require a module-scope ``fastmcp`` import, and
+``import agentcat`` has to succeed with no MCP SDK installed at all. FastMCP
+calls middleware objects, so the dispatch its base class performs is
+reproduced in ``__call__``.
+"""
+
+from __future__ import annotations
+
+import copy
+import weakref
+from typing import Any
+
+from agentcat.modules.adapters._common import (
+ current_tracking_data,
+ install_state,
+ now_ms,
+ response_payload,
+)
+from agentcat.modules.adapters._inner_tap import inner_tap, tap_method
+from agentcat.modules.callpath import (
+ ResolvedCall,
+ decorate_content,
+ detect_mrtr,
+ get_stripped_arguments,
+ publish_tool_call_event,
+ resolve_call,
+ structured_mirror,
+)
+from agentcat.modules.constants import GET_MORE_TOOLS_NAME
+from agentcat.modules.exceptions import capture_exception
+from agentcat.modules.injection import ToolSpec, build_injected_schemas
+from agentcat.modules.logging import write_to_log
+from agentcat.modules.request_extra import extra_from_request_context
+from agentcat.modules.tools import GET_MORE_TOOLS_DESCRIPTION
+from agentcat.types import AgentCatData, ErrorData
+
+# Eras this adapter serves. Verified against FastMCP 4.0.0b1: it renamed
+# nothing this middleware touches — ``Tool.output_schema`` and
+# ``ToolResult.structured_content`` are the live field names on both sides, and
+# every read that COULD have drifted goes through ``_field``, snake-first with a
+# camelCase fallback — so no code path branches on the era. (The MRTR
+# continuation probes in ``on_call_tool`` are bare ``getattr``s: the 2026-era
+# ``input_responses`` / ``request_state`` fields postdate the camelCase bridge
+# entirely, so there is no older spelling for either to fall back to, and on
+# era 3 the message model has neither.) The one v4-only difference this
+# adapter had to answer, the era's second dispatch pass, turned out to be a
+# property of the message rather than of the generation, and is handled as one
+# (``_is_typed_message``). The era is recorded anyway: it names which generation
+# an installed middleware was built for in the log, and it is the seam a later
+# divergence would use.
+ERA_V3 = 3
+ERA_V4 = 4
+
+
+def _field(obj: Any, snake: str, camel: str) -> Any:
+ """snake_case first, camelCase fallback (v3 keeps a bridge; v4 does not)."""
+ value = getattr(obj, snake, None)
+ return getattr(obj, camel, None) if value is None else value
+
+
+def _is_typed_message(message: Any) -> bool:
+ """Whether ``context.message`` is the request model the hooks expect.
+
+ FastMCP 4 dispatches the middleware chain a SECOND time for a component
+ request that failed *before* the interior chain ran — malformed params, a
+ routing failure — so that ``on_message``/``on_request`` observe every
+ inbound message (``server/low_level.py::_dispatch_component``). That pass
+ carries the same ``method``, but its message is the RAW params **mapping**:
+ reconstructing a typed model is exactly what fails on a malformed message,
+ so FastMCP deliberately does not try (``_raw_message``).
+
+ It is observation only — the pass's ``call_next`` re-raises the original
+ failure rather than dispatching — so there is nothing here for AgentCat to
+ do: no tool ran, and no event describes a request that never became one.
+ What matters is that we get out of the way intact. ``on_call_tool`` clones
+ the message with ``model_copy``, which a ``dict`` does not have, and the
+ ``AttributeError`` that raised REPLACED the customer server's own ``-32602``
+ on the wire — an analytics library changing what a client is told.
+
+ FastMCP 3 has no such pass, so this only ever fires on 4.x; it is written
+ as a property of the message rather than an era branch because it is the
+ honest precondition of both typed hooks either way.
+ """
+ return hasattr(message, "model_copy")
+
+
+def _request_context(context: Any) -> Any:
+ """The MCP ``RequestContext`` behind a middleware call, or None."""
+ try:
+ fastmcp_context = context.fastmcp_context
+ return fastmcp_context.request_context if fastmcp_context else None
+ except Exception:
+ return None
+
+
+def _connection_key(source: Any) -> Any:
+ """The per-connection object a handshake capture is filed under, or None.
+
+ FastMCP builds one ``ServerSession`` per connection — and, under stateless
+ HTTP, a fresh one per REQUEST — so it is the only thing in reach that tells
+ one caller from another. ``initialize`` runs before the MCP
+ ``RequestContext`` exists (``fastmcp_context.request_context`` is None
+ there), so the session is read off the FastMCP context when capturing and
+ off the request context when looking up; on a stateful connection those are
+ the same object, and under stateless HTTP they deliberately are not.
+ """
+ try:
+ return getattr(source, "session", None)
+ except Exception:
+ return None
+
+
+def _flattened(result: Any) -> Any:
+ """A ``ToolResult`` in the shape the error extractor recognizes.
+
+ ``capture_exception`` reads the wire ``CallToolResult`` (camelCase
+ ``isError`` + ``content``), not FastMCP's snake_case ``ToolResult``, so the
+ result is converted first — otherwise the recorded message would be a
+ pydantic repr of the model.
+ """
+ try:
+ return result.to_mcp_result()
+ except Exception:
+ return result
+
+
+class AgentCatMiddleware:
+ """One middleware for both community eras; ``era`` selects the field bridge.
+
+ Stateless apart from the initialize-time client capture: the tracking data
+ is re-read per request so a repeated ``track()`` takes effect, and a
+ ``ResolvedCall`` is never held across rounds (it carries that round's
+ request/extra for the customer's tag and property callbacks).
+ """
+
+ def __init__(self, data: AgentCatData, server: Any, era: int) -> None:
+ self._data = data
+ self._server = server
+ self._era = era
+ # Handshake clientInfo, the last rung of the client identity ladder,
+ # filed PER CONNECTION. One middleware object serves every connection,
+ # so a single "last seen" slot would let a later client's initialize
+ # rename an earlier client's call — which is exactly what happens under
+ # stateless HTTP, where the session carries no client_params and the
+ # ladder reaches this rung on every call. Weakly keyed, so a finished
+ # connection's entry goes away with its session.
+ self._handshake_clients: weakref.WeakKeyDictionary[Any, dict[str, Any]] = (
+ weakref.WeakKeyDictionary()
+ )
+ # The get_more_tools Tool object install_community registered, if any,
+ # and — once conceded — where it moves to. The conceded record is kept
+ # rather than dropped so a listing that was already in flight when we
+ # conceded still filters out the tool we just un-registered. See
+ # _concede_get_more_tools.
+ self._registered_get_more_tools: Any = None
+ self._conceded_get_more_tools: Any = None
+
+ # ── dispatch ────────────────────────────────────────────────────────────
+
+ async def __call__(self, context: Any, call_next: Any) -> Any:
+ method = getattr(context, "method", None)
+ # Deliberately silent: on v4 this is the expected outer pass, which
+ # fires on every malformed request, and a log line there would be noise
+ # a customer cannot act on. The residual cost is that a layer inserted
+ # ABOVE us after track() which hands down a mapping would take tracking
+ # dead with no diagnostic — an inversion of the index-0 install that
+ # nothing in FastMCP does today.
+ if not _is_typed_message(getattr(context, "message", None)):
+ return await call_next(context)
+ if method == "initialize":
+ return await self.on_initialize(context, call_next)
+ if method == "tools/list":
+ return await self.on_list_tools(context, call_next)
+ if method == "tools/call":
+ return await self.on_call_tool(context, call_next)
+ return await call_next(context)
+
+ # ── helpers ─────────────────────────────────────────────────────────────
+
+ def _current_data(self) -> AgentCatData:
+ """The tracking data as of this request, so a re-track takes effect."""
+ return current_tracking_data(self._server, self._data)
+
+ def _meta_sources(self, message: Any, request_context: Any) -> list[Any]:
+ """Where per-request client identity may ride, best rung first."""
+ return [
+ getattr(message, "meta", None),
+ getattr(request_context, "meta", None),
+ ]
+
+ def _legacy_client_info(self, request_context: Any) -> Any:
+ """Initialize-time ``clientInfo`` for THIS connection, or None.
+
+ Two forms of the same rung (design §7, rung 3): what the SDK kept on
+ the session, then what this middleware saw on the same session's
+ ``initialize``. Both are per connection, so neither can name a client
+ that did not make this call. A stateless-HTTP request gets a brand new
+ session that never handshook, so it misses both and identity floors at
+ empty — the honest answer, and the one ``stateless=True`` produced in
+ 1.x. Under the 2026-era protocol there is no ``initialize`` at all, so
+ this rung is simply never populated.
+ """
+ try:
+ info = request_context.session.client_params.clientInfo
+ except Exception:
+ info = None
+ if info is not None:
+ return info
+ return self._remembered_handshake_client(_connection_key(request_context))
+
+ def _remember_handshake_client(self, key: Any, client: dict[str, Any]) -> None:
+ """File one connection's handshake identity. Never raises."""
+ if key is None:
+ return
+ try:
+ self._handshake_clients[key] = client
+ except TypeError as e:
+ # A session object that cannot be weakly referenced cannot be told
+ # apart from any other, so it gets no entry rather than a shared one.
+ write_to_log(f"Warning: could not file the handshake clientInfo - {e}")
+
+ def _remembered_handshake_client(self, key: Any) -> dict[str, Any] | None:
+ if key is None:
+ return None
+ try:
+ return self._handshake_clients.get(key)
+ except TypeError:
+ return None
+
+ def _specs(self, tools: list[Any]) -> list[ToolSpec | None]:
+ """Copies of the listed schemas, ready for the in-place injection pass.
+
+ Only the schema dicts are copied. Deep-copying the ``Tool`` itself
+ raises on every tool holding live runtime state — an OpenAPI tool's
+ ``httpx.AsyncClient`` carries a ``threading.RLock`` — which silently
+ dropped injection for whole servers in v1. The same lesson applies
+ per tool: a schema dict whose copy fails yields ``None`` in the
+ aligned slot (that tool serves verbatim, un-injected) instead of
+ taking the whole listing down.
+ """
+ specs: list[ToolSpec | None] = []
+ for tool in tools:
+ try:
+ specs.append(
+ ToolSpec(
+ tool.name,
+ copy.deepcopy(getattr(tool, "parameters", None) or {}),
+ copy.deepcopy(_field(tool, "output_schema", "outputSchema")),
+ )
+ )
+ except Exception as e:
+ write_to_log(
+ "Warning: could not copy the schema of tool "
+ f"'{getattr(tool, 'name', '')}' for injection; "
+ f"serving it verbatim without handle parameters - {e}"
+ )
+ specs.append(None)
+ return specs
+
+ # ── hooks ───────────────────────────────────────────────────────────────
+
+ async def on_initialize(self, context: Any, call_next: Any) -> Any:
+ """Capture this connection's client identity. Publishes nothing (v2).
+
+ The MCP ``RequestContext`` does not exist yet at this point, so the
+ connection is identified by the FastMCP context's session — the same
+ object the tool call will present.
+ """
+ try:
+ message = context.message
+ params = getattr(message, "params", None) or message
+ info = _field(params, "client_info", "clientInfo")
+ if info is not None:
+ self._remember_handshake_client(
+ _connection_key(getattr(context, "fastmcp_context", None)),
+ {
+ "name": getattr(info, "name", None),
+ "version": getattr(info, "version", None),
+ },
+ )
+ except Exception as e:
+ write_to_log(f"Warning: could not read initialize clientInfo - {e}")
+ return await call_next(context)
+
+ async def on_list_tools(self, context: Any, call_next: Any) -> Any:
+ """Serve the customer's listing with AgentCat's parameters injected.
+
+ Publishes nothing: v2 intercepts ``tools/list`` for schema injection
+ only. On any failure the customer's own list is served unmodified.
+ """
+ # The list handed back here has passed through every layer below us, so
+ # it may hold COPIES of our own tool — hence authoritative=False.
+ tools = await self._concede_get_more_tools(
+ list(await call_next(context)), authoritative=False
+ )
+ tracking = self._current_data()
+ try:
+ specs = self._specs(tools)
+ injected = build_injected_schemas(
+ [spec for spec in specs if spec is not None],
+ tracking.options,
+ tracking.reported_conflicts,
+ )
+ tracking.injected_params_registry = injected.injected_params
+ tracking.output_injection_registry = injected.output_injected
+ # Union, never replace: membership only grows, and a concurrent
+ # listing on another instance may already have recorded a tool this
+ # one did not see.
+ tracking.declared_session_params |= injected.declared_session_params
+ return [
+ tool
+ if spec is None # uncopyable schema: served verbatim
+ else tool.model_copy(update=self._schema_update(spec))
+ for tool, spec in zip(tools, specs, strict=True)
+ ]
+ except Exception as e:
+ write_to_log(
+ "Warning: tools/list injection failed, serving the customer's "
+ f"unmodified list - {e}"
+ )
+ return tools
+
+ def _is_ours(self, tool: Any, ours: Any) -> bool:
+ """Whether a listed tool is the ``get_more_tools`` AgentCat registered.
+
+ Object identity is the exact answer but not a sufficient one: layers
+ below us hand back ``model_copy`` results — a customer middleware that
+ re-stamps tools, FastMCP's own visibility transforms, v4's
+ dereferencing middleware — and **a copy of our own tool must never read
+ as a customer's**. So identity first, then two copy-tolerant fallbacks:
+ the underlying function object, which a ``model_copy`` carries over,
+ and our canonical description, which is the copy an agent would see.
+
+ This is the single definition of "ours" for both halves of the
+ concession: whatever it calls ours is what the decision ignores AND
+ what the advertised list drops, so the two can never disagree.
+ """
+ if ours is None:
+ return False
+ if tool is ours:
+ return True
+ if getattr(tool, "name", None) != GET_MORE_TOOLS_NAME:
+ return False
+ our_fn = getattr(ours, "fn", None)
+ if our_fn is not None and getattr(tool, "fn", None) is our_fn:
+ return True
+ return bool(getattr(tool, "description", None) == GET_MORE_TOOLS_DESCRIPTION)
+
+ def _foreign_get_more_tools(self, tools: list[Any], ours: Any) -> bool:
+ """Whether anyone but us supplies ``get_more_tools`` in this listing."""
+ return any(
+ getattr(tool, "name", None) == GET_MORE_TOOLS_NAME
+ and not self._is_ours(tool, ours)
+ for tool in tools
+ )
+
+ async def _concede_get_more_tools(
+ self, tools: list[Any], authoritative: bool
+ ) -> list[Any]:
+ """Give the name back the moment another provider turns out to own it.
+
+ ``install_community`` can only read the LOCAL provider synchronously,
+ so a ``get_more_tools`` supplied by a mounted sub-server, a proxy, an
+ OpenAPI provider or ``add_provider`` is invisible to it. The local
+ provider is added first and wins aggregation, so ours would answer for
+ theirs — the one thing the ownership gate exists to prevent (spec §12).
+ An all-provider listing is the first view that can settle it, so both
+ views we get — the client-facing one and the rebuild-on-demand one —
+ run this.
+
+ ``authoritative`` says whether ``tools`` already IS the raw
+ all-provider view. When it is not, a positive finding is re-checked
+ against that raw view before anything is un-registered: if a layer
+ below us rewrote our own tool past recognition, the honest answer is
+ "nobody else supplies this" and the safe move is to leave the
+ registration alone. Un-registering on a false positive would advertise
+ a ``get_more_tools`` that no longer resolves, and the next call to it
+ would fail.
+ """
+ ours = self._registered_get_more_tools
+ if ours is None:
+ # Already conceded — keep filtering, so a listing that was already
+ # in flight when we conceded cannot advertise the tool we removed.
+ conceded = self._conceded_get_more_tools
+ if conceded is None:
+ return tools
+ return [tool for tool in tools if not self._is_ours(tool, conceded)]
+
+ if not self._foreign_get_more_tools(tools, ours):
+ return tools
+ if not authoritative and not await self._provider_view_has_a_foreign_one(ours):
+ return tools
+
+ self._registered_get_more_tools = None
+ self._conceded_get_more_tools = ours
+ removed = _unregister_get_more_tools(self._server, ours)
+ write_to_log(
+ "A provider on this server supplies its own get_more_tools; "
+ + (
+ "un-registered AgentCat's so the customer's tool answers."
+ if removed
+ else "AgentCat's was already gone from the local registry."
+ )
+ )
+ return [tool for tool in tools if not self._is_ours(tool, ours)]
+
+ async def _provider_view_has_a_foreign_one(self, ours: Any) -> bool:
+ """Re-ask the question of the raw provider listing, middleware bypassed.
+
+ Only reached when a processed listing already looked foreign, so the
+ extra listing is paid on the rare path, never on every ``tools/list``.
+ """
+ try:
+ listed = list(await self._server.list_tools(run_middleware=False))
+ except Exception as e:
+ write_to_log(
+ "Warning: could not re-read the provider listing to confirm a "
+ f"get_more_tools owner; leaving AgentCat's registered - {e}"
+ )
+ return False
+ return self._foreign_get_more_tools(listed, ours)
+
+ def _schema_update(self, spec: ToolSpec) -> dict[str, Any]:
+ update: dict[str, Any] = {"parameters": spec.input_schema}
+ if spec.output_schema is not None:
+ update["output_schema"] = spec.output_schema
+ return update
+
+ async def on_call_tool(self, context: Any, call_next: Any) -> Any:
+ tracking = self._current_data()
+ options = tracking.options
+ message = context.message
+ name = getattr(message, "name", None) or "Unknown Tool"
+ raw_arguments = dict(getattr(message, "arguments", None) or {})
+
+ async def rebuild() -> list[ToolSpec]:
+ """The list source for registry rebuild-on-demand (changelog 6.3).
+
+ ``run_middleware=False`` is the seam in both community eras: it
+ returns the provider's own tools without re-entering this hook.
+ That is the raw all-provider view, so it settles the get_more_tools
+ ownership question too — and this is the path a tools/call takes on
+ an instance that never served a listing, which is precisely the
+ case install_community registers eagerly for.
+ """
+ listed = list(await self._server.list_tools(run_middleware=False))
+ specs = self._specs(
+ await self._concede_get_more_tools(listed, authoritative=True)
+ )
+ return [spec for spec in specs if spec is not None]
+
+ stripped = await get_stripped_arguments(
+ tracking, options, name, raw_arguments, rebuild
+ )
+ stripped_context = context.copy(
+ message=message.model_copy(update={"arguments": stripped})
+ )
+
+ # Tracing off: still strip what we injected, so the customer's tool runs
+ # exactly as it would untracked — then get out of the way. No handle
+ # resolution, no mint-back (there is no session_id parameter to echo), and
+ # no event.
+ if not options.enable_tracing:
+ return await call_next(stripped_context)
+
+ request_context = _request_context(context)
+ try:
+ # Resolved fresh every round: the ResolvedCall carries this round's
+ # message/extra for the customer's tag and property callbacks.
+ resolved = await resolve_call(
+ tracking,
+ name,
+ raw_arguments,
+ message,
+ request_context,
+ meta_sources=self._meta_sources(message, request_context),
+ legacy_client=lambda: self._legacy_client_info(request_context),
+ )
+ except Exception as e:
+ # Belt and braces at the customer boundary: the resolvers are all
+ # documented not to raise, but a tool call must never fail because
+ # analytics did. Degrade to an untraced call.
+ write_to_log(
+ f"Warning: AgentCat resolution failed for tool '{name}', running "
+ f"it untraced - {e}"
+ )
+ return await call_next(stripped_context)
+
+ started = now_ms()
+ extra_params = extra_from_request_context(
+ request_context, getattr(context, "fastmcp_context", None)
+ )
+
+ # The tap's slot is open for exactly the rest of the chain and the
+ # publish that reads it, and closes on every exit path including the
+ # raise below.
+ with inner_tap() as tap:
+ try:
+ result = await call_next(stripped_context)
+ except Exception as e:
+ # FastMCP surfaces a failing tool as a raised error, so unlike
+ # the official adapters this path holds the live exception,
+ # with its type and traceback intact.
+ await self._publish(
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ response=None,
+ is_error=True,
+ error=capture_exception(e),
+ started=started,
+ mrtr=None,
+ extra_params=extra_params,
+ )
+ raise
+
+ mrtr = detect_mrtr(
+ self._result_type(result),
+ getattr(message, "input_responses", None) is not None,
+ getattr(message, "request_state", None) is not None,
+ )
+ is_error = bool(getattr(result, "is_error", False))
+ await self._publish(
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ response=response_payload(result),
+ is_error=is_error,
+ # An `is_error` result that never raised THROUGH us: a layer
+ # below answered with one. The tap holds the exception when a
+ # local one produced it, and there simply is none when a proxy
+ # passed an upstream error through — then the surfaced message
+ # is the whole truth.
+ error=tap.error(_flattened(result)) if is_error else None,
+ started=started,
+ mrtr=mrtr,
+ extra_params=extra_params,
+ )
+
+ # An intermediate multi-round-trip round is never decorated: only the
+ # completing round carries the mint-back (changelog 6.4).
+ if mrtr == "input_required":
+ return result
+ return self._decorated(result, resolved, name, tracking)
+
+ def _result_type(self, result: Any) -> str | None:
+ """The 2026-era ``resultType`` discriminator, however it is spelled."""
+ declared = _field(result, "result_type", "resultType")
+ if isinstance(declared, str):
+ return declared
+ return (
+ "input_required"
+ if type(result).__name__ == "InputRequiredToolResult"
+ else None
+ )
+
+ async def _publish(
+ self,
+ tracking: AgentCatData,
+ resolved: ResolvedCall,
+ name: str,
+ raw_arguments: dict[str, Any],
+ response: dict[str, Any] | None,
+ is_error: bool,
+ error: ErrorData | None,
+ started: int,
+ mrtr: str | None,
+ extra_params: dict[str, Any],
+ ) -> None:
+ await publish_tool_call_event(
+ self._server,
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ response=response,
+ is_error=is_error,
+ error=error,
+ duration_ms=now_ms() - started,
+ mrtr=mrtr,
+ extra_params=extra_params,
+ )
+
+ def _decorated(
+ self,
+ result: Any,
+ resolved: ResolvedCall,
+ name: str,
+ tracking: AgentCatData,
+ ) -> Any:
+ """The wire result with the mint-back appended, or the original."""
+ update: dict[str, Any] = {}
+ content = getattr(result, "content", None)
+ decorated = decorate_content(
+ list(content) if isinstance(content, list) else None,
+ resolved.resolution,
+ _text_block,
+ )
+ if decorated is not None:
+ update["content"] = decorated
+ mirrored = structured_mirror(
+ _field(result, "structured_content", "structuredContent"),
+ resolved.resolution,
+ name,
+ tracking.output_injection_registry,
+ )
+ if mirrored is not None:
+ update["structured_content"] = mirrored
+ # A copy, never an in-place edit: the event above still references the
+ # customer's own result object.
+ return result.model_copy(update=update) if update else result
+
+
+def _text_block(text: str) -> Any:
+ from mcp.types import TextContent
+
+ return TextContent(type="text", text=text)
+
+
+def _customer_owns_get_more_tools(server: Any) -> bool:
+ """Whether a tool named ``get_more_tools`` is already registered.
+
+ Read off the local provider's component store, the only synchronous view of
+ the registry (``list_tools`` is async and ``install_community`` is not).
+ Registering over an existing tool would replace the customer's handler with
+ our canned reply, and nothing AgentCat does may alter tool behavior
+ (spec §12), so an unreadable registry is treated as owned.
+ """
+ try:
+ components = server._local_provider._components.values()
+ except Exception as e:
+ write_to_log(
+ "Warning: could not read the tool registry to check for a "
+ f"get_more_tools collision; skipping registration - {e}"
+ )
+ return True
+ return any(getattr(c, "name", None) == GET_MORE_TOOLS_NAME for c in components)
+
+
+def _unregister_get_more_tools(server: Any, ours: Any) -> bool:
+ """Remove the tool we registered — and only ever that exact object.
+
+ Returns whether anything was actually removed, so the caller's log never
+ claims an un-registration that did not happen.
+
+ Removing by name would be wrong: a customer who registers their own
+ ``get_more_tools`` after ``track()`` replaces ours in the local store under
+ the same key, and deleting by name would then delete theirs. The identity
+ check makes the removal a no-op in exactly that case.
+ """
+ try:
+ provider = server._local_provider
+ if provider._components.get(ours.key) is not ours:
+ return False
+ provider.remove_tool(GET_MORE_TOOLS_NAME)
+ return True
+ except Exception as e:
+ write_to_log(f"Warning: could not un-register get_more_tools - {e}")
+ return False
+
+
+def _register_get_more_tools(server: Any) -> Any:
+ """Register ``get_more_tools`` on the server's own provider; returns it.
+
+ Provider registration rather than a listing-time append, so the tool
+ survives the compositions community FastMCP is built around: a mounted or
+ proxied parent enumerates through the provider and never runs this
+ server's middleware. The returned object is what
+ ``AgentCatMiddleware._concede_get_more_tools`` compares listings against.
+ """
+ # `fastmcp.tools` is the public home of Tool in both eras;
+ # `fastmcp.tools.tool` is a sys.modules shim that no type checker resolves.
+ from fastmcp.tools import Tool
+
+ from agentcat.modules.tools import (
+ GET_MORE_TOOLS_DESCRIPTION,
+ GET_MORE_TOOLS_SCHEMA,
+ handle_report_missing,
+ )
+
+ async def get_more_tools(context: str) -> str:
+ result = await handle_report_missing({"context": context})
+ block = result.content[0] if result.content else None
+ return getattr(block, "text", "No additional tools available.")
+
+ # Spec defaults assume the worst; declare the honest hint so
+ # annotation-aware clients skip the confirmation prompt. Passed as a plain
+ # mapping, not the v3 `ToolAnnotations` model — pydantic coerces it into
+ # whichever annotations type the running FastMCP declares.
+ annotations: Any = {"readOnlyHint": True}
+ tool = Tool.from_function(
+ get_more_tools,
+ name=GET_MORE_TOOLS_NAME,
+ description=GET_MORE_TOOLS_DESCRIPTION,
+ annotations=annotations,
+ )
+ # The generated schema loses the agent-facing parameter copy (pydantic
+ # writes no description for a bare `context: str`), so the canonical
+ # schema — byte-guarded against the TypeScript SDK — is substituted.
+ schema = copy.deepcopy(GET_MORE_TOOLS_SCHEMA)
+ registered = server.add_tool(tool.model_copy(update={"parameters": schema}))
+ write_to_log("Registered get_more_tools on the community provider")
+ return registered
+
+
+def install_community(server: Any, data: AgentCatData, era: int) -> None:
+ """Insert the AgentCat middleware at index 0 of a community FastMCP server.
+
+ ``era`` is 3 or 4. Idempotent: a repeated ``track()`` replaces the existing
+ middleware rather than stacking a second one — a stacked pass would inject
+ session_id on the inside, find it already present on the outside, and never
+ record it as strippable, handing the customer's tool a parameter it never
+ declared.
+
+ ``get_more_tools`` is registered here so it exists for a ``tools/call`` that
+ arrives on an instance which never served a listing. That is only half the
+ ownership check — this function can read the local provider synchronously
+ but not the mounted/proxied/OpenAPI ones — so every all-provider view we
+ later get (a client listing, or the rebuild-on-demand one) re-checks and
+ concedes the name if anyone else turns out to supply it.
+
+ Two residual asymmetries of provider registration, both requiring a
+ customer tool literally named ``get_more_tools``:
+
+ - Turning ``enable_report_missing`` OFF on a *later* ``track()`` leaves the
+ already-registered tool in place; disable it before the first ``track()``.
+ - If the customer registers theirs on the LOCAL provider after ``track()``,
+ the server's own ``on_duplicate`` policy decides: the default (``warn``)
+ lets theirs replace ours, which is the right outcome, but ``ignore``
+ would discard theirs and ``error`` would raise on their registration.
+ ``_on_duplicate`` is readable synchronously, so the ``error`` sub-case —
+ the only one where AgentCat makes the customer's own code raise — could
+ be guarded at no cost to ``warn``/``replace`` servers if it ever bites.
+ Registering before ``track()`` avoids all of it today, and that is what
+ the local gate below already handles.
+ """
+ chain = server.middleware
+ existing = [mw for mw in chain if isinstance(mw, AgentCatMiddleware)]
+ for mw in existing:
+ chain.remove(mw)
+ # Index 0 is outermost: FastMCP builds the chain over `reversed(middleware)`.
+ middleware = AgentCatMiddleware(data, server, era)
+ chain.insert(0, middleware)
+
+ # Carry a previous install's registration forward. The local gate below
+ # cannot tell OUR get_more_tools from a customer's, so on a re-track it
+ # reports "already owned" and registration is skipped — and without this the
+ # fresh middleware would hold no marker, could never concede the name, and a
+ # re-tracked server would go back to hijacking a provider-supplied tool.
+ for previous in existing:
+ if previous._registered_get_more_tools is not None:
+ middleware._registered_get_more_tools = previous._registered_get_more_tools
+ break
+ if previous._conceded_get_more_tools is not None:
+ middleware._conceded_get_more_tools = previous._conceded_get_more_tools
+ break
+
+ register = data.options.enable_report_missing and not _customer_owns_get_more_tools(
+ server
+ )
+ if register:
+ try:
+ middleware._registered_get_more_tools = _register_get_more_tools(server)
+ except Exception as e:
+ write_to_log(f"Warning: could not register get_more_tools - {e}")
+
+ tapped = _arm_inner_tap(server)
+ write_to_log(
+ f"Installed community adapter (era {era}) on server {id(server)} "
+ f"at middleware index 0 (replaced={len(existing)}, "
+ f"inner-tap={tapped or 'none'})"
+ )
+
+
+def _arm_inner_tap(server: Any) -> list[str]:
+ """Arm the inner tap on ``FastMCP.call_tool``. Never raises.
+
+ The one thing on a community server that a middleware seam cannot do. The
+ middleware chain sees a raised tool error and already records it in full —
+ but a layer BELOW us that answers with an ``is_error`` result instead of
+ raising (``providers/proxy.py`` does it deliberately for an upstream error,
+ and any error-handling middleware a customer writes does it too) leaves
+ nothing for the chain to catch. The tap has to sit under all of them.
+
+ ``call_tool`` is where "all of them" ends: the middleware chain's innermost
+ ``call_next`` is ``self.call_tool(..., run_middleware=False)``, dispatched
+ through the instance on every call, below every middleware and below the
+ extension interceptors. Position in ``server.middleware`` therefore cannot
+ matter — including for a middleware the customer adds after ``track()``,
+ which a tail middleware of ours would have ended up above.
+
+ The wrapper is a pass-through that records and re-raises, so the same
+ exception reaches the same layer it would have untracked. It is also
+ entered a second time per call, from the top with ``run_middleware=True``,
+ where nothing has raised yet and the outer entry is a no-op.
+ """
+ state = install_state(server)
+ if state is None:
+ return [] # already logged
+ return ["call_tool"] if tap_method(server, "call_tool", state, "call_tool") else []
diff --git a/src/agentcat/modules/adapters/lowlevel_v1.py b/src/agentcat/modules/adapters/lowlevel_v1.py
new file mode 100644
index 0000000..bc0a3d7
--- /dev/null
+++ b/src/agentcat/modules/adapters/lowlevel_v1.py
@@ -0,0 +1,552 @@
+"""Adapter for the official MCP SDK 1.x lowlevel ``Server``.
+
+It also serves ``mcp.server.fastmcp.FastMCP`` through its ``_mcp_server``, so
+both official flavors run one interception path instead of the v1 split
+between handler overrides and tool-manager monkey patches.
+
+Era-specific knowledge only: the type-keyed ``request_handlers`` dict, the
+``ServerResult`` root wrapper, and camelCase model fields
+(``inputSchema``/``structuredContent``/``isError``). Every decision about what
+to resolve, strip, decorate or publish belongs to
+:mod:`agentcat.modules.callpath` and is not re-derived here.
+
+Two invariants the wrappers exist to protect:
+
+- **Nothing customer-owned is mutated.** Listed tools are deep-copied before
+ the injection pipeline rewrites their schemas in place, and the request is
+ cloned before its arguments are stripped — v1 popped ``context`` off the
+ caller's shared dict, which corrupted concurrent retries.
+- **The event records the call as the agent made it.** Raw (unstripped)
+ arguments and the customer's undecorated result; the mint-back is wire-only.
+
+All ``mcp`` imports are function-local so ``import agentcat`` succeeds under
+either SDK major.
+"""
+
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+from agentcat.modules.adapters._common import (
+ current_tracking_data,
+ install_state,
+ now_ms,
+ response_payload,
+)
+from agentcat.modules.adapters._inner_tap import (
+ inner_tap,
+ probing,
+ tap_method,
+)
+from agentcat.modules.logging import write_to_log
+from agentcat.modules.request_extra import extra_from_request_context
+from agentcat.types import AgentCatData
+
+# The two handlers this adapter wraps, keyed by the name of the ``Server``
+# decorator that registers each one. Patching those decorators is how a
+# handler the customer registers AFTER ``track()`` still ends up wrapped: on
+# this generation registration writes straight into ``request_handlers``, so
+# the decorator is the only seam there is.
+_LIST = "list_tools"
+_CALL = "call_tool"
+
+
+def _safe_request_context(server: Any) -> Any:
+ """The in-flight request context, or None outside a request.
+
+ On a lowlevel v1 ``Server`` this is a property that raises when no request
+ is active, so it is never read without a guard.
+ """
+ try:
+ return server.request_context
+ except Exception:
+ return None
+
+
+def _meta_extras(request: Any) -> Any:
+ """The request's ``_meta`` object, where per-request client identity rides."""
+ try:
+ return getattr(getattr(request, "params", None), "meta", None)
+ except Exception:
+ return None
+
+
+def _legacy_client_info(ctx: Any) -> Any:
+ """Initialize-time ``clientInfo``, captured by the SDK's own ServerSession.
+
+ The last rung of the client identity ladder, supplied lazily so the
+ earlier meta rungs never pay for it.
+ """
+ try:
+ return ctx.session.client_params.clientInfo
+ except Exception:
+ return None
+
+
+def _arm_inner_tap(server: Any, facade: Any, state: dict[str, Any]) -> list[str]:
+ """Arm the inner tap wherever this generation lets an exception be seen.
+
+ This era has one interception point but two server shapes behind it, and
+ they need different seams:
+
+ - **The lowlevel error-result factory**, for every shape. The handler
+ ``Server.call_tool()`` builds catches everything and keeps only
+ ``str(e)`` — but it builds the result by calling
+ ``self._make_error_result(...)`` from *inside* that ``except``, where
+ ``sys.exc_info()`` still holds the live exception. Reading it there costs
+ the customer nothing: no wrapper frame in their traceback, no re-raise,
+ and the factory's own result is returned untouched. On a bare lowlevel
+ server it is also the only seam there is, and the exception it finds is
+ the customer handler's own.
+
+ The SDK calls that factory from three places, and only one of them
+ surfaces the exception's own message; `probing` records only when the
+ message it is handed IS ``str(exc)``, which is exactly that one. The
+ schema-validation sites keep the one-line wire text they always had
+ rather than a multi-line ``jsonschema`` dump of the schema and the
+ offending value.
+ - **Official FastMCP's tool manager**, for the FastMCP shape. There the
+ customer's tool is two layers below the handler and its exception arrives
+ already wrapped in a ``ToolError``, whose traceback stops at the wrapper;
+ the tool manager is the innermost seam outside the tool itself.
+ ``FastMCP.call_tool`` cannot be used instead — the SDK binds it into the
+ handler's closure at registration, so replacing the attribute afterwards
+ would be ignored — while ``self._tool_manager.call_tool`` is looked up
+ per call.
+
+ Both are armed when both exist, and the order does not matter here: the
+ tap is last-write-wins, and the second write is the same exception object
+ as the first. The tool manager's seam records what it caught and re-raises
+ it; the factory then reads `sys.exc_info()` from the ``except`` that same
+ exception is propagating through, and `capture_in_flight` only records when
+ the surfaced message IS ``str(exc)`` — so what it re-records is the object
+ already there.
+ """
+ armed: list[str] = []
+ # `probing` does not await what it wraps, so the precondition is only that
+ # the factory is callable at all — not that it is a coroutine function.
+ if tap_method(
+ server,
+ "_make_error_result",
+ state,
+ "error_result",
+ wrap=probing,
+ accepts=callable,
+ ):
+ armed.append("_make_error_result")
+ manager = getattr(facade, "_tool_manager", None)
+ if manager is not None and tap_method(manager, "call_tool", state, "tool_manager"):
+ armed.append("_tool_manager.call_tool")
+ return armed
+
+
+def install_lowlevel_v1(server: Any, data: AgentCatData, facade: Any = None) -> None:
+ """Wrap ``tools/list`` and ``tools/call`` on a lowlevel v1 server.
+
+ ``server`` is the lowlevel object the detector handed back (a bare
+ ``Server``, or a FastMCP's ``_mcp_server``); ``facade`` is the high-level
+ object that owns it when there is one, which only the inner tap needs;
+ ``data`` is the tracking data already stored for it. Idempotent: calling
+ ``track()`` again re-wraps the same originals with fresh closures rather
+ than stacking a second pass.
+
+ ``track()`` may legitimately run before a single ``@server.call_tool()``
+ exists, and a customer may register one afterwards, so the registration
+ decorators are patched too — otherwise this adapter would install nothing
+ at all on a fresh ``Server()`` and be silently overwritten by the
+ customer's own registration.
+
+ ``initialize`` is deliberately left alone. v2 publishes no initialize
+ event, and the only thing the old override took from it — the handshake
+ ``clientInfo`` — the SDK already keeps on the session, where the client
+ identity ladder reads it per request.
+ """
+ from mcp.types import (
+ CallToolRequest,
+ ListToolsRequest,
+ ServerResult,
+ TextContent,
+ Tool,
+ )
+
+ from agentcat.modules.callpath import (
+ decorate_content,
+ detect_mrtr,
+ get_stripped_arguments,
+ publish_tool_call_event,
+ resolve_call,
+ structured_mirror,
+ )
+ from agentcat.modules.constants import GET_MORE_TOOLS_NAME
+ from agentcat.modules.exceptions import capture_exception
+ from agentcat.modules.injection import ToolSpec, build_injected_schemas
+ from agentcat.modules.tools import (
+ GET_MORE_TOOLS_DESCRIPTION,
+ GET_MORE_TOOLS_SCHEMA,
+ handle_report_missing,
+ )
+
+ handlers = server.request_handlers
+ request_types = {_LIST: ListToolsRequest, _CALL: CallToolRequest}
+ state = install_state(server)
+ if state is None:
+ return # already logged
+
+ # Whether WE are the one advertising get_more_tools. Stated positively on
+ # purpose: it is set at the append site by every pass over the listing
+ # (client-facing or rebuild-on-demand), so until a listing has actually
+ # advertised our tool this stays False — and a server with no tools/list
+ # handler at all, or one whose listing raised, cannot hijack a customer's
+ # own get_more_tools by default.
+ agentcat_advertises_get_more_tools = False
+
+ def current_data() -> AgentCatData:
+ """The tracking data as of this request, so a re-track takes effect."""
+ return current_tracking_data(server, data)
+
+ def original(key: str) -> Any:
+ """The customer's handler for ``key`` as of right now.
+
+ Read from the shared state on every call rather than captured, so a
+ handler the customer re-registers after ``track()`` is the one that
+ runs — the wrapper on top of it does not need replacing for that.
+ """
+ return state.get(f"orig_{key}")
+
+ def make_get_more_tools() -> Tool:
+ # Spec defaults assume the worst; declare the honest hint so
+ # annotation-aware clients skip the confirmation prompt. Passed as a
+ # plain mapping, not the `ToolAnnotations` model — that model only
+ # exists from mcp 1.7, and importing it unconditionally used to make
+ # `install_lowlevel_v1` raise on 1.2-1.6, where `track()`'s blanket
+ # except turned AgentCat into a silent no-op. `Tool` is extra="allow"
+ # on every 1.x, so the mapping is absorbed below 1.7 and coerced into
+ # the model at and above it.
+ #
+ # Typed `Any` for the same reason `adapters/community.py` does it: the
+ # field is declared `ToolAnnotations | None`, so a type-check pass
+ # rejects the mapping that pydantic accepts at runtime.
+ annotations: Any = {"readOnlyHint": True}
+ return Tool(
+ name=GET_MORE_TOOLS_NAME,
+ description=GET_MORE_TOOLS_DESCRIPTION,
+ inputSchema=copy.deepcopy(GET_MORE_TOOLS_SCHEMA),
+ annotations=annotations,
+ )
+
+ def advertised_tools(
+ original: Any, options: Any
+ ) -> tuple[list[Any], set[int]]:
+ """Deep copies of the listed tools, plus get_more_tools when enabled.
+
+ Copies because the injection pipeline rewrites schemas in place and
+ FastMCP hands out the very dict its tool manager holds. A tool whose
+ copy fails is carried through VERBATIM (the customer's object, never
+ given to the pipeline) with its id() in the returned skip set — one
+ uncopyable tool must not take down the whole listing.
+ """
+ nonlocal agentcat_advertises_get_more_tools
+ tools: list[Any] = []
+ skipped: set[int] = set()
+ listed = getattr(getattr(original, "root", None), "tools", None)
+ if isinstance(listed, list):
+ for tool in listed:
+ try:
+ tools.append(tool.model_copy(deep=True))
+ except Exception as e:
+ write_to_log(
+ "Warning: could not copy tool "
+ f"'{getattr(tool, 'name', '')}' for injection; "
+ f"serving it verbatim without handle parameters - {e}"
+ )
+ tools.append(tool)
+ skipped.add(id(tool))
+ customer_owns_get_more_tools = any(
+ getattr(tool, "name", None) == GET_MORE_TOOLS_NAME for tool in tools
+ )
+ agentcat_advertises_get_more_tools = (
+ options.enable_report_missing and not customer_owns_get_more_tools
+ )
+ # Appended before injection so it receives handle parameters too; the
+ # context pass skips it by name, so early placement cannot double-inject.
+ if agentcat_advertises_get_more_tools:
+ tools.append(make_get_more_tools())
+ return tools, skipped
+
+ def specs_for(tools: list[Any], skipped: set[int]) -> list[ToolSpec | None]:
+ """Specs aligned index-for-index with `tools`; None for skipped ones."""
+ return [
+ None
+ if id(tool) in skipped
+ else ToolSpec(
+ tool.name, tool.inputSchema, getattr(tool, "outputSchema", None)
+ )
+ for tool in tools
+ ]
+
+ async def rebuild() -> list[ToolSpec]:
+ """The list source for registry rebuild-on-demand (changelog 6.3)."""
+ list_handler = original(_LIST)
+ if list_handler is None:
+ return []
+ listed = await list_handler(ListToolsRequest(method="tools/list"))
+ tools, skipped = advertised_tools(listed, current_data().options)
+ return [spec for spec in specs_for(tools, skipped) if spec is not None]
+
+ async def wrapped_list(request: Any) -> Any:
+ listed = await original(_LIST)(request)
+ tracking = current_data()
+ try:
+ tools, skipped = advertised_tools(listed, tracking.options)
+ specs = specs_for(tools, skipped)
+ injected = build_injected_schemas(
+ [spec for spec in specs if spec is not None],
+ tracking.options,
+ tracking.reported_conflicts,
+ )
+ tracking.injected_params_registry = injected.injected_params
+ tracking.output_injection_registry = injected.output_injected
+ # Union, never replace: membership only grows, and a concurrent
+ # listing on another instance may already have recorded a tool this
+ # one did not see.
+ tracking.declared_session_params |= injected.declared_session_params
+ for tool, spec in zip(tools, specs, strict=True):
+ if spec is None:
+ continue # uncopyable tool: served verbatim, never mutated
+ tool.inputSchema = spec.input_schema
+ if spec.output_schema is not None:
+ tool.outputSchema = spec.output_schema
+ # A copy of the customer's result rather than a rebuilt one: _meta
+ # and any extra fields their handler set survive (Result is
+ # extra='allow' on 1.x), exactly like the v2 adapter.
+ return ServerResult(listed.root.model_copy(update={"tools": tools}))
+ except Exception as e:
+ write_to_log(
+ "Warning: tools/list injection failed, serving the customer's "
+ f"unmodified list - {e}"
+ )
+ return listed
+
+ async def run_original(request: Any, arguments: dict[str, Any]) -> Any:
+ """Dispatch to the customer's handler on a clone carrying only their args."""
+ params = request.params.model_copy(update={"arguments": arguments})
+ return await original(_CALL)(request.model_copy(update={"params": params}))
+
+ async def wrapped_call(request: Any) -> Any:
+ tracking = current_data()
+ options = tracking.options
+ name = getattr(request.params, "name", None) or "Unknown Tool"
+ raw_arguments = dict(getattr(request.params, "arguments", None) or {})
+
+ # Runs first: on an instance that never served a listing this rebuilds
+ # the registries, which is also what settles whether the customer ships
+ # a get_more_tools of their own.
+ stripped = await get_stripped_arguments(
+ tracking, options, name, raw_arguments, rebuild
+ )
+
+ # Answer get_more_tools ourselves only when WE are the one advertising
+ # it. A customer who happens to name a tool `get_more_tools` keeps it:
+ # silently swapping their handler for our canned reply would alter tool
+ # behavior, which nothing AgentCat does may do (spec §12).
+ serve_report_missing = (
+ name == GET_MORE_TOOLS_NAME and agentcat_advertises_get_more_tools
+ )
+
+ # Tracing off: still strip what we injected, so the customer's tool runs
+ # exactly as it would untracked — then get out of the way. No handle
+ # resolution, no mint-back (there is no session_id parameter to echo), and
+ # no event. get_more_tools still answers (changelog 6.6).
+ if not options.enable_tracing:
+ if serve_report_missing:
+ return ServerResult(await handle_report_missing(stripped))
+ return await run_original(request, stripped)
+
+ context = _safe_request_context(server)
+ try:
+ # Resolved fresh every round: the ResolvedCall carries this round's
+ # request/extra for the customer's tag and property callbacks.
+ #
+ # `request.params`, not `request`: the customer-facing hooks
+ # (`identify`, `event_tags`, `event_properties`, `resolve_session_id`)
+ # take ONE shape on every flavor, and the params model is the only
+ # one all four adapters can produce — mcp 2.x hands its handler
+ # `(ctx, params)` with no request object anywhere, and community
+ # FastMCP's `context.message` is params too. So `request.name` and
+ # `request.arguments` here, exactly as everywhere else.
+ resolved = await resolve_call(
+ tracking,
+ name,
+ raw_arguments,
+ request.params,
+ context,
+ meta_sources=[_meta_extras(request)],
+ legacy_client=lambda: _legacy_client_info(context),
+ )
+ except Exception as e:
+ # Belt and braces at the customer boundary: the resolvers are all
+ # documented not to raise, but a tool call must never fail because
+ # analytics did. Degrade to an untraced call.
+ write_to_log(
+ f"Warning: AgentCat resolution failed for tool '{name}', running "
+ f"it untraced - {e}"
+ )
+ if serve_report_missing:
+ return ServerResult(await handle_report_missing(stripped))
+ return await run_original(request, stripped)
+
+ started = now_ms()
+
+ # The tap's slot is open for exactly the customer's handler and the
+ # publish that reads it, and closes on every exit path including the
+ # raise below.
+ with inner_tap() as tap:
+ try:
+ if serve_report_missing:
+ inner = await handle_report_missing(stripped)
+ else:
+ inner = (await run_original(request, stripped)).root
+ except Exception as e:
+ await publish_tool_call_event(
+ server,
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ response=None,
+ is_error=True,
+ # The live exception, so this path keeps its type and
+ # traceback. Better than anything the tap could hold, and
+ # it is the same object when the tap holds one at all.
+ error=capture_exception(e),
+ duration_ms=now_ms() - started,
+ mrtr=None,
+ extra_params=extra_from_request_context(context),
+ )
+ raise
+
+ mrtr = detect_mrtr(getattr(inner, "resultType", None), False)
+ is_error = bool(getattr(inner, "isError", False))
+ await publish_tool_call_event(
+ server,
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ response=response_payload(inner),
+ is_error=is_error,
+ # The SDK caught the exception and kept only its message, so
+ # `tap.error` reaches past the flattened result for the object
+ # the tap recorded — and falls back to that result's message
+ # when nothing local raised at all.
+ error=tap.error(inner) if is_error else None,
+ duration_ms=now_ms() - started,
+ mrtr=mrtr,
+ extra_params=extra_from_request_context(context),
+ )
+
+ # An intermediate multi-round-trip round is never decorated: only the
+ # completing round carries the mint-back (changelog 6.4).
+ if mrtr == "input_required":
+ return ServerResult(inner)
+
+ update: dict[str, Any] = {}
+ content = getattr(inner, "content", None)
+ decorated = decorate_content(
+ list(content) if isinstance(content, list) else None,
+ resolved.resolution,
+ lambda text: TextContent(type="text", text=text),
+ )
+ if decorated is not None:
+ update["content"] = decorated
+ mirrored = structured_mirror(
+ getattr(inner, "structuredContent", None),
+ resolved.resolution,
+ name,
+ tracking.output_injection_registry,
+ )
+ if mirrored is not None:
+ update["structuredContent"] = mirrored
+ # A copy, never an in-place edit: the event above still references the
+ # customer's own result object.
+ return ServerResult(inner.model_copy(update=update) if update else inner)
+
+ def swap(key: str) -> bool:
+ """Put our wrapper on top of whatever is registered for ``key``.
+
+ Returns whether anything was wrapped. Never raises: a handler table we
+ cannot rewrite leaves the customer's handler exactly where it was,
+ which costs analytics for that method and nothing else.
+ """
+ try:
+ current = handlers.get(request_types[key])
+ # Ours from an earlier pass? Then the customer's handler is the one
+ # we recorded, not the wrapper sitting in the table.
+ customer = original(key) if current is state.get(key) else current
+ if customer is None:
+ return False
+ wrapper = wrapped_list if key == _LIST else wrapped_call
+ state[f"orig_{key}"] = customer
+ state[key] = wrapper
+ handlers[request_types[key]] = wrapper
+ return True
+ except Exception as e:
+ write_to_log(
+ f"Warning: could not wrap '{key}' on this server; it stays "
+ f"untracked - {e}"
+ )
+ return False
+
+ def rearm(key: str) -> bool:
+ """Patch the registration decorator so later registrations land wrapped.
+
+ Registration on this generation writes straight into
+ ``request_handlers``, so there is no single ``add_request_handler`` to
+ patch the way lowlevel v2 has — the decorator that performs the write
+ is the seam. The patch is an instance attribute shadowing the class
+ method, and it unstacks itself on a re-``track()`` the same way the
+ handlers do.
+ """
+ decorator_key = f"{key}_decorator"
+ current = getattr(server, key, None)
+ if not callable(current):
+ return False
+ register = (
+ state.get(f"orig_{decorator_key}")
+ if current is state.get(decorator_key)
+ else current
+ )
+ if register is None:
+ return False
+
+ def rearming(*args: Any, **kwargs: Any) -> Any:
+ decorator = register(*args, **kwargs)
+
+ def apply(func: Any) -> Any:
+ registered = decorator(func)
+ swap(key)
+ return registered
+
+ return apply
+
+ state[f"orig_{decorator_key}"] = register
+ state[decorator_key] = rearming
+ try:
+ setattr(server, key, rearming)
+ except Exception as e:
+ write_to_log(
+ f"Warning: could not re-arm '{key}' registration; a handler "
+ f"registered after track() will not be tracked - {e}"
+ )
+ return False
+ return True
+
+ wrapped = [key for key in (_LIST, _CALL) if swap(key)]
+ rearmed = [key for key in (_LIST, _CALL) if rearm(key)]
+ tapped_seams = _arm_inner_tap(server, facade, state)
+ write_to_log(
+ f"Installed lowlevel-v1 adapter on server {id(server)} "
+ f"(wrapped={wrapped or 'none yet'}, re-armed={rearmed or 'no'}, "
+ f"inner-tap={tapped_seams or 'none'})"
+ )
diff --git a/src/agentcat/modules/adapters/lowlevel_v2.py b/src/agentcat/modules/adapters/lowlevel_v2.py
new file mode 100644
index 0000000..8571087
--- /dev/null
+++ b/src/agentcat/modules/adapters/lowlevel_v2.py
@@ -0,0 +1,644 @@
+"""Adapter for the official MCP SDK 2.x lowlevel ``Server``.
+
+It also serves ``mcp.server.mcpserver.MCPServer`` through its
+``_lowlevel_server``, so both modern official flavors run one interception
+path — the same arrangement ``lowlevel_v1`` has with official FastMCP.
+
+Era-specific knowledge only: the method-keyed ``_request_handlers`` table, the
+frozen ``HandlerEntry`` registration record, ``(ctx, params)`` handlers that
+return complete result models, snake_case model fields
+(``input_schema``/``structured_content``/``is_error``), and the 2026
+multi-round-trip result vocabulary. Every decision about what to resolve,
+strip, decorate or publish belongs to :mod:`agentcat.modules.callpath` and is
+not re-derived here.
+
+Four invariants the wrappers exist to protect:
+
+- **Nothing customer-owned is mutated.** Listed tools are deep-copied before
+ the injection pipeline rewrites their schemas in place, and the request
+ params are cloned via ``model_copy`` rather than rebuilt — a rebuilt
+ ``CallToolRequestParams`` drops ``input_responses`` / ``request_state`` /
+ ``_meta``, which severs multi-round-trip continuations.
+- **The event records the call as the agent made it.** Raw (unstripped)
+ arguments and the customer's undecorated result; the mint-back is wire-only.
+- **An intermediate MRTR round is never decorated.** This is the first era
+ where a tool can ask the client for more input, so ``input_required`` is real
+ behavior here: the round publishes, tagged, but goes back undecorated.
+- **The registration seam stays armed.** ``track()`` can run before a single
+ ``tools/*`` handler exists, and a customer may replace one afterwards;
+ either way the wrapper ends up on top exactly once.
+
+All ``mcp`` imports are function-local so ``import agentcat`` succeeds under
+either SDK major.
+"""
+
+from __future__ import annotations
+
+import copy
+import dataclasses
+from typing import Any
+
+from agentcat.modules.adapters._common import (
+ current_tracking_data,
+ install_state,
+ now_ms,
+ response_payload,
+)
+from agentcat.modules.adapters._inner_tap import inner_tap, tap_method
+from agentcat.modules.callpath import (
+ ResolvedCall,
+ decorate_content,
+ detect_mrtr,
+ get_stripped_arguments,
+ publish_tool_call_event,
+ resolve_call,
+ structured_mirror,
+)
+from agentcat.modules.constants import GET_MORE_TOOLS_NAME
+from agentcat.modules.detection import HANDLER_REGISTRATION_NAMES
+from agentcat.modules.exceptions import capture_exception
+from agentcat.modules.injection import ToolSpec, build_injected_schemas
+from agentcat.modules.logging import write_to_log
+from agentcat.modules.request_extra import extra_from_request_context
+from agentcat.modules.tools import (
+ GET_MORE_TOOLS_DESCRIPTION,
+ GET_MORE_TOOLS_SCHEMA,
+ handle_report_missing,
+)
+from agentcat.types import AgentCatData
+
+LIST_METHOD = "tools/list"
+CALL_METHOD = "tools/call"
+
+
+def _entry(server: Any, method: str) -> Any:
+ """The registration record for ``method``, or None."""
+ try:
+ return server._request_handlers.get(method)
+ except Exception:
+ return None
+
+
+def _handler_of(entry: Any) -> Any:
+ """The callable inside a registration record.
+
+ 2.0 wraps it in a ``HandlerEntry``; the pre-2.0 development line stored the
+ callable directly, and the classifier still accepts that shape.
+ """
+ return getattr(entry, "handler", entry)
+
+
+def _registration(entry: Any, handler: Any) -> Any:
+ """``entry`` with ``handler`` swapped in, for writing back to the table.
+
+ ``HandlerEntry`` is a frozen dataclass, so the record is rebuilt rather
+ than mutated — via ``dataclasses.replace``, which carries every field
+ forward by name and so cannot silently drop one upstream adds later (a
+ validator, a title, an annotation). Positional reconstruction is the
+ fallback for a record that is not a dataclass at all, and a record that is
+ just the callable has no params type to carry.
+ """
+ params_type = getattr(entry, "params_type", None)
+ if params_type is None:
+ return handler
+ if dataclasses.is_dataclass(entry) and not isinstance(entry, type):
+ return dataclasses.replace(entry, handler=handler)
+ return type(entry)(params_type, handler)
+
+
+def _legacy_client_info(ctx: Any) -> Any:
+ """Handshake-time ``client_info``, captured by the SDK's own connection.
+
+ The last rung of the client identity ladder, supplied lazily so the
+ earlier meta rungs never pay for it. On a 2026-era wire there is no
+ handshake and the envelope rung above answers first; on a legacy-era
+ connection this is the only rung there is.
+ """
+ try:
+ return ctx.session.client_params.client_info
+ except Exception:
+ return None
+
+
+def _text_block(text: str) -> Any:
+ from mcp.types import TextContent
+
+ return TextContent(type="text", text=text)
+
+
+def _make_get_more_tools() -> Any:
+ from mcp.types import Tool
+
+ # Built from a keyword mapping rather than literal kwargs. `input_schema`
+ # and `read_only_hint` are the 2.x field names, and a type-check pass run
+ # against the 1.x models — which is exactly what happens in the legacy
+ # dependency set, where this whole module is dead code — sees only their
+ # camelCase predecessors. Same era bridge `adapters/community.py` uses for
+ # its annotations mapping.
+ annotations: dict[str, Any] = {"read_only_hint": True}
+ fields: dict[str, Any] = {
+ "name": GET_MORE_TOOLS_NAME,
+ "description": GET_MORE_TOOLS_DESCRIPTION,
+ "input_schema": copy.deepcopy(GET_MORE_TOOLS_SCHEMA),
+ # Spec defaults assume the worst; declare the honest hint so
+ # annotation-aware clients skip the confirmation prompt. Handed over as
+ # the mapping itself: pydantic coerces it into whichever annotations
+ # model the running SDK declares, and nothing here has to import a type
+ # whose availability moved between generations.
+ "annotations": annotations,
+ }
+ return Tool(**fields)
+
+
+def _mcpserver_behind(server: Any, state: dict[str, Any]) -> Any:
+ """The ``MCPServer`` that owns ``server``, found from its own handler.
+
+ ``track()`` supplies the facade when it was handed one, but a customer who
+ tracks ``mcpserver._lowlevel_server`` directly hands it only the lowlevel
+ object. The registered ``tools/call`` handler is a bound method of the
+ MCPServer either way, and the ownership check — that the object's own
+ ``_lowlevel_server`` IS this server — is what keeps a customer handler
+ bound to some unrelated object with a ``call_tool`` attribute out.
+ """
+ owner = getattr(state.get(f"orig_{CALL_METHOD}"), "__self__", None)
+ if owner is None or getattr(owner, "_lowlevel_server", None) is not server:
+ return None
+ return owner
+
+
+def _arm_inner_tap(server: Any, facade: Any, state: dict[str, Any]) -> list[str]:
+ """Arm the inner tap on ``MCPServer.call_tool``, if there is an MCPServer.
+
+ ``MCPServer._handle_call_tool`` — the ``tools/call`` handler this adapter
+ wraps — catches everything its ``call_tool`` raises and keeps only
+ ``str(e)``, so the exception dies one frame below the wrapper. There is no
+ method call inside that ``except`` to read ``sys.exc_info()`` from the way
+ lowlevel v1 has, so the seam is ``call_tool`` itself: looked up on the
+ instance per call, and the innermost thing outside the customer's tool.
+
+ A **bare** lowlevel v2 server needs nothing. This generation lets a
+ handler's exception through to the runner, so the adapter's own ``except``
+ already holds it live — which is why there is no owner to find there and
+ this is a no-op.
+ """
+ owner = facade if facade is not None else _mcpserver_behind(server, state)
+ if owner is None or not callable(getattr(owner, "call_tool", None)):
+ return []
+ return ["call_tool"] if tap_method(owner, "call_tool", state, "call_tool") else []
+
+
+def install_lowlevel_v2(server: Any, data: AgentCatData, facade: Any = None) -> None:
+ """Wrap ``tools/list`` and ``tools/call`` on a lowlevel v2 server.
+
+ ``server`` is the lowlevel object the detector handed back (a bare
+ ``Server``, or an ``MCPServer``'s ``_lowlevel_server``); ``facade`` is the
+ ``MCPServer`` that owns it when there is one, which only the inner tap
+ needs; ``data`` is the tracking data already stored for it.
+
+ ``initialize`` is deliberately left alone — it cannot be overridden on this
+ generation, and there is nothing there v2 wants: the only thing the old 1.x
+ override took from it, the handshake ``clientInfo``, the SDK already keeps
+ on the connection where the client identity ladder reads it per request.
+ """
+ state = install_state(server)
+ if state is None:
+ return # already logged
+
+ # Whether WE are the one advertising get_more_tools. Stated positively on
+ # purpose: it is set at the append site by every pass over the listing
+ # (client-facing or rebuild-on-demand), so until a listing has actually
+ # advertised our tool this stays False — and a server with no tools/list
+ # handler at all, or one whose listing raised, cannot hijack a customer's
+ # own get_more_tools by default.
+ agentcat_advertises_get_more_tools = False
+
+ def current_data() -> AgentCatData:
+ """The tracking data as of this request, so a re-track takes effect."""
+ return current_tracking_data(server, data)
+
+ def original(method: str) -> Any:
+ """The customer's handler for ``method`` as of right now.
+
+ Read from the shared state on every call rather than captured, so a
+ handler the customer re-registers after ``track()`` is the one that
+ runs — the wrapper on top of it does not need replacing for that.
+ """
+ return state.get(f"orig_{method}")
+
+ def advertised_tools(
+ result: Any, options: Any
+ ) -> tuple[list[Any], set[int]] | None:
+ """Deep copies of the listed tools, plus get_more_tools when enabled.
+
+ None when the handler returned something with no tool list to inject
+ into (a raw dict, an error shape), which the caller serves untouched.
+ Copies because the injection pipeline rewrites schemas in place. A
+ tool whose copy fails is carried through VERBATIM — the customer's
+ original object, never handed to the pipeline so it is never mutated
+ — and its id() lands in the returned skip set so the callers exclude
+ it from injection; one uncopyable tool must not take down the whole
+ listing (or, on the rebuild path, brand the call's arguments).
+ """
+ nonlocal agentcat_advertises_get_more_tools
+ listed = getattr(result, "tools", None)
+ if not isinstance(listed, list):
+ return None
+ tools: list[Any] = []
+ skipped: set[int] = set()
+ for tool in listed:
+ try:
+ tools.append(tool.model_copy(deep=True))
+ except Exception as e:
+ write_to_log(
+ "Warning: could not copy tool "
+ f"'{getattr(tool, 'name', '')}' for injection; "
+ f"serving it verbatim without handle parameters - {e}"
+ )
+ tools.append(tool)
+ skipped.add(id(tool))
+ customer_owns_get_more_tools = any(
+ getattr(tool, "name", None) == GET_MORE_TOOLS_NAME for tool in tools
+ )
+ agentcat_advertises_get_more_tools = (
+ options.enable_report_missing and not customer_owns_get_more_tools
+ )
+ # Appended before injection so it receives handle parameters too; the
+ # context pass skips it by name, so early placement cannot double-inject.
+ if agentcat_advertises_get_more_tools:
+ tools.append(_make_get_more_tools())
+ return tools, skipped
+
+ def specs_for(tools: list[Any], skipped: set[int]) -> list[ToolSpec | None]:
+ """Specs aligned index-for-index with `tools`; None for skipped ones."""
+ return [
+ None
+ if id(tool) in skipped
+ else ToolSpec(
+ tool.name, tool.input_schema, getattr(tool, "output_schema", None)
+ )
+ for tool in tools
+ ]
+
+ def empty_list_params() -> Any:
+ """A default-constructed params model for a listing we ask for.
+
+ The registered params type is all-optional (``PaginatedRequestParams``),
+ which is what the runner would hand a handler for a request that
+ carried none — so a customer handler reading ``params.cursor`` sees the
+ same thing it always does. A bare-callable registration carries no
+ params_type; the real model is the fallback then, NOT None — a
+ customer list handler that dereferences its params must not crash the
+ rebuild (and with it, the call's argument strip).
+ """
+ params_type = getattr(_entry(server, LIST_METHOD), "params_type", None)
+ if params_type is None:
+ try:
+ from mcp.types import PaginatedRequestParams
+
+ return PaginatedRequestParams()
+ except Exception:
+ return None
+ try:
+ return params_type()
+ except Exception:
+ return None
+
+ async def wrapped_list(ctx: Any, params: Any) -> Any:
+ result = await original(LIST_METHOD)(ctx, params)
+ tracking = current_data()
+ try:
+ advertised = advertised_tools(result, tracking.options)
+ if advertised is None:
+ return result
+ tools, skipped = advertised
+ specs = specs_for(tools, skipped)
+ injected = build_injected_schemas(
+ [spec for spec in specs if spec is not None],
+ tracking.options,
+ tracking.reported_conflicts,
+ )
+ tracking.injected_params_registry = injected.injected_params
+ tracking.output_injection_registry = injected.output_injected
+ # Union, never replace: membership only grows, and a concurrent
+ # listing on another instance may already have recorded a tool this
+ # one did not see.
+ tracking.declared_session_params |= injected.declared_session_params
+ for tool, spec in zip(tools, specs, strict=True):
+ if spec is None:
+ continue # uncopyable tool: served verbatim, never mutated
+ tool.input_schema = spec.input_schema
+ if spec.output_schema is not None:
+ tool.output_schema = spec.output_schema
+ # A copy rather than a mutation: next_cursor, cache hints and any
+ # other field the customer's handler set are carried over, and the
+ # result object they still hold is left alone.
+ return result.model_copy(update={"tools": tools})
+ except Exception as e:
+ write_to_log(
+ "Warning: tools/list injection failed, serving the customer's "
+ f"unmodified list - {e}"
+ )
+ return result
+
+ async def wrapped_call(ctx: Any, params: Any) -> Any:
+ tracking = current_data()
+ options = tracking.options
+ name = getattr(params, "name", None) or "Unknown Tool"
+ raw_arguments = dict(getattr(params, "arguments", None) or {})
+
+ async def rebuild() -> list[ToolSpec]:
+ """The list source for registry rebuild-on-demand (changelog 6.3).
+
+ Driven off this request's own context, so a customer handler that
+ reads the session or the lifespan state gets the real thing.
+ """
+ list_handler = original(LIST_METHOD)
+ if list_handler is None:
+ return []
+ listed = await list_handler(ctx, empty_list_params())
+ advertised = advertised_tools(listed, current_data().options)
+ if advertised is None:
+ return []
+ tools, skipped = advertised
+ return [spec for spec in specs_for(tools, skipped) if spec is not None]
+
+ # Runs first: on an instance that never served a listing this rebuilds
+ # the registries, which is also what settles whether the customer ships
+ # a get_more_tools of their own.
+ stripped = await get_stripped_arguments(
+ tracking, options, name, raw_arguments, rebuild
+ )
+
+ async def run_customer() -> Any:
+ """Dispatch to the customer's handler on a clone carrying only
+ their arguments. ``model_copy`` keeps ``input_responses`` /
+ ``request_state`` / ``_meta``, which a rebuilt params model loses."""
+ return await original(CALL_METHOD)(
+ ctx, params.model_copy(update={"arguments": stripped})
+ )
+
+ # Answer get_more_tools ourselves only when WE are the one advertising
+ # it. A customer who happens to name a tool `get_more_tools` keeps it:
+ # silently swapping their handler for our canned reply would alter tool
+ # behavior, which nothing AgentCat does may do (spec §12).
+ serve_report_missing = (
+ name == GET_MORE_TOOLS_NAME and agentcat_advertises_get_more_tools
+ )
+
+ # Tracing off: still strip what we injected, so the customer's tool runs
+ # exactly as it would untracked — then get out of the way. No handle
+ # resolution, no mint-back (there is no session_id parameter to echo), and
+ # no event. get_more_tools still answers (changelog 6.6).
+ if not options.enable_tracing:
+ if serve_report_missing:
+ return await handle_report_missing(stripped)
+ return await run_customer()
+
+ try:
+ # Resolved fresh every round: the ResolvedCall carries this round's
+ # params/context for the customer's tag and property callbacks, so
+ # it must never be cached across MRTR rounds.
+ resolved = await resolve_call(
+ tracking,
+ name,
+ raw_arguments,
+ params,
+ ctx,
+ meta_sources=[getattr(ctx, "meta", None)],
+ legacy_client=lambda: _legacy_client_info(ctx),
+ protocol_fallback=getattr(ctx, "protocol_version", None),
+ )
+ except Exception as e:
+ # Belt and braces at the customer boundary: the resolvers are all
+ # documented not to raise, but a tool call must never fail because
+ # analytics did. Degrade to an untraced call.
+ write_to_log(
+ f"Warning: AgentCat resolution failed for tool '{name}', running "
+ f"it untraced - {e}"
+ )
+ if serve_report_missing:
+ return await handle_report_missing(stripped)
+ return await run_customer()
+
+ started = now_ms()
+
+ # The tap's slot is open for exactly the customer's handler and the
+ # publish that reads it, and closes on every exit path including the
+ # raise below.
+ with inner_tap() as tap:
+ try:
+ if serve_report_missing:
+ result = await handle_report_missing(stripped)
+ else:
+ result = await run_customer()
+ except Exception as e:
+ # Unlike 1.x, this generation lets a bare handler's exception
+ # through to the runner, so this path holds the live exception
+ # with its type and traceback intact.
+ await _publish(
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ ctx,
+ response=None,
+ is_error=True,
+ error=capture_exception(e),
+ started=started,
+ mrtr=None,
+ )
+ raise
+
+ mrtr = detect_mrtr(
+ getattr(result, "result_type", None),
+ getattr(params, "input_responses", None) is not None,
+ getattr(params, "request_state", None) is not None,
+ )
+ is_error = bool(getattr(result, "is_error", False))
+ await _publish(
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ ctx,
+ response=response_payload(result),
+ is_error=is_error,
+ # An `MCPServer` handler flattens the exception before this
+ # wrapper sees it, so `tap.error` reaches past the result for
+ # the object the inner tap recorded — and falls back to that
+ # result's message when nothing local raised at all.
+ error=tap.error(result) if is_error else None,
+ started=started,
+ mrtr=mrtr,
+ )
+
+ # An intermediate multi-round-trip round is never decorated: only the
+ # completing round carries the mint-back (changelog 6.4). It has no
+ # content to append to and no structured payload to mirror into, and
+ # even if it did the handle belongs on the round that finishes.
+ if mrtr == "input_required":
+ return result
+ return _decorated(result, resolved, name, tracking)
+
+ async def _publish(
+ tracking: AgentCatData,
+ resolved: ResolvedCall,
+ name: str,
+ raw_arguments: dict[str, Any],
+ ctx: Any,
+ response: dict[str, Any] | None,
+ is_error: bool,
+ error: Any,
+ started: int,
+ mrtr: str | None,
+ ) -> None:
+ await publish_tool_call_event(
+ server,
+ tracking,
+ resolved,
+ name,
+ raw_arguments,
+ response=response,
+ is_error=is_error,
+ error=error,
+ duration_ms=now_ms() - started,
+ mrtr=mrtr,
+ extra_params=extra_from_request_context(ctx),
+ )
+
+ def _decorated(
+ result: Any, resolved: ResolvedCall, name: str, tracking: AgentCatData
+ ) -> Any:
+ """The wire result with the mint-back appended, or the original."""
+ if not hasattr(result, "model_copy"):
+ # A handler that returned a raw dict is serialized as-is by the
+ # runner; there is no model to copy and nothing safe to edit. The
+ # call is still tracked, but the agent never sees the mint-back and
+ # so mints a fresh task on every call — say so, or that shows up as
+ # an unexplained pile of one-call tasks.
+ write_to_log(
+ f"Warning: tool '{name}' returned a raw mapping rather than a "
+ "result model, so the session_id mint-back cannot be attached; "
+ "every call to it will start a new task"
+ )
+ return result
+ update: dict[str, Any] = {}
+ content = getattr(result, "content", None)
+ decorated = decorate_content(
+ list(content) if isinstance(content, list) else None,
+ resolved.resolution,
+ _text_block,
+ )
+ if decorated is not None:
+ update["content"] = decorated
+ mirrored = structured_mirror(
+ getattr(result, "structured_content", None),
+ resolved.resolution,
+ name,
+ tracking.output_injection_registry,
+ )
+ if mirrored is not None:
+ update["structured_content"] = mirrored
+ # A copy, never an in-place edit: the event above still references the
+ # customer's own result object.
+ return result.model_copy(update=update) if update else result
+
+ def swap(method: str) -> bool:
+ """Put our wrapper on top of whatever is registered for ``method``.
+
+ Returns whether anything was wrapped. Never raises: a table shape we
+ cannot rewrite leaves the customer's handler exactly where it was,
+ which costs analytics for that method and nothing else.
+ """
+ entry = _entry(server, method)
+ if entry is None:
+ return False
+ try:
+ current = _handler_of(entry)
+ # Ours from an earlier pass? Then the customer's handler is the one
+ # we recorded, not the wrapper sitting in the table.
+ customer = original(method) if current is state.get(method) else current
+ if customer is None:
+ return False
+ wrapper = wrapped_list if method == LIST_METHOD else wrapped_call
+ state[f"orig_{method}"] = customer
+ state[method] = wrapper
+ server._request_handlers[method] = _registration(entry, wrapper)
+ return True
+ except Exception as e:
+ write_to_log(
+ f"Warning: could not wrap '{method}' on this server; it stays "
+ f"untracked - {e}"
+ )
+ return False
+
+ def rearm_seam(name: str, already_patched: list[Any]) -> bool:
+ """Patch one registration seam so later registrations land wrapped.
+
+ Without this, ``track()`` on a server whose ``tools/*`` handlers are
+ registered afterwards installs nothing at all, and a customer who
+ replaces a handler later silently drops out of tracking. The patch is
+ an instance attribute shadowing the class method, and it unstacks
+ itself on a re-``track()`` the same way the handlers do.
+
+ ``already_patched`` collects the underlying functions patched so far.
+ A build whose public name is a bare alias for the private one resolves
+ to the same function twice; patching it twice would register twice.
+ """
+ current = getattr(server, name, None)
+ if not callable(current):
+ return False
+ add = (
+ state.get(f"orig_seam_{name}")
+ if current is state.get(f"seam_{name}")
+ else current
+ )
+ if add is None:
+ return False
+ underlying = getattr(add, "__func__", add)
+ if any(patched is underlying for patched in already_patched):
+ return False
+
+ def rearming_add(*args: Any, **kwargs: Any) -> Any:
+ result = add(*args, **kwargs)
+ method = args[0] if args else kwargs.get("method")
+ if method in (LIST_METHOD, CALL_METHOD):
+ swap(method)
+ return result
+
+ state[f"orig_seam_{name}"] = add
+ state[f"seam_{name}"] = rearming_add
+ try:
+ setattr(server, name, rearming_add)
+ except Exception as e:
+ write_to_log(
+ f"Warning: could not re-arm '{name}'; a tools handler "
+ f"registered after track() through it will not be tracked - {e}"
+ )
+ return False
+ already_patched.append(underlying)
+ return True
+
+ def rearm() -> list[str]:
+ """Patch EVERY registration seam this build exposes.
+
+ Not just the first one found: the classifier accepts both spellings
+ because the method has been public and private at different points on
+ the 2.x line, and the natural refactor shape — a public wrapper
+ delegating to a private one — exposes both at once. Patching only the
+ public name there would silently drop re-arm for anyone who registers
+ through the private one.
+ """
+ patched: list[Any] = []
+ return [
+ name for name in HANDLER_REGISTRATION_NAMES if rearm_seam(name, patched)
+ ]
+
+ wrapped = [method for method in (LIST_METHOD, CALL_METHOD) if swap(method)]
+ seams = rearm()
+ tapped_seams = _arm_inner_tap(server, facade, state)
+ write_to_log(
+ f"Installed lowlevel-v2 adapter on server {id(server)} "
+ f"(wrapped={wrapped or 'none yet'}, re-armed={seams or 'no'}, "
+ f"inner-tap={tapped_seams or 'none'})"
+ )
diff --git a/src/agentcat/modules/callpath.py b/src/agentcat/modules/callpath.py
new file mode 100644
index 0000000..1ad9b02
--- /dev/null
+++ b/src/agentcat/modules/callpath.py
@@ -0,0 +1,321 @@
+"""Shared per-call orchestration for `tools/call`.
+
+Every adapter — lowlevel v1, lowlevel v2, community FastMCP — wraps the
+customer's handler with the same steps: resolve (handles, actor, client),
+strip the parameters we injected, run, publish exactly one event, decorate the
+wire result. This module is that shared middle. The primitives it composes
+live in handles.py / injection.py / client_identity.py and are never
+re-derived here.
+
+Engine code: nothing version-specific from `mcp`/`fastmcp` is imported, so it
+loads identically under mcp 1.x and 2.x. Nothing here may raise into the
+customer's server, and nothing here mutates a customer object — the published
+event carries the RAW arguments and the UNDECORATED result (mint-back is
+wire-only), `get_stripped_arguments` returns a new dict and `decorate_content`
+a new list.
+
+Cross-SDK contract: 2026-07-28-cross-sdk-changelog.md §3.4, §6.2-§6.5; TS
+reference src/engine/callWrap.ts.
+"""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from agentcat.modules import event_queue
+from agentcat.modules.client_identity import (
+ ClientIdentity,
+ resolve_client_identity,
+ resolve_protocol_version,
+)
+from agentcat.modules.constants import CONTEXT_PARAM
+from agentcat.modules.handles import (
+ HandleResolution,
+ build_handle_tags,
+ build_mint_back_text,
+ build_structured_mint_back,
+ mirror_into_structured_content,
+ resolve_handles,
+)
+from agentcat.modules.identify import resolve_identity
+from agentcat.modules.injection import (
+ ToolSpec,
+ build_injected_schemas,
+ injected_parameter_names,
+ strip_injected_arguments,
+)
+from agentcat.modules.internal import attach_event_metadata
+from agentcat.modules.logging import write_to_log
+from agentcat.types import (
+ AgentCatData,
+ AgentCatOptions,
+ ErrorData,
+ EventType,
+ UnredactedEvent,
+ UserIdentity,
+)
+
+# A failed call whose adapter could make nothing of the failure. Same shape as
+# every other error payload so consumers never have to branch on presence.
+_UNKNOWN_ERROR: ErrorData = {
+ "message": "Unknown error",
+ "type": None,
+ "platform": "python",
+}
+
+
+@dataclass
+class ResolvedCall:
+ """Everything resolved about one tool call before its handler runs."""
+
+ resolution: HandleResolution
+ actor: UserIdentity | None
+ client: ClientIdentity
+ protocol_version: str | None
+ intent: str | None
+ # The originating request/extra, carried so the customer's event_tags and
+ # event_properties callbacks receive the same pair `identify` got — the
+ # documented contract of `internal.attach_event_metadata`, which
+ # publish_tool_call_event runs long after the adapter's frame is gone.
+ # Consequently a ResolvedCall is single-round state: an adapter must
+ # resolve afresh on each MRTR round, never cache one across rounds, or
+ # round N's callbacks would be handed round 1's request.
+ request: Any = None
+ extra: Any = None
+
+
+async def resolve_call(
+ data: AgentCatData,
+ tool_name: str,
+ raw_arguments: dict[str, Any],
+ request: Any,
+ extra: Any,
+ meta_sources: list[Any],
+ legacy_client: Callable[[], Any | None],
+ protocol_fallback: str | None = None,
+) -> ResolvedCall:
+ """Resolve handles, actor, client and intent. Publishes nothing.
+
+ None of the four resolvers raises: a `resolve_session_id` hook that blows up
+ mints silently, a broken `identify` yields an anonymous call, and the
+ client ladder floors at an empty identity. `tool_name` is taken so every
+ adapter calls this with one shape; intent capture is unconditional.
+
+ MUST run after `get_stripped_arguments` — every adapter does — because the
+ registry it reads is what that call populates (and rebuilds on demand). It
+ is the same registry, so the handles read here are exactly the parameters
+ stripped there: a `session_id` the customer's own schema declared is neither
+ taken from their handler nor consumed as ours.
+
+ A tool absent from `declared_session_params` is ours — including one this
+ instance never listed. That is the common case on stateless HTTP and it
+ degrades safely: a customer's foreign value in that window is classified
+ `invalid` rather than `foreign`, and both publish sessionless.
+ """
+ resolution = await resolve_handles(
+ raw_arguments,
+ data.options,
+ data.project_id,
+ request,
+ extra,
+ injected_parameter_names(
+ tool_name, data.injected_params_registry, raw_arguments, data.options
+ ),
+ tool_name not in data.declared_session_params,
+ )
+ actor = await resolve_identity(data, request, extra)
+ client = resolve_client_identity(meta_sources, legacy_client)
+ protocol_version = resolve_protocol_version(meta_sources, protocol_fallback)
+ # Read from the RAW arguments (pre-strip): the event records what the agent
+ # sent, and a non-string `context` is not an explanation.
+ context_value = raw_arguments.get(CONTEXT_PARAM)
+ return ResolvedCall(
+ resolution=resolution,
+ actor=actor,
+ client=client,
+ protocol_version=protocol_version,
+ intent=context_value if isinstance(context_value, str) else None,
+ request=request,
+ extra=extra,
+ )
+
+
+async def get_stripped_arguments(
+ data: AgentCatData,
+ options: AgentCatOptions,
+ tool_name: str,
+ raw_arguments: dict[str, Any],
+ rebuild: Callable[[], Awaitable[list[ToolSpec]]] | None,
+) -> dict[str, Any]:
+ """A NEW argument dict with only the params AgentCat injected removed.
+
+ Registry-first (§6.2). A call that lands on an instance which never served
+ `tools/list` rebuilds the registries on demand from the adapter's list
+ source (§6.3); the injection pipeline is deterministic, so a rebuilt
+ registry matches what any listing instance advertised. Only a failed
+ rebuild falls back to the shape+config-aware strip (see
+ `injected_parameter_names`): a name is removed only when the enabled
+ options would have injected it AND, for `session_id`, the value matches
+ our minted shape — a customer-declared parameter rides through to their
+ handler. The fallback also clears the output-injection registry, so the
+ structured mirror stops gating on knowledge we no longer have (§3.4b).
+ """
+ registry = data.injected_params_registry
+ if registry is None and rebuild is not None:
+ try:
+ result = build_injected_schemas(
+ await rebuild(), options, data.reported_conflicts
+ )
+ except Exception as e:
+ write_to_log(
+ "Warning: injection registry rebuild-on-demand failed for tool "
+ f"'{tool_name}', falling back to heuristic strip - {e}"
+ )
+ # Only the mirror gate is cleared. `injected_params_registry` is
+ # left alone: it is already None on this path, and assigning would
+ # destroy a good registry a concurrent call had just stored while
+ # this one was awaiting — after which every later call would
+ # heuristic-strip and eat customer `context` parameters.
+ data.output_injection_registry = None
+ else:
+ registry = result.injected_params
+ data.injected_params_registry = result.injected_params
+ data.output_injection_registry = result.output_injected
+ data.declared_session_params |= result.declared_session_params
+ write_to_log(
+ "Rebuilt injection registries on demand "
+ "(tools/call before tools/list on this instance)"
+ )
+ return strip_injected_arguments(tool_name, raw_arguments, registry, options)
+
+
+def detect_mrtr(
+ result_type: str | None,
+ has_input_responses: bool,
+ has_request_state: bool = False,
+) -> str | None:
+ """Classify one round of a multi round-trip tool call (§6.4).
+
+ `input_required` wins: an intermediate round that is itself a continuation
+ is still intermediate, and only a completing round carries the mint-back.
+
+ A continuation is EITHER shape the SEP-2322 driver can send. It answers an
+ intermediate round that carried `input_requests` with the collected
+ `inputResponses`, but an intermediate round that carried only
+ `requestState` — a tool asking to be resumed rather than asking the client
+ a question — is retried after a backoff with **no** responses at all
+ (`mcp/client/_input_required.py::run_input_required_driver`). Keying the
+ tag on `inputResponses` alone left that second shape untagged, which is the
+ shape FastMCP 4's own `InputRequiredResult(request_state=...)` produces.
+ `requestState` rides only on a round that is answering an earlier one — a
+ first round never carries it — so it is a sound continuation witness on its
+ own.
+ """
+ if result_type == "input_required":
+ return "input_required"
+ if has_input_responses or has_request_state:
+ return "continuation"
+ return None
+
+
+def decorate_content(
+ content: list[Any] | None,
+ res: HandleResolution,
+ make_text_block: Callable[[str], Any],
+) -> list[Any] | None:
+ """The trailing mint-back block, or None to leave the result untouched.
+
+ Error state is deliberately not an input: the retry after a failure has to
+ carry the same session, so `isError` results decorate on identical terms
+ (§3.4a). Whether there is anything to say at all stays the single ruling of
+ `build_mint_back_text` — a session minted on this call, or a supplied one
+ this server never issued; never in hook mode, and never for a parameter
+ AgentCat did not inject.
+ """
+ text = build_mint_back_text(res)
+ if text is None or not isinstance(content, list):
+ return None
+ return [*content, make_text_block(text)]
+
+
+def structured_mirror(
+ sc: Any,
+ res: HandleResolution,
+ tool_name: str,
+ output_registry: set[str] | None,
+) -> Any | None:
+ """Handle state mirrored into `structuredContent`, or None (§3.4b).
+
+ Gated on the output-injection registry: mirroring a key the tool's own
+ `outputSchema` never declared fails the customer's entire result on a
+ schema-validating client. No registry at all means the rebuild failed —
+ mirror anyway, since no schema we know about can be in play.
+ """
+ if output_registry is not None and tool_name not in output_registry:
+ return None
+ mint = build_structured_mint_back(res)
+ if mint is None:
+ return None
+ return mirror_into_structured_content(sc, mint)
+
+
+async def publish_tool_call_event(
+ server_key: Any,
+ data: AgentCatData,
+ rc: ResolvedCall,
+ tool_name: str,
+ raw_arguments: dict[str, Any],
+ response: Any,
+ is_error: bool,
+ error: ErrorData | None,
+ duration_ms: int | None,
+ mrtr: str | None,
+ extra_params: dict[str, Any] | None,
+) -> None:
+ """Publish the one event a v2 tool call produces. Never raises.
+
+ The event records the call as the agent made it: RAW arguments (handles and
+ `context` included) and the customer's undecorated result. SDK tags merge
+ over the customer's, so they win on collision and ride outside the 50-tag
+ customer cap `validate_tags` enforces (§6.5).
+
+ `error` is whatever `modules.exceptions.capture_exception` made of the
+ failure — the adapter decides, because only it knows whether it holds a
+ live exception or the `isError` result the SDK already flattened it into.
+ A failure always records an error object, never a bare `is_error` flag.
+ """
+ try:
+ now = datetime.now(timezone.utc)
+ event = UnredactedEvent(
+ # "" is sessionless (invalid/foreign); the wire carries null.
+ session_id=rc.resolution.session_id or None,
+ # Events timestamp when the call STARTED; the adapter measured how
+ # long ago that was.
+ timestamp=now - timedelta(milliseconds=duration_ms) if duration_ms else now,
+ event_type=EventType.MCP_TOOLS_CALL.value,
+ resource_name=tool_name,
+ user_intent=rc.intent,
+ parameters={"arguments": raw_arguments, **(extra_params or {})},
+ response=response,
+ is_error=is_error,
+ error=(error or _UNKNOWN_ERROR) if is_error else None,
+ duration=duration_ms,
+ client_name=rc.client.name,
+ client_version=rc.client.version,
+ identify_actor_given_id=rc.actor.user_id if rc.actor else None,
+ identify_actor_name=rc.actor.user_name if rc.actor else None,
+ identify_data=rc.actor.user_data if rc.actor else None,
+ )
+ await attach_event_metadata(event, data, rc.request, rc.extra)
+ # SDK tags last so they win, and after validate_tags so they are exempt
+ # from the customer cap.
+ event.tags = {
+ **(event.tags or {}),
+ **build_handle_tags(rc.resolution, rc.protocol_version, mrtr),
+ }
+ event_queue.publish_event(server_key, event)
+ except Exception as e:
+ write_to_log(
+ f"Warning: failed to publish tools/call event for '{tool_name}' - {e}"
+ )
diff --git a/src/agentcat/modules/client_identity.py b/src/agentcat/modules/client_identity.py
new file mode 100644
index 0000000..5282549
--- /dev/null
+++ b/src/agentcat/modules/client_identity.py
@@ -0,0 +1,103 @@
+"""Per-request client identity + protocol version ladder (spec §7).
+
+Cross-SDK contract: 2026-07-28-cross-sdk-changelog.md §5.1; TS reference
+src/modules/session.ts (``narrowClientInfo`` / ``getClientInfoForRequest``).
+Client name/version arrives per request under the fully-qualified
+``META_CLIENT_INFO_KEY`` meta key (envelope or ``_meta`` passthrough); the
+pre-2026 initialize-time capture is the last rung, supplied lazily by the
+adapter as a callable. First hit wins and every rung is narrowed per field
+to ``isinstance(x, str)`` — a non-string leaking into an event tag is a
+wire-format break.
+
+A rung that is present but unusable (junk under the key, no string field)
+is a miss, not a stop: it must not shadow a good value further down the
+ladder. Nothing here raises — a torn-down request context or a hostile
+meta object resolves to a miss.
+"""
+
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass
+from typing import Any
+
+from agentcat.modules.constants import (
+ META_CLIENT_INFO_KEY,
+ META_PROTOCOL_VERSION_KEY,
+)
+
+
+@dataclass
+class ClientIdentity:
+ name: str | None = None
+ version: str | None = None
+
+
+def _meta_value(meta: Any, key: str) -> Any:
+ """Raw value under a fully-qualified meta key; ``None`` on any miss.
+
+ Accepts plain mappings (v2 envelopes / lifted meta) and pydantic
+ ``RequestParams.Meta``-style objects whose extras live in
+ ``.model_extra`` (``None`` when the model forbids extras).
+ """
+ if meta is None:
+ return None
+ try:
+ if isinstance(meta, Mapping):
+ return meta.get(key)
+ extra = getattr(meta, "model_extra", None)
+ if isinstance(extra, Mapping) and key in extra:
+ return extra[key]
+ return getattr(meta, key, None)
+ except Exception:
+ return None
+
+
+def _narrow(raw: Any) -> ClientIdentity | None:
+ """Per-field narrowing of a clientInfo-shaped mapping or object; a value
+ with no usable string field is a miss (``None``), mirroring the TS
+ narrower."""
+ if raw is None:
+ return None
+ try:
+ if isinstance(raw, Mapping):
+ name = raw.get("name")
+ version = raw.get("version")
+ else:
+ name = getattr(raw, "name", None)
+ version = getattr(raw, "version", None)
+ except Exception:
+ return None
+ narrowed_name = name if isinstance(name, str) else None
+ narrowed_version = version if isinstance(version, str) else None
+ if narrowed_name is None and narrowed_version is None:
+ return None
+ return ClientIdentity(name=narrowed_name, version=narrowed_version)
+
+
+def client_identity_from_meta(meta: Any) -> ClientIdentity | None:
+ return _narrow(_meta_value(meta, META_CLIENT_INFO_KEY))
+
+
+def resolve_client_identity(
+ meta_sources: list[Any], legacy: Callable[[], Any | None]
+) -> ClientIdentity:
+ for meta in meta_sources:
+ identity = client_identity_from_meta(meta)
+ if identity is not None:
+ return identity
+ try:
+ legacy_info = legacy()
+ except Exception:
+ legacy_info = None
+ identity = _narrow(legacy_info)
+ return identity if identity is not None else ClientIdentity()
+
+
+def resolve_protocol_version(
+ meta_sources: list[Any], fallback: str | None = None
+) -> str | None:
+ for meta in meta_sources:
+ value = _meta_value(meta, META_PROTOCOL_VERSION_KEY)
+ # Non-empty like TS getProtocolVersion (`value.length > 0`).
+ if isinstance(value, str) and value:
+ return value
+ return fallback
diff --git a/src/agentcat/modules/compatibility.py b/src/agentcat/modules/compatibility.py
deleted file mode 100644
index fe43d3f..0000000
--- a/src/agentcat/modules/compatibility.py
+++ /dev/null
@@ -1,262 +0,0 @@
-"""Compatibility checks for MCP servers."""
-
-from typing import Any, Protocol, runtime_checkable
-
-from mcp import ServerResult
-
-# Supported version ranges for MCP and FastMCP
-SUPPORTED_MCP_VERSIONS = ">=1.2.0"
-SUPPORTED_OFFICIAL_FASTMCP_VERSIONS = ">=1.2.0"
-SUPPORTED_COMMUNITY_FASTMCP_VERSIONS = ">=2.7.0"
-SUPPORTED_COMMUNITY_FASTMCP_V3_VERSIONS = ">=3.0.0"
-
-# Version compatibility message for errors
-COMPATIBILITY_ERROR_MESSAGE = (
- f"Server must be a supported version of a FastMCP instance "
- f"(official: {SUPPORTED_OFFICIAL_FASTMCP_VERSIONS}, "
- f"community: {SUPPORTED_COMMUNITY_FASTMCP_VERSIONS}) "
- f"or MCP Low-level Server instance ({SUPPORTED_MCP_VERSIONS})"
-)
-
-@runtime_checkable
-class MCPServerProtocol(Protocol):
- """Protocol for MCP server compatibility."""
-
- def list_tools(self) -> Any:
- """List available tools."""
- ...
-
- def call_tool(self, name: str, arguments: dict) -> Any:
- """Call a tool by name."""
- ...
-
-def is_community_fastmcp_v3(server: Any) -> bool:
- """Check if the server is a Community FastMCP v3 instance.
-
- Community FastMCP v3 uses the Provider architecture with _local_provider
- instead of the ToolManager architecture with _tool_manager.
- It also has the middleware system with add_middleware method.
- """
- # Check by class name and module
- class_name = server.__class__.__name__
- module_name = server.__class__.__module__
-
- # Community FastMCP v3 has:
- # - Class name containing 'FastMCP'
- # - Module starts with 'fastmcp'
- # - Has _local_provider (Provider architecture)
- # - Has add_middleware method (middleware system)
- # - Does NOT have _tool_manager (v2 attribute)
- return (
- "FastMCP" in class_name and
- module_name.startswith("fastmcp") and
- hasattr(server, "_local_provider") and
- hasattr(server, "add_middleware") and
- hasattr(server, "middleware") and
- not hasattr(server, "_tool_manager")
- )
-
-
-def is_community_fastmcp_v2(server: Any) -> bool:
- """Check if the server is a Community FastMCP v2 instance.
-
- Community FastMCP v2 uses the ToolManager architecture with _tool_manager.
- """
- # Check by class name and module
- class_name = server.__class__.__name__
- module_name = server.__class__.__module__
-
- # Community FastMCP v2 has:
- # - Class name containing 'FastMCP'
- # - Module starts with 'fastmcp'
- # - Has _mcp_server
- # - Has _tool_manager (ToolManager architecture)
- return (
- "FastMCP" in class_name and
- module_name.startswith("fastmcp") and
- hasattr(server, "_mcp_server") and
- hasattr(server, "_tool_manager")
- )
-
-
-def is_community_fastmcp_server(server: Any) -> bool:
- """Check if the server is a community FastMCP instance (any version).
-
- Community FastMCP comes from the 'fastmcp' package.
- Supports FastMCP subclasses like FastMCPOpenAPI, FastMCPProxy, etc.
- This function returns True for both v2 and v3.
- """
- return is_community_fastmcp_v2(server) or is_community_fastmcp_v3(server)
-
-def is_official_fastmcp_server(server: Any) -> bool:
- """Check if the server is an official FastMCP instance.
-
- Official FastMCP comes from the 'mcp.server.fastmcp' module.
- Supports FastMCP subclasses like FastMCPOpenAPI, FastMCPProxy, etc.
- """
- # Check by class name and module
- class_name = server.__class__.__name__
- module_name = server.__class__.__module__
-
- # Official FastMCP has class name containing 'FastMCP' and module
- # 'mcp.server.fastmcp'. Supports FastMCPOpenAPI, FastMCPProxy, etc.
- return (
- "FastMCP" in class_name and
- module_name.startswith("mcp.server.fastmcp") and
- hasattr(server, "_mcp_server") and
- hasattr(server, "_tool_manager")
- )
-
-
-def has_required_fastmcp_attributes(server: Any) -> bool:
- """Check if a FastMCP server has all required attributes for monkey patching.
-
- This validates that the server has all the attributes that monkey_patch.py expects.
- """
- # Check for _tool_manager and its required methods
- if not hasattr(server, "_tool_manager"):
- return False
-
- tool_manager = server._tool_manager
- required_tool_manager_methods = ["add_tool", "call_tool", "list_tools"]
- for method in required_tool_manager_methods:
- if not hasattr(tool_manager, method) or not callable(
- getattr(tool_manager, method)
- ):
- return False
-
- # Check for _tools dict on tool_manager (used for tracking existing tools)
- if not hasattr(tool_manager, "_tools") or not isinstance(tool_manager._tools, dict):
- return False
-
- # Check for add_tool method on the server itself (used for adding get_more_tools)
- if not hasattr(server, "add_tool") or not callable(server.add_tool):
- return False
-
- # Check for _mcp_server (used for event tracking and session management)
- if not hasattr(server, "_mcp_server"):
- return False
-
- # Check if _mcp_server has _get_cached_tool_definition method
- # (for community FastMCP patching)
- if not hasattr(server._mcp_server, "_get_cached_tool_definition"):
- return False
-
- return True
-
-
-def has_necessary_attributes(server: Any) -> bool:
- """Check if the server has necessary attributes for compatibility."""
- required_methods = ["list_tools", "call_tool"]
-
- # Check for core methods that both FastMCP and Server implementations have
- for method in required_methods:
- if not hasattr(server, method):
- return False
-
- # For FastMCP servers, verify all required attributes for monkey patching
- if is_official_fastmcp_server(server):
- # Use the comprehensive FastMCP validation
- if not has_required_fastmcp_attributes(server):
- return False
-
- # Additional checks for request handling
- # Use dir() to avoid triggering property getters that might raise exceptions
- if "request_context" not in dir(server._mcp_server):
- return False
- # Check for get_context method which is FastMCP specific
- if not hasattr(server, "get_context"):
- return False
- # Check for request_handlers dictionary on internal server
- if not hasattr(server._mcp_server, "request_handlers"):
- return False
- if not isinstance(server._mcp_server.request_handlers, dict):
- return False
- else:
- # Regular Server implementation - check for request_context directly
- # Use dir() to avoid triggering property getters that might raise exceptions
- if "request_context" not in dir(server):
- return False
- # Check for request_handlers dictionary
- if not hasattr(server, "request_handlers"):
- return False
- if not isinstance(server.request_handlers, dict):
- return False
-
- return True
-
-
-def is_compatible_server(server: Any) -> bool:
- """Check if the server is compatible with AgentCat."""
- # If it's FastMCP v3 (community), it's compatible
- if is_community_fastmcp_v3(server):
- return True
-
- # If it's either official or community FastMCP v2, it's compatible
- if is_official_fastmcp_server(server) or is_community_fastmcp_v2(server):
- return True
-
- # Otherwise, check for necessary attributes
- return has_necessary_attributes(server)
-
-
-def get_mcp_compatible_error_message(error: Any) -> str:
- """Get error message in a compatible format."""
- return str(error)
-
-
-def is_mcp_error_response(response: ServerResult) -> tuple[bool, str]:
- """Check if the response is an MCP error."""
- # ServerResult is a RootModel, so we need to access its root attribute
- if not hasattr(response, "root"):
- return False, ""
-
- result = response.root
-
- # Check if it's a CallToolResult with an error
- if not (hasattr(result, "isError") and result.isError):
- return False, ""
-
- # Extract error message from content
- if not (hasattr(result, "content") and result.content):
- return True, "Unknown error"
-
- # content is a list of TextContent/ImageContent/EmbeddedResource
- for content_item in result.content:
- # Check if it has a text attribute (TextContent)
- if hasattr(content_item, "text"):
- return True, str(content_item.text)
- # Check if it has type and content attributes
- if (
- hasattr(content_item, "type")
- and hasattr(content_item, "content")
- and content_item.type == "text"
- ):
- return True, str(content_item.content)
-
- # If no text content found, stringify the first item
- if result.content:
- return True, str(result.content[0])
-
- return True, "Unknown error"
-
-__all__ = [
- # Version constants
- "SUPPORTED_MCP_VERSIONS",
- "SUPPORTED_OFFICIAL_FASTMCP_VERSIONS",
- "SUPPORTED_COMMUNITY_FASTMCP_VERSIONS",
- "SUPPORTED_COMMUNITY_FASTMCP_V3_VERSIONS",
- "COMPATIBILITY_ERROR_MESSAGE",
- # Functions
- "is_compatible_server",
- "is_official_fastmcp_server",
- "is_community_fastmcp_server",
- "is_community_fastmcp_v2",
- "is_community_fastmcp_v3",
- "has_required_fastmcp_attributes",
- "has_necessary_attributes",
- "get_mcp_compatible_error_message",
- "is_mcp_error_response",
- # Protocols
- "MCPServerProtocol",
-]
diff --git a/src/agentcat/modules/constants.py b/src/agentcat/modules/constants.py
index 8146fb4..1e5462a 100644
--- a/src/agentcat/modules/constants.py
+++ b/src/agentcat/modules/constants.py
@@ -1,4 +1,3 @@
-INACTIVITY_TIMEOUT_IN_MINUTES = 30
LOG_PATH = "agentcat.log" # Default log file path
SESSION_ID_PREFIX = "ses"
EVENT_ID_PREFIX = "evt"
@@ -20,3 +19,48 @@
# DIAGNOSTICS_TOKEN env var. Must match the collector's bearer token (same
# literal as the TypeScript SDK).
DEFAULT_DIAGNOSTICS_TOKEN = "dgk_sdk_diag_3f9a2c7e1b8d4065af2e9c1d7b6a4f80"
+
+# ── Explicit handles: injected parameter names & wire keys ───────────────────
+SESSION_ID_PARAM = "session_id"
+AGENT_ID_PARAM = "agent_id"
+CONTEXT_PARAM = "context"
+GET_MORE_TOOLS_NAME = "get_more_tools"
+AGENT_ID_PREFIX = "agt" # reserved; server-side agent minting was removed
+MCP_INSTRUCTIONS_KEY = "_mcp_instructions"
+META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
+META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
+AGENTCAT_TAG_SESSION_SOURCE = "agentcat_session_id_source"
+AGENTCAT_TAG_AGENT_ID = "agentcat_agent_id"
+AGENTCAT_TAG_AGENT_SOURCE = "agentcat_agent_id_source"
+AGENTCAT_TAG_PROTOCOL_VERSION = "agentcat_protocol_version"
+AGENTCAT_TAG_MRTR = "agentcat_mrtr"
+AGENTCAT_CUSTOM_EVENT_TYPE = "agentcat:custom"
+
+# ── Explicit handles: agent-facing copy (byte-identical to TS constants.ts) ──
+# Source of truth: agentcat-typescript-sdk/src/modules/constants.ts (and the
+# copy spec it cites). Do not reword here without changing the TS SDK first;
+# tests/test_constants_copy.py enforces byte parity.
+SESSION_ID_PARAM_DESCRIPTION = "REQUIRED on every call after your first. This MCP server associates all of your tool calls for a given task using session_id; a call that arrives without it cannot be associated with your earlier work and is treated as the start of an unrelated task, disconnected from the original goal. Omit it on your first call only — the server will issue one in the _mcp_instructions field of the result (or a trailing [MCP INSTRUCTIONS] text block) — then echo that exact value on every later call. Never invent a value, and do not issue parallel tool calls until the server has issued your session_id. One session_id covers the whole goal from start to finish: when you spawn subagents you MUST give them this same session_id, or their work is severed from yours. Without session_id, this server does not function as intended."
+AGENT_ID_PARAM_DESCRIPTION = "REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent working this task using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended."
+AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE = "REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended."
+MINT_BACK_HEADER_SESSION = "[MCP INSTRUCTIONS]: session_id issued."
+MINT_BACK_HEADER_INVALID = "[MCP INSTRUCTIONS]: session_id not recognized."
+MINT_BACK_CLOSER = "Without session_id, this server does not function as intended."
+MINT_BACK_INVALID_LINE = " The session_id you sent was not issued by this server. Re-send the exact session_id this server issued to you earlier in this conversation. Never invent a value. If this server has not issued you a session_id yet, omit the parameter and one will be issued."
+MCP_INSTRUCTIONS_FIELD_DESCRIPTION = "Your handles for this task, confirmed by this MCP server on every response, and the instructions for echoing them on later calls. Read and follow."
+MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION = (
+ "Echo this exact value as the session_id argument on every subsequent tool call."
+)
+MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION = "Your agent_id as this server received it. Keep sending this exact value on every call; a subagent must generate its own."
+
+
+def mint_back_session_line(session_id: str) -> str:
+ return f" session_id={session_id} — required on every subsequent tool call"
+
+
+def mint_back_confirmed(names: list[str]) -> str:
+ tail = "these exact values" if len(names) > 1 else "this exact value"
+ return (
+ f"[MCP INSTRUCTIONS]: {' and '.join(names)} confirmed. "
+ f"Keep sending {tail} on every call."
+ )
diff --git a/src/agentcat/modules/context_parameters.py b/src/agentcat/modules/context_parameters.py
deleted file mode 100644
index 5aa6f2c..0000000
--- a/src/agentcat/modules/context_parameters.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Context parameter injection for MCP tools."""
-
-from typing import Any
-
-
-def add_context_parameter_to_tools(
- tools: list[dict[str, Any]], custom_context_description: str
-) -> list[dict[str, Any]]:
- """Add context parameter to tool schemas."""
- modified_tools = []
-
- for tool in tools:
- # Create a copy to avoid modifying original
- modified_tool = tool.copy()
-
- if "inputSchema" in modified_tool:
- modified_tool["inputSchema"] = add_context_parameter_to_schema(
- modified_tool["inputSchema"], custom_context_description
- )
-
- modified_tools.append(modified_tool)
-
- return modified_tools
-
-
-def add_context_parameter_to_schema(
- schema: dict[str, Any], custom_context_description: str
-) -> dict[str, Any]:
- """Add context parameter to a JSON schema."""
- # Create a copy to avoid modifying original
- modified_schema = schema.copy()
-
- # Ensure properties exists
- if "properties" not in modified_schema:
- modified_schema["properties"] = {}
- else:
- # Deep copy properties
- modified_schema["properties"] = modified_schema["properties"].copy()
-
- # Add context parameter
- modified_schema["properties"]["context"] = {
- "type": "string",
- "description": custom_context_description,
- }
-
- # Add to required fields
- if "required" not in modified_schema:
- modified_schema["required"] = []
- else:
- # Copy required list
- modified_schema["required"] = list(modified_schema["required"])
-
- if "context" not in modified_schema["required"]:
- modified_schema["required"].append("context")
-
- return modified_schema
diff --git a/src/agentcat/modules/detection.py b/src/agentcat/modules/detection.py
new file mode 100644
index 0000000..c124296
--- /dev/null
+++ b/src/agentcat/modules/detection.py
@@ -0,0 +1,191 @@
+"""Per-object server flavor classification (spec §8.1).
+
+Decides which adapter wraps a customer's server using only signals readable
+off the object in hand: class name, defining module prefix, and attribute
+presence. It never imports version-specific MCP symbols and never raises
+into the customer's process.
+
+Two probe strengths, deliberately different:
+
+- ``_probe`` (presence): the name resolves, or its getter raises something
+ other than ``AttributeError`` — the attribute exists even when a lazy or
+ proxy getter is unhappy. ``hasattr`` would re-raise those, so it is never
+ used here.
+- ``_get`` (retrieval): the value must actually come back. Used where the
+ classifier hands the value onward (handler tables, the wrapped lowlevel
+ object) — a table we cannot read is a shape we cannot adapt.
+
+Every ``Detection`` carries all 13 fingerprint probes (spec §8.1); for
+``UNKNOWN`` shapes the fingerprint is the payload of the fleet-drift
+diagnostics beacon.
+"""
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any
+
+
+class ServerFlavor(str, Enum):
+ """Diagnostics-beacon wire strings; keep stable across releases."""
+
+ LOWLEVEL_V1 = "lowlevel-v1"
+ LOWLEVEL_V2 = "lowlevel-v2"
+ OFFICIAL_FASTMCP_V1 = "official-fastmcp-v1"
+ MCPSERVER_V2 = "mcpserver-v2"
+ COMMUNITY_V3 = "community-v3"
+ COMMUNITY_V4 = "community-v4"
+ COMMUNITY_V2_UNSUPPORTED = "community-v2-unsupported"
+ UNKNOWN = "unknown"
+
+
+@dataclass
+class Detection:
+ """``lowlevel`` is the object the adapters wrap (``_mcp_server``,
+ ``_lowlevel_server``, or the bare server itself); ``None`` for community
+ flavors (adapted via middleware) and ``UNKNOWN`` (returned untracked)."""
+
+ flavor: ServerFlavor
+ lowlevel: Any | None
+ fingerprint: dict[str, bool]
+
+
+_MISSING = object()
+
+# What the lowlevel-v2 handler-registration seam is called, best-known name
+# first. It has been spelled both ways on the 2.x line — public
+# ``add_request_handler`` in 2.0, private ``_add_request_handler`` on the
+# development line before it — and a build that ships only the other spelling
+# must not fall through to UNKNOWN and be returned untracked with no error.
+# `adapters.lowlevel_v2` re-arms whichever one it finds, so this tuple is the
+# single definition of that seam.
+HANDLER_REGISTRATION_NAMES = ("add_request_handler", "_add_request_handler")
+
+
+def _probe(server: Any, name: str) -> bool:
+ try:
+ getattr(server, name)
+ except AttributeError:
+ return False
+ except Exception:
+ return True
+ return True
+
+
+def _get(server: Any, name: str) -> Any:
+ try:
+ return getattr(server, name)
+ except Exception:
+ return _MISSING
+
+
+def _dir_names(server: Any) -> list[str]:
+ try:
+ return dir(server)
+ except Exception:
+ return []
+
+
+def _class_info(server: Any) -> tuple[str, str]:
+ try:
+ cls = type(server)
+ name = getattr(cls, "__name__", "")
+ module = getattr(cls, "__module__", "")
+ except Exception:
+ return "", ""
+ return (
+ name if isinstance(name, str) else "",
+ module if isinstance(module, str) else "",
+ )
+
+
+def _fingerprint(server: Any, class_name: str) -> dict[str, bool]:
+ return {
+ "is_fastmcp_class": "FastMCP" in class_name,
+ "has_local_provider": _probe(server, "_local_provider"),
+ "has_add_middleware": _probe(server, "add_middleware"),
+ "has_middleware": _probe(server, "middleware"),
+ "has_tool_manager": _probe(server, "_tool_manager"),
+ "has_mcp_server_attr": _probe(server, "_mcp_server"),
+ "has_lowlevel_server_attr": _probe(server, "_lowlevel_server"),
+ "has_extensions": (
+ _probe(server, "_extensions") or _probe(server, "add_extension")
+ ),
+ "has_request_state_security": _probe(server, "_request_state_security"),
+ "has_request_handlers": _probe(server, "request_handlers"),
+ "has_private_request_handlers": _probe(server, "_request_handlers"),
+ "has_add_request_handler": any(
+ _probe(server, name) for name in HANDLER_REGISTRATION_NAMES
+ ),
+ "has_request_context": _probe(server, "request_context"),
+ }
+
+
+def _classify(
+ server: Any, class_name: str, module: str
+) -> tuple[ServerFlavor, Any | None]:
+ is_fastmcp_class = "FastMCP" in class_name
+
+ if module.startswith("fastmcp") and is_fastmcp_class:
+ # fastmcp 2.x kept the official `_tool_manager` architecture; it is
+ # unsupported and must win before the v3/v4 probe set looks, because
+ # only the module prefix separates it from official FastMCP v1.
+ if _probe(server, "_mcp_server") and _probe(server, "_tool_manager"):
+ return ServerFlavor.COMMUNITY_V2_UNSUPPORTED, None
+ if (
+ _probe(server, "_local_provider")
+ and _probe(server, "add_middleware")
+ and _probe(server, "middleware")
+ and not _probe(server, "_tool_manager")
+ ):
+ if (
+ _probe(server, "add_extension")
+ or _probe(server, "_extensions")
+ or _probe(server, "_request_state_security")
+ ):
+ return ServerFlavor.COMMUNITY_V4, None
+ return ServerFlavor.COMMUNITY_V3, None
+
+ if module.startswith("mcp.server.fastmcp") and is_fastmcp_class:
+ mcp_server = _get(server, "_mcp_server")
+ if (
+ mcp_server is not _MISSING
+ and mcp_server is not None
+ and _probe(server, "_tool_manager")
+ ):
+ return ServerFlavor.OFFICIAL_FASTMCP_V1, mcp_server
+
+ lowlevel_server = _get(server, "_lowlevel_server")
+ if (
+ lowlevel_server is not _MISSING
+ and lowlevel_server is not None
+ and _probe(server, "_tool_manager")
+ ):
+ return ServerFlavor.MCPSERVER_V2, lowlevel_server
+
+ names = _dir_names(server)
+ # `request_context` via dir() only: on a real lowlevel v1 Server it is a
+ # property that raises outside a request, so it must never be evaluated.
+ if (
+ isinstance(_get(server, "request_handlers"), dict)
+ and "request_context" in names
+ ):
+ return ServerFlavor.LOWLEVEL_V1, server
+
+ if (
+ isinstance(_get(server, "_request_handlers"), dict)
+ and any(_probe(server, name) for name in HANDLER_REGISTRATION_NAMES)
+ and "request_context" not in names
+ ):
+ return ServerFlavor.LOWLEVEL_V2, server
+
+ return ServerFlavor.UNKNOWN, None
+
+
+def detect_server(server: Any) -> Detection:
+ class_name, module = _class_info(server)
+ flavor, lowlevel = _classify(server, class_name, module)
+ return Detection(
+ flavor=flavor,
+ lowlevel=lowlevel,
+ fingerprint=_fingerprint(server, class_name),
+ )
diff --git a/src/agentcat/modules/diagnostics.py b/src/agentcat/modules/diagnostics.py
index 073c93a..d03ece1 100644
--- a/src/agentcat/modules/diagnostics.py
+++ b/src/agentcat/modules/diagnostics.py
@@ -167,6 +167,10 @@ def _build_static_attributes(project_id: str | None) -> list[dict[str, Any]]:
out += _attr("agentcat.mcp_sdk.version", version("mcp"))
except Exception:
pass
+ try:
+ out += _attr("agentcat.fastmcp_sdk.version", version("fastmcp"))
+ except Exception:
+ pass
# Runtime
out += _attr("process.runtime.name", platform.python_implementation().lower())
@@ -230,7 +234,7 @@ def capture(entry: str) -> None:
pass
-def flush_diagnostics() -> None:
+def flush_diagnostics(timeout: float = 5.0) -> None:
"""Swap out the buffer and POST it fire-and-forget. Never raises."""
try:
if not _enabled:
@@ -263,7 +267,9 @@ def flush_diagnostics() -> None:
if token:
headers["Authorization"] = f"Bearer {token}"
- requests.post(_resolve_endpoint(), json=payload, headers=headers, timeout=5)
+ requests.post(
+ _resolve_endpoint(), json=payload, headers=headers, timeout=timeout
+ )
except Exception:
# fire-and-forget: never propagate diagnostics network errors
pass
@@ -325,5 +331,20 @@ def _build_record_for_test(entry: str) -> dict[str, Any]:
return _build_record(entry)
+def _flush_at_exit() -> None:
+ """Exit-time flush, hard-bounded so the customer's shutdown is never held
+ up: skip without even taking the lock when there is nothing buffered, and
+ cap the POST at 2s (requests applies the timeout per phase — connect,
+ then between bytes — so this approximates, not guarantees, 2s wall clock).
+ This is the only atexit hook the SDK registers.
+ """
+ try:
+ if not _enabled or not _buffer:
+ return
+ flush_diagnostics(timeout=2.0)
+ except Exception:
+ pass
+
+
# Flush whatever is buffered on interpreter exit (covers non-destroy() paths).
-atexit.register(flush_diagnostics)
+atexit.register(_flush_at_exit)
diff --git a/src/agentcat/modules/event_queue.py b/src/agentcat/modules/event_queue.py
index 473ab2e..f2c4e79 100644
--- a/src/agentcat/modules/event_queue.py
+++ b/src/agentcat/modules/event_queue.py
@@ -1,13 +1,19 @@
-"""Event queue implementation for AgentCat."""
+"""Event queue implementation for AgentCat.
+
+Process-safety contract: importing this module must be side-effect free
+beyond object construction — no threads, no signal handlers, no exit
+hooks. The worker starts lazily on first publish and is always daemon.
+First publish also registers one bounded exit hook that STOPS the worker
+— it never sends: telemetry may be lost on shutdown, the customer's
+process may never be delayed or redirected by it.
+"""
import atexit
import queue
-import signal
-import os
+import sys
import threading
import time
from datetime import datetime, timezone
-from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional, TYPE_CHECKING
if TYPE_CHECKING:
@@ -17,24 +23,40 @@
from agentcat.modules.constants import AGENTCAT_API_URL, EVENT_ID_PREFIX
from ..types import Event, UnredactedEvent
-from ..utils import generate_prefixed_ksuid
-from .compatibility import get_mcp_compatible_error_message
+from ..utils import generate_prefixed_ksuid, get_agentcat_version
from .internal import get_server_tracking_data
from .logging import write_to_log
from .redaction import redact_event
from .sanitization import sanitize_event
from .truncation import truncate_event
-from .session import get_session_info, set_last_activity
+
+# Stamped on every event. Same value 1.x reported, resolved once at import
+# instead of rebuilt per event inside a per-server session cache.
+SDK_LANGUAGE = f"Python {sys.version_info.major}.{sys.version_info.minor}"
+
+# Bound on a single publish HTTP attempt so a black-holed connection can
+# wedge a worker for at most this long. Lives here, not constants.py — that
+# file is byte-parity with the TS SDK and this knob is Python-only.
+PUBLISH_TIMEOUT_SECONDS = 10
+
+
+class _Stop:
+ """Marker destroy() enqueues to wake workers parked in the untimed get()."""
+
+
+_STOP = _Stop()
class EventQueue:
"""Manages event queue and sending to AgentCat API."""
def __init__(self, api_client=None):
- self.queue: queue.Queue[UnredactedEvent] = queue.Queue(maxsize=10000)
+ self.queue: queue.Queue[UnredactedEvent | _Stop] = queue.Queue(maxsize=10000)
self.max_retries = 3
self.max_queue_size = 10000 # Prevent unbounded growth
- self.concurrency = 5 # Max parallel requests
+ # One publish at a time keeps the thread footprint minimal; the
+ # bounded queue absorbs bursts and add() drops on overflow.
+ self.concurrency = 1
# Allow injection of api_client for testing
if api_client is None:
@@ -47,12 +69,37 @@ def __init__(self, api_client=None):
self._shutdown = False
self._shutdown_event = threading.Event()
- # Thread pool for processing events
- self.executor = ThreadPoolExecutor(max_workers=self.concurrency)
-
- # Start worker thread
- self.worker_thread = threading.Thread(target=self._worker, daemon=True)
- self.worker_thread.start()
+ # Workers start lazily on first add() so constructing the queue —
+ # which happens at module import — spawns no threads and is safe
+ # from any thread, main or not.
+ self._lock = threading.Lock()
+ self._workers: list[threading.Thread] = []
+ self._workers_started = False
+ self._active = 0 # workers currently inside _process_event
+
+ def _ensure_workers(self) -> None:
+ """Start the consumer threads on first use."""
+ global _exit_stop_registered
+ if self._workers_started:
+ return
+ with self._lock:
+ if self._workers_started or self._shutdown:
+ return
+ for i in range(self.concurrency):
+ t = threading.Thread(
+ target=self._worker,
+ daemon=True,
+ name=f"agentcat-event-worker-{i}",
+ )
+ t.start()
+ self._workers.append(t)
+ self._workers_started = True
+ # The moment threads exist, the process needs the exit hook that
+ # stops them (see _stop_workers_at_exit). Registered here, not at
+ # import, so importing the SDK stays side-effect free.
+ if not _exit_stop_registered:
+ atexit.register(_stop_workers_at_exit)
+ _exit_stop_registered = True
def configure(self, api_base_url: str) -> None:
"""Reconfigure the API client with a new base URL."""
@@ -66,6 +113,7 @@ def add(self, event: UnredactedEvent) -> None:
write_to_log("Queue is shutting down, event dropped")
return
+ self._ensure_workers()
try:
# Try to add without blocking
self.queue.put_nowait(event)
@@ -76,32 +124,34 @@ def add(self, event: UnredactedEvent) -> None:
)
def _worker(self) -> None:
- """Worker thread that processes events from the queue."""
+ """Consumer thread: pulls events straight off the bounded queue.
+
+ No intermediate executor — when every worker is busy the bounded
+ queue fills and add() drops, so memory is genuinely capped at
+ max_queue_size events.
+
+ The untimed get() keeps an idle worker fully parked (no periodic
+ wakes); _stop_workers() wakes it with a _Stop marker. The real exit
+ safety, though, is the atexit hook: a daemon thread still executing
+ when interpreter finalization begins is killed via pthread_exit(),
+ and on Linux/glibc that aborts the whole process — SIGABRT over the
+ customer's exit code (CPython gh-87135, fixed in 3.14). The hook
+ stops this thread before finalization starts, so that window never
+ opens.
+ """
while not self._shutdown_event.is_set():
+ event = self.queue.get()
+ if isinstance(event, _Stop):
+ break
+ with self._lock:
+ self._active += 1
try:
- # Wait for an event with timeout
- event = self.queue.get(timeout=0.1)
-
- # Submit event processing to thread pool
- # The executor will queue it if all workers are busy
- try:
- self.executor.submit(self._process_event, event)
- except Exception as e:
- write_to_log(f"Failed to submit event for processing: {e}")
- # Put the event back in the queue if possible
- try:
- self.queue.put_nowait(event)
- except queue.Full:
- write_to_log(
- f"Could not requeue event {event.id or 'unknown'} - queue full"
- )
-
- except queue.Empty:
- continue
+ self._process_event(event)
except Exception as e:
write_to_log(f"Worker thread error (continuing): {e}")
- # Sleep briefly to avoid tight error loops
- time.sleep(0.1)
+ finally:
+ with self._lock:
+ self._active -= 1
def _process_event(self, event: UnredactedEvent) -> None:
"""Process a single event."""
@@ -114,7 +164,9 @@ def _process_event(self, event: UnredactedEvent) -> None:
# The redacted event is already the full event object, not a dict
event = redacted_event
event.redaction_fn = None # Clear the function to avoid reprocessing
- except Exception as error:
+ except (Exception, SystemExit) as error:
+ # SystemExit included: a customer hook calling sys.exit() must
+ # not kill the worker thread.
write_to_log(
f"WARNING: Dropping event {event.id or 'unknown'} due to redaction failure: {error}"
)
@@ -156,8 +208,12 @@ def _process_event(self, event: UnredactedEvent) -> None:
def _send_event(self, event: Event, retries: int = 0) -> None:
"""Send event to API."""
try:
- # Synchronous API call
- self.api_client.publish_event(publish_event_request=event)
+ # Synchronous API call, bounded so a black-holed connection
+ # cannot wedge this worker indefinitely
+ self.api_client.publish_event(
+ publish_event_request=event,
+ _request_timeout=PUBLISH_TIMEOUT_SECONDS,
+ )
write_to_log(
f"Successfully sent event {event.id} | {event.event_type} | "
f"session {event.session_id} | {event.project_id} | "
@@ -170,7 +226,7 @@ def _send_event(self, event: Event, retries: int = 0) -> None:
)
return
write_to_log(
- f"Failed to send event {event.id}, retrying... [Error: {get_mcp_compatible_error_message(error)}]"
+ f"Failed to send event {event.id}, retrying... [Error: {error}]"
)
if retries < self.max_retries:
# Exponential backoff: 1s, 2s, 4s
@@ -188,40 +244,51 @@ def _send_event(self, event: Event, retries: int = 0) -> None:
def get_stats(self) -> dict[str, Any]:
"""Get queue stats for monitoring."""
+ with self._lock:
+ active = self._active
return {
"queueLength": self.queue.qsize(),
- "activeRequests": self.executor._threads.__len__(), # Number of active threads
- "isProcessing": self.executor._threads.__len__() > 0,
+ "activeRequests": active,
+ "isProcessing": active > 0,
}
- def destroy(self) -> None:
- """Graceful shutdown - wait for active requests."""
- # Stop accepting new events
+ def _stop_workers(self, join_budget: float = 1.0) -> None:
+ """Flag shutdown, wake parked or backing-off workers, and join them
+ against one shared budget. Never sends anything. A worker wedged in
+ a customer hook outlives the join as a daemon and dies with the
+ process.
+ """
self._shutdown = True
self._shutdown_event.set()
- # Determine wait time based on queue state
- if self.queue.qsize() > 0:
- # If there are events in queue, wait 5 seconds
- wait_time = 5.0
- write_to_log(
- f"Shutting down with {self.queue.qsize()} events in queue, waiting up to {wait_time}s"
- )
- else:
- # If queue is empty, just wait 1 second for in-flight requests
- wait_time = 1.0
- write_to_log(f"Queue empty, waiting {wait_time}s for in-flight requests")
+ # Wake workers parked in the untimed get(). Best-effort: a full queue
+ # means no worker is parked (a parked worker would have taken an item
+ # already), and busy workers re-check the shutdown event before
+ # parking again.
+ if self._workers_started:
+ for _ in range(self.concurrency):
+ try:
+ self.queue.put_nowait(_STOP)
+ except queue.Full:
+ break
- # Wait for the specified time
- time.sleep(wait_time)
+ # Shared budget across all joins, never per-thread.
+ deadline = time.monotonic() + join_budget
+ for t in self._workers:
+ if t is threading.current_thread():
+ continue # called from a worker
+ t.join(timeout=max(0.0, deadline - time.monotonic()))
- # Shutdown executor, cancelling any queued (not yet running) tasks
- self.executor.shutdown(wait=True, cancel_futures=True)
+ def destroy(self) -> None:
+ """Stop workers and stop accepting events. Bounded; for tests and
+ explicit shutdown."""
+ self._stop_workers()
- # Log final status
- remaining = self.queue.qsize()
+ remaining = sum(
+ 1 for item in list(self.queue.queue) if not isinstance(item, _Stop)
+ )
if remaining > 0:
- write_to_log(f"Shutdown complete. {remaining} events were not processed.")
+ write_to_log(f"Event queue destroyed with {remaining} events unprocessed.")
# Flush any buffered SDK diagnostics on the way out. Lazy import to avoid
# an import cycle; never let diagnostics break shutdown.
@@ -252,24 +319,32 @@ def set_telemetry_manager(manager: Optional["TelemetryManager"]) -> None:
)
-# Global event queue instance
+# Global event queue instance. Constructing it starts no threads (workers
+# are lazy), so this module is importable from any thread. The SDK installs
+# no signal handlers and never drains events at exit: the customer's process
+# owns its own shutdown, and undelivered telemetry is dropped by design.
event_queue = EventQueue()
-
-def _shutdown_handler(signum, frame):
- """Handle shutdown signals."""
-
- write_to_log("Received shutdown signal, gracefully shutting down...")
-
- # Reset signal handlers to default behavior to avoid recursive calls
- signal.signal(signal.SIGINT, signal.SIG_DFL)
- signal.signal(signal.SIGTERM, signal.SIG_DFL)
-
- # Perform graceful shutdown
- event_queue.destroy()
-
- # Force exit after graceful shutdown
- os._exit(0)
+# Set once the exit hook below is registered (on first publish, never at
+# import).
+_exit_stop_registered = False
+
+
+def _stop_workers_at_exit() -> None:
+ """Stop, never drain. A daemon thread still executing when interpreter
+ finalization begins is killed via pthread_exit(), which aborts the whole
+ process on Linux/glibc (CPython gh-87135, fixed in 3.14). atexit
+ callbacks run before the finalizing flag is set, so stopping the worker
+ here removes that abort path — and setting the shutdown event also wakes
+ a worker parked in the retry backoff. Queued events are dropped by
+ design; nothing is sent from this hook. Forced exits and unhandled
+ signals skip atexit but also skip finalization, so they carry no abort
+ risk to begin with.
+ """
+ try:
+ event_queue._stop_workers()
+ except Exception:
+ pass
def set_event_queue(new_queue: EventQueue) -> None:
@@ -280,14 +355,17 @@ def set_event_queue(new_queue: EventQueue) -> None:
event_queue = new_queue
-# Register shutdown handlers
-signal.signal(signal.SIGINT, _shutdown_handler)
-signal.signal(signal.SIGTERM, _shutdown_handler)
-atexit.register(lambda: event_queue.destroy())
-
-
def publish_event(server: Any, event: UnredactedEvent) -> None:
- """Publish an event to the queue."""
+ """Publish an event to the queue.
+
+ Everything about the CALL is already on the event: the call path resolved
+ the actor, the client identity (design §7) and the handle tags per request
+ and stamped them there. This adds only what is per-SERVER or per-INSTALL —
+ project, server identity captured at track time, SDK language, SDK version
+ — and never merges anything over a field the event already carries. v1
+ merged a per-server metadata cache on top of the event, which silently
+ overwrote the ladder's client name/version on any server that kept one.
+ """
if not event.duration:
if event.timestamp:
event.duration = int(
@@ -304,23 +382,18 @@ def publish_event(server: Any, event: UnredactedEvent) -> None:
)
return
- session_info = get_session_info(server, data)
-
- # Create full event with all required fields
- # Merge event data with session info
- event_data = event.model_dump(exclude_none=True)
- session_data = session_info.model_dump(exclude_none=True)
-
- # Merge data, ensuring project_id from data takes precedence
- merged_data = {**event_data, **session_data}
- merged_data["project_id"] = (
- data.project_id
- ) # Override with tracking data's project_id
+ stamped = {
+ **event.model_dump(exclude_none=True),
+ "project_id": data.project_id,
+ "sdk_language": SDK_LANGUAGE,
+ "agentcat_version": get_agentcat_version(),
+ "server_name": data.server_name,
+ "server_version": data.server_version,
+ }
full_event = UnredactedEvent(
- **merged_data,
+ **stamped,
redaction_fn=data.options.redact_sensitive_information,
)
- set_last_activity(server)
event_queue.add(full_event)
diff --git a/src/agentcat/modules/exceptions.py b/src/agentcat/modules/exceptions.py
index c540d38..8f1c275 100644
--- a/src/agentcat/modules/exceptions.py
+++ b/src/agentcat/modules/exceptions.py
@@ -1,6 +1,5 @@
"""Exception tracking module for AgentCat."""
-import contextvars
import linecache
import os
import re
@@ -12,12 +11,45 @@
from agentcat.types import ChainedErrorData, ErrorData, StackFrame
from agentcat.modules.constants import MAX_EXCEPTION_CHAIN_DEPTH, MAX_STACK_FRAMES
-_captured_error: contextvars.ContextVar[BaseException | None] = contextvars.ContextVar(
- "_captured_error", default=None
-)
+
+def _safe_str(value: Any) -> str:
+ """str() for values whose __str__ may itself raise (customer exception
+ classes are arbitrary code). Degrades str -> repr -> placeholder."""
+ try:
+ return str(value)
+ except Exception:
+ try:
+ return repr(value)
+ except Exception:
+ return f""
def capture_exception(exc: BaseException | Any) -> ErrorData:
+ """Never-raise entry point around _capture_exception.
+
+ It runs on the request path at call sites outside every other guard
+ (evaluated as the `error=` argument before publish's own containment), so
+ an escape here would replace the customer's result or original error on
+ the wire. Anything the detailed capture chokes on — hostile __str__,
+ deleted working directory, raising properties — degrades to a minimal
+ ErrorData instead.
+ """
+ try:
+ return _capture_exception(exc)
+ except Exception:
+ error: ErrorData = {
+ "message": "Error details unavailable (exception capture failed)",
+ "type": None,
+ "platform": "python",
+ }
+ try:
+ error["type"] = type(exc).__name__ # type() runs no customer code
+ except Exception:
+ pass
+ return error
+
+
+def _capture_exception(exc: BaseException | Any) -> ErrorData:
"""
Captures detailed exception information including stack traces and cause chains.
@@ -43,7 +75,7 @@ def capture_exception(exc: BaseException | Any) -> ErrorData:
}
error_data: ErrorData = {
- "message": str(exc),
+ "message": _safe_str(exc),
"type": type(exc).__name__,
"platform": "python",
}
@@ -80,31 +112,40 @@ def parse_python_traceback(tb: types.TracebackType | None) -> list[StackFrame]:
count = 0
while current_tb is not None and count < MAX_STACK_FRAMES:
- frame = current_tb.tb_frame
- abs_path = os.path.abspath(frame.f_code.co_filename)
-
try:
- module = frame.f_globals.get("__name__")
- except (AttributeError, KeyError):
- module = None
-
- in_app = is_in_app(abs_path)
-
- frame_dict: StackFrame = {
- "filename": filename_for_module(module, abs_path),
- "abs_path": abs_path,
- "function": frame.f_code.co_name or "",
- "module": module or "",
- "lineno": current_tb.tb_lineno,
- "in_app": in_app,
- }
-
- if in_app:
- context = extract_context_line(abs_path, current_tb.tb_lineno)
- if context:
- frame_dict["context_line"] = context
-
- frames.append(frame_dict)
+ frame = current_tb.tb_frame
+ try:
+ # abspath calls os.getcwd() for relative filenames
+ # ('', exec'd frames), which raises when the process
+ # working directory has been deleted — keep the raw path then.
+ abs_path = os.path.abspath(frame.f_code.co_filename)
+ except Exception:
+ abs_path = frame.f_code.co_filename
+
+ try:
+ module = frame.f_globals.get("__name__")
+ except (AttributeError, KeyError):
+ module = None
+
+ in_app = is_in_app(abs_path)
+
+ frame_dict: StackFrame = {
+ "filename": filename_for_module(module, abs_path),
+ "abs_path": abs_path,
+ "function": frame.f_code.co_name or "",
+ "module": module or "",
+ "lineno": current_tb.tb_lineno,
+ "in_app": in_app,
+ }
+
+ if in_app:
+ context = extract_context_line(abs_path, current_tb.tb_lineno)
+ if context:
+ frame_dict["context_line"] = context
+
+ frames.append(frame_dict)
+ except Exception:
+ pass # one unreadable frame skips, the rest of the stack survives
current_tb = current_tb.tb_next
count += 1
@@ -258,41 +299,48 @@ def unwrap_exception_chain(exc: BaseException) -> list[ChainedErrorData]:
seen_ids.add(id(exc))
while current is not None and depth < MAX_EXCEPTION_CHAIN_DEPTH:
- if getattr(current, "__suppress_context__", False):
- next_exc = getattr(current, "__cause__", None)
- else:
- next_exc = getattr(current, "__context__", None)
-
- if next_exc is None:
- break
-
- exc_id = id(next_exc)
- if exc_id in seen_ids:
- break
- seen_ids.add(exc_id)
-
- if not isinstance(next_exc, BaseException):
- chain.append(
- {
- "message": stringify_non_exception(next_exc),
- "type": None,
- }
- )
+ try:
+ # Guarded as a block: __cause__/__context__/__suppress_context__
+ # can be hostile descriptors on exception subclasses, and a
+ # chained link's __str__ was never exercised by the framework —
+ # a poisoned link truncates the chain instead of raising.
+ if getattr(current, "__suppress_context__", False):
+ next_exc = getattr(current, "__cause__", None)
+ else:
+ next_exc = getattr(current, "__context__", None)
+
+ if next_exc is None:
+ break
+
+ exc_id = id(next_exc)
+ if exc_id in seen_ids:
+ break
+ seen_ids.add(exc_id)
+
+ if not isinstance(next_exc, BaseException):
+ chain.append(
+ {
+ "message": stringify_non_exception(next_exc),
+ "type": None,
+ }
+ )
+ break
+
+ chained_data: ChainedErrorData = {
+ "message": _safe_str(next_exc),
+ "type": type(next_exc).__name__,
+ }
+
+ if next_exc.__traceback__:
+ chained_data["frames"] = parse_python_traceback(next_exc.__traceback__)
+ chained_data["stack"] = format_exception_string(next_exc)
+
+ chain.append(chained_data)
+ current = next_exc
+ depth += 1
+ except Exception:
break
- chained_data: ChainedErrorData = {
- "message": str(next_exc),
- "type": type(next_exc).__name__,
- }
-
- if next_exc.__traceback__:
- chained_data["frames"] = parse_python_traceback(next_exc.__traceback__)
- chained_data["stack"] = format_exception_string(next_exc)
-
- chain.append(chained_data)
- current = next_exc
- depth += 1
-
# TODO: Add ExceptionGroup support for Python 3.11+
# ExceptionGroups have .exceptions attribute with multiple exceptions
@@ -315,7 +363,7 @@ def format_exception_string(exc: BaseException) -> str:
try:
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
except Exception:
- return f"{type(exc).__name__}: {exc}"
+ return f"{type(exc).__name__}: {_safe_str(exc)}"
def is_call_tool_result(value: Any) -> bool:
@@ -325,18 +373,36 @@ def is_call_tool_result(value: Any) -> bool:
MCP SDK converts errors to CallToolResult format:
{ content: [{ type: "text", text: "error message" }], isError: true }
+ Both spellings of the error flag count: the official SDK renamed it
+ `isError` -> `is_error` in 2.x, and community FastMCP has always used the
+ snake_case one. Missing the rename would not fail loudly — it would record
+ a pydantic repr of the whole result as the error message.
+
+ Snake case is probed FIRST, and the order is load-bearing rather than
+ cosmetic: FastMCP answers a camelCase attribute through a compatibility
+ shim that emits a `FastMCPDeprecationWarning`, so asking for `isError`
+ first warns on every error result any FastMCP server produces. Asking for
+ `is_error` first short-circuits before the shim is ever reached, and an
+ mcp 1.x `CallToolResult` — which has only the camelCase name — still falls
+ through to it.
+
Args:
value: Value to check
Returns:
True if value is a CallToolResult object
"""
- return (
- value is not None
- and hasattr(value, "isError")
- and hasattr(value, "content")
- and isinstance(getattr(value, "content", None), list)
- )
+ try:
+ # Whole predicate guarded: `content` may be an arbitrary property
+ # (lazy proxied results) that raises something other than
+ # AttributeError, which getattr's default would not suppress.
+ return (
+ value is not None
+ and (hasattr(value, "is_error") or hasattr(value, "isError"))
+ and isinstance(getattr(value, "content", None), list)
+ )
+ except Exception:
+ return False
def capture_call_tool_result_error(result: Any) -> ErrorData:
@@ -403,22 +469,13 @@ def stringify_non_exception(value: Any) -> str:
return json.dumps(value)
except Exception:
- return str(value)
-
-
-def store_captured_error(exc: BaseException) -> None:
- """Stores exception in context variable before MCP SDK processing."""
- _captured_error.set(exc)
-
-
-def get_captured_error() -> BaseException | None:
- """Retrieves and clears stored exception from context variable."""
- exc = _captured_error.get()
- if exc is not None:
- _captured_error.set(None)
- return exc
+ return _safe_str(value)
-def clear_captured_error() -> None:
- """Clears any stored exception from context variable."""
- _captured_error.set(None)
+# The inner-tap trio that used to live here — `store_captured_error` /
+# `get_captured_error` / `clear_captured_error` — was retired by Task 13.5 in
+# favour of `modules.adapters._inner_tap`. It stored the exception IN a
+# ContextVar, which cannot see a capture made below a task or thread boundary
+# (a context is copied downward, so the child's `set` never reaches the parent)
+# and has no per-call slot to close. The replacement stores a per-call cell in
+# the ContextVar and writes to the cell instead; see that module's docstring.
diff --git a/src/agentcat/modules/exporters/otlp.py b/src/agentcat/modules/exporters/otlp.py
index 4381f3f..067724c 100644
--- a/src/agentcat/modules/exporters/otlp.py
+++ b/src/agentcat/modules/exporters/otlp.py
@@ -1,6 +1,7 @@
"""OpenTelemetry Protocol (OTLP) exporter for AgentCat telemetry."""
import json
+import platform
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -9,7 +10,7 @@
from ...types import Event, OTLPExporterConfig
from ...modules.constants import AGENTCAT_SOURCE
from ...modules.logging import write_to_log
-from ...modules.session import get_agentcat_version
+from ...utils import get_agentcat_version, get_dist_version
from . import Exporter
from .trace_context import trace_context
@@ -164,6 +165,28 @@ def _get_resource_attributes(self, event: Event) -> List[Dict[str, Any]]:
}
)
+ # Runtime + MCP SDK versions; keys mirror the diagnostics beacon
+ # (modules/diagnostics.py) so both OTLP surfaces share one vocabulary.
+ attributes.append(
+ {
+ "key": "process.runtime.name",
+ "value": {"stringValue": platform.python_implementation().lower()},
+ }
+ )
+ attributes.append(
+ {
+ "key": "process.runtime.version",
+ "value": {"stringValue": platform.python_version()},
+ }
+ )
+ for dist, key in (
+ ("mcp", "agentcat.mcp_sdk.version"),
+ ("fastmcp", "agentcat.fastmcp_sdk.version"),
+ ):
+ dist_version = get_dist_version(dist)
+ if dist_version:
+ attributes.append({"key": key, "value": {"stringValue": dist_version}})
+
return attributes
def _get_span_attributes(self, event: Event) -> List[Dict[str, Any]]:
diff --git a/src/agentcat/modules/exporters/sentry.py b/src/agentcat/modules/exporters/sentry.py
index b8b3f5b..a1df1b4 100644
--- a/src/agentcat/modules/exporters/sentry.py
+++ b/src/agentcat/modules/exporters/sentry.py
@@ -9,7 +9,7 @@
from ...types import Event, SentryExporterConfig
from ...modules.constants import AGENTCAT_SOURCE
from ...modules.logging import write_to_log
-from ...modules.session import get_agentcat_version
+from ...utils import get_agentcat_version
from . import Exporter
from .trace_context import trace_context
diff --git a/src/agentcat/modules/handles.py b/src/agentcat/modules/handles.py
new file mode 100644
index 0000000..1c11d5b
--- /dev/null
+++ b/src/agentcat/modules/handles.py
@@ -0,0 +1,316 @@
+"""Explicit-handle primitives: minting, derivation, extraction, mint-back.
+
+Cross-SDK contract: 2026-07-28-cross-sdk-changelog.md §3-§4; TS reference
+src/modules/handles.ts. Derivation golden vectors are frozen — changing them
+splits customer sessions across an upgrade.
+"""
+
+import hashlib
+import re
+from dataclasses import dataclass
+from typing import Any, Literal
+
+from agentcat.modules.constants import (
+ AGENT_ID_PARAM,
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_MRTR,
+ AGENTCAT_TAG_PROTOCOL_VERSION,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ MINT_BACK_CLOSER,
+ MINT_BACK_HEADER_INVALID,
+ MINT_BACK_HEADER_SESSION,
+ MINT_BACK_INVALID_LINE,
+ SESSION_ID_PARAM,
+ SESSION_ID_PREFIX,
+ mint_back_confirmed,
+ mint_back_session_line,
+)
+from agentcat.modules.hooks import run_hook
+from agentcat.modules.logging import write_to_log
+from agentcat.thirdparty.ksuid import Ksuid
+from agentcat.types import AgentCatOptions
+from agentcat.utils import generate_prefixed_ksuid
+
+_KSUID_EPOCH_MS = 1_400_000_000_000
+_DERIVE_EPOCH_MS = 1_704_067_200_000 # 2024-01-01T00:00:00Z
+_YEAR_MS = 365 * 24 * 60 * 60 * 1000
+
+# 27 is the KSUID string length; `ses_` is the prefix both issuing paths use.
+_SESSION_ID_RE = re.compile(rf"{SESSION_ID_PREFIX}_[0-9A-Za-z]{{27}}")
+
+SessionSource = Literal["supplied", "minted", "hook", "invalid", "foreign"]
+
+
+@dataclass
+class HandleResolution:
+ # Empty string means sessionless — the event publishes with no session at
+ # all, which is what `invalid` and `foreign` both resolve to. The wire
+ # boundary turns it into null; nothing downstream sees "".
+ session_id: str
+ session_source: SessionSource
+ agent_id: str | None = None
+ agent_source: Literal["supplied"] | None = None
+ hook_mode: bool = False
+ # Whether AgentCat put its own `session_id` parameter on THIS tool. False
+ # in three cases: hook mode (none is injected anywhere), a tool whose
+ # customer schema already declared the name, and a composed
+ # (oneOf/allOf/anyOf) schema the injection pass skipped wholesale. Gates
+ # the mint-back, because a mint-back is an instruction to send
+ # `session_id=ses_…` on the next call — and if the tool has no such
+ # parameter, that instruction names a slot the agent cannot fill, or worse,
+ # one that belongs to the customer's domain. Telling an agent to overwrite
+ # it would change what the customer's tool does, which nothing AgentCat
+ # does may do.
+ #
+ # Deliberately NOT the same question as "may we READ the argument" — see
+ # `session_param_is_ours` on `resolve_handles`. They differ on the composed
+ # schema, which is ours to read but has no parameter to prompt for.
+ prompts_session_id: bool = True
+
+
+def is_valid_session_id(value: str) -> bool:
+ """True only for a session ID this SDK issued.
+
+ Both issuing paths — `new_session_id` and `derive_session_id` — satisfy
+ this by construction, so a value that fails was invented by the agent or
+ belongs to someone else.
+
+ `fullmatch`, not `match` against a trailing `$`: Python's `$` also matches
+ immediately BEFORE a final newline, so the literal transcription of the TS
+ regex would accept `"ses_" + "a" * 27 + "\\n"` that TypeScript rejects.
+ """
+ return _SESSION_ID_RE.fullmatch(value) is not None
+
+
+def new_session_id() -> str:
+ return generate_prefixed_ksuid(SESSION_ID_PREFIX)
+
+
+def derive_session_id(id: str, project_id: str | None = None) -> str:
+ # NOTE: does not trim — callers trim (resolve_handles trims hook output).
+ payload_input = f"{id}:{project_id}" if project_id else id
+ digest = hashlib.sha256(payload_input.encode("utf-8")).digest()
+ ts_ms = _DERIVE_EPOCH_MS + (int.from_bytes(digest[0:4], "big") % _YEAR_MS)
+ ts_field = (ts_ms - _KSUID_EPOCH_MS) // 1000
+ raw = ts_field.to_bytes(4, "big") + digest[4:20]
+ return f"{SESSION_ID_PREFIX}_{Ksuid.from_bytes(raw)}"
+
+
+def extract_handle(arguments: Any, name: str) -> str | None:
+ if not isinstance(arguments, dict):
+ return None
+ value = arguments.get(name)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+async def resolve_handles(
+ arguments: Any,
+ options: AgentCatOptions,
+ project_id: str | None,
+ request: Any,
+ extra: Any,
+ injected: frozenset[str] | None = None,
+ session_param_is_ours: bool = True,
+) -> HandleResolution:
+ """Resolve this call's session and agent handles. Never raises.
+
+ Two independent questions, deliberately not the same gate:
+
+ `session_param_is_ours` — may we READ `arguments["session_id"]`? False only
+ when the customer's own schema declared that name, which `callpath` reads
+ off `AgentCatData.declared_session_params`. Reading a parameter they own
+ would sever the agent's real session AND route a customer-domain
+ identifier into `Event.session_id`, a field the customer's redaction hook
+ is not allowed to touch.
+
+ `injected` — which parameter names did AgentCat actually put on THIS tool
+ (`injection.injected_parameter_names`)? None means "assume ours", the same
+ fallback the strip takes when no registry exists. It drives `agent_id`
+ reads and `prompts_session_id`, the mint-back gate.
+
+ They diverge on exactly one shape: a composed (oneOf/allOf/anyOf) schema,
+ where AgentCat injected nothing but the customer declared nothing either.
+ That tool is ours to read — an echoed valid ID still correlates — but has
+ no parameter to prompt for, so it gets no mint-back.
+ """
+ hook = options.resolve_session_id
+ prompts_session_id = injected is None or SESSION_ID_PARAM in injected
+ ours_agent_id = injected is None or AGENT_ID_PARAM in injected
+ agent_id = (
+ extract_handle(arguments, AGENT_ID_PARAM)
+ if options.enable_agent_tracking and ours_agent_id
+ else None
+ )
+ agent_source: Literal["supplied"] | None = "supplied" if agent_id else None
+
+ if callable(hook):
+ try:
+ value = await run_hook(hook, "resolve_session_id", request, extra)
+ except Exception as e: # hook errors mint silently
+ write_to_log(f"Warning: resolve_session_id hook raised: {e}")
+ value = None
+ if isinstance(value, str) and value.strip():
+ return HandleResolution(
+ derive_session_id(value.strip(), project_id),
+ "hook",
+ agent_id,
+ agent_source,
+ hook_mode=True,
+ prompts_session_id=False,
+ )
+ return HandleResolution(
+ new_session_id(),
+ "minted",
+ agent_id,
+ agent_source,
+ hook_mode=True,
+ prompts_session_id=False,
+ )
+
+ if not session_param_is_ours:
+ # The tool declares its own `session_id`. Nothing in the arguments is
+ # ours to read, and minting one per call would manufacture a phantom
+ # session per call — noise shaped like data. Sessionless is the honest
+ # signal, and it resolves the moment the customer adopts
+ # `resolve_session_id`: hook mode reads no arguments at all, so their
+ # parameter stays entirely theirs.
+ return HandleResolution(
+ "", "foreign", agent_id, agent_source, prompts_session_id=False
+ )
+
+ supplied = extract_handle(arguments, SESSION_ID_PARAM)
+ if supplied:
+ if is_valid_session_id(supplied):
+ return HandleResolution(
+ supplied,
+ "supplied",
+ agent_id,
+ agent_source,
+ prompts_session_id=prompts_session_id,
+ )
+ # Not an ID this server issued. Publish sessionless rather than adopt
+ # it: `Event.session_id` is exempt from both redaction hooks, so an
+ # agent's hallucination — or an auth token a client auto-populated
+ # into a parameter that happens to be named `session_id` — would reach
+ # every downstream exporter unredactable.
+ return HandleResolution(
+ "", "invalid", agent_id, agent_source, prompts_session_id=prompts_session_id
+ )
+ return HandleResolution(
+ new_session_id(),
+ "minted",
+ agent_id,
+ agent_source,
+ prompts_session_id=prompts_session_id,
+ )
+
+
+def _echoes_session_id(res: HandleResolution) -> bool:
+ """Whether the agent has an AgentCat `session_id` value to echo back.
+
+ Three ways to have none: hook mode and the no-parameter cases collapsed
+ into `prompts_session_id`, plus `invalid` — where the parameter is ours
+ but there is no value to confirm. That branch corrects the agent rather
+ than issuing a replacement, so naming a `session_id` would be a lie.
+ """
+ return (
+ res.prompts_session_id and not res.hook_mode and res.session_source != "invalid"
+ )
+
+
+def build_mint_back_text(res: HandleResolution) -> str | None:
+ if res.hook_mode or not res.prompts_session_id:
+ return None
+ if res.session_source == "minted":
+ return "\n".join(
+ [
+ MINT_BACK_HEADER_SESSION,
+ mint_back_session_line(res.session_id),
+ MINT_BACK_CLOSER,
+ ]
+ )
+ if res.session_source == "invalid":
+ # No replacement is handed out. An agent that sent something was
+ # usually already issued a good ID, and giving it a second one splits a
+ # session that was never split. The closing sentence of
+ # MINT_BACK_INVALID_LINE is the way out for the agent that was never
+ # issued one: omit the parameter and take the `minted` branch.
+ return "\n".join(
+ [MINT_BACK_HEADER_INVALID, MINT_BACK_INVALID_LINE, MINT_BACK_CLOSER]
+ )
+ return None
+
+
+def build_structured_mint_back(res: HandleResolution) -> dict[str, Any] | None:
+ """The persistent handle state mirrored into `structuredContent`.
+
+ Unlike `build_mint_back_text` (mint announcements only), this is present
+ on EVERY response, so an agent can re-read its own handles mid-session.
+ Handles the agent cannot echo are never named.
+
+ Suppression is per-HANDLE, not per-response: a `session_id` collision skips
+ only `session_id`. `agent_id` is a separate injection and still landed in
+ that tool's schema, so it is still ours to confirm. Dropping the whole
+ mirror would withhold a handle AgentCat issued purely because a
+ neighbouring one belongs to the customer.
+ """
+ echoes = _echoes_session_id(res)
+ names: list[str] = []
+ if echoes:
+ names.append(SESSION_ID_PARAM)
+ if res.agent_id:
+ names.append(AGENT_ID_PARAM)
+ text = build_mint_back_text(res)
+ # `not names` alone would drop the `invalid` correction whenever no
+ # agent_id is in play — the one branch that has something to say and
+ # nothing to echo.
+ if not names and not text:
+ return None
+ mint: dict[str, Any] = {}
+ if echoes:
+ mint[SESSION_ID_PARAM] = res.session_id
+ if res.agent_id:
+ mint[AGENT_ID_PARAM] = res.agent_id
+ mint["instructions"] = text or mint_back_confirmed(names)
+ return mint
+
+
+def mirror_into_structured_content(
+ sc: Any, mint: dict[str, Any]
+) -> dict[str, Any] | None:
+ if not isinstance(sc, dict) or MCP_INSTRUCTIONS_KEY in sc:
+ return None
+ return {**sc, MCP_INSTRUCTIONS_KEY: mint}
+
+
+def _clamp_tag_value(value: str) -> str:
+ """The value rule `validate_tags` enforces, applied to an SDK tag.
+
+ SDK tags are merged AFTER `validate_tags` — deliberately, so they win on
+ collision and ride outside the customer's 50-tag cap (§6.5) — which also
+ means nothing else checks them. Both values here come from the CLIENT:
+ `agent_id` is whatever the agent typed into a string parameter, and the
+ protocol version is read off untrusted request meta. Neither may put an
+ unbounded, newline-bearing string on the wire.
+ """
+ return value.replace("\r", " ").replace("\n", " ")[:200]
+
+
+def build_handle_tags(
+ res: HandleResolution,
+ protocol_version: str | None = None,
+ mrtr: str | None = None,
+) -> dict[str, str]:
+ tags: dict[str, str] = {AGENTCAT_TAG_SESSION_SOURCE: res.session_source}
+ if res.agent_id and res.agent_source:
+ tags[AGENTCAT_TAG_AGENT_ID] = _clamp_tag_value(res.agent_id)
+ tags[AGENTCAT_TAG_AGENT_SOURCE] = res.agent_source
+ if protocol_version:
+ tags[AGENTCAT_TAG_PROTOCOL_VERSION] = _clamp_tag_value(protocol_version)
+ if mrtr:
+ tags[AGENTCAT_TAG_MRTR] = mrtr
+ return tags
diff --git a/src/agentcat/modules/hooks.py b/src/agentcat/modules/hooks.py
new file mode 100644
index 0000000..031cbca
--- /dev/null
+++ b/src/agentcat/modules/hooks.py
@@ -0,0 +1,233 @@
+"""What "may be sync or async" means, defined once for every customer hook.
+
+`AgentCatOptions` takes five customer-supplied callables — `identify`,
+`event_tags`, `event_properties`, `resolve_session_id` and
+`redact_sensitive_information` — and all five are documented to accept a sync
+or an async function. Before this module each site answered that question for
+itself, in four different spellings across four files, and `identify` did not
+answer it at all: it called the hook and used the return value verbatim, so an
+`async def identify` built a coroutine, ran none of its body, failed the
+`isinstance(result, UserIdentity)` check and published the call anonymously.
+The customer saw no error — only an event with no actor.
+
+Three entry points, because the SDK runs hooks from three places:
+
+* `run_hook` — the request path's containment boundary. The hook CALL runs on
+ an anyio worker thread (a blocking sync hook stalls only its own request,
+ never the server's event loop — the same offload both FastMCP generations
+ give customers' sync tool bodies), awaitable results are awaited inline,
+ and the whole execution sits under a hard timeout. Every failure mode —
+ raise, wrong thread-behavior, timeout, even SystemExit or a spontaneous
+ CancelledError — surfaces as `HookExecutionError`, a plain Exception, so
+ each call site's own `except Exception` degradation rule applies unchanged.
+ Genuine task cancellation (client disconnect) still propagates.
+* `await_hook_result` — resolves a hook's RETURN VALUE on a path with a
+ running loop. `run_hook` uses it internally; adapters no longer call it
+ directly for customer hooks.
+* `drive_hook_result` — the publish worker (`event_queue.EventQueue`),
+ a daemon THREAD with no event loop of its own. Redaction runs there and
+ cannot await, so an awaitable has to be driven to completion with a loop of
+ its own.
+
+Both narrow on `inspect.isawaitable`, never `inspect.iscoroutine`. The
+difference is not academic: `iscoroutine` matches only native coroutines, so a
+hook returning an `asyncio.Task` or `Future` — a cached in-flight lookup, say —
+or any object implementing `__await__` would be assigned into the event
+verbatim. That is the ``-shaped failure again, and it
+looks like the hook worked.
+
+Both also narrow on the RESULT rather than the callable. `iscoroutinefunction`
+would be wrong here: it answers False for a `functools.partial` of an async
+function, for a bound method of one on some versions, and for any decorator
+that returns a non-async wrapper around async work. Calling the hook and asking
+what came back needs none of those special cases.
+"""
+
+import asyncio
+import inspect
+from collections.abc import Awaitable, Callable
+from typing import Any, TypeVar, cast
+
+import anyio
+import anyio.to_thread
+
+from agentcat.modules.logging import write_to_log
+
+T = TypeVar("T")
+
+# Hard cap on one hook execution. Generous for a lookup, small enough that a
+# wedged hook costs one request its metadata instead of stalling the call.
+HOOK_TIMEOUT_SECONDS = 5.0
+
+# An async hook may legitimately return an awaitable of an awaitable (a hook
+# returning an asyncio.Task of a cached lookup); unwrap a few hops, not forever.
+_MAX_AWAIT_HOPS = 3
+
+
+class HookExecutionError(Exception):
+ """A customer hook failed, timed out, or raised something that must not
+ ride the request path. Deliberately a plain Exception: every call site's
+ existing `except Exception` degradation rule catches it unchanged."""
+
+# Both signatures below spell `T | Awaitable[Any]` inline rather than sharing an
+# alias. A generic alias needs `Union[T, ...]` — mypy rejects a TypeVar as the
+# target of a PEP-604 alias — and ruff's UP007 then rewrites that `Union` back
+# into the form mypy rejected. Inline, both tools agree.
+#
+# `Any` rather than `T` on the awaitable half: a customer's hook is untyped at
+# runtime and its option alias is itself a union, so the concrete awaitable is
+# whatever they returned.
+#
+# Only the awaited branch casts. `inspect.isawaitable` is a TypeGuard, so its
+# NEGATIVE arm already narrows the union to `T` on its own — a cast there is
+# redundant, and mypy says so. Awaiting an `Awaitable[Any]` yields `Any`, which
+# `warn_return_any` rejects, so that arm does need one.
+
+
+def _report(hook_name: str, error: Exception) -> None:
+ """Name the await as the cause, since the caller's log cannot.
+
+ Every call site already logs "this hook failed, degrading" from its own
+ `except`. That line is true but unhelpful when the hook itself was fine and
+ only the awaiting went wrong — a customer reading it goes looking for a bug
+ in code that ran correctly. This one says which half broke; the caller's
+ still says which hook degraded, and both are wanted.
+ """
+ write_to_log(
+ f"Warning: {hook_name} returned an awaitable that could not be awaited: {error}"
+ )
+
+
+async def run_hook(
+ hook: Callable[..., Any],
+ hook_name: str,
+ request: Any,
+ extra: Any,
+ timeout: float = HOOK_TIMEOUT_SECONDS,
+) -> Any:
+ """Execute a customer hook with full containment. The request-path entry.
+
+ Threading: the CALL runs via `anyio.to_thread.run_sync`, so a sync hook
+ that blocks (a DB lookup, an HTTP call) suspends only this request — the
+ loop keeps serving every other call. `abandon_on_cancel=True` lets the
+ timeout fire while the hook still blocks; the abandoned hook keeps running
+ on its daemon worker thread with its result discarded, and can never hold
+ the process open. An async hook's coroutine is awaited inline on the loop
+ (same as before), under the same timeout.
+
+ Exceptions-as-values across the thread boundary make cancellation
+ unambiguous: a `CancelledError`/`SystemExit` the hook itself raises comes
+ back as data and becomes `HookExecutionError`, so any `CancelledError`
+ surfacing from the awaits here is, by construction, the enclosing task
+ being cancelled — and is re-raised. (On the async-hook path, Python 3.11+
+ can additionally distinguish a spontaneous CancelledError via
+ `Task.cancelling()`; on 3.10 it is conservatively re-raised.)
+
+ Behavior notes, both documented in MIGRATION.md: a sync hook that calls
+ asyncio APIs must become `async def` (worker threads have no running
+ loop), and a hook slower than `timeout` degrades that call exactly like a
+ hook that raised.
+ """
+
+ def _call_contained() -> tuple[str, Any]:
+ try:
+ return ("ok", hook(request, extra))
+ except KeyboardInterrupt:
+ raise
+ except BaseException as e: # noqa: BLE001 — the containment boundary
+ return ("raised", e)
+
+ try:
+ with anyio.fail_after(timeout):
+ status, payload = await anyio.to_thread.run_sync(
+ _call_contained, abandon_on_cancel=True
+ )
+ if status == "raised":
+ raise HookExecutionError(
+ f"{hook_name} hook raised {type(payload).__name__}: {payload}"
+ ) from payload
+ value = payload
+ hops = 0
+ while inspect.isawaitable(value) and hops < _MAX_AWAIT_HOPS:
+ value = await await_hook_result(value, hook_name)
+ hops += 1
+ return value
+ except HookExecutionError:
+ raise
+ except TimeoutError:
+ write_to_log(
+ f"Warning: {hook_name} hook timed out after {timeout}s; "
+ "proceeding without its result"
+ )
+ raise HookExecutionError(f"{hook_name} hook timed out") from None
+ except asyncio.CancelledError:
+ task = asyncio.current_task()
+ cancelling = getattr(task, "cancelling", None)
+ if callable(cancelling) and cancelling() == 0:
+ # 3.11+: no external cancel is pending, so the async hook raised
+ # CancelledError spontaneously — contained, not propagated.
+ raise HookExecutionError(
+ f"{hook_name} hook raised CancelledError outside a cancellation"
+ ) from None
+ raise # genuine task cancellation (or 3.10, which cannot probe)
+ except (Exception, SystemExit) as e:
+ raise HookExecutionError(
+ f"{hook_name} hook raised {type(e).__name__}: {e}"
+ ) from e
+
+
+async def await_hook_result(value: T | Awaitable[Any], hook_name: str) -> T:
+ """Resolve a hook's return value on a path that has a running loop.
+
+ Re-raises rather than swallowing: each call site owns its own degradation
+ rule, and they differ. A failed `identify` yields an anonymous actor, a
+ failed `resolve_session_id` mints a fresh handle, a failed redaction drops
+ the event entirely. Deciding that here would flatten three deliberate
+ behaviors into one.
+ """
+ if not inspect.isawaitable(value):
+ return value
+ try:
+ return cast(T, await value)
+ except Exception as e:
+ _report(hook_name, e)
+ raise
+
+
+def drive_hook_result(value: T | Awaitable[Any], hook_name: str) -> T:
+ """Resolve a hook's return value from a thread with no event loop.
+
+ `asyncio.run` builds a loop, runs the awaitable to completion and tears the
+ loop down. That is only valid because the publish worker is a plain thread
+ that never had one — calling this from inside a running loop raises, which
+ is why the request path uses `await_hook_result` instead.
+
+ Not cheap, and knowingly so: the one caller is redaction, a security
+ control, running off the request's hot path. A hook that cannot be driven
+ raises here and the queue drops the event rather than publishing it
+ unredacted.
+ """
+ if not inspect.isawaitable(value):
+ return value
+ try:
+ return cast(T, asyncio.run(_resolved(value)))
+ except Exception as e:
+ _report(hook_name, e)
+ raise
+ except (SystemExit, asyncio.CancelledError) as e:
+ # On a worker thread there is no enclosing task, so either of these
+ # from the awaitable is definitionally the hook's own doing. Convert
+ # to a plain Exception so the caller's drop-the-event rule applies
+ # instead of the worker thread dying.
+ write_to_log(
+ f"Warning: {hook_name} returned an awaitable that raised "
+ f"{type(e).__name__}; treating as hook failure"
+ )
+ raise HookExecutionError(
+ f"{hook_name} hook raised {type(e).__name__}"
+ ) from e
+
+
+async def _resolved(awaitable: Any) -> Any:
+ """`asyncio.run` takes a coroutine, and an arbitrary awaitable is not one."""
+ return await awaitable
diff --git a/src/agentcat/modules/identify.py b/src/agentcat/modules/identify.py
index 9f002fa..4a541ed 100644
--- a/src/agentcat/modules/identify.py
+++ b/src/agentcat/modules/identify.py
@@ -1,42 +1,39 @@
-from datetime import datetime, timezone
+from typing import Any
-from agentcat.modules import event_queue
-from agentcat.modules.internal import get_server_tracking_data
+from agentcat.modules.hooks import run_hook
from agentcat.modules.logging import write_to_log
-from agentcat.types import EventType, UnredactedEvent, UserIdentity
+from agentcat.types import AgentCatData, UserIdentity
-def identify_session(server, request: any, context: any) -> UserIdentity | None:
- """Run the configured identify hook and publish an `agentcat:identify` event.
+async def resolve_identity(
+ data: AgentCatData | None, request: Any, extra: Any
+) -> UserIdentity | None:
+ """Run the configured identify hook and return its UserIdentity.
- Returns the resulting UserIdentity, or None if no hook is configured, the
- hook raises, or it returns a non-UserIdentity value.
- """
- data = get_server_tracking_data(server)
+ v1 published a standalone `agentcat:identify` event and cached the result
+ for the connection's lifetime. v2 publishes one event per tool call and
+ stamps the actor onto it, so identity resolution is pure and runs on every
+ call. Never raises — a customer hook that blows up yields an anonymous
+ call, not a failed one.
+ Async as of the sync-or-async sweep: the hook may be an `async def`, and
+ the await lives inside the same `try` as the call, so an awaitable that
+ fails degrades exactly like a hook that raised. Callers are unaffected —
+ the one call site (`callpath.resolve_call`) was already async.
+ """
if not data or not data.options or not data.options.identify:
return None
try:
- identify_result = data.options.identify(request, context)
- if not identify_result or not isinstance(identify_result, UserIdentity):
- write_to_log(
- "User identification function did not return a valid UserIdentity "
- f"instance. Received type: {type(identify_result).__name__}"
- )
- return None
-
- event = UnredactedEvent(
- session_id=data.session_id,
- timestamp=datetime.now(timezone.utc),
- event_type=EventType.AGENTCAT_IDENTIFY.value,
- identify_actor_given_id=identify_result.user_id,
- identify_actor_name=identify_result.user_name,
- identify_data=identify_result.user_data or {},
- )
- event_queue.publish_event(server, event)
-
- return identify_result
+ result = await run_hook(data.options.identify, "identify", request, extra)
except Exception as e:
write_to_log(f"Error occurred during user identification: {e}")
return None
+
+ if not result or not isinstance(result, UserIdentity):
+ write_to_log(
+ "User identification function did not return a valid UserIdentity "
+ f"instance. Received type: {type(result).__name__}"
+ )
+ return None
+ return result
diff --git a/src/agentcat/modules/injection.py b/src/agentcat/modules/injection.py
new file mode 100644
index 0000000..5e62095
--- /dev/null
+++ b/src/agentcat/modules/injection.py
@@ -0,0 +1,416 @@
+"""Pure schema-injection pipeline: handles, context, and strip registries.
+
+The pipeline is pure and deterministic: (adapter's deep-copied tool schemas,
+options) -> schemas mutated in place + registries recording exactly what was
+injected, so rebuild-on-demand can reproduce the registries on a fresh
+instance that never served tools/list. Mirrors the TS SDK's
+handle-injection.ts and context-parameters.ts as composed by
+listWrap.buildInjectedList.
+
+Two passes over the tools, in order:
+
+1. Handle pass — session_id/agent_id input params plus the _mcp_instructions
+ outputSchema extension. Runs only when at least one handle is injectable
+ (prompted-mode tracing -> session_id, agent tracking -> agent_id); when
+ neither is, the pass is skipped wholesale and schemas keep their original
+ shape (no normalization, additionalProperties untouched).
+2. Context pass — the optional context param, independent of the handle
+ flags. get_more_tools is exempt (its bespoke context is a real
+ parameter); it is NOT exempt from handles, since its calls publish
+ events.
+
+Resulting property order: customer params, session_id, agent_id, context.
+session_id is never required — omission is the minting signal. agent_id and
+context are both appended to required; that is client-side compliance
+only, since callWrap tolerates either being absent, and it is the only
+enforcement an injected parameter has.
+"""
+
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from typing import Any
+
+from agentcat.modules.constants import (
+ AGENT_ID_PARAM,
+ AGENT_ID_PARAM_DESCRIPTION,
+ AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE,
+ CONTEXT_PARAM,
+ GET_MORE_TOOLS_NAME,
+ MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION,
+ MCP_INSTRUCTIONS_FIELD_DESCRIPTION,
+ MCP_INSTRUCTIONS_KEY,
+ MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION,
+ SESSION_ID_PARAM,
+ SESSION_ID_PARAM_DESCRIPTION,
+)
+from agentcat.modules.logging import write_to_log
+from agentcat.types import AgentCatOptions
+
+_COMPOSED_KEYS = ("oneOf", "allOf", "anyOf")
+_ALL_INJECTABLE = (SESSION_ID_PARAM, AGENT_ID_PARAM, CONTEXT_PARAM)
+
+
+@dataclass
+class ToolSpec:
+ """One listed tool as the adapter hands it to the pipeline.
+
+ The schemas are the adapter's deep copies of the customer's originals;
+ the pipeline mutates them in place.
+ """
+
+ name: str
+ input_schema: dict[str, Any]
+ output_schema: dict[str, Any] | None = None
+
+
+@dataclass
+class InjectionResult:
+ """What the pipeline injected, keyed for later stripping.
+
+ injected_params has an entry for EVERY tool seen (possibly empty), so
+ the strip fallback applies only to tools never seen in any listing.
+ output_injected lists tools whose outputSchema gained _mcp_instructions.
+ declared_session_params lists tools whose OWN schema declared
+ `session_id`; see `build_injected_schemas` for why that is a separate
+ signal rather than "absent from injected_params".
+ """
+
+ injected_params: dict[str, set[str]]
+ output_injected: set[str]
+ declared_session_params: set[str] = field(default_factory=set)
+
+
+def mcp_instructions_schema_property(
+ include_session_id: bool, include_agent_id: bool
+) -> dict[str, Any]:
+ """Build a fresh _mcp_instructions outputSchema fragment.
+
+ Sub-properties mirror the modes — no session_id in hook mode, no agent_id
+ when tracking is off — so the copy never references a parameter the
+ agent cannot see. `instructions` is unconditional.
+ """
+ sub_properties: dict[str, Any] = {}
+ if include_session_id:
+ sub_properties[SESSION_ID_PARAM] = {
+ "type": "string",
+ "description": MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION,
+ }
+ if include_agent_id:
+ sub_properties[AGENT_ID_PARAM] = {
+ "type": "string",
+ "description": MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION,
+ }
+ sub_properties["instructions"] = {"type": "string"}
+ return {
+ "type": "object",
+ "description": MCP_INSTRUCTIONS_FIELD_DESCRIPTION,
+ "properties": sub_properties,
+ }
+
+
+def _inject_param(
+ tool_name: str,
+ schema: dict[str, Any],
+ name: str,
+ description: str,
+ required: bool,
+ entry: set[str],
+) -> None:
+ """Add one string param, honoring collisions and the required array."""
+ properties = schema.setdefault("properties", {})
+ if name in properties:
+ write_to_log(
+ f"WARN: Tool \"{tool_name}\" already has '{name}' parameter. "
+ f"Skipping {name} injection."
+ )
+ return
+ properties[name] = {"type": "string", "description": description}
+ if required:
+ existing = schema.get("required")
+ if isinstance(existing, list):
+ if name not in existing:
+ existing.append(name)
+ else:
+ schema["required"] = [name]
+ entry.add(name)
+
+
+def _report_session_conflict(tool_name: str, reported: set[str] | None) -> None:
+ """Tell the customer their `session_id` collides, once per tool.
+
+ ERROR, not WARN: unlike the other two collisions, this one costs them
+ correlation on that tool entirely. The pipeline reruns on every
+ `tools/list`, so an undeduped log would repeat for the life of the
+ process. Copy is byte-parallel with the TS SDK's handle-injection.ts.
+ """
+ if reported is not None:
+ if tool_name in reported:
+ return
+ reported.add(tool_name)
+ write_to_log(
+ f'ERROR: Tool "{tool_name}" already declares a '
+ f"'{SESSION_ID_PARAM}' parameter. AgentCat will not inject its own, "
+ "and calls to this tool are published without a session, so they "
+ "cannot be correlated. Your parameter is untouched and still reaches "
+ "your handler. If you already manage sessions, pass a "
+ "resolve_session_id hook to track() — AgentCat will derive its "
+ f"session from your identifier and stop injecting {SESSION_ID_PARAM} "
+ "entirely."
+ )
+
+
+def _add_handle_parameters(
+ tool: ToolSpec,
+ inject_session_id: bool,
+ inject_agent_id: bool,
+ result: InjectionResult,
+ reported_conflicts: set[str] | None = None,
+) -> None:
+ """Inject session_id/agent_id and extend the outputSchema for one tool."""
+ schema = tool.input_schema
+ entry = result.injected_params[tool.name]
+ declares_session_id = SESSION_ID_PARAM in (schema.get("properties") or {})
+ if any(key in schema for key in _COMPOSED_KEYS):
+ # Injection is skipped, but ownership still has to be recorded: a
+ # schema that composes AND declares session_id at its root is the
+ # customer's parameter, not ours to read at call time. (Only the root
+ # bag is visible here — a session_id nested inside a branch is
+ # unreachable, the same limitation the injection itself has.)
+ if inject_session_id and declares_session_id:
+ result.declared_session_params.add(tool.name)
+ _report_session_conflict(tool.name, reported_conflicts)
+ write_to_log(
+ f'WARN: Tool "{tool.name}" has complex schema (oneOf/allOf/anyOf). '
+ "Skipping handle injection."
+ )
+ return
+ if not schema:
+ # In-place normalization: the adapter's dict object must survive.
+ schema["type"] = "object"
+ schema["properties"] = {}
+ schema["required"] = []
+ schema.setdefault("properties", {})
+ if schema.get("additionalProperties") is False:
+ del schema["additionalProperties"]
+
+ if inject_session_id:
+ if declares_session_id:
+ # The customer owns this name on this tool. Record it so call-time
+ # resolution never reads their value as an AgentCat handle.
+ result.declared_session_params.add(tool.name)
+ _report_session_conflict(tool.name, reported_conflicts)
+ else:
+ _inject_param(
+ tool.name,
+ schema,
+ SESSION_ID_PARAM,
+ SESSION_ID_PARAM_DESCRIPTION,
+ False,
+ entry,
+ )
+ if inject_agent_id:
+ # Hook mode has no session_id param anywhere; never reference one the
+ # agent cannot see.
+ description = (
+ AGENT_ID_PARAM_DESCRIPTION
+ if inject_session_id
+ else AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE
+ )
+ _inject_param(tool.name, schema, AGENT_ID_PARAM, description, True, entry)
+
+ _extend_output_schema(tool, inject_session_id, inject_agent_id, result)
+
+
+def _extend_output_schema(
+ tool: ToolSpec,
+ inject_session_id: bool,
+ inject_agent_id: bool,
+ result: InjectionResult,
+) -> None:
+ """Declare the optional _mcp_instructions property on a plain-object
+ outputSchema so schema-validating clients accept the mirrored field.
+
+ Never added to required. Composed schemas have no single properties bag
+ to extend and are skipped (mint-back stays content-only), same policy as
+ the input side.
+ """
+ schema = tool.output_schema
+ if not isinstance(schema, dict):
+ return
+ if any(key in schema for key in _COMPOSED_KEYS):
+ write_to_log(
+ f'WARN: Tool "{tool.name}" has complex outputSchema '
+ f"(oneOf/allOf/anyOf). Skipping {MCP_INSTRUCTIONS_KEY} injection; "
+ "mint-back stays content-only for this tool."
+ )
+ return
+ properties = schema.setdefault("properties", {})
+ if MCP_INSTRUCTIONS_KEY in properties:
+ write_to_log(
+ f'WARN: Tool "{tool.name}" already declares '
+ f"'{MCP_INSTRUCTIONS_KEY}' in outputSchema. Skipping injection."
+ )
+ return
+ properties[MCP_INSTRUCTIONS_KEY] = mcp_instructions_schema_property(
+ inject_session_id, inject_agent_id
+ )
+ result.output_injected.add(tool.name)
+
+
+def _add_context_parameter(tool: ToolSpec, description: str, entry: set[str]) -> None:
+ """Inject the context param for one tool, appended to `required`.
+
+ Required is the existing behavior — both v1 paths appended it
+ (`overrides/official/monkey_patch.py` and the retired
+ `modules/context_parameters.py`, on `bb1e9bc`) and the TS SDK still does
+ (`context-parameters.ts`) — and it is the only enforcement
+ there is. Nothing server-side rejects a call that omits `context`; a
+ schema-validating client refusing to send one is the whole mechanism, the
+ same argument the migration guide makes for `agent_id`. Optional `context`
+ means agents quietly stop supplying it and `user_intent` coverage decays
+ with nothing to show for it.
+
+ `session_id` is the deliberate exception (never required): omitting it is how
+ an agent signals "mint me one".
+ """
+ schema = tool.input_schema
+ properties = schema.get("properties")
+ if isinstance(properties, dict) and CONTEXT_PARAM in properties:
+ write_to_log(
+ f"WARN: Tool \"{tool.name}\" already has '{CONTEXT_PARAM}' "
+ "parameter. Skipping context injection."
+ )
+ return
+ if any(key in schema for key in _COMPOSED_KEYS):
+ write_to_log(
+ f'WARN: Tool "{tool.name}" has complex schema (oneOf/allOf/anyOf). '
+ "Skipping context injection."
+ )
+ return
+ # TS-faithful (context-parameters.ts): the context injector drops
+ # additionalProperties: false itself, so a schema the (skipped) handle
+ # pass never normalized still admits the injected param.
+ if schema.get("additionalProperties") is False:
+ del schema["additionalProperties"]
+ _inject_param(tool.name, schema, CONTEXT_PARAM, description, True, entry)
+
+
+def build_injected_schemas(
+ tools: list[ToolSpec],
+ options: AgentCatOptions,
+ reported_conflicts: set[str] | None = None,
+) -> InjectionResult:
+ """Run both injection passes over the listed tools, in place.
+
+ Deterministic and config-derived: identical (tools, options) inputs yield
+ identical schemas and registries. `reported_conflicts` is the only
+ exception — a per-server set that suppresses repeat `session_id` collision
+ reports across successive listings, and the only thing here that carries
+ state between calls.
+
+ `declared_session_params` is a POSITIVE signal — "the customer's schema
+ declared this name" — not the negation of `injected_params`. The two are
+ not complements: a composed schema gets an entry in `injected_params`
+ that stays empty, because the whole pass is skipped for it. Reading
+ ownership off that emptiness would classify every composed-schema tool as
+ the customer's and publish it sessionless, when in fact nobody declared
+ the name and the tool is ours to correlate exactly as before.
+ """
+ inject_session_id = options.enable_tracing and options.resolve_session_id is None
+ inject_agent_id = options.enable_tracing and options.enable_agent_tracking
+ injected_params: dict[str, set[str]] = {tool.name: set() for tool in tools}
+ result = InjectionResult(injected_params=injected_params, output_injected=set())
+
+ # Handle pass first: property order is customer -> session_id -> agent_id
+ # -> context. The output-schema extension lives inside this pass, so it
+ # is gated with it (handle-injection.ts:59).
+ if inject_session_id or inject_agent_id:
+ for tool in tools:
+ _add_handle_parameters(
+ tool, inject_session_id, inject_agent_id, result, reported_conflicts
+ )
+ if options.enable_tool_call_context:
+ for tool in tools:
+ if tool.name == GET_MORE_TOOLS_NAME:
+ continue
+ _add_context_parameter(
+ tool,
+ options.custom_context_description,
+ injected_params[tool.name],
+ )
+ return result
+
+
+def injected_parameter_names(
+ tool_name: str,
+ registry: dict[str, set[str]] | None,
+ arguments: dict[str, Any] | None = None,
+ options: AgentCatOptions | None = None,
+) -> frozenset[str]:
+ """The parameter names AgentCat injected into `tool_name`.
+
+ With a registry, a listed tool reports exactly its recorded entry and an
+ unlisted tool reports nothing (it was never advertised through the
+ pipeline). Without one (tools/call before any tools/list, and the rebuild
+ failed) the fallback mirrors `build_injected_schemas`' own inject gates
+ instead of blanket-claiming all three names, so a customer-declared
+ parameter is only ever at risk when its name AND shape AND the enabled
+ options all collide:
+
+ - `session_id` counts as ours iff prompted-mode tracing would have
+ injected it (`enable_tracing` on, no `resolve_session_id` hook) AND the
+ value the agent sent is absent or matches our minted `ses_` KSUID shape.
+ A non-minted value is presumed the customer's own parameter: it is not
+ stripped, and — because this same set feeds `resolve_handles` — it is
+ not branded invalid or corrected on the wire either.
+ - `agent_id` counts as ours iff agent tracking would have injected it.
+ - `context` counts as ours iff `enable_tool_call_context` is on
+ (get_more_tools' bespoke context is always a real parameter).
+
+ Single source of truth for both consumers, deliberately: what
+ `strip_injected_arguments` removes before the customer's handler runs is
+ exactly what the call path may read as an AgentCat handle. Split the two
+ and a customer's OWN `session_id` parameter — which the injection pass
+ correctly skipped, and the strip correctly spares — gets consumed as the
+ analytics handle anyway, collapsing every call to that tool into one task
+ and putting a customer-domain identifier into `session_id`, a field the
+ customer's redaction hook is not allowed to touch.
+ """
+ if registry is not None:
+ return frozenset(registry.get(tool_name, ()))
+
+ # Local import: handles.py does not import this module, so no cycle.
+ from agentcat.modules.handles import is_valid_session_id
+
+ opts = options if options is not None else AgentCatOptions()
+ args = arguments or {}
+
+ names: set[str] = set()
+ if opts.enable_tracing and opts.resolve_session_id is None:
+ value = args.get(SESSION_ID_PARAM)
+ looks_minted = value is None or (
+ isinstance(value, str) and is_valid_session_id(value.strip())
+ )
+ if looks_minted:
+ names.add(SESSION_ID_PARAM)
+ if opts.enable_tracing and opts.enable_agent_tracking:
+ names.add(AGENT_ID_PARAM)
+ if opts.enable_tool_call_context and tool_name != GET_MORE_TOOLS_NAME:
+ names.add(CONTEXT_PARAM)
+ return frozenset(names)
+
+
+def strip_injected_arguments(
+ tool_name: str,
+ arguments: dict[str, Any],
+ registry: dict[str, set[str]] | None,
+ options: AgentCatOptions | None = None,
+) -> dict[str, Any]:
+ """Return a new dict with ONLY the params AgentCat injected removed."""
+ names: Iterable[str] = injected_parameter_names(
+ tool_name, registry, arguments, options
+ )
+ cleaned = dict(arguments)
+ for name in names:
+ cleaned.pop(name, None)
+ return cleaned
diff --git a/src/agentcat/modules/internal.py b/src/agentcat/modules/internal.py
index 9884ecf..2ba3d8f 100644
--- a/src/agentcat/modules/internal.py
+++ b/src/agentcat/modules/internal.py
@@ -1,12 +1,15 @@
"""Internal data storage for AgentCat."""
-import inspect
import weakref
-from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Optional
-from ..types import EventType, AgentCatData, ToolRegistration, UnredactedEvent
-from .compatibility import is_official_fastmcp_server
+from ..types import AgentCatData, UnredactedEvent
+
+# The classifier's own presence probe, shared rather than copied — see
+# `_get_server_key`. detection.py imports nothing from this module, so this
+# does not close a cycle.
+from .detection import _probe as _attribute_present
+from .hooks import run_hook
from .logging import write_to_log
from .validation import validate_tags
@@ -15,15 +18,58 @@
weakref.WeakKeyDictionary()
)
-# Global storage for original unpatched methods (keyed by tool_manager or handler id)
-# This is global because tool managers might be shared between servers
-_original_methods: Dict[str, Any] = {}
-
def _get_server_key(server: Any) -> Any:
- """Get the canonical key for a server (handles FastMCP vs low-level)."""
- if is_official_fastmcp_server(server):
- return server._mcp_server
+ """The object a server's tracking data is stored under.
+
+ ``track()`` installs the adapter on the LOWLEVEL server for both official
+ facades — an ``mcp.server.fastmcp.FastMCP``'s ``_mcp_server`` and an
+ ``MCPServer``'s ``_lowlevel_server`` — and stores the data there, so a
+ lookup holding the facade has to make the same hop. Without it,
+ ``publish_custom_event(mcpserver, ...)`` looked up an object nothing was
+ ever filed under and dropped the event as "not a tracked server".
+
+ Applies detection.py's ``OFFICIAL_FASTMCP_V1`` and ``MCPSERVER_V2`` rules
+ inline rather than calling ``detect_server``: this runs on every request,
+ and a 13-probe sweep to answer one question is not worth paying for. Both
+ rules are reproduced in full — class name, module prefix, a non-None inner
+ server, and a ``_tool_manager``. The ``_tool_manager`` half is also what
+ keeps a community FastMCP — which is tracked on ITSELF — out of both
+ branches.
+
+ The two probe strengths are the classifier's, not lookalikes, because the
+ difference decides where data lands. ``_tool_manager`` is a PRESENCE test
+ and reuses ``detection._probe`` itself: an attribute that exists but whose
+ lazy or proxy getter raises, or that holds ``None``, still means "this is a
+ facade". Reading it with ``getattr(..., None) is not None`` instead looked
+ like a harmless tightening and was not — it made this function classify a
+ facade the classifier calls ``MCPSERVER_V2`` as an ordinary server, so
+ ``track()`` filed the data under ``_lowlevel_server`` while every later
+ lookup keyed on the facade. That silent lost lookup is the exact bug this
+ function exists to prevent, so the predicate is shared rather than
+ re-spelled.
+
+ The inner server is a RETRIEVAL test, matching detection's ``_get``, and
+ ``is not None`` rather than truthiness: a facade whose ``_mcp_server``
+ defines ``__bool__`` or ``__len__`` falsily is still the object ``track()``
+ filed the data under.
+ """
+ try:
+ cls = type(server)
+ module = getattr(cls, "__module__", "")
+ is_official_fastmcp = "FastMCP" in getattr(
+ cls, "__name__", ""
+ ) and module.startswith("mcp.server.fastmcp")
+ has_tool_manager = _attribute_present(server, "_tool_manager")
+ if is_official_fastmcp and has_tool_manager:
+ mcp_server = getattr(server, "_mcp_server", None)
+ if mcp_server is not None:
+ return mcp_server
+ lowlevel = getattr(server, "_lowlevel_server", None)
+ if lowlevel is not None and has_tool_manager:
+ return lowlevel
+ except Exception:
+ pass
return server
@@ -50,76 +96,9 @@ def reset_server_tracking_data(server: Any) -> None:
def reset_all_tracking_data() -> None:
"""Reset all server tracking data (mainly for testing)."""
_server_data_map.clear()
- _original_methods.clear()
write_to_log("Reset all server tracking data")
-
-# Dynamic tracking helper methods
-def register_tool(server: Any, name: str) -> None:
- """Register a tool in the server's tracking system."""
- data = get_server_tracking_data(server)
- if data and name not in data.tool_registry:
- data.tool_registry[name] = ToolRegistration(
- name=name, registered_at=datetime.now(timezone.utc)
- )
- write_to_log(f"Registered tool '{name}'")
-
-
-def mark_tool_tracked(server: Any, name: str) -> None:
- """Mark a tool as being tracked by AgentCat for this server."""
- data = get_server_tracking_data(server)
- if data and name in data.tool_registry:
- data.tool_registry[name].tracked = True
- data.tool_registry[name].wrapped = True
- data.wrapped_tools.add(name)
-
-
-def is_tool_tracked(server: Any, name: str) -> bool:
- """Check if a tool is already being tracked for this server."""
- data = get_server_tracking_data(server)
- return data and name in data.wrapped_tools
-
-
-def get_untracked_tools(server: Any) -> List[str]:
- """Get list of tools that aren't tracked yet for this server."""
- data = get_server_tracking_data(server)
- if not data:
- return []
- return [name for name, reg in data.tool_registry.items() if not reg.tracked]
-
-
-def discover_new_tools(server: Any, tools: List[Any]) -> List[str]:
- """Discover tools that weren't previously known for this server."""
- data = get_server_tracking_data(server)
- if not data:
- return []
-
- new_tools = []
- for tool in tools:
- if tool.name not in data.tool_registry:
- register_tool(server, tool.name)
- new_tools.append(tool.name)
- return new_tools
-
-
-# Original methods storage (global, not per-server)
-def store_original_method(key: str, method: Any) -> None:
- """Store an original unpatched method."""
- if key not in _original_methods:
- _original_methods[key] = method
-
-
-def get_original_method(key: str) -> Optional[Any]:
- """Get an original unpatched method."""
- return _original_methods.get(key)
-
-
-def get_original_methods() -> Dict[str, Any]:
- """Get the global original methods storage."""
- return _original_methods
-
-
async def resolve_event_tags(
data: AgentCatData, request: Any, extra: Any
) -> Optional[Dict[str, str]]:
@@ -133,9 +112,7 @@ async def resolve_event_tags(
return None
try:
- result = callback(request, extra)
- if inspect.iscoroutine(result):
- result = await result
+ result = await run_hook(callback, "event_tags", request, extra)
except Exception as e:
write_to_log(f"event_tags callback error: {e}")
return None
@@ -159,9 +136,7 @@ async def resolve_event_properties(
return None
try:
- result = callback(request, extra)
- if inspect.iscoroutine(result):
- result = await result
+ result = await run_hook(callback, "event_properties", request, extra)
except Exception as e:
write_to_log(f"event_properties callback error: {e}")
return None
@@ -190,33 +165,3 @@ async def attach_event_metadata(
properties = await resolve_event_properties(data, request, extra)
if properties:
event.properties = properties
-
-
-def get_tool_timeline(server: Any) -> List[Dict[str, Any]]:
- """Get a timeline of tool registrations for debugging.
-
- Args:
- server: MCP server instance
-
- Returns:
- List of tool registration events sorted by time
- """
- data = get_server_tracking_data(server)
- if not data:
- return []
- timeline = []
-
- for name, reg in data.tool_registry.items():
- timeline.append(
- {
- "name": name,
- "registered_at": reg.registered_at.isoformat(),
- "tracked": reg.tracked,
- "wrapped": reg.wrapped,
- }
- )
-
- # Sort by registration time
- timeline.sort(key=lambda x: x["registered_at"])
-
- return timeline
diff --git a/src/agentcat/modules/logging.py b/src/agentcat/modules/logging.py
index 761dac6..a460a5b 100644
--- a/src/agentcat/modules/logging.py
+++ b/src/agentcat/modules/logging.py
@@ -1,18 +1,21 @@
"""Logging functionality for AgentCat."""
+import functools
import os
+import platform
from collections.abc import Callable
from datetime import datetime, timezone
-from agentcat.types import AgentCatOptions
+def _env_debug_mode() -> bool:
+ """True when AGENTCAT_DEBUG_MODE holds a truthy value."""
+ raw = os.getenv("AGENTCAT_DEBUG_MODE")
+ return raw is not None and raw.lower() in ("true", "1", "yes", "on")
-# Initialize debug_mode from environment variable at module load time
-_env_debug = os.getenv("AGENTCAT_DEBUG_MODE")
-if _env_debug is not None:
- debug_mode = _env_debug.lower() in ("true", "1", "yes", "on")
-else:
- debug_mode = False
+
+# Seed from the environment at import time. track() only overrides this when
+# AgentCatOptions.debug_mode is explicitly set — None leaves the seed alone.
+debug_mode = _env_debug_mode()
# Optional sink that receives every (clean, newline-free) log entry. Used by the
@@ -33,9 +36,27 @@ def set_diagnostics_sink(fn: Callable[[str], None] | None) -> None:
_diagnostics_sink = fn
+@functools.lru_cache(maxsize=1)
+def _version_suffix() -> str:
+ """Environment stamp appended to every log entry, computed once per process.
+
+ A shared log excerpt must never leave the reader guessing which AgentCat
+ SDK, Python, or MCP SDK produced it; `absent` for an uninstalled MCP
+ distribution is itself a diagnostic.
+ """
+ from agentcat.utils import get_agentcat_version, get_dist_version
+
+ return (
+ f"agentcat={get_agentcat_version() or 'unknown'} "
+ f"python={platform.python_version()} "
+ f"mcp={get_dist_version('mcp') or 'absent'} "
+ f"fastmcp={get_dist_version('fastmcp') or 'absent'}"
+ )
+
+
def write_to_log(message: str) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
- log_entry = f"[{timestamp}] {message}"
+ log_entry = f"[{timestamp}] {message} | {_version_suffix()}"
# Tee to diagnostics FIRST — independent of debug_mode. Must never break logging.
if _diagnostics_sink is not None:
diff --git a/src/agentcat/modules/overrides/community/monkey_patch.py b/src/agentcat/modules/overrides/community/monkey_patch.py
deleted file mode 100644
index c7f8106..0000000
--- a/src/agentcat/modules/overrides/community/monkey_patch.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""Monkey-patching implementation for community FastMCP servers.
-
-This module patches community FastMCP servers to intercept tool operations
-and add AgentCat tracking capabilities.
-"""
-
-from datetime import datetime, timezone
-from typing import Any
-
-from mcp.types import CallToolRequest
-from mcp import ServerResult
-
-from agentcat.modules import event_queue
-from agentcat.modules.compatibility import is_mcp_error_response
-from agentcat.modules.exceptions import capture_exception
-from agentcat.modules.identify import identify_session
-from agentcat.modules.internal import attach_event_metadata, get_server_tracking_data
-from agentcat.modules.logging import write_to_log
-from agentcat.modules.request_extra import params_with_extra
-from agentcat.modules.session import (
- get_client_info_from_request_context,
- get_server_session_id,
-)
-from agentcat.types import EventType, UnredactedEvent
-
-from ..mcp_server import override_lowlevel_mcp_server_minimal, safe_request_context
-
-
-def patch_community_fastmcp(server: Any) -> None:
- """Main entry point for patching community FastMCP servers.
-
- This function:
- 1. Patches the tool manager to add context parameters to tools
- 2. Overrides the call_tool handler for tracking and context removal
- 3. Sets up minimal overrides for other MCP events
- """
- try:
- # First, patch the tool manager for context injection and tool tracking
- from .tool_manager import patch_community_fastmcp_tool_manager
- patch_community_fastmcp_tool_manager(server)
-
- # Get the low-level MCP server
- lowlevel_server = server._mcp_server
- data = get_server_tracking_data(lowlevel_server)
-
- if not data:
- write_to_log("No tracking data found for community FastMCP server")
- return
-
- # Patch _get_cached_tool_definition to remove context from validation
- original_get_cached_tool = lowlevel_server._get_cached_tool_definition
-
- async def patched_get_cached_tool_definition(tool_name: str):
- """Get tool definition with context removed for validation."""
- # Get the original tool definition
- tool = await original_get_cached_tool(tool_name)
-
- if tool and data.options.enable_tool_call_context and tool_name != "get_more_tools":
- # Create a copy of the tool to avoid modifying the cache
- import copy
- tool_copy = copy.deepcopy(tool)
-
- # Remove context from the schema for validation
- if hasattr(tool_copy, "inputSchema") and tool_copy.inputSchema:
- if "properties" in tool_copy.inputSchema:
- if "context" in tool_copy.inputSchema["properties"]:
- # Remove context from properties
- del tool_copy.inputSchema["properties"]["context"]
-
- # Remove context from required if present
- if "required" in tool_copy.inputSchema and isinstance(tool_copy.inputSchema["required"], list):
- if "context" in tool_copy.inputSchema["required"]:
- tool_copy.inputSchema["required"].remove("context")
-
- write_to_log(f"Removed context from validation schema for tool {tool_name}")
- return tool_copy
-
- return tool
-
- # Apply the patched method
- lowlevel_server._get_cached_tool_definition = patched_get_cached_tool_definition
- write_to_log("Patched _get_cached_tool_definition for community FastMCP")
-
- # Override the call_tool handler to handle context removal and tracking
- original_call_tool_handler = lowlevel_server.request_handlers.get(CallToolRequest)
-
- if not original_call_tool_handler:
- write_to_log("No original call_tool handler found")
- return
-
- async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
- """Intercept call_tool requests to handle context and tracking."""
- tool_name = request.params.name
- arguments = dict(request.params.arguments) if request.params.arguments else {}
-
- # Get request context for session tracking
- request_context = safe_request_context(lowlevel_server)
- session_id = get_server_session_id(lowlevel_server)
-
- # Handle session identification
- try:
- client_name, client_version = get_client_info_from_request_context(lowlevel_server, request_context)
- identity = identify_session(lowlevel_server, request, request_context)
- except Exception as e:
- client_name, client_version = None, None
- identity = None
- write_to_log(f"Non-critical error in session handling: {e}")
-
- # Extract user intent from context parameter
- user_intent = None
- if tool_name == "get_more_tools":
- # get_more_tools has its own context parameter that serves as user intent
- user_intent = arguments.get("context", None)
- elif data.options.enable_tool_call_context:
- # For other tools, extract user intent when context injection is enabled
- user_intent = arguments.get("context", None)
-
- # Create tracking event
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- {"name": tool_name, "arguments": arguments},
- request_context,
- ),
- event_type=EventType.MCP_TOOLS_CALL.value,
- resource_name=tool_name,
- user_intent=user_intent,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, data, request, request_context)
-
- try:
- # Handle get_more_tools specially - don't intercept for community FastMCP
- # Let it go through the normal tool handler which will return a string
- if tool_name == "get_more_tools":
- # Just track the event but let the tool execute normally
- # The tool function itself returns a string which is what community FastMCP expects
- pass # Fall through to call original handler
- elif data.options.enable_tool_call_context:
- # Remove context from arguments before calling other tools
- # Create a new request with modified arguments
- modified_args = arguments.copy()
- modified_args.pop("context", None)
-
- # Modify the request in place since we can't create a new one easily
- request.params.arguments = modified_args
-
- # Call original handler with potentially modified request
- result = await original_call_tool_handler(request)
-
- # Check for errors
- is_error, error_message = is_mcp_error_response(result)
- event.is_error = is_error
- # Use full exception capture if there's an error
- if is_error:
- event.error = capture_exception(result)
- else:
- event.error = None
- event.response = result.model_dump() if result else None
-
- return result
-
- except Exception as e:
- write_to_log(f"Error in wrapped_call_tool_handler: {e}")
- event.is_error = True
- # Use full exception capture with stack trace
- try:
- event.error = capture_exception(e)
- except Exception as capture_err:
- # Fallback to simple error if capture fails
- write_to_log(f"Error capturing exception: {capture_err}")
- event.error = {
- "message": str(e),
- "type": type(e).__name__,
- "platform": "python",
- }
- raise
- finally:
- # Always publish event if tracing is enabled
- if data.options.enable_tracing:
- try:
- event_queue.publish_event(lowlevel_server, event)
- except Exception as e:
- write_to_log(f"Error publishing event: {e}")
-
- # Apply the wrapped handler
- lowlevel_server.request_handlers[CallToolRequest] = wrapped_call_tool_handler
- write_to_log(f"Successfully patched call_tool handler for community FastMCP server {id(server)}")
-
- # Use minimal override for other events (initialize, list_tools)
- # This handles event tracking for non-tool operations
- override_lowlevel_mcp_server_minimal(lowlevel_server, data)
- write_to_log(f"Applied minimal overrides for community FastMCP server {id(server)}")
-
- except Exception as e:
- write_to_log(f"Error patching community FastMCP server: {e}")
diff --git a/src/agentcat/modules/overrides/community/tool_manager.py b/src/agentcat/modules/overrides/community/tool_manager.py
deleted file mode 100644
index ef8fc95..0000000
--- a/src/agentcat/modules/overrides/community/tool_manager.py
+++ /dev/null
@@ -1,177 +0,0 @@
-from typing import Any
-
-from agentcat.modules.compatibility import is_community_fastmcp_server
-from agentcat.modules.internal import (
- get_server_tracking_data,
- get_original_method,
- store_original_method,
- is_tool_tracked,
- register_tool,
- mark_tool_tracked,
-)
-from agentcat.modules.logging import write_to_log
-from agentcat.modules.tools import handle_report_missing
-
-from fastmcp import FastMCP
-
-
-def _ensure_context_parameter(tool: Any, description: str) -> None:
- """Add or overwrite the 'context' parameter in a tool's schema.
-
- Ensures the tool has a valid parameters dict with a 'context' property
- marked as required.
- """
- if not hasattr(tool, "parameters") or not tool.parameters:
- tool.parameters = {"type": "object", "properties": {}, "required": []}
-
- if "properties" not in tool.parameters:
- tool.parameters["properties"] = {}
-
- tool.parameters["properties"]["context"] = {
- "type": "string",
- "description": description,
- }
-
- if "required" not in tool.parameters:
- tool.parameters["required"] = []
-
- if isinstance(tool.parameters["required"], list):
- if "context" not in tool.parameters["required"]:
- tool.parameters["required"].append("context")
- else:
- tool.parameters["required"] = ["context"]
-
-
-def patch_community_fastmcp_tool_manager(server: Any) -> None:
- """Patch the community FastMCP tool manager to add AgentCat tracking.
-
- This function modifies the tool manager to:
- 1. Add context parameter to existing tools
- 2. Automatically add context to new tools via add_tool patching
- 3. Add get_more_tools if enabled
- """
- # Check that the server is a community FastMCP server
- if not is_community_fastmcp_server(server):
- write_to_log("WARNING: Incompatible community FastMCP server detected. Tracking not properly enabled.")
- return
-
- # Get tracking data from the low-level server
- data = get_server_tracking_data(server._mcp_server)
- if not data:
- write_to_log("WARNING: Unknown error when tracking community FastMCP. Tracking data for server not initialized.")
- return
-
- write_to_log(f"Patching community FastMCP tool manager for server {id(server)}")
-
- # Add get_more_tools if enabled
- if data.options.enable_report_missing:
- try:
- async def get_more_tools(context: str) -> str:
- """Check for additional tools whenever your task might benefit from specialized capabilities."""
- result = await handle_report_missing({"context": context})
- if result.content:
- return result.content[0].text
- return "No additional tools available"
-
- server.tool(
- get_more_tools,
- name="get_more_tools",
- description="Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.",
- )
-
- # Force the correct schema - Pydantic's TypeAdapter can mangle
- # the type on async closures into anyOf: [string, null]
- from agentcat.modules.tools import GET_MORE_TOOLS_SCHEMA
- if hasattr(server._tool_manager, "_tools") and "get_more_tools" in server._tool_manager._tools:
- server._tool_manager._tools["get_more_tools"].parameters = GET_MORE_TOOLS_SCHEMA
-
- write_to_log("Added get_more_tools tool to community FastMCP server")
- except Exception as e:
- write_to_log(f"Error adding get_more_tools: {e}")
-
- # Track existing tools and optionally add context parameter
- if hasattr(server._tool_manager, "_tools"):
- for tool_name, tool in server._tool_manager._tools.items():
- # Track the tool
- if not is_tool_tracked(server._mcp_server, tool_name):
- register_tool(server._mcp_server, tool_name)
- mark_tool_tracked(server._mcp_server, tool_name)
- write_to_log(f"Found existing community FastMCP tool: {tool_name}")
-
- # Patch existing tools if context injection is enabled
- if data.options.enable_tool_call_context:
- patch_existing_tools(server)
- patch_add_tool_fn(server)
-
-
-def patch_existing_tools(server: FastMCP) -> None:
- """Modify existing tools to include the context parameter."""
- try:
- data = get_server_tracking_data(server._mcp_server)
- tool_manager = server._tool_manager
- if not hasattr(tool_manager, "_tools"):
- write_to_log("No _tools dictionary found on tool manager")
- return
-
- for tool_name, tool in tool_manager._tools.items():
- if tool_name == "get_more_tools":
- continue
-
- _ensure_context_parameter(tool, data.options.custom_context_description)
- write_to_log(f"Added/updated context parameter for existing tool: {tool_name}")
-
- except Exception as e:
- write_to_log(f"Error patching existing tools: {e}")
-
-
-def patch_add_tool_fn(server: FastMCP) -> None:
- """Patch the add_tool method to automatically add context parameter to new tools."""
- try:
- tool_manager = server._tool_manager
- tool_manager_id = id(tool_manager)
- method_key = f"community_{tool_manager_id}_add_tool"
-
- # Store original method if not already stored
- if get_original_method(method_key) is None:
- store_original_method(method_key, tool_manager.add_tool)
- write_to_log(f"Stored original add_tool for community tool_manager {tool_manager_id}")
-
- original_add_tool = get_original_method(method_key)
- if not original_add_tool:
- write_to_log("Failed to get original add_tool method")
- return
-
- def patched_add_tool(tool: Any) -> Any:
- """Patched add_tool that adds context parameter to new tools."""
- try:
- # Call original method first
- result = original_add_tool(tool)
-
- # Track the tool
- tool_name = tool.key if hasattr(tool, "key") else (tool.name if hasattr(tool, "name") else "unknown")
- if not is_tool_tracked(server._mcp_server, tool_name):
- register_tool(server._mcp_server, tool_name)
- mark_tool_tracked(server._mcp_server, tool_name)
- write_to_log(f"Tracked new community FastMCP tool: {tool_name}")
-
- # Add context parameter if it's not get_more_tools
- if tool_name != "get_more_tools":
- data = get_server_tracking_data(server._mcp_server)
- if data and data.options.enable_tool_call_context:
- _ensure_context_parameter(tool, data.options.custom_context_description)
- write_to_log(f"Added/updated context parameter for new tool: {tool_name}")
-
- return result
- except Exception as e:
- write_to_log(f"Error in patched add_tool: {e}")
- # Fall back to original if something goes wrong
- if callable(original_add_tool):
- return original_add_tool(tool)
- return tool
-
- # Apply the patch
- tool_manager.add_tool = patched_add_tool
- write_to_log(f"Successfully patched add_tool for community tool_manager {tool_manager_id}")
-
- except Exception as e:
- write_to_log(f"Error patching add_tool: {e}")
diff --git a/src/agentcat/modules/overrides/community_v3/__init__.py b/src/agentcat/modules/overrides/community_v3/__init__.py
deleted file mode 100644
index 17f329c..0000000
--- a/src/agentcat/modules/overrides/community_v3/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Community FastMCP v3 integration using the middleware system."""
-
-from agentcat.modules.overrides.community_v3.integration import (
- apply_community_v3_integration,
-)
-from agentcat.modules.overrides.community_v3.middleware import AgentCatMiddleware
-
-__all__ = [
- "AgentCatMiddleware",
- "apply_community_v3_integration",
-]
diff --git a/src/agentcat/modules/overrides/community_v3/integration.py b/src/agentcat/modules/overrides/community_v3/integration.py
deleted file mode 100644
index eccf6d3..0000000
--- a/src/agentcat/modules/overrides/community_v3/integration.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""Integration module for Community FastMCP v3.
-
-This module provides the function to apply AgentCat tracking to
-FastMCP v3 servers using the middleware system.
-"""
-
-from typing import Annotated, Any
-
-from pydantic import Field
-
-from agentcat.modules.logging import write_to_log
-from agentcat.modules.overrides.community_v3.middleware import AgentCatMiddleware
-from agentcat.types import AgentCatData
-
-
-def apply_community_v3_integration(server: Any, agentcat_data: AgentCatData) -> None:
- """Apply AgentCat tracking to a Community FastMCP v3 server.
-
- This function:
- 1. Creates an AgentCatMiddleware instance
- 2. Inserts it at the beginning of the middleware chain (position 0)
- 3. Registers get_more_tools tool if enabled
-
- Args:
- server: A Community FastMCP v3 server instance.
- agentcat_data: AgentCat tracking configuration.
- """
- try:
- # Create middleware instance
- middleware = AgentCatMiddleware(agentcat_data, server)
-
- # Insert at beginning of middleware chain (position 0)
- # This ensures AgentCat sees all requests first
- server.middleware.insert(0, middleware)
- write_to_log(
- f"Inserted AgentCatMiddleware at position 0 for server {id(server)}"
- )
-
- # Register get_more_tools if enabled
- if agentcat_data.options.enable_report_missing:
- _register_get_more_tools_v3(server, agentcat_data)
-
- write_to_log(
- f"Successfully applied Community FastMCP v3 integration "
- f"for server {id(server)}"
- )
-
- except Exception as e:
- write_to_log(f"Error applying Community FastMCP v3 integration: {e}")
- raise
-
-
-def _register_get_more_tools_v3(server: Any, agentcat_data: AgentCatData) -> None:
- """Register the get_more_tools tool for FastMCP v3.
-
- Args:
- server: A Community FastMCP v3 server instance.
- agentcat_data: AgentCat tracking configuration.
- """
- from fastmcp.tools.tool import Tool
-
- from agentcat.modules.tools import handle_report_missing
-
- # Define the get_more_tools function
- async def get_more_tools(
- context: Annotated[
- str,
- Field(
- description="A description of your goal and what kind of tool would help accomplish it."
- ),
- ],
- ) -> str:
- """Check for additional tools when your task might benefit from them."""
- result = await handle_report_missing({"context": context})
-
- if result.content and hasattr(result.content[0], "text"):
- return result.content[0].text
- return "No additional tools available."
-
- try:
- tool = Tool.from_function(
- get_more_tools,
- name="get_more_tools",
- description=(
- "Check for additional tools whenever your task might benefit from "
- "specialized capabilities - even if existing tools could work as a "
- "fallback."
- ),
- )
- server.add_tool(tool)
- write_to_log("Registered get_more_tools using server.add_tool()")
-
- except Exception as e:
- write_to_log(f"Error registering get_more_tools: {e}")
diff --git a/src/agentcat/modules/overrides/community_v3/middleware.py b/src/agentcat/modules/overrides/community_v3/middleware.py
deleted file mode 100644
index 62e22cf..0000000
--- a/src/agentcat/modules/overrides/community_v3/middleware.py
+++ /dev/null
@@ -1,499 +0,0 @@
-"""AgentCat Middleware for Community FastMCP v3.
-
-This module provides a middleware implementation that integrates AgentCat
-tracking capabilities with the FastMCP v3 middleware system.
-"""
-
-from __future__ import annotations
-
-import copy
-from collections.abc import Sequence
-from datetime import datetime, timezone
-from importlib.metadata import version
-from typing import TYPE_CHECKING, Any
-
-import mcp.types as mt
-
-from agentcat.modules import event_queue
-from agentcat.modules.exceptions import (
- capture_exception,
- clear_captured_error,
- get_captured_error,
- store_captured_error,
-)
-from agentcat.modules.identify import identify_session
-from agentcat.modules.internal import attach_event_metadata, mark_tool_tracked, register_tool
-from agentcat.modules.logging import write_to_log
-from agentcat.modules.request_extra import params_with_extra
-from agentcat.modules.session import (
- get_client_info_from_request_context,
- get_server_session_id,
-)
-from agentcat.types import EventType, AgentCatData, UnredactedEvent
-
-if TYPE_CHECKING:
- from fastmcp.server.middleware import CallNext, MiddlewareContext
- from fastmcp.tools.tool import Tool, ToolResult
-
-
-def _fastmcp_version() -> str:
- """Best-effort resolved fastmcp version for diagnostics; never throws."""
- try:
- return version("fastmcp")
- except Exception:
- return "unknown"
-
-
-class AgentCatMiddleware:
- """Middleware for AgentCat tracking in FastMCP v3.
-
- This middleware intercepts tool calls, list_tools, and initialize events
- to provide analytics tracking for AgentCat.
-
- Attributes:
- agentcat_data: The AgentCat tracking data configuration.
- server: The FastMCP server instance.
- """
-
- def __init__(self, agentcat_data: AgentCatData, server: Any) -> None:
- """Initialize the AgentCat middleware.
-
- Args:
- agentcat_data: AgentCat tracking configuration.
- server: The FastMCP v3 server instance.
- """
- self.agentcat_data = agentcat_data
- self.server = server
-
- async def __call__(
- self,
- context: MiddlewareContext[Any],
- call_next: CallNext[Any, Any],
- ) -> Any:
- """Main entry point that orchestrates the pipeline."""
- from functools import partial
-
- handler = call_next
-
- # Dispatch based on method
- method = context.method
- if method == "initialize":
- handler = partial(self.on_initialize, call_next=handler)
- elif method == "tools/call":
- handler = partial(self.on_call_tool, call_next=handler)
- elif method == "tools/list":
- handler = partial(self.on_list_tools, call_next=handler)
-
- return await handler(context)
-
- async def on_initialize(
- self,
- context: MiddlewareContext[mt.InitializeRequest],
- call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None],
- ) -> mt.InitializeResult | None:
- """Track initialize events and capture client info.
-
- Args:
- context: The middleware context containing the initialize request.
- call_next: Function to call the next handler in the chain.
-
- Returns:
- The initialize result from the next handler.
- """
- session_id = self._get_session_id()
- params = context.message.params
-
- # Extract client info from initialize params (MCP protocol provides clientInfo here)
- client_name, client_version = None, None
- if params and hasattr(params, "clientInfo") and params.clientInfo:
- client_info = params.clientInfo
- if hasattr(client_info, "name") and client_info.name:
- client_name = client_info.name
- if hasattr(client_info, "version") and client_info.version:
- client_version = client_info.version
-
- # Handle session identification
- # Note: Use self.server (FastMCP) not self.server._mcp_server because
- # tracking data is stored with the FastMCP server as the key for v3
- request_context = self._get_request_context(context)
- try:
- if not client_name:
- client_name, client_version = get_client_info_from_request_context(self.server, request_context)
- else:
- get_client_info_from_request_context(self.server, request_context)
- identity = identify_session(self.server, context.message, request_context)
- except Exception as e:
- identity = None
- write_to_log(f"Non-critical error in session handling: {e}")
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- params.model_dump() if params else None,
- request_context,
- context.fastmcp_context,
- ),
- event_type=EventType.MCP_INITIALIZE.value,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, self.agentcat_data, context.message, request_context)
-
- try:
- result = await call_next(context)
- event.response = result.model_dump() if result else None
- return result
- except Exception as e:
- event.is_error = True
- event.error = capture_exception(e)
- raise
- finally:
- self._publish_event(event, "initialize")
-
- async def on_call_tool(
- self,
- context: MiddlewareContext[mt.CallToolRequestParams],
- call_next: CallNext[mt.CallToolRequestParams, ToolResult],
- ) -> ToolResult:
- """Track tool call events and handle context parameter extraction.
-
- Args:
- context: The middleware context containing the tool call request.
- call_next: Function to call the next handler in the chain.
-
- Returns:
- The tool result from the next handler.
- """
- message = context.message
- tool_name = message.name
- arguments = dict(message.arguments or {})
- session_id = self._get_session_id()
-
- # Handle session identification
- # Note: Use self.server (FastMCP) not self.server._mcp_server because
- # tracking data is stored with the FastMCP server as the key for v3
- request_context = self._get_request_context(context)
- try:
- client_name, client_version = get_client_info_from_request_context(self.server, request_context)
- identity = identify_session(self.server, context.message, request_context)
- except Exception as e:
- client_name, client_version = None, None
- identity = None
- write_to_log(f"Non-critical error in session handling: {e}")
-
- register_tool(self.server, tool_name)
- mark_tool_tracked(self.server, tool_name)
-
- # Extract user intent and determine if we should remove context from arguments
- user_intent = None
- should_remove_context = (
- self.agentcat_data.options.enable_tool_call_context
- and tool_name != "get_more_tools"
- )
-
- if tool_name == "get_more_tools":
- user_intent = arguments.get("context")
- elif should_remove_context:
- user_intent = arguments.pop("context", None)
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- {"name": tool_name, "arguments": arguments},
- request_context,
- context.fastmcp_context,
- ),
- event_type=EventType.MCP_TOOLS_CALL.value,
- resource_name=tool_name,
- user_intent=user_intent,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, self.agentcat_data, context.message, request_context)
-
- # Create modified context without context parameter if needed
- call_context = context
- if should_remove_context and "context" in (message.arguments or {}):
- modified_args = {
- k: v for k, v in (message.arguments or {}).items() if k != "context"
- }
- modified_message = mt.CallToolRequestParams(
- name=tool_name,
- arguments=modified_args or None,
- )
- call_context = context.copy(message=modified_message)
-
- clear_captured_error()
-
- try:
- result = await call_next(call_context)
-
- if hasattr(result, "is_error") and result.is_error:
- event.is_error = True
- captured = get_captured_error()
- event.error = capture_exception(captured if captured else result)
- else:
- event.is_error = False
-
- event.response = self._serialize_result(result)
- return result
-
- except Exception as e:
- write_to_log(f"Error in on_call_tool: {e}")
- event.is_error = True
- store_captured_error(e)
- event.error = capture_exception(e)
- raise
-
- finally:
- self._publish_event(event, "tool call")
-
- async def on_list_tools(
- self,
- context: MiddlewareContext[mt.ListToolsRequest],
- call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
- ) -> Sequence[Tool]:
- """Inject context parameter and track list_tools events.
-
- Args:
- context: The middleware context containing the list tools request.
- call_next: Function to call the next handler in the chain.
-
- Returns:
- The list of tools, potentially modified with context parameter.
- """
- session_id = self._get_session_id()
-
- # Handle session identification
- # Note: Use self.server (FastMCP) not self.server._mcp_server because
- # tracking data is stored with the FastMCP server as the key for v3
- request_context = self._get_request_context(context)
- try:
- client_name, client_version = get_client_info_from_request_context(self.server, request_context)
- identity = identify_session(self.server, context.message, request_context)
- except Exception as e:
- client_name, client_version = None, None
- identity = None
- write_to_log(f"Non-critical error in session handling: {e}")
-
- params = getattr(context.message, "params", None)
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- params.model_dump() if params else None,
- request_context,
- context.fastmcp_context,
- ),
- event_type=EventType.MCP_TOOLS_LIST.value,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, self.agentcat_data, context.message, request_context)
-
- try:
- tools = list(await call_next(context))
-
- for tool in tools:
- register_tool(self.server, tool.name)
- mark_tool_tracked(self.server, tool.name)
-
- if self.agentcat_data.options.enable_tool_call_context:
- tools = self._inject_context_into_tools(tools)
-
- event.response = {"tools": [self._tool_to_dict(t) for t in tools]}
- return tools
-
- except Exception as e:
- event.is_error = True
- event.error = capture_exception(e)
- raise
-
- finally:
- self._publish_event(event, "list_tools")
-
- def _get_session_id(self) -> str:
- """Get the session ID for tracking.
-
- Returns:
- The session ID string.
- """
- try:
- return get_server_session_id(self.server)
- except Exception as e:
- write_to_log(f"Error getting session ID: {e}")
- return self.agentcat_data.session_id
-
- def _get_request_context(self, context: MiddlewareContext[Any]) -> Any:
- """Get the MCP request context from middleware context.
-
- Args:
- context: The middleware context.
-
- Returns:
- The MCP request context, or None if not available.
- """
- if context.fastmcp_context:
- return context.fastmcp_context.request_context
- return None
-
- def _publish_event(self, event: UnredactedEvent, event_name: str) -> None:
- """Publish an event if tracing is enabled.
-
- Args:
- event: The event to publish.
- event_name: Human-readable name for error logging.
- """
- if not self.agentcat_data.options.enable_tracing:
- return
-
- try:
- event_queue.publish_event(self.server, event)
- except Exception as e:
- write_to_log(f"Error publishing {event_name} event: {e}")
-
- def _serialize_result(self, result: Any) -> dict[str, Any]:
- """Serialize a tool result to a dictionary.
-
- Args:
- result: The result to serialize.
-
- Returns:
- Dictionary representation of the result.
- """
- if hasattr(result, "model_dump"):
- return result.model_dump()
- if isinstance(result, dict):
- return result
- return {"content": str(result)}
-
- def _inject_context_into_tools(self, tools: list[Tool]) -> list[Tool]:
- """Inject context parameter into tool schemas.
-
- Args:
- tools: List of tools to modify.
-
- Returns:
- List of tools with context parameter injected.
- """
- context_description = self.agentcat_data.options.custom_context_description
- modified_tools = []
-
- for tool in tools:
- if tool.name == "get_more_tools":
- modified_tools.append(tool)
- continue
-
- # Only the parameters schema (a plain dict) is mutated below, so copy
- # just that — never the whole Tool. Deep-copying the Tool drags in
- # non-picklable fields it may hold (e.g. an httpx.AsyncClient with a
- # threading.RLock on OpenAPI-generated tools), which raised
- # "cannot pickle '_thread.RLock' object" and silently dropped context
- # injection on every tools/list.
- try:
- tool_copy = tool.model_copy(
- update={"parameters": copy.deepcopy(getattr(tool, "parameters", None))}
- )
- except Exception as e:
- write_to_log(
- f"Error copying tool {tool.name} (fastmcp {_fastmcp_version()}): {e}"
- )
- modified_tools.append(tool)
- continue
-
- params = self._ensure_parameters_schema(tool_copy)
- self._add_context_property(params, context_description)
- self._add_to_required(params, "context")
-
- modified_tools.append(tool_copy)
-
- return modified_tools
-
- def _ensure_parameters_schema(self, tool: Tool) -> dict[str, Any]:
- """Ensure tool has a valid parameters schema and return it.
-
- Args:
- tool: The tool to check/modify.
-
- Returns:
- The parameters dict (created if necessary).
- """
- if not hasattr(tool, "parameters") or tool.parameters is None:
- tool.parameters = {"type": "object", "properties": {}, "required": []}
-
- params = tool.parameters
- if "properties" not in params:
- params["properties"] = {}
-
- return params
-
- def _add_context_property(
- self, params: dict[str, Any], description: str
- ) -> None:
- """Add or update the context property in a parameters schema.
-
- Args:
- params: The parameters dict to modify.
- description: The description for the context property.
- """
- properties = params["properties"]
-
- if "context" not in properties:
- properties["context"] = {"type": "string", "description": description}
- elif not properties["context"].get("description"):
- properties["context"]["description"] = description
-
- def _add_to_required(self, params: dict[str, Any], field: str) -> None:
- """Add a field to the required array if not already present.
-
- Args:
- params: The parameters dict to modify.
- field: The field name to add to required.
- """
- if "required" not in params:
- params["required"] = []
-
- required = params["required"]
- if isinstance(required, list) and field not in required:
- required.append(field)
-
- def _tool_to_dict(self, tool: Tool) -> dict[str, Any]:
- """Convert a tool to a dictionary for event response.
-
- Args:
- tool: The tool to convert.
-
- Returns:
- Dictionary representation of the tool.
- """
- try:
- # Prefer the canonical MCP shape. A FastMCP Tool.model_dump() includes
- # SDK-internal fields that are not JSON-serializable — a callable ``fn``
- # (FunctionTool) and a ``tags`` set — which would break event
- # serialization; to_mcp_tool() yields the clean wire representation
- # (name/description/inputSchema/...).
- if hasattr(tool, "to_mcp_tool"):
- mcp_tool = tool.to_mcp_tool()
- if hasattr(mcp_tool, "model_dump"):
- return mcp_tool.model_dump(mode="json")
- if hasattr(tool, "model_dump"):
- return tool.model_dump(mode="json")
- return {
- "name": getattr(tool, "name", "unknown"),
- "description": getattr(tool, "description", ""),
- }
- except Exception as e:
- write_to_log(f"Error converting tool to dict: {e}")
- return {"name": getattr(tool, "name", "unknown")}
diff --git a/src/agentcat/modules/overrides/mcp_server.py b/src/agentcat/modules/overrides/mcp_server.py
deleted file mode 100644
index 12e632c..0000000
--- a/src/agentcat/modules/overrides/mcp_server.py
+++ /dev/null
@@ -1,329 +0,0 @@
-from datetime import datetime, timezone
-from typing import Any, Optional
-
-from mcp import ListToolsResult, ServerResult, Tool
-from mcp.server import Server
-from mcp.types import CallToolRequest, ListToolsRequest, InitializeRequest
-from mcp.shared.context import RequestContext
-
-from agentcat.modules import event_queue
-from agentcat.modules.compatibility import is_mcp_error_response
-from agentcat.modules.identify import identify_session
-from agentcat.modules.internal import attach_event_metadata
-from agentcat.modules.logging import write_to_log
-from agentcat.modules.request_extra import params_with_extra
-from agentcat.modules.tools import handle_report_missing
-
-from ...types import EventType, AgentCatData, UnredactedEvent
-from ..session import get_client_info_from_request_context, get_server_session_id
-
-
-def safe_request_context(server: Server) -> Optional[RequestContext]:
- """Safely extract request context, handling missing attributes."""
- try:
- request_context = server.request_context
- except Exception:
- request_context = None
-
- return request_context
-
-
-"""Tool management and interception for AgentCat."""
-
-
-def override_lowlevel_mcp_server(server: Server, data: AgentCatData) -> None:
- """Set up tool list and call handlers for FastMCP."""
- # Store original request handlers - we only need to intercept at the low-level
- # TODO: original_call_tool_handler = server.request_handlers.get(InitializeRequest)
- original_initialize_handler = server.request_handlers.get(InitializeRequest)
- original_call_tool_handler = server.request_handlers.get(CallToolRequest)
- original_list_tools_handler = server.request_handlers.get(ListToolsRequest)
-
- async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult:
- """Intercept initialize requests to add AgentCat data to the request context."""
- session_id = get_server_session_id(server)
- request_context = safe_request_context(server)
- identity = identify_session(server, request, request_context)
-
- # Extract clientInfo from InitializeRequest params (MCP protocol provides it here)
- client_name, client_version = None, None
- if request.params and hasattr(request.params, 'clientInfo') and request.params.clientInfo:
- client_name = request.params.clientInfo.name
- client_version = getattr(request.params.clientInfo, 'version', None)
- if not client_name:
- client_name, client_version = get_client_info_from_request_context(server, request_context)
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- request.params.model_dump() if request.params else None,
- request_context,
- ),
- event_type=EventType.MCP_INITIALIZE.value,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, data, request, request_context)
-
- # Call the original handler
- result = await original_initialize_handler(request)
-
- # Record the event
- event.response = result.model_dump() if result else None
- event_queue.publish_event(server, event)
- return result
-
- async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
- """Intercept list_tools requests to add AgentCat tools and modify existing ones."""
- session_id = get_server_session_id(server)
- request_context = safe_request_context(server)
- client_name, client_version = get_client_info_from_request_context(server, request_context)
- identity = identify_session(server, request, request_context)
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- request.params.model_dump() if request and request.params else None,
- request_context,
- ),
- event_type=EventType.MCP_TOOLS_LIST.value,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, data, request, request_context)
-
- # Call the original handler to get the tools
- original_result = await original_list_tools_handler(request)
- if (
- not original_result
- or not hasattr(original_result, "root")
- or not hasattr(original_result.root, "tools")
- ):
- return original_result
- tools_list = original_result.root.tools
-
- # Add report_missing tool if enabled
- if data.options.enable_report_missing:
- get_more_tools = Tool(
- name="get_more_tools",
- description="Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.",
- inputSchema={
- "type": "object",
- "properties": {
- "context": {
- "type": "string",
- "description": "A description of your goal and what kind of tool would help accomplish it.",
- }
- },
- "required": ["context"],
- },
- )
- tools_list.append(get_more_tools)
-
- # Add context parameters to existing tools if enabled
- if data.options.enable_tool_call_context:
- for tool in tools_list:
- if tool.name != "get_more_tools": # Don't modify our own tool
- if not tool.inputSchema:
- tool.inputSchema = {
- "type": "object",
- "properties": {},
- "required": [],
- }
-
- # Add context property if it doesn't exist
- if "context" not in tool.inputSchema.get("properties", {}):
- if "properties" not in tool.inputSchema:
- tool.inputSchema["properties"] = {}
-
- tool.inputSchema["properties"]["context"] = {
- "type": "string",
- "description": data.options.custom_context_description,
- }
-
- # Add context to required array if it exists
- if isinstance(tool.inputSchema.get("required"), list):
- if "context" not in tool.inputSchema["required"]:
- tool.inputSchema["required"].append("context")
- else:
- tool.inputSchema["required"] = ["context"]
-
- result = ServerResult(ListToolsResult(tools=tools_list))
- event.response = result.model_dump() if result else None
- event_queue.publish_event(server, event)
- return result
-
- async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
- """Intercept call_tool requests to add AgentCat tracking and handle special tools."""
- tool_name = request.params.name
- arguments = request.params.arguments or {}
- session_id = get_server_session_id(server)
- request_context = safe_request_context(server)
- client_name, client_version = get_client_info_from_request_context(server, request_context)
- identity = identify_session(server, request, request_context)
-
- write_to_log(
- f"Intercepted call to tool '{tool_name}' with arguments: {arguments} and request context: {request_context}"
- )
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- request.params.model_dump() if request.params else None,
- request_context,
- ),
- event_type=EventType.MCP_TOOLS_CALL.value,
- resource_name=tool_name,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, data, request, request_context)
-
- # Extract user intent from context (but don't pop yet - we need it for the event)
- if data.options.enable_tool_call_context and tool_name != "get_more_tools":
- event.user_intent = arguments.get("context", None)
- elif tool_name == "get_more_tools":
- # For get_more_tools, context is the actual parameter
- event.user_intent = arguments.get("context", None)
-
- # Handle report_missing tool directly
- if tool_name == "get_more_tools":
- result = await handle_report_missing(arguments)
- event.response = result.model_dump() if result else None
- event_queue.publish_event(server, event)
- return result
-
- # Now pop context from arguments before calling the original handler
- if data.options.enable_tool_call_context:
- arguments.pop("context", None)
- # Log warning if context is missing and tool is not report_missing
- if event.user_intent is None and tool_name != "get_more_tools":
- write_to_log(
- f"Tool '{tool_name}' called without context. agentcat.track() might have been called BEFORE tool initialization."
- )
-
- # If tracing is enabled, wrap the call with timing and logging
- if data.options.enable_tracing:
- try:
- # Call the original handler
- result = await original_call_tool_handler(request)
- is_error, error_message = is_mcp_error_response(result)
- event.is_error = is_error
- event.error = {"message": error_message} if is_error else None
- # Record the trace using existing infrastructure
- event.response = result.model_dump() if result else None
- event_queue.publish_event(server, event)
- return result
-
- except Exception as e:
- # Record the error trace
- event.is_error = True
- event.error = {"message": str(e)}
- event_queue.publish_event(server, event)
- raise
- else:
- # No tracing, just call the original handler
- return await original_call_tool_handler(request)
-
- server.request_handlers[CallToolRequest] = wrapped_call_tool_handler
- server.request_handlers[ListToolsRequest] = wrapped_list_tools_handler
- server.request_handlers[InitializeRequest] = wrapped_initialize_handler
-
-
-def override_lowlevel_mcp_server_minimal(server: Server, data: AgentCatData) -> None:
- """Set up minimal handlers for FastMCP servers (non-tool events only).
-
- This is used for FastMCP servers where tool tracking is handled by monkey-patching.
- We only need to track initialize and other non-tool events.
- """
- # Store original request handlers
- original_initialize_handler = server.request_handlers.get(InitializeRequest)
- original_list_tools_handler = server.request_handlers.get(ListToolsRequest)
-
- async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult:
- """Intercept initialize requests to add AgentCat data to the request context."""
- session_id = get_server_session_id(server)
- request_context = safe_request_context(server)
- try:
- identity = identify_session(server, request, request_context)
- except Exception as e:
- identity = None
- write_to_log(f"Ran into an error in session identification, no identity could be determined: {e}")
-
- client_name, client_version = None, None
- if request.params and hasattr(request.params, 'clientInfo') and request.params.clientInfo:
- client_name = request.params.clientInfo.name
- client_version = getattr(request.params.clientInfo, 'version', None)
- if not client_name:
- client_name, client_version = get_client_info_from_request_context(server, request_context)
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- request.params.model_dump() if request.params else None,
- request_context,
- ),
- event_type=EventType.MCP_INITIALIZE.value,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, data, request, request_context)
-
- # Call the original handler
- result = await original_initialize_handler(request)
-
- # Record the event
- event.response = result.model_dump() if result else None
- event_queue.publish_event(server, event)
- return result
-
- async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
- """Intercept list_tools requests to track the event (tool modifications handled by monkey-patch)."""
- session_id = get_server_session_id(server)
- request_context = safe_request_context(server)
- client_name, client_version = get_client_info_from_request_context(server, request_context)
- identity = identify_session(server, request, request_context)
-
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- request.params.model_dump() if request and request.params else None,
- request_context,
- ),
- event_type=EventType.MCP_TOOLS_LIST.value,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, data, request, request_context)
-
- # Call the original handler - tool modifications are handled by monkey-patch
- result = await original_list_tools_handler(request)
-
- # Record the event
- event.response = result.model_dump() if result else None
- event_queue.publish_event(server, event)
- return result
-
- # Only override initialize and list_tools for event tracking
- # Tool call tracking is handled by monkey-patching for FastMCP
- server.request_handlers[InitializeRequest] = wrapped_initialize_handler
- server.request_handlers[ListToolsRequest] = wrapped_list_tools_handler
diff --git a/src/agentcat/modules/overrides/official/monkey_patch.py b/src/agentcat/modules/overrides/official/monkey_patch.py
deleted file mode 100644
index 28f3d6b..0000000
--- a/src/agentcat/modules/overrides/official/monkey_patch.py
+++ /dev/null
@@ -1,583 +0,0 @@
-"""Monkey-patching implementation for dynamic tool tracking.
-
-This module patches MCP server methods to intercept tool registration and execution,
-enabling AgentCat to track tools regardless of when they are registered.
-"""
-
-import inspect
-from collections.abc import Callable
-from datetime import datetime, timezone
-from typing import Annotated, Any, List
-
-from pydantic import Field
-
-from agentcat.modules import event_queue
-from agentcat.modules.compatibility import is_official_fastmcp_server, is_mcp_error_response
-from agentcat.modules.exceptions import (
- capture_exception,
- clear_captured_error,
- get_captured_error,
- store_captured_error,
-)
-from agentcat.modules.internal import (
- attach_event_metadata,
- get_original_method,
- get_server_tracking_data,
- is_tool_tracked,
- mark_tool_tracked,
- register_tool,
- store_original_method,
-)
-from agentcat.modules.logging import write_to_log
-from agentcat.modules.request_extra import params_with_extra
-from agentcat.modules.session import (
- get_client_info_from_request_context,
- get_server_session_id,
-)
-from agentcat.types import EventType, AgentCatData, UnredactedEvent
-
-from ..mcp_server import safe_request_context
-
-
-def get_current_agentcat_data(server: Any, fallback: AgentCatData) -> AgentCatData:
- """Get the current AgentCat data for a server."""
- data = get_server_tracking_data(server)
- return data if data else fallback
-
-
-def patch_fastmcp_tool_manager(server: Any, agentcat_data: AgentCatData) -> bool:
- """Monkey-patch FastMCP's ToolManager to intercept tool operations.
-
- Args:
- server: FastMCP server instance
- agentcat_data: AgentCat tracking data
-
- Returns:
- True if patching was successful, False otherwise
- """
- try:
- # Check if this is a FastMCP server (which now includes _tool_manager check)
- if not is_official_fastmcp_server(server):
- return False
-
- tool_manager = server._tool_manager
- data = get_server_tracking_data(server)
- if not data:
- return False
-
- # Add the get_more_tools tool if enabled
- if agentcat_data.options.enable_report_missing:
- # Create the get_more_tools function that returns CallToolResult
- async def get_more_tools(
- context: Annotated[
- str,
- Field(
- description="A description of your goal and what kind of tool would help accomplish it."
- ),
- ],
- ) -> List[Any]:
- """Check for additional tools whenever your task might benefit from specialized capabilities."""
- from agentcat.modules.tools import handle_report_missing
-
- result = await handle_report_missing({"context": context})
- # Return just the content list for FastMCP
- return result.content
-
- # Register it with the server
- # Use inspect to determine which parameters are supported
- try:
- if hasattr(server, 'add_tool'):
- sig = inspect.signature(server.add_tool)
- kwargs = {
- "name": "get_more_tools",
- "description": "Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.",
- }
- # Only add icons if the parameter exists
- if "icons" in sig.parameters:
- kwargs["icons"] = None
- server.add_tool(get_more_tools, **kwargs)
- else:
- # Fallback for older versions
- server.add_tool(
- get_more_tools,
- name="get_more_tools",
- description="Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.",
- )
- except Exception as e:
- write_to_log(f"Error registering get_more_tools: {e}")
- write_to_log("Added get_more_tools tool to FastMCP server")
-
- # First, capture any tools that were already registered
- if hasattr(tool_manager, "_tools"):
- for tool_name, _tool in tool_manager._tools.items():
- if not is_tool_tracked(server, tool_name):
- register_tool(server, tool_name)
- mark_tool_tracked(server, tool_name)
- write_to_log(f"Found existing FastMCP tool: {tool_name}")
-
- # Store original methods - use tool_manager ID to avoid conflicts
- # We need to store the original unpatched methods once per tool_manager
- tool_manager_id = id(tool_manager)
- method_key_prefix = f"fastmcp_{tool_manager_id}_"
-
- write_to_log(
- f"Patching FastMCP server {id(server)}, tool_manager {tool_manager_id}"
- )
-
- # Only store original methods if this tool_manager hasn't been seen before
- if get_original_method(f"{method_key_prefix}add_tool") is None:
- store_original_method(f"{method_key_prefix}add_tool", tool_manager.add_tool)
- store_original_method(
- f"{method_key_prefix}call_tool", tool_manager.call_tool
- )
- store_original_method(
- f"{method_key_prefix}list_tools", tool_manager.list_tools
- )
- write_to_log(f"Stored original methods for tool_manager {tool_manager_id}")
-
- # Get original methods for this tool_manager
- original_add_tool = get_original_method(f"{method_key_prefix}add_tool")
- original_call_tool = get_original_method(f"{method_key_prefix}call_tool")
- original_list_tools = get_original_method(f"{method_key_prefix}list_tools")
-
- # Safety check - if original methods don't exist, bail out
- if not original_add_tool or not original_call_tool or not original_list_tools:
- write_to_log(
- f"Original methods not found for tool_manager {tool_manager_id}, skipping patches"
- )
- return False
-
- # Patch add_tool to track new registrations
- def patched_add_tool(
- fn: Callable[..., Any],
- **kwargs
- ) -> Any:
- """Patched add_tool that tracks tool registration."""
- try:
- # Call original method first to get the actual tool object
- # Use callable check to avoid mypy error
- if not callable(original_add_tool):
- write_to_log("Warning: original_add_tool is not callable")
- return fn
-
- # Get the signature of the original method to filter kwargs
- try:
- sig = inspect.signature(original_add_tool)
- # Filter kwargs to only include parameters that exist in the original signature
- filtered_kwargs = {
- k: v for k, v in kwargs.items()
- if k in sig.parameters
- }
- except Exception as e:
- write_to_log(f"Could not inspect signature, passing all kwargs: {e}")
- filtered_kwargs = kwargs
-
- result = original_add_tool(fn, **filtered_kwargs)
-
- # Track the tool registration (wrapped in try-catch to never fail)
- try:
- tool_name = (
- result.name
- if hasattr(result, "name")
- else (kwargs.get("name") or fn.__name__)
- )
- register_tool(server, tool_name)
-
- # Get current data for this server
- current_data = get_current_agentcat_data(server, agentcat_data)
-
- # If AgentCat is already initialized, we need to wrap this tool
- if data.tracker_initialized and current_data.options.enable_tracing:
- write_to_log(
- f"Late-registered FastMCP tool detected: {tool_name}"
- )
- except Exception as e:
- write_to_log(f"Error tracking tool registration: {e}")
- # Continue with original result
-
- return result
- except Exception as e:
- write_to_log(f"Critical error in patched_add_tool, falling back: {e}")
- # If anything fails, try to call original method directly
- if callable(original_add_tool):
- try:
- # Try with filtered kwargs first
- sig = inspect.signature(original_add_tool)
- filtered_kwargs = {
- k: v for k, v in kwargs.items()
- if k in sig.parameters
- }
- return original_add_tool(fn, **filtered_kwargs)
- except:
- # Last attempt with no kwargs
- try:
- return original_add_tool(fn)
- except:
- pass
- return fn # Last resort fallback
-
- # Patch call_tool to ensure tracking and add context
- async def patched_call_tool(
- name: str,
- arguments: dict[str, Any],
- context: Any | None = None,
- **kwargs # Accept any additional parameters for version compatibility
- ) -> Any:
- """Patched call_tool that adds AgentCat tracking."""
- # Initialize variables for tracking
- event = None
- current_data = None
-
- try:
- # Try to get tracking data, but don't fail if we can't
- try:
- session_id = get_server_session_id(server._mcp_server)
- current_data = get_current_agentcat_data(server, agentcat_data)
- except Exception as e:
- write_to_log(f"Error getting tracking data: {e}")
- session_id = "unknown"
- current_data = agentcat_data
-
- # Handle session identification (non-critical)
- request_context = None
- mock_request = None
- try:
- request_context = safe_request_context(server._mcp_server)
- client_name, client_version = (None, None)
- if request_context is not None:
- client_name, client_version = get_client_info_from_request_context(
- server._mcp_server, request_context
- )
-
- # Call identify_session for custom identification
- from agentcat.modules.identify import identify_session
-
- # Create a mock request for identify_session
- mock_request = type(
- "MockCallToolRequest",
- (),
- {
- "params": type(
- "Params", (), {"name": name, "arguments": arguments}
- )()
- },
- )()
-
- identity = identify_session(server._mcp_server, mock_request, request_context)
- except Exception as e:
- client_name, client_version = None, None
- identity = None
- write_to_log(f"Non-critical error in session handling: {e}")
-
- # Extract user intent (non-critical)
- user_intent = None
- try:
- should_capture_intent = (
- name == "get_more_tools"
- or (current_data and current_data.options.enable_tool_call_context)
- )
- if should_capture_intent:
- user_intent = arguments.get("context", None)
- except Exception as e:
- write_to_log(f"Error extracting user intent: {e}")
-
- # Track the tool (non-critical)
- try:
- if not is_tool_tracked(server, name):
- register_tool(server, name)
- mark_tool_tracked(server, name)
- write_to_log(f"Dynamically tracking FastMCP tool: {name}")
- except Exception as e:
- write_to_log(f"Error tracking tool: {e}")
-
- # Create tracking event (non-critical)
- try:
- event = UnredactedEvent(
- session_id=session_id,
- timestamp=datetime.now(timezone.utc),
- parameters=params_with_extra(
- {"name": name, "arguments": arguments},
- request_context,
- ),
- event_type=EventType.MCP_TOOLS_CALL.value,
- resource_name=name,
- user_intent=user_intent,
- identify_actor_given_id=identity.user_id if identity else None,
- identify_actor_name=identity.user_name if identity else None,
- identify_data=identity.user_data if identity else None,
- client_name=client_name,
- client_version=client_version,
- )
- await attach_event_metadata(event, current_data, mock_request, request_context)
- except Exception as e:
- write_to_log(f"Error creating event: {e}")
- event = None
-
- # Prepare arguments (remove context if needed)
- args_for_tool = arguments.copy()
- try:
- if (
- current_data
- and current_data.options.enable_tool_call_context
- and name != "get_more_tools"
- ):
- args_for_tool.pop("context", None)
- except Exception as e:
- write_to_log(f"Error preparing arguments: {e}")
- args_for_tool = arguments # Use original if modification fails
-
- # Clear any previous captured error before execution
- clear_captured_error()
-
- # Call original method - THIS IS CRITICAL, must not fail
- if not callable(original_call_tool):
- write_to_log("Critical: original_call_tool is not callable")
- raise ValueError("Original call_tool method is not callable")
-
- # Wrap execution to preserve exceptions before MCP SDK processes them
- try:
- result = await original_call_tool(
- name, args_for_tool, context=context, **kwargs
- )
- except Exception as tool_exc:
- # Preserve original exception before MCP SDK converts it
- store_captured_error(tool_exc)
- raise # Re-raise so MCP SDK can handle it normally
-
- # Try to capture response in event (non-critical)
- if event:
- try:
- # Check if result indicates an error (CallToolResult with isError=True)
- is_error_result = False
- if hasattr(result, "model_dump"):
- is_error_result, error_message = is_mcp_error_response(result)
-
- if is_error_result:
- # MCP SDK converted an exception to CallToolResult
- event.is_error = True
-
- # Try to use preserved exception first (has full traceback)
- captured = get_captured_error()
- if captured:
- event.error = capture_exception(captured)
- else:
- # Fallback: extract from CallToolResult
- event.error = capture_exception(result)
-
- # Capture response data
- if isinstance(result, tuple):
- event.response = result[1] if len(result) > 1 else None
- elif hasattr(result, "model_dump"):
- event.response = result.model_dump()
- elif isinstance(result, dict):
- event.response = result
- elif isinstance(result, list):
- event.response = {
- "content": [
- item.model_dump()
- if hasattr(item, "model_dump")
- else item
- for item in result
- ]
- }
- else:
- event.response = {"value": result}
- except Exception as e:
- write_to_log(f"Error capturing response: {e}")
-
- return result
-
- except Exception as e:
- # Log the error
- write_to_log(f"Error in patched_call_tool: {e}")
-
- # Try to mark event as error if it exists
- if event:
- try:
- event.is_error = True
- # Use full exception capture with stack trace
- event.error = capture_exception(e)
- except Exception as capture_err:
- # Fallback to simple error if capture fails
- write_to_log(f"Error capturing exception: {capture_err}")
- try:
- event.error = {
- "message": str(e),
- "type": type(e).__name__,
- "platform": "python",
- }
- except:
- pass
-
- # Re-raise to preserve original error behavior
- raise
-
- finally:
- # Try to publish event (non-critical)
- if event and current_data:
- try:
- if current_data.options.enable_tracing:
- event_queue.publish_event(server._mcp_server, event)
- except Exception as e:
- write_to_log(f"Error publishing event: {e}")
- # Don't re-raise, let the tool result be returned
-
- # Patch list_tools to add AgentCat tools and context
- def patched_list_tools() -> List[Any]:
- """Patched list_tools that adds AgentCat modifications."""
- try:
- # Get current data for this server
- current_data = get_current_agentcat_data(server, agentcat_data)
-
- # Get original tools with safety check
- if not callable(original_list_tools):
- write_to_log("Warning: original_list_tools is not callable")
- return []
- tools = original_list_tools()
-
- # Track all tools (non-critical)
- try:
- for tool in tools:
- if hasattr(tool, "name") and not is_tool_tracked(
- server, tool.name
- ):
- register_tool(server, tool.name)
- mark_tool_tracked(server, tool.name)
- except Exception as e:
- write_to_log(f"Error tracking tools in list_tools: {e}")
-
- # Add report_missing tool if enabled (non-critical)
- try:
- if current_data.options.enable_report_missing:
- # Check if already added
- if not any(
- hasattr(t, "name") and t.name == "get_more_tools"
- for t in tools
- ):
- from mcp.server.fastmcp.tools.base import (
- Tool as FastMCPTool,
- )
-
- # Create a function for get_more_tools
- async def get_more_tools_fn(context: str) -> Any:
- """Check for additional tools whenever your task might benefit from specialized capabilities."""
- from agentcat.modules.tools import handle_report_missing
-
- return await handle_report_missing({"context": context})
-
- # Create the tool from the function
- get_more_tools = FastMCPTool.from_function(
- get_more_tools_fn,
- name="get_more_tools",
- description="Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.",
- )
- tools.append(get_more_tools)
- except Exception as e:
- write_to_log(f"Error adding get_more_tools: {e}")
-
- # Add context parameter to tools if enabled (non-critical)
- try:
- if current_data.options.enable_tool_call_context:
- for tool in tools:
- if hasattr(tool, "name") and tool.name != "get_more_tools":
- if not hasattr(tool, "parameters"):
- tool.parameters = {
- "type": "object",
- "properties": {},
- "required": [],
- }
- elif not tool.parameters:
- tool.parameters = {
- "type": "object",
- "properties": {},
- "required": [],
- }
-
- # Add context property if not present
- if "context" not in tool.parameters.get(
- "properties", {}
- ):
- if "properties" not in tool.parameters:
- tool.parameters["properties"] = {}
-
- tool.parameters["properties"]["context"] = {
- "type": "string",
- "description": current_data.options.custom_context_description,
- }
-
- # Add to required array
- if isinstance(
- tool.parameters.get("required"), list
- ):
- if "context" not in tool.parameters["required"]:
- tool.parameters["required"].append(
- "context"
- )
- else:
- tool.parameters["required"] = ["context"]
- except Exception as e:
- write_to_log(f"Error adding context to tools: {e}")
-
- return list(tools) # Ensure we return a list
- except Exception as e:
- write_to_log(
- f"Critical error in patched_list_tools, falling back to original: {e}"
- )
- # If anything fails, try to call original method
- if callable(original_list_tools):
- try:
- result = original_list_tools()
- return result if isinstance(result, list) else []
- except:
- pass
- return [] # Last resort
-
- # Apply patches directly (they capture the correct context via closure)
- write_to_log(f"Applying patches to tool_manager {id(tool_manager)}")
- write_to_log(f"Before patch - call_tool: {tool_manager.call_tool}")
-
- tool_manager.add_tool = patched_add_tool
- tool_manager.call_tool = patched_call_tool
- tool_manager.list_tools = patched_list_tools
-
- write_to_log(f"After patch - call_tool: {tool_manager.call_tool}")
-
- write_to_log(
- f"Successfully monkey-patched FastMCP ToolManager for server {id(server)}"
- )
- return True
-
- except Exception as e:
- write_to_log(f"Failed to patch FastMCP ToolManager: {e}")
- return False
-
-
-def apply_official_fastmcp_patches(server: Any, agentcat_data: AgentCatData) -> bool:
- """Apply monkey patches for FastMCP servers only.
-
- Args:
- server: FastMCP server instance
- agentcat_data: AgentCat tracking data
-
- Returns:
- True if patching was successful
- """
- # The AgentCat data is already stored by the caller
- # Just verify we can get it
- data = get_server_tracking_data(server)
- if not data:
- write_to_log(f"Warning: AgentCat data not found for server {id(server)}")
- return False
-
- # Only patch FastMCP servers
- if is_official_fastmcp_server(server):
- if patch_fastmcp_tool_manager(server, agentcat_data):
- write_to_log(
- f"Monkey patches applied successfully to FastMCP server {id(server)}"
- )
- return True
-
- write_to_log(
- f"Server {id(server)} is not a FastMCP server, skipping monkey patches"
- )
- return False
diff --git a/src/agentcat/modules/redaction.py b/src/agentcat/modules/redaction.py
index 58b0dca..44d362c 100644
--- a/src/agentcat/modules/redaction.py
+++ b/src/agentcat/modules/redaction.py
@@ -2,6 +2,8 @@
from typing import Any, TYPE_CHECKING, Callable, Set
+from agentcat.modules.hooks import drive_hook_result
+
if TYPE_CHECKING:
from agentcat.types import Event, UnredactedEvent
@@ -87,12 +89,48 @@ def redact_strings_in_object(
return obj
+def _sync_redactor(redact_fn: Callable[[str], Any]) -> Callable[[str], Any]:
+ """A synchronous view of the customer's hook.
+
+ `RedactionFunction` permits an async hook, and the publish path is a
+ worker THREAD with no event loop of its own — so an awaitable result has
+ to be driven to completion here. Left alone it would be assigned straight
+ into the payload and every "redacted" string would reach the wire as
+ ``, which is worse than not redacting because it
+ looks like it worked. One loop per string is not cheap; correctness on a
+ security control wins, and this runs off the request's hot path. A hook
+ that cannot be driven raises, and the queue drops the event rather than
+ publishing it unredacted.
+
+ The driving itself lives in `modules/hooks.py`, which is where all five
+ customer hooks get their answer to "sync or async" — this is the one that
+ cannot simply await, not a different contract.
+ """
+
+ def redact(value: str) -> Any:
+ return drive_hook_result(redact_fn(value), "redact_sensitive_information")
+
+ return redact
+
+
def redact_event(event: "UnredactedEvent", redact_fn: Callable[[str], str]) -> "Event":
"""
Applies the customer's redaction function to all string fields in an Event object.
This is the main entry point for redacting sensitive information from events
before they are sent to the analytics service.
+ `redact_strings_in_object` walks `str` / `list` / `dict` and returns
+ anything else untouched — so handing it the pydantic event itself, which is
+ what the publish path holds, returned the event unchanged and made the
+ documented `redact_sensitive_information` hook a no-op on every real event.
+ The model is dumped to a plain dict, redacted, and copied back over the
+ original: `model_copy` rather than a rebuild, so a customer's redaction can
+ never fail model validation, and fields the walk drops (it skips `None`)
+ keep the values they already had.
+
+ `redaction_fn` itself is excluded — it is machinery, not event data, and
+ the customer's hook must not be handed its own function object.
+
Args:
event: The event to redact
redact_fn: The customer's redaction function
@@ -100,4 +138,16 @@ def redact_event(event: "UnredactedEvent", redact_fn: Callable[[str], str]) -> "
Returns:
A new event object with all strings redacted
"""
- return redact_strings_in_object(event, redact_fn, "", False)
+ redact = _sync_redactor(redact_fn)
+ # Duck-typed on `model_dump` rather than `isinstance(event, BaseModel)`:
+ # this module stays free of a pydantic import, and a plain mapping — what
+ # the unit tests and any caller holding a dict pass — has no such method.
+ dump = getattr(event, "model_dump", None)
+ if not callable(dump):
+ plain: Event = redact_strings_in_object(event, redact, "", False)
+ return plain
+ dumped = dump(exclude={"redaction_fn"}, warnings=False)
+ redacted: Event = event.model_copy(
+ update=redact_strings_in_object(dumped, redact, "", False)
+ )
+ return redacted
diff --git a/src/agentcat/modules/request_extra.py b/src/agentcat/modules/request_extra.py
index e35387e..71a2ee8 100644
--- a/src/agentcat/modules/request_extra.py
+++ b/src/agentcat/modules/request_extra.py
@@ -183,6 +183,21 @@ def extract_request_extra(
return extra
+def extra_from_request_context(
+ request_context: Any,
+ fastmcp_context: Any | None = None,
+) -> dict[str, Any]:
+ """`{"extra": {...}}` ready to merge into an event's `parameters`, or `{}`.
+
+ The v2 adapters build `parameters` as `{"arguments": raw, **extra_params}`
+ rather than dumping the whole request, so this is the shape they need. The
+ key is omitted entirely when there is nothing to report (stdio), matching
+ `params_with_extra` and the TypeScript SDK.
+ """
+ extra = extract_request_extra(request_context, fastmcp_context)
+ return {"extra": extra} if extra else {}
+
+
def params_with_extra(
params_dump: dict | None,
request_context: Any,
diff --git a/src/agentcat/modules/session.py b/src/agentcat/modules/session.py
deleted file mode 100644
index f6d2603..0000000
--- a/src/agentcat/modules/session.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""Session management for AgentCat."""
-
-import re
-import sys
-from datetime import datetime, timedelta, timezone
-
-from mcp.shared.context import RequestContext
-from mcp.server import Server
-
-from agentcat.modules.constants import INACTIVITY_TIMEOUT_IN_MINUTES, SESSION_ID_PREFIX
-from agentcat.modules.internal import get_server_tracking_data, set_server_tracking_data
-from agentcat.modules.logging import write_to_log
-
-from ..types import AgentCatData, SessionInfo
-from ..utils import generate_prefixed_ksuid
-
-
-def new_session_id() -> str:
- """Generate a new session ID."""
- return generate_prefixed_ksuid(SESSION_ID_PREFIX)
-
-
-def get_agentcat_version() -> str | None:
- """Get the current AgentCat SDK version."""
- try:
- import importlib.metadata
-
- return importlib.metadata.version("agentcat")
- except Exception:
- return None
-
-
-def get_headers_from_request_context(
- request_context: RequestContext,
-) -> dict[str, str] | None:
- """Safely extract HTTP headers from a request context.
-
- Args:
- request_context: The request context that may contain a Starlette Request object
-
- Returns:
- A dictionary of headers if available, None otherwise
- """
- if request_context is None:
- return None
-
- try:
- # Check if the context has a request object with headers
- if hasattr(request_context, "request") and request_context.request:
- request = request_context.request
- if hasattr(request, "headers"):
- return dict(request.headers)
- except Exception:
- pass
-
- return None
-
-
-def get_client_info_from_request_context(
- server: Server, request_context: RequestContext | None
-) -> tuple[str | None, str | None]:
- """Extract client information from request context or HTTP headers.
-
- Returns (client_name, client_version). In stateless mode, extracts per-request
- without caching. In stateful mode, caches on shared session_info.
-
- This function is designed to be resilient and never fail - any error is logged
- but won't affect the server operation.
- """
- # Handle None request_context (e.g., in stateless HTTP mode outside handlers)
- if request_context is None:
- write_to_log("Request context is None, skipping client info extraction")
- return (None, None)
-
- try:
- data = get_server_tracking_data(server)
- if not data:
- return (None, None)
-
- client_name: str | None = None
- client_version: str | None = None
-
- # In stateful mode, return cached values if already set
- if not data.is_stateless and data.session_info.client_name and data.session_info.client_version:
- return (data.session_info.client_name, data.session_info.client_version)
-
- try:
- # Try to get from MCP session (stateful mode)
- if hasattr(request_context, "session") and request_context.session:
- client_info = request_context.session.client_params.clientInfo
- if client_info:
- client_name = client_info.name
- client_version = client_info.version
- if not data.is_stateless:
- data.session_info.client_name = client_name
- data.session_info.client_version = client_version
- set_server_tracking_data(server, data)
- return (client_name, client_version)
- except (AttributeError, TypeError):
- # This is expected in stateless mode, just continue
- pass
- except Exception as e:
- write_to_log(f"Error extracting client info from session: {e}")
-
- # Fallback: Try to extract from HTTP headers (stateless mode)
- try:
- headers = get_headers_from_request_context(request_context)
- if headers:
- # Parse User-Agent header (format: "ClientName/Version ...")
- user_agent = headers.get("user-agent", "")
- if user_agent:
- match = re.match(r"^([^/]+)/([^\s]+)", user_agent)
- if match:
- client_name = match.group(1)
- client_version = match.group(2)
- else:
- # No neat match, use the whole string as client_name
- client_name = user_agent
-
- # Custom MCP headers override User-Agent if present
- if headers.get("x-mcp-client-name"):
- client_name = headers.get("x-mcp-client-name")
- if headers.get("x-mcp-client-version"):
- client_version = headers.get("x-mcp-client-version")
-
- if not data.is_stateless and (client_name or client_version):
- data.session_info.client_name = client_name
- data.session_info.client_version = client_version
- set_server_tracking_data(server, data)
-
- if client_name or client_version:
- write_to_log(
- f"Extracted client info from headers: {client_name} v{client_version}"
- )
- except Exception as e:
- write_to_log(f"Error extracting client info from headers: {e}")
- # Continue without client info
-
- return (client_name, client_version)
- except Exception as e:
- # Catch-all for any unexpected errors - log but never fail
- write_to_log(f"Unexpected error in get_client_info_from_request_context: {e}")
- return (None, None)
-
-
-def get_session_info(server: Server, data: AgentCatData | None = None) -> SessionInfo:
- """Get session information for the current MCP session."""
- session_info = SessionInfo(
- ip_address=None, # grab from django
- sdk_language=f"Python {sys.version_info.major}.{sys.version_info.minor}",
- agentcat_version=get_agentcat_version(),
- server_name=server.name if hasattr(server, "name") else None,
- server_version=server.version if hasattr(server, "version") else None,
- client_name=data.session_info.client_name
- if data and data.session_info and not data.is_stateless
- else None,
- client_version=data.session_info.client_version
- if data and data.session_info and not data.is_stateless
- else None,
- identify_actor_given_id=None,
- identify_actor_name=None,
- identify_data=None,
- )
-
- if not data:
- return session_info
-
- data.session_info = session_info
- set_server_tracking_data(server, data) # Store updated data
- return data.session_info
-
-
-def set_last_activity(server: Server) -> None:
- data = get_server_tracking_data(server)
-
- if not data:
- raise Exception("AgentCat data not initialized for this server")
-
- data.last_activity = datetime.now(timezone.utc)
- set_server_tracking_data(server, data)
-
-
-def get_server_session_id(server: Server) -> str | None:
- data = get_server_tracking_data(server)
-
- if not data:
- raise Exception("AgentCat data not initialized for this server")
-
- if data.is_stateless:
- return None
-
- now = datetime.now(timezone.utc)
- timeout = timedelta(minutes=INACTIVITY_TIMEOUT_IN_MINUTES)
- # If last activity timed out
- if now - data.last_activity > timeout:
- data.session_id = new_session_id()
- set_server_tracking_data(server, data)
- set_last_activity(server)
-
- return data.session_id
diff --git a/src/agentcat/modules/tools.py b/src/agentcat/modules/tools.py
index 71b005f..38ddafd 100644
--- a/src/agentcat/modules/tools.py
+++ b/src/agentcat/modules/tools.py
@@ -1,11 +1,24 @@
-"""Tool management and interception for AgentCat."""
+"""The get_more_tools descriptor and handler.
-from typing import Any, TYPE_CHECKING
-from mcp.types import CallToolResult, TextContent
-from agentcat.modules.version_detection import has_fastmcp_support
+Agent-facing copy here is byte-identical to the TypeScript SDK's
+`src/modules/tools.ts` and guarded by tests/test_constants_copy.py.
+
+`mcp` is imported inside the handler, never at module scope, so this module
+loads under either SDK major.
+"""
+
+from typing import TYPE_CHECKING, Any
from .logging import write_to_log
+if TYPE_CHECKING:
+ from mcp.types import CallToolResult
+
+GET_MORE_TOOLS_DESCRIPTION = (
+ "Check for additional tools whenever your task might benefit from "
+ "specialized capabilities - even if existing tools could work as a fallback."
+)
+
# Correct schema for the get_more_tools tool parameter.
# Defined explicitly because Pydantic's TypeAdapter generates a broken schema
# (anyOf: [string, null], default: "") for Annotated[str, Field(description=...)]
@@ -15,30 +28,28 @@
"properties": {
"context": {
"type": "string",
- "description": "A description of your goal and what kind of tool would help accomplish it.",
+ "description": "A description of your goal and what kind of tool would help accomplish it.", # noqa: E501
}
},
"required": ["context"],
}
-if TYPE_CHECKING or has_fastmcp_support():
- try:
- from mcp.server import FastMCP
- except ImportError:
- FastMCP = None
+REPORT_MISSING_RESPONSE_TEXT = (
+ "Unfortunately, we have shown you the full tool list. We have noted your "
+ "feedback and will work to improve the tool list in the future."
+)
+
+async def handle_report_missing(arguments: dict[str, Any]) -> "CallToolResult":
+ """Answer a get_more_tools call. Never sees the tool list; always the same."""
+ from mcp.types import CallToolResult, TextContent
-async def handle_report_missing(arguments: dict[str, Any]) -> CallToolResult:
- """Handle the report_missing tool."""
- # Metadata-only diagnostics: log the context length, never the context text.
+ # Metadata-only diagnostics: the context length, never the context text.
+ context = arguments.get("context") if isinstance(arguments, dict) else None
write_to_log(
- f"Missing tool reported (context length: {len(arguments.get('context', ''))})"
+ f"Missing tool reported (context length: "
+ f"{len(context) if isinstance(context, str) else 0})"
)
return CallToolResult(
- content=[
- TextContent(
- type="text",
- text="Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future.",
- )
- ]
+ content=[TextContent(type="text", text=REPORT_MISSING_RESPONSE_TEXT)]
)
diff --git a/src/agentcat/modules/version_detection.py b/src/agentcat/modules/version_detection.py
deleted file mode 100644
index 8c9c835..0000000
--- a/src/agentcat/modules/version_detection.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""MCP version detection utilities."""
-
-import importlib.metadata
-from typing import Tuple, Optional
-
-
-def get_mcp_version() -> Optional[str]:
- """Get the installed MCP version."""
- try:
- return importlib.metadata.version("mcp")
- except importlib.metadata.PackageNotFoundError:
- return None
-
-
-def parse_version(version_str: str) -> Tuple[int, int, int]:
- """Parse version string to tuple of integers."""
- parts = version_str.split(".")
- major = int(parts[0]) if len(parts) > 0 else 0
- minor = int(parts[1]) if len(parts) > 1 else 0
- patch = int(parts[2]) if len(parts) > 2 else 0
- return (major, minor, patch)
-
-
-def has_fastmcp_support() -> bool:
- """Check if the current MCP version supports FastMCP."""
- version = get_mcp_version()
- if not version:
- return False
-
- major, minor, _ = parse_version(version)
-
- # FastMCP was introduced after version 1.1
- if major < 1:
- return False
- if major == 1 and minor <= 1:
- return False
-
- return True
-
-
-def can_import_fastmcp() -> bool:
- """Check if FastMCP can be imported."""
- try:
- from mcp.server import FastMCP
-
- return True
- except ImportError:
- return False
diff --git a/src/agentcat/types.py b/src/agentcat/types.py
index 41f15a9..fbeaffd 100644
--- a/src/agentcat/types.py
+++ b/src/agentcat/types.py
@@ -2,16 +2,40 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
-from datetime import datetime
from enum import Enum
-from typing import Any, Dict, Optional, Set, TypedDict, Literal, Union, NotRequired
-from agentcat_api import PublishEventRequest
-from pydantic import BaseModel
-
-from agentcat.modules.constants import DEFAULT_CONTEXT_DESCRIPTION
+from typing import Any, Literal, NotRequired, Optional, TypedDict, Union
-# Type alias for identify function
-IdentifyFunction = Callable[[dict[str, Any], Any], Optional["UserIdentity"]]
+from agentcat_api import PublishEventRequest
+from pydantic import field_validator
+
+from agentcat.modules.constants import (
+ AGENTCAT_CUSTOM_EVENT_TYPE,
+ DEFAULT_CONTEXT_DESCRIPTION,
+)
+
+# Type alias for identify function.
+#
+# The first argument is the tool call's REQUEST PARAMS — the object carrying
+# `.name` and `.arguments` — never the enclosing JSON-RPC request. That is the
+# one shape every adapter can produce: mcp 2.x hands its handler `(ctx, params)`
+# with no request object in scope, and community FastMCP's `context.message` is
+# params as well, so the official 1.x adapter unwraps `request.params` to match.
+# Typed `Any` rather than a protocol because the concrete class differs per
+# generation (`mcp.types.CallToolRequestParams` on the official SDKs,
+# `fastmcp`'s own model on community). The second argument is the adapter's
+# request context, whatever it holds on that flavor.
+#
+# `event_tags`, `event_properties` and `resolve_session_id` all receive this same
+# pair — see EventTagsFunction / EventPropertiesFunction / ResolveSessionIdFunction.
+#
+# Accepts sync or async callables (mirrors RedactionFunction). This alias was the
+# last of the five to say so: `resolve_identity` used to call the hook and take
+# its return value verbatim, so an `async def` hook silently produced an
+# anonymous event. `modules/hooks.py` now defines that contract for all of them.
+IdentifyFunction = Callable[
+ [Any, Any],
+ Optional["UserIdentity"] | Awaitable[Optional["UserIdentity"]],
+]
# Type alias for redaction function
RedactionFunction = Callable[[str], str | Awaitable[str]]
# Type alias for event_tags callback — returns str:str map attached to every auto-captured event.
@@ -26,6 +50,10 @@
[Any, Any],
Optional[dict[str, Any]] | Awaitable[Optional[dict[str, Any]]],
]
+# Type alias for the resolve_session_id hook — returns the caller-managed
+# session ID, or None. Accepts sync or async callables (mirrors
+# RedactionFunction).
+ResolveSessionIdFunction = Callable[[Any, Any], str | None | Awaitable[str | None]]
@dataclass
@@ -37,27 +65,37 @@ class UserIdentity:
user_data: dict[str, str] | None
-class SessionInfo(BaseModel):
- """Session information for tracking."""
-
- ip_address: Optional[str] = None
- sdk_language: Optional[str] = None
- agentcat_version: Optional[str] = None
- server_name: Optional[str] = None
- server_version: Optional[str] = None
- client_name: Optional[str] = None
- client_version: Optional[str] = None
- identify_actor_given_id: Optional[str] = None # Actor ID for agentcat:identify events
- identify_actor_name: Optional[str] = None # Actor name for agentcat:identify events
- identify_data: Optional[dict[str, Any]] = None
-
-
class Event(PublishEventRequest):
# The generated client marks project_id as required on the wire, but the SDK
# constructs events before the project ID is known: event_queue merges it in
# at publish time, and telemetry-only mode sends events without one.
project_id: Optional[str] = None
+ @field_validator("event_type")
+ @classmethod
+ def event_type_validate_enum(cls, value: str | None) -> str | None:
+ """Validate against what THIS SDK publishes, not the generated enum.
+
+ `agentcat_api` 1.0.0 enforces the OpenAPI spec's `event_type` list,
+ which predates `agentcat:custom` — the backend has accepted that type
+ since TS 2.0, but the generated model rejects it. Left alone, every
+ custom event would fail construction inside the publish path's
+ try/except and vanish without a trace. `EventType` below is the source
+ of truth for the (two) types v2 emits; it is resolved at call time, so
+ forward-declaring it here is fine.
+
+ The NAME is load-bearing: pydantic keys collected validators by
+ attribute name, so this supersedes the generated one only because it
+ matches `agentcat_api`'s exactly. Renaming it to something more
+ idiomatic silently reinstates the stale enum — and silently re-breaks
+ custom events. `tests/test_publish_custom_event.py` has the tripwire.
+ """
+ if value is None or value in {member.value for member in EventType}:
+ return value
+ raise ValueError(
+ "must be one of " + str(sorted(member.value for member in EventType))
+ )
+
# Error tracking types
@@ -97,38 +135,22 @@ class ErrorData(TypedDict, total=False):
class EventType(str, Enum):
- """MCP event types."""
-
- MCP_PING = "mcp:ping"
- MCP_INITIALIZE = "mcp:initialize"
- MCP_COMPLETION_COMPLETE = "mcp:completion/complete"
- MCP_LOGGING_SET_LEVEL = "mcp:logging/setLevel"
- MCP_PROMPTS_GET = "mcp:prompts/get"
- MCP_PROMPTS_LIST = "mcp:prompts/list"
- MCP_RESOURCES_LIST = "mcp:resources/list"
- MCP_RESOURCES_TEMPLATES_LIST = "mcp:resources/templates/list"
- MCP_RESOURCES_READ = "mcp:resources/read"
- MCP_RESOURCES_SUBSCRIBE = "mcp:resources/subscribe"
- MCP_RESOURCES_UNSUBSCRIBE = "mcp:resources/unsubscribe"
+ """The event types AgentCat 2.0 publishes.
+
+ Two, and only two. `mcp:initialize`, `mcp:tools/list` and
+ `agentcat:identify` are gone (changelog §3.1): tools/list is still
+ intercepted for schema injection but emits nothing, and the actor the
+ `identify` hook returns rides on the tool-call event instead.
+ """
+
MCP_TOOLS_CALL = "mcp:tools/call"
- MCP_TOOLS_LIST = "mcp:tools/list"
- AGENTCAT_IDENTIFY = "agentcat:identify"
+ AGENTCAT_CUSTOM = AGENTCAT_CUSTOM_EVENT_TYPE
class UnredactedEvent(Event):
redaction_fn: RedactionFunction | None = None
-@dataclass
-class ToolRegistration:
- """Metadata about a registered tool."""
-
- name: str
- registered_at: datetime
- tracked: bool = False
- wrapped: bool = False
-
-
# Telemetry Exporter Configuration Types
@@ -174,12 +196,17 @@ class AgentCatOptions:
enable_tracing: bool = True
enable_tool_call_context: bool = True
custom_context_description: str = DEFAULT_CONTEXT_DESCRIPTION
+ # Callback invoked on every auto-captured event to attribute it to an actor.
+ # May be sync or async. Receives the `(request, extra)` pair every other hook
+ # receives. If it raises or returns anything but a UserIdentity, the event
+ # publishes anonymously rather than failing the tool call.
identify: IdentifyFunction | None = None
redact_sensitive_information: RedactionFunction | None = None
exporters: dict[str, ExporterConfig] | None = None
- debug_mode: bool = False
+ # Debug logging to ~/agentcat.log. Tri-state: None (the default) defers to
+ # the AGENTCAT_DEBUG_MODE env var read at import; explicit True/False wins.
+ debug_mode: bool | None = None
api_base_url: str | None = None
- stateless: bool | None = None
# Disables AgentCat's internal SDK diagnostics — anonymous, metadata-only
# setup/error reporting used to detect failed installs. On by default; also
# disable-able via the DISABLE_DIAGNOSTICS env var. Automatically disabled in
@@ -187,8 +214,8 @@ class AgentCatOptions:
# never send anything; set DISABLE_DIAGNOSTICS=false to re-enable there. Never
# sends event payloads or user data; the local ~/agentcat.log is unaffected.
disable_diagnostics: bool = False
- # Callback invoked on every auto-captured event (initialize, tools/list,
- # tools/call) to attach string key-value tags. Tags are intended for
+ # Callback invoked on every auto-captured event (one per tool call) to
+ # attach string key-value tags. Tags are intended for
# structured metadata you'll filter or group by in the AgentCat dashboard
# (e.g. trace IDs, environments, regions). Validated client-side: keys
# must be <=32 chars matching [a-zA-Z0-9$_.:\- ], values must be strings
@@ -204,21 +231,73 @@ class AgentCatOptions:
# (stricter than the TypeScript SDK). May be sync or async. If the callback
# raises or returns None, properties are omitted.
event_properties: EventPropertiesFunction | None = None
+ # Default False. Set True to inject a required agent_id parameter into every
+ # tool. Agents self-generate the value (model|harness|nonce, e.g.
+ # "opus-4.80-1m|claude-code|k3n9x"); it is echoed back in _mcp_instructions
+ # and stamped on events as tags. Omission never rejects a call server-side —
+ # the event is simply published without agent identity. The intended
+ # enforcement is client-side: a strict schema-validating MCP client will
+ # refuse to send a call that omits a required agent_id in the first place.
+ enable_agent_tracking: bool = False
+ # Hook mode: you manage session state. When configured, AgentCat injects no
+ # session_id parameter and prompts no session instructions; the returned value is
+ # combined with the project ID into a deterministic ses_ KSUID. None returns
+ # and raises mint silently — a configured hook should answer every request.
+ # May be sync or async. Receives the same (request, extra) arguments as
+ # `identify`.
+ resolve_session_id: ResolveSessionIdFunction | None = None
@dataclass
class AgentCatData:
- """Internal data structure for tracking."""
+ """Per-server tracking state (spec §3.3).
+
+ Nothing here is per-request. v1 kept a session ID, an idle clock, a cached
+ metadata object and a registry of tools on this object; 2.0 resolves all of
+ that per call, so what survives is the project, the customer's options, and
+ the engine state that is genuinely per-server.
+ """
project_id: str | None
- session_id: str
- session_info: SessionInfo
- last_activity: datetime
options: AgentCatOptions
- # Dynamic tracking fields (initialized on demand)
- tool_registry: Dict[str, ToolRegistration] = field(default_factory=dict)
- wrapped_tools: Set[str] = field(default_factory=set)
- tracker_initialized: bool = False
- monkey_patched: bool = False
- is_stateless: bool = False
+ # v2 engine state
+ # Per-tool set of parameter names AgentCat injected into the schema
+ # (context/session_id/agent_id), so they can be stripped before the tool runs.
+ injected_params_registry: dict[str, set[str]] | None = None
+ # Tools whose results receive an _mcp_instructions injection.
+ output_injection_registry: set[str] | None = None
+ # Tools whose OWN input schema declares `session_id` — the customer's
+ # parameter, never ours to read at call time. Membership only ever grows;
+ # a tool that once declared the name stays foreign until the process
+ # restarts, which is the conservative direction, since the alternative is
+ # adopting a value that is not ours into an unredactable field.
+ declared_session_params: set[str] = field(default_factory=set)
+ # Tools whose `session_id` collision has already been reported, so the
+ # per-listing pipeline logs it once rather than for the life of the
+ # process.
+ reported_conflicts: set[str] = field(default_factory=set)
+ # The server's original tools/list source, kept so wrapped lists can be
+ # rebuilt from the unmodified definitions.
+ original_list_source: Any = None
+ # Server identity captured at track time; stamped onto every event at
+ # publish time, since there is no session cache to read it from.
+ server_name: str | None = None
+ server_version: str | None = None
+
+
+# Custom event types for the publish_custom_event function
+class CustomEventData(TypedDict, total=False):
+ """Optional fields describing a custom event."""
+
+ # Session ID to attribute this event to.
+ session_id: str
+ resource_name: str
+ parameters: Any
+ response: Any
+ message: str
+ duration: int # milliseconds
+ is_error: bool
+ error: Any
+ tags: dict[str, str]
+ properties: dict[str, Any]
diff --git a/src/agentcat/utils.py b/src/agentcat/utils.py
index 134a54e..e176cb6 100644
--- a/src/agentcat/utils.py
+++ b/src/agentcat/utils.py
@@ -1,11 +1,42 @@
"""Utility functions for AgentCat."""
+import functools
from typing import Optional
from datetime import datetime, timezone
from .thirdparty.ksuid import Ksuid, KsuidMs
+def get_agentcat_version() -> str | None:
+ """The installed AgentCat SDK version, or None if it cannot be read.
+
+ Lives here rather than in the package root so the event pipeline and the
+ telemetry exporters can stamp it without importing `agentcat` itself.
+ """
+ try:
+ import importlib.metadata
+
+ return importlib.metadata.version("agentcat")
+ except Exception:
+ return None
+
+
+@functools.cache
+def get_dist_version(name: str) -> str | None:
+ """Best-effort installed-distribution version; None when absent.
+
+ Shared by the log-line version suffix, the diagnostics beacon, and the
+ OTLP exporter to stamp the MCP SDK in use (`mcp` and/or `fastmcp`), so
+ the lookup runs once per distribution per process.
+ """
+ try:
+ import importlib.metadata
+
+ return importlib.metadata.version(name)
+ except Exception:
+ return None
+
+
def generate_ksuid(
use_milliseconds: bool = False, dt: Optional[datetime] = None
) -> str:
diff --git a/tests/community/__init__.py b/tests/community/__init__.py
index e3e9355..13a33e8 100644
--- a/tests/community/__init__.py
+++ b/tests/community/__init__.py
@@ -1 +1 @@
-"""Community FastMCP test suite."""
\ No newline at end of file
+"""Community FastMCP test suite."""
diff --git a/tests/community/test_community_dynamic_tracking.py b/tests/community/test_community_dynamic_tracking.py
deleted file mode 100644
index 56ab5dd..0000000
--- a/tests/community/test_community_dynamic_tracking.py
+++ /dev/null
@@ -1,412 +0,0 @@
-"""Tests for dynamic tracking with community FastMCP."""
-
-
-import pytest
-
-from agentcat import track
-from agentcat.modules.internal import (
- get_server_tracking_data,
- get_tool_timeline,
- reset_all_tracking_data,
-)
-from agentcat.types import AgentCatOptions
-
-from ..test_utils.community_client import create_community_test_client
-from ..test_utils.community_todo_server import (
- HAS_COMMUNITY_FASTMCP,
- create_community_todo_server,
- get_lowlevel_server,
- get_server_tools,
-)
-
-# Skip all tests if community FastMCP is not available
-pytestmark = pytest.mark.skipif(
- not HAS_COMMUNITY_FASTMCP,
- reason="Community FastMCP not available. Install with: pip install fastmcp"
-)
-
-
-class TestCommunityDynamicTracking:
- """Test suite for dynamic tool tracking with community FastMCP."""
-
- @pytest.fixture(autouse=True)
- def setup(self):
- """Reset the tracker before each test."""
- reset_all_tracking_data()
- yield
- reset_all_tracking_data()
-
- @pytest.mark.asyncio
- async def test_dynamic_tracking_early_registration(self):
- """Test that tools registered before track() are tracked and work correctly."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Register tools before tracking
- @server.tool
- def early_tool(x: int) -> str:
- return str(x)
-
- # Enable tracking
- track(server, "test-project")
-
- # Test the tool actually works
- async with create_community_test_client(server) as client:
- result = await client.call_tool("early_tool", {"x": 42})
- assert "42" in str(result), f"Expected '42' in result, got {result}"
-
- # Test with different value
- result2 = await client.call_tool("early_tool", {"x": 999})
- assert "999" in str(result2), f"Expected '999' in result, got {result2}"
-
- # Verify tool is tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data and "early_tool" in data.tool_registry
- assert data.tool_registry["early_tool"].tracked
-
- @pytest.mark.asyncio
- async def test_dynamic_tracking_late_registration(self):
- """Test that late-registered tools are tracked with dynamic mode."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Enable tracking first
- track(server, "test-project")
-
- # Register tool after tracking
- @server.tool
- def late_tool(x: int) -> str:
- return str(x)
-
- # Test the tool actually works
- async with create_community_test_client(server) as client:
- result = await client.call_tool("late_tool", {"x": 123})
- assert "123" in str(result), f"Expected '123' in result, got {result}"
-
- # Test with another value
- result2 = await client.call_tool("late_tool", {"x": -456})
- assert "-456" in str(result2), f"Expected '-456' in result, got {result2}"
-
- # Verify tool is tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data and "late_tool" in data.tool_registry
- assert data.tool_registry["late_tool"].tracked
-
- @pytest.mark.asyncio
- async def test_late_registration_always_tracked(self):
- """Test that late registrations are always tracked and function correctly."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Enable tracking
- track(server, "test-project")
-
- # Register tool after tracking
- @server.tool
- def late_tool_always_tracked(x: int) -> str:
- return str(x)
-
- # Test the tool works correctly
- async with create_community_test_client(server) as client:
- result = await client.call_tool("late_tool_always_tracked", {"x": 777})
- assert "777" in str(result), f"Expected '777' in result, got {result}"
-
- # Test with zero
- result2 = await client.call_tool("late_tool_always_tracked", {"x": 0})
- assert "0" in str(result2), f"Expected '0' in result, got {result2}"
-
- # Check that it's tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data and "late_tool_always_tracked" in data.tool_registry
- assert data.tool_registry["late_tool_always_tracked"].tracked
-
- @pytest.mark.asyncio
- async def test_dynamic_tool_execution_tracking(self):
- """Test that dynamically added tools are tracked during execution."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Enable tracking
- track(server, "test-project")
-
- # Add tool after tracking
- @server.tool
- async def dynamic_tool(x: int) -> str:
- return f"Result: {x}"
-
- # Call the tool through client and verify results
- async with create_community_test_client(server) as client:
- result = await client.call_tool("dynamic_tool", {"x": 42})
- assert "Result: 42" in str(result)
-
- # Test with different value
- result2 = await client.call_tool("dynamic_tool", {"x": 100})
- assert "Result: 100" in str(result2)
-
- # Test with negative value
- result3 = await client.call_tool("dynamic_tool", {"x": -5})
- assert "Result: -5" in str(result3)
-
- # Verify tracking
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data and "dynamic_tool" in data.tool_registry
- assert data.tool_registry["dynamic_tool"].tracked
-
- @pytest.mark.asyncio
- async def test_tool_timeline(self):
- """Test tool registration timeline tracking and that both tools work."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Register first tool
- @server.tool
- def tool1(x: int) -> str:
- return str(x)
-
- # Enable tracking
- options = AgentCatOptions()
- track(server, "test-project", options)
-
- # Register second tool
- @server.tool
- def tool2(x: int) -> str:
- return str(x * 2) # Different logic to distinguish
-
- # Test both tools work correctly
- async with create_community_test_client(server) as client:
- result1 = await client.call_tool("tool1", {"x": 5})
- assert "5" in str(result1), f"tool1: Expected '5' in result, got {result1}"
-
- result2 = await client.call_tool("tool2", {"x": 5})
- assert "10" in str(result2)
-
- # Test with different values
- result3 = await client.call_tool("tool1", {"x": 100})
- assert "100" in str(result3)
-
- result4 = await client.call_tool("tool2", {"x": 100})
- assert "200" in str(result4)
-
- # Get timeline
- timeline = get_tool_timeline(get_lowlevel_server(server))
-
- # Should have both tools in timeline
- tool_names = [t["name"] for t in timeline]
- assert "tool1" in tool_names
- assert "tool2" in tool_names
-
- # Timeline should be sorted by registration time
- for i in range(1, len(timeline)):
- assert timeline[i]["registered_at"] >= timeline[i - 1]["registered_at"]
-
- @pytest.mark.asyncio
- async def test_context_injection_with_dynamic_tracking(self):
- """Test that context injection works with dynamic tracking."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Enable tracking with context
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test-project", options)
-
- # Add tool after tracking
- @server.tool
- def context_tool(x: int) -> str:
- return str(x * 3) # Multiply by 3 to verify logic
-
- # Test the tool works with context parameter
- async with create_community_test_client(server) as client:
- result = await client.call_tool(
- "context_tool",
- {"x": 7, "context": "Testing context injection"}
- )
- assert "21" in str(result), f"Expected '21' in result, got {result}"
-
- # Test without context (should still work as context is stripped)
- result2 = await client.call_tool("context_tool", {"x": 10})
- assert "30" in str(result2), f"Expected '30' in result, got {result2}"
-
- # Test with empty context
- result3 = await client.call_tool(
- "context_tool",
- {"x": 4, "context": ""}
- )
- assert "12" in str(result3), f"Expected '12' in result, got {result3}"
-
- # List tools should show context parameter
- tools = await get_server_tools(server)
-
- # Find our tool
- context_tool_def = tools.get("context_tool")
- assert context_tool_def is not None
-
- # Should have context in parameters
- if hasattr(context_tool_def, "parameters"):
- schema = context_tool_def.parameters
- if schema and "properties" in schema:
- assert "context" in schema["properties"]
-
- @pytest.mark.asyncio
- async def test_report_missing_tool_with_dynamic_tracking(self):
- """Test that get_more_tools is added with dynamic tracking."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Enable tracking with report_missing
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test-project", options)
-
- # Test calling get_more_tools
- async with create_community_test_client(server) as client:
- result = await client.call_tool(
- "get_more_tools",
- {"context": "Need a tool to translate text"}
- )
- # Should return the standard "Unfortunately" message
- assert "Unfortunately" in str(result)
-
- # Test with empty context
- result2 = await client.call_tool("get_more_tools", {"context": ""})
- assert "Unfortunately" in str(result2)
-
- # Test with missing context parameter - should raise ToolError
- # since context is a required parameter
- with pytest.raises(Exception, match="(?i)required"):
- await client.call_tool("get_more_tools", {})
-
- # List tools
- tools = await get_server_tools(server)
-
- # Should include get_more_tools
- tool_names = list(tools.keys())
- assert "get_more_tools" in tool_names
-
- @pytest.mark.asyncio
- async def test_multiple_servers_isolation(self):
- """Test that multiple servers can be tracked independently."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server1 = FastMCP("server1")
- server2 = FastMCP("server2")
-
- # Track both servers
- options = AgentCatOptions()
- track(server1, "project1", options)
- track(server2, "project2", options)
-
- # Add tools to each server
- @server1.tool
- def server1_tool(x: int) -> str:
- return f"Server1: {x}"
-
- @server2.tool
- def server2_tool(x: int) -> str:
- return f"Server2: {x}"
-
- # Test server1 tool works correctly
- async with create_community_test_client(server1) as client:
- result1 = await client.call_tool("server1_tool", {"x": 10})
- assert "Server1: 10" in str(result1)
-
- result1b = await client.call_tool("server1_tool", {"x": 25})
- assert "Server1: 25" in str(result1b)
-
- # Test server2 tool works correctly
- async with create_community_test_client(server2) as client:
- result2 = await client.call_tool("server2_tool", {"x": 20})
- assert "Server2: 20" in str(result2)
-
- result2b = await client.call_tool("server2_tool", {"x": 50})
- assert "Server2: 50" in str(result2b)
-
- # Verify both tools are tracked separately
- data1 = get_server_tracking_data(get_lowlevel_server(server1))
- data2 = get_server_tracking_data(get_lowlevel_server(server2))
- assert data1 and "server1_tool" in data1.tool_registry
- assert data2 and "server2_tool" in data2.tool_registry
-
- @pytest.mark.asyncio
- async def test_existing_todo_server_tools(self):
- """Test dynamic tracking with the pre-configured todo server."""
- server = create_community_todo_server()
-
- # Enable tracking
- options = AgentCatOptions()
- track(server, "test-project", options)
-
- # Test existing tools work correctly
- async with create_community_test_client(server) as client:
- # Test add_todo
- add_result = await client.call_tool("add_todo", {"text": "Test todo item"})
- assert "Added todo" in str(add_result)
-
- # Test list_todos
- list_result = await client.call_tool("list_todos", {})
- assert "Test todo item" in str(list_result)
-
- # Test complete_todo
- complete_result = await client.call_tool("complete_todo", {"id": 1})
- assert "Completed todo" in str(complete_result)
-
- # Verify existing tools are tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data
- assert "add_todo" in data.tool_registry
- assert "list_todos" in data.tool_registry
- assert "complete_todo" in data.tool_registry
-
- # Add a new tool dynamically
- @server.tool
- def delete_todo(id: int) -> str:
- return f"Deleted todo {id}"
-
- # In v3, tools are registered when list_tools or call_tool is invoked
- # So we need to list tools or call the tool to trigger registration
-
- # Test new tool execution through client
- async with create_community_test_client(server) as client:
- result = await client.call_tool("delete_todo", {"id": 1})
- assert "Deleted todo 1" in str(result)
-
- # Test with different ID
- result2 = await client.call_tool("delete_todo", {"id": 999})
- assert "Deleted todo 999" in str(result2)
-
- # After calling the tool, it should be registered
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert "delete_todo" in data.tool_registry
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/tests/community/test_community_event_capture.py b/tests/community/test_community_event_capture.py
deleted file mode 100644
index 84ab85f..0000000
--- a/tests/community/test_community_event_capture.py
+++ /dev/null
@@ -1,375 +0,0 @@
-"""Test event capture completeness with community FastMCP."""
-
-import time
-from datetime import datetime
-from unittest.mock import MagicMock
-
-import pytest
-
-from agentcat import AgentCatOptions, track
-from agentcat.modules.event_queue import EventQueue, set_event_queue
-from agentcat.modules.internal import (
- get_server_tracking_data,
- set_server_tracking_data,
-)
-from agentcat.types import UserIdentity
-
-from ..test_utils.community_client import create_community_test_client
-from ..test_utils.community_todo_server import (
- HAS_COMMUNITY_FASTMCP,
- create_community_todo_server,
- get_lowlevel_server,
-)
-
-# Skip all tests if community FastMCP is not available
-pytestmark = pytest.mark.skipif(
- not HAS_COMMUNITY_FASTMCP,
- reason="Community FastMCP not available. Install with: pip install fastmcp"
-)
-
-
-class TestCommunityEventCapture:
- """Test that all required fields are captured in events with community FastMCP."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(self):
- """Set up and tear down for each test."""
- # Store original event queue
- from agentcat.modules.event_queue import event_queue as original_queue
-
- yield
- # Restore original event queue after test
- set_event_queue(original_queue)
-
- @pytest.mark.asyncio
- async def test_event_contains_all_basic_fields(self):
- """Test that events contain all basic required fields."""
- # Create a mock API client
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- # Create event queue with mock
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- # Create and track server
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Make a tool call to generate an event
- await client.call_tool("add_todo", {"text": "Test todo"})
-
- # Wait for event processing
- time.sleep(1.0)
-
- # Find the tool call event
- tool_events = [e for e in captured_events if e.event_type == "mcp:tools/call"]
- assert len(tool_events) > 0, "No tool call event captured"
-
- event = tool_events[0]
-
- # Verify all basic fields are present
- assert event.project_id == "test_project"
- assert event.event_type == "mcp:tools/call"
- assert event.resource_name == "add_todo"
- assert event.timestamp is not None
- assert isinstance(event.timestamp, datetime)
- assert event.duration is not None
- assert isinstance(event.duration, int)
- assert event.parameters is not None
- assert event.parameters.get("arguments") == {"text": "Test todo"}
-
- # Verify event has its own ID
- assert event.id is not None
- assert event.id.startswith("evt_")
- assert len(event.id) > 10 # Should be a proper KSUID
-
- @pytest.mark.asyncio
- async def test_event_contains_server_info(self):
- """Test that events capture server name and version."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- await client.call_tool("add_todo", {"text": "Test"})
- time.sleep(1.0)
-
- tool_events = [e for e in captured_events if e.event_type == "mcp:tools/call"]
- assert len(tool_events) > 0
-
- event = tool_events[0]
-
- # Server info should be captured
- assert event.server_name == "todo-server"
-
- @pytest.mark.asyncio
- async def test_event_contains_user_intent_from_context(self):
- """Test that events capture user intent when tool context is enabled."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True, enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call tool with context parameter
- await client.call_tool(
- "add_todo",
- {
- "text": "Buy groceries",
- "context": "User wants to add a reminder to buy groceries",
- },
- )
- time.sleep(1.0)
-
- tool_events = [e for e in captured_events if e.event_type == "mcp:tools/call"]
- assert len(tool_events) > 0
-
- event = tool_events[0]
-
- # User intent should be captured from context
- assert event.user_intent == (
- "User wants to add a reminder to buy groceries"
- )
-
- @pytest.mark.asyncio
- async def test_event_contains_actor_info_after_identify(self):
- """Test that events contain actor information after identify is called."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- def identify_fn(request, context):
- return UserIdentity(
- user_id="user123",
- user_name="John Doe",
- user_data={"email": "john@example.com", "role": "admin"},
- )
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True, identify=identify_fn)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- await client.call_tool("add_todo", {"text": "Test 1"})
- time.sleep(0.5)
- await client.call_tool("add_todo", {"text": "Test 2"})
- time.sleep(1.0)
-
- tool_events = [e for e in captured_events if e.event_type == "mcp:tools/call"]
- assert len(tool_events) >= 2
-
- for event in tool_events[:2]:
- assert event.identify_actor_given_id == "user123"
- assert event.identify_actor_name == "John Doe"
- assert event.identify_data == {
- "email": "john@example.com",
- "role": "admin",
- }
-
- @pytest.mark.asyncio
- async def test_multiple_event_types_capture_all_fields(self):
- """Test that different event types all capture required fields."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Generate various event types
- await client.list_tools() # mcp:tools/list
- await client.call_tool("add_todo", {"text": "Test"}) # mcp:tools/call
- await client.call_tool("list_todos", {}) # Another tool call
- time.sleep(1.0)
-
- # Check all captured events
- assert len(captured_events) >= 3
-
- # Verify each event has all required fields
- for event in captured_events:
- # Basic fields
- assert event.project_id == "test_project"
- assert event.event_type is not None
- assert event.timestamp is not None
- assert event.id is not None
- assert event.id.startswith("evt_")
-
- # Session info fields
- assert event.server_name == "todo-server"
-
- @pytest.mark.asyncio
- async def test_event_ids_are_unique(self):
- """Test that each event gets a unique ID."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Generate multiple events
- for i in range(5):
- await client.call_tool("add_todo", {"text": f"Todo {i}"})
- time.sleep(1.0)
-
- # Focus on tool call events
- tool_call_events = [
- e for e in captured_events
- if e.event_type == "mcp:tools/call"
- ]
-
- assert len(tool_call_events) >= 5, (
- f"Expected at least 5 tool call events, got {len(tool_call_events)}"
- )
-
- # Extract tool call event IDs
- tool_call_ids = [e.id for e in tool_call_events]
-
- # All tool call IDs should be unique
- assert len(tool_call_ids) == len(set(tool_call_ids)), (
- f"Tool call event IDs are not unique: {tool_call_ids}"
- )
-
- # All IDs should have proper format
- for event_id in tool_call_ids:
- assert event_id.startswith("evt_")
- assert len(event_id) > 10
-
- @pytest.mark.asyncio
- async def test_event_duration_is_calculated(self):
- """Test that event duration is properly calculated."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Add a small delay in the tool to ensure measurable duration
- await client.call_tool("add_todo", {"text": "Test with duration"})
- time.sleep(1.0)
-
- tool_events = [e for e in captured_events if e.event_type == "mcp:tools/call"]
- assert len(tool_events) > 0
-
- event = tool_events[0]
-
- # Duration should be present and reasonable
- assert event.duration is not None
- assert isinstance(event.duration, int)
- assert event.duration >= 0 # Should be non-negative
- assert event.duration < 10000 # Should be less than 10 seconds
-
- @pytest.mark.asyncio
- async def test_server_error_capture_in_event(self):
- """Test that errors are captured in the event's is_error and error fields."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Try to complete a non-existent todo to trigger an error
- try:
- await client.call_tool("complete_todo", {"id": 999})
- except Exception:
- # The client might raise an exception, but we're interested in the event
- pass
-
- time.sleep(1.0)
-
- # Find the tool call event for complete_todo
- tool_events = [
- e
- for e in captured_events
- if e.event_type == "mcp:tools/call" and e.resource_name == "complete_todo"
- ]
- assert len(tool_events) > 0, "No complete_todo tool call event captured"
-
- event = tool_events[0]
-
- # Verify error fields are populated
- assert event.is_error is True, "Event should be marked as error"
- assert event.error is not None, "Event should have error details"
- assert isinstance(event.error, dict), "Error should be a dictionary"
- assert "message" in event.error, "Error should have a message"
- assert "Todo with ID 999 not found" in event.error["message"], (
- "Error message should contain the ValueError message"
- )
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/tests/community/test_community_fastmcp.py b/tests/community/test_community_fastmcp.py
deleted file mode 100644
index 836e3ca..0000000
--- a/tests/community/test_community_fastmcp.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""Basic tests for Community FastMCP integration with AgentCat."""
-
-import pytest
-
-from agentcat import AgentCatOptions, track
-
-from ..test_utils.community_client import create_community_test_client
-from ..test_utils.community_todo_server import (
- HAS_COMMUNITY_FASTMCP,
- create_community_todo_server,
-)
-
-# Skip all tests if community FastMCP is not available
-pytestmark = pytest.mark.skipif(
- not HAS_COMMUNITY_FASTMCP,
- reason="Community FastMCP not available. Install with: pip install fastmcp"
-)
-
-
-class TestCommunityFastMCPBasics:
- """Test basic Community FastMCP functionality."""
-
- @pytest.mark.asyncio
- async def test_create_server(self):
- """Test creating a community FastMCP server."""
- server = create_community_todo_server()
- assert server.name == "todo-server"
- # v2 has _mcp_server, v3 has _local_provider
- assert hasattr(server, "_mcp_server") or hasattr(server, "_local_provider")
-
- @pytest.mark.asyncio
- async def test_tool_registration(self):
- """Test that tools are registered correctly."""
- server = create_community_todo_server()
-
- # v2: get_tools() returns dict, v3: list_tools() returns list
- if hasattr(server, "list_tools"):
- # v3 API
- tools = await server.list_tools()
- tool_names = [t.name for t in tools]
- elif hasattr(server, "get_tools"):
- # v2 API
- tools = await server.get_tools()
- tool_names = list(tools.keys())
- else:
- raise AssertionError("Server has no tool listing method")
-
- assert "add_todo" in tool_names
- assert "list_todos" in tool_names
- assert "complete_todo" in tool_names
-
- @pytest.mark.asyncio
- async def test_is_community_fastmcp_server(self):
- """Test is_community_fastmcp_server identifies community FastMCP."""
- from agentcat.modules.compatibility import (
- is_community_fastmcp_server,
- is_compatible_server,
- is_official_fastmcp_server,
- )
-
- server = create_community_todo_server()
-
- # Should be identified as community FastMCP
- assert is_community_fastmcp_server(server) is True, (
- "Server should be identified as community FastMCP"
- )
-
- # Should NOT be identified as official FastMCP
- assert is_official_fastmcp_server(server) is False, (
- "Server should NOT be identified as official FastMCP"
- )
-
- # Should be compatible with AgentCat
- assert is_compatible_server(server) is True, (
- "Server should be compatible with AgentCat"
- )
-
-
- @pytest.mark.asyncio
- async def test_tool_execution(self):
- """Test executing tools through community client."""
- server = create_community_todo_server()
-
- async with create_community_test_client(server) as client:
- # Add a todo
- result = await client.call_tool("add_todo", {"text": "Test todo"})
- assert "Added todo" in str(result)
- assert "Test todo" in str(result)
-
- # List todos
- result = await client.call_tool("list_todos", {})
- assert "Test todo" in str(result)
- assert "○" in str(result) # Not completed
-
- # Complete the todo
- result = await client.call_tool("complete_todo", {"id": 1})
- assert "Completed todo" in str(result)
-
- # List todos again to verify completion
- result = await client.call_tool("list_todos", {})
- assert "Test todo" in str(result)
- assert "✓" in str(result) # Completed
-
-
-class TestCommunityFastMCPWithAgentCat:
- """Test Community FastMCP integration with AgentCat tracking."""
-
- @pytest.mark.asyncio
- async def test_agentcat_tracking_basic(self):
- """Test that AgentCat can track a community FastMCP server."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=False)
-
- # This will likely fail initially due to incompatibilities
- # but demonstrates the intended usage
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Test that tracking doesn't break basic functionality
- result = await client.call_tool("add_todo", {"text": "AgentCat test"})
- assert "Added todo" in str(result)
-
- @pytest.mark.asyncio
- async def test_agentcat_tracking_with_context(self):
- """Test AgentCat context injection with community FastMCP."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
-
- # Track the server with context enabled
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # List tools to check if context was added
- tools = await client.list_tools()
-
- # Check if context parameter was injected
- # This is expected to fail, demonstrating incompatibility
- for tool in tools:
- if tool.name in ["add_todo", "list_todos", "complete_todo"]:
- # Community FastMCP might handle schemas differently
- schema = (
- getattr(tool, "inputSchema", None)
- or getattr(tool, "input_schema", None)
- )
- assert schema is not None, (
- f"Tool {tool.name} has no input schema"
- )
- assert "properties" in schema, (
- f"Tool {tool.name} schema has no properties"
- )
-
- # This assertion will fail, showing that AgentCat's context injection
- # doesn't work with community FastMCP
- assert "context" in schema["properties"], (
- f"Tool {tool.name} is missing 'context' parameter. "
- f"Properties found: {list(schema['properties'].keys())}"
- )
-
- @pytest.mark.asyncio
- async def test_multiple_operations(self):
- """Test multiple todo operations in sequence."""
- server = create_community_todo_server()
-
- async with create_community_test_client(server) as client:
- # Add multiple todos
- await client.call_tool("add_todo", {"text": "First todo"})
- await client.call_tool("add_todo", {"text": "Second todo"})
- await client.call_tool("add_todo", {"text": "Third todo"})
-
- # List all todos
- result = await client.call_tool("list_todos", {})
- result_str = str(result)
-
- assert "First todo" in result_str
- assert "Second todo" in result_str
- assert "Third todo" in result_str
-
- # Complete middle todo
- await client.call_tool("complete_todo", {"id": 2})
-
- # Verify only the middle one is completed
- result = await client.call_tool("list_todos", {})
- lines = str(result).split("\n")
-
- # These assertions assume the result format
- # They might need adjustment based on actual output
- for line in lines:
- if "Second todo" in line:
- assert "✓" in line
- elif "First todo" in line or "Third todo" in line:
- assert "○" in line
diff --git a/tests/community/test_community_report_missing.py b/tests/community/test_community_report_missing.py
deleted file mode 100644
index 9db041c..0000000
--- a/tests/community/test_community_report_missing.py
+++ /dev/null
@@ -1,404 +0,0 @@
-"""Test report_missing functionality with community FastMCP."""
-
-import pytest
-from unittest.mock import MagicMock
-import time
-
-from agentcat import AgentCatOptions, track
-
-from ..test_utils.community_client import create_community_test_client
-from ..test_utils.community_todo_server import (
- HAS_COMMUNITY_FASTMCP,
- create_community_todo_server,
-)
-
-# Skip all tests if community FastMCP is not available
-pytestmark = pytest.mark.skipif(
- not HAS_COMMUNITY_FASTMCP,
- reason="Community FastMCP not available. Install with: pip install fastmcp"
-)
-
-
-class TestCommunityReportMissing:
- """Test report_missing functionality with community FastMCP."""
-
- @pytest.mark.asyncio
- async def test_report_missing_tool_injection(self):
- """Test that report_missing tool is properly injected when enabled."""
- server = create_community_todo_server()
-
- # Track the server with report_missing enabled
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- # Use client to list all tools and verify report_missing is injected
- async with create_community_test_client(server) as client:
- # List all tools on the server
- tools_result = await client.list_tools()
-
- # Get tool names
- tool_names = [tool.name for tool in tools_result]
-
- # Verify original tools are present
- assert "add_todo" in tool_names
- assert "list_todos" in tool_names
- assert "complete_todo" in tool_names
-
- # Verify report_missing tool was injected
- assert "get_more_tools" in tool_names
-
- @pytest.mark.asyncio
- async def test_report_missing_tool_schema(self):
- """Test that get_more_tools has context as a required string, not anyOf."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- get_more_tools = next(t for t in tools_result if t.name == "get_more_tools")
- schema = get_more_tools.inputSchema
-
- # context must be a simple {"type": "string"}, not anyOf/oneOf
- context_prop = schema["properties"]["context"]
- assert context_prop["type"] == "string", (
- f"Expected context type 'string', got: {context_prop}"
- )
- assert "anyOf" not in context_prop, (
- f"context should not use anyOf: {context_prop}"
- )
- assert "default" not in context_prop, (
- f"context should not have a default: {context_prop}"
- )
-
- # context must be required
- assert "context" in schema.get("required", []), (
- f"context should be required, got required={schema.get('required')}"
- )
-
- @pytest.mark.asyncio
- async def test_report_missing_disabled_by_default(self):
- """Verify tool is NOT injected when enable_report_missing=False."""
- server = create_community_todo_server()
-
- # Track with report_missing disabled
- options = AgentCatOptions(enable_report_missing=False)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- tool_names = [tool.name for tool in tools_result]
-
- # Verify report_missing is NOT present
- assert "get_more_tools" not in tool_names
- # But original tools should still be there
- assert "add_todo" in tool_names
-
- @pytest.mark.asyncio
- async def test_report_missing_tool_call_success(self):
- """Call report_missing tool and verify it executes successfully."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- result = await client.call_tool(
- "get_more_tools",
- {"context": "Need a tool to translate text between languages"},
- )
-
- # Verify successful response
- result_str = str(result)
- assert "Unfortunately" in result_str
-
- @pytest.mark.asyncio
- async def test_report_missing_with_valid_params(self):
- """Test with various valid parameters."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Test with different valid parameters
- test_cases = [
- {"context": "database_query"},
- {"context": "send_email"},
- {"context": "generate_chart"},
- ]
-
- for params in test_cases:
- result = await client.call_tool("get_more_tools", params)
- result_str = str(result)
- assert "Unfortunately" in result_str
-
- @pytest.mark.asyncio
- async def test_report_missing_with_missing_params(self):
- """Test error handling when required parameters are missing."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Test with missing context - should raise a validation error
- # since context is a required parameter
- with pytest.raises(Exception, match="(?i)required"):
- await client.call_tool("get_more_tools", {})
-
- # Test with valid context
- result = await client.call_tool("get_more_tools", {"context": "test_tool"})
- result_str = str(result)
- assert "Unfortunately" in result_str
-
- @pytest.mark.asyncio
- async def test_report_missing_with_other_tools(self):
- """Verify report_missing doesn't interfere with existing server tools."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # First use a regular tool
- add_result = await client.call_tool("add_todo", {"text": "Test todo item"})
- assert "Added todo" in str(add_result)
-
- # Then use report_missing
- report_result = await client.call_tool(
- "get_more_tools", {"context": "Delete a todo item"}
- )
- assert "Unfortunately" in str(report_result)
-
- # Verify the original tool still works
- list_result = await client.call_tool("list_todos", {})
- assert "Test todo item" in str(list_result)
-
- @pytest.mark.asyncio
- async def test_multiple_report_missing_calls(self):
- """Test calling report_missing multiple times in succession."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call report_missing multiple times
- tools_to_report = [
- ("tool1", "Description 1"),
- ("tool2", "Description 2"),
- ("tool3", "Description 3"),
- ]
-
- for tool_name, description in tools_to_report:
- result = await client.call_tool(
- "get_more_tools",
- {"context": f"{tool_name}: {description}"},
- )
- # Each call should work identically
- result_str = str(result)
- assert "Unfortunately" in result_str
-
- @pytest.mark.asyncio
- async def test_report_missing_with_context_enabled(self):
- """Test interaction when both report_missing and tool_context are enabled."""
- server = create_community_todo_server()
- options = AgentCatOptions(
- enable_report_missing=True, enable_tool_call_context=True
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find the report_missing tool
- report_missing_tool = None
- other_tool = None
- for tool in tools_result:
- if tool.name == "get_more_tools":
- report_missing_tool = tool
- elif tool.name == "add_todo":
- other_tool = tool
-
- assert report_missing_tool is not None
- assert other_tool is not None
-
- # Verify get_more_tools has its own context parameter
- assert "context" in report_missing_tool.inputSchema.get("properties", {})
-
- # Other tools should also have context
- assert "context" in other_tool.inputSchema.get("properties", {})
-
- @pytest.mark.asyncio
- async def test_report_missing_with_null_values(self):
- """Test with null/None values for parameters."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Test with None context - should raise a validation error
- # since context is required as a string
- with pytest.raises(Exception, match="(?i)string"):
- await client.call_tool("get_more_tools", {"context": None})
-
- @pytest.mark.asyncio
- async def test_report_missing_publishes_event(self):
- """Verify that calling report_missing tool publishes an event to the queue."""
- from agentcat.modules.event_queue import EventQueue, set_event_queue
-
- # Create a mock API client
- mock_api_client = MagicMock()
- mock_api_client.publish_event = MagicMock(return_value=None)
-
- # Create a new EventQueue with our mock
- test_queue = EventQueue(api_client=mock_api_client)
-
- # Replace the global event queue
- set_event_queue(test_queue)
-
- try:
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True, enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call the report_missing tool
- await client.call_tool(
- "get_more_tools",
- {"context": "Need to resize images to different dimensions"},
- )
-
- # Give the event queue worker thread time to process
- time.sleep(1.0)
-
- # Verify that publish_event was called
- assert mock_api_client.publish_event.called
- assert (
- mock_api_client.publish_event.call_count >= 1
- ) # At least one call
-
- # Find the tool call event
- tool_call_event = None
- for call in mock_api_client.publish_event.call_args_list:
- event = call[1]["publish_event_request"]
- if (
- event.event_type == "mcp:tools/call"
- and event.resource_name == "get_more_tools"
- ):
- tool_call_event = event
- break
-
- assert tool_call_event is not None, (
- "No get_more_tools tool call event found"
- )
-
- # Verify event properties
- assert tool_call_event.project_id == "test_project"
-
- # Verify the arguments contain our input
- assert (
- tool_call_event.parameters["arguments"]["context"]
- == "Need to resize images to different dimensions"
- )
-
- # Verify user_intent was captured from the context parameter
- assert tool_call_event.user_intent == "Need to resize images to different dimensions"
-
- finally:
- # Clean up: restore original event queue
- from agentcat.modules.event_queue import EventQueue, set_event_queue
-
- set_event_queue(EventQueue())
-
- @pytest.mark.asyncio
- async def test_multiple_tool_calls_publish_multiple_events(self):
- """Verify that multiple tool calls result in multiple events being published."""
- from agentcat.modules.event_queue import EventQueue, set_event_queue
-
- # Create a mock API client
- mock_api_client = MagicMock()
- mock_api_client.publish_event = MagicMock(return_value=None)
-
- # Create a new EventQueue with our mock
- test_queue = EventQueue(api_client=mock_api_client)
-
- # Replace the global event queue
- set_event_queue(test_queue)
-
- try:
- server = create_community_todo_server()
- options = AgentCatOptions(enable_report_missing=True, enable_tracing=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call report_missing tool
- await client.call_tool(
- "get_more_tools",
- {"context": "Need a tool to translate text between languages"},
- )
-
- # Call a regular tool
- await client.call_tool("add_todo", {"text": "Test todo item"})
-
- # Call get_more_tools again
- await client.call_tool(
- "get_more_tools",
- {"context": "Need a tool to translate text between languages"},
- )
-
- # Allow time for processing
- time.sleep(1.0)
-
- # Should have at least 3 tool call events (plus initialize and list_tools events)
- assert mock_api_client.publish_event.call_count >= 3
-
- # Get all published events
- events = [
- call[1]["publish_event_request"]
- for call in mock_api_client.publish_event.call_args_list
- ]
-
- # Filter to just tool call events
- tool_events = [e for e in events if e.event_type == "mcp:tools/call"]
-
- # Should have exactly 3 tool calls
- assert len(tool_events) == 3
-
- # Verify event types and tool names (order not guaranteed due to concurrent processing)
- tool_names = [e.resource_name for e in tool_events]
- assert tool_names.count("get_more_tools") == 2
- assert tool_names.count("add_todo") == 1
-
- # Find events by resource name for detailed verification
- get_more_tools_events = [
- e for e in tool_events if e.resource_name == "get_more_tools"
- ]
- add_todo_events = [
- e for e in tool_events if e.resource_name == "add_todo"
- ]
-
- # Verify get_more_tools events
- for event in get_more_tools_events:
- assert (
- event.parameters["arguments"]["context"]
- == "Need a tool to translate text between languages"
- )
- assert (
- event.user_intent
- == "Need a tool to translate text between languages"
- )
-
- # Verify add_todo event
- assert len(add_todo_events) == 1
- assert (
- add_todo_events[0].parameters["arguments"]["text"]
- == "Test todo item"
- )
-
- finally:
- # Clean up: restore original event queue
- from agentcat.modules.event_queue import EventQueue, set_event_queue
-
- set_event_queue(EventQueue())
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/community/test_community_tool_context.py b/tests/community/test_community_tool_context.py
deleted file mode 100644
index 74ee026..0000000
--- a/tests/community/test_community_tool_context.py
+++ /dev/null
@@ -1,583 +0,0 @@
-"""Test tool context functionality with community FastMCP."""
-
-import pytest
-
-from agentcat import AgentCatOptions, track
-from agentcat.modules.constants import DEFAULT_CONTEXT_DESCRIPTION
-
-from ..test_utils.community_client import create_community_test_client
-from ..test_utils.community_todo_server import (
- HAS_COMMUNITY_FASTMCP,
- create_community_todo_server,
-)
-
-# Skip all tests if community FastMCP is not available
-pytestmark = pytest.mark.skipif(
- not HAS_COMMUNITY_FASTMCP,
- reason="Community FastMCP not available. Install with: pip install fastmcp"
-)
-
-
-class TestCommunityToolContext:
- """Test tool context functionality with community FastMCP."""
-
- @pytest.mark.asyncio
- async def test_context_parameter_injection_enabled(self):
- """Test that context parameter is added when enable_tool_call_context=True."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Check each tool (except get_more_tools if present)
- for tool in tools_result:
- if tool.name == "get_more_tools":
- continue
-
- # Verify context parameter exists
- assert "context" in tool.inputSchema["properties"]
-
- # Verify context is required
- assert "context" in tool.inputSchema["required"]
-
- # Verify context schema properties
- context_schema = tool.inputSchema["properties"]["context"]
- assert context_schema["type"] == "string"
- assert (
- context_schema["description"]
- == DEFAULT_CONTEXT_DESCRIPTION
- )
-
- @pytest.mark.asyncio
- async def test_context_parameter_not_injected_when_disabled(self):
- """Test that context parameter is NOT added when enable_tool_call_context=False."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=False)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- for tool in tools_result:
- if tool.name == "get_more_tools":
- continue
-
- # Verify context parameter does NOT exist
- assert "context" not in tool.inputSchema.get("properties", {})
-
- # Verify context is NOT in required
- assert "context" not in tool.inputSchema.get("required", [])
-
- @pytest.mark.asyncio
- async def test_schema_with_existing_properties(self):
- """Test with tools that have existing inputSchema and properties."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find add_todo which has existing schema
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
-
- # Verify original properties still exist
- assert "text" in add_todo_tool.inputSchema["properties"]
-
- # Verify context was added
- assert "context" in add_todo_tool.inputSchema["properties"]
- assert "context" in add_todo_tool.inputSchema["required"]
-
- @pytest.mark.asyncio
- async def test_schema_with_no_input_schema(self):
- """Test with tools that have no inputSchema."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- # Create a custom server with a tool that has no parameters
- server = FastMCP("test-server")
-
- @server.tool
- def simple_tool():
- """A tool with no parameters."""
- return "success"
-
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- simple_tool_def = next(
- t for t in tools_result if t.name == "simple_tool"
- )
-
- # Verify inputSchema was created
- assert simple_tool_def.inputSchema is not None
- assert "properties" in simple_tool_def.inputSchema
- assert "context" in simple_tool_def.inputSchema["properties"]
- assert "required" in simple_tool_def.inputSchema
- assert "context" in simple_tool_def.inputSchema["required"]
-
- @pytest.mark.asyncio
- async def test_schema_with_empty_properties(self):
- """Test with tools that have empty properties object."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Create a tool with function that has no parameters
- @server.tool
- def empty_tool():
- """Tool with empty schema."""
- return "success"
-
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- empty_tool = next(t for t in tools_result if t.name == "empty_tool")
-
- # Verify context was added to empty properties
- assert "context" in empty_tool.inputSchema["properties"]
- assert len(empty_tool.inputSchema["properties"]) >= 1
-
- @pytest.mark.asyncio
- async def test_schema_with_existing_required_fields(self):
- """Test with tools that already have required fields."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # add_todo has 'text' as required
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
-
- # Verify both original and context are required
- assert "text" in add_todo_tool.inputSchema["required"]
- assert "context" in add_todo_tool.inputSchema["required"]
- assert len(add_todo_tool.inputSchema["required"]) >= 2
-
- @pytest.mark.asyncio
- async def test_tool_call_with_valid_context(self):
- """Test calling a tool with valid context parameter."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call tool with context
- result = await client.call_tool(
- "add_todo",
- {
- "text": "Test todo item",
- "context": "Adding a test todo to verify context handling",
- },
- )
-
- # Should succeed
- assert "Added todo" in str(result)
-
- @pytest.mark.asyncio
- async def test_tool_call_without_context_still_works(self):
- """Test that tool calls without context still work (context is stripped)."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # The implementation strips context before passing to handler
- result = await client.call_tool(
- "add_todo",
- {"text": "Test todo item"}, # Missing context
- )
-
- # The call should succeed because context is stripped before passing to handler
- assert "Added todo" in str(result)
-
- @pytest.mark.asyncio
- async def test_tool_call_with_empty_context(self):
- """Test calling a tool with empty string context."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call with empty context - should still work
- result = await client.call_tool(
- "add_todo",
- {
- "text": "Test todo",
- "context": "", # Empty but present
- },
- )
-
- assert "Added todo" in str(result)
-
- @pytest.mark.asyncio
- async def test_get_more_tools_exclusion_with_context(self):
- """Test that get_more_tools doesn't get context when both features are enabled."""
- server = create_community_todo_server()
- options = AgentCatOptions(
- enable_report_missing=True, enable_tool_call_context=True
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find get_more_tools tool
- get_more_tools_tool = next(
- t for t in tools_result if t.name == "get_more_tools"
- )
-
- # Verify it has context parameter (it's special and keeps its own context param)
- assert "context" in get_more_tools_tool.inputSchema.get("properties", {})
-
- # Verify other tools DO have context
- other_tools = [t for t in tools_result if t.name != "get_more_tools"]
- for tool in other_tools:
- assert "context" in tool.inputSchema["properties"]
-
- @pytest.mark.asyncio
- async def test_tool_with_existing_context_parameter(self):
- """Test that existing context parameter is overwritten with AgentCat's version."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- @server.tool
- def tool_with_context(context: str, data: str):
- """Tool that already has a context parameter."""
- return f"Original context: {context}, data: {data}"
-
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- tool = next(t for t in tools_result if t.name == "tool_with_context")
-
- # Verify context exists
- assert "context" in tool.inputSchema["properties"]
-
- # Check if it has AgentCat's description
- context_schema = tool.inputSchema["properties"]["context"]
- assert (
- context_schema.get("description")
- == DEFAULT_CONTEXT_DESCRIPTION
- )
-
- # Should still be in required
- assert "context" in tool.inputSchema["required"]
-
- @pytest.mark.asyncio
- async def test_original_functionality_preserved(self):
- """Verify that original tool functionality remains intact with context."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Add multiple todos
- await client.call_tool(
- "add_todo", {"text": "First todo", "context": "Adding first item"}
- )
- await client.call_tool(
- "add_todo", {"text": "Second todo", "context": "Adding second item"}
- )
-
- # List todos
- list_result = await client.call_tool(
- "list_todos", {"context": "Listing all todos to verify they were added"}
- )
-
- # Verify both todos are present
- result_str = str(list_result)
- assert "First todo" in result_str
- assert "Second todo" in result_str
-
- # Complete a todo
- complete_result = await client.call_tool(
- "complete_todo", {"id": 1, "context": "Completing the first todo"}
- )
-
- assert "Completed todo" in str(complete_result)
-
- @pytest.mark.asyncio
- async def test_context_not_passed_to_original_handler(self):
- """Verify that context parameter is stripped before passing to original handler."""
- server = create_community_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Call with context
- result = await client.call_tool(
- "add_todo",
- {"text": "test data", "context": "This context should be stripped"},
- )
-
- # The call should succeed, proving context was stripped
- # (otherwise it would fail since add_todo doesn't accept context param)
- assert "Added todo" in str(result)
-
- @pytest.mark.asyncio
- async def test_dynamically_added_tool_gets_context(self):
- """Test that tools added after tracking get context parameter."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Track with context enabled
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- # Add tool AFTER tracking
- @server.tool
- def late_tool(value: str):
- """Tool added after tracking."""
- return f"Processed: {value}"
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- late_tool_def = next(t for t in tools_result if t.name == "late_tool")
-
- # Should have context parameter
- assert "context" in late_tool_def.inputSchema["properties"]
- assert "context" in late_tool_def.inputSchema["required"]
-
- # Test calling it
- result = await client.call_tool(
- "late_tool",
- {"value": "test", "context": "Testing late-added tool"}
- )
- assert "Processed: test" in str(result)
-
- @pytest.mark.asyncio
- async def test_custom_context_description(self):
- """Test that custom context description is correctly applied in community FastMCP."""
- server = create_community_todo_server()
- custom_description = "Explain why you need to use this tool"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_description
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Check each tool (except get_more_tools)
- for tool in tools_result:
- if tool.name == "get_more_tools":
- continue
-
- # Verify context parameter has custom description
- context_schema = tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == custom_description
-
- @pytest.mark.asyncio
- async def test_custom_context_description_empty_string(self):
- """Test edge case with empty string custom description in community FastMCP."""
- server = create_community_todo_server()
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=""
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find a tool to test
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
-
- # Verify context exists with empty description
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == ""
- assert context_schema["type"] == "string"
-
- @pytest.mark.asyncio
- async def test_custom_context_description_special_characters(self):
- """Test custom description with special characters and Unicode in community FastMCP."""
- server = create_community_todo_server()
- special_description = "Why use this? 🚀 Include: 'quotes', \"double\", newlines\n, tabs\t."
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=special_description
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Verify special characters are preserved
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == special_description
-
- @pytest.mark.asyncio
- async def test_custom_context_description_very_long(self):
- """Test with a very long description string in community FastMCP."""
- server = create_community_todo_server()
- # Create a very long description
- long_description = "This is a very detailed description for the context. " * 50
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=long_description
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Verify long description is preserved
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == long_description
- assert len(context_schema["description"]) > 1000
-
- @pytest.mark.asyncio
- async def test_default_context_description(self):
- """Verify the default description is used when not specified in community FastMCP."""
- server = create_community_todo_server()
- # Don't specify custom_context_description, should use default
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Check for default description
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == DEFAULT_CONTEXT_DESCRIPTION
-
- @pytest.mark.asyncio
- async def test_custom_context_description_with_multiple_tools(self):
- """Test that custom description is applied to all tools consistently in community FastMCP."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- @server.tool
- def tool1(param: str):
- """First tool."""
- return f"Tool 1: {param}"
-
- @server.tool
- def tool2(value: int):
- """Second tool."""
- return f"Tool 2: {value}"
-
- @server.tool
- def tool3():
- """Third tool with no params."""
- return "Tool 3"
-
- custom_desc = "Custom community context for all tools"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_desc
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # All tools should have the same custom context description
- for tool in tools_result:
- if tool.name in ["tool1", "tool2", "tool3"]:
- assert "context" in tool.inputSchema["properties"]
- assert tool.inputSchema["properties"]["context"]["description"] == custom_desc
-
- @pytest.mark.asyncio
- async def test_custom_context_with_tool_call(self):
- """Test tool calls work correctly with custom context description in community FastMCP."""
- server = create_community_todo_server()
- custom_desc = "Provide reasoning for this action in the community server"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_desc
- )
- track(server, "test_project", options)
-
- async with create_community_test_client(server) as client:
- # Verify the custom description is set
- tools_result = await client.list_tools()
- add_todo_tool = next(t for t in tools_result if t.name == "add_todo")
- assert add_todo_tool.inputSchema["properties"]["context"]["description"] == custom_desc
-
- # Call the tool with context
- result = await client.call_tool(
- "add_todo",
- {
- "text": "Test with custom description in community",
- "context": "Adding todo to test custom context description in community FastMCP"
- }
- )
-
- # Should succeed
- assert "Added todo" in str(result)
-
- @pytest.mark.asyncio
- async def test_custom_context_with_dynamically_added_tool(self):
- """Test that dynamically added tools get custom context description in community FastMCP."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("test-server")
-
- # Track with custom context description
- custom_desc = "Dynamic tool custom context"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_desc
- )
- track(server, "test_project", options)
-
- # Add tool AFTER tracking
- @server.tool
- def dynamic_tool(data: str):
- """Tool added after tracking with custom context."""
- return f"Dynamic result: {data}"
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- dynamic_tool_def = next(t for t in tools_result if t.name == "dynamic_tool")
-
- # Should have context parameter with custom description
- assert "context" in dynamic_tool_def.inputSchema["properties"]
- assert dynamic_tool_def.inputSchema["properties"]["context"]["description"] == custom_desc
-
- # Test calling it
- result = await client.call_tool(
- "dynamic_tool",
- {"data": "test", "context": "Using dynamic tool with custom context"}
- )
- assert "Dynamic result: test" in str(result)
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/community/test_community_tracking_timing.py b/tests/community/test_community_tracking_timing.py
deleted file mode 100644
index 190a9d1..0000000
--- a/tests/community/test_community_tracking_timing.py
+++ /dev/null
@@ -1,382 +0,0 @@
-"""Test .track() timing flexibility with community FastMCP."""
-
-import pytest
-
-from agentcat import AgentCatOptions, track
-from agentcat.modules.internal import (
- get_server_tracking_data,
- reset_all_tracking_data,
-)
-
-from ..test_utils.community_client import create_community_test_client
-from ..test_utils.community_todo_server import (
- HAS_COMMUNITY_FASTMCP,
- get_lowlevel_server,
-)
-
-# Skip all tests if community FastMCP is not available
-pytestmark = pytest.mark.skipif(
- not HAS_COMMUNITY_FASTMCP,
- reason="Community FastMCP not available. Install with: pip install fastmcp"
-)
-
-
-class TestCommunityTrackingTiming:
- """Test that .track() works when called at different stages of server setup."""
-
- @pytest.fixture(autouse=True)
- def setup(self):
- """Reset the tracker before each test."""
- reset_all_tracking_data()
- yield
- reset_all_tracking_data()
-
- @pytest.mark.asyncio
- async def test_track_empty_server_then_add_tools(self):
- """Test tracking a server with NO tools, then adding tools later."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- # Create empty server
- server = FastMCP("empty-server")
-
- # Track the empty server first (disable report_missing to truly have no tools)
- options = AgentCatOptions(enable_report_missing=False)
- track(server, "test-project", options)
-
- # Verify tracking is initialized even with no tools
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data is not None
- assert data.tracker_initialized
- assert len(data.tool_registry) == 0 # No tools yet
-
- # Now add tools AFTER tracking
- @server.tool
- def first_tool(x: int) -> str:
- return f"First: {x}"
-
- @server.tool
- def second_tool(x: int) -> str:
- return f"Second: {x * 2}"
-
- # Test that both tools work correctly
- async with create_community_test_client(server) as client:
- result1 = await client.call_tool("first_tool", {"x": 10})
- assert "First: 10" in str(result1), f"Expected 'First: 10', got {result1}"
-
- result2 = await client.call_tool("second_tool", {"x": 10})
- assert "Second: 20" in str(result2), f"Expected 'Second: 20', got {result2}"
-
- # Verify tools are tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert "first_tool" in data.tool_registry
- assert "second_tool" in data.tool_registry
- assert data.tool_registry["first_tool"].tracked
- assert data.tool_registry["second_tool"].tracked
-
- @pytest.mark.asyncio
- async def test_track_server_with_some_tools_then_add_more(self):
- """Test tracking a server with existing tools, then adding more tools."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("partial-server")
-
- # Add initial tools
- @server.tool
- def existing_tool1(x: int) -> str:
- return f"Existing1: {x}"
-
- @server.tool
- def existing_tool2(x: int) -> str:
- return f"Existing2: {x + 1}"
-
- # Track server with some tools (disable report_missing for cleaner counts)
- options = AgentCatOptions(enable_report_missing=False)
- track(server, "test-project", options)
-
- # Test initial tools work (registered when list_tools or call_tool invoked)
- async with create_community_test_client(server) as client:
- # First list tools to trigger registration
- await client.list_tools()
-
- # Verify initial tools are now tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert len(data.tool_registry) == 2
- assert "existing_tool1" in data.tool_registry
- assert "existing_tool2" in data.tool_registry
- result = await client.call_tool("existing_tool1", {"x": 5})
- assert "Existing1: 5" in str(result)
-
- result = await client.call_tool("existing_tool2", {"x": 5})
- assert "Existing2: 6" in str(result)
-
- # Add more tools after tracking
- @server.tool
- def new_tool1(x: int) -> str:
- return f"New1: {x * 3}"
-
- @server.tool
- def new_tool2(x: int) -> str:
- return f"New2: {x - 1}"
-
- # Test all tools work (both old and new)
- async with create_community_test_client(server) as client:
- # Test existing tools still work
- result = await client.call_tool("existing_tool1", {"x": 7})
- assert "Existing1: 7" in str(result)
-
- result = await client.call_tool("existing_tool2", {"x": 7})
- assert "Existing2: 8" in str(result)
-
- # Test new tools work
- result = await client.call_tool("new_tool1", {"x": 7})
- assert "New1: 21" in str(result), f"Expected 'New1: 21', got {result}"
-
- result = await client.call_tool("new_tool2", {"x": 7})
- assert "New2: 6" in str(result), f"Expected 'New2: 6', got {result}"
-
- # Verify all tools are tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert len(data.tool_registry) == 4
- for tool_name in ["existing_tool1", "existing_tool2", "new_tool1", "new_tool2"]:
- assert tool_name in data.tool_registry
- assert data.tool_registry[tool_name].tracked
-
- @pytest.mark.asyncio
- async def test_track_server_with_all_tools_already_added(self):
- """Test tracking a server after ALL its tools have been added."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("complete-server")
-
- # Add ALL tools before tracking
- @server.tool
- def tool_a(x: int) -> str:
- return f"A: {x}"
-
- @server.tool
- def tool_b(x: int) -> str:
- return f"B: {x * 2}"
-
- @server.tool
- def tool_c(x: int) -> str:
- return f"C: {x + 10}"
-
- @server.tool
- async def async_tool_d(x: int) -> str:
- return f"D: {x - 5}"
-
- # Track server AFTER all tools added (disable report_missing for cleaner counts)
- options = AgentCatOptions(enable_report_missing=False)
- track(server, "test-project", options)
-
- # Test all tools work correctly
- async with create_community_test_client(server) as client:
- result = await client.call_tool("tool_a", {"x": 15})
- assert "A: 15" in str(result), f"Expected 'A: 15', got {result}"
-
- result = await client.call_tool("tool_b", {"x": 15})
- assert "B: 30" in str(result), f"Expected 'B: 30', got {result}"
-
- result = await client.call_tool("tool_c", {"x": 15})
- assert "C: 25" in str(result), f"Expected 'C: 25', got {result}"
-
- result = await client.call_tool("async_tool_d", {"x": 15})
- assert "D: 10" in str(result), f"Expected 'D: 10', got {result}"
-
- # Verify all tools are tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert len(data.tool_registry) == 4
- for tool_name in ["tool_a", "tool_b", "tool_c", "async_tool_d"]:
- assert tool_name in data.tool_registry
- assert data.tool_registry[tool_name].tracked
-
- @pytest.mark.asyncio
- async def test_track_with_options_on_empty_server(self):
- """Test tracking an empty server with various options enabled."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("empty-with-options")
-
- # Track with options on empty server
- options = AgentCatOptions(
- enable_tool_call_context=True,
- enable_report_missing=True,
- enable_tracing=True
- )
- track(server, "test-project", options)
-
- # Verify tracking is initialized
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert data is not None
- assert data.tracker_initialized
-
- # get_more_tools should be added due to enable_report_missing
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
- tool_names = [tool.name for tool in tools_result]
- assert "get_more_tools" in tool_names
-
- # Test that get_more_tools works
- result = await client.call_tool(
- "get_more_tools",
- {"context": "Need a tool for testing"}
- )
- assert "Unfortunately" in str(result)
-
- # Now add a tool and verify context injection works
- @server.tool
- def late_added_tool(value: str) -> str:
- return f"Value: {value}"
-
- async with create_community_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find late_added_tool
- late_tool = next(
- (t for t in tools_result if t.name == "late_added_tool"),
- None
- )
- assert late_tool is not None
-
- # Verify context was injected (due to enable_tool_call_context)
- assert "context" in late_tool.inputSchema["properties"]
- assert "context" in late_tool.inputSchema["required"]
-
- # Test the tool works with context
- result = await client.call_tool(
- "late_added_tool",
- {"value": "test", "context": "Testing late added tool"}
- )
- assert "Value: test" in str(result)
-
- @pytest.mark.asyncio
- async def test_multiple_track_calls_on_same_server(self):
- """Test that calling track() multiple times on the same server is safe."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("multi-track-server")
-
- # First track call
- track(server, "project1")
-
- @server.tool
- def tool1(x: int) -> str:
- return f"Tool1: {x}"
-
- # Second track call with different project
- track(server, "project2")
-
- @server.tool
- def tool2(x: int) -> str:
- return f"Tool2: {x * 2}"
-
- # Third track call with options
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "project3", options)
-
- @server.tool
- def tool3(x: int) -> str:
- return f"Tool3: {x + 5}"
-
- # Test all tools work
- async with create_community_test_client(server) as client:
- result = await client.call_tool("tool1", {"x": 10})
- assert "Tool1: 10" in str(result)
-
- result = await client.call_tool("tool2", {"x": 10})
- assert "Tool2: 20" in str(result)
-
- result = await client.call_tool(
- "tool3", {"x": 10, "context": "Testing tool3"}
- )
- assert "Tool3: 15" in str(result)
-
- # Verify all tools are tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- assert "tool1" in data.tool_registry
- assert "tool2" in data.tool_registry
- assert "tool3" in data.tool_registry
-
- @pytest.mark.asyncio
- async def test_track_interleaved_with_tool_additions(self):
- """Test complex scenario: add tool, track, add tool, track again, add more."""
- if not HAS_COMMUNITY_FASTMCP:
- pytest.skip("Community FastMCP not available")
-
- from fastmcp import FastMCP
-
- server = FastMCP("interleaved-server")
-
- # Add first tool
- @server.tool
- def step1_tool(x: int) -> str:
- return f"Step1: {x}"
-
- # First track
- track(server, "test-project")
-
- # Add second tool
- @server.tool
- def step2_tool(x: int) -> str:
- return f"Step2: {x * 2}"
-
- # Track again with options
- options = AgentCatOptions(enable_report_missing=True)
- track(server, "test-project", options)
-
- # Add third and fourth tools
- @server.tool
- def step3_tool(x: int) -> str:
- return f"Step3: {x + 1}"
-
- @server.tool
- async def step4_tool(x: int) -> str:
- return f"Step4: {x - 1}"
-
- # Test all tools work and verify functionality
- async with create_community_test_client(server) as client:
- # Test each tool
- result = await client.call_tool("step1_tool", {"x": 100})
- assert "Step1: 100" in str(result)
-
- result = await client.call_tool("step2_tool", {"x": 100})
- assert "Step2: 200" in str(result)
-
- result = await client.call_tool("step3_tool", {"x": 100})
- assert "Step3: 101" in str(result)
-
- result = await client.call_tool("step4_tool", {"x": 100})
- assert "Step4: 99" in str(result)
-
- # Also verify get_more_tools was added
- result = await client.call_tool(
- "get_more_tools",
- {"context": "Testing report missing"}
- )
- assert "Unfortunately" in str(result)
-
- # Verify all tools are tracked
- data = get_server_tracking_data(get_lowlevel_server(server))
- for tool_name in ["step1_tool", "step2_tool", "step3_tool", "step4_tool"]:
- assert tool_name in data.tool_registry
- assert data.tool_registry[tool_name].tracked
- assert "get_more_tools" in data.tool_registry
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/tests/community/test_community_v3_event_serialization.py b/tests/community/test_community_v3_event_serialization.py
index dade60a..7ef0e02 100644
--- a/tests/community/test_community_v3_event_serialization.py
+++ b/tests/community/test_community_v3_event_serialization.py
@@ -1,20 +1,23 @@
-"""End-to-end: tools/list events must be JSON-serializable (agentcat 1.0.1 data loss).
-
-On 1.0.1 every ``tools/list`` event carried the FastMCP tool's ``fn`` callable and
-``tags`` set (via ``_tool_to_dict`` -> ``tool.model_dump()``), so truncation logged
-``Unable to serialize unknown type: `` and the event was then
-dropped by the API client (``'set' object has no attribute '__dict__'``). These
-tests drive a real ``tools/list`` and assert the captured event survives.
+"""End-to-end: published events must be JSON-serializable (agentcat 1.0.1 data loss).
+
+On 1.0.1 every ``tools/list`` event carried the FastMCP tool's ``fn`` callable
+and ``tags`` set, so truncation logged ``Unable to serialize unknown type:
+`` and the event was then dropped by the API client (``'set'
+object has no attribute '__dict__'``).
+
+v2 publishes no ``tools/list`` event at all, so the surviving risk moved to the
+one event type that remains: a ``tools/call`` response is a FastMCP
+``ToolResult``, and ``PublishEventRequest.response`` is
+``Optional[Dict[str, Any]]`` — a non-dict there fails pydantic construction and
+silently drops the whole event. These tests drive real calls and assert the
+captured events survive the send path.
"""
import json
-import time
-from unittest.mock import MagicMock
import pytest
from agentcat import AgentCatOptions, track
-from agentcat.modules.event_queue import EventQueue, set_event_queue
from ..test_utils.community_client import (
HAS_COMMUNITY_CLIENT,
@@ -32,62 +35,104 @@
@pytest.fixture
-def captured_events():
- from agentcat.modules.event_queue import event_queue as original_queue
-
+def captured_events(monkeypatch):
events: list = []
- mock_api_client = MagicMock()
- mock_api_client.publish_event = MagicMock(
- side_effect=lambda publish_event_request: events.append(publish_event_request)
- )
- set_event_queue(EventQueue(api_client=mock_api_client))
- try:
- yield events
- finally:
- set_event_queue(original_queue)
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+def _call_events(events):
+ return [e for e in events if e.event_type == "mcp:tools/call"]
+
+async def test_only_tools_call_events_are_published(captured_events):
+ """A handshake plus a listing publishes nothing; v2 has one event type."""
+ server = create_community_todo_server()
+ track(server, "test_project", AgentCatOptions(enable_tracing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert captured_events == [], [e.event_type for e in captured_events]
+ # The listing still went through AgentCat: the handles are on the
+ # schemas.
+ add = next(t for t in listed if t.name == "add_todo")
+ assert "session_id" in add.inputSchema["properties"]
+
+ await client.call_tool("add_todo", {"text": "serialize me"})
-def _list_event(events):
- matches = [e for e in events if e.event_type == "mcp:tools/list"]
- return matches[-1] if matches else None
+ assert {e.event_type for e in captured_events} == {"mcp:tools/call"}
-@pytest.mark.asyncio
-async def test_tools_list_event_is_json_serializable(captured_events):
- """The captured tools/list event.response must be fully JSON-safe."""
+async def test_tools_call_event_response_is_json_serializable(captured_events):
+ """The captured tools/call event.response must be a fully JSON-safe dict."""
server = create_community_todo_server() # FunctionTools carry the fn callable
track(server, "test_project", AgentCatOptions(enable_tracing=True))
async with create_community_test_client(server) as client:
- await client.list_tools()
- time.sleep(1.0)
+ await client.call_tool("add_todo", {"text": "hi"})
- event = _list_event(captured_events)
- assert event is not None, "no tools/list event captured"
- # The exact thing the generated API client does before sending — must not raise.
+ event = _call_events(captured_events)[-1]
+ # A non-dict response never reaches the wire — it fails PublishEventRequest
+ # construction and the event is dropped whole.
+ assert isinstance(event.response, dict)
+ # The exact thing the generated API client does before sending.
json.dumps(event.response)
-
- # And the payload is the clean MCP shape (from to_mcp_tool), not a raw
- # tool.model_dump() with an embedded callable ``fn``.
- tool = event.response["tools"][0]
- assert "fn" not in tool
- assert "inputSchema" in tool
+ assert "fn" not in event.response
+ assert "Added todo" in json.dumps(event.response)
-@pytest.mark.asyncio
-async def test_tools_list_event_survives_api_client_serialization(captured_events):
+async def test_event_survives_api_client_serialization(captured_events):
"""Reproduce the send path: the generated client's sanitizer must not raise."""
server = create_community_todo_server()
track(server, "test_project", AgentCatOptions(enable_tracing=True))
async with create_community_test_client(server) as client:
- await client.list_tools()
- time.sleep(1.0)
+ await client.call_tool("add_todo", {"text": "sanitize me"})
- event = _list_event(captured_events)
- assert event is not None
+ event = _call_events(captured_events)[-1]
from agentcat_api.api_client import ApiClient
- # 'set' object has no attribute '__dict__' was the 1.0.1 drop; must be gone now.
+ # 'set' object has no attribute '__dict__' was the 1.0.1 drop; must be gone.
ApiClient.sanitize_for_serialization(ApiClient(), event.response)
+ ApiClient.sanitize_for_serialization(ApiClient(), event.parameters)
+
+
+async def test_an_unserializable_result_drops_only_the_response(captured_events):
+ """A result we cannot dump must not take the whole event down with it —
+ nor the customer's tool call."""
+ from fastmcp.server.middleware import Middleware
+ from fastmcp.tools import ToolResult
+ from mcp.types import TextContent
+
+ class Unserializable(ToolResult):
+ def model_dump(self, *args, **kwargs):
+ raise TypeError("cannot serialize")
+
+ class Unserializing(Middleware):
+ async def on_call_tool(self, context, call_next):
+ return Unserializable(content=[TextContent(type="text", text="opaque")])
+
+ from fastmcp import FastMCP
+
+ server = FastMCP("opaque-server")
+
+ @server.tool(output_schema=None)
+ def opaque() -> str:
+ """Answered by the middleware below AgentCat."""
+ return "unused"
+
+ server.add_middleware(Unserializing())
+ track(server, "test_project", AgentCatOptions(enable_tracing=True))
+
+ async with create_community_test_client(server) as client:
+ result = await client.call_tool("opaque", {})
+
+ assert result.is_error is False
+ assert "opaque" in "".join(c.text for c in result.content if hasattr(c, "text"))
+ events = _call_events(captured_events)
+ assert len(events) == 1
+ assert events[0].response is None
+ assert events[0].session_id.startswith("ses_")
diff --git a/tests/community/test_community_v3_handles.py b/tests/community/test_community_v3_handles.py
new file mode 100644
index 0000000..e7fe69f
--- /dev/null
+++ b/tests/community/test_community_v3_handles.py
@@ -0,0 +1,1157 @@
+"""End-to-end handle behavior on the community FastMCP adapter.
+
+The community sibling of `tests/test_lowlevel_v1_handles.py`: every assertion
+here rides a real `fastmcp.Client` against a real server, so the whole wire
+path is covered — schema injection on `tools/list`, the stripped arguments the
+customer's tool actually receives, the mint-back the agent sees, and the single
+`mcp:tools/call` event AgentCat publishes.
+"""
+
+import json
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ META_CLIENT_INFO_KEY,
+)
+from agentcat.modules.handles import derive_session_id
+
+from ..test_utils import sid
+from ..test_utils.community_client import (
+ HAS_COMMUNITY_CLIENT,
+ create_community_test_client,
+)
+from ..test_utils.community_todo_server import (
+ HAS_COMMUNITY_FASTMCP,
+ create_community_todo_server,
+)
+
+pytestmark = pytest.mark.skipif(
+ not (HAS_COMMUNITY_FASTMCP and HAS_COMMUNITY_CLIENT),
+ reason="Community FastMCP not available",
+)
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+def _call_events(capture) -> list:
+ return [e for e in capture if e.event_type == "mcp:tools/call"]
+
+
+def _named(tools, name):
+ return next(t for t in tools if t.name == name)
+
+
+def _new_server(name: str = "probe-server"):
+ from fastmcp import FastMCP
+
+ return FastMCP(name)
+
+
+async def test_prompted_mode_end_to_end(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ add = _named(listed, "add_todo")
+ assert list(add.inputSchema["properties"])[-2:] == ["session_id", "context"]
+ assert "session_id" not in add.inputSchema.get("required", [])
+ # session_id is the one injected param that is never required — omitting
+ # it is the minting signal. `context` is required, which is the only
+ # thing that makes agents supply intent at all.
+ assert "context" in add.inputSchema["required"]
+ assert any(t.name == "get_more_tools" for t in listed)
+
+ r1 = await client.call_tool(
+ "add_todo",
+ {
+ "text": "hi",
+ "context": "Adding a todo item for the user's task list to track work",
+ },
+ )
+ text = _text(r1)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+
+ r2 = await client.call_tool("add_todo", {"text": "again", "session_id": minted})
+ assert MINT_BACK_HEADER not in _text(r2)
+
+ call_events = _call_events(capture)
+ # v2 publishes tools/call and nothing else: no initialize, no tools/list,
+ # no agentcat:identify.
+ assert {e.event_type for e in capture} == {"mcp:tools/call"}
+ assert len(call_events) == 2
+ assert call_events[0].session_id == minted == call_events[1].session_id
+ # The event records the call as the agent made it: raw, unstripped.
+ assert call_events[0].parameters["arguments"]["context"]
+ assert call_events[1].parameters["arguments"]["session_id"] == minted
+ # ...and the customer's result, undecorated.
+ assert call_events[0].response is not None
+ assert "Added todo" in json.dumps(call_events[0].response)
+ assert "[MCP INSTRUCTIONS]" not in json.dumps(call_events[0].response)
+ assert call_events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ assert call_events[1].tags[AGENTCAT_TAG_SESSION_SOURCE] == "supplied"
+ assert call_events[0].user_intent.startswith("Adding a todo item")
+
+
+async def test_structured_mint_back_mirrors_into_structured_content(capture):
+ """A tool with an output schema gets `_mcp_instructions` mirrored in, and
+ its schema declares the field so schema-validating clients still accept
+ it."""
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert MCP_INSTRUCTIONS_KEY in _named(listed, "add_todo").outputSchema[
+ "properties"
+ ]
+
+ result = await client.call_tool("add_todo", {"text": "structured"})
+ mint = result.structured_content[MCP_INSTRUCTIONS_KEY]
+ assert mint["session_id"].startswith("ses_")
+ assert mint["session_id"] == _call_events(capture)[0].session_id
+ # The customer's own structured payload survives untouched.
+ assert result.structured_content["result"].startswith("Added todo")
+
+
+async def test_handler_sees_stripped_args_and_customer_result_untouched(capture):
+ """The injected params never reach the tool body, and what the tool returned
+ is exactly what the agent gets back (minus AgentCat's trailing block)."""
+ seen: dict = {}
+ server = _new_server()
+
+ @server.tool
+ def probe(text: str) -> str:
+ """A tool that would fail validation if handed an unexpected argument."""
+ seen["text"] = text
+ return f"probe:{text}"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
+ result = await client.call_tool(
+ "probe",
+ {"text": "payload", "session_id": sid("supplied"), "context": "why"},
+ )
+
+ assert result.is_error is False, _text(result)
+ assert seen == {"text": "payload"}
+ assert result.content[0].text == "probe:payload"
+ # session_id was supplied, so nothing is minted back and nothing is appended.
+ assert len(result.content) == 1
+ assert _call_events(capture)[0].session_id == sid("supplied")
+
+
+async def test_get_more_tools_keeps_its_own_context_and_publishes(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ gmt = _named(listed, "get_more_tools")
+ # Its bespoke `context` is a real parameter: still required, still
+ # described by the tool's own copy — and handles ride alongside.
+ assert gmt.inputSchema["required"] == ["context"]
+ assert gmt.inputSchema["properties"]["context"]["description"].startswith(
+ "A description of your goal"
+ )
+ assert "session_id" in gmt.inputSchema["properties"]
+ assert gmt.annotations.readOnlyHint is True
+
+ result = await client.call_tool(
+ "get_more_tools", {"context": "I need a tool to send emails"}
+ )
+ assert "Unfortunately" in _text(result)
+
+ event = _call_events(capture)[0]
+ assert event.resource_name == "get_more_tools"
+ assert event.user_intent == "I need a tool to send emails"
+ assert event.parameters["arguments"]["context"] == "I need a tool to send emails"
+
+
+async def test_get_more_tools_absent_when_disabled(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=False))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+
+ assert not any(t.name == "get_more_tools" for t in listed)
+
+
+async def test_customer_get_more_tools_is_never_replaced(capture):
+ """A customer tool that happens to be named `get_more_tools` keeps running.
+
+ Registering ours over theirs would silently swap their handler for our
+ canned reply, and nothing AgentCat does may alter tool behavior (spec §12).
+ """
+ server = _new_server("collision-server")
+
+ @server.tool
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, which happens to share our name."""
+ return f"customer answered: {context}"
+
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ # Exactly one, and it is theirs — ours is not registered alongside it.
+ assert [t.name for t in listed] == ["get_more_tools"]
+ assert listed[0].description.startswith("The customer's own tool")
+
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert result.is_error is False, _text(result)
+ assert "customer answered: mine" in _text(result)
+ assert "Unfortunately" not in _text(result)
+ # Still tracked like any other tool call.
+ assert _call_events(capture)[0].resource_name == "get_more_tools"
+
+
+async def test_a_mounted_customer_get_more_tools_is_never_usurped(capture):
+ """A customer tool can arrive from a provider the install-time gate cannot
+ see — a mounted sub-server, a proxy, an OpenAPI provider — and the local
+ provider wins aggregation, so ours would answer for theirs. The first
+ listing is the full provider view, and it concedes the name.
+ """
+ parent = _new_server("parent")
+ sub = _new_server("sub")
+
+ @sub.tool
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, supplied by a mounted sub-server."""
+ return f"customer answered: {context}"
+
+ parent.mount(sub)
+ track(parent, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(parent) as client:
+ listed = await client.list_tools()
+ # Exactly one, and it is theirs.
+ assert [t.name for t in listed] == ["get_more_tools"]
+ assert listed[0].description.startswith("The customer's own tool")
+
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert "customer answered: mine" in _text(result)
+ assert "Unfortunately" not in _text(result)
+ # And ours is gone from the registry, so the call path routes to theirs too.
+ names = [t.name for t in await parent.list_tools(run_middleware=False)]
+ assert names.count("get_more_tools") == 1
+ assert _call_events(capture)[0].resource_name == "get_more_tools"
+
+
+async def test_a_mounted_get_more_tools_survives_a_re_track(capture):
+ """The local gate cannot tell OUR get_more_tools from a customer's, so a
+ re-track skips registration — and the fresh middleware must inherit the
+ marker or it could never concede the name again."""
+ parent = _new_server("parent")
+ sub = _new_server("sub")
+
+ @sub.tool
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, supplied by a mounted sub-server."""
+ return f"customer answered: {context}"
+
+ parent.mount(sub)
+ # No listing in between: a listing would self-heal it and hide the bug.
+ track(parent, "proj_test", AgentCatOptions(enable_report_missing=True))
+ track(parent, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(parent) as client:
+ listed = await client.list_tools()
+ assert [t.description for t in listed] == [
+ "The customer's own tool, supplied by a mounted sub-server."
+ ]
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert "customer answered: mine" in _text(result)
+ assert "Unfortunately" not in _text(result)
+
+
+async def test_a_call_before_any_listing_concedes_get_more_tools(capture):
+ """rebuild-on-demand reads the same all-provider view, so the case the
+ eager registration exists for is also the case it must not hijack.
+
+ Driven through `server.call_tool`, which runs the middleware chain without
+ a tools/list: a real FastMCP client fetches output schemas on its way to a
+ call, so it can never reproduce a genuinely un-listed instance.
+ """
+ parent = _new_server("parent")
+ sub = _new_server("sub")
+
+ @sub.tool
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, supplied by a mounted sub-server."""
+ return f"customer answered: {context}"
+
+ parent.mount(sub)
+ track(parent, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ result = await parent.call_tool("get_more_tools", {"context": "mine"})
+
+ assert "customer answered: mine" in _text(result)
+ assert "Unfortunately" not in _text(result)
+ assert _call_events(capture)[0].resource_name == "get_more_tools"
+
+
+async def test_a_middleware_that_copies_tools_keeps_get_more_tools_working(capture):
+ """A layer below us handing back `model_copy` results must not make our own
+ tool read as a customer's.
+
+ Detecting ours by object identity alone did: the copy looked foreign, ours
+ was un-registered, and because the copy was not the object being filtered
+ it stayed in the listing — advertising a `get_more_tools` whose very next
+ call raised `Unknown tool`.
+ """
+ from fastmcp.server.middleware import Middleware
+
+ class Copier(Middleware):
+ async def on_list_tools(self, context, call_next):
+ return [t.model_copy(update={}) for t in await call_next(context)]
+
+ server = _new_server("copy-server")
+
+ @server.tool
+ def add_todo(text: str) -> str:
+ """Add a todo."""
+ return f"added {text}"
+
+ server.add_middleware(Copier())
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ first = sorted(t.name for t in await client.list_tools())
+ assert first == ["add_todo", "get_more_tools"]
+
+ # The listing advertised it, so it has to answer.
+ result = await client.call_tool("get_more_tools", {"context": "why"})
+ assert "Unfortunately" in _text(result)
+
+ # ...and it is still advertised on the next listing.
+ assert sorted(t.name for t in await client.list_tools()) == first
+
+ names = [t.name for t in await server.list_tools(run_middleware=False)]
+ assert "get_more_tools" in names, "our tool was un-registered on a false positive"
+
+
+async def test_a_middleware_that_rebuilds_tools_does_not_lose_get_more_tools(capture):
+ """The authoritative re-check, and the only shape that needs it.
+
+ `_is_ours` recognizes a copy of our tool three ways: object identity, the
+ underlying `fn` a `model_copy` carries over, and our canonical description.
+ A layer that REBUILDS each tool with `Tool.from_function` and a description
+ of its own defeats all three — and that is not exotic: it is what any
+ middleware that re-stamps or re-documents a listing does.
+
+ Ours then reads as a foreign `get_more_tools` in the processed listing.
+ Conceding on that would un-register the real tool while the rebuilt copy
+ stays advertised, so the very next call to it raises `Unknown tool`. The
+ re-check asks the RAW provider listing — where our own object is present
+ and recognizable — and answers "nobody else supplies this".
+ """
+ from fastmcp.server.middleware import Middleware
+ from fastmcp.tools import Tool
+
+ class Rebuilder(Middleware):
+ """Hands back tools it built itself, not copies of the originals."""
+
+ async def on_list_tools(self, context, call_next):
+ async def rebuilt_body(context: str = "") -> str:
+ return "rebuilt"
+
+ return [
+ Tool.from_function(
+ rebuilt_body,
+ name=tool.name,
+ description=f"rebuilt: {tool.description}",
+ ).model_copy(update={"parameters": tool.parameters})
+ for tool in await call_next(context)
+ ]
+
+ server = _new_server("rebuilding-server")
+
+ @server.tool
+ def add_todo(text: str) -> str:
+ """Add a todo."""
+ return f"added {text}"
+
+ server.add_middleware(Rebuilder())
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert sorted(t.name for t in listed) == ["add_todo", "get_more_tools"]
+ # Nothing in that listing is recognizable as ours any more.
+ assert all(t.description.startswith("rebuilt: ") for t in listed)
+
+ # The listing advertised it, so it has to answer — and the answer is
+ # ours, from the provider, because ours is still registered.
+ result = await client.call_tool("get_more_tools", {"context": "why"})
+ assert "Unfortunately" in _text(result)
+
+ names = [t.name for t in await server.list_tools(run_middleware=False)]
+ assert "get_more_tools" in names, "ours was un-registered on a false positive"
+
+
+async def test_a_copying_middleware_still_concedes_to_a_real_owner(capture):
+ """The copy tolerance must not blind the concession: with a genuine
+ customer tool present, ours goes even though both arrive as copies."""
+ from fastmcp.server.middleware import Middleware
+
+ class Copier(Middleware):
+ async def on_list_tools(self, context, call_next):
+ return [t.model_copy(update={}) for t in await call_next(context)]
+
+ parent = _new_server("parent")
+ sub = _new_server("sub")
+
+ @sub.tool
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, supplied by a mounted sub-server."""
+ return f"customer answered: {context}"
+
+ parent.mount(sub)
+ parent.add_middleware(Copier())
+ track(parent, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(parent) as client:
+ listed = await client.list_tools()
+ # Exactly one — ours was dropped from the list even as a copy.
+ assert [t.description for t in listed] == [
+ "The customer's own tool, supplied by a mounted sub-server."
+ ]
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert "customer answered: mine" in _text(result)
+
+
+async def test_conceding_get_more_tools_never_deletes_the_customers_tool(capture):
+ """The un-registration is by object identity, so a customer who registers
+ their own on the LOCAL provider after track() keeps it."""
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ # Replaces ours in the local store under the same key (on_duplicate=warn).
+ @server.tool
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, registered after track()."""
+ return f"customer answered: {context}"
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert [t.name for t in listed].count("get_more_tools") == 1
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert "customer answered: mine" in _text(result)
+ names = [t.name for t in await server.list_tools(run_middleware=False)]
+ assert names.count("get_more_tools") == 1
+
+
+async def test_an_unreadable_tool_registry_skips_registration(capture, monkeypatch):
+ """Registration is gated positively: only a registry we could actually read
+ and found clean lets us add our tool over the customer's server."""
+ from agentcat.modules.adapters import community
+
+ logged: list[str] = []
+ monkeypatch.setattr(community, "write_to_log", logged.append)
+
+ server = create_community_todo_server()
+ monkeypatch.setattr(server, "_local_provider", None, raising=False)
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+
+ assert any(t.name == "add_todo" for t in listed), "the server is still serving"
+ assert not any(t.name == "get_more_tools" for t in listed)
+ assert any("could not read the tool registry" in line for line in logged), logged
+
+
+async def test_agent_tracking_injection(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_agent_tracking=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ add = _named(listed, "add_todo")
+ assert list(add.inputSchema["properties"])[-3:] == [
+ "session_id",
+ "agent_id",
+ "context",
+ ]
+ assert "agent_id" in add.inputSchema["required"]
+ assert "session_id" not in add.inputSchema["required"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "with agent", "agent_id": "opus|claude-code|k3n9x"}
+ )
+ assert result.is_error is False, _text(result)
+
+ event = _call_events(capture)[0]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == "opus|claude-code|k3n9x"
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+
+
+async def test_hook_mode(capture):
+ server = create_community_todo_server()
+ track(
+ server,
+ "proj_test",
+ AgentCatOptions(resolve_session_id=lambda request, extra: "cust-1"),
+ )
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert listed
+ for tool in listed:
+ assert "session_id" not in tool.inputSchema.get("properties", {})
+
+ r1 = await client.call_tool("add_todo", {"text": "hook one"})
+ r2 = await client.call_tool("add_todo", {"text": "hook two"})
+ assert "[MCP INSTRUCTIONS]" not in _text(r1)
+ assert "[MCP INSTRUCTIONS]" not in _text(r2)
+ assert MCP_INSTRUCTIONS_KEY not in (r1.structured_content or {})
+
+ call_events = _call_events(capture)
+ expected = derive_session_id("cust-1", "proj_test")
+ assert [e.session_id for e in call_events] == [expected, expected]
+ assert call_events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "hook"
+
+
+async def test_hook_receives_the_live_request_every_round(capture):
+ """A ResolvedCall is single-round state: the hook must see each round's own
+ message, never a cached first-round one."""
+ seen: list = []
+
+ def hook(request, extra):
+ seen.append(getattr(request, "arguments", None))
+ return "cust-1"
+
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(resolve_session_id=hook))
+
+ async with create_community_test_client(server) as client:
+ await client.call_tool("add_todo", {"text": "first"})
+ await client.call_tool("add_todo", {"text": "second"})
+
+ assert [args.get("text") for args in seen] == ["first", "second"]
+
+
+async def test_tracing_disabled_strips_but_publishes_nothing(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_tracing=False))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ add = _named(listed, "add_todo")
+ # No handles when tracing is off, but the context parameter is
+ # independent — so it must still be stripped before the tool runs.
+ assert "session_id" not in add.inputSchema["properties"]
+ assert "context" in add.inputSchema["properties"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "quiet", "context": "no tracing"}
+ )
+ assert result.is_error is False, _text(result)
+ assert "[MCP INSTRUCTIONS]" not in _text(result)
+
+ assert capture == []
+
+
+async def test_resolution_failure_degrades_to_an_untraced_call(capture, monkeypatch):
+ """A tool call must never fail because analytics did — and the injected
+ parameters are still stripped on the way down."""
+ from agentcat.modules.adapters import community
+
+ async def boom(*args, **kwargs):
+ raise RuntimeError("resolver exploded")
+
+ monkeypatch.setattr(community, "resolve_call", boom)
+
+ server = create_community_todo_server()
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
+ result = await client.call_tool(
+ "add_todo", {"text": "degraded", "context": "why", "session_id": sid("x")}
+ )
+
+ assert result.is_error is False, _text(result)
+ assert "Added todo" in _text(result)
+ assert capture == []
+
+
+async def test_customer_tool_schema_is_never_mutated(capture):
+ """Injection works on copies: the server's own tool definitions and a
+ second, untracked server built the same way stay identical."""
+ server = create_community_todo_server()
+ reference = create_community_todo_server()
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
+ await client.list_tools()
+
+ tracked = {
+ t.name: t.parameters for t in await server.list_tools(run_middleware=False)
+ }
+ untracked = {
+ t.name: t.parameters for t in await reference.list_tools(run_middleware=False)
+ }
+ assert {k: v for k, v in tracked.items() if k != "get_more_tools"} == untracked
+
+
+async def test_tool_error_publishes_the_event_and_reraises(capture):
+ """FastMCP surfaces a failing tool as a raised ToolError, so unlike the
+ official adapters this path holds the live exception: the community error
+ payload keeps its type, its traceback and the cause chain, and the error
+ itself reaches the client untouched."""
+ from fastmcp.exceptions import ToolError
+
+ server = create_community_todo_server()
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
+ with pytest.raises(ToolError):
+ await client.call_tool("complete_todo", {"id": 999})
+
+ event = _call_events(capture)[0]
+ assert event.is_error is True
+ assert event.session_id.startswith("ses_")
+ assert "Todo with ID 999 not found" in event.error["message"]
+ assert event.error["type"] == "ToolError"
+ assert event.error["platform"] == "python"
+ assert event.error["frames"], "the live exception's traceback was lost"
+ assert "ValueError" in [c["type"] for c in event.error["chained_errors"]]
+ assert event.duration is not None and event.duration >= 0
+
+
+async def test_a_tool_returning_is_error_records_the_surfaced_message(capture):
+ """A tool that returns is_error instead of raising never had an exception
+ to tap, so the payload is the SDK-wide no-tap shape built from the wire
+ result — not a pydantic repr of FastMCP's ToolResult."""
+ from fastmcp.server.middleware import Middleware
+ from mcp.types import TextContent
+
+ from ..test_utils import error_tool_result
+
+ class Failing(Middleware):
+ async def on_call_tool(self, context, call_next):
+ return error_tool_result(
+ content=[TextContent(type="text", text="the tool said no")],
+ is_error=True,
+ )
+
+ server = _new_server("is-error-server")
+
+ @server.tool(output_schema=None)
+ def flaky() -> str:
+ """Answered by the middleware below AgentCat."""
+ return "unused"
+
+ server.add_middleware(Failing())
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ result = await client.call_tool("flaky", {}, raise_on_error=False)
+
+ assert result.is_error is True
+ event = _call_events(capture)[0]
+ assert event.is_error is True
+ assert event.error["message"] == "the tool said no"
+ assert event.error["type"] is None
+ assert event.error["platform"] == "python"
+ # An error result still carries the handle: the retry has to be the same
+ # task.
+ assert MINT_BACK_HEADER in _text(result)
+
+
+async def test_retracking_updates_options_without_double_wrapping(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=False))
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert [t.name for t in listed].count("get_more_tools") == 1
+ add = _named(listed, "add_todo")
+ assert list(add.inputSchema["properties"]) == ["text", "session_id", "context"]
+
+ # A second, stacked middleware would inject session_id in the inner pass
+ # and then skip it in the outer one — leaving it out of the strip
+ # registry and handing the customer's tool a parameter it never
+ # declared.
+ result = await client.call_tool(
+ "add_todo", {"text": "retracked", "session_id": sid("retrack")}
+ )
+ assert result.is_error is False, _text(result)
+
+ assert len(_call_events(capture)) == 1
+ assert _call_events(capture)[0].session_id == sid("retrack")
+
+
+async def test_agentcat_middleware_is_outermost(capture):
+ """Element 0 of `server.middleware` runs first, so a caching or
+ dereferencing middleware below us keys on STRIPPED arguments and never
+ caches our injection."""
+ from fastmcp.server.middleware import Middleware
+
+ below: dict = {}
+
+ class Probe(Middleware):
+ async def on_call_tool(self, context, call_next):
+ below["arguments"] = dict(context.message.arguments or {})
+ return await call_next(context)
+
+ async def on_list_tools(self, context, call_next):
+ tools = list(await call_next(context))
+ below["schemas"] = {t.name: dict(t.parameters or {}) for t in tools}
+ return tools
+
+ server = create_community_todo_server()
+ server.add_middleware(Probe())
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
+ await client.call_tool(
+ "add_todo", {"text": "layered", "context": "why", "session_id": sid("z")}
+ )
+
+ assert below["arguments"] == {"text": "layered"}
+ assert "session_id" not in below["schemas"]["add_todo"]["properties"]
+
+
+async def test_options_are_read_per_request_not_captured_at_install(capture):
+ """The middleware re-reads the server's tracking data every request, so
+ swapping it in place takes effect on the next call."""
+ from dataclasses import replace
+
+ from agentcat.modules.internal import (
+ get_server_tracking_data,
+ set_server_tracking_data,
+ )
+
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_agent_tracking=False))
+
+ installed = get_server_tracking_data(server)
+ set_server_tracking_data(
+ server,
+ replace(
+ installed,
+ options=AgentCatOptions(enable_agent_tracking=True),
+ injected_params_registry=None,
+ output_injection_registry=None,
+ ),
+ )
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert "agent_id" in _named(listed, "add_todo").inputSchema["properties"]
+ result = await client.call_tool(
+ "add_todo", {"text": "swapped", "agent_id": "opus|cc|abc12"}
+ )
+
+ assert result.is_error is False, _text(result)
+ assert _call_events(capture)[0].tags[AGENTCAT_TAG_AGENT_ID] == "opus|cc|abc12"
+
+
+# ── the handshake rung (design §7, rung 3) ──────────────────────────────────
+#
+# One middleware object serves every connection, so the rung that remembers
+# what `initialize` said has to be filed per connection or it will rename other
+# people's calls. The doubles below reproduce the shapes real FastMCP v3 hands
+# the middleware, verified against a live server on both transports:
+#
+# stateful initialize: fastmcp_context.request_context is None, .session=S
+# tools/call: request_context.session is the SAME S
+# stateless every request gets a NEW session, and it carries no
+# client_params — so the ladder always reaches this rung
+#
+# `client_params = None` on the double is the stateless shape on purpose: with
+# it set, rung 3a answers first and this rung is never exercised at all.
+
+
+class _FakeSession:
+ """A weak-referenceable ServerSession stand-in with no handshake recorded."""
+
+ client_params = None
+ session_id = None
+
+
+class _FakeRequestContext:
+ def __init__(self, session: object) -> None:
+ self.session = session
+ self.request = None
+ self.request_id = None
+ self.meta = None
+
+
+class _FakeConnection:
+ """One connection's FastMCP `Context`, before and during a request."""
+
+ def __init__(self) -> None:
+ self.session = _FakeSession()
+ self.request_context = None
+ self.session_id = None
+
+ def in_request(self) -> "_FakeConnection":
+ self.request_context = _FakeRequestContext(self.session)
+ return self
+
+
+async def _ok_result(_ctx):
+ from fastmcp.tools import ToolResult
+ from mcp.types import TextContent
+
+ return ToolResult(content=[TextContent(type="text", text="ok")])
+
+
+async def _handshake(middleware, connection, name: str, version: str) -> None:
+ from fastmcp.server.middleware import MiddlewareContext
+ from mcp.types import Implementation, InitializeRequest, InitializeRequestParams
+
+ initialize = InitializeRequest(
+ method="initialize",
+ params=InitializeRequestParams(
+ protocolVersion="2025-06-18",
+ capabilities={},
+ clientInfo=Implementation(name=name, version=version),
+ ),
+ )
+ await middleware(
+ MiddlewareContext(
+ message=initialize, method="initialize", fastmcp_context=connection
+ ),
+ _ok_result,
+ )
+
+
+async def _tool_call(middleware, connection, meta: dict | None = None) -> None:
+ from fastmcp.server.middleware import MiddlewareContext
+ from mcp.types import CallToolRequestParams
+
+ params = CallToolRequestParams(name="add_todo", arguments={"text": "x"})
+ if meta is not None:
+ params = params.model_copy(update={"meta": meta})
+ await middleware(
+ MiddlewareContext(
+ message=params, method="tools/call", fastmcp_context=connection.in_request()
+ ),
+ _ok_result,
+ )
+
+
+async def test_handshake_client_info_is_the_last_rung_of_the_ladder(capture):
+ """initialize publishes nothing, but what it captures still names the
+ client when no per-request source does (design §7, rung 3).
+
+ It is the LAST rung on purpose: a per-request source always wins.
+ """
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ middleware = server.middleware[0]
+
+ connection = _FakeConnection()
+ await _handshake(middleware, connection, "Cursor", "2.6.22")
+
+ await _tool_call(middleware, connection)
+ assert _call_events(capture)[-1].client_name == "Cursor"
+ assert _call_events(capture)[-1].client_version == "2.6.22"
+
+ await _tool_call(
+ middleware,
+ connection,
+ meta={META_CLIENT_INFO_KEY: {"name": "Claude", "version": "1.0.0"}},
+ )
+ assert _call_events(capture)[-1].client_name == "Claude"
+ assert _call_events(capture)[-1].client_version == "1.0.0"
+
+
+async def test_a_later_handshake_never_renames_an_earlier_connections_call(capture):
+ """Two clients, one middleware: B's initialize must not rename A's call.
+
+ A single "last seen" slot published Cursor's tool call as Claude on every
+ stateless-HTTP server — the session there carries no client_params, so the
+ ladder reaches this rung on every call, and whichever client connected most
+ recently owned it. The 1.x `stateless` option was the only guard against
+ that, and it is gone, so the rung is filed per connection instead.
+ """
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ middleware = server.middleware[0]
+
+ first, second = _FakeConnection(), _FakeConnection()
+ await _handshake(middleware, first, "Cursor", "2.6.22")
+ await _handshake(middleware, second, "Claude", "1.0.0")
+
+ await _tool_call(middleware, first)
+ assert _call_events(capture)[-1].client_name == "Cursor"
+ assert _call_events(capture)[-1].client_version == "2.6.22"
+
+ await _tool_call(middleware, second)
+ assert _call_events(capture)[-1].client_name == "Claude"
+ assert _call_events(capture)[-1].client_version == "1.0.0"
+
+
+async def test_a_connection_that_never_handshook_gets_no_client_name(capture):
+ """The stateless-HTTP shape: a fresh session per request, none of which
+ handshook. The honest answer is "unknown", never the last name we saw."""
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ middleware = server.middleware[0]
+
+ await _handshake(middleware, _FakeConnection(), "Cursor", "2.6.22")
+
+ await _tool_call(middleware, _FakeConnection())
+ assert _call_events(capture)[-1].client_name is None
+ assert _call_events(capture)[-1].client_version is None
+
+
+async def test_strip_preserves_the_rest_of_the_request_message(capture):
+ """The stripped message is a copy of the customer's, not a rebuilt
+ `CallToolRequestParams`: rebuilding drops `_meta` (and, in the 2026 era,
+ `input_responses`/`request_state`), which severs MRTR continuations."""
+ from fastmcp.server.middleware import MiddlewareContext
+ from fastmcp.tools import ToolResult
+ from mcp.types import CallToolRequestParams, TextContent
+
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ middleware = server.middleware[0]
+
+ seen: dict = {}
+
+ async def call_next(ctx):
+ seen["message"] = ctx.message
+ return ToolResult(content=[TextContent(type="text", text="ok")])
+
+ message = CallToolRequestParams(
+ name="add_todo",
+ arguments={"text": "hi", "context": "why"},
+ _meta={"trace": "abc"},
+ )
+ await middleware(
+ MiddlewareContext(message=message, method="tools/call"), call_next
+ )
+
+ assert seen["message"].arguments == {"text": "hi"}
+ assert getattr(seen["message"].meta, "trace", None) == "abc"
+ assert seen["message"].meta is message.meta
+ # The customer's own message object is untouched.
+ assert message.arguments == {"text": "hi", "context": "why"}
+
+
+@pytest.mark.parametrize("flavor", ["by_type_name", "by_result_type"])
+async def test_intermediate_mrtr_round_is_tagged_but_never_decorated(capture, flavor):
+ """A round that asks the client for more input is not the completing round,
+ so it carries no mint-back — text or structured."""
+ from fastmcp.server.middleware import Middleware
+ from fastmcp.tools import ToolResult
+ from mcp.types import TextContent
+
+ class InputRequiredToolResult(ToolResult):
+ pass
+
+ class LaterEraToolResult(ToolResult):
+ result_type: str = "input_required"
+
+ cls = InputRequiredToolResult if flavor == "by_type_name" else LaterEraToolResult
+
+ class Intermediate(Middleware):
+ async def on_call_tool(self, context, call_next):
+ return cls(content=[TextContent(type="text", text="need more")])
+
+ server = _new_server("mrtr-server")
+
+ @server.tool(output_schema=None)
+ def ask(text: str) -> str:
+ """Needs another round."""
+ return text
+
+ server.add_middleware(Intermediate())
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ result = await client.call_tool("ask", {"text": "round one"})
+
+ assert [block.text for block in result.content] == ["need more"]
+ assert result.structured_content is None
+ event = _call_events(capture)[0]
+ assert event.tags["agentcat_mrtr"] == "input_required"
+ assert event.session_id.startswith("ses_")
+
+
+async def test_tools_added_after_track_are_injected_and_tracked(capture):
+ """track() may run before a single tool exists: the middleware reads the
+ provider's listing per request, so late registrations are covered."""
+ server = _new_server("late-server")
+ track(server, "proj_test")
+
+ @server.tool
+ def late(value: str) -> str:
+ """Registered after track()."""
+ return f"late:{value}"
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert "session_id" in _named(listed, "late").inputSchema["properties"]
+
+ result = await client.call_tool(
+ "late", {"value": "v", "session_id": sid("late")}
+ )
+
+ assert "late:v" in _text(result)
+ assert _call_events(capture)[0].resource_name == "late"
+ assert _call_events(capture)[0].session_id == sid("late")
+
+
+async def test_a_call_before_any_listing_rebuilds_the_strip_registry(capture):
+ """tools/call may land on an instance that never served tools/list; the
+ rebuild reads the server's own listing so the strip still matches."""
+ seen: dict = {}
+ server = _new_server("rebuild-server")
+
+ @server.tool
+ def probe(text: str) -> str:
+ """Would fail validation if handed an argument it never declared."""
+ seen["text"] = text
+ return "ok"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ # No list_tools first.
+ result = await client.call_tool(
+ "probe", {"text": "t", "session_id": sid("rb"), "context": "why"}
+ )
+
+ assert result.is_error is False, _text(result)
+ assert seen == {"text": "t"}
+ assert _call_events(capture)[0].session_id == sid("rb")
+
+
+async def test_tool_with_its_own_context_parameter_is_left_alone(capture):
+ """A customer parameter named `context` is theirs: it is neither
+ re-described nor stripped."""
+ seen: dict = {}
+ server = _new_server("context-server")
+
+ @server.tool
+ def search(query: str, context: str) -> str:
+ """A tool whose own API already has a context argument."""
+ seen.update(query=query, context=context)
+ return "found"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ schema = _named(listed, "search").inputSchema
+ assert "description" not in schema["properties"]["context"]
+ assert schema["required"] == ["query", "context"]
+
+ await client.call_tool("search", {"query": "q", "context": "theirs"})
+
+ assert seen == {"query": "q", "context": "theirs"}
+ # It is still read as the intent — the event records what the agent sent.
+ assert _call_events(capture)[0].user_intent == "theirs"
+
+
+async def test_two_tracked_servers_stay_isolated(capture):
+ """Registries and project IDs are per-server; one server's listing never
+ settles another's strip."""
+ first = _new_server("first")
+ second = _new_server("second")
+
+ @first.tool
+ def alpha(a: str) -> str:
+ """First server's tool."""
+ return "alpha"
+
+ @second.tool
+ def beta(b: str) -> str:
+ """Second server's tool."""
+ return "beta"
+
+ track(first, "proj_one", AgentCatOptions(enable_report_missing=False))
+ track(second, "proj_two", AgentCatOptions(enable_agent_tracking=True))
+
+ async with create_community_test_client(first) as client:
+ listed = await client.list_tools()
+ assert "agent_id" not in _named(listed, "alpha").inputSchema["properties"]
+ await client.call_tool("alpha", {"a": "1", "session_id": sid("one")})
+
+ async with create_community_test_client(second) as client:
+ listed = await client.list_tools()
+ assert "agent_id" in _named(listed, "beta").inputSchema["properties"]
+ await client.call_tool("beta", {"b": "2", "session_id": sid("two")})
+
+ events = _call_events(capture)
+ assert [e.resource_name for e in events] == ["alpha", "beta"]
+ assert [e.session_id for e in events] == [sid("one"), sid("two")]
+ assert [e.project_id for e in events] == ["proj_one", "proj_two"]
+
+
+async def test_event_carries_server_and_sdk_metadata(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.call_tool("add_todo", {"text": "meta"})
+
+ event = _call_events(capture)[0]
+ assert event.server_name == "todo-server"
+ assert event.sdk_language.startswith("Python ")
+ assert event.duration is not None and event.duration >= 0
+
+
+def test_a_tracked_server_is_collectable_once_the_customer_drops_it():
+ """Nothing AgentCat holds may outlive the server (changelog §6.8).
+
+ The community adapter keeps no module-level per-server map — the middleware
+ lives in the server's own `middleware` list, and its per-connection
+ handshake cache is weakly keyed on sessions by values that do not reference
+ them — so this is a guard against that changing, and the community half of
+ the same audit the official adapters carry.
+ """
+ import gc
+ import weakref
+
+ alive = []
+ for _ in range(3):
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ alive.append(weakref.ref(server))
+ del server
+
+ gc.collect()
+ assert [ref() for ref in alive] == [None, None, None]
diff --git a/tests/community/test_community_v3_inject_context.py b/tests/community/test_community_v3_inject_context.py
index 4f5ffd7..bdeb7cd 100644
--- a/tests/community/test_community_v3_inject_context.py
+++ b/tests/community/test_community_v3_inject_context.py
@@ -1,29 +1,30 @@
-"""Regression tests for context injection in the FastMCP v3 middleware.
-
-Root cause reproduced here: OpenAPI-generated FastMCP v3 tools hold a reference
-to an ``httpx.AsyncClient``, which contains a ``threading.RLock``. The old
-implementation did ``copy.deepcopy(tool)`` to inject the ``context`` parameter,
-which raised ``TypeError: cannot pickle '_thread.RLock' object`` for every such
-tool on every ``tools/list`` — silently dropping context injection and flooding
-diagnostics with errors (observed for proj_3E07PMEFqZoF9sc6QeWvoaNbpet).
+"""Regression guard: the community adapter never deep-copies a customer Tool.
+
+Root cause reproduced here: OpenAPI-generated FastMCP tools hold a reference to
+an ``httpx.AsyncClient``, which contains a ``threading.RLock``. A middleware
+that did ``copy.deepcopy(tool)`` to inject parameters raised ``TypeError: cannot
+pickle '_thread.RLock' object`` for every such tool on every ``tools/list`` —
+silently dropping injection and flooding diagnostics with errors (observed for
+proj_3E07PMEFqZoF9sc6QeWvoaNbpet).
+
+The v2 adapter copies only the schema dicts and rebuilds each tool with
+``model_copy(update=...)``, so the invariant is: injection succeeds on a tool
+the interpreter refuses to deep-copy, and the customer's original is untouched.
"""
-import asyncio
-from datetime import datetime, timezone
-
import copy
+
import pytest
-from agentcat.modules.overrides.community_v3 import middleware as v3_middleware
-from agentcat.modules.overrides.community_v3.middleware import AgentCatMiddleware
-from agentcat.types import AgentCatData, AgentCatOptions, SessionInfo
+from agentcat.modules.constants import CONTEXT_PARAM, SESSION_ID_PARAM
+from agentcat.types import AgentCatData, AgentCatOptions
-# The community_v3 middleware and FastMCP.from_openapi are FastMCP v3+ only. Skip
-# this module entirely when FastMCP is absent (test-without-fastmcp job) or on the
-# v2 compatibility matrix, without importing fastmcp at module top level.
+# FastMCP.from_openapi is FastMCP v3+ only. Skip this module entirely when
+# FastMCP is absent (test-without-fastmcp job), without importing it at module
+# top level.
try:
- import httpx
import fastmcp
+ import httpx
from fastmcp import FastMCP
HAS_FASTMCP_V3 = int(fastmcp.__version__.split(".")[0]) >= 3
@@ -32,22 +33,19 @@
pytestmark = pytest.mark.skipif(
not HAS_FASTMCP_V3,
- reason="Requires FastMCP v3+ (community_v3 OpenAPI middleware path)",
+ reason="Requires FastMCP v3+ (community OpenAPI provider)",
)
def _make_data() -> AgentCatData:
return AgentCatData(
project_id="test_project",
- session_id="test_session",
- session_info=SessionInfo(client_name="TestClient", client_version="1.0.0"),
- last_activity=datetime.now(timezone.utc),
options=AgentCatOptions(custom_context_description="Why are you doing this?"),
)
-def _openapi_tool():
- """An OpenAPI-generated tool holding an httpx client (non-deepcopyable)."""
+def _openapi_server():
+ """A FastMCP server whose only tool holds a live httpx client."""
spec = {
"openapi": "3.0.0",
"info": {"title": "acme", "version": "1"},
@@ -62,64 +60,79 @@ def _openapi_tool():
},
}
client = httpx.AsyncClient(base_url="https://example.com")
- server = FastMCP.from_openapi(openapi_spec=spec, client=client, name="acme")
- tools = server.list_tools()
- if asyncio.iscoroutine(tools):
- tools = asyncio.run(tools)
- return server, tools[0]
+ return FastMCP.from_openapi(openapi_spec=spec, client=client, name="acme")
+
+
+async def _openapi_tool():
+ """An OpenAPI-generated tool holding an httpx client (non-deepcopyable)."""
+ server = _openapi_server()
+ return server, (await server.list_tools())[0]
+
+
+async def _inject(server, tools):
+ """Run the adapter's tools/list hook over a fixed tool list."""
+ from agentcat.modules.adapters.community import ERA_V3, AgentCatMiddleware
+ middleware = AgentCatMiddleware(_make_data(), server, ERA_V3)
-def test_openapi_tool_is_not_deepcopyable():
+ async def call_next(_context):
+ return tools
+
+ return await middleware.on_list_tools(_Context(), call_next)
+
+
+class _Context:
+ """The two attributes the tools/list hook reads off a MiddlewareContext."""
+
+ method = "tools/list"
+ message = None
+ fastmcp_context = None
+
+
+async def test_openapi_tool_is_not_deepcopyable():
"""Guard: confirms the repro condition (deepcopy raises on the RLock)."""
- _server, tool = _openapi_tool()
+ _server, tool = await _openapi_tool()
with pytest.raises(TypeError, match="cannot pickle '_thread.RLock' object"):
copy.deepcopy(tool)
-def test_context_injected_into_openapi_tool():
- """Context injection must succeed even for non-deepcopyable tools."""
- server, tool = _openapi_tool()
- middleware = AgentCatMiddleware(_make_data(), server)
+async def test_parameters_injected_into_an_openapi_tool():
+ """Injection must succeed even for non-deepcopyable tools."""
+ server, tool = await _openapi_tool()
- result = middleware._inject_context_into_tools([tool])
+ result = await _inject(server, [tool])
assert len(result) == 1
params = result[0].parameters
- assert "context" in params["properties"], "context was not injected"
- assert "context" in params["required"]
+ assert SESSION_ID_PARAM in params["properties"], "session_id was not injected"
+ assert CONTEXT_PARAM in params["properties"], "context was not injected"
assert (
- params["properties"]["context"]["description"] == "Why are you doing this?"
+ params["properties"][CONTEXT_PARAM]["description"] == "Why are you doing this?"
)
+ # Required, as in 1.x: a schema-validating client refusing to send a call
+ # without it is the only enforcement an injected parameter has.
+ assert CONTEXT_PARAM in params["required"]
-def test_original_tool_not_mutated():
+async def test_original_tool_not_mutated():
"""Injection must not mutate the server's original tool object."""
- server, tool = _openapi_tool()
- middleware = AgentCatMiddleware(_make_data(), server)
-
- middleware._inject_context_into_tools([tool])
-
- assert "context" not in (tool.parameters or {}).get("properties", {})
-
+ server, tool = await _openapi_tool()
+ before = copy.deepcopy(tool.parameters or {})
-def test_copy_failure_logs_fastmcp_version(monkeypatch):
- """If a tool still can't be copied, the log names the fastmcp version."""
- from importlib.metadata import version
+ result = await _inject(server, [tool])
- server, tool = _openapi_tool()
- middleware = AgentCatMiddleware(_make_data(), server)
+ assert result[0] is not tool
+ assert (tool.parameters or {}) == before
- def _boom(*args, **kwargs):
- raise RuntimeError("copy blew up")
- monkeypatch.setattr(v3_middleware.copy, "deepcopy", _boom)
+async def test_a_failed_injection_serves_the_customers_list(monkeypatch):
+ """If the pipeline blows up, the client still gets the customer's tools."""
+ from agentcat.modules.adapters import community
- logged: list[str] = []
- monkeypatch.setattr(v3_middleware, "write_to_log", lambda msg: logged.append(msg))
+ def boom(*args, **kwargs):
+ raise RuntimeError("injection exploded")
- result = middleware._inject_context_into_tools([tool])
+ monkeypatch.setattr(community, "build_injected_schemas", boom)
- assert result == [tool] # falls back to the untouched original
- assert len(logged) == 1
- assert f"fastmcp {version('fastmcp')}" in logged[0]
- assert "list_severities" in logged[0]
+ server, tool = await _openapi_tool()
+ assert await _inject(server, [tool]) == [tool]
diff --git a/tests/community/test_community_v3_openapi.py b/tests/community/test_community_v3_openapi.py
index 1caa96e..ac1ae57 100644
--- a/tests/community/test_community_v3_openapi.py
+++ b/tests/community/test_community_v3_openapi.py
@@ -1,22 +1,21 @@
-"""End-to-end tests for AgentCat against OpenAPI-generated FastMCP v3 servers.
+"""End-to-end tests for AgentCat against OpenAPI-generated FastMCP servers.
These drive the real middleware dispatch (``tools/list`` and ``tools/call``)
through a FastMCP ``Client`` against tools that hold a live ``httpx.AsyncClient``
(mock transport, no network). This is the coverage that was missing when the
-``copy.deepcopy(tool)`` regression (PR #38) shipped: the whole suite only ever
-exercised plain function tools, which deep-copy cleanly.
+``copy.deepcopy(tool)`` regression (PR #38) shipped: the rest of the suite only
+ever exercises plain function tools, which deep-copy cleanly.
"""
import copy
-import time
-from unittest.mock import patch
import pytest
from agentcat import AgentCatOptions, track
-from agentcat.modules.event_queue import EventQueue, set_event_queue
-from agentcat.modules.overrides.community_v3 import middleware as v3_middleware
+from agentcat.modules.adapters import community
+from agentcat.modules.constants import SESSION_ID_PARAM
+from ..test_utils import sid
from ..test_utils.community_client import create_community_test_client
from ..test_utils.community_openapi_server import (
HAS_FASTMCP_V3,
@@ -26,34 +25,24 @@
pytestmark = pytest.mark.skipif(
not HAS_FASTMCP_V3,
- reason="Requires FastMCP v3+ (OpenAPI middleware path)",
+ reason="Requires FastMCP v3+ (OpenAPI provider)",
)
CONTEXT_DESC = "Why are you making this tool call?"
-@pytest.fixture
-def captured_events():
- """Swap in a mock-backed event queue and collect published events."""
- from agentcat.modules.event_queue import event_queue as original_queue
- from unittest.mock import MagicMock
-
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
events: list = []
- mock_api_client = MagicMock()
-
- def capture_event(publish_event_request):
- events.append(publish_event_request)
+ from agentcat.modules import event_queue
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
- set_event_queue(EventQueue(api_client=mock_api_client))
- try:
- yield events
- finally:
- set_event_queue(original_queue)
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
def _options(**overrides) -> AgentCatOptions:
- base = dict(enable_tracing=True, custom_context_description=CONTEXT_DESC)
+ base = {"enable_tracing": True, "custom_context_description": CONTEXT_DESC}
base.update(overrides)
return AgentCatOptions(**base)
@@ -63,9 +52,8 @@ def _tool_call_events(events, name=None):
return [e for e in out if name is None or e.resource_name == name]
-@pytest.mark.asyncio
-async def test_list_tools_injects_context(captured_events):
- """Every OpenAPI tool exposes the injected context param to the client."""
+async def test_list_tools_injects_handles_and_context(capture):
+ """Every OpenAPI tool exposes the injected parameters to the client."""
server = create_community_openapi_server()
track(server, "test_project", _options())
@@ -77,31 +65,31 @@ async def test_list_tools_injects_context(captured_events):
assert name in by_name, f"{name} missing from list_tools"
schema = by_name[name].inputSchema
props = schema.get("properties", {})
+ assert SESSION_ID_PARAM in props, f"session_id not injected into {name}"
assert "context" in props, f"context not injected into {name}"
assert props["context"]["description"] == CONTEXT_DESC
- assert "context" in schema.get("required", []), f"context not required on {name}"
+ # session_id is never required — omitting it is the minting signal —
+ # but context is, as it was in 1.x.
+ assert SESSION_ID_PARAM not in schema.get("required", [])
+ assert "context" in schema["required"]
+
+async def test_no_copy_error_logged(capture, monkeypatch):
+ """A tools/list against OpenAPI tools must not log any injection failure."""
+ logged: list[str] = []
+ monkeypatch.setattr(community, "write_to_log", logged.append)
-@pytest.mark.asyncio
-async def test_no_copy_error_logged(captured_events):
- """A tools/list against OpenAPI tools must not log any copy failure."""
server = create_community_openapi_server()
track(server, "test_project", _options())
- with patch.object(v3_middleware, "write_to_log") as mock_log:
- async with create_community_test_client(server) as client:
- await client.list_tools()
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
- copy_errors = [
- c.args[0]
- for c in mock_log.call_args_list
- if c.args and "Error copying tool" in str(c.args[0])
- ]
- assert copy_errors == [], f"unexpected copy failures logged: {copy_errors}"
+ failures = [line for line in logged if "injection failed" in line]
+ assert failures == [], f"unexpected injection failures logged: {failures}"
-@pytest.mark.asyncio
-async def test_original_tools_not_mutated(captured_events):
+async def test_original_tools_not_mutated(capture):
"""Injection must not mutate the server's cached tools across repeated lists."""
server = create_community_openapi_server()
track(server, "test_project", _options())
@@ -111,14 +99,14 @@ async def raw_severity_props():
raw = {t.name: t for t in await server.list_tools(run_middleware=False)}
return (raw["get_severity"].parameters or {}).get("properties", {})
- # The server's own cached tool never gains a context param.
+ # The server's own cached tool never gains an injected param.
assert "context" not in await raw_severity_props()
async with create_community_test_client(server) as client:
first = {t.name: t.inputSchema for t in await client.list_tools()}
second = {t.name: t.inputSchema for t in await client.list_tools()}
- # Client sees context injected...
+ # Client sees the injection...
assert "context" in first["get_severity"]["properties"]
# ...repeated calls are stable...
assert first == second
@@ -126,103 +114,95 @@ async def raw_severity_props():
assert "context" not in await raw_severity_props()
-@pytest.mark.asyncio
-async def test_call_tool_strips_context_and_captures_intent(captured_events):
- """context is captured as intent and stripped before the downstream HTTP call."""
+async def test_call_tool_strips_injected_params_and_captures_intent(capture):
+ """Injected params are captured on the event and stripped before the
+ downstream HTTP call."""
requests: list = []
server = create_community_openapi_server(record_requests=requests)
track(server, "test_project", _options())
async with create_community_test_client(server) as client:
await client.call_tool(
- "get_severity", {"id": "42", "context": "investigating an outage"}
+ "get_severity",
+ {
+ "id": "42",
+ "context": "investigating an outage",
+ SESSION_ID_PARAM: sid("openapi"),
+ },
)
- time.sleep(1.0) # let the event-queue worker drain
- calls = _tool_call_events(captured_events, "get_severity")
+ calls = _tool_call_events(capture, "get_severity")
assert calls, "no tools/call event captured"
event = calls[-1]
assert event.user_intent == "investigating an outage"
- assert (event.parameters.get("arguments") or {}) == {"id": "42"}
- # The intent string must never reach the customer's backend.
+ assert event.session_id == sid("openapi")
+ # The event records the RAW arguments the agent sent.
+ assert event.parameters["arguments"]["context"] == "investigating an outage"
+ # Neither the intent nor the handle may reach the customer's backend.
assert requests, "downstream request was not recorded"
assert not any(b"investigating an outage" in (r.content or b"") for r in requests)
assert not any("investigating an outage" in str(r.url) for r in requests)
+ assert not any(sid("openapi") in str(r.url) for r in requests)
-@pytest.mark.asyncio
-async def test_call_tool_error_captured(captured_events):
- """A failing OpenAPI HTTP call surfaces to the client and is captured as an error."""
+async def test_call_tool_error_captured(capture):
+ """A failing OpenAPI HTTP call surfaces to the client and is captured."""
+ from fastmcp.exceptions import ToolError
+
server = create_community_openapi_server()
track(server, "test_project", _options())
async with create_community_test_client(server) as client:
- with pytest.raises(Exception):
+ with pytest.raises(ToolError):
await client.call_tool("boom", {})
- time.sleep(1.0)
- boom = _tool_call_events(captured_events, "boom")
+ boom = _tool_call_events(capture, "boom")
assert boom, "no tools/call event captured for boom"
assert boom[-1].is_error is True
assert boom[-1].error is not None
+ assert boom[-1].error["platform"] == "python"
-@pytest.mark.asyncio
-async def test_tools_list_event_serializes_openapi_tool(captured_events):
- """The tools/list event response serializes OpenAPITool subclasses cleanly."""
- server = create_community_openapi_server()
- track(server, "test_project", _options())
-
- async with create_community_test_client(server) as client:
- await client.list_tools()
- time.sleep(1.0)
-
- list_events = [e for e in captured_events if e.event_type == "mcp:tools/list"]
- assert list_events, "no tools/list event captured"
- response = list_events[-1].response
- assert response and isinstance(response.get("tools"), list)
- names = {t.get("name") for t in response["tools"]}
- assert set(OPENAPI_TOOL_NAMES).issubset(names)
-
-
-@pytest.mark.asyncio
-async def test_get_more_tools_alongside_openapi(captured_events):
- """get_more_tools coexists with OpenAPI tools and is excluded from context injection."""
+async def test_get_more_tools_alongside_openapi(capture):
+ """get_more_tools coexists with OpenAPI tools and keeps its own context."""
server = create_community_openapi_server()
track(server, "test_project", _options(enable_report_missing=True))
async with create_community_test_client(server) as client:
by_name = {t.name: t for t in await client.list_tools()}
assert "get_more_tools" in by_name
- # get_more_tools carries its own context arg by design; other tools get one injected.
+ # get_more_tools carries its own context arg by design; other tools get
+ # one injected.
assert "context" in by_name["get_severity"].inputSchema.get("properties", {})
- result = await client.call_tool("get_more_tools", {"context": "need more tools"})
- assert result is not None
+ assert by_name["get_more_tools"].inputSchema["required"] == ["context"]
+ result = await client.call_tool(
+ "get_more_tools", {"context": "need more tools"}
+ )
+ assert "Unfortunately" in "".join(
+ c.text for c in result.content if hasattr(c, "text")
+ )
+
+ assert _tool_call_events(capture, "get_more_tools")
+
+async def test_tools_call_event_response_is_json_safe(capture):
+ """The captured event's response must survive the generated API client."""
+ import json
+
+ from agentcat_api.api_client import ApiClient
-@pytest.mark.asyncio
-async def test_many_tools_single_pass_no_errors(captured_events):
- """The full multi-tool spec lists in one pass with context on all and no errors."""
server = create_community_openapi_server()
track(server, "test_project", _options())
- with patch.object(v3_middleware, "write_to_log") as mock_log:
- async with create_community_test_client(server) as client:
- tools = await client.list_tools()
+ async with create_community_test_client(server) as client:
+ await client.call_tool("get_severity", {"id": "7"})
- injected = [
- t for t in tools
- if t.name != "get_more_tools"
- and "context" in t.inputSchema.get("properties", {})
- ]
- assert len(injected) >= len(OPENAPI_TOOL_NAMES)
- assert not any(
- c.args and "Error copying tool" in str(c.args[0])
- for c in mock_log.call_args_list
- )
+ event = _tool_call_events(capture, "get_severity")[-1]
+ assert isinstance(event.response, dict)
+ json.dumps(event.response)
+ ApiClient.sanitize_for_serialization(ApiClient(), event.response)
-@pytest.mark.asyncio
async def test_openapi_tool_not_deepcopyable():
"""Precondition guard: OpenAPI tools hold non-deepcopyable runtime state."""
server = create_community_openapi_server()
diff --git a/tests/community/test_community_v3_runtime_state_tools.py b/tests/community/test_community_v3_runtime_state_tools.py
index 7aeb79a..e0e05fc 100644
--- a/tests/community/test_community_v3_runtime_state_tools.py
+++ b/tests/community/test_community_v3_runtime_state_tools.py
@@ -1,24 +1,25 @@
-"""Regression guard across every FastMCP v3 tool type that holds runtime state.
+"""Regression guard across every FastMCP tool type that holds runtime state.
The ``copy.deepcopy(tool)`` regression (PR #38) was reported for OpenAPI tools,
but the same failure mode applies to any tool that references live runtime state:
-- ``OpenAPITool`` -> holds an ``httpx.AsyncClient`` (threading.RLock)
-- ``ProxyTool`` -> holds a client factory
+- ``OpenAPITool`` -> holds an ``httpx.AsyncClient`` (threading.RLock)
+- ``ProxyTool`` -> holds a client factory
- ``FastMCPProviderTool`` -> holds a live sub-server reference
-This suite asserts the two subclass-agnostic invariants that matter for all of
-them: after ``track()``, a client ``tools/list`` sees the injected ``context``
-param on every tool, and no "Error copying tool" is ever logged.
+This suite asserts the subclass-agnostic invariants that matter for all of them:
+after ``track()``, a client ``tools/list`` sees AgentCat's parameters on every
+tool, nothing is logged as a failure, and a call round-trips with the injected
+parameters stripped back off.
"""
-from unittest.mock import patch
-
import pytest
from agentcat import AgentCatOptions, track
-from agentcat.modules.overrides.community_v3 import middleware as v3_middleware
+from agentcat.modules.adapters import community
+from agentcat.modules.constants import CONTEXT_PARAM, SESSION_ID_PARAM
+from ..test_utils import sid
from ..test_utils.community_client import create_community_test_client
from ..test_utils.community_openapi_server import (
HAS_FASTMCP_V3,
@@ -33,6 +34,15 @@
)
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
def _build(factory):
"""Build a server from a factory, skipping if this fastmcp version can't."""
try:
@@ -50,8 +60,10 @@ def _build(factory):
],
ids=["openapi", "proxy", "mounted"],
)
-@pytest.mark.asyncio
-async def test_context_injected_without_copy_errors(factory):
+async def test_parameters_injected_without_failures(factory, monkeypatch, capture):
+ logged: list[str] = []
+ monkeypatch.setattr(community, "write_to_log", logged.append)
+
server = _build(factory)
track(
server,
@@ -59,21 +71,44 @@ async def test_context_injected_without_copy_errors(factory):
AgentCatOptions(enable_tracing=True, custom_context_description="Why?"),
)
- with patch.object(v3_middleware, "write_to_log") as mock_log:
- async with create_community_test_client(server) as client:
- tools = await client.list_tools()
+ async with create_community_test_client(server) as client:
+ tools = await client.list_tools()
assert tools, "server exposed no tools"
for tool in tools:
- if tool.name == "get_more_tools":
- continue
props = tool.inputSchema.get("properties", {})
- assert "context" in props, f"context not injected into {tool.name}"
- assert "context" in tool.inputSchema.get("required", [])
-
- copy_errors = [
- c.args[0]
- for c in mock_log.call_args_list
- if c.args and "Error copying tool" in str(c.args[0])
- ]
- assert copy_errors == [], f"copy failures logged: {copy_errors}"
+ assert SESSION_ID_PARAM in props, f"session_id not injected into {tool.name}"
+ if tool.name != "get_more_tools":
+ assert CONTEXT_PARAM in props, f"context not injected into {tool.name}"
+
+ failures = [line for line in logged if "injection failed" in line]
+ assert failures == [], f"injection failures logged: {failures}"
+
+
+@pytest.mark.parametrize(
+ "factory,tool_name,arguments",
+ [
+ (create_community_proxy_server, "ping", {"text": "hi"}),
+ (create_community_mounted_server, "sub_sub_action", {"value": "hi"}),
+ ],
+ ids=["proxy", "mounted"],
+)
+async def test_call_round_trips_through_a_runtime_state_tool(
+ factory, tool_name, arguments, capture
+):
+ """The injected params are stripped before a proxied/mounted tool runs."""
+ server = _build(factory)
+ track(server, "test_project", AgentCatOptions(enable_report_missing=False))
+
+ async with create_community_test_client(server) as client:
+ listed = {t.name for t in await client.list_tools()}
+ if tool_name not in listed: # pragma: no cover - naming differs by version
+ pytest.skip(f"{tool_name} not exposed by this FastMCP: {sorted(listed)}")
+ result = await client.call_tool(
+ tool_name, {**arguments, SESSION_ID_PARAM: sid("runtime"), "context": "why"}
+ )
+
+ assert result.is_error is False
+ assert "hi" in "".join(c.text for c in result.content if hasattr(c, "text"))
+ events = [e for e in capture if e.event_type == "mcp:tools/call"]
+ assert [e.session_id for e in events] == [sid("runtime")]
diff --git a/tests/conftest.py b/tests/conftest.py
index 4f40e0a..f00f51e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,96 @@
import os
+from importlib.metadata import PackageNotFoundError, version
# Belt-and-suspenders: force diagnostics off before any test imports/runs, so no
# test ordering or future change to the auto-disable detection can ever ship OTLP
# diagnostics to the live collector from our own suite. Diagnostics-specific tests
# opt back in explicitly with DISABLE_DIAGNOSTICS=false plus mocked HTTP.
os.environ["DISABLE_DIAGNOSTICS"] = "true"
+
+
+# ── Collection gating ────────────────────────────────────────────────────────
+# Three independent reasons a module cannot even be IMPORTED in a given
+# environment. They are accumulated below rather than chosen between: the
+# compatibility matrix runs legs that trip more than one at once (mcp 2.x with
+# fastmcp deliberately uninstalled trips two).
+#
+# This has to happen at collection, not as a `skipif`. Every case here is a
+# module-scope import of a symbol that does not exist, which raises while
+# pytest is importing the module — before any mark on any test inside it runs.
+#
+# Prerelease segments are dropped rather than parsed: `2.0.0b1` yields (2, 0),
+# which orders correctly against every bound used here.
+MCP_VERSION = tuple(int(p) for p in version("mcp").split(".")[:3] if p.isdigit())
+MCP_MAJOR = MCP_VERSION[0]
+
+try:
+ version("fastmcp")
+ HAS_FASTMCP = True
+except PackageNotFoundError: # the `test-without-fastmcp` matrix legs
+ HAS_FASTMCP = False
+
+_LEGACY_ONLY = (
+ "e2e/official",
+ "e2e/community_v3",
+ "community",
+ "test_tool_context.py",
+ "test_report_missing.py",
+ "test_dynamic_tracking.py",
+ "test_multiple_servers.py",
+ "test_event_capture_completeness.py",
+ "test_request_extra.py",
+ "test_lowlevel_v1_handles.py",
+ # Every test in this module drives a tracked mcp 1.x server end to end, so
+ # there is no era-agnostic remainder to rescue: the whole file is gated.
+ # `test_event_tags_properties.py` and `test_exceptions.py` used to be gated
+ # here too, taking their era-agnostic unit tests with them; those two now
+ # import under both majors and mark only their integration class
+ # (`test_utils.LEGACY_ONLY`). The privacy guard this module encodes has no
+ # 2.x equivalent yet.
+ "test_diagnostics_no_payload.py",
+)
+_MODERN_ONLY = (
+ "e2e/official_modern",
+ "e2e/community_v4",
+ "test_lowlevel_v2_handles.py",
+ # FastMCP 4 only: the era ships a second dispatch pass, a default
+ # dereferencing middleware and real multi-round-trip results, none of which
+ # exist on the 3.x line this suite's community/ directory covers.
+ "test_community_v4_handles.py",
+)
+
+# Needs community FastMCP importable at all, independent of era. The
+# `test-without-fastmcp` matrix legs uninstall it and then verify it is gone,
+# and these trees reach for `fastmcp` while pytest imports them.
+_NEEDS_FASTMCP = (
+ "e2e/community_v3",
+ "e2e/community_v4",
+ "community",
+)
+
+# Needs a Streamable-HTTP transport AND an HTTP request object to inspect.
+# `mcp.client.streamable_http`, `FastMCP.streamable_http_app()` and the
+# `stateless_http` setting all arrive in mcp 1.8.0; `RequestContext.request` —
+# everything `extra.requestInfo` reads — arrives in 1.9.2. Below that there is
+# no transport to exercise and no request to read, so unlike the rest of the
+# old-version work there is nothing to reach into: the capability is absent
+# upstream, not merely spelled differently.
+_NEEDS_REQUEST_CONTEXT = ("e2e/official",)
+
+
+def _ignore_globs(paths: tuple[str, ...]) -> list[str]:
+ """Ignore each path itself plus everything beneath it.
+
+ Patterns are fnmatched against absolute paths, so a bare trailing `*` on a
+ directory name would also swallow siblings that merely share the prefix
+ (`e2e/official` vs `e2e/official_modern`). Anchoring the recursive form on
+ `/` keeps each entry to exactly its own subtree.
+ """
+ return [pattern for path in paths for pattern in (path, f"{path}/*")]
+
+
+collect_ignore_glob = _ignore_globs(_MODERN_ONLY if MCP_MAJOR < 2 else _LEGACY_ONLY)
+if not HAS_FASTMCP:
+ collect_ignore_glob += _ignore_globs(_NEEDS_FASTMCP)
+if MCP_VERSION < (1, 9, 2):
+ collect_ignore_glob += _ignore_globs(_NEEDS_REQUEST_CONTEXT)
diff --git a/tests/e2e/community_v2/conftest.py b/tests/e2e/community_v2/conftest.py
deleted file mode 100644
index 582730e..0000000
--- a/tests/e2e/community_v2/conftest.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Community FastMCP v2 Streamable-HTTP harness.
-
-v2 is the legacy FastMCP architecture (ToolManager-based). Skips gracefully
-when v2 is not installed — the typical dev venv has v3 installed, and v2
-would conflict with v3 (same package name, different majors).
-
-The harness uses v2's `streamable_http_app()` (or fallbacks) on a uvicorn
-thread, mirroring the official-SDK pattern.
-"""
-
-from __future__ import annotations
-
-import threading
-from typing import Any, Callable, Tuple
-
-import pytest
-
-import agentcat
-from agentcat import AgentCatOptions
-from agentcat.modules.compatibility import (
- is_community_fastmcp_v2,
- is_community_fastmcp_v3,
-)
-
-from tests.e2e._helpers import find_free_port, wait_for_port
-
-try:
- from fastmcp import FastMCP as CommunityFastMCP
-
- HAS_FASTMCP = True
-except ImportError:
- CommunityFastMCP = None # type: ignore
- HAS_FASTMCP = False
-
-
-def _create_v2_todo_server() -> Any:
- if CommunityFastMCP is None:
- raise RuntimeError("fastmcp not installed")
- mcp = CommunityFastMCP("v2-todo")
-
- @mcp.tool()
- def add_todo(text: str, context: str = "") -> str:
- return f'Added: "{text}"'
-
- return mcp
-
-
-def _default_options_factory() -> AgentCatOptions:
- return AgentCatOptions(enable_tracing=True)
-
-
-@pytest.fixture(scope="module")
-def v2_http_server(request) -> Tuple[str, Any]:
- if not HAS_FASTMCP:
- pytest.skip("fastmcp not installed")
-
- server = _create_v2_todo_server()
- if is_community_fastmcp_v3(server):
- pytest.skip(
- "installed fastmcp is v3, not v2 — v2 e2e tests require fastmcp<3"
- )
- if not is_community_fastmcp_v2(server):
- pytest.skip("server is not detected as community FastMCP v2")
-
- options_factory: Callable[[], AgentCatOptions] = getattr(
- request.module, "AGENTCAT_OPTIONS_FACTORY", _default_options_factory
- )
- options = options_factory()
- agentcat.track(server, "test_project", options)
-
- import uvicorn
-
- # v2 may expose either streamable_http_app() or http_app(transport=...).
- # Try the conventional names; fall back if needed.
- if hasattr(server, "streamable_http_app"):
- app = server.streamable_http_app()
- elif hasattr(server, "http_app"):
- app = server.http_app(transport="streamable-http")
- else:
- pytest.skip(
- "v2 server has no recognized streamable_http_app/http_app method"
- )
-
- port = find_free_port()
- config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
- uv_server = uvicorn.Server(config)
- thread = threading.Thread(target=uv_server.run, daemon=True)
- thread.start()
- try:
- wait_for_port(port, timeout=5.0)
- except TimeoutError:
- uv_server.should_exit = True
- thread.join(timeout=2.0)
- raise
-
- url = f"http://127.0.0.1:{port}/mcp"
- yield url, server
-
- uv_server.should_exit = True
- thread.join(timeout=5.0)
diff --git a/tests/e2e/community_v2/test_identify_http.py b/tests/e2e/community_v2/test_identify_http.py
deleted file mode 100644
index 93ed1f9..0000000
--- a/tests/e2e/community_v2/test_identify_http.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Community FastMCP v2 identify-per-event smoke.
-
-Skips when v2 is not installed.
-"""
-
-from __future__ import annotations
-
-import time
-from typing import Any, Optional
-
-import pytest
-
-from agentcat.modules.internal import get_server_tracking_data
-from agentcat.types import UserIdentity
-
-
-pytestmark = pytest.mark.e2e
-
-
-def _set_identify(server, fn) -> None:
- # v2 stores tracking data against server._mcp_server (the lowlevel Server),
- # not the FastMCP wrapper.
- target = getattr(server, "_mcp_server", server)
- data = get_server_tracking_data(target)
- assert data is not None
- data.options.identify = fn
-
-
-@pytest.mark.asyncio
-async def test_v2_identify_hook_receives_real_extra(
- v2_http_server, capture_queue
-):
- from fastmcp import Client
- from fastmcp.client.transports import StreamableHttpTransport
-
- url, server = v2_http_server
- seen: list = []
-
- def identify(_req: Any, extra: Any) -> Optional[UserIdentity]:
- seen.append(extra)
- return UserIdentity(user_id="v2-user", user_name=None, user_data=None)
-
- _set_identify(server, identify)
- try:
- async with Client(
- StreamableHttpTransport(url, headers={"X-Identify-V2": "yes"})
- ) as client:
- await client.call_tool(
- "add_todo", {"text": "id-v2", "context": "id"}
- )
-
- time.sleep(0.5)
- call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
- assert call_events
- assert call_events[0].identify_actor_given_id == "v2-user"
- assert seen, "v2 identify hook never invoked"
- finally:
- _set_identify(server, None)
diff --git a/tests/e2e/community_v2/test_request_extra_http.py b/tests/e2e/community_v2/test_request_extra_http.py
deleted file mode 100644
index 4bef067..0000000
--- a/tests/e2e/community_v2/test_request_extra_http.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Community FastMCP v2 headers smoke.
-
-Skips when v2 is not installed (the standard dev venv has v3).
-"""
-
-from __future__ import annotations
-
-import time
-
-import pytest
-
-
-pytestmark = pytest.mark.e2e
-
-
-@pytest.mark.asyncio
-async def test_v2_custom_header_lands_in_extra(v2_http_server, capture_queue):
- from fastmcp import Client
- from fastmcp.client.transports import StreamableHttpTransport
-
- url, _ = v2_http_server
- async with Client(
- StreamableHttpTransport(url, headers={"X-V2-Header": "v2-value"})
- ) as client:
- await client.call_tool(
- "add_todo", {"text": "v2-h", "context": "v2-h"}
- )
-
- time.sleep(0.5)
- call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
- assert call_events
- headers = (
- (call_events[0].parameters or {})
- .get("extra", {})
- .get("requestInfo", {})
- .get("headers", {})
- )
- assert headers.get("x-v2-header") == "v2-value", (
- f"expected x-v2-header in extra.requestInfo.headers, got {headers}"
- )
diff --git a/tests/e2e/community_v3/conftest.py b/tests/e2e/community_v3/conftest.py
index 1f42ce8..9fae1af 100644
--- a/tests/e2e/community_v3/conftest.py
+++ b/tests/e2e/community_v3/conftest.py
@@ -4,25 +4,31 @@
mounted on a random uvicorn port. Tests connect with
`fastmcp.Client(StreamableHttpTransport(url, headers=...))`.
+A test module declares `STATELESS_HTTP = True` at module scope to be served by
+a stateless app instead. That is a different code path, not a configuration
+detail: a stateless server builds a fresh `ServerSession` per REQUEST, so every
+call reaches the last rung of the client-identity ladder — the rung whose
+per-connection filing this exists to hold.
+
Module-scoped: one boot per test file.
"""
from __future__ import annotations
import threading
-from typing import Any, Callable, Tuple
+from collections.abc import Callable
+from typing import Any
import pytest
import agentcat
from agentcat import AgentCatOptions
-
from tests.e2e._helpers import find_free_port, wait_for_port
try:
from fastmcp import FastMCP
- from agentcat.modules.compatibility import is_community_fastmcp_v3
+ from agentcat.modules.detection import ServerFlavor, detect_server
HAS_FASTMCP_V3 = True
except ImportError:
FastMCP = None # type: ignore
@@ -34,12 +40,14 @@ def _create_v3_todo_server() -> Any:
raise RuntimeError("fastmcp v3 is not installed; cannot run v3 e2e tests")
mcp = FastMCP("v3-todo-server")
+ # No `context` parameter of their own: the one the tests send is AgentCat's
+ # injected parameter, so the wire path covers injection and stripping.
@mcp.tool
- def add_todo(text: str, context: str = "") -> str:
+ def add_todo(text: str) -> str:
return f'Added todo: "{text}"'
@mcp.tool
- def list_todos(context: str = "") -> str:
+ def list_todos() -> str:
return "no todos"
return mcp
@@ -50,12 +58,12 @@ def _default_options_factory() -> AgentCatOptions:
@pytest.fixture(scope="module")
-def v3_http_server(request) -> Tuple[str, Any]:
+def v3_http_server(request) -> tuple[str, Any]:
if not HAS_FASTMCP_V3:
pytest.skip("fastmcp v3 not installed")
server = _create_v3_todo_server()
- if not is_community_fastmcp_v3(server):
+ if detect_server(server).flavor is not ServerFlavor.COMMUNITY_V3:
pytest.skip("installed fastmcp is not v3")
options_factory: Callable[[], AgentCatOptions] = getattr(
@@ -66,7 +74,10 @@ def v3_http_server(request) -> Tuple[str, Any]:
import uvicorn
- app = server.http_app(transport="streamable-http")
+ app = server.http_app(
+ transport="streamable-http",
+ stateless_http=getattr(request.module, "STATELESS_HTTP", False),
+ )
port = find_free_port()
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
uv_server = uvicorn.Server(config)
diff --git a/tests/e2e/community_v3/test_agent_handle_http.py b/tests/e2e/community_v3/test_agent_handle_http.py
new file mode 100644
index 0000000..7e7dab7
--- /dev/null
+++ b/tests/e2e/community_v3/test_agent_handle_http.py
@@ -0,0 +1,203 @@
+"""The agent handle over real Streamable HTTP (community FastMCP 3).
+
+`enable_agent_tracking` is off by default, so every OTHER e2e module in this
+tree runs with `agent_id` never injected. This module turns it on for its own
+server — the fixture reads `AGENTCAT_OPTIONS_FACTORY` per module — and it is a
+separate file rather than a flag flipped on a shared one because `agent_id` is
+injected as REQUIRED, which changes the schema every sibling test calls against.
+
+The strip needs no recorder here: the conftest's `add_todo(text: str)` is a
+typed FastMCP tool, and community FastMCP — unlike both official tool managers
+— raises on an argument the signature never declared. A handle that survived to
+the tool body fails the call.
+
+Every `fastmcp` import is inside a test body, as in the rest of this tree: a
+module-scope import fails at COLLECTION on the no-fastmcp matrix legs, which no
+conftest gate downstream of it can rescue.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+
+from agentcat import AgentCatOptions
+from agentcat.modules.constants import (
+ AGENT_ID_PARAM,
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+
+pytestmark = pytest.mark.e2e
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+AGENT = "opus-4.80-1m|claude-code|k3n9x"
+
+
+def _agent_tracking_options() -> AgentCatOptions:
+ return AgentCatOptions(enable_tracing=True, enable_agent_tracking=True)
+
+
+AGENTCAT_OPTIONS_FACTORY = _agent_tracking_options
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+@pytest.mark.asyncio
+async def test_the_agent_handle_survives_the_wire(v3_http_server, capture_queue):
+ """Listing with agent tracking on: the schema the agent is handed.
+
+ Property order is the contract (`modules/injection.py` §"Resulting property
+ order"), and `agent_id` is required where `session_id` is not — omission is
+ the minting signal for one and nothing for the other.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v3_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ listed = await client.list_tools()
+
+ add = next(t for t in listed if t.name == "add_todo")
+ assert list(add.inputSchema["properties"])[-3:] == [
+ SESSION_ID_PARAM,
+ AGENT_ID_PARAM,
+ "context",
+ ]
+ assert AGENT_ID_PARAM in add.inputSchema["required"]
+ assert SESSION_ID_PARAM not in add.inputSchema["required"]
+ assert MCP_INSTRUCTIONS_KEY in add.outputSchema["properties"]
+
+
+@pytest.mark.asyncio
+async def test_a_supplied_agent_handle_tags_the_event(v3_http_server, capture_queue):
+ """The handle rides the event as a tag and never reaches the tool."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v3_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ result = await client.call_tool(
+ "add_todo",
+ {"text": "with agent", AGENT_ID_PARAM: AGENT, "context": "why"},
+ )
+ # The typed tool raises on any argument but `text`, so a surviving
+ # handle fails here rather than passing silently.
+ text = _text(result)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ mirror = result.structured_content[MCP_INSTRUCTIONS_KEY]
+ assert mirror[SESSION_ID_PARAM] == minted
+ assert mirror[AGENT_ID_PARAM] == AGENT
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == AGENT
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ assert event.parameters["arguments"][AGENT_ID_PARAM] == AGENT
+
+
+@pytest.mark.asyncio
+async def test_both_handles_echo_across_calls(v3_http_server, capture_queue):
+ """The agent echoes session and agent handle together on the next call.
+
+ The two are independent: the session is confirmed rather than re-minted,
+ while `agent_id` is `supplied` on both calls — the server never issues one,
+ so it has no `minted` state to pass through.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v3_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "one", AGENT_ID_PARAM: AGENT, "context": "start"}
+ )
+ minted = _text(first).split("session_id=")[1].split(" ")[0]
+
+ second = await client.call_tool(
+ "add_todo",
+ {"text": "two", SESSION_ID_PARAM: minted, AGENT_ID_PARAM: AGENT},
+ )
+ assert MINT_BACK_HEADER not in _text(second)
+ assert second.structured_content[MCP_INSTRUCTIONS_KEY][AGENT_ID_PARAM] == AGENT
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+ assert [e.tags[AGENTCAT_TAG_AGENT_ID] for e in events] == [AGENT, AGENT]
+ assert [e.tags[AGENTCAT_TAG_AGENT_SOURCE] for e in events] == [
+ "supplied",
+ "supplied",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_omitting_the_required_agent_handle_degrades_to_absence(
+ v3_http_server, capture_queue
+):
+ """`required` is advisory: nothing enforces it, and nothing may break.
+
+ AgentCat strips the handles in middleware, before the tool's own argument
+ validation, so an agent that ignores the `required` marker is served
+ normally. The event is then simply agent-less: an absent handle must never
+ become an empty or invented tag, because a customer filtering on
+ `agentcat_agent_id` has to be able to tell "no agent told us" from "this
+ agent".
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v3_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ result = await client.call_tool("add_todo", {"text": "no agent"})
+ assert MINT_BACK_HEADER in _text(result)
+ assert AGENT_ID_PARAM not in result.structured_content[MCP_INSTRUCTIONS_KEY]
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_agent_handle_is_a_miss_not_an_empty_tag(
+ v3_http_server, capture_queue
+):
+ """`extract_handle` trims and rejects, over the wire.
+
+ A whitespace-only value must leave the event with NO agent tags — an
+ `agentcat_agent_id: ""` would key every such call to one phantom agent
+ downstream, which is worse than the absence it stands in for.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v3_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool(
+ "add_todo", {"text": "blank", AGENT_ID_PARAM: " ", "context": "x"}
+ )
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ # The session handle is unaffected: suppression is per handle.
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
diff --git a/tests/e2e/community_v3/test_event_capture_http.py b/tests/e2e/community_v3/test_event_capture_http.py
index 7c3fb6b..a86c714 100644
--- a/tests/e2e/community_v3/test_event_capture_http.py
+++ b/tests/e2e/community_v3/test_event_capture_http.py
@@ -1,7 +1,8 @@
-"""FastMCP v3 event-capture tests over real Streamable HTTP.
+"""Community FastMCP event-capture tests over real Streamable HTTP.
-Initialize-event capture is intentionally omitted (handled by ServerSession
-internally; see tests/e2e/official/test_event_capture_http.py docstring).
+v2 publishes exactly one event type — mcp:tools/call. initialize only feeds the
+client-identity ladder, and tools/list is intercepted for schema injection only,
+so neither produces an event.
"""
from __future__ import annotations
@@ -10,10 +11,26 @@
import pytest
-
pytestmark = pytest.mark.e2e
+@pytest.mark.asyncio
+async def test_handshake_and_list_publish_nothing(v3_http_server, capture_queue):
+ """A real handshake plus list_tools produces no events at all."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v3_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ listed = await client.list_tools()
+
+ time.sleep(0.5)
+ # The listing still went through AgentCat: the handles are on the schemas.
+ add = next(t for t in listed if t.name == "add_todo")
+ assert "session_id" in add.inputSchema["properties"]
+ assert capture_queue == [], [e.event_type for e in capture_queue]
+
+
@pytest.mark.asyncio
async def test_call_tool_via_v3(v3_http_server, capture_queue):
from fastmcp import Client
@@ -21,27 +38,36 @@ async def test_call_tool_via_v3(v3_http_server, capture_queue):
url, _ = v3_http_server
async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": "v3-call", "context": "x"}
- )
+ await client.call_tool("add_todo", {"text": "v3-call", "context": "x"})
time.sleep(0.5)
call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
assert call_events
assert call_events[0].resource_name == "add_todo"
+ assert call_events[0].user_intent == "x"
+ # Raw arguments on the event; the tool received the stripped copy.
+ assert call_events[0].parameters["arguments"]["context"] == "x"
@pytest.mark.asyncio
-async def test_list_tools_via_v3(v3_http_server, capture_queue):
+async def test_task_handle_is_minted_then_echoed(v3_http_server, capture_queue):
+ """The mint-back travels over the wire, and the echoed handle keys the
+ second event to the same task."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
url, _ = v3_http_server
async with Client(StreamableHttpTransport(url)) as client:
- await client.list_tools()
+ first = await client.call_tool("add_todo", {"text": "one"})
+ text = "".join(c.text for c in first.content if hasattr(c, "text"))
+ assert "[MCP INSTRUCTIONS]: session_id issued." in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+
+ await client.call_tool("add_todo", {"text": "two", "session_id": minted})
time.sleep(0.5)
- assert any(e.event_type == "mcp:tools/list" for e in capture_queue)
+ call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+ assert [e.session_id for e in call_events] == [minted, minted]
@pytest.mark.asyncio
@@ -51,12 +77,76 @@ async def test_v3_event_duration_is_non_negative(v3_http_server, capture_queue):
url, _ = v3_http_server
async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": "duration", "context": "x"}
- )
+ await client.call_tool("add_todo", {"text": "duration", "context": "x"})
time.sleep(0.5)
call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
assert call_events
assert call_events[0].duration is not None
assert call_events[0].duration >= 0
+
+
+@pytest.mark.asyncio
+async def test_client_identity_reaches_the_event(v3_http_server, capture_queue):
+ """Name AND version reach the tools/call event over a real connection.
+
+ The handshake capture is the last rung of the identity ladder and the only
+ one this era can use. Asserted against a `client_info` this test supplies
+ rather than against "some name resolved": the SDK's own default satisfies a
+ truthiness check while proving nothing about what the ladder read, and
+ `client_version` is null-by-default, so a rung that dropped it would go
+ unnoticed by a name-only assertion.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+ from mcp.types import Implementation
+
+ url, _ = v3_http_server
+ async with Client(
+ StreamableHttpTransport(url),
+ client_info=Implementation(name="MyAgent", version="1.2.3"),
+ ) as client:
+ await client.call_tool("add_todo", {"text": "who", "context": "x"})
+
+ time.sleep(0.5)
+ call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+ assert call_events
+ event = call_events[-1]
+ assert (event.client_name, event.client_version) == ("MyAgent", "1.2.3")
+
+
+@pytest.mark.asyncio
+async def test_identity_rides_every_call_not_just_the_first(
+ v3_http_server, capture_queue
+):
+ """Name AND version on EVERY event of a connection.
+
+ Reading only the last event cannot tell "resolved per request" from
+ "resolved once and reused", and the two differ exactly where it matters: a
+ rung that answers only for the call following the handshake leaves every
+ later event of a long-lived connection anonymous. This is a STATEFUL app,
+ so the connection whose handshake was captured is still there for all three
+ calls — the rung has to answer more than once.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+ from mcp.types import Implementation
+
+ url, _ = v3_http_server
+ async with Client(
+ StreamableHttpTransport(url),
+ client_info=Implementation(name="Cursor", version="2.6.22"),
+ ) as client:
+ for n in range(3):
+ await client.call_tool("add_todo", {"text": f"call-{n}", "context": "id"})
+
+ time.sleep(0.5)
+ events = [e for e in capture_queue if e.event_type == "mcp:tools/call"][-3:]
+ assert [e.parameters["arguments"]["text"] for e in events] == [
+ "call-0",
+ "call-1",
+ "call-2",
+ ]
+ assert [(e.client_name, e.client_version) for e in events] == [
+ ("Cursor", "2.6.22")
+ ] * 3
diff --git a/tests/e2e/community_v3/test_identify_http.py b/tests/e2e/community_v3/test_identify_http.py
index 3c794fb..0403768 100644
--- a/tests/e2e/community_v3/test_identify_http.py
+++ b/tests/e2e/community_v3/test_identify_http.py
@@ -1,16 +1,22 @@
-"""Identify-per-event behavior under FastMCP v3 middleware over real HTTP."""
+"""Identify-per-event behavior under the community adapter over real HTTP.
+
+v2 has no standalone agentcat:identify event: the hook runs per tool call and
+its result is stamped onto that call's event.
+
+Tests mutate the running server's AgentCatData.options.identify to vary the hook
+per scenario, and reset it in finally so later tests start clean.
+"""
from __future__ import annotations
import time
-from typing import Any, Optional
+from typing import Any
import pytest
from agentcat.modules.internal import get_server_tracking_data
from agentcat.types import UserIdentity
-
pytestmark = pytest.mark.e2e
@@ -20,63 +26,164 @@ def _set_identify(server, fn) -> None:
data.options.identify = fn
+def _last_call(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"][-1]
+
+
@pytest.mark.asyncio
-async def test_identify_hook_runs_under_v3_middleware(
+async def test_identify_hook_runs_under_the_community_adapter(
v3_http_server, capture_queue
):
+ """`extra` carries the live HTTP request, not a placeholder.
+
+ The header read below is verbatim the idiom the README documents for
+ `resolve_session_id` ("receives the same `(request, extra)` pair as
+ `identify`") — keying off a header the customer's gateway set. Only a
+ socket can prove it: the in-process client has no HTTP request at all, so
+ `extra.request` is None there and the assertion would be vacuous.
+
+ Recorded rather than asserted in place: `resolve_identity` swallows every
+ exception the hook raises, so an assertion inside it would surface as a
+ silently anonymous event instead of a failure.
+ """
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
url, server = v3_http_server
seen: list = []
- def identify(_req: Any, extra: Any) -> Optional[UserIdentity]:
- seen.append(extra)
- return UserIdentity(user_id="v3-user", user_name=None, user_data=None)
+ def identify(request: Any, extra: Any) -> UserIdentity | None:
+ headers = getattr(getattr(extra, "request", None), "headers", {}) or {}
+ seen.append((getattr(request, "name", None), headers.get("x-tenant")))
+ return UserIdentity(
+ user_id="v3-user", user_name="V3 User", user_data={"plan": "pro"}
+ )
_set_identify(server, identify)
try:
- async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": "id-v3", "context": "id"}
- )
+ async with Client(
+ StreamableHttpTransport(url, headers={"X-Tenant": "acme"})
+ ) as client:
+ await client.call_tool("add_todo", {"text": "id-v3", "context": "id"})
time.sleep(0.5)
- call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
- assert call_events
- assert call_events[0].identify_actor_given_id == "v3-user"
- assert seen, "identify hook never invoked under v3"
+ assert seen == [("add_todo", "acme")], seen
+ event = _last_call(capture_queue)
+ # All three fields, not just the id: `user_name` and `user_data` are
+ # what a customer segments and displays by, and each lands in a
+ # differently-named event field.
+ assert event.identify_actor_given_id == "v3-user"
+ assert event.identify_actor_name == "V3 User"
+ assert event.identify_data == {"plan": "pro"}
finally:
_set_identify(server, None)
@pytest.mark.asyncio
-async def test_agentcat_identify_self_event_via_v3_middleware(
+async def test_actor_rides_the_tool_call_event_not_a_self_event(
v3_http_server, capture_queue
):
+ """v2 stamps the actor onto every tools/call event; the standalone
+ agentcat:identify event is gone."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
url, server = v3_http_server
- def identify(_req: Any, _extra: Any) -> Optional[UserIdentity]:
+ def identify(_req: Any, _extra: Any) -> UserIdentity | None:
return UserIdentity(user_id="v3-bob", user_name=None, user_data=None)
_set_identify(server, identify)
try:
async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": "self-v3", "context": "x"}
- )
+ await client.call_tool("add_todo", {"text": "self-v3", "context": "x"})
time.sleep(0.5)
- identify_events = [
- e for e in capture_queue if e.event_type == "agentcat:identify"
- ]
- assert identify_events, (
- f"expected agentcat:identify under v3, got "
- f"{[e.event_type for e in capture_queue]}"
+ assert {e.event_type for e in capture_queue} == {"mcp:tools/call"}
+ assert _last_call(capture_queue).identify_actor_given_id == "v3-bob"
+ finally:
+ _set_identify(server, None)
+
+
+@pytest.mark.asyncio
+async def test_identity_is_resolved_per_call_never_cached(
+ v3_http_server, capture_queue
+):
+ """The hook runs on EVERY call, so consecutive calls on one connection can
+ return different actors — v1 cached the result for the connection's life
+ and could not express this."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v3_http_server
+ counter = {"n": 0}
+
+ def identify(_req: Any, _extra: Any) -> UserIdentity | None:
+ counter["n"] += 1
+ return UserIdentity(
+ user_id=f"user-{counter['n']}", user_name=None, user_data=None
)
- assert identify_events[0].identify_actor_given_id == "v3-bob"
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool("add_todo", {"text": "first", "context": "x"})
+ await client.call_tool("add_todo", {"text": "second", "context": "x"})
+
+ time.sleep(0.5)
+ events = [e for e in capture_queue if e.event_type == "mcp:tools/call"][-2:]
+ assert counter["n"] == 2, "the hook did not run once per call"
+ assert [e.identify_actor_given_id for e in events] == ["user-1", "user-2"]
+ finally:
+ _set_identify(server, None)
+
+
+@pytest.mark.asyncio
+async def test_returning_none_yields_an_anonymous_event(v3_http_server, capture_queue):
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v3_http_server
+
+ def identify(_req: Any, _extra: Any) -> UserIdentity | None:
+ return None
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool("add_todo", {"text": "none", "context": "x"})
+
+ time.sleep(0.5)
+ event = _last_call(capture_queue)
+ assert event.identify_actor_given_id is None
+ assert event.identify_actor_name is None
+ finally:
+ _set_identify(server, None)
+
+
+@pytest.mark.asyncio
+async def test_identify_exception_does_not_break_the_tool_call(
+ v3_http_server, capture_queue
+):
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v3_http_server
+
+ def identify(_req: Any, _extra: Any) -> UserIdentity | None:
+ raise RuntimeError("identify exploded")
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "boom", "context": "x"}
+ )
+
+ assert result.is_error is False
+ time.sleep(0.5)
+ call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+ assert call_events, "tool/call event must still publish despite hook crash"
+ assert call_events[-1].identify_actor_given_id is None
finally:
_set_identify(server, None)
diff --git a/tests/e2e/community_v3/test_request_extra_http.py b/tests/e2e/community_v3/test_request_extra_http.py
index a371ace..b78d0e1 100644
--- a/tests/e2e/community_v3/test_request_extra_http.py
+++ b/tests/e2e/community_v3/test_request_extra_http.py
@@ -1,4 +1,4 @@
-"""parameters.extra.requestInfo.headers parity for FastMCP v3 over real HTTP."""
+"""parameters.extra.requestInfo.headers parity for community FastMCP over HTTP."""
from __future__ import annotations
@@ -26,9 +26,7 @@ async def test_custom_header_lands_in_extra(v3_http_server, capture_queue):
async with Client(
StreamableHttpTransport(url, headers={"X-V3-Header": "v3-value"})
) as client:
- await client.call_tool(
- "add_todo", {"text": "v3-h", "context": "v3-h"}
- )
+ await client.call_tool("add_todo", {"text": "v3-h", "context": "v3-h"})
time.sleep(0.5)
headers = _extra(_last_call(capture_queue)).get("requestInfo", {}).get(
@@ -40,49 +38,38 @@ async def test_custom_header_lands_in_extra(v3_http_server, capture_queue):
@pytest.mark.asyncio
-async def test_list_tools_event_carries_headers_via_v3(
- v3_http_server, capture_queue
-):
- """tools/list events under v3 transport carry parameters.extra. This
- exercises the v3 middleware pipeline including the FastMCP
- `_current_http_request` ContextVar fallback path."""
+async def test_extra_sits_beside_the_raw_arguments(v3_http_server, capture_queue):
+ """v2 builds parameters as {"arguments": raw, "extra": {...}} rather than
+ dumping the whole request."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
url, _ = v3_http_server
async with Client(
- StreamableHttpTransport(url, headers={"X-V3-List": "list-v"})
+ StreamableHttpTransport(url, headers={"X-V3-Shape": "shape"})
) as client:
- await client.list_tools()
+ await client.call_tool("add_todo", {"text": "shape", "context": "why"})
time.sleep(0.5)
- list_events = [e for e in capture_queue if e.event_type == "mcp:tools/list"]
- assert list_events
- headers = (
- (list_events[0].parameters or {})
- .get("extra", {})
- .get("requestInfo", {})
- .get("headers", {})
- )
- assert headers.get("x-v3-list") == "list-v", (
- f"expected x-v3-list on tools/list event, got headers={headers}"
- )
+ event = _last_call(capture_queue)
+ assert set(event.parameters) == {"arguments", "extra"}
+ assert event.parameters["arguments"] == {"text": "shape", "context": "why"}
+ assert _extra(event)["requestInfo"]["headers"]["x-v3-shape"] == "shape"
@pytest.mark.asyncio
-async def test_meta_dict_present_when_supported(v3_http_server, capture_queue):
- """Sanity: extra.meta is either absent or a dict — never a malformed value."""
+async def test_session_id_and_meta_shapes(v3_http_server, capture_queue):
+ """Sanity: extra.sessionId is a string when present, extra.meta a dict."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
url, _ = v3_http_server
async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": "v3-meta", "context": "meta"}
- )
+ await client.call_tool("add_todo", {"text": "v3-meta", "context": "meta"})
time.sleep(0.5)
extra = _extra(_last_call(capture_queue))
- meta = extra.get("meta")
- if meta is not None:
- assert isinstance(meta, dict)
+ if extra.get("sessionId") is not None:
+ assert isinstance(extra["sessionId"], str)
+ if extra.get("meta") is not None:
+ assert isinstance(extra["meta"], dict)
diff --git a/tests/e2e/community_v3/test_stateless_http.py b/tests/e2e/community_v3/test_stateless_http.py
index 935c005..685652b 100644
--- a/tests/e2e/community_v3/test_stateless_http.py
+++ b/tests/e2e/community_v3/test_stateless_http.py
@@ -1,4 +1,17 @@
-"""FastMCP v3 stateless mode over real HTTP."""
+"""Per-request resolution over a STATELESS Streamable-HTTP server (community).
+
+The `stateless` option is gone in 2.0 — resolution is per request either way —
+so these guard what the option used to protect: a task handle that survives
+without any server-side session, and client identity that never leaks from one
+connection to another even though one middleware instance serves them all.
+
+The transport is the point. `stateless_http=True` builds a fresh
+`ServerSession` per REQUEST, so the middleware's per-connection filing of the
+handshake `clientInfo` has nothing to match and the ladder falls all the way
+through on EVERY call. That is exactly where a single "last seen" slot — what
+this rung used to be — renames one client's call after another's handshake, and
+it is why the fix needed a transport test rather than only a unit one.
+"""
from __future__ import annotations
@@ -7,57 +20,80 @@
import pytest
-from agentcat import AgentCatOptions
-
-
-def AGENTCAT_OPTIONS_FACTORY() -> AgentCatOptions:
- return AgentCatOptions(enable_tracing=True, stateless=True)
-
-
pytestmark = pytest.mark.e2e
+# Read by this tree's conftest: the app really is served statelessly, so the
+# transport builds a fresh session per REQUEST and nothing survives a call.
+STATELESS_HTTP = True
+
@pytest.mark.asyncio
-async def test_v3_stateless_session_id_null(v3_http_server, capture_queue):
+async def test_every_call_carries_a_task_handle(v3_http_server, capture_queue):
+ """Handles are resolved per request from the arguments, so nothing is held
+ server-side: session_id carries the task, minted or echoed."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
url, _ = v3_http_server
async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": "s", "context": "stateless-v3"}
- )
+ first = await client.call_tool("add_todo", {"text": "s", "context": "x"})
+ text = "".join(c.text for c in first.content if hasattr(c, "text"))
+ minted = text.split("session_id=")[1].split(" ")[0]
+
+ await client.call_tool("add_todo", {"text": "s2", "session_id": minted})
time.sleep(0.5)
call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
- assert call_events
- assert call_events[0].session_id is None
+ assert len(call_events) == 2
+ assert [e.session_id for e in call_events] == [minted, minted]
@pytest.mark.asyncio
-async def test_v3_stateless_no_session_info_pollution(
+async def test_two_clients_different_clientinfo_dont_bleed(
v3_http_server, capture_queue
):
- """After stateless requests, data.session_info.client_name stays None."""
+ """One middleware object serves every connection, and its handshake capture
+ is the LAST rung of the identity ladder — the one a stateless server always
+ reaches, because the session that handshook is gone by the time the call
+ arrives. It is filed per connection, so it can only ever answer for the
+ connection that made it.
+
+ Each event is matched to the call that produced it by the argument that
+ call sent, so this asserts ATTRIBUTION: a shared slot puts one name on both
+ events, and a set-membership check can be satisfied by the wrong pairing.
+ """
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
+ from mcp.types import Implementation
- url, server = v3_http_server
+ url, _ = v3_http_server
- async def call_once(text: str) -> None:
- async with Client(StreamableHttpTransport(url)) as client:
- await client.call_tool(
- "add_todo", {"text": text, "context": "no-bleed"}
- )
+ async def call_as(name: str, version: str, text: str) -> None:
+ async with Client(
+ StreamableHttpTransport(url),
+ client_info=Implementation(name=name, version=version),
+ ) as client:
+ await client.call_tool("add_todo", {"text": text, "context": "no-bleed"})
- await asyncio.gather(call_once("a"), call_once("b"))
+ await asyncio.gather(
+ call_as("Cursor", "2.6.22", "from-cursor"),
+ call_as("Claude", "1.0.0", "from-claude"),
+ )
time.sleep(0.7)
- from agentcat.modules.internal import get_server_tracking_data
-
- data = get_server_tracking_data(server)
- assert data is not None
- assert data.session_info.client_name is None, (
- f"v3 stateless mode polluted session_info.client_name = "
- f"{data.session_info.client_name}"
- )
+ call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+ assert len(call_events) == 2
+ attributed = {e.parameters["arguments"]["text"]: e.client_name for e in call_events}
+ # No name at all is the honest answer for a connection that is already
+ # gone; SOMEONE ELSE's name never is.
+ #
+ # The tolerance is deliberate and specific to THIS era. FastMCP 3 speaks the
+ # pre-2026 wire, where `clientInfo` travels once, in `initialize` — so the
+ # per-connection capture is the only rung that can answer, and a stateless
+ # server has already discarded the connection that made it. Absence is
+ # unknowable here, not a defect. FastMCP 4 re-stamps identity into `_meta`
+ # on every request, so its sibling
+ # (`tests/e2e/community_v4/test_stateless_http.py`) asserts exact name and
+ # version. Do not loosen that one to match this.
+ assert attributed["from-cursor"] in (None, "Cursor"), attributed
+ assert attributed["from-claude"] in (None, "Claude"), attributed
diff --git a/tests/e2e/community_v4/__init__.py b/tests/e2e/community_v4/__init__.py
new file mode 100644
index 0000000..c44dfba
--- /dev/null
+++ b/tests/e2e/community_v4/__init__.py
@@ -0,0 +1 @@
+"""Streamable-HTTP e2e suite for community FastMCP 4."""
diff --git a/tests/e2e/community_v4/conftest.py b/tests/e2e/community_v4/conftest.py
new file mode 100644
index 0000000..d391612
--- /dev/null
+++ b/tests/e2e/community_v4/conftest.py
@@ -0,0 +1,97 @@
+"""FastMCP v4 Streamable-HTTP harness.
+
+The 4.x sibling of `tests/e2e/community_v3/conftest.py`: a community FastMCP 4
+server on `mcp.http_app()`, mounted on a random uvicorn port, with tests
+connecting through `fastmcp.Client(StreamableHttpTransport(url))`.
+
+A test module declares `STATELESS_HTTP = True` at module scope to be served by
+a stateless app instead. That is a different code path, not a configuration
+detail: a stateless server builds a fresh `ServerSession` per REQUEST, so every
+call reaches the last rung of the client-identity ladder — the rung whose
+per-connection filing this exists to hold.
+
+Module-scoped: one boot per test file.
+"""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Callable
+from typing import Any
+
+import pytest
+
+import agentcat
+from agentcat import AgentCatOptions
+from tests.e2e._helpers import find_free_port, wait_for_port
+
+try:
+ from fastmcp import FastMCP
+
+ from agentcat.modules.detection import ServerFlavor, detect_server
+
+ HAS_FASTMCP_V4 = True
+except ImportError: # pragma: no cover - import guard
+ FastMCP = None # type: ignore
+ HAS_FASTMCP_V4 = False
+
+
+def _create_v4_todo_server() -> Any:
+ if FastMCP is None: # pragma: no cover - import guard
+ raise RuntimeError("fastmcp v4 is not installed; cannot run v4 e2e tests")
+ mcp = FastMCP("v4-todo-server")
+
+ # No `context` parameter of their own: the one the tests send is AgentCat's
+ # injected parameter, so the wire path covers injection and stripping.
+ @mcp.tool
+ def add_todo(text: str) -> str:
+ return f'Added todo: "{text}"'
+
+ @mcp.tool
+ def list_todos() -> str:
+ return "no todos"
+
+ return mcp
+
+
+def _default_options_factory() -> AgentCatOptions:
+ return AgentCatOptions(enable_tracing=True)
+
+
+@pytest.fixture(scope="module")
+def v4_http_server(request) -> tuple[str, Any]:
+ if not HAS_FASTMCP_V4: # pragma: no cover - import guard
+ pytest.skip("fastmcp v4 not installed")
+
+ server = _create_v4_todo_server()
+ if detect_server(server).flavor is not ServerFlavor.COMMUNITY_V4:
+ pytest.skip("installed fastmcp is not v4")
+
+ options_factory: Callable[[], AgentCatOptions] = getattr(
+ request.module, "AGENTCAT_OPTIONS_FACTORY", _default_options_factory
+ )
+ agentcat.track(server, "test_project", options_factory())
+
+ import uvicorn
+
+ app = server.http_app(
+ transport="streamable-http",
+ stateless_http=getattr(request.module, "STATELESS_HTTP", False),
+ )
+ port = find_free_port()
+ config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
+ uv_server = uvicorn.Server(config)
+ thread = threading.Thread(target=uv_server.run, daemon=True)
+ thread.start()
+ try:
+ wait_for_port(port, timeout=10.0)
+ except TimeoutError: # pragma: no cover - boot failure
+ uv_server.should_exit = True
+ thread.join(timeout=2.0)
+ raise
+
+ url = f"http://127.0.0.1:{port}/mcp/"
+ yield url, server
+
+ uv_server.should_exit = True
+ thread.join(timeout=5.0)
diff --git a/tests/e2e/community_v4/test_agent_handle_http.py b/tests/e2e/community_v4/test_agent_handle_http.py
new file mode 100644
index 0000000..898b3a6
--- /dev/null
+++ b/tests/e2e/community_v4/test_agent_handle_http.py
@@ -0,0 +1,202 @@
+"""The agent handle over real Streamable HTTP (community FastMCP 4).
+
+`enable_agent_tracking` is off by default, so every OTHER e2e module in this
+tree runs with `agent_id` never injected. This module turns it on for its own
+server — the fixture reads `AGENTCAT_OPTIONS_FACTORY` per module — and it is a
+separate file rather than a flag flipped on a shared one because `agent_id` is
+injected as REQUIRED, which changes the schema every sibling test calls against.
+
+The strip needs no recorder here: the conftest's `add_todo(text: str)` is a
+typed FastMCP tool, and community FastMCP — unlike both official tool managers
+— raises on an argument the signature never declared. A handle that survived to
+the tool body fails the call.
+
+Every `fastmcp` import is inside a test body, as in the rest of this tree: a
+module-scope import fails at COLLECTION on the no-fastmcp matrix legs, which no
+conftest gate downstream of it can rescue.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+
+from agentcat import AgentCatOptions
+from agentcat.modules.constants import (
+ AGENT_ID_PARAM,
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+
+pytestmark = pytest.mark.e2e
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+AGENT = "opus-4.80-1m|claude-code|k3n9x"
+
+
+def _agent_tracking_options() -> AgentCatOptions:
+ return AgentCatOptions(enable_tracing=True, enable_agent_tracking=True)
+
+
+AGENTCAT_OPTIONS_FACTORY = _agent_tracking_options
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_the_agent_handle_survives_the_wire(v4_http_server, capture_queue):
+ """Listing with agent tracking on: the schema the agent is handed.
+
+ FastMCP validates its own outbound results, so a schema that is malformed
+ once `agent_id` joins `session_id` and `context` fails server-side here
+ rather than reaching the agent. Property order is the contract
+ (`modules/injection.py` §"Resulting property order"), and `agent_id` is
+ required where `session_id` is not — omission is the minting signal for one
+ and nothing for the other.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ listed = await client.list_tools()
+
+ add = next(t for t in listed if t.name == "add_todo")
+ assert list(add.input_schema["properties"]) == [
+ "text",
+ SESSION_ID_PARAM,
+ AGENT_ID_PARAM,
+ "context",
+ ]
+ assert AGENT_ID_PARAM in add.input_schema["required"]
+ assert SESSION_ID_PARAM not in add.input_schema["required"]
+ assert MCP_INSTRUCTIONS_KEY in add.output_schema["properties"]
+
+
+async def test_a_supplied_agent_handle_tags_the_event(v4_http_server, capture_queue):
+ """The handle rides the event as a tag and never reaches the tool."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ result = await client.call_tool(
+ "add_todo",
+ {"text": "with agent", AGENT_ID_PARAM: AGENT, "context": "why"},
+ )
+ # The typed tool raises on any argument but `text`, so a surviving
+ # handle fails here rather than passing silently.
+ text = _text(result)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ mirror = result.structured_content[MCP_INSTRUCTIONS_KEY]
+ assert mirror[SESSION_ID_PARAM] == minted
+ assert mirror[AGENT_ID_PARAM] == AGENT
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == AGENT
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ assert event.parameters["arguments"][AGENT_ID_PARAM] == AGENT
+
+
+async def test_both_handles_echo_across_calls(v4_http_server, capture_queue):
+ """The agent echoes session and agent handle together on the next call.
+
+ The two are independent: the session is confirmed rather than re-minted,
+ while `agent_id` is `supplied` on both calls — the server never issues one,
+ so it has no `minted` state to pass through.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "one", AGENT_ID_PARAM: AGENT, "context": "start"}
+ )
+ minted = _text(first).split("session_id=")[1].split(" ")[0]
+
+ second = await client.call_tool(
+ "add_todo",
+ {"text": "two", SESSION_ID_PARAM: minted, AGENT_ID_PARAM: AGENT},
+ )
+ assert MINT_BACK_HEADER not in _text(second)
+ assert second.structured_content[MCP_INSTRUCTIONS_KEY][AGENT_ID_PARAM] == AGENT
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+ assert [e.tags[AGENTCAT_TAG_AGENT_ID] for e in events] == [AGENT, AGENT]
+ assert [e.tags[AGENTCAT_TAG_AGENT_SOURCE] for e in events] == [
+ "supplied",
+ "supplied",
+ ]
+
+
+async def test_omitting_the_required_agent_handle_degrades_to_absence(
+ v4_http_server, capture_queue
+):
+ """`required` is advisory: nothing enforces it, and nothing may break.
+
+ AgentCat strips the handles in middleware, before the tool's own argument
+ validation, so an agent that ignores the `required` marker is served
+ normally. The event is then simply agent-less: an absent handle must never
+ become an empty or invented tag, because a customer filtering on
+ `agentcat_agent_id` has to be able to tell "no agent told us" from "this
+ agent".
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ result = await client.call_tool("add_todo", {"text": "no agent"})
+ assert MINT_BACK_HEADER in _text(result)
+ assert AGENT_ID_PARAM not in result.structured_content[MCP_INSTRUCTIONS_KEY]
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+async def test_a_blank_agent_handle_is_a_miss_not_an_empty_tag(
+ v4_http_server, capture_queue
+):
+ """`extract_handle` trims and rejects, over the wire.
+
+ A whitespace-only value must leave the event with NO agent tags — an
+ `agentcat_agent_id: ""` would key every such call to one phantom agent
+ downstream, which is worse than the absence it stands in for.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool(
+ "add_todo", {"text": "blank", AGENT_ID_PARAM: " ", "context": "x"}
+ )
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ # The session handle is unaffected: suppression is per handle.
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
diff --git a/tests/e2e/community_v4/test_identify_http.py b/tests/e2e/community_v4/test_identify_http.py
new file mode 100644
index 0000000..4dbbe51
--- /dev/null
+++ b/tests/e2e/community_v4/test_identify_http.py
@@ -0,0 +1,199 @@
+"""Identify-per-event behavior over real Streamable HTTP (community FastMCP 4).
+
+v2 has no standalone `agentcat:identify` event: the hook runs per tool call and
+its result is stamped onto that call's event.
+
+Tests mutate the running server's `AgentCatData.options.identify` to vary the
+hook per scenario, rather than declaring an options factory — the middleware
+re-reads the server's tracking data every request, so swapping the hook on the
+live server costs no second uvicorn boot. Each test resets it in `finally` so
+the next one starts clean.
+
+Every `fastmcp` / `httpx2` import is inside a test body, as in the rest of this
+tree: a module-scope import fails at COLLECTION on the no-fastmcp matrix legs,
+which no conftest gate downstream of it can rescue.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import Any
+
+import pytest
+
+from agentcat.modules.internal import get_server_tracking_data
+from agentcat.types import UserIdentity
+
+pytestmark = pytest.mark.e2e
+
+
+def _set_identify(server, fn) -> None:
+ data = get_server_tracking_data(server)
+ assert data is not None
+ data.options.identify = fn
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_the_hook_reads_the_real_http_request(v4_http_server, capture_queue):
+ """`extra` carries the live HTTP request, not a placeholder.
+
+ The hook's second argument is the SDK's `RequestContext`, and the header
+ read below is verbatim the idiom the README documents for
+ `resolve_session_id` ("receives the same `(request, extra)` pair as
+ `identify`") — keying off a header the customer's gateway set. Only a
+ socket can prove it: the in-process client has no HTTP request at all, so
+ `extra.request` is None there and the assertion would be vacuous.
+
+ `request` is the tool call's PARAMS on every flavor, which the same
+ assertion pins from the other side.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v4_http_server
+ # Recorded rather than asserted in place: `resolve_identity` swallows every
+ # exception the hook raises, so an assertion inside it would surface as a
+ # silently anonymous event instead of a failure.
+ seen: list[tuple[str, str | None]] = []
+
+ def identify(request: Any, extra: Any) -> UserIdentity | None:
+ headers = getattr(getattr(extra, "request", None), "headers", {}) or {}
+ tenant = headers.get("x-tenant")
+ seen.append((getattr(request, "name", None), tenant))
+ return UserIdentity(user_id=f"tenant:{tenant}", user_name=None, user_data=None)
+
+ _set_identify(server, identify)
+ try:
+ async with Client(
+ StreamableHttpTransport(url, headers={"X-Tenant": "acme"})
+ ) as client:
+ await client.call_tool("add_todo", {"text": "tenant", "context": "id"})
+
+ time.sleep(0.5)
+ assert seen == [("add_todo", "acme")], seen
+ assert _call_events(capture_queue)[-1].identify_actor_given_id == "tenant:acme"
+ finally:
+ _set_identify(server, None)
+
+
+async def test_the_actor_rides_the_tool_call_event_not_a_self_event(
+ v4_http_server, capture_queue
+):
+ """v2 stamps the actor onto every tools/call event; the standalone
+ `agentcat:identify` event is gone."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v4_http_server
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ return UserIdentity(
+ user_id="bob",
+ user_name="Bob Bobson",
+ user_data={"plan": "enterprise"},
+ )
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool("add_todo", {"text": "self", "context": "x"})
+
+ time.sleep(0.5)
+ assert {e.event_type for e in capture_queue} == {"mcp:tools/call"}
+ event = _call_events(capture_queue)[-1]
+ # All three fields, not just the id: `user_name` and `user_data` are
+ # what a customer segments and displays by, and each lands in a
+ # differently-named event field.
+ assert event.identify_actor_given_id == "bob"
+ assert event.identify_actor_name == "Bob Bobson"
+ assert event.identify_data == {"plan": "enterprise"}
+ finally:
+ _set_identify(server, None)
+
+
+async def test_identity_is_resolved_per_call_never_cached(
+ v4_http_server, capture_queue
+):
+ """The hook runs on EVERY call, so consecutive calls on one connection can
+ return different actors — v1 cached the result for the connection's life
+ and could not express this."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v4_http_server
+ counter = {"n": 0}
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ counter["n"] += 1
+ return UserIdentity(
+ user_id=f"user-{counter['n']}", user_name=None, user_data=None
+ )
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool("add_todo", {"text": "first", "context": "x"})
+ await client.call_tool("add_todo", {"text": "second", "context": "x"})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert counter["n"] == 2, "the hook did not run once per call"
+ assert [e.identify_actor_given_id for e in events] == ["user-1", "user-2"]
+ finally:
+ _set_identify(server, None)
+
+
+async def test_returning_none_yields_an_anonymous_event(v4_http_server, capture_queue):
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v4_http_server
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ return None
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ await client.call_tool("add_todo", {"text": "none", "context": "x"})
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.identify_actor_given_id is None
+ assert event.identify_actor_name is None
+ finally:
+ _set_identify(server, None)
+
+
+async def test_a_raising_hook_does_not_break_the_call(v4_http_server, capture_queue):
+ """A customer hook that blows up yields an anonymous call, not a failed one
+ — and not a dropped event either."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, server = v4_http_server
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ raise RuntimeError("identify exploded")
+
+ _set_identify(server, identify)
+ try:
+ async with Client(StreamableHttpTransport(url)) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "boom", "context": "x"}
+ )
+ assert "Added todo" in _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.parameters["arguments"]["text"] == "boom"
+ assert event.identify_actor_given_id is None
+ finally:
+ _set_identify(server, None)
diff --git a/tests/e2e/community_v4/test_session_http.py b/tests/e2e/community_v4/test_session_http.py
new file mode 100644
index 0000000..9107e08
--- /dev/null
+++ b/tests/e2e/community_v4/test_session_http.py
@@ -0,0 +1,214 @@
+"""Community FastMCP 4 over real Streamable HTTP.
+
+The in-process tests cover the adapter's logic; this file covers what only a
+socket can — the SDK's outbound validation of the injected schema, the wire
+mint-back, and the protocol error a malformed request must keep. v2 publishes
+exactly one event type: `mcp:tools/call`. `initialize` only feeds the
+client-identity ladder and `tools/list` is intercepted for schema injection, so
+neither produces an event.
+
+Every `fastmcp` / `httpx2` import is inside a test body, as in the rest of this
+tree. `tests/conftest.py` keeps the community trees out of a run with no
+fastmcp installed, but a module-scope import here would fail at COLLECTION,
+which no conftest gate downstream of it can rescue.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+
+import pytest
+
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+
+pytestmark = pytest.mark.e2e
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_handshake_and_list_publish_nothing(v4_http_server, capture_queue):
+ """A real handshake plus list_tools produces no events at all — and the
+ injected schema survives the SDK's outbound validation."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ listed = await client.list_tools()
+
+ time.sleep(0.5)
+ add = next(t for t in listed if t.name == "add_todo")
+ assert list(add.input_schema["properties"])[-2:] == [SESSION_ID_PARAM, "context"]
+ assert MCP_INSTRUCTIONS_KEY in add.output_schema["properties"]
+ assert capture_queue == [], [e.event_type for e in capture_queue]
+
+
+async def test_task_handle_is_minted_then_echoed(v4_http_server, capture_queue):
+ """The mint-back travels over the wire — text and structured — and the
+ echoed handle keys the next event to the same task."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "one", "context": "first call of the task"}
+ )
+ text = _text(first)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+ assert first.structured_content[MCP_INSTRUCTIONS_KEY]["session_id"] == minted
+
+ second = await client.call_tool(
+ "add_todo", {"text": "two", SESSION_ID_PARAM: minted}
+ )
+ assert MINT_BACK_HEADER not in _text(second)
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)
+ assert [e.session_id for e in events] == [minted, minted]
+ sources = [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events]
+ assert sources == ["minted", "supplied"]
+ # The event records the call as the agent made it: raw arguments in, the
+ # customer's undecorated result out.
+ assert events[0].parameters["arguments"]["context"] == "first call of the task"
+ assert events[0].user_intent == "first call of the task"
+ assert MINT_BACK_HEADER not in json.dumps(events[0].response)
+ assert events[0].duration is not None and events[0].duration >= 0
+
+
+async def test_client_identity_reaches_the_event(v4_http_server, capture_queue):
+ """Name AND version reach the event over a real connection.
+
+ Asserted against a `client_info` this test supplies rather than against
+ "some name resolved": the SDK's own default satisfies a truthiness check
+ while proving nothing about what the ladder actually read, and
+ `client_version` is null-by-default, so a rung that dropped it would go
+ unnoticed by a name-only assertion.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+ from mcp.types import Implementation
+
+ url, _ = v4_http_server
+ async with Client(
+ StreamableHttpTransport(url),
+ client_info=Implementation(name="MyAgent", version="1.2.3"),
+ ) as client:
+ await client.call_tool("add_todo", {"text": "who", "context": "x"})
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert (event.client_name, event.client_version) == ("MyAgent", "1.2.3")
+
+
+async def test_identity_rides_every_call_not_just_the_first(
+ v4_http_server, capture_queue
+):
+ """Name AND version on EVERY event of a connection.
+
+ Reading only the last event cannot tell "resolved per request" from
+ "resolved once and reused", and the two differ exactly where it matters: a
+ rung that answers only for the call following the handshake leaves every
+ later event of a long-lived connection anonymous. Three calls, three
+ identities.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+ from mcp.types import Implementation
+
+ url, _ = v4_http_server
+ async with Client(
+ StreamableHttpTransport(url),
+ client_info=Implementation(name="Cursor", version="2.6.22"),
+ ) as client:
+ for n in range(3):
+ await client.call_tool("add_todo", {"text": f"call-{n}", "context": "id"})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-3:]
+ assert [e.parameters["arguments"]["text"] for e in events] == [
+ "call-0",
+ "call-1",
+ "call-2",
+ ]
+ assert [(e.client_name, e.client_version) for e in events] == [
+ ("Cursor", "2.6.22")
+ ] * 3
+
+
+async def test_a_malformed_tools_call_keeps_its_own_protocol_error(
+ v4_http_server, capture_queue
+):
+ """FastMCP 4 runs the middleware chain a SECOND time for a component
+ request that failed before the interior chain did, and hands the hooks the
+ RAW params mapping rather than a typed model.
+
+ A `tools/call` with no `name` is exactly that request. AgentCat must let it
+ past untouched: an `AttributeError` raised in the hook would REPLACE the
+ customer server's own `-32602` on the wire, and no real client can be made
+ to send this, so only a hand-built request reaches it.
+ """
+ import httpx2
+
+ url, _ = v4_http_server
+ headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json, text/event-stream",
+ "MCP-Protocol-Version": "2025-06-18",
+ }
+ async with httpx2.AsyncClient(follow_redirects=True) as http:
+ handshake = await http.post(
+ url,
+ headers=headers,
+ json={
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "malformed-probe", "version": "1"},
+ },
+ },
+ )
+ session_id = handshake.headers.get("mcp-session-id")
+ if session_id:
+ headers["Mcp-Session-Id"] = session_id
+ await http.post(
+ url,
+ headers=headers,
+ json={"jsonrpc": "2.0", "method": "notifications/initialized"},
+ )
+ response = await http.post(
+ url,
+ headers=headers,
+ json={
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {"arguments": {"text": "no name at all"}},
+ },
+ )
+
+ body = response.text
+ payload = json.loads(body.split("data: ", 1)[1])
+ assert payload["error"]["code"] == -32602, body
+ assert "model_copy" not in payload["error"]["message"], body
+
+ time.sleep(0.5)
+ assert _call_events(capture_queue) == []
diff --git a/tests/e2e/community_v4/test_stateless_http.py b/tests/e2e/community_v4/test_stateless_http.py
new file mode 100644
index 0000000..a81af17
--- /dev/null
+++ b/tests/e2e/community_v4/test_stateless_http.py
@@ -0,0 +1,102 @@
+"""A genuinely stateless HTTP server — the transport the identity bug lived on.
+
+Every other e2e module in this tree boots a stateful app, where one
+`ServerSession` serves a whole connection. `stateless_http=True` builds a fresh
+one per REQUEST, and that is not a configuration detail for AgentCat: the
+session is what tells one caller from another, so under stateless HTTP the
+per-connection filing of the handshake `clientInfo` has nothing to hold and
+every call falls through to whatever rung answers next.
+
+One middleware object serves every connection. A single "last seen" slot for
+the handshake identity therefore let a later client's `initialize` rename an
+earlier client's call — and stateless HTTP is exactly where that fires on every
+call rather than never. The fix is unit-tested; this is the transport that
+would have caught it.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+
+import pytest
+
+pytestmark = pytest.mark.e2e
+
+# Read by `tests/e2e/community_v4/conftest.py`.
+STATELESS_HTTP = True
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_a_task_handle_survives_with_no_server_side_session(
+ v4_http_server, capture_queue
+):
+ """Handles are resolved per request from the arguments, so a server that
+ keeps nothing between requests keeps the task anyway."""
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+
+ url, _ = v4_http_server
+ async with Client(StreamableHttpTransport(url)) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "s", "context": "stateless first call"}
+ )
+ minted = _text(first).split("session_id=")[1].split(" ")[0]
+ await client.call_tool("add_todo", {"text": "s2", "session_id": minted})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)
+ assert len(events) == 2
+ assert [e.session_id for e in events] == [minted, minted]
+
+
+async def test_concurrent_clients_never_wear_each_others_name(
+ v4_http_server, capture_queue
+):
+ """Two clients at once, each named, on a server that remembers nothing.
+
+ Each event is matched to the call that produced it by the argument that
+ call sent, so this asserts ATTRIBUTION rather than "both names appear
+ somewhere" — the shared-slot bug produced one name on both events, and a
+ set membership check can be satisfied by the wrong pairing.
+ """
+ from fastmcp import Client
+ from fastmcp.client.transports import StreamableHttpTransport
+ from mcp.types import Implementation
+
+ url, _ = v4_http_server
+
+ async def call_as(name: str, version: str, text: str) -> None:
+ async with Client(
+ StreamableHttpTransport(url),
+ client_info=Implementation(name=name, version=version),
+ ) as client:
+ await client.call_tool("add_todo", {"text": text, "context": "no-bleed"})
+
+ await asyncio.gather(
+ call_as("Cursor", "2.6.22", "from-cursor"),
+ call_as("Claude", "1.0.0", "from-claude"),
+ )
+ time.sleep(0.7)
+
+ events = _call_events(capture_queue)
+ assert len(events) == 2
+ # Version as well as name: they travel together on every rung of the
+ # ladder, and a rung that answered with half an identity would satisfy a
+ # name-only check while leaving `client_version` silently null on every
+ # event a customer segments by.
+ attributed = {
+ e.parameters["arguments"]["text"]: (e.client_name, e.client_version)
+ for e in events
+ }
+ assert attributed == {
+ "from-cursor": ("Cursor", "2.6.22"),
+ "from-claude": ("Claude", "1.0.0"),
+ }
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 0cf0524..a726a45 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -27,7 +27,7 @@ def capture_queue() -> List[Any]:
captured: List[Any] = []
mock = MagicMock()
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured.append(publish_event_request)
mock.publish_event = MagicMock(side_effect=capture_event)
diff --git a/tests/e2e/official/conftest.py b/tests/e2e/official/conftest.py
index 0850cb8..bccbd62 100644
--- a/tests/e2e/official/conftest.py
+++ b/tests/e2e/official/conftest.py
@@ -5,6 +5,10 @@
FastMCP todo server for the module, calls `agentcat.track(...)` with those
options, mounts the server's Streamable-HTTP app, and yields the URL.
+`STATELESS_HTTP = True` at module scope serves it from a stateless app
+instead — a different code path, where the transport builds a fresh session
+per REQUEST and nothing survives between calls.
+
Module-scoped: one boot per test file, not per test.
"""
@@ -43,6 +47,9 @@ def official_http_server(request) -> Tuple[str, Any]:
)
options = options_factory()
server = create_todo_server()
+ # Before the app is built: v1 FastMCP reads this off its settings when it
+ # constructs the session manager.
+ server.settings.stateless_http = getattr(request.module, "STATELESS_HTTP", False)
agentcat.track(server, "test_project", options)
app = server.streamable_http_app()
diff --git a/tests/e2e/official/test_agent_handle_http.py b/tests/e2e/official/test_agent_handle_http.py
new file mode 100644
index 0000000..78b2ee1
--- /dev/null
+++ b/tests/e2e/official/test_agent_handle_http.py
@@ -0,0 +1,237 @@
+"""The agent handle over real Streamable HTTP (official MCP SDK 1.x).
+
+`enable_agent_tracking` is off by default, so every OTHER e2e module in this
+tree runs with `agent_id` never injected. This module turns it on for its own
+server — the fixture reads `AGENTCAT_OPTIONS_FACTORY` per module — and it is a
+separate file rather than a flag flipped on a shared one because `agent_id` is
+injected as REQUIRED, which changes the schema every sibling test calls against.
+
+The strip is read at the TOOL MANAGER (`tests.test_utils.delivery`), never from
+the tool's own result: this SDK's manager DROPS an argument the signature does
+not name without complaint, so a test that sends `agent_id` and asserts "no
+error" passes identically with the strip disabled.
+
+Structured output is gated: `Tool.outputSchema` and `structuredContent` arrive
+in mcp 1.10, and this tree runs as far back as 1.9.2. Below it AgentCat mirrors
+nothing, because there is no field to mirror into.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+from mcp import ClientSession
+from mcp.client.streamable_http import streamablehttp_client
+
+from agentcat import AgentCatOptions
+from agentcat.modules.constants import (
+ AGENT_ID_PARAM,
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+from tests.test_utils import NEEDS_STRUCTURED_OUTPUT
+from tests.test_utils.delivery import delivered_arguments_for
+
+pytestmark = pytest.mark.e2e
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+AGENT = "opus-4.80-1m|claude-code|k3n9x"
+
+
+def _agent_tracking_options() -> AgentCatOptions:
+ return AgentCatOptions(enable_tracing=True, enable_agent_tracking=True)
+
+
+AGENTCAT_OPTIONS_FACTORY = _agent_tracking_options
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+@pytest.mark.asyncio
+async def test_the_agent_handle_survives_the_wire(official_http_server, capture_queue):
+ """Listing with agent tracking on: the schema the agent is handed.
+
+ Property order is the contract (`modules/injection.py` §"Resulting property
+ order"), and `agent_id` is required where `session_id` is not — omission is
+ the minting signal for one and nothing for the other.
+ """
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ listed = await client.list_tools()
+
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.inputSchema["properties"])[-3:] == [
+ SESSION_ID_PARAM,
+ AGENT_ID_PARAM,
+ "context",
+ ]
+ assert AGENT_ID_PARAM in add.inputSchema["required"]
+ assert SESSION_ID_PARAM not in add.inputSchema["required"]
+
+
+@pytest.mark.asyncio
+async def test_a_supplied_agent_handle_tags_the_event(
+ official_http_server, capture_queue
+):
+ """The handle rides the event as a tag and never reaches the tool."""
+ url, server = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ result = await client.call_tool(
+ "add_todo",
+ {"text": "with agent", AGENT_ID_PARAM: AGENT, "context": "why"},
+ )
+ assert result.isError is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ # The tool layer never saw the handles — the only observation on this shape
+ # that a broken strip would fail. The server runs in-process (uvicorn in a
+ # thread), so its recorder is readable straight off the fixture's object.
+ assert delivered_arguments_for(server, "add_todo")[-1] == {"text": "with agent"}
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == AGENT
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ # The event records the call as the agent made it: handles included.
+ assert event.parameters["arguments"][AGENT_ID_PARAM] == AGENT
+
+
+@pytest.mark.asyncio
+async def test_both_handles_echo_across_calls(official_http_server, capture_queue):
+ """The agent echoes session and agent handle together on the next call.
+
+ The two are independent: the session is confirmed rather than re-minted,
+ while `agent_id` is `supplied` on both calls — the server never issues one,
+ so it has no `minted` state to pass through.
+ """
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ first = await client.call_tool(
+ "add_todo", {"text": "one", AGENT_ID_PARAM: AGENT, "context": "start"}
+ )
+ minted = _text(first).split("session_id=")[1].split(" ")[0]
+
+ second = await client.call_tool(
+ "add_todo",
+ {"text": "two", SESSION_ID_PARAM: minted, AGENT_ID_PARAM: AGENT},
+ )
+ assert second.isError is False, _text(second)
+ assert MINT_BACK_HEADER not in _text(second)
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+ assert [e.tags[AGENTCAT_TAG_AGENT_ID] for e in events] == [AGENT, AGENT]
+ assert [e.tags[AGENTCAT_TAG_AGENT_SOURCE] for e in events] == [
+ "supplied",
+ "supplied",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_omitting_the_required_agent_handle_degrades_to_absence(
+ official_http_server, capture_queue
+):
+ """`required` is advisory: nothing enforces it, and nothing may break.
+
+ The injected schema is what `tools/list` advertises, but AgentCat strips the
+ handles at the request-handler seam BEFORE any argument validation the tool
+ layer would do, so an agent that ignores the `required` marker is served
+ normally. The event is then simply agent-less: an absent handle must never
+ become an empty or invented tag, because a customer filtering on
+ `agentcat_agent_id` has to be able to tell "no agent told us" from "this
+ agent".
+ """
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ result = await client.call_tool("add_todo", {"text": "no agent"})
+ assert result.isError is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_agent_handle_is_a_miss_not_an_empty_tag(
+ official_http_server, capture_queue
+):
+ """`extract_handle` trims and rejects, over the wire.
+
+ A whitespace-only value must leave the event with NO agent tags — an
+ `agentcat_agent_id: ""` would key every such call to one phantom agent
+ downstream, which is worse than the absence it stands in for.
+ """
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ result = await client.call_tool(
+ "add_todo", {"text": "blank", AGENT_ID_PARAM: " ", "context": "x"}
+ )
+ assert result.isError is False, _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ # The session handle is unaffected: suppression is per handle.
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+@NEEDS_STRUCTURED_OUTPUT
+@pytest.mark.asyncio
+async def test_both_handles_are_mirrored_into_structured_content(
+ official_http_server, capture_queue
+):
+ """The agent can re-read either handle mid-session, over the wire.
+
+ Unlike the mint-back text (announcements only), the structured mirror is
+ present on every response — and it names BOTH handles, because suppression
+ is per handle rather than per response.
+ """
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert MCP_INSTRUCTIONS_KEY in add.outputSchema["properties"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "mirrored", AGENT_ID_PARAM: AGENT}
+ )
+ mirror = result.structuredContent[MCP_INSTRUCTIONS_KEY]
+ assert mirror[SESSION_ID_PARAM].startswith("ses_")
+ assert mirror[AGENT_ID_PARAM] == AGENT
+ # The customer's own structured payload survives untouched.
+ assert result.structuredContent["result"].startswith("Added todo")
+
+ time.sleep(0.5)
+ assert _call_events(capture_queue)[-1].session_id == mirror[SESSION_ID_PARAM]
diff --git a/tests/e2e/official/test_event_capture_http.py b/tests/e2e/official/test_event_capture_http.py
index eb5abab..fb45b1f 100644
--- a/tests/e2e/official/test_event_capture_http.py
+++ b/tests/e2e/official/test_event_capture_http.py
@@ -1,9 +1,8 @@
"""Event-capture and round-trip tests over real Streamable HTTP.
-NOTE: mcp:initialize events are not captured under any transport because
-ServerSession handles initialize internally before user request handlers
-fire (see the skipped in-memory test_initialization_event_capture). All
-assertions in this file use post-initialize events instead.
+v2 publishes exactly one event type — mcp:tools/call. initialize is handled by
+ServerSession before any user handler fires, and tools/list is intercepted for
+schema injection only, so neither produces an event.
"""
from __future__ import annotations
@@ -20,19 +19,19 @@
@pytest.mark.asyncio
-async def test_tools_list_event_captured(official_http_server, capture_queue):
- """Real handshake + list_tools produces a mcp:tools/list event."""
+async def test_handshake_and_list_publish_nothing(official_http_server, capture_queue):
+ """A real handshake plus list_tools produces no events at all."""
url, _server = official_http_server
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(read, write) as client:
await client.initialize()
- await client.list_tools()
+ listed = await client.list_tools()
time.sleep(0.5)
- list_events = [e for e in capture_queue if e.event_type == "mcp:tools/list"]
- assert list_events, (
- f"expected mcp:tools/list, got {[e.event_type for e in capture_queue]}"
- )
+ # The listing still went through AgentCat: the handles are on the schemas.
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert "session_id" in add.inputSchema["properties"]
+ assert capture_queue == [], [e.event_type for e in capture_queue]
@pytest.mark.asyncio
diff --git a/tests/e2e/official/test_identify_http.py b/tests/e2e/official/test_identify_http.py
index f005663..c5dd150 100644
--- a/tests/e2e/official/test_identify_http.py
+++ b/tests/e2e/official/test_identify_http.py
@@ -1,9 +1,11 @@
"""Identify-per-event behavior over real Streamable HTTP.
+v2 has no standalone agentcat:identify event: the hook runs per tool call and
+its result is stamped onto that call's event.
+
Tests mutate the running server's AgentCatData.options.identify to vary the hook
-per scenario. The default options-factory is tracing-only with no identify;
-identify-swapping on the live server matches the pattern used by
-tests/test_stateless.py.
+per scenario. The default options-factory is tracing-only with no identify, so
+the hook is swapped on the live server instead of re-tracking it.
Each test resets the hook in finally so subsequent tests start clean.
"""
@@ -38,12 +40,27 @@ def _last_call(capture_queue):
async def test_identify_hook_receives_real_request_extra(
official_http_server, capture_queue
):
+ """`extra` carries the live HTTP request, not a placeholder.
+
+ The header read below is verbatim the idiom the README documents for
+ `resolve_session_id` ("receives the same `(request, extra)` pair as
+ `identify`") — keying off a header the customer's gateway set. Only a
+ socket can prove it: the in-process client has no HTTP request at all, so
+ `extra.request` is None there and the assertion would be vacuous.
+
+ Recorded rather than asserted in place: `resolve_identity` swallows every
+ exception the hook raises, so an assertion inside it would surface as a
+ silently anonymous event instead of a failure.
+ """
url, server = official_http_server
- received_extras: list = []
+ seen: list = []
def identify(request: Any, extra: Any) -> Optional[UserIdentity]:
- received_extras.append(extra)
- return UserIdentity(user_id="alice", user_name="Alice", user_data=None)
+ headers = getattr(getattr(extra, "request", None), "headers", {}) or {}
+ seen.append((getattr(request, "name", None), headers.get("x-identify-hook")))
+ return UserIdentity(
+ user_id="alice", user_name="Alice", user_data={"plan": "pro"}
+ )
_set_identify(server, identify)
try:
@@ -57,17 +74,24 @@ def identify(request: Any, extra: Any) -> Optional[UserIdentity]:
)
time.sleep(0.5)
- assert received_extras, "identify hook never invoked"
+ assert seen == [("add_todo", "yes")], seen
ev = _last_call(capture_queue)
+ # All three fields, not just the id: `user_name` and `user_data` are
+ # what a customer segments and displays by, and each lands in a
+ # differently-named event field.
assert ev.identify_actor_given_id == "alice"
+ assert ev.identify_actor_name == "Alice"
+ assert ev.identify_data == {"plan": "pro"}
finally:
_set_identify(server, None)
@pytest.mark.asyncio
-async def test_agentcat_identify_self_event_published_per_request(
+async def test_actor_rides_the_tool_call_event_not_a_self_event(
official_http_server, capture_queue
):
+ """v2 stamps the actor onto every tools/call event; the standalone
+ agentcat:identify event is gone."""
url, server = official_http_server
def identify(_req: Any, _extra: Any) -> Optional[UserIdentity]:
@@ -83,14 +107,8 @@ def identify(_req: Any, _extra: Any) -> Optional[UserIdentity]:
)
time.sleep(0.5)
- identify_events = [
- e for e in capture_queue if e.event_type == "agentcat:identify"
- ]
- assert identify_events, (
- f"expected agentcat:identify event, got "
- f"{[e.event_type for e in capture_queue]}"
- )
- assert identify_events[0].identify_actor_given_id == "bob"
+ assert {e.event_type for e in capture_queue} == {"mcp:tools/call"}
+ assert _last_call(capture_queue).identify_actor_given_id == "bob"
finally:
_set_identify(server, None)
@@ -134,7 +152,7 @@ def identify(_req: Any, _extra: Any) -> Optional[UserIdentity]:
@pytest.mark.asyncio
-async def test_identify_returning_none_yields_no_self_event(
+async def test_identify_returning_none_yields_an_anonymous_event(
official_http_server, capture_queue
):
url, server = official_http_server
@@ -152,13 +170,9 @@ def identify(_req: Any, _extra: Any) -> Optional[UserIdentity]:
)
time.sleep(0.5)
- identify_events = [
- e for e in capture_queue if e.event_type == "agentcat:identify"
- ]
- assert not identify_events, (
- f"identify returned None; should NOT publish self-event, got "
- f"{len(identify_events)}"
- )
+ event = _last_call(capture_queue)
+ assert event.identify_actor_given_id is None
+ assert event.identify_actor_name is None
finally:
_set_identify(server, None)
diff --git a/tests/e2e/official/test_redaction_http.py b/tests/e2e/official/test_redaction_http.py
index 30cb60f..a538d5d 100644
--- a/tests/e2e/official/test_redaction_http.py
+++ b/tests/e2e/official/test_redaction_http.py
@@ -1,15 +1,13 @@
"""Redaction over real-wire payloads.
-KNOWN BUG (xfail-tracked): `agentcat.modules.redaction.redact_event` only
-recurses into `dict` and `list` types, not Pydantic `UnredactedEvent` objects.
-The event_queue worker invokes `redact_event(event, ...)` where `event` is an
-`UnredactedEvent`; the call returns the input unchanged, so customer-supplied
-redact functions never actually run on the live event-publish path.
-
-Tests below are marked xfail so they:
-1. Codify the intended behavior.
-2. Serve as a regression target — when the bug is fixed, they should be
- un-xfailed (the strict=False xfail still passes if the test starts working).
+These were xfail-tracked for the whole of the v2 branch:
+`agentcat.modules.redaction.redact_event` walked `str` / `list` / `dict` and
+returned anything else untouched, and the publish path hands it a pydantic
+`UnredactedEvent` — so the documented `redact_sensitive_information` hook was
+a no-op on every real event while the README advertised it as a security
+control. `redact_event` now dumps the model, redacts, and copies back, so the
+markers are gone and these tests are the live guard: a regression here fails
+the suite instead of quietly passing as an xpass.
"""
from __future__ import annotations
@@ -32,11 +30,6 @@ def _set_redact(server, fn) -> None:
data.options.redact_sensitive_information = fn
-@pytest.mark.xfail(
- reason="redact_event does not recurse into Pydantic UnredactedEvent; "
- "redaction never fires on real events. Track as separate fix.",
- strict=False,
-)
@pytest.mark.asyncio
async def test_redact_function_runs_on_real_event_payload(
official_http_server, capture_queue
@@ -67,11 +60,6 @@ def redact(s: str) -> str:
_set_redact(server, None)
-@pytest.mark.xfail(
- reason="redact_event does not recurse into Pydantic UnredactedEvent; "
- "redaction never fires on real events. Track as separate fix.",
- strict=False,
-)
@pytest.mark.asyncio
async def test_redaction_can_scrub_authorization_header_in_extra(
official_http_server, capture_queue
@@ -111,12 +99,6 @@ def redact(s: str) -> str:
_set_redact(server, None)
-@pytest.mark.xfail(
- reason="redact_event does not invoke the user's redact fn on Pydantic "
- "events, so redact-fn-raise never fires; the 'drop event on raise' path "
- "is unreachable until the redact_event recursion bug is fixed.",
- strict=False,
-)
@pytest.mark.asyncio
async def test_redaction_failure_drops_event(official_http_server, capture_queue):
url, server = official_http_server
diff --git a/tests/e2e/official/test_request_extra_http.py b/tests/e2e/official/test_request_extra_http.py
index 1245843..91a5779 100644
--- a/tests/e2e/official/test_request_extra_http.py
+++ b/tests/e2e/official/test_request_extra_http.py
@@ -131,10 +131,11 @@ async def test_meta_dict_present_when_supported(official_http_server, capture_qu
@pytest.mark.asyncio
-async def test_list_tools_event_carries_extra(official_http_server, capture_queue):
- """tools/list events also receive parameters.extra under HTTP transport
- (initialize events don't reach our handlers; tools/list is the next-best
- early-handshake event to verify extra propagation on)."""
+async def test_extra_survives_a_listing_in_the_same_session(
+ official_http_server, capture_queue
+):
+ """tools/list publishes no event in v2, so `extra` rides the tools/call
+ that follows it — with the headers of THAT request, not the listing's."""
url, _ = official_http_server
async with streamablehttp_client(
url, headers={"X-List-Header": "list-value"}
@@ -142,14 +143,11 @@ async def test_list_tools_event_carries_extra(official_http_server, capture_queu
async with ClientSession(read, write) as client:
await client.initialize()
await client.list_tools()
+ await client.call_tool("add_todo", {"text": "l", "context": "listing"})
time.sleep(0.5)
- list_events = [e for e in capture_queue if e.event_type == "mcp:tools/list"]
- assert list_events
- headers = (
- (list_events[0].parameters or {})
- .get("extra", {})
- .get("requestInfo", {})
- .get("headers", {})
+ assert {e.event_type for e in capture_queue} == {"mcp:tools/call"}
+ headers = _extra(_last_call_event(capture_queue)).get("requestInfo", {}).get(
+ "headers", {}
)
assert headers.get("x-list-header") == "list-value"
diff --git a/tests/e2e/official/test_session_http.py b/tests/e2e/official/test_session_http.py
index 3e46252..78fdb6b 100644
--- a/tests/e2e/official/test_session_http.py
+++ b/tests/e2e/official/test_session_http.py
@@ -1,15 +1,10 @@
-"""Client-info propagation tests over real Streamable HTTP.
-
-NOTE: User-Agent / X-MCP-Client-Name header parsing is *not* tested e2e
-because real MCP clients always populate session.client_params.clientInfo
-during initialize, and that path wins over header parsing in the SDK
-(see src/agentcat/modules/session.py::get_client_info_from_request_context).
-The header-fallback path is covered by unit tests in tests/test_stateless.py
-that mock ctx.session = None to force the fallback. e2e here verifies the
-real-world path: clientInfo from the SDK propagates to events.
-
-Uses stateless mode so each test gets independent client_info extraction
-rather than fixture-level caching.
+"""Task-handle and client-info propagation over real Streamable HTTP.
+
+Two things ride every tools/call event and both are resolved per request:
+the task handle (minted on the first call, echoed back by the agent on later
+ones) and the client identity. The identity ladder reads the per-request
+`_meta` keys first and falls back to the handshake clientInfo the SDK's own
+ServerSession captured — no header parsing, no caching of our own.
"""
from __future__ import annotations
@@ -21,13 +16,6 @@
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import Implementation
-from agentcat import AgentCatOptions
-
-
-def AGENTCAT_OPTIONS_FACTORY() -> AgentCatOptions:
- return AgentCatOptions(enable_tracing=True, stateless=True)
-
-
pytestmark = pytest.mark.e2e
@@ -80,6 +68,44 @@ async def test_default_clientinfo_used_when_unspecified(
assert ev.client_name is not None, "expected non-None client_name"
+@pytest.mark.asyncio
+async def test_identity_rides_every_call_not_just_the_first(
+ official_http_server, capture_queue
+):
+ """Name AND version on EVERY event of a connection.
+
+ Reading only the last event cannot tell "resolved per request" from
+ "resolved once and reused", and the two differ exactly where it matters: a
+ rung that answers only for the call following the handshake leaves every
+ later event of a long-lived connection anonymous. This is a STATEFUL app,
+ so the `ServerSession` that captured the handshake is still there for all
+ three calls — the rung has to answer more than once.
+ """
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(
+ read,
+ write,
+ client_info=Implementation(name="Cursor", version="2.6.22"),
+ ) as client:
+ await client.initialize()
+ for n in range(3):
+ await client.call_tool(
+ "add_todo", {"text": f"call-{n}", "context": "id"}
+ )
+
+ time.sleep(0.5)
+ events = [e for e in capture_queue if e.event_type == "mcp:tools/call"][-3:]
+ assert [e.parameters["arguments"]["text"] for e in events] == [
+ "call-0",
+ "call-1",
+ "call-2",
+ ]
+ assert [(e.client_name, e.client_version) for e in events] == [
+ ("Cursor", "2.6.22")
+ ] * 3
+
+
@pytest.mark.asyncio
async def test_clientinfo_with_special_characters(
official_http_server, capture_queue
@@ -137,3 +163,71 @@ async def test_clientinfo_in_extra_headers_when_set(
)
assert headers.get("x-mcp-client-name") == "HeaderClient"
assert headers.get("x-mcp-client-version") == "8.8.8"
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+@pytest.mark.asyncio
+async def test_minted_session_id_is_echoed_across_http_calls(
+ official_http_server, capture_queue
+):
+ """First call mints and hands the handle back; the agent echoes it on the
+ next call and both events land on the same task."""
+ url, _ = official_http_server
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ first = await client.call_tool(
+ "add_todo", {"text": "one", "context": "first call of the task"}
+ )
+ text = _text(first)
+ assert "[MCP INSTRUCTIONS]: session_id issued." in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+
+ second = await client.call_tool(
+ "add_todo", {"text": "two", "session_id": minted}
+ )
+ # Already supplied: nothing is minted back a second time.
+ assert "[MCP INSTRUCTIONS]: session_id issued." not in _text(second)
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)
+ assert len(events) == 2
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags["agentcat_session_id_source"] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_separate_connections_get_separate_tasks(
+ official_http_server, capture_queue
+):
+ """Nothing is stored server-side, so two agents that never echo a handle
+ get two different tasks."""
+ url, _ = official_http_server
+
+ async def call_once(text: str) -> str:
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as client:
+ await client.initialize()
+ result = await client.call_tool(
+ "add_todo", {"text": text, "context": "independent task"}
+ )
+ return _text(result).split("session_id=")[1].split(" ")[0]
+
+ first = await call_once("a")
+ second = await call_once("b")
+ assert first != second
+
+ time.sleep(0.5)
+ minted = {e.session_id for e in _call_events(capture_queue)}
+ assert {first, second} <= minted
diff --git a/tests/e2e/official/test_stateless_http.py b/tests/e2e/official/test_stateless_http.py
index d79f0cc..3765dcb 100644
--- a/tests/e2e/official/test_stateless_http.py
+++ b/tests/e2e/official/test_stateless_http.py
@@ -1,4 +1,16 @@
-"""Stateless mode behavior over real Streamable HTTP."""
+"""Per-request resolution over a real STATELESS Streamable-HTTP server.
+
+The `stateless` option is gone in 2.0 — resolution is per request either way —
+so these guard what the option used to protect: a task handle that survives
+without any server-side session, and client identity that never leaks from one
+connection to another.
+
+The transport is the point, and it is where the sibling e2e modules cannot
+reach: `stateless_http=True` builds a fresh session per REQUEST, so nothing a
+server might have kept between calls exists, and every identity lookup falls
+through to whatever rung can still answer. A shared "last seen" slot anywhere
+in that ladder fires here on every call rather than never.
+"""
from __future__ import annotations
@@ -10,43 +22,47 @@
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import Implementation
-from agentcat import AgentCatOptions
-
-
-def AGENTCAT_OPTIONS_FACTORY() -> AgentCatOptions:
- return AgentCatOptions(enable_tracing=True, stateless=True)
-
-
pytestmark = pytest.mark.e2e
+# Read by this tree's conftest: the app really is served statelessly, so the
+# transport builds a fresh session per REQUEST and nothing survives a call.
+STATELESS_HTTP = True
+
@pytest.mark.asyncio
-async def test_stateless_mode_returns_null_session_id(
- official_http_server, capture_queue
-):
- """In stateless mode, captured events have session_id=None."""
+async def test_every_call_carries_a_task_handle(official_http_server, capture_queue):
+ """Handles are resolved per request from the arguments, so nothing is held
+ server-side: session_id carries the task, minted or echoed."""
url, _ = official_http_server
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(read, write) as client:
await client.initialize()
- await client.call_tool(
+ result = await client.call_tool(
"add_todo", {"text": "s", "context": "stateless"}
)
+ text = "".join(c.text for c in result.content if hasattr(c, "text"))
+ minted = text.split("session_id=")[1].split(" ")[0]
+
+ await client.call_tool("add_todo", {"text": "s2", "session_id": minted})
time.sleep(0.5)
call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
- assert call_events
- # In stateless mode, the SDK-level event session_id field is None.
- assert call_events[0].session_id is None
+ assert len(call_events) == 2
+ assert [e.session_id for e in call_events] == [minted, minted]
@pytest.mark.asyncio
-async def test_stateless_two_clients_different_clientinfo_dont_bleed(
+async def test_two_clients_different_clientinfo_dont_bleed(
official_http_server, capture_queue
):
- """Concurrent stateless requests with different clientInfo must produce
- events whose client_name reflects the *requesting* connection, not a
- cached value from a different connection."""
+ """Concurrent requests with different clientInfo must produce events whose
+ client_name reflects the *requesting* connection, not a cached value from a
+ different one. There is no identity cache left to bleed from.
+
+ Each event is matched to the call that produced it by the argument that
+ call sent, so this asserts ATTRIBUTION: a shared slot puts one name on both
+ events, and a set-membership check can be satisfied by the wrong pairing.
+ """
url, _ = official_http_server
async def call_with_client(name: str, version: str, text: str) -> None:
@@ -62,52 +78,26 @@ async def call_with_client(name: str, version: str, text: str) -> None:
)
await asyncio.gather(
- call_with_client("Cursor", "2.6.22", "a"),
- call_with_client("Claude", "1.0.0", "b"),
+ call_with_client("Cursor", "2.6.22", "from-cursor"),
+ call_with_client("Claude", "1.0.0", "from-claude"),
)
time.sleep(0.7)
call_events = [e for e in capture_queue if e.event_type == "mcp:tools/call"]
- client_names = {ev.client_name for ev in call_events}
- assert "Cursor" in client_names and "Claude" in client_names, (
- f"stateless mode bled client_info across requests: {client_names}"
- )
-
-
-@pytest.mark.asyncio
-async def test_stateless_no_session_info_pollution(
- official_http_server, capture_queue
-):
- """After multiple stateless requests, the server's data.session_info
- fields should remain unset, proving we're not caching."""
- url, server = official_http_server
-
- async with streamablehttp_client(url) as (read, write, _):
- async with ClientSession(
- read,
- write,
- client_info=Implementation(name="First", version="1.0"),
- ) as client:
- await client.initialize()
- await client.call_tool("add_todo", {"text": "1", "context": "x"})
-
- async with streamablehttp_client(url) as (read, write, _):
- async with ClientSession(
- read,
- write,
- client_info=Implementation(name="Second", version="2.0"),
- ) as client:
- await client.initialize()
- await client.call_tool("add_todo", {"text": "2", "context": "x"})
-
- time.sleep(0.5)
-
- from agentcat.modules.internal import get_server_tracking_data
-
- data = get_server_tracking_data(server)
- assert data is not None
- # In stateless mode, we never cache client_info onto data.session_info.
- assert data.session_info.client_name is None, (
- f"stateless mode polluted session_info.client_name = "
- f"{data.session_info.client_name}"
- )
+ assert len(call_events) == 2
+ attributed = {e.parameters["arguments"]["text"]: e.client_name for e in call_events}
+ # No name at all is the honest answer for a connection that is already
+ # gone; SOMEONE ELSE's name never is.
+ #
+ # The tolerance is deliberate and specific to THIS wire. A pre-2026 client
+ # sends `clientInfo` once, in `initialize`, so the only rung that can answer
+ # is the session capture — and a stateless server rebuilds the session per
+ # REQUEST, which means the handshake that carried the name belongs to an
+ # object that no longer exists by the time the call arrives. Absence there
+ # is unknowable, not a defect. On the 2026-07-28 wire the client re-stamps
+ # its identity into `_meta` on every request, so the same scenario IS
+ # strictly attributable and the modern sibling
+ # (`tests/e2e/official_modern/test_stateless_http.py`) asserts exact name
+ # and version. Do not loosen that one to match this.
+ assert attributed["from-cursor"] in (None, "Cursor"), attributed
+ assert attributed["from-claude"] in (None, "Claude"), attributed
diff --git a/tests/e2e/community_v2/__init__.py b/tests/e2e/official_modern/__init__.py
similarity index 100%
rename from tests/e2e/community_v2/__init__.py
rename to tests/e2e/official_modern/__init__.py
diff --git a/tests/e2e/official_modern/conftest.py b/tests/e2e/official_modern/conftest.py
new file mode 100644
index 0000000..dc7784e
--- /dev/null
+++ b/tests/e2e/official_modern/conftest.py
@@ -0,0 +1,70 @@
+"""Uvicorn-in-thread harness for the modern official MCP SDK (mcp 2.x).
+
+The legacy sibling (`tests/e2e/official/conftest.py`) boots a FastMCP v1 todo
+server; this one boots either 2.x flavor over the same real Streamable-HTTP
+transport, so the assertions cover the wire path a customer actually deploys —
+headers, session ids, the per-request `_meta` envelope — none of which the
+in-process client exercises.
+
+A test module declares `AGENTCAT_OPTIONS_FACTORY` (callable returning
+`AgentCatOptions`) and `SERVER_FACTORY` (callable returning the server to
+track) at module scope, and `STATELESS_HTTP = True` to be served by a stateless
+app — a different code path, where the transport builds a fresh session per
+REQUEST and nothing survives between calls. Module-scoped: one boot per test
+file, not per test.
+"""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Callable
+from typing import Any
+
+import pytest
+import uvicorn
+
+import agentcat
+from agentcat import AgentCatOptions
+from tests.e2e._helpers import find_free_port, wait_for_port
+from tests.test_utils.modern_server import create_lowlevel_todo_server
+
+
+def _default_options_factory() -> AgentCatOptions:
+ return AgentCatOptions(enable_tracing=True)
+
+
+@pytest.fixture(scope="module")
+def modern_http_server(request) -> tuple[str, Any]:
+ """Boot a Streamable-HTTP MCP server for the test module.
+
+ Yields:
+ (url, server) — the Streamable-HTTP URL and the tracked server.
+ """
+ options_factory: Callable[[], AgentCatOptions] = getattr(
+ request.module, "AGENTCAT_OPTIONS_FACTORY", _default_options_factory
+ )
+ server_factory: Callable[[], Any] = getattr(
+ request.module, "SERVER_FACTORY", create_lowlevel_todo_server
+ )
+ server = server_factory()
+ agentcat.track(server, "test_project", options_factory())
+
+ app = server.streamable_http_app(
+ stateless_http=getattr(request.module, "STATELESS_HTTP", False)
+ )
+ port = find_free_port()
+ config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
+ uv_server = uvicorn.Server(config)
+ thread = threading.Thread(target=uv_server.run, daemon=True)
+ thread.start()
+ try:
+ wait_for_port(port, timeout=5.0)
+ except TimeoutError:
+ uv_server.should_exit = True
+ thread.join(timeout=2.0)
+ raise
+
+ yield f"http://127.0.0.1:{port}/mcp", server
+
+ uv_server.should_exit = True
+ thread.join(timeout=5.0)
diff --git a/tests/e2e/official_modern/test_agent_handle_http.py b/tests/e2e/official_modern/test_agent_handle_http.py
new file mode 100644
index 0000000..e38853b
--- /dev/null
+++ b/tests/e2e/official_modern/test_agent_handle_http.py
@@ -0,0 +1,198 @@
+"""The agent handle over real Streamable HTTP (modern official SDK).
+
+`enable_agent_tracking` is off by default, so every OTHER e2e module in this
+tree runs with `agent_id` never injected at all. This module turns it on for
+its own server — the fixture reads `AGENTCAT_OPTIONS_FACTORY` per module — and
+it is deliberately a separate file rather than an option flipped on a shared
+one: `agent_id` is injected as REQUIRED, which changes the listed schema every
+sibling test calls against.
+
+What only a socket proves here is the listing. The SDK validates every outbound
+spec result against the negotiated protocol surface, so an injected schema that
+is malformed once `agent_id` joins `session_id` and `context` fails server-side
+rather than reaching the agent. In-process clients on this shape skip that pass.
+
+The strip needs no recorder on this shape: `create_lowlevel_todo_server`'s
+handler calls `_reject_unexpected(arguments, {"text"})`, so a handle that
+survived to the tool body fails the call outright.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+from mcp.client import Client
+
+from agentcat import AgentCatOptions
+from agentcat.modules.constants import (
+ AGENT_ID_PARAM,
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+
+pytestmark = pytest.mark.e2e
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+AGENT = "opus-4.80-1m|claude-code|k3n9x"
+
+
+def _agent_tracking_options() -> AgentCatOptions:
+ return AgentCatOptions(enable_tracing=True, enable_agent_tracking=True)
+
+
+AGENTCAT_OPTIONS_FACTORY = _agent_tracking_options
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_the_agent_handle_survives_the_wire(modern_http_server, capture_queue):
+ """Listing with agent tracking on: the schema the agent is handed.
+
+ Property order is the contract (`modules/injection.py` §"Resulting property
+ order"), and `agent_id` is required where `session_id` is not — omission is
+ the minting signal for one and nothing for the other.
+ """
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ listed = await client.list_tools()
+
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.input_schema["properties"]) == [
+ "text",
+ SESSION_ID_PARAM,
+ AGENT_ID_PARAM,
+ "context",
+ ]
+ assert AGENT_ID_PARAM in add.input_schema["required"]
+ assert SESSION_ID_PARAM not in add.input_schema["required"]
+ assert MCP_INSTRUCTIONS_KEY in add.output_schema["properties"]
+
+
+async def test_a_supplied_agent_handle_tags_the_event(
+ modern_http_server, capture_queue
+):
+ """The handle rides the event as a tag and never reaches the tool."""
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "add_todo",
+ {"text": "with agent", AGENT_ID_PARAM: AGENT, "context": "why"},
+ )
+ # The handler rejects any argument but `text`, so a surviving handle
+ # fails here rather than passing silently.
+ assert result.is_error is False, _text(result)
+ text = _text(result)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ # Both handles are mirrored, so an agent can re-read either mid-session.
+ mirror = result.structured_content[MCP_INSTRUCTIONS_KEY]
+ assert mirror[SESSION_ID_PARAM] == minted
+ assert mirror[AGENT_ID_PARAM] == AGENT
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == AGENT
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ # The event records the call as the agent made it: handles included.
+ assert event.parameters["arguments"][AGENT_ID_PARAM] == AGENT
+
+
+async def test_both_handles_echo_across_calls(modern_http_server, capture_queue):
+ """The agent echoes session and agent handle together on the next call.
+
+ The two are independent: the session is confirmed rather than re-minted,
+ while `agent_id` is `supplied` on both calls — it is never issued by the
+ server, so it has no `minted` state to pass through.
+ """
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "one", AGENT_ID_PARAM: AGENT, "context": "start"}
+ )
+ minted = _text(first).split("session_id=")[1].split(" ")[0]
+
+ second = await client.call_tool(
+ "add_todo",
+ {"text": "two", SESSION_ID_PARAM: minted, AGENT_ID_PARAM: AGENT},
+ )
+ assert second.is_error is False, _text(second)
+ assert MINT_BACK_HEADER not in _text(second)
+ assert second.structured_content[MCP_INSTRUCTIONS_KEY][AGENT_ID_PARAM] == AGENT
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+ assert [e.tags[AGENTCAT_TAG_AGENT_ID] for e in events] == [AGENT, AGENT]
+ assert [e.tags[AGENTCAT_TAG_AGENT_SOURCE] for e in events] == [
+ "supplied",
+ "supplied",
+ ]
+
+
+async def test_omitting_the_required_agent_handle_degrades_to_absence(
+ modern_http_server, capture_queue
+):
+ """`required` is advisory: nothing enforces it, and nothing may break.
+
+ Measured on mcp 2.0 — the injected schema is what `tools/list` advertises,
+ but AgentCat strips the handles at the request-handler seam BEFORE any
+ argument validation the tool layer would do, so an agent that ignores the
+ `required` marker is served normally. The event is then simply
+ agent-less: an absent handle must never become an empty or invented tag,
+ because a customer filtering on `agentcat_agent_id` has to be able to tell
+ "no agent told us" from "this agent".
+
+ The session handle is untouched by any of it and still mints.
+ """
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ result = await client.call_tool("add_todo", {"text": "no agent"})
+ assert result.is_error is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+ # Nothing to confirm, so the mirror names only the session.
+ assert AGENT_ID_PARAM not in result.structured_content[MCP_INSTRUCTIONS_KEY]
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+async def test_a_blank_agent_handle_is_a_miss_not_an_empty_tag(
+ modern_http_server, capture_queue
+):
+ """`extract_handle` trims and rejects, over the wire.
+
+ A whitespace-only value must leave the event with NO agent tags at all —
+ an `agentcat_agent_id: ""` would key every such call to one phantom agent
+ downstream, which is worse than the absence it stands in for.
+ """
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "blank", AGENT_ID_PARAM: " ", "context": "x"}
+ )
+ assert result.is_error is False, _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert AGENTCAT_TAG_AGENT_ID not in event.tags
+ assert AGENTCAT_TAG_AGENT_SOURCE not in event.tags
+ # The session handle is unaffected: suppression is per handle.
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
diff --git a/tests/e2e/official_modern/test_identify_http.py b/tests/e2e/official_modern/test_identify_http.py
new file mode 100644
index 0000000..8a2bef3
--- /dev/null
+++ b/tests/e2e/official_modern/test_identify_http.py
@@ -0,0 +1,231 @@
+"""Identify-per-event behavior over real Streamable HTTP (modern official SDK).
+
+v2 has no standalone `agentcat:identify` event: the hook runs per tool call and
+its result is stamped onto that call's event.
+
+Tests mutate the running server's `AgentCatData.options.identify` to vary the
+hook per scenario, rather than declaring an options factory — the hook is read
+per request, so swapping it on the live server costs no second uvicorn boot.
+Each test resets it in `finally` so the next one starts clean.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import Any
+
+import httpx2
+import pytest
+from mcp.client import Client
+from mcp.client.streamable_http import streamable_http_client
+
+from agentcat.modules.internal import get_server_tracking_data
+from agentcat.types import UserIdentity
+
+pytestmark = pytest.mark.e2e
+
+
+def _set_identify(server, fn) -> None:
+ data = get_server_tracking_data(server)
+ assert data is not None
+ data.options.identify = fn
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_the_hook_reads_the_real_http_request(
+ modern_http_server, capture_queue
+):
+ """`extra` carries the live HTTP request, not a placeholder.
+
+ The hook's second argument is the SDK's `RequestContext`, and the header
+ read below is verbatim the idiom the README documents for
+ `resolve_session_id` ("receives the same `(request, extra)` pair as
+ `identify`") — keying off a header the customer's gateway set. Only a
+ socket can prove it: the in-process client has no HTTP request at all, so
+ `extra.request` is None there and the assertion would be vacuous.
+
+ `request` is the tool call's PARAMS on every flavor, which the same
+ assertion pins from the other side.
+ """
+ url, server = modern_http_server
+ # Recorded rather than asserted in place: `resolve_identity` swallows every
+ # exception the hook raises, so an assertion inside it would surface as a
+ # silently anonymous event instead of a failure.
+ seen: list[tuple[str, str | None]] = []
+
+ def identify(request: Any, extra: Any) -> UserIdentity | None:
+ headers = getattr(getattr(extra, "request", None), "headers", {}) or {}
+ tenant = headers.get("x-tenant")
+ seen.append((getattr(request, "name", None), tenant))
+ return UserIdentity(user_id=f"tenant:{tenant}", user_name=None, user_data=None)
+
+ _set_identify(server, identify)
+ try:
+ http_client = httpx2.AsyncClient(headers={"X-Tenant": "acme"})
+ async with Client(
+ streamable_http_client(url, http_client=http_client)
+ ) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "tenant", "context": "id"}
+ )
+ assert result.is_error is False, _text(result)
+
+ time.sleep(0.5)
+ assert seen == [("add_todo", "acme")], seen
+ assert _call_events(capture_queue)[-1].identify_actor_given_id == "tenant:acme"
+ finally:
+ _set_identify(server, None)
+
+
+async def test_the_actor_rides_the_tool_call_event_not_a_self_event(
+ modern_http_server, capture_queue
+):
+ """v2 stamps the actor onto every tools/call event; the standalone
+ `agentcat:identify` event is gone."""
+ url, server = modern_http_server
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ return UserIdentity(
+ user_id="bob",
+ user_name="Bob Bobson",
+ user_data={"plan": "enterprise"},
+ )
+
+ _set_identify(server, identify)
+ try:
+ async with Client(url) as client:
+ await client.call_tool("add_todo", {"text": "self", "context": "x"})
+
+ time.sleep(0.5)
+ assert {e.event_type for e in capture_queue} == {"mcp:tools/call"}
+ event = _call_events(capture_queue)[-1]
+ # All three fields, not just the id: `user_name` and `user_data` are
+ # what a customer segments and displays by, and each lands in a
+ # differently-named event field.
+ assert event.identify_actor_given_id == "bob"
+ assert event.identify_actor_name == "Bob Bobson"
+ assert event.identify_data == {"plan": "enterprise"}
+ finally:
+ _set_identify(server, None)
+
+
+async def test_identity_is_resolved_per_call_never_cached(
+ modern_http_server, capture_queue
+):
+ """The hook runs on EVERY call, so consecutive calls on one connection can
+ return different actors — v1 cached the result for the connection's life
+ and could not express this."""
+ url, server = modern_http_server
+ counter = {"n": 0}
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ counter["n"] += 1
+ return UserIdentity(
+ user_id=f"user-{counter['n']}", user_name=None, user_data=None
+ )
+
+ _set_identify(server, identify)
+ try:
+ async with Client(url) as client:
+ await client.call_tool("add_todo", {"text": "first", "context": "x"})
+ await client.call_tool("add_todo", {"text": "second", "context": "x"})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert counter["n"] == 2, "the hook did not run once per call"
+ assert [e.identify_actor_given_id for e in events] == ["user-1", "user-2"]
+ finally:
+ _set_identify(server, None)
+
+
+async def test_returning_none_yields_an_anonymous_event(
+ modern_http_server, capture_queue
+):
+ url, server = modern_http_server
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ return None
+
+ _set_identify(server, identify)
+ try:
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "none", "context": "x"}
+ )
+ assert result.is_error is False, _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.identify_actor_given_id is None
+ assert event.identify_actor_name is None
+ finally:
+ _set_identify(server, None)
+
+
+async def test_an_async_hook_works_over_the_wire(modern_http_server, capture_queue):
+ """`identify` may be `async def`, and the contract holds on a real socket.
+
+ The cross-flavor suite (`tests/test_async_hooks.py`) proves the hook
+ contract itself; this proves the request path an actual deployment takes
+ still has a running loop when the hook is reached — which is what makes
+ awaiting the hook viable at all, on every adapter and transport.
+
+ The hook awaits something to make the point: a hook that merely returned
+ from an `async def` would pass even if the coroutine were driven by
+ accident somewhere upstream.
+ """
+ import asyncio
+
+ url, server = modern_http_server
+
+ async def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ await asyncio.sleep(0)
+ return UserIdentity(user_id="async-alice", user_name="Alice", user_data=None)
+
+ _set_identify(server, identify)
+ try:
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "async id", "context": "x"}
+ )
+ assert result.is_error is False, _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.identify_actor_given_id == "async-alice"
+ assert event.identify_actor_name == "Alice"
+ finally:
+ _set_identify(server, None)
+
+
+async def test_a_raising_hook_does_not_break_the_call(
+ modern_http_server, capture_queue
+):
+ """A customer hook that blows up yields an anonymous call, not a failed one
+ — and not a dropped event either."""
+ url, server = modern_http_server
+
+ def identify(_request: Any, _extra: Any) -> UserIdentity | None:
+ raise RuntimeError("identify exploded")
+
+ _set_identify(server, identify)
+ try:
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": "boom", "context": "x"}
+ )
+ assert result.is_error is False, _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.parameters["arguments"]["text"] == "boom"
+ assert event.identify_actor_given_id is None
+ finally:
+ _set_identify(server, None)
diff --git a/tests/e2e/official_modern/test_mcpserver_http.py b/tests/e2e/official_modern/test_mcpserver_http.py
new file mode 100644
index 0000000..c4278d9
--- /dev/null
+++ b/tests/e2e/official_modern/test_mcpserver_http.py
@@ -0,0 +1,101 @@
+"""`MCPServer` over real Streamable HTTP.
+
+`MCPServer` is tracked through its `_lowlevel_server`, so this is the same
+adapter the bare-`Server` file exercises — what is new here is the whole
+higher-level stack on top of it: the tool manager's generated schemas, its
+argument delivery, and its structured-output conversion.
+
+That manager does NOT reject a parameter AgentCat failed to strip — measured on
+mcp 2.0, it drops an undeclared argument silently — so the strip is asserted
+against what the manager was handed (`tests.test_utils.delivery`), not against
+the call merely succeeding.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+from mcp.client import Client
+
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+)
+from tests.test_utils.delivery import delivered_arguments_for
+from tests.test_utils.modern_server import create_mcpserver_todo_server
+
+from ...test_utils import sid
+
+pytestmark = pytest.mark.e2e
+
+SERVER_FACTORY = create_mcpserver_todo_server
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+@pytest.mark.asyncio
+async def test_mcpserver_injects_strips_and_publishes(
+ modern_http_server, capture_queue
+):
+ url, server = modern_http_server
+ async with Client(url) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.input_schema["properties"])[-2:] == ["session_id", "context"]
+ assert MCP_INSTRUCTIONS_KEY in add.output_schema["properties"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "over http", "context": "why"}
+ )
+ assert result.is_error is False, _text(result)
+ text = _text(result)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert result.structured_content[MCP_INSTRUCTIONS_KEY]["session_id"] == minted
+ assert result.structured_content["result"].startswith("Added todo")
+
+ # The tool layer never saw `context` — the only observation on this shape
+ # that a broken strip would fail. The server runs in-process (uvicorn in a
+ # thread), so its recorder is readable straight off the fixture's object.
+ assert delivered_arguments_for(server, "add_todo") == [{"text": "over http"}]
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)
+ assert [e.resource_name for e in events] == ["add_todo"]
+ assert events[0].session_id == minted
+ assert events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ # The event carries the RAW arguments and the UNDECORATED response.
+ assert events[0].parameters["arguments"] == {"text": "over http", "context": "why"}
+ assert MCP_INSTRUCTIONS_KEY not in (events[0].response or {}).get(
+ "structuredContent", {}
+ )
+ assert MCP_INSTRUCTIONS_KEY not in (events[0].response or {}).get(
+ "structured_content", {}
+ )
+
+
+@pytest.mark.asyncio
+async def test_mcpserver_echoed_handle_is_not_minted_again(
+ modern_http_server, capture_queue
+):
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "list_todos", {"session_id": sid("supplied_over_http")}
+ )
+ assert result.is_error is False, _text(result)
+ assert MINT_BACK_HEADER not in _text(result)
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert event.session_id == sid("supplied_over_http")
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "supplied"
diff --git a/tests/e2e/official_modern/test_session_http.py b/tests/e2e/official_modern/test_session_http.py
new file mode 100644
index 0000000..c088817
--- /dev/null
+++ b/tests/e2e/official_modern/test_session_http.py
@@ -0,0 +1,258 @@
+"""Task handles, client identity and per-request extra over real Streamable HTTP.
+
+The in-process tests cover the adapter's logic; this file covers what only a
+socket can: the HTTP request object the transport hands the handler
+(`ctx.request`), the headers riding on it, and the transport's own session id.
+"""
+
+from __future__ import annotations
+
+import time
+
+import httpx2
+import pytest
+from mcp.client import Client
+from mcp.client.streamable_http import streamable_http_client
+from mcp.types import Implementation
+
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_PROTOCOL_VERSION,
+ AGENTCAT_TAG_SESSION_SOURCE,
+)
+
+pytestmark = pytest.mark.e2e
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+@pytest.mark.asyncio
+async def test_minted_session_id_is_echoed_across_http_calls(
+ modern_http_server, capture_queue
+):
+ """First call mints and hands the handle back; the agent echoes it on the
+ next call and both events land on the same task."""
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "one", "context": "first call of the task"}
+ )
+ text = _text(first)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+
+ second = await client.call_tool(
+ "add_todo", {"text": "two", "session_id": minted}
+ )
+ # Already supplied: nothing is minted back a second time.
+ assert MINT_BACK_HEADER not in _text(second)
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)
+ assert len(events) == 2
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+ assert all(e.tags[AGENTCAT_TAG_PROTOCOL_VERSION] for e in events)
+
+
+@pytest.mark.asyncio
+async def test_separate_connections_get_separate_tasks(
+ modern_http_server, capture_queue
+):
+ """Nothing is stored server-side, so two agents that never echo a handle
+ get two different tasks."""
+ url, _ = modern_http_server
+
+ async def call_once(text: str) -> str:
+ async with Client(url) as client:
+ result = await client.call_tool(
+ "add_todo", {"text": text, "context": "independent task"}
+ )
+ return _text(result).split("session_id=")[1].split(" ")[0]
+
+ first = await call_once("a")
+ second = await call_once("b")
+ assert first != second
+
+ time.sleep(0.5)
+ minted = {e.session_id for e in _call_events(capture_queue)}
+ assert {first, second} <= minted
+
+
+@pytest.mark.asyncio
+async def test_custom_clientinfo_propagates_to_event(
+ modern_http_server, capture_queue
+):
+ """`client_info` reaches the event as client_name / client_version, whether
+ it rides the handshake or the per-request `_meta` envelope."""
+ url, _ = modern_http_server
+ async with Client(
+ url, client_info=Implementation(name="MyAgent", version="1.2.3")
+ ) as client:
+ await client.call_tool("add_todo", {"text": "agent", "context": "id"})
+
+ time.sleep(0.5)
+ event = _call_events(capture_queue)[-1]
+ assert (event.client_name, event.client_version) == ("MyAgent", "1.2.3")
+
+
+@pytest.mark.asyncio
+async def test_identity_rides_every_call_not_just_the_first(
+ modern_http_server, capture_queue
+):
+ """Name AND version on EVERY event of a connection.
+
+ Reading only the last event — which every other identity assertion in the
+ e2e suite used to do — cannot tell "resolved per request" from "resolved
+ once and reused", and the two differ exactly where it matters: a rung that
+ answers only for the call that follows the handshake leaves every later
+ event of a long-lived connection anonymous. Three calls, three identities.
+ """
+ url, _ = modern_http_server
+ async with Client(
+ url, client_info=Implementation(name="Cursor", version="2.6.22")
+ ) as client:
+ for n in range(3):
+ await client.call_tool("add_todo", {"text": f"call-{n}", "context": "id"})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-3:]
+ assert [e.parameters["arguments"]["text"] for e in events] == [
+ "call-0",
+ "call-1",
+ "call-2",
+ ]
+ assert [(e.client_name, e.client_version) for e in events] == [
+ ("Cursor", "2.6.22")
+ ] * 3
+
+
+@pytest.mark.asyncio
+async def test_identity_comes_from_the_per_request_meta_rung(
+ modern_http_server, capture_queue
+):
+ """The FIRST rung of the ladder, isolated from the handshake rung.
+
+ On the 2026-07-28 wire the client stamps
+ `io.modelcontextprotocol/clientInfo` into `_meta` on every request, so the
+ identity on the event can be read without any handshake state at all. The
+ two rungs are indistinguishable while both agree — so this drives the same
+ connection twice with DIFFERENT identities, which only the per-request
+ envelope can express: a handshake-only ladder reports the first client's
+ name on the second call.
+ """
+ url, _ = modern_http_server
+
+ async def call_as(name: str, version: str, text: str) -> None:
+ async with Client(
+ url, client_info=Implementation(name=name, version=version)
+ ) as client:
+ await client.call_tool("add_todo", {"text": text, "context": "meta"})
+
+ await call_as("First", "1.0.0", "meta-first")
+ await call_as("Second", "2.0.0", "meta-second")
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert {
+ e.parameters["arguments"]["text"]: (e.client_name, e.client_version)
+ for e in events
+ } == {
+ "meta-first": ("First", "1.0.0"),
+ "meta-second": ("Second", "2.0.0"),
+ }
+
+
+@pytest.mark.asyncio
+async def test_identity_survives_the_legacy_handshake_rung(
+ modern_http_server, capture_queue
+):
+ """The LAST rung: a legacy client sends `clientInfo` only in `initialize`.
+
+ `mode="legacy"` has no per-request `_meta` envelope to read, so the ladder
+ falls through to the identity the SDK's own `ServerSession` captured at
+ handshake time. On this STATEFUL app that session outlives the handshake,
+ so both calls are still attributed — the rung has to answer more than once.
+ (Under `stateless_http=True` it cannot, which is what the looser assertion
+ in `tests/e2e/official/test_stateless_http.py` records.)
+ """
+ url, _ = modern_http_server
+ async with Client(
+ url,
+ mode="legacy",
+ client_info=Implementation(name="LegacyAgent", version="0.9.0"),
+ ) as client:
+ await client.call_tool("add_todo", {"text": "legacy-1", "context": "id"})
+ await client.call_tool("add_todo", {"text": "legacy-2", "context": "id"})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)[-2:]
+ assert [(e.client_name, e.client_version) for e in events] == [
+ ("LegacyAgent", "0.9.0")
+ ] * 2
+
+
+@pytest.mark.asyncio
+async def test_request_headers_ride_the_event(modern_http_server, capture_queue):
+ """`parameters.extra.requestInfo.headers` is only reachable from the HTTP
+ request the transport attaches to the context — the in-process client has
+ none, so this is the only place it can be asserted."""
+ url, _ = modern_http_server
+ http_client = httpx2.AsyncClient(
+ headers={"X-MCP-Client-Name": "HeaderClient", "X-Tenant": "acme"}
+ )
+ async with Client(streamable_http_client(url, http_client=http_client)) as client:
+ await client.call_tool("add_todo", {"text": "hdr", "context": "hdr"})
+
+ time.sleep(0.5)
+ extra = (_call_events(capture_queue)[-1].parameters or {}).get("extra", {})
+ headers = extra.get("requestInfo", {}).get("headers", {})
+ assert headers.get("x-mcp-client-name") == "HeaderClient"
+ assert headers.get("x-tenant") == "acme"
+
+
+@pytest.mark.asyncio
+async def test_session_id_is_reported_only_when_the_transport_issues_one(
+ modern_http_server, capture_queue
+):
+ """The 2026-07-28 wire dropped the handshake, so a connection on it has no
+ session at all and the event must not invent one. The handshake era still
+ issues `Mcp-Session-Id`, and there it is reported verbatim."""
+ url, _ = modern_http_server
+
+ async with Client(url) as client:
+ await client.call_tool("add_todo", {"text": "modern", "context": "x"})
+ time.sleep(0.5)
+ modern = (_call_events(capture_queue)[-1].parameters or {}).get("extra", {})
+ assert "sessionId" not in modern
+
+ async with Client(url, mode="legacy") as client:
+ await client.call_tool("add_todo", {"text": "legacy", "context": "x"})
+ time.sleep(0.5)
+ legacy = (_call_events(capture_queue)[-1].parameters or {}).get("extra", {})
+ assert isinstance(legacy.get("sessionId"), str) and legacy["sessionId"]
+
+
+@pytest.mark.asyncio
+async def test_injected_schema_survives_the_wire(modern_http_server, capture_queue):
+ """The SDK validates every outbound spec result against the negotiated
+ protocol surface, so a malformed injected schema fails server-side rather
+ than reaching the agent. Listing over HTTP proves ours is valid."""
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ listed = await client.list_tools()
+
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.input_schema["properties"])[-2:] == ["session_id", "context"]
+ assert "_mcp_instructions" in add.output_schema["properties"]
diff --git a/tests/e2e/official_modern/test_stateless_http.py b/tests/e2e/official_modern/test_stateless_http.py
new file mode 100644
index 0000000..fa0ccb9
--- /dev/null
+++ b/tests/e2e/official_modern/test_stateless_http.py
@@ -0,0 +1,95 @@
+"""A genuinely stateless HTTP server, on the modern official SDK.
+
+Every other e2e module in this tree boots a stateful app, where one session
+serves a whole connection. `stateless_http=True` builds a fresh one per
+REQUEST, so nothing the server might have kept between calls is there — which
+is the whole premise v2 is built on: handles are resolved per request from the
+call's own arguments, and client identity is resolved per request from the
+call's own envelope.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+
+import pytest
+from mcp.client import Client
+from mcp.types import Implementation
+
+pytestmark = pytest.mark.e2e
+
+# Read by `tests/e2e/official_modern/conftest.py`.
+STATELESS_HTTP = True
+
+
+def _call_events(capture_queue):
+ return [e for e in capture_queue if e.event_type == "mcp:tools/call"]
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+async def test_a_task_handle_survives_with_no_server_side_session(
+ modern_http_server, capture_queue
+):
+ """Nothing is held server-side, and the task is carried anyway."""
+ url, _ = modern_http_server
+ async with Client(url) as client:
+ first = await client.call_tool(
+ "add_todo", {"text": "s", "context": "stateless first call"}
+ )
+ minted = _text(first).split("session_id=")[1].split(" ")[0]
+ await client.call_tool("add_todo", {"text": "s2", "session_id": minted})
+
+ time.sleep(0.5)
+ events = _call_events(capture_queue)
+ assert len(events) == 2
+ assert [e.session_id for e in events] == [minted, minted]
+
+
+async def test_concurrent_clients_never_wear_each_others_name(
+ modern_http_server, capture_queue
+):
+ """Two named clients at once, on a server that remembers nothing.
+
+ Exact attribution of name AND version, not "in (None, 'Cursor')": on the
+ 2026-07-28 wire identity does not depend on a server-side session at all.
+ The client stamps `io.modelcontextprotocol/clientInfo` into `_meta` on every
+ request (`mcp/client/session.py`), and the modern HTTP server re-stamps it
+ from its own negotiation verdict, so the first rung of the ladder answers
+ even here. A `None` on this transport is a regression in the `_meta` rung,
+ not the honest silence it is on the legacy wire — see the sibling assertion
+ in `tests/e2e/official/test_stateless_http.py`, which keeps the looser form
+ for exactly that reason.
+
+ Each event is matched to the call that produced it by the argument that
+ call sent, so this asserts ATTRIBUTION rather than "both names appear
+ somewhere" — a shared identity slot produces one name on both events, and
+ a set membership check can be satisfied by the wrong pairing.
+ """
+ url, _ = modern_http_server
+
+ async def call_as(name: str, version: str, text: str) -> None:
+ async with Client(
+ url, client_info=Implementation(name=name, version=version)
+ ) as client:
+ await client.call_tool("add_todo", {"text": text, "context": "no-bleed"})
+
+ await asyncio.gather(
+ call_as("Cursor", "2.6.22", "from-cursor"),
+ call_as("Claude", "1.0.0", "from-claude"),
+ )
+ time.sleep(0.7)
+
+ events = _call_events(capture_queue)
+ assert len(events) == 2
+ attributed = {
+ e.parameters["arguments"]["text"]: (e.client_name, e.client_version)
+ for e in events
+ }
+ assert attributed == {
+ "from-cursor": ("Cursor", "2.6.22"),
+ "from-claude": ("Claude", "1.0.0"),
+ }
diff --git a/tests/test_api_base_url.py b/tests/test_api_base_url.py
index 3135b08..4f57a09 100644
--- a/tests/test_api_base_url.py
+++ b/tests/test_api_base_url.py
@@ -3,8 +3,12 @@
import os
from unittest.mock import MagicMock, patch
+import pytest
+
from agentcat.types import AgentCatOptions
+from .conftest import MCP_MAJOR
+
class TestAgentCatOptionsApiBaseUrl:
"""Test api_base_url field on AgentCatOptions."""
@@ -60,67 +64,35 @@ def test_default_url_used_when_not_configured(
mock_configuration.assert_called_with(host=AGENTCAT_API_URL)
+@pytest.mark.skipif(
+ MCP_MAJOR >= 2,
+ reason="needs a server flavor track() can adapt; mcp 2.x lands in Task 12",
+)
class TestTrackApiBaseUrl:
"""Test that track() wires api_base_url resolution correctly."""
- # Common patches needed to isolate track() from real MCP server logic
- TRACK_PATCHES = [
- "agentcat.is_community_fastmcp_v3",
- "agentcat.is_community_fastmcp_v2",
- "agentcat.is_official_fastmcp_server",
- "agentcat.is_compatible_server",
- "agentcat._apply_server_tracking",
- "agentcat.get_session_info",
- "agentcat.set_server_tracking_data",
- ]
-
def _call_track_with_patches(self, options, env_vars=None):
- """Helper to call track() with all internals mocked, returning a mock event_queue."""
+ """Run the real track() against a real server, with only the queue mocked.
+
+ A bare lowlevel `Server` has no tools/list or tools/call handler yet, so
+ the adapter installs nothing — but detection, data storage and the
+ api-base-url resolution all run exactly as they do in production.
+ """
+ from mcp.server.lowlevel import Server
+
from agentcat import track
mock_eq = MagicMock()
- patches = {}
- for p in self.TRACK_PATCHES:
- patches[p] = patch(p)
-
- eq_patch = patch("agentcat.modules.event_queue.event_queue", mock_eq)
-
- started = []
- try:
- for name, p in patches.items():
- m = p.start()
- started.append(p)
- if name == "agentcat.is_compatible_server":
- m.return_value = True
- elif name in (
- "agentcat.is_community_fastmcp_v3",
- "agentcat.is_community_fastmcp_v2",
- "agentcat.is_official_fastmcp_server",
- ):
- m.return_value = False
- elif name == "agentcat.get_session_info":
- from agentcat.types import SessionInfo
- m.return_value = SessionInfo()
-
- eq_patch.start()
- started.append(eq_patch)
-
- server = MagicMock()
- if env_vars is not None:
- with patch.dict(os.environ, env_vars, clear=True):
- track(server, project_id="proj-123", options=options)
- else:
+ with patch("agentcat.modules.event_queue.event_queue", mock_eq):
+ server = Server("api-base-url-test")
+ if env_vars is None:
# Clear API URL env vars to avoid interference
- env = os.environ.copy()
- env.pop("AGENTCAT_API_URL", None)
- env.pop("MCPCAT_API_URL", None)
- with patch.dict(os.environ, env, clear=True):
- track(server, project_id="proj-123", options=options)
-
- return mock_eq
- finally:
- for p in started:
- p.stop()
+ env_vars = os.environ.copy()
+ env_vars.pop("AGENTCAT_API_URL", None)
+ env_vars.pop("MCPCAT_API_URL", None)
+ with patch.dict(os.environ, env_vars, clear=True):
+ track(server, project_id="proj-123", options=options)
+ return mock_eq
def test_option_overrides_default(self):
"""api_base_url option should trigger configure() on event_queue."""
diff --git a/tests/test_async_hooks.py b/tests/test_async_hooks.py
new file mode 100644
index 0000000..555d355
--- /dev/null
+++ b/tests/test_async_hooks.py
@@ -0,0 +1,327 @@
+"""Every customer hook takes a sync OR an async callable, on every server shape.
+
+`AgentCatOptions` exposes five customer-supplied callables and all five are
+documented to accept either. Before `modules/hooks.py` each site decided that
+for itself: `identify` never awaited at all, and `event_tags` /
+`event_properties` narrowed on `inspect.iscoroutine`, which matches ONLY native
+coroutines. A hook returning an `asyncio.Task` — a cached in-flight lookup — or
+any object implementing `__await__` was assigned into the event verbatim, which
+is the ``-on-the-wire failure that looks like it worked.
+
+So the parametrization is the test. Four ways of expressing "a value, maybe
+later" run through each hook: a plain return, a native coroutine, a Task, and a
+bare `__await__` implementer. The last two are the ones the old predicate
+dropped.
+
+`flavors()` builds every server shape the installed dependency set supports, and
+CI runs this file on every mcp and fastmcp leg of the compatibility matrix — so
+"works on all supported SDK versions" is asserted rather than reasoned about.
+
+`redact_sensitive_information` is not parametrized over flavors here: it runs on
+the publish queue's worker THREAD, not the request path, so it is adapter- and
+version-independent by construction and gets its own section against
+`redact_event` directly. That thread has no event loop, which is why it needs
+`drive_hook_result` rather than an await.
+"""
+
+import asyncio
+from typing import Any
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import AGENTCAT_TAG_SESSION_SOURCE
+from agentcat.modules.handles import derive_session_id
+from agentcat.modules.hooks import await_hook_result, drive_hook_result
+from agentcat.modules.redaction import redact_event
+from agentcat.types import UserIdentity
+
+from .test_utils.flavors import flavors
+
+PROJECT = "proj_test"
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+# ── the four ways a hook can hand back a value ───────────────────────────────
+
+
+class Thenable:
+ """Awaitable, but neither a coroutine nor a Future.
+
+ The shape `inspect.iscoroutine` misses that has no asyncio machinery behind
+ it at all — what a library wrapping its own scheduler hands back.
+ """
+
+ def __init__(self, value: Any) -> None:
+ self._value = value
+
+ def __await__(self):
+ async def _inner() -> Any:
+ return self._value
+
+ return _inner().__await__()
+
+
+def _plain(value: Any):
+ def hook(_request: Any, _extra: Any) -> Any:
+ return value
+
+ return hook
+
+
+def _coroutine(value: Any):
+ async def hook(_request: Any, _extra: Any) -> Any:
+ return value
+
+ return hook
+
+
+def _task(value: Any):
+ """A Task-returning hook, async because Tasks only exist on a loop.
+
+ Since run_hook, the hook CALL runs on a worker thread with no running
+ loop, so a SYNC hook can no longer call asyncio APIs — that is a
+ documented v2 behavior change (MIGRATION.md). An async hook's body runs
+ on the loop as always, and its returned Task pins the awaitable-unwrap
+ regression: run_hook must await the coroutine AND then the Task it
+ returned, not assign either into the event verbatim.
+ """
+
+ async def hook(_request: Any, _extra: Any) -> Any:
+ return asyncio.ensure_future(_coroutine(value)(None, None))
+
+ return hook
+
+
+def _thenable(value: Any):
+ def hook(_request: Any, _extra: Any) -> Any:
+ return Thenable(value)
+
+ return hook
+
+
+WRAPPERS = [
+ pytest.param(_plain, id="sync"),
+ pytest.param(_coroutine, id="coroutine"),
+ pytest.param(_task, id="task"),
+ pytest.param(_thenable, id="thenable"),
+]
+
+FLAVORS = pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+WRAPPED = pytest.mark.parametrize("wrap", WRAPPERS)
+
+
+async def _call_once(flavor, options: AgentCatOptions):
+ built = flavor.build("async-hooks")
+ track(built.server, PROJECT, options)
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ return await flavor.call(client, "echo", {"text": "hi"})
+
+
+# ── A. the request-path hooks, on every shape and every wrapper ──────────────
+
+
+@FLAVORS
+@WRAPPED
+async def test_identify(flavor, wrap, capture):
+ identity = UserIdentity(user_id="alice", user_name="Alice", user_data=None)
+ result = await _call_once(flavor, AgentCatOptions(identify=wrap(identity)))
+
+ assert result.is_error is False
+ (event,) = capture
+ assert event.identify_actor_given_id == "alice"
+ assert event.identify_actor_name == "Alice"
+
+
+@FLAVORS
+@WRAPPED
+async def test_event_tags(flavor, wrap, capture):
+ result = await _call_once(flavor, AgentCatOptions(event_tags=wrap({"lane": "a"})))
+
+ assert result.is_error is False
+ (event,) = capture
+ assert event.tags["lane"] == "a"
+ # The SDK's own tags still merge over the customer's, as ever.
+ assert AGENTCAT_TAG_SESSION_SOURCE in event.tags
+
+
+@FLAVORS
+@WRAPPED
+async def test_event_properties(flavor, wrap, capture):
+ payload = {"flag": True, "nested": {"n": 1}}
+ result = await _call_once(flavor, AgentCatOptions(event_properties=wrap(payload)))
+
+ assert result.is_error is False
+ (event,) = capture
+ assert event.properties == payload
+
+
+@FLAVORS
+@WRAPPED
+async def test_resolve_session_id(flavor, wrap, capture):
+ """Hook mode, which also proves the awaited value is used rather than merely
+ consumed: the handle is DERIVED from what the hook returned, so a dropped
+ result would mint a random one instead and the equality would fail."""
+ result = await _call_once(
+ flavor, AgentCatOptions(resolve_session_id=wrap("corr-7"))
+ )
+
+ assert result.is_error is False
+ (event,) = capture
+ assert event.session_id == derive_session_id("corr-7", PROJECT)
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "hook"
+
+
+# ── B. an awaitable that fails degrades quietly, and says why ────────────────
+
+
+def _raising_awaitable(_request: Any, _extra: Any) -> Any:
+ async def _boom() -> Any:
+ raise RuntimeError("hook exploded mid-await")
+
+ return _boom()
+
+
+@FLAVORS
+@pytest.mark.parametrize(
+ ("option", "hook_name"),
+ [
+ ("identify", "identify"),
+ ("event_tags", "event_tags"),
+ ("event_properties", "event_properties"),
+ ("resolve_session_id", "resolve_session_id"),
+ ],
+)
+async def test_a_failing_awaitable_degrades_and_names_the_await(
+ flavor, option, hook_name, capture, log_sink
+):
+ """Silent for the customer's agent, loud in the log.
+
+ The degradation contract is unchanged — analytics never fails a tool call —
+ but the log now distinguishes "your hook raised" from "your hook's awaitable
+ could not be driven". Without that second line a customer reading
+ "identify callback error" goes looking for a bug in code that ran fine.
+ """
+ result = await _call_once(flavor, AgentCatOptions(**{option: _raising_awaitable}))
+
+ assert result.is_error is False, "a failing hook must never fail the call"
+ (event,) = capture
+ assert event.identify_actor_given_id is None
+ assert event.properties is None
+ assert "lane" not in (event.tags or {})
+ # resolve_session_id degrades by minting, never by publishing sessionless.
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+ awaited = [ln for ln in log_sink if "could not be awaited" in ln]
+ assert len(awaited) == 1, log_sink
+ assert hook_name in awaited[0], awaited[0]
+
+
+# ── C. redaction, which drives its awaitable from a thread ───────────────────
+
+
+@pytest.mark.parametrize("wrap", WRAPPERS[:1] + WRAPPERS[3:])
+def test_redaction_accepts_sync_and_non_coroutine_awaitables(wrap):
+ """`redact_event` runs on the publish worker thread, so it cannot await.
+
+ Parametrized without the coroutine and Task cases: both are already covered
+ by `test_redaction.py`, and a Task cannot even be constructed here — there
+ is no running loop on this path, which is the whole reason this hook needs
+ `drive_hook_result` rather than an await.
+ """
+ from agentcat.types import UnredactedEvent
+
+ def redact(value: str) -> Any:
+ return wrap(value.replace("secret", "[REDACTED]"))(None, None)
+
+ event = UnredactedEvent(
+ event_type="mcp:tools/call",
+ resource_name="echo",
+ parameters={"arguments": {"text": "a secret value"}},
+ )
+ redacted = redact_event(event, redact)
+ assert redacted.parameters["arguments"]["text"] == "a [REDACTED] value"
+
+
+def test_redaction_reports_an_undrivable_awaitable(log_sink):
+ """A hook that cannot be driven raises, so the queue drops the event rather
+ than publishing it unredacted — and the log says which half broke."""
+ from agentcat.types import UnredactedEvent
+
+ def redact(_value: str) -> Any:
+ async def _boom() -> str:
+ raise RuntimeError("redactor exploded mid-await")
+
+ return _boom()
+
+ event = UnredactedEvent(
+ event_type="mcp:tools/call",
+ resource_name="echo",
+ parameters={"arguments": {"text": "a secret value"}},
+ )
+ with pytest.raises(RuntimeError):
+ redact_event(event, redact)
+
+ assert [
+ ln
+ for ln in log_sink
+ if "could not be awaited" in ln and "redact_sensitive_information" in ln
+ ], log_sink
+
+
+# ── D. the helpers themselves ────────────────────────────────────────────────
+
+
+async def test_await_hook_result_passes_plain_values_straight_through():
+ assert await await_hook_result(7, "h") == 7
+ assert await await_hook_result(None, "h") is None
+ # A callable is a value, not something to invoke: detection is on the
+ # RESULT, which is what makes partials and decorated hooks work unchanged.
+ marker = lambda: None # noqa: E731
+ assert await await_hook_result(marker, "h") is marker
+
+
+def test_drive_hook_result_passes_plain_values_straight_through():
+ assert drive_hook_result(7, "h") == 7
+ assert drive_hook_result(None, "h") is None
+
+
+async def test_await_hook_result_reraises_so_call_sites_keep_their_own_rule(
+ log_sink,
+):
+ """The helper logs and re-raises rather than swallowing.
+
+ Each site degrades differently — anonymous actor, dropped tags, a freshly
+ minted handle, a dropped event — and collapsing those into one decision
+ here would flatten four deliberate behaviors.
+ """
+
+ async def boom() -> None:
+ raise ValueError("nope")
+
+ with pytest.raises(ValueError):
+ await await_hook_result(boom(), "some_hook")
+
+ assert [ln for ln in log_sink if "some_hook" in ln and "could not be awaited" in ln]
diff --git a/tests/test_callpath.py b/tests/test_callpath.py
new file mode 100644
index 0000000..b23ee61
--- /dev/null
+++ b/tests/test_callpath.py
@@ -0,0 +1,720 @@
+"""Shared per-call orchestration: resolve / strip / decorate / publish.
+
+`callpath` is the one code path every adapter (lowlevel v1, lowlevel v2,
+community FastMCP) runs for `tools/call`, so it is exercised here with pure
+Python fakes and no MCP imports at all — the module must import and behave
+identically under mcp 1.x and mcp 2.x.
+
+Behavior contract: 2026-07-28-cross-sdk-changelog.md §3.4, §6.2-§6.5; TS
+reference src/engine/callWrap.ts.
+"""
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from agentcat.modules import event_queue
+from agentcat.modules.callpath import (
+ ResolvedCall,
+ decorate_content,
+ detect_mrtr,
+ get_stripped_arguments,
+ publish_tool_call_event,
+ resolve_call,
+ structured_mirror,
+)
+from agentcat.modules.client_identity import ClientIdentity
+from agentcat.modules.handles import HandleResolution
+from agentcat.modules.injection import ToolSpec
+from agentcat.types import (
+ AgentCatData,
+ AgentCatOptions,
+ UnredactedEvent,
+ UserIdentity,
+)
+
+from .test_utils import sid
+
+CLIENT_KEY = "io.modelcontextprotocol/clientInfo"
+PV_KEY = "io.modelcontextprotocol/protocolVersion"
+
+
+def make_data(options: AgentCatOptions | None = None, **kwargs: Any) -> AgentCatData:
+ """Tracking data in its v2 shape: a project, options, and engine state."""
+ return AgentCatData(
+ project_id="proj_1",
+ options=options if options is not None else AgentCatOptions(),
+ **kwargs,
+ )
+
+
+def text_block(text: str) -> dict[str, str]:
+ return {"type": "text", "text": text}
+
+
+# ── detect_mrtr ──────────────────────────────────────────────────────────────
+
+
+def test_detect_mrtr_matrix():
+ assert detect_mrtr("input_required", False) == "input_required"
+ assert detect_mrtr("input_required", True) == "input_required" # intermediate wins
+ assert detect_mrtr(None, True) == "continuation"
+ assert detect_mrtr("complete", False) is None
+
+
+# An ordinary completing round carries neither signal; a continuation that
+# completes is tagged `continuation`, not dropped (§6.4).
+def test_detect_mrtr_plain_and_completing_continuation():
+ assert detect_mrtr(None, False) is None
+ assert detect_mrtr("complete", True) == "continuation"
+ assert detect_mrtr("", False) is None
+
+
+def test_detect_mrtr_tags_the_request_state_only_continuation():
+ """A resumed round carries `requestState` and no responses at all.
+
+ The SEP-2322 driver answers an intermediate round that asked no questions
+ by retrying after a backoff with `inputResponses=None` and the echoed
+ `requestState` — the shape FastMCP 4's own
+ `InputRequiredResult(request_state=...)` produces. Keying the tag on
+ `inputResponses` alone left it untagged.
+ """
+ assert detect_mrtr(None, False, True) == "continuation"
+ assert detect_mrtr("complete", False, True) == "continuation"
+ # Intermediate still wins over either witness.
+ assert detect_mrtr("input_required", False, True) == "input_required"
+ # And the default keeps every existing two-argument call site honest.
+ assert detect_mrtr(None, True) == "continuation"
+ assert detect_mrtr(None, False) is None
+
+
+# ── decorate_content ─────────────────────────────────────────────────────────
+
+
+def test_decorate_content_only_when_minted_prompted():
+ minted = HandleResolution(sid("T"), "minted")
+ out = decorate_content([{"type": "text", "text": "x"}], minted, text_block)
+ assert out is not None
+ assert "[MCP INSTRUCTIONS]: session_id issued." in out[-1]["text"]
+ assert decorate_content([{"t": 1}], HandleResolution(sid("T"), "supplied"), dict) is None # noqa: E501
+ assert decorate_content([{"t": 1}], HandleResolution(sid("T"), "minted", hook_mode=True), dict) is None # noqa: E501
+ assert decorate_content("not-a-list", minted, dict) is None
+
+
+# §3.4a: the appended text carries the id the agent must echo, built by the one
+# source of truth (handles.build_mint_back_text) rather than re-derived here.
+def test_decorate_content_appends_the_minted_id():
+ out = decorate_content([], HandleResolution(sid("ABC"), "minted"), text_block)
+ assert out is not None and len(out) == 1
+ assert "session_id=ses_ABC" in out[0]["text"]
+ assert out[0]["text"].endswith(
+ "Without session_id, this server does not function as intended."
+ )
+
+
+# §3.4a: "Append it to error results too" — decorate_content never inspects
+# error state, so an isError result decorates on exactly the same terms.
+def test_decorate_content_ignores_error_state():
+ error_content = [{"type": "text", "text": "boom: tool failed"}]
+ out = decorate_content(error_content, HandleResolution(sid("T"), "minted"), text_block) # noqa: E501
+ assert out is not None and len(out) == 2
+ assert "[MCP INSTRUCTIONS]" in out[-1]["text"]
+
+
+# Never mutate the customer's result: the returned list is new and the
+# original is untouched.
+def test_decorate_content_returns_a_new_list():
+ original = [{"type": "text", "text": "x"}]
+ out = decorate_content(original, HandleResolution(sid("T"), "minted"), text_block)
+ assert out is not original
+ assert original == [{"type": "text", "text": "x"}]
+
+
+def test_decorate_content_none_content():
+ assert decorate_content(None, HandleResolution(sid("T"), "minted"), dict) is None
+
+
+# ── structured_mirror ────────────────────────────────────────────────────────
+
+
+def test_structured_mirror_gate_matrix():
+ res = HandleResolution(sid("T"), "minted")
+ # In the output-injection registry: the tool's outputSchema declares the key.
+ mirrored = structured_mirror({"ok": True}, res, "search", {"search"})
+ assert mirrored is not None
+ assert mirrored["ok"] is True
+ assert mirrored["_mcp_instructions"]["session_id"] == sid("T")
+ # Registry exists but this tool is not in it: mirroring would fail the
+ # customer's own schema validation.
+ assert structured_mirror({"ok": True}, res, "other", {"search"}) is None
+ # No registry at all (rebuild failed) — mirror anyway (§3.4b).
+ assert structured_mirror({"ok": True}, res, "other", None) is not None
+ # Empty registry is a real registry, not a missing one.
+ assert structured_mirror({"ok": True}, res, "search", set()) is None
+
+
+def test_structured_mirror_delegates_shape_rules():
+ res = HandleResolution(sid("T"), "minted")
+ # Non-dict structuredContent (or none at all) is never mirrored into.
+ assert structured_mirror(None, res, "search", None) is None
+ assert structured_mirror(["a"], res, "search", None) is None
+ assert structured_mirror("str", res, "search", None) is None
+ # Customer data under the key wins.
+ assert structured_mirror({"_mcp_instructions": "mine"}, res, "search", None) is None
+
+
+def test_structured_mirror_hook_mode_without_agent_has_nothing_to_mirror():
+ hook = HandleResolution(sid("T"), "hook", hook_mode=True)
+ assert structured_mirror({"ok": True}, hook, "search", None) is None
+
+ with_agent = HandleResolution(
+ sid("T"), "hook", "agt|x|1", "supplied", hook_mode=True
+ )
+ mirrored = structured_mirror({"ok": True}, with_agent, "search", None)
+ assert mirrored is not None
+ assert "session_id" not in mirrored["_mcp_instructions"]
+ assert mirrored["_mcp_instructions"]["agent_id"] == "agt|x|1"
+
+
+# ── get_stripped_arguments ───────────────────────────────────────────────────
+
+
+async def test_strip_uses_the_existing_registry():
+ data = make_data()
+ data.injected_params_registry = {
+ "search": {"session_id", "context"},
+ "other": set(),
+ }
+ raw = {"q": 1, "session_id": sid("T"), "agent_id": "a", "context": "why"}
+
+ assert await get_stripped_arguments(data, data.options, "search", raw, None) == {
+ "q": 1,
+ "agent_id": "a",
+ }
+ # Listed with an empty entry: nothing was injected, so nothing is stripped.
+ assert await get_stripped_arguments(data, data.options, "other", raw, None) == raw
+ # Unlisted tool: never advertised through the pipeline, strip nothing.
+ assert await get_stripped_arguments(data, data.options, "ghost", raw, None) == raw
+ # Never mutate the caller's dict.
+ assert raw == {"q": 1, "session_id": sid("T"), "agent_id": "a", "context": "why"}
+
+
+async def test_strip_rebuilds_registries_on_demand():
+ data = make_data(AgentCatOptions(enable_agent_tracking=True))
+ specs = [
+ ToolSpec("search", {"type": "object", "properties": {"q": {"type": "string"}}}),
+ ToolSpec("other", {"type": "object", "properties": {}}, {"type": "object"}),
+ ]
+ calls: list[int] = []
+
+ async def rebuild() -> list[ToolSpec]:
+ calls.append(1)
+ return specs
+
+ out = await get_stripped_arguments(
+ data,
+ data.options,
+ "search",
+ {"q": "x", "session_id": sid("T"), "context": "why"},
+ rebuild,
+ )
+ assert out == {"q": "x"}
+ assert calls == [1]
+ # Registries landed on data for every later call on this instance.
+ assert data.injected_params_registry == {
+ "search": {"session_id", "agent_id", "context"},
+ "other": {"session_id", "agent_id", "context"},
+ }
+ assert data.output_injection_registry == {"other"}
+
+ # Second call reuses the stored registry instead of rebuilding.
+ await get_stripped_arguments(data, data.options, "search", {"q": "x"}, rebuild)
+ assert calls == [1]
+
+
+# Mode gating (§5): with tracing off nothing is injected, so the rebuilt
+# registry strips nothing — the registry, not a heuristic, is the truth.
+async def test_strip_registry_reflects_injection_mode_gating():
+ data = make_data(
+ AgentCatOptions(enable_tracing=False, enable_tool_call_context=False)
+ )
+
+ async def rebuild() -> list[ToolSpec]:
+ return [ToolSpec("search", {"type": "object", "properties": {}})]
+
+ raw = {"q": 1, "session_id": sid("T"), "context": "why"}
+ stripped = await get_stripped_arguments(data, data.options, "search", raw, rebuild)
+ assert stripped == raw
+ assert data.injected_params_registry == {"search": set()}
+
+
+async def test_rebuild_failure_falls_back_to_heuristic(monkeypatch):
+ logged: list[str] = []
+ monkeypatch.setattr("agentcat.modules.callpath.write_to_log", logged.append)
+ data = make_data()
+ # A stale output registry from some earlier state must not survive the
+ # failure: it would gate the mirror on knowledge we no longer have.
+ data.output_injection_registry = {"some_other_tool"}
+
+ async def broken_rebuild() -> list[ToolSpec]:
+ raise RuntimeError("list source gone")
+
+ # "s" is not a minted-shape handle: on the degraded path it is presumed
+ # the customer's own parameter and rides through to their handler; only
+ # context — which default options would have injected — is stripped.
+ out = await get_stripped_arguments(
+ data,
+ data.options,
+ "t",
+ {"q": 1, "session_id": "s", "context": "c"},
+ broken_rebuild,
+ )
+ assert out == {"q": 1, "session_id": "s"}
+ assert data.output_injection_registry is None # mirror gate bypassed
+ assert any("list source gone" in entry for entry in logged)
+
+ # A minted-shape value is ours even without a registry.
+ data2 = make_data()
+ out2 = await get_stripped_arguments(
+ data2,
+ data2.options,
+ "t",
+ {"q": 1, "session_id": sid("mine"), "context": "c"},
+ broken_rebuild,
+ )
+ assert out2 == {"q": 1}
+
+
+# Two calls can race the rebuild on a server that never served tools/list.
+# A loser's failure must not wipe the registries a winner already stored: every
+# later call would then heuristic-strip, eating the `context` parameter of any
+# customer tool that owns one.
+async def test_a_failed_rebuild_does_not_wipe_a_concurrent_success():
+ data = make_data()
+ winner_stored = asyncio.Event()
+
+ async def slow_failing_rebuild() -> list[ToolSpec]:
+ await winner_stored.wait()
+ raise RuntimeError("list source gone")
+
+ async def good_rebuild() -> list[ToolSpec]:
+ return [ToolSpec("search", {"type": "object", "properties": {}})]
+
+ # The loser reads the empty registry first, then parks inside its rebuild.
+ loser = asyncio.create_task(
+ get_stripped_arguments(
+ data, data.options, "search", {"q": 1}, slow_failing_rebuild
+ )
+ )
+ await asyncio.sleep(0)
+ await get_stripped_arguments(data, data.options, "search", {"q": 1}, good_rebuild)
+ assert data.injected_params_registry == {"search": {"session_id", "context"}}
+
+ winner_stored.set()
+ assert await loser == {"q": 1}
+ # Still the winner's registry, not None.
+ assert data.injected_params_registry == {"search": {"session_id", "context"}}
+
+
+# No rebuild hook at all (adapter has no list source): straight to the
+# shape+config-aware fallback, and get_more_tools keeps its own `context`
+# parameter (§6.6).
+async def test_strip_without_rebuild_uses_the_heuristic():
+ data = make_data()
+ # Minted-shape session_id is ours; agent_id survives (tracking off by
+ # default); context is ours by config.
+ raw = {"q": 1, "session_id": sid("m"), "agent_id": "a", "context": "c"}
+ assert await get_stripped_arguments(data, data.options, "t", raw, None) == {
+ "q": 1,
+ "agent_id": "a",
+ }
+ assert await get_stripped_arguments(
+ data, data.options, "get_more_tools", raw, None
+ ) == {"q": 1, "agent_id": "a", "context": "c"}
+ # A customer-shaped session_id value survives everywhere on this path.
+ theirs = {"q": 1, "session_id": "TICKET-9", "context": "c"}
+ assert await get_stripped_arguments(data, data.options, "t", theirs, None) == {
+ "q": 1,
+ "session_id": "TICKET-9",
+ }
+
+
+# ── resolve_call ─────────────────────────────────────────────────────────────
+
+
+async def test_resolve_call_resolves_handles_client_and_protocol():
+ data = make_data()
+ rc = await resolve_call(
+ data,
+ "search",
+ {"session_id": f" {sid('supplied')} ", "context": "why the call"},
+ request={"params": {}},
+ extra=None,
+ meta_sources=[
+ {CLIENT_KEY: {"name": "cursor", "version": "2.1"}, PV_KEY: "2026-07-28"}
+ ],
+ legacy_client=lambda: None,
+ )
+ assert rc.resolution.session_id == sid("supplied")
+ assert rc.resolution.session_source == "supplied"
+ assert rc.client == ClientIdentity("cursor", "2.1")
+ assert rc.protocol_version == "2026-07-28"
+ assert rc.intent == "why the call"
+ assert rc.actor is None
+
+
+async def test_resolve_call_protocol_fallback_and_legacy_client():
+ data = make_data()
+ rc = await resolve_call(
+ data,
+ "search",
+ {},
+ request=None,
+ extra=None,
+ meta_sources=[None],
+ legacy_client=lambda: {"name": "legacy", "version": "0.9"},
+ protocol_fallback="2025-06-18",
+ )
+ assert rc.client == ClientIdentity("legacy", "0.9")
+ assert rc.protocol_version == "2025-06-18"
+ assert rc.resolution.session_source == "minted"
+ assert rc.resolution.session_id.startswith("ses_")
+
+
+async def test_resolve_call_intent_only_when_a_string():
+ data = make_data()
+
+ async def intent_for(arguments):
+ rc = await resolve_call(
+ data,
+ "search",
+ arguments,
+ None,
+ None,
+ meta_sources=[],
+ legacy_client=lambda: None,
+ )
+ return rc.intent
+
+ assert await intent_for({"context": "third-person explanation"}) == "third-person explanation" # noqa: E501
+ assert await intent_for({}) is None
+ assert await intent_for({"context": {"nested": True}}) is None
+ assert await intent_for({"context": 42}) is None
+ assert await intent_for({"context": None}) is None
+
+
+# The intent is read from the RAW arguments, before any stripping — the event
+# records what the agent actually sent.
+async def test_resolve_call_reads_intent_before_stripping():
+ data = make_data()
+ raw = {"context": "why", "session_id": sid("T")}
+ rc = await resolve_call(
+ data, "search", raw, None, None, meta_sources=[], legacy_client=lambda: None
+ )
+ stripped = await get_stripped_arguments(data, data.options, "search", raw, None)
+ assert rc.intent == "why"
+ assert "context" not in stripped
+
+
+async def test_resolve_call_identify_hook_error_yields_no_actor():
+ def boom(request, extra):
+ raise RuntimeError("customer identify blew up")
+
+ data = make_data(AgentCatOptions(identify=boom))
+ rc = await resolve_call(
+ data, "search", {}, None, None, meta_sources=[], legacy_client=lambda: None
+ )
+ assert rc.actor is None
+ assert rc.resolution.session_id.startswith("ses_") # the call still resolves
+
+
+async def test_resolve_call_captures_the_actor():
+ identity = UserIdentity(user_id="u1", user_name="Ada", user_data={"plan": "pro"})
+ seen: list[tuple[Any, Any]] = []
+
+ def identify(request, extra):
+ seen.append((request, extra))
+ return identity
+
+ data = make_data(AgentCatOptions(identify=identify))
+ rc = await resolve_call(
+ data, "search", {}, request="REQ", extra="EXTRA", meta_sources=[], legacy_client=lambda: None # noqa: E501
+ )
+ assert rc.actor is identity
+ assert seen == [("REQ", "EXTRA")]
+
+
+# A hook returning something that is not a UserIdentity is not an actor.
+async def test_resolve_call_rejects_a_non_identity_return():
+ data = make_data(AgentCatOptions(identify=lambda request, extra: {"user_id": "u1"}))
+ rc = await resolve_call(
+ data, "search", {}, None, None, meta_sources=[], legacy_client=lambda: None
+ )
+ assert rc.actor is None
+
+
+# v2 publishes exactly one event per tool call, from publish_tool_call_event.
+# Resolution — including the identify hook — publishes nothing.
+async def test_resolve_call_publishes_nothing(monkeypatch):
+ published: list[Any] = []
+ monkeypatch.setattr(event_queue, "publish_event", lambda server, event: published.append(event)) # noqa: E501
+ identity = UserIdentity(user_id="u1", user_name=None, user_data=None)
+ data = make_data(AgentCatOptions(identify=lambda request, extra: identity))
+
+ rc = await resolve_call(
+ data, "search", {}, None, None, meta_sources=[], legacy_client=lambda: None
+ )
+ assert rc.actor is identity
+ assert published == []
+
+
+# ── publish_tool_call_event ──────────────────────────────────────────────────
+
+
+def capture_published(monkeypatch) -> list[UnredactedEvent]:
+ published: list[UnredactedEvent] = []
+ monkeypatch.setattr(
+ event_queue, "publish_event", lambda server, event: published.append(event)
+ )
+ return published
+
+
+def resolved(**kwargs: Any) -> ResolvedCall:
+ defaults: dict[str, Any] = {
+ "resolution": HandleResolution(sid("T"), "minted"),
+ "actor": None,
+ "client": ClientIdentity("cursor", "2.1"),
+ "protocol_version": "2026-07-28",
+ "intent": "why the call",
+ }
+ defaults.update(kwargs)
+ return ResolvedCall(**defaults)
+
+
+async def test_publish_tool_call_event_builds_the_event(monkeypatch):
+ published = capture_published(monkeypatch)
+ data = make_data()
+ actor = UserIdentity(user_id="u1", user_name="Ada", user_data={"plan": "pro"})
+ raw = {"q": "x", "session_id": sid("T"), "context": "why the call"}
+ server = object()
+
+ await publish_tool_call_event(
+ server,
+ data,
+ resolved(actor=actor),
+ "search",
+ raw,
+ response={"content": [{"type": "text", "text": "ok"}]},
+ is_error=False,
+ error=None,
+ duration_ms=42,
+ mrtr=None,
+ extra_params={"extra": {"requestId": 7}},
+ )
+
+ assert len(published) == 1
+ event = published[0]
+ assert event.event_type == "mcp:tools/call"
+ assert event.session_id == sid("T") # the handle, not any transport session
+ assert event.resource_name == "search"
+ assert event.user_intent == "why the call"
+ # Raw, unstripped arguments plus the adapter's per-request extras.
+ assert event.parameters == {"arguments": raw, "extra": {"requestId": 7}}
+ assert event.response == {"content": [{"type": "text", "text": "ok"}]}
+ assert event.is_error is False
+ assert event.error is None
+ assert event.duration == 42
+ assert event.client_name == "cursor"
+ assert event.client_version == "2.1"
+ assert event.identify_actor_given_id == "u1"
+ assert event.identify_actor_name == "Ada"
+ assert event.identify_data == {"plan": "pro"}
+ assert event.tags == {
+ "agentcat_session_id_source": "minted",
+ "agentcat_protocol_version": "2026-07-28",
+ }
+
+
+async def test_publish_tool_call_event_records_errors_and_mrtr(monkeypatch):
+ published = capture_published(monkeypatch)
+ data = make_data()
+ res = HandleResolution(sid("T"), "supplied", "agt|x|1", "supplied")
+
+ await publish_tool_call_event(
+ object(),
+ data,
+ resolved(resolution=res),
+ "search",
+ {"q": "x"},
+ response={"isError": True},
+ is_error=True,
+ error={"message": "tool exploded", "type": "ValueError", "platform": "python"},
+ duration_ms=7,
+ mrtr="continuation",
+ extra_params=None,
+ )
+
+ event = published[0]
+ assert event.is_error is True
+ # Whatever the adapter captured is carried through verbatim.
+ assert event.error == {
+ "message": "tool exploded",
+ "type": "ValueError",
+ "platform": "python",
+ }
+ assert event.parameters == {"arguments": {"q": "x"}}
+ assert event.tags == {
+ "agentcat_session_id_source": "supplied",
+ "agentcat_agent_id": "agt|x|1",
+ "agentcat_agent_id_source": "supplied",
+ "agentcat_protocol_version": "2026-07-28",
+ "agentcat_mrtr": "continuation",
+ }
+
+ # An adapter that could make nothing of the failure still records an error
+ # object of the same shape, not a bare flag.
+ await publish_tool_call_event(
+ object(), data, resolved(), "search", {}, None, True, None, 1, None, None
+ )
+ assert published[1].error == {
+ "message": "Unknown error",
+ "type": None,
+ "platform": "python",
+ }
+
+
+# §6.5: SDK tags merge over customer tags — SDK wins on collision.
+async def test_publish_tool_call_event_sdk_tags_win_on_collision(monkeypatch):
+ published = capture_published(monkeypatch)
+ data = make_data(
+ AgentCatOptions(
+ event_tags=lambda request, extra: {
+ "agentcat_session_id_source": "fake",
+ "env": "prod",
+ },
+ event_properties=lambda request, extra: {"region": "us-east-1"},
+ )
+ )
+
+ await publish_tool_call_event(
+ object(), data, resolved(), "search", {}, None, False, None, 1, None, None
+ )
+
+ event = published[0]
+ assert event.tags["agentcat_session_id_source"] == "minted" # SDK wins
+ assert event.tags["env"] == "prod" # customer tag survives
+ assert event.properties == {"region": "us-east-1"}
+
+
+# §6.5: SDK tags are exempt from the customer 50-tag cap.
+async def test_publish_tool_call_event_sdk_tags_exempt_from_the_cap(monkeypatch):
+ published = capture_published(monkeypatch)
+ customer_tags = {f"tag_{i:02d}": str(i) for i in range(50)}
+ data = make_data(
+ AgentCatOptions(event_tags=lambda request, extra: dict(customer_tags))
+ )
+
+ await publish_tool_call_event(
+ object(),
+ data,
+ resolved(
+ resolution=HandleResolution(sid("T"), "minted", "agt|x|1", "supplied")
+ ),
+ "search",
+ {},
+ None,
+ False,
+ None,
+ 1,
+ "input_required",
+ None,
+ )
+
+ event = published[0]
+ sdk_tags = {
+ "agentcat_session_id_source": "minted",
+ "agentcat_agent_id": "agt|x|1",
+ "agentcat_agent_id_source": "supplied",
+ "agentcat_protocol_version": "2026-07-28",
+ "agentcat_mrtr": "input_required",
+ }
+ assert all(event.tags[key] == value for key, value in customer_tags.items())
+ assert all(event.tags[key] == value for key, value in sdk_tags.items())
+ assert len(event.tags) == 55
+
+
+# The customer callbacks receive the same (request, extra) they always have.
+async def test_publish_tool_call_event_hands_callbacks_request_and_extra(monkeypatch):
+ capture_published(monkeypatch)
+ seen: list[tuple[Any, Any]] = []
+
+ def event_tags(request, extra):
+ seen.append((request, extra))
+ return {"env": "prod"}
+
+ data = make_data(AgentCatOptions(event_tags=event_tags))
+ rc = await resolve_call(
+ data,
+ "search",
+ {},
+ request="REQ",
+ extra="EXTRA",
+ meta_sources=[],
+ legacy_client=lambda: None,
+ )
+ await publish_tool_call_event(
+ object(), data, rc, "search", {}, None, False, None, 1, None, None
+ )
+ assert seen == [("REQ", "EXTRA")]
+
+
+# Nothing AgentCat does may raise into the customer's server.
+async def test_publish_tool_call_event_never_raises(monkeypatch):
+ logged: list[str] = []
+ monkeypatch.setattr("agentcat.modules.callpath.write_to_log", logged.append)
+
+ def boom(server, event):
+ raise RuntimeError("queue is gone")
+
+ monkeypatch.setattr(event_queue, "publish_event", boom)
+ data = make_data()
+
+ await publish_tool_call_event(
+ object(), data, resolved(), "search", {}, None, False, None, 1, None, None
+ )
+ assert any("queue is gone" in entry for entry in logged)
+
+
+# An unserializable response would fail event construction; the call still
+# survives it.
+async def test_publish_tool_call_event_survives_a_bad_event_payload(monkeypatch):
+ published = capture_published(monkeypatch)
+ data = make_data()
+
+ await publish_tool_call_event(
+ object(), data, resolved(), "search", {}, ["not", "a", "dict"], False, None, 1, None, None # noqa: E501
+ )
+ assert published == []
+
+
+# The event timestamps when the call STARTED (TS callWrap.ts uses `startTime`),
+# recovered from the duration the adapter measured.
+async def test_publish_tool_call_event_timestamps_the_call_start(monkeypatch):
+ published = capture_published(monkeypatch)
+ data = make_data()
+ before = datetime.now(timezone.utc)
+
+ await publish_tool_call_event(
+ object(), data, resolved(), "search", {}, None, False, None, 5000, None, None
+ )
+ await publish_tool_call_event(
+ object(), data, resolved(), "search", {}, None, False, None, None, None, None
+ )
+ after = datetime.now(timezone.utc)
+
+ timed, untimed = published
+ assert timed.timestamp is not None and untimed.timestamp is not None
+ # 5s of tool work: the call began ~5s before this publish.
+ assert before - timedelta(seconds=6) <= timed.timestamp <= before - timedelta(seconds=4) # noqa: E501
+ # No measurement to subtract: publish time is the best available.
+ assert before <= untimed.timestamp <= after
diff --git a/tests/test_client_identity.py b/tests/test_client_identity.py
new file mode 100644
index 0000000..873835c
--- /dev/null
+++ b/tests/test_client_identity.py
@@ -0,0 +1,111 @@
+"""Per-request client identity + protocol version ladder (spec §7).
+
+Client name/version now arrives per request under a fully-qualified `_meta`
+key, with the pre-2026 initialize-time capture as the last rung. The ladder is
+first-hit-wins and every field is narrowed to `isinstance(x, str)`, because a
+non-string leaking into an event tag is a wire-format break. The two key
+literals below are the cross-SDK wire contract — fix the implementation, never
+the literal.
+"""
+
+from agentcat.modules.client_identity import (
+ ClientIdentity,
+ client_identity_from_meta,
+ resolve_client_identity,
+ resolve_protocol_version,
+)
+
+KEY = "io.modelcontextprotocol/clientInfo"
+PV = "io.modelcontextprotocol/protocolVersion"
+
+
+class FakeMeta:
+ """mcp 1.x `RequestParams.Meta`: extras live in `.model_extra`, not on the
+ object, and `.model_extra` is `None` unless the model allows extras."""
+
+ def __init__(self, model_extra):
+ self.model_extra = model_extra
+
+
+def test_meta_narrowing():
+ assert client_identity_from_meta({KEY: {"name": "cursor", "version": "2.1"}}) == ClientIdentity("cursor", "2.1") # noqa: E501
+ assert client_identity_from_meta({KEY: {"name": "cursor", "version": 7}}) == ClientIdentity("cursor", None) # noqa: E501
+ assert client_identity_from_meta({KEY: "junk"}) is None
+ assert client_identity_from_meta(None) is None
+
+
+# spec §7 rung 2 — pre-2026 servers hand us the pydantic Meta model, not a
+# dict, and the fully-qualified key is an extra field rather than an attribute.
+def test_meta_reads_pydantic_style_model_extra():
+ meta = FakeMeta({KEY: {"name": "cursor", "version": "2.1"}})
+ assert client_identity_from_meta(meta) == ClientIdentity("cursor", "2.1")
+ assert client_identity_from_meta(FakeMeta(None)) is None
+ assert client_identity_from_meta(FakeMeta({})) is None
+
+
+# A meta object with no usable key must not be mistaken for a hit; the ladder
+# relies on `None` to fall through to the next rung.
+def test_meta_without_the_key_is_a_miss():
+ assert client_identity_from_meta({}) is None
+ assert client_identity_from_meta({"other": {"name": "cursor"}}) is None
+
+
+def test_ladder_order_and_legacy():
+ envelope = {KEY: {"name": "envelope", "version": "1"}}
+ passthrough = {KEY: {"name": "meta", "version": "2"}}
+
+ class Legacy: # duck-typed clientInfo object
+ name, version = "legacy", "3"
+
+ assert resolve_client_identity([envelope, passthrough], lambda: Legacy()).name == "envelope" # noqa: E501
+ assert resolve_client_identity([None, passthrough], lambda: Legacy()).name == "meta"
+ assert resolve_client_identity([None, None], lambda: Legacy()).name == "legacy"
+ assert resolve_client_identity([None], lambda: (_ for _ in ()).throw(RuntimeError())) == ClientIdentity() # noqa: E501
+
+
+# A rung that is present but unusable is a miss, not a stop: junk under the key
+# must not shadow a good value further down the ladder.
+def test_unusable_meta_rung_falls_through():
+ passthrough = {KEY: {"name": "meta", "version": "2"}}
+ assert resolve_client_identity([{KEY: "junk"}, passthrough], lambda: None).name == "meta" # noqa: E501
+ assert resolve_client_identity([{}, FakeMeta({KEY: {"name": "extra"}})], lambda: None).name == "extra" # noqa: E501
+
+
+# spec §7 rung 3 — the legacy accessor is whatever the era hands back:
+# a clientInfo model, a plain dict, `None` (v2 sessions may have none), or an
+# exception from touching a torn-down request context. All four resolve, never
+# raise, and the empty identity is the floor.
+def test_legacy_rung_shapes():
+ assert resolve_client_identity([], lambda: {"name": "dict", "version": "4"}) == ClientIdentity("dict", "4") # noqa: E501
+ assert resolve_client_identity([None], lambda: None) == ClientIdentity()
+ assert resolve_client_identity([], lambda: None) == ClientIdentity()
+
+ def boom():
+ raise RuntimeError("request context is gone")
+
+ assert resolve_client_identity([None, None], boom) == ClientIdentity()
+
+
+# Narrowing applies to every rung, not just the meta ones.
+def test_legacy_rung_is_narrowed_per_field():
+ class Weird:
+ name, version = "legacy", 3
+
+ identity = resolve_client_identity([], lambda: Weird())
+ assert identity == ClientIdentity("legacy", None)
+
+
+def test_protocol_version():
+ assert resolve_protocol_version([{PV: "2026-07-28"}]) == "2026-07-28"
+ assert resolve_protocol_version([None], fallback="2026-07-28") == "2026-07-28"
+ assert resolve_protocol_version([{PV: 9}]) is None
+
+
+# Same ladder shape as the identity resolver: first hit wins, misses fall
+# through, pydantic Meta is read the same way, and the fallback is the floor.
+def test_protocol_version_ladder():
+ assert resolve_protocol_version([{PV: "first"}, {PV: "second"}]) == "first"
+ assert resolve_protocol_version([{}, {PV: "second"}]) == "second"
+ assert resolve_protocol_version([FakeMeta({PV: "2026-07-28"})]) == "2026-07-28"
+ assert resolve_protocol_version([FakeMeta(None)], fallback="fb") == "fb"
+ assert resolve_protocol_version([]) is None
diff --git a/tests/test_community_v4_handles.py b/tests/test_community_v4_handles.py
new file mode 100644
index 0000000..5d2e165
--- /dev/null
+++ b/tests/test_community_v4_handles.py
@@ -0,0 +1,843 @@
+"""Handle behavior on the community FastMCP adapter, FastMCP 4 era.
+
+The 4.x sibling of `tests/community/test_community_v3_handles.py`. Task 9 built
+one middleware for both eras and Task 13 turned era 4 on, so this file does not
+re-prove the shared behavior the v3 file already covers — it covers what only
+FastMCP 4 can reach:
+
+- the era's own dispatch, which runs the middleware chain a SECOND time for a
+ component request that failed before the interior chain ran, handing the
+ hooks a raw params **mapping** instead of a typed model;
+- `DereferenceRefsMiddleware`, which FastMCP 4 installs on every server by
+ default and which the `get_more_tools` concession must survive;
+- `ResponseCachingMiddleware`, which proves index 0 is load-bearing rather than
+ incidental;
+- real multi-round-trip tool calls — `InputRequiredToolResult` and the
+ `input_responses` / `request_state` continuation envelope are first-class on
+ this era, not a simulation;
+- the four TS-parity behaviors, which the 3.x suite proves but cannot prove
+ HERE: `tests/community/` is collected only under mcp 1.x, so nothing in it
+ has ever run against fastmcp 4.
+
+Most of it rides a real `fastmcp.Client` against a real server. The three
+tests that call the middleware directly do so because no client can reach what
+they assert: FastMCP's own dispatch is what hands the hooks a raw mapping, and
+a client that receives an `InputRequiredToolResult` answers it and moves on
+rather than handing the round back for inspection.
+"""
+
+import copy
+
+import mcp.types as mt
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_MRTR,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+from agentcat.modules.detection import ServerFlavor, detect_server
+
+from .test_utils import sid
+from .test_utils.community_client import (
+ HAS_COMMUNITY_CLIENT,
+ create_community_test_client,
+)
+from .test_utils.community_openapi_server import (
+ OPENAPI_TOOL_NAMES,
+ create_community_openapi_server,
+)
+from .test_utils.community_todo_server import (
+ HAS_COMMUNITY_FASTMCP,
+ create_community_todo_server,
+)
+
+pytestmark = pytest.mark.skipif(
+ not (HAS_COMMUNITY_FASTMCP and HAS_COMMUNITY_CLIENT),
+ reason="Community FastMCP not available",
+)
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+def _call_events(capture) -> list:
+ return [e for e in capture if e.event_type == "mcp:tools/call"]
+
+
+def _named(tools, name):
+ return next(t for t in tools if t.name == name)
+
+
+def _new_server(name: str = "probe-server"):
+ from fastmcp import FastMCP
+
+ return FastMCP(name)
+
+
+def _agentcat_middleware(server):
+ from agentcat.modules.adapters.community import AgentCatMiddleware
+
+ return [mw for mw in server.middleware if isinstance(mw, AgentCatMiddleware)]
+
+
+# ── the era is recognized and routed ────────────────────────────────────────
+
+
+def test_track_installs_the_community_middleware_at_index_zero():
+ """`track()` routes COMMUNITY_V4 to the community adapter with era 4.
+
+ The flavor assert is the precondition every other test here rests on —
+ `tests/test_detection.py` proves the classifier, this proves what `track()`
+ does with its answer.
+
+ Index 0 is outermost — FastMCP builds its chain over `reversed(middleware)`
+ — and on this era the list is never empty to begin with:
+ `DereferenceRefsMiddleware` is installed by default, so "insert at 0" and
+ "append" are visibly different placements from the very first track().
+ """
+ from agentcat.modules.adapters.community import ERA_V4
+
+ server = create_community_todo_server()
+ assert detect_server(server).flavor is ServerFlavor.COMMUNITY_V4
+ assert server.middleware, "FastMCP 4 ships a default middleware chain"
+
+ track(server, "proj_test")
+
+ installed = _agentcat_middleware(server)
+ assert len(installed) == 1
+ assert server.middleware[0] is installed[0]
+ assert installed[0]._era == ERA_V4
+
+
+# ── the four TS-parity behaviors, executed on this era ──────────────────────
+#
+# The middleware does not branch on the era, so these four are the same code
+# the 3.x suite proves. They are re-run here anyway because `tests/community/`
+# is collected ONLY under mcp 1.x (`tests/conftest.py::_LEGACY_ONLY`), so
+# nothing in that directory has ever executed against fastmcp 4 — "shared code"
+# is an argument about risk, not a substitute for running it.
+
+
+async def test_retracking_updates_options_without_stacking_a_middleware(capture):
+ """A repeated `track()` replaces the middleware; it never adds a second.
+
+ A stacked pass would inject session_id on the inside, find it already present
+ on the outside, never record it as strippable, and hand the customer's tool
+ a parameter it never declared. The second track() also carries different
+ options, so this pins the replacement AND that the new options are the ones
+ serving requests.
+ """
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=False))
+ track(
+ server,
+ "proj_test",
+ AgentCatOptions(enable_report_missing=True, enable_agent_tracking=True),
+ )
+
+ installed = _agentcat_middleware(server)
+ assert len(installed) == 1, "a second track() stacked another middleware"
+ assert server.middleware[0] is installed[0], "the replacement left index 0"
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ # Both option changes are live: the tool the first track() suppressed is
+ # now advertised, and agent tracking is now injecting.
+ assert [t.name for t in listed].count("get_more_tools") == 1
+ add = _named(listed, "add_todo")
+ assert list(add.input_schema["properties"]) == [
+ "text",
+ SESSION_ID_PARAM,
+ "agent_id",
+ "context",
+ ]
+
+ result = await client.call_tool(
+ "add_todo",
+ {
+ "text": "retracked",
+ SESSION_ID_PARAM: sid("retrack"),
+ "agent_id": "o|cc|k3n9x",
+ },
+ )
+ assert result.is_error is False, _text(result)
+
+ events = _call_events(capture)
+ assert len(events) == 1, "a stacked middleware published the call twice"
+ assert events[0].session_id == sid("retrack")
+ assert events[0].tags[AGENTCAT_TAG_AGENT_ID] == "o|cc|k3n9x"
+
+
+async def test_options_are_read_per_request_not_captured_at_install(capture):
+ """The middleware re-reads the server's tracking data every request.
+
+ Distinct from the re-track above, which installs a fresh middleware object:
+ here the data is swapped in place and the SAME middleware has to pick it up,
+ which is the only thing that proves the lookup is per request rather than
+ captured in `__init__`.
+ """
+ from dataclasses import replace
+
+ from agentcat.modules.internal import (
+ get_server_tracking_data,
+ set_server_tracking_data,
+ )
+
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_agent_tracking=False))
+ middleware = server.middleware[0]
+
+ set_server_tracking_data(
+ server,
+ replace(
+ get_server_tracking_data(server),
+ options=AgentCatOptions(enable_agent_tracking=True),
+ injected_params_registry=None,
+ output_injection_registry=None,
+ ),
+ )
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert "agent_id" in _named(listed, "add_todo").input_schema["properties"]
+ result = await client.call_tool(
+ "add_todo", {"text": "swapped", "agent_id": "o|cc|abc12"}
+ )
+
+ assert server.middleware[0] is middleware, "the middleware was reinstalled"
+ assert result.is_error is False, _text(result)
+ assert _call_events(capture)[0].tags[AGENTCAT_TAG_AGENT_ID] == "o|cc|abc12"
+
+
+async def test_tracing_disabled_strips_but_publishes_nothing(capture):
+ """Tracing off is not injection off.
+
+ No handles are injected and nothing is published, but `context` is an
+ independent option — so it is still advertised, and it must still be
+ stripped before the customer's tool runs or the call fails validation.
+ """
+ seen: dict = {}
+ server = _new_server("quiet-server")
+
+ @server.tool
+ def probe(text: str) -> str:
+ """Would fail validation if handed an argument it never declared."""
+ seen["text"] = text
+ return f"probe:{text}"
+
+ track(server, "proj_test", AgentCatOptions(enable_tracing=False))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ schema = _named(listed, "probe").input_schema
+ assert SESSION_ID_PARAM not in schema["properties"]
+ assert "context" in schema["properties"]
+
+ result = await client.call_tool(
+ "probe", {"text": "quiet", "context": "no tracing"}
+ )
+
+ assert result.is_error is False, _text(result)
+ assert seen == {"text": "quiet"}
+ assert MINT_BACK_HEADER not in _text(result)
+ assert capture == []
+
+
+async def test_resolution_failure_degrades_to_an_untraced_call(capture, monkeypatch):
+ """A tool call must never fail because analytics did.
+
+ The injected parameters are stripped on the way DOWN, before resolution is
+ attempted, so the degrade path still hands the customer's tool a clean
+ argument set rather than failing its validation.
+ """
+ from agentcat.modules.adapters import community
+
+ seen: dict = {}
+
+ async def boom(*args, **kwargs):
+ raise RuntimeError("resolver exploded")
+
+ monkeypatch.setattr(community, "resolve_call", boom)
+
+ server = _new_server("degrade-server")
+
+ @server.tool
+ def probe(text: str) -> str:
+ """Would fail validation if handed an argument it never declared."""
+ seen["text"] = text
+ return f"probe:{text}"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.list_tools()
+ result = await client.call_tool(
+ "probe",
+ {"text": "degraded", "context": "why", SESSION_ID_PARAM: sid("x")},
+ )
+
+ assert result.is_error is False, _text(result)
+ assert "probe:degraded" in _text(result)
+ assert seen == {"text": "degraded"}
+ assert capture == []
+
+
+# ── injection, mint-back and echo ───────────────────────────────────────────
+
+
+async def test_prompted_mode_end_to_end(capture):
+ server = create_community_todo_server()
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ add = _named(listed, "add_todo")
+ tail = list(add.input_schema["properties"])[-2:]
+ assert tail == [SESSION_ID_PARAM, "context"]
+ assert SESSION_ID_PARAM not in add.input_schema.get("required", [])
+ assert MCP_INSTRUCTIONS_KEY in add.output_schema["properties"]
+ assert any(t.name == "get_more_tools" for t in listed)
+
+ r1 = await client.call_tool(
+ "add_todo", {"text": "hi", "context": "tracking the user's work"}
+ )
+ text = _text(r1)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+ assert r1.structured_content[MCP_INSTRUCTIONS_KEY]["session_id"] == minted
+
+ r2 = await client.call_tool(
+ "add_todo", {"text": "again", SESSION_ID_PARAM: minted}
+ )
+ assert MINT_BACK_HEADER not in _text(r2)
+
+ # v2 publishes tools/call and nothing else.
+ assert {e.event_type for e in capture} == {"mcp:tools/call"}
+ events = _call_events(capture)
+ assert [e.session_id for e in events] == [minted, minted]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "minted",
+ "supplied",
+ ]
+ # The event records the call as the agent made it: raw arguments, and the
+ # customer's own undecorated result.
+ assert events[0].parameters["arguments"]["context"]
+ assert MINT_BACK_HEADER not in str(events[0].response)
+
+
+async def test_handler_sees_stripped_args(capture):
+ """The injected parameters never reach the customer's tool body."""
+ seen: dict = {}
+ server = _new_server()
+
+ @server.tool
+ def probe(text: str) -> str:
+ """A tool that would fail validation if handed an extra argument."""
+ seen["text"] = text
+ return f"probe:{text}"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ result = await client.call_tool(
+ "probe",
+ {"text": "payload", SESSION_ID_PARAM: sid("supplied"), "context": "why"},
+ )
+
+ assert result.is_error is False, _text(result)
+ assert seen == {"text": "payload"}
+ assert _call_events(capture)[0].session_id == sid("supplied")
+
+
+# ── multi round-trip tool calls (SEP-2322) ─────────────────────────────────
+
+
+async def test_strip_preserves_the_mrtr_continuation_envelope(capture):
+ """The stripped message is a COPY of the customer's, not a rebuilt
+ `CallToolRequestParams`.
+
+ Rebuilding from `(name, arguments)` — what the v1 middleware did — drops
+ `_meta` and, on this era, `input_responses` / `request_state`. A tool that
+ asked for input would then be handed a continuation with no responses and
+ no state, and the round-trip would restart forever.
+ """
+ from fastmcp.server.middleware import MiddlewareContext
+ from fastmcp.tools import ToolResult
+ from mcp.types import CallToolRequestParams, ElicitResult, TextContent
+
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ middleware = server.middleware[0]
+
+ seen: dict = {}
+
+ async def call_next(ctx):
+ seen["message"] = ctx.message
+ return ToolResult(content=[TextContent(type="text", text="ok")])
+
+ responses = {"r1": ElicitResult(action="accept", content={"answer": "yes"})}
+ message = CallToolRequestParams(
+ name="add_todo",
+ arguments={"text": "hi", "context": "why"},
+ _meta={"trace": "abc"},
+ input_responses=responses,
+ request_state="opaque-state",
+ )
+ await middleware(
+ MiddlewareContext(message=message, method="tools/call"), call_next
+ )
+
+ delivered = seen["message"]
+ assert delivered.arguments == {"text": "hi"}
+ assert delivered.input_responses == responses
+ assert delivered.request_state == "opaque-state"
+ assert delivered.meta is message.meta
+ # The customer's own message object is untouched.
+ assert message.arguments == {"text": "hi", "context": "why"}
+ # A round carrying inputResponses is a continuation (changelog §6.4).
+ assert _call_events(capture)[0].tags[AGENTCAT_TAG_MRTR] == "continuation"
+
+
+async def test_input_required_round_is_tagged_but_never_decorated(capture):
+ """`InputRequiredToolResult` is FastMCP 4's real ask-for-input result.
+
+ It is not the completing round, so it carries no mint-back — and it must
+ come back as the very object the layer below produced. Its own docstring
+ warns that `content` / `structured_content` carry nothing on this subclass,
+ so a decorated copy would write a mint-back where the wire handler never
+ looks; identity is the assertion because it is the only one that fails for
+ a copy that happens to look right.
+ """
+ from fastmcp.server.middleware import MiddlewareContext
+
+ ask = _input_required_result("s1")
+
+ server = _new_server("mrtr-server")
+
+ @server.tool(output_schema=None)
+ def needs_input(text: str) -> str:
+ """Answered by the layer below AgentCat."""
+ return text
+
+ track(server, "proj_test")
+
+ async def call_next(ctx):
+ return ask
+
+ result = await server.middleware[0](
+ MiddlewareContext(
+ message=mt.CallToolRequestParams(
+ name="needs_input", arguments={"text": "round one"}
+ ),
+ method="tools/call",
+ ),
+ call_next,
+ )
+
+ assert result is ask, "the intermediate round was copied or decorated"
+ event = _call_events(capture)[0]
+ assert event.tags[AGENTCAT_TAG_MRTR] == "input_required"
+ assert event.session_id.startswith("ses_")
+
+
+def _input_required_result(state: str):
+ from fastmcp.tools import InputRequiredToolResult
+
+ return InputRequiredToolResult(mt.InputRequiredResult(request_state=state))
+
+
+async def test_a_real_client_drives_a_full_input_required_round_trip(capture):
+ """Round one asks for state, round two completes — over a real client.
+
+ Only the completing round is decorated, and only it may carry the
+ mint-back: an agent that echoed a `session_id` off an intermediate round would
+ be echoing one the server never issued.
+ """
+ rounds: list[str] = []
+ server = _new_server("mrtr-e2e")
+
+ @server.tool(output_schema=None)
+ def guarded(text: str) -> str | mt.InputRequiredResult:
+ """Asks for opaque state on the first round, completes on the second."""
+ rounds.append(text)
+ if len(rounds) == 1:
+ return mt.InputRequiredResult(request_state="round-two-please")
+ return f"completed: {text}"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ result = await client.call_tool("guarded", {"text": "hello"})
+
+ assert rounds == ["hello", "hello"]
+ assert "completed: hello" in _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ events = _call_events(capture)
+ assert len(events) == 2
+ assert events[0].tags[AGENTCAT_TAG_MRTR] == "input_required"
+ # Round two answers with `requestState` and NO `inputResponses` — the tool
+ # asked to be resumed rather than asking the client a question, so the
+ # SEP-2322 driver retries it after a backoff with nothing else. It is still
+ # a continuation, and keying the tag on `inputResponses` alone missed it.
+ assert events[1].tags[AGENTCAT_TAG_MRTR] == "continuation"
+ # The completing round is the one that mints, and its handle is the one the
+ # agent was handed.
+ minted = _text(result).split("session_id=")[1].split(" ")[0]
+ assert events[1].session_id == minted
+
+
+async def test_a_supplied_session_id_correlates_every_mrtr_round(capture):
+ """The handle the agent supplied rides every round of the conversation.
+
+ The SEP-2322 driver replays the ORIGINAL arguments verbatim on each retry
+ (`mcp/client/_input_required.py`, reached through
+ `fastmcp/client/mixins/tools.py`), so a `session_id` supplied on round one is
+ on the wire for round two as well. This is the correlation changelog §6.4
+ promises; the minted-first-call case is the only one that fragments, and
+ every fix for it is either server-side state, which design §13 forbids, or
+ a rewrite of the customer's own `requestState` — that one IS stateless and
+ does work, but design §12 forbids altering what a customer's tool produced
+ (see task 13.6's report §5).
+ """
+ rounds: list[str] = []
+ server = _new_server("mrtr-supplied")
+
+ @server.tool(output_schema=None)
+ def guarded(text: str) -> str | mt.InputRequiredResult:
+ """Asks for opaque state on the first round, completes on the second."""
+ rounds.append(text)
+ if len(rounds) == 1:
+ return mt.InputRequiredResult(request_state="round-two-please")
+ return f"completed: {text}"
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ await client.call_tool(
+ "guarded", {"text": "hello", SESSION_ID_PARAM: sid("supplied")}
+ )
+
+ # The injected parameter never reached the tool, on either round.
+ assert rounds == ["hello", "hello"]
+ events = _call_events(capture)
+ assert [e.session_id for e in events] == [sid("supplied"), sid("supplied")]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "supplied",
+ "supplied",
+ ]
+ assert [e.tags.get(AGENTCAT_TAG_MRTR) for e in events] == [
+ "input_required",
+ "continuation",
+ ]
+
+
+async def test_hook_mode_correlates_every_mrtr_round(capture):
+ """A `resolve_session_id` hook correlates the rounds on this era too.
+
+ The hook runs per request against that round's own message/extra, and every
+ round of one conversation is the same tool call on the same connection — so
+ anything a hook keys on returns the same value and derives the same task.
+ With `supplied` (above) that leaves prompted-mode MINTING as the only
+ resolution mode an MRTR conversation fragments under, on BOTH modern eras.
+ That claim is load-bearing input to the design question task 13.6 raised,
+ so it is pinned on each era rather than inferred from the shared engine.
+ """
+ from agentcat.modules.handles import derive_session_id
+
+ seen: list = []
+ rounds: list[str] = []
+ server = _new_server("mrtr-hook")
+
+ @server.tool(output_schema=None)
+ def guarded(text: str) -> str | mt.InputRequiredResult:
+ """Asks for opaque state on the first round, completes on the second."""
+ rounds.append(text)
+ if len(rounds) == 1:
+ return mt.InputRequiredResult(request_state="round-two-please")
+ return f"completed: {text}"
+
+ def hook(message, extra):
+ seen.append(message)
+ return "tenant"
+
+ track(server, "proj_test", AgentCatOptions(resolve_session_id=hook))
+
+ async with create_community_test_client(server) as client:
+ await client.call_tool("guarded", {"text": "hello"})
+
+ # Each round resolved afresh, against its own message.
+ assert len(seen) == 2 and seen[0] is not seen[1]
+ events = _call_events(capture)
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == ["hook", "hook"]
+ derived = derive_session_id("tenant", "proj_test")
+ assert [e.session_id for e in events] == [derived, derived]
+
+
+# ── FastMCP 4's own middleware, above and below us ─────────────────────────
+
+
+async def test_agentcat_is_outermost_of_the_response_cache(capture):
+ """Index 0 is load-bearing, and `ResponseCachingMiddleware` is what proves it.
+
+ Below us, the cache keys on the STRIPPED arguments and stores the
+ customer's own result, so every call still reaches AgentCat: two identical
+ calls publish two events and mint two different handles. Above us it would
+ key on the raw arguments and cache OUR decorated result — the second agent
+ would be handed the first agent's `session_id` and its call would never be
+ recorded at all.
+ """
+ from fastmcp.server.middleware import Middleware
+ from fastmcp.server.middleware.caching import ResponseCachingMiddleware
+
+ below: dict = {}
+
+ class Probe(Middleware):
+ async def on_call_tool(self, context, call_next):
+ below.setdefault("arguments", []).append(
+ dict(context.message.arguments or {})
+ )
+ return await call_next(context)
+
+ async def on_list_tools(self, context, call_next):
+ tools = list(await call_next(context))
+ below.setdefault("schemas", []).append(
+ {t.name: set((t.parameters or {}).get("properties", {})) for t in tools}
+ )
+ return tools
+
+ server = create_community_todo_server()
+ server.add_middleware(ResponseCachingMiddleware())
+ server.add_middleware(Probe())
+ track(server, "proj_test")
+
+ assert server.middleware[0] is _agentcat_middleware(server)[0]
+
+ async with create_community_test_client(server) as client:
+ first = _named(await client.list_tools(), "add_todo")
+ second = _named(await client.list_tools(), "add_todo")
+
+ r1 = await client.call_tool("add_todo", {"text": "same", "context": "why"})
+ r2 = await client.call_tool("add_todo", {"text": "same", "context": "why"})
+
+ # The listing below us was served from the cache the second time, and the
+ # injection still reached the agent on both.
+ assert SESSION_ID_PARAM in first.input_schema["properties"]
+ assert SESSION_ID_PARAM in second.input_schema["properties"]
+ assert len(below["schemas"]) == 1, "the cache below us served the second listing"
+ assert below["schemas"][0]["add_todo"] == {"text"}
+ assert SESSION_ID_PARAM not in below["schemas"][0]["get_more_tools"]
+
+ # The cache below us never saw an injected argument...
+ assert below["arguments"] == [{"text": "same"}]
+ # ...and its hit did not swallow the second call.
+ minted = [_text(r).split("session_id=")[1].split(" ")[0] for r in (r1, r2)]
+ assert minted[0] != minted[1]
+ assert [e.session_id for e in _call_events(capture)] == minted
+
+
+async def test_get_more_tools_survives_the_default_dereference_middleware(capture):
+ """FastMCP 4 installs `DereferenceRefsMiddleware` on every server.
+
+ It hands back a `model_copy` for any tool carrying `$defs`/`$ref`, and a
+ copy of our own tool must never read as a customer's — that false positive
+ un-registers ours while leaving the copy in the listing, advertising a
+ `get_more_tools` whose next call raises `Unknown tool`.
+ """
+ from fastmcp.server.middleware.dereference import DereferenceRefsMiddleware
+
+ server = _new_server("deref-server")
+ assert any(
+ isinstance(mw, DereferenceRefsMiddleware) for mw in server.middleware
+ ), "FastMCP 4 no longer installs the dereferencing middleware by default"
+
+ @server.tool
+ def with_refs(payload: dict) -> str:
+ """A tool whose schema is the kind the dereferencer rewrites."""
+ return str(payload)
+
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = sorted(t.name for t in await client.list_tools())
+ assert listed == ["get_more_tools", "with_refs"]
+
+ result = await client.call_tool("get_more_tools", {"context": "why"})
+ assert "Unfortunately" in _text(result)
+
+ assert sorted(t.name for t in await client.list_tools()) == listed
+
+ names = [t.name for t in await server.list_tools(run_middleware=False)]
+ assert "get_more_tools" in names, "ours was un-registered on a false positive"
+
+
+async def test_a_middleware_that_rebuilds_tools_does_not_lose_get_more_tools(capture):
+ """The authoritative re-check, and the only shape that needs it.
+
+ `_is_ours` recognizes a copy of our tool three ways: object identity, the
+ underlying `fn` a `model_copy` carries over, and our canonical description.
+ A layer that REBUILDS each tool with `Tool.from_function` and a description
+ of its own defeats all three — and that is not exotic: it is what any
+ middleware that re-stamps or re-documents a listing does.
+
+ Ours then reads as a foreign `get_more_tools` in the processed listing.
+ Conceding on that would un-register the real tool while the rebuilt copy
+ stays advertised, so the very next call to it raises `Unknown tool`. The
+ re-check asks the RAW provider listing — where our own object is present
+ and recognizable — and answers "nobody else supplies this".
+ """
+ from fastmcp.server.middleware import Middleware
+ from fastmcp.tools import Tool
+
+ class Rebuilder(Middleware):
+ """Hands back tools it built itself, not copies of the originals."""
+
+ async def on_list_tools(self, context, call_next):
+ async def rebuilt_body(context: str = "") -> str:
+ return "rebuilt"
+
+ return [
+ Tool.from_function(
+ rebuilt_body,
+ name=tool.name,
+ description=f"rebuilt: {tool.description}",
+ ).model_copy(update={"parameters": tool.parameters})
+ for tool in await call_next(context)
+ ]
+
+ server = _new_server("rebuilding-server")
+
+ @server.tool
+ def add_todo(text: str) -> str:
+ """Add a todo."""
+ return f"added {text}"
+
+ server.add_middleware(Rebuilder())
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_community_test_client(server) as client:
+ listed = await client.list_tools()
+ assert sorted(t.name for t in listed) == ["add_todo", "get_more_tools"]
+ # Nothing in that listing is recognizable as ours any more.
+ assert all(t.description.startswith("rebuilt: ") for t in listed)
+
+ # The listing advertised it, so it has to answer — and the answer is
+ # ours, from the provider, because ours is still registered.
+ result = await client.call_tool("get_more_tools", {"context": "why"})
+ assert "Unfortunately" in _text(result)
+
+ names = [t.name for t in await server.list_tools(run_middleware=False)]
+ assert "get_more_tools" in names, "ours was un-registered on a false positive"
+
+
+# ── the era's second dispatch pass ─────────────────────────────────────────
+
+
+async def test_a_message_that_is_not_the_typed_model_is_passed_straight_through(
+ capture,
+):
+ """FastMCP 4 runs the chain a SECOND time for a component request that
+ failed before the interior chain did (`low_level._dispatch_component`).
+
+ That pass is observation only — its `call_next` re-raises the original
+ failure — and it hands `MiddlewareContext.message` the RAW params mapping,
+ because reconstructing a typed model is exactly what fails on a malformed
+ message. A hook that assumed the typed model raised `AttributeError` there,
+ and that error replaced the customer's own protocol error on the wire.
+ """
+ from fastmcp.server.middleware import MiddlewareContext
+ from mcp.shared.exceptions import MCPError
+
+ server = create_community_todo_server()
+ track(server, "proj_test")
+ # The helper, not `middleware[0]`: index 0 is only ours while nothing else
+ # inserts ahead of us, and a blind bind would silently exercise FastMCP's
+ # own middleware instead of AgentCat's.
+ installed = _agentcat_middleware(server)
+ assert len(installed) == 1
+ middleware = installed[0]
+
+ original = MCPError(-32602, "Invalid request parameters")
+
+ async def re_raise(ctx):
+ raise original
+
+ # `initialize` is on the list because a failed handshake reconstructs no
+ # message at all — FastMCP hands the hook `None` there.
+ for method, message in (
+ ("tools/call", {"arguments": {"text": "no name"}}),
+ ("tools/list", {"cursor": 12345}),
+ ("initialize", None),
+ ):
+ with pytest.raises(MCPError) as raised:
+ await middleware(
+ MiddlewareContext(message=message, method=method), re_raise
+ )
+ assert raised.value is original, f"{method} replaced the customer's error"
+
+ assert capture == [], "a failed request published an event"
+
+
+# ── tools that hold live runtime state ─────────────────────────────────────
+
+
+async def test_openapi_generated_tools_are_still_injectable(capture):
+ """An OpenAPI tool holds a live `httpx` client, and the schema copy is the
+ only thing that makes injection possible for it.
+
+ Deep-copying the `Tool` reintroduces the `threading.RLock` pickling failure
+ that silently dropped injection for whole servers in v1, so the guard below
+ asserts the hazard is still real on this era before asserting the outcome.
+ """
+ requests: list = []
+ server = create_community_openapi_server(record_requests=requests)
+
+ raw = await server.list_tools(run_middleware=False)
+ with pytest.raises(TypeError, match="cannot pickle '_thread.RLock' object"):
+ copy.deepcopy(raw[0])
+
+ track(server, "proj_test")
+
+ async with create_community_test_client(server) as client:
+ listed = {t.name: t for t in await client.list_tools()}
+ for name in OPENAPI_TOOL_NAMES:
+ props = listed[name].input_schema["properties"]
+ assert SESSION_ID_PARAM in props, f"session_id not injected into {name}"
+ assert "context" in props, f"context not injected into {name}"
+
+ await client.call_tool(
+ "get_severity",
+ {"id": "42", "context": "an outage", SESSION_ID_PARAM: sid("openapi")},
+ )
+
+ event = _call_events(capture)[-1]
+ assert event.resource_name == "get_severity"
+ assert event.session_id == sid("openapi")
+ assert event.user_intent == "an outage"
+ # Neither injected parameter may reach the customer's own backend.
+ assert requests
+ assert not any(sid("openapi") in str(r.url) for r in requests)
+ assert not any("an outage" in str(r.url) for r in requests)
+
+ # ...and the server's own cached tools are never mutated.
+ raw_now = await server.list_tools(run_middleware=False)
+ after = {t.name: t.parameters for t in raw_now}
+ assert SESSION_ID_PARAM not in after["get_severity"]["properties"]
diff --git a/tests/test_concurrency_handles.py b/tests/test_concurrency_handles.py
new file mode 100644
index 0000000..d325a02
--- /dev/null
+++ b/tests/test_concurrency_handles.py
@@ -0,0 +1,194 @@
+"""25 simultaneous tool calls, each carrying its own handle, on every flavor.
+
+Handles are per REQUEST in v2 — resolved from that call's own arguments, and
+never held anywhere between the resolve and the publish that reads it. This
+module is the cross-flavor proof, and it is written so that it cannot pass
+against an implementation that keeps the resolution anywhere shared.
+
+**Why the barrier is the whole test.** A concurrency test whose calls do not
+actually overlap inside the window under test proves nothing: each call
+resolves, publishes and decorates before the next one starts, so a
+last-write-wins store looks exactly like per-request state. So every tool body
+blocks until all 25 have arrived, which puts all 25 calls provably between
+their own `resolve_call` and their own publish at the same moment — and
+`peak_concurrency` is asserted, so a transport that could not interleave fails
+the test instead of quietly weakening it.
+
+Each call supplies a DISTINCT `session_id`, so what every event and every response
+must name is knowable exactly rather than merely "different from the others".
+"""
+
+import asyncio
+import json
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ MINT_BACK_HEADER_SESSION,
+ SESSION_ID_PARAM,
+)
+
+from .test_utils import NEEDS_CONCURRENT_DISPATCH, sid
+from .test_utils.flavors import flavors
+
+# Every test here asserts `barrier.peak == TOTAL`, which is unsatisfiable on an
+# SDK that handles messages serially. See `NEEDS_CONCURRENT_DISPATCH`.
+pytestmark = NEEDS_CONCURRENT_DISPATCH
+
+TOTAL = 25
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+class Barrier:
+ """Holds every concurrent tool body until all of them have arrived.
+
+ `peak` is the largest number of bodies that were inside at once. The tests
+ assert it reaches `total`, which is what makes them real: it is the
+ difference between "25 calls happened" and "25 calls were simultaneously
+ between their own resolve and their own publish".
+ """
+
+ def __init__(self, total: int) -> None:
+ self.total = total
+ self.in_flight = 0
+ self.peak = 0
+ self.open = asyncio.Event()
+
+ async def wait(self) -> None:
+ self.in_flight += 1
+ self.peak = max(self.peak, self.in_flight)
+ if self.in_flight >= self.total:
+ self.open.set()
+ try:
+ await self.open.wait()
+ finally:
+ self.in_flight -= 1
+
+
+def _session_id(index: int) -> str:
+ # Fixed width, so no handle is a prefix of another and "names only its own"
+ # can be asserted by substring. Must be a SHAPE-VALID id — anything else is
+ # now rejected as `invalid` and the test would prove nothing about
+ # cross-attribution.
+ return sid(f"conc{index:02d}")
+
+
+def _call_events(capture) -> list:
+ return [e for e in capture if e.event_type == "mcp:tools/call"]
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_simultaneous_calls_never_cross_attribute_a_handle(flavor, capture):
+ """25 calls, all inside their own window at once, each with its own handle.
+
+ Two independent things must hold, and a shared resolution breaks both: the
+ event AgentCat publishes for a call names that call's handle, and the
+ result the agent is handed back mirrors that same one and no other.
+ """
+ barrier = Barrier(TOTAL)
+ built = flavor.build("concurrency", hook=barrier.wait)
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ # Listed first, so the output-injection registry is armed and the
+ # structured mirror — the handle the AGENT is told — is in play.
+ listed = await flavor.list_tools(client)
+ echo = next(tool for tool in listed if tool.name == "echo")
+ assert SESSION_ID_PARAM in echo.input_schema["properties"]
+ assert MCP_INSTRUCTIONS_KEY in (echo.output_schema or {})["properties"]
+
+ results = await asyncio.wait_for(
+ asyncio.gather(
+ *(
+ flavor.call(
+ client,
+ "echo",
+ {"text": f"t{index:02d}", SESSION_ID_PARAM: _session_id(index)},
+ )
+ for index in range(TOTAL)
+ )
+ ),
+ timeout=60,
+ )
+
+ assert barrier.peak == TOTAL, (
+ f"only {barrier.peak} of {TOTAL} calls were ever inside the window at "
+ "once; this transport cannot prove anything about concurrency"
+ )
+
+ # ── what the agent was handed ────────────────────────────────────────────
+ for index, result in enumerate(results):
+ mine = _session_id(index)
+ assert result.text.startswith(f"echo:t{index:02d}")
+ assert result.structured[MCP_INSTRUCTIONS_KEY][SESSION_ID_PARAM] == mine
+ # Nobody else's handle is anywhere in this response.
+ dumped = json.dumps(result.structured) + result.text
+ others = [_session_id(other) for other in range(TOTAL) if other != index]
+ assert not [handle for handle in others if handle in dumped]
+ # Every handle was supplied, so nothing was minted and no mint-back
+ # text block exists to name one.
+ assert MINT_BACK_HEADER_SESSION not in result.text
+
+ # ── what AgentCat published ──────────────────────────────────────────────
+ events = _call_events(capture)
+ assert len(events) == TOTAL
+ for event in events:
+ index = int(event.parameters["arguments"]["text"][1:])
+ assert event.session_id == _session_id(index)
+ assert event.parameters["arguments"][SESSION_ID_PARAM] == _session_id(index)
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "supplied"
+ assert len({event.session_id for event in events}) == TOTAL
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_simultaneous_calls_each_mint_their_own_handle(flavor, capture):
+ """The same window with nothing supplied: 25 distinct minted handles.
+
+ The supplied case above can only catch a resolution that leaked between
+ calls. This one also catches a MINT that did: every call mints, and the
+ handle each agent is told has to be the one its own event carries.
+ """
+ barrier = Barrier(TOTAL)
+ built = flavor.build("concurrency-mint", hook=barrier.wait)
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ results = await asyncio.wait_for(
+ asyncio.gather(
+ *(
+ flavor.call(client, "echo", {"text": f"t{index:02d}"})
+ for index in range(TOTAL)
+ )
+ ),
+ timeout=60,
+ )
+
+ assert barrier.peak == TOTAL
+ events = _call_events(capture)
+ assert len(events) == TOTAL
+
+ published = {
+ event.parameters["arguments"]["text"]: event.session_id for event in events
+ }
+ assert len(set(published.values())) == TOTAL, "two calls minted one handle"
+ for index, result in enumerate(results):
+ minted = result.structured[MCP_INSTRUCTIONS_KEY][SESSION_ID_PARAM]
+ assert minted.startswith("ses_")
+ # The handle in the mint-back text and the one in the mirror are the
+ # same object of trust the agent echoes back, and the event has to be
+ # keyed on it.
+ assert f"session_id={minted} " in result.text
+ assert published[f"t{index:02d}"] == minted
diff --git a/tests/test_constants_copy.py b/tests/test_constants_copy.py
new file mode 100644
index 0000000..ebc1dca
--- /dev/null
+++ b/tests/test_constants_copy.py
@@ -0,0 +1,198 @@
+"""Byte-parity guard for agent-facing copy.
+
+The TypeScript SDK is the single source of truth for every agent-facing string:
+`agentcat-typescript-sdk/src/modules/constants.ts` (and `tools.ts` for the
+get_more_tools copy). The `TS_*` literals below were copied verbatim from those
+files. If one of these tests fails the SDKs have drifted -- fix the copy; do not
+edit the expected literal unless the TypeScript source changed too.
+"""
+
+import ast
+from pathlib import Path
+
+from agentcat.modules import constants as c
+from agentcat.modules import tools as t
+
+SRC_ROOT = Path(__file__).resolve().parents[1] / "src" / "agentcat"
+
+# ── Expected bytes, copied verbatim from TS constants.ts ────────────────────
+TS_SESSION_ID_PARAM_DESCRIPTION = "REQUIRED on every call after your first. This MCP server associates all of your tool calls for a given task using session_id; a call that arrives without it cannot be associated with your earlier work and is treated as the start of an unrelated task, disconnected from the original goal. Omit it on your first call only — the server will issue one in the _mcp_instructions field of the result (or a trailing [MCP INSTRUCTIONS] text block) — then echo that exact value on every later call. Never invent a value, and do not issue parallel tool calls until the server has issued your session_id. One session_id covers the whole goal from start to finish: when you spawn subagents you MUST give them this same session_id, or their work is severed from yours. Without session_id, this server does not function as intended." # noqa: E501
+TS_AGENT_ID_PARAM_DESCRIPTION = "REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent working this task using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended." # noqa: E501
+TS_AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE = "REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended." # noqa: E501
+TS_MINT_BACK_HEADER_SESSION = "[MCP INSTRUCTIONS]: session_id issued."
+TS_MINT_BACK_HEADER_INVALID = "[MCP INSTRUCTIONS]: session_id not recognized."
+TS_MINT_BACK_CLOSER = "Without session_id, this server does not function as intended."
+TS_MINT_BACK_INVALID_LINE = " The session_id you sent was not issued by this server. Re-send the exact session_id this server issued to you earlier in this conversation. Never invent a value. If this server has not issued you a session_id yet, omit the parameter and one will be issued." # noqa: E501
+TS_MCP_INSTRUCTIONS_FIELD_DESCRIPTION = "Your handles for this task, confirmed by this MCP server on every response, and the instructions for echoing them on later calls. Read and follow." # noqa: E501
+TS_MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION = "Echo this exact value as the session_id argument on every subsequent tool call." # noqa: E501
+TS_MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION = "Your agent_id as this server received it. Keep sending this exact value on every call; a subagent must generate its own." # noqa: E501
+TS_DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = 'Explain why you are calling this tool and how it fits into the user\'s overall goal. This parameter is used for analytics and user intent tracking. YOU MUST provide 15-25 words (count carefully). NEVER use first person (\'I\', \'we\', \'you\') - maintain third-person perspective. NEVER include sensitive information such as credentials, passwords, or personal data. Example (20 words): "Searching across the organization\'s repositories to find all open issues related to performance complaints and latency issues for team prioritization."' # noqa: E501
+
+# ── Expected bytes, copied verbatim from TS tools.ts ────────────────────────
+TS_GET_MORE_TOOLS_DESCRIPTION = "Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback." # noqa: E501
+TS_GET_MORE_TOOLS_CONTEXT_DESCRIPTION = "A description of your goal and what kind of tool would help accomplish it." # noqa: E501
+TS_REPORT_MISSING_RESPONSE_TEXT = "Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future." # noqa: E501
+
+
+def test_param_names_and_keys():
+ assert c.SESSION_ID_PARAM == "session_id"
+ assert c.AGENT_ID_PARAM == "agent_id"
+ assert c.CONTEXT_PARAM == "context"
+ assert c.GET_MORE_TOOLS_NAME == "get_more_tools"
+ assert c.MCP_INSTRUCTIONS_KEY == "_mcp_instructions"
+ assert c.META_CLIENT_INFO_KEY == "io.modelcontextprotocol/clientInfo"
+ assert c.META_PROTOCOL_VERSION_KEY == "io.modelcontextprotocol/protocolVersion"
+ assert c.AGENTCAT_TAG_SESSION_SOURCE == "agentcat_session_id_source"
+ assert c.AGENTCAT_TAG_AGENT_ID == "agentcat_agent_id"
+ assert c.AGENTCAT_TAG_AGENT_SOURCE == "agentcat_agent_id_source"
+ assert c.AGENTCAT_TAG_PROTOCOL_VERSION == "agentcat_protocol_version"
+ assert c.AGENTCAT_TAG_MRTR == "agentcat_mrtr"
+ assert c.AGENTCAT_CUSTOM_EVENT_TYPE == "agentcat:custom"
+ assert c.AGENT_ID_PREFIX == "agt"
+
+
+def test_mint_back_assembly():
+ assert c.MINT_BACK_HEADER_SESSION == TS_MINT_BACK_HEADER_SESSION
+ assert c.MINT_BACK_HEADER_SESSION == "[MCP INSTRUCTIONS]: session_id issued."
+ assert c.MINT_BACK_CLOSER == TS_MINT_BACK_CLOSER
+ assert (
+ c.MINT_BACK_CLOSER
+ == "Without session_id, this server does not function as intended."
+ )
+ assert (
+ c.mint_back_session_line("ses_X")
+ == " session_id=ses_X — required on every subsequent tool call"
+ )
+ assert c.mint_back_confirmed(["session_id"]) == (
+ "[MCP INSTRUCTIONS]: session_id confirmed. "
+ "Keep sending this exact value on every call."
+ )
+ assert c.mint_back_confirmed(["session_id", "agent_id"]) == (
+ "[MCP INSTRUCTIONS]: session_id and agent_id confirmed. "
+ "Keep sending these exact values on every call."
+ )
+ assert c.mint_back_confirmed(["agent_id"]) == (
+ "[MCP INSTRUCTIONS]: agent_id confirmed. "
+ "Keep sending this exact value on every call."
+ )
+
+
+def test_invalid_correction_copy_matches_ts():
+ """The `invalid` branch corrects the agent without issuing a replacement.
+
+ The closing sentence is load-bearing: an agent that hallucinated a
+ session_id on its FIRST call was never issued one, so "re-send what you
+ were given" names a value that does not exist. Omitting the parameter puts
+ it back on the `minted` path.
+ """
+ assert c.MINT_BACK_HEADER_INVALID == TS_MINT_BACK_HEADER_INVALID
+ assert c.MINT_BACK_INVALID_LINE == TS_MINT_BACK_INVALID_LINE
+ assert c.MINT_BACK_INVALID_LINE.endswith(
+ "If this server has not issued you a session_id yet, omit the parameter "
+ "and one will be issued."
+ )
+ # No value is handed out anywhere in the correction.
+ assert "ses_" not in c.MINT_BACK_HEADER_INVALID + c.MINT_BACK_INVALID_LINE
+
+
+def test_session_id_param_description_matches_ts():
+ assert c.SESSION_ID_PARAM_DESCRIPTION == TS_SESSION_ID_PARAM_DESCRIPTION
+ assert c.SESSION_ID_PARAM_DESCRIPTION.startswith(
+ "REQUIRED on every call after your first."
+ )
+ assert c.SESSION_ID_PARAM_DESCRIPTION.endswith(
+ "Without session_id, this server does not function as intended."
+ )
+ assert "session_id and agent_id" not in c.SESSION_ID_PARAM_DESCRIPTION
+
+
+def test_agent_id_param_descriptions_match_ts():
+ assert c.AGENT_ID_PARAM_DESCRIPTION == TS_AGENT_ID_PARAM_DESCRIPTION
+ assert (
+ c.AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE
+ == TS_AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE
+ )
+ assert "working this task" in c.AGENT_ID_PARAM_DESCRIPTION
+ assert "working this task" not in c.AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE
+ assert (
+ c.AGENT_ID_PARAM_DESCRIPTION.replace(" working this task", "")
+ == c.AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE
+ )
+
+
+def test_mcp_instructions_descriptions_match_ts():
+ assert (
+ c.MCP_INSTRUCTIONS_FIELD_DESCRIPTION == TS_MCP_INSTRUCTIONS_FIELD_DESCRIPTION
+ )
+ assert (
+ c.MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION
+ == TS_MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION
+ )
+ assert (
+ c.MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION
+ == TS_MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION
+ )
+
+
+def test_existing_context_description_unchanged():
+ """v1 copy must survive the v2 upgrade byte-for-byte."""
+ assert c.DEFAULT_CONTEXT_DESCRIPTION == TS_DEFAULT_CONTEXT_PARAMETER_DESCRIPTION
+
+
+async def test_get_more_tools_copy_unchanged():
+ """v1 get_more_tools copy must survive the v2 upgrade byte-for-byte."""
+ assert t.GET_MORE_TOOLS_DESCRIPTION == TS_GET_MORE_TOOLS_DESCRIPTION
+ context_schema = t.GET_MORE_TOOLS_SCHEMA["properties"]["context"]
+ assert context_schema["description"] == TS_GET_MORE_TOOLS_CONTEXT_DESCRIPTION
+ assert t.REPORT_MISSING_RESPONSE_TEXT == TS_REPORT_MISSING_RESPONSE_TEXT
+ result = await t.handle_report_missing({"context": "why"})
+ assert result.content[0].text == TS_REPORT_MISSING_RESPONSE_TEXT
+
+
+def _declared_descriptions() -> list[tuple[str, int, str]]:
+ """Every literal `description=` / `"description":` string under src/agentcat.
+
+ Adjacent string literals are joined by the parser, so implicitly
+ concatenated copy is compared as the single string an agent would see.
+ """
+ found: list[tuple[str, int, str]] = []
+ for path in sorted(SRC_ROOT.rglob("*.py")):
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ values = []
+ if isinstance(node, ast.keyword) and node.arg == "description":
+ values.append(node.value)
+ elif isinstance(node, ast.Dict):
+ values.extend(
+ v
+ for k, v in zip(node.keys, node.values, strict=True)
+ if isinstance(k, ast.Constant) and k.value == "description"
+ )
+ for value in values:
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
+ name = str(path.relative_to(SRC_ROOT))
+ found.append((name, value.lineno, value.value))
+ return found
+
+
+def test_no_adapter_ships_a_second_copy_of_get_more_tools():
+ """Any inline get_more_tools copy an adapter grows must match the TS bytes.
+
+ v2 has one home for this copy — `modules/tools.py` — and every adapter
+ registers from there. The context description is still a literal the walk
+ can see (the `"description"` key inside GET_MORE_TOOLS_SCHEMA), so its
+ presence is still asserted; the tool description is now a named constant
+ the walk cannot reach, which is why only that one lost its presence check.
+ Byte comparison applies to every match, so a future adapter that re-types
+ either string inline (as the v1 overrides did) still has to match the TS
+ source.
+ """
+ context_descriptions = []
+ for path, lineno, text in _declared_descriptions():
+ where = f"{path}:{lineno}"
+ if text.startswith("Check for additional tools"):
+ assert text == TS_GET_MORE_TOOLS_DESCRIPTION, where
+ elif text.startswith("A description of your goal"):
+ context_descriptions.append(where)
+ assert text == TS_GET_MORE_TOOLS_CONTEXT_DESCRIPTION, where
+ assert context_descriptions, "no context description found under src/agentcat"
diff --git a/tests/test_context_parameters.py b/tests/test_context_parameters.py
deleted file mode 100644
index 6fefa53..0000000
--- a/tests/test_context_parameters.py
+++ /dev/null
@@ -1,389 +0,0 @@
-"""Unit tests for context_parameters module."""
-
-import pytest
-from copy import deepcopy
-from typing import Any
-
-from agentcat.modules.context_parameters import (
- add_context_parameter_to_tools,
- add_context_parameter_to_schema,
-)
-
-
-class TestAddContextParameterToSchema:
- """Unit tests for add_context_parameter_to_schema function."""
-
- def test_add_context_to_empty_schema(self):
- """Test adding context to an empty schema."""
- schema = {}
- custom_desc = "Test description"
-
- result = add_context_parameter_to_schema(schema, custom_desc)
-
- # Verify properties were added
- assert "properties" in result
- assert "context" in result["properties"]
- assert result["properties"]["context"]["type"] == "string"
- assert result["properties"]["context"]["description"] == custom_desc
-
- # Verify required was added
- assert "required" in result
- assert "context" in result["required"]
-
- # Verify original wasn't modified
- assert "properties" not in schema
-
- def test_add_context_to_schema_with_existing_properties(self):
- """Test adding context to schema with existing properties."""
- schema = {
- "properties": {
- "name": {"type": "string"},
- "age": {"type": "integer"}
- },
- "required": ["name"]
- }
- custom_desc = "Why this tool?"
-
- result = add_context_parameter_to_schema(schema, custom_desc)
-
- # Verify original properties still exist
- assert "name" in result["properties"]
- assert "age" in result["properties"]
-
- # Verify context was added
- assert "context" in result["properties"]
- assert result["properties"]["context"]["description"] == custom_desc
-
- # Verify required array was updated
- assert "name" in result["required"]
- assert "context" in result["required"]
- assert len(result["required"]) == 2
-
- # Verify original wasn't modified
- assert "context" not in schema["properties"]
- assert "context" not in schema["required"]
-
- def test_add_context_to_schema_with_no_required(self):
- """Test adding context when schema has no required field."""
- schema = {
- "properties": {
- "optional_field": {"type": "string"}
- }
- }
- custom_desc = "Context for optional schema"
-
- result = add_context_parameter_to_schema(schema, custom_desc)
-
- # Verify required array was created with context
- assert "required" in result
- assert result["required"] == ["context"]
-
- # Verify properties were updated
- assert "optional_field" in result["properties"]
- assert "context" in result["properties"]
-
- def test_schema_immutability(self):
- """Test that original schema is not modified."""
- original_schema = {
- "properties": {
- "field1": {"type": "string"},
- "field2": {"type": "integer"}
- },
- "required": ["field1"]
- }
-
- # Deep copy to compare later
- schema_copy = deepcopy(original_schema)
-
- result = add_context_parameter_to_schema(original_schema, "Test")
-
- # Original should be unchanged
- assert original_schema == schema_copy
-
- # Result should be different
- assert result != original_schema
- assert "context" in result["properties"]
- assert "context" not in original_schema["properties"]
-
- def test_context_already_in_required(self):
- """Test when context is already in required array."""
- schema = {
- "properties": {
- "context": {"type": "string", "description": "Existing context"}
- },
- "required": ["context"]
- }
- custom_desc = "New context description"
-
- result = add_context_parameter_to_schema(schema, custom_desc)
-
- # Context should be overwritten with new description
- assert result["properties"]["context"]["description"] == custom_desc
-
- # Required should still contain context (no duplicate)
- assert result["required"].count("context") == 1
-
- def test_empty_custom_description(self):
- """Test with empty string custom description."""
- schema = {"properties": {}}
-
- result = add_context_parameter_to_schema(schema, "")
-
- assert result["properties"]["context"]["description"] == ""
- assert result["properties"]["context"]["type"] == "string"
-
- def test_special_characters_in_description(self):
- """Test with special characters in custom description."""
- schema = {}
- special_desc = "Unicode: 🚀 Quotes: \"test\" Newline:\nTab:\t"
-
- result = add_context_parameter_to_schema(schema, special_desc)
-
- assert result["properties"]["context"]["description"] == special_desc
-
- def test_very_long_description(self):
- """Test with very long custom description."""
- schema = {}
- long_desc = "A" * 10000 # 10,000 characters
-
- result = add_context_parameter_to_schema(schema, long_desc)
-
- assert result["properties"]["context"]["description"] == long_desc
- assert len(result["properties"]["context"]["description"]) == 10000
-
- def test_nested_properties_preserved(self):
- """Test that nested/complex properties are preserved."""
- schema = {
- "properties": {
- "user": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "email": {"type": "string"}
- }
- },
- "tags": {
- "type": "array",
- "items": {"type": "string"}
- }
- },
- "required": ["user"]
- }
-
- result = add_context_parameter_to_schema(schema, "Test")
-
- # Verify nested properties are preserved
- assert "user" in result["properties"]
- assert result["properties"]["user"]["properties"]["name"]["type"] == "string"
- assert "tags" in result["properties"]
- assert result["properties"]["tags"]["type"] == "array"
-
- # Verify context was added
- assert "context" in result["properties"]
- assert "context" in result["required"]
-
-
-class TestAddContextParameterToTools:
- """Unit tests for add_context_parameter_to_tools function."""
-
- def test_empty_tools_list(self):
- """Test with empty tools list."""
- tools = []
- result = add_context_parameter_to_tools(tools, "Test description")
-
- assert result == []
-
- def test_single_tool_with_input_schema(self):
- """Test with a single tool that has inputSchema."""
- tools = [
- {
- "name": "test_tool",
- "description": "A test tool",
- "inputSchema": {
- "properties": {
- "param": {"type": "string"}
- },
- "required": ["param"]
- }
- }
- ]
- custom_desc = "Tool context"
-
- result = add_context_parameter_to_tools(tools, custom_desc)
-
- assert len(result) == 1
- assert "context" in result[0]["inputSchema"]["properties"]
- assert result[0]["inputSchema"]["properties"]["context"]["description"] == custom_desc
- assert "context" in result[0]["inputSchema"]["required"]
-
- # Original tools list should be unchanged
- assert "context" not in tools[0]["inputSchema"]["properties"]
-
- def test_tool_without_input_schema(self):
- """Test with a tool that has no inputSchema."""
- tools = [
- {
- "name": "simple_tool",
- "description": "A simple tool"
- }
- ]
-
- result = add_context_parameter_to_tools(tools, "Test")
-
- # Tool should be copied but not modified (no inputSchema)
- assert len(result) == 1
- assert result[0]["name"] == "simple_tool"
- assert "inputSchema" not in result[0]
-
- def test_multiple_tools(self):
- """Test with multiple tools of different types."""
- tools = [
- {
- "name": "tool1",
- "inputSchema": {"properties": {}}
- },
- {
- "name": "tool2",
- "inputSchema": {
- "properties": {"field": {"type": "string"}},
- "required": ["field"]
- }
- },
- {
- "name": "tool3",
- "description": "No schema"
- }
- ]
- custom_desc = "Multi-tool context"
-
- result = add_context_parameter_to_tools(tools, custom_desc)
-
- assert len(result) == 3
-
- # Tool 1: empty properties
- assert "context" in result[0]["inputSchema"]["properties"]
- assert result[0]["inputSchema"]["properties"]["context"]["description"] == custom_desc
-
- # Tool 2: existing properties
- assert "field" in result[1]["inputSchema"]["properties"]
- assert "context" in result[1]["inputSchema"]["properties"]
- assert result[1]["inputSchema"]["properties"]["context"]["description"] == custom_desc
-
- # Tool 3: no inputSchema
- assert "inputSchema" not in result[2]
-
- def test_tools_immutability(self):
- """Test that original tools list is not modified."""
- original_tools = [
- {
- "name": "test_tool",
- "inputSchema": {
- "properties": {"param": {"type": "string"}}
- }
- }
- ]
-
- tools_copy = deepcopy(original_tools)
-
- result = add_context_parameter_to_tools(original_tools, "Test")
-
- # Original should be unchanged
- assert original_tools == tools_copy
-
- # Result should be different
- assert result != original_tools
- assert "context" in result[0]["inputSchema"]["properties"]
- assert "context" not in original_tools[0]["inputSchema"]["properties"]
-
- def test_tool_with_complex_schema(self):
- """Test with a tool that has a complex schema."""
- tools = [
- {
- "name": "complex_tool",
- "inputSchema": {
- "type": "object",
- "properties": {
- "config": {
- "type": "object",
- "properties": {
- "enabled": {"type": "boolean"},
- "level": {"type": "integer", "minimum": 0, "maximum": 10}
- }
- },
- "items": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {"type": "string"},
- "value": {"type": "number"}
- }
- }
- }
- },
- "required": ["config"],
- "additionalProperties": False
- }
- }
- ]
-
- result = add_context_parameter_to_tools(tools, "Complex context")
-
- # Verify complex properties are preserved
- assert "config" in result[0]["inputSchema"]["properties"]
- assert "items" in result[0]["inputSchema"]["properties"]
- assert result[0]["inputSchema"]["additionalProperties"] == False
-
- # Verify context was added
- assert "context" in result[0]["inputSchema"]["properties"]
- assert "context" in result[0]["inputSchema"]["required"]
-
- def test_special_tool_fields_preserved(self):
- """Test that other tool fields are preserved."""
- tools = [
- {
- "name": "full_tool",
- "description": "A complete tool",
- "version": "1.0.0",
- "deprecated": False,
- "inputSchema": {"properties": {}},
- "outputSchema": {"type": "string"},
- "metadata": {"author": "test"}
- }
- ]
-
- result = add_context_parameter_to_tools(tools, "Test")
-
- # All fields should be preserved
- assert result[0]["name"] == "full_tool"
- assert result[0]["description"] == "A complete tool"
- assert result[0]["version"] == "1.0.0"
- assert result[0]["deprecated"] == False
- assert result[0]["outputSchema"] == {"type": "string"}
- assert result[0]["metadata"] == {"author": "test"}
-
- # And context should be added
- assert "context" in result[0]["inputSchema"]["properties"]
-
- def test_unicode_in_tool_names_and_descriptions(self):
- """Test tools with Unicode characters in various fields."""
- tools = [
- {
- "name": "emoji_tool_🚀",
- "description": "Tool with emojis 🎉",
- "inputSchema": {
- "properties": {
- "field_with_emoji_🌟": {"type": "string"}
- }
- }
- }
- ]
- custom_desc = "Context with emoji 🤔"
-
- result = add_context_parameter_to_tools(tools, custom_desc)
-
- # Unicode should be preserved everywhere
- assert result[0]["name"] == "emoji_tool_🚀"
- assert result[0]["description"] == "Tool with emojis 🎉"
- assert "field_with_emoji_🌟" in result[0]["inputSchema"]["properties"]
- assert result[0]["inputSchema"]["properties"]["context"]["description"] == custom_desc
\ No newline at end of file
diff --git a/tests/test_customer_owned_parameters.py b/tests/test_customer_owned_parameters.py
new file mode 100644
index 0000000..20705d6
--- /dev/null
+++ b/tests/test_customer_owned_parameters.py
@@ -0,0 +1,160 @@
+"""A customer parameter that happens to share one of AgentCat's names.
+
+`session_id`, `agent_id` and `context` are ordinary words. A task tracker, a job
+runner or a ticketing tool can already have a `session_id` of its own, and the
+injection pass is careful about it: on a name collision it logs and skips, and
+the strip spares the parameter so the customer's handler still receives it.
+
+What was missing is the third consumer. The call path read
+`arguments["session_id"]` unconditionally, so a parameter AgentCat never injected
+was consumed as the analytics handle anyway. Two things went wrong at once:
+
+- **Analytics.** Every call to that tool collapsed onto one AgentCat session
+ keyed by a customer-domain value, severed from the agent's real conversation.
+- **Privacy.** `session_id` is in `redaction.PROTECTED_FIELDS`, so that value
+ reached the wire EXEMPT from the customer's own redaction hook — and a
+ `session_id` in a customer's domain is plausibly an email, an order number or an
+ account ID.
+
+The rule this module pins: a `session_id` the customer's own schema declared is
+never read, and such calls publish **sessionless** rather than minting. Minting
+one per call on a tool that can never carry AgentCat's handle would manufacture
+a phantom session per call — noise shaped like data. Sessionless is the honest
+signal, and it resolves the moment the customer adopts `resolve_session_id`.
+
+Ownership is read off `AgentCatData.declared_session_params`, a positive record
+of what the customer declared, populated by the injection pass during listing.
+
+Runs on every server shape the installed dependency set can build, in both
+dependency sets — this is a per-flavor property, because the registry is
+populated by each adapter's own listing path.
+"""
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+ SESSION_ID_PARAM_DESCRIPTION,
+)
+
+from .test_utils import sid
+from .test_utils.flavors import CUSTOMER_SESSION_ID_DESCRIPTION, flavors
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_a_customers_own_session_id_is_never_the_handle(flavor, capture):
+ built = flavor.build("customer-owned", customer_session_id=True)
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ listed = await flavor.list_tools(client)
+ complete = next(t for t in listed if t.name == "complete_task")
+ # The schema is the customer's. A description they wrote survives (the
+ # lowlevel flavors declare one; the facades generate the schema from a
+ # typed signature and have none), and on no flavor is it replaced by
+ # AgentCat's copy telling the agent what session_id means to us.
+ described = complete.input_schema["properties"][SESSION_ID_PARAM].get(
+ "description"
+ )
+ assert described in (None, CUSTOMER_SESSION_ID_DESCRIPTION)
+ assert described != SESSION_ID_PARAM_DESCRIPTION
+
+ first = await flavor.call(
+ client, "complete_task", {"session_id": "TASK-1234", "note": "done"}
+ )
+ second = await flavor.call(
+ client, "complete_task", {"session_id": "TASK-1234", "note": "again"}
+ )
+
+ # The handler ran on the customer's value, untouched.
+ assert "completed TASK-1234: done" in first.text
+ assert first.is_error is False
+
+ events = capture
+ assert len(events) == 2
+ for event in events:
+ # Sessionless, not minted: their value is never adopted, and no
+ # phantom session is manufactured in its place.
+ assert event.session_id is None
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "foreign"
+
+ # And AgentCat never tells the agent to send `session_id=ses_…` on a tool
+ # whose `session_id` means something else — that would change what the
+ # customer's tool does — nor confirms their value back to them.
+ # (Their own tool body still echoes TASK-1234 in its result — that is their
+ # data. What must not appear is an AgentCat-authored block naming it, which
+ # is what the absence of MCP_INSTRUCTIONS_KEY asserts.)
+ for result in (first, second):
+ assert "[MCP INSTRUCTIONS]" not in result.text
+ assert MCP_INSTRUCTIONS_KEY not in (result.structured or {})
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_a_tool_agentcat_did_inject_still_supplies_normally(flavor, capture):
+ """The gate is per tool, not per server: the colliding tool above and this
+ one live on the same instance."""
+ built = flavor.build("customer-owned", customer_session_id=True)
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ listed = await flavor.list_tools(client)
+ echo = next(t for t in listed if t.name == "echo")
+ assert SESSION_ID_PARAM in echo.input_schema["properties"]
+
+ await flavor.call(client, "echo", {"text": "hi", SESSION_ID_PARAM: sid("mine")})
+ await flavor.call(
+ client, "complete_task", {"session_id": "TASK-1234", "note": "n"}
+ )
+
+ echoed, completed = capture
+ assert echoed.session_id == sid("mine")
+ assert echoed.tags[AGENTCAT_TAG_SESSION_SOURCE] == "supplied"
+ assert completed.session_id is None
+ assert completed.tags[AGENTCAT_TAG_SESSION_SOURCE] == "foreign"
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_agent_id_is_still_confirmed_on_a_colliding_tool(flavor, capture):
+ """Suppression is per handle, not per response.
+
+ A `session_id` collision skips only `session_id` injection. `agent_id` is
+ a separate branch and still lands in that tool's schema, so the agent
+ still sends one and it is still ours to confirm. Dropping the whole mirror
+ would withhold a handle AgentCat issued purely because a neighbouring one
+ belongs to the customer, and would leave agents seeing `agent_id`
+ confirmed on some tools and not others on the same server.
+ """
+ built = flavor.build("customer-owned", customer_session_id=True)
+ track(built.server, "proj_test", AgentCatOptions(enable_agent_tracking=True))
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ result = await flavor.call(
+ client,
+ "complete_task",
+ {"session_id": "TASK-1234", "note": "n", "agent_id": "opus|cc|k3n9x"},
+ )
+
+ mint = (result.structured or {}).get(MCP_INSTRUCTIONS_KEY)
+ assert mint is not None, "agent_id was withheld because session_id collided"
+ assert mint["agent_id"] == "opus|cc|k3n9x"
+ assert SESSION_ID_PARAM not in mint
+ assert "TASK-1234" not in str(mint)
+
+ (event,) = capture
+ assert event.session_id is None
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "foreign"
+ assert event.tags["agentcat_agent_id"] == "opus|cc|k3n9x"
diff --git a/tests/test_detection.py b/tests/test_detection.py
new file mode 100644
index 0000000..6889d11
--- /dev/null
+++ b/tests/test_detection.py
@@ -0,0 +1,336 @@
+"""Per-object server flavor classification (spec §8.1).
+
+The classifier decides which adapter wraps a customer's server, so a
+misclassification silently untracks a fleet or wraps the wrong handler table.
+Every double here is built from class name + module path + attribute presence
+only — exactly the signals `detect_server` is allowed to read. It must never
+import version-specific symbols, and no probe may raise into the customer's
+process.
+"""
+
+import pytest
+
+from agentcat.modules.detection import Detection, ServerFlavor, detect_server
+
+from .test_utils.flavors import FASTMCP_MAJOR, MCP_MAJOR, flavor_ids, flavors
+
+# spec §8.1 — the fingerprint keys logged for unrecognized shapes; the beacon
+# is only useful for fleet drift detection if every probe always reports.
+PROBE_KEYS = [
+ "is_fastmcp_class",
+ "has_local_provider",
+ "has_add_middleware",
+ "has_middleware",
+ "has_tool_manager",
+ "has_mcp_server_attr",
+ "has_lowlevel_server_attr",
+ "has_extensions",
+ "has_request_state_security",
+ "has_request_handlers",
+ "has_private_request_handlers",
+ "has_add_request_handler",
+ "has_request_context",
+]
+
+# Every attribute name a probe could touch.
+PROBE_NAMES = (
+ "_local_provider",
+ "add_middleware",
+ "middleware",
+ "_tool_manager",
+ "_mcp_server",
+ "_lowlevel_server",
+ "_extensions",
+ "add_extension",
+ "_request_state_security",
+ "request_handlers",
+ "_request_handlers",
+ "add_request_handler",
+ "_add_request_handler",
+ "request_context",
+)
+
+
+def make(name, module, **attrs):
+ cls = type(name, (), {})
+ cls.__module__ = module
+ obj = cls()
+ for k, v in attrs.items():
+ setattr(obj, k, v)
+ return obj
+
+
+def make_exploding(name, module, raising, **attrs):
+ """Double whose attribute ACCESS raises on `raising` names.
+
+ `hasattr` only swallows AttributeError, so an unguarded probe of a property
+ that raises anything else propagates into `track()`.
+ """
+ namespace = {}
+ for attr in raising:
+
+ def _boom(self, _attr=attr):
+ raise RuntimeError(f"probing {_attr} exploded")
+
+ namespace[attr] = property(_boom)
+ cls = type(name, (), namespace)
+ cls.__module__ = module
+ obj = cls()
+ for k, v in attrs.items():
+ setattr(obj, k, v)
+ return obj
+
+
+COMMUNITY_V3_ATTRS = {
+ "_local_provider": object(),
+ "add_middleware": lambda m: None,
+ "middleware": [],
+}
+
+
+def lowlevel_v1():
+ return make(
+ "Server",
+ "mcp.server.lowlevel.server",
+ request_handlers={},
+ request_context=None,
+ )
+
+
+def lowlevel_v2(seam="add_request_handler"):
+ return make(
+ "Server",
+ "mcp.server.lowlevel.server",
+ _request_handlers={},
+ **{seam: lambda *a: None},
+ )
+
+
+# The handler-registration seam has been spelled both ways on the 2.x line:
+# `_add_request_handler` on the development line (see the vendored checkout at
+# model-context-protocol-sdks/python-sdk), `add_request_handler` in 2.0.0. A
+# build shipping only the other spelling must not fall through to UNKNOWN —
+# that would return every lowlevel-v2 server untracked with no error raised
+# and no visible failure, which is the worst way for a fleet to drift.
+def test_lowlevel_v2_is_classified_under_either_registration_spelling():
+ for seam in ("add_request_handler", "_add_request_handler"):
+ server = lowlevel_v2(seam)
+ detected = detect_server(server)
+ assert detected.flavor is ServerFlavor.LOWLEVEL_V2, seam
+ assert detected.lowlevel is server
+ assert detected.fingerprint["has_add_request_handler"] is True, seam
+
+
+def test_mcpserver_v2_is_classified_under_either_registration_spelling():
+ for seam in ("add_request_handler", "_add_request_handler"):
+ lowlevel = lowlevel_v2(seam)
+ server = make(
+ "MCPServer",
+ "mcp.server.mcpserver.server",
+ _lowlevel_server=lowlevel,
+ _tool_manager=object(),
+ )
+ detected = detect_server(server)
+ assert detected.flavor is ServerFlavor.MCPSERVER_V2, seam
+ assert detected.lowlevel is lowlevel
+
+
+def test_community_v4_vs_v3():
+ base = COMMUNITY_V3_ATTRS
+ v3 = make("FastMCP", "fastmcp.server.server", **base)
+ v4 = make(
+ "FastMCP",
+ "fastmcp.server.server",
+ **base,
+ _extensions={},
+ add_extension=lambda e: None,
+ )
+ assert detect_server(v3).flavor is ServerFlavor.COMMUNITY_V3
+ assert detect_server(v4).flavor is ServerFlavor.COMMUNITY_V4
+
+
+# spec §8.1 — v4 is v3 plus ANY of the three discriminators, so a build that
+# grew only one of them must not fall back to the v3 middleware era.
+def test_each_v4_discriminator_alone_is_enough():
+ discriminators = (
+ {"add_extension": lambda e: None},
+ {"_extensions": {}},
+ {"_request_state_security": object()},
+ )
+ for extra in discriminators:
+ server = make("FastMCP", "fastmcp.server.server", **COMMUNITY_V3_ATTRS, **extra)
+ assert detect_server(server).flavor is ServerFlavor.COMMUNITY_V4
+
+
+def test_community_v2_unsupported():
+ v2 = make("FastMCP", "fastmcp.server", _mcp_server=object(), _tool_manager=object())
+ assert detect_server(v2).flavor is ServerFlavor.COMMUNITY_V2_UNSUPPORTED
+
+
+# The detection-order hazard: community v2 and official FastMCP v1 are
+# attribute-identical (`_mcp_server` + `_tool_manager`, class named FastMCP);
+# only the module prefix separates them. Guard each against the other so a
+# reordered or loosened prefix check fails here and not in a customer's
+# process — one direction untracks a supported server, the other adapts an
+# unsupported one.
+def test_community_v2_and_official_fastmcp_v1_are_never_confused():
+ ll1 = lowlevel_v1()
+ community = make(
+ "FastMCP", "fastmcp.server", _mcp_server=object(), _tool_manager=object()
+ )
+ official = make(
+ "FastMCP", "mcp.server.fastmcp.server", _mcp_server=ll1, _tool_manager=object()
+ )
+ assert detect_server(community).flavor is ServerFlavor.COMMUNITY_V2_UNSUPPORTED
+ assert detect_server(community).flavor is not ServerFlavor.OFFICIAL_FASTMCP_V1
+ assert detect_server(official).flavor is ServerFlavor.OFFICIAL_FASTMCP_V1
+ assert detect_server(official).flavor is not ServerFlavor.COMMUNITY_V2_UNSUPPORTED
+
+
+def test_official_flavors():
+ ll1 = lowlevel_v1()
+ ll2 = lowlevel_v2()
+ fm1 = make(
+ "FastMCP", "mcp.server.fastmcp.server", _mcp_server=ll1, _tool_manager=object()
+ )
+ ms2 = make(
+ "MCPServer",
+ "mcp.server.mcpserver.server",
+ _lowlevel_server=ll2,
+ _tool_manager=object(),
+ )
+ assert detect_server(ll1).flavor is ServerFlavor.LOWLEVEL_V1
+ d = detect_server(ll2)
+ assert d.flavor is ServerFlavor.LOWLEVEL_V2 and d.lowlevel is ll2
+ d = detect_server(fm1)
+ assert d.flavor is ServerFlavor.OFFICIAL_FASTMCP_V1 and d.lowlevel is ll1
+ d = detect_server(ms2)
+ assert d.flavor is ServerFlavor.MCPSERVER_V2 and d.lowlevel is ll2
+
+
+# spec §8.1 — a bare lowlevel v1 Server is adapted in place, so `lowlevel` is
+# the server itself rather than None.
+def test_bare_lowlevel_servers_are_their_own_lowlevel():
+ ll1 = lowlevel_v1()
+ assert detect_server(ll1).lowlevel is ll1
+
+
+# spec §8.1 — `lowlevel` is the object the adapters wrap; community flavors go
+# through middleware and unknown shapes go untracked, so neither has one.
+# A non-None value here would send an adapter at a FastMCP instance.
+def test_lowlevel_is_none_for_community_and_unknown():
+ v3 = make("FastMCP", "fastmcp.server.server", **COMMUNITY_V3_ATTRS)
+ v2 = make("FastMCP", "fastmcp.server", _mcp_server=object(), _tool_manager=object())
+ assert detect_server(v3).lowlevel is None
+ assert detect_server(v2).lowlevel is None
+ assert detect_server(object()).lowlevel is None
+
+
+def test_unknown_shape_has_fingerprint():
+ d = detect_server(object())
+ assert d.flavor is ServerFlavor.UNKNOWN and isinstance(d.fingerprint, dict)
+
+
+# The fingerprint is the payload of the fleet-drift beacon, so every probe must
+# report on every shape (including the ones that classified cleanly) and report
+# a plain bool — not a truthy handler table or a raw attribute value.
+def test_fingerprint_reports_every_probe_as_a_bool():
+ for server in (lowlevel_v1(), lowlevel_v2(), object()):
+ d = detect_server(server)
+ assert isinstance(d, Detection)
+ for key in PROBE_KEYS:
+ assert key in d.fingerprint, key
+ assert all(isinstance(v, bool) for v in d.fingerprint.values())
+ fp = detect_server(lowlevel_v1()).fingerprint
+ assert fp["has_request_handlers"] is True and fp["has_request_context"] is True
+ assert fp["is_fastmcp_class"] is False and fp["has_tool_manager"] is False
+
+
+# track() must never raise (spec §3.1), and a customer server can expose a
+# property on any probed name — lazy config, a deprecation shim, a proxy — that
+# blows up on access. hasattr() re-raises anything that is not AttributeError,
+# so every probe has to be wrapped.
+def test_probes_never_raise_and_still_classify():
+ ll1 = make_exploding(
+ "Server",
+ "mcp.server.lowlevel.server",
+ raising=(
+ "_tool_manager",
+ "_mcp_server",
+ "_lowlevel_server",
+ "_local_provider",
+ "middleware",
+ "_extensions",
+ "_request_state_security",
+ ),
+ request_handlers={},
+ request_context=None,
+ )
+ assert detect_server(ll1).flavor is ServerFlavor.LOWLEVEL_V1
+
+
+def test_a_server_that_explodes_on_every_probe_is_unknown():
+ hostile = make_exploding("Mystery", "vendor.server", raising=PROBE_NAMES)
+ d = detect_server(hostile)
+ assert d.flavor is ServerFlavor.UNKNOWN
+ assert all(isinstance(v, bool) for v in d.fingerprint.values())
+
+
+# The flavor is logged and shipped on the diagnostics beacon, so it has to
+# serialize as a string; `str, Enum` is the contract in the interface list.
+def test_flavor_is_a_string_enum_with_distinct_values():
+ assert isinstance(ServerFlavor.UNKNOWN, str)
+ values = [f.value for f in ServerFlavor]
+ assert len(set(values)) == len(values)
+ assert all(isinstance(v, str) and v for v in values)
+
+
+# ── real SDK objects ─────────────────────────────────────────────────────────
+#
+# Everything above is a synthetic double, deliberately: the doubles are how the
+# classifier's RULES get tested — shapes no installed SDK produces, probes that
+# raise, a fingerprint that must report every key. But a double encodes the
+# same assumptions the classifier does, so on its own the suite cannot notice
+# an upstream rename: FastMCP moving `_local_provider`, or `MCPServer` renaming
+# `_lowlevel_server`, would leave every test above green and every real server
+# UNKNOWN — silently untracked, in the field.
+#
+# These two run the classifier against the objects the SDKs actually build.
+
+
+class TestRealServerObjects:
+ """The classifier, against real servers from the installed dependency set."""
+
+ def test_this_era_can_build_every_shape_it_is_supposed_to(self):
+ """Nothing dropped out of the harness the cross-flavor suites use.
+
+ `flavors()` is what those suites parametrize over, so a shape missing
+ from it is coverage that vanishes without a failure anywhere. The era
+ tables are restated here rather than imported, so the two have to
+ agree.
+ """
+ official = {
+ 1: {"official-fastmcp-v1", "lowlevel-v1"},
+ 2: {"mcpserver-v2", "lowlevel-v2"},
+ }[MCP_MAJOR]
+ community = {f"community-v{FASTMCP_MAJOR}"} if FASTMCP_MAJOR else set()
+ assert set(flavor_ids()) == official | community
+
+ @pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+ def test_a_real_server_classifies_as_the_flavor_it_is(self, flavor):
+ """The one assertion a synthetic double structurally cannot make."""
+ server = flavor.build("detection").server
+ detection = detect_server(server)
+
+ assert detection.flavor is flavor.flavor
+ # The object the adapter is handed: the wrapped lowlevel server for the
+ # official facades, the server itself for a bare lowlevel one, and
+ # None for the community flavors, which are adapted by middleware.
+ if flavor.flavor in (ServerFlavor.COMMUNITY_V3, ServerFlavor.COMMUNITY_V4):
+ assert detection.lowlevel is None
+ else:
+ assert detection.lowlevel is not None
+ # Every probe answered on a real object — none raised, none went
+ # missing — which is what the fleet-drift beacon carries.
+ assert sorted(detection.fingerprint) == sorted(PROBE_KEYS)
+ assert all(isinstance(v, bool) for v in detection.fingerprint.values())
diff --git a/tests/test_diagnostics_attributes.py b/tests/test_diagnostics_attributes.py
index e13890b..02070a9 100644
--- a/tests/test_diagnostics_attributes.py
+++ b/tests/test_diagnostics_attributes.py
@@ -1,5 +1,7 @@
"""Tests for static OTLP resource attributes (identity + environment)."""
+import importlib.metadata
+
import pytest
from agentcat.modules import diagnostics
@@ -56,3 +58,17 @@ def test_sdk_and_environment_metadata_present():
assert attrs.get("os.type")
assert attrs.get("process.runtime.name")
assert attrs.get("process.runtime.version")
+
+
+def test_mcp_sdk_version_attributes():
+ """Both MCP SDK distributions are reported when installed, omitted when not."""
+ diagnostics.init_diagnostics("proj_1")
+ attrs = _attrs()
+ assert attrs.get("agentcat.mcp_sdk.version") == importlib.metadata.version("mcp")
+
+ try:
+ fastmcp_version = importlib.metadata.version("fastmcp")
+ except importlib.metadata.PackageNotFoundError:
+ assert "agentcat.fastmcp_sdk.version" not in attrs
+ else:
+ assert attrs.get("agentcat.fastmcp_sdk.version") == fastmcp_version
diff --git a/tests/test_diagnostics_export.py b/tests/test_diagnostics_export.py
index 7da2e6b..d731147 100644
--- a/tests/test_diagnostics_export.py
+++ b/tests/test_diagnostics_export.py
@@ -50,10 +50,15 @@ def test_flush_swallows_post_errors():
with patch(
"agentcat.modules.diagnostics.requests.post",
side_effect=RuntimeError("network down"),
- ):
+ ) as mock_post:
# Must not raise.
diagnostics.flush_diagnostics()
+ # ...for the right reason. Without this, a flush that never posted at all
+ # also "swallows the error", and this test would be green on a diagnostics
+ # path that had stopped exporting entirely.
+ mock_post.assert_called()
+
def test_no_post_when_disabled():
diagnostics.init_diagnostics("proj_1", disabled=True)
@@ -70,3 +75,64 @@ def test_no_post_when_buffer_empty():
with patch("agentcat.modules.diagnostics.requests.post") as mock_post:
diagnostics.flush_diagnostics()
mock_post.assert_not_called()
+
+
+# ── the exit-time flush is hard-bounded (audit finding 13) ───────────────────
+
+
+def test_flush_at_exit_with_empty_buffer_never_posts():
+ """Nothing buffered: the exit hook returns instantly, no lock, no POST."""
+ diagnostics.init_diagnostics("proj_1")
+
+ with patch("agentcat.modules.diagnostics.requests.post") as mock_post:
+ diagnostics._flush_at_exit()
+ mock_post.assert_not_called()
+
+
+def test_flush_at_exit_posts_with_a_2s_timeout():
+ """Buffered records flush at exit with the tightened 2s cap, not the 5s
+ in-process default — customer shutdown is never held longer than that."""
+ diagnostics.init_diagnostics("proj_1")
+ write_to_log("Warning: buffered at exit")
+
+ with patch("agentcat.modules.diagnostics.requests.post") as mock_post:
+ diagnostics._flush_at_exit()
+ mock_post.assert_called_once()
+ assert mock_post.call_args.kwargs["timeout"] == 2.0
+
+
+def test_flush_default_timeout_is_still_5s():
+ diagnostics.init_diagnostics("proj_1")
+ write_to_log("Warning: buffered in process")
+
+ with patch("agentcat.modules.diagnostics.requests.post") as mock_post:
+ diagnostics.flush_diagnostics()
+ assert mock_post.call_args.kwargs["timeout"] == 5.0
+
+
+def test_flush_at_exit_swallows_post_failures():
+ diagnostics.init_diagnostics("proj_1")
+ write_to_log("Warning: buffered at exit")
+
+ with patch(
+ "agentcat.modules.diagnostics.requests.post",
+ side_effect=RuntimeError("network gone"),
+ ):
+ diagnostics._flush_at_exit() # must not raise
+
+
+def test_the_sdk_has_exactly_two_bounded_atexit_hooks():
+ """The no-drain-at-exit decision, pinned: two bounded atexit hooks total —
+ the diagnostics beacon (~2s cap, skipped when empty) and the event-queue
+ worker stop (~1s join budget, sends nothing). Neither drains events."""
+ import pathlib
+
+ import agentcat
+
+ src_root = pathlib.Path(agentcat.__file__).parent
+ registrations = []
+ for path in src_root.rglob("*.py"):
+ text = path.read_text()
+ if "atexit.register" in text:
+ registrations.append(path.name)
+ assert sorted(registrations) == ["diagnostics.py", "event_queue.py"], registrations
diff --git a/tests/test_diagnostics_sink.py b/tests/test_diagnostics_sink.py
index a2fbdfe..9bf3794 100644
--- a/tests/test_diagnostics_sink.py
+++ b/tests/test_diagnostics_sink.py
@@ -20,9 +20,10 @@ def test_sink_receives_every_entry():
assert len(seen) == 1
assert "hello world" in seen[0]
- # Sink gets the timestamped, newline-free entry.
+ # Sink gets the timestamped, newline-free entry, version suffix included.
assert seen[0].startswith("[")
assert "\n" not in seen[0]
+ assert "agentcat=" in seen[0]
def test_sink_raising_never_breaks_write_to_log():
diff --git a/tests/test_diagnostics_test_env.py b/tests/test_diagnostics_test_env.py
index 2f257af..22fb243 100644
--- a/tests/test_diagnostics_test_env.py
+++ b/tests/test_diagnostics_test_env.py
@@ -26,10 +26,11 @@ def test_track_does_not_enable_diagnostics_in_pytest(monkeypatch):
monkeypatch.delenv("DISABLE_DIAGNOSTICS", raising=False)
with patch("agentcat.modules.diagnostics.requests.post") as mock_post:
- # track() runs init_diagnostics before validating the server, so even
- # the error path would latch diagnostics on without the guard.
- with pytest.raises(TypeError):
- agentcat.track(object(), "proj_test_env")
+ # track() runs init_diagnostics before inspecting the server, so even
+ # the unrecognized-shape path would latch diagnostics on without the
+ # guard. track() itself never raises: the object comes back untracked.
+ sentinel = object()
+ assert agentcat.track(sentinel, "proj_test_env") is sentinel
assert diagnostics.is_diagnostics_enabled() is False
diagnostics.flush_diagnostics()
diff --git a/tests/test_dynamic_tracking.py b/tests/test_dynamic_tracking.py
index 6b985c3..392c37b 100644
--- a/tests/test_dynamic_tracking.py
+++ b/tests/test_dynamic_tracking.py
@@ -1,21 +1,35 @@
-"""Tests for the dynamic tracking system."""
+"""Tools registered at any point in a server's life are tracked.
-import asyncio
-from datetime import datetime
-from typing import Any, List
+v1 achieved this by monkey-patching FastMCP's ToolManager. v2 intercepts one
+level down, at the protocol handlers, and the SDK's own `tools/list` handler
+reads the live tool manager — so late registrations are picked up for free and
+every assertion here runs through a real client rather than a FastMCP-level
+method call.
+"""
+
+from typing import Any
import pytest
-from mcp.server.fastmcp import FastMCP
-from mcp.server import Server
from mcp import Tool
+from mcp.server import Server
+from mcp.server.fastmcp import FastMCP
+from mcp.types import TextContent
from agentcat import track
+from agentcat.modules.internal import get_server_tracking_data, reset_all_tracking_data
from agentcat.types import AgentCatOptions
-from agentcat.modules.internal import (
- get_server_tracking_data,
- reset_all_tracking_data,
- get_tool_timeline,
-)
+
+from .test_utils.client import create_test_client
+from .test_utils.delivery import record_delivered_arguments
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
class TestDynamicTracking:
@@ -23,308 +37,195 @@ class TestDynamicTracking:
@pytest.fixture(autouse=True)
def setup(self):
- """Reset the tracker before each test."""
reset_all_tracking_data()
yield
reset_all_tracking_data()
@pytest.fixture
def fastmcp_server(self):
- """Create a FastMCP server instance."""
return FastMCP("test-server")
@pytest.fixture
def lowlevel_server(self):
- """Create a low-level MCP server instance."""
return Server("test-server")
@pytest.mark.asyncio
- async def test_dynamic_tracking_fastmcp_early_registration(self, fastmcp_server):
- """Test that tools registered before track() are tracked and work correctly."""
+ async def test_early_registration_is_listed_and_traced(
+ self, fastmcp_server, capture
+ ):
+ """A tool registered before track() works and publishes an event."""
- # Register tools before tracking
@fastmcp_server.tool()
def early_tool(x: int) -> str:
return str(x)
- # Enable tracking (dynamic mode is now always on)
track(fastmcp_server, "test-project")
- # Test the tool actually works
- result, _ = await fastmcp_server.call_tool("early_tool", {"x": 42})
- assert result[0].text == "42", f"Expected '42', got {result[0].text}"
+ async with create_test_client(fastmcp_server) as client:
+ listed = await client.list_tools()
+ assert "early_tool" in [t.name for t in listed.tools]
- # Also test with different value
- result2, _ = await fastmcp_server.call_tool("early_tool", {"x": 999})
- assert result2[0].text == "999", f"Expected '999', got {result2[0].text}"
+ result = await client.call_tool("early_tool", {"x": 42})
+ assert result.content[0].text == "42"
- # Verify tool is tracked
- data = get_server_tracking_data(fastmcp_server)
- assert data and "early_tool" in data.tool_registry
- assert data.tool_registry["early_tool"].tracked
+ assert [e.resource_name for e in capture] == ["early_tool"]
+ assert get_server_tracking_data(fastmcp_server) is not None
@pytest.mark.asyncio
- async def test_dynamic_tracking_fastmcp_late_registration(self, fastmcp_server):
- """Test that tools registered after track() are tracked with dynamic mode and work correctly."""
- # Enable tracking first (dynamic mode is now always on)
+ async def test_late_registration_is_listed_and_traced(
+ self, fastmcp_server, capture
+ ):
+ """A tool registered AFTER track() is picked up with no re-tracking."""
track(fastmcp_server, "test-project")
- # Register tool after tracking
@fastmcp_server.tool()
def late_tool(x: int) -> str:
return str(x)
- # Test the tool actually works
- result, _ = await fastmcp_server.call_tool("late_tool", {"x": 123})
- assert result[0].text == "123", f"Expected '123', got {result[0].text}"
+ async with create_test_client(fastmcp_server) as client:
+ listed = await client.list_tools()
+ late = next(t for t in listed.tools if t.name == "late_tool")
+ # Late arrivals get the same injection as anything registered early.
+ properties = list(late.inputSchema["properties"])
+ assert properties == ["x", "session_id", "context"]
- # Test with another value
- result2, _ = await fastmcp_server.call_tool("late_tool", {"x": -456})
- assert result2[0].text == "-456", f"Expected '-456', got {result2[0].text}"
+ result = await client.call_tool(
+ "late_tool", {"x": 123, "context": "late registration"}
+ )
+ assert result.content[0].text == "123"
- # Verify tool is tracked
- data = get_server_tracking_data(fastmcp_server)
- assert data and "late_tool" in data.tool_registry
- assert data.tool_registry["late_tool"].tracked
+ assert [e.resource_name for e in capture] == ["late_tool"]
+ assert capture[0].user_intent == "late registration"
@pytest.mark.asyncio
- async def test_late_registration_always_tracked(self, fastmcp_server):
- """Test that late registrations are always tracked and function correctly."""
- # Enable tracking
+ async def test_late_registration_without_a_prior_list_still_strips(
+ self, fastmcp_server, capture
+ ):
+ """A call that lands before any tools/list rebuilds the strip registry."""
track(fastmcp_server, "test-project")
- # Register tool after tracking
@fastmcp_server.tool()
- def late_tool_always_tracked(x: int) -> str:
- return str(x)
-
- # Test the tool works correctly
- result, _ = await fastmcp_server.call_tool("late_tool_always_tracked", {"x": 777})
- assert result[0].text == "777", f"Expected '777', got {result[0].text}"
-
- # Test with zero
- result2, _ = await fastmcp_server.call_tool("late_tool_always_tracked", {"x": 0})
- assert result2[0].text == "0", f"Expected '0', got {result2[0].text}"
-
- # Check that it's tracked
- data = get_server_tracking_data(fastmcp_server)
- assert data and "late_tool_always_tracked" in data.tool_registry
- assert data.tool_registry["late_tool_always_tracked"].tracked
-
- @pytest.mark.asyncio
- async def test_dynamic_tool_execution_tracking(self, fastmcp_server):
- """Test that dynamically added tools are tracked during execution and return correct results."""
- # Enable tracking (dynamic is now always on)
- track(fastmcp_server, "test-project")
-
- # Add tool after tracking
- @fastmcp_server.tool()
- async def dynamic_tool(x: int) -> str:
+ def rebuilt_tool(x: int) -> str:
return f"Result: {x}"
- # Call the tool and verify result
- result, _ = await fastmcp_server.call_tool("dynamic_tool", {"x": 42})
- assert result[0].text == "Result: 42", f"Expected 'Result: 42', got {result[0].text}"
-
- # Test with different value
- result2, _ = await fastmcp_server.call_tool("dynamic_tool", {"x": 100})
- assert result2[0].text == "Result: 100", f"Expected 'Result: 100', got {result2[0].text}"
+ # The proof that the rebuild happened has to be read at the manager:
+ # `rebuilt_tool` is typed, and an un-stripped `context` would be dropped
+ # there silently rather than failing the call.
+ seen: list[tuple[str, dict]] = []
+ record_delivered_arguments(fastmcp_server._tool_manager, seen)
- # Test with negative value
- result3, _ = await fastmcp_server.call_tool("dynamic_tool", {"x": -5})
- assert result3[0].text == "Result: -5", f"Expected 'Result: -5', got {result3[0].text}"
+ async with create_test_client(fastmcp_server) as client:
+ # No list_tools first: the registry has to be rebuilt on demand.
+ result = await client.call_tool(
+ "rebuilt_tool", {"x": 42, "context": "no listing yet"}
+ )
- # Verify tracking
- data = get_server_tracking_data(fastmcp_server)
- assert data and "dynamic_tool" in data.tool_registry
- assert "dynamic_tool" in data.wrapped_tools
- assert data.tool_registry["dynamic_tool"].tracked
+ assert result.isError is False
+ assert result.content[0].text == "Result: 42"
+ assert seen == [("rebuilt_tool", {"x": 42})]
+ # The EVENT still carries the raw pre-strip arguments, by design.
+ assert capture[0].parameters["arguments"]["context"] == "no listing yet"
@pytest.mark.asyncio
- async def test_tool_timeline(self, fastmcp_server):
- """Test tool registration timeline tracking and that both tools work."""
-
- # Register first tool
- @fastmcp_server.tool()
- def tool1(x: int) -> str:
- return str(x)
-
- # Enable tracking
- options = AgentCatOptions()
- track(fastmcp_server, "test-project", options)
-
- # Register second tool
- @fastmcp_server.tool()
- def tool2(x: int) -> str:
- return str(x * 2) # Different logic to distinguish
-
- # Test both tools work correctly
- result1, _ = await fastmcp_server.call_tool("tool1", {"x": 5})
- assert result1[0].text == "5", f"tool1: Expected '5', got {result1[0].text}"
-
- result2, _ = await fastmcp_server.call_tool("tool2", {"x": 5})
- assert result2[0].text == "10", f"tool2: Expected '10', got {result2[0].text}"
-
- # Test with different values
- result3, _ = await fastmcp_server.call_tool("tool1", {"x": 100})
- assert result3[0].text == "100", f"tool1: Expected '100', got {result3[0].text}"
-
- result4, _ = await fastmcp_server.call_tool("tool2", {"x": 100})
- assert result4[0].text == "200", f"tool2: Expected '200', got {result4[0].text}"
-
- # Get timeline
- timeline = get_tool_timeline(fastmcp_server)
-
- # Should have both tools in timeline
- tool_names = [t["name"] for t in timeline]
- assert "tool1" in tool_names
- assert "tool2" in tool_names
-
- # Timeline should be sorted by registration time
- for i in range(1, len(timeline)):
- assert timeline[i]["registered_at"] >= timeline[i - 1]["registered_at"]
-
- @pytest.mark.asyncio
- async def test_context_injection_with_dynamic_tracking(self, fastmcp_server):
- """Test that context injection works with dynamic tracking and tool still functions."""
- # Enable tracking with context
- options = AgentCatOptions(enable_tool_call_context=True)
- track(fastmcp_server, "test-project", options)
-
- # Add tool after tracking
- @fastmcp_server.tool()
- def context_tool(x: int) -> str:
- return str(x * 3) # Multiply by 3 to verify logic
-
- # Test the tool works with context parameter
- result, _ = await fastmcp_server.call_tool(
- "context_tool",
- {"x": 7, "context": "Testing context injection"}
- )
- assert result[0].text == "21", f"Expected '21', got {result[0].text}"
-
- # Test without context (should still work as context is stripped)
- result2, _ = await fastmcp_server.call_tool("context_tool", {"x": 10})
- assert result2[0].text == "30", f"Expected '30', got {result2[0].text}"
-
- # Test with empty context
- result3, _ = await fastmcp_server.call_tool(
- "context_tool",
- {"x": 4, "context": ""}
+ async def test_report_missing_tool_answers(self, fastmcp_server, capture):
+ """get_more_tools is advertised and answers, without being registered."""
+ track(
+ fastmcp_server,
+ "test-project",
+ AgentCatOptions(enable_report_missing=True),
)
- assert result3[0].text == "12", f"Expected '12', got {result3[0].text}"
-
- # List tools should show context parameter
- tools = await fastmcp_server.list_tools()
-
- # Find our tool
- context_tool_def = next((t for t in tools if t.name == "context_tool"), None)
- assert context_tool_def is not None
-
- # Should have context in parameters
- if hasattr(context_tool_def, "inputSchema"):
- schema = context_tool_def.inputSchema
- else:
- schema = context_tool_def.parameters
- if schema and "properties" in schema:
- assert "context" in schema["properties"]
+ async with create_test_client(fastmcp_server) as client:
+ listed = await client.list_tools()
+ assert [t.name for t in listed.tools] == ["get_more_tools"]
- @pytest.mark.asyncio
- async def test_report_missing_tool_with_dynamic_tracking(self, fastmcp_server):
- """Test that the get_more_tools tool is added with dynamic tracking and works correctly."""
- # Enable tracking with report_missing
- options = AgentCatOptions(enable_report_missing=True)
- track(fastmcp_server, "test-project", options)
-
- # List tools
- tools = await fastmcp_server.list_tools()
-
- # Should include get_more_tools
- tool_names = [t.name for t in tools]
- assert "get_more_tools" in tool_names
-
- # Test calling get_more_tools
- result, _ = await fastmcp_server.call_tool(
- "get_more_tools",
- {"context": "Need a tool to translate text"}
- )
- # Should return the standard "Unfortunately" message
- result_text = result[0].text if result else ""
- assert "Unfortunately" in result_text, f"Expected 'Unfortunately' in result, got: {result_text}"
- assert "tool list" in result_text.lower(), f"Expected 'tool list' in result, got: {result_text}"
+ result = await client.call_tool(
+ "get_more_tools", {"context": "Need a tool to translate text"}
+ )
+ text = "".join(c.text for c in result.content if hasattr(c, "text"))
+ assert "Unfortunately" in text
+ assert "tool list" in text.lower()
- # Test with empty context
- result2, _ = await fastmcp_server.call_tool("get_more_tools", {"context": ""})
- result2_text = result2[0].text if result2 else ""
- assert "Unfortunately" in result2_text, f"Expected 'Unfortunately' in result, got: {result2_text}"
-
- # Test with missing context parameter - should raise validation error
- # since context is a required parameter
- with pytest.raises(Exception, match="(?i)required"):
- await fastmcp_server.call_tool("get_more_tools", {})
+ # It never reaches the customer's tool manager, so it cannot collide
+ # with a tool they register later.
+ registered = [t.name for t in await fastmcp_server.list_tools()]
+ assert "get_more_tools" not in registered
@pytest.mark.asyncio
- async def test_lowlevel_server_dynamic_tracking(self, lowlevel_server):
- """Test dynamic tracking with low-level server and verify tool execution."""
+ async def test_lowlevel_server_tracking(self, lowlevel_server, capture):
+ """A bare lowlevel Server gets the same treatment as FastMCP."""
- # Define tool handler
@lowlevel_server.list_tools()
- async def list_tools() -> List[Tool]:
+ async def list_tools() -> list[Tool]:
return [
Tool(
name="lowlevel_tool",
description="A low-level tool",
- inputSchema={"type": "object", "properties": {"value": {"type": "string"}}},
+ inputSchema={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
)
]
@lowlevel_server.call_tool()
- async def call_tool(name: str, arguments: dict) -> List[Any]:
+ async def call_tool(name: str, arguments: dict) -> list[Any]:
if name == "lowlevel_tool":
- value = arguments.get("value", "default")
- return [{"type": "text", "text": f"Low-level result: {value}"}]
+ # A lowlevel handler is handed the raw dict, so unlike a typed
+ # FastMCP body it CAN police its own arguments — and it must:
+ # the tool's declared schema has no `additionalProperties:
+ # false`, so the SDK's jsonschema pass accepts extras happily
+ # and an un-stripped `agent_id` would slip through unnoticed.
+ unexpected = sorted(set(arguments) - {"value"})
+ if unexpected:
+ raise ValueError(f"unexpected arguments: {unexpected}")
+ return [
+ TextContent(
+ type="text",
+ text=f"Low-level result: {arguments.get('value', 'default')}",
+ )
+ ]
raise ValueError(f"Unknown tool: {name}")
- # Enable dynamic tracking
- options = AgentCatOptions()
- track(lowlevel_server, "test-project", options)
-
- # List tools to trigger tracking
- tools = await list_tools()
- assert len(tools) == 1
- assert tools[0].name == "lowlevel_tool"
-
- # Test tool execution
- result = await call_tool("lowlevel_tool", {"value": "test123"})
- assert result[0]["type"] == "text"
- assert result[0]["text"] == "Low-level result: test123"
+ track(
+ lowlevel_server,
+ "test-project",
+ AgentCatOptions(enable_agent_tracking=True),
+ )
- # Test with empty arguments
- result2 = await call_tool("lowlevel_tool", {})
- assert result2[0]["type"] == "text"
- assert result2[0]["text"] == "Low-level result: default"
+ async with create_test_client(lowlevel_server) as client:
+ listed = await client.list_tools()
+ tool = next(t for t in listed.tools if t.name == "lowlevel_tool")
+ assert list(tool.inputSchema["properties"]) == [
+ "value",
+ "session_id",
+ "agent_id",
+ "context",
+ ]
+ # session_id is the one injected param that is never required.
+ assert tool.inputSchema["required"] == ["agent_id", "context"]
- # Test unknown tool raises error
- with pytest.raises(ValueError, match="Unknown tool: nonexistent"):
- await call_tool("nonexistent", {})
+ # The handler above rejects anything but `value`, so this call
+ # only succeeds if both injected parameters were stripped.
+ result = await client.call_tool(
+ "lowlevel_tool",
+ {"value": "test123", "agent_id": "a|b|c", "context": "why"},
+ )
+ assert result.isError is False, result.content
+ assert result.content[0].text == "Low-level result: test123"
- # Verify tracking setup
- data = get_server_tracking_data(lowlevel_server)
- assert data and data.tracker_initialized
+ assert capture[0].tags["agentcat_agent_id"] == "a|b|c"
+ assert get_server_tracking_data(lowlevel_server) is not None
@pytest.mark.asyncio
- async def test_multiple_servers_isolation(self):
- """Test that multiple servers can be tracked independently and both function correctly."""
+ async def test_multiple_servers_isolation(self, capture):
+ """Two tracked servers keep separate tools, options and projects."""
server1 = FastMCP("server1")
server2 = FastMCP("server2")
- # Track both servers
- options = AgentCatOptions()
- track(server1, "project1", options)
- track(server2, "project2", options)
+ track(server1, "project1", AgentCatOptions(enable_report_missing=False))
+ track(server2, "project2", AgentCatOptions(enable_report_missing=True))
- # Add tools to each server
@server1.tool()
def server1_tool(x: int) -> str:
return f"Server1: {x}"
@@ -333,35 +234,25 @@ def server1_tool(x: int) -> str:
def server2_tool(x: int) -> str:
return f"Server2: {x}"
- # Test server1 tool works correctly
- result1, _ = await server1.call_tool("server1_tool", {"x": 10})
- assert result1[0].text == "Server1: 10", f"Expected 'Server1: 10', got {result1[0].text}"
-
- result1b, _ = await server1.call_tool("server1_tool", {"x": 25})
- assert result1b[0].text == "Server1: 25", f"Expected 'Server1: 25', got {result1b[0].text}"
-
- # Test server2 tool works correctly
- result2, _ = await server2.call_tool("server2_tool", {"x": 20})
- assert result2[0].text == "Server2: 20", f"Expected 'Server2: 20', got {result2[0].text}"
-
- result2b, _ = await server2.call_tool("server2_tool", {"x": 50})
- assert result2b[0].text == "Server2: 50", f"Expected 'Server2: 50', got {result2b[0].text}"
-
- # Verify tools don't cross-contaminate (server1 shouldn't have server2's tool)
- with pytest.raises(Exception): # Should raise some error when tool not found
- await server1.call_tool("server2_tool", {"x": 1})
-
- with pytest.raises(Exception): # Should raise some error when tool not found
- await server2.call_tool("server1_tool", {"x": 1})
+ async with create_test_client(server1) as client:
+ assert [t.name for t in (await client.list_tools()).tools] == [
+ "server1_tool"
+ ]
+ result = await client.call_tool("server1_tool", {"x": 10})
+ assert result.content[0].text == "Server1: 10"
+ assert (await client.call_tool("server2_tool", {"x": 1})).isError is True
+
+ async with create_test_client(server2) as client:
+ assert sorted(t.name for t in (await client.list_tools()).tools) == [
+ "get_more_tools",
+ "server2_tool",
+ ]
+ result = await client.call_tool("server2_tool", {"x": 20})
+ assert result.content[0].text == "Server2: 20"
- # Verify both tools are tracked separately
- data1 = get_server_tracking_data(server1)
- data2 = get_server_tracking_data(server2)
- assert data1 and "server1_tool" in data1.tool_registry
- assert data2 and "server2_tool" in data2.tool_registry
- # NOTE: There's currently cross-contamination between servers
- # This is a known issue where tools from different servers
- # can appear in each other's registries
+ assert get_server_tracking_data(server1).project_id == "project1"
+ assert get_server_tracking_data(server2).project_id == "project2"
+ assert {e.project_id for e in capture} == {"project1", "project2"}
if __name__ == "__main__":
diff --git a/tests/test_event_capture_completeness.py b/tests/test_event_capture_completeness.py
index 5e4cd93..0a8a8f1 100644
--- a/tests/test_event_capture_completeness.py
+++ b/tests/test_event_capture_completeness.py
@@ -35,7 +35,7 @@ async def test_event_contains_all_basic_fields(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -73,6 +73,10 @@ def capture_event(publish_event_request):
assert event.parameters is not None
assert event.parameters.get("arguments") == {"text": "Test todo"}
+ # session_id now carries the task handle, minted on this first call.
+ assert event.session_id.startswith("ses_")
+ assert event.tags["agentcat_session_id_source"] == "minted"
+
# Verify event has its own ID
assert event.id is not None
assert event.id.startswith("evt_")
@@ -84,7 +88,7 @@ async def test_event_contains_client_info(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -117,7 +121,7 @@ async def test_event_contains_server_info(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -148,7 +152,7 @@ async def test_event_contains_sdk_info(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -180,7 +184,7 @@ async def test_event_contains_user_intent_from_context(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -214,7 +218,7 @@ def capture_event(publish_event_request):
== "User wants to add a reminder to buy groceries for dinner"
)
- # Context should be stripped from arguments
+ # The event records the call as the agent made it: raw, unstripped.
assert event.parameters["arguments"] == {
"text": "Buy groceries",
"context": "User wants to add a reminder to buy groceries for dinner",
@@ -226,7 +230,7 @@ async def test_event_contains_actor_info_after_identify(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -263,12 +267,12 @@ def identify_fn(request, context):
}
@pytest.mark.asyncio
- async def test_multiple_event_types_capture_all_fields(self):
- """Test that different event types all capture required fields."""
+ async def test_only_tool_call_events_are_published(self):
+ """Only mcp:tools/call events exist in v2, and each is fully stamped."""
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -281,14 +285,15 @@ def capture_event(publish_event_request):
track(server, "test_project", options)
async with create_test_client(server) as client:
- # Generate various event types
- await client.list_tools() # mcp:tools/list
- await client.call_tool("add_todo", {"text": "Test"}) # mcp:tools/call
- await client.call_tool("list_todos") # Another tool call
+ # tools/list is intercepted for schema injection only; v2 publishes
+ # no event for it, and none for initialize or identify either.
+ await client.list_tools()
+ await client.call_tool("add_todo", {"text": "Test"})
+ await client.call_tool("list_todos")
time.sleep(1.0)
- # Check all captured events
- assert len(captured_events) >= 3
+ assert {e.event_type for e in captured_events} == {"mcp:tools/call"}
+ assert len(captured_events) == 2
# Verify each event has all required fields
for event in captured_events:
@@ -313,7 +318,7 @@ async def test_event_ids_are_unique(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -334,6 +339,10 @@ def capture_event(publish_event_request):
# Extract all event IDs
event_ids = [e.id for e in captured_events]
+ # Five calls, five events. Without this the uniqueness check below is
+ # `0 == 0` on an empty list and the format loop never runs.
+ assert len(event_ids) == 5
+
# All IDs should be unique
assert len(event_ids) == len(set(event_ids)), "Event IDs are not unique"
@@ -348,7 +357,7 @@ async def test_event_duration_is_calculated(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -376,54 +385,13 @@ def capture_event(publish_event_request):
assert event.duration >= 0 # Should be non-negative
assert event.duration < 10000 # Should be less than 10 seconds
- @pytest.mark.skip(reason="Initialization event tracking is not implemented")
- @pytest.mark.asyncio
- async def test_initialization_event_capture(self):
- """Test that initialization events are captured with all fields."""
- mock_api_client = MagicMock()
- captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
-
- server = create_todo_server()
- options = AgentCatOptions(enable_tracing=True)
- track(server, "test_project", options)
-
- # Creating the client triggers initialization
- async with create_test_client(server) as client:
- # Just need to wait for events to be processed
- time.sleep(1.0)
-
- # Find initialization event
- init_events = [e for e in captured_events if e.event_type == "mcp:initialize"]
- assert len(init_events) > 0
-
- event = init_events[0]
-
- # Verify all fields are present
- assert event.project_id == "test_project"
- assert event.id is not None
- assert event.timestamp is not None
- assert event.server_name == "todo-server"
- assert event.server_version == "1.0.0"
- assert event.client_name == "test-client"
- assert event.client_version == "1.0.0"
- assert event.sdk_language is not None
- assert event.agentcat_version is not None
-
@pytest.mark.asyncio
async def test_identify_function_integration(self):
"""Test that custom identify function affects event actor info."""
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -433,8 +401,9 @@ def capture_event(publish_event_request):
# Custom identify function
def custom_identify(request, server):
- # Extract user info from tool arguments
- arguments = request.params.arguments
+ # Extract user info from tool arguments. v2 hands every hook the
+ # request PARAMS, so `.arguments` is one hop, not two.
+ arguments = request.arguments
if "user_id" in arguments:
return UserIdentity(
user_id=arguments["user_id"],
@@ -475,7 +444,7 @@ async def test_server_error_capture_in_event(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
diff --git a/tests/test_event_queue.py b/tests/test_event_queue.py
index 1e5b987..fe3b145 100644
--- a/tests/test_event_queue.py
+++ b/tests/test_event_queue.py
@@ -1,7 +1,6 @@
"""Test event queue functionality."""
import queue
-import signal
import threading
import time
from datetime import datetime, timezone
@@ -9,14 +8,14 @@
from agentcat.modules.event_queue import EventQueue, publish_event
from agentcat.modules.logging import write_to_log
-from agentcat.types import Event, AgentCatData, AgentCatOptions, SessionInfo, UnredactedEvent
+from agentcat.types import Event, AgentCatData, AgentCatOptions, UnredactedEvent
class TestEventQueue:
"""Test EventQueue class."""
def test_init(self):
- """Test EventQueue initialization."""
+ """Test EventQueue initialization: construction starts no threads."""
eq = EventQueue()
assert isinstance(eq.queue, queue.Queue)
@@ -26,8 +25,33 @@ def test_init(self):
assert eq.concurrency > 0
assert eq._shutdown is False
assert isinstance(eq._shutdown_event, threading.Event)
- assert eq.worker_thread.is_alive()
- assert eq.worker_thread.daemon is True
+ # Workers are lazy: nothing runs until the first add(), so importing
+ # the module (which constructs the global queue) is thread-safe.
+ assert eq._workers == []
+ assert eq._workers_started is False
+
+ def test_workers_start_lazily_and_are_daemon(self):
+ """First add() starts exactly `concurrency` daemon workers."""
+ eq = EventQueue()
+ eq._process_event = lambda event: None # never hit the network
+
+ event = UnredactedEvent(
+ id="test-id",
+ event_type="mcp:tools/call",
+ project_id="project-123",
+ session_id="session-123",
+ timestamp=datetime.now(timezone.utc),
+ )
+ eq.add(event)
+
+ assert eq._workers_started is True
+ assert len(eq._workers) == eq.concurrency
+ assert all(t.daemon for t in eq._workers)
+
+ eq.add(event)
+ assert len(eq._workers) == eq.concurrency # no growth on later adds
+
+ eq.destroy()
def test_add_event_success(self):
"""Test adding event to queue successfully."""
@@ -70,6 +94,7 @@ def test_add_event_queue_full(self):
"""Test adding event when queue is full."""
eq = EventQueue()
eq.queue = queue.Queue(maxsize=2) # Small queue for testing
+ eq._workers_started = True # keep workers off so the queue stays full
# Fill the queue
event1 = UnredactedEvent(
@@ -114,6 +139,7 @@ def test_add_event_queue_full_drops_new_event(self):
"""Test that new events are dropped when queue is full."""
eq = EventQueue()
eq.queue = queue.Queue(maxsize=1)
+ eq._workers_started = True # keep workers off so the queue stays full
# Fill the queue
event1 = UnredactedEvent(
@@ -189,6 +215,37 @@ def test_process_event_with_redaction(self, mock_redact):
assert called_event.redaction_fn is None
mock_send.assert_called_once()
+ def test_process_event_redacts_for_real(self):
+ """The same path with nothing mocked.
+
+ `test_process_event_with_redaction` above patches `redact_event`, so it
+ proves the queue calls it and nothing about what it does — which is how
+ a `redact_event` that returned its pydantic input untouched survived the
+ whole v2 branch with the README advertising it as a security control.
+ """
+ eq = EventQueue()
+
+ event = UnredactedEvent(
+ id="test-id",
+ event_type="mcp:tools/call",
+ project_id="project-123",
+ session_id="session-123",
+ timestamp=datetime.now(timezone.utc),
+ parameters={"arguments": {"token": "hunter2"}},
+ user_intent="spend hunter2",
+ redaction_fn=lambda s: s.replace("hunter2", "[REDACTED]"),
+ )
+
+ with patch.object(eq, "_send_event") as mock_send:
+ eq._process_event(event)
+
+ sent = mock_send.call_args[0][0]
+ assert sent.parameters == {"arguments": {"token": "[REDACTED]"}}
+ assert sent.user_intent == "spend [REDACTED]"
+ assert sent.redaction_fn is None
+ # Protected: the handle still identifies the task on the dashboard.
+ assert sent.session_id == "session-123"
+
@patch("agentcat.modules.event_queue.redact_event")
@patch("agentcat.modules.event_queue.write_to_log")
def test_process_event_redaction_failure(self, mock_log, mock_redact):
@@ -260,7 +317,8 @@ def test_send_event_success(self, mock_log):
eq._send_event(event)
mock_api_client.publish_event.assert_called_once_with(
- publish_event_request=event
+ publish_event_request=event,
+ _request_timeout=10,
)
assert mock_log.call_count >= 1 # At least one success log
@@ -433,12 +491,11 @@ def test_get_stats(self):
assert "isProcessing" in stats
assert isinstance(stats["isProcessing"], bool)
- @patch("time.sleep")
- def test_destroy(self, mock_sleep):
- """Test graceful shutdown."""
+ def test_destroy(self):
+ """destroy() sets flags, stops workers, and returns fast — no sleeps,
+ no unbounded joins."""
eq = EventQueue()
-
- # Add an event
+ eq._process_event = lambda event: None
event = UnredactedEvent(
id="test-id",
event_type="mcp:tools/call",
@@ -446,26 +503,21 @@ def test_destroy(self, mock_sleep):
session_id="session-123",
timestamp=datetime.now(timezone.utc),
)
- eq.queue.put_nowait(event)
-
- # Mock executor
- mock_executor = MagicMock()
- eq.executor = mock_executor
+ eq.add(event)
+ start = time.monotonic()
eq.destroy()
+ elapsed = time.monotonic() - start
assert eq._shutdown is True
assert eq._shutdown_event.is_set()
- mock_executor.shutdown.assert_called_once()
+ assert elapsed < 1.5 # bounded by the shared 1s join budget
- @patch("time.time")
- @patch("time.sleep")
@patch("agentcat.modules.event_queue.write_to_log")
- def test_destroy_with_timeout(self, mock_log, mock_sleep, mock_time):
- """Test destroy with events still in queue after timeout."""
+ def test_destroy_logs_unprocessed_events(self, mock_log):
+ """Events still queued when destroy() runs are counted in the log."""
eq = EventQueue()
- # Add events that won't be processed
num_events = 5
for i in range(num_events):
event = UnredactedEvent(
@@ -475,20 +527,40 @@ def test_destroy_with_timeout(self, mock_log, mock_sleep, mock_time):
session_id="session-123",
timestamp=datetime.now(timezone.utc),
)
- eq.queue.put_nowait(event)
-
- # Mock time to simulate timeout
- mock_time.side_effect = [0, 0.1, 0.2, 10.0] # Exceeds timeout
-
- # Mock executor
- mock_executor = MagicMock()
- eq.executor = mock_executor
+ eq.queue.put_nowait(event) # no workers started: events sit there
eq.destroy()
assert mock_log.called
assert any(str(num_events) in str(call) for call in mock_log.call_args_list)
+ @patch("agentcat.modules.event_queue.write_to_log")
+ def test_destroy_wakeup_markers_are_not_counted_as_events(self, mock_log):
+ """The _Stop markers destroy() enqueues to wake idle parked workers
+ must never show up in the unprocessed-events count."""
+ eq = EventQueue()
+ eq._process_event = lambda event: None
+ eq.add(
+ UnredactedEvent(
+ id="drains",
+ event_type="mcp:tools/call",
+ project_id="project-123",
+ session_id="session-123",
+ timestamp=datetime.now(timezone.utc),
+ )
+ )
+ deadline = time.monotonic() + 2.0
+ while time.monotonic() < deadline:
+ if eq.queue.qsize() == 0 and eq.get_stats()["activeRequests"] == 0:
+ break
+ time.sleep(0.01)
+
+ eq.destroy()
+
+ assert not any(
+ "unprocessed" in str(logged) for logged in mock_log.call_args_list
+ )
+
def test_worker_thread_processes_events(self):
"""Test that worker thread processes events from queue."""
eq = EventQueue()
@@ -521,85 +593,151 @@ def mock_process(event):
@patch("agentcat.modules.event_queue.write_to_log")
def test_worker_thread_exception_handling(self, mock_log):
- """Test worker thread handles exceptions gracefully."""
+ """A raising _process_event is logged and the workers stay alive."""
eq = EventQueue()
+ eq._process_event = MagicMock(side_effect=Exception("Test exception"))
- # Mock executor.submit to raise an exception
- with patch.object(
- eq.executor, "submit", side_effect=Exception("Test exception")
- ):
- # Add an event
- event = UnredactedEvent(
- id="test-id",
- event_type="mcp:tools/call",
- project_id="project-123",
- session_id="session-123",
- timestamp=datetime.now(timezone.utc),
- )
- eq.add(event)
+ event = UnredactedEvent(
+ id="test-id",
+ event_type="mcp:tools/call",
+ project_id="project-123",
+ session_id="session-123",
+ timestamp=datetime.now(timezone.utc),
+ )
+ eq.add(event)
- # Give worker thread time to process and handle exception
- time.sleep(0.2)
+ # Give a worker time to pick it up and handle the exception
+ time.sleep(0.3)
- # Check that error was logged
- assert mock_log.called
- assert any(
- "Failed to submit event for processing" in str(call)
- for call in mock_log.call_args_list
- )
+ assert mock_log.called
+ assert any(
+ "Worker thread error (continuing)" in str(call)
+ for call in mock_log.call_args_list
+ )
+ assert all(t.is_alive() for t in eq._workers)
+
+ eq.destroy()
- # Worker thread should still be alive
- assert eq.worker_thread.is_alive()
+
+def _tracking_data(**overrides) -> AgentCatData:
+ """Track-time data in its v2 shape: project, options, server identity."""
+ fields = {
+ "project_id": "project-123",
+ "options": AgentCatOptions(),
+ "server_name": "test-server",
+ "server_version": "1.0.0",
+ }
+ fields.update(overrides)
+ return AgentCatData(**fields)
class TestPublishEvent:
"""Test publish_event function."""
@patch("agentcat.modules.event_queue.get_server_tracking_data")
- @patch("agentcat.modules.event_queue.get_session_info")
- @patch("agentcat.modules.event_queue.set_last_activity")
@patch("agentcat.modules.event_queue.event_queue")
- def test_publish_event_success(
- self, mock_eq, mock_set_activity, mock_session, mock_tracking
- ):
+ def test_publish_event_success(self, mock_eq, mock_tracking):
"""Test publishing event successfully."""
- # Mock server and data
mock_server = MagicMock()
- mock_data = AgentCatData(
- project_id="project-123",
- session_id="session-123",
- session_info=SessionInfo(),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(redact_sensitive_information=None),
+ mock_data = _tracking_data(
+ options=AgentCatOptions(redact_sensitive_information=None)
)
mock_tracking.return_value = mock_data
- mock_session_info = SessionInfo(
- server_name="test-server", server_version="1.0.0"
- )
- mock_session.return_value = mock_session_info
-
# Create event
event = UnredactedEvent(
event_type="mcp:tools/call",
- session_id="session-123",
+ session_id="ses_task_handle",
timestamp=datetime.now(timezone.utc),
)
publish_event(mock_server, event)
mock_tracking.assert_called_once_with(mock_server)
- mock_session.assert_called_once_with(mock_server, mock_data)
- mock_set_activity.assert_called_once_with(mock_server)
- # Check event was added with merged data
mock_eq.add.assert_called_once()
added_event = mock_eq.add.call_args[0][0]
assert added_event.project_id == mock_data.project_id
- # Just verify the event has the expected type and required fields
assert isinstance(added_event, UnredactedEvent)
assert added_event.event_type == "mcp:tools/call"
- assert added_event.session_id is not None
+ assert added_event.session_id == "ses_task_handle"
+
+ @patch("agentcat.modules.event_queue.get_server_tracking_data")
+ @patch("agentcat.modules.event_queue.event_queue")
+ def test_publish_event_stamps_server_and_sdk_metadata(self, mock_eq, mock_tracking):
+ """Server identity comes from the track-time capture on AgentCatData and
+ the SDK identity from package metadata — there is no session cache to
+ read them from anymore, but every event still carries all four."""
+ import importlib.metadata
+
+ mock_tracking.return_value = _tracking_data(
+ server_name="todo-server", server_version="4.2.0"
+ )
+
+ event = UnredactedEvent(
+ event_type="mcp:tools/call",
+ session_id="ses_task_handle",
+ timestamp=datetime.now(timezone.utc),
+ )
+ publish_event(MagicMock(), event)
+
+ added_event = mock_eq.add.call_args[0][0]
+ assert added_event.server_name == "todo-server"
+ assert added_event.server_version == "4.2.0"
+ assert added_event.sdk_language.startswith("Python ")
+ # Compared against the distribution directly, not against the helper the
+ # pipeline calls, so this cannot pass by agreeing with itself.
+ assert added_event.agentcat_version == importlib.metadata.version("agentcat")
+
+ @patch("agentcat.modules.event_queue.get_server_tracking_data")
+ @patch("agentcat.modules.event_queue.event_queue")
+ def test_publish_event_keeps_the_events_own_client_identity(
+ self, mock_eq, mock_tracking
+ ):
+ """The per-request client identity ladder (design §7) already stamped
+ this event. Publishing must not overwrite it — the v1 pipeline merged a
+ server-wide session cache OVER the event and defeated the ladder on
+ every non-stateless server."""
+ mock_tracking.return_value = _tracking_data()
+
+ event = UnredactedEvent(
+ event_type="mcp:tools/call",
+ session_id="ses_task_handle",
+ timestamp=datetime.now(timezone.utc),
+ client_name="Cursor",
+ client_version="2.6.22",
+ )
+ publish_event(MagicMock(), event)
+
+ added_event = mock_eq.add.call_args[0][0]
+ assert added_event.client_name == "Cursor"
+ assert added_event.client_version == "2.6.22"
+
+ @patch("agentcat.modules.event_queue.get_server_tracking_data")
+ @patch("agentcat.modules.event_queue.event_queue")
+ def test_publish_event_keeps_the_events_own_actor_and_tags(
+ self, mock_eq, mock_tracking
+ ):
+ """Same rule for everything else the call path resolved per request:
+ the actor `identify` returned and the tags the handle layer merged."""
+ mock_tracking.return_value = _tracking_data()
+
+ event = UnredactedEvent(
+ event_type="mcp:tools/call",
+ session_id="ses_task_handle",
+ timestamp=datetime.now(timezone.utc),
+ identify_actor_given_id="user-123",
+ identify_actor_name="Ada",
+ identify_data={"plan": "pro"},
+ tags={"agentcat_session_id_source": "supplied"},
+ )
+ publish_event(MagicMock(), event)
+
+ added_event = mock_eq.add.call_args[0][0]
+ assert added_event.identify_actor_given_id == "user-123"
+ assert added_event.identify_actor_name == "Ada"
+ assert added_event.identify_data == {"plan": "pro"}
+ assert added_event.tags == {"agentcat_session_id_source": "supplied"}
@patch("agentcat.modules.event_queue.get_server_tracking_data")
@patch("agentcat.modules.event_queue.write_to_log")
@@ -610,7 +748,7 @@ def test_publish_event_no_tracking_data(self, mock_log, mock_tracking):
event = UnredactedEvent(
event_type="mcp:tools/call",
- session_id="session-123",
+ session_id="ses_task_handle",
timestamp=datetime.now(timezone.utc),
)
@@ -622,29 +760,17 @@ def test_publish_event_no_tracking_data(self, mock_log, mock_tracking):
)
@patch("agentcat.modules.event_queue.get_server_tracking_data")
- @patch("agentcat.modules.event_queue.get_session_info")
- @patch("agentcat.modules.event_queue.set_last_activity")
@patch("agentcat.modules.event_queue.event_queue")
- def test_publish_event_calculates_duration(
- self, mock_eq, mock_set_activity, mock_session, mock_tracking
- ):
+ def test_publish_event_calculates_duration(self, mock_eq, mock_tracking):
"""Test publishing event calculates duration if not provided."""
mock_server = MagicMock()
- mock_data = AgentCatData(
- project_id="project-123",
- session_id="session-123",
- session_info=SessionInfo(),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
- mock_tracking.return_value = mock_data
- mock_session.return_value = SessionInfo()
+ mock_tracking.return_value = _tracking_data()
# Create event without duration
event_timestamp = datetime.now(timezone.utc)
event = UnredactedEvent(
event_type="mcp:tools/call",
- session_id="session-123",
+ session_id="ses_task_handle",
timestamp=event_timestamp,
)
@@ -661,26 +787,16 @@ def test_publish_event_calculates_duration(
assert event.duration > 0
@patch("agentcat.modules.event_queue.get_server_tracking_data")
- @patch("agentcat.modules.event_queue.get_session_info")
- @patch("agentcat.modules.event_queue.set_last_activity")
@patch("agentcat.modules.event_queue.event_queue")
- def test_publish_event_no_duration_no_timestamp(
- self, mock_eq, mock_set_activity, mock_session, mock_tracking
- ):
+ def test_publish_event_no_duration_no_timestamp(self, mock_eq, mock_tracking):
"""Test publishing event with no duration and no timestamp sets duration to None."""
mock_server = MagicMock()
- mock_data = AgentCatData(
- project_id="project-123",
- session_id="session-123",
- session_info=SessionInfo(),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
- mock_tracking.return_value = mock_data
- mock_session.return_value = SessionInfo()
+ mock_tracking.return_value = _tracking_data()
# Create event without duration or timestamp
- event = UnredactedEvent(event_type="mcp:tools/call", session_id="session-123")
+ event = UnredactedEvent(
+ event_type="mcp:tools/call", session_id="ses_task_handle"
+ )
publish_event(mock_server, event)
@@ -688,28 +804,18 @@ def test_publish_event_no_duration_no_timestamp(
assert event.duration is None
@patch("agentcat.modules.event_queue.get_server_tracking_data")
- @patch("agentcat.modules.event_queue.get_session_info")
- @patch("agentcat.modules.event_queue.set_last_activity")
@patch("agentcat.modules.event_queue.event_queue")
- def test_publish_event_with_redaction_function(
- self, mock_eq, mock_set_activity, mock_session, mock_tracking
- ):
+ def test_publish_event_with_redaction_function(self, mock_eq, mock_tracking):
"""Test publishing event includes redaction function from options."""
mock_server = MagicMock()
mock_redaction_fn = MagicMock()
- mock_data = AgentCatData(
- project_id="project-123",
- session_id="session-123",
- session_info=SessionInfo(),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(redact_sensitive_information=mock_redaction_fn),
+ mock_tracking.return_value = _tracking_data(
+ options=AgentCatOptions(redact_sensitive_information=mock_redaction_fn)
)
- mock_tracking.return_value = mock_data
- mock_session.return_value = SessionInfo()
event = UnredactedEvent(
event_type="mcp:tools/call",
- session_id="session-123",
+ session_id="ses_task_handle",
timestamp=datetime.now(timezone.utc),
)
@@ -720,69 +826,62 @@ def test_publish_event_with_redaction_function(
assert added_event.redaction_fn == mock_redaction_fn
-@patch("agentcat.modules.event_queue.signal.signal")
-@patch("agentcat.modules.event_queue.atexit.register")
-def test_shutdown_handlers_registered(mock_atexit, mock_signal):
- """Test that shutdown handlers are registered on module import."""
- # Import the module to trigger registration
- import importlib
- import agentcat.modules.event_queue
-
- importlib.reload(agentcat.modules.event_queue)
-
- # Check signal handlers registered
- assert mock_signal.call_count >= 2
- signal_calls = mock_signal.call_args_list
- registered_signals = [call[0][0] for call in signal_calls]
- assert signal.SIGINT in registered_signals
- assert signal.SIGTERM in registered_signals
-
- # Check atexit handler registered
- assert mock_atexit.called
-
-
-@patch("os._exit")
-@patch("agentcat.modules.event_queue.signal.signal")
-@patch("agentcat.modules.event_queue.event_queue")
-def test_shutdown_handler_function(mock_event_queue, mock_signal, mock_exit):
- """Test the _shutdown_handler function."""
- from agentcat.modules.event_queue import _shutdown_handler
-
- # Call the shutdown handler with proper signal handler arguments
- _shutdown_handler(signal.SIGINT, None)
+def test_module_import_installs_no_process_hooks():
+ """The SDK must never register signal handlers — the customer's process
+ owns its own shutdown. The one exit hook this module owns registers on
+ first publish, never at import. (Full subprocess-based checks live in
+ test_process_safety.py; this pins the module surface.)"""
+ import agentcat.modules.event_queue as eq_module
- # Verify signal handlers are reset to default
- mock_signal.assert_any_call(signal.SIGINT, signal.SIG_DFL)
- mock_signal.assert_any_call(signal.SIGTERM, signal.SIG_DFL)
+ assert not hasattr(eq_module, "_shutdown_handler")
+ source = open(eq_module.__file__).read()
+ assert "import signal" not in source
+ assert "signal.signal" not in source
+ assert "os._exit" not in source
- # Verify it calls destroy on the event queue
- mock_event_queue.destroy.assert_called_once()
- # Verify it exits with code 0
- mock_exit.assert_called_once_with(0)
+def test_bounded_queue_is_the_only_buffer():
+ """Audit finding 9: with every worker wedged, memory is capped by the
+ queue's maxsize — there is no second, unbounded buffer behind it (the old
+ dispatcher drained the bounded queue into ThreadPoolExecutor's unbounded
+ SimpleQueue, so the advertised cap protected nothing)."""
+ eq = EventQueue()
+ eq.queue = queue.Queue(maxsize=5)
+ wedge = threading.Event()
+ eq._process_event = lambda event: wedge.wait(10)
+ def make(i: int) -> UnredactedEvent:
+ return UnredactedEvent(
+ id=f"e{i}",
+ event_type="mcp:tools/call",
+ project_id="p",
+ session_id="s",
+ timestamp=datetime.now(timezone.utc),
+ )
-@patch("time.sleep")
-def test_destroy_cancels_pending_futures(mock_sleep):
- """Test destroy method cancels pending futures."""
- eq = EventQueue()
+ try:
+ with patch("agentcat.modules.event_queue.write_to_log") as mock_log:
+ for i in range(30):
+ eq.add(make(i))
+ deadline = time.monotonic() + 2.0
+ while (
+ eq.get_stats()["activeRequests"] < eq.concurrency
+ and time.monotonic() < deadline
+ ):
+ time.sleep(0.02)
+
+ # concurrency in flight + maxsize buffered is the whole footprint;
+ # every other event was dropped at add() with a log line.
+ assert eq.queue.qsize() <= 5
+ assert len(eq._workers) == eq.concurrency
+ assert all(t.daemon for t in eq._workers)
- # Add an event
- event = UnredactedEvent(
- id="test-id",
- event_type="mcp:tools/call",
- project_id="project-123",
- session_id="session-123",
- timestamp=datetime.now(timezone.utc),
- )
- eq.queue.put_nowait(event)
-
- # Mock executor
- mock_executor = MagicMock()
- eq.executor = mock_executor
-
- eq.destroy()
-
- assert eq._shutdown is True
- assert eq._shutdown_event.is_set()
- mock_executor.shutdown.assert_called_once_with(wait=True, cancel_futures=True)
+ stats = eq.get_stats()
+ assert stats["activeRequests"] == eq.concurrency
+ assert stats["isProcessing"] is True
+ assert any(
+ "full" in str(c).lower() for c in mock_log.call_args_list
+ ), "no drop was logged"
+ finally:
+ wedge.set()
+ eq.destroy()
diff --git a/tests/test_event_tags_properties.py b/tests/test_event_tags_properties.py
index 445b67d..0801e73 100644
--- a/tests/test_event_tags_properties.py
+++ b/tests/test_event_tags_properties.py
@@ -7,7 +7,7 @@
import pytest
from agentcat import AgentCatOptions, track
-from agentcat.modules.constants import AGENTCAT_SOURCE
+from agentcat.modules.constants import AGENTCAT_SOURCE, AGENTCAT_TAG_SESSION_SOURCE
from agentcat.modules.event_queue import EventQueue, set_event_queue
from agentcat.modules.internal import (
attach_event_metadata,
@@ -15,8 +15,9 @@
resolve_event_tags,
)
from agentcat.modules.redaction import redact_event
-from agentcat.types import EventType, AgentCatData, SessionInfo, UnredactedEvent
+from agentcat.types import EventType, AgentCatData, UnredactedEvent
+from .test_utils import LEGACY_ONLY
from .test_utils.client import create_test_client
from .test_utils.todo_server import create_todo_server
@@ -24,9 +25,6 @@
def _make_data(event_tags=None, event_properties=None) -> AgentCatData:
return AgentCatData(
project_id="p",
- session_id="ses_x",
- last_activity=None,
- session_info=SessionInfo(),
options=AgentCatOptions(
event_tags=event_tags, event_properties=event_properties
),
@@ -175,8 +173,13 @@ def test_tags_and_properties_not_redacted(self):
assert result["parameters"]["q"] == "[REDACTED]"
+@LEGACY_ONLY
class TestFastMCPIntegration:
- """End-to-end test against the FastMCP test server."""
+ """End-to-end test against the FastMCP test server.
+
+ The resolver / attach / redaction tests above are era-agnostic and run on
+ both majors; only this class needs the mcp 1.x harness.
+ """
@pytest.fixture(autouse=True)
def restore_queue(self):
@@ -188,7 +191,7 @@ def restore_queue(self):
async def test_callbacks_flow_through_to_published_event(self):
captured = []
mock_api = MagicMock()
- mock_api.publish_event = MagicMock(side_effect=lambda publish_event_request: captured.append(publish_event_request))
+ mock_api.publish_event = MagicMock(side_effect=lambda publish_event_request, **kwargs: captured.append(publish_event_request))
set_event_queue(EventQueue(api_client=mock_api))
server = create_todo_server()
@@ -208,14 +211,18 @@ async def test_callbacks_flow_through_to_published_event(self):
tool_events = [e for e in captured if e.event_type == EventType.MCP_TOOLS_CALL.value]
assert tool_events, "no tool call event captured"
event = tool_events[0]
- assert event.tags == {"env": "test"} # bad! dropped by validation
+ # "bad!" dropped by validation; AgentCat's own namespaced tags merge in
+ # last, outside the customer's 50-tag budget.
+ assert event.tags["env"] == "test"
+ assert "bad!" not in event.tags
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
assert event.properties == {"flag": True, "build": "abc"}
@pytest.mark.asyncio
async def test_callback_exception_does_not_break_publish(self):
captured = []
mock_api = MagicMock()
- mock_api.publish_event = MagicMock(side_effect=lambda publish_event_request: captured.append(publish_event_request))
+ mock_api.publish_event = MagicMock(side_effect=lambda publish_event_request, **kwargs: captured.append(publish_event_request))
set_event_queue(EventQueue(api_client=mock_api))
def boom(req, ctx):
@@ -230,14 +237,16 @@ def boom(req, ctx):
tool_events = [e for e in captured if e.event_type == EventType.MCP_TOOLS_CALL.value]
assert tool_events
- assert tool_events[0].tags is None
+ # Both callbacks blew up: nothing customer-supplied lands, and only the
+ # SDK's own tags remain.
+ assert tool_events[0].tags == {AGENTCAT_TAG_SESSION_SOURCE: "minted"}
assert tool_events[0].properties is None
@pytest.mark.asyncio
async def test_async_callbacks_flow_through_to_published_event(self):
captured = []
mock_api = MagicMock()
- mock_api.publish_event = MagicMock(side_effect=lambda publish_event_request: captured.append(publish_event_request))
+ mock_api.publish_event = MagicMock(side_effect=lambda publish_event_request, **kwargs: captured.append(publish_event_request))
set_event_queue(EventQueue(api_client=mock_api))
async def async_tags(req, ctx):
@@ -260,7 +269,9 @@ async def async_props(req, ctx):
tool_events = [e for e in captured if e.event_type == EventType.MCP_TOOLS_CALL.value]
assert tool_events, "no tool call event captured"
event = tool_events[0]
- assert event.tags == {"env": "test"}
+ assert event.tags["env"] == "test"
+ assert "bad!" not in event.tags
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
assert event.properties == {"flag": True, "nested": {"x": 1}}
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 208359d..39dc687 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -14,11 +14,13 @@
extract_context_line,
filename_for_module,
format_exception_string,
+ is_call_tool_result,
is_in_app,
parse_python_traceback,
stringify_non_exception,
)
+from .test_utils import LEGACY_ONLY
from .test_utils.client import create_test_client
from .test_utils.todo_server import create_todo_server
@@ -426,8 +428,14 @@ def test_capture_preserves_all_fields(self):
assert "stack" in error_data
+@LEGACY_ONLY
class TestExceptionIntegration:
- """Integration tests for exception capture with real MCP server calls."""
+ """Integration tests for exception capture with real MCP server calls.
+
+ The `capture_exception` unit tests above are era-agnostic and run on both
+ majors; only this class needs the mcp 1.x harness. The 2.x equivalents live
+ in `test_lowlevel_v2_handles.py`.
+ """
@pytest.fixture(autouse=True)
def setup_and_teardown(self):
@@ -442,7 +450,7 @@ def _create_mock_event_capture(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -606,6 +614,18 @@ async def test_tool_raises_has_in_app_frames(self):
# Note: MCP SDK wraps the error, so the original tool function may not appear
# but we still verify the in_app detection logic works
+ # NOTE (Task 13.5): the two tests below are restored verbatim from
+ # c32cc92^ and pass, but read them for what they are. `is_in_app` is purely
+ # path-based, and official FastMCP wraps a failing tool in a `ToolError`
+ # whose traceback stops at the wrapper — so the only top-level in_app frame
+ # here is AgentCat's own inner-tap seam, in_app ONLY because this is an
+ # editable checkout. They prove the in_app/context_line machinery runs;
+ # they prove nothing about the CUSTOMER's context lines. The
+ # environment-independent assertion for that is, in
+ # `tests/test_inner_tap.py`, TestOfficialV1's
+ # `test_fastmcp_v1_publishes_the_tool_error_and_its_cause` — which asserts
+ # the customer's own frame and context line inside `chained_errors`, where
+ # FastMCP's wrap actually puts them.
@pytest.mark.asyncio
async def test_tool_raises_captures_context_lines(self):
"""Test that context lines are captured for in_app frames."""
@@ -641,6 +661,47 @@ async def test_tool_raises_captures_context_lines(self):
assert len(context) > 0
assert context.strip() != ""
+ @pytest.mark.asyncio
+ async def test_tool_error_message_survives_the_sdk_wrapping(self):
+ """The error the agent saw is the error the event records.
+
+ v2 intercepts at the protocol boundary, outside the customer's handler,
+ so the SDK has already turned the exception into an isError
+ CallToolResult by the time AgentCat sees a failed call. The inner tap
+ (Task 13.5) recovers the exception object itself, so the event carries
+ the type, the stack and the cause chain again — and the message stays
+ exactly what the agent was told, which is what this asserts.
+ """
+ captured_events = self._create_mock_event_capture()
+
+ server = create_todo_server()
+ options = AgentCatOptions(enable_tracing=True)
+ track(server, "test_project", options)
+
+ async with create_test_client(server) as client:
+ result = await client.call_tool(
+ "tool_that_raises", {"error_type": "value"}
+ )
+ time.sleep(1.0)
+
+ assert result.isError is True
+ tool_events = [
+ e
+ for e in captured_events
+ if e.event_type == "mcp:tools/call"
+ and e.resource_name == "tool_that_raises"
+ ]
+ assert len(tool_events) == 1
+
+ event = tool_events[0]
+ assert event.is_error is True
+ assert event.error is not None
+ assert "Test value error from tool" in event.error["message"]
+ # The error message is exactly what the agent was told, verbatim.
+ assert event.error["message"] in "".join(
+ c.text for c in result.content if hasattr(c, "text")
+ )
+
@pytest.mark.asyncio
async def test_mcp_protocol_error(self):
"""Test that MCP protocol errors (McpError) are properly handled."""
@@ -668,5 +729,144 @@ async def test_mcp_protocol_error(self):
assert "Invalid parameters" in event.error["message"]
+class TestCaptureExceptionNeverRaises:
+ """Audit findings 7/8: capture_exception runs at adapter sites OUTSIDE
+ every other guard, so an escape replaces the customer's result or original
+ error on the wire. Whatever the input does, it must return an ErrorData."""
+
+ def test_hostile_str_degrades_to_repr(self):
+ class BrokenStr(Exception):
+ def __str__(self):
+ raise RuntimeError("hostile __str__")
+
+ result = capture_exception(BrokenStr("x"))
+ assert result["type"] == "BrokenStr"
+ assert result["platform"] == "python"
+ assert isinstance(result["message"], str)
+
+ def test_hostile_chained_cause_keeps_the_clean_outer_error(self):
+ """The TS-parity killer case: the customer's own exception is CLEAN,
+ only its chained cause has a broken __str__. The framework would have
+ delivered the customer's message fine; so must we."""
+
+ class BrokenCause(Exception):
+ def __str__(self):
+ raise RuntimeError("hostile cause")
+
+ try:
+ try:
+ raise BrokenCause()
+ except BrokenCause as cause:
+ raise ValueError("customer's real message") from cause
+ except ValueError as e:
+ result = capture_exception(e)
+
+ assert result["message"] == "customer's real message"
+ assert result["type"] == "ValueError"
+ # The poisoned link degrades, it does not disappear or raise.
+ assert result.get("chained_errors"), "cause chain lost entirely"
+
+ def test_deleted_cwd_keeps_frames_with_raw_paths(self, monkeypatch):
+ """os.path.abspath calls os.getcwd() for relative co_filenames
+ ('', exec'd plugin frames) and raises under a deleted working
+ directory. Frames must survive with the raw path."""
+ import agentcat.modules.exceptions as exc_module
+
+ def raising_abspath(path):
+ raise FileNotFoundError("cwd was deleted")
+
+ monkeypatch.setattr(exc_module.os.path, "abspath", raising_abspath)
+
+ try:
+ exec(compile("raise ValueError('from exec')", "", "exec"))
+ except ValueError as e:
+ result = capture_exception(e)
+
+ assert result["message"] == "from exec"
+ assert result["type"] == "ValueError"
+ assert result.get("frames"), "frames dropped instead of degrading"
+
+ def test_raising_content_property_is_not_a_call_tool_result(self):
+ class LazyProxyResult:
+ is_error = True
+
+ @property
+ def content(self):
+ raise RuntimeError("upstream proxy gone")
+
+ assert is_call_tool_result(LazyProxyResult()) is False
+ result = capture_exception(LazyProxyResult())
+ assert result["platform"] == "python"
+ assert isinstance(result["message"], str)
+
+ def test_outer_guard_returns_minimal_fallback(self, monkeypatch):
+ """If the detailed capture itself breaks, the fallback still names the
+ exception type and publishes."""
+ import agentcat.modules.exceptions as exc_module
+
+ def broken_capture(exc):
+ raise RuntimeError("capture machinery broke")
+
+ monkeypatch.setattr(exc_module, "_capture_exception", broken_capture)
+
+ result = capture_exception(ValueError("original"))
+ assert result["type"] == "ValueError"
+ assert result["platform"] == "python"
+ assert "unavailable" in result["message"]
+
+ async def test_hostile_tool_exception_still_publishes_an_event(self):
+ """Integration: a tracked tool raising a hostile exception ends in a
+ published event carrying error data — never a protocol-level crash."""
+ captured = []
+ mock_api = MagicMock()
+ mock_api.publish_event = MagicMock(
+ side_effect=lambda publish_event_request, **kw: captured.append(
+ publish_event_request
+ )
+ )
+ queue = EventQueue(api_client=mock_api)
+ set_event_queue(queue)
+
+ try: # whichever todo server this dependency leg provides
+ from .test_utils.modern_server import (
+ create_mcpserver_todo_server,
+ create_modern_client,
+ )
+
+ server = create_mcpserver_todo_server()
+ make_client = create_modern_client
+ except ImportError:
+ server = create_todo_server()
+ make_client = create_test_client
+
+ @server.tool()
+ def hostile_tool() -> str:
+ class Hostile(Exception):
+ def __str__(self):
+ raise RuntimeError("hostile")
+
+ raise Hostile("boom")
+
+ track(server, "proj_test", AgentCatOptions())
+ async with make_client(server) as client:
+ try:
+ await client.call_tool("hostile_tool", {})
+ except Exception:
+ # A tool-error RESULT surfaced client-side is a valid answer;
+ # the wire-level regression is asserted through the event below.
+ pass
+ time.sleep(1.0)
+
+ events = [
+ e
+ for e in captured
+ if e.event_type == "mcp:tools/call" and e.resource_name == "hostile_tool"
+ ]
+ assert events, "hostile exception suppressed the event entirely"
+ assert events[0].is_error is True
+ assert events[0].error is not None
+ assert events[0].error["platform"] == "python"
+
+
if __name__ == "__main__":
pytest.main([__file__, "-v"])
diff --git a/tests/test_exporter_sdk_version.py b/tests/test_exporter_sdk_version.py
index f85ee8d..9c59d12 100644
--- a/tests/test_exporter_sdk_version.py
+++ b/tests/test_exporter_sdk_version.py
@@ -5,15 +5,22 @@
"""
import importlib.metadata
+import platform
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from agentcat.modules.exporters.otlp import OTLPExporter
from agentcat.modules.exporters.sentry import SentryExporter
from agentcat.types import Event
+from agentcat.utils import get_agentcat_version
INSTALLED_VERSION = importlib.metadata.version("agentcat")
+try:
+ FASTMCP_VERSION: str | None = importlib.metadata.version("fastmcp")
+except importlib.metadata.PackageNotFoundError: # the test-without-fastmcp legs
+ FASTMCP_VERSION = None
+
def make_event(**kwargs) -> Event:
defaults = dict(
@@ -52,6 +59,62 @@ def test_scope_version_falls_back_to_unknown_when_unresolvable(self):
assert scope["version"] == "unknown"
+class TestOTLPResourceVersionAttributes:
+ """Every exported event's OTLP resource carries runtime + MCP SDK versions."""
+
+ def _export_and_get_resource_attrs(self, event: Event) -> dict:
+ exporter = OTLPExporter({"endpoint": "http://localhost:4318/v1/traces"})
+ exporter.session = MagicMock()
+ exporter.export(event)
+ assert exporter.session.post.called
+ payload = exporter.session.post.call_args.kwargs["json"]
+ attrs = payload["resourceSpans"][0]["resource"]["attributes"]
+ return {a["key"]: a["value"]["stringValue"] for a in attrs}
+
+ def test_python_runtime_attributes_present(self):
+ attrs = self._export_and_get_resource_attrs(make_event())
+ assert attrs["process.runtime.name"] == platform.python_implementation().lower()
+ assert attrs["process.runtime.version"] == platform.python_version()
+
+ def test_mcp_sdk_version_attribute_matches_installed(self):
+ attrs = self._export_and_get_resource_attrs(make_event())
+ assert attrs["agentcat.mcp_sdk.version"] == importlib.metadata.version("mcp")
+
+ def test_fastmcp_attribute_present_iff_installed(self):
+ attrs = self._export_and_get_resource_attrs(make_event())
+ if FASTMCP_VERSION is None:
+ assert "agentcat.fastmcp_sdk.version" not in attrs
+ else:
+ assert attrs["agentcat.fastmcp_sdk.version"] == FASTMCP_VERSION
+
+ def test_absent_distribution_emits_no_attribute(self):
+ with patch(
+ "agentcat.modules.exporters.otlp.get_dist_version", return_value=None
+ ):
+ attrs = self._export_and_get_resource_attrs(make_event())
+ assert "agentcat.mcp_sdk.version" not in attrs
+ assert "agentcat.fastmcp_sdk.version" not in attrs
+ # Runtime attributes do not depend on distribution lookups.
+ assert attrs["process.runtime.version"] == platform.python_version()
+
+
+class TestGetDistVersion:
+ """Best-effort installed-distribution reader shared by the log-line version
+ suffix and the exporters."""
+
+ def test_returns_installed_version(self):
+ from agentcat.utils import get_dist_version
+
+ get_dist_version.cache_clear()
+ assert get_dist_version("agentcat") == INSTALLED_VERSION
+
+ def test_returns_none_for_missing_distribution(self):
+ from agentcat.utils import get_dist_version
+
+ get_dist_version.cache_clear()
+ assert get_dist_version("definitely-not-a-real-distribution") is None
+
+
class TestSentryAuthHeaderVersion:
DSN = "https://abcdef1234567890@o123.ingest.sentry.io/456"
@@ -66,3 +129,29 @@ def test_auth_header_falls_back_to_unknown_when_unresolvable(self):
):
exporter = SentryExporter({"dsn": self.DSN})
assert "sentry_client=agentcat/unknown" in exporter.auth_header
+
+
+class TestGetAgentcatVersion:
+ """The single reader of the installed distribution version.
+
+ Every event is stamped with it and both exporters fall back to it, so its
+ two behaviors — the right lookup key, and never raising — are worth pinning
+ directly. (Restores the coverage that lived in tests/test_session.py before
+ the function moved to agentcat.utils.)
+ """
+
+ def test_returns_the_installed_distribution_version(self):
+ assert get_agentcat_version() == INSTALLED_VERSION
+
+ @patch("importlib.metadata.version")
+ def test_looks_the_version_up_under_the_distribution_name(self, mock_version):
+ mock_version.return_value = "1.2.3"
+ assert get_agentcat_version() == "1.2.3"
+ mock_version.assert_called_once_with("agentcat")
+
+ @patch("importlib.metadata.version")
+ def test_returns_none_when_the_distribution_cannot_be_read(self, mock_version):
+ """An un-readable version must never break event publishing: the field
+ is simply omitted."""
+ mock_version.side_effect = importlib.metadata.PackageNotFoundError("agentcat")
+ assert get_agentcat_version() is None
diff --git a/tests/test_handles.py b/tests/test_handles.py
new file mode 100644
index 0000000..b844ea3
--- /dev/null
+++ b/tests/test_handles.py
@@ -0,0 +1,451 @@
+"""Handle primitives: minting, derivation, extraction, mint-back, tags.
+
+Port of the TypeScript SDK's `src/tests/handles.test.ts`. The four derivation
+golden vectors and the mint-back byte expectations are frozen cross-SDK
+contracts: a change here changes every previously-derived session id on the
+wire and splits customer sessions across an upgrade. Fix the implementation,
+never the literal.
+"""
+
+from agentcat.modules.handles import (
+ HandleResolution,
+ build_handle_tags,
+ build_mint_back_text,
+ build_structured_mint_back,
+ derive_session_id,
+ extract_handle,
+ mirror_into_structured_content,
+ new_session_id,
+ resolve_handles,
+)
+from agentcat.types import AgentCatOptions
+
+from .test_utils import sid
+
+A = "opus-4.80-1m|claude-code|k3n9x"
+
+
+def test_mint_shape():
+ a, b = new_session_id(), new_session_id()
+ assert a.startswith("ses_") and len(a) == 4 + 27 and a != b
+
+
+def test_golden_vectors():
+ assert derive_session_id("customer-abc", "proj_1") == "ses_2cOHEO0LYGADMzRvWTXXVbbgxgm" # noqa: E501
+ assert derive_session_id("customer-abc") == "ses_2cZY3tvyI25O2AmL2CGVo2B1IIj"
+ assert derive_session_id(" x ", "p") == "ses_2c3yR5mYKQdLaXsJNgZH6erbfQK" # no trim
+ assert derive_session_id("x", "p") == "ses_2bw285VY9apdgUgTPXKFnT6P4G0"
+
+
+# TS handles.test.ts:26-44 — the structural properties the golden vectors pin
+# by example: derivation is deterministic, project-scoped, and works project-less.
+def test_derive_is_deterministic_and_project_scoped():
+ assert derive_session_id("customer-abc", "proj_1") == derive_session_id(
+ "customer-abc", "proj_1"
+ )
+ assert derive_session_id("customer-abc", "proj_1").startswith("ses_")
+ assert derive_session_id("customer-abc", "proj_1") != derive_session_id(
+ "customer-abc", "proj_2"
+ )
+ assert derive_session_id("customer-abc") == derive_session_id("customer-abc")
+ assert derive_session_id("customer-abc") != derive_session_id(
+ "customer-abc", "proj_1"
+ )
+
+
+# extract_handle's contract is UNCHANGED by validation: it still returns any
+# non-empty trimmed string. Shape checking happens afterwards, in
+# resolve_handles — see test_an_unrecognized_session_id_is_never_adopted.
+def test_extract_handle_returns_any_non_empty_string():
+ assert extract_handle({"session_id": f" {sid('x')} "}, "session_id") == sid("x")
+ assert extract_handle({"session_id": "my-own-correlation-id"}, "session_id") == "my-own-correlation-id" # noqa: E501
+ bad_values = ({"session_id": ""}, {"session_id": " "}, {"session_id": 4}, None, "s")
+ for bad in bad_values:
+ assert extract_handle(bad, "session_id") is None
+
+
+# TS handles.test.ts:71-79 — the agent_id key and the missing-key case.
+def test_extract_handle_reads_only_the_named_key():
+ assert extract_handle({"agent_id": f" {A} "}, "agent_id") == A
+ assert extract_handle({"agent_id": A}, "session_id") is None
+ assert extract_handle({}, "session_id") is None
+ assert extract_handle(None, "agent_id") is None
+
+
+async def test_prompted_supplied_vs_minted():
+ o = AgentCatOptions()
+ r1 = await resolve_handles({"session_id": sid("supplied")}, o, "proj", None, None)
+ assert (r1.session_id, r1.session_source, r1.hook_mode) == (sid("supplied"), "supplied", False) # noqa: E501
+ r2 = await resolve_handles({}, o, "proj", None, None)
+ assert r2.session_source == "minted" and r2.session_id.startswith("ses_")
+
+
+# TS handles.test.ts:248-271 — the agent is never minted server-side, so an
+# omitted agent_id stays unresolved in both the minted and the supplied
+# (subagent-continuation) flows.
+async def test_prompted_mode_never_mints_an_agent_id():
+ on = AgentCatOptions(enable_agent_tracking=True)
+ minted = await resolve_handles({}, on, "proj_1", None, None)
+ assert minted.session_source == "minted" and minted.hook_mode is False
+ assert minted.agent_id is None and minted.agent_source is None
+
+ supplied = await resolve_handles(
+ {"session_id": sid("parent")}, on, "proj_1", None, None
+ )
+ assert (supplied.session_id, supplied.session_source) == (sid("parent"), "supplied")
+ assert supplied.agent_id is None and supplied.agent_source is None
+
+
+# TS handles.test.ts:273-286 — supplied handles are taken verbatim (trimmed only).
+async def test_prompted_supplied_handles_are_verbatim():
+ """A well-formed session_id is honored byte-for-byte; agent_id always is.
+
+ `agent_id` is deliberately NOT shape-validated: the agent composes it
+ itself (model|harness|nonce) and AgentCat never issues one, so there is no
+ "did we mint this" question to ask. It also never reaches
+ `Event.session_id` — it rides in tags, which the clamp already bounds.
+ """
+ r = await resolve_handles(
+ {"session_id": sid("supplied"), "agent_id": f" {A} "},
+ AgentCatOptions(enable_agent_tracking=True),
+ "proj_1",
+ None,
+ None,
+ )
+ assert (r.session_id, r.session_source) == (sid("supplied"), "supplied")
+ assert (r.agent_id, r.agent_source) == (A, "supplied")
+
+
+async def test_hook_mode_derives_and_falls_back():
+ o = AgentCatOptions(resolve_session_id=lambda req, extra: " customer-abc ")
+ r = await resolve_handles({"session_id": "ignored"}, o, "proj_1", None, None)
+ # trimmed by the caller
+ assert r.session_id == derive_session_id("customer-abc", "proj_1")
+ assert r.session_source == "hook" and r.hook_mode is True
+
+ async def async_hook(req, extra):
+ return "customer-abc"
+
+ r = await resolve_handles(
+ {}, AgentCatOptions(resolve_session_id=async_hook), "proj_1", None, None
+ )
+ assert r.session_source == "hook"
+
+ def boom(req, extra):
+ raise RuntimeError("hook broke")
+
+ r = await resolve_handles(
+ {}, AgentCatOptions(resolve_session_id=boom), "proj_1", None, None
+ )
+ assert r.session_source == "minted" and r.hook_mode is True
+ r = await resolve_handles(
+ {}, AgentCatOptions(resolve_session_id=lambda q, e: None), None, None, None
+ )
+ assert r.session_source == "minted" and r.hook_mode is True
+
+
+# TS handles.test.ts:311-319 — the same hook value derives the same session id.
+async def test_hook_mode_is_deterministic_across_calls():
+ o = AgentCatOptions(resolve_session_id=lambda req, extra: "customer-42")
+ a = await resolve_handles({}, o, "proj_1", None, None)
+ b = await resolve_handles({}, o, "proj_1", None, None)
+ assert a.hook_mode is True and a.session_source == "hook"
+ assert a.session_id == b.session_id and a.session_id.startswith("ses_")
+
+
+# TS handles.test.ts:343-361 — guards the mint-back ack line, which fires on
+# session_source == "supplied". That must be unreachable in hook mode: a hook that
+# returns None or raises while the agent happens to send session_id is the danger
+# case, and it must fall back to "minted", never adopt the agent's value.
+async def test_hook_fallback_never_reports_supplied():
+ def boom(req, extra):
+ raise RuntimeError("db down")
+
+ for hook in (lambda req, extra: None, boom):
+ r = await resolve_handles(
+ {"session_id": sid("agent_sent")},
+ AgentCatOptions(resolve_session_id=hook),
+ "proj_1",
+ None,
+ None,
+ )
+ assert r.session_source == "minted" and r.hook_mode is True
+ assert r.session_id != sid("agent_sent")
+
+
+# TS handles.test.ts:376-392 — hook mode does not change agent resolution.
+async def test_hook_mode_still_resolves_a_supplied_agent_id():
+ o = AgentCatOptions(
+ resolve_session_id=lambda req, extra: "c", enable_agent_tracking=True
+ )
+ supplied = await resolve_handles({"agent_id": A}, o, "proj_1", None, None)
+ assert (supplied.agent_id, supplied.agent_source) == (A, "supplied")
+ omitted = await resolve_handles({}, o, "proj_1", None, None)
+ assert omitted.agent_id is None and omitted.agent_source is None
+
+
+# TS handles.test.ts:394-424 — the flagship documented use reads a header off
+# `extra`, so both objects must reach the hook unchanged (identity, not a copy
+# or the arguments dict), and the returned header must actually drive derivation.
+async def test_hook_receives_the_request_and_extra_objects():
+ calls = []
+ request = {"params": {"name": "add_todo", "arguments": {"session_id": sid("sent")}}}
+ extra = {"request_info": {"headers": {"x-correlation-id": "corr-1"}}}
+
+ def hook(req, ext):
+ calls.append((req, ext))
+ return ext["request_info"]["headers"]["x-correlation-id"]
+
+ r = await resolve_handles(
+ {"session_id": sid("sent")},
+ AgentCatOptions(resolve_session_id=hook),
+ "proj_1",
+ request,
+ extra,
+ )
+ assert len(calls) == 1
+ assert calls[0][0] is request and calls[0][1] is extra
+ assert r.session_source == "hook"
+ assert r.session_id == derive_session_id("corr-1", "proj_1")
+
+
+async def test_a_handle_agentcat_did_not_inject_is_never_read():
+ """A parameter the customer's own schema declared is never read.
+
+ It stayed in their handler's arguments (the strip spares it), so reading
+ it here would sever the agent's real session AND route a customer-domain
+ value into `session_id`, which is exempt from the customer's redaction
+ hook. Ownership of `session_id` is `session_param_is_ours`; ownership of
+ `agent_id` is still the injection registry.
+ """
+ o = AgentCatOptions(enable_agent_tracking=True)
+ args = {"session_id": "SESSION-1234", "agent_id": "THEIR-AGENT"}
+
+ theirs = await resolve_handles(
+ args, o, "proj", None, None, frozenset(), session_param_is_ours=False
+ )
+ assert theirs.session_source == "foreign"
+ # Sessionless, not a fresh mint: a mint per call on a tool that can never
+ # carry our handle manufactures a phantom session per call.
+ assert theirs.session_id == ""
+ assert theirs.agent_id is None and theirs.agent_source is None
+ # No slot of ours to echo into, so no mint-back: telling the agent to send
+ # session_id=ses_… would overwrite a parameter that means something else.
+ assert theirs.prompts_session_id is False
+ assert build_mint_back_text(theirs) is None
+ assert build_structured_mint_back(theirs) is None
+
+ ours = await resolve_handles(
+ {"session_id": sid("mine"), "agent_id": "THEIR-AGENT"},
+ o,
+ "proj",
+ None,
+ None,
+ frozenset({"session_id", "agent_id"}),
+ )
+ assert (ours.session_id, ours.session_source) == (sid("mine"), "supplied")
+ assert (ours.agent_id, ours.agent_source) == ("THEIR-AGENT", "supplied")
+ assert ours.prompts_session_id is True
+
+
+async def test_the_gate_is_per_handle():
+ """Injecting one handle and colliding on the other is a real shape: the
+ customer's schema declared `session_id` but not `agent_id`.
+
+ Suppression is per-handle. `agent_id` is a separate injection that DID
+ land in that tool's schema, so it is still ours to confirm — withholding
+ it because a neighbouring parameter belongs to the customer would leave
+ agents seeing agent_id confirmed on some tools and not others.
+ """
+ o = AgentCatOptions(enable_agent_tracking=True)
+ args = {"session_id": "SESSION-1234", "agent_id": "agt|x|1"}
+
+ r = await resolve_handles(
+ args,
+ o,
+ "proj",
+ None,
+ None,
+ frozenset({"agent_id"}),
+ session_param_is_ours=False,
+ )
+ assert r.session_source == "foreign" and r.session_id == ""
+ assert r.agent_id == "agt|x|1"
+ # There IS something to confirm — the agent_id — but it must not carry a
+ # session_id the agent has nowhere to put, and must not confirm the
+ # customer's own value back to the agent.
+ mint = build_structured_mint_back(r)
+ assert mint is not None and "session_id" not in mint
+ assert mint["agent_id"] == "agt|x|1"
+ assert "SESSION-1234" not in str(mint)
+
+
+async def test_a_composed_schema_tool_is_ours_to_read_but_never_prompted():
+ """The one shape where the two gates disagree.
+
+ A oneOf/allOf/anyOf schema skips the injection pass wholesale, so AgentCat
+ put no `session_id` on it — but the customer declared none either, so
+ nothing in the arguments belongs to them. It stays ours to correlate, and
+ stays silent because there is no parameter to name.
+
+ Deliberate divergence from the TS SDK, which emits the instruction here.
+ """
+ o = AgentCatOptions()
+ supplied = await resolve_handles(
+ {"session_id": sid("echoed")}, o, "proj", None, None, frozenset()
+ )
+ assert (supplied.session_id, supplied.session_source) == (sid("echoed"), "supplied")
+ assert supplied.prompts_session_id is False
+ assert build_mint_back_text(supplied) is None
+ assert build_structured_mint_back(supplied) is None
+
+ minted = await resolve_handles({}, o, "proj", None, None, frozenset())
+ assert minted.session_source == "minted"
+ assert minted.session_id.startswith("ses_")
+ assert build_mint_back_text(minted) is None
+
+
+async def test_no_registry_at_all_keeps_the_old_reading():
+ """The degraded path (tools/call before any listing, rebuild failed) is
+ where the strip removes all three names on a hunch. Handle extraction
+ follows the same hunch, so the two never disagree."""
+ o = AgentCatOptions()
+ r = await resolve_handles({"session_id": sid("x")}, o, "proj", None, None, None)
+ assert (r.session_id, r.session_source) == (sid("x"), "supplied")
+ assert r.prompts_session_id is True
+
+
+async def test_hook_mode_never_prompts_for_a_session_id():
+ o = AgentCatOptions(resolve_session_id=lambda request, extra: "corr-1")
+ r = await resolve_handles({}, o, "proj_1", None, None, frozenset())
+ assert r.hook_mode is True and r.prompts_session_id is False
+
+
+async def test_agent_extraction_gated_on_option():
+ on = AgentCatOptions(enable_agent_tracking=True)
+ r = await resolve_handles({"agent_id": " a|b|c "}, on, None, None, None)
+ assert (r.agent_id, r.agent_source) == ("a|b|c", "supplied")
+ off = AgentCatOptions()
+ r = await resolve_handles({"agent_id": "a|b|c"}, off, None, None, None)
+ assert r.agent_id is None
+
+
+def test_mint_back_text_rules():
+ minted = HandleResolution(session_id=sid("T"), session_source="minted")
+ assert build_mint_back_text(minted) == (
+ "[MCP INSTRUCTIONS]: session_id issued.\n"
+ f" session_id={sid('T')} — required on every subsequent tool call\n"
+ "Without session_id, this server does not function as intended."
+ )
+ assert build_mint_back_text(HandleResolution(sid("T"), "supplied")) is None
+ assert build_mint_back_text(HandleResolution(sid("T"), "minted", hook_mode=True)) is None # noqa: E501
+
+
+# TS handles.test.ts:91-118 — the text block is task-only; a supplied agent_id
+# never appears in it (the agent is never minted, so there is nothing to announce).
+def test_mint_back_text_ignores_a_supplied_agent():
+ with_agent = HandleResolution(
+ sid("T"), "minted", agent_id=A, agent_source="supplied"
+ )
+ text = build_mint_back_text(with_agent)
+ assert "agent_id" not in text
+ assert text == build_mint_back_text(HandleResolution(sid("T"), "minted"))
+
+
+def test_structured_mint_back_omission_rules():
+ both = HandleResolution(sid("T"), "supplied", agent_id="A", agent_source="supplied")
+ m = build_structured_mint_back(both)
+ assert m == {
+ "session_id": sid("T"),
+ "agent_id": "A",
+ "instructions": "[MCP INSTRUCTIONS]: session_id and agent_id confirmed. Keep sending these exact values on every call.", # noqa: E501
+ }
+ hook_agent = HandleResolution(
+ sid("T"), "hook", agent_id="A", agent_source="supplied", hook_mode=True
+ )
+ m = build_structured_mint_back(hook_agent)
+ assert "session_id" not in m and m["agent_id"] == "A" and "agent_id confirmed" in m["instructions"] # noqa: E501
+ assert build_structured_mint_back(HandleResolution(sid("T"), "hook", hook_mode=True)) is None # noqa: E501
+ minted = HandleResolution(sid("T"), "minted")
+ assert build_structured_mint_back(minted)["instructions"].startswith("[MCP INSTRUCTIONS]: session_id issued.") # noqa: E501
+
+
+# TS handles.test.ts:428-441 — a minted task with a supplied agent echoes both
+# ids but keeps the issued (not confirmed) copy, and never claims to have
+# issued an agent_id.
+def test_structured_mint_back_minted_task_with_supplied_agent():
+ m = build_structured_mint_back(
+ HandleResolution(sid("T"), "minted", agent_id=A, agent_source="supplied")
+ )
+ assert m["session_id"] == sid("T") and m["agent_id"] == A
+ assert "session_id issued" in m["instructions"]
+ assert "agent_id issued" not in m["instructions"]
+
+
+# TS handles.test.ts:458-469 — agent tracking off: task only, singular copy.
+def test_structured_mint_back_task_only_uses_singular_confirmed_copy():
+ m = build_structured_mint_back(HandleResolution(sid("T"), "supplied"))
+ assert m == {
+ "session_id": sid("T"),
+ "instructions": "[MCP INSTRUCTIONS]: session_id confirmed. Keep sending this exact value on every call.", # noqa: E501
+ }
+
+
+def test_mirror_rules():
+ mint = {"session_id": sid("T"), "instructions": "i"}
+ assert mirror_into_structured_content(None, mint) is None
+ assert mirror_into_structured_content([1], mint) is None
+ assert mirror_into_structured_content({"_mcp_instructions": "customer"}, mint) is None # noqa: E501
+ out = mirror_into_structured_content({"a": 1}, mint)
+ assert out == {"a": 1, "_mcp_instructions": mint}
+
+
+# TS handles.test.ts:498-516 — customer objects are never mutated, and any
+# non-plain-object structured content is left alone.
+def test_mirror_never_mutates_and_skips_non_mappings():
+ mint = {"session_id": sid("T"), "instructions": "i"}
+ sc = {"a": 1}
+ out = mirror_into_structured_content(sc, mint)
+ assert sc == {"a": 1} and out is not sc
+ assert mirror_into_structured_content("nope", mint) is None
+ assert mirror_into_structured_content(42, mint) is None
+
+
+def test_tag_clamp():
+ res = HandleResolution(sid("T"), "supplied", agent_id="a\r\nb" + "x" * 300, agent_source="supplied") # noqa: E501
+ tags = build_handle_tags(res, protocol_version="2026-07-28", mrtr="continuation")
+ assert tags["agentcat_session_id_source"] == "supplied"
+ assert tags["agentcat_agent_id"].startswith("a b") and len(tags["agentcat_agent_id"]) == 200 # noqa: E501
+ assert tags["agentcat_agent_id_source"] == "supplied"
+ assert tags["agentcat_protocol_version"] == "2026-07-28"
+ assert tags["agentcat_mrtr"] == "continuation"
+ # The protocol version is read off untrusted client meta and, like
+ # agent_id, is merged AFTER validate_tags — so nothing else bounds it.
+ hostile = build_handle_tags(res, protocol_version="2026\n" + "z" * 500)
+ assert len(hostile["agentcat_protocol_version"]) == 200
+ assert "\n" not in hostile["agentcat_protocol_version"]
+ assert build_handle_tags(HandleResolution("s", "minted")) == {"agentcat_session_id_source": "minted"} # noqa: E501
+
+
+# TS handles.test.ts:174-240 — the full tag map for a normal agent_id, and the
+# clamp/newline-strip applying to the tag copy only, never the resolution.
+def test_tags_pass_a_normal_agent_id_through():
+ res = HandleResolution(sid("T"), "supplied", agent_id=A, agent_source="supplied")
+ assert build_handle_tags(res, protocol_version="2026-07-28") == {
+ "agentcat_session_id_source": "supplied",
+ "agentcat_agent_id": A,
+ "agentcat_agent_id_source": "supplied",
+ "agentcat_protocol_version": "2026-07-28",
+ }
+
+
+def test_tag_clamp_and_newline_strip_leave_the_resolution_verbatim():
+ long_id = "a" * 500
+ res = HandleResolution(sid("T"), "supplied", agent_id=long_id, agent_source="supplied") # noqa: E501
+ assert build_handle_tags(res)["agentcat_agent_id"] == "a" * 200
+ assert res.agent_id == long_id
+
+ multiline = "line1\nline2\r\nline3"
+ res = HandleResolution(sid("T"), "supplied", agent_id=multiline, agent_source="supplied") # noqa: E501
+ assert build_handle_tags(res)["agentcat_agent_id"] == "line1 line2 line3"
+ assert res.agent_id == multiline
diff --git a/tests/test_hook_offload.py b/tests/test_hook_offload.py
new file mode 100644
index 0000000..2f49518
--- /dev/null
+++ b/tests/test_hook_offload.py
@@ -0,0 +1,207 @@
+"""run_hook: customer hooks may block, raise anything, or hang — never the loop.
+
+Regression suite for audit findings 10 (sync hooks ran inline on the event
+loop, so one blocking hook stalled every concurrent request) and 12 (hook
+isolation caught only Exception, so SystemExit / a spontaneous CancelledError
+rode the request path).
+
+The containment contract under test:
+- a blocking SYNC hook suspends only its own request — the loop keeps serving;
+- a hook slower than the timeout degrades that call and nothing else;
+- SystemExit and a hook's own CancelledError become HookExecutionError, a
+ plain Exception every call site's degradation rule already catches;
+- genuine cancellation of the enclosing task still propagates.
+"""
+
+import asyncio
+import sys
+import time
+from typing import Any
+
+import pytest
+
+from agentcat.modules.handles import resolve_handles
+from agentcat.modules.hooks import HookExecutionError, run_hook
+from agentcat.modules.identify import resolve_identity
+from agentcat.types import AgentCatData, AgentCatOptions
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+def _data(**option_overrides: Any) -> AgentCatData:
+ return AgentCatData(
+ project_id="proj_test",
+ options=AgentCatOptions(**option_overrides),
+ server_name="test-server",
+ server_version="1.0.0",
+ )
+
+
+# ── the loop stays responsive ────────────────────────────────────────────────
+
+
+async def test_blocking_sync_hook_does_not_stall_the_loop():
+ """Finding 10's repro: while a sync hook sleeps on its worker thread, a
+ sibling coroutine must keep running. Inline execution scores ~0 ticks."""
+ ticks = 0
+ running = True
+
+ async def ticker() -> None:
+ nonlocal ticks
+ while running:
+ ticks += 1
+ await asyncio.sleep(0.01)
+
+ def slow_hook(_request: Any, _extra: Any) -> str:
+ time.sleep(0.5)
+ return "done"
+
+ task = asyncio.create_task(ticker())
+ try:
+ result = await run_hook(slow_hook, "identify", None, None)
+ finally:
+ running = False
+ await task
+
+ assert result == "done"
+ assert ticks >= 10, f"loop was stalled: only {ticks} ticks during a 0.5s hook"
+
+
+# ── timeout ──────────────────────────────────────────────────────────────────
+
+
+async def test_sync_hook_timeout_degrades_and_logs(log_sink):
+ def wedged(_request: Any, _extra: Any) -> str:
+ time.sleep(3)
+ return "too late"
+
+ start = time.monotonic()
+ with pytest.raises(HookExecutionError):
+ await run_hook(wedged, "identify", None, None, timeout=0.2)
+ elapsed = time.monotonic() - start
+
+ assert elapsed < 1.0, f"timeout did not fire promptly: {elapsed:.1f}s"
+ assert any("timed out" in line for line in log_sink)
+
+
+async def test_async_hook_timeout_is_enforced_too():
+ async def wedged(_request: Any, _extra: Any) -> str:
+ await asyncio.sleep(3)
+ return "too late"
+
+ start = time.monotonic()
+ with pytest.raises(HookExecutionError):
+ await run_hook(wedged, "event_tags", None, None, timeout=0.2)
+ assert time.monotonic() - start < 1.0
+
+
+# ── containment of BaseException escapees ────────────────────────────────────
+
+
+async def test_sync_hook_system_exit_is_contained():
+ def exits(_request: Any, _extra: Any) -> None:
+ sys.exit(3)
+
+ with pytest.raises(HookExecutionError, match="SystemExit"):
+ await run_hook(exits, "identify", None, None)
+
+
+async def test_sync_hook_spontaneous_cancelled_error_is_contained():
+ """A CancelledError raised BY the hook (e.g. .result() on a cancelled
+ future from its cache layer) is the hook failing, not us being cancelled."""
+
+ def cancels(_request: Any, _extra: Any) -> None:
+ raise asyncio.CancelledError()
+
+ with pytest.raises(HookExecutionError, match="CancelledError"):
+ await run_hook(cancels, "resolve_session_id", None, None)
+
+
+async def test_async_hook_system_exit_is_contained():
+ async def exits(_request: Any, _extra: Any) -> None:
+ sys.exit(3)
+
+ with pytest.raises(HookExecutionError, match="SystemExit"):
+ await run_hook(exits, "event_properties", None, None)
+
+
+@pytest.mark.skipif(
+ sys.version_info < (3, 11),
+ reason="Task.cancelling() probe needs 3.11; 3.10 conservatively re-raises",
+)
+async def test_async_hook_spontaneous_cancelled_error_is_contained():
+ async def cancels(_request: Any, _extra: Any) -> None:
+ raise asyncio.CancelledError()
+
+ with pytest.raises(HookExecutionError, match="CancelledError"):
+ await run_hook(cancels, "identify", None, None)
+
+
+async def test_genuine_task_cancellation_still_propagates():
+ """Client disconnects mid-call: the enclosing task's cancellation must not
+ be swallowed by hook containment."""
+
+ def wedged(_request: Any, _extra: Any) -> str:
+ time.sleep(3)
+ return "unreachable"
+
+ task = asyncio.create_task(run_hook(wedged, "identify", None, None))
+ await asyncio.sleep(0.05) # let it enter the hook
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+
+# ── ordinary failures keep their shape ───────────────────────────────────────
+
+
+async def test_sync_hook_exception_becomes_hook_execution_error():
+ def broken(_request: Any, _extra: Any) -> None:
+ raise ValueError("boom")
+
+ with pytest.raises(HookExecutionError, match="ValueError"):
+ await run_hook(broken, "identify", None, None)
+
+
+async def test_well_behaved_hooks_are_unchanged():
+ def sync_hook(_request: Any, _extra: Any) -> str:
+ return "sync"
+
+ async def async_hook(_request: Any, _extra: Any) -> str:
+ return "async"
+
+ assert await run_hook(sync_hook, "identify", None, None) == "sync"
+ assert await run_hook(async_hook, "identify", None, None) == "async"
+
+
+# ── the call sites degrade by their own rules ────────────────────────────────
+
+
+async def test_identify_degrades_to_anonymous_on_system_exit():
+ def exits(_request: Any, _extra: Any) -> Any:
+ sys.exit(3)
+
+ data = _data(identify=exits)
+ assert await resolve_identity(data, None, None) is None
+
+
+async def test_resolve_session_id_mints_on_system_exit():
+ def exits(_request: Any, _extra: Any) -> str:
+ sys.exit(3)
+
+ resolution = await resolve_handles(
+ {}, AgentCatOptions(resolve_session_id=exits), "proj_test", None, None
+ )
+ assert resolution.session_source == "minted"
+ assert resolution.session_id.startswith("ses_")
+ assert resolution.hook_mode is True
diff --git a/tests/test_identify_shape.py b/tests/test_identify_shape.py
new file mode 100644
index 0000000..0beea6b
--- /dev/null
+++ b/tests/test_identify_shape.py
@@ -0,0 +1,188 @@
+"""The object AgentCat hands the customer's hooks, pinned on every flavor.
+
+`identify`, `event_tags`, `event_properties` and `resolve_session_id` all receive
+the same `(request, extra)` pair, and `request` is the tool call's **params** —
+the model carrying `.name` and `.arguments` — never the enclosing JSON-RPC
+request. That is the only shape all four adapters can produce: mcp 2.x hands
+its handler `(ctx, params)` with no request object in scope, and community
+FastMCP's `context.message` is params as well, so the official 1.x adapter
+unwraps `request.params` to match.
+
+Why this module exists at all: `identify.py` swallows every exception a hook
+raises, so a hook written against the WRONG shape does not fail — it silently
+yields an anonymous event. The whole v2 branch shipped with `identify`
+integration coverage on the legacy e2e suites only, and neither of those reads
+the argument dict, so an adapter handing over a different object was invisible.
+Here the hook indexes `request.arguments` on every shape a customer can hand
+`track()`, and the assertion is on the published event, so a regression is a
+failure rather than a `None`.
+"""
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.types import UserIdentity
+
+from .test_utils.flavors import flavors
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_identify_reads_the_arguments_off_the_request_it_is_given(
+ flavor, capture
+):
+ """The README's own example, run against every server shape.
+
+ A hook that reaches for `.name` / `.arguments` — one hop, not two — works
+ everywhere, and the actor it returns reaches the event.
+ """
+ shapes: list = []
+
+ def identify(request, extra):
+ shapes.append(request)
+ return UserIdentity(
+ user_id=f"user-{request.arguments['text']}",
+ user_name=request.name,
+ user_data={"tool": request.name},
+ )
+
+ built = flavor.build("identify-shape")
+ track(built.server, "proj_test", AgentCatOptions(identify=identify))
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ result = await flavor.call(client, "echo", {"text": "hi"})
+
+ assert result.is_error is False
+ assert len(shapes) == 1
+ # Params, not the enclosing request: a `.params` attribute here would mean
+ # this flavor hands over one more layer than the contract promises.
+ assert not hasattr(shapes[0], "params")
+
+ assert len(capture) == 1
+ event = capture[0]
+ assert event.identify_actor_given_id == "user-hi"
+ assert event.identify_actor_name == "echo"
+ assert event.identify_data == {"tool": "echo"}
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_an_async_identify_hook_reaches_the_event(flavor, capture, log_sink):
+ """`identify` may be written `async def`, on every server shape.
+
+ This test used to assert the opposite. `resolve_identity` called the hook
+ and took its return value verbatim, so an `async def` was CALLED but never
+ ran — calling it only builds a coroutine — which then failed the
+ `isinstance(result, UserIdentity)` check and published the call anonymously,
+ with no error the customer could see. The inverted version existed to fail
+ the day the contract moved, so the change would be made on purpose rather
+ than discovered. `modules/hooks.py` moved it.
+
+ The hook body running at all is asserted separately from the actor landing:
+ an awaited hook whose result went nowhere and a never-awaited hook both end
+ in an anonymous event, and only the first assertion tells them apart.
+ """
+ body_ran: list[str] = []
+
+ async def identify(request, extra):
+ body_ran.append(request.name)
+ return UserIdentity(
+ user_id=f"user-{request.arguments['text']}",
+ user_name=request.name,
+ user_data={"tool": request.name},
+ )
+
+ built = flavor.build("identify-shape")
+ track(built.server, "proj_test", AgentCatOptions(identify=identify))
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ result = await flavor.call(client, "echo", {"text": "hi"})
+
+ assert result.is_error is False
+ assert body_ran == ["echo"], "the coroutine was created but never driven"
+
+ assert len(capture) == 1
+ assert capture[0].identify_actor_given_id == "user-hi"
+ assert capture[0].identify_actor_name == "echo"
+ assert capture[0].identify_data == {"tool": "echo"}
+
+ # The old failure mode logged its way past; nothing should now.
+ assert not [ln for ln in log_sink if "did not return a valid UserIdentity" in ln]
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_every_customer_hook_receives_the_same_pair(flavor, capture):
+ """`identify`, `event_tags`, `event_properties` and `resolve_session_id` are
+ documented to share one signature — so they must share one object."""
+ seen: dict[str, tuple] = {}
+
+ def record(name):
+ def hook(request, extra):
+ seen[name] = (request, extra)
+ return None
+
+ return hook
+
+ def tags(request, extra):
+ seen["event_tags"] = (request, extra)
+ return {"tool": request.name}
+
+ def properties(request, extra):
+ seen["event_properties"] = (request, extra)
+ return {"args": dict(request.arguments or {})}
+
+ def resolve_session_id(request, extra):
+ seen["resolve_session_id"] = (request, extra)
+ return f"task-for-{request.name}"
+
+ built = flavor.build("identify-shape")
+ track(
+ built.server,
+ "proj_test",
+ AgentCatOptions(
+ identify=record("identify"),
+ event_tags=tags,
+ event_properties=properties,
+ resolve_session_id=resolve_session_id,
+ ),
+ )
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ await flavor.call(client, "echo", {"text": "hi"})
+
+ assert set(seen) == {
+ "identify",
+ "event_tags",
+ "event_properties",
+ "resolve_session_id",
+ }
+ pairs = list(seen.values())
+ assert all(pair[0] is pairs[0][0] for pair in pairs)
+ assert all(pair[1] is pairs[0][1] for pair in pairs)
+
+ assert len(capture) == 1
+ assert capture[0].tags["tool"] == "echo"
+ assert capture[0].properties == {"args": {"text": "hi"}}
diff --git a/tests/test_injection.py b/tests/test_injection.py
new file mode 100644
index 0000000..52d681a
--- /dev/null
+++ b/tests/test_injection.py
@@ -0,0 +1,577 @@
+"""Pure injection pipeline: schema mutation, registries, argument stripping.
+
+The pipeline is deliberately pure — it takes the adapter's deep copies of the
+customer's schemas, mutates them in place, and reports what it did through two
+registries. Every assertion here is a wire-visible contract: what an MCP client
+sees in tools/list, and what the customer's tool callback receives.
+"""
+
+import copy
+
+import pytest
+
+from agentcat.modules import constants as c
+from agentcat.modules.injection import (
+ InjectionResult,
+ ToolSpec,
+ build_injected_schemas,
+ injected_parameter_names,
+ mcp_instructions_schema_property,
+ strip_injected_arguments,
+)
+from agentcat.types import AgentCatOptions
+
+from .test_utils import sid
+
+
+def spec(name="t", props=None, extra=None, out=None):
+ # props={} must mean "an empty properties bag", not "give me the default".
+ default = {"q": {"type": "string"}}
+ schema = {"type": "object", "properties": dict(default if props is None else props)}
+ schema.update(extra or {})
+ return ToolSpec(name=name, input_schema=schema, output_schema=out)
+
+
+# ── Brief cases ──────────────────────────────────────────────────────────────
+
+
+def test_param_order_and_descriptions():
+ s = spec()
+ r = build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ keys = list(s.input_schema["properties"])
+ assert keys == ["q", "session_id", "agent_id", "context"]
+ props = s.input_schema["properties"]
+ assert props["session_id"]["description"] == c.SESSION_ID_PARAM_DESCRIPTION
+ assert props["agent_id"]["description"] == c.AGENT_ID_PARAM_DESCRIPTION
+ assert "session_id" not in s.input_schema.get("required", [])
+ assert "agent_id" in s.input_schema["required"]
+ assert r.injected_params["t"] == {"session_id", "agent_id", "context"}
+
+
+def test_hook_mode_omits_session_id_and_switches_agent_copy():
+ s = spec()
+ build_injected_schemas(
+ [s],
+ AgentCatOptions(
+ enable_agent_tracking=True, resolve_session_id=lambda q, e: "x"
+ ),
+ )
+ props = s.input_schema["properties"]
+ assert "session_id" not in props
+ assert props["agent_id"]["description"] == c.AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE
+
+
+def test_tracing_disabled_skips_handles_but_not_context():
+ s = spec()
+ r = build_injected_schemas([s], AgentCatOptions(enable_tracing=False))
+ assert set(s.input_schema["properties"]) == {"q", "context"}
+ assert r.injected_params["t"] == {"context"}
+
+
+def test_additional_properties_false_removed():
+ s = spec(extra={"additionalProperties": False})
+ build_injected_schemas([s], AgentCatOptions())
+ assert "additionalProperties" not in s.input_schema
+
+
+def test_composed_schema_skipped_with_empty_registry_entry():
+ s = ToolSpec(name="t", input_schema={"oneOf": [{"type": "object"}]})
+ r = build_injected_schemas([s], AgentCatOptions())
+ assert s.input_schema == {"oneOf": [{"type": "object"}]}
+ assert r.injected_params["t"] == set()
+
+
+def test_collision_skips_that_param_only():
+ s = spec(props={"session_id": {"type": "string", "description": "customer"}})
+ r = build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ assert s.input_schema["properties"]["session_id"]["description"] == "customer"
+ assert r.injected_params["t"] == {"agent_id", "context"}
+
+
+def test_get_more_tools_gets_handles_but_not_context():
+ s = spec(name=c.GET_MORE_TOOLS_NAME, props={"context": {"type": "string"}})
+ r = build_injected_schemas([s], AgentCatOptions())
+ assert r.injected_params[c.GET_MORE_TOOLS_NAME] == {"session_id"}
+
+
+def test_output_schema_extension_and_registry():
+ s = spec(out={"type": "object", "properties": {"answer": {"type": "string"}}})
+ r = build_injected_schemas([s], AgentCatOptions())
+ prop = s.output_schema["properties"][c.MCP_INSTRUCTIONS_KEY]
+ assert prop["description"] == c.MCP_INSTRUCTIONS_FIELD_DESCRIPTION
+ task_prop = prop["properties"]["session_id"]
+ assert task_prop["description"] == c.MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION
+ # Default options are prompted mode with agent tracking off, so the copy
+ # never mentions an agent_id the agent was not asked for.
+ assert list(prop["properties"]) == ["session_id", "instructions"]
+ assert c.MCP_INSTRUCTIONS_KEY not in s.output_schema.get("required", [])
+ assert r.output_injected == {"t"}
+ s2 = spec(out={"oneOf": []})
+ r2 = build_injected_schemas([s2], AgentCatOptions())
+ assert r2.output_injected == set()
+
+
+def test_determinism():
+ def make():
+ return [spec(), spec(name="u", extra={"additionalProperties": False})]
+
+ a, b = make(), make()
+ ra = build_injected_schemas(a, AgentCatOptions(enable_agent_tracking=True))
+ rb = build_injected_schemas(b, AgentCatOptions(enable_agent_tracking=True))
+ assert ra == rb and [t.input_schema for t in a] == [t.input_schema for t in b]
+
+
+def test_strip_registry_driven_and_heuristic():
+ args = {"q": 1, "session_id": "s", "agent_id": "a", "context": "c"}
+ reg = {"t": {"session_id", "context"}}
+ out = strip_injected_arguments("t", args, reg)
+ assert out == {"q": 1, "agent_id": "a"} and args["session_id"] == "s" # clone
+ # Registry present but tool not in it: it was never advertised through the
+ # pipeline, so nothing was injected for it — strip nothing.
+ assert strip_injected_arguments("unknown", args, reg) == args
+ # Registry unknown (rebuild failed): shape+config-aware. "s" is not a
+ # minted-shape handle, so it is presumed the CUSTOMER's parameter and
+ # spared; agent_id survives because agent tracking is off by default;
+ # only context — which default options would have injected — is stripped.
+ assert strip_injected_arguments("t", args, None) == {
+ "q": 1,
+ "session_id": "s",
+ "agent_id": "a",
+ }
+ # A minted-shape value IS stripped on the same degraded path...
+ minted_args = {"q": 1, "session_id": sid("mine"), "context": "c"}
+ assert strip_injected_arguments("t", minted_args, None) == {"q": 1}
+ # ...and agent_id joins only when the option that injects it is on.
+ tracking_on = AgentCatOptions(enable_agent_tracking=True)
+ assert strip_injected_arguments("t", args, None, tracking_on) == {
+ "q": 1,
+ "session_id": "s",
+ }
+ gmt = {"context": "real", "session_id": sid("theirs")}
+ stripped = strip_injected_arguments(c.GET_MORE_TOOLS_NAME, gmt, None)
+ assert stripped == {"context": "real"}
+
+
+# ── Ported from the TS SDK: required-array semantics ─────────────────────────
+# src/tests/handle-injection.test.ts — "creates the required array when the
+# schema has none", "does not duplicate agent_id in an existing required
+# array", "leaves required untouched when the customer declares agent_id".
+
+
+def test_required_list_preserved_when_appending_agent_id():
+ s = spec(props={"text": {"type": "string"}}, extra={"required": ["text"]})
+ build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ assert s.input_schema["required"] == ["text", "agent_id", "context"]
+
+
+def test_required_is_created_for_the_params_that_need_it():
+ with_agent = spec()
+ build_injected_schemas([with_agent], AgentCatOptions(enable_agent_tracking=True))
+ assert with_agent.input_schema["required"] == ["agent_id", "context"]
+
+ # session_id is the one injected param that is never required — omitting it
+ # is how an agent asks to be minted one — but context is, so a schema that
+ # declared no required array does grow one.
+ without_agent = spec()
+ build_injected_schemas([without_agent], AgentCatOptions())
+ assert without_agent.input_schema["required"] == ["context"]
+
+ # ...and with the context pass off there is nothing to require at all.
+ neither = spec()
+ build_injected_schemas([neither], AgentCatOptions(enable_tool_call_context=False))
+ assert "required" not in neither.input_schema
+
+
+def test_agent_id_not_duplicated_in_existing_required_array():
+ # A schema can list a name in required without declaring the property;
+ # injection must not push a second copy.
+ s = spec(props={}, extra={"required": ["agent_id"]})
+ build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ assert s.input_schema["required"] == ["agent_id", "context"]
+
+
+def test_customer_declared_agent_id_leaves_required_untouched():
+ s = spec(
+ props={"agent_id": {"type": "string", "description": "mine"}},
+ extra={"required": []},
+ )
+ r = build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ # agent_id is theirs, so it is neither redescribed nor required by us.
+ # context is still ours, and still required.
+ assert s.input_schema["required"] == ["context"]
+ assert s.input_schema["properties"]["agent_id"]["description"] == "mine"
+ assert "agent_id" not in r.injected_params["t"]
+
+
+def test_empty_properties_tool_receives_all_params():
+ s = spec(props={})
+ r = build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ assert list(s.input_schema["properties"]) == ["session_id", "agent_id", "context"]
+ assert r.injected_params["t"] == {"session_id", "agent_id", "context"}
+
+
+# ── Ported: additionalProperties variants ────────────────────────────────────
+# Only the `false` form blocks optional handles; every other form is the
+# customer's business and must survive untouched.
+
+
+def test_additional_properties_true_and_dict_forms_untouched():
+ permissive = spec(name="p", extra={"additionalProperties": True})
+ schema_form = spec(name="s", extra={"additionalProperties": {"type": "string"}})
+ build_injected_schemas([permissive, schema_form], AgentCatOptions())
+ assert permissive.input_schema["additionalProperties"] is True
+ assert schema_form.input_schema["additionalProperties"] == {"type": "string"}
+
+
+# ── Ported: deep-copy isolation ──────────────────────────────────────────────
+# The adapter hands the pipeline deep copies and the pipeline mutates them in
+# place, so nested customer structures must come out byte-identical and the
+# minted _mcp_instructions fragment must never be shared between tools.
+
+
+def test_nested_customer_schema_is_not_mutated():
+ nested = {
+ "type": "object",
+ "properties": {
+ "filter": {
+ "type": "object",
+ "properties": {"tags": {"type": "array", "items": {"type": "string"}}},
+ "required": ["tags"],
+ }
+ },
+ }
+ original = copy.deepcopy(nested["properties"]["filter"])
+ s = ToolSpec(name="t", input_schema=nested)
+ build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ assert s.input_schema["properties"]["filter"] == original
+
+
+def test_pipeline_mutates_in_place_rather_than_replacing_schemas():
+ s = spec()
+ schema_ref = s.input_schema
+ build_injected_schemas([s], AgentCatOptions())
+ assert s.input_schema is schema_ref
+
+
+def test_mcp_instructions_fragment_is_not_shared_between_tools():
+ a = spec(name="a", out={"type": "object", "properties": {}})
+ b = spec(name="b", out={"type": "object", "properties": {}})
+ build_injected_schemas([a, b], AgentCatOptions())
+ frag_a = a.output_schema["properties"][c.MCP_INSTRUCTIONS_KEY]
+ frag_b = b.output_schema["properties"][c.MCP_INSTRUCTIONS_KEY]
+ assert frag_a == frag_b
+ frag_a["properties"]["session_id"]["description"] = "mutated"
+ assert frag_b["properties"]["session_id"]["description"] != "mutated"
+
+
+def test_mcp_instructions_schema_property_shape():
+ frag = mcp_instructions_schema_property(True, True)
+ assert frag["type"] == "object"
+ assert frag["description"] == c.MCP_INSTRUCTIONS_FIELD_DESCRIPTION
+ assert list(frag["properties"]) == ["session_id", "agent_id", "instructions"]
+ task_prop = frag["properties"]["session_id"]
+ assert task_prop["description"] == c.MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION
+ agent_prop = frag["properties"]["agent_id"]
+ assert agent_prop["description"] == c.MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION
+ assert frag["properties"]["instructions"] == {"type": "string"}
+ assert mcp_instructions_schema_property(True, True) is not frag
+
+
+def test_mcp_instructions_schema_property_tracks_the_flags():
+ # The copy never references a parameter the agent cannot see, so each
+ # sub-property is gated by the handle that produced it. `instructions` is
+ # unconditional.
+ prompted_only = mcp_instructions_schema_property(True, False)
+ assert list(prompted_only["properties"]) == ["session_id", "instructions"]
+ hook_only = mcp_instructions_schema_property(False, True)
+ assert list(hook_only["properties"]) == ["agent_id", "instructions"]
+
+
+# ── Ported: registry completeness & injection order ──────────────────────────
+
+
+def test_registry_has_an_entry_for_every_tool():
+ plain = spec(name="plain")
+ composed = ToolSpec(name="composed", input_schema={"allOf": [{"type": "object"}]})
+ owns_all = spec(
+ name="owns_all",
+ props={
+ "session_id": {"type": "string"},
+ "agent_id": {"type": "string"},
+ "context": {"type": "string"},
+ },
+ )
+ r = build_injected_schemas(
+ [plain, composed, owns_all], AgentCatOptions(enable_agent_tracking=True)
+ )
+ assert set(r.injected_params) == {"plain", "composed", "owns_all"}
+ assert r.injected_params["composed"] == set()
+ assert r.injected_params["owns_all"] == set()
+
+
+def test_context_disabled_leaves_only_handles():
+ s = spec()
+ r = build_injected_schemas(
+ [s],
+ AgentCatOptions(enable_agent_tracking=True, enable_tool_call_context=False),
+ )
+ assert list(s.input_schema["properties"]) == ["q", "session_id", "agent_id"]
+ assert r.injected_params["t"] == {"session_id", "agent_id"}
+
+
+def test_context_uses_custom_description():
+ s = spec()
+ build_injected_schemas([s], AgentCatOptions(custom_context_description="why?"))
+ assert s.input_schema["properties"]["context"]["description"] == "why?"
+
+
+def test_get_more_tools_keeps_bespoke_context_and_gains_both_handles():
+ s = spec(
+ name=c.GET_MORE_TOOLS_NAME,
+ props={"context": {"type": "string", "description": "bespoke"}},
+ extra={"required": ["context"]},
+ )
+ r = build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ props = s.input_schema["properties"]
+ assert list(props) == ["context", "session_id", "agent_id"]
+ assert props["context"]["description"] == "bespoke"
+ assert r.injected_params[c.GET_MORE_TOOLS_NAME] == {"session_id", "agent_id"}
+
+
+# ── Ported: outputSchema edges ───────────────────────────────────────────────
+# src/tests/handle-injection.test.ts "outputSchema injection" + v2
+# schema-edges.test.ts "outputSchema injection".
+
+
+def test_output_schema_contract_otherwise_untouched():
+ out = {
+ "type": "object",
+ "properties": {"count": {"type": "number"}},
+ "required": ["count"],
+ "additionalProperties": False,
+ }
+ s = spec(out=out)
+ build_injected_schemas([s], AgentCatOptions())
+ # additionalProperties: false stays on the output side — our property is
+ # declared, so it does not need loosening.
+ assert s.output_schema["additionalProperties"] is False
+ assert s.output_schema["required"] == ["count"]
+ assert s.output_schema["properties"]["count"] == {"type": "number"}
+
+
+def test_customer_declared_mcp_instructions_output_never_clobbered():
+ out = {
+ "type": "object",
+ "properties": {
+ c.MCP_INSTRUCTIONS_KEY: {"type": "string", "description": "customer-owned"}
+ },
+ }
+ s = spec(out=out)
+ r = build_injected_schemas([s], AgentCatOptions())
+ prop = s.output_schema["properties"][c.MCP_INSTRUCTIONS_KEY]
+ assert prop == {"type": "string", "description": "customer-owned"}
+ assert r.output_injected == set()
+
+
+def test_no_output_schema_registers_nothing():
+ s = spec()
+ r = build_injected_schemas([s], AgentCatOptions())
+ assert s.output_schema is None
+ assert r.output_injected == set()
+
+
+def test_output_schema_without_properties_bag_gets_one_created():
+ # A declared-but-bare object schema is still a single extendable bag;
+ # the pipeline mints `properties` rather than skipping the tool.
+ s = spec(out={"type": "object"})
+ r = build_injected_schemas([s], AgentCatOptions())
+ assert list(s.output_schema["properties"]) == [c.MCP_INSTRUCTIONS_KEY]
+ assert r.output_injected == {"t"}
+
+
+def test_composed_input_schema_skips_output_injection_too():
+ out = {"type": "object", "properties": {"count": {"type": "number"}}}
+ s = ToolSpec(
+ name="mixed",
+ input_schema={"anyOf": [{"type": "object"}]},
+ output_schema=out,
+ )
+ r = build_injected_schemas([s], AgentCatOptions())
+ assert c.MCP_INSTRUCTIONS_KEY not in s.output_schema["properties"]
+ assert r.output_injected == set()
+ assert r.injected_params["mixed"] == set()
+
+
+# ── Handle-pass gating ───────────────────────────────────────────────────────
+# handle-injection.ts:59 returns early unless at least one handle is
+# injectable, and listWrap.ts:50-62 computes the two flags as
+# inject_session_id = enable_tracing and resolve_session_id is None
+# inject_agent_id = enable_tracing and enable_agent_tracking
+# The output-schema extension lives inside that pass, so it is gated too.
+# Context injection is a separate pass and runs regardless.
+
+
+def _out():
+ return {"type": "object", "properties": {"count": {"type": "number"}}}
+
+
+def test_tracing_disabled_leaves_output_schemas_untouched():
+ s = spec(out=_out())
+ r = build_injected_schemas([s], AgentCatOptions(enable_tracing=False))
+ assert s.output_schema == _out()
+ assert r.output_injected == set()
+ assert r.injected_params["t"] == {"context"}
+
+
+def test_hook_mode_without_agent_tracking_skips_the_whole_handle_pass():
+ s = spec(out=_out())
+ r = build_injected_schemas(
+ [s], AgentCatOptions(resolve_session_id=lambda q, e: "x")
+ )
+ assert set(s.input_schema["properties"]) == {"q", "context"}
+ assert s.output_schema == _out()
+ assert r.output_injected == set()
+ assert r.injected_params["t"] == {"context"}
+
+
+def test_no_injectable_handle_and_no_context_leaves_an_empty_registry_entry():
+ s = spec(out=_out())
+ r = build_injected_schemas(
+ [s], AgentCatOptions(enable_tracing=False, enable_tool_call_context=False)
+ )
+ assert set(s.input_schema["properties"]) == {"q"}
+ assert r.injected_params["t"] == set()
+ assert r.output_injected == set()
+
+
+def test_hook_mode_with_agent_tracking_extends_output_without_session_id():
+ s = spec(out=_out())
+ r = build_injected_schemas(
+ [s],
+ AgentCatOptions(
+ enable_agent_tracking=True, resolve_session_id=lambda q, e: "x"
+ ),
+ )
+ frag = s.output_schema["properties"][c.MCP_INSTRUCTIONS_KEY]
+ assert list(frag["properties"]) == ["agent_id", "instructions"]
+ assert r.output_injected == {"t"}
+
+
+def test_prompted_mode_with_agent_tracking_extends_output_with_both():
+ s = spec(out=_out())
+ build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ frag = s.output_schema["properties"][c.MCP_INSTRUCTIONS_KEY]
+ assert list(frag["properties"]) == ["session_id", "agent_id", "instructions"]
+
+
+# ── Input-schema normalization ───────────────────────────────────────────────
+# handle-injection.ts:82-89 — an absent schema becomes a bare object schema,
+# and an existing schema missing its properties bag has one created.
+
+
+def test_empty_input_schema_is_normalized_before_injection():
+ s = ToolSpec(name="t", input_schema={})
+ build_injected_schemas([s], AgentCatOptions())
+ assert s.input_schema["type"] == "object"
+ assert list(s.input_schema["properties"]) == ["session_id", "context"]
+ assert s.input_schema["required"] == ["context"]
+
+
+def test_input_schema_without_properties_bag_gets_one_created():
+ s = ToolSpec(name="t", input_schema={"type": "object", "title": "Bare"})
+ r = build_injected_schemas([s], AgentCatOptions(enable_agent_tracking=True))
+ assert list(s.input_schema["properties"]) == ["session_id", "agent_id", "context"]
+ assert s.input_schema["title"] == "Bare"
+ assert r.injected_params["t"] == {"session_id", "agent_id", "context"}
+
+
+# ── Ported: strip semantics ──────────────────────────────────────────────────
+
+
+def test_strip_preserves_a_customer_owned_session_id():
+ # The registry records only what the pipeline injected, so a tool that
+ # declares its own session_id keeps the caller's value.
+ reg = {"deploy": {"agent_id", "context"}}
+ args = {"session_id": "prod-42", "agent_id": "agt_1", "context": "why"}
+ assert strip_injected_arguments("deploy", args, reg) == {"session_id": "prod-42"}
+
+
+def test_injected_names_is_the_single_source_the_strip_reads():
+ """The strip and the handle extraction MUST agree, so they read one
+ function. A name this does not report is the customer's parameter: it stays
+ in their arguments and it is not ours to read as a handle."""
+ reg = {"deploy": {"agent_id", "context"}, "t": {"session_id", "context"}}
+ assert injected_parameter_names("deploy", reg) == frozenset({"agent_id", "context"})
+ assert injected_parameter_names("t", reg) == frozenset({"session_id", "context"})
+ # Registry present, tool absent: never advertised through the pipeline.
+ assert injected_parameter_names("unknown", reg) == frozenset()
+ # No registry at all (rebuild failed): the shape+config-aware fallback,
+ # which the strip follows too — so the two stay consistent even on the
+ # degraded path. With no arguments, session_id counts as ours (absence is
+ # the minting signal); agent_id needs its option on.
+ assert injected_parameter_names("t", None) == frozenset(
+ {"session_id", "context"}
+ )
+ assert injected_parameter_names(
+ "t", None, options=AgentCatOptions(enable_agent_tracking=True)
+ ) == frozenset({"session_id", "agent_id", "context"})
+ # A non-minted-shape value flips session_id to "the customer's parameter".
+ assert injected_parameter_names(
+ "t", None, {"session_id": "TICKET-9"}
+ ) == frozenset({"context"})
+ # A minted-shape value keeps it ours.
+ assert injected_parameter_names(
+ "t", None, {"session_id": sid("mine")}
+ ) == frozenset({"session_id", "context"})
+ # A resolve_session_id hook never injects session_id, so it never strips it.
+ assert injected_parameter_names(
+ "t", None, options=AgentCatOptions(resolve_session_id=lambda req, extra: "x")
+ ) == frozenset({"context"})
+ assert injected_parameter_names(c.GET_MORE_TOOLS_NAME, None) == frozenset(
+ {"session_id"}
+ )
+
+
+@pytest.mark.parametrize(
+ "registry",
+ [{"deploy": {"agent_id", "context"}}, {"deploy": set()}, {}, None],
+ ids=["own-task-id", "nothing-injected", "unlisted", "no-registry"],
+)
+def test_what_the_strip_removes_is_what_the_names_report(registry):
+ args = {"session_id": "prod-42", "agent_id": "agt_1", "context": "why", "q": 1}
+ names = injected_parameter_names("deploy", registry, args)
+ stripped = strip_injected_arguments("deploy", args, registry)
+ assert set(args) - set(stripped) == names & set(args)
+
+
+def test_strip_always_returns_a_new_dict():
+ args = {"q": 1}
+ for registry in ({"t": set()}, None, {}):
+ out = strip_injected_arguments("t", args, registry)
+ assert out is not args
+ assert strip_injected_arguments("t", {}, None) == {}
+
+
+def test_strip_heuristic_leaves_unrelated_arguments_alone():
+ # Non-minted session_id and default options: only context is ours to take.
+ args = {"text": "x", "session_id": "a", "agent_id": "b", "context": "c", "id": 7}
+ assert strip_injected_arguments("any", args, None) == {
+ "text": "x",
+ "session_id": "a",
+ "agent_id": "b",
+ "id": 7,
+ }
+ # Minted-shape session_id plus agent tracking on: the full strip, with the
+ # unrelated arguments still untouched.
+ minted = {"text": "x", "session_id": sid("m"), "agent_id": "b", "context": "c", "id": 7}
+ tracking_on = AgentCatOptions(enable_agent_tracking=True)
+ assert strip_injected_arguments("any", minted, None, tracking_on) == {
+ "text": "x",
+ "id": 7,
+ }
+
+
+def test_injection_result_equality_is_value_based():
+ a = InjectionResult(injected_params={"t": {"context"}}, output_injected=set())
+ b = InjectionResult(injected_params={"t": {"context"}}, output_injected=set())
+ assert a == b
diff --git a/tests/test_inner_tap.py b/tests/test_inner_tap.py
new file mode 100644
index 0000000..dd67cbf
--- /dev/null
+++ b/tests/test_inner_tap.py
@@ -0,0 +1,1356 @@
+"""The inner tap: one contract, proven on every server generation.
+
+v2 intercepts at the protocol boundary, where most SDK generations have already
+caught the customer's exception and flattened it into an `isError` result. The
+tap is what puts the type, the traceback and the `__cause__` chain back on the
+event. This module proves three things:
+
+- **the contract** — `inner_tap()` opens a slot, `capture()` fills it, the slot
+ closes on every exit path, and `error()` falls back to the flattened result
+ when nothing local ever raised;
+- **the concurrency argument** — parallel calls cannot read each other's
+ exception, and the proof is structural rather than a lucky single run;
+- **per-era placement** — official FastMCP v1, bare lowlevel v1, MCPServer,
+ bare lowlevel v2 and both community FastMCP eras each recover the detail,
+ and the customer's wire result is what an untracked server returns.
+
+Both eras live here on purpose: splitting the file across the two
+conftest-gated trees would hide the parity it exists to show. The era classes
+carry `LEGACY_ONLY` / `MODERN_ONLY`; the community class runs on both, because
+`--extra community` installs FastMCP 3 beside mcp 1.x and FastMCP 4 beside
+mcp 2.x.
+"""
+
+import asyncio
+import contextlib
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.adapters._inner_tap import (
+ _open_cell,
+ capture,
+ capture_in_flight,
+ inner_tap,
+ probing,
+ tap_method,
+ tapped,
+)
+
+from .test_utils import (
+ LEGACY_ONLY,
+ MODERN_ONLY,
+ NEEDS_CONCURRENT_DISPATCH,
+ NEEDS_LOWLEVEL_ERROR_SEAM,
+)
+
+try:
+ import fastmcp # noqa: F401
+
+ HAS_COMMUNITY = True
+except ImportError: # pragma: no cover - community extra not installed
+ HAS_COMMUNITY = False
+
+
+class Boom(Exception):
+ """Raised only by this module's tools, so a frame for it is unambiguous."""
+
+
+@pytest.fixture
+def events(monkeypatch):
+ """Every event the queue is handed, without touching the network."""
+ collected: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", collected.append)
+ return collected
+
+
+def _call_events(events, name):
+ return [
+ e
+ for e in events
+ if e.event_type == "mcp:tools/call" and e.resource_name == name
+ ]
+
+
+def _one(events, name):
+ matched = _call_events(events, name)
+ assert len(matched) == 1, f"expected one {name} event, got {len(matched)}"
+ return matched[0]
+
+
+def _frames_for(error, function):
+ return [f for f in error.get("frames", []) if f["function"] == function]
+
+
+def _chained_frames_for(error, function):
+ return [
+ f
+ for chained in error.get("chained_errors", [])
+ for f in chained.get("frames", [])
+ if f["function"] == function
+ ]
+
+
+class Barrier:
+ """Holds every concurrent tool body until all of them have arrived.
+
+ So the per-era parallel tests stress the window they claim to: every call
+ is provably inside its own capture slot, at the same time, before any of
+ them fails.
+ """
+
+ def __init__(self, total: int) -> None:
+ self.total = total
+ self.arrived = 0
+ self.open = asyncio.Event()
+ self.slots: list = []
+
+ async def wait(self) -> None:
+ # Recorded from inside the customer's tool body, which is where the
+ # adapter's slot is open. The cell OBJECT, not a marker derived from
+ # it: this is the structural claim the concurrency argument rests on.
+ self.slots.append(_open_cell.get())
+ self.arrived += 1
+ if self.arrived == self.total:
+ self.open.set()
+ await self.open.wait()
+
+ def assert_every_call_had_its_own_slot(self) -> None:
+ assert None not in self.slots, "no capture slot was open in the tool body"
+ assert len(self.slots) == self.total
+ # The discriminating assertion. A module-global "last error" slot, or
+ # a slot hoisted out of the per-call path to install time, gives every
+ # concurrent call the SAME object here — and both would still pass an
+ # assertion that only checks each event's own marker, because the
+ # adapter path has no `await` between the capture and the read.
+ assert len({id(slot) for slot in self.slots}) == self.total, (
+ "concurrent calls shared one capture slot"
+ )
+
+
+# ── the contract ─────────────────────────────────────────────────────────────
+
+
+class TestTapContract:
+ """What the tap promises, independent of any server."""
+
+ def test_a_capture_with_no_slot_open_is_a_no_op(self):
+ # A tap left armed on a server whose call is not being traced must not
+ # raise into the customer's process, and must not stash anything that
+ # a later call could pick up.
+ capture(Boom("nobody asked for this"))
+ with inner_tap() as tap:
+ assert tap.captured is None
+
+ def test_the_last_write_wins(self):
+ # A tool that composes another tool sees the sub-call's failure first
+ # and its own second. The event has to describe the one the agent was
+ # told about, which is always the last one to unwind.
+ with inner_tap() as tap:
+ capture(Boom("a sub-call the tool handled"))
+ capture(RuntimeError("the tool's own failure"))
+ assert isinstance(tap.captured, RuntimeError)
+
+ def test_the_slot_closes_on_the_error_path(self):
+ with contextlib.suppress(Boom):
+ with inner_tap():
+ capture(Boom("this call failed"))
+ raise Boom("this call failed")
+ with inner_tap() as tap:
+ assert tap.captured is None
+
+ def test_a_nested_slot_hands_the_outer_one_back(self):
+ # A tool that calls its own server re-enters the adapter. The inner
+ # call takes the slot and gives it back, so the outer call still
+ # records its own failure rather than the inner one's.
+ with inner_tap() as outer:
+ with inner_tap() as inner:
+ capture(Boom("inner call"))
+ assert isinstance(inner.captured, Boom)
+ assert outer.captured is None
+ capture(RuntimeError("outer call"))
+ assert isinstance(outer.captured, RuntimeError)
+
+ def test_capture_in_flight_records_the_live_exception(self):
+ with inner_tap() as tap:
+ try:
+ raise Boom("caught by the SDK")
+ except Boom:
+ capture_in_flight("caught by the SDK")
+ assert isinstance(tap.captured, Boom)
+
+ def test_capture_in_flight_ignores_a_message_the_sdk_composed(self):
+ # The lowlevel v1 SDK reaches the same factory for a schema-validation
+ # failure, handing it a sentence of its own rather than the exception's
+ # message. Recording there would replace the one-line text the agent saw
+ # with a multi-line jsonschema dump.
+ composed = "Input validation error: 'x' is not of type 'integer'"
+ with inner_tap() as tap:
+ try:
+ raise Boom("'x' is not of type 'integer'\n\nOn instance…")
+ except Boom:
+ capture_in_flight(composed)
+ assert tap.captured is None
+
+ def test_capture_in_flight_ignores_a_failure_that_predates_the_slot(self):
+ # `sys.exc_info` answers for the whole stack, and the lowlevel v1 SDK
+ # calls its error-result factory outside any `except` too (a bad return
+ # type). An older frame's exception is not this call's failure.
+ try:
+ raise RuntimeError("someone else's failure")
+ except RuntimeError:
+ with inner_tap() as tap:
+ capture_in_flight("someone else's failure")
+ assert tap.captured is None
+
+ def test_error_uses_the_tapped_exception_when_there_is_one(self):
+ with inner_tap() as tap:
+ try:
+ raise Boom("the real failure")
+ except Boom as exc:
+ capture(exc)
+ payload = tap.error("the flattened message")
+ assert payload["type"] == "Boom"
+ assert payload["message"] == "the real failure"
+ assert payload["frames"]
+ assert payload["platform"] == "python"
+
+ def test_error_falls_back_when_nothing_local_ever_raised(self):
+ # A proxy passing an upstream error through, or a tool that simply
+ # returned is_error: there is no local exception and never will be.
+ with inner_tap() as tap:
+ payload = tap.error("upstream said no")
+ assert payload == {
+ "message": "upstream said no",
+ "type": None,
+ "platform": "python",
+ }
+
+ @pytest.mark.asyncio
+ async def test_the_wrapping_form_re_raises_the_very_same_exception(self):
+ """The tap observes. It must not swallow, replace or delay."""
+ raised = Boom("the customer's own failure")
+
+ async def fails(*args, **kwargs):
+ raise raised
+
+ wrapped = tapped(fails)
+ with inner_tap() as tap:
+ with pytest.raises(Boom) as caught:
+ await wrapped("arg", keyword="value")
+ assert caught.value is raised
+ assert tap.captured is raised
+
+ @pytest.mark.asyncio
+ async def test_the_wrapping_form_returns_the_originals_own_result(self):
+ sentinel = object()
+
+ async def succeeds(*args, **kwargs):
+ return sentinel
+
+ assert await tapped(succeeds)() is sentinel
+
+ def test_the_probing_form_returns_the_originals_own_result(self):
+ sentinel = object()
+ probe = probing(lambda message: sentinel)
+ with inner_tap() as tap:
+ try:
+ raise Boom("the SDK is converting this right now")
+ except Boom:
+ assert probe("the SDK is converting this right now") is sentinel
+ assert isinstance(tap.captured, Boom)
+
+ def test_the_probing_form_survives_a_call_shape_it_does_not_know(self):
+ # A capture is what an unrecognized call costs — never the customer's
+ # error path.
+ sentinel = object()
+ probe = probing(lambda **kwargs: sentinel)
+ with inner_tap() as tap:
+ try:
+ raise Boom("live")
+ except Boom:
+ assert probe(some_future_kwarg="live") is sentinel
+ assert tap.captured is None
+
+ def test_a_tap_that_cannot_be_installed_degrades_rather_than_raises(self):
+ class ReadOnly:
+ async def call_tool(self): # pragma: no cover - never called
+ return None
+
+ def __setattr__(self, name, value):
+ raise AttributeError("this server refuses attributes")
+
+ state: dict = {}
+ assert tap_method(ReadOnly(), "call_tool", state, "call_tool") is False
+ assert tap_method(object(), "no_such_method", state, "missing") is False
+ assert state == {}
+
+ def test_a_synchronous_seam_is_refused_rather_than_broken(self):
+ """`tapped` awaits what it wraps; a sync override would break every call."""
+
+ class SyncOverride:
+ def call_tool(self, name, arguments): # pragma: no cover - never called
+ return None
+
+ server = SyncOverride()
+ state: dict = {}
+ assert tap_method(server, "call_tool", state, "call_tool") is False
+ assert state == {}
+ # The customer's own method is exactly where it was.
+ assert server.call_tool.__func__ is SyncOverride.call_tool
+
+ @pytest.mark.asyncio
+ async def test_a_capture_a_seam_handled_itself_is_discarded(self):
+ """A sub-call the caller suppressed did not produce the agent's result."""
+
+ async def fails(*args, **kwargs):
+ raise Boom("the sub-call")
+
+ sub_call = tapped(fails)
+
+ async def caller(*args, **kwargs):
+ with contextlib.suppress(Boom):
+ await sub_call()
+ return "answered anyway"
+
+ with inner_tap() as tap:
+ assert await tapped(caller)() == "answered anyway"
+ assert tap.captured is None
+
+ @pytest.mark.asyncio
+ async def test_a_capture_the_seam_re_raised_is_kept(self):
+ """The community shape: a layer below converts a real failure."""
+
+ async def fails(*args, **kwargs):
+ raise Boom("the tool")
+
+ seam = tapped(fails)
+ with inner_tap() as tap:
+ with contextlib.suppress(Boom):
+ await seam()
+ assert isinstance(tap.captured, Boom)
+
+ @pytest.mark.asyncio
+ async def test_a_sub_call_in_flight_cannot_erase_an_escaping_capture(self):
+ """A concurrent sub-call is not "the seam that handled it".
+
+ "Did this failure escape?" is answered per SEAM, not per nesting
+ level: a seam's normal return drops only what was recorded inside its
+ own dynamic extent. It used to be answered by a depth counter kept on
+ the cell — and the cell is shared by every task that inherits it, so a
+ sub-call the tool left running inflated the count, and then erased a
+ genuine capture on its own normal return. The failure the agent was
+ told about would have been published as the no-tap payload.
+ """
+ sub_call_is_inside = asyncio.Event()
+ let_the_sub_call_finish = asyncio.Event()
+
+ async def fails(*args, **kwargs):
+ raise Boom("the tool")
+
+ async def sub_call(*args, **kwargs):
+ sub_call_is_inside.set()
+ await let_the_sub_call_finish.wait()
+ return "the sub-call finished"
+
+ failing_seam = tapped(fails)
+ other_seam = tapped(sub_call)
+
+ with inner_tap() as tap:
+ # A task the tool spawned: it inherits this call's slot.
+ spawned = asyncio.create_task(other_seam())
+ await sub_call_is_inside.wait()
+
+ with contextlib.suppress(Boom):
+ await failing_seam()
+ assert isinstance(tap.captured, Boom)
+
+ # The layer below AgentCat awaits while converting the raise into
+ # an `is_error` result, and the sub-call lands in that window.
+ let_the_sub_call_finish.set()
+ await spawned
+ assert isinstance(tap.captured, Boom), (
+ "a concurrent sub-call's normal return erased the tool's failure"
+ )
+
+
+# ── the concurrency argument ─────────────────────────────────────────────────
+
+
+class TestTapIsolation:
+ """Cross-attribution has to be impossible, not merely absent in one run."""
+
+ @pytest.mark.asyncio
+ async def test_parallel_calls_cannot_read_each_others_exception(self):
+ """Eight calls, all inside their slot at once, capturing out of order.
+
+ A module-global "last error" slot passes a sequential test and fails
+ this one: every call is provably inside its own slot before any of them
+ captures, and they capture in reverse order, so a shared slot would
+ hand call 0 call 7's exception.
+ """
+ total = 8
+ arrived = 0
+ all_inside = asyncio.Event()
+ seen: dict[int, str] = {}
+
+ async def one_call(index: int) -> None:
+ nonlocal arrived
+ with inner_tap() as tap:
+ arrived += 1
+ if arrived == total:
+ all_inside.set()
+ await all_inside.wait()
+ # Every sibling's slot is open right now.
+ assert tap.captured is None
+ # Reverse order, with a yield between each, so the captures
+ # interleave with every other call's read.
+ await asyncio.sleep(0.001 * (total - index))
+ capture(Boom(f"call-{index}"))
+ await asyncio.sleep(0.02)
+ seen[index] = str(tap.captured)
+
+ await asyncio.wait_for(
+ asyncio.gather(*(one_call(i) for i in range(total))), timeout=10
+ )
+ assert seen == {i: f"call-{i}" for i in range(total)}
+
+ @pytest.mark.asyncio
+ async def test_a_capture_from_a_child_task_reaches_the_caller(self):
+ """The reason the slot is an object and not a ContextVar of exceptions.
+
+ A `ContextVar.set()` inside a child task is invisible to the parent —
+ which is what the retired `store_captured_error` did. Writing to the
+ cell the parent holds is visible, and both FastMCP generations reach
+ the tap through a child of the request task.
+ """
+
+ async def child() -> None:
+ capture(Boom("raised below a task boundary"))
+
+ with inner_tap() as tap:
+ await asyncio.create_task(child())
+ assert isinstance(tap.captured, Boom)
+
+ @pytest.mark.asyncio
+ async def test_a_capture_from_a_worker_thread_reaches_the_caller(self):
+ # How every SDK generation runs a synchronous tool body.
+ with inner_tap() as tap:
+ await asyncio.to_thread(capture, Boom("raised in a worker thread"))
+ assert isinstance(tap.captured, Boom)
+
+
+# ── official MCP SDK 1.x ─────────────────────────────────────────────────────
+
+
+def _bare_v1_server(name="bare-v1"):
+ """A lowlevel v1 `Server` whose own handler raises."""
+ import mcp.types as types
+ from mcp.server.lowlevel import Server
+
+ server = Server(name)
+
+ @server.list_tools()
+ async def list_tools():
+ return [
+ types.Tool(
+ name="boom",
+ description="raises",
+ inputSchema={
+ "type": "object",
+ "properties": {"marker": {"type": "string"}},
+ },
+ )
+ ]
+
+ @server.call_tool()
+ async def call_tool(tool: str, arguments: dict):
+ raise Boom(f"bare kaboom {arguments.get('marker')}")
+
+ return server
+
+
+@LEGACY_ONLY
+class TestOfficialV1:
+ """Official FastMCP v1 and the bare lowlevel `Server` behind it."""
+
+ @pytest.mark.asyncio
+ async def test_fastmcp_v1_publishes_the_tool_error_and_its_cause(self, events):
+ from .test_utils.client import create_test_client
+ from .test_utils.todo_server import create_todo_server
+
+ server = create_todo_server()
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ result = await client.call_tool(
+ "tool_that_raises", {"error_type": "value"}
+ )
+
+ assert result.isError is True
+ error = _one(events, "tool_that_raises").error
+ # FastMCP wraps a failing tool before the lowlevel SDK flattens it, so
+ # the recorded type is the wrapper and the customer's own exception is
+ # the cause — the same shape the community adapter has always had.
+ assert error["type"] == "ToolError"
+ assert "Test value error from tool" in error["message"]
+ assert error["stack"]
+ assert error["frames"]
+ cause = error["chained_errors"][0]
+ assert cause["type"] == "ValueError"
+ assert cause["message"] == "Test value error from tool"
+ tool_frames = _chained_frames_for(error, "tool_that_raises")
+ assert tool_frames, "the customer's own tool is missing from the traceback"
+ assert tool_frames[0]["in_app"] is True
+ assert "Test value error from tool" in tool_frames[0]["context_line"]
+
+ @NEEDS_LOWLEVEL_ERROR_SEAM
+ @pytest.mark.asyncio
+ async def test_bare_lowlevel_v1_publishes_the_handlers_own_exception(
+ self, events
+ ):
+ from .test_utils.client import create_test_client
+
+ server = _bare_v1_server()
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ result = await client.call_tool("boom", {"marker": "one"})
+
+ assert result.isError is True
+ error = _one(events, "boom").error
+ # No tool manager in the way: the handler's exception IS the failure.
+ assert error["type"] == "Boom"
+ assert error["message"] == "bare kaboom one"
+ handler_frames = _frames_for(error, "call_tool")
+ assert handler_frames and handler_frames[0]["in_app"] is True
+ assert "raise Boom" in handler_frames[0]["context_line"]
+
+ @pytest.mark.asyncio
+ async def test_the_wire_error_is_what_an_untracked_server_returns(self, events):
+ from .test_utils.client import create_test_client
+ from .test_utils.todo_server import create_todo_server
+
+ async def call(server):
+ async with create_test_client(server) as client:
+ return await client.call_tool(
+ "tool_that_raises", {"error_type": "runtime"}
+ )
+
+ untracked = await call(create_todo_server())
+ tracked_server = create_todo_server()
+ track(tracked_server, "test_project", AgentCatOptions())
+ tracked = await call(tracked_server)
+
+ assert tracked.isError == untracked.isError is True
+ # The SDK's own error block, byte for byte. (A tracked result also
+ # carries AgentCat's task mint-back, which is v2 behavior the tap
+ # neither adds to nor removes from.)
+ assert tracked.content[0].model_dump() == untracked.content[0].model_dump()
+ # Positive control. Everything above is an equality between two runs,
+ # so it passes just as well when track() is a no-op — verified by
+ # reducing track() to `return server`, which leaves the assertions
+ # above green and fails the two below.
+ assert _call_events(events, "tool_that_raises")
+ assert len(tracked.content) > len(untracked.content)
+
+ @NEEDS_CONCURRENT_DISPATCH
+ @pytest.mark.asyncio
+ async def test_parallel_failures_each_get_their_own_slot(self, events):
+ from .test_utils.client import create_test_client
+ from .test_utils.todo_server import create_todo_server
+
+ total = 6
+ barrier = Barrier(total)
+ server = create_todo_server()
+
+ @server.tool()
+ async def boom(marker: str) -> str:
+ """Raises once every parallel call is inside it."""
+ await barrier.wait()
+ raise Boom(f"kaboom {marker}")
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ await asyncio.wait_for(
+ asyncio.gather(
+ *(
+ client.call_tool("boom", {"marker": f"m{i}"})
+ for i in range(total)
+ )
+ ),
+ timeout=20,
+ )
+
+ barrier.assert_every_call_had_its_own_slot()
+ published = _call_events(events, "boom")
+ assert len(published) == total
+ for event in published:
+ marker = event.parameters["arguments"]["marker"]
+ cause = event.error["chained_errors"][0]
+ assert cause["message"] == f"kaboom {marker}"
+
+ @pytest.mark.asyncio
+ async def test_a_tool_that_composes_a_tool_publishes_its_own_failure(
+ self, events
+ ):
+ """The caller handles a sub-call's failure, then fails on its own.
+
+ The event must describe what the agent was told, not the failure the
+ caller dealt with. Keeping the FIRST capture published the sub-call's.
+ """
+ from .test_utils.client import create_test_client
+ from .test_utils.todo_server import create_todo_server
+
+ server = create_todo_server()
+
+ @server.tool()
+ async def inner(marker: str) -> str:
+ """Always fails."""
+ raise Boom(f"INNER {marker}")
+
+ @server.tool()
+ async def outer(marker: str) -> str:
+ """Handles inner's failure, then fails on its own."""
+ with contextlib.suppress(Exception):
+ await server._tool_manager.call_tool("inner", {"marker": marker})
+ raise Boom(f"OUTER {marker}")
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ result = await client.call_tool("outer", {"marker": "one"})
+
+ wire = "".join(c.text for c in result.content if hasattr(c, "text"))
+ assert "OUTER one" in wire and "INNER one" not in wire
+ error = _one(events, "outer").error
+ assert "OUTER one" in error["message"]
+ assert "INNER one" not in error["message"]
+ assert error["chained_errors"][0]["message"] == "OUTER one"
+
+ @pytest.mark.asyncio
+ async def test_a_handled_sub_call_is_not_published_as_the_result(self, events):
+ """The caller handles a sub-call's failure and answers with its own error.
+
+ Nothing the agent sees came from a Python exception, so the surfaced
+ message is the whole payload — the sub-call's stack must not be it.
+
+ The error result is produced by a layer BELOW AgentCat rather than by
+ returning a `CallToolResult` from the tool body, which mcp only honors
+ from 1.19 (PR #1459) and which JSON-serializes into an `isError=False`
+ text block below it. Wrapping `request_handlers` before `track()` puts
+ AgentCat above the layer that answers, which is the arrangement this
+ test is about, and it behaves identically on every mcp 1.x.
+ """
+ from mcp.types import CallToolRequest, ServerResult, TextContent
+
+ from .test_utils.client import create_test_client
+ from .test_utils.todo_server import create_todo_server
+
+ server = create_todo_server()
+
+ @server.tool()
+ async def inner(marker: str) -> str:
+ """Always fails."""
+ raise Boom(f"INNER {marker}")
+
+ @server.tool()
+ async def outer(marker: str) -> str:
+ """Handles inner's failure and reports its own message."""
+ with contextlib.suppress(Exception):
+ await server._tool_manager.call_tool("inner", {"marker": marker})
+ return f"OUTER declined {marker}"
+
+ low = server._mcp_server
+ answered = low.request_handlers[CallToolRequest]
+
+ async def declines(req):
+ result = await answered(req)
+ return ServerResult(
+ result.root.model_copy(
+ update={
+ "isError": True,
+ "content": [
+ TextContent(
+ type="text",
+ text=f"OUTER declined {req.params.arguments['marker']}",
+ )
+ ],
+ }
+ )
+ )
+
+ low.request_handlers[CallToolRequest] = declines
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ result = await client.call_tool("outer", {"marker": "one"})
+
+ assert result.isError is True
+ error = _one(events, "outer").error
+ assert error == {
+ "message": "OUTER declined one",
+ "type": None,
+ "platform": "python",
+ }
+
+ @NEEDS_LOWLEVEL_ERROR_SEAM
+ @pytest.mark.asyncio
+ async def test_a_schema_validation_failure_keeps_the_surfaced_message(
+ self, events
+ ):
+ """The lowlevel SDK composes that message itself; the probe skips it.
+
+ Gated on the same seam: the input validation this asserts arrived in
+ the very PR that added `_make_error_result`, so below it there is no
+ "Input validation error:" for AgentCat to keep.
+ """
+ from .test_utils.client import create_test_client
+
+ server = _bare_v1_server("validating-v1")
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ await client.list_tools()
+ result = await client.call_tool("boom", {"marker": 123})
+
+ assert result.isError is True
+ # content[0] is the SDK's own error block; anything after it is
+ # AgentCat's task mint-back, which every v2 result carries.
+ surfaced = result.content[0].text
+ assert surfaced.startswith("Input validation error:")
+ error = _one(events, "boom").error
+ assert error == {"message": surfaced, "type": None, "platform": "python"}
+
+
+# ── official MCP SDK 2.x ─────────────────────────────────────────────────────
+
+
+def _mcpserver_with_a_raising_tool(name="boom-mcpserver"):
+ from mcp.server.mcpserver import MCPServer
+
+ server = MCPServer(name)
+
+ @server.tool()
+ def boom(marker: str) -> str:
+ """Raises."""
+ raise Boom(f"kaboom {marker}")
+
+ return server
+
+
+def _bare_v2_server(name="bare-v2"):
+ from mcp import types
+ from mcp.server import Server
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="boom",
+ description="raises",
+ input_schema={
+ "type": "object",
+ "properties": {"marker": {"type": "string"}},
+ },
+ )
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ raise Boom(f"bare kaboom {(params.arguments or {}).get('marker')}")
+
+ return Server(name, on_list_tools=on_list_tools, on_call_tool=on_call_tool)
+
+
+@MODERN_ONLY
+class TestOfficialV2:
+ """`MCPServer` and the bare lowlevel v2 `Server` behind it."""
+
+ @pytest.mark.asyncio
+ async def test_mcpserver_publishes_the_tool_error_and_its_cause(self, events):
+ from .test_utils.modern_server import create_modern_client
+
+ server = _mcpserver_with_a_raising_tool()
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ result = await client.call_tool("boom", {"marker": "one"})
+
+ assert result.is_error is True
+ error = _one(events, "boom").error
+ assert error["type"] == "ToolError"
+ assert "kaboom one" in error["message"]
+ assert error["stack"]
+ assert error["frames"]
+ cause = error["chained_errors"][0]
+ assert cause["type"] == "Boom"
+ assert cause["message"] == "kaboom one"
+ tool_frames = _chained_frames_for(error, "boom")
+ assert tool_frames and tool_frames[0]["in_app"] is True
+ assert "raise Boom" in tool_frames[0]["context_line"]
+
+ @pytest.mark.asyncio
+ async def test_tracking_the_lowlevel_object_directly_still_taps(self, events):
+ """`track(mcpserver._lowlevel_server)` hands over no facade at all."""
+ from .test_utils.modern_server import create_modern_client
+
+ server = _mcpserver_with_a_raising_tool("tracked-by-lowlevel")
+ track(server._lowlevel_server, "test_project", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ await client.call_tool("boom", {"marker": "one"})
+
+ assert _one(events, "boom").error["type"] == "ToolError"
+
+ @pytest.mark.asyncio
+ async def test_a_repeated_track_does_not_stack_a_second_tap(self, events):
+ """Two taps would mean two frames and, worse, two installs to unstack."""
+ from .test_utils.modern_server import create_modern_client
+
+ server = _mcpserver_with_a_raising_tool("re-tracked")
+ track(server, "test_project", AgentCatOptions())
+ first = server.call_tool
+ track(server, "test_project", AgentCatOptions())
+ assert server.call_tool is not first
+
+ async with create_modern_client(server) as client:
+ await client.call_tool("boom", {"marker": "one"})
+
+ error = _one(events, "boom").error
+ assert error["type"] == "ToolError"
+ assert len([f for f in error["frames"] if f["function"] == "tap"]) == 1
+
+ @pytest.mark.asyncio
+ async def test_bare_lowlevel_v2_keeps_the_handlers_own_exception(self, events):
+ """This era already let a handler's exception through; do not regress."""
+ from .test_utils.modern_server import create_modern_client
+
+ server = _bare_v2_server()
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ with contextlib.suppress(Exception):
+ await client.call_tool("boom", {"marker": "one"})
+
+ error = _one(events, "boom").error
+ assert error["type"] == "Boom"
+ assert error["message"] == "bare kaboom one"
+ handler_frames = _frames_for(error, "on_call_tool")
+ assert handler_frames and handler_frames[0]["in_app"] is True
+
+ @pytest.mark.asyncio
+ async def test_a_customers_protocol_error_reaches_the_wire_unchanged(
+ self, events
+ ):
+ """The Task 13 hazard class: AgentCat must not replace a -32602."""
+ from mcp.server.mcpserver import MCPServer
+ from mcp.shared.exceptions import MCPError
+ from mcp.types import INVALID_PARAMS
+
+ from .test_utils.modern_server import create_modern_client
+
+ def build():
+ server = MCPServer("protocol-error-server")
+
+ @server.tool()
+ def strict(marker: str) -> str:
+ """Rejects at the protocol level."""
+ raise MCPError(code=INVALID_PARAMS, message="Invalid parameters")
+
+ return server
+
+ async def call(server):
+ async with create_modern_client(server) as client:
+ try:
+ await client.call_tool("strict", {"marker": "x"})
+ except Exception as exc: # noqa: BLE001 - the error IS the result
+ return type(exc).__name__, getattr(exc, "code", None), str(exc)
+ return None
+
+ untracked = await call(build())
+ tracked_server = build()
+ track(tracked_server, "test_project", AgentCatOptions())
+ tracked = await call(tracked_server)
+
+ assert untracked is not None
+ assert tracked == untracked
+ # Positive control. This is the sole regression test for the bug fixed
+ # in 18e68a1, and it is an equality between two runs — a detection
+ # regression that left MCPServer unclassified would leave it green.
+ # Verified by reducing track() to `return server`: the assertion above
+ # still passes, this one does not.
+ assert _call_events(events, "strict")
+
+ @pytest.mark.asyncio
+ async def test_the_wire_error_is_what_an_untracked_server_returns(self, events):
+ from .test_utils.modern_server import create_modern_client
+
+ async def call(server):
+ async with create_modern_client(server) as client:
+ return await client.call_tool("boom", {"marker": "one"})
+
+ untracked = await call(_mcpserver_with_a_raising_tool("untracked"))
+ tracked_server = _mcpserver_with_a_raising_tool("tracked")
+ track(tracked_server, "test_project", AgentCatOptions())
+ tracked = await call(tracked_server)
+
+ assert tracked.is_error == untracked.is_error is True
+ # Positive control: see the v1 sibling. An equality between two runs
+ # holds trivially when track() does nothing.
+ assert _call_events(events, "boom")
+ assert len(tracked.content) > len(untracked.content)
+ assert tracked.content[0].model_dump() == untracked.content[0].model_dump()
+
+ @pytest.mark.asyncio
+ async def test_parallel_failures_each_get_their_own_slot(self, events):
+ from mcp.server.mcpserver import MCPServer
+
+ from .test_utils.modern_server import create_modern_client
+
+ total = 6
+ barrier = Barrier(total)
+ server = MCPServer("parallel-boom")
+
+ @server.tool()
+ async def boom(marker: str) -> str:
+ """Raises once every parallel call is inside it."""
+ await barrier.wait()
+ raise Boom(f"kaboom {marker}")
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ await asyncio.wait_for(
+ asyncio.gather(
+ *(
+ client.call_tool("boom", {"marker": f"m{i}"})
+ for i in range(total)
+ )
+ ),
+ timeout=20,
+ )
+
+ barrier.assert_every_call_had_its_own_slot()
+ published = _call_events(events, "boom")
+ assert len(published) == total
+ for event in published:
+ marker = event.parameters["arguments"]["marker"]
+ cause = event.error["chained_errors"][0]
+ assert cause["message"] == f"kaboom {marker}"
+
+ @pytest.mark.asyncio
+ async def test_a_tool_that_composes_a_tool_publishes_its_own_failure(
+ self, events
+ ):
+ """The caller handles a sub-call's failure, then fails on its own."""
+ from mcp.server.mcpserver import MCPServer
+
+ from .test_utils.modern_server import create_modern_client
+
+ server = MCPServer("composing-server")
+
+ @server.tool()
+ async def inner(marker: str) -> str:
+ """Always fails."""
+ raise Boom(f"INNER {marker}")
+
+ @server.tool()
+ async def outer(marker: str) -> str:
+ """Handles inner's failure, then fails on its own."""
+ with contextlib.suppress(Exception):
+ await server.call_tool("inner", {"marker": marker})
+ raise Boom(f"OUTER {marker}")
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ result = await client.call_tool("outer", {"marker": "one"})
+
+ wire = "".join(c.text for c in result.content if hasattr(c, "text"))
+ assert "OUTER one" in wire and "INNER one" not in wire
+ error = _one(events, "outer").error
+ assert "OUTER one" in error["message"]
+ assert "INNER one" not in error["message"]
+ assert error["chained_errors"][0]["message"] == "OUTER one"
+
+ @pytest.mark.asyncio
+ async def test_a_handled_sub_call_is_not_published_as_the_result(self, events):
+ """The caller handles a sub-call's failure and answers with its own error."""
+ from mcp.server.mcpserver import MCPServer
+ from mcp.types import CallToolResult, TextContent
+
+ from .test_utils.modern_server import create_modern_client
+
+ server = MCPServer("declining-server")
+
+ @server.tool()
+ async def inner(marker: str) -> str:
+ """Always fails."""
+ raise Boom(f"INNER {marker}")
+
+ @server.tool()
+ async def outer(marker: str) -> CallToolResult:
+ """Handles inner's failure and reports its own error result."""
+ with contextlib.suppress(Exception):
+ await server.call_tool("inner", {"marker": marker})
+ return CallToolResult(
+ content=[TextContent(type="text", text=f"OUTER declined {marker}")],
+ is_error=True,
+ )
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ result = await client.call_tool("outer", {"marker": "one"})
+
+ assert result.is_error is True
+ error = _one(events, "outer").error
+ assert error == {
+ "message": "OUTER declined one",
+ "type": None,
+ "platform": "python",
+ }
+
+
+# ── community FastMCP (both eras) ────────────────────────────────────────────
+
+
+class _SwallowingMiddleware:
+ """A layer that turns a raised tool error into an `is_error` result.
+
+ Exactly what `fastmcp/server/providers/proxy.py` does for an upstream
+ error, and what any error-handling middleware a customer writes does. It
+ takes the branch where the community adapter used to have nothing but the
+ surfaced message.
+ """
+
+ async def __call__(self, context, call_next):
+ if getattr(context, "method", None) != "tools/call":
+ return await call_next(context)
+ try:
+ return await call_next(context)
+ except Exception as exc:
+ from mcp.types import TextContent
+
+ from .test_utils import error_tool_result
+
+ return error_tool_result(
+ content=[TextContent(type="text", text=f"swallowed: {exc}")],
+ is_error=True,
+ )
+
+
+def _community_server(name="boom-community"):
+ from fastmcp import FastMCP
+
+ server = FastMCP(name)
+
+ @server.tool
+ def boom(marker: str) -> str:
+ """Raises."""
+ raise Boom(f"kaboom {marker}")
+
+ return server
+
+
+def _create_proxy(backend):
+ """An in-process FastMCP proxy in front of ``backend``, either era.
+
+ FastMCP 4 exposes `fastmcp.server.create_proxy`; the 3.x line spells the
+ same thing `FastMCP.as_proxy`.
+ """
+ try:
+ from fastmcp.server import create_proxy
+ except ImportError:
+ from fastmcp import FastMCP
+
+ return FastMCP.as_proxy(backend)
+ return create_proxy(backend)
+
+
+@pytest.mark.skipif(not HAS_COMMUNITY, reason="Community FastMCP not installed")
+class TestCommunity:
+ """FastMCP 3 and 4 — whichever era this dependency set installed."""
+
+ @pytest.mark.asyncio
+ async def test_the_raise_path_still_carries_full_detail(self, events):
+ """Already true before the tap; this is the do-not-regress guard."""
+ from fastmcp import Client
+
+ server = _community_server("raise-path")
+ track(server, "test_project", AgentCatOptions())
+
+ async with Client(server) as client:
+ with contextlib.suppress(Exception):
+ await client.call_tool("boom", {"marker": "one"})
+
+ error = _one(events, "boom").error
+ assert error["type"] is not None
+ assert error["frames"]
+ tool_frames = _chained_frames_for(error, "boom")
+ assert tool_frames and tool_frames[0]["in_app"] is True
+
+ @pytest.mark.asyncio
+ async def test_an_is_error_result_without_a_raise_still_carries_detail(
+ self, events
+ ):
+ from fastmcp import Client
+
+ server = _community_server("swallowed")
+ server.add_middleware(_SwallowingMiddleware())
+ track(server, "test_project", AgentCatOptions())
+
+ async with Client(server) as client:
+ result = await client.call_tool(
+ "boom", {"marker": "one"}, raise_on_error=False
+ )
+
+ assert result.is_error is True
+ error = _one(events, "boom").error
+ # No exception reached the adapter — a layer below it answered with an
+ # is_error result — but one was raised, and the tap kept it.
+ assert error["type"] is not None
+ assert "kaboom one" in error["message"]
+ tool_frames = _chained_frames_for(error, "boom")
+ assert tool_frames and tool_frames[0]["in_app"] is True
+
+ @pytest.mark.asyncio
+ async def test_a_repeated_track_does_not_stack_a_second_tap(self, events):
+ from fastmcp import Client
+
+ server = _community_server("re-tracked")
+ server.add_middleware(_SwallowingMiddleware())
+ track(server, "test_project", AgentCatOptions())
+ first = server.call_tool
+ track(server, "test_project", AgentCatOptions())
+ assert server.call_tool is not first
+
+ async with Client(server) as client:
+ await client.call_tool(
+ "boom", {"marker": "one"}, raise_on_error=False
+ )
+
+ error = _one(events, "boom").error
+ assert error["type"] is not None
+ assert len([f for f in error["frames"] if f["function"] == "tap"]) == 1
+
+ @pytest.mark.asyncio
+ async def test_a_proxied_upstream_error_keeps_the_surfaced_message(self, events):
+ """The documented gap, driven through a real proxy.
+
+ From fastmcp 3.4 `providers/proxy.py` passes an upstream error result
+ through deliberately — "rather than collapsing it into a raised
+ ToolError" — from a `call_tool_mcp` that never raises. The failure
+ happened in the backend; there is no Python exception anywhere in this
+ process to recover, and no tap placement could change that.
+
+ Below 3.4 the proxy did exactly the collapsing that comment rules out
+ (`if result.isError: raise ToolError(first.text)`), so there IS a local
+ exception and the tap keeps it — the gap is a CONSEQUENCE of PR #4217
+ making the pass-through expressible, not a property of proxying. Both
+ branches are asserted rather than one being skipped, so this still
+ fails loudly if a future FastMCP changes its mind again.
+ """
+ from fastmcp import Client
+
+ from .test_utils import FASTMCP_TOOLRESULT_HAS_IS_ERROR
+
+ backend = _community_server("proxy-backend")
+ front = _create_proxy(backend)
+ track(front, "test_project", AgentCatOptions())
+
+ async with Client(front) as client:
+ result = await client.call_tool(
+ "boom", {"marker": "one"}, raise_on_error=False
+ )
+
+ assert result.is_error is True
+ # content[0] is the backend's own error block; anything after it is
+ # AgentCat's task mint-back, which every v2 result carries.
+ upstream = result.content[0].text
+ assert "kaboom one" in upstream
+ error = _one(events, "boom").error
+ assert error["message"] == upstream
+ if FASTMCP_TOOLRESULT_HAS_IS_ERROR:
+ assert error == {"message": upstream, "type": None, "platform": "python"}
+ else:
+ assert error["type"] == "ToolError"
+ assert error["frames"]
+
+ @pytest.mark.asyncio
+ async def test_the_wire_error_is_what_an_untracked_server_returns(self, events):
+ from fastmcp import Client
+
+ async def call(server):
+ async with Client(server) as client:
+ return await client.call_tool(
+ "boom", {"marker": "one"}, raise_on_error=False
+ )
+
+ untracked_server = _community_server("untracked")
+ untracked_server.add_middleware(_SwallowingMiddleware())
+ untracked = await call(untracked_server)
+
+ tracked_server = _community_server("tracked")
+ tracked_server.add_middleware(_SwallowingMiddleware())
+ track(tracked_server, "test_project", AgentCatOptions())
+ tracked = await call(tracked_server)
+
+ assert tracked.is_error == untracked.is_error is True
+ assert tracked.content[0].model_dump() == untracked.content[0].model_dump()
+ # Positive control: see the v1 sibling. An equality between two runs
+ # holds trivially when track() does nothing.
+ assert _call_events(events, "boom")
+ assert len(tracked.content) > len(untracked.content)
+
+ @pytest.mark.asyncio
+ async def test_parallel_failures_each_get_their_own_slot(self, events):
+ from fastmcp import Client, FastMCP
+
+ total = 6
+ barrier = Barrier(total)
+ server = FastMCP("parallel-community")
+ server.add_middleware(_SwallowingMiddleware())
+
+ @server.tool
+ async def boom(marker: str) -> str:
+ """Raises once every parallel call is inside it."""
+ await barrier.wait()
+ raise Boom(f"kaboom {marker}")
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with Client(server) as client:
+ await asyncio.wait_for(
+ asyncio.gather(
+ *(
+ client.call_tool(
+ "boom", {"marker": f"m{i}"}, raise_on_error=False
+ )
+ for i in range(total)
+ )
+ ),
+ timeout=20,
+ )
+
+ barrier.assert_every_call_had_its_own_slot()
+ published = _call_events(events, "boom")
+ assert len(published) == total
+ for event in published:
+ marker = event.parameters["arguments"]["marker"]
+ cause = event.error["chained_errors"][0]
+ assert cause["message"] == f"kaboom {marker}"
+
+ @pytest.mark.asyncio
+ async def test_a_tool_that_composes_a_tool_publishes_its_own_failure(
+ self, events
+ ):
+ """The caller handles a sub-call's failure, then fails on its own."""
+ from fastmcp import Client
+
+ server = _community_server("composing-community")
+
+ @server.tool
+ async def inner(marker: str) -> str:
+ """Always fails."""
+ raise Boom(f"INNER {marker}")
+
+ @server.tool
+ async def outer(marker: str) -> str:
+ """Handles inner's failure, then fails on its own."""
+ with contextlib.suppress(Exception):
+ await server.call_tool("inner", {"marker": marker})
+ raise Boom(f"OUTER {marker}")
+
+ track(server, "test_project", AgentCatOptions())
+
+ async with Client(server) as client:
+ result = await client.call_tool(
+ "outer", {"marker": "one"}, raise_on_error=False
+ )
+
+ wire = "".join(c.text for c in result.content if hasattr(c, "text"))
+ assert "OUTER one" in wire and "INNER one" not in wire
+ error = _one(events, "outer").error
+ assert "OUTER one" in error["message"]
+ assert "INNER one" not in error["message"]
+
+ @pytest.mark.asyncio
+ async def test_a_sub_call_still_running_does_not_cost_the_tools_detail(
+ self, events
+ ):
+ """The unit-level sub-call case, live on whichever era is installed.
+
+ Every piece here is something a real server does: a tool that leaves
+ work running, and a layer below AgentCat that converts the raise into
+ an `is_error` result and awaits while it does so. The sub-call inherits
+ the failing call's slot, and its normal return used to erase the
+ exception — degrading the event from the tool's own `Boom`, with its
+ frames, to the three-key payload a server with no tap at all produces.
+ """
+ from fastmcp import Client, FastMCP
+ from mcp.types import TextContent
+
+ from .test_utils import error_tool_result
+
+ sub_call_is_inside = asyncio.Event()
+ let_the_sub_call_finish = asyncio.Event()
+ spawned: list = []
+
+ class ConvertsWhileTheSubCallFinishes:
+ """A layer BELOW us doing what a proxy or an error-handling
+ middleware does — with one await in it."""
+
+ async def __call__(self, context, call_next):
+ if getattr(context, "method", None) != "tools/call":
+ return await call_next(context)
+ try:
+ return await call_next(context)
+ except Exception as exc:
+ let_the_sub_call_finish.set()
+ await spawned[0]
+ return error_tool_result(
+ content=[TextContent(type="text", text=f"swallowed: {exc}")],
+ is_error=True,
+ )
+
+ server = FastMCP("sub-call-in-flight")
+
+ @server.tool
+ async def slow(marker: str) -> str:
+ """The sub-call the failing tool left running."""
+ sub_call_is_inside.set()
+ await let_the_sub_call_finish.wait()
+ return f"slow {marker}"
+
+ @server.tool
+ async def boom(marker: str) -> str:
+ """Leaves a sub-call running on this call's slot, then raises."""
+ spawned.append(
+ asyncio.create_task(server.call_tool("slow", {"marker": marker}))
+ )
+ await sub_call_is_inside.wait()
+ raise Boom(f"kaboom {marker}")
+
+ server.add_middleware(ConvertsWhileTheSubCallFinishes())
+ track(server, "test_project", AgentCatOptions())
+
+ async with Client(server) as client:
+ result = await asyncio.wait_for(
+ client.call_tool("boom", {"marker": "one"}, raise_on_error=False),
+ timeout=20,
+ )
+
+ assert result.is_error is True
+ error = _one(events, "boom").error
+ assert error["type"] is not None, "the tap's capture was erased"
+ assert "kaboom one" in error["message"]
+ tool_frames = _chained_frames_for(error, "boom")
+ assert tool_frames and tool_frames[0]["in_app"] is True
+ # The sub-call succeeded, and its own event says so.
+ assert _one(events, "slow").is_error is False
diff --git a/tests/test_logging.py b/tests/test_logging.py
index ecc79d8..2cf3ebd 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -1,5 +1,7 @@
"""Tests for the logging module."""
+import importlib.metadata
+import platform
import time
import uuid
from unittest.mock import patch
@@ -269,5 +271,163 @@ def test_log_format(self, tmp_path):
assert timestamp[13] == ":", "Invalid hour-minute separator"
assert timestamp[16] == ":", "Invalid minute-second separator"
- # Verify message
- assert message == test_message, "Message content doesn't match"
+ # Verify message and version suffix:
+ # "MESSAGE | agentcat=… python=… mcp=… fastmcp=…"
+ message_part, suffix = message.rsplit(" | ", 1)
+ assert message_part == test_message, "Message content doesn't match"
+ for key in ("agentcat=", "python=", " mcp=", "fastmcp="):
+ assert key in f" {suffix}", f"Version suffix missing {key.strip()}"
+
+ def test_every_line_carries_version_suffix(self, tmp_path):
+ """Each entry — not just the first — carries the version suffix."""
+ set_debug_mode(True)
+ unique_id = str(uuid.uuid4())
+ log_file = tmp_path / f"test_agentcat_{unique_id}.log"
+
+ with patch(
+ "agentcat.modules.logging.os.path.expanduser", return_value=str(log_file)
+ ):
+ write_to_log(f"first {unique_id}")
+ write_to_log(f"second {unique_id}")
+
+ test_lines = [
+ line
+ for line in log_file.read_text().strip().split("\n")
+ if unique_id in line
+ ]
+ assert len(test_lines) == 2
+ for line in test_lines:
+ for key in ("agentcat=", "python=", " mcp=", "fastmcp="):
+ assert key in line, f"{key.strip()} missing from line: {line}"
+
+ def test_version_suffix_values_are_truthful(self, tmp_path):
+ """The suffix reports the real installed/runtime versions."""
+ set_debug_mode(True)
+ unique_id = str(uuid.uuid4())
+ log_file = tmp_path / f"test_agentcat_{unique_id}.log"
+
+ with patch(
+ "agentcat.modules.logging.os.path.expanduser", return_value=str(log_file)
+ ):
+ write_to_log(f"versions {unique_id}")
+
+ content = log_file.read_text()
+ assert f"agentcat={importlib.metadata.version('agentcat')}" in content
+ assert f"python={platform.python_version()}" in content
+
+ def test_missing_distribution_reported_absent(self, tmp_path):
+ """A distribution that cannot be resolved shows as `absent`, not an error."""
+ from agentcat.modules.logging import _version_suffix
+
+ set_debug_mode(True)
+ unique_id = str(uuid.uuid4())
+ log_file = tmp_path / f"test_agentcat_{unique_id}.log"
+
+ _version_suffix.cache_clear()
+ try:
+ with (
+ patch("agentcat.utils.get_dist_version", return_value=None),
+ patch(
+ "agentcat.modules.logging.os.path.expanduser",
+ return_value=str(log_file),
+ ),
+ ):
+ write_to_log(f"absent {unique_id}")
+ finally:
+ _version_suffix.cache_clear()
+
+ content = log_file.read_text()
+ assert " mcp=absent" in content
+ assert " fastmcp=absent" in content
+
+
+class TestEnvDebugMode:
+ """The AGENTCAT_DEBUG_MODE parse that seeds debug_mode at import time."""
+
+ @pytest.mark.parametrize(
+ "raw,expected",
+ [
+ ("true", True),
+ ("TRUE", True),
+ ("1", True),
+ ("yes", True),
+ ("on", True),
+ ("false", False),
+ ("0", False),
+ ("", False),
+ ("garbage", False),
+ ],
+ )
+ def test_parses_truthy_tokens(self, monkeypatch, raw, expected):
+ monkeypatch.setenv("AGENTCAT_DEBUG_MODE", raw)
+ from agentcat.modules.logging import _env_debug_mode
+
+ assert _env_debug_mode() is expected
+
+ def test_unset_means_off(self, monkeypatch):
+ monkeypatch.delenv("AGENTCAT_DEBUG_MODE", raising=False)
+ from agentcat.modules.logging import _env_debug_mode
+
+ assert _env_debug_mode() is False
+
+
+class TestTrackDebugModePrecedence:
+ """track() must honor: explicit option > AGENTCAT_DEBUG_MODE seed > off."""
+
+ @pytest.fixture(autouse=True)
+ def reset_debug_mode(self):
+ yield
+ set_debug_mode(False)
+
+ def _track(self, tmp_path, **track_kwargs):
+ """Run track(object()) with ~/agentcat.log redirected to tmp_path.
+
+ The untrackable object takes the early no-project/no-exporters warning
+ path in _apply_tracking, which write_to_log's — enough to observe the
+ debug gate without touching the event queue.
+ """
+ import agentcat
+
+ log_file = tmp_path / "agentcat.log"
+ with patch(
+ "agentcat.modules.logging.os.path.expanduser",
+ return_value=str(log_file),
+ ):
+ agentcat.track(object(), **track_kwargs)
+ return log_file
+
+ def test_default_options_preserve_env_seeded_debug_mode(self, tmp_path):
+ from agentcat.modules import logging as logging_module
+
+ set_debug_mode(True) # simulate AGENTCAT_DEBUG_MODE=true import seed
+ log_file = self._track(tmp_path)
+
+ assert logging_module.debug_mode is True, (
+ "track() with default options clobbered the env-seeded debug flag"
+ )
+ assert log_file.exists(), "debug log was not written"
+ assert "Failed to track server" in log_file.read_text()
+
+ def test_explicit_true_enables_logging(self, tmp_path):
+ import agentcat
+ from agentcat.modules import logging as logging_module
+
+ set_debug_mode(False)
+ log_file = self._track(
+ tmp_path, options=agentcat.AgentCatOptions(debug_mode=True)
+ )
+
+ assert logging_module.debug_mode is True
+ assert log_file.exists()
+
+ def test_explicit_false_overrides_env_seed(self, tmp_path):
+ import agentcat
+ from agentcat.modules import logging as logging_module
+
+ set_debug_mode(True) # simulate env seed
+ log_file = self._track(
+ tmp_path, options=agentcat.AgentCatOptions(debug_mode=False)
+ )
+
+ assert logging_module.debug_mode is False
+ assert not log_file.exists()
diff --git a/tests/test_lowlevel_v1_handles.py b/tests/test_lowlevel_v1_handles.py
new file mode 100644
index 0000000..7e84ddf
--- /dev/null
+++ b/tests/test_lowlevel_v1_handles.py
@@ -0,0 +1,752 @@
+"""End-to-end handle behavior on the official MCP SDK 1.x lowlevel adapter.
+
+Everything here drives a real `ClientSession` against a real server, so the
+assertions cover the whole wire path: schema injection on `tools/list`, the
+stripped arguments the customer's tool actually receives, the mint-back the
+agent sees, and the single `mcp:tools/call` event AgentCat publishes.
+
+The strip is read at the TOOL MANAGER (`tests.test_utils.delivery`), never
+inside a tool body: this SDK's manager drops an argument the signature does not
+name without complaint, so a body-level recorder would report the same thing
+with the strip disabled.
+
+`create_todo_server()` returns an official `FastMCP`, which v2 tracks through
+its `_mcp_server` — the same adapter a bare lowlevel `Server` gets.
+"""
+
+import json
+
+import pytest
+from mcp.server.fastmcp import FastMCP
+from mcp.types import TextContent
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+)
+from agentcat.modules.handles import derive_session_id
+
+from .test_utils import NEEDS_STRUCTURED_OUTPUT, read_only_hint, sid
+from .test_utils.client import create_test_client
+from .test_utils.delivery import delivered_arguments_for, record_delivered_arguments
+from .test_utils.todo_server import create_todo_server
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+def _call_events(capture) -> list:
+ return [e for e in capture if e.event_type == "mcp:tools/call"]
+
+
+async def test_prompted_mode_end_to_end(capture):
+ server = create_todo_server()
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.inputSchema["properties"])[-2:] == ["session_id", "context"]
+ assert "session_id" not in add.inputSchema.get("required", [])
+ # session_id is the one injected param that is never required — omitting
+ # it is the minting signal. `context` is required, which is the only
+ # thing that makes agents supply intent at all.
+ assert "context" in add.inputSchema["required"]
+ assert any(t.name == "get_more_tools" for t in listed.tools)
+
+ r1 = await client.call_tool(
+ "add_todo",
+ {
+ "text": "hi",
+ "context": "Adding a todo item for the user's task list to track work",
+ },
+ )
+ text = _text(r1)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+
+ r2 = await client.call_tool("add_todo", {"text": "again", "session_id": minted})
+ assert MINT_BACK_HEADER not in _text(r2)
+
+ call_events = _call_events(capture)
+ # v2 publishes tools/call and nothing else: no initialize, no tools/list,
+ # no agentcat:identify.
+ assert {e.event_type for e in capture} == {"mcp:tools/call"}
+ assert len(call_events) == 2
+ assert call_events[0].session_id == minted == call_events[1].session_id
+ # The event records the call as the agent made it: raw, unstripped.
+ assert call_events[0].parameters["arguments"]["context"]
+ assert call_events[1].parameters["arguments"]["session_id"] == minted
+ # ...and the customer's result, undecorated.
+ assert call_events[0].response is not None
+ assert "Added todo" in json.dumps(call_events[0].response)
+ assert "[MCP INSTRUCTIONS]" not in json.dumps(call_events[0].response)
+ assert call_events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ assert call_events[1].tags[AGENTCAT_TAG_SESSION_SOURCE] == "supplied"
+ assert call_events[0].user_intent.startswith("Adding a todo item")
+
+
+@NEEDS_STRUCTURED_OUTPUT
+async def test_structured_mint_back_mirrors_into_structured_content(capture):
+ """A tool with an outputSchema gets `_mcp_instructions` mirrored in, and its
+ schema declares the field so schema-validating clients still accept it."""
+ server = create_todo_server()
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert MCP_INSTRUCTIONS_KEY in add.outputSchema["properties"]
+
+ result = await client.call_tool("add_todo", {"text": "structured"})
+ mint = result.structuredContent[MCP_INSTRUCTIONS_KEY]
+ assert mint["session_id"].startswith("ses_")
+ assert mint["session_id"] == _call_events(capture)[0].session_id
+ # The customer's own structured payload survives untouched.
+ assert result.structuredContent["result"].startswith("Added todo")
+
+
+async def test_handler_sees_stripped_args_and_customer_result_untouched(capture):
+ """The injected params never reach the tool body, and what the tool returned
+ is exactly what the agent gets back (minus AgentCat's trailing block).
+
+ `seen` is filled at the tool manager, not inside `probe`. A typed body can
+ only ever report the parameters it declared, and this manager drops an
+ undeclared argument SILENTLY rather than raising — so a recorder in the
+ body would read `{"text": "payload"}` whether or not the strip ran. See
+ `tests.test_utils.delivery`.
+ """
+ seen: list[tuple[str, dict]] = []
+ mcp = FastMCP("probe-server")
+
+ @mcp.tool()
+ def probe(text: str) -> str:
+ """A plain typed tool; it cannot police its own arguments."""
+ return f"probe:{text}"
+
+ record_delivered_arguments(mcp._tool_manager, seen)
+ track(mcp, "proj_test")
+
+ async with create_test_client(mcp) as client:
+ await client.list_tools()
+ result = await client.call_tool(
+ "probe",
+ {"text": "payload", "session_id": sid("supplied"), "context": "why"},
+ )
+
+ assert result.isError is False, _text(result)
+ assert seen == [("probe", {"text": "payload"})]
+ assert result.content[0].text == "probe:payload"
+ # session_id was supplied, so nothing is minted back and nothing is appended.
+ assert len(result.content) == 1
+ assert _call_events(capture)[0].session_id == sid("supplied")
+
+
+async def test_get_more_tools_keeps_its_own_context_and_publishes(capture):
+ server = create_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ gmt = next(t for t in listed.tools if t.name == "get_more_tools")
+ # Its bespoke `context` is a real parameter: still required, still
+ # described by the tool's own copy — and handles ride alongside.
+ assert gmt.inputSchema["required"] == ["context"]
+ assert "session_id" in gmt.inputSchema["properties"]
+ assert read_only_hint(gmt) is True
+
+ result = await client.call_tool(
+ "get_more_tools", {"context": "I need a tool to send emails"}
+ )
+ assert "Unfortunately" in _text(result)
+
+ event = _call_events(capture)[0]
+ assert event.resource_name == "get_more_tools"
+ assert event.user_intent == "I need a tool to send emails"
+ assert event.parameters["arguments"]["context"] == "I need a tool to send emails"
+
+
+async def test_agent_tracking_injection(capture):
+ server = create_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_agent_tracking=True))
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.inputSchema["properties"])[-3:] == [
+ "session_id",
+ "agent_id",
+ "context",
+ ]
+ assert "agent_id" in add.inputSchema["required"]
+ assert "session_id" not in add.inputSchema["required"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "with agent", "agent_id": "opus|claude-code|k3n9x"}
+ )
+ assert result.isError is False, _text(result)
+
+ event = _call_events(capture)[0]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == "opus|claude-code|k3n9x"
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+
+
+async def test_hook_mode(capture):
+ server = create_todo_server()
+ track(
+ server,
+ "proj_test",
+ AgentCatOptions(resolve_session_id=lambda request, extra: "cust-1"),
+ )
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ assert listed.tools
+ for tool in listed.tools:
+ assert "session_id" not in tool.inputSchema.get("properties", {})
+
+ r1 = await client.call_tool("add_todo", {"text": "hook one"})
+ r2 = await client.call_tool("add_todo", {"text": "hook two"})
+ assert "[MCP INSTRUCTIONS]" not in _text(r1)
+ assert "[MCP INSTRUCTIONS]" not in _text(r2)
+ # `getattr`, not attribute access: `structuredContent` is a real field
+ # from mcp 1.10 and an unset extra before it, and pydantic raises
+ # AttributeError for an extra that was never assigned. The claim here
+ # is absence either way.
+ assert MCP_INSTRUCTIONS_KEY not in (
+ getattr(r1, "structuredContent", None) or {}
+ )
+
+ call_events = _call_events(capture)
+ expected = derive_session_id("cust-1", "proj_test")
+ assert [e.session_id for e in call_events] == [expected, expected]
+ assert call_events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "hook"
+
+
+async def test_tracing_disabled_strips_but_publishes_nothing(capture):
+ server = create_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_tracing=False))
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ # No handles when tracing is off, but the context parameter is
+ # independent — so it must still be stripped before the tool runs.
+ assert "session_id" not in add.inputSchema["properties"]
+ assert "context" in add.inputSchema["properties"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "quiet", "context": "no tracing"}
+ )
+ assert result.isError is False, _text(result)
+ assert "[MCP INSTRUCTIONS]" not in _text(result)
+
+ # Read at the tool manager: `add_todo` is a typed body, which cannot show
+ # an argument that arrived and was dropped on the way in.
+ assert delivered_arguments_for(server, "add_todo") == [{"text": "quiet"}]
+ assert capture == []
+
+
+async def test_customer_tool_schema_is_never_mutated(capture):
+ """Injection works on deep copies: the server's own tool definitions and a
+ second, untracked server built the same way stay identical."""
+ server = create_todo_server()
+ reference = create_todo_server()
+ track(server, "proj_test")
+
+ async with create_test_client(server) as client:
+ await client.list_tools()
+
+ tracked = {t.name: t.inputSchema for t in await server.list_tools()}
+ untracked = {t.name: t.inputSchema for t in await reference.list_tools()}
+ assert tracked == untracked
+
+
+async def test_error_result_still_carries_the_handle(capture):
+ server = create_todo_server()
+ track(server, "proj_test")
+
+ async with create_test_client(server) as client:
+ await client.list_tools()
+ result = await client.call_tool("complete_todo", {"id": 999})
+
+ assert result.isError is True
+ # A retry after a failure has to carry the same task, so error results
+ # decorate on identical terms.
+ assert MINT_BACK_HEADER in _text(result)
+ event = _call_events(capture)[0]
+ assert event.is_error is True
+ assert event.session_id.startswith("ses_")
+ # The SDK flattened the exception before this wrapper saw it, so the type
+ # comes from the inner tap (Task 13.5) rather than from the result — and
+ # the customer's own ValueError is the recorded cause.
+ assert "Todo with ID 999 not found" in event.error["message"]
+ assert event.error["type"] == "ToolError"
+ assert event.error["chained_errors"][0]["type"] == "ValueError"
+ assert event.error["platform"] == "python"
+
+
+async def test_customer_get_more_tools_is_never_hijacked(capture):
+ """A customer tool that happens to be named `get_more_tools` keeps running.
+
+ We refuse to advertise a second one; we must equally refuse to answer for
+ the one they wrote (spec §12 — nothing may alter customer tool behavior).
+ """
+ mcp = FastMCP("collision-server")
+
+ @mcp.tool()
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool, which happens to share our name."""
+ return f"customer answered: {context}"
+
+ track(mcp, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_test_client(mcp) as client:
+ listed = await client.list_tools()
+ # Exactly one, and it is theirs — ours is not advertised alongside it.
+ assert [t.name for t in listed.tools] == ["get_more_tools"]
+ assert listed.tools[0].description.startswith("The customer's own tool")
+
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert result.isError is False, _text(result)
+ assert "customer answered: mine" in _text(result)
+ assert "Unfortunately" not in _text(result)
+ # Still tracked like any other tool call.
+ assert _call_events(capture)[0].resource_name == "get_more_tools"
+
+
+async def test_customer_get_more_tools_survives_a_call_before_any_listing(capture):
+ """The ownership question is settled by the same rebuild that restores the
+ strip registry, so a first-ever call is not hijacked either."""
+ mcp = FastMCP("collision-server")
+
+ @mcp.tool()
+ def get_more_tools(context: str) -> str:
+ """The customer's own tool."""
+ return f"customer answered: {context}"
+
+ track(mcp, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_test_client(mcp) as client:
+ # No list_tools first.
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert "customer answered: mine" in _text(result)
+
+
+async def test_customer_get_more_tools_survives_a_server_with_no_listing(capture):
+ """Nothing advertised the tool, so we may not answer for it.
+
+ A server that exposes no `tools/list` at all never runs the pass that would
+ tell us who owns the name. The gate is stated positively — "AgentCat
+ advertised it" — so an absent listing keeps the customer's handler.
+ """
+ from mcp.server.lowlevel import Server
+ from mcp.types import CallToolRequest, CallToolRequestParams
+
+ server = Server("no-listing-server")
+
+ @server.call_tool()
+ async def call_tool(name: str, arguments: dict):
+ return [TextContent(type="text", text=f"customer answered: {name}")]
+
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+ handler = server.request_handlers[CallToolRequest]
+ result = await handler(
+ CallToolRequest(
+ method="tools/call",
+ params=CallToolRequestParams(
+ name="get_more_tools", arguments={"context": "mine"}
+ ),
+ )
+ )
+
+ assert "customer answered: get_more_tools" in _text(result.root)
+ assert "Unfortunately" not in _text(result.root)
+
+
+async def test_retracking_updates_options_without_double_wrapping(capture):
+ server = create_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=False))
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ assert [t.name for t in listed.tools].count("get_more_tools") == 1
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert list(add.inputSchema["properties"]) == ["text", "session_id", "context"]
+
+ # A second, stacked wrapper would inject session_id in the inner pass and
+ # then skip it in the outer one — leaving it out of the strip registry
+ # and handing the customer's tool a parameter it never declared.
+ result = await client.call_tool(
+ "add_todo", {"text": "retracked", "session_id": sid("retrack")}
+ )
+ assert result.isError is False, _text(result)
+
+ assert len(_call_events(capture)) == 1
+ assert _call_events(capture)[0].session_id == sid("retrack")
+
+
+async def test_request_arguments_are_never_mutated_in_place():
+ """The v1 path popped `context` off the caller's own arguments dict, which
+ corrupted concurrent retries. Stripping must always clone first."""
+ from mcp.types import CallToolRequest, CallToolRequestParams
+
+ server = create_todo_server()
+ track(server, "proj_test")
+ handler = server._mcp_server.request_handlers[CallToolRequest]
+
+ request = CallToolRequest(
+ method="tools/call",
+ params=CallToolRequestParams(
+ name="add_todo",
+ arguments={"text": "hi", "context": "why", "session_id": sid("supplied")},
+ ),
+ )
+ stored = request.params.arguments
+ result = await handler(request)
+
+ assert result.root.isError is False, result.root.content
+ # The very dict the request holds, still untouched — the handler ran against
+ # a clone.
+ assert request.params.arguments is stored
+ assert stored == {"text": "hi", "context": "why", "session_id": sid("supplied")}
+
+
+def test_a_tracked_server_is_collectable_once_the_customer_drops_it():
+ """Nothing AgentCat holds may outlive the server (changelog §6.8).
+
+ A module-level `WeakKeyDictionary` does not give you this for free: the
+ wrappers filed there close over the server, so the value keeps its own weak
+ key alive and every tracked server becomes immortal.
+ """
+ import gc
+ import weakref
+
+ from mcp.server.lowlevel import Server
+
+ alive = []
+ for _ in range(3):
+ server = Server("collectable")
+ _register_todo_handlers(server)
+ track(server, "proj_test")
+ alive.append(weakref.ref(server))
+ del server
+
+ gc.collect()
+ assert [ref() for ref in alive] == [None, None, None]
+
+
+def test_a_tracked_fastmcp_is_collectable_with_its_lowlevel_server():
+ """The adapter is installed on `_mcp_server`, so both halves have to go."""
+ import gc
+ import weakref
+
+ server = create_todo_server()
+ track(server, "proj_test")
+ facade, lowlevel = weakref.ref(server), weakref.ref(server._mcp_server)
+ del server
+
+ gc.collect()
+ assert (facade(), lowlevel()) == (None, None)
+
+
+def _register_todo_handlers(server) -> None:
+ """The customer's own `@server.list_tools()` / `@server.call_tool()` pair."""
+ from mcp.types import Tool
+
+ @server.list_tools()
+ async def list_tools() -> list:
+ return [
+ Tool(
+ name="add_todo",
+ description="Add a new todo item.",
+ inputSchema={
+ "type": "object",
+ "properties": {"text": {"type": "string"}},
+ "required": ["text"],
+ },
+ )
+ ]
+
+ @server.call_tool()
+ async def call_tool(name: str, arguments: dict):
+ unexpected = sorted(set(arguments) - {"text"})
+ if unexpected:
+ raise ValueError(f"unexpected arguments: {unexpected}")
+ return [TextContent(type="text", text=f"Added todo: {arguments['text']}")]
+
+
+async def test_handlers_registered_after_track_are_still_wrapped(capture):
+ """`track()` on a server with no tools handlers yet still takes effect.
+
+ Registration on this generation writes straight into `request_handlers`, so
+ without a patched decorator the customer's later `@server.call_tool()`
+ would simply overwrite our wrapper — a fresh `Server()` tracked at import
+ time would go untracked forever.
+ """
+ from mcp.server.lowlevel import Server
+
+ server = Server("late-server")
+ track(server, "proj_test")
+ _register_todo_handlers(server)
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ assert "session_id" in add.inputSchema["properties"]
+ result = await client.call_tool("add_todo", {"text": "late"})
+ assert result.isError is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_reregistering_a_handler_after_track_rewraps_it(capture):
+ """A customer who replaces their `call_tool` handler after `track()` does
+ not silently lose tracking."""
+ from mcp.server.lowlevel import Server
+
+ server = Server("replaced-server")
+ _register_todo_handlers(server)
+ track(server, "proj_test")
+
+ @server.call_tool()
+ async def replacement(name: str, arguments: dict):
+ unexpected = sorted(set(arguments) - {"text"})
+ if unexpected:
+ raise ValueError(f"unexpected arguments: {unexpected}")
+ return [TextContent(type="text", text=f"replaced: {arguments['text']}")]
+
+ async with create_test_client(server) as client:
+ await client.list_tools()
+ result = await client.call_tool("add_todo", {"text": "again"})
+ assert result.isError is False, _text(result)
+ assert "replaced: again" in _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_re_arm_is_not_stacked_by_a_second_track(capture):
+ """Two `track()` calls leave one wrapper, not two, even on a handler
+ registered after both."""
+ from mcp.server.lowlevel import Server
+
+ server = Server("double-tracked")
+ track(server, "proj_test")
+ track(server, "proj_test")
+ _register_todo_handlers(server)
+
+ async with create_test_client(server) as client:
+ listed = await client.list_tools()
+ add = next(t for t in listed.tools if t.name == "add_todo")
+ # A stacked pair would inject session_id twice or leave it unstrippable.
+ assert list(add.inputSchema["properties"]) == ["text", "session_id", "context"]
+ result = await client.call_tool("add_todo", {"text": "twice"})
+ assert result.isError is False, _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_intermediate_mrtr_round_is_tagged_but_never_decorated(capture):
+ """A round that asks the client for more input is not the completing round,
+ so it carries no mint-back — text or structured.
+
+ The round is registered straight into `request_handlers` rather than
+ through `@server.call_tool()`: returning a `CallToolResult` from the
+ decorated function is only honored from mcp 1.19 (PR #1459), and below it
+ the SDK wraps the model itself as content and fails its own validation.
+ `request_handlers` takes a `ServerResult` on every 1.x, and `resultType`
+ rides along as an extra field because `CallToolResult` is extra="allow".
+ """
+ from mcp.server.lowlevel import Server
+ from mcp.types import (
+ CallToolRequest,
+ CallToolRequestParams,
+ CallToolResult,
+ ServerResult,
+ Tool,
+ )
+
+ server = Server("mrtr-server")
+
+ @server.list_tools()
+ async def list_tools() -> list:
+ return [
+ Tool(
+ name="ask",
+ description="Needs another round.",
+ inputSchema={"type": "object", "properties": {}},
+ )
+ ]
+
+ async def call_tool(req):
+ return ServerResult(
+ CallToolResult(
+ content=[TextContent(type="text", text="need more")],
+ resultType="input_required",
+ )
+ )
+
+ server.request_handlers[CallToolRequest] = call_tool
+
+ track(server, "proj_test")
+ handler = server.request_handlers[CallToolRequest]
+ result = await handler(
+ CallToolRequest(
+ method="tools/call",
+ params=CallToolRequestParams(name="ask", arguments={}),
+ )
+ )
+
+ assert [block.text for block in result.root.content] == ["need more"]
+ # See the note in `test_hook_mode`: absence is the claim, and below mcp
+ # 1.10 an unmirrored `structuredContent` is an unset extra rather than a
+ # field holding None.
+ assert getattr(result.root, "structuredContent", None) is None
+ event = _call_events(capture)[0]
+ assert event.tags["agentcat_mrtr"] == "input_required"
+ assert event.session_id.startswith("ses_")
+
+
+def test_track_never_raises():
+ sentinel = object()
+ assert track(sentinel, None) is sentinel
+ assert track(sentinel, "proj_test") is sentinel
+ assert track(None, "proj_test") is None
+ assert track(object(), "proj_test", AgentCatOptions(enable_tracing=False))
+ # An options object of the wrong shape used to blow up on attribute access.
+ assert track(sentinel, "proj_test", {"debug_mode": True}) is sentinel
+ # Missing project_id with no exporters used to raise ValueError.
+ server = create_todo_server()
+ assert track(server, None) is server
+
+
+def test_unrecognized_shape_logs_the_fingerprint_beacon(log_sink):
+ """The unknown-shape path feeds the diagnostics sink a probe fingerprint —
+ that is the fleet-drift beacon (changelog 6.7)."""
+ track(object(), "proj_test")
+
+ beacons = [line for line in log_sink if "fingerprint=" in line]
+ assert beacons, log_sink
+ assert "Unrecognized" in beacons[0]
+ assert "has_request_handlers" in beacons[0]
+
+
+def test_community_fastmcp_v2_is_reported_unsupported(log_sink):
+ """A FastMCP 2.x-shaped object is refused with actionable copy, not tracked."""
+
+ class FastMCPV2Shape:
+ def __init__(self) -> None:
+ self._mcp_server = object()
+ self._tool_manager = object()
+
+ FastMCPV2Shape.__module__ = "fastmcp.server.server"
+ server = FastMCPV2Shape()
+
+ assert track(server, "proj_test") is server
+ assert any("agentcat<2" in line for line in log_sink), log_sink
+
+
+def test_the_installed_official_sdk_classifies_as_a_lowlevel_v1_flavor():
+ """Every other detection test builds doubles from what we *believe* each
+ generation looks like. This one asks the SDK actually installed here, so an
+ upstream shape change surfaces as a failure rather than as a silently
+ untracked fleet.
+
+ Replaces tests/test_mcp_version_compatibility.py, whose `is_compatible_server`
+ gate the flavor classifier subsumes.
+ """
+ from mcp.server import Server
+
+ from agentcat.modules.detection import ServerFlavor, detect_server
+
+ fastmcp = create_todo_server()
+ detected = detect_server(fastmcp)
+ assert detected.flavor is ServerFlavor.OFFICIAL_FASTMCP_V1
+ assert detected.lowlevel is fastmcp._mcp_server
+
+ lowlevel = Server("bare")
+ detected = detect_server(lowlevel)
+ assert detected.flavor is ServerFlavor.LOWLEVEL_V1
+ assert detected.lowlevel is lowlevel
+
+
+async def test_wrapped_list_preserves_meta_and_extra_result_fields(capture):
+ """Audit finding 11: the v1 tools/list rebuild used to reconstruct
+ `ListToolsResult(tools=..., nextCursor=...)`, silently dropping `_meta`
+ and any extra fields a raw list handler set. The result must now be a
+ `model_copy` of the customer's own object — injection applied, everything
+ else intact (mcp 1.x `Result` is extra='allow')."""
+ from mcp.server.lowlevel import Server
+ from mcp.types import ListToolsRequest, ListToolsResult, ServerResult, Tool
+
+ server = Server("meta-server")
+
+ async def list_tools(req):
+ return ServerResult(
+ ListToolsResult(
+ tools=[
+ Tool(
+ name="echo",
+ description="Echo the text back.",
+ inputSchema={
+ "type": "object",
+ "properties": {"text": {"type": "string"}},
+ },
+ )
+ ],
+ nextCursor="page-2",
+ # The aliased spelling: this mcp generation does not populate
+ # the `meta` field by name, only via its `_meta` alias.
+ **{"_meta": {"total": 41}},
+ cacheHint="warm", # an extra field, allowed by Result
+ )
+ )
+
+ server.request_handlers[ListToolsRequest] = list_tools
+
+ track(server, "proj_test", AgentCatOptions())
+ handler = server.request_handlers[ListToolsRequest]
+ result = await handler(ListToolsRequest(method="tools/list"))
+
+ listed = result.root
+ # Injection happened...
+ echo = next(t for t in listed.tools if t.name == "echo")
+ assert "session_id" in echo.inputSchema["properties"]
+ # ...and nothing the customer's handler set was dropped.
+ assert listed.nextCursor == "page-2"
+ assert listed.meta == {"total": 41}
+ assert getattr(listed, "cacheHint", None) == "warm"
diff --git a/tests/test_lowlevel_v2_handles.py b/tests/test_lowlevel_v2_handles.py
new file mode 100644
index 0000000..938b9f2
--- /dev/null
+++ b/tests/test_lowlevel_v2_handles.py
@@ -0,0 +1,1360 @@
+"""End-to-end handle behavior on the official MCP SDK 2.x lowlevel adapter.
+
+Everything here drives a real in-process `Client` against a real server, so the
+assertions cover the whole wire path — params validation, the SDK's outbound
+result sieve, and the per-version `serverInfo` stamp — rather than a
+hand-called handler. What is asserted: schema injection on `tools/list`, the
+stripped arguments the customer's tool actually receives, the mint-back the
+agent sees, and the single `mcp:tools/call` event AgentCat publishes.
+
+This is the first era where multi-round-trip tool calls are reachable, so the
+`input_required` / `continuation` rounds here are real protocol behavior, not a
+simulation.
+"""
+
+import copy
+import dataclasses
+import gc
+import json
+import weakref
+
+import pytest
+from mcp import types
+from mcp.server import Server
+from mcp.server.mcpserver import MCPServer
+from mcp.shared.exceptions import MCPError
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_AGENT_ID,
+ AGENTCAT_TAG_AGENT_SOURCE,
+ AGENTCAT_TAG_MRTR,
+ AGENTCAT_TAG_PROTOCOL_VERSION,
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MCP_INSTRUCTIONS_KEY,
+)
+from agentcat.modules.handles import derive_session_id
+
+from .test_utils import sid
+from .test_utils.delivery import delivered_arguments_for
+from .test_utils.modern_server import (
+ ADD_TODO_INPUT_SCHEMA,
+ create_lowlevel_todo_server,
+ create_mcpserver_todo_server,
+ create_modern_client,
+)
+
+MINT_BACK_HEADER = "[MCP INSTRUCTIONS]: session_id issued."
+MODERN = "2026-07-28"
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+def _text(result) -> str:
+ return "".join(c.text for c in result.content if hasattr(c, "text"))
+
+
+def _call_events(capture) -> list:
+ return [e for e in capture if e.event_type == "mcp:tools/call"]
+
+
+def _tool(listed, name):
+ return next(t for t in listed.tools if t.name == name)
+
+
+# ── injection, mint-back, strip, event ───────────────────────────────────────
+
+
+async def test_prompted_mode_end_to_end(capture):
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ add = _tool(listed, "add_todo")
+ assert list(add.input_schema["properties"])[-2:] == ["session_id", "context"]
+ # session_id is the one injected param that is never required — omitting
+ # it is the minting signal. `context` is required, which is the only
+ # thing that makes agents supply intent at all.
+ assert "session_id" not in add.input_schema.get("required", [])
+ assert "context" in add.input_schema["required"]
+ assert any(t.name == "get_more_tools" for t in listed.tools)
+
+ r1 = await client.call_tool(
+ "add_todo",
+ {
+ "text": "hi",
+ "context": "Adding a todo item for the user's task list to track work",
+ },
+ )
+ text = _text(r1)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+ assert minted.startswith("ses_")
+
+ r2 = await client.call_tool("add_todo", {"text": "again", "session_id": minted})
+ assert MINT_BACK_HEADER not in _text(r2)
+
+ call_events = _call_events(capture)
+ # v2 publishes tools/call and nothing else: no initialize, no tools/list,
+ # no agentcat:identify.
+ assert {e.event_type for e in capture} == {"mcp:tools/call"}
+ assert len(call_events) == 2
+ assert call_events[0].session_id == minted == call_events[1].session_id
+ # The event records the call as the agent made it: raw, unstripped.
+ assert call_events[0].parameters["arguments"]["context"]
+ assert call_events[1].parameters["arguments"]["session_id"] == minted
+ # ...and the customer's result, undecorated.
+ assert call_events[0].response is not None
+ assert "Added todo" in json.dumps(call_events[0].response)
+ assert "[MCP INSTRUCTIONS]" not in json.dumps(call_events[0].response)
+ assert call_events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+ assert call_events[1].tags[AGENTCAT_TAG_SESSION_SOURCE] == "supplied"
+ assert call_events[0].user_intent.startswith("Adding a todo item")
+
+
+async def test_protocol_version_tag_comes_from_the_request(capture):
+ """The 2026 wire carries the version in `_meta`; the handshake era falls
+ back to `ctx.protocol_version`. Both must reach the event."""
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.call_tool("add_todo", {"text": "modern"})
+ assert _call_events(capture)[-1].tags[AGENTCAT_TAG_PROTOCOL_VERSION] == MODERN
+
+ async with create_modern_client(server, mode="legacy") as client:
+ await client.call_tool("add_todo", {"text": "legacy"})
+ version = _call_events(capture)[-1].tags[AGENTCAT_TAG_PROTOCOL_VERSION]
+ assert version and version != MODERN
+
+
+async def test_client_identity_ladder(capture):
+ """Envelope `_meta` on the modern wire; the handshake `client_info` the
+ session kept on the legacy one."""
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+ info = types.Implementation(name="MyAgent", version="1.2.3")
+
+ for mode in (MODERN, "legacy"):
+ async with create_modern_client(server, mode=mode, client_info=info) as client:
+ await client.call_tool("add_todo", {"text": mode})
+ event = _call_events(capture)[-1]
+ assert (event.client_name, event.client_version) == ("MyAgent", "1.2.3"), mode
+
+
+async def test_structured_mint_back_mirrors_into_structured_content(capture):
+ """A tool with an output schema gets `_mcp_instructions` mirrored in, and
+ its schema declares the field so schema-validating clients still accept
+ it."""
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ assert MCP_INSTRUCTIONS_KEY in _tool(listed, "add_todo").output_schema[
+ "properties"
+ ]
+
+ result = await client.call_tool("add_todo", {"text": "structured"})
+ mint = result.structured_content[MCP_INSTRUCTIONS_KEY]
+ assert mint["session_id"].startswith("ses_")
+ assert mint["session_id"] == _call_events(capture)[0].session_id
+ # The customer's own structured payload survives untouched.
+ assert result.structured_content["result"].startswith("Added todo")
+
+
+async def test_handler_sees_stripped_args_and_customer_result_untouched(capture):
+ """The injected params never reach the tool body, and what the tool returned
+ is exactly what the agent gets back (minus AgentCat's trailing block)."""
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ result = await client.call_tool(
+ "add_todo",
+ {"text": "payload", "session_id": sid("supplied"), "context": "why"},
+ )
+
+ # The tool raises on any argument it did not declare, so a strip regression
+ # surfaces here rather than passing silently.
+ assert result.is_error is False, _text(result)
+ assert result.content[0].text == 'Added todo: "payload" with ID 1'
+ # session_id was supplied, so nothing is minted back and nothing is appended.
+ assert len(result.content) == 1
+ assert _call_events(capture)[0].session_id == sid("supplied")
+
+
+async def test_customer_tool_schema_is_never_mutated(capture):
+ """Injection works on copies, never in place on customer objects."""
+ pristine = copy.deepcopy(ADD_TODO_INPUT_SCHEMA)
+ server = create_lowlevel_todo_server()
+ reference = create_lowlevel_todo_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ assert "session_id" in _tool(listed, "add_todo").input_schema["properties"]
+
+ # The schema constant every todo server builds its tools from. Pydantic
+ # copies only the outermost dict, so `properties` is shared by reference
+ # with the listed Tool — an in-place injection pass shows up right here.
+ assert ADD_TODO_INPUT_SCHEMA == pristine
+
+ untracked = await reference.get_request_handler("tools/list").handler(None, None)
+ for tool in untracked.tools:
+ assert "session_id" not in tool.input_schema["properties"], tool.name
+ assert "context" not in tool.input_schema["properties"], tool.name
+
+
+async def test_agent_tracking_injection(capture):
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_agent_tracking=True))
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ add = _tool(listed, "add_todo")
+ assert list(add.input_schema["properties"])[-3:] == [
+ "session_id",
+ "agent_id",
+ "context",
+ ]
+ assert "agent_id" in add.input_schema["required"]
+ assert "session_id" not in add.input_schema["required"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "with agent", "agent_id": "opus|claude-code|k3n9x"}
+ )
+ assert result.is_error is False, _text(result)
+
+ event = _call_events(capture)[0]
+ assert event.tags[AGENTCAT_TAG_AGENT_ID] == "opus|claude-code|k3n9x"
+ assert event.tags[AGENTCAT_TAG_AGENT_SOURCE] == "supplied"
+
+
+async def test_hook_mode(capture):
+ server = create_lowlevel_todo_server()
+ track(
+ server,
+ "proj_test",
+ AgentCatOptions(resolve_session_id=lambda request, extra: "cust-1"),
+ )
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ assert listed.tools
+ for tool in listed.tools:
+ assert "session_id" not in tool.input_schema.get("properties", {})
+
+ r1 = await client.call_tool("add_todo", {"text": "hook one"})
+ r2 = await client.call_tool("add_todo", {"text": "hook two"})
+ assert "[MCP INSTRUCTIONS]" not in _text(r1)
+ assert "[MCP INSTRUCTIONS]" not in _text(r2)
+ assert MCP_INSTRUCTIONS_KEY not in (r1.structured_content or {})
+
+ call_events = _call_events(capture)
+ expected = derive_session_id("cust-1", "proj_test")
+ assert [e.session_id for e in call_events] == [expected, expected]
+ assert call_events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "hook"
+
+
+async def test_tracing_disabled_strips_but_publishes_nothing(capture):
+ server = create_lowlevel_todo_server()
+ track(
+ server,
+ "proj_test",
+ AgentCatOptions(enable_tracing=False, enable_report_missing=True),
+ )
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ add = _tool(listed, "add_todo")
+ # No handles when tracing is off, but the context parameter is
+ # independent — so it must still be stripped before the tool runs.
+ assert "session_id" not in add.input_schema["properties"]
+ assert "context" in add.input_schema["properties"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "quiet", "context": "no tracing"}
+ )
+ assert result.is_error is False, _text(result)
+ assert "[MCP INSTRUCTIONS]" not in _text(result)
+
+ # get_more_tools is advertised and still answers with tracing off
+ # (changelog 6.6) — it is a tool, not telemetry.
+ reported = await client.call_tool("get_more_tools", {"context": "email"})
+ assert "Unfortunately" in _text(reported)
+
+ assert capture == []
+
+
+async def test_error_result_still_carries_the_handle(capture):
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ result = await client.call_tool("complete_todo", {"id": 999})
+
+ assert result.is_error is True
+ # A retry after a failure has to carry the same task, so error results
+ # decorate on identical terms.
+ assert MINT_BACK_HEADER in _text(result)
+ event = _call_events(capture)[0]
+ assert event.is_error is True
+ assert event.session_id.startswith("ses_")
+ # The no-tap error payload: the tool flattened whatever went wrong before
+ # we saw it, so type is unrecoverable — but the message is the tool's own
+ # text, not a repr of the result model, and the shape stays SDK-wide.
+ assert event.error == {
+ "message": "Todo with ID 999 not found",
+ "type": None,
+ "platform": "python",
+ }
+
+
+async def test_raising_handler_publishes_the_live_exception(capture):
+ """A lowlevel v2 handler that raises reaches our wrapper with its traceback
+ intact, so the event keeps the real type."""
+
+ async def on_call_tool(ctx, params):
+ raise RuntimeError("kaboom")
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="boom",
+ description="Raises.",
+ input_schema={"type": "object", "properties": {}},
+ )
+ ]
+ )
+
+ server = Server("raiser", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ # The runner turns a raised handler into a protocol-level error, so the
+ # agent sees a failed request rather than an isError result.
+ with pytest.raises(MCPError):
+ await client.call_tool("boom", {})
+
+ event = _call_events(capture)[0]
+ assert event.is_error is True
+ assert event.error["type"] == "RuntimeError"
+ assert event.error["message"] == "kaboom"
+
+
+# ── multi round-trip (§6.4) ──────────────────────────────────────────────────
+
+
+def _mrtr_server() -> Server:
+ """A tool that needs one extra round before it can complete."""
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="ask",
+ description="Needs another round.",
+ input_schema={"type": "object", "properties": {}},
+ output_schema={
+ "type": "object",
+ "properties": {"answer": {"type": "string"}},
+ },
+ )
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ if params.input_responses is None:
+ result = types.InputRequiredResult(
+ request_state="opaque-state",
+ input_requests={"roots": types.ListRootsRequest(method="roots/list")},
+ )
+ else:
+ result = types.CallToolResult(
+ content=[types.TextContent(type="text", text="done")],
+ structured_content={"answer": "done"},
+ )
+ return result
+
+ return Server("mrtr", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
+
+
+async def test_intermediate_mrtr_round_is_tagged_but_never_decorated(capture):
+ """Two real rounds over the wire: the intermediate one is tagged
+ `input_required` and reaches the agent as the server built it, the
+ completing one is tagged `continuation` and carries the mint-back.
+
+ The tag assertions are what this test is for. What stops the intermediate
+ round being decorated is asserted by
+ `test_an_intermediate_round_that_carries_content_is_still_undecorated` —
+ an `InputRequiredResult` has no `content` or `structured_content` to
+ decorate in the first place, so it cannot show the gate working.
+ """
+ server = _mrtr_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ first = await client.session.call_tool("ask", {}, allow_input_required=True)
+ assert isinstance(first, types.InputRequiredResult)
+ assert first.request_state == "opaque-state"
+
+ second = await client.session.call_tool(
+ "ask",
+ {},
+ input_responses={"roots": types.ListRootsResult(roots=[])},
+ request_state=first.request_state,
+ allow_input_required=True,
+ )
+
+ events = _call_events(capture)
+ assert len(events) == 2
+ assert events[0].tags[AGENTCAT_TAG_MRTR] == "input_required"
+ assert events[1].tags[AGENTCAT_TAG_MRTR] == "continuation"
+ # The intermediate round still publishes.
+ assert events[0].session_id.startswith("ses_")
+ # Only the completing round carries the mint-back.
+ assert MINT_BACK_HEADER in _text(second)
+ assert MCP_INSTRUCTIONS_KEY in second.structured_content
+
+
+async def test_input_responses_alone_still_tags_a_continuation(capture):
+ """`inputResponses` is a continuation witness on its own.
+
+ The round above carries both witnesses, so it can no longer tell which one
+ fired. This one carries `inputResponses` with **no** `requestState`, which
+ is the shape a client resuming from persisted responses sends — and the
+ path that was pinned before `requestState` was added as a second witness.
+ """
+ server = _mrtr_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ await client.session.call_tool(
+ "ask",
+ {},
+ input_responses={"roots": types.ListRootsResult(roots=[])},
+ allow_input_required=True,
+ )
+
+ event = _call_events(capture)[0]
+ assert event.tags[AGENTCAT_TAG_MRTR] == "continuation"
+
+
+def _state_only_mrtr_server() -> Server:
+ """A tool that asks to be RESUMED rather than asking the client anything.
+
+ Its intermediate round carries `requestState` and no `inputRequests`, so
+ the SEP-2322 driver retries it after a backoff with no `inputResponses` at
+ all — the continuation shape that carries only `requestState`, and the one
+ FastMCP 4's own `InputRequiredResult(request_state=...)` produces.
+ """
+ rounds: list = []
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="resume",
+ description="Asks to be resumed.",
+ input_schema={
+ "type": "object",
+ "properties": {"text": {"type": "string"}},
+ },
+ )
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ rounds.append(params.request_state)
+ if len(rounds) == 1:
+ return types.InputRequiredResult(request_state="not-done-yet")
+ return types.CallToolResult(
+ content=[types.TextContent(type="text", text="done")]
+ )
+
+ server = Server(
+ "resume", on_list_tools=on_list_tools, on_call_tool=on_call_tool
+ )
+ server.mrtr_rounds = rounds # type: ignore[attr-defined]
+ return server
+
+
+async def test_a_request_state_only_continuation_is_tagged(capture):
+ """A real client drives the state-only chain, and round two is tagged.
+
+ Driven through `client.call_tool`, which runs the SDK's own SEP-2322
+ driver — so the second round is the one the driver built, not one this
+ test hand-shaped. Keying the tag on `inputResponses` alone left this round
+ tagged `None`.
+ """
+ server = _state_only_mrtr_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ await client.call_tool("resume", {"text": "hello"})
+
+ # The driver really did send state and no responses.
+ assert server.mrtr_rounds == [None, "not-done-yet"]
+ events = _call_events(capture)
+ assert [e.tags.get(AGENTCAT_TAG_MRTR) for e in events] == [
+ "input_required",
+ "continuation",
+ ]
+
+
+async def test_a_supplied_session_id_correlates_every_mrtr_round(capture):
+ """The handle the agent supplied rides every round of the conversation.
+
+ The SEP-2322 driver replays the ORIGINAL arguments verbatim on each retry
+ (`mcp/client/_input_required.py`), so a `session_id` the agent supplied on
+ round one is on the wire for round two as well and both rounds resolve to
+ it. This is the correlation changelog §6.4 promises, and it is why the
+ minted-first-call case is the only one that fragments.
+ """
+ server = _state_only_mrtr_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ await client.call_tool(
+ "resume", {"text": "hello", "session_id": sid("supplied")}
+ )
+
+ events = _call_events(capture)
+ assert len(events) == 2
+ assert [e.session_id for e in events] == [sid("supplied"), sid("supplied")]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == [
+ "supplied",
+ "supplied",
+ ]
+ # The customer's tool never sees the handle, on any round.
+ assert server.mrtr_rounds == [None, "not-done-yet"]
+
+
+async def test_hook_mode_correlates_every_mrtr_round(capture):
+ """A `resolve_session_id` hook correlates the rounds too.
+
+ The hook runs per request against that round's own request/extra, and every
+ round of one conversation is the same tool call on the same connection — so
+ anything a hook keys on returns the same value and derives the same task.
+ With `supplied` (above) that leaves prompted-mode MINTING as the only
+ resolution mode an MRTR conversation fragments under.
+ """
+ seen: list = []
+ server = _state_only_mrtr_server()
+
+ def hook(request, extra):
+ seen.append(request)
+ return "tenant"
+
+ track(server, "proj_test", AgentCatOptions(resolve_session_id=hook))
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ await client.call_tool("resume", {"text": "hello"})
+
+ events = _call_events(capture)
+ assert len(seen) == 2 and seen[0] is not seen[1]
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == ["hook", "hook"]
+ derived = derive_session_id("tenant", "proj_test")
+ assert [e.session_id for e in events] == [derived, derived]
+
+
+async def test_an_mrtr_conversation_is_byte_identical_tracked_or_not(capture):
+ """Tagging a round changes the event, never the wire (design §12).
+
+ Both rounds of the same real conversation are compared as serialized JSON
+ against an untracked run of the identical server. The agent supplies a
+ handle, so nothing is minted and there is no specified decoration to
+ subtract: any difference at all would be AgentCat altering what the client
+ is told. `continuation` and an untagged round take the same wire path —
+ only `input_required` gates decoration — so re-tagging one cannot move it.
+ """
+
+ async def rounds_of(tracked: bool) -> list[str]:
+ seen: list = []
+ server = _state_only_mrtr_server()
+ if tracked:
+ track(server, "proj_test")
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ # Driven by hand so the INTERMEDIATE round is observable too; the
+ # SDK's driver resolves it away before `call_tool` returns.
+ first = await client.session.call_tool(
+ "resume",
+ {"text": "hello", "session_id": sid("supplied")},
+ allow_input_required=True,
+ )
+ seen.append(first.model_dump_json())
+ second = await client.session.call_tool(
+ "resume",
+ {"text": "hello", "session_id": sid("supplied")},
+ request_state=first.request_state,
+ allow_input_required=True,
+ )
+ seen.append(second.model_dump_json())
+ return seen
+
+ tracked = await rounds_of(True)
+ untracked = await rounds_of(False)
+ assert tracked == untracked
+ # And the conversation really was tagged on the tracked run.
+ assert [e.tags.get(AGENTCAT_TAG_MRTR) for e in _call_events(capture)] == [
+ "input_required",
+ "continuation",
+ ]
+
+
+async def test_a_minted_first_round_does_not_yet_correlate(capture):
+ """KNOWN GAP, pinned deliberately: a minted MRTR chain fragments.
+
+ Round one mints a handle the agent is never told — §6.4 forbids decorating
+ an intermediate round — and the driver replays round one's arguments
+ verbatim, so round two carries no handle either and mints its own. Both
+ ways of correlating them are closed, but for different reasons: remembering
+ `requestState -> session_id` across rounds is server-side state, which design
+ §13 forbids ("no server-side session or handle storage"), while carrying
+ the handle on the wire INSIDE `requestState` is genuinely stateless and
+ does work — it just rewrites a value the customer's own tool produced,
+ which design §12 forbids. See task 13.6's report §5.
+
+ Delete this test when that design question is answered; until then it stops
+ the fragmentation being rediscovered as a surprise.
+ """
+ server = _state_only_mrtr_server()
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ result = await client.call_tool("resume", {"text": "hello"})
+
+ events = _call_events(capture)
+ assert len(events) == 2
+ assert [e.tags[AGENTCAT_TAG_SESSION_SOURCE] for e in events] == ["minted", "minted"]
+ assert events[0].session_id != events[1].session_id
+ # What the agent is handed is the COMPLETING round's handle, so every call
+ # after the conversation joins that task.
+ minted = _text(result).split("session_id=")[1].split(" ")[0]
+ assert events[1].session_id == minted
+
+
+async def test_an_intermediate_round_that_carries_content_is_still_undecorated(
+ capture,
+):
+ """`result_type` is what makes a round intermediate, not the result class:
+ a `CallToolResult` may report `input_required` while already carrying
+ partial content and structured output. That round must go back exactly as
+ the customer built it — asserted by object identity, because the mint-back
+ would otherwise be indistinguishable from a completing round's."""
+ returned: list = []
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="partial",
+ description="Answers in installments.",
+ input_schema={"type": "object", "properties": {}},
+ output_schema={
+ "type": "object",
+ "properties": {"answer": {"type": "string"}},
+ },
+ )
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ result = types.CallToolResult(
+ content=[types.TextContent(type="text", text="so far so good")],
+ structured_content={"answer": "partial"},
+ result_type="input_required",
+ )
+ returned.append(result)
+ return result
+
+ server = Server(
+ "partial", on_list_tools=on_list_tools, on_call_tool=on_call_tool
+ )
+ track(server, "proj_test")
+
+ handler = server.get_request_handler("tools/call").handler
+ result = await handler(
+ None, types.CallToolRequestParams(name="partial", arguments={})
+ )
+
+ assert result is returned[0]
+ assert MCP_INSTRUCTIONS_KEY not in result.structured_content
+ assert [block.text for block in result.content] == ["so far so good"]
+ assert _call_events(capture)[0].tags[AGENTCAT_TAG_MRTR] == "input_required"
+
+
+async def test_a_resolved_call_is_never_reused_across_rounds(capture):
+ """Each round resolves afresh, so the customer's event callbacks see that
+ round's own params."""
+ seen: list = []
+ server = _mrtr_server()
+ track(
+ server,
+ "proj_test",
+ AgentCatOptions(
+ event_tags=lambda request, extra: seen.append(request) or {"round": "x"}
+ ),
+ )
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ first = await client.session.call_tool("ask", {}, allow_input_required=True)
+ await client.session.call_tool(
+ "ask",
+ {},
+ input_responses={"roots": types.ListRootsResult(roots=[])},
+ request_state=first.request_state,
+ allow_input_required=True,
+ )
+
+ assert len(seen) == 2
+ assert seen[0] is not seen[1]
+ assert seen[0].input_responses is None
+ assert seen[1].input_responses is not None
+
+
+async def test_the_request_clone_keeps_the_continuation_fields(capture):
+ """Stripping clones the params rather than rebuilding them, so
+ `input_responses` / `request_state` / `_meta` survive into the handler."""
+ seen: list = []
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="echo",
+ description="Echo.",
+ input_schema={
+ "type": "object",
+ "properties": {"text": {"type": "string"}},
+ },
+ )
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ seen.append(params)
+ return types.CallToolResult(
+ content=[types.TextContent(type="text", text="ok")]
+ )
+
+ server = Server("echo", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
+ track(server, "proj_test")
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ await client.session.call_tool(
+ "echo",
+ {"text": "hi", "session_id": sid("x"), "context": "why"},
+ input_responses={"roots": types.ListRootsResult(roots=[])},
+ request_state="carried",
+ allow_input_required=True,
+ )
+
+ params = seen[0]
+ assert params.arguments == {"text": "hi"}
+ assert params.request_state == "carried"
+ assert params.input_responses is not None
+ assert params.meta is not None
+
+
+# ── get_more_tools ownership ─────────────────────────────────────────────────
+
+
+async def test_get_more_tools_keeps_its_own_context_and_publishes(capture):
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ gmt = _tool(listed, "get_more_tools")
+ # Its bespoke `context` is a real parameter: still required, still
+ # described by the tool's own copy — and handles ride alongside.
+ assert gmt.input_schema["required"] == ["context"]
+ assert "session_id" in gmt.input_schema["properties"]
+ assert gmt.annotations.read_only_hint is True
+
+ result = await client.call_tool(
+ "get_more_tools", {"context": "I need a tool to send emails"}
+ )
+ assert "Unfortunately" in _text(result)
+
+ event = _call_events(capture)[0]
+ assert event.resource_name == "get_more_tools"
+ assert event.user_intent == "I need a tool to send emails"
+ assert event.parameters["arguments"]["context"] == "I need a tool to send emails"
+
+
+async def test_customer_get_more_tools_is_never_hijacked(capture):
+ """A customer tool that happens to be named `get_more_tools` keeps running.
+
+ We refuse to advertise a second one; we must equally refuse to answer for
+ the one they wrote (spec §12 — nothing may alter customer tool behavior).
+ """
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="get_more_tools",
+ description="The customer's own tool.",
+ input_schema={
+ "type": "object",
+ "properties": {"context": {"type": "string"}},
+ "required": ["context"],
+ },
+ )
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ answer = (params.arguments or {}).get("context")
+ return types.CallToolResult(
+ content=[
+ types.TextContent(type="text", text=f"customer answered: {answer}")
+ ]
+ )
+
+ server = Server(
+ "collision", on_list_tools=on_list_tools, on_call_tool=on_call_tool
+ )
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ # Exactly one, and it is theirs — ours is not advertised alongside it.
+ assert [t.name for t in listed.tools] == ["get_more_tools"]
+ assert listed.tools[0].description.startswith("The customer's own tool")
+
+ result = await client.call_tool("get_more_tools", {"context": "mine"})
+
+ assert result.is_error is False, _text(result)
+ assert "customer answered: mine" in _text(result)
+ assert "Unfortunately" not in _text(result)
+ assert _call_events(capture)[0].resource_name == "get_more_tools"
+
+
+async def test_customer_get_more_tools_survives_a_server_with_no_listing(capture):
+ """Nothing advertised the tool, so we may not answer for it.
+
+ A server that exposes no `tools/list` at all never runs the pass that would
+ tell us who owns the name. The gate is stated positively — "AgentCat
+ advertised it" — so an absent listing keeps the customer's handler.
+
+ Driven through the registered handler rather than a `Client`: the modern
+ client revalidates every successful result against the tool's declared
+ output schema and fetches `tools/list` to get it, so no client can reach a
+ server that serves no listing at all.
+ """
+
+ async def on_call_tool(ctx, params):
+ return types.CallToolResult(
+ content=[
+ types.TextContent(
+ type="text", text=f"customer answered: {params.name}"
+ )
+ ]
+ )
+
+ server = Server("no-listing", on_call_tool=on_call_tool)
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ handler = server.get_request_handler("tools/call").handler
+ result = await handler(
+ None,
+ types.CallToolRequestParams(
+ name="get_more_tools", arguments={"context": "mine"}
+ ),
+ )
+
+ assert "customer answered: get_more_tools" in _text(result)
+ assert "Unfortunately" not in _text(result)
+ # Still tracked: no listing is not a reason to stop publishing.
+ assert _call_events(capture)[0].resource_name == "get_more_tools"
+
+
+# ── install semantics: idempotence, re-arm, degrade ──────────────────────────
+
+
+async def test_retracking_updates_options_without_double_wrapping(capture):
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=False))
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ assert [t.name for t in listed.tools].count("get_more_tools") == 1
+ add = _tool(listed, "add_todo")
+ assert list(add.input_schema["properties"]) == ["text", "session_id", "context"]
+
+ # A second, stacked wrapper would inject session_id in the inner pass and
+ # then skip it in the outer one — leaving it out of the strip registry
+ # and handing the customer's tool a parameter it never declared.
+ result = await client.call_tool(
+ "add_todo", {"text": "retracked", "session_id": sid("retrack")}
+ )
+ assert result.is_error is False, _text(result)
+
+ assert len(_call_events(capture)) == 1
+ assert _call_events(capture)[0].session_id == sid("retrack")
+
+
+def test_a_tracked_server_is_collectable_once_the_customer_drops_it():
+ """Nothing AgentCat holds may outlive the server.
+
+ This era's documented topology is a fresh server per request with
+ `track()` inside the factory (cross-SDK changelog §6.8), so per-server
+ state that survives the server is not a bounded cost — it is one immortal
+ `Server`, handler table, deep-copied schema set and registry pair per
+ request. A module-level `WeakKeyDictionary` does not save you: every value
+ an adapter files there closes over the server, so the value keeps its own
+ weak key alive.
+ """
+ alive = []
+ for _ in range(3):
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+ alive.append(weakref.ref(server))
+ del server
+
+ gc.collect()
+ assert [ref() for ref in alive] == [None, None, None]
+
+
+def test_a_tracked_mcpserver_is_collectable_with_its_lowlevel_server():
+ """The adapter is installed on `_lowlevel_server`, so both halves have to
+ go — a retained lowlevel server pins the MCPServer that owns it, and its
+ tool manager with it."""
+ server = create_mcpserver_todo_server()
+ track(server, "proj_test")
+ facade, lowlevel = weakref.ref(server), weakref.ref(server._lowlevel_server)
+ del server
+
+ gc.collect()
+ assert (facade(), lowlevel()) == (None, None)
+
+
+def test_a_server_that_cannot_hold_state_is_left_untracked(log_sink):
+ """State on the server is what keeps a repeated `track()` idempotent, so a
+ server that refuses the attribute is refused rather than tracked with a
+ wrapper we could never recognize again."""
+
+ class Slotted:
+ # `__weakref__` so it gets as far as the adapter: the tracking-data map
+ # is weakly keyed and would refuse it one step earlier otherwise.
+ __slots__ = ("__weakref__", "_request_handlers", "add_request_handler")
+
+ def __init__(self) -> None:
+ self._request_handlers: dict = {}
+ self.add_request_handler = lambda *a: None
+
+ server = Slotted()
+ source = create_lowlevel_todo_server("slotted-source")
+ server._request_handlers["tools/call"] = source.get_request_handler("tools/call")
+
+ assert track(server, "proj_test") is server
+ assert server._request_handlers["tools/call"] is source.get_request_handler(
+ "tools/call"
+ )
+ assert any("could not store AgentCat state" in line for line in log_sink), log_sink
+
+
+async def test_options_are_read_per_request_not_captured_at_install(capture):
+ """The wrappers re-read the tracking data on every call.
+
+ Captured once at install, a wrapper would serve the first `track()`'s
+ options forever — so anything that replaces a server's data without
+ re-installing (a re-`track()` racing a request, a restore) would silently
+ not take effect.
+ """
+ from agentcat.modules.internal import set_server_tracking_data
+ from agentcat.types import AgentCatData
+
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_tracing=False))
+ set_server_tracking_data(
+ server,
+ AgentCatData(
+ project_id="proj_test", options=AgentCatOptions(enable_tracing=True)
+ ),
+ )
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ assert "session_id" in _tool(listed, "add_todo").input_schema["properties"]
+ result = await client.call_tool("add_todo", {"text": "live"})
+ assert result.is_error is False, _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_handlers_registered_after_track_are_still_wrapped(capture):
+ """`track()` on a server with no tools handlers yet still takes effect: the
+ registration seam is re-armed, so a later `add_request_handler` lands
+ wrapped."""
+ server = Server("late")
+ track(server, "proj_test")
+
+ late = create_lowlevel_todo_server("late-source")
+ for method in ("tools/list", "tools/call"):
+ entry = late.get_request_handler(method)
+ server.add_request_handler(method, entry.params_type, entry.handler)
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ assert "session_id" in _tool(listed, "add_todo").input_schema["properties"]
+ result = await client.call_tool("add_todo", {"text": "late"})
+ assert result.is_error is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_reregistering_a_handler_after_track_rewraps_it(capture):
+ """A customer who replaces their `tools/call` handler after `track()` does
+ not silently lose tracking."""
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test")
+
+ replacement = create_lowlevel_todo_server("replacement")
+ entry = replacement.get_request_handler("tools/call")
+ server.add_request_handler("tools/call", entry.params_type, entry.handler)
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ result = await client.call_tool("add_todo", {"text": "replaced"})
+ assert result.is_error is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_re_arm_is_not_stacked_by_a_second_track(capture):
+ """Two `track()` calls leave one wrapper, not two, even on a handler
+ registered after both."""
+ server = Server("double")
+ track(server, "proj_test")
+ track(server, "proj_test")
+
+ source = create_lowlevel_todo_server("source")
+ for method in ("tools/list", "tools/call"):
+ entry = source.get_request_handler(method)
+ server.add_request_handler(method, entry.params_type, entry.handler)
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ add = _tool(listed, "add_todo")
+ # A stacked pair would inject session_id twice or leave it unstrippable.
+ assert list(add.input_schema["properties"]) == ["text", "session_id", "context"]
+ result = await client.call_tool("add_todo", {"text": "twice"})
+ assert result.is_error is False, _text(result)
+
+ assert len(_call_events(capture)) == 1
+
+
+async def test_resolution_failure_degrades_to_an_untraced_call(capture, log_sink):
+ """A blown resolver must not fail the customer's call — and the injected
+ parameters are still stripped on the way through."""
+ server = create_lowlevel_todo_server()
+ track(server, "proj_test", AgentCatOptions(enable_report_missing=True))
+
+ from agentcat.modules.adapters import lowlevel_v2
+
+ async def boom(*args, **kwargs):
+ raise RuntimeError("resolver exploded")
+
+ original = lowlevel_v2.resolve_call
+ lowlevel_v2.resolve_call = boom
+ try:
+ async with create_modern_client(server, mode=MODERN) as client:
+ await client.list_tools()
+ result = await client.call_tool(
+ "add_todo", {"text": "degraded", "context": "why"}
+ )
+ # A tool AgentCat advertised must still be answered by AgentCat, or
+ # the agent gets "unknown tool" for something it was just offered.
+ reported = await client.call_tool("get_more_tools", {"context": "email"})
+ finally:
+ lowlevel_v2.resolve_call = original
+
+ assert result.is_error is False, _text(result)
+ assert "Unfortunately" in _text(reported)
+ assert _call_events(capture) == []
+ assert any("running it untraced" in line for line in log_sink), log_sink
+
+
+# ── MCPServer ────────────────────────────────────────────────────────────────
+
+
+async def test_mcpserver_end_to_end(capture):
+ """`MCPServer` is served through its `_lowlevel_server` by the same
+ adapter."""
+ server = create_mcpserver_todo_server()
+ assert track(server, "proj_test") is server
+
+ async with create_modern_client(server, mode=MODERN) as client:
+ listed = await client.list_tools()
+ add = _tool(listed, "add_todo")
+ assert list(add.input_schema["properties"])[-2:] == ["session_id", "context"]
+
+ result = await client.call_tool(
+ "add_todo", {"text": "via mcpserver", "context": "why"}
+ )
+ assert result.is_error is False, _text(result)
+ text = _text(result)
+ assert MINT_BACK_HEADER in text
+ minted = text.split("session_id=")[1].split(" ")[0]
+
+ # `add_todo(text: str)` is typed and this manager drops an undeclared
+ # argument silently, so the delivered dict is the only place a failed
+ # strip would show. See `tests.test_utils.delivery`.
+ assert delivered_arguments_for(server, "add_todo") == [{"text": "via mcpserver"}]
+
+ events = _call_events(capture)
+ assert len(events) == 1
+ assert events[0].resource_name == "add_todo"
+ assert events[0].session_id == minted
+ # Server identity is captured off the lowlevel object at track() time.
+ assert events[0].tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+async def test_mcpserver_tracking_is_keyed_on_the_lowlevel_server(capture):
+ server = create_mcpserver_todo_server()
+ track(server, "proj_test")
+
+ from agentcat.modules.internal import get_server_tracking_data
+
+ assert get_server_tracking_data(server._lowlevel_server) is not None
+
+
+class _DevLineServer:
+ """The 2.x shape from before `HandlerEntry` and the public seam landed.
+
+ Read off the vendored upstream checkout
+ (`model-context-protocol-sdks/python-sdk`, v1.25.0-156-g3d7b311): bare
+ callables in `_request_handlers`, registration through a private
+ `_add_request_handler(method, handler)`. The classifier accepts it, so the
+ adapter has to survive it — otherwise a build shipping that spelling would
+ be classified, then explode or half-install.
+ """
+
+ def __init__(self) -> None:
+ self._request_handlers: dict = {}
+
+ def _add_request_handler(self, method, handler) -> None:
+ self._request_handlers[method] = handler
+
+
+async def test_the_pre_handler_entry_shape_installs_through_its_private_seam(
+ capture,
+):
+ server = _DevLineServer()
+ source = create_lowlevel_todo_server("dev-line-source")
+ server._add_request_handler(
+ "tools/list", source.get_request_handler("tools/list").handler
+ )
+
+ track(server, "proj_test")
+
+ # Registered after track(): it lands wrapped through the private seam.
+ server._add_request_handler(
+ "tools/call", source.get_request_handler("tools/call").handler
+ )
+
+ listed = await server._request_handlers["tools/list"](None, None)
+ assert "session_id" in _tool(listed, "add_todo").input_schema["properties"]
+
+ result = await server._request_handlers["tools/call"](
+ None,
+ types.CallToolRequestParams(
+ name="add_todo", arguments={"text": "dev line", "context": "why"}
+ ),
+ )
+ assert result.is_error is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+ assert _call_events(capture)[0].resource_name == "add_todo"
+
+
+class _DualSeamServer:
+ """A build exposing BOTH registration spellings.
+
+ The natural refactor shape when a private method is promoted: a public
+ wrapper delegating to the private one, both live at once. Whichever seam a
+ customer registers through has to end up wrapped — patching only the first
+ name found would silently drop re-arm for the other.
+ """
+
+ def __init__(self) -> None:
+ self._request_handlers: dict = {}
+
+ def _add_request_handler(self, method, params_type, handler) -> None:
+ entry = _DevLineEntry(params_type, handler)
+ self._request_handlers[method] = entry
+
+ def add_request_handler(self, method, params_type, handler) -> None:
+ self._add_request_handler(method, params_type, handler)
+
+
+@dataclasses.dataclass(frozen=True)
+class _DevLineEntry:
+ params_type: type
+ handler: object
+
+
+@pytest.mark.parametrize("seam", ["add_request_handler", "_add_request_handler"])
+async def test_every_registration_seam_is_re_armed(capture, seam):
+ server = _DualSeamServer()
+ track(server, "proj_test")
+
+ source = create_lowlevel_todo_server("dual-seam-source")
+ for method in ("tools/list", "tools/call"):
+ entry = source.get_request_handler(method)
+ getattr(server, seam)(method, entry.params_type, entry.handler)
+
+ listed = await server._request_handlers["tools/list"].handler(None, None)
+ assert "session_id" in _tool(listed, "add_todo").input_schema["properties"]
+
+ result = await server._request_handlers["tools/call"].handler(
+ None, types.CallToolRequestParams(name="add_todo", arguments={"text": "dual"})
+ )
+ assert result.is_error is False, _text(result)
+ assert MINT_BACK_HEADER in _text(result)
+ # One event, not two: the public seam delegates to the private one, so both
+ # patches fire on a public registration and the re-swap must stay idempotent.
+ assert len(_call_events(capture)) == 1
+
+
+async def test_a_registration_record_keeps_every_field_it_carries(capture):
+ """The record is rebuilt, not mutated (`HandlerEntry` is frozen), so it must
+ carry fields by name — a positional rebuild would silently drop anything
+ upstream adds beyond `params_type` and `handler`."""
+
+ @dataclasses.dataclass(frozen=True)
+ class _RicherEntry:
+ params_type: type
+ handler: object
+ title: str = "customer title"
+
+ server = _DualSeamServer()
+ source = create_lowlevel_todo_server("richer-source")
+ entry = source.get_request_handler("tools/call")
+ server._request_handlers["tools/call"] = _RicherEntry(
+ entry.params_type, entry.handler, title="do not lose me"
+ )
+
+ track(server, "proj_test")
+
+ installed = server._request_handlers["tools/call"]
+ assert installed.handler is not entry.handler
+ assert installed.params_type is entry.params_type
+ assert installed.title == "do not lose me"
+
+
+# ── detection ────────────────────────────────────────────────────────────────
+
+
+def test_the_installed_modern_sdk_classifies_as_a_v2_flavor():
+ """Every other detection test builds doubles from what we *believe* each
+ generation looks like. This one asks the SDK actually installed here, so an
+ upstream shape change surfaces as a failure rather than as a silently
+ untracked fleet."""
+ from agentcat.modules.detection import ServerFlavor, detect_server
+
+ lowlevel = Server("bare")
+ detected = detect_server(lowlevel)
+ assert detected.flavor is ServerFlavor.LOWLEVEL_V2
+ assert detected.lowlevel is lowlevel
+
+ mcpserver = MCPServer("bare")
+ detected = detect_server(mcpserver)
+ assert detected.flavor is ServerFlavor.MCPSERVER_V2
+ assert detected.lowlevel is mcpserver._lowlevel_server
+
+
+def test_track_never_raises():
+ sentinel = object()
+ assert track(sentinel, None) is sentinel
+ assert track(sentinel, "proj_test") is sentinel
+ server = create_lowlevel_todo_server()
+ assert track(server, None) is server
+ assert track(server, "proj_test", {"debug_mode": True}) is server
+
+
+async def test_an_uncopyable_tool_does_not_take_down_the_listing(log_sink):
+ """Audit finding 4c's sibling: one tool whose `model_copy(deep=True)`
+ raises (live runtime state in a schema) must not crash `tools/list` or
+ leave the whole listing un-injected. The stubborn tool serves verbatim,
+ un-injected and never mutated; every other tool still gets its handles."""
+
+ class UncopyableTool(types.Tool):
+ def model_copy(self, *args, **kwargs):
+ raise RuntimeError("live client handle in schema")
+
+ async def on_list_tools(ctx, params):
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="good_tool",
+ description="Copies fine.",
+ input_schema={"type": "object", "properties": {}},
+ ),
+ UncopyableTool(
+ name="stubborn_tool",
+ description="Cannot be copied.",
+ input_schema={"type": "object", "properties": {}},
+ ),
+ ]
+ )
+
+ async def on_call_tool(ctx, params):
+ return types.CallToolResult(
+ content=[types.TextContent(type="text", text="ok")]
+ )
+
+ server = Server(
+ "uncopyable", on_list_tools=on_list_tools, on_call_tool=on_call_tool
+ )
+ track(server, "proj_test", AgentCatOptions())
+
+ async with create_modern_client(server) as client:
+ listed = await client.list_tools()
+
+ names = {t.name for t in listed.tools}
+ assert {"good_tool", "stubborn_tool"} <= names
+ good = _tool(listed, "good_tool")
+ stubborn = _tool(listed, "stubborn_tool")
+ assert "session_id" in good.input_schema["properties"]
+ assert "session_id" not in (stubborn.input_schema.get("properties") or {})
+ assert any("could not copy tool" in line for line in log_sink)
diff --git a/tests/test_mcp_version_compatibility.py b/tests/test_mcp_version_compatibility.py
deleted file mode 100644
index fc2eb11..0000000
--- a/tests/test_mcp_version_compatibility.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Test MCP Version Compatibility."""
-
-import pytest
-
-from agentcat.modules.compatibility import is_compatible_server
-from mcp import ClientSession
-
-from .test_utils.client import create_test_client
-from .test_utils.todo_server import create_todo_server
-
-
-class TestMCPVersionCompatibility:
- """Test MCP Version Compatibility."""
-
- def test_compatible_with_currently_installed_mcp_version(self):
- """Should be compatible with currently installed MCP version."""
- # Create a new server instance
- server = create_todo_server()
-
- # Test compatibility using is_compatible_server
- result = is_compatible_server(server)
- assert result is True
-
- @pytest.mark.asyncio
- async def test_tool_call_via_client(self):
- """Test making a tool call using the client helper."""
- # Create a new server instance
- server = create_todo_server()
- async with create_test_client(server) as client:
- result = await client.call_tool("add_todo", {"text": "Test todo item"})
-
- # Call the add_todo tool via client
- assert result.content[0].text == 'Added todo: "Test todo item" with ID 1'
-
- # Verify by listing todos
- result = await client.call_tool("list_todos")
- assert "1: Test todo item ○" in result.content[0].text
diff --git a/tests/test_multiple_servers.py b/tests/test_multiple_servers.py
index 1c009a1..534d8d5 100644
--- a/tests/test_multiple_servers.py
+++ b/tests/test_multiple_servers.py
@@ -22,7 +22,7 @@ async def test_multiple_servers_with_different_options(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -64,7 +64,7 @@ def capture_event(publish_event_request):
# Should have get_more_tools
assert "get_more_tools" in tool_names1
- # Should have context in tool parameters
+ # Should have context in tool parameters — required, as in 1.x.
add_todo_tool1 = next(t for t in tools1.tools if t.name == "add_todo")
assert "context" in add_todo_tool1.inputSchema["properties"]
assert "context" in add_todo_tool1.inputSchema["required"]
@@ -80,6 +80,8 @@ def capture_event(publish_event_request):
# Should NOT have context in tool parameters
add_todo_tool2 = next(t for t in tools2.tools if t.name == "add_todo")
assert "context" not in add_todo_tool2.inputSchema.get("properties", {})
+ # ...but handles are independent of the context parameter.
+ assert "session_id" in add_todo_tool2.inputSchema["properties"]
# Test server3: should have context and get_more_tools but no tracing
async with create_test_client(server3) as client3:
@@ -89,9 +91,10 @@ def capture_event(publish_event_request):
# Should have get_more_tools
assert "get_more_tools" in tool_names3
- # Should have context in tool parameters
+ # Context injection is independent of tracing; handles are not.
add_todo_tool3 = next(t for t in tools3.tools if t.name == "add_todo")
assert "context" in add_todo_tool3.inputSchema["properties"]
+ assert "session_id" not in add_todo_tool3.inputSchema["properties"]
# Clear events before testing
captured_events.clear()
@@ -147,7 +150,7 @@ async def test_server_options_update_on_retrack(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -197,7 +200,7 @@ async def test_concurrent_server_operations(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -305,7 +308,7 @@ async def test_custom_identify_per_server(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -319,8 +322,8 @@ def capture_event(publish_event_request):
def identify1(request, server):
from agentcat.types import UserIdentity
- if hasattr(request, "params") and hasattr(request.params, "arguments"):
- args = request.params.arguments
+ if hasattr(request, "arguments"):
+ args = request.arguments
if "user" in args:
return UserIdentity(
user_id=f"s1_{args['user']}",
@@ -333,8 +336,8 @@ def identify1(request, server):
def identify2(request, server):
from agentcat.types import UserIdentity
- if hasattr(request, "params") and hasattr(request.params, "arguments"):
- args = request.params.arguments
+ if hasattr(request, "arguments"):
+ args = request.arguments
if "client_id" in args:
return UserIdentity(
user_id=f"s2_{args['client_id']}",
diff --git a/tests/test_options_v2.py b/tests/test_options_v2.py
new file mode 100644
index 0000000..f49db98
--- /dev/null
+++ b/tests/test_options_v2.py
@@ -0,0 +1,14 @@
+from agentcat.types import AgentCatOptions, CustomEventData
+
+from .test_utils import sid
+
+
+def test_v2_option_defaults():
+ o = AgentCatOptions()
+ assert o.enable_agent_tracking is False
+ assert o.resolve_session_id is None
+
+
+def test_custom_event_data_keys():
+ d: CustomEventData = {"session_id": sid("x"), "is_error": False, "tags": {"a": "b"}}
+ assert d["session_id"] == sid("x")
diff --git a/tests/test_process_safety.py b/tests/test_process_safety.py
new file mode 100644
index 0000000..4cbb727
--- /dev/null
+++ b/tests/test_process_safety.py
@@ -0,0 +1,265 @@
+"""The customer's process is theirs: signals, exit, threads, imports.
+
+Regression suite for the v2.0.0 never-halt audit. Every test here pins a
+process-level guarantee the SDK broke at least once:
+
+- importing the SDK must not install signal handlers, call os._exit, or
+ register event-draining atexit hooks (audit finding 1);
+- `import agentcat` must survive an install with no distribution metadata
+ (finding 2);
+- importing and publishing from a NON-MAIN thread must work and must not leak
+ threads (finding 3);
+- interpreter exit must be prompt even with a wedged customer hook in flight
+ (findings 5/6).
+
+Everything runs in subprocesses: these are properties of a whole process
+lifecycle, and asserting them in-suite would let pytest's own state mask a
+regression.
+"""
+
+import subprocess
+import sys
+import tempfile
+import time
+
+import pytest
+
+pytestmark = pytest.mark.skipif(
+ sys.platform == "win32", reason="POSIX signal semantics"
+)
+
+
+def _run(code: str, timeout: float = 30.0) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable, "-c", code],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ env={
+ "PATH": "/usr/bin:/bin",
+ "DISABLE_DIAGNOSTICS": "true",
+ # Isolated so the subprocess's ~/agentcat.log never touches the
+ # developer's real one.
+ "HOME": tempfile.mkdtemp(prefix="agentcat-test-home-"),
+ },
+ )
+
+
+def test_import_leaves_signal_handlers_alone():
+ """Finding 1: the SDK must never replace SIGINT/SIGTERM handlers."""
+ result = _run(
+ """
+import signal
+import agentcat
+import agentcat.modules.event_queue
+
+assert signal.getsignal(signal.SIGINT) is signal.default_int_handler, (
+ "SIGINT handler replaced: " + repr(signal.getsignal(signal.SIGINT))
+)
+assert signal.getsignal(signal.SIGTERM) is signal.SIG_DFL, (
+ "SIGTERM handler replaced: " + repr(signal.getsignal(signal.SIGTERM))
+)
+print("CLEAN")
+"""
+ )
+ assert result.returncode == 0, result.stderr
+ assert "CLEAN" in result.stdout
+
+
+def test_customer_signal_handler_and_cleanup_still_run():
+ """Finding 1: on SIGTERM the CUSTOMER's handler, finally blocks, and
+ atexit hooks all run, and the exit code is the customer's — no os._exit."""
+ result = _run(
+ """
+import atexit
+import os
+import signal
+import sys
+import time
+
+import agentcat
+import agentcat.modules.event_queue # the module that used to hijack signals
+
+ran = []
+atexit.register(lambda: print("ATEXIT-RAN", flush=True))
+
+def customer_handler(signum, frame):
+ ran.append(signum)
+ print("CUSTOMER-HANDLER-RAN", flush=True)
+ sys.exit(7) # the CUSTOMER chooses the exit path and code
+
+signal.signal(signal.SIGTERM, customer_handler)
+
+try:
+ os.kill(os.getpid(), signal.SIGTERM)
+ time.sleep(5) # never reached; the handler exits
+finally:
+ print("FINALLY-RAN", flush=True)
+"""
+ )
+ assert result.returncode == 7, (result.returncode, result.stderr)
+ assert "CUSTOMER-HANDLER-RAN" in result.stdout
+ assert "FINALLY-RAN" in result.stdout
+ assert "ATEXIT-RAN" in result.stdout
+
+
+def test_exit_is_prompt_with_a_wedged_redaction_hook():
+ """Findings 5/6: a blocking customer hook on a worker thread must not
+ hold interpreter exit — workers are daemon and nothing drains at exit."""
+ start = time.monotonic()
+ result = _run(
+ """
+import time
+from datetime import datetime, timezone
+
+from agentcat.modules.event_queue import event_queue
+from agentcat.types import UnredactedEvent
+
+event_queue.add(
+ UnredactedEvent(
+ id="wedge",
+ event_type="mcp:tools/call",
+ project_id="proj_test",
+ session_id="ses_x",
+ timestamp=datetime.now(timezone.utc),
+ redaction_fn=lambda s: time.sleep(60) or s,
+ )
+)
+time.sleep(0.3) # let a worker pick it up and enter the hook
+print("EXITING", flush=True)
+""",
+ timeout=15.0,
+ )
+ elapsed = time.monotonic() - start
+ assert result.returncode == 0, result.stderr
+ assert "EXITING" in result.stdout
+ assert elapsed < 5.0, f"exit took {elapsed:.1f}s with a wedged hook"
+
+
+def test_import_and_publish_from_non_main_thread():
+ """Finding 3: importing and using the queue off the main thread works,
+ starts exactly `concurrency` workers once, and leaks nothing per call."""
+ result = _run(
+ """
+import sys
+import threading
+from datetime import datetime, timezone
+
+def use_from_worker():
+ from agentcat.modules.event_queue import event_queue
+ from agentcat.types import UnredactedEvent
+
+ def make():
+ return UnredactedEvent(
+ id="bg",
+ event_type="mcp:tools/call",
+ project_id=None, # no project: nothing leaves the process
+ session_id="ses_x",
+ timestamp=datetime.now(timezone.utc),
+ )
+
+ event_queue.add(make())
+ event_queue.add(make())
+
+t = threading.Thread(target=use_from_worker)
+t.start()
+t.join()
+
+assert "agentcat.modules.event_queue" in sys.modules, "module evicted on import"
+from agentcat.modules.event_queue import event_queue
+assert len(event_queue._workers) == event_queue.concurrency, (
+ f"expected {event_queue.concurrency} workers, found {len(event_queue._workers)}"
+)
+assert all(w.daemon for w in event_queue._workers)
+
+# A second thread's adds must not grow the pool.
+t2 = threading.Thread(target=use_from_worker)
+t2.start()
+t2.join()
+assert len(event_queue._workers) == event_queue.concurrency
+print("NO-LEAK")
+"""
+ )
+ assert result.returncode == 0, result.stderr
+ assert "NO-LEAK" in result.stdout
+
+
+def test_worker_stop_hook_registers_on_first_publish_not_at_import():
+ """Importing the queue module must not register exit hooks; the bounded
+ worker-stop hook appears only once a publish has started the worker."""
+ result = _run(
+ """
+import atexit
+from datetime import datetime, timezone
+
+# Diagnostics registers its own hook at its import; measure after it.
+import agentcat.modules.event_queue # noqa: F401
+
+baseline = atexit._ncallbacks()
+
+from agentcat.modules.event_queue import event_queue
+from agentcat.types import UnredactedEvent
+
+assert atexit._ncallbacks() == baseline, "import registered an exit hook"
+
+event_queue.add(
+ UnredactedEvent(
+ id="first",
+ event_type="mcp:tools/call",
+ project_id=None,
+ session_id="ses_x",
+ timestamp=datetime.now(timezone.utc),
+ )
+)
+assert atexit._ncallbacks() == baseline + 1, "first publish must register the stop hook"
+
+event_queue.add(
+ UnredactedEvent(
+ id="second",
+ event_type="mcp:tools/call",
+ project_id=None,
+ session_id="ses_x",
+ timestamp=datetime.now(timezone.utc),
+ )
+)
+assert atexit._ncallbacks() == baseline + 1, "hook must register exactly once"
+print("LAZY-HOOK")
+"""
+ )
+ assert result.returncode == 0, result.stderr
+ assert "LAZY-HOOK" in result.stdout
+
+
+def test_import_survives_missing_distribution_metadata():
+ """Finding 2: PackageNotFoundError at import time degrades the version
+ string instead of crashing the customer's server at startup."""
+ result = _run(
+ """
+import importlib.metadata
+from importlib.metadata import PackageNotFoundError
+
+_real = importlib.metadata.version
+
+def fake(name, _real=_real):
+ if name == "agentcat":
+ raise PackageNotFoundError(name)
+ return _real(name)
+
+importlib.metadata.version = fake
+
+import agentcat
+
+assert agentcat.__version__ == "0.0.0", agentcat.__version__
+print("SURVIVED", agentcat.__version__)
+"""
+ )
+ assert result.returncode == 0, result.stderr
+ assert "SURVIVED 0.0.0" in result.stdout
+
+
+def test_version_matches_metadata_when_present():
+ from importlib.metadata import version
+
+ import agentcat
+
+ assert agentcat.__version__ == version("agentcat")
diff --git a/tests/test_publish_custom_event.py b/tests/test_publish_custom_event.py
new file mode 100644
index 0000000..8c05ec2
--- /dev/null
+++ b/tests/test_publish_custom_event.py
@@ -0,0 +1,447 @@
+"""`publish_custom_event` — the one event v2 publishes by hand.
+
+TS parity: `publishCustomEvent` (TS `src/index.ts:363`), design §3.4. The task
+attribution is VERBATIM in both forms: whatever the caller supplies lands in
+`Event.session_id` untouched — never trimmed, prefixed, validated or derived.
+
+Pure Python fakes for everything the semantics can be shown on: this module
+has to run under both SDK eras. The one exception is the last section, which
+walks every real server shape the installed dependency set can build — because
+"is this a tracked server?" is answered by a key derivation that differs per
+facade, and no fake can be wrong about it in the way a real one was.
+
+The published event is inspected at the queue, so every test asserts what
+actually goes on the wire rather than that the call returned.
+"""
+
+from typing import Any
+
+import pytest
+
+import agentcat
+from agentcat.modules import event_queue as event_queue_module
+from agentcat.modules.internal import (
+ reset_server_tracking_data,
+ set_server_tracking_data,
+)
+from agentcat.types import (
+ AgentCatData,
+ AgentCatOptions,
+ EventType,
+ UnredactedEvent,
+)
+
+from .test_utils.flavors import flavors
+
+
+class FakeServer:
+ """A weakref-able stand-in for a tracked server. No MCP anywhere."""
+
+
+class FakeQueue:
+ """The global event queue, replaced so nothing leaves the process."""
+
+ def __init__(self) -> None:
+ self.events: list[UnredactedEvent] = []
+
+ def add(self, event: UnredactedEvent) -> None:
+ self.events.append(event)
+
+
+@pytest.fixture
+def published(monkeypatch: pytest.MonkeyPatch) -> list[UnredactedEvent]:
+ """Every event that reached the queue, in order."""
+ fake = FakeQueue()
+ monkeypatch.setattr(event_queue_module, "event_queue", fake)
+ return fake.events
+
+
+@pytest.fixture
+def tracked() -> Any:
+ """A server tracked to `proj_tracked`, cleaned up after the test."""
+ server = FakeServer()
+ set_server_tracking_data(
+ server,
+ AgentCatData(
+ project_id="proj_tracked",
+ options=AgentCatOptions(),
+ server_name="todo-server",
+ server_version="4.2.0",
+ ),
+ )
+ yield server
+ reset_server_tracking_data(server)
+
+
+@pytest.fixture
+def logged(monkeypatch: pytest.MonkeyPatch) -> list[str]:
+ """Lines the SDK logged, so a no-op can be shown to be a loud one."""
+ lines: list[str] = []
+ monkeypatch.setattr(agentcat, "write_to_log", lambda msg: lines.append(str(msg)))
+ return lines
+
+
+# ── Task attribution: verbatim, both forms ───────────────────────────────────
+
+
+def test_string_form_uses_the_string_verbatim(published):
+ """A caller's own correlation ID is the session ID. No `ses_`, no minting."""
+ agentcat.publish_custom_event("order-9f2c/checkout", "proj_abc")
+
+ assert len(published) == 1
+ assert published[0].session_id == "order-9f2c/checkout"
+
+
+def test_session_id_in_event_data_beats_the_string(published):
+ agentcat.publish_custom_event(
+ "ignored-correlation-id", "proj_abc", {"session_id": "ses_from_event_data"}
+ )
+
+ assert published[0].session_id == "ses_from_event_data"
+
+
+def test_tracked_server_uses_event_data_session_id_verbatim(published, tracked):
+ agentcat.publish_custom_event(tracked, "proj_abc", {"session_id": "ses_LIVE"})
+
+ assert published[0].session_id == "ses_LIVE"
+
+
+def test_tracked_server_without_a_session_id_publishes_untethered(published, tracked):
+ """Handles are per-request in v2, so a tracked server has no ambient task.
+
+ The event still publishes — it just lands untethered rather than being
+ attributed to a task that was never supplied.
+ """
+ agentcat.publish_custom_event(tracked, "proj_abc", {"resource_name": "cron"})
+
+ assert len(published) == 1
+ assert published[0].session_id is None
+ assert published[0].resource_name == "cron"
+
+
+def test_an_empty_session_id_is_no_task_at_all(published, tracked):
+ agentcat.publish_custom_event("", "proj_abc")
+ agentcat.publish_custom_event(tracked, "proj_abc", {"session_id": ""})
+
+ assert len(published) == 2
+ assert all(event.session_id is None for event in published)
+
+
+def test_a_non_string_session_id_is_ignored_never_stringified(published, tracked):
+ """Verbatim cuts both ways: what cannot be sent as-is is not derived."""
+ agentcat.publish_custom_event("fallback-id", "proj_abc", {"session_id": 42})
+ agentcat.publish_custom_event(tracked, "proj_abc", {"session_id": {"a": 1}})
+
+ assert published[0].session_id == "fallback-id"
+ assert published[1].session_id is None
+
+
+# ── Event shape ──────────────────────────────────────────────────────────────
+
+
+def test_event_type_is_agentcat_custom(published, tracked):
+ agentcat.publish_custom_event("ses_T", "proj_abc")
+ agentcat.publish_custom_event(tracked, "proj_abc")
+
+ assert [event.event_type for event in published] == ["agentcat:custom"] * 2
+ assert published[0].event_type == EventType.AGENTCAT_CUSTOM.value
+
+
+def test_custom_event_data_reaches_the_event(published):
+ agentcat.publish_custom_event(
+ "ses_T",
+ "proj_abc",
+ {
+ "resource_name": "checkout",
+ "parameters": {"cart": 3},
+ "response": {"status": "ok"},
+ "message": "order confirmed",
+ "duration": 1234,
+ "is_error": True,
+ "error": {"message": "card declined", "code": "ERR_001"},
+ "tags": {"env": "prod"},
+ "properties": {"amount": 42.5},
+ },
+ )
+
+ event = published[0]
+ assert event.resource_name == "checkout"
+ assert event.parameters == {"cart": 3}
+ assert event.response == {"status": "ok"}
+ # `message` is the human explanation — the same field intent capture uses.
+ assert event.user_intent == "order confirmed"
+ assert event.duration == 1234
+ assert event.is_error is True
+ assert event.error == {"message": "card declined", "code": "ERR_001"}
+ assert event.tags == {"env": "prod"}
+ assert event.properties == {"amount": 42.5}
+
+
+def test_event_data_is_optional(published, tracked):
+ agentcat.publish_custom_event(tracked, "proj_abc")
+
+ assert len(published) == 1
+ assert published[0].event_type == "agentcat:custom"
+ assert published[0].timestamp is not None
+
+
+def test_tags_are_validated_and_bad_entries_dropped(published):
+ """Customer tags ride the same validator every other event's tags do."""
+ agentcat.publish_custom_event(
+ "ses_T",
+ "proj_abc",
+ {"tags": {"env": "prod", "bad key!": "x", "numeric": 7}},
+ )
+
+ assert published[0].tags == {"env": "prod"}
+
+
+def test_tags_that_are_not_a_dict_are_dropped_out_loud(published, logged):
+ """Every other degradation here logs; this one used to be the exception."""
+ agentcat.publish_custom_event("ses_T", "proj_abc", {"tags": "env=prod"})
+
+ assert published[0].tags is None
+ assert any("tags" in line for line in logged)
+
+
+# ── Nothing a caller can pass may make the event vanish ──────────────────────
+
+
+def test_a_non_dict_response_is_kept_not_dropped(published):
+ """`PublishEventRequest.response` is `Optional[Dict[str, Any]]`.
+
+ A non-dict fails pydantic construction, and construction failures are
+ swallowed at the publish site — so an unwrapped string response would take
+ the whole event down with it, silently. `CustomEventData["response"]` is
+ typed `Any` and comes straight from the caller, so a string, a list or a
+ number is a plausible input, not a bug to be punished.
+ """
+ agentcat.publish_custom_event("ses_T", "proj_abc", {"response": "shipped"})
+
+ assert len(published) == 1
+ assert published[0].response == {"value": "shipped"}
+
+
+def test_non_dict_parameters_and_properties_are_kept(published):
+ agentcat.publish_custom_event(
+ "ses_T", "proj_abc", {"parameters": [1, 2], "properties": "why"}
+ )
+
+ assert published[0].parameters == {"value": [1, 2]}
+ assert published[0].properties == {"value": "why"}
+
+
+def test_a_non_dict_error_becomes_a_message(published):
+ """`error` has a known shape (`ErrorData`): the message is what consumers read."""
+ agentcat.publish_custom_event(
+ "ses_T", "proj_abc", {"is_error": True, "error": "card declined"}
+ )
+
+ assert published[0].is_error is True
+ assert published[0].error == {"message": "card declined"}
+
+
+@pytest.mark.parametrize(
+ ("event_data", "wire_field"),
+ [
+ ({"duration": 12.5}, "duration"),
+ ({"duration": True}, "duration"),
+ ({"is_error": 1}, "is_error"),
+ ({"resource_name": 42}, "resource_name"),
+ ({"message": {"not": "a string"}}, "user_intent"),
+ ({"tags": "not-a-dict"}, "tags"),
+ ],
+)
+def test_a_mistyped_field_costs_that_field_not_the_event(
+ published, event_data, wire_field
+):
+ """Strict wire types (`StrictInt`/`StrictBool`/`StrictStr`) reject a near
+ miss outright, so the field is dropped — not coerced — and the event still
+ publishes."""
+ agentcat.publish_custom_event("ses_T", "proj_abc", event_data)
+
+ assert len(published) == 1
+ assert published[0].session_id == "ses_T"
+ assert getattr(published[0], wire_field) is None
+
+
+def test_custom_events_survive_the_generated_clients_stale_event_type_enum():
+ """`agentcat_api` 1.0.0 validates `event_type` against a spec enum that
+ predates `agentcat:custom`, so the generated model rejects the very event
+ this API exists to publish. `Event` overrides that check with the SDK's own
+ `EventType`, which is the source of truth for what v2 publishes."""
+ for event_type in EventType:
+ assert UnredactedEvent(event_type=event_type.value).event_type == (
+ event_type.value
+ )
+
+
+# ── Fire-and-forget: never raises, whatever it is handed ─────────────────────
+
+
+@pytest.mark.parametrize(
+ "first_argument",
+ [None, 42, {"not": "a server"}, ["nope"], FakeServer()],
+ ids=["none", "int", "dict", "list", "untracked-server"],
+)
+def test_a_bad_first_argument_never_raises(published, logged, first_argument):
+ agentcat.publish_custom_event(first_argument, "proj_abc")
+
+ assert published == []
+ assert logged, "a dropped event must say so in the log"
+
+
+def test_a_non_dict_event_data_never_raises(published):
+ agentcat.publish_custom_event("ses_T", "proj_abc", "not-event-data")
+
+ assert len(published) == 1
+ assert published[0].session_id == "ses_T"
+
+
+def test_a_queue_that_blows_up_never_raises(monkeypatch, logged, tracked):
+ class Exploding:
+ def add(self, event: UnredactedEvent) -> None:
+ raise RuntimeError("queue is on fire")
+
+ monkeypatch.setattr(event_queue_module, "event_queue", Exploding())
+
+ agentcat.publish_custom_event("ses_T", "proj_abc")
+ agentcat.publish_custom_event(tracked, "proj_abc")
+
+ assert logged
+
+
+@pytest.mark.parametrize("project_id", ["", None, 42], ids=["empty", "none", "int"])
+def test_the_string_form_needs_a_project_id(published, logged, project_id):
+ """Nothing else can supply one: a session ID string carries no tracking data."""
+ agentcat.publish_custom_event("ses_T", project_id)
+
+ assert published == []
+ assert any("project_id" in line for line in logged)
+
+
+def test_a_mistyped_project_id_never_costs_a_tracked_event(published, tracked):
+ """The tracked server's own project answers for it."""
+ agentcat.publish_custom_event(tracked, 42, {"session_id": "ses_T"})
+
+ assert published[0].project_id == "proj_tracked"
+
+
+# ── Project and SDK attribution ──────────────────────────────────────────────
+
+
+def test_the_string_form_carries_the_project_it_was_given(published):
+ agentcat.publish_custom_event("ses_T", "proj_from_argument")
+
+ assert published[0].project_id == "proj_from_argument"
+ assert published[0].sdk_language.startswith("Python ")
+ assert published[0].agentcat_version == agentcat.__version__
+
+
+def test_the_tracked_form_takes_the_project_from_the_tracked_server(published, tracked):
+ """The server was tracked to a project; that is the event's project, and
+ the server identity captured at `track()` time rides along with it."""
+ agentcat.publish_custom_event(tracked, "", {"session_id": "ses_T"})
+
+ event = published[0]
+ assert event.project_id == "proj_tracked"
+ assert event.server_name == "todo-server"
+ assert event.server_version == "4.2.0"
+ assert event.sdk_language.startswith("Python ")
+
+
+def test_a_server_tracked_with_tracing_off_publishes_nothing(published, logged):
+ """`enable_tracing=False` silences a server's tool calls in every adapter.
+
+ This entry point does not reach an adapter, and `publish_event` has no gate
+ of its own (TS keeps one inside `publishEvent`), so it carries the gate
+ itself: a customer who turned tracing off does not start emitting a new
+ event type because they called a new API.
+ """
+ server = FakeServer()
+ set_server_tracking_data(
+ server,
+ AgentCatData(
+ project_id="proj_tracked",
+ options=AgentCatOptions(enable_tracing=False),
+ ),
+ )
+ try:
+ agentcat.publish_custom_event(server, "proj_tracked", {"session_id": "ses_T"})
+ finally:
+ reset_server_tracking_data(server)
+
+ assert published == []
+ assert any("enable_tracing" in line for line in logged)
+
+
+def test_the_string_form_is_not_gated_on_a_servers_tracing_flag(published):
+ """A session ID string carries no server, so there are no options to consult —
+ the same place TS lands, since its string form bypasses `publishEvent`."""
+ agentcat.publish_custom_event("ses_T", "proj_abc")
+
+ assert len(published) == 1
+
+
+def test_the_tracked_form_applies_the_customers_redaction(published):
+ """A tracked custom event goes through the same publish path every
+ tool-call event does, so the customer's redaction hook is attached."""
+ server = FakeServer()
+ set_server_tracking_data(
+ server,
+ AgentCatData(
+ project_id="proj_tracked",
+ options=AgentCatOptions(
+ redact_sensitive_information=lambda text: "REDACTED"
+ ),
+ ),
+ )
+ try:
+ agentcat.publish_custom_event(server, "proj_tracked", {"session_id": "ses_T"})
+ finally:
+ reset_server_tracking_data(server)
+
+ assert published[0].redaction_fn is not None
+
+
+# ── Every real server shape resolves ─────────────────────────────────────────
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+def test_a_tracked_server_of_any_flavor_resolves_to_its_own_tracking_data(
+ flavor, published
+):
+ """The customer holds the object they handed `track()`, not what it wraps.
+
+ On both official facades that object is NOT where the data was stored: the
+ adapter is installed on the lowlevel server underneath, and the data is
+ filed there with it. TS unwraps `serverOrSessionId.server` at the entry
+ point; Python re-derives the key inside `_get_server_key`, and nothing
+ pinned that it did so for every facade — `MCPServer` was missing, and a
+ custom event published against one was silently dropped.
+ """
+ built = flavor.build("custom-event")
+ agentcat.track(built.server, "proj_flavor", AgentCatOptions())
+ try:
+ agentcat.publish_custom_event(
+ built.server, "proj_ignored", {"session_id": "ses_custom", "message": "hi"}
+ )
+ finally:
+ reset_server_tracking_data(built.server)
+
+ assert len(published) == 1, "the server did not resolve to its tracking data"
+ event = published[0]
+ assert event.event_type == EventType.AGENTCAT_CUSTOM.value
+ assert event.session_id == "ses_custom"
+ # The project captured at track() time wins over the argument — which is
+ # only observable if the tracked server really was found.
+ assert event.project_id == "proj_flavor"
+
+
+# ── Public API ───────────────────────────────────────────────────────────────
+
+
+def test_exported_from_the_package():
+ assert "publish_custom_event" in agentcat.__all__
+ assert "CustomEventData" in agentcat.__all__
+ assert callable(agentcat.publish_custom_event)
diff --git a/tests/test_rebuild_on_demand.py b/tests/test_rebuild_on_demand.py
new file mode 100644
index 0000000..17fbd31
--- /dev/null
+++ b/tests/test_rebuild_on_demand.py
@@ -0,0 +1,332 @@
+"""Registry rebuild-on-demand, and what happens when it cannot happen.
+
+A 2026 factory builds a fresh server per request, so a `tools/call` routinely
+lands on an instance that has never served a `tools/list` — and the strip
+registries only exist because a listing built them. The engine answers by
+rebuilding them from the adapter's own list source (changelog §6.3); the
+injection pipeline is deterministic, so a rebuilt registry matches what any
+listing instance advertised.
+
+`tests/test_callpath.py` pins that contract at the unit level. This module
+pins it per flavor, end to end, on the two things only a real server can show:
+that the CUSTOMER's tool body receives the stripped arguments, and that the
+structured mirror is gated on the rebuilt output registry.
+
+The failure path is the other half. When the list source is down there is
+nothing to rebuild from, and the engine falls back to the heuristic strip —
+which must still keep `get_more_tools`' own `context`, because on that one
+tool `context` is a real parameter rather than something AgentCat injected.
+"""
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_SESSION_SOURCE,
+ CONTEXT_PARAM,
+ GET_MORE_TOOLS_NAME,
+ MCP_INSTRUCTIONS_KEY,
+ SESSION_ID_PARAM,
+)
+
+from .test_utils import sid
+from .test_utils.flavors import flavors, tracking_data
+
+SUPPLIED = sid("supplied")
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+def _logged(log_sink, fragment: str) -> bool:
+ return any(fragment in line for line in log_sink)
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_a_call_before_any_listing_rebuilds_the_registries(
+ flavor, capture, log_sink
+):
+ """No listing has ever run, and the call still behaves as if one had.
+
+ Three things follow from the rebuild, and all three are what a customer
+ would notice if it stopped happening: their tool body sees only its own
+ arguments, the event records the call as the agent made it, and the mirror
+ lands because the rebuilt output registry says the schema declares it.
+ """
+ built = flavor.build("rebuild")
+ track(built.server, "proj_test", AgentCatOptions())
+ data = tracking_data(built.server)
+ assert data.injected_params_registry is None, "something listed before the call"
+
+ result = await flavor.call_unlisted(
+ built.server,
+ "echo",
+ {"text": "hi", SESSION_ID_PARAM: SUPPLIED, CONTEXT_PARAM: "why I called"},
+ )
+
+ assert _logged(log_sink, "Rebuilt injection registries on demand")
+ # What the customer's tool layer was actually HANDED — read at the tool
+ # manager on the facade flavors, whose typed bodies cannot report an
+ # argument the SDK silently dropped on the way in, and from the raw
+ # argument dict on the lowlevel ones. Either way an injected parameter that
+ # survived the strip shows up here.
+ assert built.seen == [("echo", {"text": "hi"})]
+ # Rebuilt from the adapter's list source, and stored for the next call.
+ assert data.injected_params_registry is not None
+ assert data.injected_params_registry["echo"] == {SESSION_ID_PARAM, CONTEXT_PARAM}
+ # get_more_tools is in the rebuilt view too — that view is also what
+ # settles whether AgentCat may advertise it at all.
+ assert data.injected_params_registry[GET_MORE_TOOLS_NAME] == {SESSION_ID_PARAM}
+ # The mirror is gated on the rebuilt OUTPUT registry, and `echo` is in it.
+ assert data.output_injection_registry is not None
+ assert "echo" in data.output_injection_registry
+ assert result.structured[MCP_INSTRUCTIONS_KEY][SESSION_ID_PARAM] == SUPPLIED
+
+ event = capture[0]
+ assert event.session_id == SUPPLIED
+ # Raw, unstripped: the event records the call as the agent made it.
+ assert event.parameters["arguments"][CONTEXT_PARAM] == "why I called"
+ assert event.user_intent == "why I called"
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_the_rebuild_recovers_a_customers_own_session_id_ownership(
+ flavor, capture
+):
+ """Ownership is rebuilt too, not just the strip registry.
+
+ This is the stateless-HTTP shape: on mcp 2.x every request can reach a
+ fresh server instance, so the listing that recorded the collision is
+ routinely NOT the instance serving the call. Without the rebuild
+ populating `declared_session_params`, that instance would read the
+ customer's value, find it malformed and tag the call `invalid` — the same
+ sessionless outcome, but attributed to the agent rather than to the
+ collision, which is the difference between a dashboard that explains the
+ gap and one that does not.
+ """
+ built = flavor.build("rebuild-owned", customer_session_id=True)
+ track(built.server, "proj_test", AgentCatOptions())
+ data = tracking_data(built.server)
+ assert data.declared_session_params == set(), "something listed before the call"
+
+ await flavor.call_unlisted(
+ built.server, "complete_task", {"session_id": "TICKET-9", "note": "done"}
+ )
+
+ assert data.declared_session_params == {"complete_task"}
+ event = capture[0]
+ assert event.session_id is None
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "foreign"
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_a_failed_rebuild_degrades_to_the_heuristic_strip(
+ flavor, capture, log_sink
+):
+ """The list source is down, so the strip has no registry to consult.
+
+ What every era owes on this path, whatever the call then does: fall back
+ rather than raise, leave the (already absent) parameter registry alone so a
+ concurrent call that just stored a good one is not robbed of it, and clear
+ the output registry so the mirror stops gating on knowledge we no longer
+ have. And still publish the one event, with the call as the agent made it.
+ """
+ built = flavor.build("rebuild-down-any", customer_get_more_tools=True)
+ track(built.server, "proj_test", AgentCatOptions())
+ flavor.break_list_source(built.server)
+ data = tracking_data(built.server)
+
+ await flavor.call_unlisted(
+ built.server,
+ GET_MORE_TOOLS_NAME,
+ {CONTEXT_PARAM: "I need a tool to send email", SESSION_ID_PARAM: SUPPLIED},
+ )
+
+ assert _logged(log_sink, "rebuild-on-demand failed")
+ assert data.injected_params_registry is None
+ assert data.output_injection_registry is None
+ assert len(capture) == 1
+ event = capture[0]
+ assert event.session_id == SUPPLIED
+ # Raw arguments: both injected names are still on the event.
+ assert event.parameters["arguments"][SESSION_ID_PARAM] == SUPPLIED
+ assert event.parameters["arguments"][CONTEXT_PARAM] == "I need a tool to send email"
+
+
+# The mcp 1.x official flavors cannot answer a call at all while their list
+# source is down — see `Flavor.survives_a_down_list_source` for why, and note
+# that it is the SDK's behavior rather than AgentCat's. Everything AgentCat
+# does on that path is asserted above; what the CUSTOMER's tool sees can only
+# be asked of an era where the call completes.
+CAN_ANSWER_WITH_A_DOWN_LIST_SOURCE = [
+ flavor for flavor in flavors() if flavor.survives_a_down_list_source
+]
+
+
+LOWLEVEL_V2_ONLY = [flavor for flavor in flavors() if flavor.id == "lowlevel-v2"]
+
+
+@pytest.mark.parametrize("flavor", LOWLEVEL_V2_ONLY, ids=lambda f: f.id)
+async def test_rebuild_survives_a_list_registration_with_no_params_type(
+ flavor, capture, log_sink
+):
+ """Audit finding 4c: a `tools/list` registration carrying no params_type
+ made rebuild-on-demand call the customer's list handler with params=None.
+ A handler that dereferences its params then crashed the rebuild — and the
+ degraded strip branded the call's arguments. The fallback params must be
+ the real all-optional model, so the rebuild SUCCEEDS.
+ """
+ import dataclasses
+
+ built = flavor.build("rebuild-bare")
+ entry = built.server._request_handlers["tools/list"]
+ inner = getattr(entry, "handler", entry)
+
+ async def dereferencing_list(ctx, params, _inner=inner):
+ _ = params.cursor # a customer handler that trusts its params
+ return await _inner(ctx, params)
+
+ if hasattr(entry, "handler"):
+ built.server._request_handlers["tools/list"] = dataclasses.replace(
+ entry, handler=dereferencing_list, params_type=None
+ )
+ else: # pre-2.0 development line stored the bare callable
+ built.server._request_handlers["tools/list"] = dereferencing_list
+
+ track(built.server, "proj_test", AgentCatOptions())
+ await flavor.call_unlisted(
+ built.server, "echo", {"text": "hi", SESSION_ID_PARAM: SUPPLIED}
+ )
+
+ assert _logged(log_sink, "Rebuilt injection registries on demand")
+ assert not _logged(log_sink, "rebuild-on-demand failed")
+ assert built.seen == [("echo", {"text": "hi"})]
+ assert capture[0].session_id == SUPPLIED
+
+
+@pytest.mark.parametrize(
+ "flavor", CAN_ANSWER_WITH_A_DOWN_LIST_SOURCE, ids=lambda f: f.id
+)
+async def test_a_failed_rebuild_spares_a_customers_own_session_id(
+ flavor, capture, log_sink
+):
+ """Audit finding 4: registry unknown + a tool that declares its own
+ `session_id`. The old fallback deleted the customer's value by name and
+ appended a correction telling the agent to stop sending their parameter.
+ The shape-aware fallback must do neither: a non-minted value is presumed
+ the customer's, reaches their handler, and draws no wire correction —
+ while the event honestly records the call as sessionless.
+ """
+ built = flavor.build("rebuild-down-owned", customer_session_id=True)
+ track(built.server, "proj_test", AgentCatOptions())
+ flavor.break_list_source(built.server)
+ data = tracking_data(built.server)
+
+ result = await flavor.call_unlisted(
+ built.server, "complete_task", {"session_id": "TICKET-9", "note": "done"}
+ )
+
+ assert _logged(log_sink, "rebuild-on-demand failed")
+ assert data.injected_params_registry is None
+
+ # The customer's parameter survived the degraded strip and reached them.
+ assert built.seen == [
+ ("complete_task", {"session_id": "TICKET-9", "note": "done"})
+ ]
+ # No "session_id not recognized" correction steering agents away from the
+ # customer's own parameter.
+ assert "not recognized" not in (result.text or "")
+ # Sessionless, tagged for the dashboard — the honest degraded record.
+ event = capture[0]
+ assert event.session_id is None
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "invalid"
+
+
+@pytest.mark.parametrize(
+ "flavor", CAN_ANSWER_WITH_A_DOWN_LIST_SOURCE, ids=lambda f: f.id
+)
+async def test_a_failed_rebuild_still_protects_get_more_tools_own_context(
+ flavor, capture, log_sink
+):
+ """The heuristic strips all three injected names — except one.
+
+ `get_more_tools`' `context` is a real parameter the tool needs, not
+ something AgentCat injected, and a customer tool by that name is what makes
+ the distinction observable: strip its `context` and the call fails or
+ answers about nothing.
+
+ And the mirror applies anyway. A cleared output registry means "no schema
+ we know about can be in play", not "no schema declares the field", so
+ gating on it would silently drop the mint-back on exactly the instances
+ that never listed.
+ """
+ built = flavor.build("rebuild-down", customer_get_more_tools=True)
+ track(built.server, "proj_test", AgentCatOptions())
+ flavor.break_list_source(built.server)
+ data = tracking_data(built.server)
+
+ result = await flavor.call_unlisted(
+ built.server,
+ GET_MORE_TOOLS_NAME,
+ {CONTEXT_PARAM: "I need a tool to send email", SESSION_ID_PARAM: SUPPLIED},
+ )
+
+ # The rebuild really did fail — otherwise the registry strip would keep
+ # `context` for its own reasons and this would prove nothing.
+ assert _logged(log_sink, "rebuild-on-demand failed")
+ assert data.injected_params_registry is None
+ assert data.output_injection_registry is None
+
+ # `context` reached the customer's tool; `session_id` did not.
+ assert built.seen == [
+ (GET_MORE_TOOLS_NAME, {CONTEXT_PARAM: "I need a tool to send email"})
+ ]
+ assert "customer answered: I need a tool to send email" in result.text
+ # ...and the handle still rides back with no registry to gate it.
+ assert result.structured[MCP_INSTRUCTIONS_KEY][SESSION_ID_PARAM] == SUPPLIED
+ assert capture[0].session_id == SUPPLIED
+
+
+@pytest.mark.parametrize(
+ "flavor", CAN_ANSWER_WITH_A_DOWN_LIST_SOURCE, ids=lambda f: f.id
+)
+async def test_a_failed_rebuild_still_strips_context_from_every_other_tool(
+ flavor, capture, log_sink
+):
+ """The other half of the heuristic: `context` is ours on every tool but one.
+
+ Same broken instance, an ordinary tool — and `echo` never declared a
+ `context` parameter, so handing it one is the injection leaking through.
+ """
+ built = flavor.build("rebuild-down-echo")
+ track(built.server, "proj_test", AgentCatOptions())
+ flavor.break_list_source(built.server)
+
+ await flavor.call_unlisted(
+ built.server,
+ "echo",
+ {"text": "hi", SESSION_ID_PARAM: SUPPLIED, CONTEXT_PARAM: "why I called"},
+ )
+
+ assert _logged(log_sink, "rebuild-on-demand failed")
+ assert built.seen == [("echo", {"text": "hi"})]
+ assert capture[0].parameters["arguments"][CONTEXT_PARAM] == "why I called"
diff --git a/tests/test_redaction.py b/tests/test_redaction.py
index 533c649..9ec2a0f 100644
--- a/tests/test_redaction.py
+++ b/tests/test_redaction.py
@@ -309,14 +309,18 @@ def redact_fn(s: str) -> str:
assert result["metadata"]["timestamp"] == "[REDACTED]"
assert result["metadata"]["ip"] == "[REDACTED]"
- def test_identify_event_special_fields(self):
- """Test agentcat:identify event with special protected fields."""
+ def test_actor_fields_are_never_redacted(self):
+ """The actor fields are protected wherever they ride.
+
+ v1 carried them on a standalone `agentcat:identify` event; v2 stamps
+ them onto every tools/call event. Either way redaction leaves them
+ alone, so the dashboard can still name the actor."""
def redact_fn(s: str) -> str:
return "XXX"
identify_event = {
- "event_type": "agentcat:identify",
+ "event_type": "mcp:tools/call",
"identify_actor_given_id": "user123", # Protected
"identify_actor_name": "John Doe", # Protected
"identify_data": { # Protected
@@ -367,3 +371,97 @@ def faulty_redact_fn(s: str) -> str:
# The function should propagate the error
with pytest.raises(ValueError, match="Redaction error"):
redact_event(event, faulty_redact_fn)
+
+
+class TestRedactEventOnTheRealEventModel:
+ """The shape the publish path actually holds.
+
+ `redact_strings_in_object` walks `str` / `list` / `dict` and returns
+ everything else untouched — and `event_queue._process_event` hands
+ `redact_event` a pydantic `UnredactedEvent`. For the whole of the v2 branch
+ that meant the documented `redact_sensitive_information` hook was a no-op on
+ every real event while the README advertised it as a security control. The
+ dict-shaped cases above never caught it; these are the ones that would.
+ """
+
+ @staticmethod
+ def _event(**overrides):
+ from agentcat.types import UnredactedEvent
+
+ fields = {
+ "session_id": "ses_keepme",
+ "id": "evt_keepme",
+ "project_id": "proj_keepme",
+ "event_type": "mcp:tools/call",
+ "resource_name": "add_todo",
+ "user_intent": "find the SECRET",
+ "parameters": {"arguments": {"text": "SECRET body"}},
+ "response": {"content": [{"type": "text", "text": "SECRET answer"}]},
+ "client_name": "SECRET client",
+ "identify_actor_given_id": "SECRET actor",
+ "identify_data": {"email": "SECRET@example.com"},
+ "tags": {"env": "SECRET tag"},
+ "duration": 12,
+ }
+ fields.update(overrides)
+ return UnredactedEvent(**fields)
+
+ def test_the_hook_actually_runs_on_a_pydantic_event(self):
+ def redact_fn(s: str) -> str:
+ return s.replace("SECRET", "[REDACTED]")
+
+ event = self._event()
+ result = redact_event(event, redact_fn)
+
+ assert result.user_intent == "find the [REDACTED]"
+ assert result.parameters == {"arguments": {"text": "[REDACTED] body"}}
+ assert result.response == {
+ "content": [{"type": "text", "text": "[REDACTED] answer"}]
+ }
+ assert result.client_name == "[REDACTED] client"
+ # ...and the original is untouched, so a failure downstream cannot
+ # publish a half-redacted object.
+ assert event.parameters == {"arguments": {"text": "SECRET body"}}
+
+ def test_protected_fields_survive_on_the_model_too(self):
+ def redact_fn(s: str) -> str:
+ return "XXX"
+
+ result = redact_event(self._event(), redact_fn)
+ assert result.session_id == "ses_keepme"
+ assert result.id == "evt_keepme"
+ assert result.project_id == "proj_keepme"
+ assert result.event_type == "mcp:tools/call"
+ assert result.resource_name == "add_todo"
+ assert result.identify_actor_given_id == "SECRET actor"
+ assert result.identify_data == {"email": "SECRET@example.com"}
+ assert result.tags == {"env": "SECRET tag"}
+
+ def test_the_result_is_still_a_publishable_event(self):
+ """`model_copy`, not a rebuild: unredacted fields keep their values and
+ non-string fields keep their types, so the queue can serialize it."""
+ result = redact_event(self._event(), lambda s: "XXX")
+ assert type(result) is type(self._event())
+ assert result.duration == 12
+ assert result.redaction_fn is None
+ assert "XXX" in result.model_dump_json()
+
+ def test_a_raising_hook_propagates_so_the_queue_drops_the_event(self):
+ def boom(_s: str) -> str:
+ raise RuntimeError("redaction exploded")
+
+ with pytest.raises(RuntimeError, match="redaction exploded"):
+ redact_event(self._event(), boom)
+
+ def test_an_async_hook_is_driven_to_completion(self):
+ """`RedactionFunction` permits an async hook and the publish path is a
+ worker thread with no loop. Un-awaited, every redacted string would
+ reach the wire as `` — redaction that looks like
+ it worked."""
+
+ async def redact_fn(s: str) -> str:
+ return s.replace("SECRET", "[REDACTED]")
+
+ result = redact_event(self._event(), redact_fn)
+ assert result.client_name == "[REDACTED] client"
+ assert "coroutine" not in result.model_dump_json()
diff --git a/tests/test_removed_events.py b/tests/test_removed_events.py
new file mode 100644
index 0000000..304fdf7
--- /dev/null
+++ b/tests/test_removed_events.py
@@ -0,0 +1,90 @@
+"""The three retired event types, proven gone on every flavor.
+
+v1 published `mcp:initialize`, `mcp:tools/list` and `agentcat:identify` beside
+the tool call. v2 publishes ONE event type automatically — `mcp:tools/call` —
+and `tools/list` is still intercepted, for schema injection only
+(changelog §3.1). That is a wire-visible promise to every consumer of the
+event stream, and it is made by four different adapters, so it is asserted
+against a full lifecycle on each of them rather than inferred from the shared
+engine.
+
+The lifecycle is deliberately the noisy one: connect (which handshakes, and on
+the 2026 wire discovers), list, call twice, call the tool AgentCat itself
+answers, and fail a call. Every one of those was an event in v1.
+"""
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import GET_MORE_TOOLS_NAME, SESSION_ID_PARAM
+
+from .test_utils.flavors import BOOM_TEXT, flavors
+
+# What v1 published beside the tool call. Named rather than implied, so a
+# regression reads as "initialize is back" instead of "a set changed".
+RETIRED = {"mcp:initialize", "mcp:tools/list", "agentcat:identify"}
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_a_full_lifecycle_publishes_only_tool_calls(flavor, capture):
+ """Handshake, listing and four calls; four events, all of one type."""
+ built = flavor.build("removed-events")
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ listed = await flavor.list_tools(client)
+ assert {tool.name for tool in listed} == {"echo", GET_MORE_TOOLS_NAME}
+
+ first = await flavor.call(client, "echo", {"text": "one"})
+ minted = first.text.split("session_id=")[1].split(" ")[0]
+ await flavor.call(client, "echo", {"text": "two", SESSION_ID_PARAM: minted})
+ # AgentCat answers this one itself, and it is still just a tool call.
+ await flavor.call(
+ client, GET_MORE_TOOLS_NAME, {"context": "I need a tool to send email"}
+ )
+
+ assert {event.event_type for event in capture} == {"mcp:tools/call"}
+ assert len(capture) == 3
+ assert not RETIRED & {event.event_type for event in capture}
+ assert [event.resource_name for event in capture] == [
+ "echo",
+ "echo",
+ GET_MORE_TOOLS_NAME,
+ ]
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_a_failed_call_publishes_only_its_own_tool_call(flavor, capture):
+ """The error path is where an extra event would be easiest to add back.
+
+ The tool body refuses the sentinel text, so the failure is a real Python
+ exception on every era — surfaced as a raise on some and as an `is_error`
+ result on others — and every era still owes exactly one event for it.
+ """
+ built = flavor.build("removed-events-error")
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ try:
+ await flavor.call(client, "echo", {"text": BOOM_TEXT})
+ except Exception:
+ # Community FastMCP surfaces a failing call as a raised error at
+ # the client; the official SDKs answer with an `is_error` result.
+ # Which one it is belongs to the SDK, not to this assertion.
+ pass
+
+ assert {event.event_type for event in capture} == {"mcp:tools/call"}
+ assert len(capture) == 1
+ assert capture[0].is_error is True
+ assert capture[0].error is not None
diff --git a/tests/test_report_missing.py b/tests/test_report_missing.py
index 73a44b9..a4ffde7 100644
--- a/tests/test_report_missing.py
+++ b/tests/test_report_missing.py
@@ -70,11 +70,12 @@ async def test_report_missing_tool_call_success(self):
{"context": "Need a tool to translate text between languages"},
)
- # Verify successful response
- assert result.content
- assert len(result.content) == 1
+ # Verify successful response. get_more_tools publishes events like
+ # any other tool, so it mints a task and carries the mint-back
+ # block after its own answer.
assert result.content[0].type == "text"
assert "Unfortunately" in result.content[0].text
+ assert "[MCP INSTRUCTIONS]: session_id issued." in result.content[-1].text
@pytest.mark.asyncio
async def test_report_missing_with_valid_params(self):
@@ -110,11 +111,12 @@ async def test_report_missing_with_missing_params(self):
track(server, "test_project", options)
async with create_test_client(server) as client:
- # Test with missing context - should return a validation error
- # since context is a required parameter
+ # `context` is required in the advertised schema, so a strict client
+ # will not send the call at all — but AgentCat never fails a tool
+ # call over its own analytics, so a lax client still gets an answer.
result = await client.call_tool("get_more_tools", {})
- assert result.content[0].text
- assert result.isError is True
+ assert result.isError is False
+ assert "Unfortunately" in result.content[0].text
# Test with valid context
result = await client.call_tool("get_more_tools", {"context": "test_tool"})
@@ -212,11 +214,15 @@ async def test_report_missing_with_context_enabled(self):
assert report_missing_tool is not None
assert other_tool is not None
- # Verify context is NOT added to report_missing tool
- assert "context" in report_missing_tool.inputSchema.get("properties", {})
+ # get_more_tools keeps its own bespoke context, not the injected one
+ from agentcat.modules.constants import DEFAULT_CONTEXT_DESCRIPTION
+
+ gmt_context = report_missing_tool.inputSchema["properties"]["context"]
+ assert gmt_context["description"] != DEFAULT_CONTEXT_DESCRIPTION
- # But context should be added to other tools
- assert "context" in other_tool.inputSchema.get("properties", {})
+ # But the injected context is added to other tools
+ other_context = other_tool.inputSchema["properties"]["context"]
+ assert other_context["description"] == DEFAULT_CONTEXT_DESCRIPTION
@pytest.mark.skip(
reason="Creating empty low-level server is complex and already tested via FastMCP"
@@ -237,11 +243,11 @@ async def test_report_missing_with_null_values(self):
track(server, "test_project", options)
async with create_test_client(server) as client:
- # Test with None context - should return a validation error
- # since context is required as a string
+ # A null context is not an explanation, but it is not a reason to
+ # fail the call either.
result = await client.call_tool("get_more_tools", {"context": None})
- assert result.content[0].text
- assert result.isError is True
+ assert result.isError is False
+ assert "Unfortunately" in result.content[0].text
@pytest.mark.asyncio
async def test_report_missing_publishes_event(self):
@@ -352,8 +358,8 @@ async def test_multiple_tool_calls_publish_multiple_events(self):
time.sleep(1.0)
- # Should have at least 3 tool call events (plus initialize and list_tools events)
- assert mock_api_client.publish_event.call_count >= 3
+ # v2 publishes exactly one event per tool call and nothing else.
+ assert mock_api_client.publish_event.call_count == 3
# Get all published events
events = [
diff --git a/tests/test_request_extra.py b/tests/test_request_extra.py
index 804b50c..283b72a 100644
--- a/tests/test_request_extra.py
+++ b/tests/test_request_extra.py
@@ -15,6 +15,7 @@
from agentcat import AgentCatOptions, track
from agentcat.modules.event_queue import EventQueue, set_event_queue
from agentcat.modules.request_extra import (
+ extra_from_request_context,
extract_request_extra,
params_with_extra,
)
@@ -34,6 +35,21 @@ def _http_request_context(headers: dict, request_id="req-123", session=None, met
)
+def _capture_into(sink: list):
+ """A publish_event double that records events.
+
+ `EventsApi.publish_event` is called with a KEYWORD argument, so a bare
+ `sink.append` raises `TypeError` — which the queue swallows as a send
+ failure, leaving the sink empty and any "no events published" assertion
+ passing vacuously.
+ """
+
+ def capture(publish_event_request, **kwargs):
+ sink.append(publish_event_request)
+
+ return capture
+
+
def _stdio_request_context(request_id="req-456"):
"""Build a mock RequestContext that mimics a stdio-transport request."""
return SimpleNamespace(
@@ -260,7 +276,7 @@ async def test_stdio_path_omits_request_info(self):
mock_api_client = MagicMock()
captured_events: list = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -289,7 +305,7 @@ async def test_http_simulated_path_populates_headers(self, monkeypatch):
mock_api_client = MagicMock()
captured_events: list = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
@@ -310,14 +326,10 @@ def capture_event(publish_event_request):
meta={"progressToken": "tok-7"},
)
- from agentcat.modules.overrides import mcp_server as mcp_server_mod
- from agentcat.modules.overrides.official import monkey_patch as official_mp
+ from agentcat.modules.adapters import lowlevel_v1
monkeypatch.setattr(
- mcp_server_mod, "safe_request_context", lambda _server: fake_ctx
- )
- monkeypatch.setattr(
- official_mp, "safe_request_context", lambda _server: fake_ctx
+ lowlevel_v1, "_safe_request_context", lambda _server: fake_ctx
)
async with create_test_client(server) as client:
@@ -343,15 +355,60 @@ def capture_event(publish_event_request):
assert extra.get("meta") == {"progressToken": "tok-7"}
@pytest.mark.asyncio
- async def test_list_tools_event_includes_extra(self, monkeypatch):
- """tools/list events should also carry parameters.extra when HTTP-shaped."""
+ async def test_transport_session_id_never_becomes_the_task_handle(
+ self, monkeypatch
+ ):
+ """`Event.session_id` carries the AgentCat task handle, not the
+ transport's `mcp-session-id`.
+
+ v1 correlated on the transport session; v2 ignores it entirely
+ (changelog §3.1) and resolves a `ses_` handle per call. The transport
+ id still rides along untouched under `parameters.extra.sessionId`, so
+ customers who need it can still read it.
+ """
mock_api_client = MagicMock()
captured_events: list = []
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
+ mock_api_client.publish_event = MagicMock(
+ side_effect=_capture_into(captured_events)
+ )
+ set_event_queue(EventQueue(api_client=mock_api_client))
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
+ server = create_todo_server()
+ track(server, "test_project", AgentCatOptions(enable_tracing=True))
+
+ fake_ctx = _http_request_context(
+ headers={"mcp-session-id": "transport-sess-xyz"},
+ request_id="req-handle",
+ )
+ from agentcat.modules.adapters import lowlevel_v1
+
+ monkeypatch.setattr(
+ lowlevel_v1, "_safe_request_context", lambda _server: fake_ctx
+ )
+
+ async with create_test_client(server) as client:
+ await client.call_tool("add_todo", {"text": "t", "context": "handles"})
+ time.sleep(1.0)
+
+ tool_events = [e for e in captured_events if e.event_type == "mcp:tools/call"]
+ assert tool_events, "expected a tools/call event"
+ event = tool_events[0]
+ extra = (event.parameters or {}).get("extra") or {}
+ assert extra.get("sessionId") == "transport-sess-xyz"
+ assert event.session_id != "transport-sess-xyz"
+ assert event.session_id.startswith("ses_")
+
+ @pytest.mark.asyncio
+ async def test_tools_list_publishes_no_event(self, monkeypatch):
+ """v2 intercepts tools/list for schema injection only — it publishes
+ nothing, so `extra` has no tools/list event to ride."""
+ mock_api_client = MagicMock()
+ captured_events: list = []
+
+ mock_api_client.publish_event = MagicMock(
+ side_effect=_capture_into(captured_events)
+ )
set_event_queue(EventQueue(api_client=mock_api_client))
server = create_todo_server()
@@ -361,23 +418,30 @@ def capture_event(publish_event_request):
headers={"x-list-header": "list-value"},
request_id="req-list",
)
- from agentcat.modules.overrides import mcp_server as mcp_server_mod
+ from agentcat.modules.adapters import lowlevel_v1
monkeypatch.setattr(
- mcp_server_mod, "safe_request_context", lambda _server: fake_ctx
+ lowlevel_v1, "_safe_request_context", lambda _server: fake_ctx
)
async with create_test_client(server) as client:
await client.list_tools()
time.sleep(1.0)
- list_events = [e for e in captured_events if e.event_type == "mcp:tools/list"]
- assert list_events, (
- f"expected a tools/list event, got {[e.event_type for e in captured_events]}"
- )
- params = list_events[0].parameters or {}
- extra = params.get("extra") or {}
- headers = (extra.get("requestInfo") or {}).get("headers") or {}
- assert headers.get("x-list-header") == "list-value", (
- f"expected list_tools event extra to include header, got params={params}"
- )
+ assert captured_events == []
+
+
+class TestExtraFromRequestContext:
+ """`extra_from_request_context` shapes the adapters' `parameters` merge."""
+
+ def test_wraps_extra_under_its_key(self):
+ ctx = _http_request_context({"x-a": "b"}, request_id="req-1")
+ assert extra_from_request_context(ctx) == {
+ "extra": {
+ "requestInfo": {"headers": {"x-a": "b"}},
+ "requestId": "req-1",
+ }
+ }
+
+ def test_empty_when_nothing_to_report(self):
+ assert extra_from_request_context(None) == {}
diff --git a/tests/test_response_shape.py b/tests/test_response_shape.py
new file mode 100644
index 0000000..60aa586
--- /dev/null
+++ b/tests/test_response_shape.py
@@ -0,0 +1,90 @@
+"""The `response` field's spelling, pinned per flavor rather than normalized.
+
+An event's `response` is the era-native dump of whatever result object that
+generation's tool call produced: `_common.response_payload` calls
+`model_dump(mode="json")` with no `by_alias`, so official mcp 1.x publishes
+`isError` / `structuredContent` and mcp 2.x and both community eras publish
+`is_error` / `structured_content`.
+
+**That divergence is deliberate — see the ruling recorded at
+`_common.response_payload`.** It is not a regression: v1 community already
+published FastMCP's snake_case dump, so "normalizing" would change data the
+backend has been receiving rather than fix it. What was missing is that
+nothing pinned it, which is how a divergence drifts silently or gets "fixed"
+by someone who reads it as a bug. This module is that pin. If you are here
+because you changed the spelling on purpose, change the ruling first.
+
+The wire result the agent receives is unaffected either way: this is the
+analytics payload, not the tool's answer.
+"""
+
+import json
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import MCP_INSTRUCTIONS_KEY, SESSION_ID_PARAM
+
+from .test_utils import FASTMCP_TOOLRESULT_HAS_IS_ERROR, sid
+from .test_utils.flavors import flavors
+
+# (error key, structured key) as each flavor's own result model spells them.
+# A `None` error key means this era's result model has no error field at all,
+# so the payload correctly carries neither spelling — which is still exactly
+# what this module pins: the event keeps the era's OWN field names.
+EXPECTED_SPELLING = {
+ "official-fastmcp-v1": ("isError", "structuredContent"),
+ "lowlevel-v1": ("isError", "structuredContent"),
+ "mcpserver-v2": ("is_error", "structured_content"),
+ "lowlevel-v2": ("is_error", "structured_content"),
+ # Community FastMCP only grew `ToolResult.is_error` in 3.4 (PR #4217); on
+ # the 3.0-3.3 line the model has three fields and the dump has no error
+ # key. The EVENT's own `is_error` is unaffected — the adapter sets it, it
+ # is not read back off this payload.
+ "community-v3": (
+ "is_error" if FASTMCP_TOOLRESULT_HAS_IS_ERROR else None,
+ "structured_content",
+ ),
+ "community-v4": ("is_error", "structured_content"),
+}
+
+BOTH_SPELLINGS = {"isError", "is_error", "structuredContent", "structured_content"}
+
+
+@pytest.fixture(autouse=True)
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_the_event_response_keeps_its_eras_own_field_names(flavor, capture):
+ error_key, structured_key = EXPECTED_SPELLING[flavor.id]
+
+ built = flavor.build("response-shape")
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ await flavor.call(
+ client, "echo", {"text": "hi", SESSION_ID_PARAM: sid("supplied")}
+ )
+
+ response = capture[0].response
+ assert isinstance(response, dict)
+ # This era's spelling is present, and the other era's is nowhere in it.
+ if error_key is not None:
+ assert error_key in response
+ assert structured_key in response
+ # With `error_key` None this asserts NEITHER error spelling appears, which
+ # is strictly stronger than the case where one is expected.
+ assert not (BOTH_SPELLINGS - {error_key, structured_key}) & set(response)
+ # ...and it is the customer's own result, undecorated: the mint-back is
+ # wire-only and must never reach the analytics payload.
+ assert response[structured_key] == {"result": "echo:hi"}
+ assert MCP_INSTRUCTIONS_KEY not in json.dumps(response)
+ assert "[MCP INSTRUCTIONS]" not in json.dumps(response)
diff --git a/tests/test_session.py b/tests/test_session.py
deleted file mode 100644
index e10550e..0000000
--- a/tests/test_session.py
+++ /dev/null
@@ -1,426 +0,0 @@
-"""Comprehensive unit tests for session.py module."""
-
-import sys
-from datetime import datetime, timedelta, timezone
-from unittest.mock import MagicMock, patch
-
-import pytest
-from freezegun import freeze_time
-
-from agentcat.modules.constants import INACTIVITY_TIMEOUT_IN_MINUTES, SESSION_ID_PREFIX
-from agentcat.modules.internal import get_server_tracking_data, set_server_tracking_data
-from agentcat.modules.session import (
- get_agentcat_version,
- get_server_session_id,
- get_session_info,
- new_session_id,
- set_last_activity,
-)
-from agentcat.types import AgentCatData, AgentCatOptions, SessionInfo
-
-from .test_utils.todo_server import create_todo_server
-
-
-class TestNewSessionId:
- """Test the new_session_id function."""
-
- def test_generates_unique_ids(self):
- """Test that new_session_id generates unique IDs."""
- ids = [new_session_id() for _ in range(100)]
- assert len(set(ids)) == 100 # All IDs should be unique
-
- def test_session_id_has_correct_prefix(self):
- """Test that session ID has the correct prefix."""
- session_id = new_session_id()
- assert session_id.startswith(SESSION_ID_PREFIX)
-
- def test_session_id_format(self):
- """Test that session ID follows expected format."""
- session_id = new_session_id()
- # Format should be: prefix_ksuid (e.g., ses_2aYXpLJGvKU1234567890abcdef)
- parts = session_id.split("_")
- assert len(parts) == 2
- assert parts[0] == SESSION_ID_PREFIX
- assert len(parts[1]) > 0 # KSUID part should not be empty
-
-
-class TestGetAgentcatVersion:
- """Test the get_agentcat_version function."""
-
- @patch("importlib.metadata.version")
- def test_returns_correct_version(self, mock_version):
- """Test that get_agentcat_version returns the correct version."""
- mock_version.return_value = "1.2.3"
- assert get_agentcat_version() == "1.2.3"
- mock_version.assert_called_once_with("agentcat")
-
- @patch("importlib.metadata.version")
- def test_returns_none_on_exception(self, mock_version):
- """Test that get_agentcat_version returns None when an exception occurs."""
- mock_version.side_effect = Exception("Package not found")
- assert get_agentcat_version() is None
-
-
-class TestGetSessionInfo:
- """Test the get_session_info function."""
-
- def setup_method(self):
- """Set up test fixtures."""
- self.server = create_todo_server()
-
- def test_without_agentcat_data(self):
- """Test get_session_info without AgentCat data."""
- session_info = get_session_info(self.server, None)
-
- assert session_info.ip_address is None
- assert (
- session_info.sdk_language
- == f"Python {sys.version_info.major}.{sys.version_info.minor}"
- )
- assert session_info.agentcat_version == get_agentcat_version()
- assert session_info.server_name == "todo-server"
- assert (
- session_info.server_version is None
- ) # FastMCP doesn't have version attribute
- assert session_info.client_name is None
- assert session_info.client_version is None
- assert session_info.identify_actor_given_id is None
- assert session_info.identify_actor_name is None
- assert session_info.identify_data is None
-
- def test_with_agentcat_data_no_actor(self):
- """Test get_session_info with AgentCat data but no identified actor."""
- data = AgentCatData(
- project_id="test_project",
- session_id="test_session",
- session_info=SessionInfo(client_name="TestClient", client_version="2.0.0"),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
-
- session_info = get_session_info(self.server, data)
-
- assert session_info.client_name == "TestClient"
- assert session_info.client_version == "2.0.0"
- assert session_info.identify_actor_given_id is None
- assert session_info.identify_actor_name is None
- assert session_info.identify_data is None
-
- # Verify that the session_info was updated in the data object
- assert data.session_info == session_info
-
- def test_session_info_never_carries_identity(self):
- """get_session_info returns None for identity fields."""
- data = AgentCatData(
- project_id="test_project",
- session_id="test_session",
- session_info=SessionInfo(client_name="TestClient", client_version="2.0.0"),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
-
- session_info = get_session_info(self.server, data)
-
- assert session_info.identify_actor_given_id is None
- assert session_info.identify_actor_name is None
- assert session_info.identify_data is None
-
- def test_server_without_name_or_version(self):
- """Test get_session_info with a server that doesn't have name or version attributes."""
- mock_server = MagicMock()
- # Remove name and version attributes
- del mock_server.name
- del mock_server.version
-
- session_info = get_session_info(mock_server, None)
-
- assert session_info.server_name is None
- assert session_info.server_version is None
-
- def test_get_session_info_with_tracked_server(self):
- """Test get_session_info when server has tracked data."""
- # Set up initial data
- data = AgentCatData(
- project_id="test_project",
- session_id="test_session",
- session_info=SessionInfo(
- client_name="TrackedClient", client_version="3.0.0"
- ),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
-
- # Store data using set_server_tracking_data
- set_server_tracking_data(self.server, data)
-
- # When called without data, get_session_info returns basic info only
- session_info_no_data = get_session_info(self.server, None)
- assert session_info_no_data.client_name is None
- assert session_info_no_data.client_version is None
- assert session_info_no_data.server_name == "todo-server"
-
- # When called with data, get_session_info uses and updates that data
- session_info_with_data = get_session_info(self.server, data)
- assert session_info_with_data.client_name == "TrackedClient"
- assert session_info_with_data.client_version == "3.0.0"
- assert session_info_with_data.server_name == "todo-server"
-
-
-class TestSetLastActivity:
- """Test the set_last_activity function."""
-
- def setup_method(self):
- """Set up test fixtures."""
- self.server = create_todo_server()
-
- def test_updates_last_activity(self):
- """Test that set_last_activity updates the last activity timestamp."""
- initial_time = datetime.now(timezone.utc)
-
- data = AgentCatData(
- project_id="test_project",
- session_id="test_session",
- session_info=SessionInfo(),
- last_activity=initial_time,
- options=AgentCatOptions(),
- )
-
- # Set up the server with tracking data
- set_server_tracking_data(self.server, data)
-
- # Move time forward
- with freeze_time(initial_time + timedelta(minutes=5)):
- set_last_activity(self.server)
-
- # Verify the timestamp was updated
- assert data.last_activity > initial_time
-
- def test_raises_exception_when_no_data(self):
- """Test that set_last_activity raises exception when no data is found."""
- with pytest.raises(Exception) as exc_info:
- set_last_activity(self.server)
-
- assert str(exc_info.value) == "AgentCat data not initialized for this server"
-
-
-class TestGetServerSessionId:
- """Test the get_server_session_id function."""
-
- def setup_method(self):
- """Set up test fixtures."""
- self.server = create_todo_server()
- self.initial_time = datetime.now(timezone.utc)
- self.initial_session_id = "ses_initial123"
-
- self.data = AgentCatData(
- project_id="test_project",
- session_id=self.initial_session_id,
- session_info=SessionInfo(),
- last_activity=self.initial_time,
- options=AgentCatOptions(),
- )
-
- def test_returns_existing_session_when_not_timed_out(self):
- """Test that existing session ID is returned when not timed out."""
- set_server_tracking_data(self.server, self.data)
-
- # Test within timeout period (e.g., 10 minutes later)
- with freeze_time(self.initial_time + timedelta(minutes=10)):
- session_id = get_server_session_id(self.server)
-
- assert session_id == self.initial_session_id
- # Verify last activity was updated
- assert self.data.last_activity > self.initial_time
-
- def test_creates_new_session_when_timed_out(self):
- """Test that new session ID is created when session has timed out."""
- set_server_tracking_data(self.server, self.data)
-
- # Test after timeout period (e.g., 31 minutes later)
- timeout_time = self.initial_time + timedelta(
- minutes=INACTIVITY_TIMEOUT_IN_MINUTES + 1
- )
- with freeze_time(timeout_time):
- session_id = get_server_session_id(self.server)
-
- # Should have a new session ID
- assert session_id != self.initial_session_id
- assert session_id.startswith(SESSION_ID_PREFIX)
- assert self.data.session_id == session_id
- # Verify last activity was updated to current time
- assert self.data.last_activity == timeout_time
-
- def test_exactly_at_timeout_boundary(self):
- """Test behavior exactly at the timeout boundary."""
- set_server_tracking_data(self.server, self.data)
-
- # Test exactly at timeout boundary
- boundary_time = self.initial_time + timedelta(
- minutes=INACTIVITY_TIMEOUT_IN_MINUTES
- )
- with freeze_time(boundary_time):
- session_id = get_server_session_id(self.server)
-
- # Should not timeout at exact boundary (> not >=)
- assert session_id == self.initial_session_id
-
- def test_raises_exception_when_no_data(self):
- """Test that get_server_session_id raises exception when no data is found."""
- with pytest.raises(Exception) as exc_info:
- get_server_session_id(self.server)
-
- assert str(exc_info.value) == "AgentCat data not initialized for this server"
-
- def test_multiple_calls_with_activity(self):
- """Test multiple calls to get_server_session_id with activity between them."""
- set_server_tracking_data(self.server, self.data)
-
- # First call at initial time
- with freeze_time(self.initial_time):
- session_id1 = get_server_session_id(self.server)
-
- # Activity at 20 minutes - should reset timeout
- activity_time = self.initial_time + timedelta(minutes=20)
- with freeze_time(activity_time):
- get_server_session_id(self.server)
- # This updates last_activity to activity_time
-
- # Call 40 minutes after initial time (but only 20 minutes after last activity)
- with freeze_time(self.initial_time + timedelta(minutes=40)):
- session_id2 = get_server_session_id(self.server)
-
- # Should still be the same session since last activity was only 20 minutes ago
- assert session_id1 == session_id2 == self.initial_session_id
-
- def test_timeout_calculation_edge_cases(self):
- """Test edge cases in timeout calculation."""
- set_server_tracking_data(self.server, self.data)
-
- # Test just before timeout
- with freeze_time(
- self.initial_time + timedelta(minutes=INACTIVITY_TIMEOUT_IN_MINUTES - 1)
- ):
- session_id = get_server_session_id(self.server)
- assert session_id == self.initial_session_id
-
- # Re-initialize data to reset the session for the next test
- self.data.session_id = self.initial_session_id
- self.data.last_activity = self.initial_time
-
- # Test just after timeout
- with freeze_time(
- self.initial_time + timedelta(minutes=INACTIVITY_TIMEOUT_IN_MINUTES + 1)
- ):
- session_id = get_server_session_id(self.server)
- assert session_id != self.initial_session_id
-
-
-class TestIntegration:
- """Integration tests for session management."""
-
- @freeze_time("2024-01-01 12:00:00")
- def test_full_session_lifecycle(self):
- """Test a complete session lifecycle with timeout and renewal."""
- server = create_todo_server()
-
- # Create initial session data
- initial_session_id = new_session_id()
- data = AgentCatData(
- project_id="integration_project",
- session_id=initial_session_id,
- session_info=SessionInfo(
- client_name="IntegrationClient", client_version="1.0.0"
- ),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
-
- set_server_tracking_data(server, data)
-
- # Get initial session
- session_id = get_server_session_id(server)
- assert session_id == initial_session_id
-
- # Get session info
- session_info = get_session_info(server, data)
- assert session_info.server_name == "todo-server"
- assert session_info.client_name == "IntegrationClient"
-
- assert session_info.identify_actor_given_id is None
- assert session_info.identify_actor_name is None
-
- # Test session timeout and renewal
- with freeze_time("2024-01-01 12:31:00"): # 31 minutes later
- new_sid = get_server_session_id(server)
- assert new_sid != initial_session_id
-
- new_data = AgentCatData(
- project_id="integration_project",
- session_id=new_sid,
- session_info=SessionInfo(
- client_name="IntegrationClient", client_version="1.0.0"
- ),
- last_activity=datetime(2024, 1, 1, 12, 31, 0),
- options=AgentCatOptions(),
- )
- session_info = get_session_info(server, new_data)
- assert session_info.identify_actor_given_id is None
- assert session_info.identify_actor_name is None
-
- def test_session_persistence_across_function_calls(self):
- """Test that session persists correctly across multiple function calls."""
- server = create_todo_server()
-
- # Initialize tracking data
- data = AgentCatData(
- project_id="persistence_test",
- session_id=new_session_id(),
- session_info=SessionInfo(),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
-
- set_server_tracking_data(server, data)
-
- # Multiple calls should return same session
- session_ids = []
- for _ in range(5):
- session_ids.append(get_server_session_id(server))
-
- assert len(set(session_ids)) == 1 # All should be the same
-
- # Verify activity tracking works
- original_activity = data.last_activity
- with freeze_time(datetime.now(timezone.utc) + timedelta(seconds=5)):
- set_last_activity(server)
-
- assert data.last_activity > original_activity
-
- def test_session_info_updates_tracked_data(self):
- """get_session_info updates the tracked data's session_info."""
- server = create_todo_server()
-
- data = AgentCatData(
- project_id="update_test",
- session_id="update_session",
- session_info=SessionInfo(
- client_name="UpdateClient", client_version="1.0.0"
- ),
- last_activity=datetime.now(timezone.utc),
- options=AgentCatOptions(),
- )
-
- set_server_tracking_data(server, data)
-
- session_info = get_session_info(server, data)
-
- assert session_info.identify_actor_given_id is None
- assert session_info.identify_actor_name is None
- assert session_info.identify_data is None
-
- # Verify the data object was updated
- assert data.session_info == session_info
-
- # Verify that the server's tracked data was also updated via set_server_tracking_data
- stored_data = get_server_tracking_data(server)
- assert stored_data.session_info == session_info
diff --git a/tests/test_session_id_validation.py b/tests/test_session_id_validation.py
new file mode 100644
index 0000000..35d605a
--- /dev/null
+++ b/tests/test_session_id_validation.py
@@ -0,0 +1,443 @@
+"""AgentCat trusts only a `session_id` it issued.
+
+Before this, `resolve_handles` adopted whatever string arrived in
+`arguments["session_id"]`, verbatim and unchecked, into `Event.session_id` —
+a field in `redaction.PROTECTED_FIELDS` and therefore exempt from the
+customer's redaction hook. Two failures followed, and the `task_id` ->
+`session_id` rename made both materially likelier, because `session_id` is a
+common parameter name on customer tools and the one most likely to hold an
+auth token:
+
+1. **Unredactable adoption.** A hallucinated value, or a token a client
+ auto-populated into a parameter that happens to be named `session_id`,
+ reached PostHog `$session_id`, Datadog, Sentry and OTLP with no way to
+ redact it.
+2. **The confirmation loop.** A customer's own `session_id` was echoed back to
+ the agent as "confirmed. Keep sending this exact value on every call."
+ AgentCat confirming a value it never issued.
+
+Everything here follows from one sentence: AgentCat only trusts a session_id
+it issued. Cross-SDK reference:
+`agentcat-typescript-sdk/docs/superpowers/specs/2026-08-02-session-id-validation-design.md`.
+"""
+
+import pytest
+
+from agentcat import AgentCatOptions, track
+from agentcat.modules.constants import (
+ AGENTCAT_TAG_SESSION_SOURCE,
+ MINT_BACK_HEADER_INVALID,
+ MINT_BACK_HEADER_SESSION,
+ SESSION_ID_PARAM,
+)
+from agentcat.modules.handles import (
+ build_mint_back_text,
+ build_structured_mint_back,
+ derive_session_id,
+ is_valid_session_id,
+ new_session_id,
+ resolve_handles,
+)
+from agentcat.modules.injection import ToolSpec, build_injected_schemas
+
+from .test_utils import sid
+from .test_utils.flavors import flavors
+
+# ── A. the shape predicate ───────────────────────────────────────────────────
+
+
+def test_accepts_ids_this_sdk_actually_issues():
+ """Both issuing paths satisfy the predicate by construction.
+
+ If this ever fails, minted handles are being rejected as invalid and every
+ conversation is severed — the loudest possible failure, deliberately
+ asserted against the real minters rather than a hand-typed literal.
+ """
+ for _ in range(50):
+ assert is_valid_session_id(new_session_id())
+ assert is_valid_session_id(derive_session_id("anything", "proj_1"))
+ assert is_valid_session_id(derive_session_id("anything"))
+ assert is_valid_session_id(sid("parent"))
+
+
+@pytest.mark.parametrize(
+ ("label", "value"),
+ [
+ ("wrong prefix", "task_2xF9kQm3rTvB8nLpYw7ZcHd4Ke1"),
+ ("no prefix", "2xF9kQm3rTvB8nLpYw7ZcHd4Ke1"),
+ ("too short", "ses_abc"),
+ ("one char short", "ses_" + "a" * 26),
+ ("one char long", "ses_" + "a" * 28),
+ ("empty", ""),
+ ("prefix only", "ses_"),
+ ("customer value", "my-app-session-42"),
+ ("non-base62 body", "ses_" + "-" * 27),
+ ("underscore body", "ses_" + "_" * 27),
+ ("inner whitespace", "ses_ " + "a" * 26),
+ # Python-specific: `re.match(r"...$")` would ACCEPT this, because `$`
+ # also matches immediately before a final newline. `fullmatch` does
+ # not, which is what keeps the predicate identical to the TS regex.
+ ("trailing newline", "ses_" + "a" * 27 + "\n"),
+ ("leading newline", "\nses_" + "a" * 27),
+ ],
+)
+def test_rejects_anything_this_sdk_did_not_issue(label, value):
+ assert is_valid_session_id(value) is False, label
+
+
+# ── B. the decision table ────────────────────────────────────────────────────
+#
+# | args.session_id | ours? | shape | Event.session_id | source |
+# | absent | yes | — | new_session_id() | minted |
+# | present | yes | valid | verbatim | supplied |
+# | present | yes | invalid | "" (sessionless) | invalid |
+# | present/absent | no | — | "" (sessionless) | foreign |
+
+
+async def _resolve(arguments, *, ours=True, **options):
+ return await resolve_handles(
+ arguments,
+ AgentCatOptions(**options),
+ "proj_1",
+ None,
+ None,
+ None,
+ session_param_is_ours=ours,
+ )
+
+
+async def test_absent_and_ours_mints():
+ r = await _resolve({})
+ assert r.session_source == "minted"
+ assert is_valid_session_id(r.session_id)
+
+
+async def test_valid_and_ours_is_taken_verbatim():
+ r = await _resolve({SESSION_ID_PARAM: sid("parent")})
+ assert (r.session_source, r.session_id) == ("supplied", sid("parent"))
+
+
+async def test_malformed_and_ours_publishes_sessionless():
+ r = await _resolve({SESSION_ID_PARAM: "nope"})
+ assert (r.session_source, r.session_id) == ("invalid", "")
+
+
+async def test_the_rejected_value_is_never_stored_anywhere():
+ """The whole point: `Event.session_id` cannot be redacted after the fact.
+
+ Asserted over the entire resolution rather than one field, because any
+ leak — a tag, a mint-back, a source string — lands somewhere exempt.
+ """
+ secret = "sk_live_51H8xQ2abcdefgHIJKLmnop"
+ r = await _resolve({SESSION_ID_PARAM: secret}, enable_agent_tracking=True)
+ assert r.session_id == ""
+ assert secret not in repr(r)
+ assert secret not in str(build_mint_back_text(r))
+ assert secret not in str(build_structured_mint_back(r))
+
+
+@pytest.mark.parametrize("arguments", [{}, {SESSION_ID_PARAM: "customer-value"}])
+async def test_a_foreign_param_is_sessionless_whatever_the_agent_sent(arguments):
+ r = await _resolve(arguments, ours=False)
+ assert (r.session_source, r.session_id) == ("foreign", "")
+
+
+async def test_hook_mode_wins_over_foreign_and_reads_no_arguments():
+ """Hook mode short-circuits before any argument is touched.
+
+ That is what makes `resolve_session_id` the documented remedy for a
+ collision: the customer's parameter stays entirely theirs while AgentCat
+ derives its own session from their identifier.
+ """
+ r = await _resolve(
+ {SESSION_ID_PARAM: "customer-value"},
+ ours=False,
+ resolve_session_id=lambda request, extra: "corr-7",
+ )
+ assert r.session_source == "hook"
+ assert r.session_id == derive_session_id("corr-7", "proj_1")
+
+
+async def test_a_missing_registry_still_validates():
+ """`tools/call` before any `tools/list` on this instance.
+
+ Nothing is in `declared_session_params` yet, so the tool counts as ours
+ and the value is validated rather than adopted. A customer's foreign value
+ in that window degrades to `invalid` instead of `foreign` — both
+ sessionless, only the tag differs.
+ """
+ r = await _resolve({SESSION_ID_PARAM: "TICKET-77"})
+ assert (r.session_source, r.session_id) == ("invalid", "")
+
+
+# ── C. what the agent is told ────────────────────────────────────────────────
+
+
+async def test_invalid_corrects_the_agent_without_issuing_a_replacement():
+ r = await _resolve({SESSION_ID_PARAM: "nope"})
+ text = build_mint_back_text(r)
+ assert text.startswith(MINT_BACK_HEADER_INVALID)
+ assert "Re-send the exact session_id" in text
+ assert "omit the parameter and one will be issued" in text
+ # Nothing that looks like an issued ID appears — this branch corrects, it
+ # does not mint. Handing out a second ID would split a session that was
+ # never split.
+ assert "ses_" not in text
+ assert MINT_BACK_HEADER_SESSION not in text
+
+
+async def test_invalid_mirror_carries_instructions_but_no_session_id():
+ """The regression `not names: return None` would cause.
+
+ With no agent_id in play there is nothing echoable, so the old early
+ return dropped the correction entirely — the one branch that has something
+ to say and nothing to confirm.
+ """
+ r = await _resolve({SESSION_ID_PARAM: "nope"})
+ mint = build_structured_mint_back(r)
+ assert mint is not None
+ assert SESSION_ID_PARAM not in mint
+ assert "not recognized" in mint["instructions"]
+
+
+async def test_foreign_says_nothing_about_session_id_at_all():
+ r = await _resolve({SESSION_ID_PARAM: "customer-value"}, ours=False)
+ assert build_mint_back_text(r) is None
+ assert build_structured_mint_back(r) is None
+
+
+async def test_foreign_never_confirms_a_value_agentcat_did_not_issue():
+ """The confirmation loop, pinned.
+
+ The bug was `mint_back_confirmed` telling the agent its own
+ customer-semantics value was "confirmed. Keep sending this exact value on
+ every call."
+ """
+ r = await _resolve(
+ {SESSION_ID_PARAM: "customer-value", "agent_id": "opus|cc|k3n9x"},
+ ours=False,
+ enable_agent_tracking=True,
+ )
+ mint = build_structured_mint_back(r)
+ # agent_id is a separate injection and still landed, so it is still ours
+ # to confirm — suppression is per handle, not per response.
+ assert mint == {
+ "agent_id": "opus|cc|k3n9x",
+ "instructions": (
+ "[MCP INSTRUCTIONS]: agent_id confirmed. "
+ "Keep sending this exact value on every call."
+ ),
+ }
+ assert "customer-value" not in str(mint)
+
+
+# ── D. what the customer is told ─────────────────────────────────────────────
+
+
+@pytest.fixture
+def log_sink():
+ """Everything `write_to_log` tees to diagnostics, as the collector sees it."""
+ from agentcat.modules import logging as agentcat_logging
+
+ previous = agentcat_logging._diagnostics_sink
+ lines: list[str] = []
+ agentcat_logging.set_diagnostics_sink(lines.append)
+ yield lines
+ agentcat_logging.set_diagnostics_sink(previous)
+
+
+def _colliding(name: str) -> ToolSpec:
+ return ToolSpec(
+ name=name,
+ input_schema={
+ "type": "object",
+ "properties": {SESSION_ID_PARAM: {"type": "string"}},
+ },
+ )
+
+
+def test_the_collision_is_an_error_with_remediation(log_sink):
+ build_injected_schemas([_colliding("own_session")], AgentCatOptions(), set())
+ (line,) = [ln for ln in log_sink if "own_session" in ln]
+ # `write_to_log` has no severity argument; the convention is a text prefix
+ # on the message, which follows the timestamp bracket.
+ assert line.split("] ", 1)[1].startswith("ERROR:")
+ assert "WARN:" not in line
+ assert "resolve_session_id" in line
+ assert "without a session" in line
+ assert "still reaches your handler" in line
+
+
+def test_the_collision_is_reported_once_per_tool_not_once_per_listing(log_sink):
+ """`build_injected_schemas` reruns on every `tools/list`.
+
+ Undeduped, this would repeat for the life of the process — which is how a
+ real signal becomes noise the customer filters out.
+ """
+ reported: set[str] = set()
+ options = AgentCatOptions()
+ for _ in range(3):
+ build_injected_schemas([_colliding("own_session")], options, reported)
+
+ assert len([ln for ln in log_sink if "own_session" in ln and "ERROR" in ln]) == 1
+
+ # Still once per DISTINCT tool, though.
+ build_injected_schemas([_colliding("other_tool")], options, reported)
+ assert len([ln for ln in log_sink if "other_tool" in ln and "ERROR" in ln]) == 1
+
+
+def test_ownership_is_recorded_so_the_call_path_can_read_it():
+ """Per tool, not per server: the colliding tool and a normal one coexist."""
+ theirs = _colliding("own_session")
+ result = build_injected_schemas(
+ [theirs, ToolSpec(name="echo", input_schema={})], AgentCatOptions()
+ )
+ assert result.declared_session_params == {"own_session"}
+ # Nothing was injected over their parameter, and nothing was recorded as
+ # strippable — so their handler still receives it.
+ assert SESSION_ID_PARAM not in result.injected_params["own_session"]
+ assert theirs.input_schema["properties"][SESSION_ID_PARAM] == {"type": "string"}
+ assert SESSION_ID_PARAM in result.injected_params["echo"]
+
+
+def test_a_composed_schema_declaring_session_id_is_still_the_customers():
+ """Injection is skipped for oneOf/allOf/anyOf, but ownership is not.
+
+ Only the root properties bag is visible here — a `session_id` nested
+ inside a branch is unreachable, the same limitation the injection has.
+ """
+ composed = ToolSpec(
+ name="composed_own",
+ input_schema={
+ "oneOf": [{"type": "object"}],
+ "properties": {SESSION_ID_PARAM: {"type": "string"}},
+ },
+ )
+ result = build_injected_schemas([composed], AgentCatOptions())
+ assert result.declared_session_params == {"composed_own"}
+
+
+def test_a_composed_schema_without_the_name_stays_ours():
+ """The bug a naive port of the TS ownership test would introduce.
+
+ A composed schema has an injection entry that stays EMPTY, because the
+ whole pass is skipped. Reading ownership off that emptiness would call
+ every such tool the customer's and publish it sessionless, when nobody
+ declared the name at all.
+ """
+ composed = ToolSpec(
+ name="composed_plain",
+ input_schema={"oneOf": [{"type": "object"}], "properties": {}},
+ )
+ result = build_injected_schemas([composed], AgentCatOptions())
+ assert result.injected_params["composed_plain"] == set()
+ assert result.declared_session_params == set()
+
+
+# ── E. end to end, on every server shape ─────────────────────────────────────
+
+
+@pytest.fixture
+def capture(monkeypatch):
+ """Collect every event the queue is handed, without touching the network."""
+ events: list = []
+ from agentcat.modules import event_queue
+
+ monkeypatch.setattr(event_queue.event_queue, "add", events.append)
+ return events
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_an_invalid_id_publishes_sessionless_and_corrects_the_agent(
+ flavor, capture
+):
+ """The whole path, over each flavor's real client and transport.
+
+ The unit tests above can only prove the decision; this proves the decision
+ reaches both consumers — the event AgentCat publishes and the result the
+ agent is handed.
+ """
+ built = flavor.build("invalid-session")
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ result = await flavor.call(
+ client, "echo", {"text": "hi", SESSION_ID_PARAM: "not-a-real-id"}
+ )
+
+ assert "session_id not recognized" in result.text
+ assert "not-a-real-id" not in result.text
+
+ (event,) = capture
+ assert event.session_id is None
+ assert event.tags[AGENTCAT_TAG_SESSION_SOURCE] == "invalid"
+ # The rejected value is nowhere near the unredactable field, but it IS
+ # still on the event as an argument the agent sent — which is `parameters`,
+ # where the customer's redaction hook can reach it.
+ assert event.parameters["arguments"][SESSION_ID_PARAM] == "not-a-real-id"
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_the_correction_lets_an_agent_recover_by_omitting_the_parameter(
+ flavor, capture
+):
+ """The deadlock the closing sentence of the copy exists to prevent.
+
+ An agent that hallucinates a session_id on its FIRST call was never issued
+ one, so "re-send what you were given" names nothing. Omitting the
+ parameter has to put it back on the minting path — otherwise the
+ conversation can never acquire a session at all.
+ """
+ built = flavor.build("invalid-recovery")
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ await flavor.list_tools(client)
+ await flavor.call(client, "echo", {"text": "a", SESSION_ID_PARAM: "guessed"})
+ recovered = await flavor.call(client, "echo", {"text": "b"})
+
+ assert MINT_BACK_HEADER_SESSION in recovered.text
+ issued = recovered.structured["_mcp_instructions"][SESSION_ID_PARAM]
+ assert is_valid_session_id(issued)
+
+ rejected, minted = capture
+ assert rejected.session_id is None
+ assert minted.session_id == issued
+ assert minted.tags[AGENTCAT_TAG_SESSION_SOURCE] == "minted"
+
+
+@pytest.mark.parametrize("flavor", flavors(), ids=lambda f: f.id)
+async def test_the_conflict_report_is_deduped_per_server_not_per_listing(
+ flavor, log_sink
+):
+ """The dedupe set has to reach the pipeline from `AgentCatData`.
+
+ The unit test above proves `build_injected_schemas` honors a set it is
+ handed; only a real server proves each adapter actually hands it one. Wire
+ it wrong and the customer gets this error on every `tools/list` for the
+ life of the process.
+ """
+ built = flavor.build("dedupe", customer_session_id=True)
+ track(built.server, "proj_test", AgentCatOptions())
+
+ async with flavor.client(built.server) as client:
+ for _ in range(3):
+ await flavor.list_tools(client)
+
+ errors = [ln for ln in log_sink if "ERROR:" in ln and "complete_task" in ln]
+ assert len(errors) == 1, f"reported {len(errors)} times across 3 listings"
+
+
+def test_hook_mode_never_reports_a_collision(log_sink):
+ """Nothing is injected in hook mode, so nothing can collide.
+
+ Reporting here would tell customers who already took the documented remedy
+ that they still have the problem.
+ """
+ build_injected_schemas(
+ [_colliding("own_session")],
+ AgentCatOptions(resolve_session_id=lambda request, extra: "corr-1"),
+ set(),
+ )
+ assert not [ln for ln in log_sink if "own_session" in ln]
diff --git a/tests/test_stateless.py b/tests/test_stateless.py
deleted file mode 100644
index 222e8cf..0000000
--- a/tests/test_stateless.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""Tests for stateless mode behavior."""
-
-from datetime import datetime, timezone
-from unittest.mock import MagicMock, patch
-
-import agentcat
-from agentcat.modules.internal import (
- get_server_tracking_data,
- set_server_tracking_data,
- reset_all_tracking_data,
-)
-from agentcat.modules.session import get_server_session_id, get_client_info_from_request_context
-from agentcat.modules.identify import identify_session
-from agentcat.types import AgentCatData, AgentCatOptions, SessionInfo, UserIdentity
-
-from .test_utils.todo_server import create_todo_server
-
-
-def _make_identify_fn(user_id="user_123", user_name="Test User"):
- """Return an identify function that always returns a UserIdentity."""
- def identify(request, context):
- return UserIdentity(user_id=user_id, user_name=user_name, user_data=None)
- return identify
-
-
-class TestStatelessMode:
- """Tests for SDK stateless mode behavior."""
-
- def setup_method(self):
- reset_all_tracking_data()
- self.server = create_todo_server()
-
- def teardown_method(self):
- reset_all_tracking_data()
-
- def _setup_data(self, stateless=False, identify=None):
- """Create and store AgentCatData on the server."""
- options = AgentCatOptions()
- if identify:
- options.identify = identify
- data = AgentCatData(
- project_id="test_project",
- session_id="ses_existing123",
- session_info=SessionInfo(),
- last_activity=datetime.now(timezone.utc),
- options=options,
- is_stateless=stateless,
- )
- set_server_tracking_data(self.server, data)
- return data
-
- def test_stateless_option_sets_flag(self):
- """AgentCatOptions(stateless=True) should set is_stateless on data."""
- data = self._setup_data(stateless=True)
- assert data.is_stateless is True
-
- def test_stateless_session_id_is_none(self):
- """In stateless mode, get_server_session_id() should return None."""
- self._setup_data(stateless=True)
- session_id = get_server_session_id(self.server)
- assert session_id is None
-
- @patch("agentcat.modules.identify.event_queue")
- def test_stateless_identify_runs_every_time(self, mock_event_queue):
- """In stateless mode, identify should run on every call (no early-return guard)."""
- mock_fn = MagicMock(return_value=UserIdentity(
- user_id="alice", user_name="Alice", user_data=None
- ))
- self._setup_data(stateless=True, identify=mock_fn)
-
- identify_session(self.server, MagicMock(), MagicMock())
- identify_session(self.server, MagicMock(), MagicMock())
-
- assert mock_fn.call_count == 2
-
- @patch("agentcat.modules.identify.event_queue")
- def test_stateless_identify_returns_identity(self, mock_event_queue):
- """In stateless mode, identify_session() should return the UserIdentity."""
- self._setup_data(stateless=True, identify=_make_identify_fn())
-
- result = identify_session(self.server, MagicMock(), MagicMock())
-
- assert isinstance(result, UserIdentity)
- assert result.user_id == "user_123"
- assert result.user_name == "Test User"
-
- @patch("agentcat.modules.identify.event_queue")
- def test_stateful_identify_runs_every_time(self, mock_event_queue):
- """Stateful mode runs identify on every request."""
- mock_fn = MagicMock(return_value=UserIdentity(
- user_id="alice", user_name="Alice", user_data=None
- ))
- self._setup_data(stateless=False, identify=mock_fn)
-
- # Session ID should be a string
- session_id = get_server_session_id(self.server)
- assert isinstance(session_id, str)
- assert session_id.startswith("ses_")
-
- identify_session(self.server, MagicMock(), MagicMock())
- identify_session(self.server, MagicMock(), MagicMock())
-
- assert mock_fn.call_count == 2
-
- def test_track_stateless_true_sets_flag(self):
- """track() with stateless=True should set is_stateless on data."""
- server = create_todo_server()
- options = AgentCatOptions(stateless=True)
- agentcat.track(server, "test_project", options)
- data = get_server_tracking_data(server)
- assert data.is_stateless is True
-
- def test_track_stateless_false_overrides_detection(self):
- """track() with stateless=False should force stateful even if server looks stateless."""
- server = create_todo_server()
- # Mock the server to look stateless
- server.settings = MagicMock()
- server.settings.stateless_http = True
- options = AgentCatOptions(stateless=False)
- agentcat.track(server, "test_project", options)
- data = get_server_tracking_data(server)
- assert data.is_stateless is False
-
- def test_track_stateless_none_auto_detects(self):
- """track() with stateless=None (default) should auto-detect from server."""
- server = create_todo_server()
- options = AgentCatOptions() # stateless=None by default
- agentcat.track(server, "test_project", options)
- data = get_server_tracking_data(server)
- # create_todo_server() is not stateless, so should be False
- assert data.is_stateless is False
-
- @patch("agentcat.modules.identify.event_queue")
- def test_stateless_identify_bad_return(self, mock_event_queue):
- """In stateless mode, identify returning non-UserIdentity should return None."""
- bad_fn = MagicMock(return_value="not a UserIdentity")
- self._setup_data(stateless=True, identify=bad_fn)
-
- result = identify_session(self.server, MagicMock(), MagicMock())
-
- assert result is None
- assert bad_fn.call_count == 1
-
- @patch("agentcat.modules.identify.event_queue")
- def test_stateless_identify_exception(self, mock_event_queue):
- """In stateless mode, identify raising should return None, not propagate."""
- raising_fn = MagicMock(side_effect=RuntimeError("identify exploded"))
- self._setup_data(stateless=True, identify=raising_fn)
-
- result = identify_session(self.server, MagicMock(), MagicMock())
-
- assert result is None
- assert raising_fn.call_count == 1
-
- def _make_request_context(self, user_agent):
- """Create a mock request context with a User-Agent header."""
- ctx = MagicMock()
- ctx.request.headers = {"user-agent": user_agent}
- # No session attribute (stateless HTTP)
- ctx.session = None
- return ctx
-
- def test_stateless_client_info_per_request(self):
- """In stateless mode, consecutive requests with different clients return different info."""
- self._setup_data(stateless=True)
-
- ctx1 = self._make_request_context("Cursor/2.6.22")
- ctx2 = self._make_request_context("Claude Desktop/1.0")
-
- result1 = get_client_info_from_request_context(self.server, ctx1)
- result2 = get_client_info_from_request_context(self.server, ctx2)
-
- assert result1 == ("Cursor", "2.6.22")
- assert result2 == ("Claude Desktop", "1.0")
-
- def test_stateless_client_info_returns_values(self):
- """In stateless mode, get_client_info_from_request_context returns a tuple."""
- self._setup_data(stateless=True)
-
- ctx = self._make_request_context("Cursor/2.6.22")
- result = get_client_info_from_request_context(self.server, ctx)
-
- assert isinstance(result, tuple)
- assert len(result) == 2
- assert result[0] == "Cursor"
- assert result[1] == "2.6.22"
-
- def test_stateful_client_info_cached_across_requests(self):
- """In stateful mode, client info is determined by the first request."""
- self._setup_data(stateless=False)
-
- ctx1 = self._make_request_context("Cursor/2.6.22")
- ctx2 = self._make_request_context("Claude Desktop/1.0")
-
- get_client_info_from_request_context(self.server, ctx1)
- get_client_info_from_request_context(self.server, ctx2)
-
- data = get_server_tracking_data(self.server)
- assert data.session_info.client_name == "Cursor"
- assert data.session_info.client_version == "2.6.22"
diff --git a/tests/test_tool_context.py b/tests/test_tool_context.py
index 92b69fe..c5e6bdf 100644
--- a/tests/test_tool_context.py
+++ b/tests/test_tool_context.py
@@ -1,97 +1,87 @@
-"""Test tool context functionality."""
+"""Context-parameter injection on the official SDK, end to end.
+
+One thing about v2 changes what this file used to assert: every tool now also
+receives the `session_id` handle, so the injected property order is
+`customer params, session_id, context`.
+
+`context` stays REQUIRED, as it was in 1.x. Nothing server-side rejects a call
+that omits it — a schema-validating client refusing to send one is the whole
+enforcement mechanism, and without it agents quietly stop supplying intent.
+`session_id` is the one injected parameter that is never required: omitting it is
+how an agent asks to be minted one.
+"""
import time
from unittest.mock import MagicMock
import pytest
-from mcp.server import Server
from mcp.server.fastmcp import FastMCP
-from mcp.types import Tool
from agentcat import AgentCatOptions, track
from agentcat.modules.constants import DEFAULT_CONTEXT_DESCRIPTION
from agentcat.modules.event_queue import EventQueue, set_event_queue
from .test_utils.client import create_test_client
+from .test_utils.delivery import delivered_arguments_for
from .test_utils.todo_server import create_todo_server
+async def _tools(server):
+ async with create_test_client(server) as client:
+ return (await client.list_tools()).tools
+
+
+def _named(tools, name):
+ return next(t for t in tools if t.name == name)
+
+
class TestToolContext:
"""Test tool context functionality."""
@pytest.mark.asyncio
async def test_context_parameter_injection_enabled(self):
- """Test that context parameter is added when enable_tool_call_context=True."""
+ """Context is added — optional — when enable_tool_call_context=True."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Check each tool (except get_more_tools if present)
- for tool in tools_result.tools:
- if tool.name == "get_more_tools":
- continue
-
- # Verify context parameter exists
- assert "context" in tool.inputSchema["properties"]
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Verify context is required
- assert "context" in tool.inputSchema["required"]
-
- # Verify context schema properties
- context_schema = tool.inputSchema["properties"]["context"]
- assert context_schema["type"] == "string"
- assert (
- context_schema["description"]
- == DEFAULT_CONTEXT_DESCRIPTION
- )
+ for tool in await _tools(server):
+ if tool.name == "get_more_tools":
+ continue
+ context_schema = tool.inputSchema["properties"]["context"]
+ assert context_schema["type"] == "string"
+ assert context_schema["description"] == DEFAULT_CONTEXT_DESCRIPTION
+ # Required, as in 1.x: a strict client refusing to send a call
+ # without it is the only thing that makes agents supply intent.
+ assert "context" in tool.inputSchema["required"]
+ # ...and session_id is not, because omitting it is the mint signal.
+ assert "session_id" not in tool.inputSchema.get("required", [])
@pytest.mark.asyncio
async def test_context_parameter_not_injected_when_disabled(self):
- """Test that context parameter is NOT added when enable_tool_call_context=False."""
+ """Context is NOT added when enable_tool_call_context=False."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=False)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=False))
- for tool in tools_result.tools:
- if tool.name == "get_more_tools":
- continue
-
- # Verify context parameter does NOT exist
+ for tool in await _tools(server):
+ if tool.name != "get_more_tools": # its own context is not ours
assert "context" not in tool.inputSchema.get("properties", {})
-
- # Verify context is NOT in required
assert "context" not in tool.inputSchema.get("required", [])
+ # Handles are independent of the context parameter.
+ assert "session_id" in tool.inputSchema["properties"]
@pytest.mark.asyncio
async def test_schema_with_existing_properties(self):
- """Test with tools that have existing inputSchema and properties."""
+ """Existing properties survive, and the injected ones follow them."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find add_todo which has existing schema
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
-
- # Verify original properties still exist
- assert "text" in add_todo_tool.inputSchema["properties"]
-
- # Verify context was added
- assert "context" in add_todo_tool.inputSchema["properties"]
- assert "context" in add_todo_tool.inputSchema["required"]
+ add_todo = _named(await _tools(server), "add_todo")
+ properties = list(add_todo.inputSchema["properties"])
+ assert properties == ["text", "session_id", "context"]
@pytest.mark.asyncio
async def test_schema_with_no_input_schema(self):
- """Test with tools that have no inputSchema."""
- # Create a custom server with a tool that has no input schema
+ """A parameterless tool still gets a usable schema with context."""
mcp = FastMCP("test-server")
@mcp.tool()
@@ -99,67 +89,40 @@ def simple_tool():
"""A tool with no parameters."""
return "success"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- simple_tool_def = next(
- t for t in tools_result.tools if t.name == "simple_tool"
- )
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Verify inputSchema was created
- assert simple_tool_def.inputSchema is not None
- assert "properties" in simple_tool_def.inputSchema
- assert "context" in simple_tool_def.inputSchema["properties"]
- assert "required" in simple_tool_def.inputSchema
- assert "context" in simple_tool_def.inputSchema["required"]
+ simple = _named(await _tools(mcp), "simple_tool")
+ assert simple.inputSchema is not None
+ assert "context" in simple.inputSchema["properties"]
+ assert simple.inputSchema["required"] == ["context"]
@pytest.mark.asyncio
async def test_schema_with_empty_properties(self):
- """Test with tools that have empty properties object."""
+ """Context lands in an empty properties object."""
mcp = FastMCP("test-server")
- # Create a tool with function that has no parameters
@mcp.tool()
def empty_tool():
"""Tool with empty schema."""
return "success"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- empty_tool = next(t for t in tools_result.tools if t.name == "empty_tool")
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Verify context was added to empty properties
- assert "context" in empty_tool.inputSchema["properties"]
- assert len(empty_tool.inputSchema["properties"]) == 1
+ empty = _named(await _tools(mcp), "empty_tool")
+ assert list(empty.inputSchema["properties"]) == ["session_id", "context"]
@pytest.mark.asyncio
async def test_schema_with_existing_required_fields(self):
- """Test with tools that already have required fields."""
+ """A tool's own required fields survive, with context appended."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # add_todo has 'text' as required
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
-
- # Verify both original and context are required
- assert "text" in add_todo_tool.inputSchema["required"]
- assert "context" in add_todo_tool.inputSchema["required"]
- assert len(add_todo_tool.inputSchema["required"]) >= 2
+ add_todo = _named(await _tools(server), "add_todo")
+ assert add_todo.inputSchema["required"] == ["text", "context"]
@pytest.mark.asyncio
async def test_schema_with_no_required_fields(self):
- """Test with tools that have no required fields."""
+ """A tool with no required fields gains one holding only context."""
mcp = FastMCP("test-server")
@mcp.tool()
@@ -167,67 +130,62 @@ def optional_params_tool(param1: str = "default"):
"""Tool with optional parameters."""
return f"Result: {param1}"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- tool = next(
- t for t in tools_result.tools if t.name == "optional_params_tool"
- )
+ tool = _named(await _tools(mcp), "optional_params_tool")
+ assert tool.inputSchema["required"] == ["context"]
- # Verify required array was created with context
- assert "required" in tool.inputSchema
- assert "context" in tool.inputSchema["required"]
- assert len(tool.inputSchema["required"]) == 1
+ # With the context pass off there is nothing to require at all, so the
+ # tool's own (absent) required array is left absent.
+ untouched = FastMCP("test-server-2")
+
+ @untouched.tool()
+ def other_tool(param1: str = "default"):
+ """Tool with optional parameters."""
+ return f"Result: {param1}"
+
+ track(
+ untouched, "test_project", AgentCatOptions(enable_tool_call_context=False)
+ )
+ listed = _named(await _tools(untouched), "other_tool")
+ assert listed.inputSchema.get("required", []) == []
@pytest.mark.asyncio
async def test_server_with_no_tools(self):
- """Test with a server that has no tools."""
+ """A server with no tools lists only get_more_tools."""
mcp = FastMCP("empty-server")
- server = mcp
-
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Should have no tools (or only get_more_tools if enabled)
- non_report_tools = [
- t for t in tools_result.tools if t.name != "get_more_tools"
- ]
- assert len(non_report_tools) == 0
+ tools = await _tools(mcp)
+ assert [t.name for t in tools] == ["get_more_tools"]
@pytest.mark.asyncio
async def test_get_more_tools_exclusion_with_context(self):
- """Test that get_more_tools doesn't get context when both features are enabled."""
+ """get_more_tools keeps its own bespoke context, not the injected one."""
server = create_todo_server()
- options = AgentCatOptions(
- enable_report_missing=True, enable_tool_call_context=True
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(enable_report_missing=True, enable_tool_call_context=True),
)
- track(server, "test_project", options)
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
+ tools = await _tools(server)
+ get_more_tools = _named(tools, "get_more_tools")
+ context_schema = get_more_tools.inputSchema["properties"]["context"]
+ assert context_schema["description"] != DEFAULT_CONTEXT_DESCRIPTION
+ assert get_more_tools.inputSchema["required"] == ["context"]
- # Find get_more_tools tool
- get_more_tools_tool = next(
- t for t in tools_result.tools if t.name == "get_more_tools"
+ for tool in tools:
+ if tool.name == "get_more_tools":
+ continue
+ assert (
+ tool.inputSchema["properties"]["context"]["description"]
+ == DEFAULT_CONTEXT_DESCRIPTION
)
- # Verify it does NOT have context parameter
- assert "context" in get_more_tools_tool.inputSchema.get("properties", {})
-
- # Verify other tools DO have context
- other_tools = [t for t in tools_result.tools if t.name != "get_more_tools"]
- for tool in other_tools:
- assert "context" in tool.inputSchema["properties"]
-
@pytest.mark.asyncio
async def test_complex_nested_schema(self):
- """Test with tools that have complex nested schemas."""
+ """Complex nested parameters are preserved alongside the injection."""
mcp = FastMCP("test-server")
@mcp.tool()
@@ -237,67 +195,45 @@ def complex_tool(
"""Tool with complex nested parameters."""
return "success"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- tool = next(t for t in tools_result.tools if t.name == "complex_tool")
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Verify original complex properties are preserved
- assert "user" in tool.inputSchema["properties"]
- assert "settings" in tool.inputSchema["properties"]
- assert "tags" in tool.inputSchema["properties"]
-
- # Verify context was added
- assert "context" in tool.inputSchema["properties"]
+ tool = _named(await _tools(mcp), "complex_tool")
+ properties = tool.inputSchema["properties"]
+ assert {"user", "settings", "tags"} <= set(properties)
+ assert "context" in properties
@pytest.mark.asyncio
async def test_schema_with_validation_rules(self):
- """Test with tools that have schema validation rules."""
+ """Pydantic-derived constraints survive injection."""
from typing import Annotated
+
from pydantic import Field
mcp = FastMCP("test-server")
- # Create tool with validation rules using Pydantic
@mcp.tool()
def validated_tool(age: Annotated[int, Field(ge=0, le=150)], email: str):
"""Tool with validation."""
return "success"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- tool = next(t for t in tools_result.tools if t.name == "validated_tool")
-
- # Verify original properties are preserved (Pydantic translates to JSON Schema)
- assert "age" in tool.inputSchema["properties"]
- assert "email" in tool.inputSchema["properties"]
- # Pydantic Field validators are converted to JSON Schema constraints
- age_schema = tool.inputSchema["properties"]["age"]
- assert age_schema["type"] == "integer"
- # Check if Pydantic added the constraints (it may use exclusiveMinimum/Maximum)
- assert (
- age_schema.get("minimum") == 0
- or age_schema.get("exclusiveMinimum") == -1
- )
- assert (
- age_schema.get("maximum") == 150
- or age_schema.get("exclusiveMaximum") == 151
- )
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Verify context was added
- assert "context" in tool.inputSchema["properties"]
- assert "context" in tool.inputSchema["required"]
+ tool = _named(await _tools(mcp), "validated_tool")
+ age_schema = tool.inputSchema["properties"]["age"]
+ assert age_schema["type"] == "integer"
+ assert (
+ age_schema.get("minimum") == 0
+ or age_schema.get("exclusiveMinimum") == -1
+ )
+ assert (
+ age_schema.get("maximum") == 150
+ or age_schema.get("exclusiveMaximum") == 151
+ )
+ assert "context" in tool.inputSchema["properties"]
@pytest.mark.asyncio
async def test_tool_with_existing_context_parameter(self):
- """Test that existing context parameter is respected and not overwritten."""
+ """A tool that already declares `context` keeps its own, untouched."""
mcp = FastMCP("test-server")
@mcp.tool()
@@ -305,84 +241,78 @@ def tool_with_context(context: str, data: str):
"""Tool that already has a context parameter."""
return f"Original context: {context}, data: {data}"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- tool = next(t for t in tools_result.tools if t.name == "tool_with_context")
+ tool = _named(await _tools(mcp), "tool_with_context")
+ context_schema = tool.inputSchema["properties"]["context"]
+ assert context_schema.get("description") != DEFAULT_CONTEXT_DESCRIPTION
+ # Still the customer's own required parameter.
+ assert "context" in tool.inputSchema["required"]
- # Verify context exists
- assert "context" in tool.inputSchema["properties"]
-
- # Check if the context has been modified or kept original
- context_schema = tool.inputSchema["properties"]["context"]
- # If context already existed, implementation checks if "context" not in properties
- # So it should keep the original schema
- # Let's check if it has our custom description or the original
- desc = context_schema.get("description", "")
- if (
- desc
- == DEFAULT_CONTEXT_DESCRIPTION
- ):
- # Our description was added - this means the implementation overwrote it
- # This happens because the check is at the property level not parameter level
- pass
- else:
- # Original schema was kept - verify it has some content
- assert context_schema.get("type") == "string"
-
- # Should still be in required
- assert "context" in tool.inputSchema["required"]
+ # ...and it reaches the tool body, because AgentCat never injected it.
+ async with create_test_client(mcp) as client:
+ result = await client.call_tool(
+ "tool_with_context", {"context": "mine", "data": "d"}
+ )
+ assert "Original context: mine" in result.content[0].text
@pytest.mark.asyncio
async def test_schema_with_allof_anyof_oneof(self):
- """Test with tools that have allOf, anyOf, oneOf schema compositions."""
- from typing import Union
-
+ """Composition inside a property does not block injection."""
mcp = FastMCP("test-server")
- # Create tool with complex schema using Union type
@mcp.tool()
- def composed_tool(data: Union[str, int], required_field: str):
+ def composed_tool(data: str | int, required_field: str):
"""Tool with schema composition."""
return f"Data: {data}, Required: {required_field}"
- server = mcp
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(mcp, "test_project", AgentCatOptions(enable_tool_call_context=True))
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- tool = next(t for t in tools_result.tools if t.name == "composed_tool")
-
- # Verify original properties are preserved
- # Union types in Python are converted to anyOf in JSON Schema
- assert "data" in tool.inputSchema["properties"]
- assert "required_field" in tool.inputSchema["properties"]
+ tool = _named(await _tools(mcp), "composed_tool")
+ data_schema = tool.inputSchema["properties"]["data"]
+ assert (
+ "anyOf" in data_schema
+ or "oneOf" in data_schema
+ or data_schema.get("type") == ["string", "integer"]
+ )
+ assert "context" in tool.inputSchema["properties"]
+
+ @pytest.mark.asyncio
+ async def test_top_level_composed_schema_is_skipped(self):
+ """A oneOf/allOf/anyOf schema has no single properties bag to extend."""
+ from mcp.server.lowlevel import Server
+ from mcp.types import Tool
+
+ server = Server("composed-server")
+
+ @server.list_tools()
+ async def list_tools():
+ return [
+ Tool(
+ name="composed_root",
+ description="Top-level composition.",
+ inputSchema={
+ "anyOf": [
+ {"type": "object", "properties": {"a": {"type": "string"}}},
+ {"type": "object", "properties": {"b": {"type": "string"}}},
+ ]
+ },
+ )
+ ]
- # Check if Union was converted properly (might be anyOf or oneOf)
- data_schema = tool.inputSchema["properties"]["data"]
- # FastMCP may handle Union differently, just verify it accepts multiple types
- assert (
- "anyOf" in data_schema
- or "oneOf" in data_schema
- or data_schema.get("type") == ["string", "integer"]
- )
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
- # Verify context was added
- assert "context" in tool.inputSchema["properties"]
+ tool = _named(await _tools(server), "composed_root")
+ assert "properties" not in tool.inputSchema
+ assert "anyOf" in tool.inputSchema
@pytest.mark.asyncio
async def test_tool_call_with_valid_context(self):
- """Test calling a tool with valid context parameter."""
+ """Calling a tool with a context argument succeeds."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
async with create_test_client(server) as client:
- # Call tool with context
result = await client.call_tool(
"add_todo",
{
@@ -391,111 +321,56 @@ async def test_tool_call_with_valid_context(self):
},
)
- # Should succeed
- assert result.content
- assert "Added todo" in result.content[0].text
+ assert "Added todo" in result.content[0].text
@pytest.mark.asyncio
- async def test_tool_call_without_context_fails(self):
- """Test that tool calls without context fail validation."""
+ async def test_tool_call_without_context_still_succeeds(self):
+ """Context is optional: omitting it must not fail the call."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
async with create_test_client(server) as client:
- # The implementation strips context before passing to handler
- # So this test verifies the behavior is logged but call still works
- result = await client.call_tool(
- "add_todo",
- {"text": "Test todo item"}, # Missing context
- )
-
- # The call should succeed because context is stripped before passing to handler
- assert result.content
- assert "Added todo" in result.content[0].text
+ result = await client.call_tool("add_todo", {"text": "Test todo item"})
- @pytest.mark.asyncio
- async def test_tool_call_with_empty_context(self):
- """Test calling a tool with empty string context."""
- server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- # Call with empty context - should still work
- result = await client.call_tool(
- "add_todo",
- {
- "text": "Test todo",
- "context": "", # Empty but present
- },
- )
-
- assert result.content
- assert "Added todo" in result.content[0].text
-
- @pytest.mark.asyncio
- async def test_tool_call_with_long_context(self):
- """Test calling a tool with very long context string."""
- server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- # Create a very long context
- long_context = "This is a very long context. " * 100
-
- result = await client.call_tool(
- "add_todo", {"text": "Test todo", "context": long_context}
- )
-
- assert result.content
- assert "Added todo" in result.content[0].text
+ assert "Added todo" in result.content[0].text
@pytest.mark.asyncio
- async def test_tool_call_with_unicode_context(self):
- """Test calling a tool with special characters/unicode in context."""
- server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- # Unicode context
- unicode_context = "Testing with emojis 🚀🎉 and special chars: ñáéíóú"
-
- result = await client.call_tool(
- "add_todo", {"text": "Test todo", "context": unicode_context}
- )
-
- assert result.content
- assert "Added todo" in result.content[0].text
+ @pytest.mark.parametrize(
+ "context",
+ [
+ "",
+ None,
+ "This is a very long context. " * 100,
+ "Testing with emojis 🚀🎉 and special chars: ñáéíóú",
+ ],
+ ids=["empty", "null", "long", "unicode"],
+ )
+ async def test_tool_call_with_edge_case_context(self, context):
+ """Every context shape is stripped before the tool body sees it.
- @pytest.mark.asyncio
- async def test_tool_call_with_null_context(self):
- """Test calling a tool with null/None context value."""
+ Read at the tool manager: `add_todo(text: str)` is a typed body, and
+ this SDK's manager drops an undeclared argument silently, so "the call
+ succeeded" is not evidence that `context` was removed.
+ """
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
async with create_test_client(server) as client:
- # Try with None/null context - it should work since context is stripped
result = await client.call_tool(
- "add_todo", {"text": "Test todo", "context": None}
+ "add_todo", {"text": "Test todo", "context": context}
)
- # Should succeed because context is stripped
- assert result.content
- assert "Added todo" in result.content[0].text
+ assert result.isError is False
+ assert "Added todo" in result.content[0].text
+ assert delivered_arguments_for(server, "add_todo") == [{"text": "Test todo"}]
@pytest.mark.asyncio
async def test_original_functionality_preserved(self):
- """Verify that original tool functionality remains intact with context."""
+ """A whole tool workflow behaves identically under tracking."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
async with create_test_client(server) as client:
- # Add multiple todos
await client.call_tool(
"add_todo", {"text": "First todo", "context": "Adding first item"}
)
@@ -503,218 +378,119 @@ async def test_original_functionality_preserved(self):
"add_todo", {"text": "Second todo", "context": "Adding second item"}
)
- # List todos
list_result = await client.call_tool(
"list_todos", {"context": "Listing all todos to verify they were added"}
)
-
- # Verify both todos are present
assert "First todo" in list_result.content[0].text
assert "Second todo" in list_result.content[0].text
- # Complete a todo
complete_result = await client.call_tool(
"complete_todo", {"id": 1, "context": "Completing the first todo"}
)
-
assert "Completed todo" in complete_result.content[0].text
@pytest.mark.asyncio
async def test_context_not_passed_to_original_handler(self):
- """Verify that context parameter is stripped before passing to original handler."""
- # This test verifies the current implementation behavior
- # Context is added to schema but stripped from arguments before passing to handler
+ """`context` is gone from what the tool layer is handed.
+
+ A successful call proves nothing on its own — the tool manager drops
+ arguments `add_todo` never declared without raising — so the evidence
+ is the argument dict the manager itself received.
+ """
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
async with create_test_client(server) as client:
- # Call with context
result = await client.call_tool(
"add_todo",
{"text": "test data", "context": "This context should be stripped"},
)
- # The call should succeed, proving context was stripped
- # (otherwise it would fail since add_todo doesn't accept context param)
- assert result.content
- assert "Added todo" in result.content[0].text
+ assert result.isError is False
+ assert "Added todo" in result.content[0].text
+ assert delivered_arguments_for(server, "add_todo") == [{"text": "test data"}]
@pytest.mark.asyncio
async def test_multiple_track_calls(self):
- """Test multiple calls to track() on the same server."""
+ """The most recent track() call's options are the ones in force."""
server = create_todo_server()
+ track(server, "project1", AgentCatOptions(enable_tool_call_context=False))
+ track(server, "project2", AgentCatOptions(enable_tool_call_context=True))
- # First track with context disabled
- options1 = AgentCatOptions(enable_tool_call_context=False)
- track(server, "project1", options1)
-
- # Second track with context enabled
- options2 = AgentCatOptions(enable_tool_call_context=True)
- track(server, "project2", options2)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Should reflect the latest tracking options
- for tool in tools_result.tools:
- if tool.name != "get_more_tools":
- assert "context" in tool.inputSchema["properties"]
+ for tool in await _tools(server):
+ if tool.name != "get_more_tools":
+ assert "context" in tool.inputSchema["properties"]
@pytest.mark.asyncio
async def test_changing_options_between_calls(self):
- """Test changing options between track calls."""
+ """Re-tracking with context off removes it — v1 could not undo an injection."""
server = create_todo_server()
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
+ assert "context" in _named(await _tools(server), "add_todo").inputSchema[
+ "properties"
+ ]
- # Track with context enabled
- options_enabled = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options_enabled)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- # Verify context is added
- add_todo = next(t for t in tools_result.tools if t.name == "add_todo")
- assert "context" in add_todo.inputSchema["properties"]
-
- # The current implementation updates the tracking data with new options
- # Track again with context disabled
- options_disabled = AgentCatOptions(enable_tool_call_context=False)
- track(server, "test_project", options_disabled)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- add_todo = next(t for t in tools_result.tools if t.name == "add_todo")
- # The handlers get wrapped multiple times, so the behavior is:
- # - First handler adds context (from first track call)
- # - Second handler checks options and doesn't add context
- # But the first handler already added it, so context will still be present
- # This is the current implementation behavior
- assert "context" in add_todo.inputSchema["properties"]
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=False))
+ assert (
+ "context"
+ not in _named(await _tools(server), "add_todo").inputSchema["properties"]
+ )
@pytest.mark.asyncio
async def test_error_handling_graceful_fallback(self):
- """Test that errors in context injection don't break original tools."""
- # This test would require mocking internal functions to force errors
- # For now, we'll test that the system is resilient
+ """Tools remain listable and callable regardless."""
server = create_todo_server()
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
async with create_test_client(server) as client:
- # Even if there were errors, tools should still be callable
tools_result = await client.list_tools()
assert len(tools_result.tools) > 0
-
- # Original functionality should work
- result = await client.call_tool(
- "list_todos",
- {"context": "Listing todos"}, # Try with context
- )
+ result = await client.call_tool("list_todos", {"context": "Listing todos"})
assert result.content
@pytest.mark.asyncio
- async def test_custom_context_description(self):
- """Test that custom context description is correctly applied."""
- server = create_todo_server()
- custom_description = "Explain your reasoning for using this tool"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_description
- )
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Check each tool (except get_more_tools)
- for tool in tools_result.tools:
- if tool.name == "get_more_tools":
- continue
-
- # Verify context parameter has custom description
- context_schema = tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == custom_description
-
- @pytest.mark.asyncio
- async def test_custom_context_description_empty_string(self):
- """Test edge case with empty string custom description."""
- server = create_todo_server()
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description="" # Empty string
- )
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Find a tool to test
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
-
- # Verify context exists with empty description
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == ""
- assert context_schema["type"] == "string"
-
- @pytest.mark.asyncio
- async def test_custom_context_description_special_characters(self):
- """Test custom description with special characters and Unicode."""
- server = create_todo_server()
- special_description = "Why are you using this? 🤔 Include: quotes\"', newlines\n, tabs\t, etc."
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=special_description
- )
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Verify special characters are preserved
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == special_description
-
- @pytest.mark.asyncio
- async def test_custom_context_description_very_long(self):
- """Test with a very long description string."""
+ @pytest.mark.parametrize(
+ "description",
+ [
+ "Explain your reasoning for using this tool",
+ "",
+ "Why are you using this? 🤔 Include: quotes\"', newlines\n, tabs\t, etc.",
+ "This is a very detailed description. " * 50,
+ ],
+ ids=["custom", "empty", "special-characters", "very-long"],
+ )
+ async def test_custom_context_description(self, description):
+ """Whatever the customer sets is what the agent sees, verbatim."""
server = create_todo_server()
- # Create a very long description
- long_description = "This is a very detailed description. " * 50 # ~1800 characters
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=long_description
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(
+ enable_tool_call_context=True, custom_context_description=description
+ ),
)
- track(server, "test_project", options)
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Verify long description is preserved
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == long_description
- assert len(context_schema["description"]) > 1000
+ for tool in await _tools(server):
+ if tool.name == "get_more_tools":
+ continue
+ context_schema = tool.inputSchema["properties"]["context"]
+ assert context_schema["description"] == description
@pytest.mark.asyncio
async def test_default_context_description(self):
- """Verify the default description is used when not specified."""
+ """The default description is used when none is specified."""
server = create_todo_server()
- # Don't specify custom_context_description, should use default
- options = AgentCatOptions(enable_tool_call_context=True)
- track(server, "test_project", options)
+ track(server, "test_project", AgentCatOptions(enable_tool_call_context=True))
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Check for default description
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- assert context_schema["description"] == DEFAULT_CONTEXT_DESCRIPTION
+ add_todo = _named(await _tools(server), "add_todo")
+ assert (
+ add_todo.inputSchema["properties"]["context"]["description"]
+ == DEFAULT_CONTEXT_DESCRIPTION
+ )
@pytest.mark.asyncio
async def test_custom_context_description_with_multiple_tools(self):
- """Test that custom description is applied to all tools consistently."""
+ """One description, applied consistently across every tool."""
mcp = FastMCP("test-server")
@mcp.tool()
@@ -732,166 +508,117 @@ def tool3():
"""Third tool with no params."""
return "Tool 3"
- server = mcp
custom_desc = "Custom context for all tools"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_desc
+ track(
+ mcp,
+ "test_project",
+ AgentCatOptions(
+ enable_tool_call_context=True, custom_context_description=custom_desc
+ ),
)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
- # All tools should have the same custom context description
- for tool in tools_result.tools:
- if tool.name in ["tool1", "tool2", "tool3"]:
- assert "context" in tool.inputSchema["properties"]
- assert tool.inputSchema["properties"]["context"]["description"] == custom_desc
+ for tool in await _tools(mcp):
+ if tool.name in ("tool1", "tool2", "tool3"):
+ assert (
+ tool.inputSchema["properties"]["context"]["description"]
+ == custom_desc
+ )
@pytest.mark.asyncio
async def test_custom_context_description_change_between_tracks(self):
- """Test changing custom description between track calls."""
+ """The latest track() wins outright."""
server = create_todo_server()
-
- # First track with one description
- options1 = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description="First description"
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(
+ enable_tool_call_context=True,
+ custom_context_description="First description",
+ ),
)
- track(server, "test_project", options1)
-
- # Second track with different description
- options2 = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description="Second description"
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(
+ enable_tool_call_context=True,
+ custom_context_description="Second description",
+ ),
)
- track(server, "test_project", options2)
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- # Should use the most recent description
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
- context_schema = add_todo_tool.inputSchema["properties"]["context"]
- # Due to wrapping behavior, the first track's description might persist
- # This test documents the actual behavior
- assert context_schema["description"] in ["First description", "Second description"]
+ add_todo = _named(await _tools(server), "add_todo")
+ assert (
+ add_todo.inputSchema["properties"]["context"]["description"]
+ == "Second description"
+ )
@pytest.mark.asyncio
async def test_custom_context_with_tool_call(self):
- """Test tool calls work correctly with custom context description."""
- server = create_todo_server()
+ """A custom description does not change call behavior."""
custom_desc = "Provide detailed reasoning for this action"
- options = AgentCatOptions(
- enable_tool_call_context=True,
- custom_context_description=custom_desc
+ server = create_todo_server()
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(
+ enable_tool_call_context=True, custom_context_description=custom_desc
+ ),
)
- track(server, "test_project", options)
async with create_test_client(server) as client:
- # Verify the custom description is set
tools_result = await client.list_tools()
- add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo")
- assert add_todo_tool.inputSchema["properties"]["context"]["description"] == custom_desc
+ add_todo = _named(tools_result.tools, "add_todo")
+ assert (
+ add_todo.inputSchema["properties"]["context"]["description"]
+ == custom_desc
+ )
- # Call the tool with context
result = await client.call_tool(
"add_todo",
{
"text": "Test with custom description",
- "context": "Adding todo to test custom context description feature"
- }
+ "context": "Adding todo to test custom context description feature",
+ },
)
-
- # Should succeed
- assert result.content
assert "Added todo" in result.content[0].text
class TestGetMoreToolsContextSchema:
"""Test that get_more_tools has a proper context parameter schema."""
- @pytest.mark.asyncio
- async def test_get_more_tools_context_has_string_type(self):
- """get_more_tools context parameter should have type 'string', not a union type."""
+ @pytest.fixture
+ async def get_more_tools_def(self):
server = create_todo_server()
- options = AgentCatOptions(
- enable_report_missing=True,
- enable_tool_call_context=True,
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(enable_report_missing=True, enable_tool_call_context=True),
)
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- get_more_tools_tool = next(
- t for t in tools_result.tools if t.name == "get_more_tools"
- )
-
- context_schema = get_more_tools_tool.inputSchema["properties"]["context"]
- # Context should be a simple string type, not a union/anyOf
- assert "anyOf" not in context_schema, (
- f"get_more_tools context should not use anyOf (union type), got: {context_schema}"
- )
- assert context_schema.get("type") == "string", (
- f"get_more_tools context should be type 'string', got: {context_schema}"
- )
+ return _named(await _tools(server), "get_more_tools")
@pytest.mark.asyncio
- async def test_get_more_tools_context_has_description(self):
- """get_more_tools context parameter should have a meaningful description."""
- server = create_todo_server()
- options = AgentCatOptions(
- enable_report_missing=True,
- enable_tool_call_context=True,
- )
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
-
- get_more_tools_tool = next(
- t for t in tools_result.tools if t.name == "get_more_tools"
- )
-
- context_schema = get_more_tools_tool.inputSchema["properties"]["context"]
- assert "description" in context_schema, (
- "get_more_tools context parameter should have a description"
- )
- assert len(context_schema["description"]) > 10, (
- "get_more_tools context description should be meaningful"
- )
+ async def test_get_more_tools_context_has_string_type(self, get_more_tools_def):
+ """It should be a simple string, not a union type."""
+ context_schema = get_more_tools_def.inputSchema["properties"]["context"]
+ assert "anyOf" not in context_schema, context_schema
+ assert context_schema.get("type") == "string", context_schema
@pytest.mark.asyncio
- async def test_get_more_tools_context_is_required(self):
- """get_more_tools context parameter should be required."""
- server = create_todo_server()
- options = AgentCatOptions(
- enable_report_missing=True,
- enable_tool_call_context=True,
- )
- track(server, "test_project", options)
-
- async with create_test_client(server) as client:
- tools_result = await client.list_tools()
+ async def test_get_more_tools_context_has_description(self, get_more_tools_def):
+ """It should carry a meaningful description."""
+ context_schema = get_more_tools_def.inputSchema["properties"]["context"]
+ assert len(context_schema["description"]) > 10
- get_more_tools_tool = next(
- t for t in tools_result.tools if t.name == "get_more_tools"
- )
-
- required = get_more_tools_tool.inputSchema.get("required", [])
- assert "context" in required, (
- "get_more_tools context parameter should be required"
- )
+ @pytest.mark.asyncio
+ async def test_get_more_tools_context_is_required(self, get_more_tools_def):
+ """Its bespoke context is a real parameter, so it stays required."""
+ assert "context" in get_more_tools_def.inputSchema.get("required", [])
class TestUserIntentCaptureInEvents:
- """Test that user_intent is captured in events for the official FastMCP monkey patching."""
+ """user_intent is captured from the context argument on published events."""
@pytest.fixture(autouse=True)
def setup_and_teardown(self):
- """Set up and tear down mock event queue."""
from agentcat.modules.event_queue import event_queue as original_queue
yield
@@ -899,31 +626,29 @@ def setup_and_teardown(self):
@pytest.mark.asyncio
async def test_get_more_tools_captures_user_intent_in_event(self):
- """get_more_tools calls should capture user_intent from context in the event."""
mock_api_client = MagicMock()
captured_events = []
-
- def capture_event(publish_event_request):
- captured_events.append(publish_event_request)
-
- mock_api_client.publish_event = MagicMock(side_effect=capture_event)
-
- test_queue = EventQueue(api_client=mock_api_client)
- set_event_queue(test_queue)
+ mock_api_client.publish_event = MagicMock(
+ side_effect=lambda publish_event_request, **kwargs: captured_events.append(
+ publish_event_request
+ )
+ )
+ set_event_queue(EventQueue(api_client=mock_api_client))
server = create_todo_server()
- options = AgentCatOptions(
- enable_tracing=True,
- enable_report_missing=True,
- enable_tool_call_context=True,
+ track(
+ server,
+ "test_project",
+ AgentCatOptions(
+ enable_tracing=True,
+ enable_report_missing=True,
+ enable_tool_call_context=True,
+ ),
)
- track(server, "test_project", options)
async with create_test_client(server) as client:
- # Call get_more_tools with context
await client.call_tool(
- "get_more_tools",
- {"context": "I need a tool to send emails"},
+ "get_more_tools", {"context": "I need a tool to send emails"}
)
time.sleep(1.0)
@@ -933,9 +658,5 @@ def capture_event(publish_event_request):
if e.event_type == "mcp:tools/call"
and e.resource_name == "get_more_tools"
]
- assert len(tool_events) > 0, "No get_more_tools event captured"
-
- event = tool_events[0]
- assert event.user_intent == "I need a tool to send emails", (
- f"user_intent should be captured from get_more_tools context, got: {event.user_intent}"
- )
+ assert len(tool_events) == 1
+ assert tool_events[0].user_intent == "I need a tool to send emails"
diff --git a/tests/test_truncation.py b/tests/test_truncation.py
index 324b104..84bc3b3 100644
--- a/tests/test_truncation.py
+++ b/tests/test_truncation.py
@@ -5,6 +5,8 @@
import pytest
+from .conftest import MCP_MAJOR
+
from agentcat import AgentCatOptions, track
from agentcat.modules.event_queue import EventQueue, set_event_queue
from agentcat.modules.truncation import (
@@ -291,6 +293,10 @@ def test_truncation_is_imported_in_event_queue(self):
assert "truncate_event" in source
+@pytest.mark.skipif(
+ MCP_MAJOR >= 2,
+ reason="uses the mcp 1.x in-memory client harness; ported in Task 12",
+)
class TestTruncationWithTodoServer:
"""Integration tests: oversized tool calls through the real todo server are truncated."""
@@ -304,7 +310,7 @@ def _capture_setup(self):
mock_api_client = MagicMock()
captured_events = []
- def capture_event(publish_event_request):
+ def capture_event(publish_event_request, **kwargs):
captured_events.append(publish_event_request)
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
diff --git a/tests/test_truncation_json_safe.py b/tests/test_truncation_json_safe.py
index cd9a2fe..fd28a4d 100644
--- a/tests/test_truncation_json_safe.py
+++ b/tests/test_truncation_json_safe.py
@@ -16,12 +16,12 @@
def _poison_response():
- # Mirrors what tool.model_dump() embeds for FastMCP v3 tools: a callable and a set.
+ # Mirrors what a FastMCP v3 tool's model_dump() embeds: a callable and a set.
return {"tools": [{"name": "t", "fn": (lambda: 1), "tags": {"a", "b"}}]}
def test_truncate_event_does_not_log_failure_on_callable_and_set():
- event = UnredactedEvent(event_type="mcp:tools/list", response=_poison_response())
+ event = UnredactedEvent(event_type="mcp:tools/call", response=_poison_response())
with patch.object(truncation, "write_to_log") as mock_log:
result = truncate_event(event)
failures = [
@@ -35,7 +35,7 @@ def test_truncate_event_does_not_log_failure_on_callable_and_set():
def test_truncated_event_is_json_serializable():
"""After truncation the event's payload must contain only JSON-safe primitives."""
- event = UnredactedEvent(event_type="mcp:tools/list", response=_poison_response())
+ event = UnredactedEvent(event_type="mcp:tools/call", response=_poison_response())
result = truncate_event(event)
# The event the API client re-serializes on send must not carry a callable/set.
json.dumps(result.response) # would raise if a set/function survived
diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py
index 7166fb8..e1e1162 100644
--- a/tests/test_utils/__init__.py
+++ b/tests/test_utils/__init__.py
@@ -1,12 +1,193 @@
"""Test utilities for AgentCat tests."""
+import functools
import os
+from importlib.metadata import version
from pathlib import Path
+from typing import Any
import pytest
LOG_FILE = "agentcat.log"
+MCP_VERSION = tuple(int(p) for p in version("mcp").split(".")[:3] if p.isdigit())
+MCP_MAJOR = MCP_VERSION[0]
+
+# For the integration tests inside otherwise era-agnostic modules. `conftest.py`
+# gates whole FILES by era; a module that mixes plain unit tests with a class
+# built on `create_todo_server()` / `create_test_client()` (both mcp 1.x-only)
+# would lose its unit tests to that gate, so it marks just the class instead.
+LEGACY_ONLY = pytest.mark.skipif(
+ MCP_MAJOR >= 2,
+ reason="built on the mcp 1.x FastMCP + in-memory client harness",
+)
+
+# The other half of the same gate, for a module whose eras belong side by side:
+# `test_inner_tap.py` proves one contract on every generation, so splitting it
+# across the two conftest-gated trees would hide the parity it exists to show.
+MODERN_ONLY = pytest.mark.skipif(
+ MCP_MAJOR < 2,
+ reason="built on the mcp 2.x MCPServer + in-process Client harness",
+)
+
+# ── Upstream capability gates ────────────────────────────────────────────────
+# Both of these were added by mcp 1.10.0 and both are probed on the capability
+# rather than compared against a version, so a backport would be honoured and
+# the probe cannot drift from what the test actually needs.
+#
+# The rest of the old-mcp work reaches past era-specific spellings, because the
+# thing wanted was there under another name. These two are different: the seam
+# is absent, so there is nothing to reach for. AgentCat still runs below them —
+# it degrades to the surfaced message with no exception type, and mirrors no
+# structured mint-back — which is why these gate tests rather than the package.
+
+# `Server._make_error_result` is the seam `modules/adapters/_inner_tap.py`
+# hooks to recover a handler's real exception before the SDK folds it into an
+# `isError` result. mcp 1.10.0 introduced it (PR #1005, "Add schema validation
+# to lowlevel server"), together with the lowlevel input validation whose
+# message that same PR surfaces.
+try:
+ from mcp.server.lowlevel import Server as _LowlevelServer
+
+ HAS_LOWLEVEL_ERROR_SEAM = hasattr(_LowlevelServer, "_make_error_result")
+except Exception: # pragma: no cover - import guard
+ HAS_LOWLEVEL_ERROR_SEAM = False
+
+NEEDS_LOWLEVEL_ERROR_SEAM = pytest.mark.skipif(
+ not HAS_LOWLEVEL_ERROR_SEAM,
+ reason=(
+ "needs Server._make_error_result (mcp>=1.10) — below it the lowlevel "
+ "server catches the handler's exception inline and the tap has no seam"
+ ),
+)
+
+# Structured tool output as DECLARED fields — `Tool.outputSchema`,
+# `CallToolResult.structuredContent`, and `mcp.server.fastmcp` deriving the
+# schema from a return annotation — also arrived in mcp 1.10.0.
+try:
+ import mcp.types as _mcp_types
+
+ HAS_STRUCTURED_OUTPUT = "outputSchema" in _mcp_types.Tool.model_fields
+except Exception: # pragma: no cover - import guard
+ HAS_STRUCTURED_OUTPUT = False
+
+NEEDS_STRUCTURED_OUTPUT = pytest.mark.skipif(
+ not HAS_STRUCTURED_OUTPUT,
+ reason="needs declared structured tool output (mcp>=1.10)",
+)
+
+# mcp 1.2.x awaits each incoming message before reading the next, so a server
+# never has two tool calls in flight; "Made message handling concurrent"
+# (da53a97e) landed in v1.3.0. Any proof built on simultaneous calls therefore
+# cannot be satisfied on 1.2 no matter what AgentCat does — measured: peak
+# in-flight is 1 of 5 on 1.2.1 and 5 of 5 on 1.9.4.
+#
+# A version compare rather than a probe, unusually: the capability is a
+# scheduling property of the session loop with no symbol to inspect, and
+# actually measuring it would mean booting a server at import time.
+NEEDS_CONCURRENT_DISPATCH = pytest.mark.skipif(
+ MCP_VERSION < (1, 3),
+ reason="mcp<1.3 handles messages serially; simultaneous calls never overlap",
+)
+
+
+# Whether the installed community FastMCP models an error result at all.
+# `ToolResult.is_error` arrived in fastmcp 3.4 (PR #4217); below it a plain
+# `ToolResult` dumps three fields and carries no error key in either spelling.
+# Probed on the model, so it cannot drift from what the assertions need.
+try:
+ from fastmcp.tools import ToolResult as _ToolResult
+
+ FASTMCP_TOOLRESULT_HAS_IS_ERROR = "is_error" in _ToolResult.model_fields
+except Exception: # pragma: no cover - community extra not installed
+ FASTMCP_TOOLRESULT_HAS_IS_ERROR = False
+
+
+@functools.lru_cache(maxsize=1)
+def _error_tool_result_class() -> Any:
+ """Built on first use: subclassing needs `ToolResult` to exist, which it
+ does not on a leg with no community FastMCP installed."""
+ from fastmcp.tools import ToolResult
+ from mcp.types import CallToolResult
+
+ class ErrorToolResult(ToolResult): # type: ignore[misc]
+ """A tool result that reports an error WITHOUT anything having raised.
+
+ FastMCP 3.4 added `ToolResult.is_error` and taught `to_mcp_result` to
+ answer with a `CallToolResult` carrying `isError` (PR #4217). Below
+ that the field does not exist and `to_mcp_result` has no branch that
+ can set `isError` at all, so the scenario is inexpressible with the
+ stock model — yet it is exactly what an error-handling middleware, and
+ FastMCP's own proxy provider, produce.
+
+ Both halves are supplied because both are read: the adapter takes
+ `getattr(result, "is_error", False)` off the OBJECT
+ (`adapters/community.py`), while the client sees only the WIRE. A
+ double that set one and not the other would pass one assertion and
+ quietly fail the other.
+
+ `__init__` is an explicit signature on the 3.x line with no `**kwargs`,
+ so the redeclared field cannot arrive through it — hence the wrapper.
+ On 3.4+ this subclass is a no-op that defers to `super()`.
+ """
+
+ is_error: bool = False
+
+ def __init__(self, *args: Any, is_error: bool = False, **kwargs: Any):
+ super().__init__(*args, **kwargs)
+ self.is_error = is_error
+
+ def to_mcp_result(self) -> Any:
+ if not self.is_error:
+ return super().to_mcp_result()
+ return CallToolResult(
+ content=self.content,
+ structuredContent=self.structured_content,
+ isError=True,
+ _meta=self.meta,
+ )
+
+ return ErrorToolResult
+
+
+def read_only_hint(tool: Any) -> Any:
+ """`readOnlyHint` off a tool, whether or not this SDK models annotations.
+
+ AgentCat hands `annotations` over as a plain mapping so it never has to
+ import a type whose availability moved between generations. From mcp 1.7
+ the `Tool.annotations` field exists and pydantic coerces the mapping into
+ the model; below it there is no such field and the mapping is carried
+ verbatim as an extra. Both spellings mean the same thing to a client.
+ """
+ annotations = getattr(tool, "annotations", None)
+ if annotations is None:
+ return None
+ if isinstance(annotations, dict):
+ return annotations.get("readOnlyHint", annotations.get("read_only_hint"))
+ return getattr(
+ annotations, "readOnlyHint", getattr(annotations, "read_only_hint", None)
+ )
+
+
+def error_tool_result(**kwargs: Any) -> Any:
+ """An `is_error` tool result on every community FastMCP 3.x and 4.x.
+
+ Use instead of `ToolResult(..., is_error=True)`, which is a TypeError
+ below fastmcp 3.4.
+ """
+ return _error_tool_result_class()(**kwargs)
+
+
+def sid(label: str) -> str:
+ """A valid 27-char session ID that still reads as its label in failures.
+
+ `resolve_handles` only honors IDs shaped like the ones this SDK issues, so
+ a fixture cannot be `"ses_parent"` any more. Real KSUIDs are opaque; test
+ fixtures should not be, hence the label survives in the body.
+ """
+ body = ("".join(c for c in label if c.isalnum()) + "0" * 27)[:27]
+ return f"ses_{body}"
+
def cleanup_log_file():
"""Remove the log file if it exists."""
diff --git a/tests/test_utils/client.py b/tests/test_utils/client.py
index ad2f13e..f4f136f 100644
--- a/tests/test_utils/client.py
+++ b/tests/test_utils/client.py
@@ -1,10 +1,19 @@
-"""Test client utilities for AgentCat tests."""
+"""Test client utilities for AgentCat tests (official MCP SDK 1.x).
+
+Import-safe under mcp 2.x, which removed both symbols below; see the note in
+`todo_server.py`. `test_utils.modern_server.create_modern_client` is the 2.x
+counterpart.
+"""
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator
from mcp import ClientSession
-from mcp.shared.memory import create_connected_server_and_client_session
+
+try:
+ from mcp.shared.memory import create_connected_server_and_client_session
+except ImportError: # mcp 2.x
+ create_connected_server_and_client_session = None
try:
from mcp.server import FastMCP
@@ -32,6 +41,12 @@ async def create_test_client(server: Any) -> AsyncGenerator[ClientSession, None]
async with create_test_client(server) as client:
result = await client.call_tool("add_todo", {"text": "Test"})
"""
+ if create_connected_server_and_client_session is None:
+ raise ImportError(
+ "mcp.shared.memory.create_connected_server_and_client_session was "
+ "removed in mcp 2.x. Use test_utils.modern_server.create_modern_client."
+ )
+
# MCP v1.2.0 doesn't support client_info parameter
# Default client name is "mcp" and version is "0.1.0"
diff --git a/tests/test_utils/community_openapi_server.py b/tests/test_utils/community_openapi_server.py
index 4f2e581..d18df45 100644
--- a/tests/test_utils/community_openapi_server.py
+++ b/tests/test_utils/community_openapi_server.py
@@ -1,4 +1,4 @@
-"""Factories for FastMCP v3 servers whose tools hold live runtime state.
+"""Factories for community FastMCP (v3/v4) servers whose tools hold live state.
These build the classes of tool that carry non-deepcopyable runtime state and
therefore broke context injection before PR #38:
@@ -20,13 +20,28 @@
# Lazy, guarded imports so importing this module never fails the no-FastMCP CI job
# or the FastMCP v2 compatibility matrix. Callers gate on HAS_FASTMCP_V3.
+#
+# The HTTP client has to be the one the INSTALLED FastMCP builds its OpenAPI
+# provider on — v3 takes an `httpx.AsyncClient`, v4 an `httpx2.AsyncClient` —
+# so the major picks it. Falling back on ImportError instead would be right only
+# for as long as the unwanted package stays absent: `httpx` is a common
+# transitive dependency, and the day it lands beside FastMCP 4 a "try httpx
+# first" guard would hand the v4 provider a client library it does not use, with
+# no error to say so. Both packages expose the same `AsyncClient` /
+# `MockTransport` / `Response` surface this module needs.
try:
- import httpx
import fastmcp
+
+ _FASTMCP_MAJOR = int(fastmcp.__version__.split(".")[0])
+ if _FASTMCP_MAJOR >= 4:
+ import httpx2 as httpx # type: ignore[no-redef]
+ else:
+ import httpx
+
from fastmcp import FastMCP as CommunityFastMCP
from fastmcp.server.providers.openapi import MCPType, RouteMap
- HAS_FASTMCP_V3 = int(fastmcp.__version__.split(".")[0]) >= 3
+ HAS_FASTMCP_V3 = _FASTMCP_MAJOR >= 3
except Exception: # pragma: no cover - import guard
httpx = None # type: ignore
CommunityFastMCP = None # type: ignore
diff --git a/tests/test_utils/delivery.py b/tests/test_utils/delivery.py
new file mode 100644
index 0000000..13cfd07
--- /dev/null
+++ b/tests/test_utils/delivery.py
@@ -0,0 +1,73 @@
+"""Observe what a facade's tool manager was actually handed.
+
+The one seam on the official facades where a strip regression is visible.
+
+A typed tool body cannot witness the strip. `def add_todo(text: str)` can only
+ever report `text` — an argument that arrived and was dropped on the way in
+leaves no trace in the body, so `seen["text"] = text` inside the function is
+the same value whether or not AgentCat stripped anything.
+
+And both official tool managers DO drop an undeclared argument silently.
+Measured on mcp 1.29 (`mcp.server.fastmcp.FastMCP`) and mcp 2.0 (`MCPServer`):
+``call_tool("add_todo", {"text": "hi", "session_id": "ses_X"})`` returns the
+tool's normal result with no error. Only community FastMCP raises. So a test
+that sends an extra parameter to a typed body and asserts "no error" proves
+nothing about the strip — it passes identically with the strip disabled.
+
+Wrapping the manager makes the delivered dict observable. Both eras spell the
+seam identically (``call_tool(name, arguments, ...)``), and the wrapper is
+installed by the server factories BEFORE ``track()``, so the adapter's inner
+tap wraps the same attribute afterwards and sits above this recorder — what it
+records is therefore post-strip delivery.
+
+**Either order works, so do not "fix" a caller that installs this AFTER
+``track()``** (`test_dynamic_tracking.py` does). The strip does not happen at
+the tool manager at all: it runs at the lowlevel request-handler seam
+(`modules/callpath.py`), which is above the manager on every flavor. So the
+arguments reaching `call_tool` are already stripped no matter where this
+wrapper sits in the manager's own decorator stack. The before-``track()``
+ordering is a convention for the shared factories, not a correctness
+requirement.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+# Where each factory parks its recording, so tests have one name to read.
+DELIVERED_ATTR = "delivered_arguments"
+
+
+def record_delivered_arguments(
+ manager: Any, seen: list[tuple[str, dict[str, Any]]]
+) -> None:
+ """Append ``(tool_name, arguments)`` to `seen` for every call `manager` runs."""
+ original = manager.call_tool
+
+ async def recording(name: str, arguments: dict[str, Any], *args: Any, **kw: Any):
+ seen.append((name, dict(arguments or {})))
+ return await original(name, arguments, *args, **kw)
+
+ manager.call_tool = recording
+
+
+def attach_delivery_recorder(server: Any, manager: Any) -> list[tuple[str, dict]]:
+ """Record `manager`'s deliveries onto ``server.delivered_arguments``.
+
+ Returns the same list the attribute holds, so a factory can hand it back
+ directly. Call before ``track()``.
+ """
+ seen: list[tuple[str, dict[str, Any]]] = []
+ record_delivered_arguments(manager, seen)
+ setattr(server, DELIVERED_ATTR, seen)
+ return seen
+
+
+def delivered(server: Any) -> list[tuple[str, dict[str, Any]]]:
+ """What `server`'s tool manager has been handed so far."""
+ return getattr(server, DELIVERED_ATTR)
+
+
+def delivered_arguments_for(server: Any, tool_name: str) -> list[dict[str, Any]]:
+ """Just the argument dicts `tool_name` was called with, in order."""
+ return [args for name, args in delivered(server) if name == tool_name]
diff --git a/tests/test_utils/flavors.py b/tests/test_utils/flavors.py
new file mode 100644
index 0000000..17d46a8
--- /dev/null
+++ b/tests/test_utils/flavors.py
@@ -0,0 +1,816 @@
+"""Every server shape the v2 adapters serve, behind one small interface.
+
+The cross-flavor regressions — concurrency, removed events, rebuild-on-demand,
+the per-flavor ``response`` spelling — have to hold on every shape a customer
+can hand ``track()``. There are six of them, four adapters wide and two
+dependency sets deep, and a suite that quietly covered five would be worse than
+one that covered none: it would read as complete.
+
+So the shapes are enumerated once, here. Each `Flavor` is a small explicit
+description of ONE real server — how to build it, how to talk to it, and where
+its list source is. There is no normalization layer over the SDKs; only the
+three era spellings the tests actually read are bridged (``inputSchema`` vs
+``input_schema``, ``structuredContent`` vs ``structured_content``, ``isError``
+vs ``is_error``).
+
+`flavors()` returns exactly the shapes the INSTALLED dependency set can build.
+``tests/test_detection.py::TestRealServerObjects`` pins that the list is
+complete for its era and that every ``Flavor.flavor`` here is what
+`detect_server` actually says about the object `build` returns, so neither can
+drift silently.
+
+Every ``mcp`` / ``fastmcp`` import is function-local: this module is imported
+by test files that collect under both SDK majors.
+
+No ``from __future__ import annotations`` here, deliberately. PEP 563 would
+stringify the annotations of the tool bodies defined inside ``build()``, and
+``mcp.server.fastmcp``'s ``Tool.from_function`` calls
+``issubclass(param.annotation, Context)`` on mcp 1.7-1.13 — which raises
+``TypeError: issubclass() arg 1 must be a class`` against a string. Nothing in
+this module needs the future import: every annotation is PEP 585/604, which
+Python 3.10 evaluates natively, and no name is used before it is defined.
+"""
+
+import contextlib
+import copy
+from collections.abc import AsyncIterator, Awaitable, Callable
+from dataclasses import dataclass, field
+from importlib.metadata import PackageNotFoundError, version
+from typing import Any
+
+from agentcat.modules.constants import GET_MORE_TOOLS_NAME
+from agentcat.modules.detection import ServerFlavor
+from tests.test_utils.delivery import record_delivered_arguments
+
+MCP_MAJOR = int(version("mcp").split(".")[0])
+
+try:
+ FASTMCP_MAJOR: int | None = int(version("fastmcp").split(".")[0])
+except PackageNotFoundError: # pragma: no cover - community extra not installed
+ FASTMCP_MAJOR = None
+
+# A tool body's hook, awaited from inside the customer's handler. The
+# concurrency test uses it to hold every call inside the adapter's per-call
+# window at once.
+Hook = Callable[[], Awaitable[None]]
+
+ECHO_INPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {"text": {"type": "string"}},
+ "required": ["text"],
+}
+RESULT_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ "required": ["result"],
+}
+CONTEXT_INPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {"context": {"type": "string"}},
+ "required": ["context"],
+}
+# A customer tool whose OWN first parameter is called `session_id` — a task
+# tracker, a ticketing system, a job runner. Nothing about it is AgentCat's:
+# the injection pass sees the collision and leaves the name alone, the strip
+# spares it, and the call path must not read it as the analytics handle.
+CUSTOMER_SESSION_ID_DESCRIPTION = "The customer's own session identifier."
+COMPLETE_INPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "description": CUSTOMER_SESSION_ID_DESCRIPTION,
+ },
+ "note": {"type": "string"},
+ },
+ "required": ["session_id"],
+}
+
+CUSTOMER_GET_MORE_TOOLS_DESCRIPTION = (
+ "The customer's own tool, which happens to share AgentCat's name."
+)
+
+# What each tool body is allowed to receive, for the LOWLEVEL flavors, whose
+# handlers are handed the raw argument dict: an argument the tool never declared
+# is fatal there rather than ignored, because a body that quietly dropped one
+# would let a strip regression pass unnoticed.
+#
+# The facade flavors cannot use this — their bodies are typed functions and the
+# SDK never delivers an argument the signature does not name. Both official tool
+# managers drop an unexpected one SILENTLY (community FastMCP is the one that
+# raises), so on those two shapes "what was delivered" is observable only at the
+# manager, which is what `tests.test_utils.delivery` reads.
+ALLOWED_ARGUMENTS = {
+ "echo": {"text"},
+ GET_MORE_TOOLS_NAME: {"context"},
+ "complete_task": {"session_id", "note"},
+}
+
+
+# The one text `echo` refuses. Every era reports a failing tool differently —
+# a raise here, an `is_error` result there — and a test that wants a failure on
+# all of them needs the failure itself to be era-independent.
+BOOM_TEXT = "boom"
+
+
+class ListSourceDown(RuntimeError):
+ """Raised by a deliberately broken list source, so a log line names it."""
+
+
+class ToolFailed(RuntimeError):
+ """Raised by `echo` on the sentinel text: one failure shape for every era."""
+
+
+def _reject_unexpected(tool_name: str, arguments: dict[str, Any]) -> None:
+ allowed = ALLOWED_ARGUMENTS.get(tool_name, set())
+ unexpected = sorted(set(arguments) - allowed)
+ if unexpected:
+ raise ValueError(f"unexpected arguments: {unexpected}")
+
+
+def _boom(*args: Any, **kwargs: Any) -> Any:
+ """A list source that is down. Synchronous on purpose: it raises before an
+ awaiting caller ever reaches the ``await``, so one function breaks both the
+ sync tool managers and the async ``list_tools`` seams."""
+ raise ListSourceDown("the list source is down")
+
+
+def _answer(tool_name: str, arguments: dict[str, Any]) -> str:
+ """What every flavor's tool body returns, so only the wiring differs."""
+ if tool_name == GET_MORE_TOOLS_NAME:
+ return f"customer answered: {arguments['context']}"
+ if tool_name == "complete_task":
+ return f"completed {arguments['session_id']}: {arguments.get('note')}"
+ if arguments["text"] == BOOM_TEXT:
+ raise ToolFailed("the tool failed")
+ return f"echo:{arguments['text']}"
+
+
+@dataclass
+class Built:
+ """A fresh, UNTRACKED server plus what its tool bodies actually receive."""
+
+ server: Any
+ seen: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class ListedTool:
+ """One advertised tool, era spellings bridged."""
+
+ name: str
+ input_schema: dict[str, Any]
+ output_schema: dict[str, Any] | None
+
+
+@dataclass(frozen=True)
+class Called:
+ """One ``tools/call`` result, era spellings bridged."""
+
+ text: str
+ structured: dict[str, Any] | None
+ is_error: bool
+ raw: Any
+
+
+def _bridged(obj: Any, snake: str, camel: str) -> Any:
+ """The snake_case field when the model HAS one, else the camelCase one.
+
+ Presence, not truthiness: 2.x keeps the old spelling as a deprecated alias,
+ so a value-based fallback would reach it — and warn — every time the real
+ field is legitimately None.
+ """
+ if hasattr(obj, snake):
+ return getattr(obj, snake)
+ return getattr(obj, camel, None)
+
+
+def _listed(tool: Any) -> ListedTool:
+ return ListedTool(
+ name=tool.name,
+ input_schema=_bridged(tool, "input_schema", "inputSchema"),
+ output_schema=_bridged(tool, "output_schema", "outputSchema"),
+ )
+
+
+def _called(result: Any) -> Called:
+ blocks = getattr(result, "content", None) or []
+ return Called(
+ text="".join(b.text for b in blocks if hasattr(b, "text")),
+ structured=_bridged(result, "structured_content", "structuredContent"),
+ is_error=bool(_bridged(result, "is_error", "isError")),
+ raw=result,
+ )
+
+
+class Flavor:
+ """One server shape: build it, talk to it, break its list source."""
+
+ id = ""
+ flavor: ServerFlavor
+
+ # Whether a `tools/call` can complete at all while the list source is down.
+ #
+ # On mcp 1.x it cannot, and that is the SDK's doing rather than AgentCat's:
+ # the lowlevel `tools/call` handler opens by resolving the tool definition
+ # from a cache it refreshes THROUGH the same list handler
+ # (`Server._get_cached_tool_definition`), unconditionally — `validate_input`
+ # does not gate it, which is why official FastMCP is no different. A listing
+ # that raises therefore fails the call before the customer's tool body runs,
+ # tracked or untracked. Everything AgentCat does on that path still happens
+ # and is asserted; the tool body and the wire mirror are simply out of reach
+ # on that era.
+ survives_a_down_list_source = True
+
+ def build(
+ self,
+ name: str = "cross-flavor",
+ *,
+ hook: Hook | None = None,
+ customer_get_more_tools: bool = False,
+ customer_session_id: bool = False,
+ ) -> Built:
+ """A fresh untracked server carrying `echo`, and optionally two tools
+ whose names collide with AgentCat's: a `get_more_tools` whose
+ ``context`` is a real parameter, and a `complete_task` whose
+ ``session_id`` is a real parameter."""
+ raise NotImplementedError
+
+ def client(self, server: Any) -> Any:
+ """An async context manager yielding a connected client."""
+ raise NotImplementedError
+
+ async def list_tools(self, client: Any) -> list[ListedTool]:
+ raise NotImplementedError
+
+ async def call(
+ self, client: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ raise NotImplementedError
+
+ async def call_unlisted(
+ self, server: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ """One call on an instance that has never served a listing.
+
+ The default is a real client that simply does not list first; the
+ community flavors override it, because their client fetches schemas on
+ its way to a call and so can never reproduce an un-listed instance.
+ """
+ async with self.client(server) as client:
+ return await self.call(client, name, arguments)
+
+ def break_list_source(self, server: Any) -> None:
+ """Make the source `rebuild()` reads raise, and nothing else."""
+ raise NotImplementedError
+
+ def __repr__(self) -> str: # pragma: no cover - test ids only
+ return self.id
+
+
+# ── official MCP SDK 1.x ─────────────────────────────────────────────────────
+
+
+def _backfill_structured_output(fastmcp_server: Any) -> None:
+ """Give a pre-1.10 `mcp.server.fastmcp.FastMCP` the structured output that
+ its later selves produce natively.
+
+ From mcp 1.10 the facade derives an ``outputSchema`` from a tool's return
+ annotation and answers with matching ``structuredContent``. Before that the
+ feature does not exist at all — not spelled differently, absent. But this
+ flavor's contract is "a FastMCP server whose tools declare an output
+ schema", because that is what the cross-flavor parity suites compare
+ against the other five shapes; a flavor that quietly dropped half its
+ contract on old mcp would make those suites read as green while covering
+ less.
+
+ Both patches go on ``_mcp_server.request_handlers`` — the same table the
+ adapter itself wraps, and the one seam whose contract is stable across all
+ of 1.x — so what AgentCat sees here is exactly what it sees on 1.10+.
+ No-ops when the running SDK already does this.
+ """
+ import mcp.types as types
+
+ if "outputSchema" in types.Tool.model_fields:
+ return
+
+ low = fastmcp_server._mcp_server
+ list_inner = low.request_handlers[types.ListToolsRequest]
+ call_inner = low.request_handlers[types.CallToolRequest]
+
+ async def list_handler(req: Any) -> Any:
+ result = await list_inner(req)
+ listed = result.root
+ return types.ServerResult(
+ listed.model_copy(
+ update={
+ "tools": [
+ tool.model_copy(
+ update={"outputSchema": dict(RESULT_OUTPUT_SCHEMA)}
+ )
+ for tool in listed.tools
+ ]
+ }
+ )
+ )
+
+ async def call_handler(req: Any) -> Any:
+ result = await call_inner(req)
+ inner = result.root
+ if inner.isError:
+ return result
+ message = "".join(
+ block.text for block in inner.content if hasattr(block, "text")
+ )
+ return types.ServerResult(
+ inner.model_copy(update={"structuredContent": {"result": message}})
+ )
+
+ low.request_handlers[types.ListToolsRequest] = list_handler
+ low.request_handlers[types.CallToolRequest] = call_handler
+
+
+class OfficialFastMCPV1(Flavor):
+ """`mcp.server.fastmcp.FastMCP`, adapted through its `_mcp_server`."""
+
+ id = "official-fastmcp-v1"
+ flavor = ServerFlavor.OFFICIAL_FASTMCP_V1
+ survives_a_down_list_source = False
+
+ def build(
+ self,
+ name: str = "cross-flavor",
+ *,
+ hook: Hook | None = None,
+ customer_get_more_tools: bool = False,
+ customer_session_id: bool = False,
+ ) -> Built:
+ from mcp.server.fastmcp import FastMCP
+
+ built = Built(FastMCP(name))
+ server = built.server
+
+ @server.tool()
+ async def echo(text: str) -> str:
+ """Echo the text back."""
+ if hook is not None:
+ await hook()
+ return _answer("echo", {"text": text})
+
+ if customer_get_more_tools:
+
+ @server.tool(name=GET_MORE_TOOLS_NAME)
+ async def customers_get_more_tools(context: str) -> str:
+ """The customer's own tool, which happens to share our name."""
+ return f"customer answered: {context}"
+
+ if customer_session_id:
+
+ @server.tool()
+ async def complete_task(session_id: str, note: str = "") -> str:
+ """The customer's own session_id, which is not AgentCat's."""
+ return _answer(
+ "complete_task", {"session_id": session_id, "note": note}
+ )
+
+ # `seen` is filled at the manager, not in the bodies above: see
+ # `record_delivered_arguments` for why a typed body cannot report it.
+ record_delivered_arguments(server._tool_manager, built.seen)
+ _backfill_structured_output(server)
+ return built
+
+ @contextlib.asynccontextmanager
+ async def client(self, server: Any) -> AsyncIterator[Any]:
+ from .client import create_test_client
+
+ async with create_test_client(server) as session:
+ yield session
+
+ async def list_tools(self, client: Any) -> list[ListedTool]:
+ return [_listed(tool) for tool in (await client.list_tools()).tools]
+
+ async def call(
+ self, client: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ return _called(await client.call_tool(name, arguments))
+
+ async def call_unlisted(
+ self, server: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ # The raw request, not `call_tool`, for the reason `MCPServerV2` gives:
+ # from mcp 1.15 the convenience method revalidates a result carrying
+ # `structuredContent` against the tool's output schema, and fetches the
+ # listing to do it. That listing is harmless to the rebuild — which has
+ # already happened by then — but fatal to a test that has deliberately
+ # taken the list source down.
+ import mcp.types as types
+
+ async with self.client(server) as client:
+ result = await client.send_request(
+ types.ClientRequest(
+ types.CallToolRequest(
+ method="tools/call",
+ params=types.CallToolRequestParams(
+ name=name, arguments=arguments
+ ),
+ )
+ ),
+ types.CallToolResult,
+ )
+ return _called(result)
+
+ def break_list_source(self, server: Any) -> None:
+ # The tool manager is what FastMCP's own `tools/list` handler reads,
+ # and that handler is what the adapter recorded as the list source.
+ server._tool_manager.list_tools = _boom
+
+
+class LowlevelV1(OfficialFastMCPV1):
+ """A bare `mcp.server.lowlevel.Server`: the same adapter, no facade."""
+
+ id = "lowlevel-v1"
+ flavor = ServerFlavor.LOWLEVEL_V1
+ survives_a_down_list_source = False
+
+ def build(
+ self,
+ name: str = "cross-flavor",
+ *,
+ hook: Hook | None = None,
+ customer_get_more_tools: bool = False,
+ customer_session_id: bool = False,
+ ) -> Built:
+ import mcp.types as types
+ from mcp.server.lowlevel import Server
+
+ built = Built(Server(name))
+ server = built.server
+ broken = {"list": False}
+ server._cross_flavor_broken = broken
+
+ tools = [
+ types.Tool(
+ name="echo",
+ description="Echo the text back.",
+ inputSchema=dict(ECHO_INPUT_SCHEMA),
+ outputSchema=dict(RESULT_OUTPUT_SCHEMA),
+ )
+ ]
+ if customer_get_more_tools:
+ tools.append(
+ types.Tool(
+ name=GET_MORE_TOOLS_NAME,
+ description=CUSTOMER_GET_MORE_TOOLS_DESCRIPTION,
+ inputSchema=dict(CONTEXT_INPUT_SCHEMA),
+ outputSchema=dict(RESULT_OUTPUT_SCHEMA),
+ )
+ )
+ if customer_session_id:
+ tools.append(
+ types.Tool(
+ name="complete_task",
+ description="The customer's own session_id, not AgentCat's.",
+ inputSchema=copy.deepcopy(COMPLETE_INPUT_SCHEMA),
+ outputSchema=dict(RESULT_OUTPUT_SCHEMA),
+ )
+ )
+
+ @server.list_tools()
+ async def list_tools() -> list[Any]:
+ if broken["list"]:
+ raise ListSourceDown("the list source is down")
+ return [tool.model_copy(deep=True) for tool in tools]
+
+ # Registered straight into `request_handlers` rather than through
+ # `@server.call_tool()`, because the decorator's return CONVENTION is
+ # era-specific and this flavor has to build the same server on every
+ # mcp 1.x. Returning `(content, structured)` needs mcp >= 1.10, and
+ # returning a `CallToolResult` needs >= 1.19; below those the SDK
+ # wraps the value as content and CallToolResult validation fails
+ # INSIDE its own try, yielding a plausible-looking `isError` result
+ # instead of an exception. `request_handlers` is the one seam whose
+ # contract — a `ServerResult` in, a `ServerResult` out — has been
+ # stable since 1.0, and it is the same table AgentCat itself wraps.
+ #
+ # The two behaviours the decorator supplies are reproduced here: a
+ # successful call answers `isError=False`, and ANY raise becomes an
+ # `isError=True` result carrying `str(exc)` — which is what makes
+ # `ToolFailed` and `_reject_unexpected` observable to the tests.
+ async def call_tool_handler(req: Any) -> Any:
+ try:
+ tool_name = req.params.name
+ arguments = dict(req.params.arguments or {})
+ _reject_unexpected(tool_name, arguments)
+ built.seen.append((tool_name, arguments))
+ if tool_name == "echo" and hook is not None:
+ await hook()
+ message = _answer(tool_name, arguments)
+ return types.ServerResult(
+ types.CallToolResult(
+ content=[types.TextContent(type="text", text=message)],
+ # `Tool`/`CallToolResult` are extra="allow" on every
+ # 1.x, so this reaches the wire below 1.10 too.
+ structuredContent={"result": message},
+ isError=False,
+ )
+ )
+ except Exception as exc:
+ return types.ServerResult(
+ types.CallToolResult(
+ content=[types.TextContent(type="text", text=str(exc))],
+ isError=True,
+ )
+ )
+
+ server.request_handlers[types.CallToolRequest] = call_tool_handler
+
+ return built
+
+ def break_list_source(self, server: Any) -> None:
+ server._cross_flavor_broken["list"] = True
+
+
+# ── official MCP SDK 2.x ─────────────────────────────────────────────────────
+
+
+class MCPServerV2(Flavor):
+ """`mcp.server.mcpserver.MCPServer`, adapted through its
+ `_lowlevel_server`."""
+
+ id = "mcpserver-v2"
+ flavor = ServerFlavor.MCPSERVER_V2
+
+ def build(
+ self,
+ name: str = "cross-flavor",
+ *,
+ hook: Hook | None = None,
+ customer_get_more_tools: bool = False,
+ customer_session_id: bool = False,
+ ) -> Built:
+ from mcp.server.mcpserver import MCPServer
+
+ built = Built(MCPServer(name))
+ server = built.server
+
+ @server.tool()
+ async def echo(text: str) -> str:
+ """Echo the text back."""
+ if hook is not None:
+ await hook()
+ return _answer("echo", {"text": text})
+
+ if customer_get_more_tools:
+
+ @server.tool(name=GET_MORE_TOOLS_NAME)
+ async def customers_get_more_tools(context: str) -> str:
+ """The customer's own tool, which happens to share our name."""
+ return f"customer answered: {context}"
+
+ if customer_session_id:
+
+ @server.tool()
+ async def complete_task(session_id: str, note: str = "") -> str:
+ """The customer's own session_id, which is not AgentCat's."""
+ return _answer(
+ "complete_task", {"session_id": session_id, "note": note}
+ )
+
+ # `seen` is filled at the manager, not in the bodies above: see
+ # `record_delivered_arguments` for why a typed body cannot report it.
+ record_delivered_arguments(server._tool_manager, built.seen)
+ return built
+
+ @contextlib.asynccontextmanager
+ async def client(self, server: Any) -> AsyncIterator[Any]:
+ from .modern_server import create_modern_client
+
+ async with create_modern_client(server) as session:
+ yield session
+
+ async def list_tools(self, client: Any) -> list[ListedTool]:
+ return [_listed(tool) for tool in (await client.list_tools()).tools]
+
+ async def call(
+ self, client: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ return _called(await client.call_tool(name, arguments))
+
+ async def call_unlisted(
+ self, server: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ # The raw request, not `call_tool`: the convenience method revalidates
+ # the result against the tool's output schema and fetches the listing
+ # to do it. That listing is harmless to the rebuild — which has already
+ # happened by then — but fatal to a test that has deliberately taken
+ # the list source down. Same wire path, without the client's own extra
+ # round trip.
+ from mcp import types
+
+ async with self.client(server) as client:
+ result = await client.session.send_request(
+ types.CallToolRequest(
+ params=types.CallToolRequestParams(
+ name=name, arguments=arguments
+ )
+ ),
+ types.CallToolResult,
+ )
+ return _called(result)
+
+ def break_list_source(self, server: Any) -> None:
+ server._tool_manager.list_tools = _boom
+
+
+class LowlevelV2(MCPServerV2):
+ """A bare `mcp.server.Server`: the same adapter, no facade."""
+
+ id = "lowlevel-v2"
+ flavor = ServerFlavor.LOWLEVEL_V2
+
+ def build(
+ self,
+ name: str = "cross-flavor",
+ *,
+ hook: Hook | None = None,
+ customer_get_more_tools: bool = False,
+ customer_session_id: bool = False,
+ ) -> Built:
+ from mcp import types
+ from mcp.server import Server
+
+ seen: list[tuple[str, dict[str, Any]]] = []
+ broken = {"list": False}
+
+ tools = [
+ types.Tool(
+ name="echo",
+ description="Echo the text back.",
+ input_schema=dict(ECHO_INPUT_SCHEMA),
+ output_schema=dict(RESULT_OUTPUT_SCHEMA),
+ )
+ ]
+ if customer_get_more_tools:
+ tools.append(
+ types.Tool(
+ name=GET_MORE_TOOLS_NAME,
+ description=CUSTOMER_GET_MORE_TOOLS_DESCRIPTION,
+ input_schema=dict(CONTEXT_INPUT_SCHEMA),
+ output_schema=dict(RESULT_OUTPUT_SCHEMA),
+ )
+ )
+ if customer_session_id:
+ tools.append(
+ types.Tool(
+ name="complete_task",
+ description="The customer's own session_id, not AgentCat's.",
+ input_schema=copy.deepcopy(COMPLETE_INPUT_SCHEMA),
+ output_schema=dict(RESULT_OUTPUT_SCHEMA),
+ )
+ )
+
+ async def on_list_tools(ctx: Any, params: Any) -> Any:
+ if broken["list"]:
+ raise ListSourceDown("the list source is down")
+ return types.ListToolsResult(
+ tools=[tool.model_copy(deep=True) for tool in tools]
+ )
+
+ async def on_call_tool(ctx: Any, params: Any) -> Any:
+ arguments = dict(params.arguments or {})
+ _reject_unexpected(params.name, arguments)
+ seen.append((params.name, arguments))
+ if params.name == "echo" and hook is not None:
+ await hook()
+ message = _answer(params.name, arguments)
+ return types.CallToolResult(
+ content=[types.TextContent(type="text", text=message)],
+ structured_content={"result": message},
+ )
+
+ server = Server(name, on_list_tools=on_list_tools, on_call_tool=on_call_tool)
+ server._cross_flavor_broken = broken
+ return Built(server, seen)
+
+ def break_list_source(self, server: Any) -> None:
+ server._cross_flavor_broken["list"] = True
+
+
+# ── community FastMCP (3.x beside mcp 1.x, 4.x beside mcp 2.x) ───────────────
+
+
+class Community(Flavor):
+ """Community `fastmcp.FastMCP`, adapted by a middleware at index 0."""
+
+ def __init__(self, era: int) -> None:
+ self.id = f"community-v{era}"
+ self.flavor = (
+ ServerFlavor.COMMUNITY_V3 if era == 3 else ServerFlavor.COMMUNITY_V4
+ )
+
+ def build(
+ self,
+ name: str = "cross-flavor",
+ *,
+ hook: Hook | None = None,
+ customer_get_more_tools: bool = False,
+ customer_session_id: bool = False,
+ ) -> Built:
+ from fastmcp import FastMCP
+
+ built = Built(FastMCP(name))
+ server = built.server
+
+ @server.tool
+ async def echo(text: str) -> str:
+ """Echo the text back."""
+ built.seen.append(("echo", {"text": text}))
+ if hook is not None:
+ await hook()
+ return _answer("echo", {"text": text})
+
+ if customer_get_more_tools:
+
+ @server.tool(name=GET_MORE_TOOLS_NAME)
+ async def customers_get_more_tools(context: str) -> str:
+ """The customer's own tool, which happens to share our name."""
+ built.seen.append((GET_MORE_TOOLS_NAME, {"context": context}))
+ return f"customer answered: {context}"
+
+ if customer_session_id:
+
+ @server.tool
+ async def complete_task(session_id: str, note: str = "") -> str:
+ """The customer's own session_id, which is not AgentCat's."""
+ built.seen.append(
+ ("complete_task", {"session_id": session_id, "note": note})
+ )
+ return _answer(
+ "complete_task", {"session_id": session_id, "note": note}
+ )
+
+ return built
+
+ @contextlib.asynccontextmanager
+ async def client(self, server: Any) -> AsyncIterator[Any]:
+ from .community_client import create_community_test_client
+
+ async with create_community_test_client(server) as session:
+ yield session
+
+ async def list_tools(self, client: Any) -> list[ListedTool]:
+ return [_listed(tool) for tool in await client.list_tools()]
+
+ async def call(
+ self, client: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ return _called(await client.call_tool(name, arguments))
+
+ async def call_unlisted(
+ self, server: Any, name: str, arguments: dict[str, Any]
+ ) -> Called:
+ # Driven through the server rather than a client: a real FastMCP client
+ # fetches output schemas on its way to a call, so it can never
+ # reproduce a genuinely un-listed instance. `call_tool` runs the
+ # middleware chain, which is the path under test.
+ return _called(await server.call_tool(name, arguments))
+
+ def break_list_source(self, server: Any) -> None:
+ # `list_tools(run_middleware=False)` is the community rebuild's list
+ # source, and an instance attribute shadows the bound method.
+ server.list_tools = _boom
+
+
+# ── what this dependency set can build ───────────────────────────────────────
+
+# The official flavors of each era, keyed by the installed `mcp` major. Stated
+# as a table rather than derived, so a missing entry reads as a gap.
+_OFFICIAL: dict[int, list[Flavor]] = {
+ 1: [OfficialFastMCPV1(), LowlevelV1()],
+ 2: [MCPServerV2(), LowlevelV2()],
+}
+
+
+def flavors() -> list[Flavor]:
+ """Every server shape the installed dependency set can build."""
+ available = list(_OFFICIAL.get(MCP_MAJOR, []))
+ if FASTMCP_MAJOR is not None:
+ available.append(Community(FASTMCP_MAJOR))
+ return available
+
+
+def flavor_ids() -> list[str]:
+ return [flavor.id for flavor in flavors()]
+
+
+def tracking_data(server: Any) -> Any:
+ """The `AgentCatData` `track()` stored for ``server``, on any flavor.
+
+ Keyed on the object the adapter was installed on — the lowlevel server for
+ the official flavors, the FastMCP itself for the community ones — which is
+ exactly what `detect_server` hands back.
+ """
+ from agentcat.modules.detection import detect_server
+ from agentcat.modules.internal import get_server_tracking_data
+
+ return get_server_tracking_data(detect_server(server).lowlevel or server)
diff --git a/tests/test_utils/modern_server.py b/tests/test_utils/modern_server.py
new file mode 100644
index 0000000..72af003
--- /dev/null
+++ b/tests/test_utils/modern_server.py
@@ -0,0 +1,144 @@
+"""Server and client factories for the modern official SDK (mcp 2.x).
+
+The legacy siblings (`todo_server.py`, `client.py`) are built on mcp 1.x APIs
+that 2.x removed — `mcp.server.fastmcp`, `mcp.shared.memory`. This module is
+their 2.x counterpart: a bare lowlevel `Server`, an `MCPServer`, and the
+in-process `Client` the modern SDK ships for exactly this purpose.
+
+Only imported from modern-gated test modules (see `tests/conftest.py`), so the
+`mcp` imports are module-scope here.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from mcp import types
+from mcp.client import Client
+from mcp.server import Server
+from mcp.server.mcpserver import MCPServer
+
+from tests.test_utils.delivery import attach_delivery_recorder
+
+ADD_TODO_INPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {"text": {"type": "string"}},
+ "required": ["text"],
+}
+ADD_TODO_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {"result": {"type": "string"}},
+ "required": ["result"],
+}
+
+
+def _reject_unexpected(arguments: dict[str, Any], allowed: set[str]) -> None:
+ """Fail loudly on any argument the tool never declared.
+
+ The point of the strip is that AgentCat's injected parameters never reach
+ the customer's tool; a handler that quietly ignored them would let a strip
+ regression pass unnoticed.
+ """
+ unexpected = sorted(set(arguments) - allowed)
+ if unexpected:
+ raise ValueError(f"unexpected arguments: {unexpected}")
+
+
+def create_lowlevel_todo_server(name: str = "todo-server-v2") -> Server[Any]:
+ """A bare lowlevel v2 `Server` with a todo tool set.
+
+ `complete_todo` reports failure the way the 2026 SDK does — an `is_error`
+ result, not a raised exception — so the error path under test is the one
+ real servers take.
+ """
+ todos: list[str] = []
+
+ async def on_list_tools(
+ ctx: Any, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name="add_todo",
+ description="Add a new todo item.",
+ input_schema=dict(ADD_TODO_INPUT_SCHEMA),
+ output_schema=dict(ADD_TODO_OUTPUT_SCHEMA),
+ ),
+ types.Tool(
+ name="complete_todo",
+ description="Mark a todo item as completed.",
+ input_schema={
+ "type": "object",
+ "properties": {"id": {"type": "integer"}},
+ "required": ["id"],
+ },
+ ),
+ ]
+ )
+
+ async def on_call_tool(
+ ctx: Any, params: types.CallToolRequestParams
+ ) -> types.CallToolResult:
+ arguments = dict(params.arguments or {})
+ if params.name == "add_todo":
+ _reject_unexpected(arguments, {"text"})
+ todos.append(str(arguments["text"]))
+ message = f'Added todo: "{arguments["text"]}" with ID {len(todos)}'
+ return types.CallToolResult(
+ content=[types.TextContent(type="text", text=message)],
+ structured_content={"result": message},
+ )
+ if params.name == "complete_todo":
+ _reject_unexpected(arguments, {"id"})
+ return types.CallToolResult(
+ content=[
+ types.TextContent(
+ type="text",
+ text=f"Todo with ID {arguments.get('id')} not found",
+ )
+ ],
+ is_error=True,
+ )
+ raise ValueError(f"Unknown tool: {params.name}")
+
+ return Server(name, on_list_tools=on_list_tools, on_call_tool=on_call_tool)
+
+
+def create_mcpserver_todo_server(name: str = "todo-mcpserver") -> MCPServer:
+ """An `MCPServer` with the same tool set, served through `_lowlevel_server`.
+
+ Its tool bodies are typed functions, so — unlike the lowlevel factory above
+ — they cannot police their own arguments: measured on mcp 2.0, the
+ `_tool_manager` DROPS an undeclared argument silently rather than failing
+ the call, so an un-stripped `session_id` would pass unnoticed. The delivery
+ recorder installed below is what makes the strip observable; read it with
+ `tests.test_utils.delivery.delivered_arguments_for(server, "add_todo")`.
+ """
+ todos: list[str] = []
+
+ server = MCPServer(name)
+
+ @server.tool()
+ def add_todo(text: str) -> str:
+ """Add a new todo item."""
+ todos.append(text)
+ return f'Added todo: "{text}" with ID {len(todos)}'
+
+ @server.tool()
+ def list_todos() -> str:
+ """List all todo items."""
+ return "\n".join(todos) if todos else "No todos found"
+
+ attach_delivery_recorder(server, server._tool_manager)
+ return server
+
+
+def create_modern_client(server: Any, **kwargs: Any) -> Client:
+ """An in-process `Client` for a v2 `Server` or `MCPServer`.
+
+ Used as an async context manager, exactly like the SDK's own tests do it,
+ so every assertion covers the real wire path (params validation, result
+ serialization, per-version outbound sieve) rather than a hand-called
+ handler.
+ """
+ return Client(server, **kwargs)
diff --git a/tests/test_utils/todo_server.py b/tests/test_utils/todo_server.py
index 7383b59..7129824 100644
--- a/tests/test_utils/todo_server.py
+++ b/tests/test_utils/todo_server.py
@@ -1,14 +1,23 @@
-"""Todo server implementation for testing."""
+"""Todo server implementation for testing (official MCP SDK 1.x).
-from mcp.shared.exceptions import McpError
-from mcp.types import ErrorData
+Import-safe under mcp 2.x, which removed `FastMCP` and renamed `McpError`, so
+a module that mixes era-agnostic unit tests with a `create_todo_server()`
+integration class can still be collected there — the factory raises, the unit
+tests run. `test_utils.modern_server` is the 2.x counterpart.
+"""
+
+from tests.test_utils.delivery import attach_delivery_recorder
try:
from mcp.server import FastMCP
+ from mcp.shared.exceptions import McpError
+ from mcp.types import ErrorData
HAS_FASTMCP = True
-except ImportError:
+except ImportError: # mcp 2.x
FastMCP = None
+ McpError = None
+ ErrorData = None
HAS_FASTMCP = False
@@ -32,7 +41,16 @@ def __init__(self, id: int, text: str, completed: bool = False):
def create_todo_server():
- """Create a todo server for testing."""
+ """Create a todo server for testing.
+
+ Every tool body here is a typed function, so none of them can police its
+ own arguments: measured on mcp 1.29, `mcp.server.fastmcp`'s tool manager
+ DROPS an undeclared argument silently rather than failing the call. A test
+ that sends AgentCat's injected parameters and asserts "no error" therefore
+ proves nothing about the strip. The delivery recorder installed below is
+ what makes it observable — read it with
+ `tests.test_utils.delivery.delivered_arguments_for(server, "add_todo")`.
+ """
if FastMCP is None:
raise ImportError(
"FastMCP is not available in this MCP version. Use create_low_level_todo_server() instead."
@@ -92,6 +110,8 @@ def tool_with_mcp_error() -> str:
error = ErrorData(code=INVALID_PARAMS, message="Invalid parameters")
raise McpError(error)
+ attach_delivery_recorder(server, server._tool_manager)
+
# Store original handlers for testing
server._original_handlers = {
"add_todo": add_todo,