-
-
Notifications
You must be signed in to change notification settings - Fork 0
The MCP server
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.
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.
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.
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 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.
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.
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.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items