Skip to main content
Glama
dragosh29

Edays MCP server

by dragosh29
README.md
# Edays MCP server

An [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT and other MCP clients work with an Edays absence management system: employees, absences, absence types, entitlement balances, rotas, public holiday and custom day patterns and groups, and (when enabled) booking, updating and deleting absences. It is built from the public API V2 documentation at developer.e-days.co.uk. Edays publishes no OpenAPI document for API V2, only JSON examples, so the tests validate against schemas written from those examples and check the schemas against the examples themselves.

Once it's connected, an HR or line manager on the system can ask things like:

- "Who is off next week, and is anything still waiting for approval?"
- "How much holiday does Dana Barrett have left this year, and how many sick days has she had in the last three months?"
- "Which team is Willie in, and who approves his leave?"
- "What rota is Priya on, and which public holiday pattern applies to her?"
- With writes enabled: "Book Dana two days' holiday on 13 and 14 October." / "Approve Willie's pending holiday request."

## Tools

| Tool | What it does | API calls |
|---|---|---|
| `list_users` | Every user on the system, filtered locally by part of the name or partner ID; leavers skipped unless asked. The endpoint is not marked as paged in the documentation; the paging headers are read anyway and further pages fetched if the system does page it, with a `paging` note saying so. Returns `partner_id` (the key the other tools take) and `edays_id` (the GUID absences use). | `GET /api/v2/users` (and `?page=n` if it pages) |
| `get_user` | One user by partner ID with their groups and their authorisation hierarchy (step one and two authorisers and alternates). | `GET /api/v2/users/{id}/`, `/groups`, `/authorisation` |
| `list_absences` | Absence records system-wide or for one user, with status, start and end, duration and absence type ID, in whole API pages up to `max_results`, continued with `page`. Filters `date_from`, `date_to`, `record_type`, `absence_type_id`, and on the system-wide endpoint `user_id`, `group_id`, `created_since`, `modified_since`, sent as the documented query parameters. | `GET /api/v2/absences`, or `GET /api/v2/users/{id}/absences` with `partner_user_id` |
| `get_absence` | One absence record by GUID. | `GET /api/v2/absences/{id}` |
| `list_absence_types` | Absence types with record type (planned, unplanned) and booking and calendar flags; the API returns Custom Day Groups (5) and Public Holiday Groups (6) from the same endpoint. | `GET /api/v2/absencetypes` |
| `get_user_entitlements` | A user's deducting balances (annual entitlement, transfers, pending, booked, taken, untaken, remaining, per booking period and element), summing balances (year to date, last 6 and 3 months, last 30 days) and entitlement pots. | `GET /api/v2/users/{id}/entitlements/deducting`, `/summing`, `/pots` |
| `get_user_rota` | A user's rota assignments with start dates, and their public holiday and custom day patterns, named from the system's lists. | `GET /api/v2/users/{id}/rotas`, `/publicholidays`, `/customdays`, `GET /api/v2/lists/rotas`, `/publicholidays`, `/customdays` |
| `list_public_holidays` | The public holiday patterns and custom day patterns held in the system (id and name; API V2 does not expose the dates inside a pattern). | `GET /api/v2/lists/publicholidays`, `/customdays` |
| `list_groups` | Group types (Country, Location, Team...) and the groups in each, or one type. | `GET /api/v2/grouptypes`, `GET /api/v2/grouptypes/{type}/groups` |
| `book_absence` | Creates an absence for a user GUID with the documented body (`UserId`, `AbsenceTypeId`, `Details`, `Status`, `StartTime`, `EndTime`, `IsOpen`). Writes only. | `POST /api/v2/absences` |
| `update_absence` | Fetches an absence and PUTs the documented body with your changes merged in: type, status (the only documented way to approve, reject or cancel through API V2), start, end, open flag, details. Writes only, marked destructive. | `GET /api/v2/absences/{id}`, `PUT /api/v2/absences/{id}`, then `GET` again when the PUT returns no body |
| `cancel_absence` | Deletes an absence record with the documented DELETE. Writes only, marked destructive. | `DELETE /api/v2/absences/{id}` |

There are no `approve_absence` or `reject_absence` tools because API V2 documents no such endpoints; the "Managing Absences" section says the status of an existing record is changed with `PUT /api/v2/absences/{id}`, which is what `update_absence` does with `status: "Approved"` or `"Rejected"`.

Not covered on purpose: creating, editing, patching and deleting users, marking leavers and reinstating, user settings and email notifications, roles and bulk roles, rota, public holiday and custom day assignment, entitlement adjustments, authorisation hierarchies (writes), user templates, group and group type writes, bulk user-group membership, global entitlement configuration and user balances, SSO certificates and IdP configuration, and the remaining lookup lists.

## Setup

Requires Node 18 or later.

```bash
npm install
npm run build
```

You need API client credentials for your Edays system. As the Authentication section documents: create a dedicated user in Edays, on its Roles tab select the account type **Api Client**, and generate a Client ID and Client Secret there (the secret is shown once; regenerating it replaces the old one). The server exchanges them for a Bearer access token at `https://YOUR-SYSTEM.e-days.co.uk/token` (OAuth 2.0 client credentials, form-encoded), which the documentation says is valid for one hour.

`YOUR-SYSTEM` is the subdomain of the address you sign in at.

**Claude Desktop:** add this to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "edays": {
      "command": "node",
      "args": ["/absolute/path/to/edays-mcp/dist/index.js"],
      "env": { "EDAYS_SYSTEM": "your-system", "EDAYS_CLIENT_ID": "your-client-id", "EDAYS_CLIENT_SECRET": "your-client-secret" }
    }
  }
}
```

**Claude Code:**

```bash
claude mcp add edays -e EDAYS_SYSTEM=your-system -e EDAYS_CLIENT_ID=your-client-id -e EDAYS_CLIENT_SECRET=your-client-secret -- node /absolute/path/to/edays-mcp/dist/index.js
```

| Variable | Required | Meaning |
|---|---|---|
| `EDAYS_SYSTEM` | yes, unless `EDAYS_BASE_URL` is set | The subdomain of your Edays system: `acme` for `https://acme.e-days.co.uk`. Letters, digits and hyphens only; anything else (such as the full host name) stops the server at start-up, whether or not `EDAYS_BASE_URL` is set. |
| `EDAYS_CLIENT_ID` | yes | The Api Client user's Client ID, sent in the token request body. |
| `EDAYS_CLIENT_SECRET` | yes | The Api Client user's Client Secret, sent in the token request body. Never logged or included in an error message. |
| `EDAYS_ALLOW_WRITES` | no | `true` to register `book_absence`, `update_absence` and `cancel_absence`. Off by default. |
| `EDAYS_BASE_URL` | no | Overrides the system URL, e.g. `https://acme.e-days.co.uk`. Used by the tests. |

