officient-mcp
# officient-mcp
An [MCP](https://modelcontextprotocol.io) server for the **Officient HR API** (`https://api.officient.io`), so an AI assistant such as Claude Code can read people, calendars, contracts, expenses, documents, fleet and webhooks — and book time off — through typed tools instead of raw HTTP.
It ships the complete Officient API surface without dumping 99 tool definitions into your context:
- **2 discovery tools** that read a bundled copy of the API spec (no network, no scopes needed).
- **1 generic escape hatch** (`officient_request`) that can call *any* documented endpoint, validated against the spec before it fires.
- **21 curated, typed tools** for the workflows people actually use every day.
---
## Requirements
- Node.js 20 or newer
- An Officient access token (see below)
## Install and build
```bash
git clone <this-repo> officient-mcp
cd officient-mcp
npm install # runs the build automatically
npm run build # or build again by hand
npm test # build + unit/integration tests, no network calls
```
## Getting an access token
> **There is no OAuth flow here.** Claude Code's OAuth support is for HTTP and SSE servers only — a
> stdio server cannot participate in it. This server manages its own credential, which means a
> **long-lived personal access token that you generate and rotate yourself**.
1. Log in to Officient as an **admin**.
2. Open the avatar menu (top right) → **Developers**, i.e. `https://<your-tenant>.officient.io/developer`.
3. Create a client app if you do not have one, then **Manage app → Generate access token** and pick an
expiry. Copy the token immediately — Officient shows it once.
4. Put it in the environment as `OFFICIENT_ACCESS_TOKEN` (see [Connecting it to Claude Code](#connecting-it-to-claude-code)).
5. Diary the expiry. When the token lapses, generate a new one the same way; nothing else changes.
### Scopes live on the app, not on the token
This trips everyone up: you do **not** choose scopes while generating a token. Scopes are configured
on the *client app*, under **Manage app → Edit scopes**. A token inherits whatever the app had ticked
at the moment it was issued.
So if a call comes back with **HTTP 401 and `{"error":"insufficient_scope"}`**, the token is fine —
the app was never granted that scope. The fix is:
1. **Manage app → Edit scopes**, tick the scope you need.
2. **Generate a fresh access token** (existing tokens do *not* pick up newly added scopes).
3. Update `OFFICIENT_ACCESS_TOKEN` and restart the server.
A plain 401 *without* `insufficient_scope` is the other problem entirely: the token itself is invalid,
revoked or expired.
Officient scopes are `<object>:<action>`, e.g. `calendar:read`. Grant the app only what you need:
| Scope | Unlocks |
| --- | --- |
| `basics:read` | people list, teams, assets, fleet, roles |
| `personal.info:read` | person detail, people search |
| `calendar:read` | calendars, event types, days-off requests |
| `calendar:write` | adding / overwriting / deleting calendar events |
| `weekly.schedules:read` | weekly schedules |
| `contracts:read` | contracts |
| `documents.read` / `documents.write` | documents (Officient spells these with a dot) |
| `expenses:read` | expenses |
| `wages:read` | wages, cost centers, cost units, departments, functions |
| `personal.info.custom.fields:read` | custom fields on a person |
| `webhooks:read` | webhook subscriptions |
Every tool description names the scope it needs. `officient_describe_endpoint` does the same for all 99 endpoints.
## Configuration
Configuration comes from environment variables only. A local `.env` is loaded for development (see `.env.example`).
| Variable | Required | Default | Meaning |
| --- | --- | --- | --- |
| `OFFICIENT_ACCESS_TOKEN` | **yes** | — | Bearer token; the server refuses to start without it |
| `OFFICIENT_BASE_URL` | no | `https://api.officient.io` | Override for sandboxes/proxies |
| `OFFICIENT_TIMEOUT_MS` | no | `30000` | Per-request timeout in milliseconds |
The token is never logged and is redacted from anything the server prints.
## Connecting it to Claude Code
Claude Code has three config scopes. Which one you want depends on whether the config is allowed to
touch disk with a secret in it:
| Scope | Stored in | Shared? | Use it when |
| --- | --- | --- | --- |
| `local` (default) | `~/.claude.json`, per project | no | **Recommended here.** Personal machine, secret-bearing server |
| `project` | `.mcp.json` in the repo root | yes, via git | A team shares the config; the token must come from each dev's shell |
| `user` | `~/.claude.json` | no, but all your projects | You want Officient available everywhere you work |
### Registering the server
```bash
claude mcp add officient --scope local --env OFFICIENT_ACCESS_TOKEN=YOUR_TOKEN_HERE \
-- node /absolute/path/to/officient-mcp/dist/index.js
```
Two CLI quirks worth knowing, both verified against Claude Code as of 2026-07:
**Put the server name first.** `--env` is variadic, so it swallows the following argument as a
second environment variable. Writing `--env KEY=value officient` fails with:
```
Invalid environment variable format: officient,
environment variables should be added as: -e KEY1=value1 -e KEY2=value2
```
**`--env` requires `KEY=value`.** A bare `--env OFFICIENT_ACCESS_TOKEN`, meaning "pass my shell's
value through", is rejected the same way. If a later version adds passthrough, prefer it.
The trade-off is therefore real and unavoidable on this path: the literal token is **persisted in
plaintext in `~/.claude.json`**, where it survives rotation, ends up in backups, and has to be
scrubbed by hand. If that matters to you, use the project-scoped `.mcp.json` route below instead —
that one *does* expand `${OFFICIENT_ACCESS_TOKEN}` from the environment, so the secret stays in your
shell profile or password manager and never touches Claude Code's config.
### Alternative: project scope via the committed `.mcp.json`
This repo ships a `.mcp.json` at its root:
```json
{
"mcpServers": {
"officient": {
"type": "stdio",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR:-.}/dist/index.js"],
"env": {
"OFFICIENT_ACCESS_TOKEN": "${OFFICIENT_ACCESS_TOKEN}"
}
}
}
}
```
Claude Code expands `${VAR}` and `${VAR:-default}` inside `command`, `args`, `env`, `url` and
`headers`, and `${CLAUDE_PROJECT_DIR}` resolves to the project root — so the file is committed with
**no secret and no absolute path in it**. Each developer supplies the token from their own shell:
```bash
export OFFICIENT_ACCESS_TOKEN=...
npm install && npm run build # .mcp.json points at dist/, so it must exist
claude # Claude Code prompts once to approve the project server
```
Never replace `${OFFICIENT_ACCESS_TOKEN}` with a literal in this file — it is committed to git.
### Verifying
```bash
claude mcp list # should show officient as connected
OFFICIENT_ACCESS_TOKEN=... npm start # or boot it by hand; it stays silent when healthy
```
If the token is missing, the server writes an actionable message to **stderr** and exits with code 1.
Claude Code does not auto-restart stdio servers, so a crash is visible rather than silently retried.
Runtime API failures (401, 429, 4xx) are returned as MCP tool errors instead — the process stays up.
## Tools
### Discovery and escape hatch
| Tool | Purpose |
| --- | --- |
| `officient_list_endpoints` | Filter all 99 documented endpoints by tag / method / free text; returns compact one-liners including the required scope. Offline. |
| `officient_describe_endpoint` | Everything about one `operationId`: parameters, body schema, worked example, scope, doc URL, prose. Offline. |
| `officient_request` | Call **any** endpoint by `operationId` with `pathParams` / `query` / `body`, validated against the spec first. Supports `dry_run: true` to see the resolved request without sending it. **Can write.** |
### Curated tools
| Tool | Purpose | Scope |
| --- | --- | --- |
| `officient_get_own_account` | Account behind the current token | `basics:read` |
| `officient_list_people` | List employees (30/page, zero-indexed) | `basics:read` |
| `officient_search_people` | Find people by name / email / national number | `personal.info:read` |
| `officient_get_person` | Full detail for one employee | `personal.info:read` |
| `officient_get_person_custom_fields` | Custom field values on a person | `personal.info.custom.fields:read` |
| `officient_get_weekly_schedule` | Current weekly working schedule | `weekly.schedules:read` |
| `officient_list_teams` | Teams and their members | `basics:read` |
| `officient_get_day_calendar` | One person, one day | `calendar:read` |
| `officient_get_month_calendar` | One person, one month | `calendar:read` |
| `officient_get_year_calendar` | One person, one year (`filter=days_with_events` keeps it small) | `calendar:read` |
| `officient_list_event_types` | Custom event types for a year, incl. the id needed to book a custom event | `calendar:read` |
| `officient_list_days_off_requests` | Days-off requests, filterable by status | `calendar:read` |
| `officient_add_calendar_event` | **Writes.** Add one or more events (day off, sick day, education, overtime) | `calendar:write` |
| `officient_overwrite_calendar_event` | **Writes.** Idempotent upsert of one event of a type on a date | `calendar:write` |
| `officient_delete_calendar_event` | **Writes.** Remove one calendar event | `calendar:write` |
| `officient_list_contracts` | Employment contracts | `contracts:read` |
| `officient_list_expenses` | Expenses for a year, or one month | `expenses:read` |
| `officient_list_documents` | Documents on an employee / asset / car | `documents.read` |
| `officient_list_vehicles` | Fleet vehicles, optionally by owner | `basics:read` |
| `officient_list_assets` | Company assets, optionally by owner | `basics:read` |
| `officient_list_webhooks` | Webhook subscriptions | `webhooks:read` |
Anything not in this table — wages, budgets, dimonas, cost centers, performance reviews, uploads, person/team/vehicle mutations — is reachable through `officient_request`. Start with `officient_list_endpoints`.
## Gotchas worth knowing
**Pagination is zero-indexed.** `?page=0` is the first page, `?page=1` is the *second*. 30 items per page. The curated tools default `page` to 0.
**Rate limit: 30 requests per 5 seconds.** Officient answers a breach with HTTP **429 and an empty body**, and sends **no `Retry-After` header**. The server therefore runs a client-side sliding-window limiter: calls beyond the budget are *queued*, never dropped, and a 429 that still slips through is retried once with backoff before being surfaced.
**401 is two different problems.** A missing OAuth scope returns HTTP 401 with `{"error":"insufficient_scope"}` — the token is fine, the app just was not granted that scope; fix it in the developer dashboard and issue a new token. A plain 401 means the token itself is invalid, revoked or expired. The server reports these as `kind: "insufficient_scope"` and `kind: "invalid_token"` respectively.
**Validation errors carry a human sentence.** Officient replies to bad writes with `{"status_code": 400, "reason_phrase": "There is already too much time off planned on <date>."}`. That phrase is surfaced verbatim.
**`overwrite-event` silently does nothing if only the start time differs.** Observed live 2026-07: when an event of the given type already exists on that date with the same `duration_minutes`, the endpoint returns `{"success": 1, "info": "no action taken"}` and leaves the event untouched — it does not compare `start_time_minutes`. Note that `success: 1` here means "request accepted", *not* "your change was applied". To move an existing event's start time you have to fall back to `delete-event` + `add-event`, which is exactly the delete-and-add cycle this endpoint was documented to spare you. **Read the calendar back after any write** rather than trusting `success: 1`.
**A full-day event still needs a sensible `start_time_minutes`.** `duration_minutes: "all_day"` correctly resolves to the person's scheduled minutes, but the start time is stored as given. Passing `0` yields a full-day event pinned to 00:00, which renders oddly next to events created in the UI. Read an existing event on a normal working day to see the house convention (typically `540`, i.e. 09:00).
**Team membership is not readable.** `list-teams` returns `id` and `name` only — no members, despite what the docs imply — and there is no team-detail endpoint. The org structure lives entirely in the manager relations: walk `list-people` and call `person-manager` per person.
**`person-manager` returns the manager, not the pairing.** The response is `{person_id, person_name, start_date}` describing the *manager*, with nothing identifying whose manager it is. Fan out over several people concurrently and you can only map results back to subjects by request order — so either issue the calls sequentially, or tag each response yourself at the call site. Silently mis-mapping an org chart is an easy and expensive mistake here.
**Uploads are base64 JSON, not multipart.** Document and avatar endpoints take `document_base64` / `photo_base64` fields inside a normal JSON body.
**Responses are lightly de-enveloped.** Officient wraps payloads in a single `data` key; the server strips exactly that one level and returns compact JSON. Nothing else is filtered and nothing is truncated.
**Two tools raise the output cap.** Claude Code warns at 10k tokens of tool output and cuts off at 25k
(override with `MAX_MCP_OUTPUT_TOKENS`). Two tools declare a higher ceiling via
`_meta["anthropic/maxResultSizeChars"]`, because truncating them loses data silently rather than
failing loudly: `officient_get_year_calendar` (200 000 chars — 365 day entries, each with an events
array) and `officient_request` (300 000 chars — it can reach *any* endpoint, so it needs the worst
case covered). Every other tool stays on the client default; the caps were measured, not guessed —
`officient_describe_endpoint`, for instance, peaks at ~2.6 KB and needs nothing.
**The server never writes to stdout.** stdout carries the JSON-RPC stream, so a single stray byte
breaks the transport. All diagnostics go to stderr, and `installStdoutGuard()` repoints every
stdout-writing `console` method at stderr before config is loaded — dotenv ≥ 17 prints its banner with
`console.log` and honours `DOTENV_CONFIG_DEBUG` from the inherited environment, which would otherwise
override the `quiet: true` this server passes.
## Regenerating the spec
```bash
npm run harvest # node scripts/harvest-spec.mjs
```
The harvester refetches every documentation page from `https://apidocs.officient.io` and writes three
things deterministically, so re-running with no upstream change produces no diff:
| Output | Committed? | What it is |
| --- | --- | --- |
| `spec/endpoints.json` | **yes** | Flat endpoint index the tools load at runtime |
| `spec/openapi.json` | **yes** | Merged OpenAPI 3.1 document |
| `spec/raw/**` | **no** (gitignored) | ~100 documentation pages copied verbatim |
`spec/raw/` is Officient's copyrighted documentation, so this repo does not redistribute it: it is
gitignored and excluded from the npm tarball, but kept on disk as the harvester's cache. **Run
`npm run harvest` to regenerate it from the public docs** — nothing at runtime ever reads it, so a
fresh clone without `spec/raw/` works exactly the same (the test suite asserts both halves of that).
Rebuild afterwards (`npm run build`) and re-run `npm test` — the suite asserts that every curated tool
still maps onto a real `operationId`.
## Development
```bash
npm run dev # tsc --watch
npm run typecheck # tsc --noEmit
npm test # build + node:test suite
```
The test suite makes **no real API calls**: HTTP is stubbed, and the stdio smoke test boots the server with a dummy token against an unreachable base URL.
## License
MIT — see [LICENSE](./LICENSE).
TDQS
Scored across 24 tools
Every tool has a clearly distinct purpose, with no overlap. Even the calendar-related tools (get_day_calendar, get_month_calendar, add_calendar_event, etc.) are well-differentiated by granularity and action.
All tools follow a consistent 'officient_verb_noun' snake_case pattern. Verbs like list, get, search, add, overwrite, delete are used predictably, with no mixed conventions.
24 tools is appropriate for a comprehensive HR API covering people, calendar, expenses, contracts, and more. Each tool addresses a distinct operation, and the meta-tools (list_endpoints, describe_endpoint, request) add valuable discoverability without bloat.
The tool set covers most core HR workflows (people, calendar, expenses, contracts, etc.) with dedicated tools. Minor gaps exist (e.g., no create/update for people, no webhook management beyond listing), but the officient_request escape hatch mitigates these, and the surface handles primary use cases well.