go-sdk/mcp/error_test.go
Robert Findley 2ce44d45e9
all: expose JSON-RPC error codes, and support URLElicitationRequired errors (#655)
Expose the jsonrpc.Error type to allow access to underlying JSON-RPC
error codes. Also, expose common JSON-RPC error codes in the jsonrpc
package, and MCP error codes in the mcp package.

Also, implement support for the MCP URL elicitation required error
(-32042), enabling servers to request out-of-band user authorization and
clients to automatically handle these requests with retry logic.

Server-side:
- Add CodeURLElicitationRequired constant
- Add URLElicitationRequiredError() constructor.

Client-side:
- Add urlElicitationMiddleware to intercept URLElicitationRequired
  errors and retry calls.
- Update callElicitationCompleteHandler to signal waiting operations.
- Fix capability advertisement to support both form and URL modes.

Fixes #452
Fixes #623
2025-12-01 11:29:17 -05:00

353 lines
10 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
}{
{
name: "missing required param",
executeCall: func() error {
_, err := cs.CallTool(ctx, &CallToolParams{
Name: "validate",
Arguments: map[string]any{}, // Missing required "name" field
})
return err
},
expectedCode: jsonrpc.CodeInvalidParams,
},
{
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")
}
})
}
}
// 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{
ElicitationModes: []string{"url"},
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)
}
})
})
}