view release on metacpan or search on metacpan
and their obsolete reference tests.
- defer outgoing mail submission research (mcp-smtp or mcp-imap)
in TODO.md.
- retry reverse connections only for TCP connection refusal and
unexpected disconnects; report and stop on other TCP errors or
TLS handshake/verification failures.
- force scalar context when checking Mail::IMAPTalk results;
list context truncated LIST responses and could corrupt SEARCH
results. Test with context-sensitive IMAP mocks.
- report HTTPS reverse-connection handshake failures rather than
silently retrying; send credentials only after verified TLS succeeds.
- omit pointless mailbox permission dimension for mcp-imap mailboxes.
- clarify expected OAuth redirect error page and account selection.
- implement mcp-imap OAuth2 authorization-code login with PKCE,
human-pasted headless browser redirect, HTTPS token exchange,
refresh-token persistence/rotation and IMAP SASL XOAUTH2.
Keep interactive credentials outside MCP tools and argv.
- omit unnecessary shell quotes in mcp-imap providers commands.
- expand mcp-imap providers with Thunderbird ISPDB domain search,
provider-hosted HTTPS autoconfig lookup and ISPDB fallback,
password-based IMAP alternatives and provider setup instructions;
continue printing optional OAuth configuration without saving it.
- add per-downstream --hidden to mcp-multibroker, omitting a
server's tools from normal discovery while retaining explicit
list-tools, discover-brokers and call-tool access.
- number the user management test consecutively as t/27_users.t.
- tidy Perl import grouping, internal comments and source layout
in the command-line tools and library modules.
- stop imposing an IMAP account file mode check; access is governed
by ordinary filesystem permissions.
- use the permission engine's literal matching convention for exact
user identities and test positive permission-scope validation.
- reject invalid browser WebSocket credentials via the documented
handshake exception interface, producing one specific warning.
- apply the same handshake rejection handling to Vim WebSocket
connections, including peer and invalid-token/path warnings.
- document user-management scopes and URL examples in command help.
- add scoped, revocable MCP users with HKDF-derived credentials,
an implicit global default user, and atomic user management;
list effective user URLs with --print-url[=USER], also accepting
--print-urls, without binding the advertised listener port.
- associate HTTP credentials with per-request users, bind user
identity to persistent permissions and permission continuations,
and delegate original users through trusted multibrokers.
- select the effective connect_user (default: default) for reverse
MCP, private Vim and browser connections, independently of normal
authenticated HTTP tool calls.
- treat empty directory paths as "." in mcp-fs list, find, grep and
VCS cwd, without accepting empty filenames.
- support opportunistic HTTP/HTTPS and reverse MCP connections on the
same listener, TLS certificate/key CLI options, and dual URL discovery;
support verified HTTPS for outgoing reverse connections.
Use the permission rules file, normally created through approval
dialogs. This file is separate from the global configuration.
Location of permission prompts
Use "--ask helper", "--ask tty", "--ask yad", "--ask auto" or "--ask
mcp", or set "PERL_ANYEVENT_MCP_PERMISSION_ASK" in the server's
environment.
Combined server connections
Put prefix/server pairs on the multibroker startup command. HTTP
child credentials can also use "[client PREFIX]" in the global
configuration.
One complete named configuration
The global file is "$XDG_CONFIG_HOME/anyevent-mcp/global.ini", falling
back to "$HOME/.config/anyevent-mcp/global.ini". Settings are UTF-8 "key
= value" lines; values are unquoted. Whole-line "#" and ";" comments are
allowed. See "GLOBAL CONFIGURATION" in AnyEvent::MCP for the complete
format.
Start with the existing file, preserving its "[global]" secret. If you
Instance settings override program settings.
File ownership and permissions are under the user's control. The global
configuration contains a master authentication secret, so take care when
sharing it. "--config FILE" on "anyevent-mcp init" selects that
command's output file; it does not make the servers use an arbitrary
alternate file. Use the same "HOME"/"XDG_CONFIG_HOME" environment when
running the server and its management commands.
Names, prefixes and profiles
"--name code" selects instance settings, derived HTTP credentials and
mcp-fs's "rules/fs-code.ini". Without "--name", mcp-fs uses "default",
giving profile "fs-default". Two processes with the same program, name
and configuration share credentials and rules, even if their workdirs
differ.
A multibroker prefix, such as "files", only routes tools: "grep" becomes
"files.grep". It does not rename the child server or select its rules.
For example:
mcp-multibroker files \
'stdio:mcp-fs --name code /home/alice/src/project'
The tools are "files.*", but the child still uses instance "code" and
notes 'stdio:mcp-fs --name notes /home/alice/notes'
"--stdio" selects the upstream connection. The child commands still use
"stdio:"; that selects each downstream connection independently. No
upstream HTTP URL is needed in this case.
The same setup can include Vim, browser pages, a third-party stdio
server or a remote HTTP service. See "Add an HTTP child" for an example
mixing local and remote servers, and "DOWNSTREAM SERVERS" in
mcp-multibroker for the complete syntax, including direct argv
execution, credentials and per-child options.
Discover and refresh tools
Ask the LLM:
Call discover-brokers and list-tools, then show me which servers
are available and what each is for.
"discover-brokers" returns advertised instructions (which may be short),
no instructions, or an error for each configured server. "list-tools"
requests current tool definitions, including any prefixed
timeout = 300
to the private global configuration, and omit "--bearer" from the
command. The prefix "remote" selects these client defaults. The
command-line URL still chooses the endpoint. Use the actual credential
accepted by that server, not the literal placeholder "TOKEN".
A token already present in a child's URL needs no separate bearer
option. The bundled servers use static URL-safe tokens; they do not
validate OAuth access tokens or implement HTTP Basic authentication. Use
TLS or an appropriate tunnel when credentials cross untrusted networks.
The multibroker does not put every child inside the mcp-fs sandbox. Each
process or remote service has its own authority and enforcement.
Add a live Vim editor
Install the Vim plugin using "VIM PLUGIN INSTALLATION" in mcp-vim. It is
included in the source distribution but is not installed by Perl's "make
install".
Without a multibroker, configure the MCP client to launch:
To add a user and obtain their multibroker URL:
anyevent-mcp create-user chatgpt
mcp-multibroker --print-url=chatgpt
An implicit global "default" user starts at generation 1. The program,
instance, user and generation derive its token; the program, instance
and master secret determine a stable port in 16384..32767. Revoke a user
to disable its token; create it again to rotate the generation. Losing
the master secret changes all derived credentials. Keep an existing
"[global]" secret when editing other configuration.
To configure explicit tokens:
[instance code]
tokens = first-secret,second-secret
Replace those placeholders with actual unguessable URL-safe tokens.
Repeated "--token" options replace the whole configured list and the
derived managed users. In this mode "--print-url" lists all enabled
An Origin allowlist restricts browser origins; it does not authenticate
clients. A CORS preflight can succeed without a token, while the MCP
POST still requires one. See "AUTHENTICATION" in
AnyEvent::MCP::Server::HTTP for the low-level server interface.
CLIENT EXAMPLES
Client settings and command syntax evolve independently of this
distribution. These examples show the connection choices; consult the
linked client documentation if its interface differs. Replace
illustrative paths and credentials with your own.
OpenAI Codex CLI
For the first direct filesystem connection:
codex mcp add code -- \
mcp-fs --stdio --name code /home/alice/src/project
codex mcp list
For a multibroker connection:
remain active while the hosted client uses the server. Use one of
"--mcp.command" and "--mcp.server-url", not both.
In ChatGPT, add a custom MCP server using a Tunnel connection and select
the tunnel ID. Creation and visibility depend on the Platform and
ChatGPT workspace permissions. The tunnel client's CLI and supported
configuration forms can vary between releases; the examples above use
the direct runtime CLI rather than its optional profile setup. See
Secure MCP Tunnel
<https://developers.openai.com/api/docs/guides/secure-mcp-tunnels> for
obtaining a tunnel, runtime, credentials and connecting it to ChatGPT.
TROUBLESHOOTING
The server does not start
Read stderr or the client's server log. Missing Perl modules, a missing
workdir, unsuitable utilities or an unavailable bubblewrap executable
can fail startup. Confirm that the client launches the same executable
and arguments you intended, with the expected "PATH" and Perl
installation.
For a local sandbox check, run from the project directory:
This assumes "/opt" exists. Restart mcp-fs after changing trusted
mounts. If your worker Perl is "/opt/bin/perl", you can also set
"executable.perl = /opt/bin/perl" in that program section. Supply the
actual executable path: selecting it does not itself mount the
executable or its dependencies inside the worker.
A trusted mount supplies baseline access to every worker using it; a
writable mount likewise supplies baseline write authority.
An arbitrary command might also need its user's configuration or
repository credentials under HOME, or network access. Request the
corresponding grants when needed; do not assume "workdir-read" includes
them. For example, CVS over SSH can use "workdir-read" and "ssh-agent".
The latter includes network access, the agent socket and
"~/.ssh/known_hosts", without exposing private key files or the rest of
HOME. The broker must inherit "SSH_AUTH_SOCK" from your environment.
User-configured helpers outside the runtime mounts need an explicit
trusted mount. An explicit exec grant array replaces the default rather
than adding to it.
My configuration change seems to be ignored
bin/anyevent-mcp view on Meta::CPAN
allowed or denied permanently, remove a rule, or change the order in which
rules are checked.
C<manage-rules> opens a graphical rule manager; C<edit-rules> opens the
rule file in your text editor. Both take a rule profile name. For example,
C<anyevent-mcp manage-rules fs-default> manages the default filesystem
server's rules. The corresponding C<*-file> commands accept a pathname
when an application stores its rules elsewhere.
C<init> creates the private global configuration and its master secret,
from which servers can derive their connection credentials. You normally
do not need to run it: a server creates a missing secret when it loads its
configuration. Use C<init> when you want to create the configuration
explicitly; it refuses to overwrite an existing file.
See L<AnyEvent::MCP::Intro> for setting up and connecting to the servers,
L<AnyEvent::MCP/GLOBAL CONFIGURATION> for the configuration file format,
and L<AnyEvent::MCP::Server::Permissions/RULE FILES> for permission rules.
=head1 COMMANDS
bin/anyevent-mcp view on Meta::CPAN
applications whose rule files do not use the named-profile convention.
=item create-user USER
Enable a new named user; rotate a revoked user at the next generation.
Use --program or --instance for a scoped override.
=item revoke-user USER
Disable a user, preserving the generation for later rotation. In a narrower
scope, a negative definition also blocks inherited credentials. Implicit
global:default is revocable. Restart affected servers after changes; these
commands do not print secrets. Use server --print-url[=USER] for URLs.
=back
Named profiles use
C<anyevent-mcp/rules/NAME.ini> in the user configuration directory.
Editing uses C<$VISUAL>, C<$EDITOR>, C<sensible-editor> or C<vi>.
For server URLs, use each server's C<--print-url> option.
bin/mcp-multibroker view on Meta::CPAN
C<PERL_ANYEVENT_MCP_ELICITATION_HELPER> to replace the form helper and
C<PERL_ANYEVENT_MCP_PERMISSION_HELPER> to replace the permission helper.
Cancelling a tool call cancels its downstream operation and pending local
prompt.
=head1 CLIENT CONNECTION
The upstream listener uses L<AnyEvent::MCP::Server::Simple/mcp_start>: HTTP
without stdout output by default, or stdio when run by another broker.
The service instance name defaults to C<default>. Repeated
C<--token TOKEN> options before the broker pairs configure credentials
accepted by the upstream HTTP listener. They are separate from C<--bearer>
after a downstream broker URL, which authenticates the client connection.
C<--name NAME>
selects C<[instance NAME]> in the global configuration, including its
credentials and optional C<bind> address. C<--bind ADDRESS[:PORT]> overrides
that address. The default is IPv4 loopback with a stable name-derived port
in 16384..32767. Port zero requests an ephemeral port.
C<--print-url> prints the credential-bearing URL without opening a listener
or starting children; it requires a nonzero port. Upstream stdio startup
skips HTTP listener configuration unless C<--listen> is specified; named HTTP
children still load their client defaults. Browser origins are unrestricted by default; HTTP requests
still require an accepted token. See L<AnyEvent::MCP::Intro> for examples.
=head1 OPTIONS
bin/mcp-vim view on Meta::CPAN
The Vim plugin uses a private protocol, not MCP. Its initial raw JSON line is
C<["mcp-vim", cookie, major, minor, target, instance_id]>; subsequent messages
use Vim's ordinary JSON channel envelope. The broker accepts clients with the
same major version and a minor version no newer than its own. Keep the broker
and plugin versions compatible when updating them.
The broker itself can connect to a multibroker using
C<--connect http://host:port/token/alias>. Its own TCP listener remains active
for Vim and browser connections. The multibroker URL and the Vim broker's own
listener URL have independent credentials.
=head1 CONNECTIONS AND AUTHENTICATION
By default, the broker derives a stable port in the 16384..32767 range
from its service-instance name, defaulting to C<default>. Managed users
have individually derived tokens. All enabled users may authenticate MCP
requests, but only C<connect_user> (default C<default>) may register Vim
editors or XPCSE WebSockets. Configured C<tokens> or repeated C<--token>
options replace managed credentials and admit those explicit tokens for all
connection types. Keep tokens private.
By default the TCP listener binds IPv4 loopback on the name-derived port.
C<bind> in C<[instance NAME]> or C<--bind> overrides the address and port.
C<--stdio>, or C<PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL=stdio>, additionally
serves MCP over stdin/stdout. The TCP listener remains active. In this mode
the process exits after stdio closes and pending responses have drained.
C<--print-url> prints enabled user URLs, annotating the connect user;
bin/mcp-webbrowser view on Meta::CPAN
this target, or any code in all targets. Operation exec prompts offer
corresponding scopes for browser operations. Independently, each exec request
offers this origin, the same scheme and host on any port, or any origin.
Only HTTP(S) origins offer persistent scopes: opaque origins such as C<null>
cannot distinguish different documents, so they allow one-time decisions only.
Saved target grants also cover future tabs using the same target name.
Saved rules use C<match.subject> and C<match.origin> as independent fields.
Rules without C<match.origin> match all origins, as do broad C<--auto-allow>
overrides. The broker derives origins from the URL already reported by the
userscript on connect, state changes and navigation, including older clients.
For HTTP(S), credentials and the path/query/fragment are excluded.
Origin identity relies on the connected userscript's reported URL, not on
independently authenticating the page's location.
C<--ask> selects the same helper, tty, yad, mcp or auto modes as other servers.
=head1 OPTIONS
Shared switches are documented in
L<AnyEvent::MCP::Server::Simple/COMMON COMMAND-LINE OPTIONS>.
The following switch is specific to C<mcp-webbrowser>.
lib/AnyEvent/MCP.pm view on Meta::CPAN
=item L<AnyEvent::MCP::Server::Permissions>
Action authorization and sandbox-grant handling for MCP servers.
=item L<AnyEvent::MCP::Permissions::Ask>
External permission dialog handling and form conversion for clients.
=item L<AnyEvent::MCP::Config>
Private global configuration, named server settings and derived credentials.
=item L<AnyEvent::MCP::Helper>
Asynchronous JSON exchange with an external form or permission helper.
=item L<AnyEvent::MCP::Workdirs> and L<AnyEvent::MCP::File>
Persistent workdir identifiers and their locked state-file utilities.
=item L<AnyEvent::MCP::Extension>
lib/AnyEvent/MCP.pm view on Meta::CPAN
permissions. A missing secret is generated automatically on first configuration
access and saved when possible. Existing secrets are never replaced implicitly.
An invalid secret is an error.
Applications can derive their own purpose-specific secrets with
L<AnyEvent::MCP::Config/derive_secret>, using the cached global configuration
or an explicitly loaded configuration object.
If saving fails, Config warns and retains the secret in memory. Credentials
then remain stable only for that process; other processes cannot derive the
same credentials. If randomness is unavailable, supply a securely generated
secret in the file.
C<[program PROGRAM]> supplies settings for the invoked program basename;
C<[instance NAME]> overrides them for a named instance.
HTTP-capable executables use C<--name NAME> to select these settings, with
C<default> as the default instance name. Names consist of an ASCII
letter or underscore followed by ASCII letters, digits, underscores or
hyphens. Their C<--bind ADDRESS[:PORT]> overrides the configured C<bind>.
The default bind address is IPv4 loopback with a port derived from the program,
instance name and master secret in the range 16384..32767; C<:0> requests an ephemeral
lib/AnyEvent/MCP.pm view on Meta::CPAN
prints just the chosen user's URLs, without listening.
Named server settings also include C<path>, C<url_host>,
C<url_scheme> and C<tokens>. C<tokens> is a comma-separated list of
URL-safe strings. Repeated C<--token TOKEN> options override this list.
Automatic permission defaults use C<auto-rule.SUFFIX = allow|deny
TOOL[,ACTION[,CAPABILITY]]> in program/instance sections, sorted by suffix.
Instance entries override matching keys or suppress them with empty values.
Command-line automatic rules are evaluated before configured defaults.
All accepted tokens work in the URL path or Authorization bearer header.
Without configured tokens, enabled users in C<[global]>,
C<[program PROGRAM]> and C<[instance NAME]> each have derived credentials,
with later scopes overriding earlier ones. A missing global
C<user.default> is equivalent to C<user.default = 1>. Positive
generations are enabled; negative generations are revoked and retain
their counter for later rotation. Zero is invalid. The principal used
for permissions is C<global:USER>, C<program:USER>, or C<instance:USER>.
All derivation contexts have the C<anyevent-mcp> namespace prefix.
The C<connect_user> setting (default: C<default>) chooses which user's
token may attach Vim editor, browser WebSocket or reverse-MCP connections.
Disabling that user prevents new auxiliary connections, without stopping
other enabled users from making MCP requests.
lib/AnyEvent/MCP/Client/HTTP.pm view on Meta::CPAN
use AnyEvent::MCP::Client::HTTP;
my $client = AnyEvent::MCP::Client::HTTP->new (
name => "example", url => $url, bearer => $token,
timeout => 30,
);
=head1 DESCRIPTION
Use this transport to talk to an MCP server through HTTP. The caller supplies
the endpoint and, if needed, credentials; the common Client layer handles
discovery and tool calls. Unlike a browser, this client does not follow
redirects or silently retry requests. A redirected request could disclose
credentials to another host, and retrying a tool call could perform an
operation twice.
=head1 FIRST CALL
For a bundled MCP server already listening on HTTP, use the full URL
it prints with C<--print-url>:
use AnyEvent;
use AnyEvent::MCP::Client::HTTP;
lib/AnyEvent/MCP/Client/HTTP.pm view on Meta::CPAN
Supplies C<Authorization: Bearer TOKEN>. Without it, the URL alone is
used; a bearer token is not inferred from a URL. The server must accept
the selected credential. With the bundled servers, the URL-path token
and bearer header present the same static credential.
=item name => $name
Loads C<url>, C<bearer> and C<timeout> defaults from C<[client NAME]>.
Explicit constructor arguments override them, including explicitly undefined
credentials. Without a name no global configuration is accessed.
=item timeout => $seconds
HTTP inactivity timeout, defaulting to 300 seconds. Use the common
C<request_timeout> option for an overall tool-call deadline.
=item headers => { %headers }
Additional HTTP headers. Protocol headers and an explicitly configured
bearer header are supplied by this module.
lib/AnyEvent/MCP/Config.pm view on Meta::CPAN
=head1 NAME
AnyEvent::MCP::Config - named servers and derived MCP credentials
=head1 SYNOPSIS
use AnyEvent::MCP::Config;
my $config = AnyEvent::MCP::Config->global;
my $token = $config->user_credentials ("agni")->[0][2];
my $url = $config->http_url ("agni", undef, path => "/");
=head1 DESCRIPTION
An MCP server and its clients often need matching credentials. Rather than
maintaining a separate password for each server and user, this module can
derive them from one private master secret. Users are defined by
C<user.NAME = GENERATION> in C<[global]>, C<[program PROGRAM]> or
C<[instance NAME]>, in that order of precedence. An absent global
C<user.default> is implicitly enabled at generation 1.
The shared file format is described in L<AnyEvent::MCP/GLOBAL CONFIGURATION>.
Loading this module does not read any files. The first call to C<global>
reads the configuration, ensures a master secret exists and caches the object
for the process.
A missing secret is generated using C<AnyEvent::MCP::random_bytes> and saved
when possible. Saving failure produces a warning; the generated secret remains
usable in this process, but another process might derive different credentials.
A supplied invalid secret is an error. Changing or losing the master secret
invalidates all credentials derived from it.
Derivation uses C<derive_secret> with the random master key.
The application namespace, purpose, program, instance name and username are
separately length-prefixed, so distinct contexts derive different credentials.
Deriving a password does not create an account or configure the server to
accept it: authentication still belongs to the server.
=head1 METHODS
=over
=item $config = AnyEvent::MCP::Config->global (file => $file)
Returns the cached global configuration. The first call accepts the same
lib/AnyEvent/MCP/Config.pm view on Meta::CPAN
Derivation uses HKDF-SHA512 expansion, with each context field prefixed by
its four-byte big-endian byte length. Changing or losing the master secret
changes every result derived from it.
=item $credential = $config->credential ($purpose, $name, $username)
Derives a 32-byte credential, returned as unpadded URL-safe Base64.
C<$purpose> is C<token>. C<$name> is the instance name, and C<$username>
is optional. This is the older unmanaged derivation API; its result is not
automatically accepted by a server. Managed credentials are enumerated by
C<user_credentials>.
Credentials use the context C<("anyevent-mcp", "token", PROGRAM, NAME, USER)>.
PROGRAM is the invoked basename, with a trailing C<.pl> or C<.perl> removed.
Changing the program or instance name changes the credential.
A credential derived with C<$username> is B<not> automatically accepted
by the server. This method neither requires a server section nor configures
authentication.
=item $users = $config->users ($instance, $program)
Return effective user definitions keyed by username, each with C<scope>
(global, program or instance) and signed C<generation>. Inheritance is
global, program, instance; a negative generation disables without fallback.
=item $credentials = $config->user_credentials ($instance, $program)
Return enabled users as C<[[USERNAME, PRINCIPAL, TOKEN]...]> with default
first. The principal is C<global:NAME>, C<program:NAME>, or C<instance:NAME>.
HKDF context includes program, instance, scope, user and generation. Rotating
a generation changes the token but preserves saved user permissions.
=item $user = $config->connect_user ($instance, $program)
Return the username allowed to register private Vim, browser WebSocket, or
multibroker reverse-MCP connections. C<connect_user> inherits through the
lib/AnyEvent/MCP/Config.pm view on Meta::CPAN
unless (exists $disk->{section}{global}{secret}) {
seek $fh, 0, 2 or die "$self->{file}: $!\n";
print {$fh} "\n[global]\nsecret = $secret\n"
or die "$self->{file}: $!\n";
$disk->{section}{global}{secret} = $secret;
}
close $fh or die "$self->{file}: $!\n";
$self->{section} = $disk->{section};
1
} or warn "Cannot save MCP master secret; credentials may only be stable for this process: $@";
$self->{section}{global}{secret} = $secret
unless exists $self->{section}{global}{secret};
}
sub init {
my ($class, %kv) = @_;
my $file = exists $kv{file} ? $kv{file} : _file;
lib/AnyEvent/MCP/Config.pm view on Meta::CPAN
my $generation = $section->{$key};
$generation =~ /\A-?[1-9][0-9]*\z/ && length $generation < 18
or die "Invalid MCP user generation for '$key'\n";
$users{$user} = { scope => $scope, generation => $generation };
}
}
\%users
}
sub user_credentials {
my ($self, $name, $program) = @_;
$name = server_name $name;
$program //= program_name;
my $users = $self->users ($name, $program);
my @credentials;
for my $user (sort { ($a ne "default") <=> ($b ne "default") || $a cmp $b } keys %$users) {
my $info = $users->{$user};
next unless $info->{generation} > 0;
my $token = MIME::Base64::encode_base64 ($self->derive_secret (
32, "user-token-v1", AnyEvent::MCP::utf8enc ($program),
AnyEvent::MCP::utf8enc ($name), $info->{scope},
AnyEvent::MCP::utf8enc ($user), "$info->{generation}"
), "");
$token =~ y!+/=!-_!d;
push @credentials, [$user, "$info->{scope}:$user", $token];
}
\@credentials
}
sub connect_user {
my ($self, $name, $program) = @_;
$name = server_name $name;
$program //= program_name;
my $user = $self->{section}{"instance $name"}{connect_user}
// $self->{section}{"program $program"}{connect_user}
// $self->{section}{global}{connect_user} // "default";
lib/AnyEvent/MCP/Config.pm view on Meta::CPAN
my (undef, $address) = AnyEvent::Socket::unpack_sockaddr $resolved->[3];
$kv{url_host} //= $host;
$kv{host} = AnyEvent::Socket::format_address $address;
}
if (exists $settings->{tokens}) {
$kv{tokens} = [split /\s*,\s*/, $settings->{tokens}, -1];
$kv{connect_tokens} = $kv{tokens};
} else {
my $credentials = $self->user_credentials ($name);
$kv{tokens} = [map $_->[2], @$credentials];
$kv{token_users} = { map { $_->[2] => $_->[1] } @$credentials };
my $connect = $self->connect_user ($name);
$kv{connect_tokens} = [map $_->[2], grep $_->[0] eq $connect, @$credentials];
}
%kv
}
sub http_url {
my ($self, $name, $bind, %options) = @_;
$name = server_name $name;
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
This file is separate from the global configuration.
=item Location of permission prompts
Use C<--ask helper>, C<--ask tty>, C<--ask yad>, C<--ask auto> or C<--ask mcp>, or set
C<PERL_ANYEVENT_MCP_PERMISSION_ASK> in the server's environment.
=item Combined server connections
Put prefix/server pairs on the multibroker startup command. HTTP child
credentials can also use C<[client PREFIX]> in the global configuration.
=back
=head2 One complete named configuration
The global file is C<$XDG_CONFIG_HOME/anyevent-mcp/global.ini>, falling
back to C<$HOME/.config/anyevent-mcp/global.ini>. Settings are UTF-8
C<key = value> lines; values are unquoted. Whole-line C<#> and C<;>
comments are allowed. See L<AnyEvent::MCP/GLOBAL CONFIGURATION> for the
complete format.
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
File ownership and permissions are under the user's control. The global
configuration contains a master authentication secret, so take care when
sharing it. C<--config FILE> on C<anyevent-mcp init>
selects that command's output file; it does not make the servers use an
arbitrary alternate file. Use the same C<HOME>/C<XDG_CONFIG_HOME>
environment when running the server and its management commands.
=head2 Names, prefixes and profiles
C<--name code> selects instance settings, derived HTTP credentials and
mcp-fs's C<rules/fs-code.ini>. Without C<--name>, mcp-fs uses C<default>,
giving profile C<fs-default>. Two processes with the same program, name and
configuration share credentials and rules, even if their workdirs differ.
A multibroker prefix, such as C<files>, only routes tools: C<grep> becomes
C<files.grep>. It does not rename the child server or select its rules.
For example:
mcp-multibroker files \
'stdio:mcp-fs --name code /home/alice/src/project'
The tools are C<files.*>, but the child still uses instance C<code>
and rule-manager profile C<fs-code>. The multibroker's own C<--name>
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
notes 'stdio:mcp-fs --name notes /home/alice/notes'
C<--stdio> selects the upstream connection. The child commands still use
C<stdio:>; that selects each downstream connection independently.
No upstream HTTP URL is needed in this case.
The same setup can include Vim, browser pages, a third-party stdio
server or a remote HTTP service. See L</Add an HTTP child> for an
example mixing local and remote servers, and L<mcp-multibroker/DOWNSTREAM
SERVERS> for the complete syntax, including direct argv execution,
credentials and per-child options.
=head2 Discover and refresh tools
Ask the LLM:
Call discover-brokers and list-tools, then show me which servers
are available and what each is for.
C<discover-brokers> returns advertised instructions (which may be short),
no instructions, or an error for each configured server.
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
timeout = 300
to the private global configuration, and omit C<--bearer> from the command.
The prefix C<remote> selects these client defaults. The command-line URL
still chooses the endpoint. Use the actual credential accepted by that
server, not the literal placeholder C<TOKEN>.
A token already present in a child's URL needs no separate bearer option.
The bundled servers use static URL-safe tokens; they do not validate
OAuth access tokens or implement HTTP Basic authentication.
Use TLS or an appropriate tunnel when credentials cross untrusted networks.
The multibroker does not put every child inside the mcp-fs sandbox.
Each process or remote service has its own authority and enforcement.
=head2 Add a live Vim editor
Install the Vim plugin using L<mcp-vim/VIM PLUGIN INSTALLATION>. It is
included in the source distribution but is not installed by Perl's
C<make install>.
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
To add a user and obtain their multibroker URL:
anyevent-mcp create-user chatgpt
mcp-multibroker --print-url=chatgpt
An implicit global C<default> user starts at generation 1. The program,
instance, user and generation derive its token; the program, instance and
master secret determine a stable port in 16384..32767. Revoke a user to
disable its token; create it again to rotate the generation. Losing the master secret changes
all derived credentials. Keep an existing C<[global]> secret when editing
other configuration.
To configure explicit tokens:
[instance code]
tokens = first-secret,second-secret
Replace those placeholders with actual unguessable URL-safe tokens.
Repeated C<--token> options replace the whole configured list and the
derived managed users. In this mode C<--print-url> lists all enabled
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
An Origin allowlist restricts browser origins; it does not authenticate
clients. A CORS preflight can succeed without a token, while the MCP POST
still requires one. See L<AnyEvent::MCP::Server::HTTP/AUTHENTICATION> for
the low-level server interface.
=head1 CLIENT EXAMPLES
Client settings and command syntax evolve independently of this distribution.
These examples show the connection choices; consult the linked client
documentation if its interface differs. Replace illustrative paths and
credentials with your own.
=head2 OpenAI Codex CLI
For the first direct filesystem connection:
codex mcp add code -- \
mcp-fs --stdio --name code /home/alice/src/project
codex mcp list
For a multibroker connection:
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
the tunnel only connects to it. Both modes require the tunnel runtime
to remain active while the hosted client uses the server. Use B<one>
of C<--mcp.command> and C<--mcp.server-url>, not both.
In ChatGPT, add a custom MCP server using a Tunnel connection and select
the tunnel ID. Creation and visibility depend on the Platform and ChatGPT
workspace permissions. The tunnel client's CLI and supported configuration
forms can vary between releases; the examples above use the direct runtime
CLI rather than its optional profile setup. See
L<Secure MCP Tunnel|https://developers.openai.com/api/docs/guides/secure-mcp-tunnels>
for obtaining a tunnel, runtime, credentials and connecting it to ChatGPT.
=head1 TROUBLESHOOTING
=head2 The server does not start
Read stderr or the client's server log. Missing Perl modules, a missing
workdir, unsuitable utilities or an unavailable bubblewrap executable can
fail startup. Confirm that the client launches the same executable and
arguments you intended, with the expected C<PATH> and Perl installation.
lib/AnyEvent/MCP/Intro.pod view on Meta::CPAN
This assumes C</opt> exists. Restart mcp-fs after changing trusted mounts.
If your worker Perl is C</opt/bin/perl>, you can also set
C<executable.perl = /opt/bin/perl> in that program section. Supply the
actual executable path: selecting it does not itself mount the
executable or its dependencies inside the worker.
A trusted mount supplies baseline access to every worker using it; a
writable mount likewise supplies baseline write authority.
An arbitrary command might also need its user's configuration or repository
credentials under HOME, or network access. Request the corresponding grants
when needed; do not assume C<workdir-read> includes them.
For example, CVS over SSH can use C<workdir-read> and C<ssh-agent>.
The latter includes network access, the agent socket and C<~/.ssh/known_hosts>,
without exposing private key files or the rest of HOME. The broker must
inherit C<SSH_AUTH_SOCK> from your environment. User-configured helpers
outside the runtime mounts need an explicit trusted mount.
An explicit exec grant array replaces the default rather than adding to it.
=head2 My configuration change seems to be ignored
lib/AnyEvent/MCP/Server/HTTP.pm view on Meta::CPAN
C<$tools> is a Toolset and C<$secret> is a previously generated private
URL-safe token. Do not use a sample or hardcoded token in a real service.
This object handles sockets and HTTP; L<AnyEvent::MCP::Server> dispatches
requests, while the Toolset runs tool callbacks. For a standalone program
with automatic configuration and startup, prefer
L<AnyEvent::MCP::Server::Simple>.
=head1 AUTHENTICATION
Unlike L<AnyEvent::MCP::Server::Simple/mcp_start>, this low-level object
does not load global configuration or derive credentials. Supply C<tokens>
explicitly, for example:
my $server = AnyEvent::MCP::Server::HTTP->new (
host => "127.0.0.1",
port => 31740,
path => "/mcp/",
tokens => ["a-long-random-secret", "another-long-random-secret"],
);
$server->listen;
Each token authorizes the same requests. For either token, a client may
POST to C</TOKEN/mcp/> without an Authorization header, or POST to
C</mcp/> with C<Authorization: Bearer TOKEN>. URL credentials are path
components, B<not> HTTP cookies. C<url> advertises the first token;
C<url (0)> returns the clean endpoint. A trailing slash may be omitted
from either request path when the configured endpoint path ends in one;
this accommodates clients that strip it from a configured URL.
The accepted tokens are static, URL-safe strings. HTTP Basic and OAuth
access-token validation are not implemented here. There is no automatic
fallback to the master-secret-derived token: that belongs to C<mcp_start>
or L<AnyEvent::MCP::Config/http_settings>.
Authentication defaults to C<tokens>, and construction B<fails> unless a
nonempty array of valid tokens is provided. To deliberately delegate
authentication to a trusted embedding layer (for example, a reverse proxy
that rejects unauthorized requests before they reach MCP), specify
C<auth =E<gt> "none"> instead, B<without> C<tokens>. This is an explicit
opt-out, not a permissive default. The embedding layer then owns the
entire authentication boundary.
The C<origins> setting filters browser origins; it is not authentication.
URLs can leak through logs and histories, and HTTP alone does not encrypt
tokens. Use TLS when credentials cross an untrusted network. A local address
is a sensible default, but not a credential.
=head1 METHODS
=over
=item $server = AnyEvent::MCP::Server::HTTP->new (%config)
Construction does not start a listener. In addition to Server options,
the following configuration options are supported:
lib/AnyEvent/MCP/Server/HTTP.pm view on Meta::CPAN
Identifies the service. Use L<AnyEvent::MCP::Config/http_settings>
to resolve named listener configuration before constructing this transport.
=item url_host => $hostname
Overrides the advertised hostname, for example when listening on a wildcard
address. Otherwise a wildcard uses the nodename from C<uname>.
=item url_scheme => $scheme
Overrides the advertised scheme; otherwise TLS credentials or handle options
select C<https>, and plain handles select C<http>.
=back
Every configured token authenticates in either the URL or the Authorization
header. Without tokens, the constructor requires C<auth =E<gt> "none">.
=item $url = $server->url ($with_token)
Returns the advertised URL with the actual listening port. C<$with_token>
lib/AnyEvent/MCP/Server/Simple.pm view on Meta::CPAN
my $client = AnyEvent::MCP::Client::HTTP->new (
url => $ARGV[0],
);
Pass the printed URL as its first command-line argument.
The rest of the call and result handling stays the same.
C<mcp_start> supplies named HTTP settings and a token derived from the
private global secret. Stdio needs no HTTP configuration. C<name> selects
local settings and credentials; C<server_name> identifies the advertised
MCP implementation. The two need not be the same.
See L<AnyEvent::MCP::Intro/CONFIGURING YOUR SETUP>.
=head2 Add an application option
Suppose you want the server user to choose the maximum text length.
Replace the original C<mcp_getoptions> call with:
my $max_length = 4096;
lib/AnyEvent/MCP/Server/Simple.pm view on Meta::CPAN
Adds server configuration. Repeated calls extend or replace previously declared
values. C<instructions> is treated as human-readable text and normalized.
Server and transport options are passed to the underlying objects.
Common options include:
=over
=item name => $instance
Local instance name used to select configuration and credentials. Independent
of the name reported to MCP clients.
=item server_name => $name
Advertised MCP server implementation name. Defaults to C<AnyEvent::MCP>.
=item server_version => $version
Advertised application version, not the MCP protocol revision. Accepts
an ordinary Perl version number; Simple stringifies it for MCP.
lib/AnyEvent/MCP/Server/Simple.pm view on Meta::CPAN
C<mcp_getoptions> parses these options for executables using
L<AnyEvent::MCP::Server::Simple>. The executable's own OPTIONS section
documents any additional switches or differences in shared-option behavior.
=over
=item --name NAME
Select the instance (default C<default>). NAME starts with a letter or
underscore, followed by letters, digits, underscores or hyphens.
Selects C<[instance NAME]> configuration, derived HTTP port and credentials;
does not change the advertised MCP implementation name.
Example: C<--name project>.
=item --bind ADDRESS[:PORT]
Override HTTP listening address and port. Defaults to C<127.0.0.1> and a
stable instance-derived port in 16384..32767. C<:31740> uses the loopback
address and port 31740; C<127.0.0.1:31740> specifies both.
Port zero requests an ephemeral port and is incompatible with
C<--print-url>. Ignored for stdio-only startup.
lib/AnyEvent/MCP/Server/Simple.pm view on Meta::CPAN
levels and destinations.
=item --print-url[=USER]
Print credential-bearing URLs and exit without starting a listener.
With managed users, prints each effective enabled user's URL, or only
the exact username USER. Comments identify scope-qualified principals
and the selected auxiliary connection user. With configured TLS, HTTPS
is printed before HTTP. Explicit tokens retain a single-token output
and cannot be combined with a user selector. Requires a predictable,
nonzero port and a persistent master secret for derived credentials.
Example: C<--name project --print-url=claude>.
=item --print-urls
Alias for C<--print-url>.
=item --tool-help
Print currently registered tool names, descriptions, argument types,
required fields and defaults; exit without starting the MCP listener.
lib/AnyEvent/MCP/Server/Simple.pm view on Meta::CPAN
? ("https", "http") : ($http{url_scheme} || "http");
my $managed = ($http{auth} || "") ne "none"
&& !exists $options{tokens}
&& !exists $global->{section}{"instance $name"}{tokens}
&& !exists $global->{section}{"program " . AnyEvent::MCP::Config::program_name ()}{tokens};
if ($managed) {
my $saved = (ref $global)->new (file => $global->{file});
$saved->{section}{global}{secret} eq $global->{section}{global}{secret}
or die "MCP master secret is not persistently stored in $global->{file}\n";
my $credentials = $global->user_credentials ($name);
if (length $print_url) {
@$credentials = grep $_->[0] eq $print_url, @$credentials;
@$credentials or die "User '$print_url' is not enabled\n";
}
my $connect = $global->connect_user ($name);
for my $entry (@$credentials) {
my ($user, $principal, $token) = @$entry;
for my $scheme (@schemes) {
print $global->http_url ($name, $selected_bind, %options,
tokens => [$token], url_scheme => $scheme),
" # user $principal", ($user eq $connect ? " (connect)" : ""), "\n";
}
}
} else {
length $print_url
and die "Cannot select a managed user with explicit tokens or auth=none\n";
t/02_config.t view on Meta::CPAN
close $fh or die $!;
$config = AnyEvent::MCP::Config->new (file => $file);
my $port = $config->server_port ("demo");
ok $port >= 16384 && $port <= 32767, "default port is in the fixed HKDF range";
ok $port == $config->server_port ("demo"), "same name derives same port";
ok $port == AnyEvent::MCP::Config->new (file => $file)->server_port ("demo"),
"default port persists across processes";
my %http = $config->http_settings ("demo");
ok $http{host} eq "127.0.0.1" && $http{port} == $port, "default listener is IPv4 loopback";
ok @{$http{tokens}} == 1 && $http{tokens}[0] eq $config->user_credentials ("demo")->[0][2],
"default listener has one derived URL/bearer token";
$config->{section}{"instance demo"}{bind} = "127.0.0.2:43001";
%http = $config->http_settings ("demo");
ok $http{host} eq "127.0.0.2" && $http{port} == 43001, "named bind is loaded";
%http = $config->http_settings ("demo", "[::1]");
ok $http{host} eq "::1" && $http{port} == 43001,
"host-only command-line bind inherits configured port";
%http = $config->http_settings ("demo", "127.0.0.1:43002");
ok $http{host} eq "127.0.0.1" && $http{port} == 43002, "full CLI bind overrides named bind";
%http = $config->http_settings ("demo", ":43003");
ok $http{host} eq "127.0.0.1" && $http{port} == 43003, "port-only bind defaults to loopback";
%http = $config->http_settings ("demo", "localhost:43005");
ok $http{url_host} eq "localhost" && $http{port} == 43005
&& ($http{host} eq "127.0.0.1" || $http{host} eq "::1"), "hostname bind resolves to numeric address";
ok $config->http_url ("demo", "127.0.0.1:43004", path => "/") eq
"http://127.0.0.1:43004/" . $config->user_credentials ("demo")->[0][2] . "/",
"printable URL uses effective settings and stable token";
eval { $config->http_url ("demo", ":0", path => "/") };
ok $@ =~ /port is not known/, "port zero cannot yield an offline URL";
eval { AnyEvent::MCP::Config::server_name ("bad.name") };
ok $@ =~ /Invalid MCP server name/, "invalid explicit instance names are rejected";
eval { $config->http_settings ("demo", ":65536") };
ok $@ =~ /Invalid MCP bind/, "invalid explicit bind ports are rejected";
my ($n, $b, $u, @tokens);
my %getopt = AnyEvent::MCP::Config::http_getopt (\$n, \$b, \$u, \@tokens);
ok $getopt{"name=s"} == \$n && $getopt{"bind=s"} == \$b && $getopt{"print-url|print-urls:s"} == \$u
t/02_config.t view on Meta::CPAN
)) & 16383), "port context contains namespace, purpose, program and instance";
ok AnyEvent::MCP::Config::server_name (undef) eq "default", "omitted instance name is default";
ok AnyEvent::MCP::Config::server_name ("default") eq AnyEvent::MCP::Config::server_name (undef),
"explicit and omitted default names agree";
{
local $0 = "/some/path/mcp-fs.pl";
ok AnyEvent::MCP::Config::program_name () eq "mcp-fs", "program identity uses invoked basename without Perl suffix";
my $fs_token = $config->credential ("token", "default");
local $0 = "/some/path/mcp-vim";
ok $fs_token ne $config->credential ("token", "default"), "default credentials differ across programs";
}
{
local $0 = "/some/path/program-one";
my $one = $config->server_port ("default");
local $0 = "/some/path/program-two";
my $two = $config->server_port ("default");
ok $one == 16384 + ((unpack "n", Digest::SHA::hmac_sha512 (
(pack "(N/a*)*", "anyevent-mcp", "port", "program-one", "default") . "\1", $master
)) & 16383)
&& $two == 16384 + ((unpack "n", Digest::SHA::hmac_sha512 (
t/03_simple.t view on Meta::CPAN
open STDOUT, ">", \$printed or die $!;
my $timer;
mcp_start name => "example", server_version => 0.61, on_start => sub {
$server = $_[0];
$timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
};
}
ok $server->isa ("AnyEvent::MCP::Server::HTTP"), "normal startup uses HTTP";
ok $server->{port} && $printed eq "", "HTTP startup leaves stdout empty";
my $config = AnyEvent::MCP::Config->global;
ok $server->{tokens}[0] eq $config->user_credentials ("example")->[0][2],
"startup token matches derived utility";
ok $server->{server_info}{name} eq "AnyEvent::MCP", "instance name is not the server implementation name";
ok JSON::XS::encode_json ($server->{server_info}) =~ /"version":"0\.61"/,
"Simple stringifies numeric module versions";
$server->close;
my $err = Symbol::gensym;
my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib", "-e", '
use AnyEvent::MCP::Server::Simple;
$ENV{XDG_CONFIG_HOME} = $ARGV[0];
t/04_client.t view on Meta::CPAN
$server->close;
$server = AnyEvent::MCP::Server::HTTP->new (toolsets => [$toolset], tokens => ["alpha", "beta"]);
$server->listen;
$client = AnyEvent::MCP::Client::HTTP->new (url => $server->url (0), bearer => "beta");
($success, $result) = wait_for (sub { $client->call (echo => {}, $_[0]) });
ok $success, "second token accepted in Authorization header";
$client->close;
$server->close;
eval { AnyEvent::MCP::Client::HTTP->new (url => "http://localhost/", bearer => "token", username => "alice", password => "pw") };
ok $@ =~ /Basic authentication is unsupported/, "Basic credentials rejected even with bearer";
eval { AnyEvent::MCP::Client::HTTP->new (url => "http://localhost/", username => "alice") };
ok $@ =~ /Basic authentication is unsupported/, "Basic client credentials rejected";
$server = AnyEvent::MCP::Server::HTTP->new (auth => "none", toolsets => [$toolset], versions => ["2025-06-18"]);
$server->listen;
$client = AnyEvent::MCP::Client::HTTP->new (url => $server->url);
($success, $result) = wait_for (sub { $client->call (echo => { value => 42 }, $_[0]) });
ok $success && $result->{structuredContent}{value} == 42, "legacy fallback tool call";
ok $client->{protocol_version} eq "2025-06-18", "negotiated legacy version";
ok $client->{server_info}{name} eq "AnyEvent::MCP", "legacy wire identity preserved";
$client->close;
$server->close;
t/04_client.t view on Meta::CPAN
$_[0]->push_write (
"HTTP/1.1 302 Found\r\nContent-Length: 0\r\nConnection: close\r\n"
. "Location: http://127.0.0.1:$redirect_port/\r\n\r\n"
);
});
}, sub { $port = $_[2]; () };
$client = AnyEvent::MCP::Client::HTTP->new (url => "http://127.0.0.1:$port/", bearer => "secret");
$client->{connected} = 1;
$client->{protocol_version} = "2026-07-28";
($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
ok !$success && !$redirected && $result->{message} =~ /302/, "redirect does not forward credentials";
$client->close;
$_->destroy for @http_handles;
undef $listener;
undef $redirect_target;
t/12_vim_startup.t view on Meta::CPAN
"print-url emits a cookie-bearing URL prefix";
is $err, "", "print-url has no diagnostics on success";
my $file = "$dir/config/anyevent-mcp/global.ini";
ok -f $file && !((stat $file)[2] & 077), "global master secret saved privately";
my $config = AnyEvent::MCP::Config->new (file => $file);
my ($default_port, $cookie);
{
local $0 = "mcp-vim";
$default_port = $config->server_port ("default");
$cookie = $config->user_credentials ("default")->[0][2];
}
like $url, qr{\Ahttp://127\.0\.0\.1:$default_port/},
"default port is derived from the service name";
# token is derived in the broker program domain above.
like $url, qr{\Q/$cookie/\E \# user global:default \(connect\)\n\z}, "cookie derives from global master secret";
my ($again_status, $again_url) = run_vim ("--print-url");
is $again_status, 0, "second invocation exits successfully";
is $again_url, $url, "cookie and URL are stable across processes";
($status, $url) = run_vim ("--print-url", "--token", "cli-first", "--token", "cli-second");
t/15_getoptions.t view on Meta::CPAN
is $open->url, "http://127.0.0.1:31740/mcp/", "unauthenticated URL has no secret segment";
my $secured = AnyEvent::MCP::Server::HTTP->new (port => 31740, tokens => ["first", "second"]);
is $secured->url, "http://127.0.0.1:31740/first/mcp/", "first token advertised";
is $secured->url (0), "http://127.0.0.1:31740/mcp/", "clean URL available";
is $config->http_url ("demo", ":31740", path => "/", auth => "none"),
"http://127.0.0.1:31740/", "explicit unauthenticated URL contains no token";
my %http = $config->http_settings ("demo");
is $config->user_credentials ("demo")->[0][2], $http{tokens}[0],
"derived default uses token HKDF purpose";
my $unstable = AnyEvent::MCP::Config->new (file => "$dir/anyevent-mcp/global.ini");
my $first = substr $unstable->{section}{global}{secret}, 0, 1;
substr ($unstable->{section}{global}{secret}, 0, 1) = $first eq "0" ? "1" : "0";
eval { $unstable->http_url ("demo", ":31740", path => "/") };
like $@, qr/not persistently stored/, "derived URL refuses an unstable master secret";
is $unstable->http_url ("demo", ":31740", path => "/", tokens => ["fixed"]),
"http://127.0.0.1:31740/fixed/", "static token URL does not need persistent HKDF secret";
t/16_vim_auth.t view on Meta::CPAN
"editor disconnect is logged without debug mode";
like $log, qr/ex target=secondary-vim command="echo 'hello' \| echo 'world'\\nlet g:example = 1"/,
"Ex call logs its command preview without requiring debug verbosity";
SKIP: {
skip "WebSocket modules not installed", 4 unless $have_websocket;
like $log, qr/Vim WebSocket rejected: invalid token \(127\.0\.0\.1:\d+\)/,
"wrong Vim WebSocket token produces one meaningful warning with peer";
like $log, qr/Vim WebSocket rejected: invalid path \(127\.0\.0\.1:\d+\)/,
"malformed Vim WebSocket path produces one meaningful warning with peer";
unlike $log, qr/secret-do-not-log-XYZ|\/secondary\//,
"rejected URL never exposes supplied credentials in the log";
unlike $log, qr/handshake response was undef/,
"rejected Vim WebSocket upgrade never reports an undef handshake response";
}
close $err;
done_testing;
t/22_webbrowser.t view on Meta::CPAN
is unpack ("n", $rejected->[1]), 1002, "incompatible protocol uses status 1002";
like substr ($rejected->[1], 2), qr/Unsupported browser protocol 2\.0 \(server 1\.1\)/,
"close frame explains the version mismatch";
close $wrong_version->{socket};
my ($old_client) = upgrade "primary", "old-client";
send_ws $old_client, ["mcp-webbrowser", 1, 0, "old-document",
{ url => 'https://user:pass@page.example/path?query', title => "Old client" }];
is_deeply read_ws ($old_client)->[1], ["welcome", "old-client", "old-document", 1],
"compatible old browser protocol 1.0 connects";
is +(tabs ())->[0]{origin}, "https://page.example", "broker derives origin from URL without credentials";
my $old_id = ++$serial;
send_rpc $old_id, "tools/call", { name => "execute", arguments => { target => "old-client", code => "1+1" } };
my $old_call = read_ws ($old_client)->[1];
is $old_call->[3]{code}, "1+1", "old browser client retains JavaScript execution";
send_ws $old_client, ["reply", $old_call->[1], JSON::XS::true, 2];
is read_rpc ($old_id)->{result}{structuredContent}{value}, 2, "old client execution completes";
is_deeply tool ("list-tab-operations", { target => "old-client" })->{result}{structuredContent}{operations},
[], "unsupported tab operations discover as empty for old client";
like tool ("call-tab-operation", { target => "old-client", operation => "test" })
->{result}{content}[0]{text}, qr/require protocol 1\.1/,
t/27_users.t view on Meta::CPAN
"help includes scoped user creation example";
($help_status, $help_text, $help_errors) = run "bin/mcp-multibroker", "--help";
is $help_status, 0, "multibroker help succeeds: $help_errors";
like $help_text, qr/--print-urls.*list URLs for all enabled users/,
"multibroker help advertises plural URL printing";
like $help_text, qr/--print-url=chatgpt/, "multibroker help shows exact user selection";
my $users = $config->users ("test", "mcp-test");
is_deeply $users, { default => { scope => "global", generation => 1 } },
"implicit default is an ordinary global generation-1 user";
my $original = $config->user_credentials ("test", "mcp-test")->[0][2];
like $original, qr/\A[A-Za-z0-9_-]{43}\z/, "default has a URL-safe token";
is $config->connect_user ("test", "mcp-test"), "default", "default connects";
my ($exit, $out, $err) = cli "create-user", "default";
isnt $exit, 0, "implicit default counts as an existing enabled user";
($exit, $out, $err) = cli "create-user", "alice";
is $exit, 0, "create global alice succeeds: $err";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{alice}{scope}, "global", "global user inherited";
my $alice = $config->user_credentials ("test", "mcp-test")->[0][2];
my @creds = @{$config->user_credentials ("test", "mcp-test")};
is scalar @creds, 2, "both default and alice accepted";
is $creds[0][0], "default", "default comes first";
isnt $creds[1][2], $original, "user tokens differ";
isnt $creds[1][2], $config->user_credentials ("test", "other-program")->[1][2],
"programs derive distinct credentials";
isnt $creds[1][2], $config->user_credentials ("other", "mcp-test")->[1][2],
"instances derive distinct credentials";
($exit, $out, $err) = cli "create-user", "alice";
isnt $exit, 0, "duplicate create rejected";
($exit, $out, $err) = cli "revoke-user", "alice";
is $exit, 0, "revoke succeeds: $err";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{alice}{generation}, -1, "revoke preserves generation";
is scalar @{$config->user_credentials ("test", "mcp-test")}, 1, "revoked token no longer accepted";
($exit, $out, $err) = cli "create-user", "alice";
is $exit, 0, "recreate revoked user succeeds: $err";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{alice}{generation}, 2, "counter increased";
isnt $config->user_credentials ("test", "mcp-test")->[1][2], $creds[1][2],
"revoked credential does not reappear";
($exit, $out, $err) = cli "--program", "mcp-test", "create-user", "alice";
is $exit, 0, "program override created: $err";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{alice}{scope}, "program", "program takes precedence";
is $config->users ("test", "other-program")->{alice}{scope}, "global",
"other program still inherits global";
($exit, $out, $err) = cli "--instance", "test", "revoke-user", "alice";
is $exit, 0, "local disable shadows inherited alice: $err";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{alice}{generation}, -1, "instance deny shadows program";
is scalar @{$config->user_credentials ("test", "mcp-test")}, 1, "shadowed user not accepted";
($exit, $out, $err) = cli "--instance", "test", "create-user", "alice";
is $exit, 0, "instance re-enable works";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{alice}{scope}, "instance", "instance takes precedence";
is $config->users ("test", "mcp-test")->{alice}{generation}, 2, "instance restarts at generation 2";
($exit, $out, $err) = cli "revoke-user", "default";
is $exit, 0, "implicit default can be revoked";
$config = AnyEvent::MCP::Config->new (file => $file);
is_deeply $config->user_credentials ("another", "mcp-test")->[0][0], "alice",
"other users work without default";
my %test_http = $config->http_settings ("test");
is scalar @{$test_http{connect_tokens}}, 0,
"revoked default cannot register auxiliary connections";
($exit, $out, $err) = cli "create-user", "default";
is $exit, 0, "default can be rotated back into existence";
$config = AnyEvent::MCP::Config->new (file => $file);
is $config->users ("test", "mcp-test")->{default}{generation}, 2, "default rotated";
my $original_request = { action => "read", dimensions => {
t/29_imap_oauth.t view on Meta::CPAN
my $cb = pop @args;
my %args = @args;
push @requests, [$method, $endpoint, \%args];
$args{on_body}->('{"access_token":"basic-access","token_type":"Bearer"}', { Status => 200 });
$cb->(undef, { Status => 200 });
undef
};
my %basic = (%kv, "oauth-client-auth" => "client_secret_basic");
my $result = $oauth->can ("token")->(\%basic, grant_type => "refresh_token", refresh_token => "old");
is $result->{access_token}, "basic-access", "client_secret_basic token response accepted";
like $requests[-1][2]{headers}{Authorization}, qr/^Basic /, "basic client credentials sent in header";
unlike $requests[-1][2]{body}, qr/client_secret/, "basic mode omits client secret from body";
}
{
local *AnyEvent::HTTP::http_request = sub {
my ($method, $endpoint, @args) = @_;
my $cb = pop @args;
my %args = @args;
$args{on_body}->('{"error":"invalid_grant","error_description":"do not print secrets"}',
{ Status => 400 });