agentcat-sdk 1.0.0

AgentCat analytics SDK for MCP servers built on rmcp
Documentation

[!NOTE] AgentCat for Rust is compatible with the MCP Protocol "Stateless" 2026-07-28 Update as the official Rust MCP SDK puts it into effect: rmcp 3.x, from 3.0.0, over stateless HTTP, streamable HTTP and stdio. 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).

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.

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.

A later release of this SDK will add a free and simple way to forward telemetry like logs, traces, and errors to any Open Telemetry collector or popular tools like Datadog, Sentry, and PostHog. This release sends analytics to AgentCat only.

cargo add agentcat-sdk

To learn more about us, check us out here. For detailed guides visit our documentation.

Why use AgentCat? 🤔

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:

  • 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 📊. Coming in a later release of the Rust SDK: 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 wrap your rmcp server handler with track(), it seamlessly modifies your tool schemas as your server lists them, following the MCP core team's explicit handles (SEP-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 that is not a well-formed AgentCat session ID 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 Options::default().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 15 to 25 words, 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, and a tool that declares its own parameter with one of these names keeps it. For tools that declare an output schema, issued IDs are also mirrored into structuredContent (as mcp_session, its first key), so clients that only read structured results still receive them. To pick the session of each call yourself instead, set a resolve_session_id hook: the tools then get no session_id parameter.

Getting Started

To get started with AgentCat, first create an account and obtain your project ID by signing up at agentcat.com. For detailed setup instructions, read the crate guide on docs.rs.

Once you have your project ID, add AgentCat to the crate of your rmcp server:

cargo add agentcat-sdk

AgentCat works with the rmcp 3.x and the tokio runtime that your server already uses, so it needs no other crate. The Rust runtimes section below lists the supported versions.

Then track your server as shown below; the examples are complete servers for each setup. AgentCat stays on while your tests run: AGENTCAT_ENABLED=false turns it off, and testing::Fake (the test-util feature) records the events of a test for your assertions.

Stateless HTTP

Stateless HTTP is the default MCP transport from the 2026-07-28 update on. In stateless mode, rmcp builds a new server handler for each request. Build one AgentCat handle for the process, and wrap each new handler with AgentCat::track in the closure that builds it. Tracking each instance is cheap — the clones of one handle share its event queue and its delivery thread. After your server stops, call AgentCat::shutdown:

use std::sync::Arc;
use std::time::Duration;

use agentcat_sdk::{AgentCat, Options};
use rmcp::ServerHandler;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};

// `my_server` is your existing rmcp server handler
async fn serve_http(my_server: impl ServerHandler + Clone) {
    // One AgentCat handle for the whole process
    let agentcat = AgentCat::new("proj_0000000", Options::default());

    // rmcp calls this closure for each request: track each new server
    let handle = agentcat.clone();
    let service = StreamableHttpService::new(
        move || Ok(handle.track(my_server.clone())),
        Arc::new(LocalSessionManager::default()),
        // No MCP sessions, and JSON responses instead of server-sent events
        StreamableHttpServerConfig::default()
            .with_legacy_session_mode(false)
            .with_json_response(true),
    );

    // ... mount `service` in your axum router, for example with `.route_service("/mcp", service)`,
    // and serve the router as you do today, until the server stops ...

    // Deliver the queued events before the process ends
    agentcat.shutdown(Duration::from_secs(5)).await;
}

AgentCat never uses the MCP session of the transport (the mcp-session-id header) to group calls. The session_id parameter groups them on every transport, so a stateless server loses no sessions. An HTTP server does not end when a client goes away, and AgentCat installs no signal handler, so call AgentCat::shutdown after your server stops. Containers and process managers stop a server with SIGTERM: the stateless_http.rs example is a complete stateless server that stops on SIGTERM (Unix) or Ctrl-C, then calls shutdown.

Streamable HTTP with sessions

A server that still uses MCP sessions builds a new server handler for each MCP session. The setup is the same as above; only the configuration of the service changes:

use rmcp::transport::streamable_http_server::StreamableHttpServerConfig;

// MCP sessions and server-sent events, the defaults of rmcp
let config = StreamableHttpServerConfig::default();

The streamable_http.rs example is a complete server with sessions.

stdio

Wrap your server handler with agentcat_sdk::track, and serve the wrapper where you serve your handler today:

