Repository: mark3labs/mcp-go

Issue:
How can I plug it into an existing net/http server?
**Description:**

I’m integrating mcp-go’s SSEServer into my project and need to combine it with other HTTP endpoints (e.g., Kubernetes health checks) in a single server. For clarity, I want to mount the SSEServer under a custom prefix (e.g., /mcp) while leaving my health endpoints at the root (like /live and /ready).

The SSEServer’s ServeHTTP method is designed to handle requests exactly at /sse and /message. When I attempt to register it using http.HandleFunc with a prefix I still encounter 404 errors when accessing /mcp/sse or /mcp/message.

Steps to Reproduce:
1. Initialize an instance of the SSEServer via the mcp-go API.
2. Register the handler using a code snippet similar to:

```go
http.HandleFunc("/ready", readinessHandler)

// assuming s is an already existing MCPServer
sse := server.NewSSEServer(s, "")
http.HandleFunc("/mcp/", sse.ServerHTTP)

log.Fatal(http.listAndServe(":80", nil))
```


3. Access `/mcp/sse`, `/mcp/message` or `/sse`, `/message` and observe a 404 response.

**Expected Behavior:**
- A request at least to `/sse` should be correctly handled.

**Environment:**
- Go version: 1.23.5 (using an Alpine-based Docker image)
- mcp-go version: v0.11.2

**Additional Context:**
- I have verified that endpoints like /live and /ready work as expected.
- The issue appears to be specific to the prefix handling for SSEServer’s endpoints.
- I’m open to suggestions on the correct approach for mounting the SSEServer under a custom path or if this might be a limitation/bug.

Any guidance or recommended changes would be greatly appreciated. Thank you!


Hints from issue comments:
@zoedsoupe: I thought mux would accept something like `:80/mcp` as base URL but I was wrong... im kinda noob with go but it seems as a limitation of the impl itself@zoedsoupe: well, with #45 one could do something like:

```go
http.HandleFunc("/ready", readinessHandler)

// assuming s is an already existing MCPServer
sse := server.NewSSEServer(s, server.WithBasePath("/mcp")
http.HandleFunc("/mcp/", sse.ServerHTTP)

log.Fatal(http.listAndServe(":80", nil))
```

and now we got it!!!

```sh
2025/03/07 15:52:51 Server running on :80
[] 2025/03/07 - 15:52:58 | 200 |  1.728694792s |       127.0.0.1 | GET      "/mcp/sse"
[] 2025/03/07 - 15:53:08 | 400 |     205.041µs |       127.0.0.1 | POST     "/mcp/message"
[] 2025/03/07 - 15:53:17 | 200 |      27.292µs |       127.0.0.1 | GET      "/ready"
```

Solution patch:
diff --git a/server/server.go b/server/server.go
index b056dd8..1af0192 100644
--- a/server/server.go
+++ b/server/server.go
@@ -67,6 +67,7 @@ type MCPServer struct {
 	mu                   sync.RWMutex // Add mutex for protecting shared resources
 	name                 string
 	version              string
+	instructions         string
 	resources            map[string]resourceEntry
 	resourceTemplates    map[string]resourceTemplateEntry
 	prompts              map[string]mcp.Prompt
@@ -198,6 +199,13 @@ func WithLogging() ServerOption {
 	}
 }
 
+// WithInstructions sets the server instructions for the client returned in the initialize response
+func WithInstructions(instructions string) ServerOption {
+	return func(s *MCPServer) {
+		s.instructions = instructions
+	}
+}
+
 // NewMCPServer creates a new MCP server instance with the given name, version and options
 func NewMCPServer(
 	name, version string,
@@ -571,6 +579,7 @@ func (s *MCPServer) handleInitialize(
 			Version: s.version,
 		},
 		Capabilities: capabilities,
+		Instructions: s.instructions,
 	}
 
 	s.initialized.Store(true)
diff --git a/server/sse.go b/server/sse.go
index 0e526be..5a28aaa 100644
--- a/server/sse.go
+++ b/server/sse.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"sync"
 
 	"github.com/google/uuid"
@@ -25,6 +26,7 @@ type sseSession struct {
 type SSEServer struct {
 	server          *MCPServer
 	baseURL         string
+	basePath        string
 	messageEndpoint string
 	sseEndpoint     string
 	sessions        sync.Map
@@ -41,6 +43,18 @@ func WithBaseURL(baseURL string) Option {
 	}
 }
 
+// Add a new option for setting base path
+func WithBasePath(basePath string) Option {
+	return func(s *SSEServer) {
+		// Ensure the path starts with / and doesn't end with /
+		if !strings.HasPrefix(basePath, "/") {
+			basePath = "/" + basePath
+		}
+		s.basePath = strings.TrimSuffix(basePath, "/")
+		s.baseURL = s.baseURL + s.basePath
+	}
+}
+
 // WithMessageEndpoint sets the message endpoint path
 func WithMessageEndpoint(endpoint string) Option {
 	return func(s *SSEServer) {
@@ -68,6 +82,7 @@ func NewSSEServer(server *MCPServer, opts ...Option) *SSEServer {
 		server:          server,
 		sseEndpoint:     "/sse",
 		messageEndpoint: "/message",
+		basePath:        "",
 	}
 
 	// Apply all options
@@ -299,12 +314,22 @@ func (s *SSEServer) SendEventToSession(
 
 // ServeHTTP implements the http.Handler interface.
 func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	switch r.URL.Path {
-	case s.sseEndpoint:
+	path := r.URL.Path
+
+	// Construct the full SSE and message paths
+	ssePath := s.basePath + s.sseEndpoint
+	messagePath := s.basePath + s.messageEndpoint
+
+	// Use exact path matching rather than Contains
+	if path == ssePath {
 		s.handleSSE(w, r)
-	case s.messageEndpoint:
+		return
+	}
+
+	if path == messagePath {
 		s.handleMessage(w, r)
-	default:
-		http.NotFound(w, r)
+		return
 	}
+
+	http.NotFound(w, r)
 }



Test patch:
diff --git a/server/server_test.go b/server/server_test.go
index 7217676..8208503 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -141,6 +141,7 @@ func TestMCPServer_Capabilities(t *testing.T) {
 		})
 	}
 }
+
 func TestMCPServer_Tools(t *testing.T) {
 	tests := []struct {
 		name                  string
@@ -207,14 +208,14 @@ func TestMCPServer_Tools(t *testing.T) {
 				assert.Equal(t, "notifications/tools/list_changed", notifications[0].Notification.Method)
 				// One for DeleteTools
 				assert.Equal(t, "notifications/tools/list_changed", notifications[1].Notification.Method)
-				
+
 				// Expect a successful response with an empty list of tools
 				resp, ok := toolsList.(mcp.JSONRPCResponse)
 				assert.True(t, ok, "Expected JSONRPCResponse, got %T", toolsList)
-				
+
 				result, ok := resp.Result.(mcp.ListToolsResult)
 				assert.True(t, ok, "Expected ListToolsResult, got %T", resp.Result)
-				
+
 				assert.Empty(t, result.Tools, "Expected empty tools list")
 			},
 		},
@@ -660,7 +661,7 @@ func TestMCPServer_HandleMethodsWithoutCapabilities(t *testing.T) {
                         "name": "test-tool"
                     }
                 }`,
-			options: []ServerOption{},  // No capabilities at all
+			options:     []ServerOption{}, // No capabilities at all
 			expectedErr: mcp.METHOD_NOT_FOUND,
 		},
 		{
@@ -673,7 +674,7 @@ func TestMCPServer_HandleMethodsWithoutCapabilities(t *testing.T) {
                         "name": "test-prompt"
                     }
                 }`,
-			options: []ServerOption{},  // No capabilities at all
+			options:     []ServerOption{}, // No capabilities at all
 			expectedErr: mcp.METHOD_NOT_FOUND,
 		},
 		{
@@ -686,7 +687,7 @@ func TestMCPServer_HandleMethodsWithoutCapabilities(t *testing.T) {
                         "uri": "test-resource"
                     }
                 }`,
-			options: []ServerOption{},  // No capabilities at all
+			options:     []ServerOption{}, // No capabilities at all
 			expectedErr: mcp.METHOD_NOT_FOUND,
 		},
 	}
@@ -707,6 +708,75 @@ func TestMCPServer_HandleMethodsWithoutCapabilities(t *testing.T) {
 	}
 }
 
+func TestMCPServer_Instructions(t *testing.T) {
+	tests := []struct {
+		name         string
+		instructions string
+		validate     func(t *testing.T, response mcp.JSONRPCMessage)
+	}{
+		{
+			name:         "No instructions",
+			instructions: "",
+			validate: func(t *testing.T, response mcp.JSONRPCMessage) {
+				resp, ok := response.(mcp.JSONRPCResponse)
+				assert.True(t, ok)
+
+				initResult, ok := resp.Result.(mcp.InitializeResult)
+				assert.True(t, ok)
+				assert.Equal(t, "", initResult.Instructions)
+			},
+		},
+		{
+			name:         "With instructions",
+			instructions: "These are test instructions for the client.",
+			validate: func(t *testing.T, response mcp.JSONRPCMessage) {
+				resp, ok := response.(mcp.JSONRPCResponse)
+				assert.True(t, ok)
+
+				initResult, ok := resp.Result.(mcp.InitializeResult)
+				assert.True(t, ok)
+				assert.Equal(t, "These are test instructions for the client.", initResult.Instructions)
+			},
+		},
+		{
+			name:         "With multiline instructions",
+			instructions: "Line 1\nLine 2\nLine 3",
+			validate: func(t *testing.T, response mcp.JSONRPCMessage) {
+				resp, ok := response.(mcp.JSONRPCResponse)
+				assert.True(t, ok)
+
+				initResult, ok := resp.Result.(mcp.InitializeResult)
+				assert.True(t, ok)
+				assert.Equal(t, "Line 1\nLine 2\nLine 3", initResult.Instructions)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var server *MCPServer
+			if tt.instructions == "" {
+				server = NewMCPServer("test-server", "1.0.0")
+			} else {
+				server = NewMCPServer("test-server", "1.0.0", WithInstructions(tt.instructions))
+			}
+
+			message := mcp.JSONRPCRequest{
+				JSONRPC: "2.0",
+				ID:      1,
+				Request: mcp.Request{
+					Method: "initialize",
+				},
+			}
+			messageBytes, err := json.Marshal(message)
+			assert.NoError(t, err)
+
+			response := server.HandleMessage(context.Background(), messageBytes)
+			tt.validate(t, response)
+		})
+	}
+}
+
 func createTestServer() *MCPServer {
 	server := NewMCPServer("test-server", "1.0.0",
 		WithResourceCapabilities(true, true),
diff --git a/server/sse_test.go b/server/sse_test.go
index 8ea49e2..bd88cac 100644
--- a/server/sse_test.go
+++ b/server/sse_test.go
@@ -16,7 +16,10 @@ import (
 func TestSSEServer(t *testing.T) {
 	t.Run("Can instantiate", func(t *testing.T) {
 		mcpServer := NewMCPServer("test", "1.0.0")
-		sseServer := NewSSEServer(mcpServer, WithBaseURL("http://localhost:8080"))
+		sseServer := NewSSEServer(mcpServer,
+			WithBaseURL("http://localhost:8080"),
+			WithBasePath("/mcp"),
+		)
 
 		if sseServer == nil {
 			t.Error("SSEServer should not be nil")
@@ -24,12 +27,18 @@ func TestSSEServer(t *testing.T) {
 		if sseServer.server == nil {
 			t.Error("MCPServer should not be nil")
 		}
-		if sseServer.baseURL != "http://localhost:8080" {
+		if sseServer.baseURL != "http://localhost:8080/mcp" {
 			t.Errorf(
-				"Expected baseURL http://localhost:8080, got %s",
+				"Expected baseURL http://localhost:8080/mcp, got %s",
 				sseServer.baseURL,
 			)
 		}
+		if sseServer.basePath != "/mcp" {
+			t.Errorf(
+				"Expected basePath /mcp, got %s",
+				sseServer.basePath,
+			)
+		}
 	})
 
 	t.Run("Can send and receive messages", func(t *testing.T) {
@@ -405,4 +414,58 @@ func TestSSEServer(t *testing.T) {
 		// Clean up SSE connection
 		cancel()
 	})
+
+	t.Run("works as http.Handler with custom basePath", func(t *testing.T) {
+		mcpServer := NewMCPServer("test", "1.0.0")
+		sseServer := NewSSEServer(mcpServer, WithBasePath("/mcp"))
+
+		ts := httptest.NewServer(sseServer)
+		defer ts.Close()
+
+		// Test 404 for unknown path first (simpler case)
+		resp, err := http.Get(fmt.Sprintf("%s/sse", ts.URL))
+		if err != nil {
+			t.Fatalf("Failed to make request: %v", err)
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode != http.StatusNotFound {
+			t.Errorf("Expected status 404, got %d", resp.StatusCode)
+		}
+
+		// Test SSE endpoint with proper cleanup
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		sseURL := fmt.Sprintf("%s/sse", ts.URL+sseServer.basePath)
+		req, err := http.NewRequestWithContext(ctx, "GET", sseURL, nil)
+		if err != nil {
+			t.Fatalf("Failed to create request: %v", err)
+		}
+
+		resp, err = http.DefaultClient.Do(req)
+		if err != nil {
+			t.Fatalf("Failed to connect to SSE endpoint: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusOK {
+			t.Errorf("Expected status 200, got %d", resp.StatusCode)
+		}
+
+		// Read initial message in goroutine
+		done := make(chan struct{})
+		go func() {
+			defer close(done)
+			buf := make([]byte, 1024)
+			_, err := resp.Body.Read(buf)
+			if err != nil && err.Error() != "context canceled" {
+				t.Errorf("Failed to read from SSE stream: %v", err)
+			}
+		}()
+
+		// Wait briefly for initial response then cancel
+		time.Sleep(100 * time.Millisecond)
+		cancel()
+		<-done
+	})
 }