Skip to content

Commit 21fb03d

Browse files
authored
mcp: support url mode elicitation (#646)
Add initial support for URL mode elicitation, by introducing the new types, as well as both server and client-side validation. A follow-up CL will add support for ErrURLElicitationRequired. For #623
1 parent d9263f7 commit 21fb03d

File tree

10 files changed

+398
-60
lines changed

10 files changed

+398
-60
lines changed

docs/rough_edges.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@ v2.
1616
landing after the SDK was at v1, we missed an opportunity to panic on invalid
1717
tool names. Instead, we have to simply produce an error log. In v2, we should
1818
panic.
19+
20+
- Inconsistent naming.
21+
- `ResourceUpdatedNotificationsParams` should probably have just been
22+
`ResourceUpdatedParams`, as we don't include the word 'notification' in
23+
other notification param types.
24+
- Similarly, `ProgressNotificationParams` should probably have been
25+
`ProgressParams`.

examples/server/everything/main.go

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package main
77

88
import (
99
"context"
10+
_ "embed"
1011
"encoding/base64"
1112
"flag"
1213
"fmt"
@@ -17,6 +18,7 @@ import (
1718
"os"
1819
"runtime"
1920
"strings"
21+
"sync/atomic"
2022

2123
"github.com/google/jsonschema-go/jsonschema"
2224
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -51,12 +53,8 @@ func main() {
5153
CompletionHandler: complete, // support completions by setting this handler
5254
}
5355

54-
// Optionally add an icon to the server implementation.
55-
icons, err := iconToBase64DataURL("./mcp.png")
56-
if err != nil {
57-
log.Fatalf("failed to read icon: %v", err)
58-
}
59-
56+
// Add an icon to the server implementation.
57+
icons := mcpIcons()
6058
server := mcp.NewServer(&mcp.Implementation{Name: "everything", WebsiteURL: "https://example.com", Icons: icons}, opts)
6159

6260
// Add tools that exercise different features of the protocol.
@@ -67,7 +65,8 @@ func main() {
6765
mcp.AddTool(server, &mcp.Tool{Name: "ping"}, pingingTool) // performs a ping
6866
mcp.AddTool(server, &mcp.Tool{Name: "log"}, loggingTool) // performs a log
6967
mcp.AddTool(server, &mcp.Tool{Name: "sample"}, samplingTool) // performs sampling
70-
mcp.AddTool(server, &mcp.Tool{Name: "elicit"}, elicitingTool) // performs elicitation
68+
mcp.AddTool(server, &mcp.Tool{Name: "elicit (form)"}, elicitFormTool) // performs form elicitation
69+
mcp.AddTool(server, &mcp.Tool{Name: "elicit (url)"}, elicitURLTool) // performs url elicitation
7170
mcp.AddTool(server, &mcp.Tool{Name: "roots"}, rootsTool) // lists roots
7271

7372
// Add a basic prompt.
@@ -235,7 +234,7 @@ func samplingTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.Ca
235234
}, nil, nil
236235
}
237236

238-
func elicitingTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
237+
func elicitFormTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
239238
res, err := req.Session.Elicit(ctx, &mcp.ElicitParams{
240239
Message: "provide a random string",
241240
RequestedSchema: &jsonschema.Schema{
@@ -255,6 +254,26 @@ func elicitingTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.C
255254
}, nil, nil
256255
}
257256

257+
var elicitations atomic.Int32
258+
259+
func elicitURLTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
260+
elicitID := fmt.Sprintf("%d", elicitations.Add(1))
261+
_, err := req.Session.Elicit(ctx, &mcp.ElicitParams{
262+
Message: "submit a string",
263+
URL: fmt.Sprintf("http://localhost:6062?id=%s", elicitID),
264+
ElicitationID: elicitID,
265+
})
266+
if err != nil {
267+
return nil, nil, fmt.Errorf("eliciting failed: %v", err)
268+
}
269+
// TODO: actually wait for the elicitation form to be submitted.
270+
return &mcp.CallToolResult{
271+
Content: []mcp.Content{
272+
&mcp.TextContent{Text: "(elicitation pending)"},
273+
},
274+
}, nil, nil
275+
}
276+
258277
func complete(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
259278
return &mcp.CompleteResult{
260279
Completion: mcp.CompletionResultDetails{
@@ -264,15 +283,14 @@ func complete(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResul
264283
}, nil
265284
}
266285

267-
func iconToBase64DataURL(path string) ([]mcp.Icon, error) {
268-
data, err := os.ReadFile(path)
269-
if err != nil {
270-
return nil, err
271-
}
286+
//go:embed mcp.png
287+
var mcpIconData []byte
288+
289+
func mcpIcons() []mcp.Icon {
272290
return []mcp.Icon{{
273-
Source: "data:image/png;base64," + base64.StdEncoding.EncodeToString(data),
291+
Source: "data:image/png;base64," + base64.StdEncoding.EncodeToString(mcpIconData),
274292
MIMEType: "image/png",
275293
Sizes: []string{"48x48"},
276294
Theme: "light", // or "dark" or empty
277-
}}, nil
295+
}}
278296
}

internal/docs/rough_edges.src.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@ v2.
1515
landing after the SDK was at v1, we missed an opportunity to panic on invalid
1616
tool names. Instead, we have to simply produce an error log. In v2, we should
1717
panic.
18+
19+
- Inconsistent naming.
20+
- `ResourceUpdatedNotificationsParams` should probably have just been
21+
`ResourceUpdatedParams`, as we don't include the word 'notification' in
22+
other notification param types.
23+
- Similarly, `ProgressNotificationParams` should probably have been
24+
`ProgressParams`.

mcp/client.go

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ type ClientOptions struct {
6666
// Setting ElicitationHandler to a non-nil value causes the client to
6767
// advertise the elicitation capability.
6868
ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error)
69+
// ElicitationModes specifies the elicitation modes supported by the client.
70+
// If ElicitationHandler is set and ElicitationModes is empty, it defaults to ["form"].
71+
ElicitationModes []string
72+
// ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete.
73+
ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest)
6974
// Handlers for notifications from the server.
7075
ToolListChangedHandler func(context.Context, *ToolListChangedRequest)
7176
PromptListChangedHandler func(context.Context, *PromptListChangedRequest)
@@ -123,6 +128,15 @@ func (c *Client) capabilities() *ClientCapabilities {
123128
}
124129
if c.opts.ElicitationHandler != nil {
125130
caps.Elicitation = &ElicitationCapabilities{}
131+
modes := c.opts.ElicitationModes
132+
if len(modes) == 0 || slices.Contains(modes, "form") {
133+
// Technically, the empty ElicitationCapabilities value is equivalent to
134+
// {"form":{}} for backward compatibility, but we explicitly set the form
135+
// capability.
136+
caps.Elicitation.Form = &FormElicitationCapabilities{}
137+
} else if slices.Contains(modes, "url") {
138+
caps.Elicitation.URL = &URLElicitationCapabilities{}
139+
}
126140
}
127141
return caps
128142
}
@@ -297,40 +311,55 @@ func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (
297311

298312
func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
299313
if c.opts.ElicitationHandler == nil {
300-
// TODO: wrap or annotate this error? Pick a standard code?
301-
return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support elicitation")
302-
}
303-
304-
// Validate that the requested schema only contains top-level properties without nesting
305-
schema, err := validateElicitSchema(req.Params.RequestedSchema)
306-
if err != nil {
307-
return nil, jsonrpc2.NewError(codeInvalidParams, err.Error())
314+
return nil, jsonrpc2.NewError(codeInvalidParams, "client does not support elicitation")
308315
}
309316

310-
res, err := c.opts.ElicitationHandler(ctx, req)
311-
if err != nil {
312-
return nil, err
317+
// Validate the elicitation parameters based on the mode.
318+
mode := req.Params.Mode
319+
if mode == "" {
320+
mode = "form"
313321
}
314322

315-
// Validate elicitation result content against requested schema
316-
if schema != nil && res.Content != nil {
317-
// TODO: is this the correct behavior if validation fails?
318-
// It isn't the *server's* params that are invalid, so why would we return
319-
// this code to the server?
320-
resolved, err := schema.Resolve(nil)
321-
if err != nil {
322-
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
323+
switch mode {
324+
case "form":
325+
if req.Params.URL != "" {
326+
return nil, jsonrpc2.NewError(codeInvalidParams, "URL must not be set for form elicitation")
323327
}
324-
if err := resolved.Validate(res.Content); err != nil {
325-
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
328+
schema, err := validateElicitSchema(req.Params.RequestedSchema)
329+
if err != nil {
330+
return nil, jsonrpc2.NewError(codeInvalidParams, err.Error())
326331
}
327-
err = resolved.ApplyDefaults(&res.Content)
332+
res, err := c.opts.ElicitationHandler(ctx, req)
328333
if err != nil {
329-
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err))
334+
return nil, err
335+
}
336+
// Validate elicitation result content against requested schema.
337+
if schema != nil && res.Content != nil {
338+
resolved, err := schema.Resolve(nil)
339+
if err != nil {
340+
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
341+
}
342+
if err := resolved.Validate(res.Content); err != nil {
343+
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
344+
}
345+
err = resolved.ApplyDefaults(&res.Content)
346+
if err != nil {
347+
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err))
348+
}
330349
}
350+
return res, nil
351+
case "url":
352+
if req.Params.RequestedSchema != nil {
353+
return nil, jsonrpc2.NewError(codeInvalidParams, "requestedSchema must not be set for URL elicitation")
354+
}
355+
if req.Params.URL == "" {
356+
return nil, jsonrpc2.NewError(codeInvalidParams, "URL must be set for URL elicitation")
357+
}
358+
// No schema validation for URL mode, just pass through to handler.
359+
return c.opts.ElicitationHandler(ctx, req)
360+
default:
361+
return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("unsupported elicitation mode: %q", mode))
331362
}
332-
333-
return res, nil
334363
}
335364

336365
// validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements.
@@ -528,6 +557,7 @@ var clientMethodInfos = map[string]methodInfo{
528557
notificationResourceUpdated: newClientMethodInfo(clientMethod((*Client).callResourceUpdatedHandler), notification|missingParamsOK),
529558
notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification),
530559
notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification),
560+
notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK),
531561
}
532562

533563
func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo {
@@ -692,6 +722,13 @@ func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, pa
692722
return nil, nil
693723
}
694724

725+
func (c *Client) callElicitationCompleteHandler(ctx context.Context, req *ElicitationCompleteNotificationRequest) (Result, error) {
726+
if h := c.opts.ElicitationCompleteHandler; h != nil {
727+
h(ctx, req)
728+
}
729+
return nil, nil
730+
}
731+
695732
// NotifyProgress sends a progress notification from the client to the server
696733
// associated with this session.
697734
// This can be used if the client is performing a long-running task that was

0 commit comments

Comments
 (0)