go-sdk/mcp/error_test.go
Recoordinate 0eb1600eef
mcp: avoid panicking when a resource handler returns nil (#1176)
### What

`readResource` dereferenced the handler's result before the `if res ==
nil`
check that was meant to guard it. A `ResourceHandler` returning `(nil,
nil)`
therefore panicked instead of returning an error:

- modern clients: `handleMultiRoundTripResult` calls `res.setResultType`
on the
  nil result, and
- older clients: `res.setDefaultCacheableValues()` runs on the nil
result.

Either way the `if res == nil` guard a few lines below was dead code.

### Why it matters

Request handlers run in their own goroutine and nothing recovers, so the
panic
does not just fail the one request. It terminates the whole server
process and
every concurrent session.

The sibling handlers already avoid this: `callTool` and `getPrompt` both
guard
their result with `if err == nil && res != nil` before touching it, so a
`(nil, nil)` return flows up and becomes a normal error response.

### Change

Move the nil check in `readResource` ahead of
`handleMultiRoundTripResult` and
`setDefaultCacheableValues`, so a nil result returns the existing
descriptive
error (`read handler returned nil information`) instead of panicking.
Added a
regression test that drives a resource handler returning `(nil, nil)`
through
the server and asserts a clean error.
2026-08-17 10:44:53 +02:00

431 lines
13 KiB
Go

// 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"
"encoding/json"
"errors"
"strings"
"sync/atomic"
"testing"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TestServerErrors validates that the server returns appropriate error codes
// for various invalid requests.
func TestServerErrors(t *testing.T) {
ctx := context.Background()
// Set up a server with tools, prompts, and resources for testing
cs, _, cleanup := basicConnection(t, func(s *Server) {
// Add a tool with required parameters
type RequiredParams struct {
Name string `json:"name" jsonschema:"the name is required"`
}
handler := func(ctx context.Context, req *CallToolRequest, args RequiredParams) (*CallToolResult, any, error) {
return &CallToolResult{
Content: []Content{&TextContent{Text: "success"}},
}, nil, nil
}
AddTool(s, &Tool{Name: "validate", Description: "validates params"}, handler)
// Add a prompt
s.AddPrompt(codeReviewPrompt, codReviewPromptHandler)
// Add a resource that returns ResourceNotFoundError
s.AddResource(
&Resource{URI: "file:///test.txt", Name: "test", MIMEType: "text/plain"},
func(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) {
return nil, ResourceNotFoundError(req.Params.URI)
},
)
})
defer cleanup()
testCases := []struct {
name string
executeCall func() error
expectedCode int64
}{
// Note: "missing required param" is tested separately below, because
// input validation errors are returned as tool results with IsError=true
// rather than JSON-RPC errors (see #450).
{
name: "unknown tool",
executeCall: func() error {
_, err := cs.CallTool(ctx, &CallToolParams{
Name: "nonexistent_tool",
Arguments: map[string]any{},
})
return err
},
expectedCode: jsonrpc.CodeInvalidParams,
},
{
name: "unknown prompt",
executeCall: func() error {
_, err := cs.GetPrompt(ctx, &GetPromptParams{
Name: "nonexistent_prompt",
Arguments: map[string]string{},
})
return err
},
expectedCode: jsonrpc.CodeInvalidParams,
},
{
name: "resource not found",
executeCall: func() error {
_, err := cs.ReadResource(ctx, &ReadResourceParams{
URI: "file:///test.txt",
})
return err
},
expectedCode: CodeResourceNotFound,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.executeCall()
if err == nil {
t.Fatal("got nil error, want non-nil")
}
var rpcErr *jsonrpc.Error
if !errors.As(err, &rpcErr) {
t.Fatalf("got error type %T, want jsonrpc.Error: %v", err, err)
}
if rpcErr.Code != tc.expectedCode {
t.Errorf("got error code %d, want %d", rpcErr.Code, tc.expectedCode)
}
if rpcErr.Message == "" {
t.Error("got empty error message, want non-empty")
}
})
}
}
// TestResourceNotFoundErrorCode verifies ResourceNotFoundError code and
// CodeResourceNotFound are -32602 (InvalidParams) per SEP-2164.
func TestResourceNotFoundErrorCode(t *testing.T) {
err := ResourceNotFoundError("file:///test.txt")
var rpcErr *jsonrpc.Error
if !errors.As(err, &rpcErr) {
t.Fatalf("got error type %T, want *jsonrpc.Error", err)
}
if rpcErr.Code != jsonrpc.CodeInvalidParams {
t.Errorf("got error code %d, want %d (InvalidParams)", rpcErr.Code, jsonrpc.CodeInvalidParams)
}
if CodeResourceNotFound != jsonrpc.CodeInvalidParams {
t.Errorf("CodeResourceNotFound = %d, want %d", CodeResourceNotFound, jsonrpc.CodeInvalidParams)
}
}
// TestReadResourceNilResult verifies that a resource handler returning
// (nil, nil) yields a clean error to the client instead of a nil pointer
// panic that crashes the server. The sibling handlers callTool and getPrompt
// already tolerate a (nil, nil) return; readResource must do the same.
func TestReadResourceNilResult(t *testing.T) {
ctx := context.Background()
cs, _, cleanup := basicConnection(t, func(s *Server) {
s.AddResource(
&Resource{URI: "file:///nil.txt", Name: "nil", MIMEType: "text/plain"},
func(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) {
return nil, nil
},
)
})
defer cleanup()
_, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "file:///nil.txt"})
if err == nil {
t.Fatal("got nil error, want non-nil error for nil resource result")
}
if !strings.Contains(err.Error(), "nil information") {
t.Errorf("got error %q, want it to mention 'nil information'", err.Error())
}
}
// TestInputValidationToolError validates that input validation errors (missing
// required params, wrong types) are returned as tool results with IsError=true,
// not as JSON-RPC errors. This allows LLMs to see the error and self-correct.
// See #450.
func TestInputValidationToolError(t *testing.T) {
ctx := context.Background()
type RequiredParams struct {
Name string `json:"name" jsonschema:"the name is required"`
}
handler := func(ctx context.Context, req *CallToolRequest, args RequiredParams) (*CallToolResult, any, error) {
return &CallToolResult{
Content: []Content{&TextContent{Text: "success"}},
}, nil, nil
}
cs, _, cleanup := basicConnection(t, func(s *Server) {
AddTool(s, &Tool{Name: "validate", Description: "validates params"}, handler)
})
defer cleanup()
// Call the tool with missing required "name" field.
result, err := cs.CallTool(ctx, &CallToolParams{
Name: "validate",
Arguments: map[string]any{},
})
if err != nil {
t.Fatalf("CallTool returned error: %v; want tool result with IsError", err)
}
if !result.IsError {
t.Fatal("got IsError=false, want IsError=true for missing required param")
}
text := result.Content[0].(*TextContent).Text
if !strings.Contains(text, "name") {
t.Errorf("error text %q does not mention missing field \"name\"", text)
}
}
// TestURLElicitationRequired validates that URL elicitation required errors
// are properly created and handled by the client.
func TestURLElicitationRequired(t *testing.T) {
ctx := context.Background()
t.Run("error creation", func(t *testing.T) {
elicitations := []*ElicitParams{
{
Mode: "url",
Message: "Please authorize",
URL: "https://example.com/auth",
ElicitationID: "auth-123",
},
}
err := URLElicitationRequiredError(elicitations)
var rpcErr *jsonrpc.Error
if !errors.As(err, &rpcErr) {
t.Fatalf("got error type %T, want jsonrpc.Error", err)
}
if rpcErr.Code != CodeURLElicitationRequired {
t.Errorf("got error code %d, want %d", rpcErr.Code, CodeURLElicitationRequired)
}
if rpcErr.Message != "URL elicitation required" {
t.Errorf("got message %q, want 'URL elicitation required'", rpcErr.Message)
}
if rpcErr.Data == nil {
t.Fatal("got nil error data, want non-nil")
}
// Verify the elicitations can be unmarshaled from the error data
var errorData struct {
Elicitations []*ElicitParams `json:"elicitations"`
}
if err := json.Unmarshal(rpcErr.Data, &errorData); err != nil {
t.Fatalf("failed to unmarshal error data: %v", err)
}
if len(errorData.Elicitations) != 1 {
t.Fatalf("got %d elicitations, want 1", len(errorData.Elicitations))
}
if errorData.Elicitations[0].URL != "https://example.com/auth" {
t.Errorf("got URL %q, want 'https://example.com/auth'", errorData.Elicitations[0].URL)
}
})
t.Run("error creation with non-URL mode panics", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("got no panic when creating URLElicitationRequiredError with non-URL mode, want panic")
}
}()
// This should panic because mode is "form"
URLElicitationRequiredError([]*ElicitParams{
{
Mode: "form",
Message: "This should panic",
ElicitationID: "bad-123",
},
})
})
t.Run("error creation with empty mode panics", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("got no panic when creating URLElicitationRequiredError with empty mode (defaults to form), want panic")
}
}()
// This should panic because empty mode defaults to "form"
URLElicitationRequiredError([]*ElicitParams{
{
Message: "This should panic",
ElicitationID: "bad-123",
},
})
})
t.Run("client middleware", func(t *testing.T) {
// Declare ss outside so it can be captured in handlers.
var ss *ServerSession
elicitCalled := false
elicitURL := ""
elicitID := "form-123"
// Create client with elicitation handler and middleware.
client := NewClient(testImpl, &ClientOptions{
Capabilities: &ClientCapabilities{
Roots: RootCapabilities{ListChanged: true},
RootsV2: &RootCapabilities{ListChanged: true},
Elicitation: &ElicitationCapabilities{
URL: &URLElicitationCapabilities{},
},
},
ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
elicitCalled = true
elicitURL = req.Params.URL
// Simulate the server sending elicitation complete notification.
// In a real scenario, this would happen out-of-band after the user
// completes the form submission.
go func() {
err := handleNotify(ctx, notificationElicitationComplete,
newServerRequest(ss, &ElicitationCompleteParams{
ElicitationID: elicitID,
}))
if err != nil {
t.Errorf("failed to send elicitation complete notification: %v", err)
}
}()
return &ElicitResult{Action: "accept"}, nil
},
})
// Add URL elicitation middleware for automatic retry.
client.AddSendingMiddleware(urlElicitationMiddleware())
var callCount atomic.Int32
cs, serverSession, cleanup := basicClientServerConnection(t,
client,
nil,
func(s *Server) {
// Tool that requires form submission on first call, succeeds on second.
handler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
if callCount.Add(1) == 1 {
// First call: require elicitation.
return nil, nil, URLElicitationRequiredError([]*ElicitParams{
{
Mode: "url",
Message: "Please complete the form",
URL: "https://example.com/form",
ElicitationID: elicitID,
},
})
}
// Second call (after retry): return success.
return &CallToolResult{
Content: []Content{&TextContent{Text: "form submitted"}},
}, nil, nil
}
AddTool(s, &Tool{Name: "submit_form", Description: "requires form submission"}, handler)
// Tool that returns invalid elicitation mode (form instead of URL).
badHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
// Manually construct an error with form mode (bypassing validation).
data, _ := json.Marshal(map[string]any{
"elicitations": []*ElicitParams{
{
Mode: "form",
Message: "Invalid mode",
ElicitationID: "bad-form",
},
},
})
return nil, nil, &jsonrpc.Error{
Code: CodeURLElicitationRequired,
Message: "URL elicitation required",
Data: json.RawMessage(data),
}
}
AddTool(s, &Tool{Name: "bad_tool", Description: "returns invalid elicitation"}, badHandler)
},
)
ss = serverSession
defer cleanup()
t.Run("auto-retry after elicitation", func(t *testing.T) {
// Reset state for this subtest.
elicitCalled = false
elicitURL = ""
callCount.Store(0)
// Call the tool that requires URL elicitation.
result, err := cs.CallTool(ctx, &CallToolParams{
Name: "submit_form",
Arguments: map[string]any{},
})
// After automatic retry, the operation should succeed.
if err != nil {
t.Fatalf("CallTool failed: %v", err)
}
// Verify the elicitation handler was called.
if !elicitCalled {
t.Error("elicitation handler not called")
}
if elicitURL != "https://example.com/form" {
t.Errorf("got elicit URL %q, want 'https://example.com/form'", elicitURL)
}
// Verify the tool was called twice (first attempt + retry).
if got, want := callCount.Load(), int32(2); got != want {
t.Errorf("CallTool(): with retry, got %d tool calls, want %d", got, want)
}
// Verify we got the successful result.
if len(result.Content) != 1 {
t.Fatalf("CallTool(): got %d content items, want 1", len(result.Content))
}
textContent, ok := result.Content[0].(*TextContent)
if !ok {
t.Fatalf("CallTool(): got content type %T, want TextContent", result.Content[0])
}
if textContent.Text != "form submitted" {
t.Errorf("CallTool(): got text %q, want 'form submitted'", textContent.Text)
}
})
t.Run("reject non-URL mode", func(t *testing.T) {
// Call the tool that returns invalid elicitation mode.
_, err := cs.CallTool(ctx, &CallToolParams{
Name: "bad_tool",
Arguments: map[string]any{},
})
// Should get an error about invalid mode.
if err == nil {
t.Fatal("got nil error for non-URL mode elicitation, want error")
}
if !strings.Contains(err.Error(), "URL mode") {
t.Errorf("got error %v, want mention of URL mode", err)
}
})
})
}