From 446ac90b65aad6c1d8c5979eefb3a19cc5e7b1f6 Mon Sep 17 00:00:00 2001 From: Paulo Date: Wed, 14 Oct 2026 20:41:48 +0200 Subject: [PATCH] Hide the board ticket tools when the tracker is not druks --- backend/druks/api/exceptions.py | 10 ++++ .../druks/contrib/software_factory/routes.py | 16 +++++- backend/druks/mcp/server.py | 51 ++++++++++++++++--- .../software_factory/test_board_pages.py | 8 +++ .../software_factory/test_board_routes.py | 8 +++ backend/tests/test_mcp_endpoint.py | 38 ++++++++++++-- docs/configuration.md | 3 +- docs/writing-an-app.md | 7 ++- 8 files changed, 125 insertions(+), 16 deletions(-) diff --git a/backend/druks/api/exceptions.py b/backend/druks/api/exceptions.py index adb4ba25b..fc24d7c2c 100644 --- a/backend/druks/api/exceptions.py +++ b/backend/druks/api/exceptions.py @@ -75,3 +75,13 @@ class SubjectBusy(AgentApiError): def __init__(self, run_id: str) -> None: super().__init__(f"The subject already has active run {run_id}.") + + +class ToolNotAvailable(AgentApiError): + # A route precondition raises it while its tool is off in this configuration. + # The MCP server lists the tool only while the precondition does not. + status_code = 404 + code = "TOOL_NOT_AVAILABLE" + + def __init__(self, reason: str) -> None: + super().__init__(reason) diff --git a/backend/druks/contrib/software_factory/routes.py b/backend/druks/contrib/software_factory/routes.py index da2e8524f..eac64132e 100644 --- a/backend/druks/contrib/software_factory/routes.py +++ b/backend/druks/contrib/software_factory/routes.py @@ -6,7 +6,7 @@ from druks.accounts.dependencies import current_account from druks.accounts.models import Account -from druks.api.exceptions import agent_error_responses +from druks.api.exceptions import ToolNotAvailable, agent_error_responses from druks.contrib.software_factory.app import SoftwareFactory from druks.contrib.software_factory.enums import Priority, Status from druks.contrib.software_factory.exceptions import ( @@ -369,7 +369,19 @@ async def request_review( ) -tickets_router = APIRouter(prefix="/tickets", tags=["tickets"]) +async def require_board() -> None: + settings = await SoftwareFactory.settings() + if settings.tracker == "druks": + return + raise ToolNotAvailable( + f"The ticket board is off: the tracker is {settings.tracker}. Board tools apply only " + "to the druks tracker. Use that tracker for the ticket." + ) + + +tickets_router = APIRouter( + prefix="/tickets", tags=["tickets"], dependencies=[Depends(require_board)] +) async def require_ticket(identifier: str) -> Ticket: diff --git a/backend/druks/mcp/server.py b/backend/druks/mcp/server.py index 94e6a0a6c..eb15360f4 100644 --- a/backend/druks/mcp/server.py +++ b/backend/druks/mcp/server.py @@ -1,6 +1,7 @@ import inspect from collections import defaultdict -from collections.abc import Generator +from collections.abc import Callable, Generator +from functools import partial import httpx2 from fastapi import FastAPI @@ -13,12 +14,13 @@ from fastmcp.server.middleware import AuthMiddleware, CallNext, Middleware, MiddlewareContext from fastmcp.server.providers.openapi import MCPType, OpenAPIProvider, OpenAPITool, RouteMap from fastmcp.tools import ToolResult -from fastmcp.utilities.authorization import AuthContext +from fastmcp.utilities.authorization import AuthCheck, AuthContext from fastmcp.utilities.openapi import HTTPRoute from mcp.types import CallToolRequestParams, ToolAnnotations from druks.accounts.exceptions import InvalidPatError from druks.accounts.models import PersonalAccessToken +from druks.api.exceptions import ToolNotAvailable from druks.apps.exceptions import AppBotError from druks.apps.loader import iter_apps from druks.apps.registry import bots @@ -183,7 +185,7 @@ def _namespace_app_operations(spec: dict, app_names: set[str]) -> None: operation["operationId"] = derived -def _install_app_namespacing(api: FastAPI) -> None: +def _install_app_namespacing(api: FastAPI, app_names: set[str]) -> None: # The tool name comes from the spec's operation id, so the namespace must # land on the document api.openapi() builds — not on FastAPI's cached, merged # route contexts, which later generation silently discards. Wrap the app's @@ -193,7 +195,6 @@ def _install_app_namespacing(api: FastAPI) -> None: # which already returns the cached schema when it is warm, so namespaced() # need not repeat that guard — and the derivation is idempotent, so running # it on a warm cache leaves the already-prefixed ids untouched. - app_names = {app.name for app in iter_apps()} # The identity gate holds a tools-limited token to these routes, found by # the endpoint a request matched. api.state.agent_tools = { @@ -211,7 +212,39 @@ def namespaced() -> dict: api.openapi = namespaced -def _annotate(route: HTTPRoute, component: object) -> None: +def _list_tool_preconditions(api: FastAPI, app_names: set[str]) -> dict[str, list[Callable]]: + # A coroutine dependency that takes no parameters can only check configuration + # and raise, so it is a precondition, not a request resource. The tool it guards + # is listed only while it holds. + return { + get_tool_name(route.operation_id, list(route.tags), app_names): [ + depends.dependency + for depends in route.dependencies + if inspect.iscoroutinefunction(depends.dependency) + and not inspect.signature(depends.dependency).parameters + ] + for route in iter_route_contexts(api.routes) + if isinstance(route.original_route, APIRoute) and _TOOL_TAGS & set(route.tags) + } + + +def _visible_while(preconditions: list[Callable]) -> AuthCheck: + async def is_visible(context: AuthContext) -> bool: + # The MCP layer binds no request session. + async with session_scope(): + try: + for precondition in preconditions: + await precondition() + except ToolNotAvailable: + return False + return True + + return is_visible + + +def _annotate( + tool_preconditions: dict[str, list[Callable]], route: HTTPRoute, component: object +) -> None: if isinstance(component, OpenAPITool): is_read = route.method == "GET" component.annotations = ToolAnnotations( @@ -219,6 +252,8 @@ def _annotate(route: HTTPRoute, component: object) -> None: destructive_hint=not is_read and route.extensions.get("x-destructive", True), idempotent_hint=route.extensions.get("x-idempotent", False), ) + if preconditions := tool_preconditions[route.operation_id]: + component.auth = _visible_while(preconditions) def _is_visible(context: AuthContext) -> bool: @@ -232,7 +267,9 @@ def _is_visible(context: AuthContext) -> bool: def create_mcp_app(api: FastAPI) -> StarletteWithLifespan: _validate_agent_tools(api) - _install_app_namespacing(api) + app_names = {app.name for app in iter_apps()} + _install_app_namespacing(api, app_names) + tool_preconditions = _list_tool_preconditions(api, app_names) # Built directly rather than via from_fastapi, which owns the transport: # raise_app_exceptions=False makes an app crash reach the tool as the # app's sanitized 500, so no masking is needed and the taxonomy travels. @@ -249,7 +286,7 @@ def create_mcp_app(api: FastAPI) -> StarletteWithLifespan: RouteMap(tags={"bot"}, mcp_type=MCPType.TOOL), RouteMap(mcp_type=MCPType.EXCLUDE), ], - mcp_component_fn=_annotate, + mcp_component_fn=partial(_annotate, tool_preconditions), ) server = FastMCP( name="druks", diff --git a/backend/tests/software_factory/test_board_pages.py b/backend/tests/software_factory/test_board_pages.py index 930420dc8..389cc2975 100644 --- a/backend/tests/software_factory/test_board_pages.py +++ b/backend/tests/software_factory/test_board_pages.py @@ -1,3 +1,5 @@ +import pytest +from druks.contrib.software_factory.app import SoftwareFactory from druks.contrib.software_factory.enums import Status from druks.contrib.software_factory.models import ProjectRepo, Ticket @@ -9,6 +11,12 @@ COLUMNS = [status.label for status in Status] +@pytest.fixture(autouse=True) +async def _druks_tracker(druks_db): + # The board exists only for the druks tracker. + await SoftwareFactory.override_setting("tracker", "druks") + + async def _open_repo(druks_client, *, project="Acme", repo="acme/druks"): created = await druks_client.post(_PROJECTS, json={"name": project}) assert created.status_code == 201 diff --git a/backend/tests/software_factory/test_board_routes.py b/backend/tests/software_factory/test_board_routes.py index 0ca57416a..487955185 100644 --- a/backend/tests/software_factory/test_board_routes.py +++ b/backend/tests/software_factory/test_board_routes.py @@ -1,10 +1,18 @@ +import pytest from druks.accounts.models import Account +from druks.contrib.software_factory.app import SoftwareFactory from druks.contrib.software_factory.enums import Status _TICKETS = "/api/software_factory/tickets" _PROJECTS = "/api/software_factory/projects" +@pytest.fixture(autouse=True) +async def _druks_tracker(druks_db): + # The board exists only for the druks tracker. + await SoftwareFactory.override_setting("tracker", "druks") + + def _published(monkeypatch): events = [] diff --git a/backend/tests/test_mcp_endpoint.py b/backend/tests/test_mcp_endpoint.py index 2f2103ec1..ceed8ab87 100644 --- a/backend/tests/test_mcp_endpoint.py +++ b/backend/tests/test_mcp_endpoint.py @@ -63,6 +63,13 @@ "get_agent_call", "get_usage", ] +_BOARD_TOOLS = { + "software_factory_create_ticket", + "software_factory_get_ticket", + "software_factory_update_ticket", + "software_factory_set_status", + "software_factory_add_comment", +} @pytest.fixture @@ -185,11 +192,6 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token, mode): "chat_require_tag", "software_factory_start", "software_factory_review", - "software_factory_create_ticket", - "software_factory_get_ticket", - "software_factory_update_ticket", - "software_factory_set_status", - "software_factory_add_comment", ] expected_annotations = { @@ -239,6 +241,7 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token, mode): async def test_issues_ticket_tools_read_and_comment_as_the_pat_account(app, account, pat_token): + await SoftwareFactory.override_setting("tracker", "druks") project = await Project.create(name="Acme") repo = await ProjectRepo.create(project_id=project.id, full_name="acme/widget") ticket = await Ticket.create( @@ -271,6 +274,7 @@ async def test_issues_ticket_tools_read_and_comment_as_the_pat_account(app, acco async def test_a_ticket_token_reaches_only_its_tools(druks_db, app, account): + await SoftwareFactory.override_setting("tracker", "druks") project = await Project.create(name="Acme") repo = await ProjectRepo.create(project_id=project.id, full_name="acme/widget") ticket = await Ticket.create(project_repo=repo, title="Add an endpoint") @@ -293,6 +297,30 @@ async def test_a_ticket_token_reaches_only_its_tools(druks_db, app, account): assert refused.is_error +async def test_board_ticket_tools_follow_the_tracker_setting(app, pat_token): + project = await Project.create(name="Acme") + repo = await ProjectRepo.create(project_id=project.id, full_name="acme/widget") + ticket = await Ticket.create(project_repo=repo, title="Add an endpoint") + + async with live(app), _client(app, pat_token) as client: + on_linear = {tool.name for tool in await client.list_tools()} + refused = await client.call_tool( + "software_factory_get_ticket", + {"identifier": ticket.identifier}, + raise_on_error=False, + ) + await SoftwareFactory.override_setting("tracker", "druks") + on_druks = {tool.name for tool in await client.list_tools()} + fetched = await client.call_tool( + "software_factory_get_ticket", {"identifier": ticket.identifier} + ) + + assert _BOARD_TOOLS.isdisjoint(on_linear) + assert refused.is_error + assert _BOARD_TOOLS.issubset(on_druks) + assert fetched.structured_content["title"] == "Add an endpoint" + + async def test_a_bot_key_calls_its_tool_for_the_person_in_its_conversation( druks_db, app, pat_token ): diff --git a/docs/configuration.md b/docs/configuration.md index 038af310f..9c5c8046d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -513,7 +513,8 @@ A ticket that enters Ready for Agent opens a build against the selected repository. If a scheduled, running, or parked run already exists for that ticket, Software Factory does not start another. -An agent reads and answers a board ticket through this appliance's own `/mcp`. A +An agent reads and answers a board ticket through this appliance's own `/mcp`. The +server lists the five ticket tools only while the tracker is **druks**. A build whose tracker is **druks** asks for that server. You paste nothing and connect nothing. Druks mints the token of the account the run belongs to, and it allows only the five ticket tools. A comment the agent writes then carries that diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index eafaa527e..3ef1108d2 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -1322,7 +1322,12 @@ supplies the description. A `GET` route is read-only. If a write is non-destructive, declare `x-destructive: false`. If a write is idempotent, declare `x-idempotent: true`. Safe defaults are destructive -and non-idempotent. Startup refuses a missing `operation_id` or docstring. +and non-idempotent. A tool that works only in some configuration guards its +router with a dependency: an `async def` with no parameters that reads the +setting or connection and raises `ToolNotAvailable` otherwise. +Druks runs it on each listing and call. The tool is listed only while it holds, +and a call that reaches the route gets the error. Startup refuses a missing +`operation_id` or docstring. Two spellings run through druks, and which one a segment wears says who owns it: