# TimeDoctor MCP — full documentation (llms-full.txt) Concatenation of the user-facing guides served by this MCP service. Authoritative agent guidance remains the MCP `instructions` field at initialize. --- ## Source: Client setup guide (`setup.md`) # Connect to the TimeDoctor MCP server The TimeDoctor MCP server lets AI clients (Claude, ChatGPT, Gemini, Cursor, VS Code, …) **read** your TimeDoctor data through a small set of safe, read-only tools — directory data (users, projects, tasks, groups/tags, work schedules and leave) plus time-tracking analytics (tracked/idle time, productivity, web & app usage, worklog timeline and meetings). This guide shows how to connect from each client. > Placeholders to fill in: > - `MCP_URL` — your server endpoint. In production this is served under the > platform base path, typically `https:///api/2.0/mcp` (local dev: > `http://localhost:8092/api/mcp`). Use whatever your admin gives you. > - `TD_TOKEN` — your TimeDoctor access token. > - `COMPANY_ID` — your TimeDoctor workspace (company) id. > > The published docs/llms.txt links below adapt automatically to the base path > the server is exposed under (via `X-Forwarded-Prefix` / `MCP_PUBLIC_BASE_PATH`), > so they stay correct under `/api/2.0/mcp` or any other prefix. ## Connection model (read this first) - **Transport:** MCP **Streamable HTTP** (the current remote transport). - **Auth:** two headers on every request: - `Authorization: Bearer ` - `x-company-id: ` (selects the workspace; you can also use `?company=` on the URL). - **Where the connection comes from:** cloud clients (Claude.ai, ChatGPT) reach the server **from the vendor's cloud**, so `MCP_URL` must be reachable over the public internet. Desktop/CLI clients connect from your machine. - **stdio-only clients** (older configs) use the **`mcp-remote`** npm bridge (needs Node.js installed). Everything is **read-only** and scoped to the one workspace your token belongs to. ### Published docs (served by the server) The server also publishes this guide and an `llms.txt` map at runtime (public, no auth). `` is the service base path (e.g. `/api/2.0/mcp` in production, `/api` in local dev): - `GET /guide` — docs index (HTML) - `GET /guide/setup` · `/guide/agent-guide` · `/guide/adding-tools` · `/guide/composable-tools` — markdown - `GET /llms.txt` — agent/crawler map (point AI tools here) - `GET /llms-full.txt` — single-fetch concatenation of the user-facing guides - `GET /.well-known/llms.txt` — same body as `/llms.txt` - `GET /.well-known/llms-full.txt` — same body as `/llms-full.txt` OAuth Protected Resource Metadata (RFC 9728 / TMCP-11) is served by the **auth** service (not this MCP pod). Public URLs: - `GET /api/2.0/auth/.well-known/oauth-protected-resource` - Host-root `/.well-known/oauth-protected-resource` (LB `lb_paths` → auth, no rewrite) Auth env (defaults **off** — align with TMCP-6 OAuth flags): | Variable | Purpose | |----------|---------| | `OAUTH_PRM_ENABLED=1` | Publish PRM (unset/`0` → 404) | | `OAUTH_AUTHORIZATION_SERVERS` | CSV issuer URLs for `authorization_servers` | | `MCP_PUBLIC_BASE_PATH` | MCP mount used to build `resource` (default `/api/2.0/mcp`) | | `OAUTH_PRM_RESOURCE_ORIGIN` | Optional absolute origin (`https://api…`) so `resource` is deterministic and not Host/X-Forwarded-derived | The PRM `resource` field still points at this MCP endpoint (`…/api/2.0/mcp/mcp`). OAuth scopes advertised come from `@api-shared/auth/mcp-oauth-scopes` (`MCP_OAUTH_SCOPES_SUPPORTED` — capability scopes including `uar:read` stay reserved until TMCP-7 DCR defaults). ### Host-root discovery (`lb_paths`, no rewrite) The MCP and auth pods also answer host-root discovery paths. The load balancer forwards the original path (`lb_paths` on each microservice) — no URL rewrite: | Client asks (host root) | Backend | |-------------------------|---------| | `GET /llms.txt` | mcp | | `GET /llms-full.txt` | mcp | | `GET /.well-known/llms.txt` | mcp | | `GET /.well-known/llms-full.txt` | mcp | | `GET /.well-known/oauth-protected-resource` | auth | | `GET /.well-known/oauth-protected-resource/api/2.0/mcp/mcp` | auth | Preserve `Host` / `X-Forwarded-*` so absolute links / `resource` in the response body stay correct. --- ## Claude Desktop (config file) Open **Settings → Developer → Edit Config** (or edit the file directly): - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "timedoctor": { "type": "http", "url": "MCP_URL", "headers": { "Authorization": "Bearer TD_TOKEN", "x-company-id": "COMPANY_ID" } } } } ``` Fully **quit and reopen** Claude after saving. If your Claude build doesn't accept custom headers in the config, use the `mcp-remote` bridge below. ## Claude.ai (web) / Claude Desktop — Custom Connector (no config file) **Settings → Connectors → Add custom connector** → name it, paste `MCP_URL`, complete auth, then enable the tools you want. (Requires the server to be on the public internet.) ## Claude Code (CLI) ```bash claude mcp add --transport http timedoctor MCP_URL \ --header "Authorization: Bearer TD_TOKEN" \ --header "x-company-id: COMPANY_ID" ``` ## ChatGPT (Developer Mode) Plus/Pro/Business/Enterprise. **Settings → Connectors → Advanced → enable Developer mode** → **Add custom connector** → name + `MCP_URL` → choose auth → save. After server changes, open the connector and click **Refresh** to re-pull the tools. ChatGPT honors the read-only hint, so these tools won't prompt for write confirmation. If your ChatGPT plan only offers OAuth-based connectors and you have a static token, use the `mcp-remote` bridge instead. ## Google Gemini CLI Edit `~/.gemini/settings.json` (or project `.gemini/settings.json`): ```json { "mcpServers": { "timedoctor": { "httpUrl": "MCP_URL", "headers": { "Authorization": "Bearer TD_TOKEN", "x-company-id": "COMPANY_ID" }, "timeout": 30000 } } } ``` (`httpUrl` = Streamable HTTP.) Verify in a session with `/mcp`. ## Cursor Edit `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project): ```json { "mcpServers": { "timedoctor": { "url": "MCP_URL", "headers": { "Authorization": "Bearer ${env:TD_TOKEN}", "x-company-id": "COMPANY_ID" } } } } ``` Use `${env:TD_TOKEN}` so the token isn't committed. ## VS Code (Copilot agent) Edit `.vscode/mcp.json` (workspace) or your user MCP config. Note the top-level key is `servers` and `type: "http"`: ```json { "inputs": [ { "id": "tdToken", "type": "promptString", "description": "TimeDoctor token", "password": true } ], "servers": { "timedoctor": { "type": "http", "url": "MCP_URL", "headers": { "Authorization": "Bearer ${input:tdToken}", "x-company-id": "COMPANY_ID" } } } } ``` ## `mcp-remote` fallback (any stdio-only client, or for header injection) Requires Node.js (`npx`): ```json { "mcpServers": { "timedoctor": { "command": "npx", "args": [ "mcp-remote", "MCP_URL", "--header", "Authorization: Bearer ${TD_TOKEN}", "--header", "x-company-id: ${COMPANY_ID}", "--header", "x-client-name: claude" ], "env": { "TD_TOKEN": "TD_TOKEN", "COMPANY_ID": "COMPANY_ID" } } } } ``` > Set `x-client-name` to the host product (`claude`, `cursor`, `chatgpt`, …) when using > `mcp-remote` — otherwise usage analytics may only see a generic Node User-Agent > (TMCP-79). Direct HTTP clients that send MCP `initialize.clientInfo` are labeled > automatically. > > Optional analytics headers (TMCP-80 / TMCP-52): `x-mcp-conversation-id` (stable > per user question), `x-mcp-turn-id`, `x-mcp-root-request-id`, `x-mcp-parent-call-id`, > `x-mcp-is-retry`. Agents can also pass the same fields as tool args. If omitted, > the server writes a **1-call baseline** only (`conversationId=requestId`, > `turnId=conversationId`) — that does **not** merge multi-call questions; send a > stable `conversationId` (header or tool arg) for true question grouping. > > Optional analytics headers (TMCP-81): `x-mcp-query` (hashed only — never stored raw) > and `x-mcp-question-category` (allowlisted override; usually omit so the server > derives the category from the tool). --- ## Verify it works After connecting, ask the client to **list tools** — you should see the `td_*` tools (e.g. `td_whoami`, `td_list_users`, `td_list_projects`, `td_get_time_summary`). Then try: *"Who am I connected as?"* (`td_whoami`) or *"List the first few projects in my workspace."* ## Troubleshooting | Symptom | Likely cause / fix | |---|---| | No server / all servers vanish | Invalid JSON (a stray comma). Validate the file. | | `command not found` / `npx` errors | Install Node.js; pre-test with `npx -y mcp-remote MCP_URL`. | | 401 / `unauthorized` | Missing/expired token or wrong `x-company-id` → regenerate `TD_TOKEN` / fix `COMPANY_ID`. | | `forbidden` | MCP isn't enabled for your workspace yet, or your role lacks access. Contact your admin. | | Connects but no tools | Wrong transport field for that client (`httpUrl` vs `url` vs `type:"http"`), or you need to Refresh (ChatGPT) / restart the client. | | Tool “didn't load” on first try, works on retry | Usually **client-side** discovery (some clients load tools on demand, e.g. Claude). The server returns the full tool list on every authenticated `tools/list`. Retry the same question once; if it keeps failing, Refresh/restart the connector and check 401/406 in troubleshooting above. | | `rate_limited` / HTTP 429 / `busy` | You're sending too fast — read `Retry-After` / `X-RateLimit-*`, slow down, then retry. | | Results say `truncated` | The page exceeded the size cap; use the returned `cursor` to fetch more. | ## Good to know - **Read-only:** the server cannot modify any TimeDoctor data. - **Single workspace:** one token = one workspace; you can't read across workspaces. - **Limits:** ~10 requests/second, up to 500 rows per page, 256 KB per response, 30-second timeout. See [`agent-guide.md`](./agent-guide.md) for details the agent uses. - **Privacy:** responses are filtered to a fixed allowlist of fields; secrets/PII beyond what each tool documents are stripped server-side. --- ## Source: Agent usage guide (`agent-guide.md`) # TimeDoctor MCP — usage guide for LLMs / agents This page tells an AI agent how to use the TimeDoctor MCP server well. The same guidance is sent to every client automatically as the MCP **server `instructions`** (at `initialize`) — see `src/mcp/instructions-tier-a.ts` (slim Tier A; full reference in `src/mcp/instructions.ts`) — so a well-behaved client already has the short version in context. This page is the longer reference. > **Behavior-first (TMCP-68).** Every tool's `tools/list` description includes > **`When to use:`** / **`When not to:`**. Prefer those lines when two tools look > similar (especially meetings vs time summary, apps vs productivity %, schedules > vs tracked hours). > **Read-only.** Every tool only reads. Nothing here creates, updates, or deletes. > Data is scoped to the **one workspace** tied to the caller's token. --- ## 1. Tenant & auth model (what the agent must know) - The client authenticates with a TimeDoctor token: `Authorization: Bearer `. The workspace/company is taken from that credential (and the `x-company-id` header the client sends) — **never** from a tool argument. - Do **not** ask the user for a company/workspace id and do **not** invent one. - If a call returns `unauthorized`, the token is missing/expired → tell the user to reconnect. Don't retry blindly. ## 2. What data exists There are two kinds of tools: **directory** (metadata) and **time-tracking analytics** (reports over a date range), plus `td_whoami`. ### Directory | Domain | Tools | When to use | When not to | |---|---|---|---| | Users | `td_list_users`, `td_list_managed_users`, `td_get_user` | Name/@mention → userId; manager reports; known-id profile (PII) | Hours/productivity (analytics); invent ids | | Projects | `td_list_projects`, `td_get_project` | Project name → id; known-id record | Time per project (`td_get_time_summary` / `td_get_activity_project_totals`) | | Tasks | `td_list_tasks`, `td_get_task` | Task name → id; list under projects | Time per task (`td_get_time_summary` / `td_get_activity_task_totals`) | | Groups (tags) | `td_list_groups`, `td_get_group` | Team/tag name → group id (then `tag=[id]`) | Person names; hours without resolving id | | Work schedules & leave | `td_list_work_schedules`, `td_get_work_schedule`, `td_get_work_schedule_issues`, `td_get_attendance`, `td_get_leave_stats` | Planned shifts/PTO; adherence conflicts; per-shift present/late/absent verdicts; leave-day totals (`from`/`to`; issues/attendance/leave accept `tag`) | Actual tracked hours (`td_get_time_summary`); start/finish timeline (`td_get_worklog`) | | Break types | `td_list_breaks` | Company break-type configuration (paid/unpaid, allowance) | Time spent on break (`td_get_worklog` mode paidBreak/unpaidBreak) | | Categories (premium) | `td_list_categories`, `td_get_unrated_category_count` | App/site name → category ids for licence tools; unrated backlog count | Licence seats/time (`td_get_software_license_*`); productivity % | | Unusual activity (UAR) | `td_get_uar_summary`, `td_get_uar_per_user`, `td_get_uar_per_activity`, `td_get_uar_events` | Unusual activity drill-down (manager+ + UAR enabled; times in **minutes**) | Normal productivity/idle (`td_get_time_summary`) | | Composable | `td_drill_down_productivity_analysis`, `td_drill_down_team_productivity` | Broad multi-step productivity / team member comparison | Single metric — prefer the specific analytics tool | ### Time-tracking analytics These answer the customer-question categories: **Time & Attendance, Productivity, Work-Life/Burnout, Meetings, App/Tool Usage, Trends**. Each **requires a `from`/`to` range** (see §3) and is scoped to the users you may see (own / managed / whole company, by role). | Tool | What it returns | When to use | |---|---|---| | `td_get_time_summary` | Aggregated **trackedSec** (includes idle), **activeSec**, **idleMins**, productive/unproductive/neutral/unrated, plus **meetingSec** / **meetingCount** / **idleMeetingMins**. Omit `groupBy` for one overall total; set `groupBy` only for a breakdown. | Hours **tracked** vs **active**, idle %, productivity scores, **meeting totals** (especially tag/group/team), overall team totals, and (with `groupBy=date` + `period`) weekly / over-time **trends**. | | `td_get_period_comparison` | Overall totals for **two** calendar periods plus a single **comparison** (`absolute`, signed **`pctChange`** on a 0–100 scale / `null` when previous=0, optional **`pctChangeDisplay`**). v1 is totals-only (no `groupBy`). | "This week vs last week", period-over-period deltas. Prefer over calling `td_get_time_summary` twice. | | `td_get_web_app_usage` | Time per website/app, each with a productivity rating (unrated/unproductive/neutral/productive) and category. | Top apps/sites, productive-vs-unproductive split, time in a specific tool (CRM/Teams/email/AI tools). | | `td_get_worklog` | Timeline of activity blocks (start/end, duration, mode, resolved task/project). Breaks appear as mode `paidBreak`/`unpaidBreak`; leave as `paidLeave`/`unpaidLeave`. Meetings are **not** a worklog mode — use `td_get_meetings` or `td_get_time_summary`. | Start/finish times, late/weekend work, activity blocks, breaks. | | `td_get_meetings` | Meeting/call **detail** only (start/end, durationSec, `appName`, talkTimeSec). **Default: merged sessions** (one row per mic session). Pass `mergeMeetings: false` for raw unmerged segments. Does **not** return `meetingCount`. | Talk time, which meeting app was used, when meetings occurred ("list my meetings"). **Never** for meeting count — use `td_get_time_summary` → **`meetingCount`**. | | `td_get_productivity_breakdown` | Time grouped by website/app **category**, productivity `score`, or task — with `trackedSec` / `activeSec` / `idleMins`. Accepts `user` or `tag`. | Which apps/categories drive productive vs unproductive time (`groupBy=score`, or `prefilterScore=2`). | | `td_get_activity_project_totals` | Flat list: project id + name + **trackedSec**. Accepts `user` or `tag`. | "Which projects did this person/team work on?" (Activity Summary side panel). | | `td_get_activity_task_totals` | Flat list: task id + name + project id + **trackedSec**. Accepts `user` or `tag`. Row `trackedSec` is time on that one task — read the `reconciliation` envelope for the unassigned residual. | "Which tasks did this person/team log time on?" | | `td_list_low_activity_screencasts` | Low-activity screencast **metadata** — **no image/download URLs**. Requires `user` or `tag`. | Spotting low-activity periods (monitoring). | | `td_get_work_life_balance` | Per-user **day counts** of WLB issues (tooManyHours / lateHours / weekendWork / outOfShiftWork). | Who works weekends/late; burnout day counts. | | `td_get_work_life_balance_timeseries` | Day-level WLB ranking (`groupBy=user` or `group`). Prefer for "how often / which team is worst". | Per-day frequency / team ranking (not simple per-user totals). | | `td_get_shift_totals` | Per-user **scheduled** shift seconds (roster only). | How much shift was scheduled — not attendance. | | `td_get_company_outliers` | One company rollup object: averages, threshold breaches, named offenders, best/worst groups. | Overall company health / outliers. | | `td_list_company_timezones` | Distinct IANA timezones in the company. | Pick reporting timezone; geographic spread. | | `td_get_software_license_usage` | Per-user Used/Rarely/Unused seats for tools (**one UTC calendar month**; category ids via `td_list_categories`). Premium. | Who uses a tool / which seats to drop. | | `td_get_software_license_summary` | Company seat rollup (`licensedUserCount` = users in scope). Premium. | How many seats used vs idle. | | `td_get_software_license_usage_timeseries` | Day/week buckets (`metric=status` or `time`). Premium. | Usage growing/dying over time. | | `td_get_disconnectivity` | Internet-disconnect intervals (explicit user ids required). | Tracking gaps / connection reliability. | | `td_list_manual_time_edits` | Manual time add/remove rows + approval state. | What was added by hand / pending approval. | | `td_whoami` | The authenticated caller: user id, name/email, company (workspace) id + name, role, access level. Takes no arguments. | Confirm whose data the other tools will return. Good first call. | ### Inputs All analytics tools accept **`tag`** (array of group ids from `td_list_groups`) for team/group questions. The server expands tags to user ids — do not loop `td_list_users` unless you need emails or ids not present in an analytics response. `td_get_time_summary`, `td_get_worklog`, and `td_get_meetings` include display names (`userName`, `projectName`, `taskName`) so a follow-up name lookup is usually unnecessary. - `td_get_time_summary` — `from`, `to` (required), `groupBy` (optional — omit for overall totals), `period`, `timezone`, `user`, `tag`, `fields`, `sort`, `limit`, `cursor`. Omitting both `user` and `tag` → whole accessible scope (admin: company; manager: direct reports). **Pass `timezone` from `td_whoami` → `user.timezone`.** The server converts inclusive calendar `from`/`to` into UTC ISO day bounds in that zone and does **not** send `timezone` upstream. Response includes `timezoneUsed`. `fields` selects which metrics to fetch — omit for the default set (no ratios); pass specific names (e.g. `["meetingSec","meetingCount"]`) to slim the request; pass `["all"]` for every metric including ratios. `sort` uses API field names (`activeSec`, `idleMins`, `prod`, `unprod`, `neutral`, `unrated`, `meeting`, `meetingCount`, `idleMinsMeeting`, `meetingRatio`, `unprodRatio`, `idleMinsRatio`); prefix with `_` for descending (e.g. `_meeting`, `_prod`, `_meetingRatio`, `_unprodRatio`). Response renames ratios to `meetingPct` / `unproductivePct` / `idlePct`. - `td_get_period_comparison` — `from`, `to` (required current period), optional `compareFrom`/`compareTo` (both or neither; omit both to auto-shift back by the same inclusive length), `timezone`, `user`, `tag`, `fields`. Returns `{ current, previous, comparison }`. No `groupBy` in v1. - `td_get_web_app_usage` — `from`, `to` (required), `user`, `tag`, `category`, `minSeconds` (consolidated only; rejected with `raw`/`includeTitle`), `raw` / `includeTitle` (unconsolidated per-URL/title fragments; `timeSec` often 1s), `limit`, `cursor`. Omitting both → authenticated caller only. - `td_get_worklog` — `from`, `to` (required), `user`, `tag`, `mode`, `limit`, `cursor`. Omitting both → caller only. - `td_get_meetings` — `from`, `to` (required), `user`, `tag`, optional `mergeMeetings` (default **true** = merged sessions; `false` = raw segments), `limit`, `cursor`. Omitting user+tag → caller only. - `td_get_productivity_breakdown` — `from`, `to`, **`user` or `tag` (one required)**, `groupBy`, `prefilterScore`, `minSeconds`, `limit`, `cursor`. - `td_get_activity_project_totals` / `td_get_activity_task_totals` — `from`, `to`, **`user` or `tag` (one required)**, `limit`, `cursor`. - `td_list_low_activity_screencasts` — `from`, `to`, **`user` or `tag` (one required)**, `maxAvgActivity`, `limit`, `cursor`. - `td_whoami` — no inputs. > **Output field names are LLM-friendly**, remapped from the legacy API: e.g. `categoryId` > (not `comCat`), `productiveSec`/`unproductiveSec`/`neutralSec`/`unratedSec` (not > `prod`/`unprod`/…), `meetingSec`, `meetingPct`/`unproductivePct`/`idlePct` (from > `meetingRatio`/`unprodRatio`/`idleMinsRatio` when `fields` includes them or `all`), > `durationSec`, `talkTimeSec`, `appOrUrl`, `avgActivityPercent`. ### Question → tool cheat-sheet | The user asks… | Call | |---|---| | hours worked / idle % / productivity / time per project, task, user / trends / **meeting totals** / **meeting count** | `td_get_time_summary` | | this week vs last week / period-over-period totals | `td_get_period_comparison` | | which apps or websites / time in a specific tool | `td_get_web_app_usage` | | which app **categories** drive (un)productivity | `td_get_productivity_breakdown` | | when did they start / finish / work late / breaks / activity timeline | `td_get_worklog` | | meeting talk time / conferencing app / individual meeting blocks only | `td_get_meetings` (never for meeting count) | | low-activity screencasts / monitoring | `td_list_low_activity_screencasts` | | who am I / which workspace / timezone / plan | `td_whoami` | | resolve person / @mention → userId | `td_list_users` | | manager's direct reports ("my team" without a tag) | `td_list_managed_users` | | resolve team / group / tag name → id | `td_list_groups` | | resolve project / task name → id | `td_list_projects` / `td_list_tasks` | | planned shifts / PTO calendar | `td_list_work_schedules` | | schedule adherence / missing shifts | `td_get_work_schedule_issues` | | who was late / absent / on leave (per shift) | `td_get_attendance` | | pending vs approved leave **days** | `td_get_leave_stats` | | unusual activity overview → type → events | `td_get_uar_summary` → `td_get_uar_per_user` → `td_get_uar_per_activity` → `td_get_uar_events` | | broad productivity issues (composed) | `td_drill_down_productivity_analysis` | | team member comparison (composed; needs tag) | `td_drill_down_team_productivity` | | weekends / late nights / WLB day counts | `td_get_work_life_balance` | | how often / which team has worst WLB | `td_get_work_life_balance_timeseries` | | scheduled shift hours (roster) | `td_get_shift_totals` | | company outliers / dashboard rollup | `td_get_company_outliers` | | company timezones in use | `td_list_company_timezones` | | app/site name → category ids (premium) | `td_list_categories` | | unrated category backlog count | `td_get_unrated_category_count` | | who uses a software licence / seats to drop | `td_get_software_license_usage` | | company licence seat rollup | `td_get_software_license_summary` | | licence usage over time (day/week) | `td_get_software_license_usage_timeseries` | | disconnect / tracking gaps | `td_get_disconnectivity` | | manual time edits / approvals | `td_list_manual_time_edits` | | break type configuration | `td_list_breaks` | ## 3. How to choose a tool 1. **Resolve ids first.** Use a `td_list_*` tool to find the id you need, then a `td_get_*` tool for the full record. Don't guess ids. 2. **Pass the narrowest filter.** Each tool's input schema lists only the filters it supports (e.g. `td_list_tasks` takes `projects`, `deleted`; `td_list_users` takes `name`, `detail`, `tag`; `td_list_groups` / `td_list_managed_users` take `name`). Use them instead of fetching everything and filtering client-side. `name` is a case-insensitive prefix search against the legacy API. 3. **Always pass a date range** to the analytics, work-schedule and leave tools (`from`, `to`, ISO dates). Prefer the smallest range that answers the question (a day / week / month) — wide ranges are slower and more likely to be truncated. 4. **If a tool “didn't load” on the first try**, retry the same call **once** before telling the user the tool is missing. Some clients (e.g. Claude) load tools on demand; the TimeDoctor MCP server returns the full registry on every authenticated `tools/list` (discovery is not gated by killswitch/GrowthBook). ### Name / @mention → id (users and tags) MCP clients (including Cursor) do **not** autocomplete `@Name` into a `userId` or tag id. Always resolve with the existing list tools before analytics: | The question mentions… | Resolve with | Then pass | |---|---|---| | A person (`@Bukunmi`, "Nasir's activity", "hours for Alice") | `td_list_users { name, detail: "name" }` | `user=` | | A team / group / tag ("Cloud Features", "Engineering") | `td_list_groups { name }` | `tag=[groupId]` | **Steps (same for users and groups):** 1. Strip a leading `@` if present. 2. Call the list tool with `name` set to a **case-insensitive prefix** (longest distinctive prefix works best — `"ali"` matches `"Alice"`). 3. Handle matches: - **1** → use that id. - **0** → try a shorter prefix, or ask the user. - **2+** → list candidates (name + id; email if present for users) and ask which one — **never guess**. 4. Only then call the analytics / UAR / schedule tool with `user` or `tag`. **Date range (analytics tools).** `from`/`to` are **required**. Future dates are rejected. History is retained ~2 years; a single request must span **less than 366 days**. Bad input (future date, range too wide, malformed date) comes back as `invalid_argument` — fix the argument and retry, don't repeat the same call. Common parameters: | Param | Tools | Meaning | |---|---|---| | `limit` | all list/analytics tools | max rows, 1–500 (default 50) | | `cursor` | all list/analytics tools | opaque token from a previous response; pass it back for the next page | | `id` | all `td_get_*` (detail) | the record id | | `from` / `to` | analytics tools, `td_list_work_schedules`, `td_get_attendance`, `td_get_leave_stats` | ISO date range | | `user` | analytics tools | one user id or comma-separated ids | | `tag` | analytics tools + `td_list_users` / `td_list_projects` / `td_get_leave_stats` / `td_get_work_schedule_issues` / `td_get_attendance` | group/tag id(s) from `td_list_groups` — preferred for team questions | | `name` | `td_list_users`, `td_list_managed_users`, `td_list_groups` | case-insensitive name **prefix** search | ## 4. Pagination Every list and analytics tool uses the **same `limit` (≤500) + `cursor` contract**. Tools return at most **500 rows**; if the response includes a `cursor`, more data exists — pass it back as `cursor` to get the next page. Only page through everything when the user genuinely needs totals; otherwise the first page is usually enough. ## 5. Limits the agent should respect | Limit | Value (default) | What it means for you | |---|---|---| | Rate | ~**10 requests/second** per credential (also per user, per company, + a global ceiling) | Every response includes `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`. On HTTP **429**, read `Retry-After` and `error.data.retryAfter`, back off, then resume. Don't hammer. | | Page size | **500** rows | Use `cursor` to continue. | | Response cap | **256 KB** per call | Oversized results come back trimmed with `"truncated": true` and a `cursor`. | | Timeout | **30 s** per call | Narrow the query if you hit `timeout`. | ## 6. Errors (JSON with `isError: true`) and empty statuses Tool failures return JSON text shaped like: ```json { "error": { "code": "invalid_argument", "message": "…", "hint": "…", "did_you_mean": [{ "id": "…", "name": "…", "type": "group" }], "suggestions": ["…"], "retryAfter": 2 } } ``` Prefer `code` + `did_you_mean` / `suggestions` over guessing or blind retry. ### Enhanced Error Recovery (TMCP-64-v2) The MCP server includes smart error recovery for common user/group lookup failures: **User/Group 404s → Smart Recovery:** - `td_get_user` and `td_get_group` with non-existent IDs now return `invalid_argument` (not `internal_error`) - Automatic `did_you_mean` suggestions when the ID looks like a name/partial name - Enhanced `suggestions` with actionable next steps **Example Recovery Flow:** ``` 1. Agent: td_get_user({id: "alice"}) // name instead of ID 2. Server: invalid_argument + did_you_mean: [{id: "amocZBUU...", name: "Alice Smith", type: "user"}] 3. Agent: td_get_user({id: "amocZBUU..."}) // auto-corrected 4. Server: success with user data ``` **Best Practices:** - Always check `did_you_mean` candidates for automatic correction - Use `suggestions` array for fallback actions when `did_you_mean` is empty - Distinguish `invalid_argument` (user error, fixable) from `internal_error` (server issue, surface to user) - Resource-scoped API keys do **not** receive `did_you_mean` (avoids leaking ids outside the key's ReBAC data scope); use `td_list_users` / `td_list_groups` instead **Rate limits** are also enforced at the HTTP layer on `tools/call`: | Signal | Meaning | What to do | |---|---|---| | Response headers `X-RateLimit-Limit` / `Remaining` / `Reset` | current quota window | pace calls; stop before remaining hits 0 | | HTTP **429** + `Retry-After` | quota exhausted | wait `Retry-After` seconds (also in `error.data.retryAfter` / tool `error.retryAfter`), then resume | | Tool `rate_limited` / structured `{ error: { code, retryAfter } }` | upstream or residual limit | same backoff | | Code | Meaning | What to do | |---|---|---| | `unauthorized` | token missing/expired/invalid | tell the user to reconnect; don't retry | | `not_enabled` | MCP (or feature) not enabled for this workspace | don't retry; ask an admin to enable | | `no_access` | missing scope / outside API-key data scope | don't retry; check `td_whoami` role/features | | `forbidden` | upstream denied this resource | don't retry; may be plan-gated — see plan section | | `invalid_argument` | bad input (future date, range too wide, unknown tag/user/group) | fix via `did_you_mean` / `suggestions` / `hint`; retry once. For `td_get_user` / `td_get_group` 404s, automatic suggestions are provided. | | `rate_limited` | too many requests | back off using `Retry-After` / `error.retryAfter` / `X-RateLimit-Reset` | | `busy` | server saturated | retry shortly | | `timeout` | upstream/query too slow | narrow the query and retry | | `service_unavailable` | temporarily disabled | retry sparingly | | `internal_error` | unexpected | retry sparingly, then surface to the user | Successful shaped responses also include: | `status` | Meaning | |---|---| | `ok` | data present | | `empty` | directory list/detail had no matches (not an error) | | `no_data_in_range` | analytics/range query returned no rows (not an error; see `message`/`hint`) | Empty vs not-enabled vs error are distinguishable — never invent data for a blank payload. ## 7. Examples - *"Who reports to manager U123?"* → `td_list_managed_users { manager: "U123" }`. - *"Find Alice among U123's reports"* → `td_list_managed_users { manager: "U123", name: "Alice" }`. - *"How much leave did the team take last month?"* → `td_list_groups { name: "Cloud Features" }` (find group id) → `td_get_leave_stats { from: "2026-05-01", to: "2026-05-31", tag: [""] }` (returns `totalPendingCount` / `totalApprovedCount` in days). - *"Who was late or absent last week?"* → `td_get_attendance { from: "2026-07-06", to: "2026-07-12", tag: [""], showAbsentLateOnly: true }` (one row per shift: `status`, `lateDiffSec`, `expectedHoursSec` vs `actualHoursWorkedSec`). Don't join `td_list_work_schedules` with `td_get_worklog` by hand. - *"Show project ACME's tasks."* → `td_list_projects` to find the id → `td_list_tasks { projects: "" }`. - *"How many hours did the team work last week, and how idle were they?"* → `td_get_time_summary { from: "2026-06-01", to: "2026-06-07" }`. - *"Show U123's daily productivity trend this month."* → `td_get_time_summary { from: "2026-06-01", to: "2026-06-12", user: "U123", groupBy: "date", period: "day" }`. - *"Which apps did the Cloud Features team spend the most time in yesterday?"* → `td_list_groups { name: "Cloud Features" }` (find group id) → `td_get_web_app_usage { from: "2026-06-11", to: "2026-06-11", tag: [""] }`. - *"Show late-night work blocks for the engineering group this week."* → `td_get_worklog { from: "2026-06-08", to: "2026-06-12", tag: [""] }`. - *"When did U123 start and finish work today?"* → `td_get_worklog { from: "2026-06-12", to: "2026-06-12", user: "U123" }`. - *"How many meetings / meeting count this week?"* → `td_get_time_summary { from, to, tag? }` → quote **`meetingCount`**. Never `td_get_meetings`. - *"How much time was spent in meetings this week?"* → `td_get_time_summary { from: "2026-06-08", to: "2026-06-12" }` → quote **`meetingHms`** / **`meetingCount`**. For a team: add `tag: [groupId]`. Use `td_get_meetings` only for talk time / app names. - *"Who am I connected as?"* → `td_whoami {}`. #### Why doesn't `meetingCount` match the number of `td_get_meetings` rows? They measure different things — do **not** validate one against the other: 1. **`meetingCount`** (from `td_get_time_summary`) = meeting **session starts** — each time the microphone became active, even if muted. 2. **`td_get_meetings` default** = **merged sessions** (`mergeMeetings` true / omit the flag). Closer to `meetingCount`, but edges can still differ. Pass `mergeMeetings: false` only for raw **segments** (many rows per long/multi-app call). 3. **Scope defaults differ:** omit `user`/`tag` on summary → whole accessible scope; on meetings → caller only. Comparing unfiltered outputs is doubly invalid. ## 8. Answering common customer questions (recipes & metrics) ### Time units — convert before answering | Raw field pattern | Unit | What to show the user | |---|---|---| | `*Sec` (trackedSec, activeSec, productiveSec, timeSec, …) | seconds | **`trackedHms` / `activeHms` / `durationHms` / `timeHms`** — never quote raw seconds | | `*Mins` (idleMins, UAR fields) | minutes | Convert to hours/minutes in prose, or use `idlePct` when present | | UAR tools | minutes | Convert to hours/minutes for the user (not raw minutes) | **Rule:** prefer server-formatted `*Hms`, `*Pct`, `dateYmd`, `startDate`/`startTime` fields. Only compute from raw `*Sec` when no display field exists: `seconds ÷ 3600` → hours, or `H:MM:SS`; percentages → one decimal + `%`. **Human-readable duration (mandatory for large totals):** `*Hms` is `H:MM:SS` where `H` may exceed 24 (e.g. `5031:30:42` = 5031 hours, 30 min, 42 sec — not a decimal hour count). When the hours part is **≥ 24**, always pair `*Hms` with a calendar-style span: > Total tracked: **5031:30:42** (≈ **6 months, 29 days**) Conversion: 1 day = 24h · 1 month = 30 days · 1 year = 365 days. Peel off years → months → days → hours → minutes; omit zero units; prefer the largest 1–3 non-zero units. Short durations (< 24h): `*Hms` alone (or `8h 5m`) is enough. Quick refs: 1h = 3600s · 8h = 28800s · 40h week = 144000s. `td_get_time_summary` returns one overall row when `groupBy` is omitted, or one row per group (`userId` / `projectId` / `taskId` / `date`) when `groupBy` is set. | The user asks… | How to answer | |---|---| | Hours **tracked** this week/month (user or team) | `td_get_time_summary` with `tag`/`user`, **no groupBy** → quote **`trackedHms`** (includes idle). | | This week vs last week / period-over-period | `td_get_period_comparison` → quote **`comparison.pctChangeDisplay`** / **`absolute`** (and current/previous `*Hms`). | | Hours **active** (non-idle) | `td_get_time_summary` → quote **`activeHms`** / `activeSec`. Never use trackedSec for this. | | Productive vs unproductive % | `td_get_time_summary` → quote **`productivePct`** / **`unproductivePct`**. | | Idle time / idle % | `td_get_time_summary` → quote **`idlePct`** (from idleMinsRatio; UI-aligned). | | Top / bottom performers | `td_get_time_summary` (groupBy=user, `sort=_prod` or `sort=prod`) → quote **`productiveHms`** / `productiveSec`. | | Time per project / task | `td_get_time_summary` groupBy=`project` / `task` → **`trackedHms`**. | | Which apps/categories drive (un)productivity | `td_get_productivity_breakdown` with `user` or `tag` → `groupBy=score`, or `prefilterScore=2`. | | Which projects/tasks (simple list) | `td_get_activity_project_totals` / `td_get_activity_task_totals` with `user` or `tag` → **`trackedSec`**. | | Weekly trend / Friday drop-off / over time | `td_get_time_summary` groupBy=`date`, `period=day|week`, **`timezone` = `user.timezone` from `td_whoami`** (see **`timezoneUsed`**) → **`trackedHms`** per **`dateYmd`**. | | Overtime / consistently > 8h a day | `td_get_time_summary` groupBy=`date` → per-day **`trackedHms`**; flag days over 8:00:00. | | Late nights / weekend work | `td_get_worklog` → **`startDate`**, **`startTime`**, **`endTime`** (in `timezoneUsed`). | | Schedule adherence / 40-hour week | `td_get_time_summary` **`trackedHms`** vs `td_list_work_schedules`. | | % time in meetings / calls vs execution | `td_get_time_summary` → quote **`meetingPct`** / **`meetingSec`** / **`meetingCount`**. | | How many meetings / meeting count | `td_get_time_summary` → quote **`meetingCount`** (never `td_get_meetings`). | | Meeting idle time | `td_get_time_summary` → quote **`idleMeetingMins`**. | | When meetings happen / talk time / app name | `td_get_meetings` → **`startDate`/`startTime`**, **`durationHms`**, **`talkTimeHms`**, **`appName`**. | | Low-activity screencasts (monitoring) | `td_list_low_activity_screencasts` → **`avgActivityPct`**, **`dateYmd`**. | | Which apps/sites, tool ROI, AI/CRM/Teams usage | `td_get_web_app_usage` → rank by **`timeHms`**. | | Who am I / which workspace | `td_whoami`. | **Tracked vs active vs idle:** `trackedSec` includes idle; `activeSec` is non-idle; use **`idleMins`** + **`idlePct`** for all idle calculations (Activity Summary UI). Do not use or expect `idleSec` / `totalSec` — they are not returned. **Display fields (server-formatted):** `*Hms` = `H:MM:SS` · `*Pct` = `12.5%` · `dateYmd` = `yyyy-mm-dd` · `startDate`/`startTime` = local date/time in `timezoneUsed`. For `*Hms` with hours ≥ 24, also quote the human-readable span (months/days/…). Raw `*Sec` / `*Mins` remain for calculations only. **Timezone:** prefer the authenticated **user** timezone for all API fetches. Call `td_whoami` → read **`user.timezone`** → pass that as `timezone` on every `from`/`to` tool (analytics, schedules, UAR). All range tools convert inclusive calendar `from`/`to` into UTC ISO day bounds in that zone (same-day `from=to` is valid). UAR also forwards `timezone` for day labels; `td_get_time_summary` only forwards it when `groupBy=date`. If omitted, the server resolves **user → company → `Etc/UTC`**. Responses include `timezoneUsed` — cite it. Pass a different IANA zone only when the user names one. **Scope & defaults (important):** - **Team/group workflow:** `td_list_groups` → copy group id → pass `tag=[groupId]` on the analytics tool. All analytics tools support `tag`. - `td_get_time_summary` — omitting both `user` and `tag` returns all users you can access (admin: whole company; manager: direct reports). For a **named group**, use `tag`, not the default. - Other analytics tools — omitting both `user` and `tag` returns **only the authenticated caller**. For team questions you must pass `tag` or `user`. - `td_get_time_summary` vs `td_get_activity_*_totals`: use **time_summary** for aggregated stats and trends; use **activity_*_totals** for a simple ranked project/task name list (Activity Summary side panel). - The per-row `score` on `td_get_web_app_usage` is only present for rated apps — for the reliable productive/unproductive split use `td_get_time_summary`. **Typical flow:** `td_whoami` (capture `user.timezone`) → resolve person/team via `td_list_users` / `td_list_groups` (see **Name / @mention → id** above; disambiguate if needed) → **`td_get_time_summary`** with `timezone: user.timezone` for the headline answer → drill into `td_get_worklog` / `td_get_web_app_usage` / `td_get_meetings` / `td_get_productivity_breakdown` with the same `timezone`, `tag` or `user` when more detail is needed. --- See also: [`setup.md`](./setup.md) (how customers connect a client) and [`ADDING-TOOLS.md`](./ADDING-TOOLS.md) (how developers add tools). --- ## Source: Composable tools (TMCP-23) (`composable-tools.md`) # Composable Tools for Multi-Step Drill-Down Aggregation (TMCP-23) Composable tools extend the Time Doctor MCP server with intelligent multi-step analysis capabilities. They orchestrate multiple existing tools to provide hierarchical drill-down insights automatically. ## Overview Composable tools solve the problem of complex analytics questions that require multiple API calls and intelligent aggregation. Instead of requiring clients to chain tool calls manually, composable tools: 1. **Automatically determine** which steps to execute based on data conditions 2. **Execute in sequence or parallel** as appropriate for the analysis 3. **Aggregate** the inner-tool rows into one composed payload (source metrics only) 4. **Track composition analytics** for performance and usage analysis ## Architecture Built on the existing MCP architecture with these key extensions: ### Core Components - **`ComposableToolDef`** - Extends `ToolDef` with composition configuration - **`executeComposition()`** - Orchestrates multi-step execution - **`makeComposableListTool()`** - Builder for list-style composable tools - **Composition patterns** - Pre-built patterns for common use cases ### Integration Points - **Tool Registry** - Automatically discovers and validates composable tools - **Usage Analytics** - Tracks composition execution with TMCP-52 enrichment - **Existing Tools** - Reuses all 26+ existing MCP tools as building blocks ### Dispatch & gates (TMCP-23) The **outer** composable tool goes through the full `dispatchTool` chain (killswitch → GrowthBook company gate → tool allowlist → scope → concurrency → timeout → handler → scrub → usage/OTel). HTTP rate-limit is enforced once at the route for that outer call. **Inner composition steps** do **not** call `dispatchTool` again (that would consume concurrency / rate-limit budget N times and risk `busy`). Instead `runCompositionInnerTool` re-enforces, per step: | Layer | Inner step | | --- | --- | | Killswitch | Yes | | GrowthBook company gate (`mcp-enabled`) | Yes | | Tool allowlist (`mcp-enabled-tools`) | Yes | | Scope / API-key permissions | Yes | | Secret scrub + byte cap + empty status | Yes | | Concurrency slot | No (outer only) | | Per-request timeout / AbortSignal | Parent signal (outer) | | Rate-limit budget | No (outer / HTTP only) | | Usage row + OTel span | No (outer only; composition metadata on ctx) | Net effect: a killswitched or feature-gated tool cannot be reached as a composition step. Rate-limit and concurrency are billed once per outer tool call, not once per step. ## Key Features ### 1. Intelligent Drill-Down Composable tools can automatically drill down into specific areas based on data conditions: ```typescript // Example: Only drill into app usage if unproductive time > 30% { toolName: 'td_get_web_app_usage', condition: (args, overview) => { const data = overview?.data?.[0] const unproductiveRatio = (data?.unproductiveSec || 0) / (data?.trackedSec || 1) return unproductiveRatio > 0.3 } } ``` ### 2. Flexible Execution Models - **Sequential** - Steps execute in order, each can use previous results - **Parallel** - Independent analyses run concurrently for speed - **Conditional** - Steps execute only when conditions are met ### 3. Domain-Specific Aggregation Aggregation returns composed source fields from the inner tools (seconds, categories, apps). It does **not** add coaching copy, ranks, or invented ratios (`topPerformers`, `productivityRatio`). Those judgements belong to the client LLM (TMCP-109). ### 4. Robust Error Handling - **Required vs Optional steps** - Composition continues if optional steps fail - **Graceful degradation** - Partial results when some analyses unavailable - **Detailed error tracking** - Full composition execution metadata ## Available Composable Tools ### `td_drill_down_productivity_analysis` **Purpose:** Comprehensive productivity analysis with automatic drill-down **Flow:** 1. Get time summary (tracked/active/idle/productive hours) 2. Drill into productivity breakdown (if significant unproductive time) 3. Analyze app usage details (if sufficient tracked time) **Use Cases:** - "Show me productivity issues for the team" - "Analyze John's productivity patterns this week" - "What's causing low productivity in the design team?" **Example Response:** ```json { "data": { "period": { "from": "2026-07-01", "to": "2026-07-31", "timezone": "UTC" }, "overview": { "trackedSec": 28800, "productiveSec": 20160, "unproductiveSec": 5760, "idleMins": 120 }, "breakdown": { "available": true, "categories": [] }, "appUsage": { "available": true, "apps": [] }, "_metadata": { "drillDownsExecuted": [ { "step": "productivity-breakdown", "executed": true }, { "step": "app-usage-details", "executed": true } ], "analysisDepth": 3 } }, "status": "ok", "_composition": { "name": "td_drill_down_productivity_analysis", "stepsExecuted": 3 } } ``` ### `td_drill_down_team_productivity` **Purpose:** Team analysis with individual member drill-down **Flow:** 1. Get team overview with per-member time rows 2. Drill into first/last members of that list for a category breakdown **Use Cases:** - "Show the engineering team's tracked/productive seconds this month" - "Compose team + member time in one call" ## Implementation Guide ### Creating a New Composable Tool 1. **Define the composition pattern:** ```typescript import { makeComposableListTool } from '../tools/composable-builders' export const myComposableTool = makeComposableListTool({ name: 'my_drill_down_analysis', description: 'My custom drill-down analysis', domain: 'analytics', scope: Scope.StatsRead, dataClass: 'pii', composition: { type: 'drill-down', // or 'parallel-aggregation' or 'custom' config: { overview: (args) => ({ toolName: 'td_get_time_summary', mapArgs: (args) => ({ ...args }) }), drillDowns: [ { toolName: 'td_get_productivity_breakdown', mapArgs: (args, overview) => ({ ...args, groupBy: 'category' }), condition: (args, overview) => overview?.data?.length > 0 } ], aggregator: (results, args) => ({ // Custom aggregation logic combined: results }) } } }) ``` 2. **Add to the registry:** Export from your domain's `index.ts` file. The tool registry auto-discovers it. 3. **Add tests:** Follow the pattern in `test/composable-tools.test.ts`. ### Custom Composition Patterns For advanced use cases, create custom composition configurations: ```typescript const customComposition: CompositionConfig = { steps: [ { toolName: 'td_get_time_summary', mapArgs: (args) => args, required: true, label: 'overview' }, { toolName: 'td_get_web_app_usage', mapArgs: (args, prev) => ({ ...args, minSeconds: calculateThreshold(prev[0]) }), condition: (args, prev) => shouldAnalyzeApps(prev[0]), required: false, label: 'app-analysis' } ], aggregator: (results, args) => { return myCustomAggregation(results, args) }, parallel: false, timeout: 30000 } ``` ## Analytics and Monitoring Composable tools integrate with TMCP-52 analytics: ### Composition Tracking - **`compositionName`** - Name of the composable tool - **`stepName`** - Individual step within the composition - **Execution metadata** - Steps executed, timing, success/failure ### Usage Patterns Monitor composition usage in BigQuery: ```sql SELECT compositionName, stepName, COUNT(*) as executions, AVG(durationMs) as avg_duration FROM `mcp_usage` WHERE compositionName IS NOT NULL GROUP BY compositionName, stepName ``` ## Best Practices ### Design Guidelines 1. **Start simple** - Begin with drill-down patterns before custom logic 2. **Fail gracefully** - Make drill-down steps optional when possible 3. **Add conditions** - Only execute expensive steps when needed 4. **Keep judgements out of the payload** - return source metrics; let the client LLM interpret them ### Performance Considerations 1. **Minimize sequential steps** - Use parallel execution when possible 2. **Add timeouts** - Prevent long-running compositions from blocking 3. **Cache-friendly** - Design for consistent argument patterns 4. **Monitor usage** - Track composition performance and optimize ### Error Handling 1. **Required vs Optional** - Mark steps appropriately 2. **Meaningful errors** - Provide context for debugging 3. **Partial success** - Return useful results even when some steps fail ## Future Enhancements ### Planned Features - **Dynamic step generation** - Generate steps based on data characteristics - **Caching between steps** - Avoid redundant tool calls within compositions - **Composition versioning** - Support for A/B testing composition strategies - **Visual composition editor** - UI for building compositions without code ### Integration Opportunities - **Custom aggregators** - Domain-specific aggregation plugins - **External data sources** - Incorporate data from outside Time Doctor - **Real-time compositions** - Live-updating multi-step analyses ## Troubleshooting ### Common Issues 1. **Tool not found errors** - Ensure referenced tools exist in `TOOL_REGISTRY` - Check tool names for typos 2. **Composition timeout** - Reduce number of steps or increase timeout - Add conditions to skip expensive steps 3. **Invalid aggregation results** - Check aggregator function handles null/undefined results - Validate data shapes from underlying tools ### Debug Mode Enable debug logging for compositions: ```typescript const result = await executeComposition(composition, args, { ...context, logger: console // Use console for debug output }, 'debug_composition') ``` ## Migration from Manual Chaining To upgrade existing manual tool chaining to composable tools: 1. **Identify the pattern** - Sequential, parallel, or conditional 2. **Map arguments** - How each step uses previous results 3. **Define conditions** - When each step should execute 4. **Test thoroughly** - Verify results match manual chaining Example migration: ```typescript // Before: Manual chaining const timeSummary = await callTool('td_get_time_summary', args) const breakdown = await callTool('td_get_productivity_breakdown', { ...args, groupBy: 'category' }) // After: Composable tool const result = await callTool('td_drill_down_productivity_analysis', args) ``` The composable tool handles the chaining, error handling, and intelligent aggregation automatically. --- ## Source: Adding tools (developers) (`ADDING-TOOLS.md`) # TimeDoctor MCP server — architecture & adding tools A read-only Model Context Protocol (MCP) gateway over the TimeDoctor platform. AI clients (Claude, ChatGPT, Gemini, …) connect over **stateless Streamable HTTP** at `/api/mcp` and call a small, curated set of tools. The gateway validates the caller, enforces protection limits, fetches data from the legacy APIs, redacts it, and returns it. --- ## 1. What we have ``` AI client ──POST /api/mcp (JSON-RPC, Bearer , x-company-id)──► mcp service (Fastify via startService: config·sentry·redis·mongo·OTel·authSDK) routes/mcp.route.ts validate Origin → resolveIdentity(token) → stateless McpServer+transport lib/define-tool.ts dispatcher: per tool call, run every cross-cutting layer tools// tool definitions (mostly via makeListTool / makeDetailTool) lib/bridge.ts tenant-scoped read-only HTTP to legacy /api/1.0 (or /internal/api/1.0) governance/ field allowlist + redaction (drops PII) models/ owned Mongo collection: usage/audit (McpUsage) ``` ### Request lifecycle (every tool call) The dispatcher in `lib/define-tool.ts` wraps **every** tool with these layers, in order (any layer can short-circuit with a JSON-RPC error — handlers never see a failed gate): 1. **auth** — `resolveIdentity(req)` already produced `{ userId, companyId, role, accessLevel }` 2. **killswitch** — global + per-tenant Redis flags (`lib/killswitch.ts`) 3. **GrowthBook company gate** — `mcp-enabled` flag per company (`mcp/growthbook-gate.ts`) 4. **GrowthBook tool allowlist** — JSON flag `mcp-enabled-tools` (string[] of tool names); filters `tools/list` registration and denies `tools/call` for names not in the list (TMCP-18) 5. **scope** — `assertToolScopes(def.requiredScopes, identity)` (`auth/scopes.ts`); OAuth missing capability scope → `insufficient_scope` (TMCP-7) 6. **rate-limit** — 10 rps, most-restrictive of token/user/tenant (`lib/ratelimit.ts`) 7. **concurrency** — per-tenant + global cap, fast-fail (`lib/concurrency.ts`) 8. **timeout** — `AbortSignal` (~30 s), passed to the bridge (`lib/bridge.ts`) 9. **handler** — your tool code (fetch + shape) 10. **usage + OTel** — one span + one `mcp.usage` record (Mongo + log): company/user/tool, latency, bytes, outcome, filters/range, plus TMCP-52 analytics (`clientName`, `conversationId`, pagination flags, `paramsHash`, `errorType`, …). Optional tool args `conversationId` / `turnId` / `rootRequestId` / `parentCallId` / `isRetry` are stripped before the handler. TMCP-80: if the client omits `conversationId`/`turnId`, the dispatcher fills them (`requestId` baseline / copy) so usage coverage is not zero. Raw prompt text is never stored; `queryHash` when the client sends `x-mcp-query` / `_meta.query` (default on; set `MCP_CAPTURE_QUERY_HASH=0` to disable). `questionCategory` always (TMCP-81). A tool author only writes step 9. Everything else is automatic. ### Auth (pluggable) `auth/resolver.ts` tries strategies in order. Today: `access-token.strategy.ts` (Bearer TD token → `fastify.authSDK.authenticateUser` → `{user, company}`; read-only from the token `ro` flag + role). A future platform API key is just another Bearer credential the auth SDK validates — same `IIdentity`, no tool/dispatcher changes and no MCP-local key store. ### Bridge (data source) — not every tool must bridge `lib/bridge.ts` is a tenant-scoped read-only client. It injects `?company=` (from the validated identity, **never** a tool arg). Mode is set by `MCP_BRIDGE_INTERNAL`: - **internal mode** (`MCP_BRIDGE_INTERNAL=1`, the **default**) → `${API_URL_INTERNAL}/internal/api/1.0/*` with trusted `x-user-id` (legacy skips token re-validation; reached over the internal LB / VPC, no IAP). - **token-forward mode** (`MCP_BRIDGE_INTERNAL=0`) → `${API_BASE_URL}/api/1.0/*`, forwarding the caller's own token (legacy validates it normally). The bridge reaches **both legacy engines**: `bridge.get(path, query, identity, signal, apiVersion)` prefixes `/api/` — `'1.0'` (default) or `'1.1'` (the modern stats engine: `/stats/total`, `/stats/category`). On EVERY call it also injects `X-MCP-User-Id / -Company-Id / -Request-Id / -Tool` headers so the LB / GCP logs show who/what made the call. (Internal mode mounts both `/internal/api/1.0/*` and `/internal/api/1.1/*`.) **UAR** uses `lib/activity-bridge.ts` on the **same** `API_BASE_URL`, with path-only `ACTIVITY_API_PREFIX` (default `/api/2.0/activity`). Staging/prod gateways serve both legacy and UAR under one host — no separate UAR base URL. A tool handler is free to ignore the bridge and read another source (Mongo via `ctx.fastify.db`, BigQuery, another service) instead. ### Redaction (PII safety) `governance/field-allowlist.ts` defines, per domain, the only top-level fields allowed out. `governance/redaction.ts` `redact(domain, data)` strips everything else. The **builders apply it automatically**; a custom handler must call it itself. A contract test (`test/unit/governance-contract.test.ts`) fails if a sensitive field leaks. ### Scopes `auth/scopes.ts` — `Scope` enum (`users:read`, `projects:read`, …, plus `*:write` for the future). Capability checks go through **`assertToolScopes()`** (single decision layer): | Credential | Grant source | Missing → | |---|---|---| | API key | `apiKeyPermissionsResolved` (catalog/ReBAC) | `no_access` | | OAuth (`credentialType: 'oauth'`) | `identity.oauthScopes` from AuthOauthStrategy → auth (TMCP-6) | `insufficient_scope` | | Legacy TD access token | `roleToScopes(role)` | `no_access` | PRM `scopes_supported` is **`MCP_OAUTH_SCOPES_SUPPORTED`** (OIDC-only: `openid`, `profile`, `email`). MCP capability enforcement uses **`MVP_OAUTH_READ_SCOPES`** in `auth/scopes.ts` (`users:read`, `projects:read`, `tasks:read`, `groups:read`, `workschedules:read`, `stats:read`) so a new role-map scope does not silently widen OAuth. That list is the catalog we advertise as MVP — **not a filter that drops token claims**. If the AS issues a reserved scope (including `uar:read`), `effectiveOAuthScopes` honors it. The TMCP-7 ticket example name `activity:read` is rewritten to `stats:read` (activity / analytics / SCI tools) so those tokens can call activity tools and are still denied on any other ungranted scope. Empty / OIDC-only (`openid` / `profile` / `email` / `offline_access`) tokens fall back to `td_role`. Any other non-reserved scope fails closed. Write scopes are not granted in this MVP. Do not invent a parallel MCP-only taxonomy. --- ## 2. Add a new tool (existing domain) Domains today: `users`, `projects`, `tasks`, `groups`, `workschedules`, plus the analytics domains `timeuse`, `worklog`, `stats`, `meetings`, `files`. To add a tool to one you touch **one file** — the domain's `index.ts`. Tools **auto-register** (a 2nd file only if you expose a new output field, to update the allowlist). **a) Define the tool** in `src/tools//index.ts` using a builder: ```ts // List tool (paginated, redacted, size-capped — all automatic) export const getActiveUsers = makeListTool({ name: 'td_list_active_users', description: 'List active users in the company (paginated, read-only). ' + 'When to use: browse/filter currently active users. ' + 'When not to: do not use for hours or productivity (use analytics tools).', domain: 'users', scope: Scope.UsersRead, path: '/users', dataClass: 'pii', extraQuery: (a) => ({ active: true }), // optional extra query params from args }) // Detail tool (single record by id) export const getTaskById = makeDetailTool({ name: 'td_get_task', description: 'Get a single task by id. ' + 'When to use: you already have a task id. ' + 'When not to: do not use to search by name (use td_list_tasks).', domain: 'tasks', scope: Scope.TasksRead, path: (id) => `/tasks/${encodeURIComponent(id)}`, dataClass: 'public', }) ``` **TMCP-68 — behavior-first descriptions (required):** every tool `description` MUST include literal phrases **`When to use:`** and **`When not to:`** so agents pick the right sibling tool. Keep each clause to one short sentence; name the alternative tool when useful. Enforced by `test/unit/tool-behavior-docs.test.ts`. Params: list tools share only pagination (`limit`, `cursor`); declare each tool's OWN filters via `input` (a zod shape — use `.describe()` so clients see what each does) and map them to the legacy query via `extraQuery`. So a tool exposes only the few params it supports. Detail tools take `{ id }`. **b) Auto-registered** — no registry edit. `src/mcp/registry.ts` collects every exported `ToolDef` from each domain module automatically. Just `export` it. **c) (If returning a new field)** add it to `FIELD_ALLOWLIST[domain]` in `src/governance/field-allowlist.ts` — otherwise redaction drops it. **d) Test** — add a handler-level test (mock `ctx.bridge.get`) in `test/unit/`, mirroring `test/unit/tools-domains.test.ts`. That's it — rate-limit, scope, GrowthBook gates, redaction, size cap, usage/OTel all apply automatically. **e) GrowthBook allowlist (TMCP-18)** — add the new tool name to the JSON feature `mcp-enabled-tools` in [GrowthBook](https://app.growthbook.io/features) for every environment that should expose it (staging vs production can differ). Until the name is in that array, `tools/list` omits it and `tools/call` returns `not_enabled`. Local/dev with no `GB_CLIENT_KEY` allows all tools. ### GrowthBook: `mcp-enabled-tools` (ops) Create a **JSON** feature named `mcp-enabled-tools`. Value is a string array of tool names. Staging can include experimental tools; production omits them. Baseline (all current tools — use for staging): ```json [ "td_list_users", "td_list_managed_users", "td_get_user", "td_list_projects", "td_get_project", "td_list_tasks", "td_get_task", "td_list_groups", "td_get_group", "td_list_work_schedules", "td_get_work_schedule", "td_get_work_schedule_issues", "td_get_leave_stats", "td_get_web_app_usage", "td_get_worklog", "td_get_time_summary", "td_get_meetings", "td_get_productivity_breakdown", "td_get_activity_project_totals", "td_get_activity_task_totals", "td_list_low_activity_screencasts", "td_get_period_comparison", "td_whoami", "td_get_uar_summary", "td_get_uar_per_user", "td_get_uar_per_activity", "td_get_uar_events", "td_drill_down_productivity_analysis", "td_drill_down_team_productivity" ] ``` Production example: same list **without** staging-first tools such as `td_get_uar_*` and `td_list_low_activity_screencasts` until ready to ship. Requires MCP env `GB_CLIENT_KEY` + `GB_TARGETED_FEATURES=1`. When GB is configured, a missing/invalid value fails closed (no tools). --- ## 3. Add a new domain (e.g. `worklog`) 1. **Allowlist** — add the domain + its output fields to `FIELD_ALLOWLIST` and the `Domain` union in `src/governance/field-allowlist.ts`. 2. **Scopes** — add `WorklogRead` (+ `WorklogWrite`) to the `Scope` enum, to `READ_SCOPES`/`WRITE_SCOPES`, and to `roleToScopes()` in `src/auth/scopes.ts`. 3. **Tools** — create `src/tools/worklog/index.ts` with `makeListTool`/`makeDetailTool` (bridge) or `makeDbListTool`/`makeDbDetailTool` (direct DB) or a custom `defineTool`. 4. **Register** — add one `import * as worklog from '../tools/worklog'` and an entry in `DOMAIN_MODULES` in `src/mcp/registry.ts` (tools within it then auto-register). 5. **Test** — handler tests + extend the governance contract test with a fixture. --- ## 4. Direct-DB tools (read Mongo, no legacy API) When the data lives in a collection (legacy endpoint messy or missing), use the DB builders — as terse as the bridge ones. Tenant isolation (`{ company }` added automatically), redaction, pagination and a query timeout are all handled for you: ```ts import { z } from 'zod' import { makeDbListTool, makeDbDetailTool } from '../builders' import { Scope } from '../../auth/scopes' export const getWidgets = makeDbListTool({ name: 'td_list_widgets', domain: 'widgets', scope: Scope.WidgetsRead, collection: 'widgets', dataClass: 'pii', input: { user: z.string().optional().describe('Filter to a user id') }, buildFilter: (a) => (a.user ? { user: a.user } : {}), // merged with the company filter sort: { createdAt: -1 }, }) export const getWidget = makeDbDetailTool({ name: 'td_get_widget', domain: 'widgets', scope: Scope.WidgetsRead, collection: 'widgets', dataClass: 'pii', }) ``` Requires `MONGODB_URI` to point at the business DB (the boilerplate exposes `fastify.db.connection`). Builders live in `src/tools/builders.ts`. **Note:** work-schedule tools (`src/tools/workschedules/index.ts`) use the **legacy bridge** (`makeListTool` / `makeDetailTool` / custom `defineTool` for stats), not direct Mongo — see `test/unit/workschedules.test.ts`. ## 5. Custom tool (aggregation / computed / other source) For an aggregation, a non-`{data}` shape, or a different data source, use `defineTool` directly and do the shaping yourself (reuse `dbCollection`/`toPublic` from `../builders` for raw Mongo access): ```ts import { z } from 'zod' import { defineTool } from '../../lib/define-tool' import { Scope } from '../../auth/scopes' import { redact } from '../../governance/redaction' import { enforceBytes } from '../../lib/shape' export const getProjectSummary = defineTool({ name: 'td_get_project_summary', description: 'Summary stats for a project.', inputSchema: z.object({ projectId: z.string(), from: z.string().optional() }), requiredScopes: [Scope.ProjectsRead], dataClass: 'public', domain: 'projects', handler: async (args, ctx) => { // bridge OR any other source via ctx.fastify (Mongo/BigQuery/etc.) const raw = await ctx.bridge.get( `/projects/${encodeURIComponent(args.projectId)}`, {}, ctx.identity, ctx.signal, ) const data = redact('projects', raw?.data ?? raw) // YOU call redact for custom tools return enforceBytes({ data, truncated: false }) // YOU call the size cap }, }) ``` Cross-cutting layers (auth/killswitch/GrowthBook/scope/rate-limit/concurrency/timeout/ usage/OTel) still apply automatically — only redaction + size-cap are your responsibility in a custom handler (the builders do them for you). --- ## 6. Builders & helpers reference The wrappers do all the cross-cutting work so a tool is a few lines. Pick the builder that matches your data source; reach for `defineTool` only when none fit. ### `makeListTool(opts)` — paginated list via the legacy bridge | opt | required | what it does | |---|---|---| | `name`,`description`,`domain`,`scope`,`path`,`dataClass` | ✓ | identity, allowlist domain, scope gate, legacy path, PII class | | `apiVersion` | | `'1.0'` (default) or `'1.1'` (stats engine: `/stats/total`, `/stats/category`) | | `input` | | zod shape of the tool's OWN filters (merged with `limit`/`cursor`); `.describe()` each | | `extraQuery` | | `(args) => params` mapping args to legacy query params | | `selectRows` | | extract rows when the legacy nests them (worklog/meetinglog return `[[…],[…]]`); default reads `raw.data` | | `requireRange` | | inject the `from`/`to` schema **and** validate (`assertRange`: required · no future · ≤2y retention · <366d span) **and** forward them to the query | ### `makeDetailTool(opts)` — one record by id Same identity opts; `path: (id) => string`; input is `{ id }` (+ optional `input`). Supports `apiVersion`/`extraQuery`. ### `makeDbListTool` / `makeDbDetailTool(opts)` — read Mongo directly (no legacy API) `collection`, `buildFilter(args, identity)`, `sort`. Tenant `{ company }` **and** row-level `scopeFilter` (own / managed / whole-company by role) are applied automatically. Needs `MONGODB_URI`. ### `defineTool(def)` — custom (aggregation / other source / non-`{data}` shape) You write the handler and **must call `redact(domain, …)` then `enforceBytes(...)` yourself** (the builders do this for you). Everything else (gates/limits/usage) still applies. ### Helpers (where to import from) | helper | module | use | |---|---|---| | `assertRange(from, to)` | `lib/range.ts` | date guardrails (throws `invalid_argument`); returns `{from,to}` | | `redact(domain, data)` | `governance/redaction.ts` | keep only allowlisted fields + apply output renames | | `enforceBytes({data, truncated})` | `lib/shape.ts` | 256 KB cap → `truncated` + `cursor` | | `resolvePage(args)` / `encodeCursor(skip)` | `lib/pagination.ts` | decode `cursor` → `{skip,limit}` / emit next cursor | | `scopeFilter(ctx)` | `tools/builders.ts` | row-level `{company, user∈…}` for direct-DB tools | | `dbCollection(ctx, name)` / `toPublic(doc)` / `idMatch(id)` | `tools/builders.ts` | raw Mongo access + `_id→id` | ### Output field naming (LLM-friendly) `governance/field-allowlist.ts` keeps two maps: `FIELD_ALLOWLIST[domain]` (the security contract, keyed by the **incoming** legacy field name) and `FIELD_RENAME[domain]` (incoming → clean **output** name, e.g. `comCat → categoryId`, `prod → productiveSec`). `redact` keeps allowlisted fields and relabels them. When adding a field with a cryptic legacy name, add it to the allowlist **and** give it a clean name in `FIELD_RENAME`. --- ## 5. Conventions - Tool names: `td__` (`td_list_*`, `td_get_*`), `td_` namespace for cross-client identifiability. - Tenant comes from the validated identity, **never** a tool argument. - Read-only for now; write tools require `accessLevel === 'readwrite'` (the scope gate enforces it) and an explicit governance review. - After adding a tool: `npx tsc -p tsconfig.json --noEmit && npx eslint . --ext .ts && NODE_ENV=testing npx jest --runInBand`.