use agentcat_sdk::Options;
use rmcp::{ServerHandler, ServiceExt};

// `my_server` is your existing rmcp server handler
async fn serve_stdio(my_server: impl ServerHandler) -> Result<(), Box<dyn std::error::Error>> {
    // Track the server with AgentCat
    let server = agentcat_sdk::track(my_server, "proj_0000000", Options::default());
    let running = server.serve(rmcp::transport::stdio()).await?;
    running.waiting().await?;
    Ok(())
}

To read the project ID from AGENTCAT_PROJECT_ID instead, wrap the server with AgentCat::from_env(Options::default()).track(my_server). A stdio server talks JSON-RPC on stdout: AgentCat writes nothing to stdout or stderr, and its own log lines go to ~/agentcat.log. Send your own logs to stderr. The server ends when the client closes stdin; the drop of the last AgentCat handle then delivers the queued events, for up to 5 s, so a stdio server needs no shutdown call.

Identifying users

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) never adds latency to a tool call: AgentCat runs it on the tokio runtime of the call, and the call never waits for it. When the event is ready to go out, AgentCat waits at most 30 s for the hook, so a lookup that takes longer than that — or a hook that fails outright — costs analytics data for that event, never your tool's response time.

The callback receives a ToolCall: the tool name, the arguments and _meta of the request, the HTTP headers, the client's name and version, the protocol version, and the rmcp request context. Most identity signals live in its headers(), with lower-case names (None on stdio). This example reads the headers with serde_json and hashes the token with the sha2 crate; if your server does not have these crates yet, add them with cargo add serde_json sha2:

use agentcat_sdk::{AgentCat, HookError, Options, ToolCall, UserIdentity};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};

let options = Options::default().identify(|call: ToolCall| async move {
    let header = |name: &str| {
        call.headers()
            .and_then(|headers| headers.get(name))
            .and_then(Value::as_str)
            .map(str::to_owned)
    };
    let Some(token) = header("authorization") else {
        return Ok::<_, HookError>(None);
    };

    // A stable anonymized ID, or your own user ID
    let user_id: String = Sha256::digest(token.as_bytes())
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect();
    let mut user_data = Map::new();
    if let Some(org_id) = header("x-org-id") {
        user_data.insert("orgId".to_owned(), Value::String(org_id));
    }
    Ok(Some(UserIdentity::new(user_id).user_data(user_data)))
});

// Track your servers with this handle, as in Getting Started
let agentcat = AgentCat::new("proj_0000000", options);

track, AgentCat::new and AgentCat::from_env all take the Options. The event_tags and event_properties hooks get the same ToolCall, and the hooks.rs example uses every hook except resolve_session_id.

Redacting sensitive data

AgentCat redacts all data sent to its servers and encrypts at rest, but for additional security, it offers a hook to do your own redaction on all text data returned back to our servers.

use agentcat_sdk::{AgentCat, HookError, Options};

let options = Options::default().redact_sensitive_information(|text: String| async move {
    // Replace each sk_live_ key, wherever it is in the text
    let mut redacted = String::with_capacity(text.len());
    let mut rest = text.as_str();
    while let Some(start) = rest.find("sk_live_") {
        redacted.push_str(&rest[..start]);
        redacted.push_str("[REDACTED]");
        let key = &rest[start..];
        let end = key
            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
            .unwrap_or(key.len());
        rest = &key[end..];
    }
    redacted.push_str(rest);
    Ok::<_, HookError>(redacted)
});

let agentcat = AgentCat::new("proj_0000000", options);

For redaction decisions that need more context than a single string — such as which tool was called or what type of event is being published — use the event-level redact_event hook. It receives the full event and returns a modified event, or None to drop the event entirely. It runs before redact_sensitive_information, and the two can be combined. When a redaction hook fails, AgentCat drops the event rather than send it unredacted.

use agentcat_sdk::{AgentCat, Event, HookError, Options};

let options = Options::default().redact_event(|mut event: Event| async move {
    // Drop events from tools that handle secrets entirely
    if event.resource_name.as_deref() == Some("get_credentials") {
        return Ok::<_, HookError>(None);
    }
    // Strip response payloads from a specific tool
    if event.resource_name.as_deref() == Some("export_report") {
        event.response = None;
    }
    Ok(Some(event))
});

let agentcat = AgentCat::new("proj_0000000", options);

