refactor(mcp)!: derive a surface's presence from its peers

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>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-01 16:13:01 +02:00
1 parent c94a0a0b5d
commit ffdef28da3
61 files changed
+292 -322

No files matched your search

@@ -9,8 +9,6 @@ A role declares `meta/mcp.yml` when it actually serves or consumes MCP. The file
```yaml
mcp:
bond: 1
enabled: true
shared: true
classification: native_server
direction: server # server | client | both
transport: sse # streamable_http | sse
@@ -81,7 +79,7 @@ A client-only block MUST NOT declare an `endpoint`.
## Rules
- `classification` is required and MUST be deployable; all MCP-specific fields MUST be literals (only `enabled`/`shared` may carry Jinja).
- `enabled`/`shared` MUST NOT name a peer role. Provider enablement is explicit operator or variant state; the variant axis lives in `meta/variants.yml`.
- `enabled`/`shared` are omitted. [`utils/cache/applications.py`](../../../../../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`](../../../../../tests/lint/ansible/services/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.
+1 -2
View File
@@ -1,6 +1,5 @@
---
enabled: false
shared: false
# nocheck: mcp-playwright-coverage the role ships no server block and no domain, so it renders no vhost and no playwright.env.j2; there is no URL a browser-side spec could address
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
+2 -10
View File
@@ -2,12 +2,7 @@
# Matrix-deploy variant overrides for svc-db-qdrant.
# See docs/contributing/artefact/files/role/variants.md for the file format.
# variant 0: MCP adapter enabled (canonical default).
- mcp:
enabled: true
shared: true
services:
- services:
tor:
enabled: true
openwebui:
@@ -22,10 +17,7 @@
flowise:
enabled: false
shared: false
- mcp:
enabled: false
shared: false
services:
- services:
tor:
enabled: true
openwebui:
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: adapter_server
direction: server
source_url: https://baserow.io/user-docs/mcp-server # nocheck: url — page is live (HEAD+GET 200); baserow.io edge intermittently 404s CI runner IPs
-6
View File
@@ -49,9 +49,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -98,6 +95,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-9
View File
@@ -41,9 +41,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -87,9 +84,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -133,6 +127,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-6
View File
@@ -47,9 +47,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
dashboard:
enabled: false
@@ -93,6 +90,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: native_client
direction: client
source_url: https://github.com/FlowiseAI/Flowise/tree/flowise%403.1.4/packages/server/src/routes/custom-mcp-servers
-6
View File
@@ -50,9 +50,6 @@
shared: true
tor:
enabled: true
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -99,6 +96,3 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: true
mcp:
enabled: false
shared: false
+2 -2
View File
@@ -169,8 +169,8 @@ repository, issue, pull request, search, file, branch, tag, commit and release.
### Default state
Off. `mcp.enabled` is false unless `web-app-hermes` or
`web-app-openclaw` is deployed alongside.
Off. `mcp.enabled` is false unless `web-app-hermes`, `web-app-openclaw` or
`web-app-openwebui` is deployed alongside.
### Public vhost
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: adapter_server
direction: server
source_url: https://pkg.go.dev/gitea.com/gitea/gitea-mcp
-9
View File
@@ -52,9 +52,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -104,9 +101,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -152,6 +146,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: adapter_server
direction: server
source_url: https://docs.gitlab.com/user/gitlab_duo/model_context_protocol/mcp_server/
-6
View File
@@ -49,9 +49,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -98,6 +95,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: native_client
direction: client
source_url: https://hermes-agent.nousresearch.com/
-6
View File
@@ -28,9 +28,6 @@
shared: true
tor:
enabled: true
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -58,6 +55,3 @@
shared: false # nocheck: variants-static-override variant pins a literal off-switch
tor:
enabled: true
mcp:
enabled: false
shared: false
+2 -2
View File
@@ -147,8 +147,8 @@ for the current state.
### Default state
Off. `mcp.enabled` is false unless `web-app-hermes` or
`web-app-openclaw` is deployed alongside.
Off. `mcp.enabled` is false unless `web-app-flowise`, `web-app-hermes`,
`web-app-openclaw` or `web-app-openwebui` is deployed alongside.
### Tool scope
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: native_server
direction: server
source_url: https://www.home-assistant.io/integrations/mcp_server/
@@ -26,9 +26,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -53,6 +50,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-9
View File
@@ -38,9 +38,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -81,9 +78,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -124,6 +118,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: adapter_server
direction: server
source_url: https://plugins.jenkins.io/mcp-server/
-9
View File
@@ -44,9 +44,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -86,9 +83,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -128,6 +122,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-6
View File
@@ -48,9 +48,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
asset:
enabled: false
@@ -95,6 +92,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
+1 -1
View File
@@ -156,7 +156,7 @@ Mattermost exposes a Model Context Protocol server through the prepackaged Agent
### Default state
`mcp.enabled` resolves to `true` only when `web-app-hermes` or `web-app-openclaw` is part of the same deployment, and is `false` otherwise. While it is `true` the deploy:
`mcp.enabled` resolves to `true` only when `web-app-hermes`, `web-app-openclaw` or `web-app-openwebui` is part of the same deployment, and is `false` otherwise. While it is `true` the deploy:
- sets `MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS=true`,
- enables the `mattermost-ai` plugin through `mmctl --local`,
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: adapter_server
direction: server
source_url: https://docs.mattermost.com/agents/mcpserver/README.html
@@ -52,9 +52,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -104,9 +101,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
@@ -157,6 +151,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: plugin_server
direction: server
source_url: https://moodle.org/plugins/webservice_mcp
-9
View File
@@ -54,9 +54,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -105,9 +102,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -155,6 +149,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: native_both
direction: both
source_url: https://github.com/n8n-io/n8n/tree/n8n%401.95.3/packages/%40n8n/nodes-langchain/nodes/mcp
-6
View File
@@ -43,9 +43,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -90,6 +87,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
+2 -2
View File
@@ -208,7 +208,7 @@ Nextcloud serves a Model Context Protocol endpoint through the AppAPI proxy of t
| Auth | Nextcloud app password, sent as `Authorization: Bearer <app-password>` |
| Identity | the Nextcloud user the app password belongs to; every tool call runs with that user's permissions |
| Implementation | plugin (the `context_agent` ExApp container, registered through the `manual_install` deploy daemon) |
| Default state | off; `mcp.enabled` turns on when `web-app-hermes` or `web-app-openclaw` is deployed |
| Default state | off; `mcp.enabled` turns on when `web-app-hermes`, `web-app-openclaw` or `web-app-openwebui` is deployed |
### Deployment
@@ -244,7 +244,7 @@ An unauthenticated request to the same path answers `404`; [`files/playwright/te
### Default state
Off. `mcp.enabled` is true only while `web-app-hermes` or `web-app-openclaw` is part of the deployment.
Off. `mcp.enabled` is true only while `web-app-hermes`, `web-app-openclaw` or `web-app-openwebui` is part of the deployment.
### How to disable
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: plugin_server
direction: server
source_url: https://docs.nextcloud.com/server/latest/admin_manual/ai/app_context_agent.html
-42
View File
@@ -114,9 +114,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
# Variant 1: minimal - SSO off, no partners
- services:
container_backup:
@@ -262,9 +259,6 @@
enabled: false
tasks:
enabled: false
mcp:
enabled: false
shared: false
# Variant 2: openwebui
- services:
container_backup:
@@ -366,9 +360,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 3: openproject
- services:
container_backup:
@@ -470,9 +461,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 4: onlyoffice + mastodon
- services:
container_backup:
@@ -574,9 +562,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 5: zammad
- services:
container_backup:
@@ -678,9 +663,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 6: gitlab
- services:
container_backup:
@@ -782,9 +764,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 7: discourse
- services:
container_backup:
@@ -886,9 +865,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 8: peertube - clearnet, because the OIDC plugin rejects a plaintext onion issuer
- services:
container_backup:
@@ -990,9 +966,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 9: xwiki
- services:
container_backup:
@@ -1094,9 +1067,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 10: mattermost
- services:
container_backup:
@@ -1198,9 +1168,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 11: bigbluebutton
- services:
container_backup:
@@ -1302,9 +1269,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 12: matrix
- services:
container_backup:
@@ -1406,9 +1370,6 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
# Variant 13: litellm
- services:
container_backup:
@@ -1510,6 +1471,3 @@
enabled: false
shared: false
addons: *addons_static_off
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: native_client
direction: client
source_url: https://openclaw.ai/
-6
View File
@@ -31,9 +31,6 @@
shared: true
tor:
enabled: true
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -64,6 +61,3 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: true
shared: true
classification: native_client
direction: client
source_url: https://docs.openwebui.com/features/extensibility/mcp/
@@ -45,9 +45,6 @@
shared: true
tor:
enabled: true
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -90,9 +87,6 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -135,6 +129,3 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-6
View File
@@ -46,9 +46,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -92,6 +89,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
@@ -40,9 +40,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -79,6 +76,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-9
View File
@@ -52,9 +52,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -104,9 +101,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -154,6 +148,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
+7 -5
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: plugin_server
direction: server
source_url: https://github.com/WordPress/mcp-adapter/tree/v0.5.0
@@ -28,9 +26,13 @@ limits:
result_items: 20
stream_seconds: 300
tools:
# Exception: hyphens, not the `infinito/…` ability names
# files/mu-plugins/infinito-mcp-abilities.php registers. The adapter's
# McpNameSanitizer rewrites `/` to `-` before publishing an ability as a tool,
# so the served surface never carries the ability spelling.
allowlist:
- infinito/get-post
- infinito/list-categories
- infinito/search-posts
- infinito-get-post
- infinito-list-categories
- infinito-search-posts
read_only_default: true
mutating_tools_enabled: false
@@ -56,9 +56,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -118,6 +115,3 @@
- blog.{{ DOMAIN_PRIMARY }}
- biberblog.{{ DOMAIN_PRIMARY }}
- biber.blog.{{ DOMAIN_PRIMARY }}
mcp:
enabled: false
shared: false
-2
View File
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
-9
View File
@@ -54,9 +54,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
container_backup:
enabled: false
@@ -109,9 +106,6 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
- services:
container_backup:
enabled: false
@@ -163,6 +157,3 @@
flowise:
enabled: false
shared: false
mcp:
enabled: false
shared: false
@@ -1,6 +1,4 @@
---
enabled: false
shared: false
classification: adapter_server
direction: server
source_url: https://s.infinito.nexus/code
@@ -34,9 +34,6 @@
flowise:
enabled: false
shared: false
mcp:
enabled: true
shared: true
- services:
matomo:
enabled: false
@@ -66,6 +63,3 @@
flowise:
enabled: true
shared: true
mcp:
enabled: false
shared: false
@@ -1,19 +1,19 @@
"""Every deployable MCP surface is switched on by at least one variant.
"""Every deployable MCP surface has at least one peer that can reach it.
``test_dynamic_flags.py`` exempts the ``mcp`` key from the peer-role rule,
because an MCP provider must not decide its own exposure from another role's
presence. That exemption is what makes the "``false`` in ``meta/services.yml``,
``true`` in variant 0" pattern legal, and it leaves a hole: a role can declare a
whole served surface, wire its sidecar and its probe, and never enable any of
it. The deploy stays green because every gated task is skipped, so the surface
looks proven while nothing ran.
presence. That exemption leaves a hole: a role can declare a whole served
surface, wire its sidecar and its probe, and never enable any of it. The deploy
stays green because every gated task is skipped, so the surface looks proven
while nothing ran.
This lint closes that hole. A role that declares a deployable ``mcp`` block MUST
have a variant that turns it on.
This lint closes that hole. ``mcp.enabled`` is derived from the consumer edges
in ``meta/services.yml``, so a role whose surface nobody admits resolves to a
literal ``False`` and can never run.
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
* ``# nocheck: mcp-variant-coverage`` in the head of a ``meta/services.yml``
* ``# nocheck: mcp-variant-coverage`` in the head of a ``meta/mcp.yml``
file exempts that role.
"""
@@ -23,10 +23,11 @@ import unittest
from collections.abc import Mapping
from utils.annotations.suppress import is_suppressed_in_head
from utils.cache.applications import get_application_defaults
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.applications.mcp import MCP_DEPLOYABLE_CLASSIFICATIONS
from utils.roles.mapping import ROLE_FILE_META_MCP, ROLE_FILE_META_VARIANTS
from utils.roles.mapping import ROLE_FILE_META_MCP
from . import PROJECT_ROOT
@@ -34,13 +35,14 @@ _RULE = "mcp-variant-coverage"
class TestMcpVariantCoverage(unittest.TestCase):
"""Hard lint: a declared MCP surface is reachable in some variant."""
"""Hard lint: a declared MCP surface is reachable by some peer."""
def test_every_deployable_mcp_block_is_enabled_by_a_variant(self) -> None:
def test_every_deployable_mcp_block_has_an_admitted_peer(self) -> None:
roles_root = PROJECT_ROOT / "roles"
if not roles_root.is_dir():
self.skipTest("no roles/ directory")
defaults = get_application_defaults(roles_dir=roles_root)
offenders: list[str] = []
for role_dir in sorted(p for p in roles_root.iterdir() if p.is_dir()):
@@ -55,29 +57,14 @@ class TestMcpVariantCoverage(unittest.TestCase):
continue
if mcp.get("classification") not in MCP_DEPLOYABLE_CLASSIFICATIONS:
continue
if mcp.get("enabled") is True:
continue
variants_path = role_dir / ROLE_FILE_META_VARIANTS
if not variants_path.is_file():
block = (defaults.get(role_dir.name) or {}).get("mcp")
resolved = block.get("enabled") if isinstance(block, Mapping) else None
if resolved is None or resolved is False:
offenders.append(
f"{role_dir.name}: mcp is off in meta/services.yml "
f"and the role ships no meta/variants.yml, so its adapter, "
f"probe and endpoint can never run"
)
continue
variants = load_yaml_any(str(variants_path), default_if_missing=[])
enabled = any(
((variant or {}).get("mcp") or {}).get("enabled") is True
for variant in variants or []
if isinstance(variant, Mapping)
)
if not enabled:
offenders.append(
f"{role_dir.name}: mcp is off in meta/services.yml "
f"and no variant enables it, so its adapter, probe and "
f"endpoint can never run"
f"{role_dir.name}: no role declares itself an "
f"mcp_consumer this surface admits, so its adapter, probe "
f"and endpoint can never run"
)
if offenders:
@@ -32,6 +32,7 @@ from collections.abc import Mapping
from pathlib import Path
from utils.annotations.suppress import is_suppressed_in_head
from utils.cache.applications import get_application_defaults
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_MCP
@@ -51,16 +52,20 @@ _CLIENT_SURFACE = re.compile(
def _enabled_mcp_roles() -> dict[str, str]:
"""Return ``{role: direction}`` for every role serving or consuming MCP."""
roles_root = Path(PROJECT_ROOT, "roles")
defaults = get_application_defaults(roles_dir=roles_root)
roles: dict[str, str] = {}
for mcp_path in sorted(Path(PROJECT_ROOT, "roles").glob(f"*/{ROLE_FILE_META_MCP}")):
for mcp_path in sorted(roles_root.glob(f"*/{ROLE_FILE_META_MCP}")):
mcp = load_yaml_any(str(mcp_path), default_if_missing={})
if not isinstance(mcp, Mapping) or not mcp.get("enabled"):
if not isinstance(mcp, Mapping):
continue
role = mcp_path.parent.parent.name
block = (defaults.get(role) or {}).get("mcp")
if not (block.get("enabled") if isinstance(block, Mapping) else None):
continue
if is_suppressed_in_head(read_text(str(mcp_path)).splitlines(), _RULE):
continue
roles[mcp_path.parent.parent.name] = (
str(mcp.get("direction") or "").strip().lower()
)
roles[role] = str(mcp.get("direction") or "").strip().lower()
return roles
@@ -26,6 +26,7 @@ from collections.abc import Mapping
from pathlib import Path
from utils.annotations.suppress import is_suppressed_in_head
from utils.cache.applications import get_application_defaults
from utils.cache.files import iter_project_files_with_content, read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_MCP
@@ -39,18 +40,23 @@ _PROBE = "roles/svc-ai-mcp-adapter/tasks/probe.yml"
def _enabled_providers() -> list[Path]:
"""Return the role directories serving an MCP endpoint right now."""
roles_root = Path(PROJECT_ROOT, "roles")
defaults = get_application_defaults(roles_dir=roles_root)
roles: list[Path] = []
for mcp_path in sorted(Path(PROJECT_ROOT, "roles").glob(f"*/{ROLE_FILE_META_MCP}")):
for mcp_path in sorted(roles_root.glob(f"*/{ROLE_FILE_META_MCP}")):
mcp = load_yaml_any(str(mcp_path), default_if_missing={})
if not isinstance(mcp, Mapping):
continue
if str(mcp.get("direction") or "").strip().lower() not in _SERVER_DIRECTIONS:
continue
if not bool(mcp.get("enabled")):
role_dir = mcp_path.parent.parent
block = (defaults.get(role_dir.name) or {}).get("mcp")
resolved = block.get("enabled") if isinstance(block, Mapping) else None
if not resolved:
continue
if is_suppressed_in_head(read_text(str(mcp_path)).splitlines(), _RULE):
continue
roles.append(mcp_path.parent.parent)
roles.append(role_dir)
return roles
@@ -313,21 +313,25 @@ class TestMcpSchema(unittest.TestCase):
self._check_adapter(mcp, prefix, flag, role)
def _check_no_peer_gate(self, mcp, prefix, flag) -> None:
"""Pairing is the allowed_consumers intersection, never a deployed peer.
"""Admission is declared, presence is derived; never both by hand here.
A block that reads ``'web-app-openwebui' in group_names`` turns "that
client happens to be deployed" into "that client may connect", which is
a different question and one nobody declared. It also cannot scale: each
new client would need an edit in every provider.
``utils/cache/applications.py`` builds exactly that expression from the
``mcp_consumer`` edges instead, so a hand-written one is a second source
for the same fact and drifts the moment an edge changes.
"""
for key in ("enabled", "shared"):
value = mcp.get(key)
if isinstance(value, str) and "group_names" in value:
flag(
key,
f"{prefix}.{key} gates on group_names; provider enablement "
f"is operator or variant state, and pairing is the "
f"allowed_consumers intersection",
f"{prefix}.{key} gates on group_names by hand; the presence "
f"expression is derived from the allowed_consumers "
f"intersection, so declare admission in meta/services.yml",
)
def _check_delegation(self, mcp, prefix, flag) -> None:
@@ -14,6 +14,7 @@ import unittest
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from utils.cache.applications import get_application_defaults
from utils.cache.yaml import load_yaml
from utils.roles.mapping import ROLE_FILE_META_MCP
@@ -153,12 +154,22 @@ class TestPublicSurfacesAreCovered(unittest.TestCase):
def endpoint_roles() -> dict[str, dict]:
"""Return every role declaring an MCP endpoint path, by role name."""
"""Return every role declaring an MCP endpoint path, by role name.
``enabled`` comes from the resolved defaults, not from the meta file: it is
derived from the admitted clients and a role's own ``meta/mcp.yml`` carries
it only when it overrides that.
"""
defaults = get_application_defaults(roles_dir=PROJECT_ROOT / "roles")
found = {}
for meta in (PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_META_MCP}"):
block = load_yaml(meta)
if (block.get("endpoint") or {}).get("path"):
found[meta.parent.parent.name] = block
role = meta.parent.parent.name
block = dict(load_yaml(meta))
if not (block.get("endpoint") or {}).get("path"):
continue
resolved = (defaults.get(role) or {}).get("mcp") or {}
block["enabled"] = resolved.get("enabled")
found[role] = block
return found
@@ -195,7 +206,7 @@ class TestTheAppVhostServesOnlyPublicSurfaces(unittest.TestCase):
def test_the_public_roles_are_the_only_ones_served(self):
served = {r for r, b in endpoint_roles().items() if self.served(b)}
self.assertEqual(served, {r for r in PUBLIC_ROLES if r != "web-app-wordpress"})
self.assertEqual(served, set(PUBLIC_ROLES))
class TestTheHiddenPathIsTheOneTheAppServes(unittest.TestCase):
+134
View File
@@ -19,9 +19,11 @@ from pathlib import Path
from utils.cache import _reset_cache_for_tests
from utils.cache import applications as cache_apps
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import (
ROLE_DIR_META_ADDONS,
ROLE_FILE_META_MAIN,
ROLE_FILE_META_MCP,
ROLE_FILE_META_SERVICES,
ROLE_FILE_META_USERS,
ROLE_FILE_META_VARIANTS,
@@ -449,5 +451,137 @@ class TestApplicationsImportableWithoutAnsible(unittest.TestCase):
self.assertIn("OK", result.stdout)
class TestDeriveMcpPresence(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
@staticmethod
def _seed_pair(tmp: Path) -> Path:
roles = tmp / "roles"
_write(
roles / "web-app-prov" / ROLE_FILE_META_SERVICES,
"""
prov:
image: prov
version: latest
cons:
mcp_consumer: true
""",
)
_write(
roles / "web-app-prov" / ROLE_FILE_META_MCP,
"""
direction: server
""",
)
_write(
roles / "web-app-cons" / ROLE_FILE_META_SERVICES,
"""
cons:
image: cons
version: latest
mcp_consumer: true
""",
)
_write(
roles / "web-app-cons" / ROLE_FILE_META_MCP,
"""
direction: client
""",
)
return roles
def test_provider_resolves_to_its_admitted_consumers(self):
with tempfile.TemporaryDirectory() as tmp:
roles = self._seed_pair(Path(tmp))
defaults = cache_apps.get_application_defaults(roles_dir=roles)
self.assertEqual(
defaults["web-app-prov"]["mcp"]["enabled"],
"{{ 'web-app-cons' in group_names }}",
)
self.assertEqual(
defaults["web-app-prov"]["mcp"]["shared"],
defaults["web-app-prov"]["mcp"]["enabled"],
)
def test_provider_that_excludes_its_only_consumer_resolves_to_false(self):
with tempfile.TemporaryDirectory() as tmp:
roles = self._seed_pair(Path(tmp))
_write(
roles / "web-app-prov" / ROLE_FILE_META_SERVICES,
"""
prov:
image: prov
version: latest
cons:
mcp_consumer: false
""",
)
defaults = cache_apps.get_application_defaults(roles_dir=roles)
self.assertIs(defaults["web-app-prov"]["mcp"]["enabled"], False)
def test_configured_value_wins_over_the_derivation(self):
with tempfile.TemporaryDirectory() as tmp:
roles = self._seed_pair(Path(tmp))
_write(
roles / "web-app-prov" / ROLE_FILE_META_MCP,
"""
direction: server
enabled: false
shared: false
""",
)
defaults = cache_apps.get_application_defaults(roles_dir=roles)
self.assertIs(defaults["web-app-prov"]["mcp"]["enabled"], False)
def test_every_variant_carries_the_derived_value(self):
with tempfile.TemporaryDirectory() as tmp:
roles = self._seed_pair(Path(tmp))
_write(
roles / "web-app-prov" / ROLE_FILE_META_VARIANTS,
"""
- {}
- services:
prov:
image: prov-alt
""",
)
variants = cache_apps.get_variants(roles_dir=roles)["web-app-prov"]
self.assertEqual(len(variants), 2)
for variant in variants:
self.assertEqual(
variant["mcp"]["enabled"],
"{{ 'web-app-cons' in group_names }}",
)
def test_derivation_leaves_the_yaml_cache_untouched(self):
with tempfile.TemporaryDirectory() as tmp:
roles = self._seed_pair(Path(tmp))
mcp_path = roles / "web-app-prov" / ROLE_FILE_META_MCP
cache_apps.get_application_defaults(roles_dir=roles)
raw = load_yaml_any(str(mcp_path), default_if_missing={})
self.assertNotIn("enabled", raw)
self.assertNotIn("shared", raw)
def test_variant_override_wins_over_the_derivation(self):
with tempfile.TemporaryDirectory() as tmp:
roles = self._seed_pair(Path(tmp))
_write(
roles / "web-app-prov" / ROLE_FILE_META_VARIANTS,
"""
- {}
- mcp:
enabled: false
shared: false
""",
)
variants = cache_apps.get_variants(roles_dir=roles)["web-app-prov"]
self.assertEqual(
variants[0]["mcp"]["enabled"],
"{{ 'web-app-cons' in group_names }}",
)
self.assertIs(variants[1]["mcp"]["enabled"], False)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -15,6 +15,7 @@ from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from plugins.filter.merge.with_defaults import merge_with_defaults
from utils.roles.applications.mcp import derive_mcp_presence
from utils.roles.mapping import ROLE_FILE_META_VARIANTS
from .base import (
@@ -281,10 +282,12 @@ def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
behaviour.
"""
variants: dict[str, list[Any]] = {}
bases: dict[str, Any] = {}
for role_dir in _iter_application_role_dirs(roles_dir):
application_id = role_dir.name
base_config = _build_role_base_config(role_dir, roles_dir)
bases[application_id] = base_config
meta_path = role_dir / ROLE_FILE_META_VARIANTS
override_list = _load_variants_overrides(meta_path)
role_variants: list[Any] = []
@@ -295,6 +298,19 @@ def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
role_variants.append(copy.deepcopy(override))
variants[application_id] = role_variants
derive_mcp_presence(bases)
for application_id, role_variants in variants.items():
derived = (bases.get(application_id) or {}).get("mcp")
if not isinstance(derived, dict):
continue
for variant_config in role_variants:
block = (
variant_config.get("mcp") if isinstance(variant_config, dict) else None
)
if isinstance(block, dict):
block.setdefault("enabled", derived["enabled"])
block.setdefault("shared", derived["shared"])
return {key: variants[key] for key in sorted(variants)}
@@ -306,6 +322,7 @@ def _build_application_defaults(roles_dir: Path) -> dict[str, Any]:
defaults: dict[str, Any] = {}
for role_dir in _iter_application_role_dirs(roles_dir):
defaults[role_dir.name] = _build_role_base_config(role_dir, roles_dir)
derive_mcp_presence(defaults)
return {key: defaults[key] for key in sorted(defaults)}
+60
View File
@@ -62,6 +62,8 @@ own ``meta/services.yml``. ``derive_allowed_consumers`` resolves the pair.
from __future__ import annotations
from typing import Any
from utils.roles.entity.name import get_entity_name
MCP_DIRECTIONS = frozenset({"server", "client", "both"})
@@ -320,6 +322,64 @@ def derive_allowed_consumers(
)
def derive_mcp_presence(applications: dict[str, Any]) -> dict[str, Any]:
"""Materialise every ``mcp`` topic's enable state from its declared peers.
Args:
applications: ``{application_id: config}``, mutated in place.
Returns:
The same mapping.
A surface is reachable only while a peer that uses it is deployed, so both
keys resolve to the ``in group_names`` form the rest of the tree uses, and
to ``False`` when a role has no peer at all. An explicitly configured
``enabled``/``shared`` always wins, so config stays the override channel.
Keying on peer PRESENCE rather than on a peer's own value is required: a
re-entrant read under ``_RENDER_GUARD`` returns the unrendered dict.
The resolved block replaces the mapping rather than being written into it.
``_build_role_base_config`` parks the YAML cache's own object under ``mcp``,
so an in-place write would inject these keys into every later raw read of
``meta/mcp.yml``.
"""
blocks = {
role_id: config["mcp"]
for role_id, config in applications.items()
if isinstance(config, dict) and isinstance(config.get("mcp"), dict)
}
services_by_role = {
role_id: (config or {}).get("services")
for role_id, config in applications.items()
if isinstance(config, dict)
}
for role_id, block in blocks.items():
direction = str(block.get("direction") or "").lower()
peers: set[str] = set()
if direction in ("server", "both"):
peers |= set(derive_allowed_consumers(role_id, services_by_role))
if direction in ("client", "both") and declares_mcp_consumer(
role_id, services_by_role.get(role_id)
):
peers |= {
peer
for peer, peer_block in blocks.items()
if peer != role_id
and str(peer_block.get("direction") or "").lower() in ("server", "both")
and admits_mcp_consumer(services_by_role.get(peer), role_id)
}
value: Any = (
"{{ " + " or ".join(f"'{p}' in group_names" for p in sorted(peers)) + " }}"
if peers
else False
)
resolved = dict(block)
resolved.setdefault("enabled", value)
resolved.setdefault("shared", value)
applications[role_id]["mcp"] = resolved
return applications
def is_deployable(mcp: object) -> bool:
"""Return whether this block may carry a served surface and be discovered.