supported_version and minimum_version sat in 24 roles and were read by no template, no task and no lookup. Their only consumer was the audit report version column, which turned false the moment an automated image bump ran: qdrant reported a verified version nobody had verified, two releases long. Both leave MCP_KEYS, so a role that declares them again fails the schema lint instead of carrying a field with no effect.
Requirement 025 mandated minimum_version as a MUST and 035 carried it in an example; both are corrected here, otherwise the repository would contradict its own code.
The gate then surfaced five dead docs.mattermost.com links. Mattermost moved to /administration-guide/ and /deployment-guide/, and every replacement was probed for 200 rather than guessed. The MCP page is gone with no successor, so mattermost now points at mcpserver/README.md in the Agents repository, the source rather than a rendered mirror.
Both concerns share three files - mattermost's meta/mcp.yml, requirement 025, and the generated audit.md, which derives from a source of each - so splitting them would commit an artifact that does not match its sources.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.
It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.
The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.
Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.
Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.
mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.
Hermes and OpenClaw keep their client-side proof as CLI tests of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enabled and shared were hand-maintained in 24 roles and wrong in both
directions. Both now resolve from the mcp_consumer edges already declared
in meta/services.yml: a server takes the consumers it admits, a client
takes the servers that admit it, and the pair renders as "'peer' in
group_names or ...", or False when no peer is admitted at all. setdefault
keeps meta/mcp.yml and meta/variants.yml as the override channel, so
pinning either key still wins.
The resolved block replaces the mapping instead of being written into it.
_build_role_base_config parks the YAML cache's own object under mcp, so an
in-place setdefault injected the derived keys into every later raw read and
made test_mcp_schema pass alone but fail in a directory run.
The derivation keys on peer presence, never on a peer's own value: a
re-entrant read under _RENDER_GUARD returns the unrendered dict.
Four lints now ask the resolver instead of reading meta/mcp.yml, which they
could not do while the key was hand-written. That is what exposed the eleven
missing hostile-path specs the previous commit closed.
Carries one change that belongs to the WordPress probe but shares a file
with this one: the tool allowlist moves to the hyphenated names the adapter
actually serves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
allowed_consumers was twenty hand-kept lists of which fourteen were identical, so every new client meant an edit in every provider. A client now declares services.<own-entity>.mcp_consumer: true once and a provider refuses one with mcp_consumer: false, which is also what tells a deliberate exclusion apart from a forgotten entry.
The group_names-gated consumer entries give the inventory resolver the co-deploy edge meta/mcp.yml never had: one of sixty-nine matrix rounds carried an MCP client before, twenty-three role/round pairs do now. bond 0.5 keeps the clients out of round 0 so the host memory budget still holds.
BREAKING CHANGE: meta/mcp.yml no longer accepts allowed_consumers; declare services.<entity>.mcp_consumer on the client instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The least-privilege half of the criterion could not hold as built.
provision_mcp.py creates the endpoint owner's workspace through
CoreHandler().create_workspace, which makes that account its ADMIN, and
2.3.3 knows only ADMIN and MEMBER - both write table data. There is no
read-only workspace role to demote to, so mutating_tools_enabled: false
bounded nothing while create_rows, update_rows and delete_rows sat in the
served allowlist.
The adapter's contract simply omits them: a client calling one is refused
with DENY_UNKNOWN_TOOL. Omission is the mechanism, since passthrough.py
refuses to load a contract that lists a mutating tool while mutations are
off. The other ten stay recorded under upstream_serves so a version bump
enabling one shows up as drift rather than as a silently wider surface.
The transport was what reverted this twice. Baserow speaks classic
HTTP+SSE, where a GET opens the response stream and the stream names a
second URL for requests, while the adapter only ever spoke Streamable
HTTP. adapter.upstream_transport selects sse.py and defaults to
streamable_http, so no existing provider changes.
The endpoint key stops being a client credential. Clients present
credentials.mcp_bearer to the sidecar; the key reaches the sidecar as
ADAPTER_UPSTREAM_PATH_KEY and is spliced in percent-encoded, so it never
enters the rendered contract and a key carrying a separator cannot open a
path of its own.
Deploy is green: the probe reached list_databases over SSE, the served
surface matched the contract, and the sidecar proved it reaches only its
provider.
The adapter was the only image in the tree pinned by a hand-maintained digest,
and the pin was never recomputed by anything. It drifted twice: run 31374959721
lost seven of eight jobs to mirror/docker.io/python@sha256:6771159c... not
found, and the renewal to the newest upstream digest earlier today would have
reproduced it, because builds resolve FROM against the GHCR mirror while the
mirror is copied by tag and lags upstream by design.
The base is now FROM <image>:<version>, taken from the sidecar service in
meta/services.yml - the only file iter_role_images scans, so the tag the build
asks for is by construction the tag the mirror was told to copy. The version
moves from the moving 3.13-slim to 3.13.15-slim, which upstream stops rewriting
once published, and which the existing version drift job renews like every other
image.
With the build reading services.yml, the adapter block's digest, image and
version had no consumer left. They are removed together with the schema check
for the digest form, the warn-only drift test, and the manifest_digest helper
that existed only for it. What remains in the block describes the adapter rather
than the image it is built from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools.allowlist meant two things at once: the surface a consumer may reach,
which is a decision of ours, and the surface the upstream offers, which the
vendor changes without asking. Conflating them made a version bump read as a
contract violation, and left no place to record what was actually measured.
Split them. allowlist stays policy; upstream_serves records the observation at
supported_version. State the second only where it DIFFERS, because a provider
that serves exactly what is allowed would otherwise carry the same list twice
and the copies would drift apart. Absence means identical.
The difference between the two measures unenforced exposure: mattermost 103,
gitlab 2, and zero wherever the fields collapse. A non-empty difference is what
tells us a provider needs a gateway in front of it.
Require a non-empty allowlist for an enabled, non-adapter, server-capable
provider. An empty one used to make the probe's comparison vacuous, which read
as a pass while every tool the upstream serves stayed reachable. Nextcloud was
in exactly that state.
Three exemptions, all semantic: an adapter authors its tools here against a
REST upstream, so there is nothing to observe; a provider declared
enabled: false is never admitted; and upstream_serves: dynamic covers an
upstream composing its surface at runtime, which nextcloud does by proxying
MCP servers an administrator configures.
Reject an allowlist naming a tool the upstream does not serve. That found
mattermost's add_user_to_channel, gone since 11.10.0 renamed it
add_channel_member, which would have been advertised and never callable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these guards a failure the work already hit. An adapter path
declared in two files drifts silently and surfaces as a 404 on the first
tool call. A provider borrowing the administrator token hands every
client that account's rights, and the read-back never matched the store,
so every deploy minted a fresh secret. A task that both carries a secret
and produces printed output puts it in the deploy log verbatim.
Provider probes, consumer transports, the schema and variant coverage
are checked the same way.
The block sat under services but never behaved like a service. A service has
exactly one providing role, declared with provides, so the registry can map the
key to that role and consumers bind their flag to its presence. No role declares
provides: mcp. There are seventeen providers and the pairing is done by
allowed_consumers inside the block itself.
Every rule that enforces the service model therefore needed an exemption for it:
a shared-provider suppression in fourteen roles, a playwright-service-flag
suppression, and two freshly added exemptions in the dynamic-flag and
variants-static-override checks. Four independent rules, four escape hatches,
one cause. They are all gone now, together with a bond key that only the
resource budgeter reads and only for entries under services, and a wordpress
addon bridge that pointed at a service block that no longer exists.
The block moves to meta/mcp.yml. The applications loader maps meta/<name>.yml to
applications.<app>.<name> generically, so the move needs no wiring; the file
takes the same shape as meta/rbac.yml. Every config path follows, the variant
overrides are lifted out of their services map, and the vocabulary module leaves
the services package because it no longer describes one. roles_with_service
gains an explicit topic parameter rather than an implicit fallback, so nothing
silently resolves against the wrong container.
Files that are otherwise unrelated to this move are included where the committed
tree would otherwise keep pointing at a path that no longer resolves: thirteen
vars files, eleven playwright env templates and one task file.
Defines what a services.mcp block may declare and enforces it: classification,
transport, auth, implementation and the provenance fields that make an upstream
claim checkable. The lint refuses an unknown key anywhere in the block and
refuses a provider whose allowed consumers cannot speak its transport or its
auth scheme.
Discovery resolves each provider's credential and endpoint per consumer and
per deployment, rejecting a server with a named reason. Transport, auth and
endpoint faults are fatal; a missing credential is not, because it also occurs
when the provider is simply not provisioned yet in the same play.
The filters render the Authorization header from the declared scheme, build
Open WebUI's tool-server connections with their group grants, and derive the
members of those groups from the RBAC assignments. Role scoping gains
application-scoped roles, so an mcp role only counts when it is granted on a
specific application.
roles_with_service gains a scope and a direction filter, and drops the canonical
domain requirement for direction-filtered entries: an MCP URL is built from the
service key and the internal port, so a provider without a public vhost stays
valid.
Add a blocker vocabulary so a role whose upstream MCP path this
deployment cannot use says why instead of reporting an absence. Without
it OpenProject reads as "upstream has no MCP", which is false: the server
ships, it is Enterprise-only. A blocker and a served surface are mutually
exclusive, so a role cannot claim both.
Add endpoint.key_credential and endpoint.suffix for servers that address
a session through the URL path. Baserow streams from
{base}/mcp/{key}/sse, and endpoint fields must stay literal, so the
composition belongs in the discovery layer. A server that declares a path
key without a resolvable value is dropped rather than handed to clients
half-built.
Carry the upstream provenance in the role block, so the audit derives it
instead of keeping a second copy.
Require an explicit mutating_tools_enabled: false for a privileged
auth_subject; the rule previously only fired when the key was present and
true, so omitting it slipped an administrator-scoped surface through.
Move the MCP filters into plugins/filter/mcp/ so they stop sharing a name
prefix with their siblings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A role with a Model Context Protocol surface declares one mcp: entry in
meta/services.yml. The vocabulary module owns every allowed value, a hard
lint validates each declaration, and roles_with_service gains an opt-in
direction= kwarg so a client role resolves its servers at deploy time.
The lookup stays backwards compatible: without direction= the entries
keep their original four keys, so the existing dashboard, prometheus and
matomo callers are untouched. With it, each entry additionally carries
transport, auth, auth_subject and an endpoint dict whose port is
resolved from the referenced service's ports.local or ports.internal.
The lint rejects Jinja on MCP-specific fields (only enabled/shared may
carry it) because the static resolver cannot evaluate a templated block,
and it verifies that endpoint.service_key names a service in the same
file whose ports.* actually contain endpoint.port_key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>