A client reaches the MCP endpoint by the provider's service name on the
container network, so the hop is plain HTTP. WordPress hands out an application
password only over SSL and, on an onion deployment, home_url() is
http://<onion>, so the gate stays shut: core never even attempts to validate the
credential and answers rest_not_logged_in. The client learns nothing about why.
Measured on a live deployment, same credential, same endpoint, the only
difference being the declaration of the scheme: 401 without it, 200 with it.
Deliberately wrong passwords produced the same rest_not_logged_in as correct
ones, which is what "never validated" looks like from outside.
The accommodation belongs on the provider's side, as 5e810c4a5a already settled
for Moodle: a probe that spoofs Host and X-Forwarded-Proto asserts the endpoint
works when spoofed and says nothing about how a real client reaches it. That is
exactly what happened here - the contract test sent the header and passed while
hermes and openclaw, which send only their Authorization, were refused in run
34699757369 on both compose and swarm.
WORDPRESS_INTERNAL_HOST is rendered from the same mcp.endpoint.service_key the
clients build their URL from, so both sides name one thing once. The filter
opens the gate for a request whose HTTP_HOST matches it, splits on ':' so a port
cannot defeat the match, and changes nothing when the variable is unset. Its
existing branches are untouched.
contract.py loses the X-Forwarded-Proto it used to send. The test now takes the
route a client takes, and can no longer be green while every client fails.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mu-plugin header describes the split as 'the adapter authorises the transport, the callback authorises the operation'. The second half held, every ability carries infinito_mcp_may_read. The first did not: the adapter answered the initialize handshake to anyone, so the server and its protocol version were readable without a credential.
Measured in run 34653754175, the swarm WordPress#0: test-mcp-guest.js posts an anonymous initialize and expects at least 400, and the error context reads 'Expected: >= 400, Received: 200'. Its neighbour addons/mcp-adapter.spec.js posts an anonymous tools/list and passed, so the tool call was already refused and only the handshake was not.
The filter hangs on rest_pre_dispatch and refuses every route under the mcp namespace while the site cannot attribute the request. It leaves an already short-circuited response alone rather than overwriting it, scopes itself by route prefix so the rest of the REST API stays as open as it was, and reaches is_user_logged_in, which triggers determine_current_user and with it wp_validate_application_password, so a client presenting its application password passes. A WP_Error renders no jsonrpc key, which is the second thing the spec asserts.
The new unit test reads the guarded prefix out of the PHP and the endpoint out of the spec and requires the endpoint to sit under the prefix. Without it an adapter that moves namespace would leave the guard protecting nothing while both negative specs keep passing.
A positive spec was tried and reverted: handing the MCP credential to the Playwright env is forbidden by tests/lint/ansible/services/test_mcp_secret_not_in_playwright.py, because the trace travels in the CI artifact. No role probes its MCP endpoint with the credential at deploy time either, so the authenticated direction stays unproven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI contract test rejected WordPress in job 102705652296 of run
34421944328:
REJECTED server serves ['mcp-adapter-discover-abilities', 'mcp-adapter-execute-ability', 'mcp-adapter-get-ability-info'] but the contract declares ['infinito-get-post', 'infinito-list-categories', 'infinito-search-posts']
There are two layers. The mcp-adapter v0.5.0 default server lists only its
three meta-tools and reaches public abilities through discover and execute; it
takes its tool list from the mcp_adapter_default_server_config filter
(includes/Servers/DefaultServerFactory.php). And the three infinito abilities
were never registered at all: the mu-plugin hooked abilities_api_init and
passed no category, while WordPress 7.1.0 core fires only
wp_abilities_api_init, refuses wp_register_ability() outside it and requires a
category (src/wp-includes/abilities-api.php:290-297,
abilities-api/class-wp-ability.php:295-297).
The mu-plugin now registers an `infinito` category on
wp_abilities_api_categories_init, registers the abilities on
wp_abilities_api_init with that category, and sets the default server's tools
to exactly the three abilities, with empty resources and prompts, at
PHP_INT_MAX. With the meta-tools gone, execute-ability no longer reaches every
public ability any other plugin registers.
The abilities return objects ({posts}, {categories}, {post}), since MCP defines
structuredContent as a JSON object. get-post now serves published posts only,
as search-posts already did; before, it returned any published type, including
customize_changeset and wp_block entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same shape as 31fa1e5540 for the Zammad apply scripts.
A mu-plugin is included by the WordPress loader, not imported: it exports
nothing, and defined( 'ABSPATH' ) || exit ends it outside a booted site. Its
content is three wp_register_ability calls behind the abilities_api_init hook,
whose callbacks stand on get_posts and current_user_can. A unit test would
mock all of it and prove nothing.
The suppression is structurally honest but leaves the three abilities, the
permission check among them, without automated coverage. A probe against the
running instance would be the real substitute and does not exist yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mcp_servers hands every client the bare URL http://<service_key>:<port><path>,
which resolves only when the client shares a network with the provider. The
overlay declared modes: [swarm] with neither a topology nor a consumer kind, so
under compose _compute_attachments produced no attachment at all.
Nothing failed, which is why this survived deploys. The docker daemon ships
dns-search: [DOMAIN_PRIMARY], so the unresolvable service name was completed
into a public FQDN, the wildcard record answered with the ingress address, and
every MCP tool call for the Apache container went out over the internet and back
in through the reverse proxy. The calls succeeded; only the route was wrong.
The overlay now runs in both modes, creates shared_net, and admits mcp_client so
each role named in mcp.allowed_consumers is attached. aliases becomes
proxy_aliases, because the public-domain alias belongs on the proxy leg rather
than on the shared network.
auth changes from app_password to basic_auth: WordPress application passwords are
presented over HTTP Basic, and the adapter needs the transport, not the
credential's product name. web-app-openwebui leaves allowed_consumers.
The mu-plugin filters wp_is_application_passwords_available. Core gates them on
is_ssl(), which is false on the internal hop because TLS terminates at the proxy
and no X-Forwarded-Proto reaches wp-config.php, so the service account could not
authenticate. The filter reads the site's own canonical scheme, leaving the gate
closed on a genuinely plain-HTTP site.
The new lint fails any role declaring mcp.endpoint whose overlay has no topology
or does not admit mcp_client, with a nocheck opt-out in the networks file head.
This role is what motivated it.
Measured while writing this: from a throwaway container sharing a provider's
network, `nslookup <service>` returned <service>.infinito.example -> the ingress
address, while the service answered 401 immediately from inside its own
container. That is this failure mode observed live.
The same measurement bounds the claim. In that case the client already shared
the network AND the container alias was registered, and the search suffix still
won, so attachment may be necessary without being sufficient. The WordPress
endpoint has not been re-probed since this change, and the lint encodes
co-residency as the whole condition, which that observation puts in doubt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WordPress serves MCP through two addons rather than a sidecar, because the
application already carries the extension point.
The mu-plugin is the entire tool surface. The MCP Adapter publishes an ability
only when it carries meta.mcp.public, so whatever is not registered in that file
cannot be reached over MCP no matter what else the installation has. Every
ability reads already-published content and carries its own permission
callback, which is not decoration: the adapter authorizes the transport, the
callback authorizes the operation, and dropping it would let any authenticated
caller reach the ability.
The second addon fetches the release asset rather than the tag's source
archive. The source archive unpacks as mcp-adapter-<version>, which no longer
answers to the slug the generic activation step uses, and it ships no vendor
directory.
Three Playwright specs cover what needs no credential: both addons active, the
abilities registered, and a guest served nothing. The mcp role joins the role
catalogue instead of replacing it.