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
5 changes: 5 additions & 0 deletions internal/jsonrpc2/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,11 @@ func (c *Connection) handleAsync() {
ctx := context.WithValue(req.ctx, asyncKey, releaser)
go func() {
defer releaser.release(true)
defer func() {
if r := recover(); r != nil {
c.processResult(c.handler, req, nil, fmt.Errorf("%w: panic in handler: %v", ErrInternal, r))
}
}()
result, err := c.handler.Handle(ctx, req.Request)
c.processResult(c.handler, req, result, err)
}()
Expand Down
106 changes: 106 additions & 0 deletions mcp/panic_recovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.

package mcp

import (
"context"
"testing"
"time"

"github.com/google/jsonschema-go/jsonschema"
)

// TestToolHandler_PanicRecovery verifies that a panicking tool handler does
// not crash the server process. The panic should be recovered and returned
// as a JSON-RPC internal error to the client.
func TestToolHandler_PanicRecovery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

ct, st := NewInMemoryTransports()

s := NewServer(testImpl, nil)
AddTool(s, &Tool{
Name: "panic-tool",
Description: "a tool that panics",
InputSchema: &jsonschema.Schema{Type: "object"},
}, func(_ context.Context, _ *CallToolRequest, _ map[string]any) (*CallToolResult, any, error) {
panic("deliberate panic in tool handler")
})

ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()

c := NewClient(testImpl, nil)
cs, err := c.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()

// Call the panicking tool. Without recovery, this crashes the process.
// With recovery, we get an error response.
_, err = cs.CallTool(ctx, &CallToolParams{Name: "panic-tool"})

// We expect an error (the panic is caught and returned as internal error).
if err == nil {
t.Fatal("expected error from panicking tool handler, got success")
}
// The important thing is we reached this line: the process didn't crash.
}

// TestToolHandler_PanicDoesNotAffectSubsequentCalls verifies that after a
// panic in one tool handler, the server continues to serve other requests.
func TestToolHandler_PanicDoesNotAffectSubsequentCalls(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

ct, st := NewInMemoryTransports()

s := NewServer(testImpl, nil)
AddTool(s, &Tool{
Name: "panic-tool",
Description: "a tool that panics",
InputSchema: &jsonschema.Schema{Type: "object"},
}, func(_ context.Context, _ *CallToolRequest, _ map[string]any) (*CallToolResult, any, error) {
panic("deliberate panic")
})
AddTool(s, &Tool{
Name: "safe-tool",
Description: "a tool that works",
InputSchema: &jsonschema.Schema{Type: "object"},
}, func(_ context.Context, _ *CallToolRequest, _ map[string]any) (*CallToolResult, any, error) {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
})

ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()

c := NewClient(testImpl, nil)
cs, err := c.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()

// First: call the panicking tool.
_, _ = cs.CallTool(ctx, &CallToolParams{Name: "panic-tool"})

// Second: call the safe tool. This should succeed, proving the server
// is still alive after the panic.
result, err := cs.CallTool(ctx, &CallToolParams{Name: "safe-tool"})
if err != nil {
t.Fatalf("safe tool call failed after panic: %v", err)
}
if result == nil {
t.Fatal("expected result from safe tool, got nil")
}
}
5 changes: 5 additions & 0 deletions mcp/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,11 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr
*cancelPtr = cancel

go func() {
defer func() {
if r := recover(); r != nil {
logger.Error("panic in keepalive goroutine", "error", r)
}
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()

Expand Down
6 changes: 6 additions & 0 deletions mcp/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"crypto/rand"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
Expand Down Expand Up @@ -420,6 +421,11 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {

go func() {
defer s.Close() // close the transport when the GET exits
defer func() {
if r := recover(); r != nil {
slog.Default().Error("panic in SSE reader goroutine", "error", r)
}
}()

for evt, err := range scanEvents(resp.Body) {
if err != nil {
Expand Down
10 changes: 10 additions & 0 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,11 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error {
}

func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) {
defer func() {
if r := recover(); r != nil {
c.fail(fmt.Errorf("%s: panic in handleJSON: %v", requestSummary, r))
}
Comment on lines +2061 to +2064
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the threat this recover() is intended to address?
A recover() here wouldn't catch panics from user handlers since those execute on the goroutine spawned in handleAsync.

}()
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
Expand All @@ -2083,6 +2088,11 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp
// stream is complete when we receive its response. Otherwise, this is the
// standalone stream.
func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc2.Request) {
defer func() {
if r := recover(); r != nil {
c.fail(fmt.Errorf("%s: panic in handleSSE: %v", requestSummary, r))
}
}()
// Track the last event ID to detect progress.
// The retry counter is only reset when progress is made (lastEventID advances).
// This prevents infinite retry loops when a server repeatedly terminates
Expand Down
8 changes: 8 additions & 0 deletions mcp/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,14 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn {
// but that is unavoidable since AFAIK there is no (easy and portable) way to
// guarantee that reads of stdin are unblocked when closed.
go func() {
defer func() {
if r := recover(); r != nil {
select {
case incoming <- msgOrErr{err: fmt.Errorf("panic in reader: %v", r)}:
case <-closed:
}
}
}()
dec := json.NewDecoder(rwc)
for {
var raw json.RawMessage
Expand Down
Loading