fix(mcp): narrow discovery to the providers the run deploys

A client rendered an endpoint for every provider the inventory knew,
including providers this run never deploys, so it carried connections
nothing was serving. Discovery now intersects with
application_closure(deployment.whitelist), the same set the service
loader preloads from. An unresolvable closure filters nothing, because
dropping every provider would look exactly like a deployment without
any.

credential_missing stays non-fatal in the app pass, where a provider may
still be waiting to write its secret, and becomes fatal at the
reconciliation stage, where every provider has already run.
This commit is contained in:
kevinveenbirkenbach committed 2026-08-09 22:52:19 +02:00
1 parent c8ff5e8605
commit 001aeda0b1
2 files changed
+146 -9

No files matched your search

+66 -9
View File
@@ -12,12 +12,25 @@ Everything else lands in ``rejected`` with a stable code:
``consumer_not_allowed``, ``transport_unsupported``, ``auth_unsupported``,
``credential_missing`` and ``endpoint_unreachable``.
``consumer_not_allowed`` is a decision and simply narrows the result. The other
four mean the provider did authorize this consumer and the connection still
cannot be rendered, so they abort the run instead of disappearing quietly: a
client that silently ends up with fewer tools than the deployment declared is
``consumer_not_allowed`` is a decision and simply narrows the result.
``transport_unsupported``, ``auth_unsupported`` and ``endpoint_unreachable``
mean the provider did authorize this consumer and the connection still cannot
be rendered, so they abort the run instead of disappearing quietly: a client
that silently ends up with fewer tools than the deployment declared is
indistinguishable from one that works.
Providers are narrowed to ``application_closure(deployment.whitelist)``, the same
set ``sys-service-loader`` preloads from. Inventory membership, ``group_names``
and the raw whitelist all admit roles the run never deploys, and a client then
carries an endpoint nothing is serving. In compose the closure covers everything
present, so only swarm makes the difference visible.
``credential_missing`` is the one authorized rejection that does not abort,
because a provider deploying later in the same play has not written its secret
yet. That exemption ends at the reconciliation stage, which runs once every
provider has: ``assert_authorized_are_renderable(discovery, strict=True)``
treats it as fatal there.
The provider credential is whatever ``mcp.credential`` declares:
``owner`` names the principal, ``source`` where its secret lives
(``token_store`` or the role's own ``credentials``), ``key`` the entry. No
@@ -45,6 +58,8 @@ REJECT_ENDPOINT = "endpoint_unreachable"
FATAL_REJECTIONS = frozenset({REJECT_TRANSPORT, REJECT_AUTH, REJECT_ENDPOINT})
RECONCILE_STRICT_VAR = "MCP_RECONCILE_STRICT"
SOURCE_TOKEN_STORE = "token_store" # noqa: S105 - a source name, not a secret
SOURCE_CREDENTIALS = "credentials"
@@ -101,6 +116,22 @@ def resolve_credential(
return "", owner
def select_deployed(
servers: list[dict[str, Any]] | None, closure: set[str] | None
) -> list[dict[str, Any]]:
"""Drop providers this run does not deploy.
An empty or unresolvable closure filters nothing: discovery that silently
dropped every provider would look exactly like a deployment without any,
which is the failure this module refuses to produce elsewhere.
"""
if not servers:
return list(servers or [])
if not closure:
return list(servers)
return [server for server in servers if server.get("id") in closure]
def build_mcp_discovery(
servers: Sequence[Mapping[str, Any]] | None,
consumer_id: str,
@@ -212,20 +243,28 @@ def build_mcp_discovery(
return {"selected": selected, "rejected": rejected}
def assert_authorized_are_renderable(discovery: Mapping[str, Any]) -> None:
def assert_authorized_are_renderable(
discovery: Mapping[str, Any], strict: bool = False
) -> None:
"""Abort when a provider authorized this consumer but nothing can connect.
Args:
discovery: the ``{"selected": ..., "rejected": ...}`` result.
strict: also treat ``credential_missing`` as fatal. Its exemption buys
exactly one thing, a provider that deploys later in the same play
and has not written its secret yet. The reconciliation stage runs
after every provider, so there the exemption would only hide a
provider whose credential never resolved at all.
"""
fatal_reasons = FATAL_REJECTIONS | ({REJECT_CREDENTIAL} if strict else set())
fatal = [
entry
for entry in discovery.get("rejected") or []
if entry.get("reason") in FATAL_REJECTIONS
if entry.get("reason") in fatal_reasons
]
if not fatal:
return
detail = "; ".join(f"{e['id']}: {e['reason']} — {e['detail']}" for e in fatal)
detail = "; ".join(f"{e['id']}: {e['reason']}: {e['detail']}" for e in fatal)
raise AnsibleError(
f"mcp_servers: {len(fatal)} authorized MCP server(s) cannot be "
f"rendered. {detail}"
@@ -233,6 +272,20 @@ def assert_authorized_are_renderable(discovery: Mapping[str, Any]) -> None:
class LookupModule(LookupBase):
def _deploy_closure(self, templar: Any, vars_: dict[str, Any]) -> set[str]:
"""Roles this run deploys, or an empty set when that cannot be resolved."""
try:
whitelist = lookup_loader.get(
"deployment", loader=self._loader, templar=templar
).run([], variables=vars_)[0]["whitelist"]
return set(
lookup_loader.get(
"application_closure", loader=self._loader, templar=templar
).run([whitelist], variables=vars_)[0]
)
except (AnsibleError, KeyError, IndexError, TypeError):
return set()
def run(
self,
terms: Sequence[Any] | None,
@@ -240,7 +293,7 @@ class LookupModule(LookupBase):
**kwargs: Any,
) -> list[Any]:
if terms:
raise AnsibleError("mcp_servers: expected no terms — lookup('mcp_servers')")
raise AnsibleError("mcp_servers: expected no terms, lookup('mcp_servers')")
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
templar = getattr(self, "_templar", None)
@@ -261,6 +314,8 @@ class LookupModule(LookupBase):
scope="deployment",
)[0]
servers = select_deployed(servers, self._deploy_closure(templar, vars_))
applications = lookup_loader.get(
"applications", loader=self._loader, templar=templar
).run([], variables=vars_)[0]
@@ -288,5 +343,7 @@ class LookupModule(LookupBase):
discovery = build_mcp_discovery(
servers, consumer_id, consumer, credentials, path_keys
)
assert_authorized_are_renderable(discovery)
assert_authorized_are_renderable(
discovery, strict=bool(vars_.get(RECONCILE_STRICT_VAR))
)
return [discovery]
@@ -43,6 +43,42 @@ 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, []):
@@ -189,6 +225,29 @@ class TestAssertAuthorizedAreRenderable(unittest.TestCase):
}
)
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",
@@ -242,6 +301,27 @@ class TestResolveCredential(unittest.TestCase):
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"))
if __name__ == "__main__":
unittest.main()