Skip to main content
Glama
dragosh29

StaffSavvy MCP Server

by dragosh29

StaffSavvy MCP server

An MCP server that lets Claude, ChatGPT and other MCP clients read a StaffSavvy rota and time-and-attendance account: venues, groups (skills or roles), shifts, absences and absence types, time entries, venue events and staff names. It is built from StaffSavvy's public documentation: the OpenAPI 3.0 document "StaffSavvy REST API" 1.1.0 published on SwaggerHub (SmartBlue/StaffSavvy) and the support article StaffSavvy Open API.

Once it's connected, a manager can ask things like:

  • "Who's working in the Main Theatre on Saturday, and in which roles?"

  • "Who is off this week, and is it sickness or holiday?"

  • "What events are on at the Studio in October, and how many shifts are rostered for the Macbeth performance?"

  • "Show Sam Evans's clocked hours for last week and whether they've been approved."

  • "Which staff records changed since the start of the month?"

This version is read-only. There are no write tools at all (see "Why there are no write tools" below), and no tool returns pay data: /salaries and /wagesheets are never called, and the pay elements that StaffSavvy includes in time-entry responses are dropped, never returned.

Tools

Tool

What it does

API calls

get_info

Checks that the credentials work and returns the software and API version numbers. The API has no endpoint that says which user the credentials belong to.

GET /auth (first call only), GET /info

list_venues

Venues with the groups and shift actions allowed at each.

GET /venues

list_groups

Groups (skills or roles) with id and title.

GET /groups

list_shifts

Shifts filtered by date range (on the shift start, whole days), venue, group and venue event, with the staff member's name.

GET /shifts, GET /accounts (names)

get_shift

One shift by ID.

GET /shifts/{shiftid}, GET /accounts (name)

list_absences

Absences and holidays with the staff member's name, type, dates and hours, filtered by staff member and by date range, and optionally including deleted or rejected ones (marked deleted_or_rejected: true). The date range finds absences that overlap it, including ones that started up to lookback_days (default 31) before from; see below.

GET /absences, GET /accounts (names)

list_absence_types

Absence types with id, title and whether each is an absence or a holiday.

GET /absence-types

list_time_entries

Clocked time with staff member, start, end, venue, role, status, approval and notes, filtered by date range (on the entry start, whole days), staff member and venue event.

GET /time-entries, GET /accounts (names)

list_venue_events

Events at venues with date, start hour, venue, title and cost codes, filtered by date range and venue.

GET /venue-events

list_accounts

Staff accounts: name, known-as name, default venues; email, phone and legal name only on request. Filters by first name and last name (the documented equals filter), a list of IDs, or changed since a date.

GET /accounts

Every list tool takes page (where to start) and max_pages (how many API pages to fetch, default 4, at most 20). StaffSavvy sets the page size; a result with complete: false names the next_page to continue from, or, when the listing cannot be continued, says why in note.

Date filters. Shift and time-entry starts are date-times, so from/to are sent as whole days in the date-time "between" form the docs show for /shifts (start=[2026-10-03 00:00:00~2026-10-03 23:59:59]), with lte:2026-10-03 23:59:59 for to alone and the documented gte:2026-10-03 for from alone. Absence starts and venue-event dates are dates, so those use the date form ([2026-10-01:2026-10-31]). StaffSavvy documents absence filters on the start only, so an absence that began before from (Monday's "who is off this week" when someone's leave started on Friday) would be missed by a plain start filter. list_absences therefore asks for absences starting from lookback_days before from up to to, drops the ones whose period_end is before from, and says in range_note how many it dropped. An absence that started more than lookback_days before from (long-term sickness, parental leave) is not found unless lookback_days is raised (at most 366). In that mode total is left out, because StaffSavvy's total counts the wider start range.

The staff-name lookup uses the documented "one of the options" filter on /accounts (id=[101,102,103]), 50 IDs per query, following each query's pages to the end, and can be switched off with include_staff_names: false. If the API user may not read accounts (a 403, or a 400), the tool still answers, with staff IDs only and a note. If the lookup is cut short (the time budget ran out, or the paging could not be followed), the note names the IDs that were not looked up; only when the lookup finished does it say that no account record was returned for an ID.

