iit-mcp-autotask 0.38.8 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.env.example +10 -11
  2. package/README.md +57 -71
  3. package/capabilities.json +27 -10
  4. package/dist/access/gate.d.ts +1 -2
  5. package/dist/access/gate.js +4 -18
  6. package/dist/access/write-env.d.ts +5 -10
  7. package/dist/access/write-env.js +20 -46
  8. package/dist/auth.d.ts +1 -1
  9. package/dist/auth.js +1 -1
  10. package/dist/config/schema.d.ts +4 -11
  11. package/dist/config/schema.js +10 -27
  12. package/dist/doctor.d.ts +1 -3
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +1 -1
  15. package/dist/main.js +0 -7
  16. package/dist/tools/catalog-data.d.ts +11 -20
  17. package/dist/tools/catalog-data.js +22 -22
  18. package/dist/tools/context/client.d.ts +2 -4
  19. package/dist/tools/context/client.js +15 -15
  20. package/dist/tools/context/types.d.ts +1 -1
  21. package/dist/tools/dispatch.js +2 -23
  22. package/dist/tools/lists.js +16 -28
  23. package/dist/tools/meta.js +7 -9
  24. package/dist/tools/reasons.d.ts +1 -1
  25. package/dist/tools/reasons.js +8 -3
  26. package/dist/tools/register.d.ts +1 -1
  27. package/dist/tools/register.js +0 -2
  28. package/dist/tools/results/envelope.d.ts +5 -5
  29. package/dist/tools/results/envelope.js +24 -3
  30. package/dist/tools/results/errors.js +15 -7
  31. package/dist/tools/results/untrusted.js +3 -2
  32. package/dist/tools/revenue-rollup.d.ts +3 -0
  33. package/dist/tools/revenue-rollup.js +35 -0
  34. package/dist/tools/schemas.d.ts +36 -66
  35. package/dist/tools/schemas.js +42 -60
  36. package/dist/tools/server.d.ts +8 -7
  37. package/dist/tools/server.js +23 -26
  38. package/dist/tools/surface.js +5 -5
  39. package/dist/tools/write/apply.d.ts +11 -2
  40. package/dist/tools/write/apply.js +385 -23
  41. package/dist/tools/write/facts.d.ts +2 -6
  42. package/dist/tools/write/facts.js +12 -5
  43. package/dist/tools/write/outcome.d.ts +1 -1
  44. package/dist/tools/write/outcome.js +2 -6
  45. package/dist/tools/write/preview.js +98 -147
  46. package/dist/tools/write/snapshot.d.ts +1 -2
  47. package/dist/tools/write/snapshot.js +0 -3
  48. package/dist/tools/write.d.ts +3 -24
  49. package/dist/tools/write.js +3 -24
  50. package/dist/write/canonical.d.ts +7 -5
  51. package/dist/write/canonical.js +3 -6
  52. package/dist/write/evaluate.d.ts +36 -5
  53. package/dist/write/evaluate.js +62 -11
  54. package/dist/write/runtime.d.ts +0 -2
  55. package/dist/write/runtime.js +4 -5
  56. package/dist/write/store/audit-log.d.ts +2 -2
  57. package/dist/write/store/audit-log.js +6 -36
  58. package/dist/write/store/constants.d.ts +2 -2
  59. package/dist/write/store/constants.js +2 -2
  60. package/dist/write/store/ledger.d.ts +3 -6
  61. package/dist/write/store/ledger.js +5 -59
  62. package/dist/write/store/lifecycle.d.ts +1 -1
  63. package/dist/write/store/lifecycle.js +1 -1
  64. package/dist/write/store/plan-store.d.ts +1 -1
  65. package/dist/write/store/plan-store.js +2 -28
  66. package/dist/write/store/types.d.ts +9 -64
  67. package/dist/write/store.d.ts +2 -4
  68. package/dist/write/store.js +5 -13
  69. package/examples/agentgateway.write-gate.yaml +2 -2
  70. package/package.json +4 -3
  71. package/server.json +3 -3
  72. package/skills/contract-profitability.md +2 -1
  73. package/skills/crm-management.md +5 -5
  74. package/skills/log-time-entry.md +8 -9
  75. package/skills/revenue-rollup.md +62 -0
  76. package/dist/moves.d.ts +0 -17
  77. package/dist/moves.js +0 -120
  78. package/dist/tools/write/apply-phases.d.ts +0 -107
  79. package/dist/tools/write/apply-phases.js +0 -421
  80. package/dist/write/store/audit-chain.d.ts +0 -5
  81. package/dist/write/store/audit-chain.js +0 -23
  82. package/dist/write/store/unresolved.d.ts +0 -9
  83. package/dist/write/store/unresolved.js +0 -45
package/.env.example CHANGED
@@ -52,13 +52,11 @@ MCP_TRANSPORT=stdio
52
52
  # readonly (default): reads only, no state. operator: adds autotask_write (preview) and
53
53
  # autotask_apply (execute the previewed plan), governed by AUTOTASK_WRITE; needs the state dir and run scope below.
54
54
  AUTOTASK_PROFILE=readonly
55
- # operator: which writes are served: allow, gate (a person confirms) or deny per operation; unlisted is read-only.
56
- # Grammar, dedup, limits and trusted host prompts: docs/write-gate.md.
57
- # AUTOTASK_WRITE=*:gate,tickets.*:allow,time_entries.create:allow,companies.update:deny
55
+ # operator: which writes are served: allow or deny per operation; unlisted is denied.
56
+ # Hard rules require human confirmation. Grammar and dedup: docs/write-gate.md.
57
+ # AUTOTASK_WRITE=*:deny,tickets.*:allow,time_entries.create:allow,companies.update:deny
58
58
  # AUTOTASK_DEDUP=companies.create:companyName
59
- # AUTOTASK_WRITE_LIMITS=50/h,200/d
60
- # AUTOTASK_TRUSTED_HOST_PROMPT=stdio
61
- # operator: the state dir (mode 0700; plans, ledger, audit log, lock; one process per dir).
59
+ # operator: the state dir (mode 0700; plans, ledger, audit, locks; same-version processes on one host may share it).
62
60
  # AUTOTASK_STATE_DIR=/var/lib/autotask-mcp
63
61
  # operator on stdio: the run scope (required), {"companyIds":[...]} or {"all":true} (any
64
62
  # company). On HTTP set runScope per token instead.
@@ -68,7 +66,7 @@ AUTOTASK_PROFILE=readonly
68
66
  # The kill file: while it exists every preview and apply is denied (default <state dir>/KILL).
69
67
  # AUTOTASK_KILL_FILE=/var/lib/autotask-mcp/KILL
70
68
  # Optional toolsets on top of core (core is always on): a comma list of service, records, finance,
71
- # or all. An unknown name fails boot. Unset = core only (12 tools).
69
+ # or all. An unknown name fails boot. Unset = core only.
72
70
  # AUTOTASK_TOOLSETS=service,records,finance
73
71
  # IANA time zone of the people asking (their local "today"), default UTC; not the tenant's or
74
72
  # the server's zone. Autotask stores UTC; the zone is reported by
@@ -81,10 +79,11 @@ AUTOTASK_PROFILE=readonly
81
79
  # AUTOTASK_SECRET_READS. authenticate.* (a credential-minting read) is always refused.
82
80
 
83
81
  # --- Per-tenant read cache (in-process; never cross-tenant; never on disk) ---
84
- # on (default): each pooled tenant client applies the SDK reference TTL table (structural ~1 h /
85
- # mutable ~5 min / base ~1 min) plus the field-metadata cache; repeated reads of the same record
86
- # are served from the tenant's cache until TTL expiry. off: every read re-dials the vendor.
87
- AUTOTASK_CACHE_PRESET=on
82
+ # on (default): the SDK's default-on caches apply to each pooled tenant client (read ~5 min,
83
+ # reference tables ~1 h, field metadata ~5 min); repeated reads of the same record are served
84
+ # from the tenant's cache until TTL expiry. off: `cache: false` is sent — every read re-dials
85
+ # the vendor.
86
+ AUTOTASK_CACHE=on
88
87
 
89
88
  # --- Vendor budget (the server boots the SDK in the conservative `agent` profile; overrides here) ---
90
89
  # AUTOTASK_RATE_LIMIT_PER_HOUR=8000
package/README.md CHANGED
@@ -11,7 +11,7 @@ client + SDK-owned TTL caches).
11
11
  ## Tool surface
12
12
 
13
13
  The **core** toolset (10 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
14
- `finance` (or `all`, 17 tools); the operator profile adds two. The whole tool surface (19 tools) is
14
+ `finance` (or `all`, 18 tools); the operator profile adds two. The whole tool surface (20 tools) is
15
15
  listed below. Every other operation in the SDK's operation registry stays
16
16
  reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
17
17
  its contract (slim by default: transport and retry detail dropped, long text cut, the first two
@@ -22,7 +22,7 @@ examples; `full: true` serves the complete record, `summary: true` about 2 KB),
22
22
  | core (always) | `autotask_guide` · `autotask_catalog` · `autotask_describe` · `autotask_read` · `autotask_resolve` · `autotask_query` · `autotask_tenant_status` · `autotask_ticket_full_context` · `autotask_company_full_profile` · `autotask_resource_workload` |
23
23
  | `service` | `autotask_project_get_context` · `autotask_service_call_full_profile` |
24
24
  | `records` | `autotask_contact_full_profile` · `autotask_configuration_item_profile` · `autotask_contract_full_profile` |
25
- | `finance` | `autotask_quote_get` · `autotask_invoice_get` (`format`: `record`, `html`, `xml`, `pdf`) |
25
+ | `finance` | `autotask_quote_get` · `autotask_invoice_get` (`format`: `record`, `html`, `xml`, `pdf`) · `autotask_revenue_rollup` |
26
26
  | `operator` (operator profile) | `autotask_write` (preview, changes nothing) · `autotask_apply` (execute the previewed plan) |
27
27
 
28
28
  - `autotask_resolve` turns any identifier into a record: an id or name, `by: "ticket_number"`,
@@ -81,7 +81,7 @@ vendor, so loading a playbook costs no wire and adds no injection surface.
81
81
  - `{skill, section}`: one of its `## ` sections.
82
82
 
83
83
  Skills: `ticket-triage`, `company-360`, `unbilled-work-review`, `contract-profitability`,
84
- `invoice-export`, `filter-semantics`, `slim-reads`, and on the operator profile `log-time-entry` and `crm-management`.
84
+ `revenue-rollup`, `invoice-export`, `filter-semantics`, `slim-reads`, and on the operator profile `log-time-entry` and `crm-management`.
85
85
 
86
86
  The playbooks are discoverable in both directions:
87
87
 
@@ -100,6 +100,7 @@ part miss never fails the call (typed `absent`); only a missing root record fail
100
100
  { status: ok|needs_input|ambiguous|denied|error,
101
101
  data, next: proceed|supply_input|choose_candidate|...|retry|stop,
102
102
  reasons: [{ code, field?, detail }], retryable, correlationId, summary }
103
+ // status ok omits next, reasons, retryable and correlationId
103
104
  ```
104
105
 
105
106
  `isError` is set exactly for `denied` and `error`; on an error `data` is the typed error payload
@@ -143,7 +144,7 @@ warning naming any unrecognised `AUTOTASK_*` variable at boot), so the profile g
143
144
 
144
145
  ### Operator profile (writes)
145
146
 
146
- Writes are two calls. `autotask_write {operation, args, reason?, idempotency_key?}` previews: it
147
+ Writes are two calls. `autotask_write {operation, args, reason?}` previews: it
147
148
  reads the target live, runs the SDK dry run, evaluates the policy and returns a decision, a diff and,
148
149
  when allowed, a plan handle (`pln_...`, valid 15 minutes). Nothing is written. `autotask_apply
149
150
  {plan}` executes exactly that plan, after checking that nothing it depends on has changed. Targets
@@ -155,44 +156,28 @@ An operator deployment needs:
155
156
  | Variable | Meaning |
156
157
  |----------|---------|
157
158
  | `AUTOTASK_PROFILE=operator` (or an operator token) | the profile |
158
- | `AUTOTASK_WRITE` | which writes are served and how: `allow`, `gate` or `deny` per operation, unlisted is read-only (grammar in [docs/write-gate.md](docs/write-gate.md)) |
159
- | `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the hash-chained audit log, the primary lock and the holder registry (many processes may share one dir — see below) |
159
+ | `AUTOTASK_WRITE` | which writes are served and how: `allow` or `deny` per operation, unlisted is read-only (grammar in [docs/write-gate.md](docs/write-gate.md)) |
160
+ | `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the append-only JSONL audit log, the primary lock and the holder registry (many processes may share one dir — see below) |
160
161
  | `AUTOTASK_RUN_SCOPE` | `{"companyIds":[...]}` or `{"all":true}`: the run scope on stdio and in gateway mode (one scope shared by every gateway principal). In tokens mode each operator token carries `runScope` instead |
161
162
  | `AUTOTASK_PRINCIPAL` | stdio: the name plans and the audit log are bound to (default `stdio`) |
162
163
  | `AUTOTASK_KILL_FILE` | optional: while this file exists every preview and apply is denied (default `<state dir>/KILL`) |
163
164
 
164
165
  What the write access can and cannot do (full reference: [docs/write-gate.md](docs/write-gate.md)):
165
166
 
166
- - `AUTOTASK_WRITE` lists operations, families (`tickets.*`) or `*`, each `allow` (applies without a
167
- person), `gate` (a person confirms the apply) or `deny`. An operation not listed is `OP_NOT_ALLOWED`.
168
- `AUTOTASK_DEDUP` names the duplicate-check fields for a create, `AUTOTASK_WRITE_LIMITS` caps writes
169
- (`50/h,200/d`) and `AUTOTASK_TRUSTED_HOST_PROMPT` names the clients whose own permission prompt counts
170
- as the confirmation. A bad entry fails boot.
171
- - A gated apply is confirmed by a trusted host's own prompt, else by one confirm form (MCP elicitation);
172
- a client that can do neither gets `GATE_UNSUPPORTED`, and a decline or dismissal `GATE_DECLINED`.
173
- Nothing is sent in either case.
174
- - Moving a time entry or ticket charge to another ticket (#107): `time_entries.moveToTicket` and
175
- `ticket_charges.moveToTicket`. Preview `autotask_write {operation, args: [sourceId, toTicketId]}`, then
176
- apply. The server creates the copy on the target ticket, verifies it and deletes the original (the
177
- vendor ignores an update of `ticketID`): the new record gets a new id, create date and creator, and
178
- notifications may fire. A move into another company or contract is always gated. An unfinished earlier
179
- move of the same source blocks another until its ledger entry ages out (14 days) or a person clears it:
180
- in a terminal, as the server's OS user, with `AUTOTASK_STATE_DIR` set, `autotask-mcp moves list` shows
181
- the blocked sources and `autotask-mcp moves resolve <resource>:<id>` (after you checked both tickets in
182
- Autotask; you type the source id, and the copy's id when the ledger names one) closes the ledger lines
183
- as `MOVE_RESOLVED` and audits it (`move_resolved`). It refuses within 10 minutes of the move being sent
184
- and, while the engine may still run, when the sending server is still registered or alive. A plan
185
- previewed before the resolve cannot apply after it. It is not an MCP tool, and it is a guardrail, not a
186
- boundary against an agent with a same-user shell. Billed, attached, approved, closed-target and
187
- older-than-14-days sources are refused.
188
- - Hard rules no setting overrides: `authenticate.*` is never served; operations the SDK flags
189
- `requiresApproval` or sensitive (webhooks, `resources.transferOwnership`, `contacts.moveToCompany`, deletes) can only
190
- be `gate` or `deny`. An exact `allow` entry for one is a boot error; a family or `*` entry resolves it
191
- to `gate`. Destructive and move operations can be `allow`ed (see the trust model in the doc).
192
- - When a person must finish a job by hand (a move whose source delete the vendor refused), the result is
193
- `next: manual_action` and `data.move.manualAction` carries what to do and, when the pinned SDK
194
- provides `operations.uiLink`, a clickable Autotask deep link. There is no `autotask_delete` (D-12):
195
- deletes preview through `autotask_write`.
167
+ - `AUTOTASK_WRITE` lists operations, families (`tickets.*`) or `*`, each `allow` or `deny`.
168
+ Unlisted writes are `OP_NOT_ALLOWED`. `AUTOTASK_DEDUP` names duplicate-check fields for creates.
169
+ A bad entry fails boot; the former `gate` mode is refused.
170
+ - Hard rules still require a person for SDK `requiresApproval` or sensitive operations, and moves
171
+ across companies or contracts. The server asks through MCP form elicitation; a client without
172
+ form support gets `GATE_UNSUPPORTED`, and decline or dismissal gives `GATE_DECLINED`.
173
+ A gateway client id or host permission prompt never bypasses this check.
174
+ - Move a time entry or ticket charge with `time_entries.moveToTicket` or `ticket_charges.moveToTicket`,
175
+ args `[sourceId, toTicketId]`. The SDK copies, verifies, then deletes the source; the new record
176
+ gets a new id and history, and notifications may fire. Billed, attached, approved, closed-target
177
+ and old sources are refused by the SDK. Follow `data.move.manualAction` when a person must delete
178
+ the source. A partial or unknown move is never blindly retried: check both tickets in Autotask.
179
+ The server retains write-ahead/replay protection, but no separate move recovery snapshot or
180
+ `moves list/resolve` command. Deletes preview through `autotask_write`.
196
181
  - The company scope is read from the live records the write touches (the target, its parent, the
197
182
  ticket, project, task or contract it references, and any `companyID` it sets), never from names
198
183
  or titles. A reference that cannot be read is refused.
@@ -205,19 +190,24 @@ the preview diff shows exactly what is sent. A string that still carries a combi
205
190
  precomposed form after NFC (for example a lone U+0301) is refused `INPUT_INVALID`: send the
206
191
  precomposed character.
207
192
 
208
- Outcomes the caller acts on: `ok`/`proceed`; `needs_input`/`supply_input` (`missing` names the
193
+ Outcomes the caller acts on: `ok`; `needs_input`/`supply_input` (`missing` names the
209
194
  field); `denied`/`stop`; `STATE_DRIFT` or `PLAN_EXPIRED` →
210
195
  `repreview`; a failed write: `WRITE_FAILED` with `stop` (a deterministic rejection; it replays,
211
- fix the input and use a new key) or `RATE_LIMITED` with `retry` (nothing was written: apply the
196
+ fix the input and preview the corrected payload) or `RATE_LIMITED` with `retry` (nothing was written: apply the
212
197
  same plan again after the wait); a vendor that accepted an update but kept the old value (`FIELD_NOT_PERSISTED` with `stop`: the apply reads the stored record back and compares every field you sent; the write itself is `succeeded`, `data.record` shows what is stored and `data.notPersisted` names the fields — the apply re-reads twice, after waiting 0.75 s, then a further 1.5 s, before concluding, because the vendor read path can lag its write; re-read the record before repeating the update; a replay answers the same); an unknown outcome (`WRITE_OUTCOME_UNKNOWN`,
213
- `verify_then_repreview`): that plan is never sent again, and re-applying it or re-previewing under
214
- the same key only replays `unknown`. Re-read the target; if the write is absent, preview again under
215
- a new `idempotency_key` (a new attempt) after the 10-minute cooldown; its duplicate query refuses
216
- it if the first write did land. Payload field names are matched case-insensitively, and keys that
198
+ `verify_then_repreview`): an identical plan is never sent again, even when previewed under a new
199
+ handle. Re-read the target and verify in Autotask. A changed payload or changed stable vendor facts
200
+ creates a different plan; it still waits for the 10-minute target cooldown after an unknown outcome.
201
+ Payload field names are matched case-insensitively, and keys that
217
202
  differ only by case, or are not plain ASCII field names, are refused. `STATE_UNAVAILABLE` means the
218
203
  state dir could not be checked (`retry`) or the server's own registration in the state dir was removed (`stop`; see below). Use a separate Autotask API user for operator deployments, and run
219
204
  the live write leg on throwaway data before relying on it (B7 in the 0.6.0 plan).
220
205
 
206
+ Upgrade note (#250): stop **all** processes sharing a state directory before upgrading and restart
207
+ all on the same version. Mixed old/new versions are unsupported. Old pending plans must be previewed
208
+ again. Existing ledger entries and historical chained audit lines remain on disk; new audit lines are
209
+ plain JSONL. Old move snapshot files are inert and are not automatically deleted.
210
+
221
211
  #### The state lock: recovery
222
212
 
223
213
  Many processes may share a state dir (issue #68): every process registers its boot in a holder
@@ -243,7 +233,7 @@ The succession rules:
243
233
  lease is expired 15 s after the old container stopped, so the next boot takes the lock over on
244
234
  its own. With a new hostname (the docker default: the container id) the R6 host refusal applies
245
235
  and the operator deletes `<state dir>/lock` (the case below). Every takeover is appended to the
246
- `audit.jsonl` hash chain as a `lock_takeover` line, and `/ready` reports the lock's age, holder
236
+ `audit.jsonl` log as a `lock_takeover` line, and `/ready` reports the lock's age, holder
247
237
  id, lease freshness and the holder count, so an orchestrator can tell a stale lock from a live
248
238
  one.
249
239
 
@@ -285,6 +275,8 @@ signal. Keep the rest of the state dir; `audit.jsonl` is the write history.
285
275
 
286
276
  ### Query results: TSV, `output_path` and the saved-file shape
287
277
 
278
+ `autotask_query` always wraps untrusted text and uses the SDK's default transport and labels; `via`, `names`, and `untrusted_wrapper` are refused as unknown inputs.
279
+
288
280
  `autotask_query` answers `data.result` with the SDK's bounded-query envelope:
289
281
 
290
282
  ```
@@ -301,12 +293,11 @@ writes a result file on its own: when a result is too large, the MCP host persis
301
293
  tool result, and the file it writes holds this same envelope as JSON — so a saved result can be read
302
294
  back with exactly the fields above.
303
295
 
304
- Three per-call options change the result form:
296
+ Two per-call options change the result form:
305
297
 
306
298
  | Option | Effect |
307
299
  |--------|--------|
308
300
  | `format: 'tsv'` | serves the table as `data.result.tsv` — a header row of column names plus one tab-separated line per record — with `result.columns` and `result.rowCount`, and **omits `result.items`** (that omission is the size saving; the JSON form stays the default). Inside a value a literal backslash is written as `\\`, a tab as `\t`, a newline as `\n` and a carriage return as `\r`; the `<untrusted_content>` markers survive inside cells. |
309
- | `untrusted_wrapper: false` | skips the `<untrusted_content>` wrapping for that call only; the result then carries `untrustedWrapper: 'disabled'` and the human text says so. Absent (the default) wraps exactly as before. |
310
301
  | `output_path: '<relative path>'` | **stdio transport only**: walks the FULL match set with the resource's own query iterator, writes the envelope JSON to a file under `AUTOTASK_OUTPUT_DIR`, and returns a summary (`outputPath`, `columns`, `rowCount`, the first rows, `bytes`) instead of the table. |
311
302
 
312
303
  `output_path` is deliberately strict: the deployment sets an **absolute** `AUTOTASK_OUTPUT_DIR`
@@ -335,7 +326,7 @@ so `O_EXCL` refuses it too.
335
326
 
336
327
  The walk stops at 10,000 rows with an explicit truncation note in the summary (`hasMore: true` then
337
328
  means the cap, not only the query limit). The call is refused outright on the `http` transport (the
338
- caller is a request, not the process owner), and it cannot be combined with `format`, `latest` or
329
+ caller is a request, not the process owner), and it cannot be combined with `latest` or
339
330
  `note_chars` — one result form per call.
340
331
 
341
332
  The invoice export is the second `output_path` file kind (issue #54): `autotask_invoice_get` with
@@ -461,10 +452,9 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
461
452
  |----------|----------|---------|---------|
462
453
  | `MCP_TRANSPORT` | no | `http` | `stdio` or `http`; the CLI `--transport` flag overrides |
463
454
  | `AUTOTASK_PROFILE` | no | `readonly` | `readonly` or `operator` (see [Profiles and writes](#profiles-and-writes)) |
464
- | `AUTOTASK_WRITE` | operator | — | per-operation `allow`/`gate`/`deny` (see [docs/write-gate.md](docs/write-gate.md)) |
455
+ | `AUTOTASK_WRITE` | operator | — | per-operation `allow`/`deny`; hard rules require human confirmation (see [docs/write-gate.md](docs/write-gate.md)) |
465
456
  | `AUTOTASK_DEDUP` | no | — | create duplicate-check fields, e.g. `companies.create:companyName` |
466
- | `AUTOTASK_WRITE_LIMITS` | no | — | write caps, e.g. `50/h,200/d` |
467
- | `AUTOTASK_TRUSTED_HOST_PROMPT` | no | — | `stdio` and client ids whose own prompt confirms a gated apply |
457
+ | `AUTOTASK_EXPENSE_REPORT_SUBMITTER_PIN` | no | — | one resource id admitted for a company-less `expense_reports.create` (see [write-gate.md](docs/write-gate.md)) |
468
458
  | `AUTOTASK_STATE_DIR` | operator | — | the operator state dir (0700) |
469
459
  | `AUTOTASK_RUN_SCOPE` | operator on stdio or gateway | — | `{"companyIds":[...]}` or `{"all":true}` |
470
460
  | `AUTOTASK_PRINCIPAL` | no | `stdio` | the stdio operator principal |
@@ -478,10 +468,9 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
478
468
  | `AUTOTASK_BASE_URL` | stdio: yes · http: no (default origin) | — | the zone origin, e.g. `https://webservices6.autotask.net` (zone lives in the host; base path `/atservicesrest` is fixed — never configurable) |
479
469
  | `AUTOTASK_ALLOWED_BASE_HOSTS` | http: unless `AUTOTASK_ALLOW_ANY_BASE_HOST=true` | — | comma-separated exact hostnames or `*.suffix` subdomain suffixes a caller may name in `X-Autotask-Base-Url` (no scheme/port/path). Unset + default origin set → only that origin accepted. Unset + no default → boot refused unless the lab-only flag. **Recommended production value: `*.autotask.net`**. Literal private-IP origins are always refused. |
480
470
  | `AUTOTASK_ALLOW_ANY_BASE_HOST` | no | `false` | lab-only opt-in to boot with neither allow-list nor default origin; `warn`-logged at boot (decision D-4); refused with the operator profile. Not set in production. |
481
- | `AUTOTASK_CACHE_PRESET` | no | `on` | per-tenant in-process caches (see [Per-tenant pool & cache](#per-tenant-pool--cache)): `on` (default) sends no cache blocks — the SDK's default-on caches apply to each pooled tenant client (read ~5 min + the plan-derived reference table, field ~5 min, resource-name ~1 h); `off` opts every SDK cache channel out explicitly (every read re-dials the vendor) |
471
+ | `AUTOTASK_CACHE` | no | `on` | the single off switch for the SDK's per-tenant in-process caches (see [Per-tenant pool & cache](#per-tenant-pool--cache)): `on` (default) sends no cache block — the SDK's default-on caches apply to each pooled tenant client (read ~5 min + the reference tables at 1 h, field metadata ~5 min); `off` sends `cache: false` (every read re-dials the vendor) |
482
472
  | `AUTOTASK_CLOSED_TICKET_STATUS_IDS` | no | unset | comma-separated ticket status ids that mean closed (e.g. `5,9`), pinning what the open-ticket parts of `autotask_resource_workload`, `autotask_contact_full_profile` and `autotask_company_full_profile` exclude; unset = read from the tenant's live status picklist (the ids used are reported in the part's `closedStatus`); if the picklist can't be read and nothing is pinned, `autotask_resource_workload` and `autotask_contact_full_profile` degrade the open-ticket part to `absent: CLOSED_STATUS_UNRESOLVED` rather than answer on a guessed id, while `autotask_company_full_profile` (which keeps the SDK's `allowAssumed: true`) answers on the SDK's assumed closed id and says so in the part's disclosure note |
483
473
  | `AUTOTASK_COMPOSITE_NAMES` | no | `off` | opt-in name/label siblings on the four SDK-backed composites (`tickets.fullContext`, `companies.fullProfile`, `configuration_items.profile`, `projects.getContext`) — reuses the SDK's existing caches (no new cache); `off` = byte-identical output to today; `on` adds the name siblings + the top-level `names` section, bounded by the SDK name-read budget (default 20 cold wire reads, lowerable only) |
484
- | `AUTOTASK_FIELD_CACHE_TTL_MS` | no | unset (SDK 5 min) | pass-through to the SDK's field-metadata cache TTL (positive integer ms), sent as the scalar `fieldCache` block on the `on` preset path only — the server holds no TTL value of its own: unset or invalid (non-integer, <= 0) sends no `fieldCache` block and the SDK default (5 min) applies; `off` keeps its explicit opt-out; `fieldCatalog` `fresh: true` still bypasses the cache |
485
474
  | `AUTOTASK_OUTPUT_DIR` | no | unset | the ONE directory the `output_path` option may write into (the bounded query tools and the invoice export; stdio transport only). Unset = defaults to `output/` under `AUTOTASK_STATE_DIR` (the feature refuses when neither is set); must be absolute, must already EXIST (the server never creates the root) and must not be the filesystem root. A caller path is relative to this root, and the root and target are canonically resolved (`realpath`) so a traversal or symlink escape is refused — with nothing created outside the root. See [Query results](#query-results-tsv-output_path-and-the-saved-file-shape) |
486
475
  | `AUTOTASK_RATE_LIMIT_PER_HOUR` | no | 8000 (agent profile) | override the SDK's local hourly budget (never above the vendor cap; `null`/off is rejected at boot — it would void the budget guard) |
487
476
  | `AUTOTASK_MAX_CONCURRENT` | no | 3 (agent profile) | override the SDK's local concurrency bound |
@@ -641,17 +630,16 @@ security decision D-1 ratified): a bounded **in-process** client pool with a per
641
630
  cache, because a fresh client per request would silently disable the SDK's local vendor-budget
642
631
  guards (rate limiter, 429 cooldown) — a worse security posture, not a safer one.
643
632
 
644
- - **Pool** — `createClientPool` from `node-autotask/pool`: bounded LRU (default `maxSize` 128; the
645
- server may lower it). Key = SHA-256 digest of the **normalised credential tuple**
646
- (user, integration code, secret, resolved base URL, impersonation id). Optional idle TTL (lazy,
647
- no timers). Eviction is logged without values. A vendor `401` (or an impersonation-scope `403`)
648
- deletes the entry (no negative caching).
649
- - **Cache** — `AUTOTASK_CACHE_PRESET=on` (default) sends no cache blocks — the SDK's default-on
650
- caches apply to each pooled tenant client (the reference TTL table moved into the SDK with
651
- 0.9.0's default-on change: structural ~1 h / mutable ~5 min, field ~5 min, resource-name ~1 h).
652
- Repeated reads of the same record are served from that tenant's cache until TTL expiry; composites
653
- de-duplicate their 3–8 fan-out reads through it. `off` opts every SDK cache channel out explicitly
654
- (every read re-dials the vendor).
633
+ - **Pool** — the SDK's `createClientPool` (no server-side wrapper; issue #252): a bounded LRU of
634
+ tenant clients at the SDK default `maxSize` 128. Key = SHA-256 digest of the
635
+ **normalised credential tuple** (user, integration code, secret, resolved base URL,
636
+ impersonation id). Eviction is logged without values. A vendor `401` (or an
637
+ impersonation-scope `403`) deletes the entry (no negative caching).
638
+ - **Cache** — `AUTOTASK_CACHE=on` (default) sends no cache block — the SDK's default-on caches
639
+ apply to each pooled tenant client (SDK 0.9.0's default-on change: read ~5 min, the reference
640
+ tables ~1 h, field metadata ~5 min). Repeated reads of the same record are served from that
641
+ tenant's cache until TTL expiry; composites de-duplicate their 3–8 fan-out reads through it.
642
+ `off` sends `cache: false` (every read re-dials the vendor).
655
643
  - **Boundaries — never relaxed:** in-process only (never on disk), **per-tenant** (one tenant's
656
644
  cached records or limiter state can never answer another tenant's request), TTL-bounded, cleared
657
645
  on eviction and on `401`. The same boundary covers the limiter/cooldown state.
@@ -710,12 +698,11 @@ guards (rate limiter, 429 cooldown) — a worse security posture, not a safer on
710
698
  liveness-only healthcheck (no vendor call), direct `SIGTERM` (stop accepting, 2 s bounded drain of
711
699
  in-flight work, exit 0), TLS termination at the gateway on a private unpublished network.
712
700
  - **Supply chain:** `node-autotask` is the only **vendor** runtime dependency (the other two are
713
- `@modelcontextprotocol/server` and `zod`); the pre-publish checklist
714
- (manual — this repo has no CI) asserts it carries **zero runtime dependencies** and **no install
715
- lifecycle hooks**, asserts the resolved `@modelcontextprotocol/server` version is ≥ 2.3.0 (the
701
+ `@modelcontextprotocol/server` and `zod`); the pre-publish checklist asserts it carries **zero
702
+ runtime dependencies** and **no install lifecycle hooks**, asserts the resolved `@modelcontextprotocol/server` version is ≥ 2.3.0 (the
716
703
  baseline that serves the era pair on one endpoint), runs a production dependency audit, and
717
- scans the image. This repo ships no GitHub Actions workflow; quality gates and publishing are
718
- run manually by the maintainer.
704
+ scans the image. CI (`.github/workflows/ci.yml`) runs the full gate on every PR; npm releases are
705
+ published only by the tag-triggered release workflow (see [docs/RELEASING.md](docs/RELEASING.md)).
719
706
 
720
707
  Full control set: [`docs/architecture.md`](docs/architecture.md) (sections 7.3, 8 and 10).
721
708
 
@@ -737,10 +724,9 @@ mirrored-surface re-review, gates), not a range float: see the
737
724
 
738
725
  ## Development
739
726
 
740
- - Node **24** (current LTS; the SDK requires `node >= 24`), **pnpm 10**. The `pnpm-lock.yaml` is
741
- committed with the first `node-autotask` publish (S8/C8.2); until then installs resolve without
742
- a lockfile and the install step says so explicitly (reproducibility via the committed lockfile
743
- is a pre-publish requirement; gates run manually — no CI in this repo).
727
+ - Node **24** (current LTS; the SDK requires `node >= 24`), **pnpm 10**. `pnpm-lock.yaml` is committed
728
+ and installs are frozen (`pnpm install --frozen-lockfile`). `pnpm verify` is the full gate; CI runs it on
729
+ every PR and push to `main`.
744
730
  - `pnpm i` — install · `pnpm build` — compile to `dist/` · `pnpm typecheck` · `pnpm lint`
745
731
  - Tests: `pnpm test` (all) or per suite: `pnpm exec vitest run test/unit` / `test/integration` /
746
732
  `test/conformance`. **No live vendor API and no credentials are ever required** — every suite
package/capabilities.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "description": "Machine-readable capability catalogue for iit-mcp-autotask: the tools the server can serve, with the toolset and the lowest profile that serves each. Everything else in the registry is reachable through the generic layer (autotask_catalog / autotask_describe / autotask_read) and documented in docs/capability-matrix.md. Generated from src/tools/surface.ts by scripts/build-capabilities.mjs.",
5
5
  "exposedToolCount": {
6
6
  "readonlyCore": 10,
7
- "readonlyAll": 17,
7
+ "readonlyAll": 18,
8
8
  "operatorCore": 12,
9
- "operatorAll": 19
9
+ "operatorAll": 20
10
10
  },
11
11
  "profiles": {
12
12
  "readonly": {
@@ -40,7 +40,8 @@
40
40
  ],
41
41
  "finance": [
42
42
  "autotask_quote_get",
43
- "autotask_invoice_get"
43
+ "autotask_invoice_get",
44
+ "autotask_revenue_rollup"
44
45
  ],
45
46
  "operator": [
46
47
  "autotask_write",
@@ -67,7 +68,7 @@
67
68
  },
68
69
  {
69
70
  "id": "autotask.platform.catalog",
70
- "description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,281 registry operations, with effect and whether this deployment serves it. No vendor call.",
71
+ "description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,280 registry operations, with effect and whether this deployment serves it. No vendor call.",
71
72
  "mcpPrimitive": "tool",
72
73
  "toolName": "autotask_catalog",
73
74
  "type": "read",
@@ -99,7 +100,7 @@
99
100
  },
100
101
  {
101
102
  "id": "autotask.generic.read",
102
- "description": "Run reads by exact registry key, or up to 10 in one call: unbilled work, profitability and margin, SLA health. *.query: 50 rows; scope by company/contract ids (unscoped = tenant walk); totals: autotask_query group_by; slim: includeFields, noteChars.",
103
+ "description": "Run one or several reads in one call by exact registry key: unbilled work, profitability and margin, SLA health. Scope by company/contract ids (unscoped = tenant walk); totals: autotask_query group_by; slim: includeFields, noteChars.",
103
104
  "mcpPrimitive": "tool",
104
105
  "toolName": "autotask_read",
105
106
  "type": "read",
@@ -179,7 +180,7 @@
179
180
  },
180
181
  {
181
182
  "id": "autotask.company.full_profile",
182
- "description": "One company with locations, contacts, notes, open tickets and projects, 10 rows per part (capped: true = more exist; raise the limit). fields {part:[names]} trims columns. Open = not tenant-closed. Missing parts degrade to typed absent.",
183
+ "description": "One company with locations, contacts, notes, open tickets and projects (capped: true = more exist; raise the limit). fields {part:[names]} trims columns. Open = not tenant-closed. Missing parts degrade to typed absent.",
183
184
  "mcpPrimitive": "tool",
184
185
  "toolName": "autotask_company_full_profile",
185
186
  "type": "read",
@@ -195,7 +196,7 @@
195
196
  },
196
197
  {
197
198
  "id": "autotask.resource.workload",
198
- "description": "A resource's open tickets (primary or secondary, de-duplicated), assignments, time off. For \"how many\" use totals (exact; capped = link cap hit, not row capped; extra reads, up to 20 pages; totalsAbsent = failed). Missing parts: typed absent.",
199
+ "description": "A resource's open tickets (primary or secondary, de-duplicated), assignments, time off. For \"how many\" use totals (exact; capped = link cap hit, not row capped; extra reads; totalsAbsent = failed). Missing parts: typed absent.",
199
200
  "mcpPrimitive": "tool",
200
201
  "toolName": "autotask_resource_workload",
201
202
  "type": "read",
@@ -243,7 +244,7 @@
243
244
  },
244
245
  {
245
246
  "id": "autotask.contact.full_profile",
246
- "description": "One contact with their company and open tickets (closed set from the tenant status picklist, 2-5 reads). Contains personal data. Missing parts degrade to typed absent.",
247
+ "description": "One contact with their company and open tickets (closed set from the tenant status picklist). Contains personal data. Missing parts degrade to typed absent.",
247
248
  "mcpPrimitive": "tool",
248
249
  "toolName": "autotask_contact_full_profile",
249
250
  "type": "read",
@@ -275,7 +276,7 @@
275
276
  },
276
277
  {
277
278
  "id": "autotask.contract.full_profile",
278
- "description": "One contract with services, billing rules, the 50 newest charges and service units. Monthly P&L: autotask_read (guide: contract-profitability).",
279
+ "description": "One contract with services, billing rules, newest charges and service units. Monthly P&L: autotask_read (guide: contract-profitability).",
279
280
  "mcpPrimitive": "tool",
280
281
  "toolName": "autotask_contract_full_profile",
281
282
  "type": "read",
@@ -321,6 +322,22 @@
321
322
  "openWorld": true
322
323
  }
323
324
  },
325
+ {
326
+ "id": "autotask.finance.revenue_rollup",
327
+ "description": "Revenue for recurring and fixed price contracts by vertical, company or month, with excluded amounts and coverage. One scope required; full-month windows in the user zone. May take 15-45 seconds. Finance playbook: revenue-rollup.",
328
+ "mcpPrimitive": "tool",
329
+ "toolName": "autotask_revenue_rollup",
330
+ "type": "read",
331
+ "toolset": "finance",
332
+ "profile": "readonly",
333
+ "backingOperation": "operations.revenueRollup",
334
+ "annotations": {
335
+ "readOnly": true,
336
+ "idempotent": true,
337
+ "destructive": false,
338
+ "openWorld": true
339
+ }
340
+ },
324
341
  {
325
342
  "id": "autotask.generic.write",
326
343
  "description": "Preview any write (create, update, delete or a composite helper) by exact key, args in declaration order, ids never names: validates, reads the target, returns a plan and diff. A composite op echoes its args only, no field diff.",
@@ -361,7 +378,7 @@
361
378
  "autotask_describe",
362
379
  "autotask_read"
363
380
  ],
364
- "backingRegistryOperations": 1281,
381
+ "backingRegistryOperations": 1280,
365
382
  "note": "autotask_catalog finds the exact operation key (ranked search), autotask_describe serves its contract, autotask_read runs any effect=read key. Writes go through the operator profile (autotask_write then autotask_apply); authenticate.* is never served."
366
383
  },
367
384
  "schemas": {}
@@ -1,6 +1,6 @@
1
1
  import { type ClientCapabilities, type InputRequiredResult } from '@modelcontextprotocol/server';
2
2
  import { type ToolReturn } from '../tools/results/envelope.js';
3
- import type { Confirmation } from '../tools/write/apply-phases.js';
3
+ import type { Confirmation } from '../tools/write/apply.js';
4
4
  import type { PlanRecord } from '../write/store.js';
5
5
  /** The key the confirm form's answer rides under in `inputResponses`. */
6
6
  export declare const GATE_KEY = "confirm";
@@ -23,7 +23,6 @@ export declare function formElicitation(extra: unknown, caller: Caller, legacyCa
23
23
  export declare function gateApply(a: {
24
24
  plan: PlanRecord;
25
25
  caller: Caller;
26
- trusted: ReadonlySet<string>;
27
26
  extra: unknown;
28
27
  legacyCaps: ClientCapabilities | undefined;
29
28
  }): GateOutcome;
@@ -1,19 +1,9 @@
1
- /**
2
- * access/gate.ts — the apply-time human gate for a gated plan (#178).
3
- *
4
- * A trusted caller's own host prompt counts as the confirmation (AUTOTASK_TRUSTED_HOST_PROMPT). Any
5
- * other caller whose client shows form elicitation gets one plain confirm form through the SDK's
6
- * multi-round-trip `input_required` result, and the client retries the same call with the answer.
7
- * The plan is already held on the server, so there is no code, key or `requestState` to carry.
8
- * Everything else is a typed `GATE_UNSUPPORTED` deny: an untrusted 2025-era client over HTTP
9
- * included, because the legacy shim cannot push a request from a stateless per-request instance.
10
- */
1
+ /** Apply-time human confirmation through MCP form elicitation; unsupported clients fail closed. */
11
2
  import * as z from 'zod';
12
3
  import { inputRequired } from '@modelcontextprotocol/server';
13
4
  import { cleanUntrustedText } from '../tools/results/untrusted.js';
14
5
  import {} from '../tools/results/envelope.js';
15
6
  import { answer, reason } from '../tools/write/common.js';
16
- import { approvalDigest } from '../write/canonical.js';
17
7
  /** The key the confirm form's answer rides under in `inputResponses`. */
18
8
  export const GATE_KEY = 'confirm';
19
9
  const DATA_CAP = 500;
@@ -53,24 +43,20 @@ export function formElicitation(extra, caller, legacyCaps) {
53
43
  }
54
44
  /** Decide one gated plan's apply: a confirmation, the confirm form, or a typed deny. Zero vendor calls on every path. */
55
45
  export function gateApply(a) {
56
- const { plan, caller, trusted, extra, legacyCaps } = a;
57
- const digest = approvalDigest(plan.planHash, plan.facts);
46
+ const { plan, caller, extra, legacyCaps } = a;
58
47
  const deny = (code, detail) => ({
59
48
  result: answer('denied', 'stop', [reason(code, detail)], { operation: plan.op, targets: plan.targets, plan: plan.handle }, { plan: plan.handle, planHash: plan.planHash }),
60
49
  });
61
- if ((caller.transport === 'stdio' && trusted.has('stdio')) || (caller.transport === 'http' && caller.clientId !== undefined && trusted.has(caller.clientId))) {
62
- return { ok: { via: 'host_prompt', digest } };
63
- }
64
50
  // an answer counts only from a client that declared form elicitation; it is untrusted input, so only an explicit accept confirms
65
51
  if (formElicitation(extra, caller, legacyCaps)) {
66
52
  const response = extra?.mcpReq?.inputResponses?.[GATE_KEY];
67
53
  if (response !== undefined) {
68
54
  if (response?.action === 'accept')
69
- return { ok: { via: 'elicitation', digest } };
55
+ return { ok: { via: 'elicitation', planHash: plan.planHash } };
70
56
  return deny('GATE_DECLINED', `the person declined or dismissed ${plan.op}; nothing was sent`);
71
57
  }
72
58
  return { result: inputRequired({ inputRequests: { [GATE_KEY]: inputRequired.elicit({ message: gateMessage(plan), requestedSchema: z.object({}) }) } }) };
73
59
  }
74
- return deny('GATE_UNSUPPORTED', `${plan.op} needs a person to confirm it, and this client cannot show a confirm form (and is not a trusted host); nothing was sent`);
60
+ return deny('GATE_UNSUPPORTED', `${plan.op} needs a person to confirm it, and this client cannot show a confirm form; nothing was sent`);
75
61
  }
76
62
  //# sourceMappingURL=gate.js.map
@@ -1,5 +1,5 @@
1
1
  import { type WriteShape } from '../write/hard-rules.js';
2
- export type WriteMode = 'allow' | 'gate' | 'deny';
2
+ export type WriteMode = 'allow' | 'deny';
3
3
  export interface ResolvedOp {
4
4
  mode: WriteMode;
5
5
  rule: string;
@@ -7,21 +7,16 @@ export interface ResolvedOp {
7
7
  upgraded?: true;
8
8
  dedup?: readonly string[];
9
9
  }
10
- export interface WriteLimits {
11
- perHour?: number;
12
- perDay?: number;
13
- }
10
+ /** `expensePin`: the one submitter id a company-less `expense_reports.create` is admitted for (absent = no exception). */
14
11
  export interface LoadedPolicy {
15
12
  table: ReadonlyMap<string, ResolvedOp>;
16
- limits?: WriteLimits;
13
+ expensePin?: number;
17
14
  policyHash: string;
18
15
  }
19
16
  export declare function resolveWritePolicy(env: {
20
17
  write: string;
21
18
  dedup?: string;
22
- limits?: string;
19
+ expensePin?: string;
23
20
  }): LoadedPolicy;
24
- /** The trusted host-prompt ids (`stdio` or a connection id), comma separated. */
25
- export declare function parseTrustedHosts(raw: string | undefined): ReadonlySet<string>;
26
- /** A `gate` row, or a non-deny move row (a move can escalate to a gate). */
21
+ /** Any permitted write whose hard rule requires a human, or a move that can escalate. */
27
22
  export declare const anyGate: (loaded: LoadedPolicy) => boolean;