Provide support for resource templates on the client and server. Adding, removing and listing resource templates looks just like those operations on resources. Although the spec provides no guidance on the unique ID for resource templates, we follow most existing SDKs, as well as the parallel with resources, and index on the URI template rather than the name. As the spec recommends, the server verifies an incoming URI in a ReadResource request against its list of resources and resource templates, to make sure the URI came from one of them. The main technical challenge here is the need to check that a URI template could have produced a particular URI. I wrote a very simplified version of this check. At some point we should switch to github.com/yosida95/uritemplate/v3. Change-Id: I1e34dd01c8f2171123c68c045ad62f5e85c05e7c Reviewed-on: https://go-review.googlesource.com/c/tools/+/680158 Auto-Submit: Jonathan Amsterdam <jba@google.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Robert Findley <rfindley@google.com>
421 lines
15 KiB
Go
421 lines
15 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"iter"
|
|
"slices"
|
|
"sync"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
)
|
|
|
|
// A Client is an MCP client, which may be connected to an MCP server
|
|
// using the [Client.Connect] method.
|
|
type Client struct {
|
|
name string
|
|
version string
|
|
opts ClientOptions
|
|
mu sync.Mutex
|
|
roots *featureSet[*Root]
|
|
sessions []*ClientSession
|
|
sendingMethodHandler_ MethodHandler[*ClientSession]
|
|
receivingMethodHandler_ MethodHandler[*ClientSession]
|
|
}
|
|
|
|
// NewClient creates a new Client.
|
|
//
|
|
// Use [Client.Connect] to connect it to an MCP server.
|
|
//
|
|
// If non-nil, the provided options configure the Client.
|
|
func NewClient(name, version string, opts *ClientOptions) *Client {
|
|
c := &Client{
|
|
name: name,
|
|
version: version,
|
|
roots: newFeatureSet(func(r *Root) string { return r.URI }),
|
|
sendingMethodHandler_: defaultSendingMethodHandler[*ClientSession],
|
|
receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession],
|
|
}
|
|
if opts != nil {
|
|
c.opts = *opts
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ClientOptions configures the behavior of the client.
|
|
type ClientOptions struct {
|
|
// Handler for sampling.
|
|
// Called when a server calls CreateMessage.
|
|
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)
|
|
LoggingMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams)
|
|
}
|
|
|
|
// bind implements the binder[*ClientSession] interface, so that Clients can
|
|
// be connected using [connect].
|
|
func (c *Client) bind(conn *jsonrpc2.Connection) *ClientSession {
|
|
cs := &ClientSession{
|
|
conn: conn,
|
|
client: c,
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.sessions = append(c.sessions, cs)
|
|
return cs
|
|
}
|
|
|
|
// disconnect implements the binder[*Client] interface, so that
|
|
// Clients can be connected using [connect].
|
|
func (c *Client) disconnect(cs *ClientSession) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.sessions = slices.DeleteFunc(c.sessions, func(cs2 *ClientSession) bool {
|
|
return cs2 == cs
|
|
})
|
|
}
|
|
|
|
// Connect begins an MCP session by connecting to a server over the given
|
|
// transport, and initializing the session.
|
|
//
|
|
// Typically, it is the responsibility of the client to close the connection
|
|
// when it is no longer needed. However, if the connection is closed by the
|
|
// server, calls or notifications will return an error wrapping
|
|
// [ErrConnectionClosed].
|
|
func (c *Client) Connect(ctx context.Context, t Transport) (cs *ClientSession, err error) {
|
|
cs, err = connect(ctx, t, c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
caps := &ClientCapabilities{}
|
|
caps.Roots.ListChanged = true
|
|
if c.opts.CreateMessageHandler != nil {
|
|
caps.Sampling = &SamplingCapabilities{}
|
|
}
|
|
|
|
params := &InitializeParams{
|
|
ClientInfo: &implementation{Name: c.name, Version: c.version},
|
|
Capabilities: caps,
|
|
}
|
|
res, err := handleSend[*InitializeResult](ctx, cs, methodInitialize, params)
|
|
if err != nil {
|
|
_ = cs.Close()
|
|
return nil, err
|
|
}
|
|
cs.initializeResult = res
|
|
if err := handleNotify(ctx, cs, notificationInitialized, &InitializedParams{}); err != nil {
|
|
_ = cs.Close()
|
|
return nil, err
|
|
}
|
|
return cs, nil
|
|
}
|
|
|
|
// A ClientSession is a logical connection with an MCP server. Its
|
|
// methods can be used to send requests or notifications to the server. Create
|
|
// a session by calling [Client.Connect].
|
|
//
|
|
// Call [ClientSession.Close] to close the connection, or await client
|
|
// termination with [ServerSession.Wait].
|
|
type ClientSession struct {
|
|
conn *jsonrpc2.Connection
|
|
client *Client
|
|
initializeResult *InitializeResult
|
|
}
|
|
|
|
// Close performs a graceful close of the connection, preventing new requests
|
|
// from being handled, and waiting for ongoing requests to return. Close then
|
|
// terminates the connection.
|
|
func (cs *ClientSession) Close() error {
|
|
return cs.conn.Close()
|
|
}
|
|
|
|
// Wait waits for the connection to be closed by the server.
|
|
// Generally, clients should be responsible for closing the connection.
|
|
func (cs *ClientSession) Wait() error {
|
|
return cs.conn.Wait()
|
|
}
|
|
|
|
// AddRoots adds the given roots to the client,
|
|
// replacing any with the same URIs,
|
|
// and notifies any connected servers.
|
|
func (c *Client) AddRoots(roots ...*Root) {
|
|
// Only notify if something could change.
|
|
if len(roots) == 0 {
|
|
return
|
|
}
|
|
c.changeAndNotify(notificationRootsListChanged, &RootsListChangedParams{},
|
|
func() bool { c.roots.add(roots...); return true })
|
|
}
|
|
|
|
// RemoveRoots removes the roots with the given URIs,
|
|
// and notifies any connected servers if the list has changed.
|
|
// It is not an error to remove a nonexistent root.
|
|
// TODO: notification
|
|
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 Params, change func() bool) {
|
|
var sessions []*ClientSession
|
|
// Lock for the change, but not for the notification.
|
|
c.mu.Lock()
|
|
if change() {
|
|
sessions = slices.Clone(c.sessions)
|
|
}
|
|
c.mu.Unlock()
|
|
notifySessions(sessions, notification, params)
|
|
}
|
|
|
|
func (c *Client) listRoots(_ context.Context, _ *ClientSession, _ *ListRootsParams) (*ListRootsResult, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return &ListRootsResult{
|
|
Roots: slices.Collect(c.roots.all()),
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) createMessage(ctx context.Context, cs *ClientSession, params *CreateMessageParams) (*CreateMessageResult, error) {
|
|
if c.opts.CreateMessageHandler == nil {
|
|
// TODO: wrap or annotate this error? Pick a standard code?
|
|
return nil, &jsonrpc2.WireError{Code: CodeUnsupportedMethod, Message: "client does not support CreateMessage"}
|
|
}
|
|
return c.opts.CreateMessageHandler(ctx, cs, params)
|
|
}
|
|
|
|
// AddSendingMiddleware wraps the current sending method handler using the provided
|
|
// middleware. Middleware is applied from right to left, so that the first one is
|
|
// executed first.
|
|
//
|
|
// For example, AddSendingMiddleware(m1, m2, m3) augments the method handler as
|
|
// m1(m2(m3(handler))).
|
|
//
|
|
// Sending middleware is called when a request is sent. It is useful for tasks
|
|
// such as tracing, metrics, and adding progress tokens.
|
|
func (c *Client) AddSendingMiddleware(middleware ...Middleware[*ClientSession]) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
addMiddleware(&c.sendingMethodHandler_, middleware)
|
|
}
|
|
|
|
// AddReceivingMiddleware wraps the current receiving method handler using
|
|
// the provided middleware. Middleware is applied from right to left, so that the
|
|
// first one is executed first.
|
|
//
|
|
// For example, AddReceivingMiddleware(m1, m2, m3) augments the method handler as
|
|
// m1(m2(m3(handler))).
|
|
//
|
|
// Receiving middleware is called when a request is received. It is useful for tasks
|
|
// such as authentication, request logging and metrics.
|
|
func (c *Client) AddReceivingMiddleware(middleware ...Middleware[*ClientSession]) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
addMiddleware(&c.receivingMethodHandler_, middleware)
|
|
}
|
|
|
|
// clientMethodInfos maps from the RPC method name to serverMethodInfos.
|
|
var clientMethodInfos = map[string]methodInfo{
|
|
methodPing: newMethodInfo(sessionMethod((*ClientSession).ping)),
|
|
methodListRoots: newMethodInfo(clientMethod((*Client).listRoots)),
|
|
methodCreateMessage: newMethodInfo(clientMethod((*Client).createMessage)),
|
|
notificationToolListChanged: newMethodInfo(clientMethod((*Client).callToolChangedHandler)),
|
|
notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler)),
|
|
notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler)),
|
|
notificationLoggingMessage: newMethodInfo(clientMethod((*Client).callLoggingHandler)),
|
|
}
|
|
|
|
func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo {
|
|
return serverMethodInfos
|
|
}
|
|
|
|
func (cs *ClientSession) receivingMethodInfos() map[string]methodInfo {
|
|
return clientMethodInfos
|
|
}
|
|
|
|
func (cs *ClientSession) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) {
|
|
return handleReceive(ctx, cs, req)
|
|
}
|
|
|
|
func (cs *ClientSession) sendingMethodHandler() methodHandler {
|
|
cs.client.mu.Lock()
|
|
defer cs.client.mu.Unlock()
|
|
return cs.client.sendingMethodHandler_
|
|
}
|
|
|
|
func (cs *ClientSession) receivingMethodHandler() methodHandler {
|
|
cs.client.mu.Lock()
|
|
defer cs.client.mu.Unlock()
|
|
return cs.client.receivingMethodHandler_
|
|
}
|
|
|
|
// getConn implements [session.getConn].
|
|
func (cs *ClientSession) getConn() *jsonrpc2.Connection { return cs.conn }
|
|
|
|
func (*ClientSession) ping(context.Context, *PingParams) (*emptyResult, error) {
|
|
return &emptyResult{}, nil
|
|
}
|
|
|
|
// Ping makes an MCP "ping" request to the server.
|
|
func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error {
|
|
_, err := handleSend[*emptyResult](ctx, cs, methodPing, params)
|
|
return err
|
|
}
|
|
|
|
// ListPrompts lists prompts that are currently available on the server.
|
|
func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) {
|
|
return handleSend[*ListPromptsResult](ctx, cs, methodListPrompts, params)
|
|
}
|
|
|
|
// GetPrompt gets a prompt from the server.
|
|
func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) {
|
|
return handleSend[*GetPromptResult](ctx, cs, methodGetPrompt, params)
|
|
}
|
|
|
|
// ListTools lists tools that are currently available on the server.
|
|
func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) {
|
|
return handleSend[*ListToolsResult](ctx, cs, methodListTools, params)
|
|
}
|
|
|
|
// CallTool calls the tool with the given name and arguments.
|
|
// Pass a [CallToolOptions] to provide additional request fields.
|
|
func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams[json.RawMessage]) (*CallToolResult, error) {
|
|
return handleSend[*CallToolResult](ctx, cs, methodCallTool, params)
|
|
}
|
|
|
|
// CallTool is a helper to call a tool with any argument type. It returns an
|
|
// error if params.Arguments fails to marshal to JSON.
|
|
func CallTool[TArgs any](ctx context.Context, cs *ClientSession, params *CallToolParams[TArgs]) (*CallToolResult, error) {
|
|
wireParams, err := toWireParams(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cs.CallTool(ctx, wireParams)
|
|
}
|
|
|
|
func toWireParams[TArgs any](params *CallToolParams[TArgs]) (*CallToolParams[json.RawMessage], error) {
|
|
data, err := json.Marshal(params.Arguments)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal arguments: %v", err)
|
|
}
|
|
// The field mapping here must be kept up to date with the CallToolParams.
|
|
// This is partially enforced by TestToWireParams, which verifies that all
|
|
// comparable fields are mapped.
|
|
return &CallToolParams[json.RawMessage]{
|
|
Meta: params.Meta,
|
|
Name: params.Name,
|
|
Arguments: data,
|
|
}, nil
|
|
}
|
|
|
|
func (cs *ClientSession) SetLevel(ctx context.Context, params *SetLevelParams) error {
|
|
_, err := handleSend[*emptyResult](ctx, cs, methodSetLevel, params)
|
|
return err
|
|
}
|
|
|
|
// ListResources lists the resources that are currently available on the server.
|
|
func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) {
|
|
return handleSend[*ListResourcesResult](ctx, cs, methodListResources, params)
|
|
}
|
|
|
|
// ListResourceTemplates lists the resource templates that are currently available on the server.
|
|
func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) {
|
|
return handleSend[*ListResourceTemplatesResult](ctx, cs, methodListResourceTemplates, params)
|
|
}
|
|
|
|
// ReadResource ask the server to read a resource and return its contents.
|
|
func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) {
|
|
return handleSend[*ReadResourceResult](ctx, cs, methodReadResource, params)
|
|
}
|
|
|
|
func (c *Client) callToolChangedHandler(ctx context.Context, s *ClientSession, params *ToolListChangedParams) (Result, error) {
|
|
return callNotificationHandler(ctx, c.opts.ToolListChangedHandler, s, params)
|
|
}
|
|
|
|
func (c *Client) callPromptChangedHandler(ctx context.Context, s *ClientSession, params *PromptListChangedParams) (Result, error) {
|
|
return callNotificationHandler(ctx, c.opts.PromptListChangedHandler, s, params)
|
|
}
|
|
|
|
func (c *Client) callResourceChangedHandler(ctx context.Context, s *ClientSession, params *ResourceListChangedParams) (Result, error) {
|
|
return callNotificationHandler(ctx, c.opts.ResourceListChangedHandler, s, params)
|
|
}
|
|
|
|
func (c *Client) callLoggingHandler(ctx context.Context, cs *ClientSession, params *LoggingMessageParams) (Result, error) {
|
|
if h := c.opts.LoggingMessageHandler; h != nil {
|
|
h(ctx, cs, params)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Tools provides an iterator for all tools available on the server,
|
|
// automatically fetching pages and managing cursors.
|
|
// The `params` argument can set the initial cursor.
|
|
// Iteration stops at the first encountered error, which will be yielded.
|
|
func (cs *ClientSession) Tools(ctx context.Context, params *ListToolsParams) iter.Seq2[Tool, error] {
|
|
if params == nil {
|
|
params = &ListToolsParams{}
|
|
}
|
|
return paginate(ctx, params, cs.ListTools, func(res *ListToolsResult) []*Tool {
|
|
return res.Tools
|
|
})
|
|
}
|
|
|
|
// Resources provides an iterator for all resources available on the server,
|
|
// automatically fetching pages and managing cursors.
|
|
// The `params` argument can set the initial cursor.
|
|
// Iteration stops at the first encountered error, which will be yielded.
|
|
func (cs *ClientSession) Resources(ctx context.Context, params *ListResourcesParams) iter.Seq2[Resource, error] {
|
|
if params == nil {
|
|
params = &ListResourcesParams{}
|
|
}
|
|
return paginate(ctx, params, cs.ListResources, func(res *ListResourcesResult) []*Resource {
|
|
return res.Resources
|
|
})
|
|
}
|
|
|
|
// Prompts provides an iterator for all prompts available on the server,
|
|
// automatically fetching pages and managing cursors.
|
|
// The `params` argument can set the initial cursor.
|
|
// Iteration stops at the first encountered error, which will be yielded.
|
|
func (cs *ClientSession) Prompts(ctx context.Context, params *ListPromptsParams) iter.Seq2[Prompt, error] {
|
|
if params == nil {
|
|
params = &ListPromptsParams{}
|
|
}
|
|
return paginate(ctx, params, cs.ListPrompts, func(res *ListPromptsResult) []*Prompt {
|
|
return res.Prompts
|
|
})
|
|
}
|
|
|
|
// paginate is a generic helper function to provide a paginated iterator.
|
|
func paginate[P listParams, R listResult[T], T any](ctx context.Context, params P, listFunc func(context.Context, P) (R, error), items func(R) []*T) iter.Seq2[T, error] {
|
|
return func(yield func(T, error) bool) {
|
|
for {
|
|
res, err := listFunc(ctx, params)
|
|
if err != nil {
|
|
var zero T
|
|
yield(zero, err)
|
|
return
|
|
}
|
|
for _, r := range items(res) {
|
|
if !yield(*r, nil) {
|
|
return
|
|
}
|
|
}
|
|
nextCursorVal := res.nextCursorPtr()
|
|
if nextCursorVal == nil || *nextCursorVal == "" {
|
|
return
|
|
}
|
|
*params.cursorPtr() = *nextCursorVal
|
|
}
|
|
}
|
|
}
|