Skip to content

The MCP server

Hussein Jarrar edited this page Sep 12, 2026 · 2 revisions

Radd embeds an MCP (Model Context Protocol) server, so an agent can authenticate with an ordinary API token and drive the tracker directly. It grants an agent nothing more than its principal could already do over REST.

The endpoint

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

— server/src/radd/modules/mcp/router.py

The server sits under the API prefix and answers at POST /api/v1/mcp — Streamable HTTP, one JSON-RPC 2.0 message per request body. A notification channel sits at GET /api/v1/mcp (text/event-stream). Point an MCP client at it with Authorization: Bearer radd_pat_…:

@router.post("")
async def mcp_endpoint(request: Request, session: Session, user: OptionalUser) -> Response:
    if not settings.mcp_enabled:
        raise ForbiddenError("MCP server is disabled (RADD_MCP_ENABLED=false)")
    if user is None:
        raise UnauthorizedError(
            "MCP requires a personal access token: Authorization: Bearer radd_pat_…"
        )

— server/src/radd/modules/mcp/router.py

RADD_MCP_ENABLED defaults to true. A domain error raised inside a tool (NotFoundError, ForbiddenError, any RaddError, or a plain ValueError from bad tool arguments) never becomes a JSON-RPC protocol error. It comes back as a normal tool result instead, with isError: true and the message text. An agent can read what went wrong and try something else, rather than the connection failing outright.

Contributing a tool: McpToolSpec

A plugin registers a tool on its manifest as an McpToolSpec. The spec is the tool's authorization annotation — there is no separate place to declare what a tool needs:

@dataclass(frozen=True)
class McpToolSpec:
    """An MCP tool a plugin contributes...

    A registered tool inherits BOTH halves with no extra code: `visible_catalog`
    hides it from keys lacking `permission` (and enum-rewrites `project_param`,
    spec 114), and the MCP dispatcher REQUIRES the atom before the handler runs —
    so a plugin cannot accidentally expose an unfiltered tool. Disabling the
    plugin unregisters it (the spec-94 unmount path): it leaves the catalog and
    stops dispatching in the same breath."""

    name: str
    description: str
    input_schema: dict[str, Any]
    handler: Callable[..., Awaitable[Any]]
    permission: str = ""
    project_scoped: bool = False
    project_param: str = ""
    input_schema_builder: Callable[..., dict[str, Any]] | None = None

— server/src/radd/kernel/specs.py

The dispatcher enforces the declared atom before the handler runs, on the project named by project_param when the call carries one:

async def _call_registry_tool(
    session: AsyncSession, actor: User, spec: Any, arguments: Mapping[str, Any]
) -> Any:
    """A registered tool inherits ENFORCEMENT, not just catalog filtering
    (RADD-640): for a kernel-enforced spec the declared atom is required before
    the handler runs, so a plugin cannot accidentally expose an unfiltered tool."""
    if spec.kernel_enforced:
        project = None
        if spec.project_param and arguments.get(spec.project_param):
            project = await projects_service.get_by_key(
                session, str(arguments[spec.project_param])
            )
        if spec.permission:
            await authz.require(session, actor, cast(Permission, spec.permission), project=project)
    return await spec.handler(session, actor, arguments)

— server/src/radd/modules/mcp/tools.py

spec.kernel_enforced defaults to True, and every new tool should leave it there. list_milestones does not set it, so it gets this enforcement for free. The handful of tools migrated from before spec 114 set it False, because their handlers already call authz.require at the service layer they share with REST. A second blanket check on top would re-refuse the project-scoped keys RADD-672 fixed. The permission still drives catalog filtering either way.

The enforcement check is a live registry lookup. Disabling the owning plugin therefore removes the tool from both the catalog and dispatch in the same step, with no separate unregister call to forget.

The catalog is a function of the caller

tools/list is not a constant. The server computes it per request from what the authenticated principal — account and key scope together — may actually execute:

async def visible_catalog(
    session: AsyncSession, user: User | None, catalog: list[dict[str, Any]]
) -> list[dict[str, Any]]:
    """The subset of `catalog` this principal can actually execute."""
    ...
    for tool in catalog:
        requirement = requirement_for(tool["name"])
        if requirement is None:
            logger.warning("mcp tool %r has no requirement; hiding it", tool["name"])
            continue
        if requirement.permission is None:
            visible.append(tool)
            continue
        if not requirement.project_scoped:
            if authz.holds_base(global_permissions, requirement.permission):
                visible.append(tool)
            continue
        allowed = [
            keys_by_id[pid]
            for pid, permissions in per_project.items()
            if authz.holds_base(permissions, requirement.permission)
        ]
        if not allowed:
            continue
        allowed.sort()
        visible.append(
            _with_project_enum(tool, requirement.project_param, allowed)
            if requirement.project_param
            else tool
        )
    return visible

— server/src/radd/modules/mcp/requirements.py

A tool absent from the registry is a wiring bug, not a real omission. The server hides it and logs a warning, rather than showing it unfiltered. Every legitimate tool carries its own McpToolSpec, so an unannotated name has no business being callable at all.

For a project-scoped tool, the project parameter is rewritten to an enum of exactly the projects the caller may act in. An agent cannot even name a project it has no rights to:

def _with_project_enum(tool: dict[str, Any], param: str, keys: Sequence[str]) -> dict[str, Any]:
    """Narrow the project parameter to the keys the caller may act in.

    Above `mcp_project_enum_max` the enum costs more context than it saves, so it
    degrades to a plain string that says how many projects are in play."""
    schema = tool.get("inputSchema") or {}
    properties = schema.get("properties") or {}
    if param not in properties:
        return tool
    prop = dict(properties[param])
    if len(keys) <= settings.mcp_project_enum_max:
        prop["enum"] = list(keys)
        prop["description"] = f"{prop.get('description', '').rstrip()} One of: {', '.join(keys)}."
    else:
        prop["description"] = (
            f"{prop.get('description', '').rstrip()} "
            f"{len(keys)} projects are available to this key."
        )
    return {
        **tool,
        "inputSchema": {**schema, "properties": {**properties, param: prop}},
    }

