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>
7.8 KiB
MCP Service Block 🔌
A role declares meta/mcp.yml when it actually serves or consumes MCP. The file is a capability descriptor, not a service entry: no single role provides MCP, so the service registry cannot pair provider and consumer, and the mcp_consumer flag in meta/services.yml does it instead. Vocabulary lives in utils/roles/applications/mcp.py; the hard lint is test_mcp_schema.py.
classification is required and is one of native_server, native_client, native_both, plugin_server, sidecar_server, adapter_server. A role that cannot serve declares no mcp block at all.
Server shape
mcp:
bond: 1
classification: native_server
direction: server # server | client | both
transport: sse # streamable_http | sse
exposure: internal # internal | public
auth: app_password # oidc | app_password | basic_auth | bearer_token | upstream_session | none
auth_subject: service_account # user | service_account | administrator | none
implementation: native # native | plugin | sidecar | adapter | external
credential:
owner: mcp-web-app-example # a dedicated non-login identity, never `administrator`
source: token_store # token_store | credentials
key: web-app-example
endpoint:
service_key: example # names a service entry in the same file
path: /mcp
port_key: http # resolves under that service's ports.local or ports.internal
host_header: true # optional; set it when the endpoint sits behind the role's own vhost
tools:
read_only_default: true
mutating_tools_enabled: false
exposure: public additionally requires a complete limits block.
Client shape
A client declares what it can present instead of what it serves:
mcp:
classification: native_client
direction: client
implementation: native
supported_transports: [streamable_http, sse]
supported_auths: [bearer_token, app_password, upstream_session]
A client-only block MUST NOT declare an endpoint.
Adapter shape
implementation: adapter requires classification: adapter_server and a pinned, hash-checked contract:
adapter:
type: openapi_allowlist # see below
image: registry.example.invalid/infinito-mcp-adapter
version: 1.2.3
digest: sha256:0123456789abcdef
upstream_api_version: v1
specification_path: roles/web-app-example/files/mcp/openapi-v1.json
specification_sha256: sha256:fedcba9876543210
limits:
request_bytes: 65536
response_bytes: 1048576
timeout_seconds: 15
concurrent_requests: 4
page_size: 100
result_items: 500
stream_seconds: 300
tools:
allowlist: [example_search, example_get]
schema_sha256: sha256:abcdef0123456789
read_only_default: true
mutating_tools_enabled: false
type is one of openapi_allowlist, graphql_allowlist, named_query, s3_prefix, prometheus_readonly, n8n_workflow, resource_readonly. graphql_allowlist, named_query, s3_prefix and resource_readonly additionally require a non-empty adapter.scope naming the exact buckets, prefixes, collections or queries reachable. openapi_allowlist and graphql_allowlist require a checked-in specification_path under the role's own files/.
Rules
classificationis required and MUST be deployable; all MCP-specific fields MUST be literals (onlyenabled/sharedmay carry Jinja).enabled/sharedare omitted.utils/cache/applications.pyderives both from the admitted clients as"{{ '<client>' in group_names or … }}", andfalsewhen no client is admitted, so a surface is reachable exactly while a peer that uses it is deployed. Write either key to override that: a value inmeta/variants.ymlwins over one inmeta/mcp.yml, and both win over the derivation. Do not hand-write agroup_namesgate: it is a second source for the derived fact and drifts when an edge changes.direction: server | bothrequiresauth,credential, a completeendpoint, and at least one admitted client.- Admission lives in
meta/services.yml, not here. A client setsservices.<own-entity>.mcp_consumer: truein its own role; a provider refuses one by settingmcp_consumer: falseon that client's entry.test_mcp_consumer_services_entry.pyrequires every provider to carry a"{{ '<client>' in group_names }}"-gated entry for every declared client, so a round plans provider and client together. credential.ownerMUST NOT beadministrator. Two providers resolving to the same owner and secret abort discovery.auth: nonerequiresexposure: internal.auth_subject: service_account | administratorrequires an explicittools.mutating_tools_enabled: false.auth_subject: userorauth: oidcrequires adelegationblock; see delegation.md.endpoint.service_keyMUST name a service in the same file;endpoint.port_keyMUST resolve under itsports.localorports.internal.- Suppress a finding with
# nocheck: mcp-schema(file head = whole file, on/above a line = that finding).
Public exposure
exposure: public puts the endpoint on the role's own vhost, so it inherits what that vhost already provides: routing through the shared proxy, TLS from the platform certificate, client_max_body_size from server.client_max_body_size, and the proxy read timeout of the location it is mounted in.
Rate limiting is not among them. The proxy ships no limit_req or limit_conn anywhere, so a public MCP endpoint is only as protected as its own authentication and its declared limits. An operator exposing one to the open internet has to put a rate limiter in front of the vhost; the platform will not supply one.
Prefer exposure: internal. A client inside the container network reaches the endpoint without any of this.
Why stdio is not deployed
transport accepts streamable_http and sse only. A stdio MCP server is a local process the client spawns, so it inherits the client container's filesystem and network with no endpoint to authenticate, no proxy to route through, and nothing for a probe to reach. Every property the other rules rely on (auth, exposure, endpoint) is undefined for it. Clients that support stdio are pinned away from it in their own role: Flowise sets CUSTOM_MCP_PROTOCOL=sse so no flow can spawn a command.
Client discovery
Clients do not read the server list directly. lookup('mcp_servers') computes the intersection of the provider's admitted clients, resolved by derive_allowed_consumers in utils/roles/applications/mcp.py, with the calling client's supported_transports and supported_auths, resolves each provider's declared credential, and returns:
MCP_DISCOVERY: "{{ lookup('mcp_servers') }}"
MCP_DISCOVERED_SERVERS: "{{ MCP_DISCOVERY.selected }}"
MCP_REJECTED_SERVERS: "{{ MCP_DISCOVERY.rejected }}"
Every rejected entry carries a stable reason: consumer_not_allowed, transport_unsupported, auth_unsupported, credential_missing or endpoint_unreachable.
RBAC
Reaching a tool server is a distinct grant from using the application, carried by the provider's mcp RBAC role. mcp is application-scoped: it is granted through application_roles, never through the unscoped roles list, so granting it on one application grants it nowhere else.
users:
alice:
application_roles:
web-app-baserow: [mcp]
See Also
- Service metadata basics: base.md
- User delegation audit: delegation.md