Skip to main content
Glama
README.md
# 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

A3.7/5.0

Scored across 12 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness3/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues