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
This commit is contained in:
Robert Findley 2025-12-01 11:29:17 -05:00 • committed by GitHub
commit 2ce44d45e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 670 additions and 44 deletions

View file

@ -17,6 +17,8 @@ type (
Request = jsonrpc2.Request
// Response is a JSON-RPC response.
Response = jsonrpc2.Response
// Error is a structured error in a JSON-RPC response.
Error = jsonrpc2.WireError
)
// MakeID coerces the given Go value to an ID. The value should be the
@ -37,3 +39,18 @@ func EncodeMessage(msg Message) ([]byte, error) {
func DecodeMessage(data []byte) (Message, error) {
return jsonrpc2.DecodeMessage(data)
}
// Standard JSON-RPC 2.0 error codes.
// See https://www.jsonrpc.org/specification#error_object
const (
// CodeParseError indicates invalid JSON was received by the server.
CodeParseError = -32700
// CodeInvalidRequest indicates the JSON sent is not a valid Request object.
CodeInvalidRequest = -32600
// CodeMethodNotFound indicates the method does not exist or is not available.
CodeMethodNotFound = -32601
// CodeInvalidParams indicates invalid method parameter(s).
CodeInvalidParams = -32602
// CodeInternalError indicates an internal JSON-RPC error.
CodeInternalError = -32603
)

View file

@ -7,9 +7,11 @@ package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
@ -45,7 +47,7 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client {
c := &Client{
impl: impl,
roots: newFeatureSet(func(r *Root) string { return r.URI }),
sendingMethodHandler_: defaultSendingMethodHandler[*ClientSession],
sendingMethodHandler_: defaultSendingMethodHandler,
receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession],
}
if opts != nil {
@ -134,7 +136,8 @@ func (c *Client) capabilities() *ClientCapabilities {
// {"form":{}} for backward compatibility, but we explicitly set the form
// capability.
caps.Elicitation.Form = &FormElicitationCapabilities{}
} else if slices.Contains(modes, "url") {
}
if slices.Contains(modes, "url") {
caps.Elicitation.URL = &URLElicitationCapabilities{}
}
}
@ -206,6 +209,10 @@ type ClientSession struct {
// No mutex is (currently) required to guard the session state, because it is
// only set synchronously during Client.Connect.
state clientSessionState
// Pending URL elicitations waiting for completion notifications.
pendingElicitationsMu sync.Mutex
pendingElicitations map[string]chan struct{}
}
type clientSessionState struct {
@ -250,6 +257,46 @@ func (cs *ClientSession) Wait() error {
return cs.conn.Wait()
}
// registerElicitationWaiter registers a waiter for an elicitation complete
// notification with the given elicitation ID. It returns two functions: an await
// function that waits for the notification or context cancellation, and a cleanup
// function that must be called to unregister the waiter. This must be called before
// triggering the elicitation to avoid a race condition where the notification
// arrives before the waiter is registered.
//
// The cleanup function must be called even if the await function is never called,
// to prevent leaking the registration.
func (cs *ClientSession) registerElicitationWaiter(elicitationID string) (await func(context.Context) error, cleanup func()) {
// Create a channel for this elicitation.
ch := make(chan struct{}, 1)
// Register the channel.
cs.pendingElicitationsMu.Lock()
if cs.pendingElicitations == nil {
cs.pendingElicitations = make(map[string]chan struct{})
}
cs.pendingElicitations[elicitationID] = ch
cs.pendingElicitationsMu.Unlock()
// Return await and cleanup functions.
await = func(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled while waiting for elicitation completion: %w", ctx.Err())
case <-ch:
return nil
}
}
cleanup = func() {
cs.pendingElicitationsMu.Lock()
delete(cs.pendingElicitations, elicitationID)
cs.pendingElicitationsMu.Unlock()
}
return await, cleanup
}
// startKeepalive starts the keepalive mechanism for this client session.
func (cs *ClientSession) startKeepalive(interval time.Duration) {
startKeepalive(cs, interval, &cs.keepaliveCancel)
@ -304,14 +351,118 @@ func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRoots
func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) {
if c.opts.CreateMessageHandler == nil {
// TODO: wrap or annotate this error? Pick a standard code?
return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support CreateMessage")
return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"}
}
return c.opts.CreateMessageHandler(ctx, req)
}
// urlElicitationMiddleware returns middleware that automatically handles URL elicitation
// required errors by executing the elicitation handler, waiting for completion notifications,
// and retrying the operation.
//
// This middleware should be added to clients that want automatic URL elicitation handling:
//
// client := mcp.NewClient(impl, opts)
// client.AddSendingMiddleware(mcp.urlElicitationMiddleware())
//
// TODO(rfindley): this isn't strictly necessary for the SEP, but may be
// useful. Propose exporting it.
func urlElicitationMiddleware() Middleware {
return func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
// Call the underlying handler.
res, err := next(ctx, method, req)
if err == nil {
return res, nil
}
// Check if this is a URL elicitation required error.
var rpcErr *jsonrpc.Error
if !errors.As(err, &rpcErr) || rpcErr.Code != CodeURLElicitationRequired {
return res, err
}
// Notifications don't support retries.
if strings.HasPrefix(method, "notifications/") {
return res, err
}
// Extract the client session.
cs, ok := req.GetSession().(*ClientSession)
if !ok {
return res, err
}
// Check if the client has an elicitation handler.
if cs.client.opts.ElicitationHandler == nil {
return res, err
}
// Parse the elicitations from the error data.
var errorData struct {
Elicitations []*ElicitParams `json:"elicitations"`
}
if rpcErr.Data != nil {
if err := json.Unmarshal(rpcErr.Data, &errorData); err != nil {
return nil, fmt.Errorf("failed to parse URL elicitation error data: %w", err)
}
}
// Validate that all elicitations are URL mode.
for _, elicit := range errorData.Elicitations {
mode := elicit.Mode
if mode == "" {
mode = "form" // Default mode.
}
if mode != "url" {
return nil, fmt.Errorf("URLElicitationRequired error must only contain URL mode elicitations, got %q", mode)
}
}
// Register waiters for all elicitations before executing handlers
// to avoid race condition where notification arrives before waiter is registered.
type waiter struct {
await func(context.Context) error
cleanup func()
}
waiters := make([]waiter, 0, len(errorData.Elicitations))
for _, elicitParams := range errorData.Elicitations {
await, cleanup := cs.registerElicitationWaiter(elicitParams.ElicitationID)
waiters = append(waiters, waiter{await: await, cleanup: cleanup})
}
// Ensure cleanup happens even if we return early.
defer func() {
for _, w := range waiters {
w.cleanup()
}
}()
// Execute the elicitation handler for each elicitation.
for _, elicitParams := range errorData.Elicitations {
elicitReq := newClientRequest(cs, elicitParams)
_, elicitErr := cs.client.elicit(ctx, elicitReq)
if elicitErr != nil {
return nil, fmt.Errorf("URL elicitation failed: %w", elicitErr)
}
}
// Wait for all elicitations to complete.
for _, w := range waiters {
if err := w.await(ctx); err != nil {
return nil, err
}
}
// All elicitations complete, retry the original operation.
return next(ctx, method, req)
}
}
}
func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
if c.opts.ElicitationHandler == nil {
return nil, jsonrpc2.NewError(codeInvalidParams, "client does not support elicitation")
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "client does not support elicitation"}
}
// Validate the elicitation parameters based on the mode.
@ -323,11 +474,11 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult,
switch mode {
case "form":
if req.Params.URL != "" {
return nil, jsonrpc2.NewError(codeInvalidParams, "URL must not be set for form elicitation")
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "URL must not be set for form elicitation"}
}
schema, err := validateElicitSchema(req.Params.RequestedSchema)
if err != nil {
return nil, jsonrpc2.NewError(codeInvalidParams, err.Error())
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: err.Error()}
}
res, err := c.opts.ElicitationHandler(ctx, req)
if err != nil {
@ -337,28 +488,28 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult,
if schema != nil && res.Content != nil {
resolved, err := schema.Resolve(nil)
if err != nil {
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to resolve requested schema: %v", err)}
}
if err := resolved.Validate(res.Content); err != nil {
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("elicitation result content does not match requested schema: %v", err)}
}
err = resolved.ApplyDefaults(&res.Content)
if err != nil {
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err))
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)}
}
}
return res, nil
case "url":
if req.Params.RequestedSchema != nil {
return nil, jsonrpc2.NewError(codeInvalidParams, "requestedSchema must not be set for URL elicitation")
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "requestedSchema must not be set for URL elicitation"}
}
if req.Params.URL == "" {
return nil, jsonrpc2.NewError(codeInvalidParams, "URL must be set for URL elicitation")
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "URL must be set for URL elicitation"}
}
// No schema validation for URL mode, just pass through to handler.
return c.opts.ElicitationHandler(ctx, req)
default:
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("unsupported elicitation mode: %q", mode))
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("unsupported elicitation mode: %q", mode)}
}
}
@ -723,6 +874,20 @@ func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, pa
}
func (c *Client) callElicitationCompleteHandler(ctx context.Context, req *ElicitationCompleteNotificationRequest) (Result, error) {
// Check if there's a pending elicitation waiting for this notification.
if cs, ok := req.GetSession().(*ClientSession); ok {
cs.pendingElicitationsMu.Lock()
if ch, exists := cs.pendingElicitations[req.Params.ElicitationID]; exists {
select {
case ch <- struct{}{}:
default:
// Channel already signaled.
}
}
cs.pendingElicitationsMu.Unlock()
}
// Call the user's handler if provided.
if h := c.opts.ElicitationCompleteHandler; h != nil {
h(ctx, req)
}

View file

@ -222,6 +222,61 @@ func TestClientCapabilities(t *testing.T) {
Sampling: &SamplingCapabilities{},
},
},
{
name: "With form elicitation",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
ElicitationModes: []string{"form"},
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: struct {
ListChanged bool "json:\"listChanged,omitempty\""
}{ListChanged: true},
Elicitation: &ElicitationCapabilities{
Form: &FormElicitationCapabilities{},
},
},
},
{
name: "With URL elicitation",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
ElicitationModes: []string{"url"},
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: struct {
ListChanged bool "json:\"listChanged,omitempty\""
}{ListChanged: true},
Elicitation: &ElicitationCapabilities{
URL: &URLElicitationCapabilities{},
},
},
},
{
name: "With both form and URL elicitation",
configureClient: func(s *Client) {},
clientOpts: ClientOptions{
ElicitationModes: []string{"form", "url"},
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
return nil, nil
},
},
wantCapabilities: &ClientCapabilities{
Roots: struct {
ListChanged bool "json:\"listChanged,omitempty\""
}{ListChanged: true},
Elicitation: &ElicitationCapabilities{
Form: &FormElicitationCapabilities{},
URL: &URLElicitationCapabilities{},
},
},
},
}
for _, tc := range testCases {

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TODO: migrate other elicitation tests here.
@ -74,7 +75,7 @@ func TestElicitationURLMode(t *testing.T) {
Message: "URL is missing",
},
wantErrMsg: "URL must be set for URL elicitation",
wantErrCode: codeInvalidParams,
wantErrCode: jsonrpc.CodeInvalidParams,
},
{
name: "schema not allowed",
@ -90,7 +91,7 @@ func TestElicitationURLMode(t *testing.T) {
},
},
wantErrMsg: "requestedSchema must not be set for URL elicitation",
wantErrCode: codeInvalidParams,
wantErrCode: jsonrpc.CodeInvalidParams,
},
}
for _, tc := range testCases {

353
mcp/error_test.go Normal file
View file

@ -0,0 +1,353 @@
// 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)
}
})
})
}

