Serves a Model Context Protocol server from Leantime at
/mcp/itk, so AI agents can read and write Leantime data through a small, explicit set of
tools.
Clients connect to a URL — there is no local process to install and no API key copied onto each machine.
Why
/mcp/itk? Since Leantime 3.9.7, core reserves/mcpfor Leantime's own commercialMcpServerplugin — both servers registeringPOST /mcpwould let route order decide which one answers. It has to be a path under/mcp/rather than a sibling like/mcp-itk, because core'sRequestRateLimiteronly recognises/mcp,/mcp/*and/mcp?*as MCP traffic; a sibling path bypasses the rate limiter entirely and gets no budget at all.
Leantime's JSON-RPC API is reflective: leantime.rpc.{Domain}.{Service}.{method} reaches
any public method of any domain service, so one key can read every user record and delete
tickets. The abilities column on access tokens is stored but never enforced.
This plugin exposes only the tools listed on Mcp\LeantimeMcpServer, authenticated by
Databridge API keys whose grants are
enforced server-side. There is deliberately no generic "call any service" tool.
- Leantime 3.9.7 or newer, which ships the
laravel/mcppackage this plugin builds on. (Earlier versions shippedphp-mcp/laravel; plugin versions up to 0.1.x targeted that and do not work on 3.9.7+.) - The Databridge plugin, installed and enabled — it provides API keys and the grant model this plugin authenticates against.
-
Place this folder in
app/Plugins/LeantimeMcp(the folder name must match, as Leantime derives the namespace from it). -
Install and enable:
php bin/leantime plugin:install leantime/leantime-mcp php bin/leantime plugin:enable leantime/leantime-mcp php bin/leantime cache:clear
/mcp/itk is exempt from Leantime's session auth and is instead protected by Databridge's
ApiKeyAuth middleware — that middleware is the only thing standing in front of the
endpoint. The key is passed in the x-api-key header.
Grants live in <leantime>/config/databridge_auth.yaml:
users:
- name: claude-agent
key: "32+-random-chars" # openssl rand -base64 32
operations: [read, write] # read-only keys cannot call write tools
projects: [4, 7] # or `all`Opening a session requires the read grant. Tools that mutate data additionally require
write, and every tool checks the project against the key's grants — a key can never reach
a project it was not granted, regardless of the arguments it passes.
claude mcp add --transport http leantime https://leantime.example.dk/mcp/itk \
--header "x-api-key: <your-key>"Desktop's claude_desktop_config.json accepts stdio servers only — its schema is
{command, args?, env?} with no url field, so a remote server cannot be entered directly.
Adding one is silently skipped with "Some MCP servers could not be loaded".
Bridge to it with mcp-remote instead. Edit
~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or
%APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"leantime": {
"command": "npx",
"args": [
"-y", "mcp-remote", "https://leantime.example.dk/mcp/itk",
"--header", "x-api-key:your-key"
]
}
}
}Desktop reads this file once at startup, so quit it fully (Cmd+Q, not just closing the
window) and reopen. The key is stored in plaintext here; scope it to the projects and
operations it actually needs.
Any other MCP client that speaks Streamable HTTP connects like Claude Code, by URL. Clients
that only support stdio need the mcp-remote bridge above.
A development instance behind a private CA (for example a local Traefik cert) is rejected by
Node's own trust store even when the certificate is trusted by the OS, failing with
DEPTH_ZERO_SELF_SIGNED_CERT.
Point Node at the CA:
-
Claude Code — export it in your shell before launching; a
settings.jsonenvblock does not work, because that applies to spawned subprocesses rather than to Claude Code's own TLS connections:export NODE_EXTRA_CA_CERTS="/path/to/ca.crt"
-
Claude Desktop — add it to the bridge's
env, which does apply sincemcp-remoteis a spawned subprocess. Desktop is launched from the GUI and never reads your shell profile, so the shell export above has no effect on it:"env": { "NODE_EXTRA_CA_CERTS": "/path/to/ca.crt" }
Neither is needed against a deployment with a publicly trusted certificate.
Read tools are marked readOnlyHint so clients can treat writes with more care. Every tool is
scoped to the calling key's granted projects, and there is no delete tool and no generic
"call any service" tool — those are unreachable by construction, not by a warning.
| Tool | Grant | Description |
|---|---|---|
ping |
read | Connectivity check; touches no data |
whoami |
read | Reports the calling key's operations and projects |
list_projects |
read | Projects this key can access |
list_users |
read | People on those projects, with the username other tools take |
get_project_progress |
read | Percent complete and completion dates |
list_statuses |
read | A project's status labels and their types |
list_milestones |
read | Milestones with status and due date |
list_todos |
read | A person's todos, filterable by status and due date |
get_todo |
read | One todo in full |
list_comments |
read | A todo's discussion |
list_attachments |
read | Attachment metadata (never file contents) |
list_time_entries |
read | Logged time for a todo or project |
create_todo |
write | Create a todo and assign it |
update_todo |
write | Change selected fields; omitted fields are left alone |
set_todo_status |
write | Move a todo to NEW, INPROGRESS or DONE |
log_time |
write | Log hours against a todo |
add_comment |
write | Comment on a todo |
Tools that act on a person — list_todos, create_todo, log_time — identify them by
username (their email). Start with list_users to turn a name into that username; it also
reports each person's job title and projects, so an agent asked about "the designer" or
"whoever is on Deltag" can find them without being told an address.
list_users shows only people assigned to projects the key can access, and each person's
projects is filtered to that same grant — a narrowly scoped key cannot use it to enumerate
the whole organisation.
Status is exchanged as NEW/INPROGRESS/DONE rather than a number, because status ids are
configured per project and can be renamed or translated. Use list_statuses to show a person
the project's own wording.
Write tools read the todo back after writing, so the status they report is what was actually stored rather than what was requested.
log_time is safe to retry. Only one entry can exist per person, todo, date and kind, so a
call whose result never came back — a timeout, say — can be repeated without double-booking the
hours; it reports that the entry already exists instead. The other write tools are not
idempotent: create_todo and add_comment will produce a second row if retried, so read back
with list_todos or list_comments before repeating one.
No notifications are sent. Writes go through Databridge, which updates Leantime directly rather than through core's services. Team members get no email when an agent creates a todo, changes a status, logs time, or comments.
Layout:
Mcp/LeantimeMcpServer.php— the server: the tool list ($tools), its name, and the route path (ROUTE). Add a tool by adding its class here.routes.php— mounts the server withMcp::web()and applies Databridge'sApiKeyAuth.register.php— exempts the route from core'sAuthCheckso that middleware is the authenticator.Mcp/Tools/*.php— one class per tool, extendingLeantimeTool.
A tool declares its own name, description and input schema:
#[IsReadOnly]
class ListStatuses extends LeantimeTool
{
public function name(): string { return 'list_statuses'; }
public function description(): string { return 'Lists the statuses…'; }
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema->integer('projectId')->description('Project…')->required();
}
protected function run(array $arguments): array
{
$projectId = (int) $this->requireArg($arguments, 'projectId');
// …
}
}Implement run(), not handle(). LeantimeTool::handle() wraps it so a thrown exception
becomes a readable tool error instead of an HTTP 500 — laravel/mcp only catches
ValidationException and ItemNotFoundException itself, and our grant denials are plain
RuntimeExceptions. Use requireArg() for required arguments: the advertised schema marks
them required but v0.1.1 does not enforce that server-side.
ToolInputSchema in v0.1.1 has no array builder — declare list-valued arguments with
->raw('tags', ['type' => 'array', 'items' => [...]]).
Tools must resolve services lazily (inside the handler, via app()->make() or
constructor injection at request time). Plugins load in database order with no dependency
graph, so touching another plugin's classes during registration is not safe.
Run the grant self-check (exits non-zero on failure, so it works in CI):
php app/Plugins/LeantimeMcp/tests/grants_test.phpProbe the endpoint directly:
curl -s -X POST https://leantime.example.dk/mcp/itk \
-H "x-api-key: <your-key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}},"id":1}'tools/list and tools/call can then be sent the same way — each request carries the API key
and stands alone, so no session handshake is needed first.
The server raises laravel/mcp's default page size from 15 to 50, so all 16 tools arrive on
one page of tools/list — following the pagination cursor is optional for clients, and one
that ignored it would otherwise never see the 16th tool.