go-sdk/mcp/client.go
Jonathan Amsterdam 1ae23e7031 internal/mcp: implement resources
Implementation of resources.

This follows the design sketched in design/design.md, except for
Server.FileResourceHandler, which will be in a forthcoming CL.

As the spec recommends, the server verifies an incoming URI from the
client against its list of resources, to make sure the URI came from one
of them.

Some things that aren't in the design doc:
- I provide an exported function that creates a "resource not found"
  jsonrpc2.WireEror error for server authors to return.
  A wrinkle: the MCP spec defines the error code for
  "resource not found" as -32002 [1]. That code is reserved for use
  by implementations of JSON-RPC, not applications. That matters to us
  because our JSON-RPC implementation already uses that code for the
  "server closing" error. I filed a bug against MCP about it [2] and
  chose a different code for the "resource not found" error.

- As a slight convenience, I fill in some fields in the
  ReadResourceResult if the handler doesn't set them.

[1] https://modelcontextprotocol.io/specification/2025-03-26/server/resources#error-handling
[2] https://github.com/modelcontextprotocol/modelcontextprotocol/issues/509

Change-Id: I06153da982ea00748a3691ded923448a880cea2a
Reviewed-on: https://go-review.googlesource.com/c/tools/+/671363
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Robert Findley <rfindley@google.com>
2025-05-13 11:39:49 -07:00

240 lines
7.2 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"
"github.com/modelcontextprotocol/go-sdk/mcp/protocol"
)
// A Client is an MCP client, which may be connected to an MCP server
// using the [Client.Start] method.
type Client struct {
name string
version string
transport Transport
opts ClientOptions
mu sync.Mutex
conn *jsonrpc2.Connection
roots *featureSet[protocol.Root]
initializeResult *protocol.InitializeResult
}
// NewClient creates a new Client.
//
// Use [Client.Start] to connect it to an MCP server.
//
// If non-nil, the provided options configure the Client.
func NewClient(name, version string, t Transport, opts *ClientOptions) *Client {
c := &Client{
name: name,
version: version,
transport: t,
roots: newFeatureSet(func(r protocol.Root) string { return r.URI }),
}
if opts != nil {
c.opts = *opts
}
return c
}
// ClientOptions configures the behavior of the client.
type ClientOptions struct {
ConnectionOptions
}
// bind implements the binder[*Client] interface, so that Clients can
// be connected using [connect].
func (c *Client) bind(conn *jsonrpc2.Connection) *Client {
c.mu.Lock()
defer c.mu.Unlock()
c.conn = conn
return c
}
// disconnect implements the binder[*Client] interface, so that
// Clients can be connected using [connect].
func (c *Client) disconnect(*Client) {
// Do nothing. In particular, do not set conn to nil: it needs to exist so it can
// return an error.
}
// Start begins an MCP session by connecting the MCP client over its transport.
//
// 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) Start(ctx context.Context) (err error) {
defer func() {
if err != nil {
_ = c.Close()
}
}()
_, err = connect(ctx, c.transport, &c.opts.ConnectionOptions, c)
if err != nil {
return err
}
params := &protocol.InitializeParams{
ClientInfo: protocol.Implementation{Name: c.name, Version: c.version},
}
if err := call(ctx, c.conn, "initialize", params, &c.initializeResult); err != nil {
return err
}
if err := c.conn.Notify(ctx, "notifications/initialized", &protocol.InitializedParams{}); err != nil {
return err
}
return nil
}
// 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 *Client) 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 *Client) 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 ...protocol.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, _ *protocol.ListRootsParams) (*protocol.ListRootsResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
return &protocol.ListRootsResult{
Roots: slices.Collect(c.roots.all()),
}, nil
}
func (c *Client) 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.listRoots(ctx, nil)
}
return nil, jsonrpc2.ErrNotHandled
}
// Ping makes an MCP "ping" request to the server.
func (c *Client) Ping(ctx context.Context) error {
return call(ctx, c.conn, "ping", nil, nil)
}
// ListPrompts lists prompts that are currently available on the server.
func (c *Client) ListPrompts(ctx context.Context) ([]protocol.Prompt, error) {
var (
params = &protocol.ListPromptsParams{}
result protocol.ListPromptsResult
)
if err := call(ctx, c.conn, "prompts/list", params, &result); err != nil {
return nil, err
}
return result.Prompts, nil
}
// GetPrompt gets a prompt from the server.
func (c *Client) GetPrompt(ctx context.Context, name string, args map[string]string) (*protocol.GetPromptResult, error) {
var (
params = &protocol.GetPromptParams{
Name: name,
Arguments: args,
}
result = &protocol.GetPromptResult{}
)
if err := call(ctx, c.conn, "prompts/get", params, result); err != nil {
return nil, err
}
return result, nil
}
// ListTools lists tools that are currently available on the server.
func (c *Client) ListTools(ctx context.Context) ([]protocol.Tool, error) {
var (
params = &protocol.ListToolsParams{}
result protocol.ListToolsResult
)
if err := call(ctx, c.conn, "tools/list", params, &result); err != nil {
return nil, err
}
return result.Tools, nil
}
// CallTool calls the tool with the given name and arguments.
func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (_ *protocol.CallToolResult, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("calling tool %q: %w", name, err)
}
}()
argsJSON := make(map[string]json.RawMessage)
for name, arg := range args {
argJSON, err := json.Marshal(arg)
if err != nil {
return nil, fmt.Errorf("marshaling argument %s: %v", name, err)
}
argsJSON[name] = argJSON
}
params := &protocol.CallToolParams{
Name: name,
Arguments: argsJSON,
}
return standardCall[protocol.CallToolResult](ctx, c.conn, "tools/call", params)
}
// ListResources lists the resources that are currently available on the server.
func (c *Client) ListResources(ctx context.Context, params *protocol.ListResourcesParams) (*protocol.ListResourcesResult, error) {
return standardCall[protocol.ListResourcesResult](ctx, c.conn, "resources/list", params)
}
// ReadResource ask the server to read a resource and return its contents.
func (c *Client) ReadResource(ctx context.Context, params *protocol.ReadResourceParams) (*protocol.ReadResourceResult, error) {
return standardCall[protocol.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
}