— server/src/radd/modules/mcp/requirements.py

mcp_project_enum_max defaults to 25. Above that count the enum would spend more context than it saves. The parameter then degrades to a plain string property, with the project count folded into the description instead.

Hiding is presentation, not enforcement. Every tool still calls authz.require — in the handler's own service call for the pre-spec-114 builtins, or in the dispatcher itself for a kernel-enforced spec. A tool invoked by name without being listed fails with exactly the same domain error it always would. The catalog only decides what an agent is told exists.

The tool list, by area

The builtin catalog (server/src/radd/modules/mcp/catalog.py, CATALOG_ORDER), grouped by the permission family that gates it:

Area Tools Gated on
Search and read search_items, find_items, get_item, list_projects item.read (project-scoped where applicable)
Write create_item, update_item, comment_item, link_items, unlink_items item.create / item.update / comment.write
Workflow get_allowed_transitions, transition_item item.read / item.update
Time log_work, list_worklogs, update_worklog, delete_worklog worklog.write (catalog floor; see below)
Releases list_releases, create_release, sweep_release, set_item_release item.read / release.create / release.update / item.update
Pages get_page, search_pages page.read, and present only while the pages plugin is enabled
Administration list_users, list_service_accounts, create_service_account user.manage / global.manage / service_account.create

The four Time tools all set kernel_enforced=False. Their module docstring says why: "the spec's permission is the spec-114 catalog FLOOR, not the enforcement." worklog.write decides only whether the tool shows up in the catalog. The real per-call check is finer-grained. list_worklogs requires only item.read, through authz.require_anywhere, and narrows results to the caller's own time unless they also hold timesheet.view. update_worklog and delete_worklog call timelogging.service.authorize_mutation, the same row-level check the REST router uses. An agent can correct its own entry, but needs a stricter atom to touch someone else's.

Any plugin can add to this list. milestones/mcptool.py contributes list_milestones the same way. It shows up in the catalog next to the builtins the moment the plugin is enabled, with no catalog code of its own to write.

A complete worked example

list_milestones is the reference contribution — the smallest tool that exercises the whole mechanism, deliberately written with no authorization call in its own body:

"""The plugin-contributed MCP tool (RADD-640) — proof the registry seam works.

One declaration and the kernel does the rest: `visible_catalog` hides the tool
from keys without item.read (and enum-rewrites `project_key`, spec 114), the
dispatcher requires the atom on the named project BEFORE `_list` runs, and
disabling the plugin unregisters the tool from catalog + dispatch together.
This handler contains no authz call on purpose — that absence is the feature.
"""

async def _list(session: AsyncSession, actor: Any, args: Mapping[str, Any]) -> Any:
    from radd.modules.projects import service as projects_service

    key = str(args["project_key"]).upper()
    project = next(
        (p for p in await projects_service.list_projects(session) if p.key == key), None
    )
    if project is None:
        raise NotFoundError("project", key)
    rows = (
        (
            await session.execute(
                select(Milestone)
                .where(Milestone.project_id == project.id)
                .order_by(Milestone.due_on.asc().nulls_last(), Milestone.title.asc())
            )
        )
        .scalars()
        .all()
    )
    return {
        "milestones": [
            {
                "id": str(m.id),
                "title": m.title,
                "description": m.description,
                "due_on": m.due_on.isoformat() if m.due_on else None,
                "status": m.status,
            }
            for m in rows
        ],
        "count": len(rows),
    }


LIST_MILESTONES = McpToolSpec(
    name="list_milestones",
    description="Milestones in a project, ordered by due date.",
    input_schema={
        "type": "object",
        "properties": {
            "project_key": {"type": "string", "description": "Project key, e.g. TD."}
        },
        "required": ["project_key"],
        "additionalProperties": False,
    },
    handler=_list,
    permission="item.read",  # entity reads are member reads (kernel.entities.authz_read_atom)
    project_scoped=True,
    project_param="project_key",
)

— server/src/radd/modules/milestones/mcptool.py

Registered on the plugin manifest:

mcp_tools=(LIST_MILESTONES,),

— server/src/radd/modules/milestones/init.py

That is the whole contribution. permission="item.read" and project_scoped=True together mean two things. visible_catalog shows list_milestones only to a caller who holds item.read in at least one project, with project_key rewritten to an enum of exactly those projects. The dispatcher separately requires item.read on the named project before _list runs, whether or not the caller ever looked at the catalog first. Neither behavior is code the plugin wrote.

What is not on MCP yet

The tool surface is workflow-complete for the tracker loop: file, transition, comment, log time, release, sweep. It is not everything the REST API can do. Notably, wiki page writes are not exposed over MCP: PAGE_TOOLS is {get_page, search_pages}, reads only.

# The doc tools ride the pages plugin (spec 43), which may be absent or disabled.
PAGE_TOOLS = frozenset({McpTool.GET_PAGE, McpTool.SEARCH_PAGES})

— server/src/radd/modules/mcp/types.py

You must still use the REST API to create or edit a page. This is a known gap, not a design choice to defend — see RADD-1005.

TODO(verify): whether any tools beyond the page-write gap have a REST-only fallback worth documenting here. The CLAUDE.md working agreement asks that every REST fallback be filed as an MCP gap when taken. The tracker itself is therefore the current source of truth for what else is outstanding.


Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.

Clone this wiki locally