## Safety defaults

- Read-only unless `EDAYS_ALLOW_WRITES=true`. Read tools carry the MCP `readOnlyHint` annotation; `update_absence` and `cancel_absence` are marked destructive (a PUT replaces the whole record, and DELETE removes it); `book_absence` is not.
- Employee records are third-party personal and HR data. By default a user's `Email`, `Login`, `HomeEmail`, `HomePhone`, `WorkPhone`, `WorkPhoneExt`, `HomeAddress`, `NextOfKin`, `NextOfKinContactDetails`, `Dob`, `PayrollNumber`, `EmployeeNumber`, `SsoUserId`, `ClientProvidedId` and `AnnualPay` are not returned, an absence's `PayrollNumber` and `EmployeeNumber` are not returned, and an entitlement's `Login` is not returned. In every other free-text field (names, job titles, absence type, entitlement, rota, pattern, group and group type names, and Edays' own error messages, including the excerpt of a non-JSON body) email addresses are replaced with `[email redacted]`, phone-number-like sequences with `[phone redacted]` and UK postcodes with `[postcode redacted]`. Dates inside free text are not redacted (a date in a rota or group name is far more likely to be a schedule than a date of birth; the `Dob` field itself is withheld). `include_contact_details=true` returns all of it as stored. Names and partner IDs are always returned as stored, including the authoriser partner IDs on `get_user`: the partner ID is the key every user endpoint is addressed by, so on a system whose partner IDs are login email addresses those addresses appear by default. The phone match is a heuristic: it covers international numbers written with `+` or `00` (including the `+44 (0)7700 …` form), UK numbers with a bracketed area code such as `(020) 7946 0958`, and UK-style `0…` numbers of 9 to 11 digits with spaces, dots or hyphens between groups; other digit strings that happen to start with `0` are redacted too, while GUIDs, numeric IDs and timestamps are left alone. The postcode match is upper case only (`CF64 3DH`, `SW1A 1AA`, `EC1A1BB`), so another code written in that shape would be redacted too. Bank and payment details do not exist in API V2.
- The access token is held in memory only, refreshed a minute before its documented one-hour expiry, and fetched afresh once when a call answers 401; it never appears in logs or error messages, and neither does the client secret. Only a JSON message from the token endpoint is ever passed on, never its raw body: a 200 without an `access_token` where the server looks is reported with the body's top-level key names (a bare value could be the token under another key), and a 5xx from `POST /token` is retried like a GET (fetching a token is idempotent) and reported without the gateway's HTML.
- `list_absences` returns whole API pages, never part of one. The page size sent is `min(100, max_results)`, and a call stops before a page that could take it past `max_results` (allowing for a shorter last page when the total says fewer remain), so `count` can be below `max_results`. The result carries `page_size`, `next_page` and a `note` saying to call again with that page **and the same `max_results`**, because page numbers only line up for one page size. The end of the list is judged from the records actually received against the documented `edays-pagination-total` header, not from the `edays-pagination-page-size` header alone: no maximum page size is documented, and a cap that still echoed the requested size would otherwise end a list after one page. A continuation call that starts on a short page therefore confirms the end with one extra request, which returns an empty page.
- IDs are checked before any call is made: partner IDs may be any single path segment (spaces included, as in the documented `"Phil Jones"`; no slashes, control characters or leading or trailing spaces; at most 200 characters) and are URL-encoded; absence and user GUIDs must look like GUIDs; absence type IDs must be positive integers. The four names documented directly under `/api/v2/users/` (`autosetupauthorisers`, `recalculateauthorisationhierarchy`, `authorisation`, `applyRotaToUsers`) are refused as user IDs in any letter case: the first two change data when fetched with GET (they add users to the authoriser role and reassign pending requests), so a read-only tool must never reach them. `date_from`/`date_to` take `YYYY-MM-DD` (or the documented `YYYYMMDD`) and real calendar dates only. Absence times take `YYYY-MM-DD HH:MM` or ISO 8601 with a `T` and are sent in the documented `YYYY-MM-DD HH:MM` form; no time zone is accepted because none is documented. `12/08/2026`, a bare year, `25:00` and a trailing `Z` are refused locally. `book_absence` and `update_absence` refuse an end before the start, `update_absence` refuses a call with no change, and `list_absences` refuses the system-wide filters together with `partner_user_id`, because the per-user endpoint documents only `recordtype`, `absencetype`, `datestart` and `dateend`.
- Edays does not document a rate limit anywhere on the API V2 page. Requests are spaced 250 ms apart (about four per second). A 429 is retried at most twice for any method, including `POST /api/v2/absences`, on the assumption that a rate-limited request was not processed. The retry waits for `Retry-After` (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when the header is absent or unreadable). Each wait is capped at 10 seconds so a single request stays well under the MCP client's default 60-second request timeout: if Edays asks for a longer wait the call gives up at once and the message says how long to wait. The cap is per request, not per tool call: `list_absences` can make up to 20 requests in one call and `get_user_rota` up to six, so a long run of 429s across those could still exceed the client's timeout.
- 502, 503 and 504 are retried the same way for `GET` and for `POST /token` only; when all three attempts fail the error says the service may be unavailable and to try again in a few minutes, without the gateway's HTML. A `POST`, `PUT` or `DELETE` on an absence is never retried after a gateway error, because the request may already have been processed and a retry could book the same absence twice; the error says to check with `list_absences` or `get_absence` before repeating it.
- A 200 whose body is not JSON (a proxy or a login page in the way) is reported as an error naming `EDAYS_SYSTEM` / `EDAYS_BASE_URL`, never as an empty list or an empty record; the 60-character excerpt of the body in that message goes through the same contact redaction as everything else.
- Rejected client credentials produce a message that says which variables to fix and where the credentials come from; a 401 that survives a token refresh says to check the Api Client user; a 403 says the user's roles do not allow the operation; a 400 passes on Edays' message and, if present, its `ModelState` validation errors field by field.

