Skip to content

Commit ebb4e67

Browse files
committed
feat!(server/sse): Add support for dynamic base paths
This change introduces the ability to mount SSE endpoints at dynamic paths with variable segments (e.g., `/api/{tenant}/sse`) by adding a new `WithDynamicBasePath` option and related functionality. This enables advanced use cases such as multi-tenant architectures or integration with routers that support path parameters. Key Features: * DynamicBasePathFunc: New function type and option (WithDynamicBasePath) to generate the SSE server's base path dynamically per request/session. * Flexible Routing: New SSEHandler() and MessageHandler() methods allow mounting handlers at arbitrary or dynamic paths using any router (e.g., net/http, chi, gorilla/mux). * Endpoint Generation: GetMessageEndpointForClient now supports both static and dynamic path modes, and correctly generates full URLs when configured. * Example: Added examples/dynamic_path/main.go demonstrating dynamic path mounting and usage. ```go mcpServer := mcp.NewMCPServer("dynamic-path-example", "1.0.0") sseServer := mcp.NewSSEServer( mcpServer, mcp.WithDynamicBasePath(func(r *http.Request, sessionID string) string { tenant := r.PathValue("tenant") return "/api/" + tenant }), mcp.WithBaseURL("http://localhost:8080"), ) mux := http.NewServeMux() mux.Handle("/api/{tenant}/sse", sseServer.SSEHandler()) mux.Handle("/api/{tenant}/message", sseServer.MessageHandler()) ```
1 parent ae96a68 commit ebb4e67

File tree

4 files changed

+345
-27
lines changed

4 files changed

+345
-27
lines changed

examples/dynamic_path/main.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
)
13+
14+
func main() {
15+
var addr string
16+
flag.StringVar(&addr, "addr", ":8080", "address to listen on")
17+
flag.Parse()
18+
19+
mcpServer := server.NewMCPServer("dynamic-path-example", "1.0.0")
20+
21+
// Add a trivial tool for demonstration
22+
mcpServer.AddTool(mcp.NewTool("echo"), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
23+
return mcp.NewToolResultText(fmt.Sprintf("Echo: %v", req.Params.Arguments["message"])), nil
24+
})
25+
26+
// Use a dynamic base path based on a path parameter (Go 1.22+)
27+
sseServer := server.NewSSEServer(
28+
mcpServer,
29+
server.WithDynamicBasePath(func(r *http.Request, sessionID string) string {
30+
tenant := r.PathValue("tenant")
31+
return "/api/" + tenant
32+
}),
33+
server.WithBaseURL(fmt.Sprintf("http://localhost%s", addr)),
34+
server.WithUseFullURLForMessageEndpoint(true),
35+
)
36+
37+
mux := http.NewServeMux()
38+
mux.Handle("/api/{tenant}/sse", sseServer.SSEHandler())
39+
mux.Handle("/api/{tenant}/message", sseServer.MessageHandler())
40+
41+
log.Printf("Dynamic SSE server listening on %s", addr)
42+
if err := http.ListenAndServe(addr, mux); err != nil {
43+
log.Fatalf("Server error: %v", err)
44+
}
45+
}
46+

server/errors.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"errors"
5+
"fmt"
56
)
67

78
var (
@@ -21,3 +22,12 @@ var (
2122
ErrNotificationNotInitialized = errors.New("notification channel not initialized")
2223
ErrNotificationChannelBlocked = errors.New("notification channel full or blocked")
2324
)
25+
26+
// ErrDynamicPathConfig is returned when attempting to use static path methods with dynamic path configuration
27+
type ErrDynamicPathConfig struct {
28+
Method string
29+
}
30+
31+
func (e *ErrDynamicPathConfig) Error() string {
32+
return fmt.Sprintf("%s cannot be used with WithDynamicBasePath. Use dynamic path logic in your router.", e.Method)
33+
}

server/sse.go

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ type sseSession struct {
3333
// content. This can be used to inject context values from headers, for example.
3434
type SSEContextFunc func(ctx context.Context, r *http.Request) context.Context
3535

36+
// DynamicBasePathFunc allows the user to provide a function to generate the
37+
// base path for a given request and sessionID. This is useful for cases where
38+
// the base path is not known at the time of SSE server creation, such as when
39+
// using a reverse proxy or when the base path is dynamically generated. The
40+
// function should return the base path (e.g., "/mcp/tenant123").
41+
type DynamicBasePathFunc func(r *http.Request, sessionID string) string
42+
3643
func (s *sseSession) SessionID() string {
3744
return s.sessionID
3845
}
@@ -68,6 +75,9 @@ type SSEServer struct {
6875
keepAliveInterval time.Duration
6976

7077
mu sync.RWMutex
78+
79+
// user-provided function for determining the dynamic base path
80+
dynamicBasePathFunc DynamicBasePathFunc
7181
}
7282

7383
// SSEOption defines a function type for configuring SSEServer
@@ -96,7 +106,7 @@ func WithBaseURL(baseURL string) SSEOption {
96106
}
97107
}
98108

99-
// WithBasePath adds a new option for setting base path
109+
// WithBasePath adds a new option for setting a static base path
100110
func WithBasePath(basePath string) SSEOption {
101111
return func(s *SSEServer) {
102112
// Ensure the path starts with / and doesn't end with /
@@ -107,6 +117,24 @@ func WithBasePath(basePath string) SSEOption {
107117
}
108118
}
109119

120+
// WithDynamicBasePath accepts a function for generating the base path. This is
121+
// useful for cases where the base path is not known at the time of SSE server
122+
// creation, such as when using a reverse proxy or when the server is mounted
123+
// at a dynamic path.
124+
func WithDynamicBasePath(fn DynamicBasePathFunc) SSEOption {
125+
return func(s *SSEServer) {
126+
if fn != nil {
127+
s.dynamicBasePathFunc = func(r *http.Request, sid string) string {
128+
bp := fn(r, sid)
129+
if !strings.HasPrefix(bp, "/") {
130+
bp = "/" + bp
131+
}
132+
return strings.TrimSuffix(bp, "/")
133+
}
134+
}
135+
}
136+
}
137+
110138
// WithMessageEndpoint sets the message endpoint path
111139
func WithMessageEndpoint(endpoint string) SSEOption {
112140
return func(s *SSEServer) {
@@ -308,7 +336,8 @@ func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) {
308336
}
309337

310338
// Send the initial endpoint event
311-
fmt.Fprintf(w, "event: endpoint\ndata: %s\r\n\r\n", s.GetMessageEndpointForClient(sessionID))
339+
endpoint := s.GetMessageEndpointForClient(r, sessionID)
340+
fmt.Fprintf(w, "event: endpoint\ndata: %s\r\n\r\n", endpoint)
312341
flusher.Flush()
313342

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

330359
// GetMessageEndpointForClient returns the appropriate message endpoint URL with session ID
331-
// based on the useFullURLForMessageEndpoint configuration.
332-
func (s *SSEServer) GetMessageEndpointForClient(sessionID string) string {
333-
messageEndpoint := s.messageEndpoint
334-
if s.useFullURLForMessageEndpoint {
335-
messageEndpoint = s.CompleteMessageEndpoint()
360+
// for the given request. This is the canonical way to compute the message endpoint for a client.
361+
// It handles both dynamic and static path modes, and honors the WithUseFullURLForMessageEndpoint flag.
362+
func (s *SSEServer) GetMessageEndpointForClient(r *http.Request, sessionID string) string {
363+
basePath := s.basePath
364+
if s.dynamicBasePathFunc != nil {
365+
basePath = s.dynamicBasePathFunc(r, sessionID)
366+
}
367+
368+
endpointPath := basePath + s.messageEndpoint
369+
if s.useFullURLForMessageEndpoint && s.baseURL != "" {
370+
endpointPath = s.baseURL + endpointPath
336371
}
337-
return fmt.Sprintf("%s?sessionId=%s", messageEndpoint, sessionID)
372+
373+
return fmt.Sprintf("%s?sessionId=%s", endpointPath, sessionID)
338374
}
339375

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

449-
func (s *SSEServer) CompleteSseEndpoint() string {
450-
return s.baseURL + s.basePath + s.sseEndpoint
485+
func (s *SSEServer) CompleteSseEndpoint() (string, error) {
486+
if s.dynamicBasePathFunc != nil {
487+
return "", &ErrDynamicPathConfig{Method: "CompleteSseEndpoint"}
488+
}
489+
return s.baseURL + s.basePath + s.sseEndpoint, nil
451490
}
452491

453492
func (s *SSEServer) CompleteSsePath() string {
454-
path, err := s.GetUrlPath(s.CompleteSseEndpoint())
493+
path, err := s.CompleteSseEndpoint()
455494
if err != nil {
456495
return s.basePath + s.sseEndpoint
457496
}
458-
return path
497+
urlPath, err := s.GetUrlPath(path)
498+
if err != nil {
499+
return s.basePath + s.sseEndpoint
500+
}
501+
return urlPath
459502
}
460503

461-
func (s *SSEServer) CompleteMessageEndpoint() string {
462-
return s.baseURL + s.basePath + s.messageEndpoint
504+
func (s *SSEServer) CompleteMessageEndpoint() (string, error) {
505+
if s.dynamicBasePathFunc != nil {
506+
return "", &ErrDynamicPathConfig{Method: "CompleteMessageEndpoint"}
507+
}
508+
return s.baseURL + s.basePath + s.messageEndpoint, nil
463509
}
464510

465511
func (s *SSEServer) CompleteMessagePath() string {
466-
path, err := s.GetUrlPath(s.CompleteMessageEndpoint())
512+
path, err := s.CompleteMessageEndpoint()
513+
if err != nil {
514+
return s.basePath + s.messageEndpoint
515+
}
516+
urlPath, err := s.GetUrlPath(path)
467517
if err != nil {
468518
return s.basePath + s.messageEndpoint
469519
}
470-
return path
520+
return urlPath
521+
}
522+
523+
// SSEHandler returns an http.Handler for the SSE endpoint.
524+
//
525+
// This method allows you to mount the SSE handler at any arbitrary path
526+
// using your own router (e.g. net/http, gorilla/mux, chi, etc.). It is
527+
// intended for advanced scenarios where you want to control the routing or
528+
// support dynamic segments.
529+
//
530+
// IMPORTANT: When using this handler in advanced/dynamic mounting scenarios,
531+
// you must use the WithDynamicBasePath option to ensure the correct base path
532+
// is communicated to clients.
533+
//
534+
// Example usage:
535+
//
536+
// // Advanced/dynamic:
537+
// sseServer := NewSSEServer(mcpServer,
538+
// WithDynamicBasePath(func(r *http.Request, sessionID string) string {
539+
// tenant := r.PathValue("tenant")
540+
// return "/mcp/" + tenant
541+
// }),
542+
// WithBaseURL("http://localhost:8080")
543+
// )
544+
// mux.Handle("/mcp/{tenant}/sse", sseServer.SSEHandler())
545+
// mux.Handle("/mcp/{tenant}/message", sseServer.MessageHandler())
546+
//
547+
// For non-dynamic cases, use ServeHTTP method instead.
548+
func (s *SSEServer) SSEHandler() http.Handler {
549+
return http.HandlerFunc(s.handleSSE)
550+
}
551+
552+
// MessageHandler returns an http.Handler for the message endpoint.
553+
//
554+
// This method allows you to mount the message handler at any arbitrary path
555+
// using your own router (e.g. net/http, gorilla/mux, chi, etc.). It is
556+
// intended for advanced scenarios where you want to control the routing or
557+
// support dynamic segments.
558+
//
559+
// IMPORTANT: When using this handler in advanced/dynamic mounting scenarios,
560+
// you must use the WithDynamicBasePath option to ensure the correct base path
561+
// is communicated to clients.
562+
//
563+
// Example usage:
564+
//
565+
// // Advanced/dynamic:
566+
// sseServer := NewSSEServer(mcpServer,
567+
// WithDynamicBasePath(func(r *http.Request, sessionID string) string {
568+
// tenant := r.PathValue("tenant")
569+
// return "/mcp/" + tenant
570+
// }),
571+
// WithBaseURL("http://localhost:8080")
572+
// )
573+
// mux.Handle("/mcp/{tenant}/sse", sseServer.SSEHandler())
574+
// mux.Handle("/mcp/{tenant}/message", sseServer.MessageHandler())
575+
//
576+
// For non-dynamic cases, use ServeHTTP method instead.
577+
func (s *SSEServer) MessageHandler() http.Handler {
578+
return http.HandlerFunc(s.handleMessage)
471579
}
472580

473581
// ServeHTTP implements the http.Handler interface.
474582
func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
583+
if s.dynamicBasePathFunc != nil {
584+
http.Error(w, (&ErrDynamicPathConfig{Method: "ServeHTTP"}).Error(), http.StatusInternalServerError)
585+
return
586+
}
475587
path := r.URL.Path
476588
// Use exact path matching rather than Contains
477589
ssePath := s.CompleteSsePath()

0 commit comments

Comments
 (0)