Skip to main content
Glama
README.md
# woffu-adapter-mcp

An MCP server for [Woffu](https://www.woffu.com/): clock in the days you have left,
see what time off you have and when it expires, and tell how much of your team is
actually around next sprint.

Ask your agent, in whatever words you use:

- _"Which days haven't I clocked in this month?"_ — then confirm, and it fills them.
- _"How many holidays do I have left, and when do they expire?"_
- _"Book me the 14th of August off"_ — then confirm, and it files the request.
- _"Who is off in August?"_ / _"How much of the team is around next sprint?"_
- _"Is Maria away the week of the 20th?"_
- _"When is the next public holiday?"_

Nothing is written to your timesheet or filed on your behalf without you seeing it first.

## Tools

Read-only unless it says otherwise.

| Tool                                          | What it answers                                                                                                                                        |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `list_pending_days(month?)`                   | Workdays with no hours logged, each with the exact time ranges that would be sent. Plus `lockedDays`: unfilled days Woffu no longer lets you edit.       |
| `clock_in_days(dates[], ranges?)`             | **Writes.** Clocks in the days you name. Every one is re-checked against Woffu first, and refused with a reason rather than a blanket "not pending".     |
| `get_time_off_balance()`                      | What is left in each bucket **and when it expires** — soonest first, because whatever is left in a bucket is lost on that date.                          |
| `list_time_off_requests(status?)`             | Your requests: `pending`, `approved` or `denied`. Each carries its `requestId`, which is what edit and cancel take.                                      |
| `request_time_off(type, from, to?, startTime?, endTime?, comment?)` | **Writes.** Files a request for a leave type, validated against Woffu first. Whole-day types take a date range; hourly types take a day and a time span. |
| `edit_time_off_request(requestId, from?, to?, startTime?, endTime?, comment?)` | **Writes.** Changes a request's dates, times or comment. The leave type cannot change — cancel and file anew for that.                     |
| `cancel_time_off_request(requestId)`          | **Writes.** Withdraws a request; a pending one is deleted and its balance returned.                                                                      |
| `list_holidays(from?)`                        | The public holidays ahead. One costs no days from any bucket, and one next to a weekend makes a long weekend.                                            |
| `get_team_availability(from?, to?, team?, person?)` | How many days each teammate is around over a range, and how many they are off. For sizing a sprint before committing to it.                        |

### Clocking in

`clock_in_days` needs an explicit list of dates — there is no "clock in everything",
so you always see what is about to be logged. `ranges` overrides the schedule when you
want specific hours (`[{"in": "09:00", "out": "13:30"}, ...]`); omit it and each day
uses the theoretical schedule Woffu itself proposes, which is nearly always what you
meant. A weekend, a public holiday, an absence, a day already logged or one Woffu has
locked is refused **by name**, so you can tell which of those it was.

Working a public holiday is possible in Woffu, but only by filing a "work on a holiday"
request. That is not implemented, so holidays are simply refused.

### Requesting time off

`request_time_off` files a request for a leave type by name — the same names
`get_time_off_balance` lists (`"Vacaciones"`, `"Horas Ajuste Calendario"`...). Whole-day
types take `from` and an optional `to`; the day count is whatever Woffu charges after its
own weekends and holidays, so a Friday-to-Monday range costs two days, not four. Hourly
types take a single `from` with `startTime` and `endTime` instead.

Every request is checked against Woffu **before** anything is written: an overdrawn
balance, a weekend or a public holiday is refused with a reason rather than filed. Types
that need a supporting document, and half-day requests, are out of scope — file those in
Woffu directly.

`edit_time_off_request` and `cancel_time_off_request` both take a `requestId` from
`list_time_off_requests`. Editing changes dates, times or the comment; the leave type is
fixed, so to change that you cancel and file a new one. Cancelling a pending request
deletes it and returns the balance it held.

### Team availability

Per person: `workingDays` in the range (weekends and public holidays already out),
`daysOff` already approved, `pendingDaysOff` still awaiting approval, and
`availableDays` — working days minus approved time off. `totals` carries the whole
team's capacity in person-days. Least-available first, since those are the people who
constrain the sprint.

Pending absences are **not** subtracted from `availableDays`: they are the risk, not the
plan, so they are reported next to it. Woffu does not count them as absent either.

It reads **your own team** by default — no id, no name, nothing to configure. Name
another team to look at it instead, or a `person` to narrow the answer to one of them.

## Install

Nothing to download. The server is published to npm, so `npx` fetches and runs it — the
package name is the whole prerequisite:

```sh
claude mcp add woffu \
  -e WOFFU_HOST=yourcompany.woffu.com \
  -e WOFFU_USERNAME=you@company.com \
  -e WOFFU_PASSWORD='...' \
  -- npx -y woffu-adapter-mcp
```

`npx -y woffu-adapter-mcp` always pulls the latest published version. Pin it if you would
rather it did not move under you: `woffu-adapter-mcp@1.0.0` takes any published version.

### Configuration

| Variable         | Required | Example                | What it is                                                                                                     |
| ---------------- | -------- | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
| `WOFFU_HOST`     | Yes      | `yourcompany.woffu.com`| Your Woffu domain. Paste the whole URL if it is easier; only the host is kept.                                   |
| `WOFFU_USERNAME` | Yes      | `you@company.com`      | Your Woffu login.                                                                                                |
| `WOFFU_PASSWORD` | Yes      | `********`             | Used once, to request a token. Never stored.                                                                      |
| `WOFFU_TEAM`     | No       | `platform`             | Only to make `get_team_availability` default to a team **other** than your own. Your own is read off your profile. |
| `WOFFU_TZ`       | No       | `Europe/Madrid`        | The office time zone, any IANA name. It decides which instant a 09:00 clock-in lands on. Defaults to `Europe/Madrid`. |

The password is only used to request a token and is never stored. The token _is_ cached,
in `~/.woffu-manager/token.json` (mode 600), and Woffu issues it with a one-year lifetime
— so logging in happens once. Delete that file to force a new login.

### GitHub Copilot, in VS Code

Put this in `.vscode/mcp.json` (or the user-level one, via `MCP: Open User
Configuration`), then hit the `Start` action the file shows above the entry. Copilot
says `servers`, not `mcpServers`, and a local server has to declare `"type": "stdio"`.

The password goes in as an **input**, so VS Code asks once, masks it and keeps it in the
OS keychain — which means the file can be committed with the rest of the project without
a password in it:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "woffu-password",
      "description": "Woffu password",
      "password": true
    }
  ],
  "servers": {
    "woffu": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "woffu-adapter-mcp"],
      "env": {
        "WOFFU_HOST": "yourcompany.woffu.com",
        "WOFFU_USERNAME": "you@company.com",
        "WOFFU_PASSWORD": "${input:woffu-password}"
      }
    }
  }
}
```

The tools show up under the 🛠️ icon in Copilot Chat, in **Agent mode**.

### Any other MCP client

```json
{
  "mcpServers": {
    "woffu": {
      "command": "npx",
      "args": ["-y", "woffu-adapter-mcp"],
      "env": {
        "WOFFU_HOST": "yourcompany.woffu.com",
        "WOFFU_USERNAME": "you@company.com",
        "WOFFU_PASSWORD": "..."
      }
    }
  }
}
```

Clone it only to work _on_ it:

```sh
git clone git@github.com:alechagonzalo/woffu-adapter-mcp.git
cd woffu-adapter-mcp
npm install && npm test
```

## What counts as "pending"

Two layers, because neither is enough on its own:

1. **From the month summary:** weekends, holidays, events, **absences**
   (`absenceEvents` / `pendingAbsenceEvents`: leave, adjustment days, sick leave),
   future days and days with hours already worked are all excluded. `isUserEditable`
   is the hard gate — Woffu refuses writes wherever it is `false`.
2. **From the day's slots:** a day that was clocked in but not yet confirmed **still
   shows zero hours in the summary**. What gives it away is a real `signId` on its
   slots. Without this second check the same day would be clocked in twice.

Today is included, even mid-workday: its full theoretical schedule gets logged.

## The Woffu API, in case it needs revisiting

- `POST /api/svc/accounts/authorization/token` — OAuth2 login (`grant_type=password`).
  Returns `accessToken` (a JWT carrying the `UserId`, which is where we read it from)
  and `expiresIn`.
- `GET /api/svc/core/diariesquery/users/{id}/diaries/summary/presence` — days in a month.
- `GET /api/svc/core/diariesquery/diarysummaries/{diarySummaryId}/workday/slots/self` —
  a day's exact time ranges. The summary only gives first entry, last exit and total
  break; it never says _where_ the break falls. Only this endpoint knows a workday is
  09:00–13:30 plus 14:00–17:53. The slots come back with `signId: 0`: they are the
  proposal the UI preloads, not actual clocked time.
- `PUT /api/svc/core/users/{id}/diarysummaries/workday/slots/self` — saves. Times travel
  **in UTC**, `signType` goes from 1 (proposal) to **3** (manual clock-in, which is how
  Woffu displays them), and `signStatus` to **1**. It answers **200 with an empty body**.
- `GET /api/users/calendar-events/next` — upcoming holidays. `Type` is 2 on every one
  seen so far; other values are surfaced rather than assumed to be holidays.
- `GET /api/user-agreement-events/availability` — time-off balance per bucket. Carries
  no dates.
- `POST /api/users/allocations/row` — the allocations behind one bucket, and **the only
  place the expiry date lives**. Body: `{agreementEventId, pageIndex, pageSize,
  createdOnFilter: {filterType: "CurrentCycle"}, affectedCycleFilter: "CurrentCycle"}`.
  The cycle filter matters: `/allocations/table` reports across cycles and disagrees
  with the availability endpoint (12 days vs 9), while the current cycle agrees.
- `GET /api/users/{id}` — the profile. **`DepartmentId` is the user's team**, and it is
  the same number the calendar wants as an `EntityId`, so nobody has to configure their
  own team. `DepartmentName` comes back empty; only the calendar spells the name out.
- `GET /api/scope/search?query=&types=999678` — resolves a team name to the
  `{EntityId, EntityType}` pair the calendar wants; a team answers with `EntityType` 32.
  `types` is a bitmask over the scopes the calendar's own search box asks for.
- `POST /api/svc/core/calendar` — the shared calendar: one entry per person, one per day,
  already clipped to the range. **`visibilities` is not optional**: with an empty list
  Woffu answers with the whole company rather than the team. `keyword` matches a person's
  name. `requestStates: 3` brings back approved _and_ pending absences. No total comes
  back, so paging runs until a short page.
  **`eventDuration` / `absenceEventsDuration` are an enum, not a count of days**: `1` is a
  full day and `2` is an hours-only absence (`isTimeOnly: true`, `startDate` "09:00" rather
  than a date). Summing them books a half-day as two days off — `isTimeOnly` is what tells
  them apart. Absences do land on weekends and holidays, and cost nobody a day.
  `presenceEvents` is telework: the person is still available.
- `POST /api/svc/core/calendar/totals-simple` — presents/absents per day. Unused: it counts
  every weekend as "everyone absent". The per-person calendar above agrees with it exactly
  on working days, once the same absences are counted.
- `GET /api/users/requests/list?statusType=` — time-off requests. `statusType` is
  **10 pending, 20 approved, 30 denied**; it is required, so all three are fetched to
  list everything. Amounts arrive as `{Resource, Values}`, where the resource name is
  the only hint at the unit, and hours come split as `["8", "30"]` meaning 8h30m.

`scripts/inspect.mjs /api/some/endpoint` dumps any endpoint using the cached token —
useful when reverse-engineering a new surface.

`npm test` covers the filters, the day-counting rules, and that the payload matches,
field for field, the one the web app sends.

TDQS

A4.6/5.0

Scored across 9 tools

Disambiguation5/5

Each tool targets a distinct action (cancel, clock in, edit, get availability, get balance, list holidays, list pending, list requests, request) with no overlap. An agent can easily differentiate them.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern in snake_case (e.g., cancel_time_off_request, list_holidays), making them predictable and easy to remember.

Tool Count5/5

Nine tools is a well-scoped set for a time-off and attendance manager, covering essential operations without unnecessary bloat.

Completeness4/5

The toolset covers core CRUD for time-off requests, clock-in for pending days, and support data (holidays, balances). Minor gaps exist: no ability to view past clocked days or edit clock-in entries, but the main workflows are supported.

Maintenance

ActivitySlowing
ResponsivenessNo issues