Skip to content

About

Analytics for MCP servers - rust SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mcpulse

Analytics for MCP servers, in Rust.

getmcpulse.com · Docs · Dashboard

use mcpulse::{McPulse, Options};

let mcpulse = McPulse::new(Options::new("mp_live_…"));

// Around your tool handler:
let result = mcpulse.record("search", Some(&arguments), || {
    my_handler(&arguments)
});

Wrapping the handler rather than watching from outside is what lets MCPulse tell a handler that returned an error from one whose result carried isError — a distinction an MCP server erases before anything outside can see it.

Install

[dependencies]
mcpulse = "0.1"

Four small dependencies and no async runtime. This crate links into other people's servers, and forcing a choice between tokio and async-std for a single POST — one that already happens on a thread of its own — is a cost with no buyer.

Options

Builder Default Meaning
Options::new(key) — Ingest key, mp_live_…, minted per MCP in the dashboard
.endpoint(…) https://api.getmcpulse.com Point at a local API while developing
.enabled(false) enabled Makes every method a no-op — useful in tests and CI
.debug(true) off Log what is sent, and why a send failed, to stderr
.agree(…) off Fields that more than one tool returns. See below
.agree_window(…) 5 min How long a value stays comparable
.agree_tolerance(…) 0.0001 Relative, for floats

An empty key turns it off, so a server started without its key configured is silent rather than a source of 401s on every flush.

Cross-tool agreement

Opt-in, and it catches the failure every other metric here calls healthy.

Two tools return the same underlying field. A stale cache key, or a versioned key a cron did not follow, and they disagree for hours. Every number stays green the whole time — the calls succeed, the results are non-empty, there are no retries and the latency is fine. Callers get two different answers to one question and nothing reports it.

Name the value once, and say where each tool returns it:

use std::collections::BTreeMap;
use mcpulse::{AgreementSite, McPulse, Options};

let mcpulse = McPulse::new(
    Options::new(std::env::var("MCPULSE_KEY").unwrap_or_default()).agree(BTreeMap::from([(
        "global_liquidity".to_string(),
        vec![
            AgreementSite::new("getGlobalLiquidity", "globalLiquidity.value_t"),
            AgreementSite::new("getPillars", "pillars.global_liquidity.value"),
        ],
    )])),
);

A map rather than a list of field names, because the same value routinely ships under a different name and a different shape in each tool — which is most of why two copies of it drift apart without anyone noticing.

The comparison happens in your process, and only the verdict is sent.

{
  "v": 1, "type": "agreement", "session_id": "s_7f2a91",
  "field": "global_liquidity",
  "tool_a": "getGlobalLiquidity", "tool_b": "getPillars",
  "agreed": false, "checked_at": "2026-09-11T14:22:31Z", "window_ms": 300000
}

No value, no difference, no hash of a value. Hashing could not work anyway: 25.22, 25.220 and "25.22" are the same number and three different hashes, so a server-side check would report every representation change as a divergence forever.

Four things worth knowing before turning it on:

  • .agree_window(…) must be shorter than your data's refresh interval. A window that outlives a refresh compares a figure against its own predecessor and calls a legitimate change a divergence.
  • Integers and strings are compared exactly. The tolerance is relative and applies to floats only — a count that is off by one is off by one.
  • Nothing is reported until both tools have been called inside one window. A low-traffic tool can stay silently wrong for a long time, which is a limit of the method rather than a clean result. Run once with .debug(true) after setting it up: a path that never resolves says so there, which is how a typo'd declaration shows up as something other than a passing check.
  • The path is searched in structuredContent, in the JSON of a text content part, and in the result itself, so it does not matter which envelope your tool returns.

Flushing

Rust has no process-exit hook, so call mcpulse::flush_all() when your server shuts down — otherwise the last few seconds of calls go unsent.

What leaves your process

Sizes and hashes. Arguments and results do not, and no option turns that on.

args_hash_raw hashes arguments straight from the wire bytes, before defaults are applied or unknown fields are dropped.

The three rules

  1. Never panic. #![forbid(unsafe_code)], and every path swallows. Your error is returned untouched; ours never reach you.
  2. Never block. The queue is a mutex around a VecDeque rather than a bounded channel, precisely so that pushing past the ceiling drops the oldest entry instead of parking the caller — which is what a bounded send would do on the path a model is waiting on.
  3. Never store customer data. See above.

Cross-language consistency

args_hash is the first 12 hex characters of the SHA-256 of the RFC 8785 canonical form of the arguments. tests/canonical.json is the shared conformance suite every MCPulse SDK runs.

Rust needed two things undone. {} on an f64 is shortest-round-tripping but writes 1000000000000000000000 for 1e21 and 0.0000001 for 1e-7, where ECMAScript switches to exponent form at both. And RFC 8785 sorts keys by UTF-16 code unit while Rust's str ordering is by UTF-8 byte — the two disagree above the BMP, where U+1F680 (the surrogate pair D83D DE80) sorts before U+FFFD.

Licence

MIT

About

Analytics for MCP servers - rust SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages