ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/bin/mcp-http
Revision: 1.9
Committed: Tue Nov 10 21:05:58 2026 UTC (5 hours, 33 minutes ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.8: +8 -5 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 $short_url = length $full > 100 ? substr ($full, 0, 97) . "..." : $full;
183 my $short_path = "$origin$path";
184 $short_path = substr ($short_path, 0, 97) . "..." if length $short_path > 100;
185 my @choices = (
186 { label => "Exact URL: $short_url", match => $full },
187 { label => "This path: $short_path (any query)",
188 match => "^" . (quotemeta "$origin$path") . "(?:\\?.*)?\\z" },
189 { label => "This origin: $origin",
190 match => "^" . (quotemeta $origin) . "/" },
191 { label => "This hostname: $host (any scheme or port)",
192 match => "^https?://" . (quotemeta $host) . "(?::[0-9]+)?/" },
193 );
194 if ($host !~ /^\[/ && $host =~ /\./ && $host !~ /^[0-9.]+$/) {
195 push @choices, {
196 label => "This hostname and subdomains: $host (any scheme or port)",
197 match => "^https?://(?:[a-z0-9-]+\\.)*" . (quotemeta $host) . "(?::[0-9]+)?/",
198 };
199 }
200 push @choices, { label => "Any HTTP(S) URL", match => "^https?://" };
201
202 ($full, \@choices)
203 }
204
205 sub text_regex {
206 my ($pattern) = @_;
207
208 defined $pattern && !ref $pattern && length $pattern <= 512
209 or die "Invalid regex pattern\n";
210 my $regex = eval { qr/$pattern/ };
211 $@ and die "Invalid regex: $@";
212
213 $regex
214 }
215
216 sub filter_text {
217 my ($text, $args) = @_;
218
219 if (defined $args->{grep} || defined $args->{regex}) {
220 my $grep = $args->{grep};
221 my $regex = defined $args->{regex} ? text_regex ($args->{regex}) : undef;
222 $text = join "", grep {
223 (!defined $grep || index ($_, $grep) >= 0)
224 && (!defined $regex || /$regex/)
225 } split /(?<=\n)/, $text;
226 }
227
228 if (my $subst = $args->{substitute}) {
229 my $regex = text_regex ($subst->{pattern});
230 my $replacement = $subst->{replacement};
231 $text =~ s/$regex/$replacement/g;
232 }
233
234 $text
235 }
236
237 mcp_tool request => "HTTP request. Method and URL permissions. No redirects; caller-managed cookie_jar returned. "
238 . "UTF-8 body or Base64, bounded response, optional grep/regex/substitute.",
239 {
240 type => "object",
241 required => ["url"],
242 additionalProperties => JSON::XS::false,
243 properties => {
244 url => { type => "string", minLength => 1 },
245 method => { type => "string", default => "GET" },
246 headers => { type => "object", additionalProperties => { type => "string" } },
247 body => { type => "string", description => "UTF-8 request body" },
248 body_base64 => { type => "string", description => "Raw request body, Base64" },
249 cookie_jar => { type => "object", description => "AnyEvent::HTTP cookie jar returned by an earlier call" },
250 timeout => { type => "number", minimum => 1, maximum => 300, default => 30 },
251 max_bytes => { type => "integer", minimum => 1, maximum => 2097152, default => 65536 },
252 grep => { type => "string", description => "Keep lines containing this literal substring" },
253 regex => { type => "string", description => "Keep lines matching this Perl regex" },
254 substitute => {
255 type => "object",
256 required => ["pattern", "replacement"],
257 additionalProperties => JSON::XS::false,
258 properties => {
259 pattern => { type => "string", maxLength => 512 },
260 replacement => { type => "string", maxLength => 512 },
261 },
262 },
263 },
264 },
265 sub {
266 my ($req, $args) = @_;
267
268 my $method = $args->{method} // "GET";
269 $method =~ /\A[A-Za-z0-9!#\$%&'*+.^_\x60|~-]+\z/
270 or die "Invalid HTTP method\n";
271 exists $args->{body} && exists $args->{body_base64}
272 and die "Specify body or body_base64, not both\n";
273 my ($url, $scopes) = url_scopes ($args->{url});
274
275 my $headers = $args->{headers} || {};
276 for my $key (keys %$headers) {
277 $key =~ /\A[A-Za-z0-9!#\$%&'*+.^_\x60|~-]+\z/
278 && defined $headers->{$key} && !ref $headers->{$key}
279 && $headers->{$key} !~ /[\r\n\x00]/
280 or die "Invalid HTTP header\n";
281 }
282 $headers = { %$headers };
283 $headers->{"User-Agent"} = "mcp-http/$AnyEvent::MCP::VERSION"
284 unless grep lc $_ eq "user-agent", keys %$headers;
285 my $data = exists $args->{body} ? (AnyEvent::MCP::utf8enc $args->{body}) : undef;
286 if (defined $args->{body_base64}) {
287 $args->{body_base64} =~ /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/
288 or die "Invalid Base64 body\n";
289 $data = MIME::Base64::decode_base64 ($args->{body_base64});
290 }
291 !defined $data || length $data <= 1048576 or die "Request body exceeds 1 MiB\n";
292 my $limit = $args->{max_bytes} // 65536;
293 $limit >= 1 && $limit <= 2097152 or die "Invalid max_bytes\n";
294
295 !defined $args->{grep} || !ref $args->{grep} or die "Invalid grep\n";
296 text_regex ($args->{regex}) if defined $args->{regex};
297 if (defined $args->{substitute}) {
298 ref $args->{substitute} eq "HASH" && defined $args->{substitute}{replacement}
299 && !ref $args->{substitute}{replacement} && length $args->{substitute}{replacement} <= 512
300 or die "Invalid substitute\n";
301 text_regex ($args->{substitute}{pattern});
302 }
303 my $jar = $args->{cookie_jar};
304 !defined $jar || ref $jar eq "HASH" or die "Invalid cookie jar\n";
305
306 $req->require_permission ({
307 tool => $req->{tool},
308 action => $method,
309 dimensions => { url => { label => "URL", value => $url, choices => $scopes } },
310 sandbox_grants => [],
311 reason => "$method $url",
312 });
313
314 my $body = "";
315 my $truncated = 0;
316 my $response_headers;
317 my $guard;
318 $guard = AnyEvent::HTTP::http_request
319 $method => $url,
320 recurse => 0,
321 persistent => 0,
322 proxy => undef,
323 tls_ctx => "high",
324 timeout => $args->{timeout} // 30,
325 headers => $headers,
326 (defined $data ? (body => $data) : ()),
327 (defined $jar ? (cookie_jar => $jar) : ()),
328 on_header => sub {
329 $response_headers = { %{$_[0]} };
330 1
331 },
332 on_body => sub {
333 my ($part) = @_;
334 if (length ($body) + length ($part) > $limit) {
335 $body .= substr $part, 0, $limit - length $body;
336 $truncated = 1;
337 return 0;
338 }
339 $body .= $part;
340 1
341 },
342 sub {
343 my ($unused, $hdr) = @_;
344
345 undef $guard;
346 return if $req->{finished};
347 $hdr = $response_headers if $truncated && $response_headers;
348 my %result = (
349 status => 0 + $hdr->{Status},
350 reason => $hdr->{Reason},
351 headers => { map +($_ => $hdr->{$_}), grep /^[a-z]/, keys %$hdr },
352 captured_bytes => length $body,
353 truncated => $truncated ? JSON::XS::true : JSON::XS::false,
354 (defined $jar ? (cookie_jar => $jar) : ()),
355 );
356
357 if (defined $unused || $truncated) {
358 my $bytes = $body;
359 my $text = eval { Encode::decode ("UTF-8", $bytes, Encode::FB_CROAK) };
360 if (defined $text) {
361 $text = filter_text ($text, $args);
362 if (length $text > $limit) {
363 $text = substr $text, 0, $limit;
364 $result{truncated} = JSON::XS::true;
365 }
366 $result{body} = $text;
367 } elsif (defined $args->{grep} || defined $args->{regex} || defined $args->{substitute}) {
368 $req->error ("Cannot filter a non-UTF-8 response");
369 return;
370 } else {
371 $result{body_base64} = MIME::Base64::encode_base64 ($body, "");
372 }
373 }
374
375 $req->result (\%result);
376 }
377 ;
378 $req->{on_cancel} = sub { undef $guard };
379 };
380
381 mcp_start %$options;