Skip to main content
This page covers a server built with the official Rust MCP SDK, rmcp, and the agentcat-sdk crate that tracks it: a stdio server, and a streamable HTTP server that is stateful or stateless. Rust runtimes says what the SDK needs from your tokio runtime and how it delivers the events. Rust: behavior and limits lists the supported versions and the known limits. The crate guide on docs.rs is the full reference. Each Rust snippet on this page is an excerpt of a compiled source in the crate: one of its examples or one of the doctests of its documentation. A line that the excerpt leaves out is marked // .... Calculator is the server of the quickstart, and Demo is the server of the examples.

Requirements

  • Rust 1.88 or later.
  • rmcp 3.0.0 or a later 3.x release. CI tests the latest patch of each 3.x minor release.
  • A tokio runtime with the time driver. #[tokio::main] and #[tokio::test] turn it on (see The tokio runtime).
  • One TLS feature of agentcat-sdk: rustls (on by default) or native-tls, for the connection to the AgentCat API.
To use native-tls, turn off the default features:
Rust: behavior and limits lists the features, and the test-util feature that the Testing section uses.

Track the server

agentcat_sdk::track wraps your ServerHandler with a Tracked server. Serve the Tracked server where you served your handler: With use agentcat_sdk::Options; and rmcp::ServiceExt in scope:
Replace proj_... with the ID of your project. To read the project ID from the AGENTCAT_PROJECT_ID environment variable instead, build the handle with AgentCat::from_env, and wrap the server with AgentCat::track:
  • AgentCat is the handle. AgentCat::new(project_id, options) and AgentCat::from_env(options) build it. It starts no thread until the first event or log line. A clone of the handle is cheap and shares everything with the original.
  • An empty project ID is telemetry only. The handle builds each event and runs your hooks, but sends nothing to AgentCat. from_env does the same when the variable is unset or empty.
  • Options sets the hooks and the switches. Options::default() has the defaults that the Options section of the crate guide lists, and each setter consumes the options and returns them changed:
The SDK pages show each option with a Rust tab: Identifying Users, Event Tags & Properties, Client-Side Redaction and Tool Call Context. The SDK runs the four tool-call hooks on the tokio runtime of the call, and it waits at most 30 seconds for each before it sends the event without that result.

stdio

A stdio server serves the Tracked server on rmcp’s stdio transport, which needs the transport-io feature of rmcp:
  • stdout is for JSON-RPC. The SDK writes nothing to stdout or stderr. Send your own logs, and your tracing output, to stderr (see Logging on stdio servers).
  • No shutdown call. The server ends when the client closes stdin. Then the last clone of the handle drops, and the drop delivers the queued events, for up to 5 seconds (see When the process stops).

Streamable HTTP

A streamable HTTP server builds a new server handler for each MCP session, or for each request when it is stateless. Build one AgentCat handle for the process, and wrap each new handler with AgentCat::track in the closure that rmcp calls. This server needs the transport-streamable-http-server feature of rmcp, the macros, rt-multi-thread, net and signal features of tokio, and axum.

Stateful, with server-sent events

The default StreamableHttpServerConfig of rmcp keeps one MCP session per client and answers with server-sent events (SSE):
The SDK never groups calls by the mcp-session-id header of the transport. The session_id parameter that it adds to each tool groups them on every transport (see Stateless Servers).

Stateless, with JSON responses

In stateless mode, rmcp builds a new server handler for each request. The setup is the same. Only the configuration of the service changes:
A stateless server loses no sessions, because the session_id parameter groups the calls. On a stateless server, an event names the client only when the request names it in its _meta. Stateless Servers has the details.

Stop on SIGTERM and Ctrl-C

An HTTP server does not end when a client goes away, and the SDK installs no signal handler. So the server waits for a stop signal, and calls AgentCat::shutdown after that: the queued events then go out before the process ends. Containers and process managers stop a server with SIGTERM, and a server that does not catch it skips shutdown and loses the queued events. This stop_signal function of the streamable_http and stateless_http examples resolves on SIGTERM (Unix) or Ctrl-C:

The call context in a tool

The SDK gives each tool call a CallContext. A tool reads it as an Extension<CallContext> parameter, next to its Parameters<..>:
  • session_id() returns the session of the call: the ID that the agent sent, or the one the SDK issued for this call. It returns None when the call has no session, for example when the agent sent a value that this server did not issue, when the tool declares its own session_id parameter, when the options set a resolve_session_id hook, or when the SDK does not trace the call.
  • agent_id() returns the agent_id that the agent sent, when agent tracking is on.
  • agentcat() returns the handle that tracks the call, so a tool needs no clone of it.
  • publish_custom_event publishes a custom event with the client and the server of this call.
A custom event has no session unless its CustomEvent names one. Pass call.session_id() to CustomEvent::session_id to put the event in the session of the call:
The event goes to the project of the handle: the project ID argument only has to be non-empty. The redact_event hook runs on the event, and redact_sensitive_information does not. rmcp turns an error that a tool returns into the text of an error result, so the event gets only that text. Call capture_error before the tool returns the error, and the event also gets the type of the error and the texts of its source() chain:

Custom events from outside a tool

Code that runs outside a tool call has no CallContext. Publish from the handle with the session ID, the project ID and the event. The string is the session ID of the event, unchanged, unless the CustomEvent has a session_id that is not empty. This call needs no tracked server, and it runs no hook of a server: the event has no server or client fields, and no redaction hook runs for it.
For an event with the client and the server of a tracked server, call Tracked::publish_custom_event on the Tracked server:
Both calls only queue the event and return at once. An empty project ID returns Error::ProjectIdRequired, unless AGENTCAT_ENABLED turned the SDK off. Custom Events covers the fields of the event.

Testing

AgentCat stays on in tests unless you set AGENTCAT_ENABLED to false. To assert on what a tracked server publishes, turn on the test-util feature of the SDK. It adds the testing module with testing::Fake. The fake builds handles whose events stay in the process, and it records each event as a real handle sends it, after the hooks and the redaction. The test below serves a tracked server to an rmcp client in memory, calls a tool, and makes sure that the fake recorded the call. It needs these dev-dependencies:
In a #[tokio::test]:
Fake::events returns the recorded events, for checks of your own. Fake also has assert_tracked_count, assert_nothing_tracked and assert_custom_event_published. To unit-test a redact_event hook, call it with an event from Event::default(), and set the fields that the hook reads. Run this in an async test that returns a Result<(), HookError>:
A client or a test that reads the first content block of the first call of a task gets the text block that begins [session_id issued, not the content of the tool (see Stateless Servers).

When the process stops

The SDK delivers the events from a thread of its own. What you do so that the last events go out depends on the server:
  • flush and shutdown. AgentCat::flush(timeout) waits until the handle delivered every event that it took, for at most timeout. AgentCat::shutdown(timeout) does the same, then stops the handle for good: the handle drops every later event. Both return true when the events went out in time:
  • The drain of the last drop. When the last clone of a handle drops, the SDK runs shutdown with a timeout of 5 seconds. The drop blocks the dropping thread until the shutdown ends. The Tracked servers hold clones of the handle too. The drain does not run after std::process::exit, or after a panic with panic = "abort".
  • A current-thread runtime. A drop on the thread of the runtime blocks its only thread, so no hook can run during the wait. Call shutdown before the runtime ends.
  • A hook that holds a clone of its handle makes a reference cycle, and the handle never drops. Let the hook capture only what it needs, or call shutdown before the process ends.
Flush and shutdown and The drain of the last drop have the details.