Skip to content

Commit c058c6a

Browse files
authored
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
1 parent 62db914 commit c058c6a

File tree

6 files changed

+836
-1
lines changed

6 files changed

+836
-1
lines changed

mcp/client.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ package mcp
66

77
import (
88
"context"
9+
"encoding/json"
910
"fmt"
1011
"iter"
1112
"slices"
1213
"sync"
1314
"time"
1415

16+
"github.com/google/jsonschema-go/jsonschema"
1517
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
1618
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
1719
)
@@ -56,6 +58,9 @@ type ClientOptions struct {
5658
// Handler for sampling.
5759
// Called when a server calls CreateMessage.
5860
CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error)
61+
// Handler for elicitation.
62+
// Called when a server requests user input via Elicit.
63+
ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error)
5964
// Handlers for notifications from the server.
6065
ToolListChangedHandler func(context.Context, *ToolListChangedRequest)
6166
PromptListChangedHandler func(context.Context, *PromptListChangedRequest)
@@ -111,6 +116,9 @@ func (c *Client) capabilities() *ClientCapabilities {
111116
if c.opts.CreateMessageHandler != nil {
112117
caps.Sampling = &SamplingCapabilities{}
113118
}
119+
if c.opts.ElicitationHandler != nil {
120+
caps.Elicitation = &ElicitationCapabilities{}
121+
}
114122
return caps
115123
}
116124

@@ -268,6 +276,168 @@ func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (
268276
return c.opts.CreateMessageHandler(ctx, req)
269277
}
270278

279+
func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
280+
if c.opts.ElicitationHandler == nil {
281+
// TODO: wrap or annotate this error? Pick a standard code?
282+
return nil, jsonrpc2.NewError(CodeUnsupportedMethod, "client does not support elicitation")
283+
}
284+
285+
// Validate that the requested schema only contains top-level properties without nesting
286+
if err := validateElicitSchema(req.Params.RequestedSchema); err != nil {
287+
return nil, jsonrpc2.NewError(CodeInvalidParams, err.Error())
288+
}
289+
290+
res, err := c.opts.ElicitationHandler(ctx, req)
291+
if err != nil {
292+
return nil, err
293+
}
294+
295+
// Validate elicitation result content against requested schema
296+
if req.Params.RequestedSchema != nil && res.Content != nil {
297+
resolved, err := req.Params.RequestedSchema.Resolve(nil)
298+
if err != nil {
299+
return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
300+
}
301+
302+
if err := resolved.Validate(res.Content); err != nil {
303+
return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
304+
}
305+
}
306+
307+
return res, nil
308+
}
309+
310+
// validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements.
311+
// Per the MCP specification, elicitation schemas are limited to flat objects with primitive properties only.
312+
func validateElicitSchema(schema *jsonschema.Schema) error {
313+
if schema == nil {
314+
return nil // nil schema is allowed
315+
}
316+
317+
// The root schema must be of type "object" if specified
318+
if schema.Type != "" && schema.Type != "object" {
319+
return fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type)
320+
}
321+
322+
// Check if the schema has properties
323+
if schema.Properties != nil {
324+
for propName, propSchema := range schema.Properties {
325+
if propSchema == nil {
326+
continue
327+
}
328+
329+
if err := validateElicitProperty(propName, propSchema); err != nil {
330+
return err
331+
}
332+
}
333+
}
334+
335+
return nil
336+
}
337+
338+
// validateElicitProperty validates a single property in an elicitation schema.
339+
func validateElicitProperty(propName string, propSchema *jsonschema.Schema) error {
340+
// Check if this property has nested properties (not allowed)
341+
if len(propSchema.Properties) > 0 {
342+
return fmt.Errorf("elicit schema property %q contains nested properties, only primitive properties are allowed", propName)
343+
}
344+
345+
// Validate based on the property type - only primitives are supported
346+
switch propSchema.Type {
347+
case "string":
348+
return validateElicitStringProperty(propName, propSchema)
349+
case "number", "integer":
350+
return validateElicitNumberProperty(propName, propSchema)
351+
case "boolean":
352+
return validateElicitBooleanProperty(propName, propSchema)
353+
default:
354+
return fmt.Errorf("elicit schema property %q has unsupported type %q, only string, number, integer, and boolean are allowed", propName, propSchema.Type)
355+
}
356+
}
357+
358+
// validateElicitStringProperty validates string-type properties, including enums.
359+
func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema) error {
360+
// Handle enum validation (enums are a special case of strings)
361+
if len(propSchema.Enum) > 0 {
362+
// Enums must be string type (or untyped which defaults to string)
363+
if propSchema.Type != "" && propSchema.Type != "string" {
364+
return fmt.Errorf("elicit schema property %q has enum values but type is %q, enums are only supported for string type", propName, propSchema.Type)
365+
}
366+
// Enum values themselves are validated by the JSON schema library
367+
// Validate enumNames if present - must match enum length
368+
if propSchema.Extra != nil {
369+
if enumNamesRaw, exists := propSchema.Extra["enumNames"]; exists {
370+
// Type check enumNames - should be a slice
371+
if enumNamesSlice, ok := enumNamesRaw.([]interface{}); ok {
372+
if len(enumNamesSlice) != len(propSchema.Enum) {
373+
return fmt.Errorf("elicit schema property %q has %d enum values but %d enumNames, they must match", propName, len(propSchema.Enum), len(enumNamesSlice))
374+
}
375+
} else {
376+
return fmt.Errorf("elicit schema property %q has invalid enumNames type, must be an array", propName)
377+
}
378+
}
379+
}
380+
return nil
381+
}
382+
383+
// Validate format if specified - only specific formats are allowed
384+
if propSchema.Format != "" {
385+
allowedFormats := map[string]bool{
386+
"email": true,
387+
"uri": true,
388+
"date": true,
389+
"date-time": true,
390+
}
391+
if !allowedFormats[propSchema.Format] {
392+
return fmt.Errorf("elicit schema property %q has unsupported format %q, only email, uri, date, and date-time are allowed", propName, propSchema.Format)
393+
}
394+
}
395+
396+
// Validate minLength constraint if specified
397+
if propSchema.MinLength != nil {
398+
if *propSchema.MinLength < 0 {
399+
return fmt.Errorf("elicit schema property %q has invalid minLength %d, must be non-negative", propName, *propSchema.MinLength)
400+
}
401+
}
402+
403+
// Validate maxLength constraint if specified
404+
if propSchema.MaxLength != nil {
405+
if *propSchema.MaxLength < 0 {
406+
return fmt.Errorf("elicit schema property %q has invalid maxLength %d, must be non-negative", propName, *propSchema.MaxLength)
407+
}
408+
// Check that maxLength >= minLength if both are specified
409+
if propSchema.MinLength != nil && *propSchema.MaxLength < *propSchema.MinLength {
410+
return fmt.Errorf("elicit schema property %q has maxLength %d less than minLength %d", propName, *propSchema.MaxLength, *propSchema.MinLength)
411+
}
412+
}
413+
414+
return nil
415+
}
416+
417+
// validateElicitNumberProperty validates number and integer-type properties.
418+
func validateElicitNumberProperty(propName string, propSchema *jsonschema.Schema) error {
419+
if propSchema.Minimum != nil && propSchema.Maximum != nil {
420+
if *propSchema.Maximum < *propSchema.Minimum {
421+
return fmt.Errorf("elicit schema property %q has maximum %g less than minimum %g", propName, *propSchema.Maximum, *propSchema.Minimum)
422+
}
423+
}
424+
425+
return nil
426+
}
427+
428+
// validateElicitBooleanProperty validates boolean-type properties.
429+
func validateElicitBooleanProperty(propName string, propSchema *jsonschema.Schema) error {
430+
// Validate default value if specified - must be a valid boolean
431+
if propSchema.Default != nil {
432+
var defaultValue bool
433+
if err := json.Unmarshal(propSchema.Default, &defaultValue); err != nil {
434+
return fmt.Errorf("elicit schema property %q has invalid default value, must be a boolean: %v", propName, err)
435+
}
436+
}
437+
438+
return nil
439+
}
440+
271441
// AddSendingMiddleware wraps the current sending method handler using the provided
272442
// middleware. Middleware is applied from right to left, so that the first one is
273443
// executed first.
@@ -308,6 +478,7 @@ var clientMethodInfos = map[string]methodInfo{
308478
methodPing: newClientMethodInfo(clientSessionMethod((*ClientSession).ping), missingParamsOK),
309479
methodListRoots: newClientMethodInfo(clientMethod((*Client).listRoots), missingParamsOK),
310480
methodCreateMessage: newClientMethodInfo(clientMethod((*Client).createMessage), 0),
481+
methodElicit: newClientMethodInfo(clientMethod((*Client).elicit), missingParamsOK),
311482
notificationCancelled: newClientMethodInfo(clientSessionMethod((*ClientSession).cancel), notification|missingParamsOK),
312483
notificationToolListChanged: newClientMethodInfo(clientMethod((*Client).callToolChangedHandler), notification|missingParamsOK),
313484
notificationPromptListChanged: newClientMethodInfo(clientMethod((*Client).callPromptChangedHandler), notification|missingParamsOK),

mcp/elicitation_example_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp_test
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"log"
11+
12+
"github.com/google/jsonschema-go/jsonschema"
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
)
15+
16+
func Example_elicitation() {
17+
ctx := context.Background()
18+
clientTransport, serverTransport := mcp.NewInMemoryTransports()
19+
20+
// Create server
21+
server := mcp.NewServer(&mcp.Implementation{Name: "config-server", Version: "v1.0.0"}, nil)
22+
23+
serverSession, err := server.Connect(ctx, serverTransport, nil)
24+
if err != nil {
25+
log.Fatal(err)
26+
}
27+
28+
// Create client with elicitation handler
29+
// Note: Never use elicitation for sensitive data like API keys or passwords
30+
client := mcp.NewClient(&mcp.Implementation{Name: "config-client", Version: "v1.0.0"}, &mcp.ClientOptions{
31+
ElicitationHandler: func(ctx context.Context, request *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
32+
fmt.Printf("Server requests: %s\n", request.Params.Message)
33+
34+
// In a real application, this would prompt the user for input
35+
// Here we simulate user providing configuration data
36+
return &mcp.ElicitResult{
37+
Action: "accept",
38+
Content: map[string]any{
39+
"serverEndpoint": "https://api.example.com",
40+
"maxRetries": float64(3),
41+
"enableLogs": true,
42+
},
43+
}, nil
44+
},
45+
})
46+
47+
_, err = client.Connect(ctx, clientTransport, nil)
48+
if err != nil {
49+
log.Fatal(err)
50+
}
51+
52+
// Server requests user configuration via elicitation
53+
configSchema := &jsonschema.Schema{
54+
Type: "object",
55+
Properties: map[string]*jsonschema.Schema{
56+
"serverEndpoint": {Type: "string", Description: "Server endpoint URL"},
57+
"maxRetries": {Type: "number", Minimum: ptr(1.0), Maximum: ptr(10.0)},
58+
"enableLogs": {Type: "boolean", Description: "Enable debug logging"},
59+
},
60+
Required: []string{"serverEndpoint"},
61+
}
62+
63+
result, err := serverSession.Elicit(ctx, &mcp.ElicitParams{
64+
Message: "Please provide your configuration settings",
65+
RequestedSchema: configSchema,
66+
})
67+
if err != nil {
68+
log.Fatal(err)
69+
}
70+
71+
if result.Action == "accept" {
72+
fmt.Printf("Configuration received: Endpoint: %v, Max Retries: %.0f, Logs: %v\n",
73+
result.Content["serverEndpoint"],
74+
result.Content["maxRetries"],
75+
result.Content["enableLogs"])
76+
}
77+
78+
// Output:
79+
// Server requests: Please provide your configuration settings
80+
// Configuration received: Endpoint: https://api.example.com, Max Retries: 3, Logs: true
81+
}
82+
83+
// ptr is a helper function to create pointers for schema constraints
84+
func ptr[T any](v T) *T {
85+
return &v
86+
}

0 commit comments

Comments
 (0)