Timesheet Portal MCP server
by dragosh29
README.md
# Timesheet Portal MCP server
An [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT and other MCP clients read a Timesheet Portal account: timesheet (time and cost) reports, invoices, leave bookings and balances, projects or jobs, clients, users and cost centres. It supports both editions of the product, Standard (Project) and Recruitment, and it is read-only. It is built from Timesheet Portal's public API documentation and its two published Swagger 2.0 specs.
Once it's connected, an administrator can ask things like:
- "How many hours did the team log on the Acme account last week, and how much is that at the charge rate?"
- "Which timesheets for October are still only submitted, not approved?"
- "List this month's client invoices that are still in draft."
- "Who is off next week?"
- "Who has the most annual leave left this year?"
- "Which placements for Beta Logistics end before Christmas?" (Recruitment edition)
## Tools
| Tool | Edition | What it does | API calls |
|---|---|---|---|
| `list_timesheets` | both | Time recorded for a date range, summed per task and employee (Standard) or employee and job (Recruitment) for each period of the chosen time grouping, with status, client, project, task, approver, quantity, units, rate (Standard) and charge. Filters: statuses, client, cost centre, employee group, employee/project/task (Standard) or contractor/job (Recruitment), approval dates, modified after. One page per call. | `POST /reports/timesheets` |
| `list_invoices` | both | Invoices dated in a range: number, status, dates, client, project, task, worker, currency, net, tax and total, and optionally the line items. Client invoices by default; client credit notes, self-billing invoices or self-billing credit notes on request. | `POST /invoices` |
| `list_leave` | Standard | Leave bookings in a date range: employee, leave category, status, start and end dates, working days, units, request and review dates. Filters: statuses, employee, employee group, modified after. The comments only on request. | `POST /leavebookings/reports/bookings` |
| `list_leave_balances` | Standard | Every employee's leave year: allowance, carried over, approved booked leave, non-deductible leave, adjustments and end-of-year balance. | `GET /leavebookings/summary` |
| `list_projects` | Standard | Projects with client, category, dates, charge budget, manager, cost centre and purchase order. | `GET /projects` |
| `list_jobs` | Recruitment | Jobs (placements) with client, category, cost centre, dates, frequencies, owner, purchase order and assigned contractors (names and codes). | `GET /jobs` |
| `list_clients` | both | Clients with category, cost centre, currency, payment terms, invoicing settings and notes. | `GET /clients` |
| `list_users` | both | Users with name, job title, role, employment type, group, home cost centre and line manager. | `GET /users` |
| `list_cost_centres` | both | Cost centre codes and descriptions. | `GET /costcentres` |
`list_timesheets`, `list_invoices` and `list_leave` call endpoints that are POSTs in the API but only read: they take the report settings in the body and change nothing. They carry the MCP `readOnlyHint` annotation like the other tools.
Every tool except `list_cost_centres` takes `max_results` (100 by default; at most 1,000, or 500 for invoices) and says how many records it left out and how to narrow the query.
Report field names are documented in both specs, as an enum of 1,272 names shared by `ReportSettingsModel`, `LeaveReportSettings` and the other report settings, but without descriptions. The fields and groupings `list_timesheets` asks for are the ones in each edition's documented example request for `POST /reports/timesheets`, whose use in that report the vendor shows, minus the pay columns unless they are requested. `POST /leavebookings/reports/bookings` has no example request; `list_leave` asks for `EmployeeReference`, `EmployeeName`, `LeaveCategory`, `LeaveStatus`, `LeaveStartDate`, `LeaveEndDate`, `LeaveTotalWorkingDays`, `LeaveUnits`, `LeaveRequestDate` and `LeaveReviewedDate`, names from that enum that plainly say what they hold, and `LeaveComments` only with `include_contact_details`.
Not covered on purpose:
- **Writes.** The only write this prototype would have offered is approving or rejecting a timesheet, and neither spec documents an endpoint for that. Invoice approval, emailing and exporting (`/invoiceaction/*`), marking timesheets exported (`/timesheetaction/export`), every "Add or update multiple" endpoint and `DELETE /jobs` are left out, so there is no write flag.
- **Expense reports** (`POST /expenseentries`, `POST /expenseforms`): the request is a documented `ReportSettingsModel` (its field enum includes 66 `Expense…` names), but the only documented response is a 201 whose `ResponseModel.returnObject` is an untyped object, so there is no documented shape to read the report from. (The spec's `V2` expense definitions are not used by any documented path.)
- **The invoice reports** (`/reports/invoicereport`, `/reports/invoicereport2`): `list_invoices` already reads invoices from `POST /invoices`, whose records are typed (`InvoiceModel`), which makes it clear which values are amounts, pay or free text; the report endpoints return untyped rows of strings.
- **Custom reports** (`/reports/customreports`, `/reports/customreport`): their columns are defined by each account and can include bank or pay data that this server could not reliably withhold.
- **Files**: expense receipts and invoice PDFs are never downloaded.
- `GET /users/search` and `GET /contractors/search` (documented as GETs with a request body, which standard HTTP clients cannot send; `list_users` with `user_id` covers a single lookup), `GET /rates` (pay rates), `GET /thirdparties` (includes bank accounts), `GET /chargecodes` (its `id` is typed as an integer while its example is a code), `/reports/builtinreport`, `/reports/reportfields`, `/customfields`, `/logintoken`, and the email/password `POST /token` route.
## Setup
Requires Node 18 or later.
```bash
npm install
npm run build
```
You need API credentials for your Timesheet Portal account: log in as a user with full administrator rights, go to **Settings > Account > Account**, open the **API access** tab, select a user account to associate with the credentials (Timesheet Portal says it should have the System Administrator role) and click **Generate API credentials**. The server uses the OAuth2 client-credentials flow only.
**Claude Desktop:** add this to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"timesheetportal": {
"command": "node",
"args": ["/absolute/path/to/timesheetportal-mcp/dist/index.js"],
"env": {
"TSP_CLIENT_ID": "your-client-id",
"TSP_CLIENT_SECRET": "your-client-secret",
"TSP_EDITION": "standard"
}
}
}
}
```
**Claude Code:**
```bash
claude mcp add timesheetportal -e TSP_CLIENT_ID=your-client-id -e TSP_CLIENT_SECRET=your-client-secret -e TSP_EDITION=standard -- node /absolute/path/to/timesheetportal-mcp/dist/index.js
```
| Variable | Required | Meaning |
|---|---|---|
| `TSP_CLIENT_ID` | yes | The API client id. |
| `TSP_CLIENT_SECRET` | yes | The API client secret. |
| `TSP_EDITION` | no | `standard` (the Standard / Project edition, default) or `recruitment`. It decides which tools are registered and which report fields are requested. |
| `TSP_BASE_URL` | no | Defaults to `https://tenant.api.timesheetportal.com`, the host the documentation names for the token endpoint and the API. The spec's security definitions name `yourhost.api.timesheetportal.com` instead; if your account has its own API host, set it here. Also used by the tests. |
| `TSP_CALL_BUDGET_SECONDS` | no | How long one tool call may spend walking pages (default 25, from 1 to 50): no new page is started after it, and the answer says where to continue. With the retry waits of the page in flight (about 20 s at most), a call stays under the 60-second default request timeout of MCP clients. One test uses 1. |
| `TSP_REQUESTS_PER_MINUTE` | no | How many requests per minute this server may send, spread evenly (default 30, the documented per-account limit, so one request every 2 seconds). Lower it if other integrations use the same account. Values above 30 exceed the documented limit and are only useful against a mock; the tests use 6000. |
## Safety defaults
- Read-only. There are no write tools, whatever the environment says. Every tool carries `readOnlyHint: true` and `destructiveHint: false`. The only POSTs the server sends are `POST /oauth/token`, `POST /reports/timesheets`, `POST /invoices` and `POST /leavebookings/reports/bookings`, and the last three only read.
- Personal data is withheld unless a tool is called with `include_contact_details=true`: email addresses, mobile numbers, dates of birth, gender and title, home addresses, join and leave dates, the accounting code (which the spec says "is usually used to reference payroll numbers") and a limited-company contractor's company details (users); client addresses, billing name and email, tax and company numbers (clients, which also sends `include=address,billing` only then); the billing contact, address and pay budget (projects); the billing contact, IR35 status, pay currency and frequency, pay budget and assigned pay and charge rates (jobs, which also sends `Include=assignedrates` only then; `Include=assignedcontractorcodes` is always sent, for the contractor codes); the leave comments (`LeaveComments`, free text that can hold the reason for an absence, not even requested from the API otherwise); the pay columns of the timesheet report (`PayRate`, `TotalPay` in Standard; `TotalPay`, `NetMargin`, `GrossMargin`, `Deductions` in Recruitment), which are not even requested from the API otherwise; and the amounts of self-billing invoices, which are what a contractor is paid (every invoice returned for a self-billing `invoice_type` counts as self-billing, since the `invoiceSelfBilling` flag is optional in the spec). Names of users, approvers, managers and workers, and the leave category (such as sickness) of a leave booking, are returned by default.
- Never returned, even on request: bank details (the `bankName`, `bankAccountName`, `branchCode`, `bankAccountNo`, `iBAN`, `swiftCode` and `bankCountryCode` fields), the national insurance and tax number fields, the payroll data in `employmentDetails`, the HR data in `personalDetails` (next of kin, nationality, ethnic origin, marital status, passport number, home phone), custom field values (their content is defined by each account) and audit events. The `includedFields` parameter of `GET /users` (whose documented values include `bankDetails` and `personalDetails`) is never sent.
- Free text (notes, descriptions including cost centre descriptions, timesheet notes, names, job titles, purchase orders, invoice external references, rate names, error messages, and every value of a report row) is redacted by default: email addresses become `[email redacted]`, phone-number-like sequences `[phone redacted]` (the same heuristic as the other servers in this series: `+`/`00` international numbers, bracketed UK area codes and UK `0…` numbers of 9 to 11 digits), UK national insurance numbers `[NI number redacted]` and upper-case UK postcodes `[postcode redacted]`. Other digit strings that happen to look like these are redacted too; the raw text is available with `include_contact_details`. Card numbers (13 to 19 digits passing the Luhn check), IBANs, and sort codes or account numbers introduced by the words "sort code" or "account" are replaced with `[card number redacted]` or `[bank details redacted]` always, with or without `include_contact_details`. A bare 6- or 8-digit number cannot be told from a date or a reference and is left alone.
- The report columns returned are only the ones the server asked for: anything else the API sends in a row is dropped. Rows of values are matched to the requested field names by position, so a row with more or fewer values than fields requested is refused with an error rather than returned under the wrong names.
- Rate limits. The documentation's usage policy says, per account and in UTC windows: every request counts towards 30 requests per minute and 5,000 per day; "basic entity updating / creating" has 1,000 requests per hour, 2,000 per day, 500 record updates per hour and 3,000 per day; "Timesheet & Invoice Reports" have 24 requests per hour, 576 per day, 500 record reads per hour and 3,000 per day. A refused request gets `429 Too Many Requests` with a `Retry-After` header giving the seconds until the limit resets, and the response states which limit was reached. This server spaces its requests by `TSP_REQUESTS_PER_MINUTE` (2 seconds apart by default). A 429 is retried at most twice, for any method, waiting for `Retry-After` (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when absent). Each wait is capped at 10 seconds: if the API asks for longer (as it will for an hourly or daily limit), the call gives up at once and passes on how long to wait and the API's message.
- 502, 503 and 504 are retried the same way for `GET` only (the suite exercises 502 and 503). A POST (the three report POSTs and the token request) is never retried after a gateway error; since the reports only read, the error says it is safe to call the tool again, and that each call counts against the report limits.
- The usage policy says the API "is not intended to be a live data source" for dashboards that pull data on every refresh. The server's instructions tell the assistant about the limits and to answer with as few calls as possible; `list_timesheets`, `list_invoices` and `list_leave` fetch one request's worth of data per call and refuse ranges over 366 days before any request.
- Paged lists (`GET /projects`, `/clients`, `/users`, `/jobs`) use the documented zero-based `page` parameter. Only `/projects` documents its page size (250). The walk stops at an empty page, at a page shorter than the documented size, or at a page shorter than the one before it; a lookup by code fetches one page. A page that repeats the previous page's records (an API ignoring `page`) is reported as incomplete rather than looped over. Each call fetches at most `max_pages` pages (2 for projects, 4 otherwise), stops once `max_results` records are in hand (a page is fetched whole, so the rest of the last page is left out and counted), and says where to continue.
- Codes (client, project, job, user, cost centre, employee group, task) are checked before any request: 1 to 100 characters, no control characters, no surrounding spaces. Dates must be real `YYYY-MM-DD` dates; date-times are `YYYY-MM-DDTHH:MM[:SS]` without a zone.
- The token is fetched with the documented form body, cached, refreshed before it expires (a minute before the end of its lifetime, which the documentation gives as an hour, or after half of it when the lifetime is under two minutes; the suite tests the latter with a 2-second token), and fetched afresh once when a call answers 401. The client secret and the token are scrubbed from any text the API echoes back. Rejected credentials produce a message that names `TSP_CLIENT_ID` and `TSP_CLIENT_SECRET` and where to generate them; a 403 says the credentials need a System Administrator user; an unknown code passes on the API's own message.
- A 200 whose body is not JSON (a proxy or login page in the way), or is an object where the documented response is an array (a `ResponseModel` with `success: false`, say), is reported as an error naming `TSP_BASE_URL` and passing on the API's message, never as an empty list.
- Time. A paged walk starts no new page once `TSP_CALL_BUDGET_SECONDS` (25 s) has passed, and says where to continue. A call the MCP client cancels, including when it times out, stops: the server passes the MCP request's abort signal to its API requests and its waits (throttle spacing and `Retry-After`), so the request in flight is aborted and nothing more is sent for that call. A token request already under way is left to finish, because concurrent calls share it.
## Tests
```bash
npm test
```
The suite downloads both specs (`docs/standardJson` to `spec-standard.json`, `docs/recruitmentJson` to `spec-recruitment.json`) on the first run and then:
1. Validates every fixture record against the definitions in the specs with Ajv in JSON Schema draft-04 mode (Swagger 2.0), for each edition that has the endpoint: `ChargeCodeGroupModel` (253 projects, crossing the documented page size), `JobModel`, `ClientModel`, `EmployeeModel`, `CostCentreModel`, `InvoiceModel` with `InvoiceItemModel`, `LeaveSummaryModel`, and the report rows against the documented `POST /reports/timesheets` and `POST /leavebookings/reports/bookings` responses (arrays of arrays of strings), with every leave field name checked against the `LeaveReportSettings` enum. The definitions allow any extra key, so a separate walk fails on any key a definition does not declare. The two editions' definitions are asserted identical. The vendor's own examples write date-times without a zone (`2022-12-01T00:00:00`), which strict RFC 3339 rejects, so `date-time` is checked with the zone optional; the suite proves the published example request fails the strict format and passes this one (after leaving out the `null` filters it sends, which Swagger 2.0 cannot declare). It also checks that the mock's client id and secret are obviously fake (`tsp-test-…-not-real`), appear nowhere in the specs, and that none of the example tokens and ids in the vendor's documentation appears in the test files.
2. Starts a local mock of the API that implements `POST /oauth/token` (form-encoded or JSON, as documented), Bearer auth, the zero-based paging (250 per page for projects, 3 per page, its own choice, where no size is documented), the documented filters, the leave bookings report, and the documented error bodies: `{ "Message": … }` for the 400/404 examples, `ResponseModel` and an array of `ValidationErrorModel` where those are documented, and a 429 with `Retry-After` using the documented throttle message. The mock's list, report, token and error responses are validated against the response schemas; the token responses, which the documentation describes only by example (`access_token`, `token_type`, `expiry_time`; `error`, `error_description`), against schemas written from those examples. What the specs do not document is the mock's own choice and says so in its comments: the status of a rejected token request (401), the body of a 401 for a bad Bearer token, a 403 with `MissingAdministratorPermission` for a non-administrator, an empty array for an unknown user id.
3. Starts the built server and drives it over stdio with the official MCP client, first as the Standard edition, then as the Recruitment edition: 37 checks in all. They cover the tool lists and annotations of both editions, that `TSP_ALLOW_WRITES=true` adds nothing (Standard), and the server instructions about the limits; the token request's exact form body and the token's reuse; paging to each end condition (a page shorter than 250, a shorter page after a full one, an empty page after a single page, a page repeating the previous one) and continuing from `next_page`; every documented filter sent under its documented name and date format (`lastModified` as `yyyy-MM-ddTHH:mm:ss`, `ModifiedSince` as `yyyy-mm-dd HH:MM:SS`, `EndsAfter`/`StartsBefore` as `yyyy-MM-dd HH:mm`); the report, leave report and invoice request bodies validated against `ReportSettingsModel`, `LeaveReportSettings` and `InvoiceReportSettingsModel` of the edition, and equal to the documented example's fields minus pay; report rows with a header row, as objects or as Key/Value pairs (injected responses that are deliberately off-schema), and a row with one value too many or too few refused rather than mislabelled; `max_results` capping projects (100 of the first page of 250) and invoices (100 of 150) with the number left out, and its hard maximum; the self-billing amounts withheld for a self-billing request even when the record's optional `invoiceSelfBilling` flag is absent; redaction by default (emails, phone numbers, NI numbers and postcodes in users, clients, projects, jobs, timesheet notes and any other report column, invoice line items and rate names, purchase orders, invoice external references, cost centre descriptions and leave names; a bare 8-digit reference and a hyphenated purchase order left alone) and the opt-in (users with their dates and accounting code, clients, projects, jobs, timesheet notes and pay columns, leave comments, self-billing amounts); bank, NI, tax, payroll, HR, custom field and audit data never returned even on request; card, IBAN, sort code and account numbers in free text removed even on request; the 429 retry with `Retry-After` in seconds, fractional seconds and HTTP-date form, the 2 s fallback, giving up after three attempts and at once above the 10 s cap (11 s gives up, exactly 10 s is waited out); the token endpoint's own 429, waited out on a session's first request and, three times over, reported with its own message; a 429 on a report POST retried; a 502 retried for `GET` and never for a POST; three 503s reported without the gateway HTML; a non-JSON 200 reported as an error with the excerpt redacted before it is cut; a 200 carrying an object where an array is documented reported as an error for cost centres, leave balances, users and the three report POSTs; the token refreshed after a 401 and before expiry, a second 401 after the refresh ending the call (one refresh, one retry), three concurrent calls on a fresh session sharing one token request, the secret and the cached token scrubbed from echoed messages, the token request not retried after a 503, the 403 message; invalid codes, dates, statuses and ranges rejected before any request; the request spacing of `TSP_REQUESTS_PER_MINUTE`; a call the client times out on sending nothing more (the retry and the next page are never sent); a paged walk stopping at the `TSP_CALL_BUDGET_SECONDS` budget with where to continue; startup refusing missing credentials and bad settings; wrong credentials giving an actionable message; and, last, that every request of the whole suite carried a Bearer token the mock issued (or the documented token form body), hit a method and path documented in its edition's spec with only query parameter names that operation documents and body keys its body schema declares, never touched `/token`, a write endpoint or a file endpoint, and never put the secret in a query string.
The suite takes about 30 seconds (the Retry-After boundary check waits the full 10 seconds).
## Status
This is a working prototype. It has **not yet been run against the live API**, because it was built without a Timesheet Portal account. Everything below is taken from the published documentation and should be confirmed on a real account (the 30-day free trial may do, if it includes the API access tab):
- The API host: whether `tenant.api.timesheetportal.com` is literally the shared host (as the general guidance writes it) or a placeholder for a per-account host (the spec's security definitions write `yourhost.api.timesheetportal.com`).
- The token request end to end: that a form-encoded body is accepted (the guidance says JSON or form), that `expiry_time` comes back as documented (the server also reads `expires_in` and assumes an hour when neither is present), and the HTTP status of the documented `invalid_client` error (not documented; the server treats 400, 401 and 403 alike). Also the body and status the API answers for an expired or wrong Bearer token, and what a user without the System Administrator role gets (the mock answers 403 with `MissingAdministratorPermission`, an error code the spec lists).
- Paging: the page size of `/clients`, `/users` and `/jobs` (undocumented), that `page` is zero-based on `/projects` too (the request models say zero-based; `/projects` only says "page size is 250 records"), and what a page past the end returns (the mock answers an empty array).
- The timesheet report: that with `reportFormat` `ValuesArray` each row is an array of strings in the order of `reportFields` (the columns are mapped by position, and a row of any other length is refused as an error; a first row repeating the field names is dropped; rows as objects or Key/Value pairs are also read by name), that the fields and groupings of the documented examples work as sent, that `All` in `timesheetStatusFilters` includes every status, that `pageIndex` is zero-based, and the report's page size (undocumented; the tool asks the assistant to request the next page if more rows are expected).
- Which filters are exact-match codes: `employeeFilter`, `contractorFilter` and `chargeCodeGroupFilter` have no description in the spec, and whether `jobFilter` takes a job code.
- Date formats and zones: the parameters document several formats (above), and the `EndsAfter`/`StartsBefore` parameters of `GET /jobs` say `yyyy-MM-dd HH:mm` while its 400 description says `yyyy-MM-dd HH:mm:ss`. Dates are sent without a zone, as in the vendor's examples; which time zone the API reads them in is not documented.
- The leave bookings report (`POST /leavebookings/reports/bookings`): that each row is an array of strings in the order of `reportFields` (mapped by position as above), that the chosen field names are accepted and hold what their names say (the enum has no descriptions), that leaving out `statusFilters` means every status (the spec says "Set to null for no status filtering"; this server never sends a null), how `startDate` and `endDate` select bookings that span them, whether the report is paged (nothing is documented), and whether it counts against the report limits.
- `POST /invoices`: that `invoiceReportType` selects client or self-billing invoices and credit notes (its spec description reads "Deprecated - (only used by /invoices/ POST endpoint)"), that an empty `invoiceStatusFilters` returns every status as documented, that `costCentreCode` and `chargeCodeGroupCodes` filter as their names say, and whether the endpoint counts against the "Timesheet & Invoice Reports" limits.
- `GET /leavebookings/summary`: the array-of-arrays response shape (flattened here) and the `leaveYear` format.
- Which fields the live API fills: whether `GET /users` returns email, mobile and date of birth without `includedFields`, whether `GET /clients` needs `include=address,billing` for the address and billing fields, and whether `GET /jobs` returns the assigned contractors' names without an `Include` value (their codes are requested with `Include=assignedcontractorcodes`).
- What `GET /users?id=` expects (the docs say "a single user specified by the id"; the server sends the value given, tested with a user code) and what it answers for an unknown id (only a 200 is documented; the mock answers an empty array).
- The wording of the API's error and 429 messages and whether any echo request data; the server redacts contact details from them and scrubs its own secrets regardless.
- How the per-account limits interact with other integrations on the same account; `TSP_REQUESTS_PER_MINUTE` can be lowered to leave room.
## Going to production
This version runs locally over stdio with the account's own API credentials. For customers to connect from claude.ai or ChatGPT without handling credentials, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by Timesheet Portal, that maps each signed-in user to their own permissions rather than to one System Administrator's, and then a listing in the Claude and ChatGPT connector directories. Timesheet approval and rejection can follow once an endpoint for it is documented, and expense reports once their response shape is documented; both need testing on a real account.
## Licence
MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues