Timeusage MCP
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 |
| read | One Monday-anchored week: entries, hours per day, Halo's own target, submitted state |
| read | Totals over a date range, grouped by day / ticket / client / week |
| read | Find a ticket to log against — your own, a text search, or open projects |
| write | Create one entry (ticket, day, hours, note) |
| write | Change one entry's hours and/or note |
| 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.
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 inauth.ALL_SCOPES:openid read:tickets read:timesheets editMine:timesheets edit:tickets. Nothing wider.edit:ticketsis the grant a time entry actually needs — measured 2026-08-14, when adding it turned a warned 201 into a persisted write.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.
The server serves RFC 9728 metadata at
/.well-known/oauth-protected-resourcewhoseresourcebyte-matches the connector URL, and answers every unauthenticated request with401+ the exactWWW-Authenticate: Bearer resource_metadata=...challenge.Claude runs auth-code + S256 PKCE against Halo, holds and refreshes the token itself, and sends it as
Authorization: Beareron every call.The server validates each token by functional probe:
GET /api/Agent/mewith 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 bysha256(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:32belongs 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/TimesheetEventwith a JSON array of one object;event_type: 0is 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_warningfield of the echoed object. Every write and delete response is checked for it.GET /api/TimesheetEventreturnsid: 0on every row. Entries are ticket actions underneath; the row carriesaction_number, measured equal to the/api/Actionsrow'sidfor the same entry.Edit is
POST /api/Actions, array of one,ticket_idas a string,iseditaction: true, and the end instant in bothdatetimeandactioncompletiondate. A blank note omitsnote_htmlentirely — blank means "keep Halo's note".Delete is
DELETE /api/Actions/{actionId}?ticket_id={ticketId}.A day carries an
approvalobject 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/Ticketsignores pagination unless bothpageinate=trueandpage_noare sent, andsearchis the only filter it honours besidesopen_only—ticket_type_idandtype_idare 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=-480with_submitapproval), but submitting somebody's timesheet from a chat tool is a bigger decision than logging an hour./api/Timesheetis 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
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/mcpstdio mode (Claude Code)
# 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_mainstdio 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 modeTests: 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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/hajinatorzetta/timeusage-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server