Skip to content
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
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,11 @@ server := mcp.NewServer(
| `WithGetSSEEnabled` | Allow GET for SSE connections | `true` |
| `WithNotificationBufferSize` | Size of notification buffer | `10` |
| `WithStatelessMode` | Run in stateless mode | `false` |
| `WithServerKeepAlive` | Enable SSE comment keepalive | `true` |
| `WithServerKeepAliveInterval` | Interval for SSE comment keepalive | `30s` |
| `WithServerPingKeepAlive` | Enable JSON-RPC ping keepalive | `false` |
| `WithServerPingInterval` | Interval for ping requests | `30s` |
| `WithServerPingTimeout` | Timeout for ping requests | `15s` |

### Client Configuration

Expand Down Expand Up @@ -728,6 +733,89 @@ basicPromptHandler := func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp
server.RegisterPrompt(basicPrompt, basicPromptHandler)
```

### Connection Keepalive

The framework provides two keepalive mechanisms for SSE connections to prevent timeouts:

#### 1. SSE Comment Keepalive (Default)

Sends SSE comment lines (`: keepalive`) at regular intervals. This is lightweight and enabled by default.

```go
server := mcp.NewServer(
"My-Server",
"1.0.0",
// Comment keepalive is enabled by default
// Optionally customize:
mcp.WithServerKeepAlive(true),
mcp.WithServerKeepAliveInterval(30*time.Second),
)
```

#### 2. JSON-RPC Ping Keepalive (Optional)

Sends JSON-RPC ping requests to clients, allowing health detection. Enable this for production systems.

```go
server := mcp.NewServer(
"My-Server",
"1.0.0",
// Enable ping keepalive (comment keepalive remains enabled by default)
mcp.WithServerPingKeepAlive(true),
mcp.WithServerPingInterval(30*time.Second),
mcp.WithServerPingTimeout(15*time.Second),
)
```

#### Comparison

| Feature | SSE Comment | JSON-RPC Ping |
|---------|-------------|---------------|
| Overhead | Small (~15 bytes) | Large (~100 bytes) |
| Health Check | ❌ No | ✅ Yes |
| Client Response | Not required | Required |
| Default | ✅ Enabled | ❌ Disabled |

#### Configuration Examples

**Default (Comment only)**:
```go
server := mcp.NewServer("My-Server", "1.0.0")
// ✅ SSE comment keepalive enabled (30s interval)
// ❌ Ping keepalive disabled
```

**Both modes (Recommended for production)**:
```go
server := mcp.NewServer("My-Server", "1.0.0",
mcp.WithServerPingKeepAlive(true),
)
// ✅ SSE comment keepalive enabled (30s interval)
// ✅ Ping keepalive enabled (30s interval, 15s timeout)
```

**Ping only**:
```go
server := mcp.NewServer("My-Server", "1.0.0",
mcp.WithServerKeepAlive(false),
mcp.WithServerPingKeepAlive(true),
)
// ❌ SSE comment keepalive disabled
// ✅ Ping keepalive enabled (30s interval, 15s timeout)
```

**Custom intervals**:
```go
server := mcp.NewServer("My-Server", "1.0.0",
mcp.WithServerKeepAliveInterval(60*time.Second),
mcp.WithServerPingKeepAlive(true),
mcp.WithServerPingInterval(45*time.Second),
mcp.WithServerPingTimeout(20*time.Second),
)
```

For a complete example, see [`examples/ping-keepalive/`](examples/ping-keepalive/).

## Struct-First API (Recommended)

Define MCP tools using Go structs for automatic schema generation and type safety:
Expand Down
66 changes: 66 additions & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/http"
"sync"
"sync/atomic"
"time"
)

// Common errors
Expand Down Expand Up @@ -71,6 +72,13 @@ type serverConfig struct {
getSSEEnabled bool
notificationBufferSize int

// Keepalive configuration
keepAliveEnabled bool
keepAliveInterval time.Duration
pingEnabled bool
pingInterval time.Duration
pingTimeout time.Duration

// HTTP context functions for extracting information from HTTP requests
httpContextFuncs []HTTPContextFunc

Expand Down Expand Up @@ -120,6 +128,11 @@ func NewServer(name, version string, options ...ServerOption) *Server {
postSSEEnabled: true,
getSSEEnabled: true,
notificationBufferSize: defaultNotificationBufferSize,
keepAliveEnabled: true, // Default: SSE comment keepalive enabled
keepAliveInterval: 30 * time.Second, // Default: 30 seconds
pingEnabled: false, // Default: ping keepalive disabled
pingInterval: 30 * time.Second, // Default: 30 seconds
pingTimeout: 15 * time.Second, // Default: 15 seconds
}

// Create server with provided serverInfo
Expand Down Expand Up @@ -220,6 +233,12 @@ func (s *Server) initComponents() {
withTransportNotificationBufferSize(s.config.notificationBufferSize),
)

// Keepalive configuration.
httpOptions = append(httpOptions,
withKeepAliveConfig(s.config.keepAliveEnabled, s.config.keepAliveInterval),
withPingConfig(s.config.pingEnabled, s.config.pingInterval, s.config.pingTimeout),
)

// HTTP context functions configuration.
if len(s.config.httpContextFuncs) > 0 {
httpOptions = append(httpOptions, withTransportHTTPContextFuncs(s.config.httpContextFuncs))
Expand Down Expand Up @@ -300,6 +319,53 @@ func WithStatelessMode(enabled bool) ServerOption {
}
}

// WithServerKeepAlive enables or disables SSE comment keepalive for all SSE connections.
// When enabled, the server will send SSE comment lines (": keepalive") at regular intervals
// to prevent connection timeouts. This is enabled by default.
func WithServerKeepAlive(enabled bool) ServerOption {
return func(s *Server) {
s.config.keepAliveEnabled = enabled
}
}

// WithServerKeepAliveInterval sets the interval for SSE comment keepalive messages.
// This option does not automatically enable keepalive; use WithServerKeepAlive(true) to enable it.
// Default is 30 seconds.
func WithServerKeepAliveInterval(interval time.Duration) ServerOption {
return func(s *Server) {
s.config.keepAliveInterval = interval
}
}

// WithServerPingKeepAlive enables or disables JSON-RPC ping keepalive.
// When enabled, the server will periodically send ping requests to all connected clients.
// This provides application-layer health checking in addition to (or instead of) SSE comment keepalive.
// Disabled by default.
func WithServerPingKeepAlive(enabled bool) ServerOption {
return func(s *Server) {
s.config.pingEnabled = enabled
}
}

// WithServerPingInterval sets the interval for sending ping requests.
// This option does not automatically enable ping keepalive; use WithServerPingKeepAlive(true) to enable it.
// Default is 30 seconds.
func WithServerPingInterval(interval time.Duration) ServerOption {
return func(s *Server) {
s.config.pingInterval = interval
}
}

// WithServerPingTimeout sets the timeout for ping requests.
// If a ping request does not receive a response within this timeout,
// it will be logged as failed but the connection will remain open.
// Default is 15 seconds.
func WithServerPingTimeout(timeout time.Duration) ServerOption {
return func(s *Server) {
s.config.pingTimeout = timeout
}
}

// WithToolListFilter sets a tool list filter that will be applied to tools/list requests.
// The filter function receives the request context and all registered tools, and should
// return a filtered list of tools that should be visible to the client.
Expand Down
83 changes: 83 additions & 0 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -356,3 +357,85 @@ func TestServer_UnregisterTools(t *testing.T) {
tools = server.toolManager.getTools()
assert.Len(t, tools, 0)
}

// TestServerDefaultKeepAliveConfig tests that server has correct default keepalive configuration
func TestServerDefaultKeepAliveConfig(t *testing.T) {
server := NewServer("test-server", "1.0.0")

// Verify default keepalive config
assert.True(t, server.config.keepAliveEnabled)
assert.Equal(t, 30*time.Second, server.config.keepAliveInterval)
assert.False(t, server.config.pingEnabled)
assert.Equal(t, 30*time.Second, server.config.pingInterval)
assert.Equal(t, 15*time.Second, server.config.pingTimeout)
}

// TestServerPingKeepAliveOptions tests server ping keepalive configuration options
func TestServerPingKeepAliveOptions(t *testing.T) {
server := NewServer("test-server", "1.0.0",
WithServerPingKeepAlive(true),
WithServerPingInterval(60*time.Second),
WithServerPingTimeout(30*time.Second),
)

// Verify ping configuration
assert.True(t, server.config.pingEnabled)
assert.Equal(t, 60*time.Second, server.config.pingInterval)
assert.Equal(t, 30*time.Second, server.config.pingTimeout)
// Verify comment keepalive is still enabled
assert.True(t, server.config.keepAliveEnabled)
}

// TestServerCommentKeepAliveOptions tests server comment keepalive configuration options
func TestServerCommentKeepAliveOptions(t *testing.T) {
server := NewServer("test-server", "1.0.0",
WithServerKeepAlive(false),
WithServerKeepAliveInterval(45*time.Second),
)

// Verify comment keepalive configuration
assert.False(t, server.config.keepAliveEnabled)
assert.Equal(t, 45*time.Second, server.config.keepAliveInterval)
// Verify ping is still disabled
assert.False(t, server.config.pingEnabled)
}

// TestServerBackwardCompatibility tests that existing server config is not affected
func TestServerBackwardCompatibility(t *testing.T) {
// Test 1: Server without any keepalive options
server1 := NewServer("test-server", "1.0.0")
assert.True(t, server1.config.keepAliveEnabled)
assert.False(t, server1.config.pingEnabled)

// Test 2: Server with only existing options
server2 := NewServer("test-server", "1.0.0",
WithServerAddress(":8080"),
WithServerPath("/api"),
)
assert.True(t, server2.config.keepAliveEnabled)
assert.False(t, server2.config.pingEnabled)
assert.Equal(t, ":8080", server2.config.addr)
assert.Equal(t, "/api", server2.config.path)
}

// TestServerPingOnlyMode tests using only ping keepalive
func TestServerPingOnlyMode(t *testing.T) {
server := NewServer("test-server", "1.0.0",
WithServerKeepAlive(false),
WithServerPingKeepAlive(true),
)

assert.False(t, server.config.keepAliveEnabled)
assert.True(t, server.config.pingEnabled)
}

// TestServerBothKeepAliveModes tests using both comment and ping keepalive
func TestServerBothKeepAliveModes(t *testing.T) {
server := NewServer("test-server", "1.0.0",
WithServerPingKeepAlive(true),
)

// Both should be enabled
assert.True(t, server.config.keepAliveEnabled) // Comment is default
assert.True(t, server.config.pingEnabled) // Explicitly enabled
}
24 changes: 24 additions & 0 deletions sse_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ func (t *sseClientTransport) handleIncomingRequest(data string) {
switch request.Method {
case MethodRootsList:
t.handleRootsListRequest(&request)
case MethodPing:
t.handlePingRequest(&request)
default:
// Send method not found error.
t.sendErrorResponse(&request, ErrCodeMethodNotFound, fmt.Sprintf("Method not found: %s", request.Method))
Expand Down Expand Up @@ -443,6 +445,28 @@ func (t *sseClientTransport) handleRootsListRequest(request *JSONRPCRequest) {
t.sendResponseMessage(response)
}

// handlePingRequest handles ping requests from the server.
// It responds with an empty result to indicate the client is alive.
func (t *sseClientTransport) handlePingRequest(request *JSONRPCRequest) {
if t.logger != nil {
t.logger.Debugf("Received ping request, ID: %v", request.ID)
}

// Create empty response
response := &JSONRPCResponse{
JSONRPC: JSONRPCVersion,
ID: request.ID,
Result: map[string]interface{}{}, // Empty object
}

// Send response
t.sendResponseMessage(response)

if t.logger != nil {
t.logger.Debugf("Responded to ping request, ID: %v", request.ID)
}
}

// sendErrorResponse sends an error response to the server.
func (t *sseClientTransport) sendErrorResponse(request *JSONRPCRequest, code int, message string) {
errorResp := newJSONRPCErrorResponse(request.ID, code, message, nil)
Expand Down
Loading