Skip to main content
Glama
hajinatorzetta

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues