WorkMobile MCP server
by dragosh29
README.md
# WorkMobile MCP server
An [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT and other MCP clients work with a WorkMobile account: jobs dispatched to field staff, job types, forms and the records submitted on them, approvals and mobile users, and (when enabled) creating and allocating jobs and approving or rejecting records. It is built from WorkMobile's public documentation only: the Web API v2 OpenAPI spec, the WorkMobile help centre's API 2.0 articles, and the Power Automate connector definition that eSAY Solutions Ltd publishes (the source of the response schemas; the v2 spec has none for these endpoints).
Once it's connected, someone in the office can ask things like:
- "Which jobs are booked for Sam tomorrow, and which are still unallocated?"
- "Show me the history of job 70001."
- "What came in on the Gas Meter Inspection form this week where the meter type was Gas or Water?"
- "What's waiting for my approval on the Customer Visit Record form?"
- With writes enabled: "Raise a meter read job at 3 Example Lane for Friday and give it to Sam", "Approve record 30000 on the inspection form."
## Tools
| Tool | What it does | API calls |
|---|---|---|
| `list_jobs` | Searches jobs by status IDs, estimated start date range, the mobile users they are allocated to, job types, description, priority, backlog and whether a duration is set; optional sort by estimated start. Pages of 500 (the smallest page size the API allows). A POST that only reads. | `POST /api/jobs/search` |
| `get_job` | One job: description, status, type, priority, dates, allocation, history (status changes with narrative) and job data. | `GET /api/jobs/{id}` |
| `list_job_statuses` | WorkMobile's static list of job status IDs, returned as WorkMobile sends it (the format is not documented). | `GET /api/jobs/jobstatuslist` |
| `list_job_types` | Job types with the form each uses, default duration and location settings. | `GET /api/jobtypes` |
| `get_job_type` | One job type and the fields a job of that type takes (name, type, required, allowed values, limits), read from its JSON Schema document. | `GET /api/jobtypes/{id}`, `GET /api/jobtypes/{id}/schema` |
| `list_forms` | Forms visible to the API user (sub-forms hidden unless asked). The API returns the whole list without paging. | `GET /api/forms` |
| `list_form_submissions` | Searches the records submitted on one form by created or uploaded date, job, mobile user, user group, or searchable field values with the documented operators; newest first by default. Pages of 500. A POST that only reads. | `POST /api/forms/{FormId}/completedrecords/search` |
| `get_form_submission` | One record by its Id (the search's `id` criterion). | `POST /api/forms/{FormId}/completedrecords/search` |
| `list_approvals` | Without `form_id`: the API user's approval inbox totals, returned as sent (format not documented). With `form_id`: the records on that form pending the API user's approval (`onlyMyPendingApprovals`). | `GET /api/approvals/getinboxtotals` or the records search |
| `list_mobile_users` | Field staff with name, job title, user group name and whether active (inactive users hidden unless asked). | `GET /api/mobileusers`, `GET /api/usergroups` |
| `create_job` | Raises a job of one type, optionally allocated to one mobile user. Fetches the job type's schema first and refuses locally when a data field is unknown, a required field is missing, a value is not an allowed one, a numeric field gets text, or only one of latitude/longitude is given. Only registered when writes are enabled. | `GET /api/jobtypes/{id}/schema`, `POST /api/jobs` (multipart form) |
| `allocate_job` | Allocates or reallocates a job to one mobile user, replacing any previous allocation. Marked destructive. Writes only. | `POST /api/jobs/{JobId}/allocate/{MobileUserId}` |
| `approve_record` | Approves the current approval step of a record as the API user. Cannot be undone. Marked destructive. Writes only. | `POST /api/approvals/approve` |
| `reject_record` | Rejects a record's current approval step. Rejection is terminal: the workflow stops for good. Marked destructive. Writes only. | `POST /api/approvals/reject` |
The v2 API is the whole web application's API (158 operations). Not covered on purpose: the username/password login (`/api/account/authenticate`) and every other account, password, SSO and API-token route; CRM customers and contacts and lone worker event logs (their read endpoints exist, but WorkMobile publishes neither a response schema nor an example for them, so their field names would have to be guessed); record revision and approval history (same reason); reports, media attachments, resources and every other download; track-worker locations; notification history; filtered views, menus and push notifications; user (portal login) management; job editing, closing, unallocation, group broadcast and routing; and every delete endpoint.
## Setup
Requires Node 18 or later.
```bash
npm install
npm run build
```
You need an API token for your WorkMobile account. WorkMobile's help centre recommends it for unattended use: create a portal user with suitably limited access on the Logins page, then click Generate in its API Token section and copy the GUID. The token gives non-expiring access until it is revoked or regenerated. The server sends it in the `X-API-Key` header on every request; it never uses a username or password.
**Claude Desktop:** add this to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"workmobile": {
"command": "node",
"args": ["/absolute/path/to/workmobile-mcp/dist/index.js"],
"env": { "WORKMOBILE_API_KEY": "your-api-token" }
}
}
}
```
**Claude Code:**
```bash
claude mcp add workmobile -e WORKMOBILE_API_KEY=your-api-token -- node /absolute/path/to/workmobile-mcp/dist/index.js
```
| Variable | Required | Meaning |
|---|---|---|
| `WORKMOBILE_API_KEY` | yes | The API token (a GUID) of a portal user, sent as the `X-API-Key` header. |
| `WORKMOBILE_ALLOW_WRITES` | no | `true` to register `create_job`, `allocate_job`, `approve_record` and `reject_record`. Off by default. |
| `WORKMOBILE_BASE_URL` | no | Defaults to `https://www.esayworkmobile.co.uk/webapi2`. Used by the tests; an on-premise instance would set its own. Must not contain a username or password. |
## Safety defaults
- Read-only unless `WORKMOBILE_ALLOW_WRITES=true`. Every read tool carries the MCP `readOnlyHint` annotation, including the ones that call a search endpoint with `POST` (`list_jobs`, `list_form_submissions`, `get_form_submission`, and `list_approvals` with a `form_id`): WorkMobile runs its job and record searches as `POST` requests with a criteria body, and they change nothing. `allocate_job` (it replaces the job's current allocation), `approve_record` and `reject_record` (they move an approval workflow on for good) carry `destructiveHint: true`, since MCP reserves `false` for tools that only add; `create_job`, which only adds a job, is a write that is not destructive.
- Personal data is withheld by default and returned only when a tool is called with `include_contact_details=true`:
- Job data and form answers are keyed by the account's own field names, so fields are classified by name. A field whose name mentions an email, phone, mobile or "mob", postcode, address, street, what3words, customer, client, tenant, resident, patient, witness, next of kin, name (any "name", so "Site Name" too), signature, photo, image, sketch, video, audio, attachment or file, location, GPS, latitude/longitude, date of birth (including "DoB" and "D.O.B"), passport, NHS or national insurance number (including "NINumber" and "NINo"), vehicle registration, injury, medical, medication, allergy, disability, illness or diagnosis, salary, wage, earnings, overtime, pay, paid or rate, or contact/person/owner/driver is replaced with `[withheld: personal data; …]`. Names are matched in any style: PascalCase unique names as WorkMobile's documentation writes them ("MeterType"), acronyms ("GPSCoords"), and labels with spaces, dots or hyphens. "Health" alone is not a trigger, because "Health and Safety" fields are common in field-service forms. Nested values are checked the same way.
- A job's `Location` (its address) is withheld; a mobile user's `Username` (often an email address) is not returned.
- In every other text (job descriptions and history narratives, answers in fields with ordinary names, names and job titles, form descriptions, job type names, WorkMobile's own error messages, the job status list, the approval inbox totals) email addresses become `[email redacted]`, phone-number-like sequences `[phone redacted]`, latitude/longitude pairs `[location redacted]` and UK postcodes `[postcode redacted]`. The phone match is the same heuristic as the other prototypes: international numbers with `+` or `00`, UK numbers with a bracketed area code, and UK-style `0…` numbers of 9 to 11 digits; other digit strings starting with `0` are caught too. The postcode match is upper case only. People's names and street addresses written in free text are **not** recognised: "lives at 14 Example Road" comes back as written (only a postcode after it is redacted), and so does a customer's name in a job description or history narrative.
- A value that is a bare link (`http…` or `www.…`) in job data or form answers is withheld, because it may point to a photo, signature or file.
- Staff names are returned: mobile users' names, `CreatedBy` on records and job history, and the approval columns of a record. A record's `Customer` column, like any field with "customer" in its name, is withheld. A system column (such as `CreatedBy` or `UserGroup`) or a job `Location` that arrives as an object or an array rather than text is walked like job data: its keys are classified by name and its text redacted.
- Never returned, whatever is asked: fields whose name mentions a bank, sort code, IBAN, SWIFT/BIC, account number ("Acc No", "Acct"), card, CVV/CVC, expiry, PAN, last 4 or last four, or payment; any 13-to-19-digit number that passes the Luhn check in any text (replaced with `[card number redacted]`); in any text, an IBAN that passes its mod-97 check, and a sort code, account number or card's last four digits when the words in front name them ("sort code 12-34-56", "acct 12345678", "card ending 4242") (replaced with `[bank details redacted]`; a bare 6- or 8-digit number is left alone); and any embedded file (a `data:` URI, as a signature or photo could be sent) (replaced with `[embedded file omitted; files are never returned]`). No file is ever downloaded: the server never calls the report, media, resource or download endpoints.
- IDs are checked before any call is made: every ID must be a whole number from 1 to 2147483647 (the spec types them as int32, except `FormId` in the path of the records search, which it types as a string; form IDs are int32 everywhere else). Dates take `YYYY-MM-DD` (real calendar dates only; an impossible month or day is refused with "Not a real calendar date"), which becomes the start of that day in UTC for a "from" and the end for a "to", or an ISO 8601 date-time with a zone (`Z` or an offset) and an hour from 00 to 23, as in WorkMobile's documented examples. Field filter operators must be one of the eleven the search documentation lists.
- WorkMobile does not document a rate limit (nothing in the v2 spec, the connector definition or the help centre's API 2.0 articles). Requests are spaced 250 ms apart (about four per second). A 429 is retried at most twice for any method, on the assumption that a rate-limited request was not processed, waiting for `Retry-After` (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when the header is absent or unreadable). Each wait is capped at 10 seconds; if WorkMobile asks for a longer wait the call gives up at once and the message says how long to wait. A tool call that makes several requests (up to 10 pages) can still run past the MCP client's default 60-second request timeout.
- 502, 503 and 504 are retried the same way for `GET` only; when all three attempts fail the error says the service may be unavailable, without the gateway's HTML. No `POST` is ever retried after a gateway error. For the searches the message says the search only reads, so calling the tool again is safe; for a write it says the request may already have been processed and names the tool to check with (`list_jobs`, `get_job` or `list_approvals`) before repeating it.
- A 200 whose body is not JSON (a proxy or a login page in the way) is reported as an error naming `WORKMOBILE_BASE_URL`, never as an empty list; the body is described by type and size, never quoted. A search that answers with anything other than a JSON array of rows is reported with the key names it got, never read as empty. The connector definition types the job search answer and the job type schema document as strings, so a JSON array or object sent double-encoded (as a JSON string holding JSON text) is decoded once; any other string is still refused. A single-record `GET` answering 200 with `null` or an empty object is reported as not found.
- A rejected API key (401) produces a message that says which variable to fix and where the token comes from; a 403 names the roles WorkMobile's documentation lists for the API user; a 400 passes on WorkMobile's `title`, `detail` and field `errors` when the body is ProblemDetails JSON. The API key is scrubbed from every error message, and contact details are redacted from WorkMobile's error text.
## Tests
```bash
npm test
```
The spec (`spec.json`, from `https://www.esayworkmobile.co.uk/webapi2/swagger/v2/swagger.json`) and the connector definition (`connector.json`, from `microsoft/PowerPlatformConnectors`, `certified-connectors/WorkMobile/apiDefinition.swagger.json`, publisher eSAY Solutions Ltd) are downloaded on the first run if they are missing. Both are gitignored.
Where the schemas come from:
- **Request bodies:** the v2 spec's own component schemas (`JobSearchCriteria`, `CompletedRecordsSearchCriteria`, `ApproveRequest`, `RejectRequest`, all with `additionalProperties: false`) and its multipart schema for `POST /api/jobs`. One substitution: the spec's `UserDefinedFieldFilter` is an object with no properties and `additionalProperties: false`, so it accepts only `{}` and would reject every documented filter example; filter items are validated against the spec's `UserDefinedFieldFilterDefinition` (`uniqueName`, `operator`, `value`, …) instead, and record search bodies without filters are validated against the unmodified schema.
- **Responses of forms, job types, jobs, mobile users and user groups:** the connector definition's response schemas. The help centre's documented `GET /api/Forms` example shows `"LastUpload": null` where the connector types a string, so that one field is allowed to be null.
- **Job search rows:** WorkMobile documents the response only as "the list of jobs that meets the criteria". The mock answers a bare JSON array of objects shaped like the connector's `GET /api/Jobs/{id}` schema; this is an assumption.
- **Completed records:** WorkMobile publishes neither a schema nor an example. `test/schemas.mjs` holds a schema written by hand from what is documented (the numeric `Id`, `OriginalId` and `JobId` columns the search documentation filters and sorts on, and the `Created` and `CreatedBy` static fields of the documented form example), with every other key taken to be one of the form's fields by unique name. The whole row shape, and the bare-array envelope, are assumptions.
- **Job type schema document:** the help centre's sample, copied verbatim (`test/doc-examples.mjs`) and served as job type 10238; the two documented `GET /api/Forms` examples are served verbatim too.
- **Job status list, approval inbox totals, error bodies, write replies:** not documented; the mock's placeholders are listed at the top of `test/mock-server.mjs`. The tools that return the first two pass them through without relying on any field name.
The test suite (28 checks):
1. Validates every fixture against those schemas, reports any fixture key the connector schema does not declare, confirms the documented examples are served unchanged, and runs negative controls (a string `JobId`, a record without `OriginalId`, an undeclared key). Checks the field-name classifier on its own against personal, financial and ordinary names in PascalCase, acronym, dotted and spaced forms, and the bank-detail text patterns.
2. Starts a local mock of the API under `/webapi2` that requires the `X-API-Key` header (401 "Unauthorised" without it or with a wrong key), serves 1,100 jobs and 620 records on one form in pages of 500 with the documented `pagination` body (refusing a `rowCount` below 500, and a record search without `orderBy`, which the help centre says is always required), answers unknown IDs with a 404 and bad requests with a 400 as ProblemDetails, and answers the first `GET /api/forms` with a 429. The mock's list and detail responses are validated against the schemas above.
3. Starts the built server and drives it over stdio with the official MCP client: tools/list and annotations; every read tool; job search pagination across three pages to the short last page, continuation from `next_page`, and the note when rows of a fetched page are not returned; every documented job search filter and the record search criteria (created and uploaded dates, job, mobile user, user group, field filters with AND/OR, sort order) sent exactly as documented, in bodies validated against the spec; redaction by default (in `list_jobs`, `get_job`, `list_form_submissions`, `get_form_submission`, `list_approvals` with a `form_id`, `list_mobile_users`, `list_forms` and the approval inbox totals: personal fields by name, including `DoB`, `D.O.B`, `NINumber`, `What3Words` and `Mob No`, a bare link, emails, phone numbers, postcodes and coordinates in text) and its return on request (in `get_job`, `get_form_submission`, `list_approvals` and `list_mobile_users`), with bank and card fields, a Luhn-valid card number, a sort code, an account number and an IBAN typed into text, and an embedded image withheld even on request; object- and array-valued system columns and an object `Location` redacted like job data; a double-encoded job type schema (read by `get_job_type` and used by `create_job`) and job search answer decoded; the write tools absent with `WORKMOBILE_ALLOW_WRITES` unset and set to `false`; `create_job` posting a multipart form whose fields are all in the spec's schema and validate against it, with `Data` as `{"jobData": …}`, and refusing unknown fields, missing required fields, disallowed values, text for a numeric field and half a location before any `POST`; `allocate_job`, `approve_record` and `reject_record` bodies validated against the spec, and a 400 passed on; invalid IDs, dates (including month 13, 30 February and hour 24, with the validation message checked) and operators refused with no request made; the 404 message for unknown job and job type IDs and a 200 `null` or `{}` job read as not found; requests of a paged search spaced by the 250 ms throttle, and paging stopping once `max_results` rows are in hand; the 429 retry waiting for `Retry-After` in the seconds, fractional-seconds and HTTP-date forms and the 2 s fallback when the header is absent, retrying a `POST` with the same body (page 2 of a three-page search asked for again with no row lost or duplicated, and a rate-limited `POST /api/jobs` creating exactly one job), giving up after three attempts on a persistent 429 and at once on a `Retry-After` above the cap; a 502 retried for a `GET` and never for a `POST` search or write, with the right advice in each case; three 503s on a `GET` reported without HTML; a non-JSON 200, a search answer that is not an array, and a 403 (whose body echoing the API key and an email address reaches the tool result scrubbed); the 401 message for a wrong key, after exactly one request; and, last, that every request carried `X-API-Key` (the configured one, except the deliberate wrong-key call) and no `Authorization` header, matched exactly one method and path documented in the spec (integer path parameters match digits only, so a parameter cannot stand in for a literal segment), sent a body content type the spec accepts for that operation, and never touched a login, download, media or report endpoint.
The suite takes about 35 seconds.
## Status
This is a working prototype. It has **not been run against the live API**, because it was built without a WorkMobile account (trials are set up through WorkMobile's sales team). Everything below comes from the published documentation and should be confirmed on a real account:
- The response of `POST /api/jobs/search` and `POST /api/forms/{FormId}/completedrecords/search`: that each page is a bare JSON array (not an object with the rows and a total, and not the JSON string the connector definition types the job search answer as; a double-encoded array is decoded, but that path has only been tried against the mock), that job rows carry the same fields as `GET /api/jobs/{id}`, and that a page with fewer than 500 rows is the last. If the envelope differs, the server says so with the key names it got rather than guessing.
- The shape of a completed record: that `Id`, `OriginalId` and `JobId` are columns with those names, which other system columns exist and what they are called, and that the form's fields are keyed by unique name with scalar values. Also how photos, signatures, sketches and locations appear in a record (a link, an ID, embedded data), so the name- and value-based withholding can be checked against real data.
- Whether the record search criteria are sent as arrays (`createdDateFrom: ["…"]`, `id: [21524]`), as the current spec types them, or as the single values the 2021 help centre article shows; and how `userDefinedFilter` items are really read, given the spec's empty `UserDefinedFieldFilter` schema.
- Which time zone WorkMobile applies to the search dates. The server sends UTC (`…Z`), as the documented examples do; the timestamps in the documented responses carry no zone.
- Whether `GET /api/jobtypes/{id}/schema` returns the JSON Schema document as an object (as the help centre sample shows) or as a JSON string holding it (as the connector types it); both are handled, only against the mock. And whether a job's `Location` is ever an object rather than the connector's string.
- What the API answers for an unknown job, job type or form ID (the spec lists only 200 and 401 for these GETs); the server handles a 404 and a 200 with `null` or `{}`.
- The format of `GET /api/jobs/jobstatuslist` and `GET /api/approvals/getinboxtotals` (returned as sent, with text redaction), and the IDs of the nine documented statuses.
- The error bodies: whether 400s come as ProblemDetails, and whether 401 and 403 carry anything. What a 403 looks like for a portal user that lacks a role.
- `POST /api/jobs`: that `Data` is the JSON document `{"jobData": {…}}` (the documented JSON Schema describes an object with a `jobData` property), that `AllocatedMobileUserId` and `AllocatedUserGroupId` of `0` mean unallocated (as the connector says), what `Priority` values and `Duration` unit are valid, that `EstimatedStart` accepts a UTC date-time, and that the reply is the new job ID as an integer (as the connector types it). The local check of job data covers only unknown fields, required fields (marked, as in the documented sample, by a `required` array holding the field's own name), allowed values and numeric types; WorkMobile's own validation is the authority.
- The replies of `allocate` (typed as a string by the connector), `approve` and `reject` (documented only as "Success"), and what `approve`/`reject` answer for a record with no step pending for the API user.
- Whether a 429 on a `POST` means the request was not processed (the retry assumes so), and how many requests per second the API tolerates; nothing is documented, so the 250 ms spacing is a guess on the polite side.
- The roles the API user needs for each tool; the 403 message quotes role names from the example token in WorkMobile's authentication article.
## Going to production
This version runs locally over stdio, with the account holder's own API token. For customers to connect from claude.ai or ChatGPT without handling tokens, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by eSAY Solutions, and then a listing in the Claude and ChatGPT connector directories. A production version would also cover what this one leaves out for lack of documented response shapes (CRM customers and contacts, lone worker events, record history), once those shapes are confirmed.
## Licence
MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues