Files
computer-playbook/tests/unit/python/plugins/lookup/test_mcp_servers.py
T
kevinveenbirkenbachandClaude Opus 5.5 ca4f53d0e4 fix(mcp): keep a persistent host's providers through a partial deploy
mcp_servers narrowed the MCP providers to application_closure(deployment.whitelist), which on a persistent host drops every provider an earlier run deployed and a partial run does not touch. sys-svc-mcp-reconcile then converges each client present on the host against that narrowed set: on echoserver the --id web-opt-rdr-domains run rdr-3 removed the Prometheus registration from Flowise.

deployment now reports ephemeral (runtime act or github), and the closure narrows only there, where a whitelist is all a run deploys; a persistent host narrows nothing, as a full deploy already did with its empty whitelist, so its clients keep every provider it serves. CI keeps its narrowing unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 09:24:28 +01:00

452 lines
17 KiB
Python

"""Unit tests for ``plugins/lookup/mcp_servers.py``.
The pure builder, the credential resolver and the deploy closure are exercised;
the lookup wrapper forwards ``roles_with_service``, ``applications`` and ``users``
into them.
"""
import importlib
import unittest
from typing import ClassVar
from unittest.mock import MagicMock, patch
from ansible.errors import AnsibleError
plugin_module = importlib.import_module("plugins.lookup.mcp_servers")
build_mcp_discovery = plugin_module.build_mcp_discovery
resolve_credential = plugin_module.resolve_credential
resolve_consumer_id = plugin_module.resolve_consumer_id
role_credentials_of = plugin_module.role_credentials_of
CONSUMER = "web-app-openwebui"
CLIENT = {
"supported_transports": ["streamable_http", "sse"],
"supported_auths": ["bearer_token", "app_password"],
}
HOMEASSISTANT = {
"id": "web-app-homeassistant",
"auth": "bearer_token",
"auth_subject": "service_account",
"transport": "streamable_http",
"allowed_consumers": [CONSUMER],
"credential": {
"owner": "mcp-web-app-homeassistant",
"source": "token_store",
"key": "web-app-homeassistant",
},
"endpoint": {"service_key": "homeassistant", "port": 8123, "path": "/api/mcp"},
"tools": ["GetDateTime", "HassTurnOn"],
"mutating": ["HassTurnOn"],
}
CREDENTIALS = {"web-app-homeassistant": ("ha-token", "mcp-web-app-homeassistant")}
def reasons(result):
return {entry["id"]: entry["reason"] for entry in result["rejected"]}
class TestSelectDeployed(unittest.TestCase):
"""Providers the run does not deploy must not reach a client's config.
Inventory membership, group_names and the raw whitelist all admit roles the
play never runs; the deployed set is the whitelist's dependency closure, the
same one sys-service-loader preloads from.
"""
SERVERS: ClassVar[list] = [
{"id": "web-app-homeassistant"},
{"id": "web-app-prometheus"},
]
def test_a_provider_outside_the_closure_is_dropped(self):
self.assertEqual(
["web-app-homeassistant"],
[
s["id"]
for s in plugin_module.select_deployed(
self.SERVERS, {"web-app-hermes", "web-app-homeassistant"}
)
],
)
def test_an_unresolvable_closure_filters_nothing(self):
for closure in (None, set()):
self.assertEqual(
2,
len(plugin_module.select_deployed(self.SERVERS, closure)),
"dropping every provider would look like a deployment with none",
)
def test_no_servers_stays_empty(self):
self.assertEqual([], plugin_module.select_deployed(None, {"web-app-hermes"}))
class TestDeployClosure(unittest.TestCase):
def closure(self, deployment):
def get(name, **kwargs):
plugin = MagicMock()
if name == "deployment":
plugin.run.return_value = [deployment]
else:
plugin.run.side_effect = lambda terms, variables=None: [
{*terms[0], "svc-db-postgres"}
]
return plugin
with patch.object(plugin_module.lookup_loader, "get", side_effect=get):
return plugin_module.LookupModule()._deploy_closure(None, {})
def test_a_partial_run_on_a_persistent_host_narrows_nothing(self):
self.assertEqual(
set(),
self.closure({"ephemeral": False, "whitelist": ["web-opt-rdr-domains"]}),
)
def test_an_ephemeral_run_narrows_to_the_whitelist_closure(self):
self.assertEqual(
{"web-app-hermes", "svc-db-postgres"},
self.closure({"ephemeral": True, "whitelist": ["web-app-hermes"]}),
)
class TestBuildMcpDiscovery(unittest.TestCase):
def test_no_servers_yields_nothing(self):
for servers in (None, []):
result = build_mcp_discovery(servers, CONSUMER, CLIENT, {})
self.assertEqual(result, {"selected": [], "rejected": []})
def test_authorized_server_becomes_a_container_network_entry(self):
entry = build_mcp_discovery([HOMEASSISTANT], CONSUMER, CLIENT, CREDENTIALS)[
"selected"
][0]
self.assertEqual(entry["id"], "web-app-homeassistant")
self.assertEqual(entry["url"], "http://homeassistant:8123/api/mcp")
self.assertEqual(entry["token"], "ha-token")
self.assertEqual(entry["auth"], "bearer_token")
self.assertEqual(entry["auth_subject"], "service_account")
self.assertEqual(entry["owner"], "mcp-web-app-homeassistant")
self.assertEqual(entry["transport"], "streamable-http")
def test_unlisted_consumer_is_rejected_with_a_reason(self):
result = build_mcp_discovery(
[HOMEASSISTANT], "web-app-flowise", CLIENT, CREDENTIALS
)
self.assertEqual(result["selected"], [])
self.assertEqual(
reasons(result), {"web-app-homeassistant": "consumer_not_allowed"}
)
def test_transport_the_client_cannot_speak_is_rejected(self):
client = dict(CLIENT, supported_transports=["sse"])
result = build_mcp_discovery([HOMEASSISTANT], CONSUMER, client, CREDENTIALS)
self.assertEqual(
reasons(result), {"web-app-homeassistant": "transport_unsupported"}
)
def test_auth_the_client_cannot_present_is_rejected(self):
client = dict(CLIENT, supported_auths=["app_password"])
result = build_mcp_discovery([HOMEASSISTANT], CONSUMER, client, CREDENTIALS)
self.assertEqual(reasons(result), {"web-app-homeassistant": "auth_unsupported"})
def test_unresolved_credential_is_rejected(self):
result = build_mcp_discovery([HOMEASSISTANT], CONSUMER, CLIENT, {})
self.assertEqual(
reasons(result), {"web-app-homeassistant": "credential_missing"}
)
def test_incomplete_endpoint_is_rejected(self):
no_port = dict(HOMEASSISTANT, endpoint={"service_key": "x", "path": "/mcp"})
result = build_mcp_discovery([no_port], CONSUMER, CLIENT, CREDENTIALS)
self.assertEqual(
reasons(result), {"web-app-homeassistant": "endpoint_unreachable"}
)
def test_two_providers_sharing_one_credential_abort_discovery(self):
twin = dict(HOMEASSISTANT, id="web-app-baserow")
credentials = dict(
CREDENTIALS,
**{"web-app-baserow": ("ha-token", "mcp-web-app-homeassistant")},
)
with self.assertRaises(AnsibleError):
build_mcp_discovery([HOMEASSISTANT, twin], CONSUMER, CLIENT, credentials)
def test_a_client_never_discovers_itself(self):
itself = dict(HOMEASSISTANT, id=CONSUMER)
result = build_mcp_discovery([itself], CONSUMER, CLIENT, CREDENTIALS)
self.assertEqual(result, {"selected": [], "rejected": []})
def test_a_discovered_url_never_carries_a_secret_path_segment(self):
"""A URL segment cannot be scoped per consumer, nor revoked alone.
A provider that keys its session that way is fronted by an adapter, so
the segment stays between adapter and upstream and never reaches a
client's rendered configuration.
"""
keyed = dict(
HOMEASSISTANT,
endpoint={
"service_key": "baserow",
"port": 80,
"path": "/mcp",
"key_credential": "mcp_endpoint_key",
"suffix": "sse",
},
)
entry = build_mcp_discovery([keyed], CONSUMER, CLIENT, CREDENTIALS)["selected"][
0
]
self.assertEqual(entry["url"], "http://baserow:80/mcp")
def test_an_endpoint_without_a_port_is_rejected(self):
portless = dict(HOMEASSISTANT, endpoint={"service_key": "x", "path": "/mcp"})
result = build_mcp_discovery([portless], CONSUMER, CLIENT, CREDENTIALS)
self.assertEqual(
reasons(result), {"web-app-homeassistant": "endpoint_unreachable"}
)
def test_a_provider_naming_no_tool_is_rejected(self):
"""A runtime-composed surface keeps its contract in ``tools.categories``.
A client filters by tool name, so that contract reaches it as no
contract at all: the rendered filter would be empty and the agent
would carry a server it can never call.
"""
dynamic = dict(HOMEASSISTANT, tools=[], mutating=[])
result = build_mcp_discovery([dynamic], CONSUMER, CLIENT, CREDENTIALS)
self.assertEqual(result["selected"], [])
self.assertEqual(
reasons(result), {"web-app-homeassistant": "tools_unenforceable"}
)
def test_a_provider_withholding_every_tool_it_serves_is_rejected(self):
withheld = dict(HOMEASSISTANT, tools=["HassTurnOn"], mutating=["HassTurnOn"])
result = build_mcp_discovery([withheld], CONSUMER, CLIENT, CREDENTIALS)
self.assertEqual(result["selected"], [])
self.assertEqual(
reasons(result), {"web-app-homeassistant": "tools_unenforceable"}
)
def test_a_provider_keeping_one_callable_tool_is_selected(self):
entry = build_mcp_discovery([HOMEASSISTANT], CONSUMER, CLIENT, CREDENTIALS)[
"selected"
][0]
self.assertEqual(["GetDateTime", "HassTurnOn"], entry["tools"])
self.assertEqual(["HassTurnOn"], entry["mutating"])
def test_order_of_the_discovered_servers_is_kept(self):
jenkins = dict(HOMEASSISTANT, id="web-app-jenkins")
credentials = dict(CREDENTIALS, **{"web-app-jenkins": ("jk", "mcp-jenkins")})
result = build_mcp_discovery(
[jenkins, HOMEASSISTANT], CONSUMER, CLIENT, credentials
)
self.assertEqual(
[e["id"] for e in result["selected"]],
["web-app-jenkins", "web-app-homeassistant"],
)
class TestAssertAuthorizedAreRenderable(unittest.TestCase):
assert_renderable = staticmethod(plugin_module.assert_authorized_are_renderable)
def test_nothing_rejected_passes(self):
self.assert_renderable({"selected": [], "rejected": []})
def test_a_narrowing_decision_passes(self):
self.assert_renderable(
{
"selected": [],
"rejected": [
{"id": "a", "reason": "consumer_not_allowed", "detail": "d"}
],
}
)
def test_a_provider_not_provisioned_yet_does_not_abort(self):
self.assert_renderable(
{
"selected": [],
"rejected": [
{"id": "a", "reason": "credential_missing", "detail": "d"}
],
}
)
def test_a_credential_that_never_resolved_aborts_the_reconciliation(self):
with self.assertRaises(AnsibleError):
self.assert_renderable(
{
"selected": [],
"rejected": [
{"id": "a", "reason": "credential_missing", "detail": "d"}
],
},
strict=True,
)
def test_a_narrowing_decision_still_passes_under_reconciliation(self):
self.assert_renderable(
{
"selected": [],
"rejected": [
{"id": "a", "reason": "consumer_not_allowed", "detail": "d"}
],
},
strict=True,
)
def test_an_authorized_but_unrenderable_server_aborts(self):
for reason in (
"transport_unsupported",
"auth_unsupported",
"endpoint_unreachable",
"tools_unenforceable",
):
with self.subTest(reason=reason), self.assertRaises(AnsibleError):
self.assert_renderable(
{
"selected": [],
"rejected": [{"id": "a", "reason": reason, "detail": "d"}],
}
)
class TestResolveCredential(unittest.TestCase):
USERS: ClassVar[dict] = {
"mcp-web-app-baserow": {"tokens": {"web-app-baserow": " key "}}
}
def _server(self, **credential):
return {"credential": credential}
def test_token_store_owner_yields_its_stripped_token(self):
server = self._server(
owner="mcp-web-app-baserow", source="token_store", key="web-app-baserow"
)
self.assertEqual(
resolve_credential(server, self.USERS, {}), ("key", "mcp-web-app-baserow")
)
def test_role_credentials_source_reads_the_role_secret(self):
server = self._server(
owner="mcp-web-app-x", source="credentials", key="mcp_token"
)
self.assertEqual(
resolve_credential(server, {}, {"mcp_token": "v"}), ("v", "mcp-web-app-x")
)
def test_the_role_secret_is_read_where_the_inventory_writes_it(self):
"""The function was tested; the address its caller used was not.
Provisioning writes `<role>.secrets.credentials.<key>`, which is also
how the roles' own vars address it. Discovery read the top-level
`credentials` and therefore found nothing for all fifteen providers
whose source is `credentials`, aborting the deploy on a secret that
had been generated and vaulted all along.
"""
applications = {
"web-app-prometheus": {
"secrets": {"credentials": {"mcp_bearer": "resolved"}}
}
}
self.assertEqual(
{"mcp_bearer": "resolved"},
role_credentials_of(applications, "web-app-prometheus"),
)
def test_a_top_level_credentials_block_is_not_the_address(self):
applications = {"web-app-x": {"credentials": {"mcp_bearer": "wrong-place"}}}
self.assertEqual({}, role_credentials_of(applications, "web-app-x"))
def test_an_absent_role_yields_an_empty_mapping(self):
self.assertEqual({}, role_credentials_of({}, "web-app-missing"))
def test_the_extraction_feeds_resolve_credential(self):
"""End to end: the payload shape provisioning produces must resolve."""
applications = {
"web-app-prometheus": {
"secrets": {"credentials": {"mcp_bearer": "resolved"}}
}
}
server = self._server(
owner="mcp-web-app-prometheus", source="credentials", key="mcp_bearer"
)
self.assertEqual(
("resolved", "mcp-web-app-prometheus"),
resolve_credential(
server, {}, role_credentials_of(applications, "web-app-prometheus")
),
)
def test_administrator_owner_resolves_to_nothing(self):
server = self._server(
owner="administrator", source="token_store", key="web-app-baserow"
)
users = {"administrator": {"tokens": {"web-app-baserow": "k"}}}
self.assertEqual(resolve_credential(server, users, {}), ("", "administrator"))
def test_unknown_source_resolves_to_nothing(self):
server = self._server(owner="o", source="vault", key="k")
self.assertEqual(resolve_credential(server, {}, {}), ("", "o"))
def test_missing_declaration_resolves_to_nothing(self):
self.assertEqual(resolve_credential({}, {}, {}), ("", ""))
def test_a_blank_stored_token_resolves_to_nothing(self):
server = self._server(
owner="mcp-web-app-x", source="token_store", key="web-app-x"
)
users = {"mcp-web-app-x": {"tokens": {"web-app-x": " "}}}
self.assertEqual(resolve_credential(server, users, {}), ("", "mcp-web-app-x"))
def test_a_blank_role_credential_resolves_to_nothing(self):
server = self._server(
owner="mcp-web-app-x", source="credentials", key="mcp_token"
)
self.assertEqual(
resolve_credential(server, {}, {"mcp_token": ""}), ("", "mcp-web-app-x")
)
def test_a_principal_the_store_never_saw_resolves_to_nothing(self):
server = self._server(
owner="mcp-web-app-x", source="token_store", key="web-app-x"
)
self.assertEqual(resolve_credential(server, {}, {}), ("", "mcp-web-app-x"))
class TestResolveConsumerId(unittest.TestCase):
"""The consumer must be a rendered role id before it is matched.
sys-svc-mcp-reconcile sets ``application_id`` from a loop variable, so the
lookup receives the literal ``{{ mcp_client.id }}``. An unrendered id
matches no ``allowed_consumers``, which rejects every provider as
``consumer_not_allowed`` and empties the registry without an error.
"""
class _Templar:
def template(self, value):
return {"{{ mcp_client.id }}": CONSUMER}[value]
def test_a_deferred_application_id_is_rendered(self):
self.assertEqual(
resolve_consumer_id(
{"application_id": "{{ mcp_client.id }}"}, self._Templar()
),
CONSUMER,
)
def test_a_plain_application_id_is_kept(self):
self.assertEqual(
resolve_consumer_id({"application_id": CONSUMER}, self._Templar()), CONSUMER
)
def test_a_missing_application_id_aborts(self):
with self.assertRaises(AnsibleError):
resolve_consumer_id({}, self._Templar())
if __name__ == "__main__":
unittest.main()