Skip to content

Commit bd02820

Browse files
manusaaljesusg
authored andcommitted
feat(mcp): add URL-mode elicitation support (containers#862)
Extend the Elicitor interface to support URL-mode elicitation added in go-sdk v1.4.0. Refactor Elicit() to accept an ElicitParams struct (instead of separate message/schema arguments) with fields for both form mode (Message, RequestedSchema) and URL mode (URL, ElicitationID). Fix error detection for mode-specific unsupported elicitation errors by matching all three go-sdk error variants. Follow-up to containers#804. Signed-off-by: Marc Nuri <[email protected]>
1 parent 30d2ff6 commit bd02820

4 files changed

Lines changed: 167 additions & 10 deletions

File tree

internal/test/mcp.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type mcpClientConfig struct {
2626
endpoint string
2727
allowConnectionError bool
2828
elicitationHandler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error)
29+
capabilities *mcp.ClientCapabilities
2930
}
3031

3132
// httpHeaderOption sets custom HTTP headers
@@ -112,6 +113,21 @@ func WithElicitationHandler(handler func(context.Context, *mcp.ElicitRequest) (*
112113
return elicitationHandlerOption{handler: handler}
113114
}
114115

116+
// clientCapabilitiesOption sets custom client capabilities on the MCP client
117+
type clientCapabilitiesOption struct {
118+
capabilities *mcp.ClientCapabilities
119+
}
120+
121+
func (o clientCapabilitiesOption) apply(c *mcpClientConfig) {
122+
c.capabilities = o.capabilities
123+
}
124+
125+
// WithClientCapabilities sets explicit client capabilities on the MCP client.
126+
// This overrides capabilities inferred from handlers (e.g., elicitation mode support).
127+
func WithClientCapabilities(capabilities *mcp.ClientCapabilities) McpClientOption {
128+
return clientCapabilitiesOption{capabilities: capabilities}
129+
}
130+
115131
// headerRoundTripper injects HTTP headers into requests
116132
type headerRoundTripper struct {
117133
base http.RoundTripper
@@ -202,6 +218,7 @@ func NewMcpClient(t *testing.T, mcpHttpServer http.Handler, options ...McpClient
202218
// Create go-sdk client with notification handlers
203219
clientOptions := &mcp.ClientOptions{
204220
ElicitationHandler: cfg.elicitationHandler,
221+
Capabilities: cfg.capabilities,
205222
ToolListChangedHandler: func(_ context.Context, req *mcp.ToolListChangedRequest) {
206223
ret.notifications.capture(&CapturedNotification{
207224
Method: "notifications/tools/list_changed",

pkg/api/toolsets.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,26 @@ type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error)
110110

111111
// Elicitor provides a mechanism for tools and prompts to request additional information
112112
// from the user during execution via the MCP elicitation protocol.
113-
// The elicitation request is forwarded to the MCP client, which presents a form to the user.
113+
// It supports two modes:
114+
// - Form mode: presents a schema-based form to the user (set Message and RequestedSchema in ElicitParams).
115+
// - URL mode: directs the user to a URL (set Message, URL, and optionally ElicitationID in ElicitParams).
116+
//
114117
// See MCP specification: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
115118
type Elicitor interface {
116-
Elicit(ctx context.Context, message string, requestedSchema *jsonschema.Schema) (*ElicitResult, error)
119+
Elicit(ctx context.Context, params *ElicitParams) (*ElicitResult, error)
120+
}
121+
122+
// ElicitParams contains the parameters for an elicitation request.
123+
// The elicitation mode is inferred from the fields: if URL is set, URL mode is used; otherwise form mode.
124+
type ElicitParams struct {
125+
// Message is the message to present to the user.
126+
Message string
127+
// RequestedSchema is a JSON Schema defining the expected form fields. Used in form mode only.
128+
RequestedSchema *jsonschema.Schema
129+
// URL is the URL to present to the user. Used in URL mode only.
130+
URL string
131+
// ElicitationID is a tracking identifier for out-of-band URL elicitation completion. Used in URL mode only.
132+
ElicitationID string
117133
}
118134

119135
// ElicitAction constants define the possible user responses to an elicitation request.
@@ -129,7 +145,7 @@ const (
129145

130146
// ElicitResult represents the user's response to an elicitation request.
131147
type ElicitResult struct {
132-
// Action is one of ElicitActionAccept, ElicitActionDecline, or ElicitActionCancel.
148+
// Action is one of the ElicitAction constants.
133149
Action string
134150
// Content contains the submitted form data. Only populated when Action is ElicitActionAccept.
135151
Content map[string]any

pkg/mcp/elicit.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88

99
"github.com/containers/kubernetes-mcp-server/pkg/api"
1010
"github.com/containers/kubernetes-mcp-server/pkg/mcplog"
11-
"github.com/google/jsonschema-go/jsonschema"
1211
"github.com/modelcontextprotocol/go-sdk/mcp"
1312
)
1413

@@ -20,15 +19,24 @@ type sessionElicitor struct{}
2019

2120
var _ api.Elicitor = &sessionElicitor{}
2221

23-
func (s *sessionElicitor) Elicit(ctx context.Context, message string, requestedSchema *jsonschema.Schema) (*api.ElicitResult, error) {
22+
func (s *sessionElicitor) Elicit(ctx context.Context, params *api.ElicitParams) (*api.ElicitResult, error) {
2423
session, ok := ctx.Value(mcplog.MCPSessionContextKey).(*mcp.ServerSession)
2524
if !ok || session == nil {
2625
return nil, fmt.Errorf("no MCP session found in context")
2726
}
2827

29-
result, err := session.Elicit(ctx, &mcp.ElicitParams{Message: message, RequestedSchema: requestedSchema})
28+
result, err := session.Elicit(ctx, &mcp.ElicitParams{
29+
Message: params.Message,
30+
RequestedSchema: params.RequestedSchema,
31+
URL: params.URL,
32+
ElicitationID: params.ElicitationID,
33+
})
3034
if err != nil {
31-
if strings.Contains(err.Error(), "does not support elicitation") {
35+
// The go-sdk does not export a typed error for unsupported elicitation.
36+
// This string check mirrors the go-sdk's own test approach (mcp/mcp_test.go).
37+
// The go-sdk returns three variants: "client does not support elicitation",
38+
// "client does not support "form" elicitation", and "client does not support "url" elicitation".
39+
if strings.Contains(err.Error(), "does not support") && strings.Contains(err.Error(), "elicitation") {
3240
return nil, fmt.Errorf("%w: %s", ErrElicitationNotSupported, err.Error())
3341
}
3442
return nil, err

pkg/mcp/elicit_test.go

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mcp
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"testing"
78

89
"github.com/containers/kubernetes-mcp-server/internal/test"
@@ -59,7 +60,7 @@ func (s *ElicitationSuite) registerElicitingToolset(handler api.ToolHandlerFunc)
5960

6061
func (s *ElicitationSuite) TestElicitationAccepted() {
6162
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
62-
result, err := params.Elicit(params.Context, "Please confirm", nil)
63+
result, err := params.Elicit(params.Context, &api.ElicitParams{Message: "Please confirm"})
6364
if err != nil {
6465
return nil, err
6566
}
@@ -90,7 +91,7 @@ func (s *ElicitationSuite) TestElicitationAccepted() {
9091

9192
func (s *ElicitationSuite) TestElicitationDeclined() {
9293
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
93-
result, err := params.Elicit(params.Context, "Please confirm", nil)
94+
result, err := params.Elicit(params.Context, &api.ElicitParams{Message: "Please confirm"})
9495
if err != nil {
9596
return nil, err
9697
}
@@ -118,7 +119,7 @@ func (s *ElicitationSuite) TestElicitationDeclined() {
118119

119120
func (s *ElicitationSuite) TestElicitationWithUnsupportedClient() {
120121
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
121-
_, err := params.Elicit(params.Context, "Please confirm", nil)
122+
_, err := params.Elicit(params.Context, &api.ElicitParams{Message: "Please confirm"})
122123
if err != nil {
123124
if errors.Is(err, ErrElicitationNotSupported) {
124125
return api.NewToolCallResult("fallback-result", nil), nil
@@ -144,6 +145,121 @@ func (s *ElicitationSuite) TestElicitationWithUnsupportedClient() {
144145
})
145146
}
146147

148+
func (s *ElicitationSuite) TestElicitationURLAccepted() {
149+
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
150+
result, err := params.Elicit(params.Context, &api.ElicitParams{
151+
Message: "Please complete the form",
152+
URL: "https://example.com/form",
153+
})
154+
if err != nil {
155+
return nil, err
156+
}
157+
return api.NewToolCallResult("action="+result.Action, nil), nil
158+
})
159+
160+
s.InitMcpClient(
161+
test.WithElicitationHandler(
162+
func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
163+
return &mcp.ElicitResult{Action: "accept"}, nil
164+
},
165+
),
166+
test.WithClientCapabilities(&mcp.ClientCapabilities{
167+
Elicitation: &mcp.ElicitationCapabilities{
168+
URL: &mcp.URLElicitationCapabilities{},
169+
},
170+
}),
171+
)
172+
173+
toolResult, err := s.CallTool("elicit_test_tool", map[string]any{})
174+
175+
s.Run("returns accepted action for URL elicitation", func() {
176+
s.NoError(err)
177+
s.Require().NotNil(toolResult)
178+
s.False(toolResult.IsError)
179+
s.Require().Len(toolResult.Content, 1)
180+
textContent, ok := toolResult.Content[0].(*mcp.TextContent)
181+
s.Require().True(ok)
182+
s.Equal("action=accept", textContent.Text)
183+
})
184+
}
185+
186+
func (s *ElicitationSuite) TestElicitationURLWithElicitationID() {
187+
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
188+
result, err := params.Elicit(params.Context, &api.ElicitParams{
189+
Message: "Please complete the form",
190+
URL: "https://example.com/form",
191+
ElicitationID: "elicit-123",
192+
})
193+
if err != nil {
194+
return nil, err
195+
}
196+
return api.NewToolCallResult("action="+result.Action, nil), nil
197+
})
198+
199+
s.InitMcpClient(
200+
test.WithElicitationHandler(
201+
func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
202+
if req.Params.ElicitationID != "elicit-123" {
203+
return nil, fmt.Errorf("expected elicitationID 'elicit-123', got '%s'", req.Params.ElicitationID)
204+
}
205+
return &mcp.ElicitResult{Action: "accept"}, nil
206+
},
207+
),
208+
test.WithClientCapabilities(&mcp.ClientCapabilities{
209+
Elicitation: &mcp.ElicitationCapabilities{
210+
URL: &mcp.URLElicitationCapabilities{},
211+
},
212+
}),
213+
)
214+
215+
toolResult, err := s.CallTool("elicit_test_tool", map[string]any{})
216+
217+
s.Run("forwards elicitationID to the client", func() {
218+
s.NoError(err)
219+
s.Require().NotNil(toolResult)
220+
s.False(toolResult.IsError)
221+
s.Require().Len(toolResult.Content, 1)
222+
textContent, ok := toolResult.Content[0].(*mcp.TextContent)
223+
s.Require().True(ok)
224+
s.Equal("action=accept", textContent.Text)
225+
})
226+
}
227+
228+
func (s *ElicitationSuite) TestElicitationURLWithUnsupportedClient() {
229+
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
230+
_, err := params.Elicit(params.Context, &api.ElicitParams{
231+
Message: "Please complete the form",
232+
URL: "https://example.com/form",
233+
})
234+
if err != nil {
235+
if errors.Is(err, ErrElicitationNotSupported) {
236+
return api.NewToolCallResult("url-fallback-result", nil), nil
237+
}
238+
return nil, err
239+
}
240+
return api.NewToolCallResult("should-not-reach", nil), nil
241+
})
242+
243+
// Client supports elicitation but not URL mode (default capabilities = form only)
244+
s.InitMcpClient(test.WithElicitationHandler(
245+
func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
246+
return &mcp.ElicitResult{Action: "accept"}, nil
247+
},
248+
))
249+
250+
toolResult, err := s.CallTool("elicit_test_tool", map[string]any{})
251+
252+
s.Run("tool handles unsupported URL elicitation gracefully", func() {
253+
s.NoError(err)
254+
s.Require().NotNil(toolResult)
255+
s.False(toolResult.IsError)
256+
s.Require().Len(toolResult.Content, 1)
257+
textContent, ok := toolResult.Content[0].(*mcp.TextContent)
258+
s.Require().True(ok)
259+
s.Equal("url-fallback-result", textContent.Text)
260+
})
261+
}
262+
147263
// mockElicitToolset is a test toolset that provides tools using the Elicitor interface
148264
type mockElicitToolset struct {
149265
tools []api.ServerTool

0 commit comments

Comments
 (0)