mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 11:22:31 +00:00
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:
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,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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: true
|
||||
shared: true
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://pkg.go.dev/gitea.com/gitea/gitea-mcp
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: true
|
||||
shared: true
|
||||
classification: native_client
|
||||
direction: client
|
||||
source_url: https://hermes-agent.nousresearch.com/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: true
|
||||
shared: true
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://plugins.jenkins.io/mcp-server/
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: true
|
||||
shared: true
|
||||
classification: plugin_server
|
||||
direction: server
|
||||
source_url: https://moodle.org/plugins/webservice_mcp
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: true
|
||||
shared: true
|
||||
classification: native_client
|
||||
direction: client
|
||||
source_url: https://openclaw.ai/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
enabled: false
|
||||
shared: false
|
||||
classification: adapter_server
|
||||
direction: server
|
||||
source_url: https://s.infinito.nexus/code
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Vendored
+17
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user