Skip to content

Commit 1999773

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 cfeb0ee commit 1999773

File tree

4 files changed

+366
-32
lines changed

4 files changed

+366
-32
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: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ type sseSession struct {
3434
// content. This can be used to inject context values from headers, for example.
3535
type SSEContextFunc func(ctx context.Context, r *http.Request) context.Context
3636

37+
// DynamicBasePathFunc allows the user to provide a function to generate the
38+
// base path for a given request and sessionID. This is useful for cases where
39+
// the base path is not known at the time of SSE server creation, such as when
40+
// using a reverse proxy or when the base path is dynamically generated. The
41+
// function should return the base path (e.g., "/mcp/tenant123").
42+
type DynamicBasePathFunc func(r *http.Request, sessionID string) string
43+
3744
func (s *sseSession) SessionID() string {
3845
return s.sessionID
3946
}
@@ -58,19 +65,19 @@ type SSEServer struct {
5865
server *MCPServer
5966
baseURL string
6067
basePath string
68+
appendQueryToMessageEndpoint bool
6169
useFullURLForMessageEndpoint bool
6270
messageEndpoint string
6371
sseEndpoint string
6472
sessions sync.Map
6573
srv *http.Server
6674
contextFunc SSEContextFunc
75+
dynamicBasePathFunc DynamicBasePathFunc
6776

6877
keepAlive bool
6978
keepAliveInterval time.Duration
7079

7180
mu sync.RWMutex
72-
73-
appendQueryToMessageEndpoint bool
7481
}
7582

7683
// SSEOption defines a function type for configuring SSEServer
@@ -99,7 +106,7 @@ func WithBaseURL(baseURL string) SSEOption {
99106
}
100107
}
101108

