Timeusage MCP
README.md
# Timeusage MCP
A HaloPSA **timesheet** MCP server for Zetta: read your own week, and log,
edit or delete your own time entries — from Claude.
Scope is timesheets only. It does not browse Halo generally (clients,
invoices, assets, KB); the `halo-mcp` server covers that. The only
non-timesheet thing it reads is tickets, because you cannot log time
without one.
Built to the same design as `halo-mcp` (see its `MCP-BUILD-GUIDE.md` —
that document is the approved pattern for every Zetta MCP server):
stateless streamable HTTP, user-delegated Halo tokens, no server secret,
markdown/TSV responses, routing-document tool descriptions.
## The six tools
| Tool | Kind | What it does |
|---|---|---|
| `time_week` | read | One Monday-anchored week: entries, hours per day, Halo's own target, submitted state |
| `time_summary` | read | Totals over a date range, grouped by day / ticket / client / week |
| `time_tickets` | read | Find a ticket to log against — your own, a text search, or open projects |
| `time_log` | write | Create one entry (ticket, day, hours, note) |
| `time_edit` | write | Change one entry's hours and/or note |
| `time_delete` | write (destructive) | Remove one entry |
An entry is addressed by the `Entry` reference `time_week` prints —
`17042-8`, meaning ticket 17042, action 8. That pair is the identity
because Halo's `GET /api/TimesheetEvent` returns `id: 0` on every row (see
"Measured facts" below).
## How auth works
Two-world split, as always. **Halo is world 2** — its own OAuth2 server,
no Entra OBO anywhere near this.
1. A dedicated **Authorisation Code** application in Halo (Config >
Integrations > Halo API), redirect URI
`https://claude.ai/api/mcp/auth_callback`, Permissions capped to
exactly the five scopes in `auth.ALL_SCOPES`:
`openid read:tickets read:timesheets editMine:timesheets edit:tickets`.
Nothing wider. `edit:tickets` is the grant a time entry actually needs
— measured 2026-08-14, when adding it turned a warned 201 into a
persisted write.
2. Halo has no Dynamic Client Registration, so the app's client ID and
secret go in Claude's connector **Advanced settings**. The secret never
touches this server.
3. The server serves RFC 9728 metadata at
`/.well-known/oauth-protected-resource` whose `resource` byte-matches
the connector URL, and answers every unauthenticated request with
`401` + the exact `WWW-Authenticate: Bearer resource_metadata=...`
challenge.
4. Claude runs auth-code + S256 PKCE against Halo, holds and refreshes the
token itself, and sends it as `Authorization: Bearer` on every call.
5. The server validates each token by **functional probe**:
`GET /api/Agent/me` with that same token. Halo is the trust anchor —
2xx means the token is good — and the same call answers the question a
timesheet server cannot work without: *whose* timesheet is this. The
verdict is cached 60s, keyed by `sha256(token)`.
**The server holds no secret of any kind.** No Key Vault, no service
credential, no token storage.
**The accepted trade, stated plainly:** forwarding the caller's bearer
upstream is the "token passthrough" pattern the MCP spec prohibits in
general. Accepted here for the same reasons as `halo-mcp` — single-tenant
internal use, the token belongs to the acting user and targets the same
Halo instance, the Halo app's permission cap bounds the damage, and this
client hard-codes the endpoints it will call. **It now also carries write
scope**, which the guide said to revisit before doing: the mitigation is
that the write surface is three fixed timesheet verbs, every one of them
re-verifies ownership against Halo immediately before writing, and no
tool accepts an agent id. Revisit again before any external exposure.
## Measured facts this server depends on
Everything below was measured live against the Zetta instance by the
`timeusage` app (`src/timesheetapp/halo/client.py`) and is **ported, not
re-derived**. Halo's `/apidoc` is a client-side-rendered SPA that returns
nothing to an automated fetch, so those measurements are the only schema
that exists.
- **Reading buckets by Perth day.** Halo stamps events in UTC but dates
its rollup rows in Australia/Perth. The live sample event at
`2026-06-14T17:32` belongs to the 15 June rollup. A naive timestamp is
read as UTC.
- **Writing stamps 09:00 Perth**, because 09:00 Perth is 01:00 UTC on the
*same* calendar date — so the entry lands on the chosen day whichever
way Halo reads the value. Midnight would not have that property.
- **Create** is `POST /api/TimesheetEvent` with a JSON **array** of one
object; `event_type: 0` is required (omitting it surfaced Halo's own
NULL-column error).
- **A 2xx is not proof of a write.** Halo answered `201 Created`, echoed a
plausible id, and did not persist the entry — the refusal rode in a
`_warning` field of the echoed object. Every write and delete response
is checked for it.
- **`GET /api/TimesheetEvent` returns `id: 0` on every row.** Entries are
ticket *actions* underneath; the row carries `action_number`, measured
equal to the `/api/Actions` row's `id` for the same entry.
- **Edit** is `POST /api/Actions`, array of one, `ticket_id` as a
**string**, `iseditaction: true`, and the end instant in both
`datetime` and `actioncompletiondate`. A blank note omits `note_html`
entirely — blank means "keep Halo's note".
- **Delete** is `DELETE /api/Actions/{actionId}?ticket_id={ticketId}`.
- **A day carries an `approval` object only once submitted**, so its
presence is the submitted signal. Submitted days are refused for
writing: what Halo does to a write against an approved period was never
measured.
- **`/api/Tickets` ignores pagination** unless both `pageinate=true` and
`page_no` are sent, and `search` is the only filter it honours besides
`open_only` — `ticket_type_id` and `type_id` are dropped silently.
Charge type is **not settable**: no such field exists on the write
schema, so Halo derives it from the ticket.
### Not built, deliberately
- **Submit week for approval.** The captured contract exists (`POST
/api/Timesheet?utcoffset=-480` with `_submitapproval`), but submitting
somebody's timesheet from a chat tool is a bigger decision than logging
an hour. `/api/Timesheet` is deliberately absent from the write
allowlist.
- **Deep links.** Halo's web paths for a ticket or timesheet day were
never verified for this instance, and a URL that 404s reads as
authoritative.
## The one thing an MCP cannot inherit from the app
The `timeusage` app stops a double submission with a single-use
server-side **write nonce**. A stateless MCP server has no per-request
state to hang one on, and a retried tool call is indistinguishable from a
deliberate second entry.
The stand-in is a **duplicate check against Halo's own rows**: same day,
same ticket, same hours already logged means `time_log` refuses and names
the existing entry, and the model has to pass `allow_duplicate=true` to
proceed. That turns an invisible failure — a silently doubled day — into a
visible one.
## Local development
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # then fill it in (mode 600)
.venv/bin/python -m pytest -q
bash dev-server.sh # http://127.0.0.1:8472/mcp
```
### stdio mode (Claude Code)
```bash
# In a REAL terminal -- the VS Code integrated one. Not a `!` command and
# not an agent shell: the script prints a login URL and waits for you to
# paste one back, which a pipe cannot do. It refuses rather than hangs.
.venv/bin/python scripts/get_refresh_token.py # one-time, interactive
claude mcp add timesheet -- \
/home/hajidaly/timeusage-mcp/.venv/bin/python -m timeusage_mcp.stdio_main
```
stdio is **delegated only** — there is no `client_credentials` fallback,
because a service token would silently write time to whoever the Halo
application is configured as.
> **There is no Halo sandbox.** A write from 127.0.0.1 lands on Zetta's
> real instance, on your real timesheet. That is deliberate: a local write
> that behaved differently would prove nothing about prod.
## SDK note
Written against **mcp SDK 2.x**, where `FastMCP` is renamed `MCPServer`
and the transport knobs (`stateless_http`, `json_response`,
`transport_security`) moved off the constructor onto
`streamable_http_app()`. `halo-mcp` is on 1.x; that is the only
structural difference between the two servers' wiring.
## Layout
```
src/timeusage_mcp/
config.py environment only; no secret on the HTTP path
auth.py bearer gate, identity probe, verdict cache, RFC 9728
halo_client.py the ONLY module that talks to Halo; 3 allowlists
timesheet.py dates, hours, entry identity, validation — no HTTP
formatting.py every tool result is built here
tools.py the six tools and their descriptions
app.py Starlette + MCPServer wiring, headers, rate limit
stdio_main.py local delegated mode
```
Tests: `pytest -q` (176 tests, no network). `tests/test_live_smoke.py` is
skipped unless a refresh token is present, and its write half needs an
explicit opt-in because it writes to production.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues