Files
kevinveenbirkenbachandClaude Opus 5 791eee7c9f refactor(mcp)!: prove the endpoint contract with a CLI test, not a deploy probe
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>
2026-09-01 20:46:26 +02:00

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

  • classification is required and MUST be deployable; all MCP-specific fields MUST be literals (only enabled/shared may carry Jinja).
  • enabled/shared are omitted. utils/cache/applications.py derives both from the admitted clients as "{{ '<client>' in group_names or … }}", and false when 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 in meta/variants.yml wins over one in meta/mcp.yml, and both win over the derivation. Do not hand-write a group_names gate: it is a second source for the derived fact and drifts when an edge changes.
  • direction: server | both requires auth, credential, a complete endpoint, and at least one admitted client.
  • Admission lives in meta/services.yml, not here. A client sets services.<own-entity>.mcp_consumer: true in its own role; a provider refuses one by setting mcp_consumer: false on that client's entry. test_mcp_consumer_services_entry.py requires every provider to carry a "{{ '<client>' in group_names }}"-gated entry for every declared client, so a round plans provider and client together.
  • credential.owner MUST NOT be administrator. Two providers resolving to the same owner and secret abort discovery.
  • auth: none requires exposure: internal.
  • auth_subject: service_account | administrator requires an explicit tools.mutating_tools_enabled: false.
  • auth_subject: user or auth: oidc requires a delegation block; see delegation.md.
  • endpoint.service_key MUST name a service in the same file; endpoint.port_key MUST resolve under its ports.local or ports.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