node-autotask 0.20.0 → 0.23.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 (56) hide show
  1. package/ARCHITECTURE.md +44 -21
  2. package/CHANGELOG.md +42 -0
  3. package/MCP_TOOL_CATALOG.json +1458 -12
  4. package/MCP_TOOL_MANIFEST.md +23 -20
  5. package/MCP_TOOL_OVERRIDES.json +7 -7
  6. package/N8N_MCP_MANIFEST.json +20 -4
  7. package/N8N_MCP_MANIFEST.md +6 -5
  8. package/README.md +9 -9
  9. package/capabilities.json +12362 -7886
  10. package/capabilities.schema.json +33 -12
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.d.cts +9 -2
  13. package/dist/capabilities.d.ts +9 -2
  14. package/dist/capabilities.js +1 -1
  15. package/dist/{chunk-GA6UCX3Z.js → chunk-B5SNZ3TZ.js} +3507 -3113
  16. package/dist/{chunk-L6V66V22.cjs → chunk-D5TVBI4U.cjs} +3039 -2645
  17. package/dist/{chunk-FLYTKJKV.cjs → chunk-JUO3URUI.cjs} +12361 -7885
  18. package/dist/{chunk-YH5CPRDN.cjs → chunk-N27NUB4R.cjs} +2466 -1745
  19. package/dist/{chunk-2T77J2ZM.js → chunk-OGNUI3UR.js} +12361 -7885
  20. package/dist/{chunk-XSYJB4IW.js → chunk-PVUUOM5G.js} +2092 -1371
  21. package/dist/{chunk-RMLGOCGA.cjs → chunk-TTQB537V.cjs} +135 -135
  22. package/dist/{chunk-JEJUFXHX.js → chunk-VYFO2N5F.js} +2 -2
  23. package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
  24. package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
  25. package/dist/{index-AN-NjLi9.d.cts → index-DBoX-Dnk.d.ts} +430 -952
  26. package/dist/{index-CvLLsOCP.d.ts → index-NC-x-bYl.d.cts} +430 -952
  27. package/dist/index.cjs +91 -62
  28. package/dist/index.d.cts +360 -5
  29. package/dist/index.d.ts +360 -5
  30. package/dist/index.js +74 -45
  31. package/dist/operations/index.cjs +462 -462
  32. package/dist/operations/index.d.cts +78 -84
  33. package/dist/operations/index.d.ts +78 -84
  34. package/dist/operations/index.js +382 -382
  35. package/dist/pool.cjs +8 -8
  36. package/dist/pool.d.cts +2 -2
  37. package/dist/pool.d.ts +2 -2
  38. package/dist/pool.js +4 -4
  39. package/dist/resources/index.cjs +4 -4
  40. package/dist/resources/index.d.cts +2 -2
  41. package/dist/resources/index.d.ts +2 -2
  42. package/dist/resources/index.js +3 -3
  43. package/dist/types/index.d.cts +1 -1
  44. package/dist/types/index.d.ts +1 -1
  45. package/docs/API.md +140 -45
  46. package/docs/N8N-ADAPTER.md +3 -3
  47. package/examples/mcp/errors.ts +135 -0
  48. package/examples/mcp/manifest.ts +168 -0
  49. package/examples/mcp/register.ts +66 -0
  50. package/examples/mcp/schemas.ts +203 -0
  51. package/examples/mcp/tools-meta.ts +120 -0
  52. package/examples/mcp/tools-mode.ts +177 -0
  53. package/examples/mcp/tools-read.ts +264 -0
  54. package/examples/mcp/types.ts +34 -0
  55. package/examples/mcp-register.ts +9 -1074
  56. package/package.json +1 -1
package/docs/API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # API Reference — node-autotask
2
2
 
3
- > Generated from: `src/` (core modules), `src/errors.ts`, `src/config.ts`,
3
+ > Generated from: `src/` (core modules), `src/errors/`, `src/config/`,
4
4
  > `src/pagination.ts`, `src/query.ts`, `src/operations/`, and the capability
5
5
  > registry (`capabilities.json` + `capabilities.plan.json`). The normative
6
6
  > architecture is `ARCHITECTURE.md`; this reference documents the public
@@ -49,7 +49,7 @@ Integration endpoints are on their own accessors: `authenticateResource.get()`
49
49
  `zoneInformation.get(user?)` (the swagger-required `user` query param
50
50
  defaults to the configured UserName — the vendor 404s the param-less call).
51
51
 
52
- ### 1.2 `config.ts` — configuration + zone catalog
52
+ ### 1.2 `config/` — configuration + zone catalog
53
53
 
54
54
  `AutotaskConfig` / `ResolvedAutotaskConfig` + the 23-zone catalog
55
55
  (`ZONE_CATALOG`, `ZONES`, `DEFAULT_ZONE`), `zoneUrl()`, `resolveBaseUrl()`,
@@ -76,7 +76,7 @@ endpoint (`client.authenticateResource.get()` → `AuthTokenResultModel
76
76
  { authToken, expiration }`), which is a credential probe, not a stored-token
