mcpspan

Ruby SDK

Analytics for MCP servers. Find out which of your tools get called, by which client, how long they take, and which ones fail.

Your own logs tell you a tool ran. This tells you whether it was Claude, Cursor, or something you have not heard of, how that call compares to the other nine hundred, and whether the failures are your tool breaking or your tool politely saying no.

Install

bundle add mcpspan

For a server built on the official Ruby MCP SDK, the mcp gem, 1.6 or newer. Needs Ruby 3.2 or newer. Nothing else: delivery uses the standard library.

Use

One line, after the server is built:

server = MCP::Server.new(name: "flights", tools: [SearchFlights, BookFlight])
McpSpan.instrument(server, api_key: ENV["MCPSPAN_API_KEY"], endpoint: "http://localhost:6271")

MCP::Server::Transports::StdioTransport.new(server).open

Every tool on the server is measured, whether it was given to the server or added later with define_tool. Nothing about how you write tools changes, and each tool returns and raises exactly what it did before.

Over streamable HTTP, instrument the server you hand the transport:

transport = MCP::Server::Transports::StreamableHTTPTransport.new(McpSpan.instrument(server))

With no api_key, it is read from MCPSPAN_API_KEY.

Sessions and clients

Calls are grouped into sessions when there is a connection to group them by: a stdio process, or an HTTP transport that hands out session IDs. A stateless HTTP endpoint, and every endpoint on the 2026-07-28 protocol, which dropped sessions, records calls without one.

The client is read from the call itself on 2026-07-28, where each request names its client, and from the handshake on 2025-11-25.

A tool that asks the client for more before it can finish is one call however many round trips that takes.

Without a key

If there is no key, nothing is collected, nothing is sent, and no thread is started. That makes it safe to leave in place in tests, in CI, and in a fork somebody is only reading.

One tool at a time

For a server instrument does not cover, track the tool class:

McpSpan.track(SearchFlights)

A tracked tool on an instrumented server is counted once.

Leaving a tool out

McpSpan.exclude(HealthCheck)

For tools called by machinery rather than by an agent. A health check polled every few seconds outnumbers everything a person does and drags the whole server's error rate and response time towards its own. The mark sits on the tool class, so a rename carries it along. A tool made with define_tool has no class of its own, and is left out by name:

server.define_tool(name: McpSpan.exclude("health_check")) { MCP::Tool::Response.new([]) }

Resources and prompts

Reads of your resources and gets of your prompts are measured too, with nothing to add: each is one event, in the same session and from the same client as the tool calls around it, and the dashboard shows them in a card of their own and in each session's timeline. Listings are not recorded.

A resource at a fixed address is named by that address. One read through a template is named by the template, trips://{id}, never by the address the client asked for, which can carry a user's data; the template's variables are its parameters, by name only. A read of an address the server has nothing for is named by its scheme alone, db://. A prompt is named by its name, and its arguments are its parameters, as a tool's are.

Resources and templates defined as classes, and ones answered by your own resources_read_handler, are named alike.

Versions

Every call carries the version of the server that answered it, so the dashboard marks where each release began and compares it with the one before. There is nothing to add: it is the version the server gives itself, MCP::Server.new(name: "flights", version: "1.4.0"). A server that gives none is announced by the gem as 0.1.0, and recorded so. To record a commit or a deploy instead, set server_version (or MCPSPAN_SERVER_VERSION). The client's version is recorded beside its name.

Shutting down

What is queued is delivered as the program exits, so most servers need nothing here. If yours has its own shutdown path and you want to be explicit:

McpSpan.shutdown

A process killed outright runs nothing after that, and the last few seconds of calls go with it.

Two kinds of failure

MCP asks tools to report their own errors inside the result, with error: true on the MCP::Tool::Response, so the model can see what went wrong. An exception is the deviation from that, and usually means the tool broke.

Both are recorded, and each event says which happened, with the exception's class for the second: a BookingError is recorded as BookingError, as your tool raised it, though the MCP SDK hands the client a generic internal error.

And two that never reach your tool

Calls the server refuses on its own are recorded too: arguments that are missing or fail the tool's input schema, and names it has no tool for. A refused call carries no message, because validation text can quote back what the agent sent. Whether a call reached your tool is observed directly, not read from the server's wording.

For a refusal of arguments, the SDK also says which ones. It checks what the agent sent against the input schema your server listed, in your process, and records the top-level arguments that did not match, by the names the schema declares, so the tool's page can show that 29 refusals were all passengers. Values are never sent, and neither is a name the agent made up. It works from the latest tools/list your server answered in the same process; a refusal before any listing names nothing.

Privacy

Parameter values never leave your process. Not by default, not in any mode, not in debug.

What is collected: the tool name, how long it took, whether it succeeded, the error type and a truncated message when it did not, how large the answer was in bytes (its size only, never its content), whether it repeated the previous call's arguments to the same tool in its session (compared in your process; the arguments, or any digest of them, never leave it), a fingerprint of the tool's definition as your server lists it (its name, title, description and input schema, hashed, so the dashboard can mark when you changed it), for a call your server refused for its arguments the names of those that did not match the tool's schema (never what was sent), which client called, and the SDK version. When your server answers a request for its list of tools: each tool's name, that fingerprint and the size of its definition in bytes, once per session, so the dashboard can show what the list costs an agent and which tools are never called. For a resource or a prompt, the same, under the name it was registered with: never the address a client read, only its template or, for an address the server does not have, its scheme.

Optionally, parameter names and types:

McpSpan.instrument(server, capture_parameter_names: true)

That records {"destination": "string", "passengers": "number"}, in JSON's vocabulary, as the client sent them. Knowing search_flights is always called with destination and never with departure_date tells you your tool description is not landing. Knowing which destination tells you nothing you needed, and puts your users' data somewhere it does not belong.

Error messages are sent, cut short, because they are usually what says why a call failed. If your tools can fail with text you would not send anywhere, as a tool that runs commands or reads files might quote a path or a token, turn them off:

McpSpan.instrument(server, capture_error_messages: false)

Every failure is still recorded, with where it came from and the exception's class. Only the text is left out.

Self-hosting

McpSpan.instrument(server, endpoint: "https://mcpspan.example.com")

Or set MCPSPAN_ENDPOINT. There is no default: events go only where you point them. With a key and no endpoint, nothing is collected, and the SDK says so once on standard error.

When it starts with a key, the SDK sends one empty batch to say it is there. That is how the dashboard's Status page tells a server nobody has used yet from one pointed at the wrong address, and how a wrong key is reported when your server starts rather than at its first tool call.

It will not break your server

Options

Keywords of McpSpan.instrument and McpSpan.configure.

OptionDefaultWhat it does
api_keyMCPSPAN_API_KEYIdentifies your server. Without it, nothing is collected.
endpointMCPSPAN_ENDPOINT; noneYour mcpspan installation. Nothing is collected without it.
capture_parameter_namesfalseRecords parameter names and types, never values.
capture_error_messagestrueSends the text of a failure, cut short. Off sends that it failed and how, without the text.
server_versionMCPSPAN_SERVER_VERSION, then the server's ownThe version to record calls under: a release, a tag, a commit.
debugfalseWrites delivery diagnostics to standard error.
on_diagnostic-Receives diagnostics instead. Implies debug.
flush_on_exittrueDelivers what is queued as the program exits.
flush_interval5 secondsHow long a partly filled batch waits.
max_batch_size100Events per request. Reaching it sends early.
max_queue_size10000Events held while delivery is failing.

Configuring again with the same settings changes nothing.

Developing

bundle install
bundle exec rake

The SDK follows the contract every mcpspan SDK follows, checked by the suite in conformance/.

Licence

MIT.

This page is packages/mcpspan-ruby/README.md on GitHub.