Files
computer-playbook/tests/unit/python/plugins/lookup/test_mcp_servers.py
T
kevinveenbirkenbachandClaude Opus 5 adacac7f29 fix(mcp): present the account's login in basic auth, not the users-dict key
The WordPress MCP endpoint answered 401 to every authenticated call, and
web-app-hermes and web-app-openclaw failed their CLI tests as cascades of the
same refusal. The WordPress container's own Apache log settles it: 121 POST,
100 GET and 21 HEAD on the MCP path, each with %u = mcp-web-app-wordpress and
status 401. A populated %u rules out a stripped Authorization header, and the
body is infinito_mcp_unauthorized, which only the mu-plugin's rest_pre_dispatch
guard emits - a wrong password for an existing user would surface WP core's
incorrect_password instead. So WordPress never resolved a user, because the
deploy sent the users-dict key while the login the deploy creates is
mcp-wordpress.

7916c0177e introduced the split when it capped usernames, deliberately keeping
the key because meta/mcp.yml's owner and the token store wire to it.
web-app-wordpress is the only role with auth: basic_auth - every other MCP
provider uses bearer, where the owner never travels - which is why only this
one broke.

resolve_credential now also returns the login, mcp_servers emits it on every
discovered entry, and the filter encodes login:token. It raises when no login
resolves rather than authenticating as the empty user, so the failure is loud
instead of another 401. The new mcp_client_login lookup shares
resolve_provider_credential with mcp_client_bearer so a second resolver cannot
drift from the first, and a test pins the two to the same function object.
test.env.j2 is the one place that hand-builds the server dict instead of
reading MCP_DISCOVERED_SERVERS, so it passes login as well.

The lint closes the class: auth: basic_auth requires credential.owner to be a
key some role's meta/users.yml declares. Without it a future basic-auth
provider sourced from credentials would hit the same fallback silently. It
imports BASIC_AUTHS from the filter so the rule and the renderer cannot drift
onto different spellings.

Tool names were verified against the adapter version the addon actually
installs, v0.7.0, not the v0.5.0 recorded in meta/mcp.yml: the sanitizer turns
the three registered abilities into infinito-get-post,
infinito-list-categories and infinito-search-posts, matching the rendered
allowlist exactly. Adding login to a discovered entry changes nothing for the
five consumers - three of them do not list basic_auth in supported_auths and
never see this server at all, and the other two read named keys only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-16 12:37:30 +02:00

473 lines
17 KiB
Python

"""Unit tests for ``plugins/lookup/mcp_servers.py``.
Only the pure builder and the credential resolver are exercised; the lookup
wrapper forwards ``roles_with_service``, ``applications`` and ``users`` into
them.
"""
import importlib
import unittest
from typing import ClassVar
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",
"mcp-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 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["login"], "mcp-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",
"mcp-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-web-app-jenkins", "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", "mcp-web-app-baserow"),
)
def test_the_login_is_the_declared_username_not_the_user_key(self):
"""A shortened account name must reach the wire, or basic auth 401s.
web-app-wordpress keeps the key ``mcp-web-app-wordpress`` because the
token store writes under it, while the account logs in as
``mcp-wordpress``. Presenting the key authenticated as nobody.
"""
server = self._server(
owner="mcp-web-app-wordpress",
source="token_store",
key="web-app-wordpress",
)
users = {
"mcp-web-app-wordpress": {
"username": "mcp-wordpress",
"tokens": {"web-app-wordpress": "pw"},
}
}
self.assertEqual(
resolve_credential(server, users, {}),
("pw", "mcp-web-app-wordpress", "mcp-wordpress"),
)
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", "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", "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", "administrator"),
)
def test_unknown_source_resolves_to_nothing(self):
server = self._server(owner="o", source="vault", key="k")
self.assertEqual(resolve_credential(server, {}, {}), ("", "o", "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", "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", "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", "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()