ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/bin/mcp-http
Revision: 1.8
Committed: Sat Nov 7 17:09:30 2026 UTC (2 days, 16 hours ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.7: +5 -0 lines
Log Message:
*** empty log message ***

File Contents

# Content
1 #!/opt/bin/perl
2
3 =encoding utf8
4
5 =head1 NAME
6
7 mcp-http - HTTP requests with URL and method permissions
8
9 =head1 SYNOPSIS
10
11 mcp-http --stdio
12 mcp-http --connect http://host:port/TOKEN/http
13
14 =head1 DESCRIPTION
15
16 One MCP C<request> tool backed by L<AnyEvent::HTTP>. HTTP methods are
17 permission actions. URL scopes: exact URL, path with any query, origin,
18 hostname, hostname and subdomains, or any HTTP(S) URL.
19
20 Supply a C<cookie_jar> from an earlier response to continue a session;
21 the server keeps no cookies. This uses AnyEvent::HTTP's simplified jar,
22 not a full browser cookie implementation. Treat the jar as confidential.
23
24 Responses include status, headers, UTF-8 body or Base64 bytes. Optional
25 C<grep> keeps lines containing a literal string; C<regex> keeps lines
26 matching a Perl regex; C<substitute> replaces regex matches with literal
27 replacement text. C<max_bytes> bounds downloads and C<truncated> reports
28 partial results.
29
30 Redirects and retries are disabled. HTTPS verifies certificates.
31 Network access is NOT sandboxed; permitted names can reach local or
32 private hosts. HTTP failures do not prove that a write did not happen.
33
34 When spawned as a multibroker stdio child, the server exits after five
35 minutes without MCP requests and is restarted on demand. Directly connected
36 clients and HTTP listeners are unaffected.
37
38 =head1 FIRST USE
39
40 Configure your MCP client to launch:
41
42 mcp-http --stdio --name web
43
44 Or combine it with other servers:
45
46 mcp-multibroker --stdio web --- mcp-http --name web --stdio ---
47
48 Ask the assistant:
49
50 Fetch https://example.org/ and report the status and page title.
51 Do not submit forms or send any modifying requests.
52
53 The C<request> tool receives arguments such as:
54
55 {"url": "https://example.org/", "method": "GET", "max_bytes": 8192}
56
57 The first request asks for permission to GET that URL. An approval for
58 GET does not authorize POST, and an approval for an exact URL need not
59 authorize its whole hostname. Review broader scope choices carefully,
60 especially on hosts serving private or local resources.
61
62 To carry a session between requests, supply the returned C<cookie_jar>
63 to the next call. To inspect a large text response without returning it
64 all, use C<grep> or C<regex> and a suitable C<max_bytes>. Redirect
65 responses are returned to the caller; they are not followed. Repeating
66 a failed modifying request can repeat its side effects.
67
68 =head1 TOOL
69
70 =over
71
72 =item request
73
74 Required C<url>. Optional C<method> (GET), C<headers>, UTF-8 C<body>
75 or C<body_base64>, C<cookie_jar>, C<timeout> (30s inactivity),
76 C<max_bytes> (65536 default, 2097152 maximum), C<grep>, C<regex>,
77 and C<substitute> with C<pattern> and C<replacement>. Responses contain
78 C<captured_bytes> and C<truncated>. Replacement text is literal.
79
80 =back
81
82 =head1 PERMISSIONS
83
84 Profile C<http-NAME>. Rules match HTTP method as C<action> and URL
85 as C<match.url>. Hostname and subdomain scopes are textual DNS suffixes,
86 not organizational or public-suffix boundaries. No sandbox grants.
87
88 =head1 OPTIONS
89
90 Shared switches are documented in
91 L<AnyEvent::MCP::Server::Simple/COMMON COMMAND-LINE OPTIONS>.
92 The following shared switch has HTTP-permission-specific behavior.
93
94 =over
95
96 =item --ask MODE
97
98 Select permission prompt handling: C<helper> (default) uses the local
99 permission helper; C<tty> or C<yad> selects only that interface;
100 C<mcp> forwards prompts to the MCP client; C<auto> uses client forms
101 when advertised, otherwise the local helper; C<none> denies unmatched
102 permission checks without prompting.
103
104 =back
105
106 =cut
107
108 use common::sense;
109
110 use MIME::Base64 ();
111 use Encode ();
112 use JSON::XS ();
113
114 use AnyEvent::HTTP ();
115 use AnyEvent::MCP ();
116 use AnyEvent::MCP::Server::Permissions ();
117
118 use AnyEvent::MCP::Server::Simple;
119
120 my $options = mcp_getoptions
121 {
122 usage => "Usage: mcp-http [OPTION]...\n",
123 description => "HTTP requests with URL/method permissions and caller-managed cookies.",
124 examples => " mcp-http --stdio\n",
125 };
126
127 @ARGV and die "Unexpected arguments\n";
128
129 mcp_permissions +AnyEvent::MCP::Server::Permissions->new (
130 name => "http-$options->{name}",
131 );
132
133 mcp_config
134 idle_exit => 300,
135 server_name => "mcp-http",
136 server_version => $AnyEvent::MCP::VERSION,
137 short_instructions => "HTTP requests with URL/method permissions; pass cookie_jar between calls. No redirects.",
138 instructions => "request makes authorized HTTP(S) requests. Method is the permission action. "
139 . "URL scopes range from exact URL to any HTTP(S) URL. No automatic redirects or retries. "
140 . "Pass a returned cookie_jar to subsequent calls. "
141 . "grep/regex select lines; substitute applies literal replacements. "
142 . "Responses may be truncated and failed writes can still have taken effect.",
143 on_request => sub {
144 my ($server, $req) = @_;
145
146 return mcp_log_request $server, $req
147 unless $req->{method} eq "tools/call" && $req->{tool} eq "request";
148
149 my $args = $req->{args};
150 my $method = $args->{method} // "GET";
151 my $url = $args->{url} // "";
152 $method = "<invalid>" if ref $method;
153 $url = "<invalid>" if ref $url;
154 $url =~ s/[?#].*//s;
155 $url =~ s{(://)[^/@]*@}{$1};
156 $url =~ s/[\x00-\x1f\x7f]/ /g;
157
158 mcp_log_request $server, $req,
159 method => (substr $method, 0, 20),
160 url => (substr $url, 0, 160);
161 };
162
163 sub url_scopes {
164 my ($url) = @_;
165
166 defined $url && !ref $url && $url !~ /[\x00-\x20\x7f]/
167 or die "Invalid URL\n";
168 $url =~ m{\A(https?)://(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]+))?([^#]*)(?:#.*)?\z}i
169 or die "Expected HTTP(S) URL without userinfo\n";
170
171 my ($scheme, $host, $port, $rest) = (lc $1, lc $2, $3, $4);
172 !defined $port || $port >= 1 && $port <= 65535
173 or die "Invalid URL port\n";
174 $port = undef if defined $port && $port == ($scheme eq "https" ? 443 : 80);
175 $rest =~ m{\A(?:/|\?|\z)} or die "Invalid URL path\n";
176 $rest = "/$rest" unless $rest =~ m{\A/};
177
178 my $origin = "$scheme://$host" . (defined $port ? ":$port" : "");
179 my $path = $rest;
180 $path =~ s/\?.*//s;
181 my $full = "$origin$rest";
182 my @choices = (
183 { label => "Exact URL including query", match => $full },
184 { label => "This path, any query",
185 match => "^" . (quotemeta "$origin$path") . "(?:\\?.*)?\\z" },
186 { label => "This origin (scheme, host, port)",
187 match => "^" . (quotemeta $origin) . "/" },
188 { label => "This hostname, any scheme or port",
189 match => "^https?://" . (quotemeta $host) . "(?::[0-9]+)?/" },
190 );
191 if ($host !~ /^\[/ && $host =~ /\./ && $host !~ /^[0-9.]+$/) {
192 push @choices, {
193 label => "Hostname and subdomains, any scheme or port",
194 match => "^https?://(?:[a-z0-9-]+\\.)*" . (quotemeta $host) . "(?::[0-9]+)?/",
195 };
196 }
197 push @choices, { label => "Any HTTP(S) URL", match => "^https?://" };
198
199 ($full, \@choices)
200 }
201
202 sub text_regex {
203 my ($pattern) = @_;
204
205 defined $pattern && !ref $pattern && length $pattern <= 512
206 or die "Invalid regex pattern\n";
207 my $regex = eval { qr/$pattern/ };
208 $@ and die "Invalid regex: $@";
209
210 $regex
211 }
212
213 sub filter_text {
214 my ($text, $args) = @_;
215
216 if (defined $args->{grep} || defined $args->{regex}) {
217 my $grep = $args->{grep};
218 my $regex = defined $args->{regex} ? text_regex ($args->{regex}) : undef;
219 $text = join "", grep {
220 (!defined $grep || index ($_, $grep) >= 0)
221 && (!defined $regex || /$regex/)
222 } split /(?<=\n)/, $text;
223 }
224
225 if (my $subst = $args->{substitute}) {
226 my $regex = text_regex ($subst->{pattern});
227 my $replacement = $subst->{replacement};
228 $text =~ s/$regex/$replacement/g;
229 }
230
231 $text
232 }
233
234 mcp_tool request => "HTTP request. Method and URL permissions. No redirects; caller-managed cookie_jar returned. "
235 . "UTF-8 body or Base64, bounded response, optional grep/regex/substitute.",
236 {
237 type => "object",
238 required => ["url"],
239 additionalProperties => JSON::XS::false,
240 properties => {
241 url => { type => "string", minLength => 1 },
242 method => { type => "string", default => "GET" },
243 headers => { type => "object", additionalProperties => { type => "string" } },
244 body => { type => "string", description => "UTF-8 request body" },
245 body_base64 => { type => "string", description => "Raw request body, Base64" },
246 cookie_jar => { type => "object", description => "AnyEvent::HTTP cookie jar returned by an earlier call" },
247 timeout => { type => "number", minimum => 1, maximum => 300, default => 30 },
248 max_bytes => { type => "integer", minimum => 1, maximum => 2097152, default => 65536 },
249 grep => { type => "string", description => "Keep lines containing this literal substring" },
250 regex => { type => "string", description => "Keep lines matching this Perl regex" },
251 substitute => {
252 type => "object",
253 required => ["pattern", "replacement"],
254 additionalProperties => JSON::XS::false,
255 properties => {
256 pattern => { type => "string", maxLength => 512 },
257 replacement => { type => "string", maxLength => 512 },
258 },
259 },
260 },
261 },
262 sub {
263 my ($req, $args) = @_;
264
265 my $method = $args->{method} // "GET";
266 $method =~ /\A[A-Za-z0-9!#\$%&'*+.^_\x60|~-]+\z/
267 or die "Invalid HTTP method\n";
268 exists $args->{body} && exists $args->{body_base64}
269 and die "Specify body or body_base64, not both\n";
270 my ($url, $scopes) = url_scopes ($args->{url});
271
272 my $headers = $args->{headers} || {};
273 for my $key (keys %$headers) {
274 $key =~ /\A[A-Za-z0-9!#\$%&'*+.^_\x60|~-]+\z/
275 && defined $headers->{$key} && !ref $headers->{$key}
276 && $headers->{$key} !~ /[\r\n\x00]/
277 or die "Invalid HTTP header\n";
278 }
279 $headers = { %$headers };
280 $headers->{"User-Agent"} = "mcp-http/$AnyEvent::MCP::VERSION"
281 unless grep lc $_ eq "user-agent", keys %$headers;
282 my $data = exists $args->{body} ? (AnyEvent::MCP::utf8enc $args->{body}) : undef;
283 if (defined $args->{body_base64}) {
284 $args->{body_base64} =~ /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/
285 or die "Invalid Base64 body\n";
286 $data = MIME::Base64::decode_base64 ($args->{body_base64});
287 }
288 !defined $data || length $data <= 1048576 or die "Request body exceeds 1 MiB\n";
289 my $limit = $args->{max_bytes} // 65536;
290 $limit >= 1 && $limit <= 2097152 or die "Invalid max_bytes\n";
291
292 !defined $args->{grep} || !ref $args->{grep} or die "Invalid grep\n";
293 text_regex ($args->{regex}) if defined $args->{regex};
294 if (defined $args->{substitute}) {
295 ref $args->{substitute} eq "HASH" && defined $args->{substitute}{replacement}
296 && !ref $args->{substitute}{replacement} && length $args->{substitute}{replacement} <= 512
297 or die "Invalid substitute\n";
298 text_regex ($args->{substitute}{pattern});
299 }
300 my $jar = $args->{cookie_jar};
301 !defined $jar || ref $jar eq "HASH" or die "Invalid cookie jar\n";
302
303 $req->require_permission ({
304 tool => $req->{tool},
305 action => $method,
306 dimensions => { url => { label => "URL", value => $url, choices => $scopes } },
307 sandbox_grants => [],
308 reason => "$method $url",
309 });
310
311 my $body = "";
312 my $truncated = 0;
313 my $response_headers;
314 my $guard;
315 $guard = AnyEvent::HTTP::http_request
316 $method => $url,
317 recurse => 0,
318 persistent => 0,
319 proxy => undef,
320 tls_ctx => "high",
321 timeout => $args->{timeout} // 30,
322 headers => $headers,
323 (defined $data ? (body => $data) : ()),
324 (defined $jar ? (cookie_jar => $jar) : ()),
325 on_header => sub {
326 $response_headers = { %{$_[0]} };
327 1
328 },
329 on_body => sub {
330 my ($part) = @_;
331 if (length ($body) + length ($part) > $limit) {
332 $body .= substr $part, 0, $limit - length $body;
333 $truncated = 1;
334 return 0;
335 }
336 $body .= $part;
337 1
338 },
339 sub {
340 my ($unused, $hdr) = @_;
341
342 undef $guard;
343 return if $req->{finished};
344 $hdr = $response_headers if $truncated && $response_headers;
345 my %result = (
346 status => 0 + $hdr->{Status},
347 reason => $hdr->{Reason},
348 headers => { map +($_ => $hdr->{$_}), grep /^[a-z]/, keys %$hdr },
349 captured_bytes => length $body,
350 truncated => $truncated ? JSON::XS::true : JSON::XS::false,
351 (defined $jar ? (cookie_jar => $jar) : ()),
352 );
353
354 if (defined $unused || $truncated) {
355 my $bytes = $body;
356 my $text = eval { Encode::decode ("UTF-8", $bytes, Encode::FB_CROAK) };
357 if (defined $text) {
358 $text = filter_text ($text, $args);
359 if (length $text > $limit) {
360 $text = substr $text, 0, $limit;
361 $result{truncated} = JSON::XS::true;
362 }
363 $result{body} = $text;
364 } elsif (defined $args->{grep} || defined $args->{regex} || defined $args->{substitute}) {
365 $req->error ("Cannot filter a non-UTF-8 response");
366 return;
367 } else {
368 $result{body_base64} = MIME::Base64::encode_base64 ($body, "");
369 }
370 }
371
372 $req->result (\%result);
373 }
374 ;
375 $req->{on_cancel} = sub { undef $guard };
376 };
377
378 mcp_start %$options;