Skip to content

Commit 1e5bacc

Browse files
authored
Implement Elicitation URL mode for MCP spec 2025-11-25 (#666)
* Implement Elicitation URL mode for MCP spec 2025-11-25 * Clean up duplicated/misaligned ElicitationHandler doc comment bullets * add `JSONRPC_VERSION` field to `JSONRPCError()` * Add `URLElicitationRequiredError` parsing to `JSONRPCErrorDetails.AsError`, implement its `Is` method, and add a corresponding test. * fix: Initialize mock elicitation session notification channel once with increased capacity and correct test assertion for accepted response content. * fix: comments-> completion notification sent before browser authentication completes. * Add: `MethodNotificationElicitationComplete` and refactor `NewElicitationCompleteNotification`. * use a const for `URL_ELICITATION_REQUIRED` * refactor: simplify elicitation complete notification creation and add nil session check * fix: renaming shadowed err variables and documenting buffer choices * add validation for `ElicitationParams` * add tests for URL elicitation request parameters * test: refactor `SendElicitationComplete` test to verify independent operation and improve `ElicitationParams.Validate` error assertions.
1 parent a429ab3 commit 1e5bacc

File tree

13 files changed

+619
-52
lines changed

13 files changed

+619
-52
lines changed

client/client.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ func (c *Client) Initialize(
199199
}
200200
// Add elicitation capability if handler is configured
201201
if c.elicitationHandler != nil {
202-
capabilities.Elicitation = &struct{}{}
202+
capabilities.Elicitation = &mcp.ElicitationCapability{}
203203
}
204204

205205
// Ensure we send a params object with all required fields
@@ -629,6 +629,10 @@ func (c *Client) handleElicitationRequestTransport(ctx context.Context, request
629629
}
630630
}
631631

632+
if err := params.Validate(); err != nil {
633+
return nil, fmt.Errorf("invalid elicitation params: %w", err)
634+
}
635+
632636
// Create the MCP request
633637
mcpRequest := mcp.ElicitationRequest{
634638
Request: mcp.Request{

client/elicitation.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import (
1111
type ElicitationHandler interface {
1212
// Elicit handles an elicitation request from the server and returns the user's response.
1313
// The implementation should:
14-
// 1. Present the request message to the user
15-
// 2. Validate input against the requested schema
14+
// 1. Present the request message to the user (and URL if in URL mode)
15+
// 2. Validate input against the requested schema (for form mode)
1616
// 3. Allow the user to accept, decline, or cancel
1717
// 4. Return the appropriate response
1818
Elicit(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error)

client/inprocess_elicitation_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func TestInProcessElicitation(t *testing.T) {
124124
Version: "1.0.0",
125125
},
126126
Capabilities: mcp.ClientCapabilities{
127-
Elicitation: &struct{}{},
127+
Elicitation: &mcp.ElicitationCapability{},
128128
},
129129
},
130130
})

examples/elicitation/main.go

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/signal"
99
"sync/atomic"
1010

11+
"github.com/google/uuid"
1112
"github.com/mark3labs/mcp-go/mcp"
1213
"github.com/mark3labs/mcp-go/server"
1314
)
@@ -129,7 +130,7 @@ func main() {
129130
server.WithElicitation(), // Enable elicitation
130131
)
131132