View file

@ -24,7 +24,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
type hiParams struct {
@ -316,7 +316,7 @@ func TestEndToEnd(t *testing.T) {
} {
rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri})
if err != nil {
if code := errorCode(err); code == codeResourceNotFound {
if code := errorCode(err); code == CodeResourceNotFound {
if tt.mimeType != "" {
t.Errorf("%s: not found but expected it to be", tt.uri)
}
@ -576,7 +576,7 @@ func errorCode(err error) int64 {
if err == nil {
return 0
}
var werr *jsonrpc2.WireError
var werr *jsonrpc.Error
if errors.As(err, &werr) {
return werr.Code
}
@ -1367,8 +1367,8 @@ func TestElicitationSchemaValidation(t *testing.T) {
t.Errorf("expected error for invalid schema %q, got nil", tc.name)
return
}
if code := errorCode(err); code != codeInvalidParams {
t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, codeInvalidParams)
if code := errorCode(err); code != jsonrpc.CodeInvalidParams {
t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, jsonrpc.CodeInvalidParams)
}
if !strings.Contains(err.Error(), tc.expectedError) {
t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError)

View file

@ -15,8 +15,8 @@ import (
"path/filepath"
"strings"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/yosida95/uritemplate/v3"
)
@ -40,8 +40,8 @@ type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceR
// ResourceNotFoundError returns an error indicating that a resource being read could
// not be found.
func ResourceNotFoundError(uri string) error {
return &jsonrpc2.WireError{
Code: codeResourceNotFound,
return &jsonrpc.Error{
Code: CodeResourceNotFound,
Message: "Resource not found",
Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)),
}

View file

@ -146,7 +146,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }),
sendingMethodHandler_: defaultSendingMethodHandler[*ServerSession],
sendingMethodHandler_: defaultSendingMethodHandler,
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
resourceSubscriptions: make(map[string]map[*ServerSession]bool),
}
@ -293,12 +293,12 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
// Call typed handler.
res, out, err := h(ctx, req, in)
// Handle server errors appropriately:
// - If the handler returns a structured error (like jsonrpc2.WireError), return it directly
// - If the handler returns a structured error (like jsonrpc.Error), return it directly
// - If the handler returns a regular error, wrap it in a CallToolResult with IsError=true
// - This allows tools to distinguish between protocol errors and tool execution errors
if err != nil {
// Check if this is already a structured JSON-RPC error
if wireErr, ok := err.(*jsonrpc2.WireError); ok {
if wireErr, ok := err.(*jsonrpc.Error); ok {
return nil, wireErr
}
// For regular errors, embed them in the tool result as per MCP spec
@ -542,8 +542,8 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm
s.mu.Unlock()
if !ok {
// Return a proper JSON-RPC error with the correct error code
return nil, &jsonrpc2.WireError{
Code: codeInvalidParams,
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: fmt.Sprintf("unknown prompt %q", req.Params.Name),
}
}
@ -569,8 +569,8 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
st, ok := s.tools.get(req.Params.Name)
s.mu.Unlock()
if !ok {
return nil, &jsonrpc2.WireError{
Code: codeInvalidParams,
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: fmt.Sprintf("unknown tool %q", req.Params.Name),
}
}

View file

@ -88,7 +88,7 @@ func addMiddleware(handlerp *MethodHandler, middleware []Middleware) {
}
}
func defaultSendingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) {
func defaultSendingMethodHandler(ctx context.Context, method string, req Request) (Result, error) {
info, ok := req.GetSession().sendingMethodInfos()[method]
if !ok {
// This can be called from user code, with an arbitrary value for method.
@ -331,16 +331,52 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont
}
}
// Error codes
// MCP-specific error codes.
const (
// CodeResourceNotFound indicates that a requested resource could not be found.
CodeResourceNotFound = -32002
// CodeURLElicitationRequired indicates that the server requires URL elicitation
// before processing the request. The client should execute the elicitation handler
// with the elicitations provided in the error data.
CodeURLElicitationRequired = -32042
)
// URLElicitationRequiredError returns an error indicating that URL elicitation is required
// before the request can be processed. The elicitations parameter should contain the
// elicitation requests that must be completed.
func URLElicitationRequiredError(elicitations []*ElicitParams) error {
// Validate that all elicitations are URL mode
for _, elicit := range elicitations {
mode := elicit.Mode
if mode == "" {
mode = "form" // default mode
}
if mode != "url" {
panic(fmt.Sprintf("URLElicitationRequiredError requires all elicitations to be URL mode, got %q", mode))
}
}
data, err := json.Marshal(map[string]any{
"elicitations": elicitations,
})
if err != nil {
// This should never happen with valid ElicitParams
panic(fmt.Sprintf("failed to marshal elicitations: %v", err))
}
return &jsonrpc.Error{
Code: CodeURLElicitationRequired,
Message: "URL elicitation required",
Data: json.RawMessage(data),
}
}
// Internal error codes
const (
codeResourceNotFound = -32002
// The error code if the method exists and was called properly, but the peer does not support it.
//
// TODO(rfindley): this code is wrong, and we should fix it to be
// consistent with other SDKs.
codeUnsupportedMethod = -31001
// The error code for invalid parameters
codeInvalidParams = -32602
)
// notifySessions calls Notify on all the sessions.

View file

@ -962,7 +962,7 @@ func TestStreamableServerTransport(t *testing.T) {
method: "POST",
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
wantStatusCode: http.StatusOK,
wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc2.WireError{
wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc.Error{
Message: `method "tools/call" is invalid during session initialization`,
})},
},

View file

@ -14,7 +14,7 @@ import (
"testing"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
func TestApplySchema(t *testing.T) {
@ -65,8 +65,8 @@ func TestToolErrorHandling(t *testing.T) {
// Create a tool that returns a structured error
structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
return nil, nil, &jsonrpc2.WireError{
Code: codeInvalidParams,
return nil, nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "internal server error",
}
}
@ -106,13 +106,13 @@ func TestToolErrorHandling(t *testing.T) {
t.Fatal("expected error, got nil")
}
var wireErr *jsonrpc2.WireError
var wireErr *jsonrpc.Error
if !errors.As(err, &wireErr) {
t.Fatalf("expected WireError, got %[1]T: %[1]v", err)
t.Fatalf("expected jsonrpc.Error, got %[1]T: %[1]v", err)
}
if wireErr.Code != codeInvalidParams {
t.Errorf("expected error code %d, got %d", codeInvalidParams, wireErr.Code)
if wireErr.Code != jsonrpc.CodeInvalidParams {
t.Errorf("expected error code %d, got %d", jsonrpc.CodeInvalidParams, wireErr.Code)
}
})

View file

@ -204,8 +204,7 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a
// call executes and awaits a jsonrpc2 call on the given connection,
// translating errors into the mcp domain.
func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error {
// TODO: the "%w"s in this function effectively make jsonrpc2.WireError part of the API.
// Consider alternatives.
// The "%w"s in this function expose jsonrpc.Error as part of the API.
call := conn.Call(ctx, method, params)
err := call.Await(ctx, result)
switch {