102-
// WithBasePath adds a new option for setting base path
109+
// WithBasePath adds a new option for setting a static base path
103110
func WithBasePath(basePath string) SSEOption {
104111
return func(s *SSEServer) {
105112
// Ensure the path starts with / and doesn't end with /
@@ -110,6 +117,24 @@ func WithBasePath(basePath string) SSEOption {
110117
}
111118
}
112119

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+
113138
// WithMessageEndpoint sets the message endpoint path
114139
func WithMessageEndpoint(endpoint string) SSEOption {
115140
return func(s *SSEServer) {
@@ -208,8 +233,8 @@ func (s *SSEServer) Start(addr string) error {
208233

209234
if s.srv == nil {
210235
s.srv = &http.Server{
211-
Addr: addr,
212-
Handler: s,
236+
Addr: addr,
237+
Handler: s,
213238
}
214239
} else {
215240
if s.srv.Addr == "" {
@@ -218,7 +243,7 @@ func (s *SSEServer) Start(addr string) error {
218243
return fmt.Errorf("conflicting listen address: WithHTTPServer(%q) vs Start(%q)", s.srv.Addr, addr)
219244
}
220245
}
221-
246+
222247
return s.srv.ListenAndServe()
223248
}
224249

@@ -331,7 +356,7 @@ func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) {
331356
}
332357

333358
// Send the initial endpoint event
334-
endpoint := s.GetMessageEndpointForClient(sessionID)
359+
endpoint := s.GetMessageEndpointForClient(r, sessionID)
335360
if s.appendQueryToMessageEndpoint && len(r.URL.RawQuery) > 0 {
336361
endpoint += "&" + r.URL.RawQuery
337362
}
@@ -355,13 +380,20 @@ func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) {
355380
}
356381

357382
// GetMessageEndpointForClient returns the appropriate message endpoint URL with session ID
358-
// based on the useFullURLForMessageEndpoint configuration.
359-
func (s *SSEServer) GetMessageEndpointForClient(sessionID string) string {
360-
messageEndpoint := s.messageEndpoint
361-
if s.useFullURLForMessageEndpoint {
362-
messageEndpoint = s.CompleteMessageEndpoint()
383+
// for the given request. This is the canonical way to compute the message endpoint for a client.
384+
// It handles both dynamic and static path modes, and honors the WithUseFullURLForMessageEndpoint flag.
385+
func (s *SSEServer) GetMessageEndpointForClient(r *http.Request, sessionID string) string {
386+
basePath := s.basePath
387+
if s.dynamicBasePathFunc != nil {
388+
basePath = s.dynamicBasePathFunc(r, sessionID)
363389
}
364-
return fmt.Sprintf("%s?sessionId=%s", messageEndpoint, sessionID)
390+
391+
endpointPath := basePath + s.messageEndpoint
392+
if s.useFullURLForMessageEndpoint && s.baseURL != "" {
393+
endpointPath = s.baseURL + endpointPath
394+
}
395+
396+
return fmt.Sprintf("%s?sessionId=%s", endpointPath, sessionID)
365397
}
366398

367399
// handleMessage processes incoming JSON-RPC messages from clients and sends responses
@@ -479,32 +511,108 @@ func (s *SSEServer) GetUrlPath(input string) (string, error) {
479511
return parse.Path, nil
480512
}
481513

482-
func (s *SSEServer) CompleteSseEndpoint() string {
483-
return s.baseURL + s.basePath + s.sseEndpoint
514+
func (s *SSEServer) CompleteSseEndpoint() (string, error) {
515+
if s.dynamicBasePathFunc != nil {
516+
return "", &ErrDynamicPathConfig{Method: "CompleteSseEndpoint"}
517+
}
518+
return s.baseURL + s.basePath + s.sseEndpoint, nil
484519
}
485520

486521
func (s *SSEServer) CompleteSsePath() string {
487-
path, err := s.GetUrlPath(s.CompleteSseEndpoint())
522+
path, err := s.CompleteSseEndpoint()
523+
if err != nil {
524+
return s.basePath + s.sseEndpoint
525+
}
526+
urlPath, err := s.GetUrlPath(path)
488527
if err != nil {
489528
return s.basePath + s.sseEndpoint
490529
}
491-
return path
530+
return urlPath
492531
}
493532

494-
func (s *SSEServer) CompleteMessageEndpoint() string {
495-
return s.baseURL + s.basePath + s.messageEndpoint
533+
func (s *SSEServer) CompleteMessageEndpoint() (string, error) {
534+
if s.dynamicBasePathFunc != nil {
535+
return "", &ErrDynamicPathConfig{Method: "CompleteMessageEndpoint"}
536+
}
537+
return s.baseURL + s.basePath + s.messageEndpoint, nil
496538
}
497539

498540
func (s *SSEServer) CompleteMessagePath() string {
499-
path, err := s.GetUrlPath(s.CompleteMessageEndpoint())
541+
path, err := s.CompleteMessageEndpoint()
542+
if err != nil {
543+
return s.basePath + s.messageEndpoint
544+
}
545+
urlPath, err := s.GetUrlPath(path)
500546
if err != nil {
501547
return s.basePath + s.messageEndpoint
502548
}
503-
return path
549+
return urlPath
550+
}
551+
552+
// SSEHandler returns an http.Handler for the SSE endpoint.
553+
//
554+
// This method allows you to mount the SSE 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) SSEHandler() http.Handler {
578+
return http.HandlerFunc(s.handleSSE)
579+
}
580+
581+
// MessageHandler returns an http.Handler for the message endpoint.
582+
//
583+
// This method allows you to mount the message handler at any arbitrary path
584+
// using your own router (e.g. net/http, gorilla/mux, chi, etc.). It is
585+
// intended for advanced scenarios where you want to control the routing or
586+
// support dynamic segments.
587+
//
588+
// IMPORTANT: When using this handler in advanced/dynamic mounting scenarios,
589+
// you must use the WithDynamicBasePath option to ensure the correct base path
590+
// is communicated to clients.
591+
//
592+
// Example usage:
593+
//
594+
// // Advanced/dynamic:
595+
// sseServer := NewSSEServer(mcpServer,
596+
// WithDynamicBasePath(func(r *http.Request, sessionID string) string {
597+
// tenant := r.PathValue("tenant")
598+
// return "/mcp/" + tenant
599+
// }),
600+
// WithBaseURL("http://localhost:8080")
601+
// )
602+
// mux.Handle("/mcp/{tenant}/sse", sseServer.SSEHandler())
603+
// mux.Handle("/mcp/{tenant}/message", sseServer.MessageHandler())
604+
//
605+
// For non-dynamic cases, use ServeHTTP method instead.
606+
func (s *SSEServer) MessageHandler() http.Handler {
607+
return http.HandlerFunc(s.handleMessage)
504608
}
505609

506610
// ServeHTTP implements the http.Handler interface.
507611
func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
612+
if s.dynamicBasePathFunc != nil {
613+
http.Error(w, (&ErrDynamicPathConfig{Method: "ServeHTTP"}).Error(), http.StatusInternalServerError)
614+
return
615+
}
508616
path := r.URL.Path
509617
// Use exact path matching rather than Contains
510618
ssePath := s.CompleteSsePath()

0 commit comments

Comments
 (0)