internal/mcp: add notifications

Add notification logic for intialization and list changes.

Change-Id: I1d392bb49d5995f2046c400da955a033ab2715ce
Reviewed-on: https://go-review.googlesource.com/c/tools/+/673775
Reviewed-by: Sam Thanawalla <samthanawalla@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Robert Findley <rfindley@google.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-17 12:59:45 -04:00
commit ce5b6cd926
6 changed files with 273 additions and 78 deletions

View file

@ -49,6 +49,10 @@ type ClientOptions struct {
// Handler for sampling. // Handler for sampling.
// Called when a server calls CreateMessage. // Called when a server calls CreateMessage.
CreateMessageHandler func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error) CreateMessageHandler func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error)
// Handlers for notifications from the server.
ToolListChangedHandler func(context.Context, *ClientSession, *ToolListChangedParams)
PromptListChangedHandler func(context.Context, *ClientSession, *PromptListChangedParams)
ResourceListChangedHandler func(context.Context, *ClientSession, *ResourceListChangedParams)
} }
// bind implements the binder[*ClientSession] interface, so that Clients can // bind implements the binder[*ClientSession] interface, so that Clients can
@ -86,10 +90,13 @@ func (c *Client) Connect(ctx context.Context, t Transport) (cs *ClientSession, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
caps := &ClientCapabilities{} caps := &ClientCapabilities{}
caps.Roots.ListChanged = true
if c.opts.CreateMessageHandler != nil { if c.opts.CreateMessageHandler != nil {
caps.Sampling = &SamplingCapabilities{} caps.Sampling = &SamplingCapabilities{}
} }
params := &InitializeParams{ params := &InitializeParams{
ClientInfo: &implementation{Name: c.name, Version: c.version}, ClientInfo: &implementation{Name: c.name, Version: c.version},
Capabilities: caps, Capabilities: caps,
@ -133,11 +140,13 @@ func (c *ClientSession) Wait() error {
// AddRoots adds the given roots to the client, // AddRoots adds the given roots to the client,
// replacing any with the same URIs, // replacing any with the same URIs,
// and notifies any connected servers. // and notifies any connected servers.
// TODO: notification
func (c *Client) AddRoots(roots ...*Root) { func (c *Client) AddRoots(roots ...*Root) {
c.mu.Lock() // Only notify if something could change.
defer c.mu.Unlock() if len(roots) == 0 {
c.roots.add(roots...) return
}
c.changeAndNotify(notificationRootsListChanged, &RootsListChangedParams{},
func() bool { c.roots.add(roots...); return true })
} }
// RemoveRoots removes the roots with the given URIs, // RemoveRoots removes the roots with the given URIs,
@ -145,9 +154,22 @@ func (c *Client) AddRoots(roots ...*Root) {
// It is not an error to remove a nonexistent root. // It is not an error to remove a nonexistent root.
// TODO: notification // TODO: notification
func (c *Client) RemoveRoots(uris ...string) { func (c *Client) RemoveRoots(uris ...string) {
c.changeAndNotify(notificationRootsListChanged, &RootsListChangedParams{},
func() bool { return c.roots.remove(uris...) })
}
// changeAndNotify is called when a feature is added or removed.
// It calls change, which should do the work and report whether a change actually occurred.
// If there was a change, it notifies a snapshot of the sessions.
func (c *Client) changeAndNotify(notification string, params any, change func() bool) {
var sessions []*ClientSession
// Lock for the change, but not for the notification.
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() if change() {
c.roots.remove(uris...) sessions = slices.Clone(c.sessions)
}
c.mu.Unlock()
notifySessions(sessions, notification, params)
} }
func (c *Client) listRoots(_ context.Context, _ *ClientSession, _ *ListRootsParams) (*ListRootsResult, error) { func (c *Client) listRoots(_ context.Context, _ *ClientSession, _ *ListRootsParams) (*ListRootsResult, error) {
@ -180,10 +202,12 @@ func (c *Client) AddMiddleware(middleware ...Middleware[ClientSession]) {
// clientMethodInfos maps from the RPC method name to serverMethodInfos. // clientMethodInfos maps from the RPC method name to serverMethodInfos.
var clientMethodInfos = map[string]methodInfo[ClientSession]{ var clientMethodInfos = map[string]methodInfo[ClientSession]{
methodPing: newMethodInfo(sessionMethod((*ClientSession).ping)), methodPing: newMethodInfo(sessionMethod((*ClientSession).ping)),
methodListRoots: newMethodInfo(clientMethod((*Client).listRoots)), methodListRoots: newMethodInfo(clientMethod((*Client).listRoots)),
methodCreateMessage: newMethodInfo(clientMethod((*Client).createMessage)), methodCreateMessage: newMethodInfo(clientMethod((*Client).createMessage)),
// TODO: notifications notificationToolListChanged: newMethodInfo(clientMethod((*Client).callToolChangedHandler)),
notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler)),
notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler)),
} }
var _ session[ClientSession] = (*ClientSession)(nil) var _ session[ClientSession] = (*ClientSession)(nil)
@ -202,6 +226,9 @@ func (cs *ClientSession) methodHandler() MethodHandler[ClientSession] {
return cs.client.methodHandler_ return cs.client.methodHandler_
} }
// getConn implements [session.getConn].
func (cs *ClientSession) getConn() *jsonrpc2.Connection { return cs.conn }
func (c *ClientSession) ping(ct context.Context, params *PingParams) (struct{}, error) { func (c *ClientSession) ping(ct context.Context, params *PingParams) (struct{}, error) {
return struct{}{}, nil return struct{}{}, nil
} }
@ -263,6 +290,18 @@ func (c *ClientSession) ReadResource(ctx context.Context, params *ReadResourcePa
return standardCall[ReadResourceResult](ctx, c.conn, methodReadResource, params) return standardCall[ReadResourceResult](ctx, c.conn, methodReadResource, params)
} }
func (c *Client) callToolChangedHandler(ctx context.Context, s *ClientSession, params *ToolListChangedParams) (any, error) {
return callNotificationHandler(ctx, c.opts.ToolListChangedHandler, s, params)
}
func (c *Client) callPromptChangedHandler(ctx context.Context, s *ClientSession, params *PromptListChangedParams) (any, error) {
return callNotificationHandler(ctx, c.opts.PromptListChangedHandler, s, params)
}
func (c *Client) callResourceChangedHandler(ctx context.Context, s *ClientSession, params *ResourceListChangedParams) (any, error) {
return callNotificationHandler(ctx, c.opts.ResourceListChangedHandler, s, params)
}
func standardCall[TRes, TParams any](ctx context.Context, conn *jsonrpc2.Connection, method string, params TParams) (*TRes, error) { func standardCall[TRes, TParams any](ctx context.Context, conn *jsonrpc2.Connection, method string, params TParams) (*TRes, error) {
var result TRes var result TRes
if err := call(ctx, conn, method, params, &result); err != nil { if err := call(ctx, conn, method, params, &result); err != nil {

View file

@ -120,8 +120,12 @@ var declarations = config{
"Prompt": {}, "Prompt": {},
"PromptMessage": {}, "PromptMessage": {},
"PromptArgument": {}, "PromptArgument": {},
"ProgressToken": {Name: "-", Substitute: "any"}, // null|number|string "PromptListChangedNotification": {
"RequestId": {Name: "-", Substitute: "any"}, // null|number|string Name: "-",
Fields: config{"Params": {Name: "PromptListChangedParams"}},
},
"ProgressToken": {Name: "-", Substitute: "any"}, // null|number|string
"RequestId": {Name: "-", Substitute: "any"}, // null|number|string
"ReadResourceRequest": { "ReadResourceRequest": {
Name: "-", Name: "-",
Fields: config{"Params": {Name: "ReadResourceParams"}}, Fields: config{"Params": {Name: "ReadResourceParams"}},
@ -130,8 +134,16 @@ var declarations = config{
Fields: config{"Contents": {Substitute: "*ResourceContents"}}, Fields: config{"Contents": {Substitute: "*ResourceContents"}},
}, },
"Resource": {}, "Resource": {},
"Role": {}, "ResourceListChangedNotification": {
"Root": {}, Name: "-",
Fields: config{"Params": {Name: "ResourceListChangedParams"}},
},
"Role": {},
"Root": {},
"RootsListChangedNotification": {
Name: "-",
Fields: config{"Params": {Name: "RootsListChangedParams"}},
},
"SamplingCapabilities": {Substitute: "struct{}"}, "SamplingCapabilities": {Substitute: "struct{}"},
"SamplingMessage": {}, "SamplingMessage": {},
@ -147,6 +159,10 @@ var declarations = config{
Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}}, Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}},
}, },
"ToolAnnotations": {}, "ToolAnnotations": {},
"ToolListChangedNotification": {
Name: "-",
Fields: config{"Params": {Name: "ToolListChangedParams"}},
},
} }
func main() { func main() {

View file

@ -39,7 +39,26 @@ func TestEndToEnd(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ct, st := NewInMemoryTransports() ct, st := NewInMemoryTransports()
s := NewServer("testServer", "v1.0.0", nil) // Channels to check if notification callbacks happened.
notificationChans := map[string]chan int{}
for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources"} {
notificationChans[name] = make(chan int, 1)
}
waitForNotification := func(t *testing.T, name string) {
t.Helper()
select {
case <-notificationChans[name]:
case <-time.After(time.Second):
t.Fatalf("%s handler never called", name)
}
}
sopts := &ServerOptions{
InitializedHandler: func(context.Context, *ServerSession, *InitializedParams) { notificationChans["initialized"] <- 0 },
RootsListChangedHandler: func(context.Context, *ServerSession, *RootsListChangedParams) { notificationChans["roots"] <- 0 },
}
s := NewServer("testServer", "v1.0.0", sopts)
// The 'greet' tool says hi. // The 'greet' tool says hi.
s.AddTools(NewTool("greet", "say hi", sayHi)) s.AddTools(NewTool("greet", "say hi", sayHi))
@ -89,6 +108,9 @@ func TestEndToEnd(t *testing.T) {
CreateMessageHandler: func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error) { CreateMessageHandler: func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error) {
return &CreateMessageResult{Model: "aModel"}, nil return &CreateMessageResult{Model: "aModel"}, nil
}, },
ToolListChangedHandler: func(context.Context, *ClientSession, *ToolListChangedParams) { notificationChans["tools"] <- 0 },
PromptListChangedHandler: func(context.Context, *ClientSession, *PromptListChangedParams) { notificationChans["prompts"] <- 0 },
ResourceListChangedHandler: func(context.Context, *ClientSession, *ResourceListChangedParams) { notificationChans["resources"] <- 0 },
} }
c := NewClient("testClient", "v1.0.0", opts) c := NewClient("testClient", "v1.0.0", opts)
rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files"))
@ -103,6 +125,7 @@ func TestEndToEnd(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
waitForNotification(t, "initialized")
if err := cs.Ping(ctx, nil); err != nil { if err := cs.Ping(ctx, nil); err != nil {
t.Fatalf("ping failed: %v", err) t.Fatalf("ping failed: %v", err)
} }
@ -141,6 +164,11 @@ func TestEndToEnd(t *testing.T) {
if _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "fail"}); err == nil || !strings.Contains(err.Error(), failure.Error()) { if _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "fail"}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure) t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
} }
s.AddPrompts(&ServerPrompt{Prompt: &Prompt{Name: "T"}})
waitForNotification(t, "prompts")
s.RemovePrompts("T")
waitForNotification(t, "prompts")
}) })
t.Run("tools", func(t *testing.T) { t.Run("tools", func(t *testing.T) {
@ -198,6 +226,11 @@ func TestEndToEnd(t *testing.T) {
if diff := cmp.Diff(wantFail, gotFail); diff != "" { if diff := cmp.Diff(wantFail, gotFail); diff != "" {
t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff)
} }
s.AddTools(&ServerTool{Tool: &Tool{Name: "T"}})
waitForNotification(t, "tools")
s.RemoveTools("T")
waitForNotification(t, "tools")
}) })
t.Run("resources", func(t *testing.T) { t.Run("resources", func(t *testing.T) {
@ -254,6 +287,11 @@ func TestEndToEnd(t *testing.T) {
} }
} }
} }
s.AddResources(&ServerResource{Resource: &Resource{URI: "http://U"}})
waitForNotification(t, "resources")
s.RemoveResources("http://U")
waitForNotification(t, "resources")
}) })
t.Run("roots", func(t *testing.T) { t.Run("roots", func(t *testing.T) {
rootRes, err := ss.ListRoots(ctx, &ListRootsParams{}) rootRes, err := ss.ListRoots(ctx, &ListRootsParams{})
@ -265,6 +303,11 @@ func TestEndToEnd(t *testing.T) {
if diff := cmp.Diff(wantRoots, gotRoots); diff != "" { if diff := cmp.Diff(wantRoots, gotRoots); diff != "" {
t.Errorf("roots/list mismatch (-want +got):\n%s", diff) t.Errorf("roots/list mismatch (-want +got):\n%s", diff)
} }
c.AddRoots(&Root{URI: "U"})
waitForNotification(t, "roots")
c.RemoveRoots("U")
waitForNotification(t, "roots")
}) })
t.Run("sampling", func(t *testing.T) { t.Run("sampling", func(t *testing.T) {
// TODO: test that a client that doesn't have the handler returns CodeUnsupportedMethod. // TODO: test that a client that doesn't have the handler returns CodeUnsupportedMethod.
@ -462,6 +505,10 @@ func TestMiddleware(t *testing.T) {
2 >initialize 2 >initialize
2 <initialize 2 <initialize
1 <initialize 1 <initialize
1 >notifications/initialized
2 >notifications/initialized
2 <notifications/initialized
1 <notifications/initialized
1 >tools/list 1 >tools/list
2 >tools/list 2 >tools/list
2 <tools/list 2 <tools/list

View file

@ -323,6 +323,13 @@ type PromptArgument struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
} }
type PromptListChangedParams struct {
// This parameter name is reserved by MCP to allow clients and servers to attach
// additional metadata to their notifications.
Meta struct {
} `json:"_meta,omitempty"`
}
// Describes a message returned as part of a prompt. // Describes a message returned as part of a prompt.
// //
// This is similar to `SamplingMessage`, but also supports the embedding of // This is similar to `SamplingMessage`, but also supports the embedding of
@ -371,6 +378,13 @@ type Resource struct {
URI string `json:"uri"` URI string `json:"uri"`
} }
type ResourceListChangedParams struct {
// This parameter name is reserved by MCP to allow clients and servers to attach
// additional metadata to their notifications.
Meta struct {
} `json:"_meta,omitempty"`
}
// The sender or recipient of messages and data in a conversation. // The sender or recipient of messages and data in a conversation.
type Role string type Role string
@ -386,6 +400,13 @@ type Root struct {
URI string `json:"uri"` URI string `json:"uri"`
} }
type RootsListChangedParams struct {
// This parameter name is reserved by MCP to allow clients and servers to attach
// additional metadata to their notifications.
Meta struct {
} `json:"_meta,omitempty"`
}
// Present if the client supports sampling from an LLM. // Present if the client supports sampling from an LLM.
type SamplingCapabilities struct { type SamplingCapabilities struct {
} }
@ -448,6 +469,13 @@ type ToolAnnotations struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
} }
type ToolListChangedParams struct {
// This parameter name is reserved by MCP to allow clients and servers to attach
// additional metadata to their notifications.
Meta struct {
} `json:"_meta,omitempty"`
}
// Describes the name and version of an MCP implementation. // Describes the name and version of an MCP implementation.
type implementation struct { type implementation struct {
Name string `json:"name"` Name string `json:"name"`
@ -496,28 +524,28 @@ type toolCapabilities struct {
} }
const ( const (
notificationCancelled = "notifications/cancelled"
methodInitialize = "initialize"
notificationProgress = "notifications/progress"
methodSetLevel = "logging/setLevel"
methodCreateMessage = "sampling/createMessage" methodCreateMessage = "sampling/createMessage"
notificationResourceListChanged = "notifications/resources/list_changed"
notificationInitialized = "notifications/initialized"
methodUnsubscribe = "resources/unsubscribe"
notificationLoggingMessage = "notifications/message"
methodSubscribe = "resources/subscribe"
methodComplete = "completion/complete"
methodCallTool = "tools/call"
notificationPromptListChanged = "notifications/prompts/list_changed"
methodReadResource = "resources/read"
methodListResourceTemplates = "resources/templates/list"
methodListRoots = "roots/list"
notificationToolListChanged = "notifications/tools/list_changed" notificationToolListChanged = "notifications/tools/list_changed"
methodGetPrompt = "prompts/get" notificationResourceListChanged = "notifications/resources/list_changed"
methodListPrompts = "prompts/list" methodListPrompts = "prompts/list"
methodPing = "ping" notificationPromptListChanged = "notifications/prompts/list_changed"
notificationRootsListChanged = "notifications/roots/list_changed"
methodListTools = "tools/list"
methodListResources = "resources/list"
notificationResourceUpdated = "notifications/resources/updated" notificationResourceUpdated = "notifications/resources/updated"
notificationCancelled = "notifications/cancelled"
methodSetLevel = "logging/setLevel"
methodInitialize = "initialize"
methodListRoots = "roots/list"
notificationProgress = "notifications/progress"
methodGetPrompt = "prompts/get"
methodListResourceTemplates = "resources/templates/list"
methodPing = "ping"
methodComplete = "completion/complete"
methodListResources = "resources/list"
notificationInitialized = "notifications/initialized"
methodCallTool = "tools/call"
notificationLoggingMessage = "notifications/message"
methodReadResource = "resources/read"
methodListTools = "tools/list"
notificationRootsListChanged = "notifications/roots/list_changed"
methodSubscribe = "resources/subscribe"
methodUnsubscribe = "resources/unsubscribe"
) )

View file

@ -36,11 +36,16 @@ type Server struct {
// ServerOptions is used to configure behavior of the server. // ServerOptions is used to configure behavior of the server.
type ServerOptions struct { type ServerOptions struct {
// Optional instructions for connected clients.
Instructions string Instructions string
// If non-nil, called when "notifications/intialized" is received.
InitializedHandler func(context.Context, *ServerSession, *InitializedParams)
// If non-nil, called when "notifications/roots/list_changed" is received.
RootsListChangedHandler func(context.Context, *ServerSession, *RootsListChangedParams)
} }
// NewServer creates a new MCP server. The resulting server has no features: // NewServer creates a new MCP server. The resulting server has no features:
// add features using [Server.AddTools]. (TODO: support more features). // add features using [Server.AddTools], [Server.AddPrompts] and [Server.AddResources].
// //
// The server can be connected to one or more MCP clients using [Server.Start] // The server can be connected to one or more MCP clients using [Server.Start]
// or [Server.Run]. // or [Server.Run].
@ -64,43 +69,43 @@ func NewServer(name, version string, opts *ServerOptions) *Server {
// AddPrompts adds the given prompts to the server, // AddPrompts adds the given prompts to the server,
// replacing any with the same names. // replacing any with the same names.
func (s *Server) AddPrompts(prompts ...*ServerPrompt) { func (s *Server) AddPrompts(prompts ...*ServerPrompt) {
s.mu.Lock() // Only notify if something could change.
defer s.mu.Unlock() if len(prompts) == 0 {
s.prompts.add(prompts...) return
// Assume there was a change, since add replaces existing prompts. }
// (It's possible a prompt was replaced with an identical one, but not worth checking.) // Assume there was a change, since add replaces existing roots.
// TODO(rfindley): notify connected clients // (It's possible a root was replaced with an identical one, but not worth checking.)
s.changeAndNotify(
notificationPromptListChanged,
&PromptListChangedParams{},
func() bool { s.prompts.add(prompts...); return true })
} }
// RemovePrompts removes the prompts with the given names. // RemovePrompts removes the prompts with the given names.
// It is not an error to remove a nonexistent prompt. // It is not an error to remove a nonexistent prompt.
func (s *Server) RemovePrompts(names ...string) { func (s *Server) RemovePrompts(names ...string) {
s.mu.Lock() s.changeAndNotify(notificationPromptListChanged, &PromptListChangedParams{},
defer s.mu.Unlock() func() bool { return s.prompts.remove(names...) })
if s.prompts.remove(names...) {
// TODO: notify
}
} }
// AddTools adds the given tools to the server, // AddTools adds the given tools to the server,
// replacing any with the same names. // replacing any with the same names.
func (s *Server) AddTools(tools ...*ServerTool) { func (s *Server) AddTools(tools ...*ServerTool) {
s.mu.Lock() // Only notify if something could change.
defer s.mu.Unlock() if len(tools) == 0 {
s.tools.add(tools...) return
}
// Assume there was a change, since add replaces existing tools. // 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.) // (It's possible a tool was replaced with an identical one, but not worth checking.)
// TODO(rfindley): notify connected clients s.changeAndNotify(notificationToolListChanged, &ToolListChangedParams{},
func() bool { s.tools.add(tools...); return true })
} }
// RemoveTools removes the tools with the given names. // RemoveTools removes the tools with the given names.
// It is not an error to remove a nonexistent tool. // It is not an error to remove a nonexistent tool.
func (s *Server) RemoveTools(names ...string) { func (s *Server) RemoveTools(names ...string) {
s.mu.Lock() s.changeAndNotify(notificationToolListChanged, &ToolListChangedParams{},
defer s.mu.Unlock() func() bool { return s.tools.remove(names...) })
if s.tools.remove(names...) {
// TODO: notify
}
} }
// AddResource adds the given resource to the server and associates it with // AddResource adds the given resource to the server and associates it with
@ -108,27 +113,45 @@ func (s *Server) RemoveTools(names ...string) {
// If a resource with the same URI already exists, this one replaces it. // If a resource with the same URI already exists, this one replaces it.
// AddResource panics if a resource URI is invalid or not absolute (has an empty scheme). // AddResource panics if a resource URI is invalid or not absolute (has an empty scheme).
func (s *Server) AddResources(resources ...*ServerResource) { func (s *Server) AddResources(resources ...*ServerResource) {
s.mu.Lock() // Only notify if something could change.
defer s.mu.Unlock() if len(resources) == 0 {
for _, r := range resources { return
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)
} }
// TODO: notify 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)
}
return true
})
} }
// RemoveResources removes the resources with the given URIs. // RemoveResources removes the resources with the given URIs.
// It is not an error to remove a nonexistent resource. // It is not an error to remove a nonexistent resource.
func (s *Server) RemoveResources(uris ...string) { func (s *Server) RemoveResources(uris ...string) {
s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{},
func() bool { return s.resources.remove(uris...) })
}
// changeAndNotify is called when a feature is added or removed.
// It calls change, which should do the work and report whether a change actually occurred.
// If there was a change, it notifies a snapshot of the sessions.
func (s *Server) changeAndNotify(notification string, params any, change func() bool) {
var sessions []*ServerSession
// Lock for the change, but not for the notification.
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() if change() {
s.resources.remove(uris...) sessions = slices.Clone(s.sessions)
}
s.mu.Unlock()
notifySessions(sessions, notification, params)
} }
// Sessions returns an iterator that yields the current set of server sessions. // Sessions returns an iterator that yields the current set of server sessions.
@ -303,6 +326,14 @@ func (s *Server) Connect(ctx context.Context, t Transport) (*ServerSession, erro
return connect(ctx, t, s) return connect(ctx, t, s)
} }
func (s *Server) callInitializedHandler(ctx context.Context, ss *ServerSession, params *InitializedParams) (any, error) {
return callNotificationHandler(ctx, s.opts.InitializedHandler, ss, params)
}
func (s *Server) callRootsListChangedHandler(ctx context.Context, ss *ServerSession, params *RootsListChangedParams) (any, error) {
return callNotificationHandler(ctx, s.opts.RootsListChangedHandler, ss, params)
}
// A ServerSession is a logical connection from a single MCP client. Its // A ServerSession is a logical connection from a single MCP client. Its
// methods can be used to send requests or notifications to the client. Create // methods can be used to send requests or notifications to the client. Create
// a session by calling [Server.Connect]. // a session by calling [Server.Connect].
@ -347,15 +378,16 @@ func (s *Server) AddMiddleware(middleware ...Middleware[ServerSession]) {
// serverMethodInfos maps from the RPC method name to serverMethodInfos. // serverMethodInfos maps from the RPC method name to serverMethodInfos.
var serverMethodInfos = map[string]methodInfo[ServerSession]{ var serverMethodInfos = map[string]methodInfo[ServerSession]{
methodInitialize: newMethodInfo(sessionMethod((*ServerSession).initialize)), methodInitialize: newMethodInfo(sessionMethod((*ServerSession).initialize)),
methodPing: newMethodInfo(sessionMethod((*ServerSession).ping)), methodPing: newMethodInfo(sessionMethod((*ServerSession).ping)),
methodListPrompts: newMethodInfo(serverMethod((*Server).listPrompts)), methodListPrompts: newMethodInfo(serverMethod((*Server).listPrompts)),
methodGetPrompt: newMethodInfo(serverMethod((*Server).getPrompt)), methodGetPrompt: newMethodInfo(serverMethod((*Server).getPrompt)),
methodListTools: newMethodInfo(serverMethod((*Server).listTools)), methodListTools: newMethodInfo(serverMethod((*Server).listTools)),
methodCallTool: newMethodInfo(serverMethod((*Server).callTool)), methodCallTool: newMethodInfo(serverMethod((*Server).callTool)),
methodListResources: newMethodInfo(serverMethod((*Server).listResources)), methodListResources: newMethodInfo(serverMethod((*Server).listResources)),
methodReadResource: newMethodInfo(serverMethod((*Server).readResource)), methodReadResource: newMethodInfo(serverMethod((*Server).readResource)),
// TODO: notifications notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler)),
notificationRootsListChanged: newMethodInfo(serverMethod((*Server).callRootsListChangedHandler)),
} }
// *ServerSession implements the session interface. // *ServerSession implements the session interface.
@ -372,6 +404,9 @@ func (ss *ServerSession) methodHandler() MethodHandler[ServerSession] {
return ss.server.methodHandler_ return ss.server.methodHandler_
} }
// getConn implements [session.getConn].
func (ss *ServerSession) getConn() *jsonrpc2.Connection { return ss.conn }
// handle invokes the method described by the given JSON RPC request. // handle invokes the method described by the given JSON RPC request.
func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) {
ss.mu.Lock() ss.mu.Lock()
@ -414,10 +449,13 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
ProtocolVersion: "2024-11-05", ProtocolVersion: "2024-11-05",
Capabilities: &serverCapabilities{ Capabilities: &serverCapabilities{
Prompts: &promptCapabilities{ Prompts: &promptCapabilities{
ListChanged: false, // not yet supported ListChanged: true,
}, },
Tools: &toolCapabilities{ Tools: &toolCapabilities{
ListChanged: false, // not yet supported ListChanged: true,
},
Resources: &resourceCapabilities{
ListChanged: true,
}, },
}, },
Instructions: ss.server.opts.Instructions, Instructions: ss.server.opts.Instructions,

View file

@ -13,7 +13,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"slices" "slices"
"time"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
) )
@ -40,6 +42,7 @@ func addMiddleware[S ClientSession | ServerSession](handlerp *MethodHandler[S],
type session[S ClientSession | ServerSession] interface { type session[S ClientSession | ServerSession] interface {
methodHandler() MethodHandler[S] methodHandler() MethodHandler[S]
methodInfos() map[string]methodInfo[S] methodInfos() map[string]methodInfo[S]
getConn() *jsonrpc2.Connection
} }
// toSession[S] converts its argument to a session[S]. // toSession[S] converts its argument to a session[S].
@ -147,3 +150,27 @@ const (
// The error code if the method exists and was called properly, but the peer does not support it. // The error code if the method exists and was called properly, but the peer does not support it.
CodeUnsupportedMethod = -31001 CodeUnsupportedMethod = -31001
) )
func callNotificationHandler[S ClientSession | ServerSession, P any](ctx context.Context, h func(context.Context, *S, *P), sess *S, params *P) (any, error) {
if h != nil {
h(ctx, sess, params)
}
return nil, nil
}
// notifySessions calls Notify on all the sessions.
// Should be called on a copy of the peer sessions.
func notifySessions[S ClientSession | ServerSession](sessions []*S, method string, params any) {
if sessions == nil {
return
}
// TODO: make this timeout configurable, or call Notify asynchronously.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, ss := range sessions {
if err := toSession(ss).getConn().Notify(ctx, method, params); err != nil {
// TODO(jba): surface this error better
log.Printf("calling %s: %v", method, err)
}
}
}