Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions metrics/metrics.go
Original file line number Diff line number Diff line change
@@ -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) {}
23 changes: 23 additions & 0 deletions metrics/metrics_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion otel/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions otel/meter.go
Original file line number Diff line number Diff line change
@@ -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
}
90 changes: 90 additions & 0 deletions otel/meter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions otel/options.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package otel

import (
otelmetric "go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"

"github.com/mark3labs/mcp-go/client"
"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 {
Expand Down
3 changes: 3 additions & 0 deletions server/internal/gen/request_handler.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}:
Expand Down
Loading
Loading