132-
// Add a tool that uses elicitation
133+
// Add a tool that uses elicitation (Form Mode)
133134
mcpServer.AddTool(
134135
mcp.NewTool(
135136
"create_project",
@@ -138,7 +139,7 @@ func main() {
138139
demoElicitationHandler(mcpServer),
139140
)
140141

141-
// Add another tool that demonstrates conditional elicitation
142+
// Add another tool that demonstrates conditional elicitation (Form Mode)
142143
mcpServer.AddTool(
143144
mcp.NewTool(
144145
"process_data",
@@ -236,7 +237,102 @@ func main() {
236237
},
237238
)
238239

239-
// Create and start stdio server
240+
// Add a tool that uses URL elicitation (auth flow)
241+
mcpServer.AddTool(
242+
mcp.NewTool(
243+
"auth_via_url",
244+
mcp.WithDescription("Demonstrates out-of-band authentication via URL"),
245+
),
246+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
247+
session := server.ClientSessionFromContext(ctx)
248+
if session == nil {
249+
return nil, fmt.Errorf("no active session")
250+
}
251+
252+
// Generate unique elicitation ID
253+
elicitationID := uuid.New().String()
254+
255+
// Create URL with elicitation ID for tracking
256+
// In a real application, you would store the ID and associate it with the user session
257+
url := fmt.Sprintf("https://myserver.com/set-api-key?elicitationId=%s", elicitationID)
258+
259+
// Request URL mode elicitation
260+
result, err := mcpServer.RequestURLElicitation(
261+
ctx,
262+
session,
263+
elicitationID,
264+
url,
265+
"Please authenticate in your browser to continue.",
266+
)
267+
if err != nil {
268+
return nil, fmt.Errorf("URL elicitation failed: %w", err)
269+
}
270+
271+
if result.Action == mcp.ElicitationResponseActionAccept {
272+
// User consented to open the URL
273+
// They will complete the flow in their browser
274+
// Server will store credentials when user submits the form
275+
276+
// Simulate sending completion notification
277+
// NOTE: In production, this notification would be sent after
278+
// the server receives the authentication callback from the browser.
279+
// Here we simulate immediate completion for demonstration purposes.
280+
if err := mcpServer.SendElicitationComplete(ctx, session, elicitationID); err != nil {
281+
// Log error but continue
282+
fmt.Fprintf(os.Stderr, "Failed to send completion notification: %v\n", err)
283+
}
284+
285+
return &mcp.CallToolResult{
286+
Content: []mcp.Content{
287+
mcp.NewTextContent("Authentication flow initiated. User accepted URL open request."),
288+
},
289+
}, nil
290+
}
291+
292+
return &mcp.CallToolResult{
293+
Content: []mcp.Content{
294+
mcp.NewTextContent(fmt.Sprintf("User declined authentication: %s", result.Action)),
295+
},
296+
}, nil
297+
},
298+
)
299+
300+
// Add a tool that demonstrates returning URLElicitationRequiredError
301+
mcpServer.AddTool(
302+
mcp.NewTool(
303+
"protected_action",
304+
mcp.WithDescription("A protected action that requires prior authorization"),
305+
),
306+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
307+
// TODO: In production, check actual authorization state
308+
// For demo purposes, we always trigger elicitation
309+
isAuthorized := false // Always false to demonstrate error flow
310+
311+
if !isAuthorized {
312+
// When a request needs authorization that hasn't been set up
313+
elicitationID := uuid.New().String()
314+
315+
// Return a special error that tells the client to start elicitation
316+
return nil, mcp.URLElicitationRequiredError{
317+
Elicitations: []mcp.ElicitationParams{
318+
{
319+
Mode: mcp.ElicitationModeURL,
320+
ElicitationID: elicitationID,
321+
URL: fmt.Sprintf("https://myserver.com/authorize?id=%s", elicitationID),
322+
Message: "Authorization is required to access this resource.",
323+
},
324+
},
325+
}
326+
}
327+
328+
return &mcp.CallToolResult{
329+
Content: []mcp.Content{
330+
mcp.NewTextContent("Action completed successfully!"),
331+
},
332+
}, nil
333+
},
334+
)
335+
240336
stdioServer := server.NewStdioServer(mcpServer)
241337

242338
// Handle graceful shutdown

mcp/consts.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@ const (
66
ContentTypeAudio = "audio"
77
ContentTypeLink = "resource_link"
88
ContentTypeResource = "resource"
9+
10+
ElicitationModeForm = "form"
11+
ElicitationModeURL = "url"
912
)

mcp/elicitation_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestElicitationParamsSerialization(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
params ElicitationParams
15+
expected string
16+
}{
17+
{
18+
name: "Form Mode Default",
19+
params: ElicitationParams{
20+
Message: "Please enter data",
21+
RequestedSchema: map[string]any{
22+
"type": "string",
23+
},
24+
},
25+
expected: `{"message":"Please enter data","requestedSchema":{"type":"string"}}`,
26+
},
27+
{
28+
name: "Form Mode Explicit",
29+
params: ElicitationParams{
30+
Mode: ElicitationModeForm,
31+
Message: "Please enter data",
32+
RequestedSchema: map[string]any{
33+
"type": "string",
34+
},
35+
},
36+
expected: `{"mode":"form","message":"Please enter data","requestedSchema":{"type":"string"}}`,
37+
},
38+
{
39+
name: "URL Mode",
40+
params: ElicitationParams{
41+
Mode: ElicitationModeURL,
42+
Message: "Please auth",
43+
ElicitationID: "123",
44+
URL: "https://example.com/auth",
45+
},
46+
expected: `{"mode":"url","message":"Please auth","elicitationId":"123","url":"https://example.com/auth"}`,
47+
},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
data, err := json.Marshal(tt.params)
53+
require.NoError(t, err)
54+
assert.JSONEq(t, tt.expected, string(data))
55+
56+
// Round trip
57+
var decoded ElicitationParams
58+
err = json.Unmarshal(data, &decoded)
59+
require.NoError(t, err)
60+
61+
assert.Equal(t, tt.params.Message, decoded.Message)
62+
assert.Equal(t, tt.params.Mode, decoded.Mode)
63+
64+
if tt.params.Mode == ElicitationModeURL {
65+
assert.Equal(t, tt.params.ElicitationID, decoded.ElicitationID)
66+
assert.Equal(t, tt.params.URL, decoded.URL)
67+
}
68+
})
69+
}
70+
}
71+
72+
func TestElicitationCapabilitySerialization(t *testing.T) {
73+
// Test empty struct for backward compatibility
74+
cap := ElicitationCapability{}
75+
data, err := json.Marshal(cap)
76+
require.NoError(t, err)
77+
assert.JSONEq(t, "{}", string(data))
78+
79+
// Test with Form support
80+
cap = ElicitationCapability{
81+
Form: &struct{}{},
82+
}
83+
data, err = json.Marshal(cap)
84+
require.NoError(t, err)
85+
assert.JSONEq(t, `{"form":{}}`, string(data))
86+
87+
// Test with URL support
88+
cap = ElicitationCapability{
89+
URL: &struct{}{},
90+
}
91+
data, err = json.Marshal(cap)
92+
require.NoError(t, err)
93+
assert.JSONEq(t, `{"url":{}}`, string(data))
94+
}