77
77
  flow.
78
78
 
79
- ### 1.4 `http.ts` — HTTP transport
79
+ ### 1.4 `http/` — HTTP transport
80
80
 
81
81
  Native global `fetch` (Node >= 24) — zero runtime dependencies across the
82
82
  whole package. `HttpClient.request<T>(opts): Promise<T>` is the single
@@ -101,7 +101,7 @@ Every 2xx JSON body also passes through the response redaction transform
101
101
  (`redactResponseBody`, section 10) at this single choke point — secret fields
102
102
  always, personal/business-sensitive data by default.
103
103
 
104
- ### 1.5 `errors.ts` — error hierarchy
104
+ ### 1.5 `errors/` — error hierarchy
105
105
 
106
106
  `AutotaskError` is the base: every error carries the structured contract —
107
107
  `code` (stable SCREAMING_SNAKE machine code), `category`, `retryable`,
@@ -593,7 +593,7 @@ diagnostic is appended as a separate field, and a 401 is still never retried.
593
593
 
594
594
  `circuitBreaker: { enabled: true }` opts into the optional circuit breaker
595
595
  at the transport (OFF by default — without it, behaviour is byte-identical
596
- to before; no env overrides — `src/config.ts` never reads `process.env`).
596
+ to before; no env overrides — `src/config/` never reads `process.env`).
597
597
  Vendor health is HOST-scoped (a zone outage hits every tenant on that
598
598
  zone), so the key is the lowercased host of the resolved base URL
599
599
  (`buildBreakerKey` — the same derivation as `buildTenantKey`'s host part,
@@ -776,8 +776,11 @@ carry paging parameters.
776
776
  initial `count(F)` + every `id gt T` threshold probe) are cached per
777
777
  client in a short-TTL in-process store: 30 s TTL, 4096-entry memory
778
778
  guard, keyed by (resource plan key, canonical filter serialisation).
779
- A re-asked "top N" within 30 s makes ZERO extra vendor count calls
780
- (full-set AND threshold mode). A real write through the SAME client
779
+ A re-asked "top N" within 30 s makes ZERO extra vendor count calls for
780
+ every CACHED probe (full-set AND threshold mode); a probe that answered
781
+ 0 is deliberately re-wired (a 0 is never cached — see below), so a
782
+ threshold search that hit a 0 pays those probes again. A real write
783
+ through the SAME client
781
784
  clears the cache — and a probe still in flight across the write is
782
785
  DISCARDED by the cache's invalidation epoch (issue #139: its pre-write
783
786
  answer must not re-anchor a stale top-N within the TTL — the next
@@ -937,11 +940,18 @@ MCP-independent: no MCP imports, no zod.
937
940
 
938
941
  Bounded multi-resource text search. Runs each requested resource's OWN
939
942
  `search` helper (server-side bounded `contains` — never a listAll scan)
940
- across the selected resources with one limit budget. `resources` defaults to
941
- `WORKFLOW_RESOURCES`; `limit` defaults to 25 per resource, clamped to the
942
- vendor max of 500. Resources without a `search` helper are skipped and
943
- REPORTED (the `skipped` array), never errored. Returns
944
- `{ query, results: [{ resource, matches }], skipped }`.
943
+ across the selected resources. `resources` defaults to `WORKFLOW_RESOURCES`;
944
+ `limit` is **per resource** (default 25, clamped to the vendor max of 500), so
945
+ a sweep over N resources can return up to N x `limit` matches. Optional
946
+ `totalLimit` (positive integer) caps the whole sweep: resources are searched in
947
+ request order, each asked for `min(limit, remaining)`; once the cap is met the
948
+ rest are not queried and are listed in `notSearched`, and `truncated` is true
949
+ when more matches may exist (a resource was not searched, or a searched
950
+ resource returned exactly the quota it was asked for, `min(limit, remaining)`). Resources without a
951
+ `search` helper are skipped and REPORTED (the `skipped` array), never errored.
952
+ Returns `{ query, results: [{ resource, matches }], skipped }`; with
953
+ `totalLimit` the result also carries `notSearched` and `truncated`, and
954
+ without it the result is unchanged.
945
955
 
946
956
  ### 7.2 `resolveAny(client, identifier, opts?)`
947
957
 
@@ -1022,8 +1032,8 @@ dry-run envelope) is that path's — not a second implementation.
1022
1032
  The three MCP mode tools (`autotask_read` / `autotask_write` /
1023
1033
  `autotask_delete`) dispatch through the SAME router as `invoke` with the mode
1024
1034
  pinned (`InvokeMode = 'read' | 'write' | 'delete'`; the mode predicate is the
1025
- registry `effect`, verified one-to-one: 961 read / 198 write / 54
1026
- destructive of the 1213 ops). `invoke()` itself is UNCHANGED — the
1035
+ registry `effect`, verified one-to-one: 962 read / 198 write / 54
1036
+ destructive of the 1214 ops). `invoke()` itself is UNCHANGED — the
1027
1037
  programmatic escape hatch, its mode derived from the record, so it can never
1028
1038
  be cross-mode-refused.
1029
1039
 
@@ -1038,27 +1048,27 @@ be cross-mode-refused.
1038
1048
  - everything else is the governor's, unchanged: the dry-run-first default
1039
1049
  and the `confirm === <op key>` gate (all 54 deletes; the 3 approval-gated
1040
1050
  writes — inside `autotask_write` too) apply exactly as in `invoke`.
1041
- Pinned by the full-surface suite (`test/operations/invoke-modes.test.ts`:
1042
- all 1213 ops × 3 modes, zero wire).
1051
+ Pinned by the full-surface suite (`test/operations/invoke/modes.test.ts`:
1052
+ all 1214 ops × 3 modes, zero wire).
1043
1053
 
1044
1054
  **Harness approval policy (recommended).** Require approval on every tool
1045
- whose MCP annotation is `readOnlyHint !== true` — today exactly **254
1046
- tools**: the 252 dedicated write/delete tools (198 `effect=write` + 54
1047
- `effect=destructive`, every one of them has a dedicated tool) +
1048
- `autotask_write` + `autotask_delete`. The remaining **601 tools** (the 588
1055
+ whose MCP annotation is `readOnlyHint !== true` — today exactly **256
1056
+ tools**: the 254 dedicated write/delete tools (198 `effect=write` + 56
1057
+ `effect=destructive`, every one of them has a dedicated tool) +
1058
+ `autotask_write` + `autotask_delete`. The remaining **602 tools** (the 589
1049
1059
  dedicated read tools + the 10 core read tools + `autotask_read` +
1050
1060
  `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
1051
1061
  and are safe ungated: every op they can route is `effect=read` (zero
1052
1062
  mutating requests — pinned by the test inventory, §6 of the split design).
1053
- Refined class for the highest-risk ops: the **55 tools with
1054
- `destructiveHint: true`** (54 dedicated delete tools + `autotask_delete`)
1063
+ Refined class for the highest-risk ops: the **57 tools with
1064
+ `destructiveHint: true`** (56 dedicated delete tools + `autotask_delete`)
1055
1065
  additionally require `confirm === <operation key>` even inside
1056
1066
  `autotask_delete`, and the 3 approval-gated writes
1057
1067
  (`configuration_items.moveConfigurationItem`, `contacts.moveToCompany`,
1058
1068
  `resources.transferOwnership`) require the same confirm inside
1059
1069
  `autotask_write`. Migration: `autotask_invoke` is REPLACED by
1060
1070
  `autotask_read`/`autotask_write`/`autotask_delete` — update harness config
1061
- (approve the 254 above) and remove any `autotask_invoke` entry. The rule is
1071
+ (approve the 256 above) and remove any `autotask_invoke` entry. The rule is
1062
1072
  derivable from the registry `effect` column (and the mode tools' fixed
1063
1073
  annotations) — it never needs a hand-maintained name list.
1064
1074
 
@@ -1117,7 +1127,7 @@ const applied = await recovery(client, { resource: 'configuration_item_dns_recor
1117
1127
  preserved) + the boundary note in `warnings[]` (the SAME generated note
1118
1128
  `recovery` serves — the describe and dry-run channels cannot drift).
1119
1129
  Pinned by `test/operations/recovery.test.ts` (11 tests) + the full-surface
1120
- governor suite (`test/operations/invoke-modes.test.ts`).
1130
+ governor suite (`test/operations/invoke/modes.test.ts`).
1121
1131
 
1122
1132
  ### 7.6 `fetchMany(client, items, opts?)` / `fetchByIds(client, items, opts?)` — bulk fetch by ids
1123
1133
 
@@ -1125,7 +1135,7 @@ Two cross-resource bulk reads with per-item typed results — one entry per
1125
1135
  input occurrence: `{ resource, id, found, value, error?: { code, httpStatus?,
1126
1136
  message } }`; a NOT_FOUND or fault on one item never aborts the rest; the
1127
1137
  result is `{ results, ok, failed }`. Both refuse pre-request (zero wire):
1128
- non-numeric string ids (pointing at `autotask_resolve_any` — canonical
1138
+ non-numeric string ids (pointing at `operations.resolveAny` — canonical
1129
1139
  integer strings above 2^53 pass with their exact digits), parent-scoped
1130
1140
  children (their `get` needs `parentId`), unknown `fields` keys (the complete
1131
1141
  problem list in one message), unsafe number ids (the canonical-string
@@ -1201,7 +1211,7 @@ the unlabeled field.
1201
1211
  - **Input**: `ids: (number | string)[]` — vendor numeric resource ids
1202
1212
  (safe integers, or the canonical integer string for ids above 2^53 —
1203
1213
  the exact digits ride the wire URL verbatim). Non-numeric string ids
1204
- are refused pre-request pointing at `autotask_resolve_any` (the
1214
+ are refused pre-request pointing at `operations.resolveAny` (the
1205
1215
  complete problem list in one message, zero wire). Cap 500 per call
1206
1216
  (the pinned `EXPAND_RESOURCE_NAMES_MAX_IDS`; `opts.maxIds` may lower
1207
1217
  the cap, never raise it); over → `CONFIG_ERROR` pre-request, zero wire.
@@ -1300,6 +1310,79 @@ const n = await aggregate(client, 'billing_items.query', [{ field: 'companyID',
1300
1310
  const fold = await aggregate(client, 'time_entries.query', [{ field: 'dateWorked', op: 'gte', value: '2026-09-01' }], { groupBy: ['ticketID'], sum: ['hoursWorked'], top: 10 });
1301
1311
  ```
1302
1312
 
1313
+ ### 7.9 `uiLink(client, input)` / `buildUiLink(input)` — Autotask UI deep links (issue #202)
1314
+
1315
+ Pure, **zero-wire** builder of ExecuteCommand deep links:
1316
+ `https://ww<N>.autotask.net/Autotask/AutotaskExtend/ExecuteCommand.aspx?Code=<Command>&<Param>=<value>`.
1317
+ `input` is `{ entity, id | ticketNumber, host? }`; the op returns `{ url }`, or
1318
+ `{ url: null, reason }` when no UI host can be derived. Reachable as
1319
+ `operations.uiLink` through `invoke` / `autotask_read`; `buildUiLink` is the
1320
+ in-process helper (returns the URL or `undefined`; it also accepts `zone` /
1321
+ `baseUrl` to derive the host).
1322
+
1323
+ | `entity` | Link |
1324
+ | --- | --- |
1325
+ | `time_entries` | `Code=EditTimeEntry&WorkEntryID=<id>` |
1326
+ | `tickets` | `Code=OpenTicketDetail&TicketID=<id>` or `&TicketNumber=<T…>` |
1327
+ | `tasks` | `Code=OpenTaskDetail&TaskID=<id>` |
1328
+ | `projects` | `Code=OpenProject&ProjectID=<id>` |
1329
+ | `companies` / `contacts` / `contracts` | `OpenAccount&AccountID` / `OpenContact&ContactID` / `OpenContract&ContractID` |
1330
+ | `configuration_items` | `Code=EditInstalledProduct&InstalledProductID=<id>` |
1331
+
1332
+ - **Host derivation:** `ww<N>` comes from the client's live REST base
1333
+ (`webservices<N>`); a base without `<N>` (`pre-release-de`/`-es`, a custom
1334
+ host) yields no link — a host is never guessed. Pass `host: 'ww<N>.autotask.net'`
1335
+ to name it.
1336
+ - **Safety:** ids are positive safe integers; `ticketNumber` must match
1337
+ `^T\d{8}\.\d{4}$` and is only valid for `tickets`; exactly one of
1338
+ `id` / `ticketNumber`; an unknown entity, bad id or bad host is a typed
1339
+ `CONFIG_ERROR` naming the valid values — no vendor string is interpolated.
1340
+ - **Caveats:** the user must be logged in and their security level applies (a
1341
+ link grants no access); ExecuteCommand opens a popup; an invalid query string
1342
+ shows an error page; there is no `OpenTimeEntry` command. The op never checks
1343
+ the record exists. Unverified live: the `ww<N>` ↔ `webservices<N>` mapping for
1344
+ each catalog zone (`zoneInformation.get().webUrl` is the authority) and that
1345
+ `WorkEntryID` equals the REST time-entry `id`.
1346
+
1347
+
1348
+ ### 7.10 `moveToTicket` — governed create-verify-delete moves (issue #197)
1349
+
1350
+ `time_entries.moveToTicket(sourceId, toTicketId, opts)` and
1351
+ `ticket_charges.moveToTicket(sourceId, toTicketId, opts)` re-parent a record the
1352
+ vendor will not re-parent by update (probed live: an update of `ticketID`
1353
+ returns success and keeps the old value). Both run on one shared engine
1354
+ (`src/resources/special/_shared/move-engine.ts`); notes are different —
1355
+ `ticket_notes` update with a new `ticketID` (parent-scoped) does move a note, and
1356
+ notes cannot be deleted, so they never use create-then-delete.
1357
+
1358
+ - **Governed:** effect `destructive`; dry-run first (a GET-only preview that
1359
+ returns the create payload); apply needs `dryRun: false` and
1360
+ `confirm: '<resource>.moveToTicket'`. Options: `maxSourceAgeDays` (default 14,
1361
+ lowerable never raised), `lastModifiedDate` (stale guard), `maxRetries` (reads only).
1362
+ - **Hard invariants (typed `MOVE_*` refusal, zero writes, re-run at apply):**
1363
+ source exists and is on a ticket (task/internal refused), target exists and is
1364
+ open, target differs from the source ticket, source not billed/posted
1365
+ (`billing_items` by `timeEntryID`; unreadable fails closed) or approved, no
1366
+ attachments (a delete destroys them), not older than the age limit, and no twin
1367
+ already on the target (checked in the preview and again immediately before the POST).
1368
+ - **Apply order:** POST (once, never retried) -> read the new record back and compare
1369
+ -> re-read the source (any drift rolls the new record back) -> DELETE the source.
1370
+ - **Result:** `{ status: 'moved' | 'moved_source_absent' | 'moved_manual_delete',
1371
+ sourceId, newId, correlationId, snapshot, facts, manualAction? }`. Every other
1372
+ outcome throws `MoveRefusedError` whose `details.status` is `failed` (nothing
1373
+ changed), `partial` (both records exist) or `unknown`; details carry ids, never values.
1374
+ The SDK stores nothing — persist `snapshot` if you need it.
1375
+ - **Manual GUI path:** a refusal that a person can finish in the UI (billed,
1376
+ approved, attachments, too old) and a source delete the vendor refuses carry
1377
+ `manualAction: { action: 'delete_source', uiLink, reason }`. `uiLink` is an
1378
+ `operations.uiLink` deep link: the time entry itself (its edit page), or the
1379
+ parent ticket for a charge (no ExecuteCommand code exists for charges). It is
1380
+ `null` when no UI host can be derived from the client base.
1381
+ - **Caveats:** the new record gets a fresh create date and creator; creating and
1382
+ deleting may trigger notifications, SLA and workflow rules the API cannot
1383
+ suppress; vendor acceptance of the create is not validated by a dry run.
1384
+ Contract fields are copied only when the target ticket has the same company and contract.
1385
+
1303
1386
  ## 8. Resource surface (128 resources)
1304
1387
 
1305
1388
  <!-- BEGIN GENERATED:api-per-entity -->
@@ -1541,7 +1624,7 @@ cannot drift from the plan):
1541
1624
  - `EMPTY_REASON_STAMPS` (issue #14) — resource → op → the typed empty-reason
1542
1625
  `{ surface, kind, note, authority, remedy }` (`plan.emptyReasons`, the
1543
1626
  ww29-sweep-pinned 184 rows): the runtime lookup `base.ts` attaches to
1544
- 0/empty count/query results + the update dry-run envelope, and `http.ts`
1627
+ 0/empty count/query results + the update dry-run envelope, and the HTTP transport (`src/http/`)
1545
1628
  attaches to the typed error's `details` (the SAME generated data the plan
1546
1629
  serves — the result channel and the plan cannot drift). The
1547
1630
  `blind-surface` kind is ABSENT by design: `base.ts` synthesizes it from
@@ -1703,15 +1786,16 @@ resource paths, of which **40** expose a create op (the stampable set; the
1703
1786
  the validation-layer spelling `InternallAllocationCodeID` is rejected by the
1704
1787
  deserializer (key oracle, S9b §5: the gate-1 error text names the validation
1705
1788
  layer, not the body key).
1706
- - **2026-10-06 vendor drift (confirm-100)**: the vendor now refuses ALL
1707
- time-entry writes for this API-only user with
1708
- "API-only user cannot be selected for this field" — `create` **and**
1709
- `createForResource` (stage-11 proven, 8 live creates 2026-10-04) alike.
1710
- The wall is vendor eligibility, not missing tenant data: valid (resource,
1711
- role) pairs exist in the tenant (postedWithin rows: resourceID
1712
- 31131373 / 29869529 with roleID 29682834) but belong to human resources
1713
- the API user cannot select. Unsatifiable for an API-only integration
1714
- user; the typed W-A3 pointer note carries this.
1789
+ - **2026-10-06 vendor drift (confirm-100), re-scoped 2026-10-21 (#195)**: a
1790
+ (resource, role) pair that selects the API-only user's own resource was
1791
+ refused with "API-only user cannot be selected for this field" — `create`
1792
+ and `createForResource` (stage-11 proven, 8 live creates 2026-10-04)
1793
+ alike. This is a pair-scoped wall, not a blanket refusal: live
1794
+ `time_entries.create` calls succeeded on 2026-10-21 (pair not recorded);
1795
+ the authorised pair set has NOT been re-adjudicated by a live probe. Valid
1796
+ (resource, role) pairs exist in the tenant (postedWithin rows: resourceID
1797
+ 31131373 / 29869529 with roleID 29682834). The typed W-A3 pointer note
1798
+ carries this.
1715
1799
 
1716
1800
  ### 11.5 Company-create side effect
1717
1801
 
@@ -1728,7 +1812,7 @@ redacted `vendorError` naming the offending field and a deterministic
1728
1812
  quirk**: the error contract labels 500s `retryable: true` ("the server error is
1729
1813
  transient") even when the 500 is a *deterministic* missing-field rejection —
1730
1814
  retrying with the same body fails identically; fix the field instead
1731
- (`src/errors.ts` sets `retryable: true` for `SERVER_ERROR` and for every
1815
+ (`src/errors/` sets `retryable: true` for `SERVER_ERROR` and for every
1732
1816
  httpStatus >= 500). **Resolution (issue #23):** the *deterministic*
1733
1817
  validation 500 is no longer labelled retryable — a 500 whose `errors[]` text
1734
1818
  matches the documented heuristic phrases (`isDeterministicVendor500`:
@@ -1823,7 +1907,7 @@ synthesis. The 9 registry-backed records — `tickets` / `companies` /
1823
1907
  `.create` + `time_entries.create` / `time_entries.createForResource` — render
1824
1908
  the override verbatim, and every example passes its own record's `inputSchema`
1825
1909
  (the governor dispatch validator; pinned by
1826
- `test/scripts/mcp-surface-guidance.test.ts`). The `configuration_items`
1910
+ `test/scripts/mcp-surface/create-examples.test.ts`). The `configuration_items`
1827
1911
  body documents the §11.3 product wall, not a working create;
1828
1912
  `time_entries.create` points at `createForResource` (the linkage-aware
1829
1913
  method). The plan's 12-target list also names `tickets.createIfNotExists`,
@@ -1917,9 +2001,9 @@ so the answer to "why is this empty?" is data, not speculation:
1917
2001
  (`ticket_history.query` W1: the vendor requires exactly ONE ticketID-eq
1918
2002
  filter — unfiltered 500s by contract; WITH the filter the query WORKS
1919
2003
  (live 2026-10-06) → `tickets.timeline` / `tickets.fullContext` stay the
1920
- composites; `time_entries.create` W-A3: the API-only-resource wall
1921
- ("API-only user cannot be selected for this field" — 2026-10-06 drift,
1922
- `createForResource` walled by the same drift); `authenticate.get` W2:
2004
+ composites; `time_entries.create` W-A3: the pair-scoped wall on the
2005
+ API-only user's own resource ("API-only user cannot be selected for this
2006
+ field" — 2026-10-06; creates succeeded 2026-10-21, pair not recorded); `authenticate.get` W2:
1923
2007
  designator-only;
1924
2008
  `time_entries.getPosted` / `time_entries.getUnposted` W-D3: this zone's
1925
2009
  TimeEntry entity exposes no posted-state filter field — the working read
@@ -2455,7 +2539,7 @@ what makes it usable as a recovery signal from inside the client.
2455
2539
  lifetime, like a cache); no change to the served MCP/n8n text (transport
2456
2540
  behaviour, like #34).
2457
2541
 
2458
- Pins: `test/transport-zone-reresolve.test.ts` (11 pins, mock wire only —
2542
+ Pins: `test/transport-zone-reresolve/*.test.ts` (11 pins, mock wire only —
2459
2543
  no vendor wire in tests; the 10 issue-required + the breaker probe
2460
2544
  host-pinning pin).
2461
2545
 
@@ -2599,6 +2683,17 @@ TIME"). A `fresh: true` call closes the gap immediately: it bypasses
2599
2683
  BOTH the name cache and the ReadCache and refreshes both, so the rename
2600
2684
  is served on the very next fresh call (issue #77).
2601
2685
 
2686
+ **Per-part projection (issue #200)**: `companies.fullProfile` accepts
2687
+ `fields?: { locations?, contacts?, notes?, projects?, openTickets? }` — each a
2688
+ non-empty list of exact vendor field names — and `locationLimit?: number`
2689
+ (default 50, 1-500). A listed part serves ONLY those fields (sent as
2690
+ `IncludeFields`, then picked client-side); an absent part keeps the full model,
2691
+ and without `fields` the call and result are unchanged. An unknown part key, an
2692
+ empty/non-array list or a field name unknown to the part's vendored field
2693
+ catalog is a typed `CONFIG_ERROR` before any request. `capped`, `absent` and
2694
+ `parts` semantics are unchanged; with `names: true` the siblings appear only for
2695
+ the id fields you kept.
2696
+
2602
2697
  **Composite wiring (issue #77)**: the full-context composites
2603
2698
  (`tickets.fullContext`, `companies.fullProfile`,
2604
2699
  `configuration_items.profile`, `projects.getContext`) accept an
@@ -3152,12 +3247,12 @@ returns the contract's reconciled P&L over a date window:
3152
3247
  `effect: 'read'`, `dryRun: false`, the closed-bag `inputSchema` (the
3153
3248
  signature-audited arg map in `scripts/lib/args-derive.mjs`); the emitted
3154
3249
  method delegates to the hand-written special module
3155
- (`src/resources/special/contracts.ts`). The MCP projection serves
3250
+ (`src/resources/special/contracts/profitability.ts` and its `profitability.*.ts` siblings). The MCP projection serves
3156
3251
  `autotask_contract_profitability` (catalog + manifest); the N8N
3157
3252
  manifests legitimately EXCLUDE the op — `contracts` is not an n8n
3158
3253
  curated entity (the curation rule is evidence-based: the daily support
3159
3254
  subset is ticket/contact/company/resource/time_entry only).
3160
- - **Test pins.** `test/resources/contracts.profitability.test.ts`: the
3255
+ - **Test pins.** `test/resources/contracts.profitability.*.test.ts` (revenue, rate-ladder, cost-ladder, bounds, warnings): the
3161
3256
  hand-computed 2-month synthetic fixture (every figure derived from the
3162
3257
  fixture rows; `wires.issued = 20` with the exact queue; every wire pinned
3163
3258
  in issue order; the structural exclusions + zero-mutating recorded paths;
@@ -3174,7 +3269,7 @@ returns the contract's reconciled P&L over a date window:
3174
3269
  charge cost/period-type/line-type classifiers, the Block Hours
3175
3270
  `contract_blocks` path, T&M unposted pricing + the time-referenced old
3176
3271
  ticket, the exclusive window ends, the month-level `coverage_gaps`).
3177
- `test/resources/profitability-degradation.test.ts`: the empty-tenant
3272
+ `test/resources/contracts.profitability.degradation.test.ts`: the empty-tenant
3178
3273
  13-wire suite (every part degrades, zero tool failure), the dangling
3179
3274
  customer FK, the non-404 customer fault (`absent`), a bounded-part fault
3180
3275
  (`absent` + `coverage_gaps`, the call resolves), the resources blind-list get-fallback (rung 3 from the
@@ -16,11 +16,11 @@ knowledge**:
16
16
  | entity catalog (138 entity metadata entries, 121 resource dirs) | `constants/entities.ts` + `resources/<dir>/description.ts` | the SDK's 128 resource accessors + the capability registry (`capabilities.json`) |
17
17
  | per-entity operations (get/getMany/count/create/update/delete + specials) | `resources/<dir>/execute.ts` switch cases | SDK primitive + helper operations, one per registry row |
18
18
  | auth (3 headers + optional impersonation) | `helpers/http/headers.ts` | `src/auth.ts` (swagger casing) + fail-closed config |
19
- | zone catalog (23 zones) | the node credential definition | `src/config.ts` `ZONE_CATALOG` |
19
+ | zone catalog (23 zones) | the node credential definition | `src/config/zones.ts` `ZONE_CATALOG` |
20
20
  | pagination (nextPageUrl cursor walk, `PAGINATION.MAX_PAGES` = 1000) | `helpers/pagination.ts` | `src/pagination.ts` (same cursor-follow walk — POST the filter to the absolute `nextPageUrl`, rebased to the client base URL — + 1000-page guard) |
21
21
  | filter grammar (13 operators + and/or, caps) | `constants/filters.ts` | `src/query.ts` |
22
- | envelopes (item/page/operation/count unwrapping) | `helpers/request.ts` + per-op unwrapping | `src/http.ts` + the pinned envelopes in the registry |
23
- | error mapping | `errorHandler` + `error.constants` | `src/errors.ts` (the class → code → status hierarchy) |
22
+ | envelopes (item/page/operation/count unwrapping) | `helpers/request.ts` + per-op unwrapping | `src/http/envelope.ts` + the pinned envelopes in the registry |
23
+ | error mapping | `errorHandler` + `error.constants` | `src/errors/` (the class → code → status hierarchy) |
24
24
  | dry-run / stale / audit guards | inline per operation | `src/operations/invoke.ts` governor + the pinned `DryRunResult` envelope |
25
25
 
26
26
  What remains in the node is the **n8n wiring**: credential UI, trigger
@@ -0,0 +1,135 @@
1
+ /**
2
+ * errors.ts — result shaping: the structured success/DRY results and the
3
+ * typed error payload (the SDK AutotaskError contract, served verbatim).
4
+ */
5
+ import type { AutotaskError } from 'node-autotask';
6
+ import { unknownKeysRefusal } from 'node-autotask/operations';
7
+ import {
8
+ UNTRUSTED_CLOSE,
9
+ UNTRUSTED_CLOSE_NEUTRALISED,
10
+ UNTRUSTED_OPEN,
11
+ UNTRUSTED_OPEN_NEUTRALISED,
12
+ wrapUntrusted,
13
+ } from 'node-autotask/untrusted';
14
+ import type { ToolReturn } from './types.js';
15
+
16
+ export const DRY_NOTE = 'No Autotask credentials in the environment (set AUTOTASK_TRACKING_IDENTIFIER, AUTOTASK_USERNAME, AUTOTASK_SECRET, optionally AUTOTASK_ZONE). The call was validated and NOT issued.';
17
+
18
+ /** A successful structured result. `outputSchema` describes `structuredContent`. */
19
+ export function ok(text: string, structuredContent: Record<string, unknown>): ToolReturn {
20
+ return { content: [{ type: 'text', text }], structuredContent };
21
+ }
22
+
23
+ /** A DRY-mode structured result (no request was issued). */
24
+ export function dryResult(opId: string): ToolReturn {
25
+ return ok(`DRY mode — "${opId}" was NOT issued (no credentials in the environment).`, { dry_mode: true, operation: opId, note: DRY_NOTE } satisfies Record<string, unknown>);
26
+ }
27
+
28
+ /** Surface the SDK error contract so a model can self-correct: what failed, whether to retry, what to do next. Credentials never appear in an error message.
29
+ *
30
+ * `details` is passed through verbatim (issue #9/#14): the additive
31
+ * machine-checked extras — the typed `emptyReason` / `pointerNote` on a
32
+ * plan-pinned op's failure, the `operations.recovery` refusal payload
33
+ * (`candidateIds` / marker info), the #13 shape-drift extras — ride the
34
+ * served payload exactly as they do on the typed client's `AutotaskError`.
35
+ * The content is plan-derived (or the caller's own echoed token): never a
36
+ * credential, never free vendor prose beyond the already-published notes.
37
+ *
38
+ * Issue #24: `details.bodySnippet` is the ONE host-sourced free-text value
39
+ * that can ride the details (a host-supplied baseUrl can serve arbitrary HTML
40
+ * as the UNEXPECTED_CONTENT_TYPE snippet) — when `untrustedWrap` is on,
41
+ * exactly that value is wrapped in `<untrusted_content>`; the rest of the
42
+ * details stays verbatim (machine-checked / plan-derived).
43
+ *
44
+ * `vendorError` (issue #33): the deep-redacted vendor body rides the served
45
+ * payload (it is set upstream by `errorFromStatus` — redaction already ran
46
+ * before the payload is built). A STRING body is host-sourced free text and
47
+ * gets the SAME `<untrusted_content>` treatment as `details.bodySnippet`
48
+ * (wrapped when `untrustedWrap` is on, served raw when off); a parsed
49
+ * (non-string) body is a redacted copy — served structured, and with
50
+ * `untrustedWrap` on its string leaves get the SAME marker neutralisation
51
+ * `wrapUntrusted` applies, so a vendor body cannot forge a wrapper boundary.
52
+ * Absent or empty body → NO `vendorError` key: the unpinned payload stays
53
+ * byte-identical on the wire (an undefined key drops out of the JSON text).
54
+ */
55
+ /**
56
+ * Issue #33 QA minor: a parsed (non-string) vendor body is served structured, so
57
+ * `wrapUntrusted` cannot wrap it as a whole. With `untrustedWrap` on, apply the
58
+ * same marker neutralisation to every string leaf instead — a vendor body
59
+ * containing the literal wrapper markers cannot forge a boundary.
60
+ */
61
+ function neutraliseParsedMarkers(value: unknown): unknown {
62
+ if (typeof value === 'string') {
63
+ return value
64
+ .split(UNTRUSTED_OPEN)
65
+ .join(UNTRUSTED_OPEN_NEUTRALISED)
66
+ .split(UNTRUSTED_CLOSE)
67
+ .join(UNTRUSTED_CLOSE_NEUTRALISED);
68
+ }
69
+ if (Array.isArray(value)) return value.map(neutraliseParsedMarkers);
70
+ if (value !== null && typeof value === 'object') {
71
+ const out: Record<string, unknown> = {};
72
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = neutraliseParsedMarkers(v);
73
+ return out;
74
+ }
75
+ return value;
76
+ }
77
+
78
+ export function errorContent(err: unknown, untrustedWrap = false): ToolReturn {
79
+ const e = err as Partial<AutotaskError> | undefined;
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ let details = e?.details;
82
+ if (
83
+ untrustedWrap &&
84
+ details !== undefined &&
85
+ details !== null &&
86
+ typeof (details as Record<string, unknown>).bodySnippet === 'string'
87
+ ) {
88
+ details = {
89
+ ...(details as Record<string, unknown>),
90
+ bodySnippet: wrapUntrusted((details as Record<string, unknown>).bodySnippet),
91
+ };
92
+ }
93
+ // issue #33: the redacted vendor body under a fixed key (absent/empty →
94
+ // undefined → no key on the wire — see the doc block).
95
+ const vendorErrorRaw = e?.vendorError;
96
+ const vendorError: unknown =
97
+ typeof vendorErrorRaw === 'string'
98
+ ? vendorErrorRaw === ''
99
+ ? undefined
100
+ : untrustedWrap
101
+ ? wrapUntrusted(vendorErrorRaw)
102
+ : vendorErrorRaw
103
+ : vendorErrorRaw !== undefined && vendorErrorRaw !== null
104
+ ? untrustedWrap
105
+ ? neutraliseParsedMarkers(vendorErrorRaw)
106
+ : vendorErrorRaw
107
+ : undefined;
108
+ const payload = {
109
+ error: true,
110
+ code: e?.code,
111
+ category: e?.category,
112
+ retryable: e?.retryable,
113
+ httpStatus: e?.httpStatus,
114
+ operation: e?.operation,
115
+ vendorError,
116
+ suggestedAction: e?.suggestedAction,
117
+ correlationId: e?.correlationId,
118
+ resourceIds: e?.resourceIds,
119
+ details,
120
+ message,
121
+ };
122
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }], isError: true };
123
+ }
124
+
125
+ /**
126
+ * The handler-side typed refusal for a tool call (issue #3). Runs the
127
+ * SDK's shared closed-set check (`unknownKeysRefusal`) against the tool's
128
+ * valid top-level keys and, on a refusal, serves the full error contract
129
+ * through the same `errorContent` path the rest of the refusals use.
130
+ * Returns null when every key is known (no refusal).
131
+ */
132
+ export function closedArgsError(raw: unknown, validKeys: readonly string[]): ToolReturn | null {
133
+ const refusal = unknownKeysRefusal(raw, validKeys, 'argument');
134
+ return refusal === null ? null : errorContent(refusal);
135
+ }