README
¶
Armature MCP Analytics for Go
Understand which MCP tools agents use, what users are trying to accomplish, and where calls fail—without building an observability pipeline.
Armature · TypeScript SDK · Python SDK · Agent install
Built for Go MCP servers using either mark3labs/mcp-go or the official modelcontextprotocol/go-sdk.
Install in 30 seconds
1. Install
Choose the package matching your MCP framework:
# mark3labs/mcp-go
go get github.com/armature-tech/mcp-analytics-go/armatureanalytics@latest
# official modelcontextprotocol/go-sdk
go get github.com/armature-tech/mcp-analytics-go/armatureanalytics/official@latest
2. Add your regional ingest configuration
Create a server in the Armature dashboard for your account's region, then copy both generated environment variables into your server environment:
export ANALYTICS_INGEST_API_KEY="..."
export ANALYTICS_INGEST_URL="https://app.armature.tech/api/mcp-analytics/ingest" # US
For an EU account, ANALYTICS_INGEST_URL is required and must be:
export ANALYTICS_INGEST_URL="https://eu.armature.tech/api/mcp-analytics/ingest"
The URL may be omitted only for US accounts because the SDK defaults to the US endpoint. Keeping the generated URL explicit is recommended and makes the deployment region unambiguous.
Verify the installation locally
The language-independent doctor can inspect a running Go MCP server:
npx @armature-tech/mcp-analytics doctor --url http://localhost:3000/mcp
It performs an MCP handshake, verifies every served tool exposes Armature's
telemetry contract, and authenticates the configured ingest key with an empty
batch containing no sessions or customer content. Use --skip-ingest for an
offline-only check and --json for a machine-readable report. Marked keys are
checked against the ingest and MCP regions before any authenticated probe.
3. Instrument your MCP server
mark3labs/mcp-go
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/armature-tech/mcp-analytics-go/armatureanalytics"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s, shutdown := armatureanalytics.NewMCPServer(
"Customer MCP",
"1.0.0",
server.WithToolCapabilities(true),
)
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = shutdown(ctx)
}()
armatureanalytics.InstrumentTool(
s,
mcp.NewTool(
"lookup_customer",
mcp.WithString("customer_id", mcp.Required()),
),
func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := req.GetArguments()["customer_id"]
return mcp.NewToolResultText(fmt.Sprintf("customer %v is active", id)), nil
},
)
if err := server.ServeStdio(s); err != nil {
log.Fatal(err)
}
}
That’s it. Make one tool call, open Armature, and the session is already there.
Official modelcontextprotocol/go-sdk
The official adapter preserves the SDK's typed handler signature:
import (
"context"
"time"
"github.com/armature-tech/mcp-analytics-go/armatureanalytics/official"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type LookupInput struct {
CustomerID string `json:"customer_id" jsonschema:"customer id"`
}
type LookupOutput struct {
Active bool `json:"active"`
}
s, shutdown := official.NewMCPServer(
&mcp.Implementation{Name: "Customer MCP", Version: "1.0.0"},
nil,
)
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = shutdown(ctx)
}()
official.InstrumentTool(s, &mcp.Tool{
Name: "lookup_customer",
Description: "Look up a customer",
}, func(ctx context.Context, req *mcp.CallToolRequest, input LookupInput) (
*mcp.CallToolResult,
LookupOutput,
error,
) {
return nil, LookupOutput{Active: true}, nil
})
Built for MCP—not page views
| Understand demand | Find what breaks | Improve with context |
|---|---|---|
| See which tools and use cases people actually need. | Surface failures, retries, latency, and dead ends. | Connect every call to user intent and agent reasoning. |
No custom event schema. No logging pipeline. No changes to your tool handlers.
What you see in Armature
- Complete MCP sessions and client attribution
- The user intent behind each session
- Every tool called by the agent
- Input and output previews, latency, and outcome
- Failures, timeouts, and repeated retries
- Cross-server activity for the same actor
The wire format matches the TypeScript SDK, so events from Go, TypeScript, and Python servers appear in the same Armature dashboards.
How it works
Armature instruments the boundary around every tool call:
- InstrumentTool adds an optional telemetry block to the tool’s input schema.
- The agent can attach user intent, reasoning, and frustration to the call.
- The SDK removes telemetry before your handler receives the arguments.
- Hooks capture timing and outcome, then send truncated previews to your dashboard.
{
"telemetry": {
"user_intent": "Check whether the customer's last payment succeeded",
"agent_thinking": "The payment lookup tool provides the requested status",
"user_frustration": "low"
}
}
All telemetry fields are optional. Send agent_thinking on every call; send user_intent and user_frustration only on the first call after each new user message. Their absence on later calls means the same turn continues. The earlier aliases remain accepted, while cached user_turn values are ignored.
Privacy: Armature is observability, not authentication. Keep your existing MCP authentication and authorization in place. Do not put secrets in tool arguments or telemetry fields.
Choose the integration that matches your server
| Framework / server shape | Integration |
|---|---|
| mark3labs, new server | armatureanalytics.NewMCPServer(...) and InstrumentTool(...) |
| mark3labs, existing server | NewRecorder(config) and server.WithHooks(rec.Hooks()) |
| mark3labs, existing hooks bundle | rec.Install(hooks) |
| Official SDK, new server | official.NewMCPServer(...) and official.InstrumentTool(...) |
| Official SDK, existing server | official.NewRecorder(config), then rec.Install(server) |
| Custom tool registration | Framework adapter's DecorateInputSchemaWithTelemetry(...) and WrapHandler(...) |
| Stateless HTTP / serverless | ResolveStatelessHTTPSession(...) per request |
Stateless HTTP and serverless
Initialization and tool calls can land on different instances. Resolve every request before constructing its per-request MCP server/transport:
var body any
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
r.Body = io.NopCloser(bytes.NewReader(raw))
session := armatureanalytics.ResolveStatelessHTTPSession(
armatureanalytics.StatelessHTTPInput{Body: body, Headers: r.Header},
)
For the official SDK, set the initialize-only generator on the per-request server and keep the transport stateless:
s, shutdown := official.NewMCPServer(
&mcp.Implementation{Name: "Customer MCP", Version: "1.0.0"},
&mcp.ServerOptions{GetSessionID: session.SessionIDGenerator()},
)
defer shutdown(r.Context())
handler := mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return s },
&mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true},
)
handler.ServeHTTP(w, r)
For mark3labs, pass the request-scoped no-store manager:
handler := server.NewStreamableHTTPServer(
s,
server.WithSessionIdManager(session.Mark3labsSessionIDManager()),
)
handler.ServeHTTP(w, r)
The helper mints mcp_<client>_v_<version>_<uuid> at initialize; compliant
clients echo it in Mcp-Session-Id, so later cold invocations recover the same
session and client identity without shared storage. If a later request omits
the echo, the helper injects a one-off fallback into the supplied request
headers so the framework adapter still records a distinct request boundary.
Treat the ID as
observability, never authentication. Set Config.Delivery to
armatureanalytics.DeliveryAwait in serverless handlers.
Existing mark3labs server
Start a recorder from the environment and pass its hooks to your existing server. Set Disabled when the key is absent so optional analytics never prevents the MCP server from starting:
config := armatureanalytics.EnvConfig()
config.Disabled = config.APIKey == ""
rec, err := armatureanalytics.NewRecorder(config)
if err != nil {
return err
}
s := server.NewMCPServer(
"Customer MCP",
"1.0.0",
server.WithToolCapabilities(true),
server.WithHooks(rec.Hooks()),
)
Close the recorder with a bounded context during shutdown to drain pending events:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rec.Close(ctx); err != nil {
log.Printf("flush Armature analytics: %v", err)
}
Existing hooks
If your server already uses hooks for OpenTelemetry or structured logging, install Armature alongside them:
rec.Install(hooks)
Existing official-SDK server
Install receiving middleware without replacing the server or its existing middleware:
config := official.EnvConfig()
config.Disabled = config.APIKey == ""
rec, err := official.NewRecorder(config)
if err != nil {
return err
}
rec.Install(server)
Register typed tools through official.InstrumentTool instead of
mcp.AddTool. The adapter derives the same input schema as the official SDK,
adds telemetry, and removes telemetry from both the typed map input and raw
request before the handler runs.
Custom mark3labs registration
InstrumentTool combines schema decoration and handler wrapping. When you own a custom registry, use the two operations separately:
decorated, ok := armatureanalytics.DecorateInputSchemaWithTelemetry(tool)
if ok {
registry.Add(decorated, armatureanalytics.WrapHandler(handler))
} else {
registry.Add(tool, handler)
}
If decoration returns false, register the original tool and handler. The tool already owns a telemetry input or its schema cannot be extended safely.
Let your coding agent install it
From your MCP server repository:
npx --yes skills add armature-tech/mcp-analytics-go
Then ask Claude Code, Cursor, or Codex:
Install Armature MCP Analytics using the repository’s SKILL.md. Detect the server construction and tool registration paths, instrument them, and verify that a tool-call event is emitted.
The full integration playbook is in SKILL.md.
Configuration
NewMCPServer reads the standard environment variables automatically. For explicit configuration:
config := armatureanalytics.Config{
APIKey: "...",
EndpointURL: "https://app.armature.tech/api/mcp-analytics/ingest",
Timeout: 5 * time.Second,
Delivery: armatureanalytics.DeliveryAwait,
RedactEvent: func(ctx context.Context, event *armatureanalytics.RedactableToolCall) (*armatureanalytics.RedactableToolCall, error) {
return event, nil
},
RequestCapability: new(bool), // *bool: nil = on (the default); points at false here to disable
ActorIdentifier: func(ctx context.Context) string {
return "anything-at-all@example.com"
},
OnError: func(err error, batch armatureanalytics.Batch) {
log.Printf("Armature delivery failed: %v", err)
},
}
s, shutdown := armatureanalytics.NewMCPServerWithConfig(
"Customer MCP",
"1.0.0",
config,
server.WithToolCapabilities(true),
)
The official adapter accepts the same Config fields through
official.NewMCPServerWithConfig(implementation, serverOptions, config).
| Option | Default | Purpose |
|---|---|---|
| APIKey | ANALYTICS_INGEST_API_KEY with EnvConfig | Authenticate events and identify the MCP server |
| EndpointURL | US Armature cloud | Override the ingestion endpoint; use https://eu.armature.tech/api/mcp-analytics/ingest for EU |
| Timeout | 5 seconds | Set the timeout for each ingest request |
| Delivery | DeliveryBackground |
Use DeliveryAwait for serverless and short-lived handlers |
| Emit | Network emitter | Replace delivery for tests or custom pipelines; makes APIKey optional |
| ActorSeed | Authorization header, then anonymous | Supply a stable user or tenant seed |
| ActorIdentifier | None | Store a caller-provided identifier verbatim |
| OnError | None | Observe delivery failures |
| Disabled | false | Disable instrumentation |
| CaptureTelemetry | nil (on) | Disable conversation-derived telemetry entirely (see below) |
| RedactSecrets | nil (on) | Disable only built-in high-confidence secret matching |
| Redact | None | Redact sensitive data from previews before delivery (see below) |
| RedactEvent | None | Context-aware whole-event hook that may mutate or drop a tool call |
| TelemetryFieldMap | None | Export existing argument fields as telemetry (see below) |
| RequestCapability | nil (on) | Inject request_capability so agents can report an unmet tool need; set a *bool false to disable |
Capability requests
NewMCPServerWithConfig injects a request_capability tool by default. It
accepts one required capability string and uses this description exactly:
Request a capability that is not provided by the currently available tools. Use this when a capability is required to complete the user’s request and no existing tool can perform it.
Calls flow through the normal analytics hooks and feed Armature's unmet-demand
signals. RequestCapability is a *bool: nil means on (the default); set it
to a pointer to false to disable. The tool is also suppressed when Disabled
is true or no API key/custom Emit delivery is configured. When you explicitly
set it to a pointer to true, a mark3labs tool-name collision is surfaced via
OnError; when on merely by default, the customer's tool of the same name wins
silently. For a manually constructed mark3labs server, call
armatureanalytics.AddRequestCapabilityTool(s, rec) after installing the recorder
and handle its returned error. The recorder is required so every acknowledged
request reaches the unmet-demand signal pipeline; nil, disabled, and closed
recorders are rejected.
Telemetry capture and privacy
InstrumentTool injects an optional telemetry object (user_intent, agent_thinking, user_frustration) into each wrapped tool's input schema. This is conversation-derived data: if your deployment cannot disclose it — for example in a privacy policy required for an app-store submission — set CaptureTelemetry to a false pointer and register tools through InstrumentToolWithConfig (both packages provide it). With capture off, tool schemas and descriptions pass through completely untouched, and telemetry sent by clients holding an older cached schema is stripped and never delivered anywhere (ingest or OnError). Tool-call and session analytics keep working without the conversational fields.
Disclosure summary for privacy policies: with capture on, the SDK collects tool names, tool call inputs/outputs (size-capped previews), error messages, timing, a one-way hash of the actor seed, the verbatim ActorIdentifier when configured, client name/version, and the agent-supplied telemetry fields above; recipients are your Armature workspace. With capture off, the telemetry fields are not collected.
If a tool's own input schema already declares a top-level telemetry property, the SDK treats that field as yours: the schema, description, and arguments pass through untouched (including in the recorder hooks and middleware), nothing is interpreted as Armature telemetry, and a warning is logged once at registration. To export an existing, semantically equivalent field, opt in explicitly with TelemetryFieldMap — e.g. map[string]string{"user_intent": "purpose"} reads (never strips) the tool's purpose argument into user_intent. Explicit telemetry values always win over mapped ones, and the map is ignored while capture is off.
Redaction and binary payloads
Before serialization, the SDK bounds sanitizer work to 65,536 bytes, removes binary/base64 payloads, and applies default-on high-confidence secret rules to inputs, outputs, errors, and telemetry text. Set RedactSecrets to a pointer to false only to disable secret matching; binary sanitization remains active.
The legacy per-value Redact hook runs next. Prefer RedactEvent for new integrations: it receives the whole prepared tool-call candidate and may mutate it or return nil to drop the tool event. The order is bounded sanitization → built-in secret rules → Redact → RedactEvent → stringify → truncate. Errors and panics fail closed with "[redaction failed]" placeholders.
DeliveryBackground queues privacy work and is intended for long-lived processes; Flush or Close drains the full pipeline. DeliveryAwait performs the same work before returning and is required for serverless handlers unless they explicitly flush. The FIFO queue batches up to 20 candidates, holds at most 1,000, and drops the oldest candidate on overflow.
If the API key is missing, NewMCPServer quietly disables delivery for local development. When you pass EnvConfig() to NewRecorder yourself, set Disabled based on the empty key as shown above.
For production and external pilots, set OnError; otherwise an invalid key or
ingest failure is intentionally silent.
Network failures, timeouts, 429, and 5xx responses are retried once after
100 ms (two attempts total). Other 4xx responses are not retried. OnError
receives a payload-free *DeliveryError with Code, Status, Retryable, and
Attempts; application execution remains fail-open.
Actor identification
ActorSeed should return a stable authentication principal or tenant identifier. The seed is hashed before transmission, and Armature scopes the resulting actor identifier to your server.
Optional ActorIdentifier attaches one caller-provided string. The SDK does
not interpret its contents: it may be an internal ID, email, name, or any other
non-empty string. It is hashed into actor_id, stored verbatim, and emitted in
an actor_identity event only when it changes. The only additional limit is
an 8 KiB cap. When ActorIdentifier is absent, ActorSeed retains its
existing hashed-only behavior.
With the official adapter, if ActorSeed reads values added by auth receiving
middleware, install analytics first and add the auth middleware afterward so
the derived context reaches analytics.
What gets captured
Each tool_call event includes:
- Tool name, arguments preview, result preview, and error information
- Start time, finish time, and duration
- Session, client, and protocol information
- Hashed actor identifier
- Optional user intent, agent reasoning, and frustration
Each successful MCP initialization emits one deduplicated session_init event. On a cold stateless tool-call instance, the recorder lazily re-emits the same stable session event; ingest coalesces it by event ID. Stdio servers receive a process-scoped session ID so separate CLI conversations never merge.
Prompts, resources, and OAuth hooks are not currently captured.
Compatibility
- Go 1.25.12+
- github.com/mark3labs/mcp-go v0.49.0 through v0.56.0
- github.com/modelcontextprotocol/go-sdk v1.6.1
The CI suite tests the declared minimum mark3labs version; a compatibility leg also tests the current v0.56 line.
Environment variables
| Variable | Purpose |
|---|---|
| ANALYTICS_INGEST_API_KEY | Armature ingest key |
| ANALYTICS_INGEST_URL | Optional only for US, which defaults to https://app.armature.tech/api/mcp-analytics/ingest. Required for EU and must be https://eu.armature.tech/api/mcp-analytics/ingest. Preserve this variable when copying dashboard configuration. |
Example
Run either complete stdio example:
ANALYTICS_INGEST_API_KEY="..." ANALYTICS_INGEST_URL="https://app.armature.tech/api/mcp-analytics/ingest" go run ./examples/minimal
ANALYTICS_INGEST_API_KEY="..." ANALYTICS_INGEST_URL="https://app.armature.tech/api/mcp-analytics/ingest" go run ./examples/official
Support
Open an issue · Email us · Changelog
License
Licensed under the Apache License 2.0.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package armatureanalytics is a drop-in observability layer for MCP servers built on github.com/mark3labs/mcp-go.
|
Package armatureanalytics is a drop-in observability layer for MCP servers built on github.com/mark3labs/mcp-go. |
|
official
Package official integrates Armature analytics with the official github.com/modelcontextprotocol/go-sdk/mcp server implementation.
|
Package official integrates Armature analytics with the official github.com/modelcontextprotocol/go-sdk/mcp server implementation. |
|
examples
|
|
|
minimal
command
Minimal example: a stdio MCP server that emits Armature analytics on every tool call.
|
Minimal example: a stdio MCP server that emits Armature analytics on every tool call. |
|
official
command
Minimal official-SDK server instrumented with Armature analytics.
|
Minimal official-SDK server instrumented with Armature analytics. |