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ยถ