mcp: change tool handler design (#325)
Change the design of tool handlers by removing genericity from the common path. - `ToolHandler` gets the args as a `json.RawMessage`. Using Server.AddTool does no unmarshaling or schema validation. - `ToolHandlerFor`, installed with `AddTool`, are the only generic pieces. TODO: uncomment and fix some tests
This commit is contained in:
parent
79f063bc98
commit
32cf71de3a
14 changed files with 789 additions and 966 deletions
|
|
@ -24,9 +24,14 @@ func TestList(t *testing.T) {
|
|||
t.Run("tools", func(t *testing.T) {
|
||||
var wantTools []*mcp.Tool
|
||||
for _, name := range []string{"apple", "banana", "cherry"} {
|
||||
t := &mcp.Tool{Name: name, Description: name + " tool"}
|
||||
wantTools = append(wantTools, t)
|
||||
mcp.AddTool(server, t, SayHi)
|
||||
tt := &mcp.Tool{Name: name, Description: name + " tool"}
|
||||
mcp.AddTool(server, tt, SayHi)
|
||||
is, err := jsonschema.For[SayHiParams](nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.InputSchema = is
|
||||
wantTools = append(wantTools, tt)
|
||||
}
|
||||
t.Run("list", func(t *testing.T) {
|
||||
res, err := clientSession.ListTools(ctx, nil)
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ func TestContentUnmarshalNil(t *testing.T) {
|
|||
{
|
||||
name: "CallToolResultFor nil Content",
|
||||
json: `{"content":[{"type":"text","text":"hello"}]}`,
|
||||
content: &mcp.CallToolResultFor[string]{},
|
||||
want: &mcp.CallToolResultFor[string]{Content: []mcp.Content{&mcp.TextContent{Text: "hello"}}},
|
||||
content: &mcp.CallToolResult{},
|
||||
want: &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "hello"}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func Example_loggingMiddleware() {
|
|||
server.AddReceivingMiddleware(loggingMiddleware)
|
||||
|
||||
// Add a simple tool
|
||||
server.AddTool(
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "greet",
|
||||
Description: "Greet someone with logging.",
|
||||
|
|
@ -89,19 +89,19 @@ func Example_loggingMiddleware() {
|
|||
},
|
||||
func(
|
||||
ctx context.Context,
|
||||
req *mcp.ServerRequest[*mcp.CallToolParamsFor[map[string]any]],
|
||||
) (*mcp.CallToolResultFor[any], error) {
|
||||
name, ok := req.Params.Arguments["name"].(string)
|
||||
req *mcp.ServerRequest[*mcp.CallToolParams], args map[string]any,
|
||||
) (*mcp.CallToolResult, any, error) {
|
||||
name, ok := args["name"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("name parameter is required and must be a string")
|
||||
return nil, nil, fmt.Errorf("name parameter is required and must be a string")
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Hello, %s!", name)
|
||||
return &mcp.CallToolResultFor[any]{
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: message},
|
||||
},
|
||||
}, nil
|
||||
}, nil, nil
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
|
|
@ -18,14 +17,6 @@ type SayHiParams struct {
|
|||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func SayHi(ctx context.Context, cc *ServerSession, params *CallToolParamsFor[SayHiParams]) (*CallToolResultFor[any], error) {
|
||||
return &CallToolResultFor[any]{
|
||||
Content: []Content{
|
||||
&TextContent{Text: "Hi " + params.Name},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestFeatureSetOrder(t *testing.T) {
|
||||
toolA := &Tool{Name: "apple", Description: "apple tool"}
|
||||
toolB := &Tool{Name: "banana", Description: "banana tool"}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ type hiParams struct {
|
|||
// TODO(jba): after schemas are stateless (WIP), this can be a variable.
|
||||
func greetTool() *Tool { return &Tool{Name: "greet", Description: "say hi"} }
|
||||
|
||||
func sayHi(ctx context.Context, req *ServerRequest[*CallToolParamsFor[hiParams]]) (*CallToolResultFor[any], error) {
|
||||
func sayHi(ctx context.Context, req *ServerRequest[*CallToolParams], args hiParams) (*CallToolResult, any, error) {
|
||||
if err := req.Session.Ping(ctx, nil); err != nil {
|
||||
return nil, fmt.Errorf("ping failed: %v", err)
|
||||
return nil, nil, fmt.Errorf("ping failed: %v", err)
|
||||
}
|
||||
return &CallToolResultFor[any]{Content: []Content{&TextContent{Text: "hi " + req.Params.Arguments.Name}}}, nil
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + args.Name}}}, nil, nil
|
||||
}
|
||||
|
||||
var codeReviewPrompt = &Prompt{
|
||||
|
|
@ -97,9 +97,9 @@ func TestEndToEnd(t *testing.T) {
|
|||
Name: "greet",
|
||||
Description: "say hi",
|
||||
}, sayHi)
|
||||
s.AddTool(&Tool{Name: "fail", InputSchema: &jsonschema.Schema{}},
|
||||
func(context.Context, *ServerRequest[*CallToolParamsFor[map[string]any]]) (*CallToolResult, error) {
|
||||
return nil, errTestFailure
|
||||
AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{}},
|
||||
func(context.Context, *ServerRequest[*CallToolParams], map[string]any) (*CallToolResult, any, error) {
|
||||
return nil, nil, errTestFailure
|
||||
})
|
||||
s.AddPrompt(codeReviewPrompt, codReviewPromptHandler)
|
||||
s.AddPrompt(&Prompt{Name: "fail"}, func(_ context.Context, _ *ServerSession, _ *GetPromptParams) (*GetPromptResult, error) {
|
||||
|
|
@ -663,18 +663,18 @@ func TestCancellation(t *testing.T) {
|
|||
start = make(chan struct{})
|
||||
cancelled = make(chan struct{}, 1) // don't block the request
|
||||
)
|
||||
slowRequest := func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
slowRequest := func(ctx context.Context, req *ServerRequest[*CallToolParams], args any) (*CallToolResult, any, error) {
|
||||
start <- struct{}{}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancelled <- struct{}{}
|
||||
case <-time.After(5 * time.Second):
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
cs, _ := basicConnection(t, func(s *Server) {
|
||||
AddTool(s, &Tool{Name: "slow"}, slowRequest)
|
||||
AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{}}, slowRequest)
|
||||
})
|
||||
defer cs.Close()
|
||||
|
||||
|
|
@ -852,7 +852,7 @@ func traceCalls[S Session](w io.Writer, prefix string) Middleware {
|
|||
}
|
||||
}
|
||||
|
||||
func nopHandler(context.Context, *ServerRequest[*CallToolParamsFor[map[string]any]]) (*CallToolResult, error) {
|
||||
func nopHandler(context.Context, *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -1015,11 +1015,11 @@ func TestSynchronousNotifications(t *testing.T) {
|
|||
}
|
||||
server := NewServer(testImpl, serverOpts)
|
||||
cs, ss := basicClientServerConnection(t, client, server, func(s *Server) {
|
||||
AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *ServerRequest[*CallToolParams], args any) (*CallToolResult, any, error) {
|
||||
if !rootsChanged.Load() {
|
||||
return nil, fmt.Errorf("didn't get root change notification")
|
||||
return nil, nil, fmt.Errorf("didn't get root change notification")
|
||||
}
|
||||
return new(CallToolResult), nil
|
||||
return new(CallToolResult), nil, nil
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1064,13 +1064,13 @@ func TestNoDistributedDeadlock(t *testing.T) {
|
|||
}
|
||||
client := NewClient(testImpl, clientOpts)
|
||||
cs, _ := basicClientServerConnection(t, client, nil, func(s *Server) {
|
||||
AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *ServerRequest[*CallToolParams], args any) (*CallToolResult, any, error) {
|
||||
req.Session.CreateMessage(ctx, new(CreateMessageParams))
|
||||
return new(CallToolResult), nil
|
||||
return new(CallToolResult), nil, nil
|
||||
})
|
||||
AddTool(s, &Tool{Name: "tool2"}, func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
AddTool(s, &Tool{Name: "tool2"}, func(ctx context.Context, req *ServerRequest[*CallToolParams], args any) (*CallToolResult, any, error) {
|
||||
req.Session.Ping(ctx, nil)
|
||||
return new(CallToolResult), nil
|
||||
return new(CallToolResult), nil, nil
|
||||
})
|
||||
})
|
||||
defer cs.Close()
|
||||
|
|
|
|||
|
|
@ -40,20 +40,32 @@ type Annotations struct {
|
|||
Priority float64 `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
type CallToolParams = CallToolParamsFor[any]
|
||||
|
||||
type CallToolParamsFor[In any] struct {
|
||||
type CallToolParams struct {
|
||||
// This property is reserved by the protocol to allow clients and servers to
|
||||
// attach additional metadata to their responses.
|
||||
Meta `json:"_meta,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Arguments In `json:"arguments,omitempty"`
|
||||
Arguments any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// When unmarshalling CallToolParams on the server side, we need to delay unmarshaling of the arguments.
|
||||
func (c *CallToolParams) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Meta `json:"_meta,omitempty"`
|
||||
Name string `json:"name"`
|
||||
RawArguments json.RawMessage `json:"arguments,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Meta = raw.Meta
|
||||
c.Name = raw.Name
|
||||
c.Arguments = raw.RawArguments
|
||||
return nil
|
||||
}
|
||||
|
||||
// The server's response to a tool call.
|
||||
type CallToolResult = CallToolResultFor[any]
|
||||
|
||||
type CallToolResultFor[Out any] struct {
|
||||
type CallToolResult struct {
|
||||
// This property is reserved by the protocol to allow clients and servers to
|
||||
// attach additional metadata to their responses.
|
||||
Meta `json:"_meta,omitempty"`
|
||||
|
|
@ -62,7 +74,7 @@ type CallToolResultFor[Out any] struct {
|
|||
Content []Content `json:"content"`
|
||||
// An optional JSON object that represents the structured result of the tool
|
||||
// call.
|
||||
StructuredContent Out `json:"structuredContent,omitempty"`
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
// Whether the tool call ended in an error.
|
||||
//
|
||||
// If not set, this is assumed to be false (the call was successful).
|
||||
|
|
@ -78,12 +90,12 @@ type CallToolResultFor[Out any] struct {
|
|||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
func (*CallToolResultFor[Out]) isResult() {}
|
||||
func (*CallToolResult) isResult() {}
|
||||
|
||||
// UnmarshalJSON handles the unmarshalling of content into the Content
|
||||
// interface.
|
||||
func (x *CallToolResultFor[Out]) UnmarshalJSON(data []byte) error {
|
||||
type res CallToolResultFor[Out] // avoid recursion
|
||||
func (x *CallToolResult) UnmarshalJSON(data []byte) error {
|
||||
type res CallToolResult // avoid recursion
|
||||
var wire struct {
|
||||
res
|
||||
Content []*wireContent `json:"content"`
|
||||
|
|
@ -95,13 +107,13 @@ func (x *CallToolResultFor[Out]) UnmarshalJSON(data []byte) error {
|
|||
if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
*x = CallToolResultFor[Out](wire.res)
|
||||
*x = CallToolResult(wire.res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CallToolParamsFor[Out]) isParams() {}
|
||||
func (x *CallToolParamsFor[Out]) GetProgressToken() any { return getProgressToken(x) }
|
||||
func (x *CallToolParamsFor[Out]) SetProgressToken(t any) { setProgressToken(x, t) }
|
||||
func (x *CallToolParams) isParams() {}
|
||||
func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) }
|
||||
func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) }
|
||||
|
||||
type CancelledParams struct {
|
||||
// This property is reserved by the protocol to allow clients and servers to
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ func TestCompleteReference(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteParams(t *testing.T) {
|
||||
// Define test cases specifically for Marshalling
|
||||
marshalTests := []struct {
|
||||
|
|
@ -514,13 +515,13 @@ func TestContentUnmarshal(t *testing.T) {
|
|||
var got CallToolResult
|
||||
roundtrip(ctr, &got)
|
||||
|
||||
ctrf := &CallToolResultFor[int]{
|
||||
ctrf := &CallToolResult{
|
||||
Meta: Meta{"m": true},
|
||||
Content: content,
|
||||
IsError: true,
|
||||
StructuredContent: 3,
|
||||
StructuredContent: 3.0,
|
||||
}
|
||||
var gotf CallToolResultFor[int]
|
||||
var gotf CallToolResult
|
||||
roundtrip(ctrf, &gotf)
|
||||
|
||||
pm := &PromptMessage{
|
||||
|
|
|
|||
147
mcp/server.go
147
mcp/server.go
|
|
@ -15,10 +15,12 @@ import (
|
|||
"maps"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
|
|
@ -146,53 +148,128 @@ func (s *Server) RemovePrompts(names ...string) {
|
|||
// The tool's input schema must be non-nil. For a tool that takes no input,
|
||||
// or one where any input is valid, set [Tool.InputSchema] to the empty schema,
|
||||
// &jsonschema.Schema{}.
|
||||
//
|
||||
// When the handler is invoked as part of a CallTool request, req.Params.Arguments
|
||||
// will be a json.RawMessage. Unmarshaling the arguments and validating them against the
|
||||
// input schema are the handler author's responsibility.
|
||||
//
|
||||
// Most users will prefer the top-level function [AddTool].
|
||||
func (s *Server) AddTool(t *Tool, h ToolHandler) {
|
||||
if t.InputSchema == nil {
|
||||
// This prevents the tool author from forgetting to write a schema where
|
||||
// one should be provided. If we papered over this by supplying the empty
|
||||
// schema, then every input would be validated and the problem wouldn't be
|
||||
// discovered until runtime, when the LLM sent bad data.
|
||||
panic(fmt.Sprintf("adding tool %q: nil input schema", t.Name))
|
||||
}
|
||||
if err := addToolErr(s, t, h); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// AddTool adds a [Tool] to the server, or replaces one with the same name.
|
||||
// If the tool's input schema is nil, it is set to the schema inferred from the In
|
||||
// type parameter, using [jsonschema.For].
|
||||
// If the tool's output schema is nil and the Out type parameter is not the empty
|
||||
// interface, then the output schema is set to the schema inferred from Out.
|
||||
// The Tool argument must not be modified after this call.
|
||||
func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) {
|
||||
if err := addToolErr(s, t, h); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addToolErr[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) (err error) {
|
||||
defer util.Wrapf(&err, "adding tool %q", t.Name)
|
||||
// If the exact same Tool pointer has already been registered under this name,
|
||||
// avoid rebuilding schemas and re-registering. This prevents duplicate
|
||||
// registration from causing errors (and unnecessary work).
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.tools.get(t.Name); ok && existing.tool == t {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
st, err := newServerTool(t, h)
|
||||
if err != nil {
|
||||
return err
|
||||
panic(fmt.Errorf("AddTool %q: missing input schema", t.Name))
|
||||
}
|
||||
st := &serverTool{tool: t, handler: h}
|
||||
// Assume there was a change, since add replaces existing tools.
|
||||
// (It's possible a tool was replaced with an identical one, but not worth checking.)
|
||||
// TODO: Batch these changes by size and time? The typescript SDK doesn't.
|
||||
// TODO: Surface notify error here? best not, in case we need to batch.
|
||||
s.changeAndNotify(notificationToolListChanged, &ToolListChangedParams{},
|
||||
func() bool { s.tools.add(st); return true })
|
||||
return nil
|
||||
}
|
||||
|
||||
// toolFor returns a shallow copy of t and a [ToolHandler] that wraps h.
|
||||
// If the tool's input schema is nil, it is set to the schema inferred from the In
|
||||
// type parameter, using [jsonschema.For].
|
||||
// If the tool's output schema is nil and the Out type parameter is not the empty
|
||||
// interface, then the output schema is set to the schema inferred from Out.
|
||||
//
|
||||
// Most users will call [AddTool]. Use [toolFor] if you wish to wrap the ToolHandler
|
||||
// before calling [Server.AddTool].
|
||||
func toolFor[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler) {
|
||||
tt, hh, err := toolForErr(t, h)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ToolFor: tool %q: %v", t.Name, err))
|
||||
}
|
||||
return tt, hh
|
||||
}
|
||||
|
||||
// TODO(v0.3.0): test
|
||||
func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) {
|
||||
var err error
|
||||
tt := *t
|
||||
tt.InputSchema = t.InputSchema
|
||||
if tt.InputSchema == nil {
|
||||
tt.InputSchema, err = jsonschema.For[In](nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("input schema: %w", err)
|
||||
}
|
||||
}
|
||||
inputResolved, err := tt.InputSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolving input schema: %w", err)
|
||||
}
|
||||
|
||||
if tt.OutputSchema == nil && reflect.TypeFor[Out]() != reflect.TypeFor[any]() {
|
||||
tt.OutputSchema, err = jsonschema.For[Out](nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("output schema: %w", err)
|
||||
}
|
||||
var outputResolved *jsonschema.Resolved
|
||||
if tt.OutputSchema != nil {
|
||||
outputResolved, err = tt.OutputSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolving output schema: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
th := func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
// Unmarshal and validate args.
|
||||
rawArgs := req.Params.Arguments.(json.RawMessage)
|
||||
var in In
|
||||
if rawArgs != nil {
|
||||
if err := unmarshalSchema(rawArgs, inputResolved, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
return nil, wireErr
|
||||
}
|
||||
// For regular errors, embed them in the tool result as per MCP spec
|
||||
return &CallToolResult{
|
||||
Content: []Content{&TextContent{Text: err.Error()}},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TODO(v0.3.0): Validate out.
|
||||
_ = outputResolved
|
||||
|
||||
// TODO: return the serialized JSON in a TextContent block, as per spec?
|
||||
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
|
||||
// But people may use res.Content for other things.
|
||||
if res == nil {
|
||||
res = &CallToolResult{}
|
||||
}
|
||||
res.StructuredContent = out
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return &tt, th, nil
|
||||
}
|
||||
|
||||
// AddTool adds a tool and handler to the server.
|
||||
//
|
||||
// A shallow copy of the tool is made first.
|
||||
// If the tool's input schema is nil, the copy's input schema is set to the schema
|
||||
// inferred from the In type parameter, using [jsonschema.For].
|
||||
// If the tool's output schema is nil and the Out type parameter is not the empty
|
||||
// interface, then the copy's output schema is set to the schema inferred from Out.
|
||||
func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) {
|
||||
s.AddTool(toolFor(t, h))
|
||||
}
|
||||
|
||||
// RemoveTools removes the tools with the given names.
|
||||
|
|
@ -352,7 +429,7 @@ func (s *Server) listTools(_ context.Context, req *ServerRequest[*ListToolsParam
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) callTool(ctx context.Context, req *ServerRequest[*CallToolParamsFor[json.RawMessage]]) (*CallToolResult, error) {
|
||||
func (s *Server) callTool(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
s.mu.Lock()
|
||||
st, ok := s.tools.get(req.Params.Name)
|
||||
s.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ type SayHiParams struct {
|
|||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func SayHi(ctx context.Context, req *mcp.ServerRequest[*mcp.CallToolParamsFor[SayHiParams]]) (*mcp.CallToolResultFor[any], error) {
|
||||
return &mcp.CallToolResultFor[any]{
|
||||
func SayHi(ctx context.Context, req *mcp.ServerRequest[*mcp.CallToolParams], args SayHiParams) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "Hi " + req.Params.Arguments.Name},
|
||||
&mcp.TextContent{Text: "Hi " + args.Name},
|
||||
},
|
||||
}, nil
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
func ExampleServer() {
|
||||
|
|
|
|||
|
|
@ -4,232 +4,222 @@
|
|||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
// TODO(v0.3.0): rewrite this test.
|
||||
// func TestToolValidate(t *testing.T) {
|
||||
// // Check that the tool returned from NewServerTool properly validates its input schema.
|
||||
|
||||
// TODO(jba): this shouldn't be in this file, but tool_test.go doesn't have access to unexported symbols.
|
||||
func TestToolValidate(t *testing.T) {
|
||||
// Check that the tool returned from NewServerTool properly validates its input schema.
|
||||
// type req struct {
|
||||
// I int
|
||||
// B bool
|
||||
// S string `json:",omitempty"`
|
||||
// P *int `json:",omitempty"`
|
||||
// }
|
||||
|
||||
type req struct {
|
||||
I int
|
||||
B bool
|
||||
S string `json:",omitempty"`
|
||||
P *int `json:",omitempty"`
|
||||
}
|
||||
// dummyHandler := func(context.Context, *ServerRequest[*CallToolParams], req) (*CallToolResultFor[any], error) {
|
||||
// return nil, nil
|
||||
// }
|
||||
|
||||
dummyHandler := func(context.Context, *ServerRequest[*CallToolParamsFor[req]]) (*CallToolResultFor[any], error) {
|
||||
return nil, nil
|
||||
}
|
||||
// st, err := newServerTool(&Tool{Name: "test", Description: "test"}, dummyHandler)
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
|
||||
st, err := newServerTool(&Tool{Name: "test", Description: "test"}, dummyHandler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
args map[string]any
|
||||
want string // error should contain this string; empty for success
|
||||
}{
|
||||
{
|
||||
"both required",
|
||||
map[string]any{"I": 1, "B": true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"optional",
|
||||
map[string]any{"I": 1, "B": true, "S": "foo"},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"wrong type",
|
||||
map[string]any{"I": 1.5, "B": true},
|
||||
"cannot unmarshal",
|
||||
},
|
||||
{
|
||||
"extra property",
|
||||
map[string]any{"I": 1, "B": true, "C": 2},
|
||||
"unknown field",
|
||||
},
|
||||
{
|
||||
"value for pointer",
|
||||
map[string]any{"I": 1, "B": true, "P": 3},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"null for pointer",
|
||||
map[string]any{"I": 1, "B": true, "P": nil},
|
||||
"",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
raw, err := json.Marshal(tt.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = st.handler(context.Background(), &ServerRequest[*CallToolParamsFor[json.RawMessage]]{
|
||||
Params: &CallToolParamsFor[json.RawMessage]{Arguments: json.RawMessage(raw)},
|
||||
})
|
||||
if err == nil && tt.want != "" {
|
||||
t.Error("got success, wanted failure")
|
||||
}
|
||||
if err != nil {
|
||||
if tt.want == "" {
|
||||
t.Fatalf("failed with:\n%s\nwanted success", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("got:\n%s\nwanted to contain %q", err, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// for _, tt := range []struct {
|
||||
// desc string
|
||||
// args map[string]any
|
||||
// want string // error should contain this string; empty for success
|
||||
// }{
|
||||
// {
|
||||
// "both required",
|
||||
// map[string]any{"I": 1, "B": true},
|
||||
// "",
|
||||
// },
|
||||
// {
|
||||
// "optional",
|
||||
// map[string]any{"I": 1, "B": true, "S": "foo"},
|
||||
// "",
|
||||
// },
|
||||
// {
|
||||
// "wrong type",
|
||||
// map[string]any{"I": 1.5, "B": true},
|
||||
// "cannot unmarshal",
|
||||
// },
|
||||
// {
|
||||
// "extra property",
|
||||
// map[string]any{"I": 1, "B": true, "C": 2},
|
||||
// "unknown field",
|
||||
// },
|
||||
// {
|
||||
// "value for pointer",
|
||||
// map[string]any{"I": 1, "B": true, "P": 3},
|
||||
// "",
|
||||
// },
|
||||
// {
|
||||
// "null for pointer",
|
||||
// map[string]any{"I": 1, "B": true, "P": nil},
|
||||
// "",
|
||||
// },
|
||||
// } {
|
||||
// t.Run(tt.desc, func(t *testing.T) {
|
||||
// raw, err := json.Marshal(tt.args)
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// _, err = st.handler(context.Background(), &ServerRequest[*CallToolParamsFor[json.RawMessage]]{
|
||||
// Params: &CallToolParamsFor[json.RawMessage]{Arguments: json.RawMessage(raw)},
|
||||
// })
|
||||
// if err == nil && tt.want != "" {
|
||||
// t.Error("got success, wanted failure")
|
||||
// }
|
||||
// if err != nil {
|
||||
// if tt.want == "" {
|
||||
// t.Fatalf("failed with:\n%s\nwanted success", err)
|
||||
// }
|
||||
// if !strings.Contains(err.Error(), tt.want) {
|
||||
// t.Fatalf("got:\n%s\nwanted to contain %q", err, tt.want)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
// TestNilParamsHandling tests that nil parameters don't cause panic in unmarshalParams.
|
||||
// This addresses a vulnerability where missing or null parameters could crash the server.
|
||||
func TestNilParamsHandling(t *testing.T) {
|
||||
// Define test types for clarity
|
||||
type TestArgs struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
type TestParams = *CallToolParamsFor[TestArgs]
|
||||
type TestResult = *CallToolResultFor[string]
|
||||
// func TestNilParamsHandling(t *testing.T) {
|
||||
// // Define test types for clarity
|
||||
// type TestArgs struct {
|
||||
// Name string `json:"name"`
|
||||
// Value int `json:"value"`
|
||||
// }
|
||||
|
||||
// Simple test handler
|
||||
testHandler := func(ctx context.Context, req *ServerRequest[TestParams]) (TestResult, error) {
|
||||
result := "processed: " + req.Params.Arguments.Name
|
||||
return &CallToolResultFor[string]{StructuredContent: result}, nil
|
||||
}
|
||||
// // Simple test handler
|
||||
// testHandler := func(ctx context.Context, req *ServerRequest[**GetPromptParams]) (*GetPromptResult, error) {
|
||||
// result := "processed: " + req.Params.Arguments.Name
|
||||
// return &CallToolResultFor[string]{StructuredContent: result}, nil
|
||||
// }
|
||||
|
||||
methodInfo := newServerMethodInfo(testHandler, missingParamsOK)
|
||||
// methodInfo := newServerMethodInfo(testHandler, missingParamsOK)
|
||||
|
||||
// Helper function to test that unmarshalParams doesn't panic and handles nil gracefully
|
||||
mustNotPanic := func(t *testing.T, rawMsg json.RawMessage, expectNil bool) Params {
|
||||
t.Helper()
|
||||
// // Helper function to test that unmarshalParams doesn't panic and handles nil gracefully
|
||||
// mustNotPanic := func(t *testing.T, rawMsg json.RawMessage, expectNil bool) Params {
|
||||
// t.Helper()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("unmarshalParams panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
// defer func() {
|
||||
// if r := recover(); r != nil {
|
||||
// t.Fatalf("unmarshalParams panicked: %v", r)
|
||||
// }
|
||||
// }()
|
||||
|
||||
params, err := methodInfo.unmarshalParams(rawMsg)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshalParams failed: %v", err)
|
||||
}
|
||||
// params, err := methodInfo.unmarshalParams(rawMsg)
|
||||
// if err != nil {
|
||||
// t.Fatalf("unmarshalParams failed: %v", err)
|
||||
// }
|
||||
|
||||
if expectNil {
|
||||
if params != nil {
|
||||
t.Fatalf("Expected nil params, got %v", params)
|
||||
}
|
||||
return params
|
||||
}
|
||||
// if expectNil {
|
||||
// if params != nil {
|
||||
// t.Fatalf("Expected nil params, got %v", params)
|
||||
// }
|
||||
// return params
|
||||
// }
|
||||
|
||||
if params == nil {
|
||||
t.Fatal("unmarshalParams returned unexpected nil")
|
||||
}
|
||||
// if params == nil {
|
||||
// t.Fatal("unmarshalParams returned unexpected nil")
|
||||
// }
|
||||
|
||||
// Verify the result can be used safely
|
||||
typedParams := params.(TestParams)
|
||||
_ = typedParams.Name
|
||||
_ = typedParams.Arguments.Name
|
||||
_ = typedParams.Arguments.Value
|
||||
// // Verify the result can be used safely
|
||||
// typedParams := params.(TestParams)
|
||||
// _ = typedParams.Name
|
||||
// _ = typedParams.Arguments.Name
|
||||
// _ = typedParams.Arguments.Value
|
||||
|
||||
return params
|
||||
}
|
||||
// return params
|
||||
// }
|
||||
|
||||
// Test different nil parameter scenarios - with missingParamsOK flag, nil/null should return nil
|
||||
t.Run("missing_params", func(t *testing.T) {
|
||||
mustNotPanic(t, nil, true) // Expect nil with missingParamsOK flag
|
||||
})
|
||||
// // Test different nil parameter scenarios - with missingParamsOK flag, nil/null should return nil
|
||||
// t.Run("missing_params", func(t *testing.T) {
|
||||
// mustNotPanic(t, nil, true) // Expect nil with missingParamsOK flag
|
||||
// })
|
||||
|
||||
t.Run("explicit_null", func(t *testing.T) {
|
||||
mustNotPanic(t, json.RawMessage(`null`), true) // Expect nil with missingParamsOK flag
|
||||
})
|
||||
// t.Run("explicit_null", func(t *testing.T) {
|
||||
// mustNotPanic(t, json.RawMessage(`null`), true) // Expect nil with missingParamsOK flag
|
||||
// })
|
||||
|
||||
t.Run("empty_object", func(t *testing.T) {
|
||||
mustNotPanic(t, json.RawMessage(`{}`), false) // Empty object should create valid params
|
||||
})
|
||||
// t.Run("empty_object", func(t *testing.T) {
|
||||
// mustNotPanic(t, json.RawMessage(`{}`), false) // Empty object should create valid params
|
||||
// })
|
||||
|
||||
t.Run("valid_params", func(t *testing.T) {
|
||||
rawMsg := json.RawMessage(`{"name":"test","arguments":{"name":"hello","value":42}}`)
|
||||
params := mustNotPanic(t, rawMsg, false)
|
||||
// t.Run("valid_params", func(t *testing.T) {
|
||||
// rawMsg := json.RawMessage(`{"name":"test","arguments":{"name":"hello","value":42}}`)
|
||||
// params := mustNotPanic(t, rawMsg, false)
|
||||
|
||||
// For valid params, also verify the values are parsed correctly
|
||||
typedParams := params.(TestParams)
|
||||
if typedParams.Name != "test" {
|
||||
t.Errorf("Expected name 'test', got %q", typedParams.Name)
|
||||
}
|
||||
if typedParams.Arguments.Name != "hello" {
|
||||
t.Errorf("Expected argument name 'hello', got %q", typedParams.Arguments.Name)
|
||||
}
|
||||
if typedParams.Arguments.Value != 42 {
|
||||
t.Errorf("Expected argument value 42, got %d", typedParams.Arguments.Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
// // For valid params, also verify the values are parsed correctly
|
||||
// typedParams := params.(TestParams)
|
||||
// if typedParams.Name != "test" {
|
||||
// t.Errorf("Expected name 'test', got %q", typedParams.Name)
|
||||
// }
|
||||
// if typedParams.Arguments.Name != "hello" {
|
||||
// t.Errorf("Expected argument name 'hello', got %q", typedParams.Arguments.Name)
|
||||
// }
|
||||
// if typedParams.Arguments.Value != 42 {
|
||||
// t.Errorf("Expected argument value 42, got %d", typedParams.Arguments.Value)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// TestNilParamsEdgeCases tests edge cases to ensure we don't over-fix
|
||||
func TestNilParamsEdgeCases(t *testing.T) {
|
||||
type TestArgs struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
type TestParams = *CallToolParamsFor[TestArgs]
|
||||
// func TestNilParamsEdgeCases(t *testing.T) {
|
||||
// type TestArgs struct {
|
||||
// Name string `json:"name"`
|
||||
// Value int `json:"value"`
|
||||
// }
|
||||
// type TestParams = *CallToolParamsFor[TestArgs]
|
||||
|
||||
testHandler := func(context.Context, *ServerRequest[TestParams]) (*CallToolResultFor[string], error) {
|
||||
return &CallToolResultFor[string]{StructuredContent: "test"}, nil
|
||||
}
|
||||
// testHandler := func(context.Context, *ServerRequest[TestParams]) (*CallToolResultFor[string], error) {
|
||||
// return &CallToolResultFor[string]{StructuredContent: "test"}, nil
|
||||
// }
|
||||
|
||||
methodInfo := newServerMethodInfo(testHandler, missingParamsOK)
|
||||
// methodInfo := newServerMethodInfo(testHandler, missingParamsOK)
|
||||
|
||||
// These should fail normally, not be treated as nil params
|
||||
invalidCases := []json.RawMessage{
|
||||
json.RawMessage(""), // empty string - should error
|
||||
json.RawMessage("[]"), // array - should error
|
||||
json.RawMessage(`"null"`), // string "null" - should error
|
||||
json.RawMessage("0"), // number - should error
|
||||
json.RawMessage("false"), // boolean - should error
|
||||
}
|
||||
// // These should fail normally, not be treated as nil params
|
||||
// invalidCases := []json.RawMessage{
|
||||
// json.RawMessage(""), // empty string - should error
|
||||
// json.RawMessage("[]"), // array - should error
|
||||
// json.RawMessage(`"null"`), // string "null" - should error
|
||||
// json.RawMessage("0"), // number - should error
|
||||
// json.RawMessage("false"), // boolean - should error
|
||||
// }
|
||||
|
||||
for i, rawMsg := range invalidCases {
|
||||
t.Run(fmt.Sprintf("invalid_case_%d", i), func(t *testing.T) {
|
||||
params, err := methodInfo.unmarshalParams(rawMsg)
|
||||
if err == nil && params == nil {
|
||||
t.Error("Should not return nil params without error")
|
||||
}
|
||||
})
|
||||
}
|
||||
// for i, rawMsg := range invalidCases {
|
||||
// t.Run(fmt.Sprintf("invalid_case_%d", i), func(t *testing.T) {
|
||||
// params, err := methodInfo.unmarshalParams(rawMsg)
|
||||
// if err == nil && params == nil {
|
||||
// t.Error("Should not return nil params without error")
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// Test that methods without missingParamsOK flag properly reject nil params
|
||||
t.Run("reject_when_params_required", func(t *testing.T) {
|
||||
methodInfoStrict := newServerMethodInfo(testHandler, 0) // No missingParamsOK flag
|
||||
// // Test that methods without missingParamsOK flag properly reject nil params
|
||||
// t.Run("reject_when_params_required", func(t *testing.T) {
|
||||
// methodInfoStrict := newServerMethodInfo(testHandler, 0) // No missingParamsOK flag
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
params json.RawMessage
|
||||
}{
|
||||
{"nil_params", nil},
|
||||
{"null_params", json.RawMessage(`null`)},
|
||||
}
|
||||
// testCases := []struct {
|
||||
// name string
|
||||
// params json.RawMessage
|
||||
// }{
|
||||
// {"nil_params", nil},
|
||||
// {"null_params", json.RawMessage(`null`)},
|
||||
// }
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := methodInfoStrict.unmarshalParams(tc.params)
|
||||
if err == nil {
|
||||
t.Error("Expected error for required params, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing required \"params\"") {
|
||||
t.Errorf("Expected 'missing required params' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// for _, tc := range testCases {
|
||||
// t.Run(tc.name, func(t *testing.T) {
|
||||
// _, err := methodInfoStrict.unmarshalParams(tc.params)
|
||||
// if err == nil {
|
||||
// t.Error("Expected error for required params, got nil")
|
||||
// }
|
||||
// if !strings.Contains(err.Error(), "missing required \"params\"") {
|
||||
// t.Errorf("Expected 'missing required params' error, got: %v", err)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ type AddParams struct {
|
|||
X, Y int
|
||||
}
|
||||
|
||||
func Add(ctx context.Context, req *mcp.ServerRequest[*mcp.CallToolParamsFor[AddParams]]) (*mcp.CallToolResultFor[any], error) {
|
||||
return &mcp.CallToolResultFor[any]{
|
||||
func Add(ctx context.Context, req *mcp.ServerRequest[*mcp.CallToolParams], args AddParams) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: fmt.Sprintf("%d", req.Params.Arguments.X+req.Params.Arguments.Y)},
|
||||
&mcp.TextContent{Text: fmt.Sprintf("%d", args.X+args.Y)},
|
||||
},
|
||||
}, nil
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
func ExampleSSEHandler() {
|
||||
|
|
|
|||
|
|
@ -49,18 +49,18 @@ func TestStreamableTransports(t *testing.T) {
|
|||
start = make(chan struct{})
|
||||
cancelled = make(chan struct{}, 1) // don't block the request
|
||||
)
|
||||
hang := func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
hang := func(ctx context.Context, req *ServerRequest[*CallToolParams], args any) (*CallToolResult, any, error) {
|
||||
start <- struct{}{}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancelled <- struct{}{}
|
||||
case <-time.After(5 * time.Second):
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
AddTool(server, &Tool{Name: "hang"}, hang)
|
||||
AddTool(server, &Tool{Name: "sample"}, func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
AddTool(server, &Tool{Name: "sample"}, func(ctx context.Context, req *ServerRequest[*CallToolParams], args map[string]any) (*CallToolResult, any, error) {
|
||||
// Test that we can make sampling requests during tool handling.
|
||||
//
|
||||
// Try this on both the request context and a background context, so
|
||||
|
|
@ -71,13 +71,13 @@ func TestStreamableTransports(t *testing.T) {
|
|||
} {
|
||||
res, err := req.Session.CreateMessage(ctx, &CreateMessageParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if g, w := res.Model, "aModel"; g != w {
|
||||
return nil, fmt.Errorf("got %q, want %q", g, w)
|
||||
return nil, nil, fmt.Errorf("got %q, want %q", g, w)
|
||||
}
|
||||
}
|
||||
return &CallToolResultFor[any]{}, nil
|
||||
return &CallToolResult{}, nil, nil
|
||||
})
|
||||
|
||||
// Start an httptest.Server with the StreamableHTTPHandler, wrapped in a
|
||||
|
|
@ -219,8 +219,8 @@ func testClientReplay(t *testing.T, test clientReplayTest) {
|
|||
// proxy-killing action.
|
||||
serverReadyToKillProxy := make(chan struct{})
|
||||
serverClosed := make(chan struct{})
|
||||
server.AddTool(&Tool{Name: "multiMessageTool", InputSchema: &jsonschema.Schema{}},
|
||||
func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[map[string]any]]) (*CallToolResult, error) {
|
||||
AddTool(server, &Tool{Name: "multiMessageTool", InputSchema: &jsonschema.Schema{}},
|
||||
func(ctx context.Context, req *ServerRequest[*CallToolParams], args map[string]any) (*CallToolResult, any, error) {
|
||||
// Send one message to the request context, and another to a background
|
||||
// context (which will end up on the hanging GET).
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ func testClientReplay(t *testing.T, test clientReplayTest) {
|
|||
// the client's connection drops.
|
||||
req.Session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg3"})
|
||||
req.Session.NotifyProgress(bgCtx, &ProgressNotificationParams{Message: "msg4"})
|
||||
return new(CallToolResult), nil
|
||||
return new(CallToolResult), nil, nil
|
||||
})
|
||||
|
||||
realServer := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))
|
||||
|
|
@ -353,9 +353,9 @@ func TestServerInitiatedSSE(t *testing.T) {
|
|||
t.Fatalf("client.Connect() failed: %v", err)
|
||||
}
|
||||
defer clientSession.Close()
|
||||
server.AddTool(&Tool{Name: "testTool", InputSchema: &jsonschema.Schema{}},
|
||||
func(context.Context, *ServerRequest[*CallToolParamsFor[map[string]any]]) (*CallToolResult, error) {
|
||||
return &CallToolResult{}, nil
|
||||
AddTool(server, &Tool{Name: "testTool", InputSchema: &jsonschema.Schema{}},
|
||||
func(context.Context, *ServerRequest[*CallToolParams], map[string]any) (*CallToolResult, any, error) {
|
||||
return &CallToolResult{}, nil, nil
|
||||
})
|
||||
receivedNotifications := readNotifications(t, ctx, notifications, 1)
|
||||
wantReceived := []string{"toolListChanged"}
|
||||
|
|
@ -657,12 +657,14 @@ func TestStreamableServerTransport(t *testing.T) {
|
|||
// Create a server containing a single tool, which runs the test tool
|
||||
// behavior, if any.
|
||||
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
|
||||
AddTool(server, &Tool{Name: "tool"}, func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[any]]) (*CallToolResultFor[any], error) {
|
||||
if test.tool != nil {
|
||||
test.tool(t, ctx, req.Session)
|
||||
}
|
||||
return &CallToolResultFor[any]{}, nil
|
||||
})
|
||||
server.AddTool(
|
||||
&Tool{Name: "tool", InputSchema: &jsonschema.Schema{}},
|
||||
func(ctx context.Context, req *ServerRequest[*CallToolParams]) (*CallToolResult, error) {
|
||||
if test.tool != nil {
|
||||
test.tool(t, ctx, req.Session)
|
||||
}
|
||||
return &CallToolResult{}, nil
|
||||
})
|
||||
|
||||
// Start the streamable handler.
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
||||
|
|
@ -1070,12 +1072,12 @@ func TestEventID(t *testing.T) {
|
|||
func TestStreamableStateless(t *testing.T) {
|
||||
// This version of sayHi expects
|
||||
// that request from our client).
|
||||
sayHi := func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[hiParams]]) (*CallToolResult, error) {
|
||||
sayHi := func(ctx context.Context, req *ServerRequest[*CallToolParams], args hiParams) (*CallToolResult, any, error) {
|
||||
if err := req.Session.Ping(ctx, nil); err == nil {
|
||||
// ping should fail, but not break the connection
|
||||
t.Errorf("ping succeeded unexpectedly")
|
||||
}
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + req.Params.Arguments.Name}}}, nil
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + args.Name}}}, nil, nil
|
||||
}
|
||||
server := NewServer(testImpl, nil)
|
||||
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
|
||||
|
|
@ -1177,8 +1179,8 @@ func TestTokenInfo(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
|
||||
// Create a server with a tool that returns TokenInfo.
|
||||
tokenInfo := func(ctx context.Context, req *ServerRequest[*CallToolParamsFor[struct{}]]) (*CallToolResultFor[any], error) {
|
||||
return &CallToolResultFor[any]{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil
|
||||
tokenInfo := func(ctx context.Context, req *ServerRequest[*CallToolParams], _ struct{}) (*CallToolResult, any, error) {
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil, nil
|
||||
}
|
||||
server := NewServer(testImpl, nil)
|
||||
AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo)
|
||||
|
|
|
|||
93
mcp/tool.go
93
mcp/tool.go
|
|
@ -9,109 +9,22 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
)
|
||||
|
||||
// A ToolHandler handles a call to tools/call.
|
||||
// [CallToolParams.Arguments] will contain a map[string]any that has been validated
|
||||
// against the input schema.
|
||||
type ToolHandler = ToolHandlerFor[map[string]any, any]
|
||||
type ToolHandler func(context.Context, *ServerRequest[*CallToolParams]) (*CallToolResult, error)
|
||||
|
||||
// A ToolHandlerFor handles a call to tools/call with typed arguments and results.
|
||||
type ToolHandlerFor[In, Out any] func(context.Context, *ServerRequest[*CallToolParamsFor[In]]) (*CallToolResultFor[Out], error)
|
||||
|
||||
// A rawToolHandler is like a ToolHandler, but takes the arguments as as json.RawMessage.
|
||||
// Second arg is *Request[*ServerSession, *CallToolParamsFor[json.RawMessage]], but that creates
|
||||
// a cycle.
|
||||
type rawToolHandler = func(context.Context, any) (*CallToolResult, error)
|
||||
type ToolHandlerFor[In, Out any] func(context.Context, *ServerRequest[*CallToolParams], In) (*CallToolResult, Out, error)
|
||||
|
||||
// A serverTool is a tool definition that is bound to a tool handler.
|
||||
type serverTool struct {
|
||||
tool *Tool
|
||||
handler rawToolHandler
|
||||
// Resolved tool schemas. Set in newServerTool.
|
||||
inputResolved, outputResolved *jsonschema.Resolved
|
||||
}
|
||||
|
||||
// newServerTool creates a serverTool from a tool and a handler.
|
||||
// If the tool doesn't have an input schema, it is inferred from In.
|
||||
// If the tool doesn't have an output schema and Out != any, it is inferred from Out.
|
||||
func newServerTool[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*serverTool, error) {
|
||||
st := &serverTool{tool: t}
|
||||
|
||||
if err := setSchema[In](&t.InputSchema, &st.inputResolved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reflect.TypeFor[Out]() != reflect.TypeFor[any]() {
|
||||
if err := setSchema[Out](&t.OutputSchema, &st.outputResolved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
st.handler = func(ctx context.Context, areq any) (*CallToolResult, error) {
|
||||
req := areq.(*ServerRequest[*CallToolParamsFor[json.RawMessage]])
|
||||
var args In
|
||||
if req.Params.Arguments != nil {
|
||||
if err := unmarshalSchema(req.Params.Arguments, st.inputResolved, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// TODO(jba): future-proof this copy.
|
||||
params := &CallToolParamsFor[In]{
|
||||
Meta: req.Params.Meta,
|
||||
Name: req.Params.Name,
|
||||
Arguments: args,
|
||||
}
|
||||
// TODO(jba): improve copy
|
||||
res, err := h(ctx, &ServerRequest[*CallToolParamsFor[In]]{
|
||||
Session: req.Session,
|
||||
Params: params,
|
||||
Extra: req.Extra,
|
||||
})
|
||||
// Handle server errors appropriately:
|
||||
// - 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 {
|
||||
// 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{
|
||||
Content: []Content{&TextContent{Text: err.Error()}},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
var ctr CallToolResult
|
||||
// TODO(jba): What if res == nil? Is that valid?
|
||||
// TODO(jba): if t.OutputSchema != nil, check that StructuredContent is present and validates.
|
||||
if res != nil {
|
||||
// TODO(jba): future-proof this copy.
|
||||
ctr.Meta = res.Meta
|
||||
ctr.Content = res.Content
|
||||
ctr.IsError = res.IsError
|
||||
ctr.StructuredContent = res.StructuredContent
|
||||
}
|
||||
return &ctr, nil
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) error {
|
||||
var err error
|
||||
if *sfield == nil {
|
||||
*sfield, err = jsonschema.For[T](nil)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*rfield, err = (*sfield).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
|
||||
return err
|
||||
handler ToolHandler
|
||||
}
|
||||
|
||||
// unmarshalSchema unmarshals data into v and validates the result according to
|
||||
|
|
|
|||
|
|
@ -13,91 +13,10 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
)
|
||||
|
||||
// testToolHandler is used for type inference in TestNewServerTool.
|
||||
func testToolHandler[In, Out any](context.Context, *ServerRequest[*CallToolParamsFor[In]]) (*CallToolResultFor[Out], error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func srvTool[In, Out any](t *testing.T, tool *Tool, handler ToolHandlerFor[In, Out]) *serverTool {
|
||||
t.Helper()
|
||||
st, err := newServerTool(tool, handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestNewServerTool(t *testing.T) {
|
||||
type (
|
||||
Name struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
Size struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
)
|
||||
|
||||
nameSchema := &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
|
||||
}
|
||||
sizeSchema := &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Required: []string{"size"},
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"size": {Type: "integer"},
|
||||
},
|
||||
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
tool *serverTool
|
||||
wantIn, wantOut *jsonschema.Schema
|
||||
}{
|
||||
{
|
||||
srvTool(t, &Tool{Name: "basic"}, testToolHandler[Name, Size]),
|
||||
nameSchema,
|
||||
sizeSchema,
|
||||
},
|
||||
{
|
||||
srvTool(t, &Tool{
|
||||
Name: "in untouched",
|
||||
InputSchema: &jsonschema.Schema{},
|
||||
}, testToolHandler[Name, Size]),
|
||||
&jsonschema.Schema{},
|
||||
sizeSchema,
|
||||
},
|
||||
{
|
||||
srvTool(t, &Tool{Name: "out untouched", OutputSchema: &jsonschema.Schema{}}, testToolHandler[Name, Size]),
|
||||
nameSchema,
|
||||
&jsonschema.Schema{},
|
||||
},
|
||||
{
|
||||
srvTool(t, &Tool{Name: "nil out"}, testToolHandler[Name, any]),
|
||||
nameSchema,
|
||||
nil,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if diff := cmp.Diff(test.wantIn, test.tool.tool.InputSchema, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
|
||||
t.Errorf("newServerTool(%q) input schema mismatch (-want +got):\n%s", test.tool.tool.Name, diff)
|
||||
}
|
||||
if diff := cmp.Diff(test.wantOut, test.tool.tool.OutputSchema, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
|
||||
t.Errorf("newServerTool(%q) output schema mismatch (-want +got):\n%s", test.tool.tool.Name, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalSchema(t *testing.T) {
|
||||
schema := &jsonschema.Schema{
|
||||
Type: "object",
|
||||
|
|
@ -142,16 +61,16 @@ func TestToolErrorHandling(t *testing.T) {
|
|||
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{
|
||||
structuredErrorHandler := func(ctx context.Context, req *ServerRequest[*CallToolParams], args map[string]any) (*CallToolResult, any, error) {
|
||||
return nil, 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")
|
||||
regularErrorHandler := func(ctx context.Context, req *ServerRequest[*CallToolParams], args map[string]any) (*CallToolResult, any, error) {
|
||||
return nil, nil, fmt.Errorf("tool execution failed")
|
||||
}
|
||||
|
||||
AddTool(server, &Tool{Name: "error_tool", Description: "returns structured error"}, structuredErrorHandler)
|
||||
|
|
@ -201,7 +120,6 @@ func TestToolErrorHandling(t *testing.T) {
|
|||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue