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.
- package/ARCHITECTURE.md +44 -21
- package/CHANGELOG.md +42 -0
- package/MCP_TOOL_CATALOG.json +1458 -12
- package/MCP_TOOL_MANIFEST.md +23 -20
- package/MCP_TOOL_OVERRIDES.json +7 -7
- package/N8N_MCP_MANIFEST.json +20 -4
- package/N8N_MCP_MANIFEST.md +6 -5
- package/README.md +9 -9
- package/capabilities.json +12362 -7886
- package/capabilities.schema.json +33 -12
- package/dist/capabilities.cjs +2 -2
- package/dist/capabilities.d.cts +9 -2
- package/dist/capabilities.d.ts +9 -2
- package/dist/capabilities.js +1 -1
- package/dist/{chunk-GA6UCX3Z.js → chunk-B5SNZ3TZ.js} +3507 -3113
- package/dist/{chunk-L6V66V22.cjs → chunk-D5TVBI4U.cjs} +3039 -2645
- package/dist/{chunk-FLYTKJKV.cjs → chunk-JUO3URUI.cjs} +12361 -7885
- package/dist/{chunk-YH5CPRDN.cjs → chunk-N27NUB4R.cjs} +2466 -1745
- package/dist/{chunk-2T77J2ZM.js → chunk-OGNUI3UR.js} +12361 -7885
- package/dist/{chunk-XSYJB4IW.js → chunk-PVUUOM5G.js} +2092 -1371
- package/dist/{chunk-RMLGOCGA.cjs → chunk-TTQB537V.cjs} +135 -135
- package/dist/{chunk-JEJUFXHX.js → chunk-VYFO2N5F.js} +2 -2
- package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
- package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
- package/dist/{index-AN-NjLi9.d.cts → index-DBoX-Dnk.d.ts} +430 -952
- package/dist/{index-CvLLsOCP.d.ts → index-NC-x-bYl.d.cts} +430 -952
- package/dist/index.cjs +91 -62
- package/dist/index.d.cts +360 -5
- package/dist/index.d.ts +360 -5
- package/dist/index.js +74 -45
- package/dist/operations/index.cjs +462 -462
- package/dist/operations/index.d.cts +78 -84
- package/dist/operations/index.d.ts +78 -84
- package/dist/operations/index.js +382 -382
- package/dist/pool.cjs +8 -8
- package/dist/pool.d.cts +2 -2
- package/dist/pool.d.ts +2 -2
- package/dist/pool.js +4 -4
- package/dist/resources/index.cjs +4 -4
- package/dist/resources/index.d.cts +2 -2
- package/dist/resources/index.d.ts +2 -2
- package/dist/resources/index.js +3 -3
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/API.md +140 -45
- package/docs/N8N-ADAPTER.md +3 -3
- package/examples/mcp/errors.ts +135 -0
- package/examples/mcp/manifest.ts +168 -0
- package/examples/mcp/register.ts +66 -0
- package/examples/mcp/schemas.ts +203 -0
- package/examples/mcp/tools-meta.ts +120 -0
- package/examples/mcp/tools-mode.ts +177 -0
- package/examples/mcp/tools-read.ts +264 -0
- package/examples/mcp/types.ts +34 -0
- package/examples/mcp-register.ts +9 -1074
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
941
|
-
`
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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:
|
|
1026
|
-
destructive of 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
|
|
1042
|
-
all
|
|
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 **
|
|
1046
|
-
tools**: the
|
|
1047
|
-
`effect=destructive`, every one of them has a dedicated tool) +
|
|
1048
|
-
`autotask_write` + `autotask_delete`. The remaining **
|
|
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 **
|
|
1054
|
-
`destructiveHint: true`** (
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
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
|
|
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)
|
|
1707
|
-
|
|
1708
|
-
"API-only user cannot be selected for this field" — `create`
|
|
1709
|
-
`createForResource` (stage-11 proven, 8 live creates 2026-10-04)
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
|
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-
|
|
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
|
|
1921
|
-
("API-only user cannot be selected for this
|
|
1922
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
package/docs/N8N-ADAPTER.md
CHANGED
|
@@ -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
|
|
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
|
+
}
|