mcp: new API for features (#88)

BREAKING CHANGE

Change the API for adding tools, prompts, resources and resource
templates to a server.

The ServerXXX types are gone along with the plural Add methods
(AddTools, AddPrompts etc.)
Instead there are single Add methods (AddTool, AddPrompt).
In addition, instead of NewServerTool, there is AddTool[In, Out].

Fixes #73.
This commit is contained in:
Jonathan Amsterdam 2025-07-07 14:54:52 -04:00 • committed by GitHub
commit aebd244981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 366 additions and 612 deletions

View file

@ -22,12 +22,12 @@ func TestList(t *testing.T) {
defer serverSession.Close()
t.Run("tools", func(t *testing.T) {
toolA := mcp.NewServerTool("apple", "apple tool", SayHi)
toolB := mcp.NewServerTool("banana", "banana tool", SayHi)
toolC := mcp.NewServerTool("cherry", "cherry tool", SayHi)
tools := []*mcp.ServerTool{toolA, toolB, toolC}
wantTools := []*mcp.Tool{toolA.Tool, toolB.Tool, toolC.Tool}
server.AddTools(tools...)
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)
}
t.Run("list", func(t *testing.T) {
res, err := clientSession.ListTools(ctx, nil)
if err != nil {
@ -43,12 +43,13 @@ func TestList(t *testing.T) {
})
t.Run("resources", func(t *testing.T) {
resourceA := &mcp.ServerResource{Resource: &mcp.Resource{URI: "http://apple"}}
resourceB := &mcp.ServerResource{Resource: &mcp.Resource{URI: "http://banana"}}
resourceC := &mcp.ServerResource{Resource: &mcp.Resource{URI: "http://cherry"}}
wantResources := []*mcp.Resource{resourceA.Resource, resourceB.Resource, resourceC.Resource}
resources := []*mcp.ServerResource{resourceA, resourceB, resourceC}
server.AddResources(resources...)
var wantResources []*mcp.Resource
for _, name := range []string{"apple", "banana", "cherry"} {
r := &mcp.Resource{URI: "http://" + name}
wantResources = append(wantResources, r)
server.AddResource(r, nil)
}
t.Run("list", func(t *testing.T) {
res, err := clientSession.ListResources(ctx, nil)
if err != nil {
@ -64,15 +65,12 @@ func TestList(t *testing.T) {
})
t.Run("templates", func(t *testing.T) {
resourceTmplA := &mcp.ServerResourceTemplate{ResourceTemplate: &mcp.ResourceTemplate{URITemplate: "http://apple/{x}"}}
resourceTmplB := &mcp.ServerResourceTemplate{ResourceTemplate: &mcp.ResourceTemplate{URITemplate: "http://banana/{x}"}}
resourceTmplC := &mcp.ServerResourceTemplate{ResourceTemplate: &mcp.ResourceTemplate{URITemplate: "http://cherry/{x}"}}
wantResourceTemplates := []*mcp.ResourceTemplate{
resourceTmplA.ResourceTemplate, resourceTmplB.ResourceTemplate,
resourceTmplC.ResourceTemplate,
var wantResourceTemplates []*mcp.ResourceTemplate
for _, name := range []string{"apple", "banana", "cherry"} {
rt := &mcp.ResourceTemplate{URITemplate: "http://" + name + "/{x}"}
wantResourceTemplates = append(wantResourceTemplates, rt)
server.AddResourceTemplate(rt, nil)
}
resourceTemplates := []*mcp.ServerResourceTemplate{resourceTmplA, resourceTmplB, resourceTmplC}
server.AddResourceTemplates(resourceTemplates...)
t.Run("list", func(t *testing.T) {
res, err := clientSession.ListResourceTemplates(ctx, nil)
if err != nil {
@ -88,12 +86,12 @@ func TestList(t *testing.T) {
})
t.Run("prompts", func(t *testing.T) {
promptA := newServerPrompt("apple", "apple prompt")
promptB := newServerPrompt("banana", "banana prompt")
promptC := newServerPrompt("cherry", "cherry prompt")
wantPrompts := []*mcp.Prompt{promptA.Prompt, promptB.Prompt, promptC.Prompt}
prompts := []*mcp.ServerPrompt{promptA, promptB, promptC}
server.AddPrompts(prompts...)
var wantPrompts []*mcp.Prompt
for _, name := range []string{"apple", "banana", "cherry"} {
p := &mcp.Prompt{Name: name, Description: name + " prompt"}
wantPrompts = append(wantPrompts, p)
server.AddPrompt(p, testPromptHandler)
}
t.Run("list", func(t *testing.T) {
res, err := clientSession.ListPrompts(ctx, nil)
if err != nil {
@ -123,14 +121,6 @@ func testIterator[T any](ctx context.Context, t *testing.T, seq iter.Seq2[*T, er
}
}
// testPromptHandler is used for type inference newServerPrompt.
func testPromptHandler(context.Context, *mcp.ServerSession, *mcp.GetPromptParams) (*mcp.GetPromptResult, error) {
panic("not implemented")
}
func newServerPrompt(name, desc string) *mcp.ServerPrompt {
return &mcp.ServerPrompt{
Prompt: &mcp.Prompt{Name: name, Description: desc},
Handler: testPromptHandler,
}
}

View file

@ -31,8 +31,7 @@ func runServer() {
ctx := context.Background()
server := mcp.NewServer("greeter", "v0.0.1", nil)
server.AddTools(mcp.NewServerTool("greet", "say hi", SayHi))
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
if err := server.Run(ctx, mcp.NewStdioTransport()); err != nil {
log.Fatal(err)
}

View file

@ -27,9 +27,9 @@ func SayHi(ctx context.Context, cc *ServerSession, params *CallToolParamsFor[Say
}
func TestFeatureSetOrder(t *testing.T) {
toolA := NewServerTool("apple", "apple tool", SayHi).Tool
toolB := NewServerTool("banana", "banana tool", SayHi).Tool
toolC := NewServerTool("cherry", "cherry tool", SayHi).Tool
toolA := &Tool{Name: "apple", Description: "apple tool"}
toolB := &Tool{Name: "banana", Description: "banana tool"}
toolC := &Tool{Name: "cherry", Description: "cherry tool"}
testCases := []struct {
tools []*Tool
@ -52,9 +52,9 @@ func TestFeatureSetOrder(t *testing.T) {
}
func TestFeatureSetAbove(t *testing.T) {
toolA := NewServerTool("apple", "apple tool", SayHi).Tool
toolB := NewServerTool("banana", "banana tool", SayHi).Tool
toolC := NewServerTool("cherry", "cherry tool", SayHi).Tool
toolA := &Tool{Name: "apple", Description: "apple tool"}
toolB := &Tool{Name: "banana", Description: "banana tool"}
toolC := &Tool{Name: "cherry", Description: "cherry tool"}
testCases := []struct {
tools []*Tool

View file

@ -21,7 +21,6 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/jsonschema"
)
@ -30,6 +29,9 @@ type hiParams struct {
Name string
}
// 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, ss *ServerSession, params *CallToolParamsFor[hiParams]) (*CallToolResultFor[any], error) {
if err := ss.Ping(ctx, nil); err != nil {
return nil, fmt.Errorf("ping failed: %v", err)
@ -63,9 +65,31 @@ func TestEndToEnd(t *testing.T) {
},
}
s := NewServer("testServer", "v1.0.0", sopts)
add(tools, s.AddTools, "greet", "fail")
add(prompts, s.AddPrompts, "code_review", "fail")
add(resources, s.AddResources, "info.txt", "fail.txt")
AddTool(s, &Tool{
Name: "greet",
Description: "say hi",
}, sayHi)
s.AddTool(&Tool{Name: "fail", InputSchema: &jsonschema.Schema{}},
func(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error) {
return nil, errTestFailure
})
s.AddPrompt(&Prompt{
Name: "code_review",
Description: "do a code review",
Arguments: []*PromptArgument{{Name: "Code", Required: true}},
}, func(_ context.Context, _ *ServerSession, params *GetPromptParams) (*GetPromptResult, error) {
return &GetPromptResult{
Description: "Code review prompt",
Messages: []*PromptMessage{
{Role: "user", Content: &TextContent{Text: "Please review the following code: " + params.Arguments["Code"]}},
},
}, nil
})
s.AddPrompt(&Prompt{Name: "fail"}, func(_ context.Context, _ *ServerSession, _ *GetPromptParams) (*GetPromptResult, error) {
return nil, errTestFailure
})
s.AddResource(resource1, readHandler)
s.AddResource(resource2, readHandler)
// Connect the server.
ss, err := s.Connect(ctx, st)
@ -154,39 +178,14 @@ func TestEndToEnd(t *testing.T) {
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, errTestFailure)
}
s.AddPrompts(&ServerPrompt{Prompt: &Prompt{Name: "T"}})
s.AddPrompt(&Prompt{Name: "T"}, nil)
waitForNotification(t, "prompts")
s.RemovePrompts("T")
waitForNotification(t, "prompts")
})
t.Run("tools", func(t *testing.T) {
res, err := cs.ListTools(ctx, nil)
if err != nil {
t.Errorf("tools/list failed: %v", err)
}
wantTools := []*Tool{
{
Name: "fail",
InputSchema: nil,
},
{
Name: "greet",
Description: "say hi",
InputSchema: &jsonschema.Schema{
Type: "object",
Required: []string{"Name"},
Properties: map[string]*jsonschema.Schema{
"Name": {Type: "string"},
},
AdditionalProperties: falseSchema(),
},
},
}
if diff := cmp.Diff(wantTools, res.Tools, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
t.Fatalf("tools/list mismatch (-want +got):\n%s", diff)
}
// ListTools is tested in client_list_test.go.
gotHi, err := cs.CallTool(ctx, &CallToolParams{
Name: "greet",
Arguments: map[string]any{"name": "user"},
@ -222,7 +221,7 @@ func TestEndToEnd(t *testing.T) {
t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff)
}
s.AddTools(&ServerTool{Tool: &Tool{Name: "T"}, Handler: nopHandler})
s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{}}, nopHandler)
waitForNotification(t, "tools")
s.RemoveTools("T")
waitForNotification(t, "tools")
@ -246,8 +245,7 @@ func TestEndToEnd(t *testing.T) {
MIMEType: "text/template",
URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/'
}
st := &ServerResourceTemplate{ResourceTemplate: template, Handler: readHandler}
s.AddResourceTemplates(st)
s.AddResourceTemplate(template, readHandler)
tres, err := cs.ListResourceTemplates(ctx, nil)
if err != nil {
t.Fatal(err)
@ -292,7 +290,7 @@ func TestEndToEnd(t *testing.T) {
}
}
s.AddResources(&ServerResource{Resource: &Resource{URI: "http://U"}})
s.AddResource(&Resource{URI: "http://U"}, nil)
waitForNotification(t, "resources")
s.RemoveResources("http://U")
waitForNotification(t, "resources")
@ -434,40 +432,6 @@ func TestEndToEnd(t *testing.T) {
var (
errTestFailure = errors.New("mcp failure")
tools = map[string]*ServerTool{
"greet": NewServerTool("greet", "say hi", sayHi),
"fail": {
Tool: &Tool{Name: "fail"},
Handler: func(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error) {
return nil, errTestFailure
},
},
}
prompts = map[string]*ServerPrompt{
"code_review": {
Prompt: &Prompt{
Name: "code_review",
Description: "do a code review",
Arguments: []*PromptArgument{{Name: "Code", Required: true}},
},
Handler: func(_ context.Context, _ *ServerSession, params *GetPromptParams) (*GetPromptResult, error) {
return &GetPromptResult{
Description: "Code review prompt",
Messages: []*PromptMessage{
{Role: "user", Content: &TextContent{Text: "Please review the following code: " + params.Arguments["Code"]}},
},
}, nil
},
},
"fail": {
Prompt: &Prompt{Name: "fail"},
Handler: func(_ context.Context, _ *ServerSession, _ *GetPromptParams) (*GetPromptResult, error) {
return nil, errTestFailure
},
},
}
resource1 = &Resource{
Name: "public",
MIMEType: "text/plain",
@ -484,11 +448,6 @@ var (
URI: "embedded:info",
}
readHandler = fileResourceHandler("testdata/files")
resources = map[string]*ServerResource{
"info.txt": {resource1, readHandler},
"fail.txt": {resource2, readHandler},
"info": {resource3, handleEmbeddedResource},
}
)
var embeddedResources = map[string]string{
@ -540,21 +499,21 @@ func errorCode(err error) int64 {
return -1
}
// basicConnection returns a new basic client-server connection configured with
// the provided tools.
// basicConnection returns a new basic client-server connection, with the server
// configured via the provided function.
//
// The caller should cancel either the client connection or server connection
// when the connections are no longer needed.
func basicConnection(t *testing.T, tools ...*ServerTool) (*ServerSession, *ClientSession) {
func basicConnection(t *testing.T, config func(*Server)) (*ServerSession, *ClientSession) {
t.Helper()
ctx := context.Background()
ct, st := NewInMemoryTransports()
s := NewServer("testServer", "v1.0.0", nil)
// The 'greet' tool says hi.
s.AddTools(tools...)
if config != nil {
config(s)
}
ss, err := s.Connect(ctx, st)
if err != nil {
t.Fatal(err)
@ -569,7 +528,9 @@ func basicConnection(t *testing.T, tools ...*ServerTool) (*ServerSession, *Clien
}
func TestServerClosing(t *testing.T) {
cc, cs := basicConnection(t, NewServerTool("greet", "say hi", sayHi))
cc, cs := basicConnection(t, func(s *Server) {
AddTool(s, greetTool(), sayHi)
})
defer cs.Close()
ctx := context.Background()
@ -651,11 +612,9 @@ func TestCancellation(t *testing.T) {
}
return nil, nil
}
st := &ServerTool{
Tool: &Tool{Name: "slow"},
Handler: slowRequest,
}
_, cs := basicConnection(t, st)
_, cs := basicConnection(t, func(s *Server) {
s.AddTool(&Tool{Name: "slow"}, slowRequest)
})
defer cs.Close()
ctx, cancel := context.WithCancel(context.Background())
@ -852,7 +811,7 @@ func TestKeepAlive(t *testing.T) {
KeepAlive: 100 * time.Millisecond,
}
s := NewServer("testServer", "v1.0.0", serverOpts)
s.AddTools(NewServerTool("greet", "say hi", sayHi))
AddTool(s, greetTool(), sayHi)
ss, err := s.Connect(ctx, st)
if err != nil {
@ -897,7 +856,7 @@ func TestKeepAliveFailure(t *testing.T) {
// Server without keepalive (to test one-sided keepalive)
s := NewServer("testServer", "v1.0.0", nil)
s.AddTools(NewServerTool("greet", "say hi", sayHi))
AddTool(s, greetTool(), sayHi)
ss, err := s.Connect(ctx, st)
if err != nil {
t.Fatal(err)

View file

@ -11,8 +11,7 @@ import (
// A PromptHandler handles a call to prompts/get.
type PromptHandler func(context.Context, *ServerSession, *GetPromptParams) (*GetPromptResult, error)
// A Prompt is a prompt definition bound to a prompt handler.
type ServerPrompt struct {
Prompt *Prompt
Handler PromptHandler
type serverPrompt struct {
prompt *Prompt
handler PromptHandler
}

View file

@ -20,16 +20,16 @@ import (
"github.com/modelcontextprotocol/go-sdk/internal/util"
)
// A ServerResource associates a Resource with its handler.
type ServerResource struct {
Resource *Resource
Handler ResourceHandler
// A serverResource associates a Resource with its handler.
type serverResource struct {
resource *Resource
handler ResourceHandler
}
// A ServerResourceTemplate associates a ResourceTemplate with its handler.
type ServerResourceTemplate struct {
ResourceTemplate *ResourceTemplate
Handler ResourceHandler
// A serverResourceTemplate associates a ResourceTemplate with its handler.
type serverResourceTemplate struct {
resourceTemplate *ResourceTemplate
handler ResourceHandler
}
// A ResourceHandler is a function that reads a resource.
@ -156,8 +156,8 @@ func fileRoot(root *Root) (_ string, err error) {
// Matches reports whether the receiver's uri template matches the uri.
// TODO: use "github.com/yosida95/uritemplate/v3"
func (sr *ServerResourceTemplate) Matches(uri string) bool {
re, err := uriTemplateToRegexp(sr.ResourceTemplate.URITemplate)
func (sr *serverResourceTemplate) Matches(uri string) bool {
re, err := uriTemplateToRegexp(sr.resourceTemplate.URITemplate)
if err != nil {
return false
}

View file

@ -12,6 +12,7 @@ import (
"encoding/json"
"fmt"
"iter"
"log"
"net/url"
"path/filepath"
"slices"
@ -20,7 +21,6 @@ import (
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/jsonschema"
)
const DefaultPageSize = 1000
@ -36,10 +36,10 @@ type Server struct {
opts ServerOptions
mu sync.Mutex
prompts *featureSet[*ServerPrompt]
tools *featureSet[*ServerTool]
resources *featureSet[*ServerResource]
resourceTemplates *featureSet[*ServerResourceTemplate]
prompts *featureSet[*serverPrompt]
tools *featureSet[*serverTool]
resources *featureSet[*serverResource]
resourceTemplates *featureSet[*serverResourceTemplate]
sessions []*ServerSession
sendingMethodHandler_ MethodHandler[*ServerSession]
receivingMethodHandler_ MethodHandler[*ServerSession]
@ -87,28 +87,23 @@ func NewServer(name, version string, opts *ServerOptions) *Server {
name: name,
version: version,
opts: *opts,
prompts: newFeatureSet(func(p *ServerPrompt) string { return p.Prompt.Name }),
tools: newFeatureSet(func(t *ServerTool) string { return t.Tool.Name }),
resources: newFeatureSet(func(r *ServerResource) string { return r.Resource.URI }),
resourceTemplates: newFeatureSet(func(t *ServerResourceTemplate) string { return t.ResourceTemplate.URITemplate }),
prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }),
tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }),
resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }),
resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }),
sendingMethodHandler_: defaultSendingMethodHandler[*ServerSession],
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
}
}
// AddPrompts adds the given prompts to the server,
// replacing any with the same names.
func (s *Server) AddPrompts(prompts ...*ServerPrompt) {
// Only notify if something could change.
if len(prompts) == 0 {
return
}
// Assume there was a change, since add replaces existing roots.
// (It's possible a root was replaced with an identical one, but not worth checking.)
// AddPrompt adds a [Prompt] to the server, or replaces one with the same name.
func (s *Server) AddPrompt(p *Prompt, h PromptHandler) {
// Assume there was a change, since add replaces existing items.
// (It's possible an item was replaced with an identical one, but not worth checking.)
s.changeAndNotify(
notificationPromptListChanged,
&PromptListChangedParams{},
func() bool { s.prompts.add(prompts...); return true })
func() bool { s.prompts.add(&serverPrompt{p, h}); return true })
}
// RemovePrompts removes the prompts with the given names.
@ -118,55 +113,44 @@ func (s *Server) RemovePrompts(names ...string) {
func() bool { return s.prompts.remove(names...) })
}
// AddTools adds the given tools to the server,
// replacing any with the same names.
// The arguments must not be modified after this call.
//
// AddTools panics if errors are detected.
func (s *Server) AddTools(tools ...*ServerTool) {
if err := s.addToolsErr(tools...); err != nil {
// AddTool adds a [Tool] to the server, or replaces one with the same name.
// The tool's input schema must be non-nil.
// The Tool argument must not be modified after this call.
func (s *Server) AddTool(t *Tool, h ToolHandler) {
// TODO(jba): This is a breaking behavior change. Add before v0.2.0?
if t.InputSchema == nil {
log.Printf("mcp: tool %q has a nil input schema. This will panic in a future release.", t.Name)
// panic(fmt.Sprintf("adding tool %q: nil input schema", t.Name))
}
if err := addToolErr(s, t, h); err != nil {
panic(err)
}
}
// addToolsErr is like [AddTools], but returns an error instead of panicking.
func (s *Server) addToolsErr(tools ...*ServerTool) error {
// Only notify if something could change.
if len(tools) == 0 {
return nil
// 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)
}
// Wrap the user's Handlers with rawHandlers that take a json.RawMessage.
for _, st := range tools {
if st.rawHandler == nil {
// This ServerTool was not created with NewServerTool.
if st.Handler == nil {
return fmt.Errorf("AddTools: tool %q has no handler", st.Tool.Name)
}
st.rawHandler = newRawHandler(st)
// Resolve the schemas, with no base URI. We don't expect tool schemas to
// refer outside of themselves.
if st.Tool.InputSchema != nil {
r, err := st.Tool.InputSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
if err != nil {
return err
}
st.inputResolved = r
}
}
// if st.Tool.OutputSchema != nil {
// st.outputResolved, err := st.Tool.OutputSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
// if err != nil {
// return 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)
st, err := newServerTool(t, h)
if err != nil {
return err
}
// 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: surface notify error here?
// 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(tools...); return true })
func() bool { s.tools.add(st); return true })
return nil
}
@ -177,26 +161,19 @@ func (s *Server) RemoveTools(names ...string) {
func() bool { return s.tools.remove(names...) })
}
// AddResources adds the given resources to the server.
// If a resource with the same URI already exists, it is replaced.
// AddResources panics if a resource URI is invalid or not absolute (has an empty scheme).
func (s *Server) AddResources(resources ...*ServerResource) {
// Only notify if something could change.
if len(resources) == 0 {
return
}
// AddResource adds a [Resource] to the server, or replaces one with the same URI.
// AddResource panics if the resource URI is invalid or not absolute (has an empty scheme).
func (s *Server) AddResource(r *Resource, h ResourceHandler) {
s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{},
func() bool {
for _, r := range resources {
u, err := url.Parse(r.Resource.URI)
if err != nil {
panic(err) // url.Parse includes the URI in the error
}
if !u.IsAbs() {
panic(fmt.Errorf("URI %s needs a scheme", r.Resource.URI))
}
s.resources.add(r)
u, err := url.Parse(r.URI)
if err != nil {
panic(err) // url.Parse includes the URI in the error
}
if !u.IsAbs() {
panic(fmt.Errorf("URI %s needs a scheme", r.URI))
}
s.resources.add(&serverResource{r, h})
return true
})
}
@ -208,20 +185,13 @@ func (s *Server) RemoveResources(uris ...string) {
func() bool { return s.resources.remove(uris...) })
}
// AddResourceTemplates adds the given resource templates to the server.
// If a resource template with the same URI template already exists, it will be replaced.
// AddResourceTemplates panics if a URI template is invalid or not absolute (has an empty scheme).
func (s *Server) AddResourceTemplates(templates ...*ServerResourceTemplate) {
// Only notify if something could change.
if len(templates) == 0 {
return
}
// AddResourceTemplate adds a [ResourceTemplate] to the server, or replaces on with the same URI.
// AddResourceTemplate panics if a URI template is invalid or not absolute (has an empty scheme).
func (s *Server) AddResourceTemplate(t *ResourceTemplate, h ResourceHandler) {
s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{},
func() bool {
for _, t := range templates {
// TODO: check template validity.
s.resourceTemplates.add(t)
}
// TODO: check template validity.
s.resourceTemplates.add(&serverResourceTemplate{t, h})
return true
})
}
@ -268,10 +238,10 @@ func (s *Server) listPrompts(_ context.Context, _ *ServerSession, params *ListPr
if params == nil {
params = &ListPromptsParams{}
}
return paginateList(s.prompts, s.opts.PageSize, params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*ServerPrompt) {
return paginateList(s.prompts, s.opts.PageSize, params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) {
res.Prompts = []*Prompt{} // avoid JSON null
for _, p := range prompts {
res.Prompts = append(res.Prompts, p.Prompt)
res.Prompts = append(res.Prompts, p.prompt)
}
})
}
@ -284,7 +254,7 @@ func (s *Server) getPrompt(ctx context.Context, cc *ServerSession, params *GetPr
// TODO: surface the error code over the wire, instead of flattening it into the string.
return nil, fmt.Errorf("%s: unknown prompt %q", jsonrpc2.ErrInvalidParams, params.Name)
}
return prompt.Handler(ctx, cc, params)
return prompt.handler(ctx, cc, params)
}
func (s *Server) listTools(_ context.Context, _ *ServerSession, params *ListToolsParams) (*ListToolsResult, error) {
@ -293,22 +263,22 @@ func (s *Server) listTools(_ context.Context, _ *ServerSession, params *ListTool
if params == nil {
params = &ListToolsParams{}
}
return paginateList(s.tools, s.opts.PageSize, params, &ListToolsResult{}, func(res *ListToolsResult, tools []*ServerTool) {
return paginateList(s.tools, s.opts.PageSize, params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) {
res.Tools = []*Tool{} // avoid JSON null
for _, t := range tools {
res.Tools = append(res.Tools, t.Tool)
res.Tools = append(res.Tools, t.tool)
}
})
}
func (s *Server) callTool(ctx context.Context, cc *ServerSession, params *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error) {
s.mu.Lock()
tool, ok := s.tools.get(params.Name)
st, ok := s.tools.get(params.Name)
s.mu.Unlock()
if !ok {
return nil, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, params.Name)
}
return tool.rawHandler(ctx, cc, params)
return st.handler(ctx, cc, params)
}
func (s *Server) listResources(_ context.Context, _ *ServerSession, params *ListResourcesParams) (*ListResourcesResult, error) {
@ -317,10 +287,10 @@ func (s *Server) listResources(_ context.Context, _ *ServerSession, params *List
if params == nil {
params = &ListResourcesParams{}
}
return paginateList(s.resources, s.opts.PageSize, params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*ServerResource) {
return paginateList(s.resources, s.opts.PageSize, params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) {
res.Resources = []*Resource{} // avoid JSON null
for _, r := range resources {
res.Resources = append(res.Resources, r.Resource)
res.Resources = append(res.Resources, r.resource)
}
})
}
@ -332,10 +302,10 @@ func (s *Server) listResourceTemplates(_ context.Context, _ *ServerSession, para
params = &ListResourceTemplatesParams{}
}
return paginateList(s.resourceTemplates, s.opts.PageSize, params, &ListResourceTemplatesResult{},
func(res *ListResourceTemplatesResult, rts []*ServerResourceTemplate) {
func(res *ListResourceTemplatesResult, rts []*serverResourceTemplate) {
res.ResourceTemplates = []*ResourceTemplate{} // avoid JSON null
for _, rt := range rts {
res.ResourceTemplates = append(res.ResourceTemplates, rt.ResourceTemplate)
res.ResourceTemplates = append(res.ResourceTemplates, rt.resourceTemplate)
}
})
}
@ -376,12 +346,12 @@ func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, boo
defer s.mu.Unlock()
// Try resources first.
if r, ok := s.resources.get(uri); ok {
return r.Handler, r.Resource.MIMEType, true
return r.handler, r.resource.MIMEType, true
}
// Look for matching template.
for rt := range s.resourceTemplates.all() {
if rt.Matches(uri) {
return rt.Handler, rt.ResourceTemplate.MIMEType, true
return rt.handler, rt.resourceTemplate.MIMEType, true
}
}
return nil, "", false

View file

@ -29,7 +29,7 @@ func ExampleServer() {
clientTransport, serverTransport := mcp.NewInMemoryTransports()
server := mcp.NewServer("greeter", "v0.0.1", nil)
server.AddTools(mcp.NewServerTool("greet", "say hi", SayHi))
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
serverSession, err := server.Connect(ctx, serverTransport)
if err != nil {

View file

@ -12,7 +12,7 @@ import (
)
// TODO(jba): this shouldn't be in this file, but tool_test.go doesn't have access to unexported symbols.
func TestNewServerToolValidate(t *testing.T) {
func TestToolValidate(t *testing.T) {
// Check that the tool returned from NewServerTool properly validates its input schema.
type req struct {
@ -26,9 +26,10 @@ func TestNewServerToolValidate(t *testing.T) {
return nil, nil
}
tool := NewServerTool("test", "test", dummyHandler)
// Need to add the tool to a server to get resolved schemas.
// s := NewServer("", "", nil)
st, err := newServerTool(&Tool{Name: "test", Description: "test"}, dummyHandler)
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
desc string
@ -71,7 +72,7 @@ func TestNewServerToolValidate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
_, err = tool.rawHandler(context.Background(), nil,
_, err = st.handler(context.Background(), nil,
&CallToolParamsFor[json.RawMessage]{Arguments: json.RawMessage(raw)})
if err == nil && tt.want != "" {
t.Error("got success, wanted failure")

View file

@ -28,7 +28,7 @@ func Add(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsF
func ExampleSSEHandler() {
server := mcp.NewServer("adder", "v0.0.1", nil)
server.AddTools(mcp.NewServerTool("add", "add two numbers", Add))
mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add)
handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server })
httpServer := httptest.NewServer(handler)

View file

@ -20,7 +20,7 @@ func TestSSEServer(t *testing.T) {
t.Run(fmt.Sprintf("closeServerFirst=%t", closeServerFirst), func(t *testing.T) {
ctx := context.Background()
server := NewServer("testServer", "v1.0.0", nil)
server.AddTools(NewServerTool("greet", "say hi", sayHi))
AddTool(server, &Tool{Name: "greet"}, sayHi)
sseHandler := NewSSEHandler(func(*http.Request) *Server { return server })

View file

@ -32,8 +32,7 @@ func TestStreamableTransports(t *testing.T) {
// 1. Create a server with a simple "greet" tool.
server := NewServer("testServer", "v1.0.0", nil)
server.AddTools(NewServerTool("greet", "say hi", sayHi))
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
// 2. Start an httptest.Server with the StreamableHTTPHandler, wrapped in a
// cookie-checking middleware.
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
@ -323,13 +322,12 @@ func TestStreamableServerTransport(t *testing.T) {
// Create a server containing a single tool, which runs the test tool
// behavior, if any.
server := NewServer("testServer", "v1.0.0", nil)
tool := NewServerTool("tool", "test tool", func(ctx context.Context, ss *ServerSession, params *CallToolParamsFor[any]) (*CallToolResultFor[any], error) {
AddTool(server, &Tool{Name: "tool"}, func(ctx context.Context, ss *ServerSession, params *CallToolParamsFor[any]) (*CallToolResultFor[any], error) {
if test.tool != nil {
test.tool(t, ctx, ss)
}
return &CallToolResultFor[any]{}, nil
})
server.AddTools(tool)
// Start the streamable handler.
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)

View file

@ -9,7 +9,7 @@ import (
"context"
"encoding/json"
"fmt"
"slices"
"reflect"
"github.com/modelcontextprotocol/go-sdk/jsonschema"
)
@ -17,8 +17,7 @@ import (
// A ToolHandler handles a call to tools/call.
// [CallToolParams.Arguments] will contain a map[string]any that has been validated
// against the input schema.
// TODO: Perhaps this should be an alias for ToolHandlerFor[map[string]any, map[string]any]?
type ToolHandler func(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error)
type ToolHandler = ToolHandlerFor[map[string]any, any]
// A ToolHandlerFor handles a call to tools/call with typed arguments and results.
type ToolHandlerFor[In, Out any] func(context.Context, *ServerSession, *CallToolParamsFor[In]) (*CallToolResultFor[Out], error)
@ -26,62 +25,33 @@ type ToolHandlerFor[In, Out any] func(context.Context, *ServerSession, *CallTool
// A rawToolHandler is like a ToolHandler, but takes the arguments as as json.RawMessage.
type rawToolHandler = func(context.Context, *ServerSession, *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error)
// A ServerTool is a tool definition that is bound to a tool handler.
type ServerTool struct {
Tool *Tool
Handler ToolHandler
// Set in NewServerTool or Server.addToolsErr.
rawHandler rawToolHandler
// Resolved tool schemas. Set in Server.addToolsErr.
// 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 is a helper to make a tool using reflection on the given type parameters.
// When the tool is called, CallToolParams.Arguments will be of type In.
//
// If provided, variadic [ToolOption] values may be used to customize the tool.
//
// The input schema for the tool is extracted from the request type for the
// handler, and used to unmmarshal and validate requests to the handler. This
// schema may be customized using the [Input] option.
//
// TODO(jba): check that structured content is set in response.
func NewServerTool[In, Out any](name, description string, handler ToolHandlerFor[In, Out], opts ...ToolOption) *ServerTool {
st, err := newServerToolErr[In, Out](name, description, handler, opts...)
if err != nil {
panic(fmt.Errorf("NewServerTool(%q): %w", name, err))
}
return st
}
// 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}
func newServerToolErr[In, Out any](name, description string, handler ToolHandlerFor[In, Out], opts ...ToolOption) (*ServerTool, error) {
// TODO: check that In is a struct.
ischema, err := jsonschema.For[In]()
if err != nil {
if err := setSchema[In](&t.InputSchema, &st.inputResolved); err != nil {
return nil, err
}
// TODO: uncomment when output schemas drop.
// oschema, err := jsonschema.For[TRes]()
// if err != nil {
// return nil, err
// }
t := &ServerTool{
Tool: &Tool{
Name: name,
Description: description,
InputSchema: ischema,
// OutputSchema: oschema,
},
}
for _, opt := range opts {
opt.set(t)
if reflect.TypeFor[Out]() != reflect.TypeFor[any]() {
if err := setSchema[Out](&t.OutputSchema, &st.outputResolved); err != nil {
return nil, err
}
}
t.rawHandler = func(ctx context.Context, ss *ServerSession, rparams *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error) {
st.handler = func(ctx context.Context, ss *ServerSession, rparams *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error) {
var args In
if rparams.Arguments != nil {
if err := unmarshalSchema(rparams.Arguments, t.inputResolved, &args); err != nil {
if err := unmarshalSchema(rparams.Arguments, st.inputResolved, &args); err != nil {
return nil, err
}
}
@ -91,45 +61,7 @@ func newServerToolErr[In, Out any](name, description string, handler ToolHandler
Name: rparams.Name,
Arguments: args,
}
res, err := handler(ctx, ss, params)
if err != nil {
return nil, err
}
var ctr CallToolResult
if res != nil {
// TODO(jba): future-proof this copy.
ctr.Meta = res.Meta
ctr.Content = res.Content
ctr.IsError = res.IsError
}
return &ctr, nil
}
return t, nil
}
// newRawHandler creates a rawToolHandler for tools not created through NewServerTool.
// It unmarshals the arguments into a map[string]any and validates them against the
// schema, then calls the ServerTool's handler.
func newRawHandler(st *ServerTool) rawToolHandler {
if st.Handler == nil {
panic("st.Handler is nil")
}
return func(ctx context.Context, ss *ServerSession, rparams *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error) {
// Unmarshal the args into what should be a map.
var args map[string]any
if rparams.Arguments != nil {
if err := unmarshalSchema(rparams.Arguments, st.inputResolved, &args); err != nil {
return nil, err
}
}
// TODO: generate copy
params := &CallToolParamsFor[map[string]any]{
Meta: rparams.Meta,
Name: rparams.Name,
Arguments: args,
}
res, err := st.Handler(ctx, ss, params)
res, err := h(ctx, ss, params)
// TODO(rfindley): investigate why server errors are embedded in this strange way,
// rather than returned as jsonrpc2 server errors.
if err != nil {
@ -138,8 +70,32 @@ func newRawHandler(st *ServerTool) rawToolHandler {
IsError: true,
}, nil
}
return res, 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]()
}
if err != nil {
return err
}
*rfield, err = (*sfield).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
return err
}
// unmarshalSchema unmarshals data into v and validates the result according to
@ -169,105 +125,6 @@ func unmarshalSchema(data json.RawMessage, resolved *jsonschema.Resolved, v any)
return nil
}
// A ToolOption configures the behavior of a Tool.
type ToolOption interface {
set(*ServerTool)
}
type toolSetter func(*ServerTool)
func (s toolSetter) set(t *ServerTool) { s(t) }
// Input applies the provided [SchemaOption] configuration to the tool's input
// schema.
func Input(opts ...SchemaOption) ToolOption {
return toolSetter(func(t *ServerTool) {
for _, opt := range opts {
opt.set(t.Tool.InputSchema)
}
})
}
// A SchemaOption configures a jsonschema.Schema.
type SchemaOption interface {
set(s *jsonschema.Schema)
}
type schemaSetter func(*jsonschema.Schema)
func (s schemaSetter) set(schema *jsonschema.Schema) { s(schema) }
// Property configures the schema for the property of the given name.
// If there is no such property in the schema, it is created.
func Property(name string, opts ...SchemaOption) SchemaOption {
return schemaSetter(func(schema *jsonschema.Schema) {
propSchema, ok := schema.Properties[name]
if !ok {
propSchema = new(jsonschema.Schema)
schema.Properties[name] = propSchema
}
// Apply the options, with special handling for Required, as it needs to be
// set on the parent schema.
for _, opt := range opts {
if req, ok := opt.(required); ok {
if req {
if !slices.Contains(schema.Required, name) {
schema.Required = append(schema.Required, name)
}
} else {
schema.Required = slices.DeleteFunc(schema.Required, func(s string) bool {
return s == name
})
}
} else {
opt.set(propSchema)
}
}
})
}
// Required sets whether the associated property is required. It is only valid
// when used in a [Property] option: using Required outside of Property panics.
func Required(v bool) SchemaOption {
return required(v)
}
// required must be a distinguished type as it needs special handling to mutate
// the parent schema, and to mutate prompt arguments.
type required bool
func (required) set(s *jsonschema.Schema) {
panic("use of required outside of Property")
}
// Enum sets the provided values as the "enum" value of the schema.
func Enum(values ...any) SchemaOption {
return schemaSetter(func(s *jsonschema.Schema) {
s.Enum = values
})
}
// Description sets the provided schema description.
func Description(desc string) SchemaOption {
return description(desc)
}
// description must be a distinguished type so that it can be handled by prompt
// options.
type description string
func (d description) set(s *jsonschema.Schema) {
s.Description = string(d)
}
// Schema overrides the inferred schema with a shallow copy of the given
// schema.
func Schema(schema *jsonschema.Schema) SchemaOption {
return schemaSetter(func(s *jsonschema.Schema) {
*s = *schema
})
}
// schemaJSON returns the JSON value for s as a string, or a string indicating an error.
func schemaJSON(s *jsonschema.Schema) string {
m, err := json.Marshal(s)

View file

@ -16,76 +16,80 @@ import (
)
// testToolHandler is used for type inference in TestNewServerTool.
func testToolHandler[T any](context.Context, *ServerSession, *CallToolParamsFor[T]) (*CallToolResultFor[any], error) {
func testToolHandler[In, Out any](context.Context, *ServerSession, *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
want *jsonschema.Schema
tool *serverTool
wantIn, wantOut *jsonschema.Schema
}{
{
NewServerTool("basic", "", testToolHandler[struct {
Name string `json:"name"`
}]),
&jsonschema.Schema{
Type: "object",
Required: []string{"name"},
Properties: map[string]*jsonschema.Schema{
"name": {Type: "string"},
},
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
},
srvTool(t, &Tool{Name: "basic"}, testToolHandler[Name, Size]),
nameSchema,
sizeSchema,
},
{
NewServerTool("enum", "", testToolHandler[struct{ Name string }], Input(
Property("Name", Enum("x", "y", "z")),
)),
&jsonschema.Schema{
Type: "object",
Required: []string{"Name"},
Properties: map[string]*jsonschema.Schema{
"Name": {Type: "string", Enum: []any{"x", "y", "z"}},
},
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
},
srvTool(t, &Tool{
Name: "in untouched",
InputSchema: &jsonschema.Schema{},
}, testToolHandler[Name, Size]),
&jsonschema.Schema{},
sizeSchema,
},
{
NewServerTool("required", "", testToolHandler[struct {
Name string `json:"name"`
Language string `json:"language"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
}], Input(
Property("x", Required(true)))),
&jsonschema.Schema{
Type: "object",
Required: []string{"name", "language", "x"},
Properties: map[string]*jsonschema.Schema{
"language": {Type: "string"},
"name": {Type: "string"},
"x": {Type: "integer"},
"y": {Type: "integer"},
},
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
},
srvTool(t, &Tool{Name: "out untouched", OutputSchema: &jsonschema.Schema{}}, testToolHandler[Name, Size]),
nameSchema,
&jsonschema.Schema{},
},
{
NewServerTool("set_schema", "", testToolHandler[struct {
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
}], Input(
Schema(&jsonschema.Schema{Type: "object"})),
),
&jsonschema.Schema{
Type: "object",
},
srvTool(t, &Tool{Name: "nil out"}, testToolHandler[Name, any]),
nameSchema,
nil,
},
}
for _, test := range tests {
if diff := cmp.Diff(test.want, test.tool.Tool.InputSchema, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
t.Errorf("NewServerTool(%v) mismatch (-want +got):\n%s", test.tool.Tool.Name, diff)
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)
}
}
}