## Tests

```bash
npm test
```

The test suite:

1. Extracts the documentation into `spec.json` on the first run: `test/extract-examples.mjs` fetches `https://developer.e-days.co.uk/`, drops the parts of the page that sit in HTML comments (they are not published), and records every "Resource URL" with its "Supported HTTP Methods" line and every "Example ... Request/Response" JSON block (119 examples on 90 resources; 6 examples are not valid JSON on the page and are recorded as such; a repeated Resource URL continues the same resource, and the one resource with no methods line, the bulk authorisation PATCH, takes its method from its example and is marked as inferred). The JSON schemas in `test/schemas.mjs` were written by hand from those examples (every documented key required, no other keys, types as shown); the first check validates each schema against the documented example it came from (26 examples), confirms every endpoint this server calls is documented with that method, that the two data-changing GET endpoints under `/api/v2/users/` are indeed documented as such, and that the mock's client ID and secret are obviously fake values that do not appear on the documentation page. The second check validates every fixture record against the schemas.
2. Starts a local mock of the system: `POST /token` with the documented form-encoded client-credentials grant (answering the documented one-element array, or a plain object when told to), a 400 `invalid_client` for wrong credentials, a 401 for any API call without a token the mock issued, the endpoints used here with the documented `page`/`pagesize` paging and the four `edays-pagination-*` headers (absences capped at 4 per page so lists span several pages; on request the page-size header echoes the requested size instead, and `GET /api/v2/users` pages too), 404s for unknown IDs, `POST /api/v2/absences` answering 201 (with the record, or with no body when told to), 204 on PUT and DELETE, injected failures on any endpoint including `/token`, and a one-off 429 on the first `GET /api/v2/absencetypes`. The third check validates the mock's token and list, detail and write responses against the schemas.
3. Starts the built server and drives it over stdio with the official MCP client: 29 checks (32 in the whole suite) covering every tool, tool annotations, the token fetched with the documented body before the first API call and reused on every call as a Bearer header, a revoked token refreshed once with the call retried, a token near expiry refreshed before it expires, the object-shaped token response, page-based pagination stopping at `edays-pagination-total` and returning whole pages only (following the tool's own continuation notes from a first page and from a later one yields every record exactly once; `max_results` equal to the total fetches the short last page; a page-size header that echoes the requested size while serving fewer does not end the walk early), `list_users` following the paging headers when `GET /api/v2/users` pages and saying so, every documented `list_absences` filter passed through exactly (`datestart`/`dateend` from `YYYY-MM-DD` and `YYYYMMDD`, `recordtype`, `absencetype`, `userId`, `groupId`, `dateCreated`, `dateModified`) with bad dates (including a mix of the two date forms) and IDs refused before any call, the four reserved endpoint names under `/api/v2/users/` (read from the documentation, in any letter case) refused by every user tool with no request made, a partner ID with a space sent as one encoded segment and the single-user URL sent with its documented trailing slash, the per-user endpoint with its three filters and the refusal of the others, redaction of contact and HR details and of emails, phone numbers and postcodes typed into names, job titles, rota and group names by default and their return on request, authoriser partner IDs returned as stored, absence types named for record types 1, 2, 5 and 6, entitlement balances with booking period and time unit names, rotas and patterns named from the lists with the lists cached, the `POST` and `PUT` bodies validated against the schemas from the documented POST and PUT examples (times normalised, `Details` sent empty on a booking when omitted and left out of a PUT when not given), the DELETE, the 429 retry waiting for `Retry-After` in the seconds, fractional-seconds and HTTP-date forms, giving up after three attempts on a persistent 429 and at once on a `Retry-After` above the cap, a 429 on `POST` retried once, a 502 retried for `GET` and never for `POST`, `PUT` or `DELETE`, a 401 that survives a token refresh reported with the Api Client advice, a `GET` failing three times with 503 reported with advice and without the gateway HTML, a 503 on `POST /token` retried and reported the same way, a 429 on `POST /token` without `Retry-After` retried after the 2 s fallback, a 200 from `POST /token` without a token reported with key names only (never a value), Edays' own error text passed on with contact details redacted and `ModelState` errors listed, the 403 message, a 200 with a non-JSON body reported as an error with the excerpt redacted, the write gate with the variable unset and set to `false`, the 400 for wrong client credentials naming the variables without echoing the secret, a bad `EDAYS_SYSTEM` or a missing secret stopping the server at start-up, `EDAYS_SYSTEM` alone producing `https://<system>.e-days.co.uk` (read from the start-up line; no request is made to that host), and that every request either carried the documented form body to `/token` with no `Authorization` header or carried a Bearer token the mock issued to a documented method and path, none of them a reserved endpoint name in place of a user.

## Status

This is a working prototype. It has **not yet been run against the live API**, because it was built without an Edays system or Api Client credentials. Everything below is taken from the documentation page and should be confirmed on a real system:

- The token endpoint: whether the response is the one-element array shown in the documented example or a plain object (both are accepted), what a wrong Client ID or Secret answers (the mock uses the OAuth 2.0 `400 invalid_client`; the page documents nothing), and whether the API answers 401 for an expired token (the server refreshes once on a 401 and a minute before the documented hour either way).
- Whether `GET /api/v2/users/{partnerUserId}` returns `EdaysId`. The documented example omits it while the list example has it, so `get_user` reports no `edays_id` and `list_users` does; the mock follows the examples. The server sends the documented URL with its trailing slash (the mock accepts both forms).
- Whether `GET /api/v2/users` pages. The page marks only the two absence lists as paged, but that marker is not exhaustive (the `/usergroups` endpoint says in prose that it pages at 500), so `list_users` reads the paging headers and fetches further pages if the first answer is short of `edays-pagination-total`; untested against a real system, as is how long the call takes on a large one. Whether it includes leavers (`IsLeaver` is filtered locally), and whether a real record ever carries `null` where the examples show empty strings (the formatter copes; the schemas allow `null` only for `EmploymentStartDate` and `ContinuousStartDate`, where the examples show it).
- Whether a partner ID with a space (`"Phil Jones"` in the documented authorisation example) really is addressable as `/api/v2/users/Phil%20Jones/`; the server accepts and encodes it.
- Paging: the query parameter is written `pageSize` in the Paging text and `pagesize` in every example; the server sends `pagesize`. No maximum page size is documented (the default is 50, the paging example shows 500); the server asks for at most 100 and ends the list when the records received reach `edays-pagination-total`, taking a page shorter than requested as the end only when that header is missing. What a page past the end returns is not documented (the mock answers 200 with an empty list).
- The `datestart`/`dateend` filters: whether a record must start inside the range or merely overlap it, and whether the bounds are inclusive (the mock uses inclusive overlap). The formats of `dateCreated` and `dateModified` and whether `groupId` takes the group's GUID or its partner ID are not documented; those three values are sent exactly as given. `userId` is assumed to be the GUID shown as `UserId` on absence records and `EdaysId` on users (the examples show the same value for both).
- `POST /api/v2/absences`: whether it answers 200 or 201, what the body is (the page documents neither; the tool formats a body that looks like an absence record and passes anything else through), and whether a `Location` header is sent. Which `Status` values it accepts on creation (the example uses `Pending`; the tool offers the seven texts from `/api/v2/lists/recordstatus`) and whether the caller's roles allow booking for others.
- `PUT /api/v2/absences/{id}`: whether it answers 200, 201 or 204 (all documented; the tool re-reads the record when no body comes back), and what happens to `Details` when the key is omitted, since `GET` does not return it. Whether setting `Status` to `Approved`, `Rejected` or `Cancelled` through PUT really approves, rejects or cancels the request as the UI would, including notifications.
- `DELETE /api/v2/absences/{id}`: whether the record is removed or kept as Cancelled, and what the response is beyond the documented 204.
- The time zone of `StartTime`/`EndTime` (none is documented; the server sends the values as given) and the "midnight to midnight" rule for day-based absences quoted from the Managing Absences section.
- The record type discriminator names 5 (Custom Day Group) and 6 (Public Holiday Group) come from the Absence Types section; 1 and 2 from the lists section. Booking period and time unit names come from the documented `/api/v2/lists/bookingperiods` and `/api/v2/lists/timeunits` examples and are mapped locally rather than fetched.
- The documented example for `GET /api/v2/users/{partnerUserId}/customdays` is not valid JSON (`{ [ "Pattern": 3 ] }`); the server reads that endpoint like `publicholidays` (`[{"Pattern": n}]`).
- Error bodies: none are documented. The server reads `Message`, `message`, `error`, `error_description`, `ExceptionMessage` and `ModelState` and redacts contact details from them regardless.
- Rate limits: nothing is documented anywhere on the page (the word does not appear), so the 250 ms spacing here is a guess on the polite side, and a 429 on `POST` is retried on the assumption that a rate-limited request was not processed.
- Which roles the Api Client user needs for each endpoint; a 403 is reported with that explanation but the page does not describe the role model.

## Going to production

This version runs locally over stdio, with the customer's own Api Client 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 Edays, and then a listing in the Claude and ChatGPT connector directories.

## Licence

MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.

TDQS

A3.8/5.0

Scored across 9 tools

Disambiguation4/5

Most tools target clearly distinct resources and verbs (list_users vs get_user, list_absences vs get_absence, entitlements vs rota). The main overlap is conceptual: list_absence_types also returns custom day and public holiday groups, blurring its boundary with list_public_holidays, but the descriptions call this out and help steer selection.

Naming Consistency5/5

Every tool follows a predictable get_/list_ + resource pattern in snake_case (list_users, get_user, get_user_rota), including consistent handling of nested resources like user entitlements and rota.

Tool Count5/5

9 tools is well-scoped for an HR/absence domain, each covering a distinct facet (holidays, groups, absence types, users, absences, entitlements, rota) without redundant entries.

Completeness3/5

The surface is entirely read-only: there is no way to book, update, delete, or approve an absence, yet the domain is fundamentally about managing absences, leaving an obvious lifecycle gap. Read coverage of users, absences, entitlements and rota is solid, so an agent can inspect but not act.

Maintenance

ActivityMaintained
ResponsivenessNo issues