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 (6 hours, 32 minutes ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.7: +5 -0 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.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 root 1.8 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 root 1.4 =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 root 1.1 =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 root 1.5 Shared switches are documented in
91     L<AnyEvent::MCP::Server::Simple/COMMON COMMAND-LINE OPTIONS>.
92 root 1.6 The following shared switch has HTTP-permission-specific behavior.
93 root 1.1
94     =over
95    
96     =item --ask MODE
97    
98 root 1.5 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 root 1.6 when advertised, otherwise the local helper; C<none> denies unmatched
102     permission checks without prompting.
103 root 1.1
104     =back
105    
106     =cut
107    
108     use common::sense;
109 root 1.3
110     use MIME::Base64 ();
111     use Encode ();
112     use JSON::XS ();
113    
114 root 1.1 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 root 1.6 };
126 root 1.1
127     @ARGV and die "Unexpected arguments\n";
128    
129 root 1.7 mcp_permissions +AnyEvent::MCP::Server::Permissions->new (
130 root 1.1 name => "http-$options->{name}",
131     );
132    
133     mcp_config
134 root 1.8 idle_exit => 300,
135 root 1.1 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 root 1.2 mcp_log_request $server, $req,
159     method => (substr $method, 0, 20),
160     url => (substr $url, 0, 160);
161 root 1.1 };
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;