Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 31 additions & 34 deletions backend/druks/mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,54 +40,51 @@ 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
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
Expand All @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions backend/druks/mcp/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
107 changes: 98 additions & 9 deletions backend/druks/mcp/routes.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
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,
)

router = APIRouter(prefix="/api/mcp-servers", tags=["mcp-servers"])


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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion backend/druks/mcp/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading