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