factorial_discover
Discover FactorialHR Tools
List available FactorialHR tool categories and their actions. Use this first to understand what operations are available.
category
io.github.t4dhgPublished by GitHub account t4dhgv12.0.0Updated Nov 4, 2026
Full CRUD MCP server for FactorialHR: employees, teams, time off, projects, ATS, payroll.
Local MCP server that lets an assistant read and manage FactorialHR HR data: employees, teams, time off, attendance, projects, ATS and payroll.
Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.
Follow the launch instructions in the repository.
The definitive Model Context Protocol server for FactorialHR
[License: MIT] [CI] [codecov] [TypeScript] [Node.js] [npm version] [MCP Compatible]
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.
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.
Example Usage:
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.
"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 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.
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.
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.
Create a .env file in your project root:
Or pass it directly in the MCP config:
Once configured, ask Claude things like:
You'll need a FactorialHR API key to use this MCP server. Here's how to get one:
.env file or MCP configurationImportant: 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.
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.
http://localhost:8080/callback (or any URL you can access)Open this URL in your browser (replace YOUR_CLIENT_ID):
?code=AUTHORIZATION_CODERun this curl command (replace placeholders):
You'll get a response with access_token and refresh_token. Save the refresh_token.
Add OAuth2 credentials to your MCP configuration:
Or add to your .env file:
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 employeefactorial_teams({ action: 'delete' }) - Permanently deletes a teamfactorial_locations({ action: 'delete' }) - Permanently deletes a locationfactorial_projects({ action: 'delete' }) - Permanently deletes a projectfactorial_projects({ action: 'delete_task' }) - Deletes a project taskfactorial_projects({ action: 'delete_time' }) - Deletes a time recordfactorial_projects({ action: 'remove_worker' }) - Deletes an employee's assignment to a projectfactorial_attendance({ action: 'delete' }) - Deletes a shift recordfactorial_time_off({ action: 'cancel' }) - Cancels a leave requestfactorial_time_off({ action: 'reject' }) - Rejects a leave requestfactorial_training({ action: 'delete' }) - Deletes a training program and its enrollmentsfactorial_training({ action: 'delete_session' }) - Deletes a training sessionfactorial_training({ action: 'unenroll' }) - Deletes an employee's training enrollmentfactorial_ats({ action: 'delete_posting' }) - Deletes a job posting and its applicationsfactorial_ats({ action: 'delete_candidate' }) - Permanently deletes a candidatefactorial_ats({ action: 'delete_application' }) - Permanently deletes an applicationAttendance 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 personfactorial_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: truefactorial_attendance({ action: 'clock_in' }) - Clocks another person infactorial_attendance({ action: 'clock_out' }) - Clocks another person outAlways 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 rangefactorial_attendance({ action: 'log_days' }) - Writes an explicit list of daysfactorial_attendance({ action: 'create_edit_request' }) - Files a request to change a timesheet, which notifies a person, so it is gated even for the configured identityRe-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 names come from Factorial metadata rather than from the caller, so downloads treat them as untrusted:
output_dir.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.
Some categories are intentionally read-only for security:
Document and contract list operations return summary format by default to prevent token overflow:
Documents (factorial_documents({ action: 'list' })):
id, name, folder_id, employee_id, mime_type (5 fields)factorial_documents({ action: 'get', id: X }) for complete metadataContracts (factorial_contracts({ action: 'list' })):
id, employee_id, job_title, effective_on (4 fields)All list operations accept page and limit parameters for pagination control.
All write operations (create, update, delete, approve, reject) are recorded with:
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.
Use Node.js 22 or 24 for development; .nvmrc selects Node 24. The installed server requires Node.js 20+.
The codebase is organized into domain-based modules for maintainability:
Adding a new feature:
src/schemas/{domain}.tssrc/api/{domain}.tssrc/tools/{domain}.tssrc/schemas/index.ts, src/api/index.ts exports if needednpm test to verifyThe server implements exponential backoff for rate limits. If you're hitting limits frequently:
hired_on field: The FactorialHR API may not populate this for all employeesDocument 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 above.
Note: OAuth2 refresh tokens expire after 1 week. If downloads suddenly stop working, re-authorize and get a new refresh token.
The Factorial API's individual document endpoint (GET /documents/{id}) has limitations accessing employee-specific documents. This happens because:
list_documents with employee_ids filter correctly returns all employee documentsget_document by ID cannot access those same documents individuallyWorkaround: 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:
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.
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.
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:
Some fields may be null even when you expect data:
job_title: Only populated if set in employee's contractmanager_id: Only populated if reporting structure is configuredseniority_calculation_date: Use this instead of the non-existent hired_on fieldname, mime_type, size_bytes): May be null for some documentsSalary information is available in the Contract entity, not the Employee entity:
Use get_employee_with_contract to retrieve employee data with their latest salary information.
get_employee_with_contract instead of get_employeelist_employees_by_job_role with a job role IDlist_teams and check employee_ids arraysContributions are welcome! Please see CONTRIBUTING.md for guidelines.
MIT © Taig Mac Carthy
Built with the Model Context Protocol by Anthropic
Source: README.md at commit 8f7585f
Discover FactorialHR Tools
List available FactorialHR tool categories and their actions. Use this first to understand what operations are available.
categoryFactorialHR Employees
Manage employees: list, get, search, create, update, terminate
idpageemaillimitqueryactionrequiredgenderconfirmteam_idlast_namefirst_namemanager_id+5 moreFactorialHR Teams
Manage teams: list, get, create, update, delete
idnamepagelimitactionrequiredconfirmlead_idsdescriptionemployee_idsFactorialHR Locations
Manage locations: list, get, create, update, delete
idcitynamepagelimitstateactionrequiredconfirmcountrypostal_codephone_numberaddress_line_1+1 moreFactorialHR Contracts
Contract and salary data: list, get with employee, filter by job role/level
pagelimitactionrequiredemployee_idjob_role_idjob_level_idFactorialHR Time Off
Manage leave requests: list, get, create, update, cancel, approve, reject, view types and allowances
idpagelimitactionrequiredconfirmhalf_daystart_onfinish_ondescriptionemployee_idleave_type_idFactorialHR Attendance
Attendance (registro horario): list/get/create/update/delete shift records, clock_in, clock_out and status for live clocking, gaps and audit to find days that do not match the contract, log_range / log_days to enter many days at once, and list_edit_requests / create_edit_request for days whose timesheet has already been signed off. Without date and time, clock_in and clock_out record the current moment; given both, they record the declared moment instead, for someone who forgot to clock and knows when they started or stopped. A declared moment in the future is refused before anything is sent, and clock_out with a declared moment is refused if nothing is open or if it would close a shift before it started. Bulk writes, writes for another employee and edit requests preview first: the call returns a plan and a confirmation_token, and nothing is written until the identical call is repeated with that token within 15 minutes. audit, gaps and every preview begin with a Data read line saying how much of the window was actually read; audit reports a day Factorial returned nothing for as no_contract_data, and log_range never writes such a day (log_days, which takes explicit dates, will). Times are HH:MM in company local time, applied by Factorial in the company zone; the server never converts. Declared time versus entry time: a shift carries two independent sets of timestamps. The declared working time is date, clock_in, clock_out and, for a shift that crosses midnight, reference_date; these are inputs, and they are what the attendance sheet and the hour totals show. Separately, Factorial sets created_at, updated_at, in_source and out_source on the server to record when and how the record was entered; they are read-only through this API and no action here can set or change them. For example, a record written on 2026-09-07 for 2024-01-02 09:59 comes back with date: 2024-01-02, clock_in: 09:59, created_at of 2026-09-07 and in_source: api; that is the correct and only correct o
ididsdatedayspagetimelimitactionrequiredend_onfieldsformatreason+23 moreFactorialHR Documents
Document management: list folders, list/get documents, search, download (OAuth2 required for downloads)
idpagelimitactionrequiredfolder_idoutput_diremployee_idemployee_namedocument_patternFactorialHR Job Catalog
View job roles and levels
idactionrequiredFactorialHR Projects
Project management: projects, tasks, workers, time records. Full CRUD operations.
iddatenamepagelimitactionrequiredstatusconfirmends_onminutesstarts_onproject_id+3 moreFactorialHR Training
Training programs, sessions, and enrollments. Full CRUD operations.
idnamepagelimitactionrequiredconfirmends_onlocationstarts_ondescriptionemployee_idtraining_id+1 moreFactorialHR Work Areas
Manage work areas within locations
idnamepagelimitactionrequireddescriptionlocation_idFactorialHR ATS (Recruiting)
Applicant tracking: job postings, candidates, applications, hiring stages. Full CRUD.
idpageemaillimitphonetitleactionrequiredstatusconfirmlast_namefirst_namedescription+3 moreFactorialHR Payroll
View payroll data: supplements, tax identifiers, family situations (read-only)
idpagelimitactionrequiredemployee_id90% of the applicable checks met
How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.
Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.
// List all employeesfactorial_employees({ action: 'list', page: 1, limit: 50 });
// Get a specific employeefactorial_employees({ action: 'get', id: 123 });
// Search employeesfactorial_employees({ action: 'search', query: 'john' });
// Create a leave requestfactorial_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 categoryfactorial_discover({ category: 'employees' });// Find the missing days firstfactorial_attendance({ action: 'gaps', start_on: '2026-03-01', end_on: '2026-03-31' });
// Preview a month of split days; nothing is written yetfactorial_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 itfactorial_attendance({ action: 'log_range', /* same arguments */ confirmation_token: '<token>' });
// Afterwards, reconcile the monthfactorial_attendance({ action: 'audit', start_on: '2026-03-01', end_on: '2026-03-31' });{ "mcpServers": { "factorial": { "command": "npx", "args": ["-y", "@t4dhg/mcp-factorial"] } }}FACTORIAL_API_KEY=your-api-key-here{ "mcpServers": { "factorial": { "command": "npx", "args": ["-y", "@t4dhg/mcp-factorial"], "env": { "FACTORIAL_API_KEY": "your-api-key-here" } } }}https://api.factorialhr.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8080/callback&response_type=codecurl -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'{ "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" } } }}FACTORIAL_API_KEY=your-api-keyFACTORIAL_OAUTH_CLIENT_ID=your-client-idFACTORIAL_OAUTH_CLIENT_SECRET=your-client-secretFACTORIAL_OAUTH_REFRESH_TOKEN=your-refresh-token# Clone the repositorygit clone https://github.com/t4dhg/mcp-factorial.gitcd mcp-factorial
# Install dependenciesnpm ci
# Enable local pre-commit checksnpm run setup:hooks
# Buildnpm run build
# Run testsnpm test
# Run tests with coveragenpm run test:coverage
# Lintnpm run lint
# Formatnpm run format
# Run locallyFACTORIAL_API_KEY=your-key npm start
# Test with MCP Inspectornpx @modelcontextprotocol/inspectorsrc/├── 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/// This works - uses document list internallyfactorial_documents({ action: 'download_payslips', employee_id: 123, output_dir: '/path/to/downloads',});salary_amount: number (in cents, e.g., 7000000 = €70,000)salary_frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly'