Skip to content

feat(server/sse): Add support for dynamic base paths #214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
46 changes: 46 additions & 0 deletions examples/dynamic_path/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"net/http"

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

func main() {
var addr string
flag.StringVar(&addr, "addr", ":8080", "address to listen on")
flag.Parse()

mcpServer := server.NewMCPServer("dynamic-path-example", "1.0.0")

// Add a trivial tool for demonstration
mcpServer.AddTool(mcp.NewTool("echo"), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText(fmt.Sprintf("Echo: %v", req.Params.Arguments["message"])), nil
})

// Use a dynamic base path based on a path parameter (Go 1.22+)
sseServer := server.NewSSEServer(
mcpServer,
server.WithDynamicBasePath(func(r *http.Request, sessionID string) string {
tenant := r.PathValue("tenant")
return "/api/" + tenant
}),
server.WithBaseURL(fmt.Sprintf("http://localhost%s", addr)),
server.WithUseFullURLForMessageEndpoint(true),
)

mux := http.NewServeMux()
mux.Handle("/api/{tenant}/sse", sseServer.SSEHandler())
mux.Handle("/api/{tenant}/message", sseServer.MessageHandler())

log.Printf("Dynamic SSE server listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}

10 changes: 10 additions & 0 deletions server/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"errors"
"fmt"
)

