ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/17_ask_user.t
Revision: 1.4
Committed: Wed Oct 14 15:52:57 2026 UTC (7 days ago) by root
Content type: application/x-troff
Branch: MAIN
CVS Tags: HEAD
Changes since 1.3: +75 -3 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 use common::sense;
2     use Test::More;
3     use AnyEvent ();
4     use AnyEvent::MCP::Client::JSONL ();
5    
6 root 1.3 my @command = ($^X, "-Ilib", "bin/mcp-ask-user", "--stdio", "--ask", "mcp");
7 root 1.1 my $direct_client = AnyEvent::MCP::Client::JSONL->new (
8     command => \@command,
9     client_capabilities => { elicitation => { form => {} } },
10     );
11     my $legacy_client = AnyEvent::MCP::Client::JSONL->new (
12     command => \@command,
13     versions => ["2025-11-25"],
14     );
15    
16     sub wait_for {
17     my ($start) = @_;
18     my $cv = AnyEvent->condvar;
19     my $timeout = AnyEvent->timer (after => 8, cb => sub { die "ask-user test timeout\n" });
20     $start->(sub { $cv->send ([@_]) });
21     @{$cv->recv}
22     }
23    
24     my $options = [
25     { label => "Keep existing", description => "Preserve the current workdir" },
26     { label => "Create new", description => "Start from scratch" },
27     ];
28     my $args = { id => "path_choice", header => "Workdir", question => "Which workdir?", options => $options };
29    
30     sub call {
31 root 1.4 my ($arguments, $extra, $caps, $legacy, $name) = @_;
32 root 1.1 $caps = { elicitation => { form => {} } } unless defined $caps;
33     my $params = {
34 root 1.4 name => $name || "ask-user", arguments => $arguments,
35 root 1.1 %{$extra || {}},
36     (!$legacy ? (_meta => {
37     "io.modelcontextprotocol/clientCapabilities" => $caps,
38 root 1.2 %{$extra->{_meta} || {}},
39 root 1.1 }) : ()),
40     };
41     my ($success, $result) = wait_for (sub {
42     ($legacy ? $legacy_client : $direct_client)->rpc ("tools/call", $params, $_[0])
43     });
44     die "ask-user transport failure: " . ($result->{message} // "") . "\n" unless $success;
45     $result
46     }
47    
48     sub resume {
49     my ($result, $contents, $arguments, $caps) = @_;
50     call ($arguments || $args, {
51     requestState => $result->{requestState},
52     inputResponses => { question => $contents },
53     }, $caps)
54     }
55    
56     my $first = call ($args);
57     is $first->{resultType}, "input_required", "initial call requests human input";
58     like $first->{requestState}, qr/\Aae-mcp-question:[0-9a-f]{64}\z/,
59     "continuation uses unpredictable token";
60     my $prompt = $first->{inputRequests}{question};
61     is $prompt->{method}, "elicitation/create", "native MCP elicitation, not YAD";
62     is $prompt->{params}{mode}, "form", "uses form elicitation";
63     is_deeply $prompt->{params}{requestedSchema}{properties}{choice}{enum},
64     ["Keep existing", "Create new"], "choice values come from the supplied labels";
65     like $prompt->{params}{message}, qr/Preserve the current workdir/,
66     "descriptions appear in question text";
67     ok $prompt->{params}{requestedSchema}{properties}{free_text},
68     "alternative free text field is always available";
69    
70     my $answered = resume ($first, { action => "accept",
71     content => { choice => "Keep existing" } });
72     is_deeply $answered->{structuredContent}, {
73     id => "path_choice", status => "answered", answer => "Keep existing", source => "choice",
74     }, "chosen answer is returned in structured tool result";
75    
76     my $replay = resume ($first, { action => "accept", content => { choice => "Create new" } });
77     ok $replay->{isError} && $replay->{content}[0]{text} =~ /Invalid or expired/,
78     "continuation token cannot be reused";
79    
80     my $free = call ($args);
81     my $custom = resume ($free, { action => "accept",
82     content => { choice => "Keep existing", free_text => "Use current parent directory" } });
83     is $custom->{structuredContent}{answer}, "Use current parent directory",
84     "free text overrides an optional choice";
85     is $custom->{structuredContent}{source}, "free_text",
86     "free text is identified without inventing a choice";
87    
88     my $open = { id => "free", question => "Please explain" };
89     my $plain = call ($open);
90     is_deeply $plain->{inputRequests}{question}{params}{requestedSchema}{required},
91     ["free_text"], "free-text-only question requires a written answer";
92     my $text = resume ($plain, { action => "accept", content => { free_text => "Explanation" } }, $open);
93     is $text->{structuredContent}{answer}, "Explanation", "free text without choices is accepted";
94    
95     my $empty = call ($args);
96     my $noanswer = resume ($empty, { action => "accept", content => {} });
97     ok $noanswer->{isError} && $noanswer->{content}[0]{text} =~ /No answer/,
98     "empty response is rejected rather than fabricating a choice";
99    
100     my $declined = call ($args);
101     my $d = resume ($declined, { action => "decline" });
102     is $d->{structuredContent}{status}, "declined", "decline is not mistaken for an answer";
103     my $cancelled = call ($args);
104     my $c = resume ($cancelled, { action => "cancel" });
105     is $c->{structuredContent}{status}, "cancelled", "cancel is distinct from decline";
106    
107     my $changed = call ($args);
108     my $bad = resume ($changed, { action => "accept", content => { choice => "Keep existing" } },
109     { %$args, question => "Something else" });
110     ok $bad->{isError}, "continuation is bound to the original call";
111     ok resume ($changed, { action => "decline" })->{isError},
112     "modified arguments consume the continuation token";
113    
114     my $unavailable = call ($args, {}, {});
115     ok $unavailable->{isError}
116     && $unavailable->{content}[0]{text} =~ /unavailable/,
117     "no advertised form capability returns a clear tool error";
118     my $url_only = call ($args, {}, { elicitation => { url => {} } });
119     ok $url_only->{isError}
120     && $url_only->{content}[0]{text} =~ /unavailable/,
121     "URL-only elicitation is insufficient for form questions";
122     my $implicit = call ($args, {}, { elicitation => {} });
123     is $implicit->{resultType}, "input_required",
124     "empty elicitation capability implies form support for compatibility";
125     my $implicit_done = resume ($implicit, { action => "accept",
126     content => { choice => "Create new" } }, undef, { elicitation => {} });
127     is $implicit_done->{structuredContent}{answer}, "Create new",
128     "empty elicitation capabilities work through the complete round trip";
129 root 1.2 my $work_initial = call ($args, { _meta => {
130     progressToken => "first-request",
131     "io.modelcontextprotocol/clientInfo" => { name => "ChatGPT Work", version => "1" },
132     } });
133     my $work_resumed = call ($args, {
134     requestState => $work_initial->{requestState},
135     inputResponses => { question => {
136     action => "accept", content => { choice => "Keep existing" },
137     } },
138     _meta => {
139     progressToken => "resumed-request",
140     "io.modelcontextprotocol/clientInfo" => { name => "ChatGPT Work", version => "2" },
141     },
142     }, { elicitation => {} });
143     is $work_resumed->{structuredContent}{answer}, "Keep existing",
144     "ask-user continuation ignores mutable request metadata, preserving original choice";
145    
146 root 1.1 my $old = call ($args, {}, {}, 1);
147     ok $old->{isError}, "legacy clients receive a clear error rather than YAD";
148     my $duplicate = call ({ question => "Pick one", options => [
149     { label => "Same" }, { label => "Same" },
150     ] });
151     ok $duplicate->{isError}, "duplicate option labels are rejected";
152    
153 root 1.4 my $form_args = {
154     message => "Configure the test",
155     requestedSchema => {
156     type => "object", required => ["enabled", "count", "note"],
157     properties => {
158     enabled => { type => "boolean", default => JSON::XS::false },
159     count => { type => "integer", minimum => 1, maximum => 10 },
160     note => { type => "string", title => "Notes" },
161     },
162     },
163     };
164     my $form_content = { enabled => JSON::XS::true, count => 3, note => "\x{03c0}\nsecond line" };
165     sub form_call {
166     my ($args, $extra, $caps, $legacy) = @_;
167    
168     call ($args, $extra, $caps, $legacy, "ask-form")
169     }
170     sub form_resume {
171     my ($challenge, $response, $args) = @_;
172    
173     form_call ($args || $form_args, {
174     requestState => $challenge->{requestState},
175     inputResponses => { form => $response },
176     })
177     }
178    
179     my $form = form_call ($form_args);
180     is $form->{resultType}, "input_required", "generic form requests input";
181     like $form->{requestState}, qr/\Aae-mcp-form:[0-9a-f]{64}\z/, "form continuation has its own token prefix";
182     is_deeply $form->{inputRequests}{form}{params}, {
183     mode => "form", %$form_args,
184     }, "generic schema and message reach native elicitation unchanged";
185     my $form_answer = form_resume ($form, { action => "accept", content => $form_content });
186     is_deeply $form_answer->{structuredContent}, { action => "accept", content => $form_content },
187     "generic result preserves field names, booleans, numbers, Unicode and multiline text";
188     ok form_resume ($form, { action => "decline" })->{isError}, "form token cannot be reused";
189     for my $action (qw(decline cancel)) {
190     my $challenge = form_call ($form_args);
191     is_deeply form_resume ($challenge, { action => $action })->{structuredContent},
192     { action => $action }, "$action is a distinct standard form response";
193     }
194     my $changed_form = form_call ($form_args);
195     ok form_resume ($changed_form, { action => "accept", content => $form_content }, {
196     %$form_args, requestedSchema => { type => "object", properties => {} },
197     })->{isError}, "form continuation is bound to the supplied schema";
198     ok form_resume ($changed_form, { action => "decline" })->{isError},
199     "changed form consumes its continuation";
200     my $cross_tool = form_call ($form_args);
201     ok resume ($cross_tool, { action => "accept", content => { choice => "Keep existing" } })->{isError},
202     "form token cannot authorize the question tool";
203    
204     for my $response (
205     { action => "bogus" }, { action => "accept", content => [] }, {},
206     ) {
207     my $challenge = form_call ($form_args);
208     ok form_resume ($challenge, $response)->{isError}, "malformed generic response is rejected";
209     }
210     ok form_call ({ %$form_args, message => [] })->{isError}, "generic form rejects a non-text message";
211     ok form_call ({ message => "Invalid", requestedSchema => { type => "string" } })->{isError},
212     "generic form requires an object schema";
213     ok form_call ($form_args, {}, {})->{isError}, "generic native form requires client capability";
214     ok form_call ($form_args, {}, {}, 1)->{isError}, "generic native form rejects a legacy client";
215    
216 root 1.1 # Actually cross two stdio processes: client -> multibroker -> mcp-ask-user.
217     # The upstream client must receive the downstream elicitation and its answer
218     # must be routed back to the same child's continuation token.
219     my $elicited = 0;
220     my $client = AnyEvent::MCP::Client::JSONL->new (
221 root 1.3 command => [$^X, "-Ilib", "bin/mcp-multibroker", "--ask", "mcp", "questions",
222 root 1.1 "stdio:" . join (" ", $^X, "-Ilib", "bin/mcp-ask-user")],
223     env => { PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL => "stdio" },
224     client_capabilities => { elicitation => { form => {} } },
225     on_request => sub {
226     my ($client, $req) = @_;
227     ++$elicited;
228     is $req->{method}, "elicitation/create",
229     "upstream receives original child's elicitation request";
230 root 1.4 $req->reply ({ action => "accept", content =>
231     exists $req->{params}{requestedSchema}{properties}{count}
232     ? $form_content : { choice => "Create new" },
233     });
234 root 1.1 },
235     );
236     my $cv = AnyEvent->condvar;
237     my $timer = AnyEvent->timer (after => 10, cb => sub { $cv->send ([0, "timed out"]) });
238     $client->call ("questions.ask-user", $args, sub { $cv->send ([@_]) });
239     my ($success, $through) = @{$cv->recv};
240     ok $success && $through->{structuredContent}{answer} eq "Create new"
241     && $elicited == 1,
242     "native elicitation and response round-trip through the real multibroker";
243    
244     # The upstream caller's capabilities, not the broker's own advertised
245     # capabilities or any cached peer state, decide whether each call may elicit.
246     $client->{client_capabilities} = {};
247     ($success, $through) = wait_for (sub {
248     $client->call ("questions.ask-user", $args, $_[0])
249     });
250     ok $success && $through->{isError}
251     && $through->{content}[0]{text} =~ /elicitation is unavailable/
252     && $elicited == 1,
253     "multibroker rejects unsupported per-request caller without eliciting";
254    
255     $client->{client_capabilities} = { elicitation => {} };
256     ($success, $through) = wait_for (sub {
257     $client->call ("questions.ask-user", $args, $_[0])
258     });
259     ok $success && $through->{structuredContent}{answer} eq "Create new"
260     && $elicited == 2,
261     "same upstream connection then accepts implicit form elicitation";
262 root 1.2 # A raw multi-round-trip request through the multibroker can change _meta
263     # between the initial call and the response (as Work-like clients may do).
264     ($success, $through) = wait_for (sub {
265     $client->rpc ("tools/call", {
266     name => "questions.ask-user", arguments => $args,
267     _meta => { progressToken => "first-hop" },
268     }, $_[0]);
269     });
270     is $through->{resultType}, "input_required",
271     "broker delivers initial challenge for raw Work-like tool call";
272     my $broker_state = $through->{requestState};
273     ($success, $through) = wait_for (sub {
274     $client->rpc ("tools/call", {
275     name => "questions.ask-user", arguments => $args,
276     requestState => $broker_state,
277     inputResponses => { question => {
278     action => "accept", content => { choice => "Keep existing" },
279     } },
280     _meta => { progressToken => "second-hop" },
281     }, $_[0]);
282     });
283     ok $success && $through->{structuredContent}{answer} eq "Keep existing",
284     "multibroker forwards Work-like changed metadata without losing question continuation";
285 root 1.4 ($success, $through) = wait_for (sub {
286     $client->call ("questions.ask-form", $form_args, $_[0])
287     });
288     ok $success, "generic form transport crosses the real multibroker";
289     is_deeply $through->{structuredContent}, { action => "accept", content => $form_content },
290     "generic form content survives the real client/broker/child round trip";
291 root 1.1 $client->close;
292     $direct_client->close;
293     $legacy_client->close;
294    
295     done_testing;