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