mcpulse

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT Imports: 22 Imported by: 0

README

mcpulse-go

Analytics for MCP servers, for the official Go MCP SDK.

One import, one wrap — see which of your tools actually work for the models calling them.

import (
    "github.com/modelcontextprotocol/go-sdk/mcp"
    mcpulse "github.com/getmcpulse/mcpulse-go"
)

server := mcp.NewServer(&mcp.Implementation{Name: "my-server"}, nil)
mcpulse.Watch(server, mcpulse.Options{Key: "mp_live_…"})

The server is instrumented in place and handed straight back, so the call drops in around an existing server without moving anything else. It attaches through the SDK's public AddReceivingMiddleware — nothing here reaches for an unexported field.

Install

go get github.com/getmcpulse/mcpulse-go

Options

Field Default Meaning
Key Ingest key, mp_live_…, minted per MCP in the dashboard
Endpoint https://api.getmcpulse.com Point at a local API while developing
Disabled false true makes Watch a no-op — useful in tests and CI
Debug false Log what is sent, and why a send failed, to stderr

Disabled is negative rather than an Enabled bool so the zero value of Options is the working one. An empty Key also turns it off, so a server started without its key configured is silent rather than a source of 401s.

Two things Go needs that other SDKs do not

WrapTool, for the full outcome split
mcp.AddTool(server, tool, mcpulse.WrapTool(myHandler))

Optional, and what it buys is precision. The Go SDK validates arguments against the input schema and, on failure, returns a CallToolResult with IsError set — byte for byte what a handler returning an error produces. From outside the handler the two are identical, and reading the difference back out of an error string is not an interface anyone promised to keep.

Wrap your handlers and MCPulse reports bad_args, crashed and tool_error separately. Don't, and validation failures arrive as tool_error. Everything else — timing, argument hashes, response sizes, emptiness — works from Watch alone either way.

FlushAll, if you shut down without a signal
defer mcpulse.FlushAll()

Go has no atexit. SIGINT and SIGTERM are handled for you (and forwarded on, so your own handler still runs), but a server that returns from main on its own needs this line, or 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.

Arguments are hashed straight from the raw wire bytes the client sent, before defaults are applied or unknown fields are dropped.

The three rules

  1. Never throw. Every entry point recovers. If MCPulse fails inside your tool call, your tool fails and you blame us.
  2. Never block. The wake channel is buffered and every send to it is a select-with-default, so a request path can never wait on the sender.
  3. Never store customer data. See above.

Cross-language consistency

ArgsHash is the first 12 hex characters of the SHA-256 of the RFC 8785 canonical form of the arguments. testdata/canonical.json is the shared conformance suite every MCPulse SDK runs, so a call hashed here and a call hashed by the TypeScript or Python SDK land in the same bucket.

Getting there took undoing three Go defaults: encoding/json HTML-escapes <, > and &; map keys sort by UTF-8 bytes where JCS sorts by UTF-16 code unit; and strconv writes 1e-07 where ECMAScript writes 1e-7.

Licence

MIT

Documentation

Index

Constants

View Source
const DefaultEndpoint = "https://api.getmcpulse.com"

DefaultEndpoint is where payloads go when Endpoint is not set.

View Source
const Unhashable = "000000000000"

Unhashable is what an argument set hashes to when it cannot be serialised.

View Source
const WireVersion = 1

WireVersion is bumped only for a breaking change; the API rejects anything else.

Variables

View Source
var ErrNotJSON = errors.New("mcpulse: value cannot be represented as JSON")

ErrNotJSON is returned for anything JSON cannot represent: NaN, an infinity, a channel, a cycle.

Functions

func ArgsHash

func ArgsHash(args any) string

ArgsHash is a short, one-way fingerprint of a call's arguments.

This is the only thing MCPulse ever learns about what was passed to a tool, and it is deliberately not enough to learn anything: 12 hex characters of a SHA-256 over the RFC 8785 canonical form, with no way back. All the product asks of it is "were these two calls made with the same arguments or different ones" — which is what separates a model retrying a reworded request from a client paging through results.

func ArgsHashRaw

func ArgsHashRaw(raw json.RawMessage) string

ArgsHashRaw hashes arguments still in their wire form.

The Go MCP SDK hands tool arguments over as json.RawMessage, so this is the path a real call takes: decoding here rather than accepting the SDK's typed struct means the hash is computed from what the client actually sent, before defaults are applied or unknown fields are dropped.

func Canonicalize

func Canonicalize(v any) (string, error)

Canonicalize returns the RFC 8785 canonical JSON form of v.

It is written for the shapes encoding/json produces when decoding into any — map[string]any, []any, float64, string, bool, nil — which is what arrives from the wire. Other Go values are marshalled to JSON and re-read first, so a struct canonicalises as the JSON it would have become.

func FlushAll

func FlushAll()

FlushAll sends everything buffered and stops accepting more.

Call it from a defer in main if the server shuts down without a signal — Go cannot hook process exit, so this is the one part of the SDK a Go server may have to invoke by hand.

func NewSessionID

func NewSessionID() string

NewSessionID identifies one run of the customer's server, so calls can be grouped and a cost-per-session worked out.

Random rather than derived — there is nothing about the process worth encoding here, and anything derived from the machine would be an identifier we did not intend to collect.

func Watch

func Watch(server *mcp.Server, opts Options) *mcp.Server

Watch starts recording what this MCP server does.

server := mcp.NewServer(&mcp.Implementation{Name: "my-server"}, nil)
mcpulse.Watch(server, mcpulse.Options{Key: "mp_live_…"})

The server is instrumented in place and handed straight back, so the call can be dropped around an existing one without moving anything else.

Nothing here is allowed to break the server it is measuring. If anything goes wrong attaching, the server is returned untouched and the process carries on without analytics, because a customer's tool failing over our telemetry is worse than no telemetry.

func WrapTool

func WrapTool[In, Out any](handler mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out]

WrapTool wraps a tool handler so MCPulse can tell a crash from a rejected argument set.

mcp.AddTool(server, tool, mcpulse.WrapTool(myHandler))

This is optional, and what it buys is precision. The Go SDK validates arguments against the input schema and, on failure, returns a CallToolResult with IsError set — the same shape a handler that returned an error produces. From outside the handler the two are identical, and reading the difference back out of an error string is not an interface anyone promised to keep.

Without it a call that failed validation is reported as tool_error rather than bad_args, and nothing else changes: timing, argument hashes, response sizes and emptiness all work from Watch alone.

Types

type Options

type Options struct {
	// Key is the ingest key, mp_live_…, minted per MCP in the dashboard.
	Key string
	// Endpoint overrides where payloads are sent. Point this at a local API
	// while developing.
	Endpoint string
	// Disabled makes Watch a no-op — useful in tests and CI.
	//
	// Negative rather than an Enabled bool so the zero value of Options is the
	// working one: mcpulse.Options{Key: k} must not be silently switched off.
	Disabled bool
	// Debug logs what is being sent, and why a send failed, to stderr.
	Debug bool
}

Options is everything Watch accepts.

type Outcome

type Outcome string

Outcome is how a tool call ended. Exactly one of these, always.

const (
	// OutcomeOK means the tool ran and returned a result.
	OutcomeOK Outcome = "ok"
	// OutcomeBadArgs means arguments failed validation and the handler never ran.
	OutcomeBadArgs Outcome = "bad_args"
	// OutcomeToolError means the tool ran and returned isError: true.
	OutcomeToolError Outcome = "tool_error"
	// OutcomeCrashed means the tool threw.
	OutcomeCrashed Outcome = "crashed"
)

type Payload

type Payload struct {
	V          int    `json:"v"`
	Type       string `json:"type"`
	SessionID  string `json:"session_id"`
	ClientName string `json:"client_name"`

	// startup
	Tools []ToolInfo `json:"tools,omitempty"`

	// call
	ToolName      string  `json:"tool_name,omitempty"`
	StartedAt     string  `json:"started_at,omitempty"`
	DurationMS    int64   `json:"duration_ms,omitempty"`
	Outcome       Outcome `json:"outcome,omitempty"`
	ResponseBytes int     `json:"response_bytes,omitempty"`
	IsEmpty       bool    `json:"is_empty,omitempty"`
	ArgsHash      string  `json:"args_hash,omitempty"`
}

Payload is a startup or a call record. One struct rather than two, because the batch is heterogeneous and omitempty keeps each shape to its own fields.

type ToolInfo

type ToolInfo struct {
	Name string `json:"name"`
	// SchemaBytes is what this tool costs the context window, every session,
	// called or not.
	SchemaBytes int `json:"schema_bytes"`
}

ToolInfo is one tool as the client will see it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL