mcp: implement completion (#55)

This CL implements a completion handler for the server and a complete
method for the client.

Fixes: #12 
<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
This adds support for completion.

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->
Added tests and an example.

## Breaking Changes
<!-- Will users need to update their code or configurations? -->
N/A

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->
This commit is contained in:
Sam Thanawalla 2025-07-01 13:09:15 -04:00 • committed by GitHub
commit e1b1cd0072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 579 additions and 5 deletions

View file

@ -0,0 +1,49 @@
// 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 main
import (
"context"
"fmt"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// This example demonstrates the minimal code to declare and assign
// a CompletionHandler to an MCP Server's options.
func main() {
// Define your custom CompletionHandler logic.
myCompletionHandler := func(_ context.Context, _ *mcp.ServerSession, params *mcp.CompleteParams) (*mcp.CompleteResult, error) {
// In a real application, you'd implement actual completion logic here.
// For this example, we return a fixed set of suggestions.
var suggestions []string
switch params.Ref.Type {
case "ref/prompt":
suggestions = []string{"suggestion1", "suggestion2", "suggestion3"}
case "ref/resource":
suggestions = []string{"suggestion4", "suggestion5", "suggestion6"}
default:
return nil, fmt.Errorf("unrecognized content type %s", params.Ref.Type)
}
return &mcp.CompleteResult{
Completion: mcp.CompletionResultDetails{
HasMore: false,
Total: len(suggestions),
Values: suggestions,
},
}, nil
}
// Create the MCP Server instance and assign the handler.
// No server running, just showing the configuration.
_ = mcp.NewServer("myServer", "v1.0.0", &mcp.ServerOptions{
CompletionHandler: myCompletionHandler,
})
log.Println("MCP Server instance created with a CompletionHandler assigned (but not running).")
log.Println("This example demonstrates configuration, not live interaction.")
}

View file

@ -257,6 +257,7 @@ func (c *Client) AddReceivingMiddleware(middleware ...Middleware[*ClientSession]
// clientMethodInfos maps from the RPC method name to serverMethodInfos.
var clientMethodInfos = map[string]methodInfo{
methodComplete: newMethodInfo(sessionMethod((*ClientSession).Complete)),
methodPing: newMethodInfo(sessionMethod((*ClientSession).ping)),
methodListRoots: newMethodInfo(clientMethod((*Client).listRoots)),
methodCreateMessage: newMethodInfo(clientMethod((*Client).createMessage)),
@ -352,6 +353,10 @@ func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceP
return handleSend[*ReadResourceResult](ctx, cs, methodReadResource, orZero[Params](params))
}
func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) {
return handleSend[*CompleteResult](ctx, cs, methodComplete, orZero[Params](params))
}
func (c *Client) callToolChangedHandler(ctx context.Context, s *ClientSession, params *ToolListChangedParams) (Result, error) {
return callNotificationHandler(ctx, c.opts.ToolListChangedHandler, s, params)
}

View file

@ -12,6 +12,7 @@ package mcp
import (
"encoding/json"
"fmt"
"github.com/modelcontextprotocol/go-sdk/jsonschema"
)
@ -133,6 +134,93 @@ type ClientCapabilities struct {
Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"`
}
type CompleteParamsArgument struct {
// The name of the argument
Name string `json:"name"`
// The value of the argument to use for completion matching.
Value string `json:"value"`
}
// CompleteContext represents additional, optional context for completions.
type CompleteContext struct {
// Previously-resolved variables in a URI template or prompt.
Arguments map[string]string `json:"arguments,omitempty"`
}
// CompleteReference represents a completion reference type (ref/prompt ref/resource).
// The Type field determines which other fields are relevant.
type CompleteReference struct {
Type string `json:"type"`
// Name is relevant when Type is "ref/prompt".
Name string `json:"name,omitempty"`
// URI is relevant when Type is "ref/resource".
URI string `json:"uri,omitempty"`
}
func (r *CompleteReference) UnmarshalJSON(data []byte) error {
type wireCompleteReference CompleteReference // for naive unmarshaling
var r2 wireCompleteReference
if err := json.Unmarshal(data, &r2); err != nil {
return err
}
switch r2.Type {
case "ref/prompt", "ref/resource":
if r2.Type == "ref/prompt" && r2.URI != "" {
return fmt.Errorf("reference of type %q must not have a URI set", r2.Type)
}
if r2.Type == "ref/resource" && r2.Name != "" {
return fmt.Errorf("reference of type %q must not have a Name set", r2.Type)
}
default:
return fmt.Errorf("unrecognized content type %q", r2.Type)
}
*r = CompleteReference(r2)
return nil
}
func (r *CompleteReference) MarshalJSON() ([]byte, error) {
// Validation for marshalling: ensure consistency before converting to JSON.
switch r.Type {
case "ref/prompt":
if r.URI != "" {
return nil, fmt.Errorf("reference of type %q must not have a URI set for marshalling", r.Type)
}
case "ref/resource":
if r.Name != "" {
return nil, fmt.Errorf("reference of type %q must not have a Name set for marshalling", r.Type)
}
default:
return nil, fmt.Errorf("unrecognized reference type %q for marshalling", r.Type)
}
type wireReference CompleteReference
return json.Marshal(wireReference(*r))
}
type CompleteParams struct {
// This property is reserved by the protocol to allow clients and servers to
// attach additional metadata to their responses.
Meta `json:"_meta,omitempty"`
// The argument's information
Argument CompleteParamsArgument `json:"argument"`
Context *CompleteContext `json:"context,omitempty"`
Ref *CompleteReference `json:"ref"`
}
type CompletionResultDetails struct {
HasMore bool `json:"hasMore,omitempty"`
Total int `json:"total,omitempty"`
Values []string `json:"values"`
}
// The server's response to a completion/complete request
type CompleteResult struct {
// This property is reserved by the protocol to allow clients and servers to
// attach additional metadata to their responses.
Meta `json:"_meta,omitempty"`
Completion CompletionResultDetails `json:"completion"`
}
type CreateMessageParams struct {
// This property is reserved by the protocol to allow clients and servers to
// attach additional metadata to their responses.
@ -787,6 +875,9 @@ type implementation struct {
Version string `json:"version"`
}
// Present if the server supports argument autocompletion suggestions.
type completionCapabilities struct{}
// Present if the server supports sending log messages to the client.
type loggingCapabilities struct{}
@ -809,7 +900,7 @@ type resourceCapabilities struct {
// additional capabilities.
type serverCapabilities struct {
// Present if the server supports argument autocompletion suggestions.
Completions struct{} `json:"completions,omitempty"`
Completions *completionCapabilities `json:"completions,omitempty"`
// Experimental, non-standard capabilities that the server supports.
Experimental map[string]struct{} `json:"experimental,omitempty"`
// Present if the server supports sending log messages to the client.

View file

@ -70,6 +70,423 @@ func TestParamsMeta(t *testing.T) {
p.SetProgressToken(int64(1))
}
func TestCompleteReference(t *testing.T) {
marshalTests := []struct {
name string
in CompleteReference // The Go struct to marshal
want string // The expected JSON string output
wantErr bool // True if json.Marshal is expected to return an error
}{
{
name: "ValidPrompt",
in: CompleteReference{Type: "ref/prompt", Name: "my_prompt"},
want: `{"type":"ref/prompt","name":"my_prompt"}`,
wantErr: false,
},
{
name: "ValidResource",
in: CompleteReference{Type: "ref/resource", URI: "file:///path/to/resource.txt"},
want: `{"type":"ref/resource","uri":"file:///path/to/resource.txt"}`,
wantErr: false,
},
{
name: "ValidPromptEmptyName",
in: CompleteReference{Type: "ref/prompt", Name: ""},
want: `{"type":"ref/prompt"}`,
wantErr: false,
},
{
name: "ValidResourceEmptyURI",
in: CompleteReference{Type: "ref/resource", URI: ""},
want: `{"type":"ref/resource"}`,
wantErr: false,
},
// Error cases for MarshalJSON
{
name: "InvalidType",
in: CompleteReference{Type: "ref/unknown", Name: "something"},
wantErr: true,
},
{
name: "PromptWithURI",
in: CompleteReference{Type: "ref/prompt", Name: "my_prompt", URI: "unexpected_uri"},
wantErr: true,
},
{
name: "ResourceWithName",
in: CompleteReference{Type: "ref/resource", URI: "my_uri", Name: "unexpected_name"},
wantErr: true,
},
{
name: "MissingTypeField",
in: CompleteReference{Name: "missing"}, // Type is ""
wantErr: true,
},
}
// Define test cases specifically for Unmarshalling
unmarshalTests := []struct {
name string
in string // The JSON string input
want CompleteReference // The expected Go struct output
wantErr bool // True if json.Unmarshal is expected to return an error
}{
{
name: "ValidPrompt",
in: `{"type":"ref/prompt","name":"my_prompt"}`,
want: CompleteReference{Type: "ref/prompt", Name: "my_prompt"},
wantErr: false,
},
{
name: "ValidResource",
in: `{"type":"ref/resource","uri":"file:///path/to/resource.txt"}`,
want: CompleteReference{Type: "ref/resource", URI: "file:///path/to/resource.txt"},
wantErr: false,
},
// Error cases for UnmarshalJSON
{
name: "UnrecognizedType",
in: `{"type":"ref/unknown","name":"something"}`,
want: CompleteReference{}, // placeholder, as unmarshal will fail
wantErr: true,
},
{
name: "PromptWithURI",
in: `{"type":"ref/prompt","name":"my_prompt","uri":"unexpected_uri"}`,
want: CompleteReference{}, // placeholder
wantErr: true,
},
{
name: "ResourceWithName",
in: `{"type":"ref/resource","uri":"my_uri","name":"unexpected_name"}`,
want: CompleteReference{}, // placeholder
wantErr: true,
},
{
name: "MissingType",
in: `{"name":"missing"}`,
want: CompleteReference{}, // placeholder
wantErr: true,
},
{
name: "InvalidJSON",
in: `invalid json`,
want: CompleteReference{}, // placeholder
wantErr: true, // json.Unmarshal will fail natively
},
}
// Run Marshal Tests
for _, test := range marshalTests {
t.Run("Marshal/"+test.name, func(t *testing.T) {
gotBytes, err := json.Marshal(&test.in)
if (err != nil) != test.wantErr {
t.Errorf("json.Marshal(%v) got error %v (want error %t)", test.in, err, test.wantErr)
}
if !test.wantErr { // Only check JSON output if marshal was expected to succeed
if diff := cmp.Diff(test.want, string(gotBytes)); diff != "" {
t.Errorf("json.Marshal(%v) mismatch (-want +got):\n%s", test.in, diff)
}
}
})
}
// Run Unmarshal Tests
for _, test := range unmarshalTests {
t.Run("Unmarshal/"+test.name, func(t *testing.T) {
var got CompleteReference
err := json.Unmarshal([]byte(test.in), &got)
if (err != nil) != test.wantErr {
t.Errorf("json.Unmarshal(%q) got error %v (want error %t)", test.in, err, test.wantErr)
}
if !test.wantErr { // Only check content if unmarshal was expected to succeed
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("json.Unmarshal(%q) mismatch (-want +got):\n%s", test.in, diff)
}
}
})
}
}
func TestCompleteParams(t *testing.T) {
// Define test cases specifically for Marshalling
marshalTests := []struct {
name string
in CompleteParams
want string // Expected JSON output
}{
{
name: "BasicPromptCompletion",
in: CompleteParams{
Ref: &CompleteReference{
Type: "ref/prompt",
Name: "my_prompt",
},
Argument: CompleteParamsArgument{
Name: "language",
Value: "go",
},
},
want: `{"argument":{"name":"language","value":"go"},"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
},
{
name: "ResourceCompletionRequest",
in: CompleteParams{
Ref: &CompleteReference{
Type: "ref/resource",
URI: "file:///src/main.java",
},
Argument: CompleteParamsArgument{
Name: "class",
Value: "MyClas",
},
},
want: `{"argument":{"name":"class","value":"MyClas"},"ref":{"type":"ref/resource","uri":"file:///src/main.java"}}`,
},
{
name: "PromptCompletionEmptyArgumentValue",
in: CompleteParams{
Ref: &CompleteReference{
Type: "ref/prompt",
Name: "another_prompt",
},
Argument: CompleteParamsArgument{
Name: "query",
Value: "",
},
},
want: `{"argument":{"name":"query","value":""},"ref":{"type":"ref/prompt","name":"another_prompt"}}`,
},
{
name: "PromptCompletionWithContext",
in: CompleteParams{
Ref: &CompleteReference{
Type: "ref/prompt",
Name: "my_prompt",
},
Argument: CompleteParamsArgument{
Name: "language",
Value: "go",
},
Context: &CompleteContext{
Arguments: map[string]string{
"framework": "mcp",
"language": "python",
},
},
},
want: `{"argument":{"name":"language","value":"go"},"context":{"arguments":{"framework":"mcp","language":"python"}},"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
},
{
name: "PromptCompletionEmptyContextArguments",
in: CompleteParams{
Ref: &CompleteReference{
Type: "ref/prompt",
Name: "my_prompt",
},
Argument: CompleteParamsArgument{
Name: "language",
Value: "go",
},
Context: &CompleteContext{
Arguments: map[string]string{},
},
},
want: `{"argument":{"name":"language","value":"go"},"context":{},"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
},
}
// Define test cases specifically for Unmarshalling
unmarshalTests := []struct {
name string
in string // JSON string input
want CompleteParams // Expected Go struct output
}{
{
name: "BasicPromptCompletion",
in: `{"argument":{"name":"language","value":"go"},"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
want: CompleteParams{
Ref: &CompleteReference{Type: "ref/prompt", Name: "my_prompt"},
Argument: CompleteParamsArgument{Name: "language", Value: "go"},
},
},
{
name: "ResourceCompletionRequest",
in: `{"argument":{"name":"class","value":"MyClas"},"ref":{"type":"ref/resource","uri":"file:///src/main.java"}}`,
want: CompleteParams{
Ref: &CompleteReference{Type: "ref/resource", URI: "file:///src/main.java"},
Argument: CompleteParamsArgument{Name: "class", Value: "MyClas"},
},
},
{
name: "PromptCompletionWithContext",
in: `{"argument":{"name":"language","value":"go"},"context":{"arguments":{"framework":"mcp","language":"python"}},"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
want: CompleteParams{
Ref: &CompleteReference{Type: "ref/prompt", Name: "my_prompt"},
Argument: CompleteParamsArgument{Name: "language", Value: "go"},
Context: &CompleteContext{Arguments: map[string]string{
"framework": "mcp",
"language": "python",
}},
},
},
{
name: "PromptCompletionEmptyContextArguments",
in: `{"argument":{"name":"language","value":"go"},"context":{"arguments":{}},"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
want: CompleteParams{
Ref: &CompleteReference{Type: "ref/prompt", Name: "my_prompt"},
Argument: CompleteParamsArgument{Name: "language", Value: "go"},
Context: &CompleteContext{Arguments: map[string]string{}},
},
},
{
name: "PromptCompletionNilContext", // JSON `null` for context
in: `{"argument":{"name":"language","value":"go"},"context":null,"ref":{"type":"ref/prompt","name":"my_prompt"}}`,
want: CompleteParams{
Ref: &CompleteReference{Type: "ref/prompt", Name: "my_prompt"},
Argument: CompleteParamsArgument{Name: "language", Value: "go"},
Context: nil, // Should unmarshal to nil pointer
},
},
}
// Run Marshal Tests
for _, test := range marshalTests {
t.Run("Marshal/"+test.name, func(t *testing.T) {
got, err := json.Marshal(&test.in) // Marshal takes a pointer
if err != nil {
t.Fatalf("json.Marshal(CompleteParams) failed: %v", err)
}
if diff := cmp.Diff(test.want, string(got)); diff != "" {
t.Errorf("CompleteParams marshal mismatch (-want +got):\n%s", diff)
}
})
}
// Run Unmarshal Tests
for _, test := range unmarshalTests {
t.Run("Unmarshal/"+test.name, func(t *testing.T) {
var got CompleteParams
if err := json.Unmarshal([]byte(test.in), &got); err != nil {
t.Fatalf("json.Unmarshal(CompleteParams) failed: %v", err)
}
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("CompleteParams unmarshal mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCompleteResult(t *testing.T) {
// Define test cases specifically for Marshalling
marshalTests := []struct {
name string
in CompleteResult
want string // Expected JSON output
}{
{
name: "BasicCompletionResult",
in: CompleteResult{
Completion: CompletionResultDetails{
Values: []string{"golang", "google", "goroutine"},
Total: 10,
HasMore: true,
},
},
want: `{"completion":{"hasMore":true,"total":10,"values":["golang","google","goroutine"]}}`,
},
{
name: "CompletionResultNoTotalNoHasMore",
in: CompleteResult{
Completion: CompletionResultDetails{
Values: []string{"only"},
HasMore: false,
Total: 0,
},
},
want: `{"completion":{"values":["only"]}}`,
},
{
name: "CompletionResultEmptyValues",
in: CompleteResult{
Completion: CompletionResultDetails{
Values: []string{},
Total: 0,
HasMore: false,
},
},
want: `{"completion":{"values":[]}}`,
},
}
// Define test cases specifically for Unmarshalling
unmarshalTests := []struct {
name string
in string // JSON string input
want CompleteResult // Expected Go struct output
}{
{
name: "BasicCompletionResult",
in: `{"completion":{"hasMore":true,"total":10,"values":["golang","google","goroutine"]}}`,
want: CompleteResult{
Completion: CompletionResultDetails{
Values: []string{"golang", "google", "goroutine"},
Total: 10,
HasMore: true,
},
},
},
{
name: "CompletionResultNoTotalNoHasMore",
in: `{"completion":{"values":["only"]}}`,
want: CompleteResult{
Completion: CompletionResultDetails{
Values: []string{"only"},
HasMore: false,
Total: 0,
},
},
},
{
name: "CompletionResultEmptyValues",
in: `{"completion":{"hasMore":false,"total":0,"values":[]}}`,
want: CompleteResult{
Completion: CompletionResultDetails{
Values: []string{},
Total: 0,
HasMore: false,
},
},
},
}
// Run Marshal Tests
for _, test := range marshalTests {
t.Run("Marshal/"+test.name, func(t *testing.T) {
got, err := json.Marshal(&test.in) // Marshal takes a pointer
if err != nil {
t.Fatalf("json.Marshal(CompleteResult) failed: %v", err)
}
if diff := cmp.Diff(test.want, string(got)); diff != "" {
t.Errorf("CompleteResult marshal mismatch (-want +got):\n%s", diff)
}
})
}
// Run Unmarshal Tests
for _, test := range unmarshalTests {
t.Run("Unmarshal/"+test.name, func(t *testing.T) {
var got CompleteResult
if err := json.Unmarshal([]byte(test.in), &got); err != nil {
t.Fatalf("json.Unmarshal(CompleteResult) failed: %v", err)
}
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("CompleteResult unmarshal mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestContentUnmarshal(t *testing.T) {
// Verify that types with a Content field round-trip properly.
roundtrip := func(in, out any) {

View file

@ -58,6 +58,8 @@ type ServerOptions struct {
RootsListChangedHandler func(context.Context, *ServerSession, *RootsListChangedParams)
// If non-nil, called when "notifications/progress" is received.
ProgressNotificationHandler func(context.Context, *ServerSession, *ProgressNotificationParams)
// If non-nil, called when "completion/complete" is received.
CompletionHandler func(context.Context, *ServerSession, *CompleteParams) (*CompleteResult, error)
// If non-zero, defines an interval for regular "ping" requests.
// If the peer fails to respond to pings originating from the keepalive check,
// the session is automatically closed.
@ -231,6 +233,13 @@ func (s *Server) RemoveResourceTemplates(uriTemplates ...string) {
func() bool { return s.resourceTemplates.remove(uriTemplates...) })
}
func (s *Server) complete(ctx context.Context, ss *ServerSession, params *CompleteParams) (Result, error) {
if s.opts.CompletionHandler == nil {
return nil, jsonrpc2.ErrMethodNotFound
}
return s.opts.CompletionHandler(ctx, ss, params)
}
// 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.
@ -570,6 +579,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware[*ServerSession]
// serverMethodInfos maps from the RPC method name to serverMethodInfos.
var serverMethodInfos = map[string]methodInfo{
methodComplete: newMethodInfo(serverMethod((*Server).complete)),
methodInitialize: newMethodInfo(sessionMethod((*ServerSession).initialize)),
methodPing: newMethodInfo(sessionMethod((*ServerSession).ping)),
methodListPrompts: newMethodInfo(serverMethod((*Server).listPrompts)),
@ -653,6 +663,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
// reject unsupported features.
ProtocolVersion: version,
Capabilities: &serverCapabilities{
Completions: &completionCapabilities{},
Prompts: &promptCapabilities{
ListChanged: true,
},

View file

@ -143,10 +143,11 @@ func TestStreamableServerTransport(t *testing.T) {
initReq := req(1, "initialize", &InitializeParams{})
initResp := resp(1, &InitializeResult{
Capabilities: &serverCapabilities{
Logging: &loggingCapabilities{},
Prompts: &promptCapabilities{ListChanged: true},
Resources: &resourceCapabilities{ListChanged: true},
Tools: &toolCapabilities{ListChanged: true},
Completions: &completionCapabilities{},
Logging: &loggingCapabilities{},
Prompts: &promptCapabilities{ListChanged: true},
Resources: &resourceCapabilities{ListChanged: true},
Tools: &toolCapabilities{ListChanged: true},
},
ProtocolVersion: "2025-03-26",
ServerInfo: &implementation{Name: "testServer", Version: "v1.0.0"},