On streamable HTTP, a tool-call event also carries the HTTP request of the call in parameters.extra.requestInfo: each header under its lower-case name, authorization and cookie included, and the URL with its query string, which can carry an API key. This redact_event hook removes the authorization and cookie headers and the query string; remove any other header that carries a secret, such as x-api-key, the same way. Options holds one redact_event hook, so merge this code with your other redact_event logic:

use agentcat_sdk::{AgentCat, Event, HookError, Options};
use serde_json::Value;

let options = Options::default().redact_event(|mut event: Event| async move {
    let info = event
        .parameters
        .as_mut()
        .and_then(|parameters| parameters.pointer_mut("/extra/requestInfo"))
        .and_then(Value::as_object_mut);
    if let Some(info) = info {
        if let Some(Value::Object(headers)) = info.get_mut("headers") {
            headers.remove("authorization");
            headers.remove("cookie");
        }
        if let Some(Value::String(url)) = info.get_mut("url") {
            if let Some(query) = url.find('?') {
                url.truncate(query);
            }
        }
    }
    Ok::<_, HookError>(Some(event))
});

let agentcat = AgentCat::new("proj_0000000", options);

Vendor Support

Exporters for OpenTelemetry, Datadog, Sentry and PostHog come in a later release of the Rust SDK; this release forwards no telemetry to them.

Internal diagnostics

SDK diagnostics come in a later release of the Rust SDK; this release sends AgentCat no diagnostics, and the SDK writes its own log lines only to ~/agentcat.log (or to tracing, with LogDestination::Tracing).

Rust runtimes

  • tokio: a tracked server needs a tokio runtime with the time driver. #[tokio::main], #[tokio::test] and tokio::runtime::Runtime::new turn it on; a runtime from tokio::runtime::Builder needs enable_time or enable_all. AgentCat runs the hooks as Send tasks on the runtime of the call, so it does not support the local feature of rmcp.
  • Delivery: each AgentCat handle sends its events from its own thread, agentcat-delivery, so a tool call never waits for the network. The queue holds at most 10 000 events, and a failed send gets 3 more attempts, after 1, 2 and 4 s.
  • stdio: AgentCat writes nothing to stdout or stderr, so stdout stays JSON-RPC. The fmt subscriber of tracing-subscriber writes to stdout unless you give it another writer, such as .with_writer(std::io::stderr). When the client closes stdin, the drop of the last handle delivers the queued events, for up to 5 s.
  • HTTP servers and containers: AgentCat installs no signal handler. Call AgentCat::shutdown after your server stops, and stop on SIGTERM as well as Ctrl-C, as the stateless_http.rs example does.
  • Current-thread runtimes: a drop of the last handle on the runtime's only thread blocks that thread, so no hook can run during the wait; call shutdown before the runtime ends.
  • Process exit: the drain of the last drop does not run after std::process::exit, or after a panic with panic = "abort". Call shutdown, or shutdown_blocking, which needs no tokio runtime, first.
  • TLS: the default rustls feature connects to the AgentCat API with rustls. For the TLS library of the platform, turn off the default features and turn on native-tls. Without a TLS feature, AgentCat can send events only to an http: URL.
  • serde_json: AgentCat turns on the preserve_order feature of serde_json, so that mcp_session stays the first key of a result. Cargo turns a feature on for the whole build, so every serde_json::Map in your program keeps the order of insertion and does not sort its keys.

AgentCat supports Rust 1.88 and later, the minimum supported Rust version (MSRV) of rmcp 3, and rmcp 3.x from 3.0.0. The MSRV is never higher than the MSRV of the rmcp versions that the crate supports, and it is always a Rust release that is at least 6 months old; an MSRV increase is a minor release with its own CHANGELOG section. On every pull request and every push to main, CI reads crates.io and tests the newest patch of each rmcp 3.x minor release, from 3.0.0 up, and runs the tests and the doctests on Rust 1.88. The crate guide covers the options, the environment variables, delivery and shutdown, logging and testing, and its section on the known limits lists the rest.

Free for open source

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

Already using AgentCat? We'll upgrade your account immediately.

Community Cats 🐱

Meet the cats behind AgentCat! Add your cat to our community by submitting a PR with your cat's photo in the docs/cats/ directory of the TypeScript SDK, whose cats this README shows too.

Want to add your cat? Create a PR adding your cat's photo to docs/cats/ of the TypeScript SDK and update its README!