mcp/elicitation_validation_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package mcp_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestElicitationParams_Validate(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
params mcp.ElicitationParams
14+
wantErr bool
15+
}{
16+
{
17+
name: "Valid Form Mode",
18+
params: mcp.ElicitationParams{
19+
Mode: mcp.ElicitationModeForm,
20+
Message: "Fill this form",
21+
RequestedSchema: map[string]any{"type": "object"},
22+
},
23+
wantErr: false,
24+
},
25+
{
26+
name: "Valid URL Mode",
27+
params: mcp.ElicitationParams{
28+
Mode: mcp.ElicitationModeURL,
29+
Message: "Click this link",
30+
ElicitationID: "123",
31+
URL: "https://example.com/auth",
32+
},
33+
wantErr: false,
34+
},
35+
{
36+
name: "Implicit Form Form Mode (Default)",
37+
params: mcp.ElicitationParams{
38+
Mode: "",
39+
Message: "Fill this form",
40+
RequestedSchema: map[string]any{"type": "object"},
41+
},
42+
wantErr: false, // Should default to form and validate schema
43+
},
44+
{
45+
name: "Invalid Mode",
46+
params: mcp.ElicitationParams{
47+
Mode: "invalid-mode",
48+
},
49+
wantErr: true,
50+
},
51+
{
52+
name: "Form Mode Missing Schema",
53+
params: mcp.ElicitationParams{
54+
Mode: mcp.ElicitationModeForm,
55+
Message: "Missing schema",
56+
},
57+
wantErr: true,
58+
},
59+
{
60+
name: "URL Mode Missing URL",
61+
params: mcp.ElicitationParams{
62+
Mode: mcp.ElicitationModeURL,
63+
ElicitationID: "123",
64+
Message: "Missing URL",
65+
},
66+
wantErr: true,
67+
},
68+
{
69+
name: "URL Mode Missing ElicitationID",
70+
params: mcp.ElicitationParams{
71+
Mode: mcp.ElicitationModeURL,
72+
URL: "https://example.com",
73+
Message: "Missing ID",
74+
},
75+
wantErr: true,
76+
},
77+
}
78+
79+
for _, tt := range tests {
80+
t.Run(tt.name, func(t *testing.T) {
81+
err := tt.params.Validate()
82+
if tt.wantErr {
83+
require.Error(t, err, "expected error for test case: %s", tt.name)
84+
} else {
85+
require.NoError(t, err, "unexpected error for test case: %s", tt.name)
86+
}
87+
})
88+
}
89+
}

0 commit comments

Comments
 (0)