Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,30 @@ via the command line flag:
./build/mkp-server --enable-rate-limiting=false
```

### OpenTelemetry

MKP supports OpenTelemetry for distributed tracing and metrics. When enabled,
tool calls are instrumented with spans and metrics to help monitor and debug
your MCP server.

Configuration is done via environment variables:

- `MKP_OTEL_ENABLED`: Enable OpenTelemetry (default: false)
- `MKP_OTEL_SERVICE_NAME`: Service name for tracing (default: mkp)
- `MKP_OTEL_SERVICE_VERSION`: Service version (default: 0.1.0)
- `OTEL_EXPORTER_OTLP_ENDPOINT`: OTLP endpoint (e.g., localhost:4317)

```bash
# Run with OpenTelemetry enabled, sending traces to a local collector
MKP_OTEL_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 ./build/mkp-server
```

If no OTLP endpoint is configured, traces are printed to stdout for debugging.

Metrics exported:
- `mkp.tool.requests`: Counter of tool requests (with tool name and error status)
- `mkp.tool.duration`: Histogram of tool request duration in milliseconds

## Development

### Running tests
Expand Down
32 changes: 23 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ go 1.24.2
require (
github.com/mark3labs/mcp-go v0.43.2
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0
go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
k8s.io/api v0.34.3
k8s.io/apimachinery v0.34.3
k8s.io/client-go v0.34.3
Expand All @@ -13,18 +19,21 @@ require (
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
Expand All @@ -42,15 +51,21 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand All @@ -59,7 +74,6 @@ require (
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
158 changes: 59 additions & 99 deletions go.sum

Large diffs are not rendered by default.

63 changes: 44 additions & 19 deletions pkg/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/mark3labs/mcp-go/server"

"github.com/StacklokLabs/mkp/pkg/k8s"
"github.com/StacklokLabs/mkp/pkg/otel"
"github.com/StacklokLabs/mkp/pkg/ratelimit"
)

Expand All @@ -27,20 +28,27 @@ type Config struct {
// EnableRateLimiting determines whether to enable rate limiting for tool calls
// When true, a default rate limiter will be used to prevent excessive API calls
EnableRateLimiting bool

// EnableOtel determines whether to enable OpenTelemetry tracing and metrics
// When true, tool calls will be instrumented with OpenTelemetry
EnableOtel bool
}

// DefaultConfig returns a Config with default values
func DefaultConfig() *Config {
otelConfig := otel.DefaultConfig()
return &Config{
ServeResources: true, // Default to serving resources for backward compatibility
ReadWrite: false, // Default to read-only mode
EnableRateLimiting: true, // Default to enabling rate limiting
ServeResources: true, // Default to serving resources for backward compatibility
ReadWrite: false, // Default to read-only mode
EnableRateLimiting: true, // Default to enabling rate limiting
EnableOtel: otelConfig.Enabled, // Default based on MKP_OTEL_ENABLED env var
}
}

// serverResources holds resources that need to be cleaned up when the server is stopped
type serverResources struct {
rateLimiter *ratelimit.RateLimiter
rateLimiter *ratelimit.RateLimiter
otelProvider *otel.Provider
}

// Global variable to hold server resources
Expand All @@ -52,6 +60,10 @@ func CreateServer(k8sClient *k8s.Client, config *Config) *server.MCPServer {
if config == nil {
config = DefaultConfig()
}

// Initialize server resources
resources = &serverResources{}

// Create MCP implementation
impl := NewImplementation(k8sClient)

Expand All @@ -63,20 +75,25 @@ func CreateServer(k8sClient *k8s.Client, config *Config) *server.MCPServer {
server.WithRecovery(),
}

// Add OpenTelemetry middleware if enabled
if config.EnableOtel {
log.Println("OpenTelemetry enabled, initializing provider")
otelConfig := otel.DefaultConfig()
provider, err := otel.NewProvider(context.Background(), otelConfig)
if err != nil {
log.Printf("Failed to initialize OpenTelemetry: %v", err)
} else {
resources.otelProvider = provider
options = append(options, server.WithToolHandlerMiddleware(otel.Middleware()))
}
}

// Add rate limiting middleware if enabled
if config.EnableRateLimiting {
log.Println("Server rate limiting enabled, initializing rate limiter")
// Create and store the rate limiter for cleanup
limiter := ratelimit.GetDefaultRateLimiter()

// Store the limiter for cleanup when the server is stopped
resources = &serverResources{
rateLimiter: limiter,
}

// Add the middleware to the server options
middleware := limiter.Middleware()
options = append(options, server.WithToolHandlerMiddleware(middleware))
resources.rateLimiter = limiter
options = append(options, server.WithToolHandlerMiddleware(limiter.Middleware()))
}

// Create MCP server with all options
Expand Down Expand Up @@ -132,11 +149,19 @@ func CreateServer(k8sClient *k8s.Client, config *Config) *server.MCPServer {

// StopServer stops the MCP server and cleans up resources
func StopServer() {
// Clean up resources
if resources != nil {
// Stop the rate limiter if it exists
if resources.rateLimiter != nil {
resources.rateLimiter.Stop()
if resources == nil {
return
}

// Stop the rate limiter if it exists
if resources.rateLimiter != nil {
resources.rateLimiter.Stop()
}

// Shutdown OpenTelemetry provider if it exists
if resources.otelProvider != nil {
if err := resources.otelProvider.Shutdown(context.Background()); err != nil {
log.Printf("Error shutting down OpenTelemetry: %v", err)
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions pkg/otel/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Package otel provides OpenTelemetry instrumentation for the MCP server
package otel

import (
"os"
)

// Config holds the OpenTelemetry configuration
type Config struct {
// Enabled determines whether OpenTelemetry is enabled
Enabled bool
// ServiceName is the name of the service for tracing
ServiceName string
// ServiceVersion is the version of the service
ServiceVersion string
// OTLPEndpoint is the endpoint for the OTLP exporter (e.g., "localhost:4317")
// If empty, stdout exporter is used for debugging
OTLPEndpoint string
}

// DefaultConfig returns the default OpenTelemetry configuration
func DefaultConfig() *Config {
return &Config{
Enabled: getEnvBool("MKP_OTEL_ENABLED", false),
ServiceName: getEnvString("MKP_OTEL_SERVICE_NAME", "mkp"),
ServiceVersion: getEnvString("MKP_OTEL_SERVICE_VERSION", "0.1.0"),
OTLPEndpoint: getEnvString("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
}
}

func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
return value == "true" || value == "1"
}
return defaultValue
}

func getEnvString(key string, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
71 changes: 71 additions & 0 deletions pkg/otel/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package otel

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
)

func TestDefaultConfig(t *testing.T) {
config := DefaultConfig()
assert.NotNil(t, config)
assert.False(t, config.Enabled)
assert.Equal(t, "mkp", config.ServiceName)
assert.Equal(t, "0.1.0", config.ServiceVersion)
assert.Empty(t, config.OTLPEndpoint)
}

func TestConfigFromEnv(t *testing.T) {
// Save original env values
origEnabled := os.Getenv("MKP_OTEL_ENABLED")
origName := os.Getenv("MKP_OTEL_SERVICE_NAME")
origVersion := os.Getenv("MKP_OTEL_SERVICE_VERSION")
origEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
defer func() {
os.Setenv("MKP_OTEL_ENABLED", origEnabled)
os.Setenv("MKP_OTEL_SERVICE_NAME", origName)
os.Setenv("MKP_OTEL_SERVICE_VERSION", origVersion)
os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", origEndpoint)
}()

// Set custom env values
os.Setenv("MKP_OTEL_ENABLED", "true")
os.Setenv("MKP_OTEL_SERVICE_NAME", "test-service")
os.Setenv("MKP_OTEL_SERVICE_VERSION", "1.0.0")
os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317")

config := DefaultConfig()
assert.True(t, config.Enabled)
assert.Equal(t, "test-service", config.ServiceName)
assert.Equal(t, "1.0.0", config.ServiceVersion)
assert.Equal(t, "localhost:4317", config.OTLPEndpoint)
}

func TestGetEnvBool(t *testing.T) {
tests := []struct {
name string
envValue string
expected bool
}{
{"true", "true", true},
{"1", "1", true},
{"false", "false", false},
{"0", "0", false},
{"empty", "", false},
{"invalid", "invalid", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "TEST_BOOL_" + tt.name
defer os.Unsetenv(key)

if tt.envValue != "" {
os.Setenv(key, tt.envValue)
}
result := getEnvBool(key, false)
assert.Equal(t, tt.expected, result)
})
}
}
Loading