Skip to content

Commit 7b75311

Browse files
authored
feat(api): support elicitation from tool calls (#804)
* feat(mcp): support elicitation from tool calls Signed-off-by: Calum Murray <cmurray@redhat.com> * cleanup: address review comments Signed-off-by: Calum Murray <cmurray@redhat.com> --------- Signed-off-by: Calum Murray <cmurray@redhat.com>
1 parent 8dd872a commit 7b75311

7 files changed

Lines changed: 257 additions & 0 deletions

File tree

internal/test/mcp.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type mcpClientConfig struct {
2525
clientInfo *mcp.Implementation
2626
endpoint string
2727
allowConnectionError bool
28+
elicitationHandler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error)
2829
}
2930

3031
// httpHeaderOption sets custom HTTP headers
@@ -95,6 +96,22 @@ func WithAllowConnectionError() McpClientOption {
9596
return allowConnectionErrorOption{}
9697
}
9798

99+
// elicitationHandlerOption sets a custom elicitation handler on the MCP client
100+
type elicitationHandlerOption struct {
101+
handler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error)
102+
}
103+
104+
func (o elicitationHandlerOption) apply(c *mcpClientConfig) {
105+
c.elicitationHandler = o.handler
106+
}
107+
108+
// WithElicitationHandler sets an elicitation handler on the MCP client.
109+
// When set, the client advertises elicitation support and the handler is invoked
110+
// when the server sends an elicitation request during tool execution.
111+
func WithElicitationHandler(handler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error)) McpClientOption {
112+
return elicitationHandlerOption{handler: handler}
113+
}
114+
98115
// headerRoundTripper injects HTTP headers into requests
99116
type headerRoundTripper struct {
100117
base http.RoundTripper
@@ -184,6 +201,7 @@ func NewMcpClient(t *testing.T, mcpHttpServer http.Handler, options ...McpClient
184201

185202
// Create go-sdk client with notification handlers
186203
clientOptions := &mcp.ClientOptions{
204+
ElicitationHandler: cfg.elicitationHandler,
187205
ToolListChangedHandler: func(_ context.Context, req *mcp.ToolListChangedRequest) {
188206
ret.notifications.capture(&CapturedNotification{
189207
Method: "notifications/tools/list_changed",

pkg/api/prompts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type PromptHandlerParams struct {
8787
ExtendedConfigProvider
8888
KubernetesClient
8989
PromptCallRequest
90+
Elicitor
9091
}
9192

9293
// PromptHandlerFunc is a function that handles prompt execution

pkg/api/toolsets.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,38 @@ type ToolHandlerParams struct {
103103
KubernetesClient
104104
ToolCallRequest
105105
ListOutput output.Output
106+
Elicitor
106107
}
107108

108109
type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error)
109110

111+
// Elicitor provides a mechanism for tools and prompts to request additional information
112+
// 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.
114+
// See MCP specification: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
115+
type Elicitor interface {
116+
Elicit(ctx context.Context, message string, requestedSchema *jsonschema.Schema) (*ElicitResult, error)
117+
}
118+
119+
// ElicitAction constants define the possible user responses to an elicitation request.
120+
// See MCP specification: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
121+
const (
122+
// ElicitActionAccept indicates the user submitted the form with content.
123+
ElicitActionAccept = "accept"
124+
// ElicitActionDecline indicates the user explicitly declined the request.
125+
ElicitActionDecline = "decline"
126+
// ElicitActionCancel indicates the user dismissed the form without making a choice.
127+
ElicitActionCancel = "cancel"
128+
)
129+
130+
// ElicitResult represents the user's response to an elicitation request.
131+
type ElicitResult struct {
132+
// Action is one of ElicitActionAccept, ElicitActionDecline, or ElicitActionCancel.
133+
Action string
134+
// Content contains the submitted form data. Only populated when Action is ElicitActionAccept.
135+
Content map[string]any
136+
}
137+
110138
type Tool struct {
111139
// The name of the tool.
112140
// Intended for programmatic or logical use, but used as a display name in past

pkg/mcp/elicit.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/containers/kubernetes-mcp-server/pkg/api"
10+
"github.com/containers/kubernetes-mcp-server/pkg/mcplog"
11+
"github.com/google/jsonschema-go/jsonschema"
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
)
14+
15+
// ErrElicitationNotSupported is returned when the MCP client does not support elicitation.
16+
// Tool authors can check for this error using errors.Is() to implement fallback behavior.
17+
var ErrElicitationNotSupported = errors.New("client does not support elicitation")
18+
19+
type sessionElicitor struct{}
20+
21+
var _ api.Elicitor = &sessionElicitor{}
22+
23+
func (s *sessionElicitor) Elicit(ctx context.Context, message string, requestedSchema *jsonschema.Schema) (*api.ElicitResult, error) {
24+
session, ok := ctx.Value(mcplog.MCPSessionContextKey).(*mcp.ServerSession)
25+
if !ok || session == nil {
26+
return nil, fmt.Errorf("no MCP session found in context")
27+
}
28+
29+
result, err := session.Elicit(ctx, &mcp.ElicitParams{Message: message, RequestedSchema: requestedSchema})
30+
if err != nil {
31+
if strings.Contains(err.Error(), "does not support elicitation") {
32+
return nil, fmt.Errorf("%w: %s", ErrElicitationNotSupported, err.Error())
33+
}
34+
return nil, err
35+
}
36+
37+
return &api.ElicitResult{Action: result.Action, Content: result.Content}, nil
38+
}

pkg/mcp/elicit_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/containers/kubernetes-mcp-server/internal/test"
9+
"github.com/containers/kubernetes-mcp-server/pkg/api"
10+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
11+
"github.com/google/jsonschema-go/jsonschema"
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
"github.com/stretchr/testify/suite"
14+
"k8s.io/utils/ptr"
15+
)
16+
17+
type ElicitationSuite struct {
18+
BaseMcpSuite
19+
originalToolsets []api.Toolset
20+
}
21+
22+
func (s *ElicitationSuite) SetupTest() {
23+
s.BaseMcpSuite.SetupTest()
24+
s.originalToolsets = toolsets.Toolsets()
25+
}
26+
27+
func (s *ElicitationSuite) TearDownTest() {
28+
s.BaseMcpSuite.TearDownTest()
29+
toolsets.Clear()
30+
for _, toolset := range s.originalToolsets {
31+
toolsets.Register(toolset)
32+
}
33+
}
34+
35+
func (s *ElicitationSuite) registerElicitingToolset(handler api.ToolHandlerFunc) {
36+
testToolset := &mockElicitToolset{
37+
tools: []api.ServerTool{
38+
{
39+
Tool: api.Tool{
40+
Name: "elicit_test_tool",
41+
Description: "Tool that uses elicitation for testing",
42+
Annotations: api.ToolAnnotations{
43+
ReadOnlyHint: ptr.To(true),
44+
},
45+
InputSchema: &jsonschema.Schema{
46+
Type: "object",
47+
Properties: make(map[string]*jsonschema.Schema),
48+
},
49+
},
50+
Handler: handler,
51+
},
52+
},
53+
}
54+
55+
toolsets.Clear()
56+
toolsets.Register(testToolset)
57+
s.Cfg.Toolsets = []string{"elicit-test"}
58+
}
59+
60+
func (s *ElicitationSuite) TestElicitationAccepted() {
61+
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
62+
result, err := params.Elicit(params.Context, "Please confirm", nil)
63+
if err != nil {
64+
return nil, err
65+
}
66+
return api.NewToolCallResult("action="+result.Action+",name="+result.Content["name"].(string), nil), nil
67+
})
68+
69+
s.InitMcpClient(test.WithElicitationHandler(
70+
func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
71+
return &mcp.ElicitResult{
72+
Action: "accept",
73+
Content: map[string]any{"name": "test-value"},
74+
}, nil
75+
},
76+
))
77+
78+
toolResult, err := s.CallTool("elicit_test_tool", map[string]any{})
79+
80+
s.Run("returns accepted elicitation content", func() {
81+
s.NoError(err)
82+
s.Require().NotNil(toolResult)
83+
s.False(toolResult.IsError)
84+
s.Require().Len(toolResult.Content, 1)
85+
textContent, ok := toolResult.Content[0].(*mcp.TextContent)
86+
s.Require().True(ok)
87+
s.Equal("action=accept,name=test-value", textContent.Text)
88+
})
89+
}
90+
91+
func (s *ElicitationSuite) TestElicitationDeclined() {
92+
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
93+
result, err := params.Elicit(params.Context, "Please confirm", nil)
94+
if err != nil {
95+
return nil, err
96+
}
97+
return api.NewToolCallResult("action="+result.Action, nil), nil
98+
})
99+
100+
s.InitMcpClient(test.WithElicitationHandler(
101+
func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
102+
return &mcp.ElicitResult{Action: "decline"}, nil
103+
},
104+
))
105+
106+
toolResult, err := s.CallTool("elicit_test_tool", map[string]any{})
107+
108+
s.Run("returns declined action", func() {
109+
s.NoError(err)
110+
s.Require().NotNil(toolResult)
111+
s.False(toolResult.IsError)
112+
s.Require().Len(toolResult.Content, 1)
113+
textContent, ok := toolResult.Content[0].(*mcp.TextContent)
114+
s.Require().True(ok)
115+
s.Equal("action=decline", textContent.Text)
116+
})
117+
}
118+
119+
func (s *ElicitationSuite) TestElicitationWithUnsupportedClient() {
120+
s.registerElicitingToolset(func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
121+
_, err := params.Elicit(params.Context, "Please confirm", nil)
122+
if err != nil {
123+
if errors.Is(err, ErrElicitationNotSupported) {
124+
return api.NewToolCallResult("fallback-result", nil), nil
125+
}
126+
return nil, err
127+
}
128+
return api.NewToolCallResult("should-not-reach", nil), nil
129+
})
130+
131+
// No ElicitationHandler = client does not support elicitation
132+
s.InitMcpClient()
133+
134+
toolResult, err := s.CallTool("elicit_test_tool", map[string]any{})
135+
136+
s.Run("tool handles unsupported elicitation gracefully", func() {
137+
s.NoError(err)
138+
s.Require().NotNil(toolResult)
139+
s.False(toolResult.IsError)
140+
s.Require().Len(toolResult.Content, 1)
141+
textContent, ok := toolResult.Content[0].(*mcp.TextContent)
142+
s.Require().True(ok)
143+
s.Equal("fallback-result", textContent.Text)
144+
})
145+
}
146+
147+
// mockElicitToolset is a test toolset that provides tools using the Elicitor interface
148+
type mockElicitToolset struct {
149+
tools []api.ServerTool
150+
}
151+
152+
func (m *mockElicitToolset) GetName() string {
153+
return "elicit-test"
154+
}
155+
156+
func (m *mockElicitToolset) GetDescription() string {
157+
return "Test toolset for elicitation"
158+
}
159+
160+
func (m *mockElicitToolset) GetTools(_ api.Openshift) []api.ServerTool {
161+
return m.tools
162+
}
163+
164+
func (m *mockElicitToolset) GetPrompts() []api.ServerPrompt {
165+
return nil
166+
}
167+
168+
func TestElicitationSuite(t *testing.T) {
169+
suite.Run(t, new(ElicitationSuite))
170+
}

pkg/mcp/gosdk.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func ServerToolToGoSdkTool(s *Server, tool api.ServerTool) (*mcp.Tool, mcp.ToolH
5252
KubernetesClient: k,
5353
ToolCallRequest: toolCallRequest,
5454
ListOutput: s.configuration.ListOutput(),
55+
Elicitor: &sessionElicitor{},
5556
})
5657
if err != nil {
5758
return nil, err

pkg/mcp/prompts_gosdk.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func ServerPromptToGoSdkPrompt(s *Server, serverPrompt api.ServerPrompt) (*mcp.P
6060
ExtendedConfigProvider: s.configuration,
6161
KubernetesClient: k8s,
6262
PromptCallRequest: &promptCallRequestAdapter{request: request},
63+
Elicitor: &sessionElicitor{},
6364
}
6465

6566
result, err := serverPrompt.Handler(params)

0 commit comments

Comments
 (0)