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
10 changes: 10 additions & 0 deletions backend/druks/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
16 changes: 14 additions & 2 deletions backend/druks/contrib/software_factory/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down
51 changes: 44 additions & 7 deletions backend/druks/mcp/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -211,14 +212,48 @@ 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(
read_only_hint=is_read,
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:
Expand All @@ -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.
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions backend/tests/software_factory/test_board_pages.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions backend/tests/software_factory/test_board_routes.py
Original file line number Diff line number Diff line change
@@ -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 = []

Expand Down
38 changes: 33 additions & 5 deletions backend/tests/test_mcp_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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
):
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading