Skip to main content
Glama
hajinatorzetta

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

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).

Related MCP server: TimeChimp MCP Server

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_onlyticket_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

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)

# 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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

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