synchroteam-mcp
by kuzgunay
README.md
# synchroteam-mcp
An open-source [Model Context Protocol](https://modelcontextprotocol.io) server that lets Claude (or any MCP
client) work with [Synchroteam](https://www.synchroteam.com) field-service data: jobs, customers, sites, equipment,
technicians and their schedules, invoices, quotations, parts and stock.
> **Status: unofficial, pre-release.**
> - Not affiliated with, endorsed by or supported by Synchroteam.
> - Built from the public Synchroteam REST API v3 documentation (read 2026-11-08; links at the end).
> - Tested only against a local mock of that API (`httpx.MockTransport`, 157 tests).
> - **Not yet tested against a live Synchroteam account.** Expect small mismatches where the docs are
> ambiguous (listed under [Known gaps](#known-gaps-and-doc-ambiguities)) until someone runs it against a real tenant.
## What you get
- **21 read-only tools** (19 lookups + 2 composite "business" tools), on by default.
- **5 write tools** (create job, add note, schedule, complete, cancel). They are **not registered at all** unless
`SYNCHROTEAM_ALLOW_WRITE=true`.
- **Two transports from one codebase:** stdio (Claude Desktop, Claude Code) and Streamable HTTP (remote clients).
- **Role-based access:** a JSON file maps roles (e.g. engineer / sales / management) to tools. A tool outside the
caller's role is not listed, and calling it is refused.
- **Bearer-token auth for HTTP:** tokens map to roles, are stored as SHA-256 hashes, and a missing or unknown
token gets `401`.
- **Audit log:** one JSON line per tool call with role, tool, sanitised arguments, result size, duration and
outcome. No secrets, and no full personal data.
- **Careful API client:** documented Basic auth, pagination, timeouts, retries with backoff that read the
documented `X-RateLimit-*` / `X-Quota-*` headers, and clear error messages.
Built with the official [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x. Its high-level
API, `MCPServer`, is what 1.x called `FastMCP`.
## Tools
Endpoints are relative to `https://ws.synchroteam.com/api/v3`. "Roles" refers to `config/roles.example.json`
(management has `*`).
| Tool | What it answers | Synchroteam endpoint(s) | Example roles |
|---|---|---|---|
| `list_jobs` | Jobs by status, date range, technician, team, customer, site, equipment, type | `GET job/list` | engineer, sales |
| `get_job` | One job in full: schedule, actual times, time spent, time entries, parts used, report summary | `GET job/details` | engineer |
| `search_customers` | Customers by name (wildcard) or email (exact) | `GET customer/list` | sales |
| `get_customer` | One customer: contact, VAT number, custom fields | `GET customer/details` | engineer, sales |
| `list_sites` / `get_site` | Customer sites (service locations) | `GET site/list`, `GET site/details` | engineer, sales |
| `list_equipment` | Installed equipment for a customer or site | `GET equipment/list` | engineer, sales |
| `get_equipment` | One machine plus its most recent jobs (quick service history) | `GET equipment/details`, `GET job/list` | engineer |
| `list_users` | Technicians, managers, admins: login, teams, skilled trades | `GET user/list` | management |
| `list_teams` | Teams with technician/manager counts | `GET team/list` | management |
| `get_technician_schedule` | One technician's jobs and activities (holiday, training, travel) with a per-day summary, up to 31 days | `GET user/details`, `GET job/list`, `GET activity/list` (x1-2) | engineer |
| `list_invoices` / `get_invoice` | Invoices by status/customer/job, and one invoice with line items | `GET invoice/list`, `GET invoice/details` | sales |
| `list_quotations` | Quotations by status/customer/job | `GET quotation/list` | sales |
| `list_parts` | Parts and services catalogue with stock level and a below-minimum flag; optional text search | `GET part/list` (several pages when searching) | engineer, sales |
| `get_part` | One part by id or exact reference | `GET part/details` | engineer, sales |
| `list_stock_depots` / `get_stock_depot` | Depots and vans, and the parts held in one | `GET depot/list`, `GET depot/details` | engineer |
| `list_timeline` | Notes, calls, emails and status notifications for a job/customer/site/equipment | `GET timeline/list` | engineer, sales |
| **`completed_jobs_not_invoiced`** | Completed/validated jobs in a date range with no invoice linked | `GET job/list`, `GET invoice/list` (+ optional per-job `GET invoice/list`) | management |
| **`customer_overview`** | Account summary: sites, equipment, open and recent jobs, unpaid invoices, open quotes | `GET customer/details`, `site/list`, `equipment/list`, `job/list` x2, `invoice/list` x3, `quotation/list` x2 | sales |
| `create_job` *(write)* | Create an unscheduled job for a customer | `GET job/details` (myId check), `POST job/send` | - |
| `add_job_note` *(write)* | Add a timeline note to a job | (`GET job/details`), `POST timeline/send` | engineer |
| `schedule_job` *(write)* | Assign technician and time slot | `POST job/schedule` | - |
| `complete_job` *(write)* | Mark completed with actual start/end | `POST job/complete` | engineer |
| `cancel_job` *(write)* | Cancel a job | `POST job/cancel` | - |
List tools return compact summaries plus `page`, `total`, `has_more`, `next_page` and a hint when more results
exist (`page_size` is capped at the documented maximum of 100). Every tool carries MCP annotations
(`readOnlyHint`, `destructiveHint`, ...), and every write tool says "WRITES TO SYNCHROTEAM" in its description.
### The two business tools, and their limits
**`completed_jobs_not_invoiced(date_from, date_to, ...)`**: the documented job object has no "invoiced" flag. The
tool lists completed (and, by default, validated) jobs in the range, then scans invoices created since
`date_from - invoice_lookback_days` (default 30), ignoring cancelled ones. It matches the two through each
invoice's `job` reference (id, num or myId). It returns the uninvoiced jobs together with `caveats` covering:
- invoices created without a job link (it reports how many it saw);
- quotations and credit notes, which are not considered;
- truncation caps (`max_jobs`, a 5000-invoice scan).
`verify_each_job=true` adds one `invoice/list?job_num=` lookup per candidate (at most 100). That also catches
invoices created outside the scan window.
**`customer_overview(customer_id | customer_my_id | customer_name)`**: about 10 API calls. Invoice totals are
summed over at most 100 invoices per status. The API exposes no currency field, so amounts have no currency.
Invoices and quotations are queried once per status, because the docs do not say whether their `status` filter
accepts several values.
## Setup
Requires Python 3.11 or newer.
```bash
git clone <this repo> synchroteam-mcp && cd synchroteam-mcp
python -m venv .venv
# Windows: .venv\Scripts\activate macOS/Linux: source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # then fill in the placeholders, or set the variables in your MCP client config
synchroteam-mcp check-config
```
Where the credentials come from (according to the Synchroteam docs):
- **Domain:** the `mycompany` part of `https://mycompany.synchroteam.com`.
- **API key:** in the Synchroteam web app under **Configuration > Authentication Key**.
### Configuration
| Variable | Default | Meaning |
|---|---|---|
| `SYNCHROTEAM_DOMAIN` | *(required)* | Your Synchroteam domain identifier |
| `SYNCHROTEAM_API_KEY` | *(required)* | API key (Configuration > Authentication Key) |
| `SYNCHROTEAM_BASE_URL` | `https://ws.synchroteam.com/api/v3` | API base. `http://` is accepted only for localhost (the fake API) |
| `SYNCHROTEAM_ALLOW_WRITE` | `false` | Register the write tools |
| `SYNCHROTEAM_TIMEOUT` / `SYNCHROTEAM_MAX_RETRIES` | `30` / `3` | Per-request timeout (s) and retry budget |
| `SYNCHROTEAM_MCP_ROLES_FILE` | *(none)* | JSON roles file. Without it, a single role `full` sees every registered tool |
| `SYNCHROTEAM_MCP_ROLE` | `full` if no roles file | Role used by the stdio transport (required when a roles file is set) |
| `SYNCHROTEAM_MCP_TOKENS_FILE` | *(none)* | HTTP: JSON file of `{name, role, sha256}` token entries |
| `SYNCHROTEAM_MCP_TOKENS` | *(none)* | HTTP: `role:token,role:token` (tokens of 24 characters or more), e.g. from a secret store |
| `SYNCHROTEAM_MCP_AUDIT_LOG` | *(none)* | Path of the JSON-lines audit log |
| `SYNCHROTEAM_MCP_HOST` / `_PORT` / `_PATH` | `127.0.0.1` / `8000` / `/mcp` | HTTP bind address and endpoint |
| `SYNCHROTEAM_MCP_ALLOWED_HOSTS` | *(none)* | Comma-separated public hostnames. Turns on Host/Origin checking when serving behind a domain |
| `SYNCHROTEAM_MCP_STATELESS` / `_JSON_RESPONSE` | `true` / `true` | Stateless Streamable HTTP with plain JSON responses (load-balancer friendly) |
| `SYNCHROTEAM_MCP_LOG_LEVEL` | `INFO` | Log level (logs go to stderr) |
## Use with Claude Desktop (stdio)
Edit `claude_desktop_config.json` (Settings > Developer > Edit Config):
```json
{
"mcpServers": {
"synchroteam": {
"command": "C:\\path\\to\\synchroteam-mcp\\.venv\\Scripts\\synchroteam-mcp.exe",
"args": ["--transport", "stdio"],
"env": {
"SYNCHROTEAM_DOMAIN": "your-domain",
"SYNCHROTEAM_API_KEY": "your-api-key",
"SYNCHROTEAM_MCP_ROLES_FILE": "C:\\path\\to\\synchroteam-mcp\\config\\roles.example.json",
"SYNCHROTEAM_MCP_ROLE": "management",
"SYNCHROTEAM_MCP_AUDIT_LOG": "C:\\path\\to\\synchroteam-mcp\\logs\\audit.jsonl"
}
}
}
}
```
On macOS/Linux, the command is `/path/to/synchroteam-mcp/.venv/bin/synchroteam-mcp`.
## Use with Claude Code
```bash
# Local, stdio
claude mcp add synchroteam \
--env SYNCHROTEAM_DOMAIN=your-domain \
--env SYNCHROTEAM_API_KEY=your-api-key \
-- /path/to/.venv/bin/synchroteam-mcp --transport stdio
# Remote, Streamable HTTP with a bearer token
claude mcp add --transport http synchroteam https://mcp.example.com/mcp \
--header "Authorization: Bearer <token issued to you>"
```
## Remote deployment (Streamable HTTP)
```bash
synchroteam-mcp hash-token # prints a new random token (give it to the user) and a tokens-file entry
synchroteam-mcp --transport http # serves http://127.0.0.1:8000/mcp, plus GET /healthz without auth
```
Docker (non-root user, `/healthz` health check):
```bash
docker build -t synchroteam-mcp .
docker run --rm -p 8000:8000 --env-file .env \
-e SYNCHROTEAM_MCP_ROLES_FILE=/etc/synchroteam-mcp/roles.example.json \
-e SYNCHROTEAM_MCP_TOKENS_FILE=/run/secrets/tokens.json \
-v "$PWD/tokens.json:/run/secrets/tokens.json:ro" \
synchroteam-mcp
```
> The Dockerfile has **not been built** in the development environment (Docker was not available there). Only
> the Python wheel it installs was built and checked.
Deployment notes:
- Put TLS in front (reverse proxy, load balancer or platform ingress). The server speaks plain HTTP.
- Set `SYNCHROTEAM_MCP_ALLOWED_HOSTS=mcp.example.com` when serving on a public hostname. This turns on the SDK's
Host/Origin validation; it is automatic only when bound to localhost.
- Stateless mode (the default) needs no sticky sessions, so you can run several replicas behind a load balancer.
- One server instance uses one Synchroteam API key. Roles restrict which tools a caller can use, but every call
runs with that key's permissions inside Synchroteam.
### OAuth (what Claude.ai custom connectors prefer) is not implemented
This server authenticates HTTP callers with static bearer tokens. That works for Claude Code, scripts, the MCP
Inspector and other clients that can send an `Authorization` header. Claude.ai custom connectors are built around
the MCP authorization spec (OAuth 2.1), so this server is not yet a drop-in Claude.ai connector. Check
Anthropic's current connector documentation for the exact requirements.
Adding OAuth would take:
1. An authorization server (Microsoft Entra ID, Auth0, Keycloak, ...) that issues access tokens for this server
as an audience/resource, ideally with dynamic client registration or a pre-registered client for Claude.
2. A `TokenVerifier` implementation. The MCP SDK provides the hook (`MCPServer(token_verifier=..., auth=AuthSettings(...))`).
It would validate the JWT signature, issuer, audience and expiry, and map a group/role claim onto the roles file.
3. Publishing Protected Resource Metadata (RFC 9728). The SDK serves it from `AuthSettings`.
4. Replacing `BearerAuthMiddleware` with the SDK's auth middleware. The role and audit layers stay unchanged.
## Try it without a Synchroteam account (fake API + MCP Inspector)
```bash
python scripts/fake_synchroteam.py # fake API on http://127.0.0.1:8765/api/v3 with fictional data
# second shell (bash syntax; use `set`/`$env:` on Windows)
export SYNCHROTEAM_BASE_URL=http://127.0.0.1:8765/api/v3
export SYNCHROTEAM_DOMAIN=acme-test
export SYNCHROTEAM_API_KEY=00000000-test-key-not-real-0000 # placeholder accepted only by the fake
npx @modelcontextprotocol/inspector .venv/bin/synchroteam-mcp --transport stdio
```
The Inspector opens a browser UI where you can list tools and call them. To inspect the HTTP transport, start
`synchroteam-mcp --transport http` with `SYNCHROTEAM_MCP_TOKENS=full:<a 24+ character token>`. Then choose
"Streamable HTTP" in the Inspector, use `http://127.0.0.1:8000/mcp` and add an `Authorization: Bearer ...` header.
## Security model
- **Read-only by default.** Write tools are only registered when `SYNCHROTEAM_ALLOW_WRITE=true`. When it is off,
they are absent from `tools/list` and cannot be called by any role. `create_job` refuses to reuse an existing
`myId`, because Synchroteam's `job/send` would otherwise silently *update* that job (the documented upsert).
- **Roles.** Enforced in an MCP server middleware for every transport. The role filters `tools/list` and refuses
`tools/call` with a protocol error before the API is touched. The check fails closed: an HTTP request without
an authenticated principal sees no tools. Tool names in the roles file are validated at startup, so a typo
does not silently hide a tool.
- **HTTP authentication.** `Authorization: Bearer` is checked in plain ASGI middleware before the MCP SDK runs.
Tokens are compared as SHA-256 digests with `hmac.compare_digest`. The tokens file holds only hashes and is
rejected if it contains plaintext. `tools/list` results are marked cache scope `private`.
- **Audit.** One JSON line per call:
```json
{"ts":"2026-11-08T17:20:01.123Z","event":"tool_call","transport":"http","role":"sales","principal":"sales-team",
"tool":"get_customer","arg_keys":["customer_email"],"args":{"customer_email":"<redacted:28>"},
"outcome":"ok","error":null,"result_bytes":1834,"duration_ms":212.4}
```
- Argument values are kept only for ids, enums, dates and paging; names, emails and free text become length
markers.
- Results are never written, only their size.
- Refused calls are logged with `outcome: "denied"`.
- **Secrets.**
- The API key and tokens come only from the environment or files outside git (`.env`, `tokens.json` and
`config/roles.json` are git-ignored).
- They are excluded from `repr()`.
- The base URL must be `https://` unless it points at localhost.
- **Log hygiene.**
- All logging goes through a redacting filter that masks emails and phone numbers and strips Basic/Bearer
credentials and the API key.
- `httpx` request logging (which would print query strings such as email filters) is turned down to WARNING.
- **Data minimisation.**
- User `password` fields are always stripped. The docs show the field in the user object example, although
they call it write-only.
- Technician GPS (`lastLocation`, `startLocation`) is not returned.
- **Error messages** tell the model what to fix ("No job found with num=99999", "rate limit reached", "check
SYNCHROTEAM_API_KEY") without echoing credentials.
## Known gaps and doc ambiguities
Not verified against a live account. The points below come from reading the docs, and each is handled in the
code as described:
1. **Base path.** Resource examples use `/api/v3/...`, the auth example `/Api/v3/`, and the pagination/error
examples `/v3/job/list`. The default is `/api/v3`; override it with `SYNCHROTEAM_BASE_URL`.
2. **Rate limits.** The text says 1000 requests/minute, while the error example shows `X-RateLimit-Limit: 120`.
The daily quota appears as 15000 in one example and 10000 in another. The client reads the headers instead of
assuming a number. It treats a 429 with `X-Quota-Remaining: 0` as the daily quota (not retried, resets at UTC
midnight), inferred from the docs' example. `Retry-After` is not documented but is honoured if present.
3. **Date formats.**
- Job object examples use `19/07/2016 14:43:00` (day/month/year); other objects use `2018-04-06 10:19`.
Whether this depends on account locale is not stated. The server parses both and always returns the
original string.
- Job `dateFrom`/`dateTo` filters do not state a time zone. `changedSince`-style filters are documented as
UTC, and activities as local time.
4. **`customer/list`.** The parameter table says `changedSince`, but the example URL uses `changedFrom`. The
server uses `changedSince`.
5. **`activity/list`.**
- `user_login` is typed "integer", apparently a typo. The server filters by `user_id`.
- The category filter lists `unavailability`, while the category code is `unavail`. The server filters by
category client-side and accepts both.
6. **Job reports.** The block title is `name` in one example and `blockName` in another. Both are read. The
report embedded in `job/details` is used; if it is missing, `get_job` says so rather than guessing.
7. **`job_id` filters typed as integer** on `invoice/list`, `quotation/list` and `timeline/list`, although job
ids are strings such as `265_160719144323649`. The invoice and quotation tools expose `job_num` / `job_my_id`
instead. `list_timeline` passes `job_id` as given, which is untested live.
8. **Multi-value `status`.** It is documented for `job/list` but not for invoices or quotations, so those are
queried one status at a time.
9. **Depot ids.** Typed int, but the example is a GUID. They are treated as strings.
10. **Timeline.**
- Timeline (and tasks) lists are documented as bare arrays with no pagination parameters. `list_timeline`
caps output client-side.
- The timeline create example uses `client` for the customer while the object uses `customer`.
- Whether `user` is required on `timeline/send` is not stated; `add_job_note` sends only the job reference
(by id, resolving num/myId first because the example only shows id).
11. **`pageSize` above 100.** The behaviour is undocumented, so the client never sends more than 100.
12. **Missing data.** The job object has no invoice flag, invoices carry no currency, and there is no
working-hours endpoint. `get_technician_schedule` therefore reports booked jobs and unavailability, not
computed free slots.
13. **API access by plan.** The Synchroteam help-centre article on web services
(https://support.synchroteam.com/hc/en-us/articles/360015843091-Web-services) returned a Cloudflare bot
check (HTTP 403) and was not read. Which subscription plans include API access is therefore unconfirmed.
14. **Error bodies.** They are assumed to follow the documented `{"error", "message"}` shape. Other shapes are
tolerated and reported with the HTTP status.
## Before testing against a live Synchroteam account
1. Use a Synchroteam account with API access, ideally a trial or sandbox tenant rather than production. Confirm
with Synchroteam that your plan includes web services.
2. Copy the domain and the key from **Configuration > Authentication Key**.
3. Start read-only (the default) and run `synchroteam-mcp check-config`. Then try small calls: `list_teams`,
`list_jobs` with `page_size: 1`, then `get_job`.
4. Check the items listed above, especially the date formats in responses, the rate-limit headers, the
`timeline/list` `job_id` filter, the `job` field on invoices (which `completed_jobs_not_invoiced` relies on)
and whether the job report is embedded in `job/details`.
5. Enable `SYNCHROTEAM_ALLOW_WRITE=true` only on a test tenant, and try each write tool once.
## Development
```bash
pip install -e ".[dev]"
pytest # 157 tests; no external network (end-to-end tests use localhost only); ~10 s
```
- `src/synchroteam_mcp/client.py`: HTTP client (auth, pagination, retries, error mapping)
- `src/synchroteam_mcp/tools/`: `read.py`, `business.py`, `write.py`
- `src/synchroteam_mcp/middleware.py`: bearer auth (ASGI), role filter and audit (MCP middleware)
- `src/synchroteam_mcp/testing/fake_api.py`: fake of the documented API used by the tests and the demo script
- `tests/`:
- client behaviour (auth header, pagination, 429/5xx retry and backoff, error mapping)
- every tool over an in-memory MCP session
- write-flag behaviour
- role filtering
- HTTP bearer checks via the SDK client
- audit and log redaction
- end-to-end runs of the real CLI over stdio and HTTP
## Documentation used
- Synchroteam REST API v3: https://api.synchroteam.com (main reference)
- Synchroteam API v2 (legacy, consulted for comparison only; not used): https://api.synchroteam.com/v2/
- Synchroteam web services help article (could not be retrieved, see above):
https://support.synchroteam.com/hc/en-us/articles/360015843091-Web-services
- MCP Python SDK: https://github.com/modelcontextprotocol/python-sdk, migration notes for 2.x:
https://py.sdk.modelcontextprotocol.io/v2/migration/
## License
MIT, see [LICENSE](LICENSE). "Synchroteam" is a trademark of its owner and is used here only to describe
compatibility.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues