internal/mcp: support prompts

Add support for 'prompts/list' and 'prompts/get', and registering
prompts on the server using AddPrompts. Add a 'MakePrompt' helper to
construct prompts from a prompt handler using reflection.

Change-Id: I479cb9c9b99313cca99640c1f5ea1939363759e6
Reviewed-on: https://go-review.googlesource.com/c/tools/+/669015
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
This commit is contained in:
Rob Findley 2025-04-30 01:16:23 +00:00 • committed by Robert Findley
commit af4d138732
12 changed files with 531 additions and 98 deletions

View file

@ -150,6 +150,33 @@ func (sc *ServerConnection) Ping(ctx context.Context) error {
return call(ctx, sc.conn, "ping", nil, nil)
}
// ListPrompts lists prompts that are currently available on the server.
func (sc *ServerConnection) ListPrompts(ctx context.Context) ([]protocol.Prompt, error) {
var (
params = &protocol.ListPromptsParams{}
result protocol.ListPromptsResult
)
if err := call(ctx, sc.conn, "prompts/list", params, &result); err != nil {
return nil, err
}
return result.Prompts, nil
}
// GetPrompt gets a prompt from the server.
func (sc *ServerConnection) 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, sc.conn, "prompts/get", params, result); err != nil {
return nil, err
}
return result, nil
}
// ListTools lists tools that are currently available on the server.
func (sc *ServerConnection) ListTools(ctx context.Context) ([]protocol.Tool, error) {
var (
@ -164,23 +191,27 @@ func (sc *ServerConnection) ListTools(ctx context.Context) ([]protocol.Tool, err
// CallTool calls the tool with the given name and arguments.
//
// TODO: make the following true:
// TODO(jba): make the following true:
// If the provided arguments do not conform to the schema for the given tool,
// the call fails.
func (sc *ServerConnection) CallTool(ctx context.Context, name string, args any) (_ []Content, err error) {
func (sc *ServerConnection) CallTool(ctx context.Context, name string, args map[string]any) (_ []Content, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("calling tool %q: %w", name, err)
}
}()
argJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("marshaling args: %v", 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
}
var (
params = &protocol.CallToolParams{
Name: name,
Arguments: argJSON,
Arguments: argsJSON,
}
result protocol.CallToolResult
)

View file

@ -53,7 +53,7 @@ func TestCmdTransport(t *testing.T) {
if err != nil {
log.Fatal(err)
}
got, err := serverConn.CallTool(ctx, "greet", SayHiParams{Name: "user"})
got, err := serverConn.CallTool(ctx, "greet", map[string]any{"name": "user"})
if err != nil {
log.Fatal(err)
}

View file

@ -63,8 +63,12 @@
//
// # TODO
//
// - Support all content types.
// - Support pagination.
// - Support completion.
// - Support oauth.
// - Support all client/server operations.
// - Pass the client connection in the context.
// - Support streamable HTTP transport.
// - Support multiple versions of the spec.
// - Implement full JSON schema support, with both client-side and

View file

@ -6,6 +6,7 @@ package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
@ -41,9 +42,34 @@ func TestEndToEnd(t *testing.T) {
// The 'fail' tool returns this error.
failure := errors.New("mcp failure")
s.AddTools(MakeTool("fail", "just fail", func(context.Context, *ClientConnection, struct{}) ([]Content, error) {
return nil, failure
}))
s.AddTools(
MakeTool("fail", "just fail", func(context.Context, *ClientConnection, struct{}) ([]Content, error) {
return nil, failure
}),
)
s.AddPrompts(
MakePrompt("code_review", "do a code review", func(_ context.Context, _ *ClientConnection, params struct{ Code string }) (*protocol.GetPromptResult, error) {
// TODO(rfindley): clean up this handling of content.
content, err := json.Marshal(protocol.TextContent{
Type: "text",
Text: "Please review the following code: " + params.Code,
})
if err != nil {
return nil, err
}
return &protocol.GetPromptResult{
Description: "Code review prompt",
Messages: []protocol.PromptMessage{
// TODO: move 'Content' to the protocol package.
{Role: "user", Content: json.RawMessage(content)},
},
}, nil
}),
MakePrompt("fail", "", func(_ context.Context, _ *ClientConnection, params struct{}) (*protocol.GetPromptResult, error) {
return nil, failure
}),
)
// Connect the server.
cc, err := s.Connect(ctx, st, nil)
@ -80,6 +106,34 @@ func TestEndToEnd(t *testing.T) {
t.Fatalf("ping failed: %v", err)
}
gotPrompts, err := sc.ListPrompts(ctx)
if err != nil {
t.Errorf("prompts/list failed: %v", err)
}
wantPrompts := []protocol.Prompt{
{
Name: "code_review",
Description: "do a code review",
Arguments: []protocol.PromptArgument{{Name: "Code", Required: true}},
},
{Name: "fail"},
}
if diff := cmp.Diff(wantPrompts, gotPrompts); diff != "" {
t.Fatalf("prompts/list mismatch (-want +got):\n%s", diff)
}
gotReview, err := sc.GetPrompt(ctx, "code_review", map[string]string{"Code": "1+1"})
if err != nil {
t.Fatal(err)
}
// TODO: assert on the full review, once content is easier to create.
if got, want := gotReview.Description, "Code review prompt"; got != want {
t.Errorf("prompts/get 'code_review': got description %q, want %q", got, want)
}
if _, err := sc.GetPrompt(ctx, "fail", map[string]string{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
}
gotTools, err := sc.ListTools(ctx)
if err != nil {
t.Errorf("tools/list failed: %v", err)
@ -107,7 +161,7 @@ func TestEndToEnd(t *testing.T) {
t.Fatalf("tools/list mismatch (-want +got):\n%s", diff)
}
gotHi, err := sc.CallTool(ctx, "greet", hiParams{"user"})
gotHi, err := sc.CallTool(ctx, "greet", map[string]any{"name": "user"})
if err != nil {
t.Fatal(err)
}
@ -116,7 +170,7 @@ func TestEndToEnd(t *testing.T) {
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
}
if _, err := sc.CallTool(ctx, "fail", struct{}{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
if _, err := sc.CallTool(ctx, "fail", map[string]any{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
}
@ -175,12 +229,12 @@ func TestServerClosing(t *testing.T) {
}
wg.Done()
}()
if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); err != nil {
if _, err := sc.CallTool(ctx, "greet", map[string]any{"name": "user"}); err != nil {
t.Fatalf("after connecting: %v", err)
}
cc.Close()
wg.Wait()
if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); !errors.Is(err, ErrConnectionClosed) {
if _, err := sc.CallTool(ctx, "greet", map[string]any{"name": "user"}); !errors.Is(err, ErrConnectionClosed) {
t.Errorf("after disconnection, got error %v, want EOF", err)
}
}
@ -244,7 +298,7 @@ func TestCancellation(t *testing.T) {
defer sc.Close()
ctx, cancel := context.WithCancel(context.Background())
go sc.CallTool(ctx, "slow", struct{}{})
go sc.CallTool(ctx, "slow", map[string]any{})
<-start
cancel()
select {

122
mcp/prompt.go Normal file
View file

@ -0,0 +1,122 @@
// 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"
"reflect"
"slices"
"github.com/modelcontextprotocol/go-sdk/internal/jsonschema"
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
"github.com/modelcontextprotocol/go-sdk/internal/util"
)
// A PromptHandler handles a call to prompts/get.
type PromptHandler func(context.Context, *ClientConnection, map[string]string) (*protocol.GetPromptResult, error)
// A Prompt is a prompt definition bound to a prompt handler.
type Prompt struct {
Definition protocol.Prompt
Handler PromptHandler
}
// MakePrompt is a helper to use reflection to create a prompt for the given
// handler.
//
// The arguments for the prompt are extracted from the request type for the
// handler. The handler request type must be a struct consisting only of fields
// of type string or *string. The argument names for the resulting prompt
// definition correspond to the JSON names of the request fields, and any
// fields that are not marked "omitempty" are considered required.
func MakePrompt[TReq any](name, description string, handler func(context.Context, *ClientConnection, TReq) (*protocol.GetPromptResult, error), opts ...PromptOption) *Prompt {
schema, err := jsonschema.For[TReq]()
if err != nil {
panic(err)
}
if schema.Type != "object" || !reflect.DeepEqual(schema.AdditionalProperties, &jsonschema.Schema{Not: &jsonschema.Schema{}}) {
panic(fmt.Sprintf("handler request type must be a struct"))
}
prompt := &Prompt{
Definition: protocol.Prompt{
Name: name,
Description: description,
},
}
required := make(map[string]bool)
for _, p := range schema.Required {
required[p] = true
}
for name, prop := range util.Sorted(schema.Properties) {
if prop.Type != "string" {
panic(fmt.Sprintf("handler type must consist only of string fields"))
}
prompt.Definition.Arguments = append(prompt.Definition.Arguments, protocol.PromptArgument{
Name: name,
Description: prop.Description,
Required: required[name],
})
}
prompt.Handler = func(ctx context.Context, cc *ClientConnection, args map[string]string) (*protocol.GetPromptResult, error) {
// For simplicity, just marshal and unmarshal the arguments.
// This could be avoided in the future.
rawArgs, err := json.Marshal(args)
if err != nil {
return nil, err
}
var v TReq
if err := unmarshalSchema(rawArgs, schema, &v); err != nil {
return nil, err
}
return handler(ctx, cc, v)
}
for _, opt := range opts {
opt.set(prompt)
}
return prompt
}
// A PromptOption configures the behavior of a Prompt.
type PromptOption interface {
set(*Prompt)
}
type promptSetter func(*Prompt)
func (s promptSetter) set(p *Prompt) { s(p) }
// Argument configures the 'schema' of a prompt argument.
// If the argument does not exist, it is added.
//
// Since prompt arguments are not a full JSON schema, Argument only accepts
// Required and Description, and panics when encountering any other option.
func Argument(name string, opts ...SchemaOption) PromptOption {
return promptSetter(func(p *Prompt) {
i := slices.IndexFunc(p.Definition.Arguments, func(arg protocol.PromptArgument) bool {
return arg.Name == name
})
var arg protocol.PromptArgument
if i < 0 {
i = len(p.Definition.Arguments)
arg = protocol.PromptArgument{Name: name}
p.Definition.Arguments = append(p.Definition.Arguments, arg)
} else {
arg = p.Definition.Arguments[i]
}
for _, opt := range opts {
switch v := opt.(type) {
case required:
arg.Required = bool(v)
case description:
arg.Description = string(v)
default:
panic(fmt.Sprintf("unsupported prompt argument schema option %T", opt))
}
}
p.Definition.Arguments[i] = arg
})
}

54
mcp/prompt_test.go Normal file
View file

@ -0,0 +1,54 @@
// 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_test
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
)
// testPromptHandler is used for type inference in TestMakePrompt.
func testPromptHandler[T any](context.Context, *mcp.ClientConnection, T) (*protocol.GetPromptResult, error) {
panic("not implemented")
}
func TestMakePrompt(t *testing.T) {
tests := []struct {
prompt *mcp.Prompt
want []protocol.PromptArgument
}{
{
mcp.MakePrompt("empty", "", testPromptHandler[struct{}]),
nil,
},
{
mcp.MakePrompt("add_arg", "", testPromptHandler[struct{}], mcp.Argument("x")),
[]protocol.PromptArgument{{Name: "x"}},
},
{
mcp.MakePrompt("combo", "", testPromptHandler[struct {
Name string `json:"name"`
Country string `json:"country,omitempty"`
State string
}],
mcp.Argument("name", mcp.Description("the person's name")),
mcp.Argument("State", mcp.Required(false))),
[]protocol.PromptArgument{
{Name: "State"},
{Name: "country"},
{Name: "name", Required: true, Description: "the person's name"},
},
},
}
for _, test := range tests {
if diff := cmp.Diff(test.want, test.prompt.Definition.Arguments); diff != "" {
t.Errorf("MakePrompt(%v) mismatch (-want +got):\n%s", test.prompt.Definition.Name, diff)
}
}
}

View file

@ -26,6 +26,7 @@ type Server struct {
opts ServerOptions
mu sync.Mutex
prompts []*Prompt
tools []*Tool
clients []*ClientConnection
}
@ -53,6 +54,15 @@ func NewServer(name, version string, opts *ServerOptions) *Server {
}
}
// AddPrompts adds the given prompts to the server.
//
// TODO(rfindley): notify connected clients of any changes.
func (s *Server) AddPrompts(prompts ...*Prompt) {
s.mu.Lock()
defer s.mu.Unlock()
s.prompts = append(s.prompts, prompts...)
}
// AddTools adds the given tools to the server.
//
// TODO(rfindley): notify connected clients of any changes.
@ -71,6 +81,33 @@ func (s *Server) Clients() iter.Seq[*ClientConnection] {
return slices.Values(clients)
}
func (s *Server) listPrompts(_ context.Context, _ *ClientConnection, params *protocol.ListPromptsParams) (*protocol.ListPromptsResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
res := new(protocol.ListPromptsResult)
for _, p := range s.prompts {
res.Prompts = append(res.Prompts, p.Definition)
}
return res, nil
}
func (s *Server) getPrompt(ctx context.Context, cc *ClientConnection, params *protocol.GetPromptParams) (*protocol.GetPromptResult, error) {
s.mu.Lock()
var prompt *Prompt
if i := slices.IndexFunc(s.prompts, func(t *Prompt) bool {
return t.Definition.Name == params.Name
}); i >= 0 {
prompt = s.prompts[i]
}
s.mu.Unlock()
if prompt == nil {
return nil, fmt.Errorf("%s: unknown prompt %q", jsonrpc2.ErrInvalidParams, params.Name)
}
return prompt.Handler(ctx, cc, params.Arguments)
}
func (s *Server) listTools(_ context.Context, _ *ClientConnection, params *protocol.ListToolsParams) (*protocol.ListToolsResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -185,6 +222,12 @@ func (cc *ClientConnection) handle(ctx context.Context, req *jsonrpc2.Request) (
// The spec says that 'ping' expects an empty object result.
return struct{}{}, nil
case "prompts/list":
return dispatch(ctx, cc, req, cc.server.listPrompts)
case "prompts/get":
return dispatch(ctx, cc, req, cc.server.getPrompt)
case "tools/list":
return dispatch(ctx, cc, req, cc.server.listTools)
@ -216,8 +259,11 @@ func (cc *ClientConnection) initialize(ctx context.Context, _ *ClientConnection,
// TODO(rfindley): support multiple protocol versions.
ProtocolVersion: "2024-11-05",
Capabilities: protocol.ServerCapabilities{
Prompts: &protocol.PromptCapabilities{
ListChanged: false, // not yet supported
},
Tools: &protocol.ToolCapabilities{
ListChanged: true,
ListChanged: false, // not yet supported
},
},
Instructions: cc.server.opts.Instructions,
@ -249,5 +295,11 @@ func dispatch[TConn, TParams, TResult any](ctx context.Context, conn TConn, req
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, err
}
return f(ctx, conn, params)
// Important: avoid returning a typed nil, as it can't be handled by the
// jsonrpc2 package.
res, err := f(ctx, conn, params)
if err != nil {
return nil, err
}
return res, nil
}

View file

@ -40,7 +40,7 @@ func ExampleServer() {
log.Fatal(err)
}
content, err := serverConnection.CallTool(ctx, "greet", SayHiParams{Name: "user"})
content, err := serverConnection.CallTool(ctx, "greet", map[string]any{"name": "user"})
if err != nil {
log.Fatal(err)
}

View file

@ -40,7 +40,7 @@ func ExampleSSEHandler() {
}
defer serverConn.Close()
content, err := serverConn.CallTool(ctx, "add", AddParams{1, 2})
content, err := serverConn.CallTool(ctx, "add", map[string]any{"x": 1, "y": 2})
if err != nil {
log.Fatal(err)
}

View file

@ -44,7 +44,7 @@ func TestSSEServer(t *testing.T) {
t.Fatal(err)
}
cc := <-clients
gotHi, err := sc.CallTool(ctx, "greet", hiParams{"user"})
gotHi, err := sc.CallTool(ctx, "greet", map[string]any{"name": "user"})
if err != nil {
t.Fatal(err)
}

View file

@ -14,7 +14,7 @@ import (
)
// A ToolHandler handles a call to tools/call.
type ToolHandler func(context.Context, *ClientConnection, json.RawMessage) (*protocol.CallToolResult, error)
type ToolHandler func(context.Context, *ClientConnection, map[string]json.RawMessage) (*protocol.CallToolResult, error)
// A Tool is a tool definition that is bound to a tool handler.
type Tool struct {
@ -30,20 +30,30 @@ type Tool struct {
// handler, and used to unmmarshal and validate requests to the handler. This
// schema may be customized using the [Input] option.
//
// It is the caller's responsibility that the handler request type can produce
// a valid schema, as documented by [jsonschema.ForType]; otherwise, MakeTool
// panics.
// The handler request type must translate to a valid schema, as documented by
// [jsonschema.ForType]; otherwise, MakeTool panics.
//
// TODO: just have the handler return a CallToolResult: returning []Content is
// going to be inconsistent with other server features.
func MakeTool[TReq any](name, description string, handler func(context.Context, *ClientConnection, TReq) ([]Content, error), opts ...ToolOption) *Tool {
schema, err := jsonschema.For[TReq]()
if err != nil {
panic(err)
}
wrapped := func(ctx context.Context, cc *ClientConnection, args json.RawMessage) (*protocol.CallToolResult, error) {
wrapped := func(ctx context.Context, cc *ClientConnection, args map[string]json.RawMessage) (*protocol.CallToolResult, error) {
// For simplicity, just marshal and unmarshal the arguments.
// This could be avoided in the future.
rawArgs, err := json.Marshal(args)
if err != nil {
return nil, err
}
var v TReq
if err := unmarshalSchema(args, schema, &v); err != nil {
if err := unmarshalSchema(rawArgs, schema, &v); err != nil {
return nil, err
}
content, err := handler(ctx, cc, v)
// TODO: investigate why server errors are embedded in this strange way,
// rather than returned as jsonrpc2 server errors.
if err != nil {
return &protocol.CallToolResult{
Content: marshalContent([]Content{TextContent{Text: err.Error()}}),
@ -140,6 +150,8 @@ 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) {
@ -154,10 +166,16 @@ func Enum(values ...any) SchemaOption {
}
// Description sets the provided schema description.
func Description(description string) SchemaOption {
return schemaSetter(func(schema *jsonschema.Schema) {
schema.Description = 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

View file

@ -13,8 +13,8 @@ import (
"github.com/modelcontextprotocol/go-sdk/internal/jsonschema"
)
// testHandler is used for type inference in TestMakeTool.
func testHandler[T any](context.Context, *mcp.ClientConnection, T) ([]mcp.Content, error) {
// testToolHandler is used for type inference in TestMakeTool.
func testToolHandler[T any](context.Context, *mcp.ClientConnection, T) ([]mcp.Content, error) {
panic("not implemented")
}
@ -24,7 +24,7 @@ func TestMakeTool(t *testing.T) {
want *jsonschema.Schema
}{
{
mcp.MakeTool("basic", "", testHandler[struct {
mcp.MakeTool("basic", "", testToolHandler[struct {
Name string `json:"name"`
}]),
&jsonschema.Schema{
@ -37,7 +37,7 @@ func TestMakeTool(t *testing.T) {
},
},
{
mcp.MakeTool("enum", "", testHandler[struct{ Name string }], mcp.Input(
mcp.MakeTool("enum", "", testToolHandler[struct{ Name string }], mcp.Input(
mcp.Property("Name", mcp.Enum("x", "y", "z")),
)),
&jsonschema.Schema{
@ -50,7 +50,7 @@ func TestMakeTool(t *testing.T) {
},
},
{
mcp.MakeTool("required", "", testHandler[struct {
mcp.MakeTool("required", "", testToolHandler[struct {
Name string `json:"name"`
Language string `json:"language"`
X int `json:"x,omitempty"`
@ -70,7 +70,7 @@ func TestMakeTool(t *testing.T) {
},
},
{
mcp.MakeTool("set_schema", "", testHandler[struct {
mcp.MakeTool("set_schema", "", testToolHandler[struct {
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
}], mcp.Input(