npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

paymo-mcp

v0.3.0

Published

Unofficial MCP server for the Paymo time-tracking API

Readme

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:

{
  "mcpServers": {
    "paymo": {
      "command": "npx",
      "args": ["-y", "paymo-mcp"],
      "env": {
        "PAYMO_API_KEY": "your-key-here"
      }
    }
  }
}

Claude Desktop (claude_desktop_config.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:

{
  "mcpServers": {
    "paymo": {
      "command": "npx",
      "args": ["-y", "paymo-mcp"],
      "env": {
        "PAYMO_API_KEY": "your-key-here"
      }
    }
  }
}

A single toolset — just identity and workflow statuses:

{
  "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:

{
  "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

npm test        # node --test, no framework
npm run build   # tsc to dist/

License

MIT — see LICENSE.