officient-mcp

officient-mcp

An MCP server that provides typed tools for AI assistants to read and write data from the Officient HR API, including people, calendars, contracts, expenses, and more.

Category
访问服务器

README

officient-mcp

An MCP 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

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).
  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

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:

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

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

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

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

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.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选