# MCP FactorialHR

**Category:** 💬 Communication  
**Repository:** https://github.com/t4dhg/mcp-factorial  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/mcp-factorialhr

## Description
Full CRUD MCP server for FactorialHR: employees, teams, time off, projects, ATS, payroll.

## Claude Desktop Quick Installation
Heuristic fallback — verify the package name and runner against the repository README before running it. Uses `npx` (confidence: low):

```json
"mcpServers": {
  "mcp-factorialhr": {
    "command": "npx",
    "args": ["-y","mcp-factorialhr"]
  }
}
```

## Documentation & README

<p align="center">
  <img src="https://raw.githubusercontent.com/t4dhg/mcp-factorial/main/assets/factorial-logo.svg" alt="FactorialHR" height="48">
</p>

# MCP FactorialHR

> **The definitive Model Context Protocol server for [FactorialHR](https://factorialhr.com)**

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![CI](https://github.com/t4dhg/mcp-factorial/actions/workflows/ci.yml/badge.svg)](https://github.com/t4dhg/mcp-factorial/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/t4dhg/mcp-factorial/branch/main/graph/badge.svg)](https://codecov.io/gh/t4dhg/mcp-factorial)
[![bundle](https://codecov.io/gh/t4dhg/mcp-factorial/graph/bundle/mcp-factorial/badge.svg)](https://codecov.io/gh/t4dhg/mcp-factorial)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
[![Node.js](https://img.shields.io/badge/Node.js-18%2B-brightgreen.svg)](https://nodejs.org/)
[![npm version](https://img.shields.io/npm/v/@t4dhg/mcp-factorial.svg)](https://www.npmjs.com/package/@t4dhg/mcp-factorial)
[![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-green.svg)](https://modelcontextprotocol.io/)

A comprehensive Model Context Protocol (MCP) server that provides AI assistants like Claude with full access to FactorialHR. Manage employees, teams, time off, projects, training, recruiting, and more - all with built-in safety guardrails.

## Why This MCP Server?

- **Context-Optimized**: 14 hierarchical tools (117 operations) with 88% less context usage than individual tools
- **Full CRUD Operations**: Create, read, update, and delete across all major entities
- **Safety Guardrails**: High-risk operations require explicit confirmation
- **Audit Logging**: All write operations are logged with timestamps and context for debugging
- **Enterprise Ready**: Built for companies who need AI integration with proper controls

## Features

### Hierarchical Tool Discovery (v8.0.0+)

The MCP server uses a hierarchical tool structure for optimal context usage. Instead of 124 individual tools, you get 14 category-based tools with an `action` parameter.

| Tool                    | Description                   | Actions                                            |
| ----------------------- | ----------------------------- | -------------------------------------------------- |
| `factorial_discover`    | Discover available categories | -                                                  |
| `factorial_employees`   | Employee management           | list, get, search, create, update, terminate       |
| `factorial_teams`       | Team management               | list, get, create, update, delete                  |
| `factorial_locations`   | Location management           | list, get, create, update, delete                  |
| `factorial_contracts`   | Contract/salary data          | list, get_with_employee, by_job_role, by_job_level |
| `factorial_time_off`    | Leave management              | 10 actions                                         |
| `factorial_attendance`  | Shifts and registro horario   | 14 actions incl. clock_in, audit, log_range        |
| `factorial_documents`   | Document management           | 8 actions (downloads require OAuth2 - see below)   |
| `factorial_job_catalog` | Job roles/levels              | list_roles, get_role, list_levels                  |
| `factorial_projects`    | Project management            | 16 actions for projects, tasks, workers, time      |
| `factorial_training`    | Training management           | 12 actions for trainings, sessions, enrollments    |
| `factorial_work_areas`  | Work area management          | list, get, create, update, archive, unarchive      |
| `factorial_ats`         | Applicant tracking            | 17 actions for recruiting                          |
| `factorial_payroll`     | Payroll data (read-only)      | 6 actions                                          |

**Example Usage:**

```typescript
// List all employees
factorial_employees({ action: 'list', page: 1, limit: 50 });

// Get a specific employee
factorial_employees({ action: 'get', id: 123 });

// Search employees
factorial_employees({ action: 'search', query: 'john' });

// Create a leave request
factorial_time_off({
  action: 'create',
  employee_id: 123,
  leave_type_id: 1,
  start_on: '2026-02-01',
  finish_on: '2026-02-05',
});

// Discover available actions for a category
factorial_discover({ category: 'employees' });
```

### 124 Operations Across 14 Categories

| Category        | Operations                                                                                                                                |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Employees**   | list, get, search, create, update, terminate                                                                                              |
| **Teams**       | list, get, create, update, delete                                                                                                         |
| **Locations**   | list, get, create, update, delete                                                                                                         |
| **Time Off**    | list_leaves, get_leave, list_types, get_type, list_allowances, create, update, cancel, approve, reject                                    |
| **Attendance**  | list, get, create, update, delete, clock_in, clock_out, status, gaps, audit, log_range, log_days, list_edit_requests, create_edit_request |
| **Projects**    | 16 operations for projects, tasks, workers, time records                                                                                  |
| **Training**    | 12 operations for trainings, sessions, enrollments                                                                                        |
| **Work Areas**  | list, get, create, update, archive, unarchive                                                                                             |
| **ATS**         | 17 operations for job postings, candidates, applications, hiring stages                                                                   |
| **Payroll**     | list/get supplements, tax identifiers, family situations (read-only)                                                                      |
| **Documents**   | 8 operations for folders, documents, and downloads (⚠️ downloads require OAuth2)                                                          |
| **Job Catalog** | list_roles, get_role, list_levels (read-only)                                                                                             |
| **Contracts**   | list, get_with_employee, by_job_role, by_job_level (read-only)                                                                            |

### Attendance and Registro Horario

Factorial asks employees to record their working hours day by day. `factorial_attendance` lets Claude do that, for one day or for a whole month, and for any employee the API key can see.

Times are `HH:MM` in the company's local time, exactly as Factorial shows them; Factorial applies them in the company zone and the server never converts between zones. Records written by this server carry `source: "api"`, so they are distinguishable from live clocks in Factorial's own activity log.

A write's **declared working time** (`date`, `clock_in`, `clock_out`) is independent of its **entry metadata**: Factorial stamps `created_at`, `updated_at` and `in_source`/`out_source` with when and how the record was entered, and those cannot be set or changed through the API. Every create, update, `clock_in`, `clock_out`, and every bulk preview and result, states the declared working time and says this once.

| Action                                      | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `status`                                    | Whether the employee is clocked in and since when. Always prints the configured identity.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `clock_in`, `clock_out`                     | Live clocking at the current time, or, given `date` and `time` (`HH:MM` company local), a declared moment for someone who forgot to clock. A declared moment in the future is refused before any request is sent. A declared `clock_out` is refused if nothing is open, or if the moment precedes the open shift's start.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `gaps`                                      | Workdays in a date range where the contract expects more hours than were tracked. Weekends, bank holidays and full-day leave are excluded.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `audit`                                     | One row per calendar day in a range: day type, expected and tracked hours, leave cover, the shifts on record and a status (`complete`, `missing`, `short`, `over`, `weekend`, `bank_holiday`, `on_leave`, `half_day_leave`, `no_contract_data`, `future`). `missing` is nothing on record; `short` is some hours tracked but under expected by more than the tolerance, shown with its delta. A signed-off date is marked separately and is closed for writing; `create_edit_request` is how it gets corrected. The header gives `expected` (bank holidays and leave at full contract minutes) alongside `workday expected` (what tracked hours should actually meet). `statuses` restricts the summary to a given set. `format` is `summary` (default, only the days needing attention), `table` (every day) or `json` (the ledger). The starting point for reconciling what was clocked against what should have been. |
| `log_range`                                 | Apply a daily pattern (`segments`) to every workable day in a range. Skips weekends, bank holidays, days the contract expects 0 minutes, approved leave, future dates, signed-off dates, `exclude_dates`, and any segment overlapping an existing shift.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `log_days`                                  | Write an explicit list of days with their segments, for migrating from another platform. Only refuses future dates, approved leave, signed-off dates and overlaps, so a Saturday someone worked can be written.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `list`, `get`, `create`, `update`, `delete` | Individual shift records. `list` needs `start_on` and `end_on` (or `ids`, or `updated_at`); Factorial ignores paging on this endpoint, so paging is client-side. `fields: "compact"` on `list` returns a smaller payload (date, clock_in, clock_out, minutes, in_source); `get` returns the complete record.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `list_edit_requests`, `create_edit_request` | Factorial's route for correcting a signed-off day, since hours cannot be written onto a reviewed date directly. Filing a request notifies whoever approves timesheets, so `create_edit_request` is previewed and token-gated even for the configured identity.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |

```typescript
// Find the missing days first
factorial_attendance({ action: 'gaps', start_on: '2026-03-01', end_on: '2026-03-31' });

// Preview a month of split days; nothing is written yet
factorial_attendance({
  action: 'log_range',
  start_on: '2026-03-01',
  end_on: '2026-03-31',
  segments: [
    { clock_in: '09:00', clock_out: '14:00' },
    { clock_in: '15:00', clock_out: '18:00' },
  ],
});
// -> "Plan for <name> (<id>) ... 20 days to write, 40 shift records, 160h ... confirmation_token: <token>"

// Same call plus the token writes it
factorial_attendance({ action: 'log_range', /* same arguments */ confirmation_token: '<token>' });

// Afterwards, reconcile the month
factorial_attendance({ action: 'audit', start_on: '2026-03-01', end_on: '2026-03-31' });
```

"Today" for the future-date rule is the date in the zone of the machine running the server, so run it in the company's zone or accept that the boundary day may be off by one. Bank holidays come from the company's own calendar in Factorial (`worked_times.day_type`), so no holiday list is needed. Approved leave is read from `timeoff/leaves`; pass `skip_leave: false` when the source system is right and Factorial's leave record is stale. Half-day leave days are left out of `log_range` and named in the preview; write the worked half with `log_days`.

Nobody clocks in at exactly 09:00 every day, and a month of identical entries is the one pattern a real registro never shows. Pass `jitter_minutes` (5 to 10 is sensible) to `log_range` or `log_days` and each written time varies by up to that many minutes from your pattern, within the day; segments never cross each other. Pass `variation_minutes` for a different kind of variation: it shifts a whole day by one deterministic offset so the start time drifts from day to day, which `jitter_minutes` cannot produce because it only varies segments within a day. Both are derived from the employee, the date and (for jitter) the segment, so the preview lists the exact times that will be written, the confirmation token binds to them, and a retry recognises its own earlier records. The records still carry `source: "api"`; these options make reconstructed hours realistic, they do not disguise where they came from.

Every `gaps`, `audit` and bulk-write preview starts with a `Data read` line: how many days of the window have contract data, and how many leave and shift records were read. Reads follow Factorial's pagination to the end, so a window of any length is complete; a date the API returned nothing for is reported as `no_contract_data` and never written, rather than passed off as a day that was not workable. Those dates normally precede the start of employment. If they do not, the read was incomplete and the result should not be trusted.

**Signed-off periods.** Once a date's timesheet has been reviewed in Factorial, it is closed for writing; a shift write on it is refused with a 403. `audit` reads `attendance/reviews` alongside the other facts and marks such dates signed off before anything is written; `log_range` and `log_days` skip them the same way they skip a weekend or an approved leave day. The way to correct a signed-off day is `create_edit_request`, which files a request that whoever approves timesheets then decides on; `list_edit_requests` reads what has been filed. Filing a request is previewed and token-gated even for the configured identity, because it notifies a person.

A date that does not exist, such as `2026-02-30`, is refused rather than silently rolled forward to the next valid date; a real leap day such as `2024-02-29` is accepted normally.

**Auditing a month.** Run `audit` for the range first. A day within `tolerance_minutes` (default 15) of its expected total counts as `complete`, so realistic clock-ins and jittered backfills do not read as shortfalls. Compare the ledger with what you know locally (your calendar, another time-tracking system, days you actually worked on a holiday), then fix the differences: `log_range` or `log_days` for missing days, `delete` or `update` for wrong records, and `audit` again to confirm every workday reads `complete`.

Five prompts wrap these workflows for the user, and a guide resource documents them for the model; see [5 MCP Prompts](#5-mcp-prompts) and `factorial://guides/registro-horario`. Some clients (Claude Code included) surface a prompt only as a slash command the human invokes, with no way for the model itself to read one, so each prompt's procedure is also published as a resource at `factorial://prompts/<name>`.

Set `FACTORIAL_EMPLOYEE_ID` to your own employee id so that `employee_id` can be omitted. Writes aimed at anyone else, and every bulk write, require a confirmation token; see [Safety & Security](#safety--security).

### 6 MCP Resources

| Resource URI                          | Description                                                                         |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| `factorial://org-chart`               | Complete organizational hierarchy (Markdown)                                        |
| `factorial://guides/registro-horario` | How to audit, fill and maintain a registro horario with this server (Markdown)      |
| `factorial://employees/directory`     | Employee directory by team (Markdown)                                               |
| `factorial://locations/directory`     | Location directory with employee counts (Markdown)                                  |
| `factorial://timeoff/policies`        | All leave types and policies (JSON)                                                 |
| `factorial://teams/{team_id}`         | Team details with member list (JSON, templated)                                     |
| `factorial://prompts/{name}`          | The procedure text of an attendance prompt, readable without invoking it (Markdown) |

### 5 MCP Prompts

Prompts are procedures the user invokes (in Claude Code they appear as `/mcp__factorial__<name>` slash commands). The attendance prompts pre-read the data the procedure starts from and state the exact tool calls that follow, so a small model can carry the workflow through. A prompt never writes anything itself; writes go through `factorial_attendance` and its confirmation gate.

| Prompt                 | Arguments                                                                                    | What it does                                                                                                                                                                                                                                          |
| ---------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attendance_audit`     | `start_on`, `end_on`, `employee_id` (all optional)                                           | Runs the audit (default: this month to today, `FACTORIAL_EMPLOYEE_ID`) and asks for a read-only report: expected vs tracked, missing and over days by month, data-coverage warnings.                                                                  |
| `attendance_fill`      | `segments` (required), `start_on`, `end_on`, `employee_id`, `observations`, `jitter_minutes` | Reads the gaps and hands over the exact `log_range` call with the parsed pattern, then the preview, human confirmation, token, retry and verification steps. `segments` is `"09:00-14:00, 15:00-18:00"`.                                              |
| `attendance_today`     | `segments` (required), `employee_id`, `observations`, `jitter_minutes`                       | Reads today's status and decides: nothing on weekends, holidays, leave, open shifts or complete days; otherwise `log_days` for today, confirmed in the same session for the configured identity only.                                                 |
| `attendance_reconcile` | `known_absences` (required), `start_on`, `end_on`, `employee_id`                             | Runs the audit and reports only the days that disagree with a stated list of known absences (days off, sick days, trips). Read-only.                                                                                                                  |
| `attendance_fill_days` | `days` (required), `employee_id`, `observations`, `jitter_minutes`                           | Enters registro horario for a list of explicit dates, each with its own daily pattern, as one `log_days` call: preview, human confirmation, token, retry and verification. `days` is `[{"date":"2026-03-02","segments":"09:00-14:00, 15:00-18:00"}]`. |
| `summarize_team`       | `team_id`                                                                                    | Team summary with members and roles.                                                                                                                                                                                                                  |
| `time_off_report`      | `employee_id`                                                                                | Time off report for an employee: allowances and recent leaves.                                                                                                                                                                                        |

**Running the daily record on a schedule.** MCP has no scheduler, so the schedule lives in the client. In Claude Code, `/schedule` creates a routine that invokes `attendance_today` with your pattern, and `/loop` repeats it while a session is open; any cron can run `claude -p` with the prompt as its input. The prompt writes only when today is a workday with nothing tracked, and only for the employee in `FACTORIAL_EMPLOYEE_ID`; it reports one line either way. The record it produces is a legal document of hours worked, so the pattern you schedule must be the hours you actually work, and the day you do not work needs a leave record or a manual correction.

### Architecture Features

- **Safety Guardrails**: High-risk operations (terminate, delete) marked for confirmation
- **Audit Logging**: All write operations logged in-process with timestamps and context
- **Caching**: In-memory TTL-based caching (configurable by resource type)
- **Pagination**: All list operations support pagination
- **Retry Logic**: Exponential backoff with rate limit handling
- **Validation**: Runtime validation with Zod schemas

## Quick Start

### 1. Add to your MCP configuration

```json
{
  "mcpServers": {
    "factorial": {
      "command": "npx",
      "args": ["-y", "@t4dhg/mcp-factorial"]
    }
  }
}
```

### 2. Set your API key

Create a `.env` file in your project root:

```env
FACTORIAL_API_KEY=your-api-key-here
```

Or pass it directly in the MCP config:

```json
{
  "mcpServers": {
    "factorial": {
      "command": "npx",
      "args": ["-y", "@t4dhg/mcp-factorial"],
      "env": {
        "FACTORIAL_API_KEY": "your-api-key-here"
      }
    }
  }
}
```

### 3. Start using it!

Once configured, ask Claude things like:

- _"Who's on the Engineering team?"_
- _"Create a new employee John Smith"_
- _"Approve the pending time off request for employee 42"_
- _"Create a new project called Q1 Marketing Campaign"_
- _"Enroll Sarah in the Leadership Training program"_
- _"Show me all open job postings"_
- _"What candidates applied for the Senior Developer position?"_

## Getting an API Key

You'll need a FactorialHR API key to use this MCP server. Here's how to get one:

1. Log in to [FactorialHR](https://app.factorialhr.com) as an administrator
2. Go to [**Settings → API keys**](https://app.factorialhr.com/settings/api-keys)
3. Click the **"New API key"** button
4. Give your key a descriptive name (e.g., "Claude Code" or "MCP Server")
5. Click **Create** - your API key will be displayed
6. **Copy the key immediately** - it's only shown once and cannot be retrieved later
7. Add the key to your `.env` file or MCP configuration

> **Important**: API keys have full access to your FactorialHR data and never expire. Store them securely, never commit them to version control, and rotate them periodically.

## OAuth2 Setup (Required for Document Downloads)

Document download actions (`download_payslips`, `download`) **require OAuth2 authentication**. This is a Factorial API limitation - the download endpoint does not accept API key authentication.

> **Note**: You need admin access in Factorial to create OAuth applications.

### Step 1: Create an OAuth2 Application

1. Go to: **https://api.factorialhr.com/oauth/applications**
2. Click **"New application"**
3. Fill in:
   - **Redirect URI**: `http://localhost:8080/callback` (or any URL you can access)
   - **Confidentiality**: Yes (server application)
   - **Scopes**: Select the scopes you need:
     - **Required for downloads**: Documents, Employees
     - **Recommended for full MCP functionality**: Contracts, Payroll, Payroll supplements, Time off, Shift management, Trainings, Recruitment, Company locations, Job catalog
4. Save and note your **Client ID** and **Client Secret**

### Step 2: Get Authorization Code

Open this URL in your browser (replace `YOUR_CLIENT_ID`):

```
https://api.factorialhr.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8080/callback&response_type=code
```

- Log in and authorize the app
- You'll be redirected to your callback URL with `?code=AUTHORIZATION_CODE`
- Copy that code from the URL (it expires quickly, so proceed to step 3 immediately)

### Step 3: Exchange Code for Tokens

Run this curl command (replace placeholders):

```bash
curl -X POST 'https://api.factorialhr.com/oauth/token' \
  -d 'client_id=YOUR_CLIENT_ID' \
  -d 'client_secret=YOUR_CLIENT_SECRET' \
  -d 'code=AUTHORIZATION_CODE' \
  -d 'grant_type=authorization_code' \
  -d 'redirect_uri=http://localhost:8080/callback'
```

You'll get a response with `access_token` and `refresh_token`. Save the **refresh_token**.

### Step 4: Configure MCP Server

Add OAuth2 credentials to your MCP configuration:

```json
{
  "mcpServers": {
    "factorial": {
      "command": "npx",
      "args": ["-y", "@t4dhg/mcp-factorial"],
      "env": {
        "FACTORIAL_API_KEY": "your-api-key",
        "FACTORIAL_OAUTH_CLIENT_ID": "your-client-id",
        "FACTORIAL_OAUTH_CLIENT_SECRET": "your-client-secret",
        "FACTORIAL_OAUTH_REFRESH_TOKEN": "your-refresh-token"
      }
    }
  }
}
```

Or add to your `.env` file:

```env
FACTORIAL_API_KEY=your-api-key
FACTORIAL_OAUTH_CLIENT_ID=your-client-id
FACTORIAL_OAUTH_CLIENT_SECRET=your-client-secret
FACTORIAL_OAUTH_REFRESH_TOKEN=your-refresh-token
```

### Important Notes

- **Refresh tokens expire after 1 week** - you'll need to repeat steps 2-3 if it expires
- The MCP server automatically refreshes access tokens using the refresh token
- If document downloads suddenly stop working, your refresh token has likely expired

## Use Cases

### For Managers

- Create and manage team structures
- Approve or reject time off requests
- Assign employees to projects
- Track project time records
- Monitor training enrollments

### For HR

- Onboard new employees with full data entry
- Manage job postings and recruiting pipeline
- Track candidate applications through hiring stages
- Generate org structure analysis
- Manage training programs and enrollments

### For Developers

- Build AI workflows that need employee context
- Create custom Claude integrations
- Automate HR processes with AI assistance
- Generate reports and analytics

## Configuration Options

| Environment Variable            | Description                                                          | Default      |
| ------------------------------- | -------------------------------------------------------------------- | ------------ |
| `FACTORIAL_API_KEY`             | Your FactorialHR API key                                             | Required     |
| `FACTORIAL_API_VERSION`         | API version                                                          | `2026-07-01` |
| `FACTORIAL_EMPLOYEE_ID`         | Your employee id: default target and ungated identity for attendance | -            |
| `FACTORIAL_TIMEOUT_MS`          | Request timeout (ms)                                                 | `30000`      |
| `FACTORIAL_MAX_RETRIES`         | Max retry attempts                                                   | `3`          |
| `DEBUG`                         | Enable debug logging                                                 | `false`      |
| `FACTORIAL_OAUTH_CLIENT_ID`     | OAuth2 client ID (for downloads)                                     | -            |
| `FACTORIAL_OAUTH_CLIENT_SECRET` | OAuth2 client secret (for downloads)                                 | -            |
| `FACTORIAL_OAUTH_REFRESH_TOKEN` | OAuth2 refresh token (for downloads)                                 | -            |

## Safety & Security

### Operations That Require Confirmation

The following operations require explicit confirmation (`confirm: true`). Called without it, the tool returns a warning describing the impact and makes no change. Risk is classified per operation in `src/write-safety.ts`; everything below is gated regardless of whether it is rated high or medium:

- `factorial_employees({ action: 'terminate' })` - Terminates an employee
- `factorial_teams({ action: 'delete' })` - Permanently deletes a team
- `factorial_locations({ action: 'delete' })` - Permanently deletes a location
- `factorial_projects({ action: 'delete' })` - Permanently deletes a project
- `factorial_projects({ action: 'delete_task' })` - Deletes a project task
- `factorial_projects({ action: 'delete_time' })` - Deletes a time record
- `factorial_projects({ action: 'remove_worker' })` - Deletes an employee's assignment to a project
- `factorial_attendance({ action: 'delete' })` - Deletes a shift record
- `factorial_time_off({ action: 'cancel' })` - Cancels a leave request
- `factorial_time_off({ action: 'reject' })` - Rejects a leave request
- `factorial_training({ action: 'delete' })` - Deletes a training program and its enrollments
- `factorial_training({ action: 'delete_session' })` - Deletes a training session
- `factorial_training({ action: 'unenroll' })` - Deletes an employee's training enrollment
- `factorial_ats({ action: 'delete_posting' })` - Deletes a job posting and its applications
- `factorial_ats({ action: 'delete_candidate' })` - Permanently deletes a candidate
- `factorial_ats({ action: 'delete_application' })` - Permanently deletes an application

### Operations Gated by a Confirmation Token

Attendance writes are gated by **who they target and how many records they touch**, which a per-operation policy cannot express. A first call writes nothing and returns a preview that names the person, the dates and the totals, plus a `confirmation_token` valid for fifteen minutes and bound to exactly that plan. Repeating the call with the token executes it; if the plan changed in between (someone wrote a shift), the token is refused and a new preview is issued. `confirm: true` cannot bypass this gate, because there is no token to pass on a first call.

Gated whenever the target is not the configured `FACTORIAL_EMPLOYEE_ID`, and always when that variable is unset:

- `factorial_attendance({ action: 'create' })` - Creates a shift for another person
- `factorial_attendance({ action: 'update' })` - Updates another person's shift (the shift is fetched first to learn whose it is)
- `factorial_attendance({ action: 'delete' })` - Deletes another person's shift, in addition to `confirm: true`
- `factorial_attendance({ action: 'clock_in' })` - Clocks another person in
- `factorial_attendance({ action: 'clock_out' })` - Clocks another person out

Always gated, whatever the target, because volume is its own hazard:

- `factorial_attendance({ action: 'log_range' })` - Writes one or more shift records per workable day in a range
- `factorial_attendance({ action: 'log_days' })` - Writes an explicit list of days
- `factorial_attendance({ action: 'create_edit_request' })` - Files a request to change a timesheet, which notifies a person, so it is gated even for the configured identity

Re-running a bulk call after a partial failure is safe against its own earlier writes: the planner re-reads existing shifts and skips whatever overlaps. It does not protect against another writer between the read and the writes.

### Document Downloads

Document names come from Factorial metadata rather than from the caller, so downloads treat them as untrusted:

- The name is reduced to a single path segment, so a name containing path separators cannot redirect the download outside `output_dir`.
- Control characters are dropped and over-long names are shortened, keeping the extension.
- **Nothing is ever overwritten.** If the target name is already taken, the file is saved as `name (1).ext`, `name (2).ext` and so on. This protects existing files in `output_dir` and stops two documents that share a name from collapsing into one.

Downloads still write wherever you point `output_dir`, so point it at a directory meant for downloads rather than a source tree or your home directory.

### Read-Only Categories

Some categories are intentionally read-only for security:

- **Payroll**: Supplements, tax identifiers, family situations
- **Documents**: Folder and document metadata (download tools available for payslips and documents)
- **Contracts**: Historical contract data

### Response Optimization

Document and contract list operations return **summary format** by default to prevent token overflow:

**Documents** (`factorial_documents({ action: 'list' })`):

- Returns: `id`, `name`, `folder_id`, `employee_id`, `mime_type` (5 fields)
- Default limit: 100 documents per page
- For full details: Use `factorial_documents({ action: 'get', id: X })` for complete metadata

**Contracts** (`factorial_contracts({ action: 'list' })`):

- Returns: `id`, `employee_id`, `job_title`, `effective_on` (4 fields)
- Default limit: 100 contracts per page

All list operations accept `page` and `limit` parameters for pagination control.

### Audit Logging

All write operations (create, update, delete, approve, reject) are recorded with:

- Timestamp
- Operation type
- Entity type and ID
- Changes made
- Success or failure, and duration

**Scope of this log.** The trail is held in memory in the running server process, capped at the most recent 1000 entries, and is not exposed through any tool or resource. It is lost when the process exits. Set `DEBUG=true` to have each entry written to the server's stderr, which is the only way to retain it today. It is a debugging aid, not a compliance record: if you need a durable, queryable audit trail, use FactorialHR's own activity log as the system of record.

## Development

```bash
# Clone the repository
git clone https://github.com/t4dhg/mcp-factorial.git
cd mcp-factorial

# Install dependencies
npm install

# Build
npm run build

# Run tests
npm test

# Run tests with coverage
npm run test:coverage

# Lint
npm run lint

# Format
npm run format

# Run locally
FACTORIAL_API_KEY=your-key npm start

# Test with MCP Inspector
npx @modelcontextprotocol/inspector
```

### Project Structure

The codebase is organized into domain-based modules for maintainability:

```
src/
├── schemas/           # Zod schemas by domain
│   ├── employees.ts   # Employee, Team, Location, Contract schemas
│   ├── time-off.ts    # Leave, LeaveType, Allowance, Shift schemas
│   ├── projects.ts    # Project, Task, Worker, TimeRecord schemas
│   ├── training.ts    # Training, Session, Membership schemas
│   ├── ats.ts         # JobPosting, Candidate, Application schemas
│   └── ...
├── api/               # API functions by domain
│   ├── employees.ts   # listEmployees, getEmployee, createEmployee, etc.
│   ├── time-off.ts    # listLeaves, createLeave, approveLeave, etc.
│   ├── projects.ts    # listProjects, createProject, etc.
│   └── ...
├── tools/             # MCP tool registrations by domain
│   ├── employees.ts   # factorial_employees tool registration
│   ├── time-off.ts    # factorial_time_off tool registration
│   ├── index.ts       # Server setup, discovery tool, resources, prompts
│   └── ...
├── index.ts           # Entry point (re-exports from tools/)
├── api.ts             # Re-exports from api/
└── schemas.ts         # Re-exports from schemas/
```

**Adding a new feature:**

1. Add schemas to `src/schemas/{domain}.ts`
2. Add API functions to `src/api/{domain}.ts`
3. Add tool actions to `src/tools/{domain}.ts`
4. Update `src/schemas/index.ts`, `src/api/index.ts` exports if needed
5. Run `npm test` to verify

## Troubleshooting

### API Key Not Working

- Ensure the API key has appropriate permissions
- Check if the key has been revoked or expired
- Verify the key is set correctly in environment variables

### Rate Limiting

The server implements exponential backoff for rate limits. If you're hitting limits frequently:

- Reduce request frequency
- Use pagination with smaller page sizes
- Enable caching by avoiding cache-busting parameters

### Missing Data

- **`hired_on` field**: The FactorialHR API may not populate this for all employees
- **Team membership**: Some employees may not be assigned to teams
- **Empty responses**: Check if the resource exists in your Factorial account

### Document Downloads Not Working

Document downloads **require OAuth2 authentication**. This is a Factorial API limitation - the download endpoint does not accept API key authentication.

If you see an error like:

> "Document download requires OAuth2 authentication"

You need to set up OAuth2 credentials. See [OAuth2 Setup](#oauth2-setup-required-for-document-downloads) above.

**Note**: OAuth2 refresh tokens expire after 1 week. If downloads suddenly stop working, re-authorize and get a new refresh token.

### "Document with ID X not found" Error

The Factorial API's individual document endpoint (`GET /documents/{id}`) has limitations accessing employee-specific documents. This happens because:

1. `list_documents` with `employee_ids` filter correctly returns all employee documents
2. `get_document` by ID cannot access those same documents individually

**Workaround**: Use `download_payslips` action instead of `download` action. The `download_payslips` action uses the document metadata from the list operation directly, bypassing the problematic individual GET endpoint:

```typescript
// This works - uses document list internally
factorial_documents({
  action: 'download_payslips',
  employee_id: 123,
  output_dir: '/path/to/downloads',
});
```

## FAQ

**Q: Does this expose salary/payroll data?**
A: Payroll data (supplements, tax identifiers, family situations) is available read-only. No write operations for payroll are supported.

**Q: Can Claude modify data in Factorial?**
A: Yes! Full CRUD operations are available for employees, teams, locations, time off, projects, training, and recruiting. High-risk operations are clearly marked.

**Q: How is data cached?**
A: Data is cached in-memory with TTLs: employees (5 min), teams (10 min), locations (15 min), contracts (3 min).

**Q: What FactorialHR API version is used?**
A: Version `2026-07-01` by default. Override with `FACTORIAL_API_VERSION` environment variable. Since that version every Factorial identifier (`id` and `*_id` fields) is a string, not a number; treat them as opaque strings.

**Q: Can Claude fill in my registro horario?**
A: Yes. Set `FACTORIAL_EMPLOYEE_ID`, run `gaps` to see the missing days, then `log_range` with your daily segments. The first call returns a preview and a token; the second call writes. See [Attendance and Registro Horario](#attendance-and-registro-horario).

**Q: Are write operations logged?**
A: Yes, every write is recorded by the audit module with a timestamp, entity, changes, and outcome. The log lives in memory in the running process (last 1000 entries) and is not retrievable through the MCP interface, so treat it as a debugging aid rather than a compliance record. See [Audit Logging](#audit-logging).

## Factorial API Quirks and Limitations

The FactorialHR API has some design patterns that differ from typical REST APIs. This MCP server handles these automatically, but understanding them helps when debugging or extending:

### Data Location Quirks

| Data                    | Expected Location                  | Actual Location                                          | Impact                                         |
| ----------------------- | ---------------------------------- | -------------------------------------------------------- | ---------------------------------------------- |
| **Team membership**     | On Employee object (`team_ids`)    | On Team object (`employee_ids`)                          | Use `list_teams` to find an employee's teams   |
| **Job role assignment** | On Employee object (`job_role_id`) | In Contract object (`job_catalog_role_id`)               | Use `get_employee_with_contract` for role info |
| **Salary information**  | On Employee object                 | In Contract object (`salary_amount`, `salary_frequency`) | Use `get_employee_with_contract` for salary    |
| **Job title**           | On Employee object                 | In Contract object (`job_title`)                         | May be null if not set in Factorial            |

### Endpoint Quirks

| Endpoint                       | Quirk                                               | Workaround                                     |
| ------------------------------ | --------------------------------------------------- | ---------------------------------------------- |
| `GET /employees/{id}`          | May return 404 for valid employees                  | Server falls back to listing all and filtering |
| `GET /documents/{id}`          | May return 404 for employee-specific documents      | Use `download_payslips` which bypasses this    |
| `GET /contracts?employee_id=X` | Filtering unreliable                                | Server fetches all and filters client-side     |
| Empty results                  | Returns `{"errors": null}` instead of `{"data": []} | Server handles both formats                    |
| Document download URLs         | Requires OAuth2 (API key does not work)             | Configure OAuth2 credentials for downloads     |

### Field Availability

Some fields may be null even when you expect data:

- **`job_title`**: Only populated if set in employee's contract
- **`manager_id`**: Only populated if reporting structure is configured
- **`seniority_calculation_date`**: Use this instead of the non-existent `hired_on` field
- **Document metadata** (`name`, `mime_type`, `size_bytes`): May be null for some documents

### Salary Data

Salary information is available in the **Contract** entity, not the Employee entity:

```
salary_amount: number (in cents, e.g., 7000000 = €70,000)
salary_frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly'
```

Use `get_employee_with_contract` to retrieve employee data with their latest salary information.

### Best Practices

1. **To get an employee's job role**: Use `get_employee_with_contract` instead of `get_employee`
2. **To find employees by role**: Use `list_employees_by_job_role` with a job role ID
3. **To find an employee's teams**: Query `list_teams` and check `employee_ids` arrays
4. **For salary data**: Always use contract endpoints, not employee endpoints

## Contributing

Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/t4dhg/mcp-factorial/blob/HEAD/CONTRIBUTING.md) for guidelines.

## License

MIT © [Taig Mac Carthy](https://taigmaccarthy.com/)

---

_Built with the [Model Context Protocol](https://modelcontextprotocol.io/) by Anthropic_

