From 39b2f2eb70b0a6244c45fe187b0714185383daf0 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Mon, 18 May 2026 13:14:14 +0200 Subject: [PATCH] feat(server): add WithMeter for OTEL-style metrics on requests and tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror WithTracer's design for metrics. A new abstract metrics/ package exposes Meter / Counter / Histogram / Attribute interfaces with a noop default, so mcp-go itself stays free of any OTEL dependency. A new server.WithMeter(metrics.Meter) installs the meter and registers the following instruments: - mcp.request.calls (counter, "{call}") with attributes mcp.method, mcp.session.id (when set), mcp.protocol.version (from the Mcp-Protocol-Version header), outcome (ok|error). - mcp.request.duration (histogram, "s") with the same attributes. - mcp.tool.calls (counter, "{call}") with attributes mcp.tool.name, outcome (ok|error|error_result). - mcp.tool.duration (histogram, "s") with the same attributes. When a Tracer is also installed via WithTracer, the OTEL SDK attaches exemplars carrying the active span's TraceID/SpanID to histogram observations (the SDK does this automatically with its default TraceBasedFilter), enabling "click latency bucket → jump to trace" pivots in Grafana / Tempo. The OpenTelemetry adapter at github.com/mark3labs/mcp-go/otel grows WithServerMetrics(otelmetric.Meter) plus a NewMeter helper, matching the existing tracer adapter API. --- metrics/metrics.go | 50 +++++ metrics/metrics_test.go | 23 +++ otel/go.mod | 3 +- otel/meter.go | 73 +++++++ otel/meter_test.go | 90 +++++++++ otel/options.go | 9 + server/internal/gen/request_handler.go.tmpl | 3 + server/metrics.go | 121 +++++++++++ server/metrics_test.go | 210 ++++++++++++++++++++ server/request_handler.go | 3 + server/server.go | 4 + 11 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 metrics/metrics.go create mode 100644 metrics/metrics_test.go create mode 100644 otel/meter.go create mode 100644 otel/meter_test.go create mode 100644 server/metrics.go create mode 100644 server/metrics_test.go diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 000000000..9a484685f --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,50 @@ +// Package metrics defines the metric interfaces used by the mcp-go server. +// Concrete implementations live in adapter modules; an OpenTelemetry +// adapter ships at github.com/mark3labs/mcp-go/otel. +package metrics + +import "context" + +// Attribute is a string key/value pair attached to a metric observation. +type Attribute struct { + Key, Value string +} + +// String returns an Attribute with the given key and value. +func String(key, value string) Attribute { + return Attribute{Key: key, Value: value} +} + +// Meter creates instruments. Implementations are expected to deduplicate +// instruments by name, so calling Counter("x") twice returns equivalent +// recorders. +type Meter interface { + Counter(name, description, unit string) Counter + Histogram(name, description, unit string) Histogram +} + +// Counter is a cumulative integer counter (e.g. number of requests). +type Counter interface { + Add(ctx context.Context, n int64, attrs ...Attribute) +} + +// Histogram records a distribution of values (e.g. request latencies). +type Histogram interface { + Record(ctx context.Context, value float64, attrs ...Attribute) +} + +// NoopMeter returns a Meter whose instruments record nothing. +func NoopMeter() Meter { return noopMeter{} } + +type noopMeter struct{} + +func (noopMeter) Counter(string, string, string) Counter { return noopCounter{} } +func (noopMeter) Histogram(string, string, string) Histogram { return noopHistogram{} } + +type noopCounter struct{} + +func (noopCounter) Add(context.Context, int64, ...Attribute) {} + +type noopHistogram struct{} + +func (noopHistogram) Record(context.Context, float64, ...Attribute) {} diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go new file mode 100644 index 000000000..ff2cbaf38 --- /dev/null +++ b/metrics/metrics_test.go @@ -0,0 +1,23 @@ +package metrics + +import ( + "testing" +) + +func TestNoopMeter_IsNonNilAndDoesNotPanic(t *testing.T) { + m := NoopMeter() + if m == nil { + t.Fatalf("NoopMeter() returned nil") + } + c := m.Counter("test", "", "") + h := m.Histogram("test", "", "") + c.Add(t.Context(), 1, String("k", "v")) + h.Record(t.Context(), 1.5, String("k", "v")) +} + +func TestString_BuildsAttribute(t *testing.T) { + a := String("mcp.method", "tools/list") + if a.Key != "mcp.method" || a.Value != "tools/list" { + t.Fatalf("unexpected attr: %+v", a) + } +} diff --git a/otel/go.mod b/otel/go.mod index 6c2aee2dd..19d0fc16d 100644 --- a/otel/go.mod +++ b/otel/go.mod @@ -6,7 +6,9 @@ require ( github.com/mark3labs/mcp-go v0.53.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 ) @@ -22,7 +24,6 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/otel/meter.go b/otel/meter.go new file mode 100644 index 000000000..b5a4de8e6 --- /dev/null +++ b/otel/meter.go @@ -0,0 +1,73 @@ +package otel + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + otelmetric "go.opentelemetry.io/otel/metric" + + "github.com/mark3labs/mcp-go/metrics" +) + +// NewMeter wraps an OpenTelemetry metric.Meter as a metrics.Meter. A nil +// meter is treated as a no-op. +func NewMeter(m otelmetric.Meter) metrics.Meter { + if m == nil { + return metrics.NoopMeter() + } + return otelMeter{meter: m} +} + +type otelMeter struct { + meter otelmetric.Meter +} + +func (o otelMeter) Counter(name, description, unit string) metrics.Counter { + opts := []otelmetric.Int64CounterOption{} + if description != "" { + opts = append(opts, otelmetric.WithDescription(description)) + } + if unit != "" { + opts = append(opts, otelmetric.WithUnit(unit)) + } + c, err := o.meter.Int64Counter(name, opts...) + if err != nil { + return metrics.NoopMeter().Counter(name, description, unit) + } + return otelCounter{c: c} +} + +func (o otelMeter) Histogram(name, description, unit string) metrics.Histogram { + opts := []otelmetric.Float64HistogramOption{} + if description != "" { + opts = append(opts, otelmetric.WithDescription(description)) + } + if unit != "" { + opts = append(opts, otelmetric.WithUnit(unit)) + } + h, err := o.meter.Float64Histogram(name, opts...) + if err != nil { + return metrics.NoopMeter().Histogram(name, description, unit) + } + return otelHistogram{h: h} +} + +type otelCounter struct{ c otelmetric.Int64Counter } + +func (c otelCounter) Add(ctx context.Context, n int64, attrs ...metrics.Attribute) { + c.c.Add(ctx, n, otelmetric.WithAttributes(toOTelMetricAttrs(attrs)...)) +} + +type otelHistogram struct{ h otelmetric.Float64Histogram } + +func (h otelHistogram) Record(ctx context.Context, v float64, attrs ...metrics.Attribute) { + h.h.Record(ctx, v, otelmetric.WithAttributes(toOTelMetricAttrs(attrs)...)) +} + +func toOTelMetricAttrs(attrs []metrics.Attribute) []attribute.KeyValue { + out := make([]attribute.KeyValue, len(attrs)) + for i, a := range attrs { + out[i] = attribute.String(a.Key, a.Value) + } + return out +} diff --git a/otel/meter_test.go b/otel/meter_test.go new file mode 100644 index 000000000..bffb202df --- /dev/null +++ b/otel/meter_test.go @@ -0,0 +1,90 @@ +package otel_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + otelmetric "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "github.com/mark3labs/mcp-go/metrics" + otelmcp "github.com/mark3labs/mcp-go/otel" +) + +func newTestMeter(t *testing.T) (otelmetric.Meter, *sdkmetric.ManualReader) { + t.Helper() + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(t.Context()) }) + return mp.Meter("test"), reader +} + +func collect(t *testing.T, reader *sdkmetric.ManualReader) metricdata.ResourceMetrics { + t.Helper() + var rm metricdata.ResourceMetrics + if err := reader.Collect(t.Context(), &rm); err != nil { + t.Fatalf("collect: %v", err) + } + return rm +} + +func findCounter(rm metricdata.ResourceMetrics, name string) *metricdata.Sum[int64] { + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == name { + if d, ok := m.Data.(metricdata.Sum[int64]); ok { + return &d + } + } + } + } + return nil +} + +func findHistogram(rm metricdata.ResourceMetrics, name string) *metricdata.Histogram[float64] { + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == name { + if d, ok := m.Data.(metricdata.Histogram[float64]); ok { + return &d + } + } + } + } + return nil +} + +func TestNewMeter_NilFallsBackToNoop(t *testing.T) { + m := otelmcp.NewMeter(nil) + require.NotNil(t, m, "NewMeter(nil) must return a non-nil noop meter") + m.Counter("x", "", "").Add(t.Context(), 1) + m.Histogram("y", "", "").Record(t.Context(), 1.0) +} + +func TestNewMeter_RecordsCounterAndHistogram(t *testing.T) { + otelMeter, reader := newTestMeter(t) + m := otelmcp.NewMeter(otelMeter) + + c := m.Counter("svc.calls", "calls", "{call}") + h := m.Histogram("svc.duration", "duration", "s") + + ctx := t.Context() + c.Add(ctx, 1, metrics.String("method", "tools/list")) + c.Add(ctx, 1, metrics.String("method", "tools/list")) + h.Record(ctx, 0.125, metrics.String("method", "tools/list")) + + rm := collect(t, reader) + calls := findCounter(rm, "svc.calls") + require.NotNil(t, calls, "missing svc.calls counter") + require.Len(t, calls.DataPoints, 1, "expected one data point keyed by method=tools/list") + require.Equal(t, int64(2), calls.DataPoints[0].Value) + gotMethod, _ := calls.DataPoints[0].Attributes.Value(attribute.Key("method")) + require.Equal(t, "tools/list", gotMethod.AsString()) + + hist := findHistogram(rm, "svc.duration") + require.NotNil(t, hist, "missing svc.duration histogram") + require.Len(t, hist.DataPoints, 1) + require.Equal(t, uint64(1), hist.DataPoints[0].Count) +} diff --git a/otel/options.go b/otel/options.go index b662a3a72..fb89011da 100644 --- a/otel/options.go +++ b/otel/options.go @@ -1,6 +1,7 @@ package otel import ( + otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" @@ -8,6 +9,14 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// WithServerMetrics installs an OpenTelemetry meter on the server. The +// server emits mcp.request.calls / mcp.request.duration and +// mcp.tool.calls / mcp.tool.duration; see server.WithMeter for the full +// attribute schema. +func WithServerMetrics(m otelmetric.Meter) server.ServerOption { + return server.WithMeter(NewMeter(m)) +} + // WithServerTracing installs an OpenTelemetry tracer and a W3C TraceContext // propagator on the server. func WithServerTracing(t trace.Tracer) server.ServerOption { diff --git a/server/internal/gen/request_handler.go.tmpl b/server/internal/gen/request_handler.go.tmpl index 9f945ab22..34ddcbcaa 100644 --- a/server/internal/gen/request_handler.go.tmpl +++ b/server/internal/gen/request_handler.go.tmpl @@ -95,6 +95,9 @@ func (s *MCPServer) HandleMessage( ctx, endSpan := s.startMessageSpan(ctx, headers, string(baseMessage.Method)) defer func() { endSpan(resp) }() + endMetric := s.startMessageMetric(ctx, string(baseMessage.Method), headers.Get(HeaderKeyProtocolVersion)) + defer func() { endMetric(resp) }() + switch baseMessage.Method { {{- range .}} case mcp.{{.MethodName}}: diff --git a/server/metrics.go b/server/metrics.go new file mode 100644 index 000000000..29490ef3e --- /dev/null +++ b/server/metrics.go @@ -0,0 +1,121 @@ +package server + +import ( + "context" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/metrics" +) + +const ( + metricRequestCalls = "mcp.request.calls" + metricRequestDuration = "mcp.request.duration" + metricToolCalls = "mcp.tool.calls" + metricToolDuration = "mcp.tool.duration" + attrKeyMethod = "mcp.method" + attrKeyToolName = "mcp.tool.name" + attrKeySessionID = "mcp.session.id" + attrKeyProtocolVersion = "mcp.protocol.version" + attrKeyOutcome = "outcome" + + metricOutcomeOK = "ok" + metricOutcomeError = "error" + metricOutcomeErrorResult = "error_result" +) + +// WithMeter installs a metric meter on the server. The server emits: +// +// - mcp.request.calls (counter, unit "{call}") with attributes +// mcp.method, mcp.session.id (when set), mcp.protocol.version +// (from the Mcp-Protocol-Version header), outcome (ok|error). +// - mcp.request.duration (histogram, unit "s") with the same attributes. +// - mcp.tool.calls (counter, unit "{call}") with attributes +// mcp.tool.name, outcome (ok|error|error_result). +// - mcp.tool.duration (histogram, unit "s") with the same attributes. +// +// Histogram observations attach exemplars carrying the active span's +// TraceID/SpanID when a Tracer is installed via WithTracer, enabling +// "click latency bucket → jump to trace" pivots in Grafana / Tempo. +// A nil meter is treated as a no-op. +func WithMeter(meter metrics.Meter) ServerOption { + if meter == nil { + meter = metrics.NoopMeter() + } + return func(s *MCPServer) { + s.meter = meter + s.requestCallsCounter = meter.Counter(metricRequestCalls, + "Number of JSON-RPC requests dispatched by the MCP server.", "{call}") + s.requestDurationHistogram = meter.Histogram(metricRequestDuration, + "Duration of JSON-RPC requests dispatched by the MCP server.", "s") + toolCalls := meter.Counter(metricToolCalls, + "Number of MCP tool handler invocations.", "{call}") + toolDuration := meter.Histogram(metricToolDuration, + "Duration of MCP tool handler invocations.", "s") + s.toolMiddlewareMu.Lock() + s.toolHandlerMiddlewares = append(s.toolHandlerMiddlewares, + toolMetricsMiddleware(toolCalls, toolDuration)) + s.toolMiddlewareMu.Unlock() + } +} + +// startMessageMetric opens a per-request measurement scope and returns a +// finalizer that records the request counter + duration histogram keyed by +// the method outcome. When no meter is installed the finalizer is a no-op. +func (s *MCPServer) startMessageMetric( + ctx context.Context, + method string, + protocolVersion string, +) func(mcp.JSONRPCMessage) { + calls := s.requestCallsCounter + duration := s.requestDurationHistogram + if calls == nil || duration == nil { + return func(mcp.JSONRPCMessage) {} + } + start := time.Now() + + attrs := []metrics.Attribute{metrics.String(attrKeyMethod, method)} + if session := ClientSessionFromContext(ctx); session != nil { + if id := session.SessionID(); id != "" { + attrs = append(attrs, metrics.String(attrKeySessionID, id)) + } + } + if protocolVersion != "" { + attrs = append(attrs, metrics.String(attrKeyProtocolVersion, protocolVersion)) + } + + return func(resp mcp.JSONRPCMessage) { + outcome := metricOutcomeOK + if _, ok := resp.(mcp.JSONRPCError); ok { + outcome = metricOutcomeError + } + final := append(attrs[:len(attrs):len(attrs)], + metrics.String(attrKeyOutcome, outcome), + ) + calls.Add(ctx, 1, final...) + duration.Record(ctx, time.Since(start).Seconds(), final...) + } +} + +func toolMetricsMiddleware(calls metrics.Counter, duration metrics.Histogram) ToolHandlerMiddleware { + return func(next ToolHandlerFunc) ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + start := time.Now() + result, err := next(ctx, request) + outcome := metricOutcomeOK + switch { + case err != nil: + outcome = metricOutcomeError + case result != nil && result.IsError: + outcome = metricOutcomeErrorResult + } + attrs := []metrics.Attribute{ + metrics.String(attrKeyToolName, request.Params.Name), + metrics.String(attrKeyOutcome, outcome), + } + calls.Add(ctx, 1, attrs...) + duration.Record(ctx, time.Since(start).Seconds(), attrs...) + return result, err + } + } +} diff --git a/server/metrics_test.go b/server/metrics_test.go new file mode 100644 index 000000000..13088e4f6 --- /dev/null +++ b/server/metrics_test.go @@ -0,0 +1,210 @@ +package server + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/metrics" +) + +// recordingMeter captures every Counter.Add and Histogram.Record so tests +// can assert attribute and value behaviour without an OTEL backend. +type recordingMeter struct { + mu sync.Mutex + counters map[string]*recordingCounter + histograms map[string]*recordingHistogram +} + +func newRecordingMeter() *recordingMeter { + return &recordingMeter{ + counters: map[string]*recordingCounter{}, + histograms: map[string]*recordingHistogram{}, + } +} + +func (m *recordingMeter) Counter(name, _, _ string) metrics.Counter { + m.mu.Lock() + defer m.mu.Unlock() + if c, ok := m.counters[name]; ok { + return c + } + c := &recordingCounter{name: name} + m.counters[name] = c + return c +} + +func (m *recordingMeter) Histogram(name, _, _ string) metrics.Histogram { + m.mu.Lock() + defer m.mu.Unlock() + if h, ok := m.histograms[name]; ok { + return h + } + h := &recordingHistogram{name: name} + m.histograms[name] = h + return h +} + +type counterCall struct { + n int64 + attrs map[string]string +} + +type recordingCounter struct { + mu sync.Mutex + name string + calls []counterCall +} + +func (c *recordingCounter) Add(_ context.Context, n int64, attrs ...metrics.Attribute) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls = append(c.calls, counterCall{n: n, attrs: flatten(attrs)}) +} + +type histogramObservation struct { + value float64 + attrs map[string]string +} + +type recordingHistogram struct { + mu sync.Mutex + name string + observations []histogramObservation +} + +func (h *recordingHistogram) Record(_ context.Context, v float64, attrs ...metrics.Attribute) { + h.mu.Lock() + defer h.mu.Unlock() + h.observations = append(h.observations, histogramObservation{value: v, attrs: flatten(attrs)}) +} + +func flatten(attrs []metrics.Attribute) map[string]string { + out := map[string]string{} + for _, a := range attrs { + out[a.Key] = a.Value + } + return out +} + +func TestWithMeter_NilFallsBackToNoop(t *testing.T) { + s := NewMCPServer("meter-srv", "1.0", WithMeter(nil)) + if s.meter == nil { + t.Fatalf("WithMeter(nil) must install a non-nil noop meter") + } + // Counters/Histograms must be wired so the request hook does not panic. + end := s.startMessageMetric(t.Context(), "tools/list", "") + end(nil) +} + +func TestWithMeter_RecordsRequestOnDispatch(t *testing.T) { + m := newRecordingMeter() + s := NewMCPServer("meter-srv", "1.0", WithMeter(m)) + + end := s.startMessageMetric(t.Context(), "tools/list", "2025-11-25") + end(nil) + + c := m.counters[metricRequestCalls] + if c == nil || len(c.calls) != 1 { + t.Fatalf("expected 1 mcp.request.calls observation; got %+v", c) + } + got := c.calls[0] + if got.n != 1 { + t.Fatalf("counter delta must be 1; got %d", got.n) + } + if got.attrs[attrKeyMethod] != "tools/list" { + t.Fatalf("missing method attr: %+v", got.attrs) + } + if got.attrs[attrKeyProtocolVersion] != "2025-11-25" { + t.Fatalf("missing protocol attr: %+v", got.attrs) + } + if got.attrs[attrKeyOutcome] != metricOutcomeOK { + t.Fatalf("want outcome=ok, got %s", got.attrs[attrKeyOutcome]) + } + + h := m.histograms[metricRequestDuration] + if h == nil || len(h.observations) != 1 { + t.Fatalf("expected 1 mcp.request.duration observation; got %+v", h) + } + if h.observations[0].value < 0 { + t.Fatalf("duration must be non-negative; got %v", h.observations[0].value) + } +} + +func TestWithMeter_RecordsRequestErrorOutcome(t *testing.T) { + m := newRecordingMeter() + s := NewMCPServer("meter-srv", "1.0", WithMeter(m)) + + end := s.startMessageMetric(t.Context(), "tools/call", "") + end(mcp.NewJSONRPCError(mcp.NewRequestId(1), mcp.INVALID_PARAMS, "bad", nil)) + + c := m.counters[metricRequestCalls] + if c == nil || len(c.calls) != 1 { + t.Fatalf("expected 1 counter observation") + } + if got := c.calls[0].attrs[attrKeyOutcome]; got != metricOutcomeError { + t.Fatalf("want outcome=error, got %s", got) + } +} + +func TestWithMeter_ToolMiddlewareCovers3Outcomes(t *testing.T) { + m := newRecordingMeter() + s := NewMCPServer("meter-srv", "1.0", WithMeter(m)) + + // Pull out the registered tool middleware and exercise it. + s.toolMiddlewareMu.RLock() + mws := append([]ToolHandlerMiddleware(nil), s.toolHandlerMiddlewares...) + s.toolMiddlewareMu.RUnlock() + if len(mws) == 0 { + t.Fatalf("expected at least one tool middleware after WithMeter") + } + + cases := []struct { + name string + handler ToolHandlerFunc + wantOutcome string + }{ + {"ok", + func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + }, + metricOutcomeOK, + }, + {"error", + func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, errors.New("boom") + }, + metricOutcomeError, + }, + {"error_result", + func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{IsError: true}, nil + }, + metricOutcomeErrorResult, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := tc.handler + for i := len(mws) - 1; i >= 0; i-- { + wrapped = mws[i](wrapped) + } + _, _ = wrapped(t.Context(), mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "list_pods"}}) + }) + } + + c := m.counters[metricToolCalls] + if c == nil || len(c.calls) != 3 { + t.Fatalf("expected 3 tool.call observations; got %+v", c) + } + for i, want := range []string{metricOutcomeOK, metricOutcomeError, metricOutcomeErrorResult} { + if got := c.calls[i].attrs[attrKeyOutcome]; got != want { + t.Fatalf("call[%d] want outcome=%s, got %s", i, want, got) + } + if got := c.calls[i].attrs[attrKeyToolName]; got != "list_pods" { + t.Fatalf("call[%d] want tool=list_pods, got %s", i, got) + } + } +} diff --git a/server/request_handler.go b/server/request_handler.go index 380583786..4a8b5c81f 100644 --- a/server/request_handler.go +++ b/server/request_handler.go @@ -95,6 +95,9 @@ func (s *MCPServer) HandleMessage( ctx, endSpan := s.startMessageSpan(ctx, headers, string(baseMessage.Method)) defer func() { endSpan(resp) }() + endMetric := s.startMessageMetric(ctx, string(baseMessage.Method), headers.Get(HeaderKeyProtocolVersion)) + defer func() { endMetric(resp) }() + switch baseMessage.Method { case mcp.MethodInitialize: var request mcp.InitializeRequest diff --git a/server/server.go b/server/server.go index 15a96800b..802851957 100644 --- a/server/server.go +++ b/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/metrics" "github.com/mark3labs/mcp-go/tracing" ) @@ -221,6 +222,9 @@ type MCPServer struct { strictInputSchemaDefault bool tracer tracing.Tracer propagator tracing.Propagator + meter metrics.Meter + requestCallsCounter metrics.Counter + requestDurationHistogram metrics.Histogram } // WithPaginationLimit sets the pagination limit for the server.