feat/Issue-13: elicitation support (#188)
fixes: #13 Implements elicitation functionality from MCP 2025-06-18 specification. Changes - Add ElicitParams and ElicitResult protocol types - Add ServerSession.Elicit() method and ClientOptions.ElicitationHandler - Schema validation enforces top-level properties only - Support for accept/decline/cancel actions with progress tokens Tests - Integration test in TestEndToEnd - Schema validation, error handling, and capability declaration tests
This commit is contained in:
parent
62db914082
commit
c058c6a93b
6 changed files with 836 additions and 1 deletions
171
mcp/client.go
171
mcp/client.go
|
|
@ -6,12 +6,14 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
|
@ -56,6 +58,9 @@ type ClientOptions struct {
|
|||
// Handler for sampling.
|
||||
// Called when a server calls CreateMessage.
|
||||
CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error)
|
||||
// Handler for elicitation.
|
||||
// Called when a server requests user input via Elicit.
|
||||
ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error)
|
||||
// Handlers for notifications from the server.
|
||||
ToolListChangedHandler func(context.Context, *ToolListChangedRequest)
|
||||
PromptListChangedHandler func(context.Context, *PromptListChangedRequest)
|
||||
|
|
@ -111,6 +116,9 @@ func (c *Client) capabilities() *ClientCapabilities {
|
|||
if c.opts.CreateMessageHandler != nil {
|
||||
caps.Sampling = &SamplingCapabilities{}
|
||||
}
|
||||
if c.opts.ElicitationHandler != nil {
|
||||
caps.Elicitation = &ElicitationCapabilities{}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +276,168 @@ func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (
|
|||
return c.opts.CreateMessageHandler(ctx, req)
|
||||
}
|
||||
|
||||
func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
|
||||
if c.opts.ElicitationHandler == nil {
|
||||
// TODO: wrap or annotate this error? Pick a standard code?
|
||||
return nil, jsonrpc2.NewError(CodeUnsupportedMethod, "client does not support elicitation")
|
||||
}
|
||||
|
||||
// Validate that the requested schema only contains top-level properties without nesting
|
||||
if err := validateElicitSchema(req.Params.RequestedSchema); err != nil {
|
||||
return nil, jsonrpc2.NewError(CodeInvalidParams, err.Error())
|
||||
}
|
||||
|
||||
res, err := c.opts.ElicitationHandler(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate elicitation result content against requested schema
|
||||
if req.Params.RequestedSchema != nil && res.Content != nil {
|
||||
resolved, err := req.Params.RequestedSchema.Resolve(nil)
|
||||
if err != nil {
|
||||
return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
|
||||
}
|
||||
|
||||
if err := resolved.Validate(res.Content); err != nil {
|
||||
return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements.
|
||||
// Per the MCP specification, elicitation schemas are limited to flat objects with primitive properties only.
|
||||
func validateElicitSchema(schema *jsonschema.Schema) error {
|
||||
if schema == nil {
|
||||
return nil // nil schema is allowed
|
||||
}
|
||||
|
||||
// The root schema must be of type "object" if specified
|
||||
if schema.Type != "" && schema.Type != "object" {
|
||||
return fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type)
|
||||
}
|
||||
|
||||
// Check if the schema has properties
|
||||
if schema.Properties != nil {
|
||||
for propName, propSchema := range schema.Properties {
|
||||
if propSchema == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := validateElicitProperty(propName, propSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateElicitProperty validates a single property in an elicitation schema.
|
||||
func validateElicitProperty(propName string, propSchema *jsonschema.Schema) error {
|
||||
// Check if this property has nested properties (not allowed)
|
||||
if len(propSchema.Properties) > 0 {
|
||||
return fmt.Errorf("elicit schema property %q contains nested properties, only primitive properties are allowed", propName)
|
||||
}
|
||||
|
||||
// Validate based on the property type - only primitives are supported
|
||||
switch propSchema.Type {
|
||||
case "string":
|
||||
return validateElicitStringProperty(propName, propSchema)
|
||||
case "number", "integer":
|
||||
return validateElicitNumberProperty(propName, propSchema)
|
||||
case "boolean":
|
||||
return validateElicitBooleanProperty(propName, propSchema)
|
||||
default:
|
||||
return fmt.Errorf("elicit schema property %q has unsupported type %q, only string, number, integer, and boolean are allowed", propName, propSchema.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// validateElicitStringProperty validates string-type properties, including enums.
|
||||
func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema) error {
|
||||
// Handle enum validation (enums are a special case of strings)
|
||||
if len(propSchema.Enum) > 0 {
|
||||
// Enums must be string type (or untyped which defaults to string)
|
||||
if propSchema.Type != "" && propSchema.Type != "string" {
|
||||
return fmt.Errorf("elicit schema property %q has enum values but type is %q, enums are only supported for string type", propName, propSchema.Type)
|
||||
}
|
||||
// Enum values themselves are validated by the JSON schema library
|
||||
// Validate enumNames if present - must match enum length
|
||||
if propSchema.Extra != nil {
|
||||
if enumNamesRaw, exists := propSchema.Extra["enumNames"]; exists {
|
||||
// Type check enumNames - should be a slice
|
||||
if enumNamesSlice, ok := enumNamesRaw.([]interface{}); ok {
|
||||
if len(enumNamesSlice) != len(propSchema.Enum) {
|
||||
return fmt.Errorf("elicit schema property %q has %d enum values but %d enumNames, they must match", propName, len(propSchema.Enum), len(enumNamesSlice))
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("elicit schema property %q has invalid enumNames type, must be an array", propName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate format if specified - only specific formats are allowed
|
||||
if propSchema.Format != "" {
|
||||
allowedFormats := map[string]bool{
|
||||
"email": true,
|
||||
"uri": true,
|
||||
"date": true,
|
||||
"date-time": true,
|
||||
}
|
||||
if !allowedFormats[propSchema.Format] {
|
||||
return fmt.Errorf("elicit schema property %q has unsupported format %q, only email, uri, date, and date-time are allowed", propName, propSchema.Format)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate minLength constraint if specified
|
||||
if propSchema.MinLength != nil {
|
||||
if *propSchema.MinLength < 0 {
|
||||
return fmt.Errorf("elicit schema property %q has invalid minLength %d, must be non-negative", propName, *propSchema.MinLength)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate maxLength constraint if specified
|
||||
if propSchema.MaxLength != nil {
|
||||
if *propSchema.MaxLength < 0 {
|
||||
return fmt.Errorf("elicit schema property %q has invalid maxLength %d, must be non-negative", propName, *propSchema.MaxLength)
|
||||
}
|
||||
// Check that maxLength >= minLength if both are specified
|
||||
if propSchema.MinLength != nil && *propSchema.MaxLength < *propSchema.MinLength {
|
||||
return fmt.Errorf("elicit schema property %q has maxLength %d less than minLength %d", propName, *propSchema.MaxLength, *propSchema.MinLength)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateElicitNumberProperty validates number and integer-type properties.
|
||||
func validateElicitNumberProperty(propName string, propSchema *jsonschema.Schema) error {
|
||||
if propSchema.Minimum != nil && propSchema.Maximum != nil {
|
||||
if *propSchema.Maximum < *propSchema.Minimum {
|
||||
return fmt.Errorf("elicit schema property %q has maximum %g less than minimum %g", propName, *propSchema.Maximum, *propSchema.Minimum)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateElicitBooleanProperty validates boolean-type properties.
|
||||
func validateElicitBooleanProperty(propName string, propSchema *jsonschema.Schema) error {
|
||||
// Validate default value if specified - must be a valid boolean
|
||||
if propSchema.Default != nil {
|
||||
var defaultValue bool
|
||||
if err := json.Unmarshal(propSchema.Default, &defaultValue); err != nil {
|
||||
return fmt.Errorf("elicit schema property %q has invalid default value, must be a boolean: %v", propName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -308,6 +478,7 @@ var clientMethodInfos = map[string]methodInfo{
|
|||
methodPing: newClientMethodInfo(clientSessionMethod((*ClientSession).ping), missingParamsOK),
|
||||
methodListRoots: newClientMethodInfo(clientMethod((*Client).listRoots), missingParamsOK),
|
||||
methodCreateMessage: newClientMethodInfo(clientMethod((*Client).createMessage), 0),
|
||||
methodElicit: newClientMethodInfo(clientMethod((*Client).elicit), missingParamsOK),
|
||||
notificationCancelled: newClientMethodInfo(clientSessionMethod((*ClientSession).cancel), notification|missingParamsOK),
|
||||
notificationToolListChanged: newClientMethodInfo(clientMethod((*Client).callToolChangedHandler), notification|missingParamsOK),
|
||||
notificationPromptListChanged: newClientMethodInfo(clientMethod((*Client).callPromptChangedHandler), notification|missingParamsOK),
|
||||
|
|
|
|||
86
mcp/elicitation_example_test.go
Normal file
86
mcp/elicitation_example_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// 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"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func Example_elicitation() {
|
||||
ctx := context.Background()
|
||||
clientTransport, serverTransport := mcp.NewInMemoryTransports()
|
||||
|
||||
// Create server
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "config-server", Version: "v1.0.0"}, nil)
|
||||
|
||||
serverSession, err := server.Connect(ctx, serverTransport, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create client with elicitation handler
|
||||
// Note: Never use elicitation for sensitive data like API keys or passwords
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "config-client", Version: "v1.0.0"}, &mcp.ClientOptions{
|
||||
ElicitationHandler: func(ctx context.Context, request *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
|
||||
fmt.Printf("Server requests: %s\n", request.Params.Message)
|
||||
|
||||
// In a real application, this would prompt the user for input
|
||||
// Here we simulate user providing configuration data
|
||||
return &mcp.ElicitResult{
|
||||
Action: "accept",
|
||||
Content: map[string]any{
|
||||
"serverEndpoint": "https://api.example.com",
|
||||
"maxRetries": float64(3),
|
||||
"enableLogs": true,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
_, err = client.Connect(ctx, clientTransport, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Server requests user configuration via elicitation
|
||||
configSchema := &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"serverEndpoint": {Type: "string", Description: "Server endpoint URL"},
|
||||
"maxRetries": {Type: "number", Minimum: ptr(1.0), Maximum: ptr(10.0)},
|
||||
"enableLogs": {Type: "boolean", Description: "Enable debug logging"},
|
||||
},
|
||||
Required: []string{"serverEndpoint"},
|
||||
}
|
||||
|
||||
result, err := serverSession.Elicit(ctx, &mcp.ElicitParams{
|
||||
Message: "Please provide your configuration settings",
|
||||
RequestedSchema: configSchema,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if result.Action == "accept" {
|
||||
fmt.Printf("Configuration received: Endpoint: %v, Max Retries: %.0f, Logs: %v\n",
|
||||
result.Content["serverEndpoint"],
|
||||
result.Content["maxRetries"],
|
||||
result.Content["enableLogs"])
|
||||
}
|
||||
|
||||
// Output:
|
||||
// Server requests: Please provide your configuration settings
|
||||
// Configuration received: Endpoint: https://api.example.com, Max Retries: 3, Logs: true
|
||||
}
|
||||
|
||||
// ptr is a helper function to create pointers for schema constraints
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
540
mcp/mcp_test.go
540
mcp/mcp_test.go
|
|
@ -7,6 +7,7 @@ package mcp
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -132,6 +133,15 @@ func TestEndToEnd(t *testing.T) {
|
|||
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
|
||||
return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil
|
||||
},
|
||||
ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
|
||||
return &ElicitResult{
|
||||
Action: "accept",
|
||||
Content: map[string]any{
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {
|
||||
notificationChans["tools"] <- 0
|
||||
},
|
||||
|
|
@ -474,6 +484,19 @@ func TestEndToEnd(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("elicitation", func(t *testing.T) {
|
||||
result, err := ss.Elicit(ctx, &ElicitParams{
|
||||
Message: "Please provide information",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Action != "accept" {
|
||||
t.Errorf("got action %q, want %q", result.Action, "accept")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Disconnect.
|
||||
cs.Close()
|
||||
clientWG.Wait()
|
||||
|
|
@ -906,6 +929,518 @@ func TestKeepAlive(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestElicitationUnsupportedMethod(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ct, st := NewInMemoryTransports()
|
||||
|
||||
// Server
|
||||
s := NewServer(testImpl, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
// Client without ElicitationHandler
|
||||
c := NewClient(testImpl, &ClientOptions{
|
||||
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
|
||||
return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// Test that elicitation fails when no handler is provided
|
||||
_, err = ss.Elicit(ctx, &ElicitParams{
|
||||
Message: "This should fail",
|
||||
RequestedSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"test": {Type: "string"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error when ElicitationHandler is not provided, got nil")
|
||||
}
|
||||
if code := errorCode(err); code != CodeUnsupportedMethod {
|
||||
t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, CodeUnsupportedMethod)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not support elicitation") {
|
||||
t.Errorf("error should mention unsupported elicitation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElicitationSchemaValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ct, st := NewInMemoryTransports()
|
||||
|
||||
s := NewServer(testImpl, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
c := NewClient(testImpl, &ClientOptions{
|
||||
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
||||
return &ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// Test valid schemas - these should not return errors
|
||||
validSchemas := []struct {
|
||||
name string
|
||||
schema *jsonschema.Schema
|
||||
}{
|
||||
{
|
||||
name: "nil schema",
|
||||
schema: nil,
|
||||
},
|
||||
{
|
||||
name: "empty object schema",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "simple string property",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string with valid formats",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"email": {Type: "string", Format: "email"},
|
||||
"website": {Type: "string", Format: "uri"},
|
||||
"birthday": {Type: "string", Format: "date"},
|
||||
"created": {Type: "string", Format: "date-time"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string with constraints",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string", MinLength: ptr(1), MaxLength: ptr(100)},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "number with constraints",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"age": {Type: "integer", Minimum: ptr(0.0), Maximum: ptr(150.0)},
|
||||
"score": {Type: "number", Minimum: ptr(0.0), Maximum: ptr(100.0)},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "boolean with default",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"enabled": {Type: "boolean", Default: json.RawMessage("true")},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string enum",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"status": {
|
||||
Type: "string",
|
||||
Enum: []any{
|
||||
"active",
|
||||
"inactive",
|
||||
"pending",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enum with matching enumNames",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"priority": {
|
||||
Type: "string",
|
||||
Enum: []any{
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"enumNames": []interface{}{"High Priority", "Medium Priority", "Low Priority"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range validSchemas {
|
||||
t.Run("valid_"+tc.name, func(t *testing.T) {
|
||||
_, err := ss.Elicit(ctx, &ElicitParams{
|
||||
Message: "Test valid schema: " + tc.name,
|
||||
RequestedSchema: tc.schema,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for valid schema %q, got: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test invalid schemas - these should return errors
|
||||
invalidSchemas := []struct {
|
||||
name string
|
||||
schema *jsonschema.Schema
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "root schema non-object type",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "string",
|
||||
},
|
||||
expectedError: "elicit schema must be of type 'object', got \"string\"",
|
||||
},
|
||||
{
|
||||
name: "nested object property",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"user": {
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"user\" contains nested properties, only primitive properties are allowed",
|
||||
},
|
||||
{
|
||||
name: "property with explicit object type",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"config": {Type: "object"},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"config\" has unsupported type \"object\", only string, number, integer, and boolean are allowed",
|
||||
},
|
||||
{
|
||||
name: "array property",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"tags": {Type: "array", Items: &jsonschema.Schema{Type: "string"}},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"tags\" has unsupported type \"array\", only string, number, integer, and boolean are allowed",
|
||||
},
|
||||
{
|
||||
name: "array without items",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"items": {Type: "array"},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"items\" has unsupported type \"array\", only string, number, integer, and boolean are allowed",
|
||||
},
|
||||
{
|
||||
name: "unsupported string format",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"phone": {Type: "string", Format: "phone"},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"phone\" has unsupported format \"phone\", only email, uri, date, and date-time are allowed",
|
||||
},
|
||||
{
|
||||
name: "unsupported type",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"data": {Type: "null"},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"data\" has unsupported type \"null\"",
|
||||
},
|
||||
{
|
||||
name: "string with invalid minLength",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string", MinLength: ptr(-1)},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"name\" has invalid minLength -1, must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "string with invalid maxLength",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string", MaxLength: ptr(-5)},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"name\" has invalid maxLength -5, must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "string with maxLength less than minLength",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string", MinLength: ptr(10), MaxLength: ptr(5)},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"name\" has maxLength 5 less than minLength 10",
|
||||
},
|
||||
{
|
||||
name: "number with maximum less than minimum",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"score": {Type: "number", Minimum: ptr(100.0), Maximum: ptr(50.0)},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"score\" has maximum 50 less than minimum 100",
|
||||
},
|
||||
{
|
||||
name: "boolean with invalid default",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"enabled": {Type: "boolean", Default: json.RawMessage(`"not-a-boolean"`)},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"enabled\" has invalid default value, must be a boolean",
|
||||
},
|
||||
{
|
||||
name: "enum with mismatched enumNames length",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"priority": {
|
||||
Type: "string",
|
||||
Enum: []any{
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"enumNames": []interface{}{"High Priority", "Medium Priority"}, // Only 2 names for 3 values
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"priority\" has 3 enum values but 2 enumNames, they must match",
|
||||
},
|
||||
{
|
||||
name: "enum with invalid enumNames type",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"status": {
|
||||
Type: "string",
|
||||
Enum: []any{
|
||||
"active",
|
||||
"inactive",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"enumNames": "not an array", // Should be array
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"status\" has invalid enumNames type, must be an array",
|
||||
},
|
||||
{
|
||||
name: "enum without explicit type",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"priority": {
|
||||
Enum: []any{
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"priority\" has unsupported type \"\", only string, number, integer, and boolean are allowed",
|
||||
},
|
||||
{
|
||||
name: "untyped property",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"data": {},
|
||||
},
|
||||
},
|
||||
expectedError: "elicit schema property \"data\" has unsupported type \"\", only string, number, integer, and boolean are allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range invalidSchemas {
|
||||
t.Run("invalid_"+tc.name, func(t *testing.T) {
|
||||
_, err := ss.Elicit(ctx, &ElicitParams{
|
||||
Message: "Test invalid schema: " + tc.name,
|
||||
RequestedSchema: tc.schema,
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid schema %q, got nil", tc.name)
|
||||
return
|
||||
}
|
||||
if code := errorCode(err); code != CodeInvalidParams {
|
||||
t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, CodeInvalidParams)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.expectedError) {
|
||||
t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestElicitationProgressToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ct, st := NewInMemoryTransports()
|
||||
|
||||
s := NewServer(testImpl, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
c := NewClient(testImpl, &ClientOptions{
|
||||
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
||||
return &ElicitResult{Action: "accept"}, nil
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
params := &ElicitParams{
|
||||
Message: "Test progress token",
|
||||
Meta: Meta{},
|
||||
}
|
||||
params.SetProgressToken("test-token")
|
||||
|
||||
if token := params.GetProgressToken(); token != "test-token" {
|
||||
t.Errorf("got progress token %v, want %q", token, "test-token")
|
||||
}
|
||||
|
||||
_, err = ss.Elicit(ctx, params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElicitationCapabilityDeclaration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("with_handler", func(t *testing.T) {
|
||||
ct, st := NewInMemoryTransports()
|
||||
|
||||
// Client with ElicitationHandler should declare capability
|
||||
c := NewClient(testImpl, &ClientOptions{
|
||||
ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) {
|
||||
return &ElicitResult{Action: "cancel"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
s := NewServer(testImpl, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// The client should have declared elicitation capability during initialization
|
||||
// We can verify this worked by successfully making an elicitation call
|
||||
result, err := ss.Elicit(ctx, &ElicitParams{
|
||||
Message: "Test capability",
|
||||
RequestedSchema: &jsonschema.Schema{Type: "object"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("elicitation should work when capability is declared, got error: %v", err)
|
||||
}
|
||||
if result.Action != "cancel" {
|
||||
t.Errorf("got action %q, want %q", result.Action, "cancel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without_handler", func(t *testing.T) {
|
||||
ct, st := NewInMemoryTransports()
|
||||
|
||||
// Client without ElicitationHandler should not declare capability
|
||||
c := NewClient(testImpl, &ClientOptions{
|
||||
CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) {
|
||||
return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
s := NewServer(testImpl, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// Elicitation should fail with UnsupportedMethod
|
||||
_, err = ss.Elicit(ctx, &ElicitParams{
|
||||
Message: "This should fail",
|
||||
RequestedSchema: &jsonschema.Schema{Type: "object"},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected UnsupportedMethod error when no capability declared")
|
||||
}
|
||||
if code := errorCode(err); code != CodeUnsupportedMethod {
|
||||
t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, CodeUnsupportedMethod)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeepAliveFailure(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
|
@ -1185,3 +1720,8 @@ func TestPointerArgEquivalence(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ptr is a helper function to create pointers for schema constraints
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -968,7 +968,39 @@ func (*ResourceUpdatedNotificationParams) isParams() {}
|
|||
|
||||
// TODO(jba): add CompleteRequest and related types.
|
||||
|
||||
// TODO(jba): add ElicitRequest and related types.
|
||||
// A request from the server to elicit additional information from the user via the client.
|
||||
type ElicitParams 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 message to present to the user.
|
||||
Message string `json:"message"`
|
||||
// A restricted subset of JSON Schema.
|
||||
// Only top-level properties are allowed, without nesting.
|
||||
RequestedSchema *jsonschema.Schema `json:"requestedSchema"`
|
||||
}
|
||||
|
||||
func (x *ElicitParams) isParams() {}
|
||||
|
||||
func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) }
|
||||
func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) }
|
||||
|
||||
// The client's response to an elicitation/create request from the server.
|
||||
type ElicitResult 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 user action in response to the elicitation.
|
||||
// - "accept": User submitted the form/confirmed the action
|
||||
// - "decline": User explicitly declined the action
|
||||
// - "cancel": User dismissed without making an explicit choice
|
||||
Action string `json:"action"`
|
||||
// The submitted form data, only present when action is "accept".
|
||||
// Contains values matching the requested schema.
|
||||
Content map[string]any `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
func (*ElicitResult) isResult() {}
|
||||
|
||||
// An Implementation describes the name and version of an MCP implementation, with an optional
|
||||
// title for UI representation.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type (
|
|||
|
||||
type (
|
||||
CreateMessageRequest = ClientRequest[*CreateMessageParams]
|
||||
ElicitRequest = ClientRequest[*ElicitParams]
|
||||
InitializedClientRequest = ClientRequest[*InitializedParams]
|
||||
InitializeRequest = ClientRequest[*InitializeParams]
|
||||
ListRootsRequest = ClientRequest[*ListRootsParams]
|
||||
|
|
|
|||
|
|
@ -834,6 +834,11 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag
|
|||
return handleSend[*CreateMessageResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params)))
|
||||
}
|
||||
|
||||
// Elicit sends an elicitation request to the client asking for user input.
|
||||
func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*ElicitResult, error) {
|
||||
return handleSend[*ElicitResult](ctx, methodElicit, newServerRequest(ss, orZero[Params](params)))
|
||||
}
|
||||
|
||||
// Log sends a log message to the client.
|
||||
// The message is not sent if the client has not called SetLevel, or if its level
|
||||
// is below that of the last SetLevel.
|
||||
|
|
|
|||
Loading…
Reference in a new issue