improve error handling for tools and server methods (#311)
- Fix tool error handling to properly distinguish between protocol errors and tool execution errors - Return structured JSON-RPC errors directly for protocol-level issues - Embed regular tool errors in CallToolResult as per MCP specification - Improve server error responses for unknown prompts and tools - Add comprehensive tests for both structured and regular error handling
This commit is contained in:
parent
8e6ab130d8
commit
719f68d0bf
4 changed files with 113 additions and 5 deletions
|
|
@ -328,8 +328,11 @@ func (s *Server) getPrompt(ctx context.Context, req *ServerRequest[*GetPromptPar
|
||||||
prompt, ok := s.prompts.get(req.Params.Name)
|
prompt, ok := s.prompts.get(req.Params.Name)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
// TODO: surface the error code over the wire, instead of flattening it into the string.
|
// Return a proper JSON-RPC error with the correct error code
|
||||||
return nil, fmt.Errorf("%s: unknown prompt %q", jsonrpc2.ErrInvalidParams, req.Params.Name)
|
return nil, &jsonrpc2.WireError{
|
||||||
|
Code: CodeInvalidParams,
|
||||||
|
Message: fmt.Sprintf("unknown prompt %q", req.Params.Name),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return prompt.handler(ctx, req.Session, req.Params)
|
return prompt.handler(ctx, req.Session, req.Params)
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +356,10 @@ func (s *Server) callTool(ctx context.Context, req *ServerRequest[*CallToolParam
|
||||||
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, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, req.Params.Name)
|
return nil, &jsonrpc2.WireError{
|
||||||
|
Code: CodeInvalidParams,
|
||||||
|
Message: fmt.Sprintf("unknown tool %q", req.Params.Name),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO: if handler returns nil content, it will serialize as null.
|
// TODO: if handler returns nil content, it will serialize as null.
|
||||||
// Add a test and fix.
|
// Add a test and fix.
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,8 @@ const (
|
||||||
CodeResourceNotFound = -32002
|
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.
|
||||||
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.
|
||||||
|
|
|
||||||
12
mcp/tool.go
12
mcp/tool.go
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A ToolHandler handles a call to tools/call.
|
// A ToolHandler handles a call to tools/call.
|
||||||
|
|
@ -70,9 +71,16 @@ func newServerTool[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*serverTool
|
||||||
Params: params,
|
Params: params,
|
||||||
Extra: req.Extra,
|
Extra: req.Extra,
|
||||||
})
|
})
|
||||||
// TODO(rfindley): investigate why server errors are embedded in this strange way,
|
// Handle server errors appropriately:
|
||||||
// rather than returned as jsonrpc2 server errors.
|
// - If the handler returns a structured error (like jsonrpc2.WireError), 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 {
|
if err != nil {
|
||||||
|
// Check if this is already a structured JSON-RPC error
|
||||||
|
if wireErr, ok := err.(*jsonrpc2.WireError); ok {
|
||||||
|
return nil, wireErr
|
||||||
|
}
|
||||||
|
// For regular errors, embed them in the tool result as per MCP spec
|
||||||
return &CallToolResult{
|
return &CallToolResult{
|
||||||
Content: []Content{&TextContent{Text: err.Error()}},
|
Content: []Content{&TextContent{Text: err.Error()}},
|
||||||
IsError: true,
|
IsError: true,
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testToolHandler is used for type inference in TestNewServerTool.
|
// testToolHandler is used for type inference in TestNewServerTool.
|
||||||
|
|
@ -132,3 +136,91 @@ func TestUnmarshalSchema(t *testing.T) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolErrorHandling(t *testing.T) {
|
||||||
|
// Construct server and add both tools at the top level
|
||||||
|
server := NewServer(testImpl, nil)
|
||||||
|
|
||||||
|
// Create a tool that returns a structured error
|
||||||
|
structuredErrorHandler := func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[map[string]any]]) (*CallToolResultFor[any], error) {
|
||||||
|
return nil, &jsonrpc2.WireError{
|
||||||
|
Code: CodeInvalidParams,
|
||||||
|
Message: "internal server error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a tool that returns a regular error
|
||||||
|
regularErrorHandler := func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[map[string]any]]) (*CallToolResultFor[any], error) {
|
||||||
|
return nil, fmt.Errorf("tool execution failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
AddTool(server, &Tool{Name: "error_tool", Description: "returns structured error"}, structuredErrorHandler)
|
||||||
|
AddTool(server, &Tool{Name: "regular_error_tool", Description: "returns regular error"}, regularErrorHandler)
|
||||||
|
|
||||||
|
// Connect server and client once
|
||||||
|
ct, st := NewInMemoryTransports()
|
||||||
|
_, err := server.Connect(context.Background(), st, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewClient(testImpl, nil)
|
||||||
|
cs, err := client.Connect(context.Background(), ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer cs.Close()
|
||||||
|
|
||||||
|
// Test that structured JSON-RPC errors are returned directly
|
||||||
|
t.Run("structured_error", func(t *testing.T) {
|
||||||
|
// Call the tool
|
||||||
|
_, err = cs.CallTool(context.Background(), &CallToolParams{
|
||||||
|
Name: "error_tool",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should get the structured error directly
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var wireErr *jsonrpc2.WireError
|
||||||
|
if !errors.As(err, &wireErr) {
|
||||||
|
t.Fatalf("expected WireError, got %[1]T: %[1]v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wireErr.Code != CodeInvalidParams {
|
||||||
|
t.Errorf("expected error code %d, got %d", CodeInvalidParams, wireErr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test that regular errors are embedded in tool results
|
||||||
|
t.Run("regular_error", func(t *testing.T) {
|
||||||
|
// Call the tool
|
||||||
|
result, err := cs.CallTool(context.Background(), &CallToolParams{
|
||||||
|
Name: "regular_error_tool",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should not get an error at the protocol level
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected protocol error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get a result with IsError=true
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected IsError=true, got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have error message in content
|
||||||
|
if len(result.Content) == 0 {
|
||||||
|
t.Error("expected error content, got empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if textContent, ok := result.Content[0].(*TextContent); !ok {
|
||||||
|
t.Error("expected TextContent")
|
||||||
|
} else if !strings.Contains(textContent.Text, "tool execution failed") {
|
||||||
|
t.Errorf("expected error message in content, got: %s", textContent.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue