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:
parent
96385fde3c
commit
2ce44d45e9
12 changed files with 670 additions and 44 deletions
|
|
@ -17,6 +17,8 @@ type (
|
||||||
Request = jsonrpc2.Request
|
Request = jsonrpc2.Request
|
||||||
// Response is a JSON-RPC response.
|
// Response is a JSON-RPC response.
|
||||||
Response = jsonrpc2.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
|
// 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) {
|
func DecodeMessage(data []byte) (Message, error) {
|
||||||
return jsonrpc2.DecodeMessage(data)
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
189
mcp/client.go
189
mcp/client.go
|
|
@ -7,9 +7,11 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"iter"
|
"iter"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -45,7 +47,7 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
impl: impl,
|
impl: impl,
|
||||||
roots: newFeatureSet(func(r *Root) string { return r.URI }),
|
roots: newFeatureSet(func(r *Root) string { return r.URI }),
|
||||||
sendingMethodHandler_: defaultSendingMethodHandler[*ClientSession],
|
sendingMethodHandler_: defaultSendingMethodHandler,
|
||||||
receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession],
|
receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession],
|
||||||
}
|
}
|
||||||
if opts != nil {
|
if opts != nil {
|
||||||
|
|
@ -134,7 +136,8 @@ func (c *Client) capabilities() *ClientCapabilities {
|
||||||
// {"form":{}} for backward compatibility, but we explicitly set the form
|
// {"form":{}} for backward compatibility, but we explicitly set the form
|
||||||
// capability.
|
// capability.
|
||||||
caps.Elicitation.Form = &FormElicitationCapabilities{}
|
caps.Elicitation.Form = &FormElicitationCapabilities{}
|
||||||
} else if slices.Contains(modes, "url") {
|
}
|
||||||
|
if slices.Contains(modes, "url") {
|
||||||
caps.Elicitation.URL = &URLElicitationCapabilities{}
|
caps.Elicitation.URL = &URLElicitationCapabilities{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +209,10 @@ type ClientSession struct {
|
||||||
// No mutex is (currently) required to guard the session state, because it is
|
// No mutex is (currently) required to guard the session state, because it is
|
||||||
// only set synchronously during Client.Connect.
|
// only set synchronously during Client.Connect.
|
||||||
state clientSessionState
|
state clientSessionState
|
||||||
|
|
||||||
|
// Pending URL elicitations waiting for completion notifications.
|
||||||
|
pendingElicitationsMu sync.Mutex
|
||||||
|
pendingElicitations map[string]chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientSessionState struct {
|
type clientSessionState struct {
|
||||||
|
|
@ -250,6 +257,46 @@ func (cs *ClientSession) Wait() error {
|
||||||
return cs.conn.Wait()
|
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.
|
// startKeepalive starts the keepalive mechanism for this client session.
|
||||||
func (cs *ClientSession) startKeepalive(interval time.Duration) {
|
func (cs *ClientSession) startKeepalive(interval time.Duration) {
|
||||||
startKeepalive(cs, interval, &cs.keepaliveCancel)
|
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) {
|
func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) {
|
||||||
if c.opts.CreateMessageHandler == nil {
|
if c.opts.CreateMessageHandler == nil {
|
||||||
// TODO: wrap or annotate this error? Pick a standard code?
|
// 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)
|
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) {
|
func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
|
||||||
if c.opts.ElicitationHandler == nil {
|
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.
|
// Validate the elicitation parameters based on the mode.
|
||||||
|
|
@ -323,11 +474,11 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult,
|
||||||
switch mode {
|
switch mode {
|
||||||
case "form":
|
case "form":
|
||||||
if req.Params.URL != "" {
|
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)
|
schema, err := validateElicitSchema(req.Params.RequestedSchema)
|
||||||
if err != nil {
|
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)
|
res, err := c.opts.ElicitationHandler(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -337,28 +488,28 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult,
|
||||||
if schema != nil && res.Content != nil {
|
if schema != nil && res.Content != nil {
|
||||||
resolved, err := schema.Resolve(nil)
|
resolved, err := schema.Resolve(nil)
|
||||||
if err != 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 {
|
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)
|
err = resolved.ApplyDefaults(&res.Content)
|
||||||
if err != nil {
|
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
|
return res, nil
|
||||||
case "url":
|
case "url":
|
||||||
if req.Params.RequestedSchema != nil {
|
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 == "" {
|
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.
|
// No schema validation for URL mode, just pass through to handler.
|
||||||
return c.opts.ElicitationHandler(ctx, req)
|
return c.opts.ElicitationHandler(ctx, req)
|
||||||
default:
|
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) {
|
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 {
|
if h := c.opts.ElicitationCompleteHandler; h != nil {
|
||||||
h(ctx, req)
|
h(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,61 @@ func TestClientCapabilities(t *testing.T) {
|
||||||
Sampling: &SamplingCapabilities{},
|
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 {
|
for _, tc := range testCases {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: migrate other elicitation tests here.
|
// TODO: migrate other elicitation tests here.
|
||||||
|
|
@ -74,7 +75,7 @@ func TestElicitationURLMode(t *testing.T) {
|
||||||
Message: "URL is missing",
|
Message: "URL is missing",
|
||||||
},
|
},
|
||||||
wantErrMsg: "URL must be set for URL elicitation",
|
wantErrMsg: "URL must be set for URL elicitation",
|
||||||
wantErrCode: codeInvalidParams,
|
wantErrCode: jsonrpc.CodeInvalidParams,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "schema not allowed",
|
name: "schema not allowed",
|
||||||
|
|
@ -90,7 +91,7 @@ func TestElicitationURLMode(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErrMsg: "requestedSchema must not be set for URL elicitation",
|
wantErrMsg: "requestedSchema must not be set for URL elicitation",
|
||||||
wantErrCode: codeInvalidParams,
|
wantErrCode: jsonrpc.CodeInvalidParams,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
|
|
||||||
353
mcp/error_test.go
Normal file
353
mcp/error_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
type hiParams struct {
|
type hiParams struct {
|
||||||
|
|
@ -316,7 +316,7 @@ func TestEndToEnd(t *testing.T) {
|
||||||
} {
|
} {
|
||||||
rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri})
|
rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code := errorCode(err); code == codeResourceNotFound {
|
if code := errorCode(err); code == CodeResourceNotFound {
|
||||||
if tt.mimeType != "" {
|
if tt.mimeType != "" {
|
||||||
t.Errorf("%s: not found but expected it to be", tt.uri)
|
t.Errorf("%s: not found but expected it to be", tt.uri)
|
||||||
}
|
}
|
||||||
|
|
@ -576,7 +576,7 @@ func errorCode(err error) int64 {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var werr *jsonrpc2.WireError
|
var werr *jsonrpc.Error
|
||||||
if errors.As(err, &werr) {
|
if errors.As(err, &werr) {
|
||||||
return werr.Code
|
return werr.Code
|
||||||
}
|
}
|
||||||
|
|
@ -1367,8 +1367,8 @@ func TestElicitationSchemaValidation(t *testing.T) {
|
||||||
t.Errorf("expected error for invalid schema %q, got nil", tc.name)
|
t.Errorf("expected error for invalid schema %q, got nil", tc.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if code := errorCode(err); code != codeInvalidParams {
|
if code := errorCode(err); code != jsonrpc.CodeInvalidParams {
|
||||||
t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, codeInvalidParams)
|
t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, jsonrpc.CodeInvalidParams)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), tc.expectedError) {
|
if !strings.Contains(err.Error(), tc.expectedError) {
|
||||||
t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError)
|
t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError)
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||||
"github.com/yosida95/uritemplate/v3"
|
"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
|
// ResourceNotFoundError returns an error indicating that a resource being read could
|
||||||
// not be found.
|
// not be found.
|
||||||
func ResourceNotFoundError(uri string) error {
|
func ResourceNotFoundError(uri string) error {
|
||||||
return &jsonrpc2.WireError{
|
return &jsonrpc.Error{
|
||||||
Code: codeResourceNotFound,
|
Code: CodeResourceNotFound,
|
||||||
Message: "Resource not found",
|
Message: "Resource not found",
|
||||||
Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)),
|
Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
|
||||||
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
|
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
|
||||||
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
|
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
|
||||||
resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }),
|
resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }),
|
||||||
sendingMethodHandler_: defaultSendingMethodHandler[*ServerSession],
|
sendingMethodHandler_: defaultSendingMethodHandler,
|
||||||
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
|
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
|
||||||
resourceSubscriptions: make(map[string]map[*ServerSession]bool),
|
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.
|
// Call typed handler.
|
||||||
res, out, err := h(ctx, req, in)
|
res, out, err := h(ctx, req, in)
|
||||||
// Handle server errors appropriately:
|
// 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
|
// - 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
|
// - This allows tools to distinguish between protocol errors and tool execution errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if this is already a structured JSON-RPC error
|
// 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
|
return nil, wireErr
|
||||||
}
|
}
|
||||||
// For regular errors, embed them in the tool result as per MCP spec
|
// 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()
|
s.mu.Unlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
// Return a proper JSON-RPC error with the correct error code
|
// Return a proper JSON-RPC error with the correct error code
|
||||||
return nil, &jsonrpc2.WireError{
|
return nil, &jsonrpc.Error{
|
||||||
Code: codeInvalidParams,
|
Code: jsonrpc.CodeInvalidParams,
|
||||||
Message: fmt.Sprintf("unknown prompt %q", req.Params.Name),
|
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)
|
st, ok := s.tools.get(req.Params.Name)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, &jsonrpc2.WireError{
|
return nil, &jsonrpc.Error{
|
||||||
Code: codeInvalidParams,
|
Code: jsonrpc.CodeInvalidParams,
|
||||||
Message: fmt.Sprintf("unknown tool %q", req.Params.Name),
|
Message: fmt.Sprintf("unknown tool %q", req.Params.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
info, ok := req.GetSession().sendingMethodInfos()[method]
|
||||||
if !ok {
|
if !ok {
|
||||||
// This can be called from user code, with an arbitrary value for method.
|
// 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 (
|
const (
|
||||||
codeResourceNotFound = -32002
|
|
||||||
// The error code if the method exists and was called properly, but the peer does not support it.
|
// 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
|
// TODO(rfindley): this code is wrong, and we should fix it to be
|
||||||
// consistent with other SDKs.
|
// consistent with other SDKs.
|
||||||
codeUnsupportedMethod = -31001
|
codeUnsupportedMethod = -31001
|
||||||
// The error code for invalid parameters
|
|
||||||
codeInvalidParams = -32602
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// notifySessions calls Notify on all the sessions.
|
// notifySessions calls Notify on all the sessions.
|
||||||
|
|
|
||||||
|
|
@ -962,7 +962,7 @@ func TestStreamableServerTransport(t *testing.T) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
|
||||||
wantStatusCode: http.StatusOK,
|
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`,
|
Message: `method "tools/call" is invalid during session initialization`,
|
||||||
})},
|
})},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplySchema(t *testing.T) {
|
func TestApplySchema(t *testing.T) {
|
||||||
|
|
@ -65,8 +65,8 @@ func TestToolErrorHandling(t *testing.T) {
|
||||||
|
|
||||||
// Create a tool that returns a structured error
|
// Create a tool that returns a structured error
|
||||||
structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
|
structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
|
||||||
return nil, nil, &jsonrpc2.WireError{
|
return nil, nil, &jsonrpc.Error{
|
||||||
Code: codeInvalidParams,
|
Code: jsonrpc.CodeInvalidParams,
|
||||||
Message: "internal server error",
|
Message: "internal server error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,13 +106,13 @@ func TestToolErrorHandling(t *testing.T) {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
var wireErr *jsonrpc2.WireError
|
var wireErr *jsonrpc.Error
|
||||||
if !errors.As(err, &wireErr) {
|
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 {
|
if wireErr.Code != jsonrpc.CodeInvalidParams {
|
||||||
t.Errorf("expected error code %d, got %d", codeInvalidParams, wireErr.Code)
|
t.Errorf("expected error code %d, got %d", jsonrpc.CodeInvalidParams, wireErr.Code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
// call executes and awaits a jsonrpc2 call on the given connection,
|
||||||
// translating errors into the mcp domain.
|
// translating errors into the mcp domain.
|
||||||
func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error {
|
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.
|
// The "%w"s in this function expose jsonrpc.Error as part of the API.
|
||||||
// Consider alternatives.
|
|
||||||
call := conn.Call(ctx, method, params)
|
call := conn.Call(ctx, method, params)
|
||||||
err := call.Await(ctx, result)
|
err := call.Await(ctx, result)
|
||||||
switch {
|
switch {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue