Skip to content
Merged
7 changes: 4 additions & 3 deletions cmd/jaeger/internal/extension/jaegermcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This approach prevents context-window exhaustion in LLMs and enables more effici

## Status

🚧 **Phase 1: Foundation (In Progress)** - Extension scaffold and lifecycle management
**Phase 1: Foundation (Complete)** - Extension scaffold, lifecycle management, and MCP SDK integration

Future phases will add:
- Phase 2: Basic MCP tools (search, span details, errors)
Expand Down Expand Up @@ -54,5 +54,6 @@ Phase 1 implements:
- ✅ Configuration validation
- ✅ Factory implementation
- ✅ Server lifecycle management
- ✅ Basic health endpoint
- 🚧 MCP SDK integration (coming in Phase 2)
- ✅ MCP SDK integration
- ✅ Streamable HTTP transport
- ✅ Basic health tool (placeholder for Phase 2)
5 changes: 5 additions & 0 deletions cmd/jaeger/internal/extension/jaegermcp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"github.com/asaskevich/govalidator"
"go.opentelemetry.io/collector/config/confighttp"
"go.opentelemetry.io/collector/confmap/xconfmap"

"github.com/jaegertracing/jaeger/internal/version"
)

// Config represents the configuration for the Jaeger MCP server extension.
Expand All @@ -29,6 +31,9 @@ type Config struct {

// Validate checks if the configuration is valid.
func (cfg *Config) Validate() error {
if cfg.ServerVersion == "" {
cfg.ServerVersion = version.Get().GitVersion
}
_, err := govalidator.ValidateStruct(cfg)
return err
}
Expand Down
12 changes: 9 additions & 3 deletions cmd/jaeger/internal/extension/jaegermcp/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ func TestValidate(t *testing.T) {
expectError bool
}{
{
name: "Empty config - valid",
config: &Config{},
name: "Empty config - valid (ServerVersion auto-filled)",
config: &Config{
MaxSpanDetailsPerRequest: 20,
MaxSearchResults: 100,
},
expectError: false,
},
{
name: "Valid config",
name: "Valid config with ServerVersion",
config: &Config{
ServerVersion: "1.0.0",
MaxSpanDetailsPerRequest: 20,
MaxSearchResults: 100,
},
Expand All @@ -31,6 +35,7 @@ func TestValidate(t *testing.T) {
{
name: "Invalid MaxSpanDetailsPerRequest (too high)",
config: &Config{
ServerVersion: "1.0.0",
MaxSpanDetailsPerRequest: 101,
MaxSearchResults: 100,
},
Expand All @@ -39,6 +44,7 @@ func TestValidate(t *testing.T) {
{
name: "Invalid MaxSearchResults (too high)",
config: &Config{
ServerVersion: "1.0.0",
MaxSpanDetailsPerRequest: 20,
MaxSearchResults: 1001,
},
Expand Down
69 changes: 59 additions & 10 deletions cmd/jaeger/internal/extension/jaegermcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/extension"
"go.opentelemetry.io/collector/extension/extensioncapabilities"
Expand All @@ -30,6 +31,7 @@ type server struct {
telset component.TelemetrySettings
httpServer *http.Server
listener net.Listener
mcpServer *mcp.Server
}

// newServer creates a new MCP server instance.
Expand All @@ -47,28 +49,52 @@ func (*server) Dependencies() []component.ID {
}

// Start initializes and starts the MCP server.
func (s *server) Start(_ context.Context, host component.Host) error {
func (s *server) Start(ctx context.Context, host component.Host) error {
s.telset.Logger.Info("Starting Jaeger MCP server", zap.String("endpoint", s.config.HTTP.Endpoint))

// TODO Phase 2: Get QueryService from jaegerquery extension
// TODO Phase 2 (part 2): Get QueryService from jaegerquery extension
// This will require jaegerquery to expose QueryService through an Extension interface,
// similar to how jaegerstorage exposes storage factories.
// For now, we just verify that jaegerquery extension is available.
_ = host

// TODO: Initialize MCP server with Streamable HTTP transport
// This will be implemented in Phase 2 once we add the MCP SDK dependency

// For Phase 1, we just set up a basic HTTP server to validate the extension lifecycle
//nolint:noctx // Phase 1 temporary implementation, will be replaced with MCP SDK in Phase 2
listener, err := net.Listen("tcp", s.config.HTTP.Endpoint)
// Initialize MCP server with implementation details
impl := &mcp.Implementation{
Name: s.config.ServerName,
Version: s.config.ServerVersion,
}
// Pass empty ServerOptions to use default settings.
// Custom options (e.g., logging, handlers) can be added in Phase 2 if needed.
s.mcpServer = mcp.NewServer(impl, &mcp.ServerOptions{})

// Register a placeholder health tool for Phase 1 Part 2
// Actual MCP tools will be implemented in Phase 2
mcp.AddTool(s.mcpServer, &mcp.Tool{
Name: "health",
Description: "Check if the Jaeger MCP server is running",
}, s.healthTool)

// Set up TCP listener with context
lc := net.ListenConfig{}
listener, err := lc.Listen(ctx, "tcp", s.config.HTTP.Endpoint)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", s.config.HTTP.Endpoint, err)
}
s.listener = listener

// Create a basic HTTP server
// Create MCP streamable HTTP handler
mcpHandler := mcp.NewStreamableHTTPHandler(
func(_ *http.Request) *mcp.Server { return s.mcpServer },
&mcp.StreamableHTTPOptions{
JSONResponse: false, // Use SSE for streamed events
Stateless: false, // Session state management
SessionTimeout: 5 * time.Minute,
},
)

// Create HTTP server with MCP handler and health endpoint
mux := http.NewServeMux()
mux.Handle("/mcp", mcpHandler)
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("MCP server is running"))
Expand All @@ -86,7 +112,9 @@ func (s *server) Start(_ context.Context, host component.Host) error {
}
}()

s.telset.Logger.Info("Jaeger MCP server started successfully", zap.String("endpoint", s.config.HTTP.Endpoint))
s.telset.Logger.Info("Jaeger MCP server started successfully",
zap.String("endpoint", s.config.HTTP.Endpoint),
zap.String("mcp_endpoint", "http://"+s.config.HTTP.Endpoint+"/mcp"))
return nil
}

Expand All @@ -103,3 +131,24 @@ func (s *server) Shutdown(ctx context.Context) error {

return errors.Join(errs...)
}

// HealthToolOutput is the strongly-typed output for the health tool.
type HealthToolOutput struct {
Status string `json:"status" jsonschema:"Server status (ok/error)"`
Server string `json:"server" jsonschema:"Server name"`
Version string `json:"version" jsonschema:"Server version"`
}

// healthTool is a placeholder MCP tool that checks server health.
// Actual MCP tools for trace querying will be implemented in Phase 2.
func (s *server) healthTool(
_ context.Context,
_ *mcp.CallToolRequest,
_ struct{},
) (*mcp.CallToolResult, HealthToolOutput, error) {
return nil, HealthToolOutput{
Comment thread
yurishkuro marked this conversation as resolved.
Status: "ok",
Server: s.config.ServerName,
Version: s.config.ServerVersion,
}, nil
}
101 changes: 77 additions & 24 deletions cmd/jaeger/internal/extension/jaegermcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package jaegermcp

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
Expand All @@ -19,6 +20,51 @@ import (
"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegerquery"
)

// startTestServer creates and starts a test server with a random available port.
// It waits for the server to be ready and registers shutdown via t.Cleanup().
// Returns the started server and its address.
func startTestServer(t *testing.T) (*server, string) {
t.Helper()

host := componenttest.NewNopHost()
telset := componenttest.NewNopTelemetrySettings()

config := &Config{
HTTP: confighttp.ServerConfig{
Endpoint: "localhost:0", // OS will assign a free port
},
ServerName: "jaeger",
ServerVersion: "1.0.0",
MaxSpanDetailsPerRequest: 20,
MaxSearchResults: 100,
}

server := newServer(config, telset)
err := server.Start(context.Background(), host)
require.NoError(t, err)

// Register cleanup
t.Cleanup(func() {
err := server.Shutdown(context.Background())
assert.NoError(t, err)
})

// Get the actual address the server is listening on
addr := server.listener.Addr().String()

// Wait for server to be ready
assert.Eventually(t, func() bool {
resp, err := http.Get(fmt.Sprintf("http://%s/health", addr))
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 1*time.Second, 10*time.Millisecond, "Server should be ready")

return server, addr
}

func TestServerLifecycle(t *testing.T) {
// Since we're not actually accessing storage in Phase 1,
// we just need a basic host for the lifecycle test
Expand All @@ -32,7 +78,11 @@ func TestServerLifecycle(t *testing.T) {
{
name: "successful start and shutdown",
config: &Config{
HTTP: createDefaultConfig().(*Config).HTTP,
HTTP: createDefaultConfig().(*Config).HTTP,
ServerName: "jaeger",
ServerVersion: "1.0.0",
MaxSpanDetailsPerRequest: 20,
MaxSearchResults: 100,
},
expectedError: "",
},
Expand Down Expand Up @@ -78,29 +128,7 @@ func TestServerStartFailsWithInvalidEndpoint(t *testing.T) {
}

func TestServerHealthEndpoint(t *testing.T) {
host := componenttest.NewNopHost()
telset := componenttest.NewNopTelemetrySettings()

// Use a random available port
config := &Config{
HTTP: confighttp.ServerConfig{
Endpoint: "localhost:0", // OS will assign a free port
},
}

server := newServer(config, telset)
err := server.Start(context.Background(), host)
require.NoError(t, err)
defer func() {
err := server.Shutdown(context.Background())
assert.NoError(t, err)
}()

// Give the server a moment to start
time.Sleep(100 * time.Millisecond)

// Get the actual address the server is listening on
addr := server.listener.Addr().String()
_, addr := startTestServer(t)

// Test the health endpoint
resp, err := http.Get(fmt.Sprintf("http://%s/health", addr))
Expand All @@ -113,6 +141,31 @@ func TestServerHealthEndpoint(t *testing.T) {
assert.Equal(t, "MCP server is running", string(body))
}

func TestServerMCPEndpoint(t *testing.T) {
_, addr := startTestServer(t)

// Test the MCP endpoint with a GET request
// According to MCP Streamable HTTP spec, GET should return session info or error
resp, err := http.Get(fmt.Sprintf("http://%s/mcp", addr))
Comment thread
yurishkuro marked this conversation as resolved.
require.NoError(t, err)
defer resp.Body.Close()

// The MCP endpoint should not return 404 (it exists)
assert.NotEqual(t, http.StatusNotFound, resp.StatusCode)

// Read and validate the response body if it's JSON
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

// If the response is JSON, it should be valid JSON
// The MCP spec indicates GET without session ID may return an error or session info
if resp.Header.Get("Content-Type") == "application/json" {
var result map[string]any
err := json.Unmarshal(body, &result)
assert.NoError(t, err, "Response should be valid JSON")
}
}

func TestServerShutdownWithError(t *testing.T) {
host := componenttest.NewNopHost()
telset := componenttest.NewNopTelemetrySettings()
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/jaegertracing/jaeger-idl v0.6.0
github.com/kr/pretty v0.3.1
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/olivere/elastic/v7 v7.0.32
github.com/open-telemetry/opentelemetry-collector-contrib/connector/spanmetricsconnector v0.142.0
github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.142.0
Expand Down Expand Up @@ -121,6 +122,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
Comment thread
yurishkuro marked this conversation as resolved.
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
Expand All @@ -138,6 +140,7 @@ require (
github.com/tg123/go-htpasswd v1.2.4 // indirect
github.com/twmb/franz-go/pkg/kadm v1.17.1 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opentelemetry.io/collector/config/configopaque v1.49.0 // indirect
go.opentelemetry.io/collector/semconv v0.128.1-0.20250610090210-188191247685 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/go-tpm-tools v0.4.4 h1:oiQfAIkc6xTy9Fl5NKTeTJkBTlXdHsxAofmQyxBKY98=
github.com/google/go-tpm-tools v0.4.4/go.mod h1:T8jXkp2s+eltnCDIsXR84/MTcVU9Ja7bh3Mit0pa4AY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand Down Expand Up @@ -404,6 +406,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -645,6 +649,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand Down
Loading