Not covered on purpose: /salaries, /wagesheets/* and the pay elements on time entries and groups (pay data, never exposed); /reports/* and /report/* (custom reports); /my/shifts (the API user's own shifts); the shift and time-entry histories; the /help endpoints; get-by-id for absences, time entries, venue events and accounts; /shift-actions; and every PUT and PATCH.

Why there are no write tools

The candidates were creating an absence and accepting or rejecting a shift. Neither is documented clearly enough to ship without testing on a real account:

  • PUT /absences/new takes everything as query parameters. It requires repeat, whose values are not documented; it has no parameter that says whose absence it is; absence-type is typed as a string although absence types have integer IDs; and the only documented answers are 201 ("absence updated") and 304, with no body.

  • PATCH /my/shifts/{shiftid}/action takes a required _action query parameter typed as an object with accept, reject and request-cover booleans, without saying how that object is written into the URL.

A write that sends the wrong thing to a live rota is worse than no write, so these wait for an account to test on (see "Going to production"). STAFFSAVVY_ALLOW_WRITES is accepted but has no effect in this version.

Related MCP server: Tipsoi MCP

Setup

Requires Node 18 or later.

npm install
npm run build

You need an API User and API Key for your StaffSavvy instance. From the support article: give the account's level the API permissions it needs (System > Levels & Permissions > Manage Permissions; a dedicated level can be set to "API only" so the account cannot log in to the main interface), then open My Account > API Access as that account to see the API User and API Key. The same page can restrict the key to certain IP addresses, and a replaced key keeps working for 14 days unless it is cancelled.

The base URL is your instance's address followed by /api/v1 (the article: "The API endpoint is [your instance url]/api/v1/").

Claude Desktop: add this to claude_desktop_config.json:

{
  "mcpServers": {
    "staffsavvy": {
      "command": "node",
      "args": ["/absolute/path/to/staffsavvy-mcp/dist/index.js"],
      "env": {
        "STAFFSAVVY_BASE_URL": "https://your-instance-address/api/v1",
        "STAFFSAVVY_API_USER": "your-api-user-id",
        "STAFFSAVVY_API_KEY": "your-api-key"
      }
    }
  }
}

Claude Code:

claude mcp add staffsavvy -e STAFFSAVVY_BASE_URL=https://your-instance-address/api/v1 -e STAFFSAVVY_API_USER=your-api-user-id -e STAFFSAVVY_API_KEY=your-api-key -- node /absolute/path/to/staffsavvy-mcp/dist/index.js

Variable

Required

Meaning

STAFFSAVVY_BASE_URL

yes

Your instance address followed by /api/v1. There is no default. Must be http(s) without a username, password or query string; the server refuses to start otherwise. The tests point it at a local mock.

STAFFSAVVY_API_USER

yes

The API User ID from My Account > API Access, sent as the x-user header to GET /auth.

STAFFSAVVY_API_KEY

yes

The API Key from the same page, sent as the x-auth header to GET /auth.

STAFFSAVVY_ALLOW_WRITES

no

Has no effect: this version has no write tools.

STAFFSAVVY_TOOL_BUDGET_S

no

Seconds a tool call may spend on retry waits and further pages (default 45; see Safety defaults). Lowered by the tests.

Safety defaults

  • Read-only. Every tool carries the MCP readOnlyHint annotation (and destructiveHint: false), and the server only sends GET requests.

  • The API User and API Key are sent only to GET /auth, as the x-user and x-auth request headers, never in a URL. The spec lists them as query parameters on GET /auth; the support article says they "can be passed in the GET, POST or REQUEST HEADER", and headers keep the key out of proxy and server logs. The token that comes back is sent as Authorization: Bearer … on every other request. It is kept until a call answers 401 (the article: tokens "expire after a period of inactivity"), then fetched again once; if the fresh token is refused too, the error says to check the API user's permissions. The API key and every token issued are replaced with [redacted] in any message passed on from StaffSavvy.

  • Staff are identified by ID and name by default. Email addresses, phone numbers and legal names (legal_firstname, middlenames, legal_lastname) are only returned by list_accounts with include_contact_details=true.

  • On absences, the free-text period_Category (the spec's example is "Sinus infection"), period_title and absence_shift_excuse can hold health or other personal information and are only returned with include_contact_details=true. The absence type (for example "Sickness") is returned by default, since that is what the tool is for.

  • Pay data is never returned, with or without include_contact_details: the pay-element and pay-element-value of time entries are dropped, the group formatter reads only id and title (the spec's Groups component carries a pay rate), and /salaries and /wagesheets are never called.

  • In free text (shift arrival information, time-entry notes, status and role titles, staff names, absence shift-repost text) email addresses, phone-number-like sequences, UK postcodes and dates of birth are replaced with [email redacted], [phone redacted], [postcode redacted] and [date of birth redacted] by default, and returned as stored with include_contact_details=true. Venue, group and absence type titles, the absence type's type, and venue event titles, uuid and cost codes are always redacted this way (those tools have no switch). Payment card numbers (13 to 19 digits that pass the Luhn check) are replaced with [card number redacted] whether or not contact details were requested. These are heuristics: the phone match covers international numbers written with + or 00 (including the +44 (0)7700 … form), 44… without a prefix (like the spec's own 447815000000 example), UK numbers with a bracketed area code such as (020) 7946 0958, UK-style 0… numbers of 9 to 11 digits and 10-digit mobiles written without the 0 (7700 900123), with spaces, dots or hyphens between groups; other digit strings of those shapes are redacted too, while numeric IDs, dates and hyphenated references are left alone. Postcodes are matched in capitals only (BH9 2SQ). A date is treated as a date of birth only after "DOB", "date of birth" or "born"; other dates are left alone. Street addresses without a postcode are not detected. The same redaction is applied to StaffSavvy's error messages before they are passed on.

  • Shift records: the spec's Shift schema documents only arrival-info. The server reads id, start, end, venue, group, account, task and event (names inferred from the documented /shifts filters and parameters; see Status). Any other key a shift carries is listed by name in other_fields, never by value.

  • Input is checked before any call: IDs must be positive whole numbers (every path ID in the spec is an integer); dates must be real YYYY-MM-DD calendar dates (2026-02-30 is refused) with from not after to; first and last names may not contain the filter operator characters [ ] : ~ ,.

  • StaffSavvy documents no rate limit. Requests are spaced 250 ms apart (about four per second). A 429 is retried at most twice, waiting for Retry-After (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when the header is absent or unreadable). If StaffSavvy asks for a wait longer than 10 seconds, that request gives up at once and the message says how long to wait, so one request waits at most about 20 seconds. One tool call can make many requests (a token request, up to 20 list pages, the staff-name lookups), so the cap alone does not keep a call under the MCP client's default 60-second request timeout. Each tool call therefore also has a 45-second budget shared by all its requests (STAFFSAVVY_TOOL_BUDGET_S): a retry whose wait would end after it is not attempted; a list that runs out of budget after its first page returns the pages it has with complete: false and the next_page to continue from; a staff-name lookup that runs out returns the records by staff ID with a note; otherwise the call fails saying so. 45 seconds leaves room for the last request under the 60-second default, but a single request that is slow to answer is not cut off, so a very slow StaffSavvy can still make a call time out. The tests check the mechanism with a 3-second budget, not the 60-second outcome.

  • Paging always advances from the page that was asked for. If a response's current is not that page (an API that ignores page would answer page 1 every time), the listing stops with complete: false and a note, and that response's records are dropped, so the same records are never returned twice.

  • 502, 503 and 504 are retried the same way for GET only (the only method this server sends); when all three attempts fail the error says the service may be unavailable and to try again in a few minutes, without the gateway's HTML.

  • A 200 whose body is not a JSON object (a proxy, a login page, a wrong base URL) and a 200 with success: false are reported as errors, never as empty lists. A get-by-id answered with an empty data array is reported as not found, like a 404.

  • Rejected credentials, a wrong base URL (404 on /auth), a 403 and a 400 each produce a message that says what to check.

Tests

npm test

The test suite runs in about 50 seconds:

  1. Validates every fixture record against the schemas in StaffSavvy's published OpenAPI document (Venue, AccountItem and the GET /accounts/{accountid} item, Shift, AbsenceItem, TimeEntry, VenueEventItem, Paging, and the inline items of GET /groups, /absence-types and /info) with Ajv and ajv-formats. The document is downloaded from api.swaggerhub.com/apis/SmartBlue/StaffSavvy/1.1.0 to spec.json on the first run. Because the schemas set no additionalProperties: false, every fixture is also walked key by key against its schema, and a key the schema does not declare fails the check, except for a listed set of inferred keys: the shift fields (the Shift schema declares only arrival-info), id on groups and venue events, and approved on the deleted absence (from the inc-deleted filter note). Shifts are additionally validated against a schema written for this suite from the documented /shifts filter and parameter names, which is an assumption, not StaffSavvy's. Negative controls check that the schemas and the key walk still reject what they should.

  2. Starts a local mock of the API under /api/v1 that serves those fixtures with the documented page pagination (25 per page, the spec's per-page example), the documented filter operators (equals, gte:, lte:, [a:b] and [a~b] between, [a,b,c] one of, and inc-deleted=1) compared strictly (a date-only bound does not reach into that day's date-times, which the suite checks, so the server's filters cannot pass on a lenient reading of the docs), GET /auth with x-user/x-auth headers issuing bearer tokens, 401 on bad credentials or an unknown token, 404 for unknown IDs, 400 for an unexpected query parameter, and 429, 502, 503, HTML, success: false, delayed and other substitute answers when armed (per path, optionally per page). The mock's /auth, list, get-by-id and error responses are validated against the documented response schemas. Error bodies are {success: false, message}: the spec documents no error body, so this shape is an assumption.

  3. Starts the built server and drives it over stdio with the official MCP client: 28 checks covering tools/list and annotations (10 read-only tools, STAFFSAVVY_ALLOW_WRITES=true adding nothing); the token fetched once from GET /auth with header credentials and reused; every tool; paging to paging.last across pages 1, 2 and 3 with no fourth request, requests spaced at least about 250 ms apart, and max_pages/page continuation; the paging fallbacks (no paging object, total-data-count alone, page-count alone, an empty last page) and an API that ignores page; each filter passed through exactly (shift and time-entry start as [from 00:00:00~to 23:59:59], gte:from and lte:to 23:59:59, absence start as [from minus lookback:to], venue, group, event, account, inc-deleted=1, date on venue events, firstname, lastname, id=[…], _last_history_record=gte:…); absences overlapping the range found (leave that started before from), ended ones dropped, and none found with lookback_days: 0; a deleted absence marked by its negative approved; staff names for 60 staff looked up in two id=[…] queries of 50 and 10 IDs, the first spanning two pages, and skipped when switched off; the "no account record" note for an unknown staff ID and the "cut short" note when the lookup cannot finish; contact details, legal names and absence reasons withheld by default and returned on request; redaction of emails and phone numbers in arrival information, notes, names and venue, group, absence type and venue event titles, and names returned as stored on request; one text with a postcode, a date of birth, 44…, 7… and +44 phone numbers, a card number and an email injected into the free-text fields of get_shift, list_venue_events (title, uuid, cost codes), list_time_entries (notes, status, role), list_absence_types (title, type), list_groups, list_venues, list_accounts and list_absences, and into an error message, with none of them coming back by default, and the arrival information returned as stored on request except for the card number; pay elements and values on time entries and a pay rate on a group never returned; unread shift keys named without their values; ID, date and name validation before any call; the 404 and empty-data not-found messages; an expired token fetched again once and a persistently refused token reported; the 429 retry waiting for Retry-After in the seconds, fractional-seconds and HTTP-date forms and 2 s then 4 s without it, giving up after three attempts and at once above the 10 s cap; with a 3-second tool budget, retry waits on /auth and /shifts adding up past the budget (each well under the cap) ending the call, a rate-limited second page, or a slow first page that uses up the budget, returning the first page with complete: false and next_page, and a rate-limited name lookup returning the shifts by staff ID with a note, the rate-limited cases answering in under 3.5 s; a 502 retried and three 503s reported without HTML; non-JSON and success: false 200s reported as errors; an error message with an email, a phone number, the API key and the token passed on redacted and scrubbed; a 403 or a 400 on the name lookup degrading to IDs with a note; wrong credentials and a wrong base URL; start-up refused without the three variables, or with a base URL that is not http(s), carries a username and password (not repeated in the message) or has a query string; and, last, that every request of the whole run (including the wrong-credential and wrong-base-URL ones) was a GET to a documented path, with credentials only as /auth headers, an issued bearer token and no x-user or x-auth header everywhere else, and no salary, wage-sheet or report endpoint called.

Status

This is a working prototype. It has not yet been run against the live API, because it was built without a StaffSavvy account (no self-service trial was found; the pricing page offers a demo). Everything below comes from the published spec and support article and should be confirmed on a real instance:

  • Authentication: that GET /auth accepts x-user and x-auth as request headers (the article says headers, GET or POST variables work; the spec lists query parameters only), that it answers {success, token}, that the token is sent as Authorization: Bearer <token> (the spec's bearerAuth scheme), how long a token lives, and what an expired token returns (the article says tokens expire after inactivity; the server assumes a 401).

  • Error bodies and status codes. The spec documents 401 "Unauthorised" and 400 "bad input parameter" without a body, and no 403, 404 or 429. The server reads a message field when there is one. How an unknown ID is reported (404, 400, or 200 with an empty data array) is not documented; all three are handled.

  • Pagination: that page is 1-based, that current echoes the page asked for (the server stops if it does not), what last and page-count mean (the server treats last as the last page number, falling back to page-count; the spec's own example gives total-data-count 100, per-page 25, page-count 1 and last 10, which do not agree with each other), what the page size is (no page-size parameter is documented), and what a page past the end returns.

  • Shift fields. The Shift schema documents only arrival-info. The fields id, start, end, venue, group, account, task and event are inferred from the /shifts filter examples (group, start, venue), the parameters of PUT /shifts/new and PUT /shifts/{shiftid} (start, end, account, group, venue, task) and the event query parameter; whether they come back as numbers or as objects is unknown (both are handled). A live run shows any other keys in other_fields.

  • GET /venues: the spec's 200 schema has no data array (only allowed-groups, allowed-shift-actions and _available_actions at the top level); the server reads data like every other list. id is not in the GET /groups item schema or in VenueEventItem; the server reads it when present.

  • Filters. The spec's filters parameter says to "use variable name as the parameter", so the server sends start=[2026-10-01:2026-10-07] rather than filters=…. To confirm: that bracketed and colon values are accepted URL-encoded; whether "between" includes both ends; that the date-time forms the server sends for shift and time-entry starts ([… 00:00:00~… 23:59:59], lte:… 23:59:59) are accepted (the ~ form is documented for /shifts, /absences and /venue-events; the /time-entries docs show only the generic [valueStart:valueEnd]); whether an absence can be filtered on its end (none is documented, hence the lookback_days approach); that absences filter on start (the documented examples use start although the record field is period_start); that venue events filter on date (their filter examples are copied from absences and use start); that time entries filter on start; how inc-deleted=1 behaves; that _last_history_record=gte:… works on /accounts; and whether firstname/lastname matching is exact and case-sensitive.

  • The sort order of every list. None is documented; the server returns what the API returns.

  • approved on absences: the AbsenceItem schema does not declare it; the inc-deleted filter note says deleted or rejected absences are "Denoted by a negative approved integer", so it is read when present. What an active absence carries there is not documented.

  • The shape of account on absences and time entries (Account is {id, access} in the spec), and what period_end_least means on an absence (it is passed through as period_end_least).

  • Date-times are documented as local time (YYYY-MM-DD HH:MM:SS); the server passes them through unchanged.

  • What a level without a given API permission returns (403, 401 or success: false), and what an IP-restricted key returns from a non-allowed address.

  • How many requests per second the API tolerates; nothing is documented, so the 250 ms spacing is a guess on the polite side.

Going to production

This version runs locally over stdio, with an API User and Key that each customer creates in their own instance. For managers to connect from claude.ai or ChatGPT without handling keys, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by StaffSavvy, where each instance's own login decides what a user can see, and then a listing in the Claude and ChatGPT connector directories. Write tools (booking absences, accepting or rejecting shifts, creating shifts) can follow once their parameters are confirmed on a test instance.

Licence

MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    B
    maintenance
    Enables Claude, Cursor, and other MCP clients to query PeopleForce HRIS data (employees, time-off, recruitment) via 27 read-only tools.
    28
    4
    MIT
  • F
    license
    A
    quality
    D
    maintenance
    Read-only MCP server for the Tipsoi HRM API, exposing 15 tools to read employee data, attendance, leave, overtime, and more.
    15
    1
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables read-only access to Workday HCM data such as workers, organizations, locations, job profiles, and cost centers through MCP tools an LLM can call.
    -