Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.23.0

require (
github.com/google/go-cmp v0.7.0
github.com/google/jsonschema-go v0.2.2
github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2
github.com/yosida95/uritemplate/v3 v3.0.2
golang.org/x/tools v0.34.0
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.2.2 h1:qb9KM/pATIqIPuE9gEDwPsco8HHCTlA88IGFYHDl03A=
github.com/google/jsonschema-go v0.2.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 h1:IIj7X4SH1HKy0WfPR4nNEj4dhIJWGdXM5YoBAbfpdoo=
github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
Expand Down
43 changes: 39 additions & 4 deletions mcp/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import (
"strings"
"testing"
"testing/synctest"
"time"

"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"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"golang.org/x/tools/txtar"
Expand Down Expand Up @@ -97,16 +99,40 @@ func TestServerConformance(t *testing.T) {
}
}

type input struct {
type structuredInput struct {
In string `jsonschema:"the input"`
}

type output struct {
type structuredOutput struct {
Out string `jsonschema:"the output"`
}

func structuredTool(ctx context.Context, req *CallToolRequest, args *input) (*CallToolResult, *output, error) {
return nil, &output{"Ack " + args.In}, nil
func structuredTool(ctx context.Context, req *CallToolRequest, args *structuredInput) (*CallToolResult, *structuredOutput, error) {
return nil, &structuredOutput{"Ack " + args.In}, nil
}

type tomorrowInput struct {
Now time.Time
}

type tomorrowOutput struct {
Tomorrow time.Time
}

func tomorrowTool(ctx context.Context, req *CallToolRequest, args tomorrowInput) (*CallToolResult, tomorrowOutput, error) {
return nil, tomorrowOutput{args.Now.Add(24 * time.Hour)}, nil
}

type incInput struct {
X int `json:"x,omitempty"`
}

type incOutput struct {
Y int `json:"y"`
}

func incTool(_ context.Context, _ *CallToolRequest, args incInput) (*CallToolResult, incOutput, error) {
return nil, incOutput{args.X + 1}, nil
}

// runServerTest runs the server conformance test.
Expand All @@ -124,6 +150,15 @@ func runServerTest(t *testing.T, test *conformanceTest) {
}, sayHi)
case "structured":
AddTool(s, &Tool{Name: "structured"}, structuredTool)
case "tomorrow":
AddTool(s, &Tool{Name: "tomorrow"}, tomorrowTool)
case "inc":
inSchema, err := jsonschema.For[incInput](nil)
if err != nil {
t.Fatal(err)
}
inSchema.Properties["x"].Default = json.RawMessage(`6`)
AddTool(s, &Tool{Name: "inc", InputSchema: inSchema}, incTool)
default:
t.Fatalf("unknown tool %q", tn)
}
Expand Down
6 changes: 3 additions & 3 deletions mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func TestEndToEnd(t *testing.T) {
// ListTools is tested in client_list_test.go.
gotHi, err := cs.CallTool(ctx, &CallToolParams{
Name: "greet",
Arguments: map[string]any{"name": "user"},
Arguments: map[string]any{"Name": "user"},
})
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -648,7 +648,7 @@ func TestServerClosing(t *testing.T) {
}()
if _, err := cs.CallTool(ctx, &CallToolParams{
Name: "greet",
Arguments: map[string]any{"name": "user"},
Arguments: map[string]any{"Name": "user"},
}); err != nil {
t.Fatalf("after connecting: %v", err)
}
Expand Down Expand Up @@ -1646,7 +1646,7 @@ var testImpl = &Implementation{Name: "test", Version: "v1.0.0"}
// If anyone asks, we can add an option that controls how pointers are treated.
func TestPointerArgEquivalence(t *testing.T) {
type input struct {
In string
In string `json:",omitempty"`
}
type output struct {
Out string
Expand Down
7 changes: 7 additions & 0 deletions mcp/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ type CallToolResult struct {
IsError bool `json:"isError,omitempty"`
}

// TODO(#64): consider exposing setError (and getError), by adding an error
// field on CallToolResult.
func (r *CallToolResult) setError(err error) {
r.Content = []Content{&TextContent{Text: err.Error()}}
r.IsError = true
}

func (*CallToolResult) isResult() {}

// UnmarshalJSON handles the unmarshalling of content into the Content
Expand Down
45 changes: 29 additions & 16 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) {
func() bool { s.tools.add(st); return true })
}

// TODO(v0.3.0): test
func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) {
tt := *t

Expand Down Expand Up @@ -221,11 +220,23 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
}

th := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
var input json.RawMessage
if req.Params.Arguments != nil {
input = req.Params.Arguments
}
// Validate input and apply defaults.
var err error
input, err = applySchema(input, inputResolved)
if err != nil {
// TODO(#450): should this be considered a tool error? (and similar below)
return nil, fmt.Errorf("%w: validating \"arguments\": %v", jsonrpc2.ErrInvalidParams, err)
}

// Unmarshal and validate args.
var in In
if req.Params.Arguments != nil {
if err := unmarshalSchema(req.Params.Arguments, inputResolved, &in); err != nil {
return nil, err
if input != nil {
if err := json.Unmarshal(input, &in); err != nil {
return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err)
}
}

Expand All @@ -241,22 +252,15 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
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
}

// Validate output schema, if any.
// Skip if out is nil: we've removed "null" from the output schema, so nil won't validate.
if v := reflect.ValueOf(out); v.Kind() == reflect.Pointer && v.IsNil() {
} else if err := validateSchema(outputResolved, &out); err != nil {
return nil, fmt.Errorf("tool output: %w", err)
var errRes CallToolResult
errRes.setError(err)
return &errRes, nil
}

if res == nil {
res = &CallToolResult{}
}

// Marshal the output and put the RawMessage in the StructuredContent field.
var outval any = out
if elemZero != nil {
Expand All @@ -272,7 +276,16 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
if err != nil {
return nil, fmt.Errorf("marshaling output: %w", err)
}
res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire
outJSON := json.RawMessage(outbytes)
// Validate the output JSON, and apply defaults.
//
// We validate against the JSON, rather than the output value, as
// some types may have custom JSON marshalling (issue #447).
outJSON, err = applySchema(outJSON, outputResolved)
if err != nil {
return nil, fmt.Errorf("validating tool output: %w", err)
}
res.StructuredContent = outJSON // avoid a second marshal over the wire

// If the Content field isn't being used, return the serialized JSON in a
// TextContent block, as the spec suggests:
Expand Down
53 changes: 34 additions & 19 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"log"
"slices"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -491,7 +492,7 @@ func TestAddTool(t *testing.T) {

type schema = jsonschema.Schema

func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErr bool) {
func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErrContaining string) {
t.Helper()
th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) {
return nil, out, nil
Expand All @@ -513,34 +514,48 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out
}
_, err = goth(context.Background(), ctr)

if gotErr := err != nil; gotErr != wantErr {
t.Errorf("got error: %t, want error: %t", gotErr, wantErr)
if wantErrContaining != "" {
if err == nil {
t.Errorf("got nil error, want error containing %q", wantErrContaining)
} else {
if !strings.Contains(err.Error(), wantErrContaining) {
t.Errorf("got error %q, want containing %q", err, wantErrContaining)
}
}
} else if err != nil {
t.Errorf("got error %v, want no error", err)
}
}

func TestToolForSchemas(t *testing.T) {
// Validate that ToolFor handles schemas properly.
// Validate that toolForErr handles schemas properly.
type in struct {
P int `json:"p,omitempty"`
}
type out struct {
B bool `json:"b,omitempty"`
}

var (
falseSchema = &schema{Not: &schema{}}
inSchema = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"p": {Type: "integer"}}}
inSchema2 = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"p": {Type: "string"}}}
outSchema = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"b": {Type: "boolean"}}}
outSchema2 = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"b": {Type: "integer"}}}
)

// Infer both schemas.
testToolForSchema[int](t, &Tool{}, "3", true,
&schema{Type: "integer"}, &schema{Type: "boolean"}, false)
testToolForSchema[in](t, &Tool{}, `{"p":3}`, out{true}, inSchema, outSchema, "")
// Validate the input schema: expect an error if it's wrong.
// We can't test that the output schema is validated, because it's typed.
testToolForSchema[int](t, &Tool{}, `"x"`, true,
&schema{Type: "integer"}, &schema{Type: "boolean"}, true)

testToolForSchema[in](t, &Tool{}, `{"p":"x"}`, out{true}, inSchema, outSchema, `want "integer"`)
// Ignore type any for output.
testToolForSchema[int, any](t, &Tool{}, "3", 0,
&schema{Type: "integer"}, nil, false)
testToolForSchema[in, any](t, &Tool{}, `{"p":3}`, 0, inSchema, nil, "")
// Input is still validated.
testToolForSchema[int, any](t, &Tool{}, `"x"`, 0,
&schema{Type: "integer"}, nil, true)

testToolForSchema[in, any](t, &Tool{}, `{"p":"x"}`, 0, inSchema, nil, `want "integer"`)
// Tool sets input schema: that is what's used.
testToolForSchema[int, any](t, &Tool{InputSchema: &schema{Type: "string"}}, "3", 0,
&schema{Type: "string"}, nil, true) // error: 3 is not a string

testToolForSchema[in, any](t, &Tool{InputSchema: inSchema2}, `{"p":3}`, 0, inSchema2, nil, `want "string"`)
// Tool sets output schema: that is what's used, and validation happens.
testToolForSchema[string, any](t, &Tool{OutputSchema: &schema{Type: "integer"}}, "3", "x",
&schema{Type: "string"}, &schema{Type: "integer"}, true) // error: "x" is not an integer
testToolForSchema[in, any](t, &Tool{OutputSchema: outSchema2}, `{"p":3}`, out{true},
inSchema, outSchema2, `want "integer"`)
}
3 changes: 2 additions & 1 deletion mcp/sse_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (
)

type AddParams struct {
X, Y int
X int `json:"x"`
Y int `json:"y"`
}

func Add(ctx context.Context, req *mcp.CallToolRequest, args AddParams) (*mcp.CallToolResult, any, error) {
Expand Down
5 changes: 3 additions & 2 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func TestStreamableTransports(t *testing.T) {
// The "greet" tool should just work.
params := &CallToolParams{
Name: "greet",
Arguments: map[string]any{"name": "foo"},
Arguments: map[string]any{"Name": "foo"},
}
got, err := session.CallTool(ctx, params)
if err != nil {
Expand Down Expand Up @@ -239,10 +239,11 @@ func TestStreamableServerShutdown(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer clientSession.Close()

params := &CallToolParams{
Name: "greet",
Arguments: map[string]any{"name": "foo"},
Arguments: map[string]any{"Name": "foo"},
}
// Verify that we can call a tool.
if _, err := clientSession.CallTool(ctx, params); err != nil {
Expand Down
Loading