Implement the dispatcher middleware framework, allowing users to wrap method dispatching with their own code. The Dispatcher functions have an untyped params arg, but expect its value to be a typed, unmarshaled XXXParams struct. That required splitting the elegant dispatch[P, R] function into two parts, one to do the unmarshaling and one to call the method. The nicest way I could find to do that was to map from method names to a pair of functions, one to unmarshal and one to call. We could also write it as two switches on method name, which would require less machinery but would duplicate the list of method names, already a tad fragile because they are literal strings. I also exported the initialize params and result, so user Dispatchers could access them. All Params and Result types will have to be exported for that reason. Change-Id: Ie2c95cc17b4ef3d3d095dccdadb294e5f1590796 Reviewed-on: https://go-review.googlesource.com/c/tools/+/673515 Reviewed-by: Robert Findley <rfindley@google.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
231 lines
7.3 KiB
Go
231 lines
7.3 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.
|
|
|
|
// TODO: consider passing Transport to NewClient and merging {Connection,Client}Options
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
// 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 }),
|
|
}
|
|
if opts != nil {
|
|
c.opts = *opts
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ClientOptions configures the behavior of the client.
|
|
type ClientOptions struct{}
|
|
|
|
// 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
|
|
}
|
|
params := &InitializeParams{
|
|
ClientInfo: &implementation{Name: c.name, Version: c.version},
|
|
}
|
|
if err := call(ctx, cs.conn, "initialize", params, &cs.initializeResult); err != nil {
|
|
_ = cs.Close()
|
|
return nil, err
|
|
}
|
|
if err := cs.conn.Notify(ctx, "notifications/initialized", &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 (c *ClientSession) Close() error {
|
|
return c.conn.Close()
|
|
}
|
|
|
|
// Wait waits for the connection to be closed by the server.
|
|
// Generally, clients should be responsible for closing the connection.
|
|
func (c *ClientSession) Wait() error {
|
|
return c.conn.Wait()
|
|
}
|
|
|
|
// AddRoots adds the given roots to the client,
|
|
// replacing any with the same URIs,
|
|
// and notifies any connected servers.
|
|
// TODO: notification
|
|
func (c *Client) AddRoots(roots ...*Root) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.roots.add(roots...)
|
|
}
|
|
|
|
// 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.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.roots.remove(uris...)
|
|
}
|
|
|
|
func (c *Client) listRoots(_ context.Context, _ *ListRootsParams) (*ListRootsResult, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return &ListRootsResult{
|
|
Roots: slices.Collect(c.roots.all()),
|
|
}, nil
|
|
}
|
|
|
|
func (c *ClientSession) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) {
|
|
// TODO: when we switch to ClientSessions, use a copy of the server's dispatch function, or
|
|
// maybe just add another type parameter.
|
|
//
|
|
// No need to check that the connection is initialized, since we initialize
|
|
// it in Connect.
|
|
switch req.Method {
|
|
case "ping":
|
|
// The spec says that 'ping' expects an empty object result.
|
|
return struct{}{}, nil
|
|
case "roots/list":
|
|
// ListRootsParams happens to be unused.
|
|
return c.client.listRoots(ctx, nil)
|
|
}
|
|
return nil, jsonrpc2.ErrNotHandled
|
|
}
|
|
|
|
// Ping makes an MCP "ping" request to the server.
|
|
func (c *ClientSession) Ping(ctx context.Context, params *PingParams) error {
|
|
return call(ctx, c.conn, "ping", params, nil)
|
|
}
|
|
|
|
// ListPrompts lists prompts that are currently available on the server.
|
|
func (c *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) {
|
|
return standardCall[ListPromptsResult](ctx, c.conn, "prompts/list", params)
|
|
}
|
|
|
|
// GetPrompt gets a prompt from the server.
|
|
func (c *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) {
|
|
return standardCall[GetPromptResult](ctx, c.conn, "prompts/get", params)
|
|
}
|
|
|
|
// ListTools lists tools that are currently available on the server.
|
|
func (c *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) {
|
|
return standardCall[ListToolsResult](ctx, c.conn, "tools/list", params)
|
|
}
|
|
|
|
// CallTool calls the tool with the given name and arguments.
|
|
// Pass a [CallToolOptions] to provide additional request fields.
|
|
func (c *ClientSession) CallTool(ctx context.Context, name string, args map[string]any, opts *CallToolOptions) (_ *CallToolResult, err error) {
|
|
defer func() {
|
|
if err != nil {
|
|
err = fmt.Errorf("calling tool %q: %w", name, err)
|
|
}
|
|
}()
|
|
|
|
data, err := json.Marshal(args)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling arguments: %w", err)
|
|
}
|
|
params := &CallToolParams{
|
|
Name: name,
|
|
Arguments: json.RawMessage(data),
|
|
}
|
|
return standardCall[CallToolResult](ctx, c.conn, "tools/call", params)
|
|
}
|
|
|
|
// NOTE: the following struct should consist of all fields of callToolParams except name and arguments.
|
|
|
|
// CallToolOptions contains options to [ClientSession.CallTool].
|
|
type CallToolOptions struct {
|
|
ProgressToken any // string or int
|
|
}
|
|
|
|
// ListResources lists the resources that are currently available on the server.
|
|
func (c *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) {
|
|
return standardCall[ListResourcesResult](ctx, c.conn, "resources/list", params)
|
|
}
|
|
|
|
// ReadResource ask the server to read a resource and return its contents.
|
|
func (c *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) {
|
|
return standardCall[ReadResourceResult](ctx, c.conn, "resources/read", params)
|
|
}
|
|
|
|
func standardCall[TRes, TParams any](ctx context.Context, conn *jsonrpc2.Connection, method string, params TParams) (*TRes, error) {
|
|
var result TRes
|
|
if err := call(ctx, conn, method, params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|