paymo-mcp
# paymo-mcp
An unofficial Model Context Protocol (MCP) server for the **Paymo** time-tracking API. Read and
write Paymo tasks, workflow statuses and time entries from any MCP client with `npx`.
Not affiliated with Paymo — no warranty and no endorsement.
## Install
No install needed. Any MCP client can launch it with `npx -y paymo-mcp` into any project that has
a Paymo API key. Example `.mcp.json`:
```json
{
"mcpServers": {
"paymo": {
"command": "npx",
"args": ["-y", "paymo-mcp"],
"env": {
"PAYMO_API_KEY": "your-key-here"
}
}
}
}
```
Claude Desktop (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"paymo": {
"command": "npx",
"args": ["-y", "paymo-mcp"],
"env": {
"PAYMO_API_KEY": "your-key-here"
}
}
}
}
```
## Configuration
Environment variables read by `paymo-mcp`:
| Variable | Required | Default | Description |
|---|---|---|---|
| `PAYMO_API_KEY` | yes | — | Paymo API key used with HTTP Basic auth. |
| `PAYMO_BASE_URL` | no | `https://app.paymoapp.com/api` | API base URL. |
| `PAYMO_DEFAULT_PROJECT_ID` | no | — | Project used when a project id argument is omitted. |
| `PAYMO_STATUS_ALIASES` | no | discovered automatically | JSON object overriding status aliases, e.g. `{"wip":123,"done":456}`. Keys are slugified, values must be positive integers. |
| `PAYMO_TOOLSETS` | no | `default` | Comma-separated toolset names, plus the keywords `default` and `all`. Case-insensitive and whitespace-tolerant. |
| `PAYMO_TOOLS` | no | — | Comma-separated tool names, additive on top of the toolsets. Matched exactly as registered. |
| `PAYMO_DENIED_TOOLS_REGEX` | no | — | One JS regex, matched against tool names and subtracted from the resolved set last. |
| `PAYMO_PERMISSION_MODE` | no | `modify` | One of `readonly`, `modify`, `full` — see below. |
| `PAYMO_READ_ONLY` | no | — | **Deprecated.** Use `PAYMO_PERMISSION_MODE`. `true` maps to `readonly`, `false` to `modify`. |
An unusable value in any of these aborts startup with an error instead of falling back to a default:
a typo that silently granted write access would be worse than a server that refuses to boot. Setting
both `PAYMO_PERMISSION_MODE` and `PAYMO_READ_ONLY` also aborts — drop the deprecated one rather than
guessing which variable won.
### Toolsets
| Toolset | Covers | Tools | Schema cost (chars) |
|---|---|---|---|
| `context` | `paymo_me`, `paymo_list_workflow_statuses` — who am I, what statuses exist | 2 | 1,008 |
| `tasks` | tasks, tasklists (read), comments (read and write), subtasks | 9 | 6,796 |
| `time` | time entries: timer control, manual logging, deletion | 4 | 876 |
| `projects` | project CRUD, project templates, project statuses, tasklists (write) — milestones deferred | 9 | 5,794 |
| `clients` | client CRUD (no delete), client contacts, company — portal access included | 8 | 6,136 |
| `billing` | invoices, invoice payments, estimates, invoice templates — **read-only, deliberately** | 6 | 4,124 |
| `all` | every toolset above | 36 | 23,134 |
`default` = `context` + `tasks` + `time`, which is approximately the surface the package exposed
before toolsets existed — an existing user upgrading sees no change. Names are additive and
order-independent, so `PAYMO_TOOLSETS=default,billing` would mean defaults *plus* billing.
Schema cost is measured in **characters** of `inputSchema`, not tokens: no `count_tokens` key was
available, and a characters-per-token estimate would be guessing. The `projects` toolset references
`paymo_list_projects` and `paymo_list_tasklists` by name from `tasks`, so selecting `projects` alone
still gives you the lookups you need to create a task.
### How the tool set is resolved
1. `PAYMO_TOOLSETS` expands to a set of tool names (`default` → the default toolsets; `all` → every
toolset; names are additive).
2. `PAYMO_TOOLS` is unioned in.
3. `PAYMO_DENIED_TOOLS_REGEX` is subtracted.
4. `PAYMO_PERMISSION_MODE` filters last — never overridable by naming a tool explicitly.
The resolved set is fixed at startup and never changes mid-session. Changing any of these variables
requires restarting the MCP client; a fixed `tools` array is what keeps the prompt cache valid.
### Permission modes
| Mode | Registers |
|---|---|
| `readonly` | reads only |
| `modify` | reads + create/update; **all delete tools omitted** |
| `full` | everything, deletes included |
`modify` is the default: an MCP server handing a model an API key with full account access should not
delete by default. `paymo_delete_entry` is the only delete tool, and it exists because Paymo refuses
to set an `end_time` on an entry under a minute old — such a timer can only be closed by deleting it.
It therefore requires `PAYMO_PERMISSION_MODE=full`; under `modify`, `paymo_stop_timer`'s error names
that requirement instead of leaving you at a dead end.
### Examples
Defaults — read and write, no deletes:
```json
{
"mcpServers": {
"paymo": {
"command": "npx",
"args": ["-y", "paymo-mcp"],
"env": {
"PAYMO_API_KEY": "your-key-here"
}
}
}
}
```
A single toolset — just identity and workflow statuses:
```json
{
"mcpServers": {
"paymo": {
"command": "npx",
"args": ["-y", "paymo-mcp"],
"env": {
"PAYMO_API_KEY": "your-key-here",
"PAYMO_TOOLSETS": "context"
}
}
}
}
```
A single-tool allowlist — the context toolset plus exactly one write tool:
```json
{
"mcpServers": {
"paymo": {
"command": "npx",
"args": ["-y", "paymo-mcp"],
"env": {
"PAYMO_API_KEY": "your-key-here",
"PAYMO_TOOLSETS": "context",
"PAYMO_TOOLS": "paymo_create_task"
}
}
}
}
```
## Getting an API key
Your Paymo API key grants **full read and write access to your Paymo account**. Get it from your
Paymo account settings. Unless you need to create or mutate data, set `PAYMO_PERMISSION_MODE=readonly`
— an accident with a write tool is much harder to undo than a wrong read.
## Tools
**Read** — registered in every permission mode, subject to the selected toolsets.
| Tool | Args | Endpoint |
|---|---|---|
| `paymo_me` | `verbose?` | `GET /me` |
| `paymo_list_projects` | `active?: boolean` (default true), `limit?`, `offset?`, `verbose?` | `GET /projects` |
| `paymo_list_tasklists` | `project_id?: number`, `limit?`, `offset?`, `verbose?` | `GET /tasklists?where=project_id=…` |
| `paymo_list_tasks` | `project_id?: number`, `include_completed?: boolean`, `limit?`, `offset?`, `verbose?` | `GET /tasks?where=…` |
| `paymo_get_task` | `task: string` (id or code), `verbose?` | `GET /tasks/{id}` |
| `paymo_list_comments` | `task: string` (id or code), `limit?`, `offset?`, `verbose?` | `GET /comments?where=task_id=…` |
| `paymo_list_workflow_statuses` | `limit?`, `offset?`, `verbose?` | `GET /workflowstatuses` |
| `paymo_timer_status` | `verbose?` | `GET /entries?where=end_time=null and user_id={me}` |
| `paymo_get_project` | `project_id` (req), `verbose?` | `GET /projects/{id}` |
| `paymo_list_project_templates` | `limit?`, `offset?`, `verbose?` | `GET /projecttemplates` |
| `paymo_list_project_statuses` | `limit?`, `offset?`, `verbose?` | `GET /projectstatuses` |
| `paymo_list_clients` | `active?: boolean` (default true), `limit?`, `offset?`, `verbose?` | `GET /clients` |
| `paymo_get_client` | `client_id` (req), `verbose?` | `GET /clients/{id}` |
| `paymo_list_client_contacts` | `client_id?: number`, `limit?`, `offset?`, `verbose?` | `GET /clientcontacts?where=client_id=…` |
| `paymo_get_company` | `verbose?` | `GET /company` |
| `paymo_list_invoices` | `status?: draft/sent/viewed/paid/void`, `client_id?: number`, `limit?`, `offset?`, `verbose?` | `GET /invoices?where=…` |
| `paymo_get_invoice` | `invoice_id` (req), `verbose?` | `GET /invoices/{id}?include=invoiceitems` |
| `paymo_list_invoice_payments` | `invoice_id?: number`, `limit?`, `offset?`, `verbose?` | `GET /invoicepayments?where=invoice_id=…` |
| `paymo_list_estimates` | `status?: draft/sent/viewed/accepted/invoiced/void`, `client_id?: number`, `limit?`, `offset?`, `verbose?` | `GET /estimates?where=…` |
| `paymo_get_estimate` | `estimate_id` (req), `verbose?` | `GET /estimates/{id}?include=estimateitems` |
| `paymo_list_invoice_templates` | `limit?`, `offset?`, `verbose?` | `GET /invoicetemplates` |
**Write** — registered under `modify` and `full`.
| Tool | Args | Endpoint |
|---|---|---|
| `paymo_create_task` | `name` (req), `project_id?`, `tasklist_id?`, `description?`, `due_date?`, `priority?`, `users?: number[]` | `POST /tasks` |
| `paymo_update_task` | `task` (req), plus any of `name`, `description`, `due_date`, `priority`, `users`, `complete`, `status` | `PUT /tasks/{id}` |
| `paymo_add_comment` | `task` (req), `content` (req) | `POST /comments` |
| `paymo_update_comment` | `comment_id` (req), `content` (req) | `PUT /comments/{id}` |
| `paymo_start_timer` | `task` (req) | `POST /entries` |
| `paymo_stop_timer` | — | `PUT /entries/{id}` |
| `paymo_create_project` | `name` (req), `code?`, `client_id?`, `template_id?`, `description?`, `budget_hours?`, `billable?` | `POST /projects` |
| `paymo_update_project` | `project_id` (req), plus any of `name`, `code`, `client_id`, `description`, `budget_hours`, `billable`, `active`, `status_id` | `PUT /projects/{id}` |
| `paymo_create_tasklist` | `project_id` (req), `name` (req), `seq?`, `milestone_id?` | `POST /tasklists` |
| `paymo_update_tasklist` | `tasklist_id` (req), plus any of `name`, `seq`, `milestone_id` | `PUT /tasklists/{id}` |
| `paymo_create_client` | `name` (req), `email?`, `phone?`, `fax?`, `website?`, `address?`, `city?`, `state?`, `postal_code?`, `country?`, `fiscal_information?` | `POST /clients` |
| `paymo_update_client` | `client_id` (req), plus any of the create fields or `active` | `PUT /clients/{id}` |
| `paymo_create_client_contact` | `client_id` (req), `name` (req), `email?`, `mobile?`, `phone?`, `fax?`, `skype?`, `position?`, `is_main?`, `access?`, `password?`, `notes?` | `POST /clientcontacts` |
| `paymo_update_client_contact` | `contact_id` (req), plus any of `name`, `email`, `mobile`, `phone`, `fax`, `skype`, `position`, `is_main`, `access`, `password`, `notes` | `PUT /clientcontacts/{id}` |
**Delete** — registered under `full` only.
| Tool | Args | Endpoint |
|---|---|---|
| `paymo_delete_entry` | `entry_id` (req) | `DELETE /entries/{id}` |
A timer that ran for under a minute cannot be stopped: Paymo rejects an `end_time` that would make
the entry shorter than that. Delete it with `paymo_delete_entry` instead — that is the documented
way out, and the only reason this package ships a delete tool at all. Because `full` is not the
default, the stop error names that requirement when the tool is not registered.
### Response size
Paymo answers with 20–40 fields per record; a project listing comes back several times larger than
the whole tool catalogue. The list and detail tools therefore return a **documented field subset** —
an agent needs `id`, `name`, `code`, not `flat_billing` or `cover_file_id`. List tools take
`limit` (**default 50**) and `offset` for paging, and return a **narrower summary subset** than
detail reads: a
listing exists to pick a record, so it carries `id`/`name`/`code` and the couple of fields needed to
choose, while `paymo_get_task` and `paymo_me` keep the fuller set. Pass `verbose: true` to any read
tool when you genuinely need every field Paymo provides — it bypasses both subsets.
### Rate limiting
Paymo answers a 429 with `X-Ratelimit-Decay-Period`, and a request that hits one is retried once
after waiting exactly that long (capped at 60s) before the error surfaces. Bursts still hurt: a
single tool call can cost two or three requests, since resolving a task code and the current user
are separate lookups.
Where a `task` is expected, pass either a numeric **id** or a human **task code** (e.g. `ABC-1`).
Where a `status` is expected, pass a numeric id or a slug alias such as `in_progress`; aliases are
discovered from your workflow, overridable with `PAYMO_STATUS_ALIASES`.
### Projects
There is no project or tasklist delete tool on purpose: `DELETE /projects/{id}` destroys the
project's tasks *and their time entries* — cascading, unrecoverable loss of billing data. Archive
instead with `paymo_update_project` and `active: false`; that is why the tool accepts it. Milestones
are not covered yet: `paymo_update_tasklist` accepts a `milestone_id` created in the Paymo UI.
Creating a project from a `template_id` may copy the template's tasklists and tasks into the new
project, and the created project is what comes back.
The project and tasklist **writes are unverified live**: the account this package was developed
against uses an Employee key, and Paymo answers `POST /projects` with a 403. The reads are verified
against a real account; the writes are covered by stub-client unit tests and await a human-run
verification pass with an administrator key. `template_id` is doubly unverified — that account has
no project templates.
### Clients
There is no client or client-contact delete tool on purpose: `DELETE /clients/{id}` destroys every
project, task and time entry attached to the client — cascading, unrecoverable loss of billing data.
Archive instead with `paymo_update_client` and `active: false`, the same `active`-boolean pattern
`projects` uses. To reach a person at a client, list contacts with `paymo_list_client_contacts`; to
know your own billing identity and invoice defaults, read `paymo_get_company`.
Granting a contact access to the client portal is part of updating or creating the contact: send
`access: true` together with a `password`. `paymo_update_client_contact` with `access: false`
revokes it.
The client, client-contact, and company writes are **unverified live**: the package's example key is
an Employee, and Paymo answers admin-level creates (`POST /clients`, `POST /clientcontacts`) with a
403. The reads are verified against a real account; the writes are covered by stub-client unit tests
and are slated for the human-run admin verification pass once an administrator key is available.
### Billing
The billing toolset is **read-only, deliberately** — it is the one toolset that ships no write tool
on purpose, and this is a decision, not an oversight. An invoice is a client-facing financial
document: a wrong write lands as a draft in an accounting export, a payment against the wrong
invoice, or a document sent to a client, and none of that is recoverable the way a wrong task status
is. Automated invoicing was never a requirement; reading state to answer "has this been paid" is.
If invoicing writes are ever needed, that is a separate, separately-argued addition built after the
admin verification pass.
The `billing` reads (invoices, estimates, invoice templates) return 403 on the account this package
was developed against — `{"message":"Listing invoices denied"}`. That is the **account role**, not
the plan: the same account reports a six-figure invoice allowance, and an Employee simply cannot read
company invoices. An Administrator key reads them. So those reads are **unverified live** and covered
by stub-client unit tests instead; `paymo_list_invoice_payments` is readable and
`paymo_get_estimate` reaches Paymo's estimate lookup. If these 403 for you, check the role of the
key before suspecting the request.
### Comments
A comment is a resource of its own, not a field of the task, so the thread is read with
`paymo_list_comments` (filtered by task, accepting the same numeric id or human code as every other
task argument) rather than through `paymo_get_task`. The listing is returned **oldest first**: a
conversation reads in the order it was written, and Paymo documents no ordering on `/comments`, so
the server imposes it by `created_on`, breaking ties by id.
There is no comment delete tool, for the same reason the other toolsets ship none. `PUT /comments`
replaces the body wholesale, so `paymo_update_comment` is the repair path for a comment posted with
the wrong markup — pass the `comment_id` from the listing and the full new body, not a patch.
Both tools are **verified live**. `paymo_list_comments` was read against a real task and
`paymo_update_comment` rewrote a real comment: the response came back with `edited_on` set to the
edit time and `created_on` unchanged, which also confirms `PUT /comments/{id}` takes the same
form-encoded body as `POST /comments` rather than JSON.
`scripts/field-diff.mjs` has been run for the `comment` record kind. It dropped exactly one field
beyond the documented set, `options` — a notification envelope (`{"notification_to": []}`) that no
reader and no write tool consumes. `edited_on` was the other field it surfaced, and it is now part of
the subset: with an update tool in the toolset, it is the only thing separating a comment as written
from one rewritten afterwards.
### Rich text
Task descriptions and comments accept HTML. Paymo keeps `p`, `b`, `i`, `u`, `s`, `ul`, `ol`, `li`,
`h2`, `blockquote`, `a`, `br`, `code`, `pre` and `span` (including inline `style`), adds
`rel="noopener noreferrer"` to links, and strips everything else — `<script>` included. Markdown is
**not** rendered: `**bold**` stays literal and backticks come back HTML-escaped, so use tags.
Note that a WAF sits in front of the Paymo API and can reject a request whose body merely *looks*
dangerous. Posting a comment containing `<script>` returns a 403 HTML page rather than a Paymo
error, even though Paymo itself would have stripped the tag.
## Development
```sh
npm test # node --test, no framework
npm run build # tsc to dist/
```
## License
MIT — see [LICENSE](LICENSE).TDQS
Scored across 12 tools
Each tool targets a unique resource/action combination: tasks, tasklists, projects, statuses, comments, and timers are all clearly separated. No two tools appear to do the same thing, so an agent can reliably select the right one.
Most tools follow the paymo_verb_noun pattern (get_task, create_task, update_task, start_timer). Minor deviations like paymo_me and paymo_timer_status are still understandable and do not cause confusion, but they break the predominant pattern slightly.
Twelve tools is appropriate for a project management server covering tasks, projects, workflows, comments, and time tracking. Each tool has a clear purpose and the set is not bloated.
The core task lifecycle (create, get, list, update) is covered, along with useful supporting features like comments and timers. However, task deletion is missing, and the stop_timer description references a non-existent paymo_delete_entry tool, indicating an incomplete surface.