From 579bf0c2db624127094cd7f4572c7ffea836ade6 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 13 Jul 2026 18:07:05 +0200 Subject: [PATCH] Install registry servers by name through the MCP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/mcp-servers/registry?q= surfaces the resolved candidates — the route fetches (registry + pins) and the response models are pure projections: druks name, registry name, description, url, the official badge, the bearer classification, and each declared header input the form renders. Registry failures surface as a 502, an unreadable pins file as a named 500 — never an empty list that reads as "no such server". POST /api/mcp-servers grows the registry path beside the custom one: a body naming a registry entry is re-resolved server-side, so the url, the auth shape and each header's secrecy come from the registry — the client supplies only the druks name and the filled values. Required headers are enforced, unknown ones rejected, and the values split into the row's plain/secret columns by the spec's isSecret flags. A remote that authenticates through a secret header installs ready and enabled; anything else is assumed OAuth and ships dark until its Connect lands, exactly like the catalog's oauth default. Connect now looks the server up in the resolved view, so an installed registry row reuses the existing consent flow against its registry-supplied url; deleting a row drops its grant with it — an orphan grant would revive as the name's credential on re-add. Co-Authored-By: Claude Fable 5 --- backend/druks/mcp/models.py | 65 ++++---- backend/druks/mcp/registry.py | 9 +- backend/druks/mcp/routes.py | 107 +++++++++++-- backend/druks/mcp/schemas.py | 18 ++- backend/tests/test_mcp_registry.py | 234 ++++++++++++++++++++++++++--- backend/tests/test_mcp_servers.py | 8 +- backend/tests/test_secrets.py | 2 +- 7 files changed, 373 insertions(+), 70 deletions(-) diff --git a/backend/druks/mcp/models.py b/backend/druks/mcp/models.py index e53c1c88..303d6e48 100644 --- a/backend/druks/mcp/models.py +++ b/backend/druks/mcp/models.py @@ -40,7 +40,7 @@ class McpServer(Base, Uuid7Pk): @classmethod def list_all(cls) -> list["McpServer"]: - # The raw overlay rows — not the merged registry view (list_resolved). + # The raw overlay rows — not the merged registry view (get_resolved). return list(db_session().execute(select(cls).order_by(cls.name)).scalars()) @classmethod @@ -48,46 +48,43 @@ def get_by_name(cls, name: str) -> "McpServer | None": return db_session().execute(select(cls).where(cls.name == name)).scalar_one_or_none() @classmethod - def list_resolved(cls) -> list[dict]: - # The full view the API reads and delivery resolves from: each built-in - # definition (url + auth from the registry) overlaid with its operator - # row's enable choice and secrets, then any fully custom rows. + def get_resolved(cls) -> dict[str, dict]: + # The full view the API reads and delivery resolves from, keyed by + # name: each built-in definition (url + auth from the registry) + # overlaid with its operator row's enable choice and secrets, then any + # fully custom rows. rows = {server.name: server for server in cls.list_all()} - servers: list[dict] = [] + servers: dict[str, dict] = {} for definition in mcp_servers.all(): row = rows.pop(definition["name"], None) - servers.append( - { - "name": definition["name"], - "url": definition["url"], - "token_source": definition["token_source"], - "source_env_var": definition["source_env_var"], - "is_enabled": row.is_enabled if row else definition["enabled"], - "token": row.token if row else Secret(b"", ""), - "headers": row.headers if row else {}, - "secret_headers": row.secret_headers if row else {}, - "builtin": True, - } - ) + servers[definition["name"]] = { + "name": definition["name"], + "url": definition["url"], + "token_source": definition["token_source"], + "source_env_var": definition["source_env_var"], + "is_enabled": row.is_enabled if row else definition["enabled"], + "token": row.token if row else Secret(b"", ""), + "headers": row.headers if row else {}, + "secret_headers": row.secret_headers if row else {}, + "builtin": True, + } for row in rows.values(): - servers.append( - { - "name": row.name, - "url": row.url, - "token_source": row.token_source, - "source_env_var": "", - "is_enabled": row.is_enabled, - "token": row.token, - "headers": row.headers, - "secret_headers": row.secret_headers, - "builtin": False, - } - ) + servers[row.name] = { + "name": row.name, + "url": row.url, + "token_source": row.token_source, + "source_env_var": "", + "is_enabled": row.is_enabled, + "token": row.token, + "headers": row.headers, + "secret_headers": row.secret_headers, + "builtin": False, + } # has_token = nothing blocks this server's auth at delivery, read from # wherever its source keeps the secret: druks' env for an env-sourced # server, a stored grant for a connected one, the stored token for a # static one; a bearerless server has none to miss. - for server in servers: + for server in servers.values(): source = server["token_source"] if not source: server["has_token"] = True @@ -102,7 +99,7 @@ def list_resolved(cls) -> list[dict]: @classmethod def list_enabled(cls) -> list[dict]: # The enabled subset — what a run delivers and the settings UI shows active. - return [server for server in cls.list_resolved() if server["is_enabled"]] + return [server for server in cls.get_resolved().values() if server["is_enabled"]] @classmethod def set_enabled(cls, name: str, is_enabled: bool) -> bool: diff --git a/backend/druks/mcp/registry.py b/backend/druks/mcp/registry.py index 9e33ceea..14a35f33 100644 --- a/backend/druks/mcp/registry.py +++ b/backend/druks/mcp/registry.py @@ -62,11 +62,12 @@ async def search_registry(query: str) -> list[dict]: return entries -def resolve_candidates(entries: list[dict], pins: dict[str, str]) -> list[dict]: +def resolve_candidates(entries: list[dict], pins: dict[str, str]) -> dict[str, dict]: """One installable candidate per entry reachable over streamable HTTP — from its own declared remote, or from a pinned official url the registry - omits; stdio/oci-only entries drop out. ``official`` is the trust badge: - the publisher provably owns the remote's domain, or a pin vouches.""" + omits; stdio/oci-only entries drop out. Keyed by registry name, official + candidates first. ``official`` is the trust badge: the publisher provably + owns the remote's domain, or a pin vouches.""" # A url pin lifts exactly one entry among those deriving its name. The pin # itself supplies the url, so this choice only picks display text; the # tiebreak just needs determinism: prefer the publisher whose own name @@ -128,4 +129,4 @@ def resolve_candidates(entries: list[dict], pins: dict[str, str]) -> list[dict]: } ) candidates.sort(key=lambda candidate: (not candidate["official"], candidate["name"])) - return candidates + return {candidate["registry_name"]: candidate for candidate in candidates} diff --git a/backend/druks/mcp/routes.py b/backend/druks/mcp/routes.py index 89e49d1f..4b728e68 100644 --- a/backend/druks/mcp/routes.py +++ b/backend/druks/mcp/routes.py @@ -1,14 +1,22 @@ +import json + from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse from druks.extensions.registry import mcp_servers -from druks.mcp import oauth +from druks.mcp import oauth, registry from druks.mcp.enums import TokenSource -from druks.mcp.exceptions import InvalidServerNameError, OauthConnectError +from druks.mcp.exceptions import ( + InvalidServerNameError, + OauthConnectError, + RegistryUnavailableError, +) from druks.mcp.models import McpOauthGrant, McpServer from druks.mcp.schemas import ( ConnectMcpServerResponse, CreateMcpServerRequest, + InstallMcpServerRequest, + McpRegistryCandidateResponse, McpServerResponse, ) @@ -16,14 +24,27 @@ def _response(name: str) -> McpServerResponse: - return McpServerResponse.model_validate( - next(s for s in McpServer.list_resolved() if s["name"] == name) - ) + return McpServerResponse.model_validate(McpServer.get_resolved()[name]) @router.get("", response_model=list[McpServerResponse]) async def list_mcp_servers() -> list[McpServerResponse]: - return [McpServerResponse.model_validate(server) for server in McpServer.list_resolved()] + return [ + McpServerResponse.model_validate(server) for server in McpServer.get_resolved().values() + ] + + +@router.get("/registry", response_model=list[McpRegistryCandidateResponse]) +async def search_mcp_registry(query: str, request: Request) -> list[McpRegistryCandidateResponse]: + pins = json.loads(request.app.state.settings.mcp_trusted_path.read_text()) + try: + entries = await registry.search_registry(query) + except RegistryUnavailableError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + candidates = registry.resolve_candidates(entries, pins) + return [ + McpRegistryCandidateResponse.model_validate(candidate) for candidate in candidates.values() + ] @router.post("", response_model=McpServerResponse) @@ -53,6 +74,70 @@ async def add_mcp_server(body: CreateMcpServerRequest) -> McpServerResponse: return _response(body.name) +@router.post("/registry", response_model=McpServerResponse) +async def install_mcp_server(body: InstallMcpServerRequest, request: Request) -> McpServerResponse: + if body.name in mcp_servers: + raise HTTPException( + status_code=409, + detail=f"MCP server {body.name!r} is built-in; configure it instead of adding it.", + ) + if McpServer.get_by_name(body.name): + raise HTTPException( + status_code=409, detail=f"MCP server {body.name!r} already exists; remove it first." + ) + # url, auth shape and header secrecy come from the re-resolved registry + # entry, never the client. + pins = json.loads(request.app.state.settings.mcp_trusted_path.read_text()) + try: + entries = await registry.search_registry(body.registry) + except RegistryUnavailableError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + candidate = registry.resolve_candidates(entries, pins).get(body.registry) + if not candidate: + raise HTTPException( + status_code=404, + detail=f"Registry entry {body.registry!r} is not installable over HTTP.", + ) + declared = {spec["name"] for spec in candidate["headers"]} + unknown = sorted(set(body.headers) - declared) + if unknown: + raise HTTPException( + status_code=422, + detail=f"{body.registry!r} declares no header(s): {', '.join(unknown)}.", + ) + filled = {} + for header, value in body.headers.items(): + if stripped := value.strip(): + filled[header] = stripped + required = {spec["name"] for spec in candidate["headers"] if spec.get("isRequired")} + missing = sorted(required - set(filled)) + if missing: + raise HTTPException( + status_code=422, detail=f"Missing required header value(s): {', '.join(missing)}." + ) + secret = {spec["name"] for spec in candidate["headers"] if spec.get("isSecret")} + if secret: + # A secret declared header carries the auth itself — no bearer. + token_source = "" + is_enabled = True + else: + # OAuth: ships dark until its Connect lands. + token_source = TokenSource.OAUTH + is_enabled = False + try: + McpServer.create( + name=body.name, + url=candidate["url"], + token_source=token_source, + headers={h: v for h, v in filled.items() if h not in secret}, + secret_headers={h: v for h, v in filled.items() if h in secret}, + is_enabled=is_enabled, + ) + except InvalidServerNameError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + return _response(body.name) + + @router.patch("/{name}", response_model=McpServerResponse) async def set_mcp_server_enabled( name: str, is_enabled: bool = Body(embed=True) @@ -74,12 +159,16 @@ async def remove_mcp_server(name: str) -> None: if not server: raise HTTPException(status_code=404, detail=f"MCP server {name!r} not found") server.delete() + if grant := McpOauthGrant.get_by_server(name): + # An orphan grant would revive as this name's credential on re-add. + grant.delete() + await oauth.evict_access_token(name) @router.post("/{name}/connect", response_model=ConnectMcpServerResponse) async def connect_mcp_server(name: str, request: Request) -> ConnectMcpServerResponse: - definition = mcp_servers.get(name) - if not definition or definition["token_source"] != TokenSource.OAUTH: + server = McpServer.get_resolved().get(name) + if not server or server["token_source"] != TokenSource.OAUTH: raise HTTPException(status_code=404, detail=f"MCP server {name!r} is not an OAuth server.") endpoint = request.app.state.settings.endpoint if not endpoint: @@ -91,7 +180,7 @@ async def connect_mcp_server(name: str, request: Request) -> ConnectMcpServerRes "at, to connect OAuth MCP servers.", ) try: - authorization_url = await oauth.begin_connect(name, definition["url"], endpoint) + authorization_url = await oauth.begin_connect(name, server["url"], endpoint) except OauthConnectError as error: raise HTTPException(status_code=502, detail=str(error)) from error return ConnectMcpServerResponse(authorization_url=authorization_url) diff --git a/backend/druks/mcp/schemas.py b/backend/druks/mcp/schemas.py index 778f5aa6..fac34388 100644 --- a/backend/druks/mcp/schemas.py +++ b/backend/druks/mcp/schemas.py @@ -8,7 +8,7 @@ class McpServerResponse(BaseResponse): - # A pure projection of one ``McpServer.list_resolved()`` item — the dict's + # A pure projection of one ``McpServer.get_resolved()`` item — the dict's # ``token`` is not a field here, so the secret can't serialize (and it # arrives as a Secret, redacted even if it did). name: str @@ -29,12 +29,28 @@ class ConnectMcpServerResponse(BaseResponse): authorization_url: str +class McpRegistryCandidateResponse(BaseResponse): + name: str + registry_name: str + description: str + url: str + official: bool + # The remote's declared inputs, verbatim — the registry owns their shape. + headers: list[dict] + + class CreateMcpServerRequest(BaseModel): name: str url: str token: str = "" +class InstallMcpServerRequest(BaseModel): + name: str + registry: str + headers: dict[str, str] = {} + + # The catalog file is operator input, so its entries parse through a # discriminated union — each auth strategy carries exactly its own fields, and # an unknown key is rejected (a stray key in a catalog is a typo, not diff --git a/backend/tests/test_mcp_registry.py b/backend/tests/test_mcp_registry.py index e2310b7c..8972a4e1 100644 --- a/backend/tests/test_mcp_registry.py +++ b/backend/tests/test_mcp_registry.py @@ -2,12 +2,15 @@ import httpx import pytest +from conftest import configure_app_for_test, make_settings from druks.mcp import registry from druks.mcp.constants import REGISTRY_SEARCH_CACHE_PREFIX from druks.mcp.exceptions import RegistryUnavailableError +from druks.mcp.models import McpOauthGrant, McpServer from druks.mcp.registry import derive_server_name, resolve_candidates, search_registry from druks.redis import get_client from druks.settings import PACKAGED_MCP_TRUSTED +from fastapi.testclient import TestClient # Canned latest-version entries, shaped like the live /v0/servers?search=… # payload (verified against the registry 2026-07-13): grafana publishes a @@ -53,7 +56,7 @@ def _entry(name, *, description="aggregated wrapper", remotes=None): "description": description, "version": "1.0.0", "packages": [{"registryType": "pypi", "transport": {"type": "stdio"}}], - **({"remotes": remotes} if remotes is not None else {}), + **({"remotes": remotes} if remotes else {}), }, "_meta": {"io.modelcontextprotocol.registry/official": {"isLatest": True}}, } @@ -71,11 +74,10 @@ def test_publisher_pin_marks_grafana_official_with_the_registry_url(): # stays the live registry value. candidates = resolve_candidates([_GRAFANA], _PINS) - assert [c["name"] for c in candidates] == ["grafana"] - grafana = candidates[0] + grafana = candidates["io.github.grafana/mcp-grafana"] + assert grafana["name"] == "grafana" assert grafana["official"] is True assert grafana["url"] == "https://mcp.grafana.com/mcp" - assert grafana["registry_name"] == "io.github.grafana/mcp-grafana" assert grafana["description"].startswith("An MCP server") @@ -84,7 +86,7 @@ def test_url_pin_lifts_sentry_whose_registry_entry_has_no_remote(): # text still comes from the registry entry. candidates = resolve_candidates([_SENTRY], _PINS) - sentry = next(c for c in candidates if c["name"] == "sentry") + sentry = candidates["io.github.getsentry/sentry-mcp"] assert sentry["official"] is True assert sentry["url"] == "https://mcp.sentry.dev/mcp" assert "error monitoring" in sentry["description"] @@ -99,10 +101,8 @@ def test_url_pin_attaches_to_the_product_publisher_not_the_aggregator(): candidates = resolve_candidates(entries, _PINS) - sentries = [c for c in candidates if c["name"] == "sentry"] - assert len(sentries) == 1 - assert sentries[0]["registry_name"] == "io.github.getsentry/sentry-mcp" - assert "error monitoring" in sentries[0]["description"] + assert list(candidates) == ["io.github.getsentry/sentry-mcp"] + assert "error monitoring" in candidates["io.github.getsentry/sentry-mcp"]["description"] def test_domain_ownership_rule_needs_no_pin(): @@ -117,7 +117,7 @@ def test_domain_ownership_rule_needs_no_pin(): candidates = resolve_candidates(entries, {}) - assert candidates[0]["official"] is True + assert candidates["com.cloudflare/browser"]["official"] is True def test_unpinned_unmatched_remote_is_community(): @@ -130,14 +130,14 @@ def test_unpinned_unmatched_remote_is_community(): candidates = resolve_candidates(entries, _PINS) - assert [c["official"] for c in candidates] == [False] + assert candidates["io.github.someone/grafana-tools"]["official"] is False def test_stdio_only_entries_are_dropped(): # No streamable-http remote and no url pin — not installable, not shown. candidates = resolve_candidates([_entry("com.mcparmory/grafana")], _PINS) - assert candidates == [] + assert candidates == {} def test_official_candidates_sort_first(): @@ -148,7 +148,7 @@ def test_official_candidates_sort_first(): candidates = resolve_candidates([community, _GRAFANA], _PINS) - assert [c["name"] for c in candidates] == ["grafana", "aardvark"] + assert [c["name"] for c in candidates.values()] == ["grafana", "aardvark"] def test_packaged_pins_resolve_grafana_and_sentry(): @@ -158,11 +158,11 @@ def test_packaged_pins_resolve_grafana_and_sentry(): candidates = resolve_candidates([_GRAFANA, _SENTRY], pins) - assert [(c["name"], c["official"]) for c in candidates] == [ + assert [(c["name"], c["official"]) for c in candidates.values()] == [ ("grafana", True), ("sentry", True), ] - assert next(c for c in candidates if c["name"] == "sentry")["url"] == pins["sentry"] + assert candidates["io.github.getsentry/sentry-mcp"]["url"] == pins["sentry"] # --- declared inputs: the form spec --------------------------------------- @@ -173,7 +173,7 @@ def test_declared_header_inputs_pass_through_verbatim(): # response model owns their optionality, not the resolver. candidates = resolve_candidates([_GRAFANA], _PINS) - assert candidates[0]["headers"] == [_GRAFANA_HEADER] + assert candidates["io.github.grafana/mcp-grafana"]["headers"] == [_GRAFANA_HEADER] # --- the druks-side name --------------------------------------------------- @@ -254,7 +254,207 @@ async def test_search_registry_result_feeds_the_resolver(monkeypatch): candidates = resolve_candidates(await search_registry("observability"), _PINS) - assert [(c["name"], c["official"]) for c in candidates] == [ + assert [(c["name"], c["official"]) for c in candidates.values()] == [ ("grafana", True), ("sentry", True), ] + + +# --- the install API -------------------------------------------------------- + +_ACME_ENTRY = _entry( + "com.acme/observer", + description="Observability for agents", + remotes=[ + { + "type": "streamable-http", + "url": "https://mcp.acme.com/mcp", + "headers": [ + { + "name": "X-Api-Key", + "description": "Acme API key", + "isSecret": True, + "isRequired": True, + }, + {"name": "X-Region", "description": "Acme region"}, + ], + } + ], +) + + +def _client_with_registry(tmp_path, monkeypatch, *entries): + payload = {"servers": list(entries)} + monkeypatch.setattr( + registry, + "_http", + _client_returning(lambda _r: httpx.Response(200, json=payload)), + ) + app = configure_app_for_test(settings=make_settings(tmp_path, endpoint="http://druks.test")) + return TestClient(app) + + +def test_registry_search_route_projects_resolved_candidates(tmp_path, monkeypatch, db_session): + with _client_with_registry(tmp_path, monkeypatch, _GRAFANA, _SENTRY) as client: + response = client.get("/api/mcp-servers/registry", params={"query": "observability"}) + + assert response.status_code == 200 + grafana, sentry = response.json() + assert grafana["name"] == "grafana" + assert grafana["registryName"] == "io.github.grafana/mcp-grafana" + assert grafana["official"] is True + # Declared inputs ride verbatim — the registry owns their shape. + assert grafana["headers"] == [_GRAFANA_HEADER] + # The url-pinned sentry resolves with the pinned official url. + assert sentry["url"] == "https://mcp.sentry.dev/mcp" + + +def test_registry_search_route_maps_unavailability_to_502(tmp_path, monkeypatch, db_session): + monkeypatch.setattr( + registry, "_http", _client_returning(lambda _r: httpx.Response(503, text="down")) + ) + app = configure_app_for_test(settings=make_settings(tmp_path)) + with TestClient(app) as client: + response = client.get("/api/mcp-servers/registry", params={"query": "grafana"}) + + assert response.status_code == 502 + assert "registry search" in response.json()["detail"] + + +def test_add_from_registry_writes_the_row_and_redacts_the_secret(tmp_path, monkeypatch, db_session): + with _client_with_registry(tmp_path, monkeypatch, _ACME_ENTRY) as client: + created = client.post( + "/api/mcp-servers/registry", + json={ + "name": "observer", + "registry": "com.acme/observer", + "headers": {"X-Api-Key": "acme-api-secret", "X-Region": "eu"}, + }, + ) + + assert created.status_code == 200 + body = created.json() + # Header-auth'd: no bearer to miss, ready and enabled immediately. + assert body["tokenSource"] == "" + assert body["isEnabled"] is True + assert body["hasToken"] is True + assert "acme-api-secret" not in created.text + + listed = client.get("/api/mcp-servers") + assert "acme-api-secret" not in listed.text + + # The row: url from the registry (never the client), values split by the + # spec's secrecy — the plain one readable, the secret one ciphertext at + # rest and redacted in repr. + row = McpServer.get_by_name("observer") + assert row.url == "https://mcp.acme.com/mcp" + assert row.headers == {"X-Region": "eu"} + assert "acme-api-secret" not in repr(row.secret_headers) + assert row.secret_headers["X-Api-Key"] == "acme-api-secret" + + +def test_add_from_registry_oauth_candidate_ships_dark_and_connects( + tmp_path, monkeypatch, db_session +): + with _client_with_registry(tmp_path, monkeypatch, _GRAFANA) as client: + created = client.post( + "/api/mcp-servers/registry", + json={ + "name": "grafana", + "registry": "io.github.grafana/mcp-grafana", + "headers": {"X-Grafana-URL": "https://acme.grafana.net"}, + }, + ) + + assert created.status_code == 200 + body = created.json() + assert body["tokenSource"] == "oauth" + # Dark until its Connect lands — an enabled unconnected oauth server + # would fail every delivery. + assert body["isEnabled"] is False + assert body["hasToken"] is False + + # The added row connects through the existing flow, against the row's + # registry-supplied url. + begun = [] + + async def fake_begin_connect(name, server_url, endpoint): + begun.append((name, server_url, endpoint)) + return "https://consent.example/authorize" + + monkeypatch.setattr("druks.mcp.oauth.begin_connect", fake_begin_connect) + connect = client.post("/api/mcp-servers/grafana/connect") + + assert connect.status_code == 200 + assert connect.json()["authorizationUrl"] == "https://consent.example/authorize" + assert begun == [("grafana", "https://mcp.grafana.com/mcp", "http://druks.test")] + + row = McpServer.get_by_name("grafana") + assert row.headers == {"X-Grafana-URL": "https://acme.grafana.net"} + + +def test_add_from_registry_rejects_missing_required_and_unknown_headers( + tmp_path, monkeypatch, db_session +): + with _client_with_registry(tmp_path, monkeypatch, _ACME_ENTRY) as client: + # Required X-Api-Key blank → named 422; nothing persisted. + missing = client.post( + "/api/mcp-servers/registry", + json={ + "name": "observer", + "registry": "com.acme/observer", + "headers": {"X-Api-Key": " ", "X-Region": "eu"}, + }, + ) + assert missing.status_code == 422 + assert "X-Api-Key" in missing.json()["detail"] + + # An unknown header is a client bug even when its value is blank. + for bogus_value in ("v", ""): + unknown = client.post( + "/api/mcp-servers/registry", + json={ + "name": "observer", + "registry": "com.acme/observer", + "headers": {"X-Api-Key": "k", "X-Bogus": bogus_value}, + }, + ) + assert unknown.status_code == 422 + assert "X-Bogus" in unknown.json()["detail"] + + assert not McpServer.get_by_name("observer") + + +def test_add_from_registry_rejects_an_entry_without_an_http_remote( + tmp_path, monkeypatch, db_session +): + # stdio/oci-only and unpinned: resolvable in search, not installable. + with _client_with_registry(tmp_path, monkeypatch, _entry("com.mcparmory/grafana")) as client: + created = client.post( + "/api/mcp-servers/registry", + json={"name": "grafana", "registry": "com.mcparmory/grafana", "headers": {}}, + ) + + assert created.status_code == 404 + assert "not installable" in created.json()["detail"] + + +def test_removing_a_connected_row_drops_its_grant(tmp_path, monkeypatch, db_session): + with _client_with_registry(tmp_path, monkeypatch, _GRAFANA) as client: + client.post( + "/api/mcp-servers/registry", + json={"name": "grafana", "registry": "io.github.grafana/mcp-grafana", "headers": {}}, + ) + McpOauthGrant.store( + server_name="grafana", + refresh_token="rt", + token_endpoint="https://as.example/token", + resource="https://mcp.grafana.com/mcp", + client_id="cid", + ) + + assert client.delete("/api/mcp-servers/grafana").status_code == 204 + + # An orphan grant would revive as this name's credential on re-add. + assert not McpServer.get_by_name("grafana") + assert not McpOauthGrant.get_by_server("grafana") diff --git a/backend/tests/test_mcp_servers.py b/backend/tests/test_mcp_servers.py index 5b0f8067..a43806b1 100644 --- a/backend/tests/test_mcp_servers.py +++ b/backend/tests/test_mcp_servers.py @@ -336,7 +336,7 @@ async def test_bearerless_server_delivers_without_a_bearer(db_session): def test_bearerless_server_resolves_ready_with_its_headers(db_session): _grafana_shaped_server() - grafana = next(s for s in McpServer.list_resolved() if s["name"] == "grafana") + grafana = McpServer.get_resolved()["grafana"] assert grafana["token_source"] == "" assert grafana["headers"] == {"X-Grafana-URL": "https://acme.grafana.net"} # Nothing blocks delivery auth — the secret header is stored — so the @@ -483,7 +483,7 @@ def test_packaged_catalog_ships_linear_disabled(registry_state, db_session): load_mcp_catalog(PACKAGED_MCP_CATALOG) assert "github" not in mcp_servers - builtins = [s for s in McpServer.list_resolved() if s["builtin"]] + builtins = [s for s in McpServer.get_resolved().values() if s["builtin"]] assert [s["name"] for s in builtins] == ["linear"] linear = builtins[0] assert linear["url"] == "https://mcp.linear.app/mcp" @@ -590,7 +590,7 @@ def test_db_overlay_still_disables_a_catalog_entry(tmp_path, registry_state, db_ McpServer.create(name="figma_test", url="https://mcp.figma.test/", is_enabled=False) - resolved = next(s for s in McpServer.list_resolved() if s["name"] == "figma_test") + resolved = McpServer.get_resolved()["figma_test"] assert resolved["builtin"] is True assert "figma_test" not in {s["name"] for s in McpServer.list_enabled()} @@ -609,7 +609,7 @@ def test_catalog_enabled_false_ships_the_entry_dark(tmp_path, registry_state, db ) ) - resolved = {s["name"]: s for s in McpServer.list_resolved()} + resolved = McpServer.get_resolved() assert resolved["dark_test"]["is_enabled"] is False assert resolved["lit_test"]["is_enabled"] is True enabled_names = {s["name"] for s in McpServer.list_enabled()} diff --git a/backend/tests/test_secrets.py b/backend/tests/test_secrets.py index 68165db1..6559bcc2 100644 --- a/backend/tests/test_secrets.py +++ b/backend/tests/test_secrets.py @@ -49,7 +49,7 @@ def test_stored_secrets_are_ciphertext_and_reads_restore_them(db_engine, db_sess assert row.token.decrypt() == _TOKEN # The resolved view every consumer reads carries the Secret itself, so it # plaintext exists only where decrypt() is called. - resolved = next(s for s in McpServer.list_resolved() if s["name"] == "linear") + resolved = McpServer.get_resolved()["linear"] assert resolved["token"].decrypt() == _TOKEN assert resolved["has_token"] is True