var (
Expand All @@ -21,3 +22,12 @@ var (
ErrNotificationNotInitialized = errors.New("notification channel not initialized")
ErrNotificationChannelBlocked = errors.New("notification channel full or blocked")
)

// ErrDynamicPathConfig is returned when attempting to use static path methods with dynamic path configuration
type ErrDynamicPathConfig struct {
Method string
}

func (e *ErrDynamicPathConfig) Error() string {
return fmt.Sprintf("%s cannot be used with WithDynamicBasePath. Use dynamic path logic in your router.", e.Method)
}
144 changes: 128 additions & 16 deletions server/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ type sseSession struct {
// content. This can be used to inject context values from headers, for example.
type SSEContextFunc func(ctx context.Context, r *http.Request) context.Context

// DynamicBasePathFunc allows the user to provide a function to generate the
// base path for a given request and sessionID. This is useful for cases where
// the base path is not known at the time of SSE server creation, such as when
// using a reverse proxy or when the base path is dynamically generated. The
// function should return the base path (e.g., "/mcp/tenant123").
type DynamicBasePathFunc func(r *http.Request, sessionID string) string

func (s *sseSession) SessionID() string {
return s.sessionID
}
Expand Down Expand Up @@ -68,6 +75,9 @@ type SSEServer struct {
keepAliveInterval time.Duration

mu sync.RWMutex

// user-provided function for determining the dynamic base path
dynamicBasePathFunc DynamicBasePathFunc
}

// SSEOption defines a function type for configuring SSEServer
Expand Down Expand Up @@ -96,7 +106,7 @@ func WithBaseURL(baseURL string) SSEOption {
}
}

// Add a new option for setting base path
// Add a new option for setting a static base path
func WithBasePath(basePath string) SSEOption {
return func(s *SSEServer) {
// Ensure the path starts with / and doesn't end with /
Expand All @@ -107,6 +117,24 @@ func WithBasePath(basePath string) SSEOption {
}
}

// WithDynamicBasePath accepts a function for generating the base path. This is
// useful for cases where the base path is not known at the time of SSE server
// creation, such as when using a reverse proxy or when the server is mounted
// at a dynamic path.
func WithDynamicBasePath(fn DynamicBasePathFunc) SSEOption {
return func(s *SSEServer) {
if fn != nil {
s.dynamicBasePathFunc = func(r *http.Request, sid string) string {
bp := fn(r, sid)
if !strings.HasPrefix(bp, "/") {
bp = "/" + bp
}
return strings.TrimSuffix(bp, "/")
}
}
}
}

// WithMessageEndpoint sets the message endpoint path
func WithMessageEndpoint(endpoint string) SSEOption {
return func(s *SSEServer) {
Expand Down Expand Up @@ -308,7 +336,8 @@ func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) {
}

// Send the initial endpoint event
fmt.Fprintf(w, "event: endpoint\ndata: %s\r\n\r\n", s.GetMessageEndpointForClient(sessionID))
endpoint := s.GetMessageEndpointForClient(r, sessionID)
fmt.Fprintf(w, "event: endpoint\ndata: %s\r\n\r\n", endpoint)
flusher.Flush()

// Main event loop - this runs in the HTTP handler goroutine
Expand All @@ -328,13 +357,20 @@ func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) {
}

// GetMessageEndpointForClient returns the appropriate message endpoint URL with session ID
// based on the useFullURLForMessageEndpoint configuration.
func (s *SSEServer) GetMessageEndpointForClient(sessionID string) string {
messageEndpoint := s.messageEndpoint
if s.useFullURLForMessageEndpoint {
messageEndpoint = s.CompleteMessageEndpoint()
// for the given request. This is the canonical way to compute the message endpoint for a client.
// It handles both dynamic and static path modes, and honors the WithUseFullURLForMessageEndpoint flag.
func (s *SSEServer) GetMessageEndpointForClient(r *http.Request, sessionID string) string {
basePath := s.basePath
if s.dynamicBasePathFunc != nil {
basePath = s.dynamicBasePathFunc(r, sessionID)
}

endpointPath := basePath + s.messageEndpoint
if s.useFullURLForMessageEndpoint && s.baseURL != "" {
endpointPath = s.baseURL + endpointPath
}
return fmt.Sprintf("%s?sessionId=%s", messageEndpoint, sessionID)

return fmt.Sprintf("%s?sessionId=%s", endpointPath, sessionID)
}

// handleMessage processes incoming JSON-RPC messages from clients and sends responses
Expand Down Expand Up @@ -446,32 +482,108 @@ func (s *SSEServer) GetUrlPath(input string) (string, error) {
return parse.Path, nil
}

func (s *SSEServer) CompleteSseEndpoint() string {
return s.baseURL + s.basePath + s.sseEndpoint
func (s *SSEServer) CompleteSseEndpoint() (string, error) {
if s.dynamicBasePathFunc != nil {
return "", &ErrDynamicPathConfig{Method: "CompleteSseEndpoint"}
}
return s.baseURL + s.basePath + s.sseEndpoint, nil
}

func (s *SSEServer) CompleteSsePath() string {
path, err := s.GetUrlPath(s.CompleteSseEndpoint())
path, err := s.CompleteSseEndpoint()
if err != nil {
return s.basePath + s.sseEndpoint
}
return path
urlPath, err := s.GetUrlPath(path)
if err != nil {
return s.basePath + s.sseEndpoint
}
return urlPath
}

func (s *SSEServer) CompleteMessageEndpoint() string {
return s.baseURL + s.basePath + s.messageEndpoint
func (s *SSEServer) CompleteMessageEndpoint() (string, error) {
if s.dynamicBasePathFunc != nil {
return "", &ErrDynamicPathConfig{Method: "CompleteMessageEndpoint"}
}
return s.baseURL + s.basePath + s.messageEndpoint, nil
}

func (s *SSEServer) CompleteMessagePath() string {
path, err := s.GetUrlPath(s.CompleteMessageEndpoint())
path, err := s.CompleteMessageEndpoint()
if err != nil {
return s.basePath + s.messageEndpoint
}
urlPath, err := s.GetUrlPath(path)
if err != nil {
return s.basePath + s.messageEndpoint
}
return path
return urlPath
}

// SSEHandler returns an http.Handler for the SSE endpoint.
//
// This method allows you to mount the SSE handler at any arbitrary path
// using your own router (e.g. net/http, gorilla/mux, chi, etc.). It is
// intended for advanced scenarios where you want to control the routing or
// support dynamic segments.
//
// IMPORTANT: When using this handler in advanced/dynamic mounting scenarios,
// you must use the WithDynamicBasePath option to ensure the correct base path
// is communicated to clients.
//
// Example usage:
//
// // Advanced/dynamic:
// sseServer := NewSSEServer(mcpServer,
// WithDynamicBasePath(func(r *http.Request, sessionID string) string {
// tenant := r.PathValue("tenant")
// return "/mcp/" + tenant
// }),
// WithBaseURL("http://localhost:8080")
// )
// mux.Handle("/mcp/{tenant}/sse", sseServer.SSEHandler())
// mux.Handle("/mcp/{tenant}/message", sseServer.MessageHandler())
//
// For non-dynamic cases, use ServeHTTP method instead.
func (s *SSEServer) SSEHandler() http.Handler {
return http.HandlerFunc(s.handleSSE)
}

// MessageHandler returns an http.Handler for the message endpoint.
//
// This method allows you to mount the message handler at any arbitrary path
// using your own router (e.g. net/http, gorilla/mux, chi, etc.). It is
// intended for advanced scenarios where you want to control the routing or
// support dynamic segments.
//
// IMPORTANT: When using this handler in advanced/dynamic mounting scenarios,
// you must use the WithDynamicBasePath option to ensure the correct base path
// is communicated to clients.
//
// Example usage:
//
// // Advanced/dynamic:
// sseServer := NewSSEServer(mcpServer,
// WithDynamicBasePath(func(r *http.Request, sessionID string) string {
// tenant := r.PathValue("tenant")
// return "/mcp/" + tenant
// }),
// WithBaseURL("http://localhost:8080")
// )
// mux.Handle("/mcp/{tenant}/sse", sseServer.SSEHandler())
// mux.Handle("/mcp/{tenant}/message", sseServer.MessageHandler())
//
// For non-dynamic cases, use ServeHTTP method instead.
func (s *SSEServer) MessageHandler() http.Handler {
return http.HandlerFunc(s.handleMessage)
}

// ServeHTTP implements the http.Handler interface.
func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.dynamicBasePathFunc != nil {
http.Error(w, (&ErrDynamicPathConfig{Method: "ServeHTTP"}).Error(), http.StatusInternalServerError)
return
}
path := r.URL.Path
// Use exact path matching rather than Contains
ssePath := s.CompleteSsePath()
Expand Down
Loading