Root cause
Authentication & access control introduced
Implement code for Consul MCP Server (#2)
AI-assisted change: GitHub Copilot
How AI contributed: Direct introductionPublished Jul 29, 2026 in
hashicorp/consul-mcp-serverAll 2 findings in this repository
Root cause
Implement code for Consul MCP Server (#2)
AI-assisted change: GitHub Copilot
Fix
refactor(client): harden session isolation and stateless mode handling
Fix by Sanika Chavan · no AI marker found
Case facts
@@ -0,0 +1,55 @@+// Copyright (c) HashiCorp, Inc.+// SPDX-License-Identifier: MPL-2.0++package tools++import (+ "github.com/mark3labs/mcp-go/mcp"+ "testing"++ "github.com/mark3labs/mcp-go/server"+ log "github.com/sirupsen/logrus"+ "github.com/stretchr/testify/assert"+)++func TestRegisterTools(t *testing.T) {+ // Create a mock MCP server+ mcpServer := &mockMCPServer{+ tools: make(map[string]server.ServerTool),+ }++ // Test tool registration+ RegisterTools(mcpServer, log.New())++ // Verify that tools were registered+ assert.Greater(t, len(mcpServer.tools), 0, "Expected tools to be registered")++ // Check for some expected tools+ expectedTools := []string{+ "catalog_services",+ "catalog_nodes",+ "health_service",+ "kv_get",+ "acl_tokens",+ }++ for _, toolName := range expectedTools {+ _, exists := mcpServer.tools[toolName]+ assert.True(t, exists, "Expected tool %s to be registered", toolName)+ }++ // Check the number of tools registered+ assert.Equal(t, len(mcpServer.tools), 85)+}++// Mock MCP Server for testing+type mockMCPServer struct {+ tools map[string]server.ServerTool+}++func (m *mockMCPServer) AddTool(tool mcp.Tool, handler server.ToolHandlerFunc) {+ m.tools[tool.Name] = server.ServerTool{+ Tool: tool,+ Handler: handler,+ }+}@@ -0,0 +1,292 @@+// Copyright (c) HashiCorp, Inc.+// SPDX-License-Identifier: MPL-2.0++package tools++import (+ tools "github.com/hashicorp/consul-mcp-server/pkg/tools/functions"+ "github.com/mark3labs/mcp-go/mcp"+ "github.com/mark3labs/mcp-go/server"+ log "github.com/sirupsen/logrus"+)++// MCPServerInterface defines the interface for MCP server operations needed by tools+type MCPServerInterface interface {+ AddTool(tool mcp.Tool, handler server.ToolHandlerFunc)+}++func RegisterTools(hcServer MCPServerInterface, logger *log.Logger) {+ // Consul Catalog tools+ getCatalogServicesTool := tools.GetCatalogServicesTool(logger)+ hcServer.AddTool(getCatalogServicesTool.Tool, getCatalogServicesTool.Handler)++ getCatalogNodesTool := tools.GetCatalogNodesTool(logger)+ hcServer.AddTool(getCatalogNodesTool.Tool, getCatalogNodesTool.Handler)++ getCatalogServiceTool := tools.GetCatalogServiceTool(logger)+ hcServer.AddTool(getCatalogServiceTool.Tool, getCatalogServiceTool.Handler)++ getCatalogConnectTool := tools.GetCatalogConnectTool(logger)+ hcServer.AddTool(getCatalogConnectTool.Tool, getCatalogConnectTool.Handler)++ getCatalogNodeTool := tools.GetCatalogNodeTool(logger)+ hcServer.AddTool(getCatalogNodeTool.Tool, getCatalogNodeTool.Handler)++ getCatalogDatacentersTool := tools.GetCatalogDatacentersTool(logger)+ hcServer.AddTool(getCatalogDatacentersTool.Tool, getCatalogDatacentersTool.Handler)++ getCatalogGatewayServicesTool := tools.GetCatalogGatewayServicesTool(logger)+ hcServer.AddTool(getCatalogGatewayServicesTool.Tool, getCatalogGatewayServicesTool.Handler)++ // Consul Agent Overview tools+ getAgentSelfTool := tools.GetAgentSelfTool(logger)+ hcServer.AddTool(getAgentSelfTool.Tool, getAgentSelfTool.Handler)++ getAgentConfigTool := tools.GetAgentConfigTool(logger)+ hcServer.AddTool(getAgentConfigTool.Tool, getAgentConfigTool.Handler)++ getAgentMembersTool := tools.GetAgentMembersTool(logger)+ hcServer.AddTool(getAgentMembersTool.Tool, getAgentMembersTool.Handler)++ getAgentMetricsTool := tools.GetAgentMetricsTool(logger)+ hcServer.AddTool(getAgentMetricsTool.Tool, getAgentMetricsTool.Handler)++ getAgentHostTool := tools.GetAgentHostTool(logger)+ hcServer.AddTool(getAgentHostTool.Tool, getAgentHostTool.Handler)++ getAgentVersionTool := tools.GetAgentVersionTool(logger)+ hcServer.AddTool(getAgentVersionTool.Tool, getAgentVersionTool.Handler)++ getAgentReloadTool := tools.GetAgentReloadTool(logger)+ hcServer.AddTool(getAgentReloadTool.Tool, getAgentReloadTool.Handler)++ // Consul Agent Check tools+ getAgentChecksTool := tools.GetAgentChecksTool(logger)+ hcServer.AddTool(getAgentChecksTool.Tool, getAgentChecksTool.Handler)++ getAgentCheckTool := tools.GetAgentCheckTool(logger)+ hcServer.AddTool(getAgentCheckTool.Tool, getAgentCheckTool.Handler)++ getAgentCheckByNameTool := tools.GetAgentCheckByNameTool(logger)+ hcServer.AddTool(getAgentCheckByNameTool.Tool, getAgentCheckByNameTool.Handler)++ getAgentServiceHealthTool := tools.GetAgentServiceHealthTool(logger)+ hcServer.AddTool(getAgentServiceHealthTool.Tool, getAgentServiceHealthTool.Handler)++ getAgentServiceHealthByNameTool := tools.GetAgentServiceHealthByNameTool(logger)+ hcServer.AddTool(getAgentServiceHealthByNameTool.Tool, getAgentServiceHealthByNameTool.Handler)++ // Consul Agent Service tools+ getAgentServicesTool := tools.GetAgentServicesTool(logger)+ hcServer.AddTool(getAgentServicesTool.Tool, getAgentServicesTool.Handler)++ getAgentServiceTool := tools.GetAgentServiceTool(logger)+ hcServer.AddTool(getAgentServiceTool.Tool, getAgentServiceTool.Handler)++ getAgentServiceConfigurationTool := tools.GetAgentServiceConfigurationTool(logger)+ hcServer.AddTool(getAgentServiceConfigurationTool.Tool, getAgentServiceConfigurationTool.Handler)++ getAgentServiceLocalStateTool := tools.GetAgentServiceLocalStateTool(logger)@@ -0,0 +1,148 @@+// Copyright (c) HashiCorp, Inc.+// SPDX-License-Identifier: MPL-2.0++package functions++import (+ "encoding/json"+ "net/http"+ "net/http/httptest"+ "testing"++ log "github.com/sirupsen/logrus"+ "github.com/stretchr/testify/assert"+)++func TestGetStatusLeaderTool(t *testing.T) {+ logger := log.New()+ tool := GetStatusLeaderTool(logger)++ assert.NotNil(t, tool.Tool)+ assert.NotNil(t, tool.Handler)+ assert.Equal(t, "status_leader", tool.Tool.Name)+}++func TestGetStatusLeaderHandler(t *testing.T) {+ mockLeader := "192.168.1.10:8300"++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ assert.Equal(t, "GET", r.Method)+ assert.Equal(t, "/v1/status/leader", r.URL.Path)++ w.Header().Set("Content-Type", "application/json")+ json.NewEncoder(w).Encode(mockLeader)+ }))+ defer server.Close()++ t.Run("successful leader retrieval", func(t *testing.T) {+ // Test would verify leader address is returned correctly+ assert.True(t, true) // Placeholder for actual implementation+ })+}++func TestGetStatusPeersTool(t *testing.T) {+ logger := log.New()+ tool := GetStatusPeersTool(logger)++ assert.NotNil(t, tool.Tool)+ assert.NotNil(t, tool.Handler)+ assert.Equal(t, "status_peers", tool.Tool.Name)+}++func TestGetStatusPeersHandler(t *testing.T) {+ mockPeers := []string{+ "192.168.1.10:8300",+ "192.168.1.11:8300",+ "192.168.1.12:8300",+ }++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ assert.Equal(t, "GET", r.Method)+ assert.Equal(t, "/v1/status/peers", r.URL.Path)++ w.Header().Set("Content-Type", "application/json")+ json.NewEncoder(w).Encode(mockPeers)+ }))+ defer server.Close()++ t.Run("successful peers retrieval", func(t *testing.T) {+ // Test would verify peer list is returned correctly+ assert.True(t, true) // Placeholder for actual implementation+ })+}++func TestStatusResponseProcessing(t *testing.T) {+ t.Run("leader address validation", func(t *testing.T) {+ leaderAddress := "192.168.1.10:8300"++ // Validate leader address format+ assert.Contains(t, leaderAddress, ":")+ assert.Contains(t, leaderAddress, "192.168.1.10")+ assert.Contains(t, leaderAddress, "8300")+ })++ t.Run("peers list validation", func(t *testing.T) {+ peers := []string{+ "192.168.1.10:8300",+ "192.168.1.11:8300",+ "192.168.1.12:8300",+ }@@ -13,20 +13,57 @@ import ( // contextKey is a type alias to avoid lint warnings while maintaining compatibility type contextKey string +const (+ contextKeySessionID contextKey = "session_id"+ contextKeyConsulToken contextKey = "consul_token"+ contextKeyMCPStateless contextKey = "mcp_stateless"+)++func contextStringValue(ctx context.Context, key contextKey) (string, bool) {+ if value, ok := ctx.Value(key).(string); ok {+ return value, true+ }++ // Preserve compatibility with existing tests and callers that use string keys.+ if value, ok := ctx.Value(string(key)).(string); ok {+ return value, true+ }++ return "", false+}++func contextBoolValue(ctx context.Context, key contextKey) bool {+ if value, ok := ctx.Value(key).(bool); ok {+ return value+ }++ if value, ok := ctx.Value(string(key)).(bool); ok {+ return value+ }++ return false+}++// IsStatelessRequest returns true when this request is being handled by the+// stateless StreamableHTTP transport mode.+func IsStatelessRequest(ctx context.Context) bool {+ return contextBoolValue(ctx, contextKeyMCPStateless)+}+ // NewSessionHandler initializes clients for the session func NewSessionHandler(ctx context.Context, session server.ClientSession, logger *log.Logger) { // Create a unique Consul client per session consulClient := NewConsulClient(ctx, session.SessionID(), logger) if consulClient != nil {- logger.WithField("session_id", session.SessionID()).Info("Created Consul HTTP client for session")+ logger.Info("Created Consul HTTP client for MCP session") } else {- logger.WithField("session_id", session.SessionID()).Warn("Consul HTTP client is nil for session")+ logger.Warn("Consul HTTP client is nil for MCP session") } } // EndSessionHandler cleans up clients when the session ends func EndSessionHandler(_ context.Context, session server.ClientSession, logger *log.Logger) { DeleteConsulHttpClientForSession(session.SessionID())- logger.WithField("session_id", session.SessionID()).Info("Cleaned up clients for session")+ logger.Info("Cleaned up clients for MCP session") }@@ -90,6 +90,8 @@ type RateLimitMiddleware struct { logger *log.Logger } +const anonymousRateLimitKey = "__anonymous__"+ // NewRateLimitMiddleware creates a new rate limiting middleware func NewRateLimitMiddleware(config RateLimitConfig, logger *log.Logger) *RateLimitMiddleware { return &RateLimitMiddleware{@@ -135,13 +137,12 @@ func (m *RateLimitMiddleware) Middleware() server.ToolHandlerMiddleware { return nil, errors.New("rate limit exceeded: too many requests globally") } - // Check per-session rate limit if we can get session ID from context- if sessionID := getSessionIDFromContext(ctx); sessionID != "" {- sessionLimiter := m.getSessionLimiter(sessionID)- if !sessionLimiter.Allow() {- m.logger.Warnf("Session rate limit exceeded for session: %s, tool: %s", sessionID, toolName)- return nil, errors.New("rate limit exceeded: too many requests from this session")- }+ // Apply the per-session budget to a trusted server session, or to a+ // shared anonymous bucket when there is no trusted session.+ sessionLimiter := m.getSessionLimiter(getRateLimitKeyFromContext(ctx))+ if !sessionLimiter.Allow() {+ m.logger.Warnf("Session rate limit exceeded for tool: %s", toolName)+ return nil, errors.New("rate limit exceeded: too many requests from this session") } m.logger.Debugf("Rate limit check passed for tool: %s", toolName)@@ -150,14 +151,32 @@ func (m *RateLimitMiddleware) Middleware() server.ToolHandlerMiddleware { } } -// getSessionIDFromContext extracts session ID from context-// This is a helper function that tries to get session ID from the context-func getSessionIDFromContext(ctx context.Context) string {+// getRateLimitKeyFromContext extracts a trusted rate-limit bucket from context.+func getRateLimitKeyFromContext(ctx context.Context) string {+ if IsStatelessRequest(ctx) {+ return anonymousRateLimitKey+ }+ // Try to get session from context if session := server.ClientSessionFromContext(ctx); session != nil {- return session.SessionID()+ if sessionID := session.SessionID(); sessionID != "" {+ return sessionID+ } }- return ""+ return anonymousRateLimitKey+}++// DeleteSession removes the limiter for a session when that session ends.+func (m *RateLimitMiddleware) DeleteSession(sessionID string) {+ if sessionID == "" {+ return+ }++ m.mu.Lock()+ defer m.mu.Unlock()++ delete(m.sessionLimiters, sessionID)+ m.logger.Debug("Cleaned up rate limiter for inactive session") } // CleanupSessions removes inactive session limiters to prevent memory leaks@@ -173,7 +192,7 @@ func (m *RateLimitMiddleware) CleanupSessions(activeSessions []string) { for sessionID := range m.sessionLimiters { if !activeSet[sessionID] { delete(m.sessionLimiters, sessionID)- m.logger.Debugf("Cleaned up rate limiter for inactive session: %s", sessionID)+ m.logger.Debug("Cleaned up rate limiter for inactive session") } } }@@ -6,10 +6,10 @@ package client import ( "context" "net/http"- "net/textproto" "os" "strings" + "github.com/mark3labs/mcp-go/server" log "github.com/sirupsen/logrus" ) @@ -123,32 +123,33 @@ func NewSecurityHandler(handler http.Handler, allowedOrigins []string, corsMode } } -// ConsulContextMiddleware is a middleware that extracts Consul connection details from the request headers-// and adds them to the request context. This allows the application to have a single, unified way of-// accessing Consul connection details regardless of the transport.-func ConsulContextMiddleware(logger *log.Logger) func(http.Handler) http.Handler {+// ConsulContextMiddleware extracts request-scoped Consul credentials from+// headers. The Consul address is intentionally configured only through+// CONSUL_HTTP_ADDR so clients cannot redirect server-side traffic per request.+func ConsulContextMiddleware(logger *log.Logger, statelessMode ...bool) func(http.Handler) http.Handler {+ isStateless := len(statelessMode) > 0 && statelessMode[0]+ return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context()+ ctx = context.WithValue(ctx, contextKeyMCPStateless, isStateless) // Extract session ID from header- sessionID := r.Header.Get(textproto.CanonicalMIMEHeaderKey("x-mcp-session-id"))- if sessionID != "" {- ctx = context.WithValue(ctx, "session_id", sessionID)- logger.Debugf("session_id: %s", sessionID)- }-- // Extract Consul address from header- consulAddr := r.Header.Get(textproto.CanonicalMIMEHeaderKey("X-Consul-Address"))- if consulAddr != "" {- ctx = context.WithValue(ctx, "consul_address", consulAddr)- logger.Debugf("consul_address: %s", consulAddr)+ sessionID := r.Header.Get(server.HeaderKeySessionID)+ if isStateless {+ if sessionID != "" {+ logger.Debug("Ignoring MCP session ID header in stateless mode")+ }+ r.Header.Del(server.HeaderKeySessionID)+ } else if sessionID != "" {+ ctx = context.WithValue(ctx, contextKeySessionID, sessionID)+ logger.Debug("Received MCP session ID header") } // Extract Consul token from header- consulToken := r.Header.Get(textproto.CanonicalMIMEHeaderKey("X-Consul-Token"))+ consulToken := r.Header.Get("X-Consul-Token") if consulToken != "" {- ctx = context.WithValue(ctx, "consul_token", consulToken)+ ctx = context.WithValue(ctx, contextKeyConsulToken, consulToken) } next.ServeHTTP(w, r.WithContext(ctx))AI-assisted change fbb69c20f921d43fceabae4c4aa734a8f09b96ba01da14dc81cea946df27a6e7 · Fix a11fc7c1ed0ca93d91699a729c5f2c2a2f87e923a8e59f8328ee947c7ffd3cef