iit-mcp-autotask 0.36.0 → 0.38.1

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 (41) hide show
  1. package/README.md +42 -27
  2. package/capabilities.json +8 -42
  3. package/dist/mocks/fixture-ops.d.ts +2 -16
  4. package/dist/mocks/fixture-ops.js +0 -2
  5. package/dist/tools/aggregate.d.ts +15 -3
  6. package/dist/tools/aggregate.js +39 -35
  7. package/dist/tools/capabilities-manifest.js +0 -1
  8. package/dist/tools/catalog-data.d.ts +8 -1
  9. package/dist/tools/catalog-data.js +10 -9
  10. package/dist/tools/context/ops.js +3 -3
  11. package/dist/tools/context/types.d.ts +2 -14
  12. package/dist/tools/dispatch.d.ts +8 -0
  13. package/dist/tools/dispatch.js +39 -19
  14. package/dist/tools/lists.d.ts +6 -0
  15. package/dist/tools/lists.js +52 -169
  16. package/dist/tools/meta.d.ts +0 -2
  17. package/dist/tools/meta.js +67 -58
  18. package/dist/tools/rank.d.ts +7 -1
  19. package/dist/tools/rank.js +5 -7
  20. package/dist/tools/resolves.d.ts +14 -1
  21. package/dist/tools/resolves.js +121 -38
  22. package/dist/tools/results/envelope.d.ts +2 -1
  23. package/dist/tools/results/envelope.js +2 -2
  24. package/dist/tools/results/untrusted.js +2 -2
  25. package/dist/tools/schemas.d.ts +37 -72
  26. package/dist/tools/schemas.js +74 -108
  27. package/dist/tools/scope-guard.js +4 -2
  28. package/dist/tools/server-version.d.ts +1 -1
  29. package/dist/tools/server-version.js +1 -1
  30. package/dist/tools/server.d.ts +2 -2
  31. package/dist/tools/server.js +10 -9
  32. package/dist/tools/surface.js +1 -4
  33. package/dist/tools/write-guidance.d.ts +6 -1
  34. package/dist/tools/write-guidance.js +21 -2
  35. package/package.json +2 -2
  36. package/server.json +2 -2
  37. package/skills/company-360.md +12 -5
  38. package/skills/contract-profitability.md +3 -3
  39. package/skills/filter-semantics.md +4 -4
  40. package/skills/slim-reads.md +7 -7
  41. package/skills/unbilled-work-review.md +5 -5
package/README.md CHANGED
@@ -9,9 +9,9 @@ packaged playbooks, strong security boundaries, and predictable per-tenant in-pr
9
9
  client + SDK-owned TTL caches).
10
10
 
11
11
  > **Package status (2026-10-29):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.37.0`; the committed `pnpm-lock.yaml` pins that
12
+ > at `0.38.0`; the committed `pnpm-lock.yaml` pins that
13
13
  > release with its integrity hash, so installs resolve deterministically. The dependency is always npm
14
- > (node-autotask pinned exactly at `0.37.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.38.0`) — never a `file:`, `git:`, `link:` or workspace reference
15
15
  > to a local checkout.
16
16
 
17
17
  > **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
@@ -19,15 +19,16 @@ client + SDK-owned TTL caches).
19
19
 
20
20
  ## Tool surface
21
21
 
22
- The **core** toolset (12 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
23
- `finance` (or `all`, 19 tools); the operator profile adds two. The whole tool surface (21 tools) is
22
+ The **core** toolset (10 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
23
+ `finance` (or `all`, 17 tools); the operator profile adds two. The whole tool surface (19 tools) is
24
24
  listed below. Every other operation in the SDK's 1,281-operation registry stays
25
25
  reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
26
- its contract, `autotask_read` runs it.
26
+ its contract (slim by default: transport and retry detail dropped, long text cut, the first two
27
+ examples; `full: true` serves the complete record, `summary: true` about 2 KB), `autotask_read` runs it.
27
28
 
28
29
  | Toolset | Tools |
29
30
  |---------|-------|
30
- | core (always) | `autotask_guide` · `autotask_catalog` · `autotask_describe` · `autotask_read` · `autotask_resolve` · `autotask_query` · `autotask_aggregate` · `autotask_fetch_many` · `autotask_tenant_status` · `autotask_ticket_full_context` · `autotask_company_full_profile` · `autotask_resource_workload` |
31
+ | 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` |
31
32
  | `service` | `autotask_project_get_context` · `autotask_service_call_full_profile` |
32
33
  | `records` | `autotask_contact_full_profile` · `autotask_configuration_item_profile` · `autotask_contract_full_profile` |
33
34
  | `finance` | `autotask_quote_get` · `autotask_invoice_get` (`format`: `record`, `html`, `xml`, `pdf`) |
@@ -35,12 +36,30 @@ its contract, `autotask_read` runs it.
35
36
 
36
37
  - `autotask_resolve` turns any identifier into a record: an id or name, `by: "ticket_number"`,
37
38
  `"serial"` or `"email"`, in one `resource` or swept across the five workflow resources;
38
- `match: "contains"` for companies, tickets (title) or a sweep. `resource: "resources"` finds staff by
39
+ the default `match: "auto"` tries exact, then contains on a miss for companies, tickets (title) or a
40
+ sweep (one hit: `ok` with `matched: "contains"`; several: `ambiguous`), `match: "exact"` has no
41
+ fallback, `match: "contains"` searches only. `resource: "resources"` finds staff by
39
42
  email, user name or name (active only; API users, which carry their owner's email, are excluded unless
40
43
  `include_api_users: true`; `by: "email"` narrows to the email field; a numeric identifier is looked up
41
- as a Resource id); compact rows carry `apiUser` and the licence label when the tenant's labels tell it, never an email. Every match carries `ref`, its human reference. A sweep returns compact matches (`resource`, `ref`, `id`, the human fields) unless `expand: true` (the full record under `record`); `limit` caps the TOTAL matches across resources (resource order, then the SDK's order) and a contains sweep reports `truncated` and per-resource `counts {returned, found}`.
42
- - `autotask_query` is the bounded filtered list over `resource`: `tickets`, `time_entries`,
43
- `service_calls`, `contracts`, `time_off_requests` (limit default 50, max 2500). The served filter
44
+ as a Resource id); compact rows carry `apiUser` and the licence label when the tenant's labels tell it, never an email. Every match carries `ref`, its human reference, as safe text: control and markup characters (`< > { } [ ] \` " | \\`) dropped, whitespace collapsed, at most 80 characters (a cut ends with `…`), never wrapped; still vendor-authored data (#230). A sweep returns compact matches (`resource`, `ref`, `id`, the human fields) unless `expand: true` (the record under `record`, without its `[REDACTED]`, null and empty fields; `expand: "full"` keeps them); `limit` caps the TOTAL matches across resources (resource order, then the SDK's order) and a contains sweep reports `truncated` and per-resource `counts {returned, found}`.
45
+ - `autotask_query` is the bounded filtered list over `resource`: any resource with a
46
+ `<resource>.query` read (`tickets`, `time_entries`, `service_calls`, `contracts`,
47
+ `time_off_requests`, `companies`, `billing_items`, ...; limit default 50, max 2500). With
48
+ `group_by`, `sum` or `count: true` it answers totals instead of rows (the SDK's
49
+ `operations.aggregate`; `top` 1-100 groups, `limit` up to 2,500 scanned rows; `hasMore` /
50
+ `groupsTruncated` mean partial). Records by id: `filter: [{field: "id", op: "in", value: "1,2,3"}]`
51
+ (`in` / `notIn` take an array or a comma string). Time entries have no `companyID`: the relation
52
+ filter `{field: "ticket.companyID", op: "eq", value: "<id>"}` (also `task.<field>`; `project.<field>`
53
+ where the entity has the FK) scopes them through one parent read of at most 300 ids (more is a typed
54
+ refusal; no parent match is an empty result with a `relationNote`), in rows, counts and totals.
55
+ On a relation target itself (`ticket.<field>` on `tickets`, `task.<field>` on `tasks`,
56
+ `project.<field>` on `projects`) the prefix is dropped: the own field (0.38.1, #234). Rows on
57
+ `tickets` and `time_entries` without `include_fields` carry default columns (18 / 13,
58
+ `DEFAULT_COLUMNS` in `src/tools/lists.ts`; 0.38.1, #235): name any other field (`description`,
59
+ `resolution`, `internalNotes`) in `include_fields`. `output_path` and `autotask_read` of
60
+ `<resource>.query` keep the full row.
61
+ The separate `autotask_aggregate` and `autotask_fetch_many` tools are removed (#228, 0.37.0); a
62
+ mixed-resource batch by id is `autotask_read` `operations.fetchByIds`. The served filter
44
63
  schema declares each condition as `{field, op, value}` strings, so a client that builds one type
45
64
  per array column writes `"29"`, `"true"`, `"2026-10-22"`; numbers, `in` arrays and and/or groups are
46
65
  still accepted and passed through unchanged.
@@ -51,11 +70,10 @@ its contract, `autotask_read` runs it.
51
70
  fields, because form-filling clients (Copilot Studio) fill every optional input they are shown. A
52
71
  client that cannot send a mixed-type array asks for a time entry window over a resource with
53
72
  `autotask_query` `time_entries` (`resourceID` eq, `dateWorked` gte/lte, every value a string: the
54
- served filter schema types `value` as a string). `autotask_read` and `autotask_fetch_many` answer ONE envelope (the next minor,
73
+ served filter schema types `value` as a string). `autotask_read` answers ONE envelope (the next minor,
55
74
  breaking): `data = { results: Slot[], ok, failed, budget?, budgetWarning? }`, an array in input
56
75
  order even for a single read (`data.results[0].result`). A slot is `{ ok: true, operation, result,
57
- windowNote?, scopeNote? }` or `{ ok: false, operation, error }`; fetch_many slots also echo `resource` and `id`
58
- (`operation` is `<resource>.get`; a miss is `ok: false` with `NOT_FOUND`). A single read that fails
76
+ windowNote?, scopeNote? }` or `{ ok: false, operation, error }`. A single read that fails
59
77
  still answers the whole-call error envelope.
60
78
  - `autotask_guide` serves the packaged playbooks — the server's skill layer (below).
61
79
 
@@ -96,7 +114,7 @@ part miss never fails the call (typed `absent`); only a missing root record fail
96
114
  (`src/tools/reasons.ts`). Vendor free text is wrapped in `<untrusted_content>`,
97
115
  stripped of every control (except newline and tab), format and default-ignorable character (bidi,
98
116
  zero-width, tag characters, variation selectors) and capped at 500 characters
99
- (`truncated: true` on the envelope); the wrapper is the injection control — the angle-bracket
117
+ (`textCapped: true` on the envelope; row cuts are `hasMore`); the wrapper is the injection control — the angle-bracket
100
118
  escape earlier builds added was removed in 0.9.0 (issue #70): it was pure cost for LLM consumers
101
119
  and broke read-modify-write round-trips, so wrapped text carries raw `&`/`<`/`>` and is data,
102
120
  never instructions. User-defined field values get the same treatment; `output_path` files keep the
@@ -336,18 +354,15 @@ decoded bytes for `pdf`) under the same root rules — never a JSON envelope, ne
336
354
  to the exact vendor markup); a `pdf` past the vendor 1 MiB inline limit is truncated and carries no
337
355
  data (and no file).
338
356
 
339
- **`enrich` resolves FK ids to names in the same call** (issue #67): `enrich: ['company',
340
- 'resource', 'assignedResource', 'ticket']` — the keys are per-resource (a key that does not apply to
341
- the resource is refused pre-dispatch, zero wire) — fetches the referenced records per key — a
342
- `fetchByIds` batch of 250 distinct ids, then one authority read per id the batch did not return
343
- (query-blind resources are read one by one) — and lands `<key>Name` in each row (`companyName`, the
344
- joined `firstName middleName lastName`, `ticketNumber`). A key with more than 500 distinct ids is
345
- skipped (its rows are null) and named in the summary; `include_fields` never blinds it — the server
346
- adds the keys' FK ids to the outgoing projection; a miss (an id the vendor does not resolve) is a
347
- `null` on that row only. There is no `contact` key in this release: the SDK's bulk id read refuses
348
- parent-scoped resources (`contacts` is one — its get needs (parentId, id)) before the request,
349
- after the query wire is spent; contact names stay reachable via `autotask_resolve` and the
350
- composite tools, and a scoped contact read is a follow-up.
357
+ **Labels come with the rows** (node-autotask 0.38.0, #227): query rows, `group_by` keys and
358
+ fetched records carry SDK label siblings by default: picklists as `<field>Label` (`statusLabel`),
359
+ ids as `<field>Name` (`companyIDName`, `contractIDName`, `resourceIDName`, `ticketIDName`, ...;
360
+ `assignedResourceName` stays as a deprecated alias for one release). Each name is capped at 200
361
+ characters and wrapped once as untrusted content; since node-autotask 0.39.0 the sibling key is always present, `null` when the id is null, 0 or unresolved (or the picklist value is null); a `names` note says when
362
+ the SDK's cold-read cap left something unlabelled; `names: false` turns labels off. The server-side
363
+ `enrich` parameter is removed (0.37.0): a call that still sends it is refused as an unknown input; read
364
+ the SDK label instead (`companyIDName` for the old `companyName`, `resourceIDName` for `resourceName`,
365
+ `ticketIDName` for `ticketName`).
351
366
 
352
367
  ## Transports
353
368
 
@@ -763,7 +778,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
763
778
 
764
779
  ## SDK dependency
765
780
 
766
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.37.0`** (an exact pin —
781
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.38.0`** (an exact pin —
767
782
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
768
783
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
769
784
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
package/capabilities.json CHANGED
@@ -3,10 +3,10 @@
3
3
  "domain": "autotask",
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
- "readonlyCore": 12,
7
- "readonlyAll": 19,
8
- "operatorCore": 14,
9
- "operatorAll": 21
6
+ "readonlyCore": 10,
7
+ "readonlyAll": 17,
8
+ "operatorCore": 12,
9
+ "operatorAll": 19
10
10
  },
11
11
  "profiles": {
12
12
  "readonly": {
@@ -24,8 +24,6 @@
24
24
  "autotask_read",
25
25
  "autotask_resolve",
26
26
  "autotask_query",
27
- "autotask_aggregate",
28
- "autotask_fetch_many",
29
27
  "autotask_tenant_status",
30
28
  "autotask_ticket_full_context",
31
29
  "autotask_company_full_profile",
@@ -101,7 +99,7 @@
101
99
  },
102
100
  {
103
101
  "id": "autotask.generic.read",
104
- "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); roll-ups: autotask_aggregate; slim: includeFields, noteChars.",
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.",
105
103
  "mcpPrimitive": "tool",
106
104
  "toolName": "autotask_read",
107
105
  "type": "read",
@@ -117,7 +115,7 @@
117
115
  },
118
116
  {
119
117
  "id": "autotask.discovery.resolve",
120
- "description": "Find the record behind an identifier: id or name, ticket number, CI serial, contact email (by), staff by email, user name or name (resource resources, include_api_users); one resource or a sweep. Exact by default; contains: companies, tickets, sweep.",
118
+ "description": "Find the record for an identifier: id or name, ticket number, CI serial, contact email (by), staff by email, user name or name (resource resources, include_api_users); one resource or a sweep. Exact, then contains on a miss.",
121
119
  "mcpPrimitive": "tool",
122
120
  "toolName": "autotask_resolve",
123
121
  "type": "read",
@@ -133,45 +131,13 @@
133
131
  },
134
132
  {
135
133
  "id": "autotask.discovery.query",
136
- "description": "List records with a filter: tickets, time entries, service calls, contracts or time-off requests. Scope by company/contract ids (unscoped = tenant walk); roll-ups: autotask_aggregate. default 50, max 2500; latest N. Slim: include_fields, note_chars.",
134
+ "description": "List records or totals with a filter: tickets, time entries, service calls, contracts, time off, more. Totals, no rows: group_by, sum, count (hours logged by ticket, how many match). By id: id in. Scope by company ids (unscoped = tenant walk).",
137
135
  "mcpPrimitive": "tool",
138
136
  "toolName": "autotask_query",
139
137
  "type": "read",
140
138
  "toolset": "core",
141
139
  "profile": "readonly",
142
- "backingOperation": "tickets.query | time_entries.query | service_calls.query | contracts.query | time_off_requests.query",
143
- "annotations": {
144
- "readOnly": true,
145
- "idempotent": true,
146
- "destructive": false,
147
- "openWorld": true
148
- }
149
- },
150
- {
151
- "id": "autotask.core.aggregate",
152
- "description": "Totals, counts and group-by sums (hours by ticket) over any <resource>.query table without reading rows. No groupBy or sum = one count call. hasMore or groupsTruncated = a partial answer, never a tenant total.",
153
- "mcpPrimitive": "tool",
154
- "toolName": "autotask_aggregate",
155
- "type": "read",
156
- "toolset": "core",
157
- "profile": "readonly",
158
- "backingOperation": "operations.aggregate",
159
- "annotations": {
160
- "readOnly": true,
161
- "idempotent": true,
162
- "destructive": false,
163
- "openWorld": true
164
- }
165
- },
166
- {
167
- "id": "autotask.discovery.fetch_many",
168
- "description": "Fetch up to 500 records by numeric id across mixed resources in one call: items: [{resource, id, fields?}]. Answers data.results[{ok, operation, result|error}], ok, failed (same as autotask_read); a miss is a typed NOT_FOUND slot.",
169
- "mcpPrimitive": "tool",
170
- "toolName": "autotask_fetch_many",
171
- "type": "read",
172
- "toolset": "core",
173
- "profile": "readonly",
174
- "backingOperation": "operations.fetchByIds",
140
+ "backingOperation": "tickets.query | time_entries.query | service_calls.query | contracts.query | time_off_requests.query | operations.aggregate",
175
141
  "annotations": {
176
142
  "readOnly": true,
177
143
  "idempotent": true,
@@ -20,7 +20,7 @@ import type { InvokeOptions } from 'node-autotask/operations';
20
20
  import type { HourlyBudget, QueryWalkParams, QueryWalkResult, ToolClient, ToolOps } from '../tools/context.js';
21
21
  /** One recorded ops invocation (the handler-driven vendor-call view). */
22
22
  export interface OpCall {
23
- method: 'invokeRead' | 'invokeWrite' | 'invokeDelete' | 'resolveAny' | 'searchAcrossResources' | 'fetchMany' | 'fetchByIds' | 'relationshipMap' | 'queryWalk' | 'hourlyBudget';
23
+ method: 'invokeRead' | 'invokeWrite' | 'invokeDelete' | 'resolveAny' | 'searchAcrossResources' | 'relationshipMap' | 'queryWalk' | 'hourlyBudget';
24
24
  client: ToolClient;
25
25
  /** The operation key (invoke*) or the helper's primary argument. */
26
26
  target: string;
@@ -35,7 +35,7 @@ export type FixtureBehaviour = {
35
35
  kind: 'error';
36
36
  error: () => unknown;
37
37
  }
38
- /** The helper-shaped outcomes (resolveAny / searchAcrossResources / fetchMany). */
38
+ /** The helper-shaped outcomes (resolveAny / searchAcrossResources). */
39
39
  | {
40
40
  kind: 'helper';
41
41
  value: () => unknown;
@@ -83,20 +83,6 @@ export declare class MockToolOps implements ToolOps {
83
83
  resources?: readonly string[];
84
84
  limit?: number;
85
85
  }, signal?: AbortSignal) => Promise<unknown>;
86
- fetchMany: (client: ToolClient, items: readonly Readonly<{
87
- resource: string;
88
- id: number;
89
- fields?: string[];
90
- }>[], opts?: {
91
- maxItems?: number;
92
- }, signal?: AbortSignal) => Promise<unknown>;
93
- fetchByIds: (client: ToolClient, items: readonly Readonly<{
94
- resource: string;
95
- id: number | string;
96
- fields?: string[];
97
- }>[], opts?: {
98
- maxItems?: number;
99
- }, signal?: AbortSignal) => Promise<unknown>;
100
86
  relationshipMap: (client: ToolClient, resourceKey: string) => Promise<unknown>;
101
87
  /** The `output_path` walk (target = the query operation key). */
102
88
  queryWalk: (client: ToolClient, operation: string, params: QueryWalkParams, maxRows: number, signal?: AbortSignal) => Promise<QueryWalkResult | undefined>;
@@ -108,8 +108,6 @@ export class MockToolOps {
108
108
  invokeDelete = (client, operation, args, opts, signal) => this.answer(client, 'invokeDelete', operation, args, opts, signal);
109
109
  resolveAny = (client, identifier, opts, signal) => this.answer(client, 'resolveAny', 'resolveAny', [identifier, opts?.resources], undefined, signal);
110
110
  searchAcrossResources = (client, query, opts, signal) => this.answer(client, 'searchAcrossResources', 'searchAcrossResources', [query, opts?.resources, opts?.limit], undefined, signal);
111
- fetchMany = (client, items, opts, signal) => this.answer(client, 'fetchMany', 'fetchMany', [items, opts?.maxItems], undefined, signal);
112
- fetchByIds = (client, items, opts, signal) => this.answer(client, 'fetchByIds', 'fetchByIds', [items, opts?.maxItems], undefined, signal);
113
111
  relationshipMap = (client, resourceKey) => this.answer(client, 'relationshipMap', 'relationshipMap', [resourceKey], undefined, undefined);
114
112
  /** The `output_path` walk (target = the query operation key). */
115
113
  queryWalk = (client, operation, params, maxRows, signal) => this.answer(client, 'queryWalk', operation, [params, maxRows], undefined, signal);
@@ -1,4 +1,16 @@
1
1
  import type { ToolContext } from './context.js';
2
- import type { BuiltTool } from './surface.js';
3
- /** `autotask_aggregate`: counts, totals and group-by sums over one `<resource>.query` surface. */
4
- export declare function buildAggregate(ctx: ToolContext): BuiltTool;
2
+ import { type ToolReturn } from './results.js';
3
+ /** The SDK aggregate opts this server passes (the SDK's own names). */
4
+ export interface AggregateOpts {
5
+ groupBy?: string[];
6
+ sum?: string[];
7
+ top?: number;
8
+ limit?: number;
9
+ /** false: no label siblings on the group keys (SDK default on). */
10
+ names?: boolean;
11
+ }
12
+ /**
13
+ * Counts, totals and group-by sums over one `<resource>.query` surface: `autotask_query` with
14
+ * group_by / sum / count (#228). `extra` rides the data.
15
+ */
16
+ export declare function runAggregate(ctx: ToolContext, operation: string, rawFilter: unknown, opts: AggregateOpts, signal: AbortSignal | undefined, extra?: Record<string, unknown>): Promise<ToolReturn>;
@@ -1,21 +1,22 @@
1
1
  /**
2
- * tools/aggregate.ts — `autotask_aggregate` (#197): a thin wrapper over the SDK's
3
- * `operations.aggregate`, served in core so a roll-up question (totals, counts, group-by sums) is one
4
- * call to a tool the model sees, not a ~50 KB discovery trip through catalog, describe and read.
2
+ * tools/aggregate.ts — the totals mode of `autotask_query` (`group_by` / `sum` / `count`; #197, #228):
3
+ * a thin wrapper over the SDK's `operations.aggregate`, so a roll-up question (totals, counts, group-by
4
+ * sums) is one call to a core tool, not a ~50 KB discovery trip through catalog, describe and read.
5
5
  *
6
6
  * Nothing is reimplemented: the SDK validates the operation, the field names and the caps (top 1-100,
7
7
  * limit at most 2,500), folds the rows and reports its own truncation honesty (`hasMore`,
8
- * `groupsTruncated`, `totalScanned`, `blind`); the tool passes the answer through unchanged. What the
9
- * tool adds is the gates every read passes (the deployment policy on both the helper and the target
10
- * surface, the inbound untrusted-wrapper strip, the filter shape) and the untrusted marking of the
11
- * answer by the TARGET resource's field stamps, the same list `autotask_read` of `<resource>.query` uses.
8
+ * `groupsTruncated`, `totalScanned`, `blind`); the answer passes through unchanged. What this adds is
9
+ * the gates every read passes (the deployment policy on both the helper and the target surface, the
10
+ * inbound untrusted-wrapper strip, the filter shape) and the untrusted marking of the answer by the
11
+ * TARGET resource's field stamps, the same list `autotask_read` of `<resource>.query` uses.
12
12
  */
13
13
  import { AutotaskConfigError } from 'node-autotask';
14
14
  import { stripUntrustedDeep } from 'node-autotask/untrusted';
15
- import { AGGREGATE_INPUT, assertFilterShape, closedInput } from './schemas.js';
15
+ import { assertFilterShape } from './schemas.js';
16
16
  import { abortedAnswer } from './dispatch.js';
17
17
  import { markResult, ok } from './results.js';
18
18
  import { assertOperationPermitted } from '../policy.js';
19
+ import { scopedCatalogCall } from './field-pointer.js';
19
20
  /** The server line for an SDK aggregate answer: numbers only, and the partial marker whenever the SDK flags truncation. */
20
21
  function summaryLine(operation, result) {
21
22
  const r = (result ?? {});
@@ -28,32 +29,35 @@ function summaryLine(operation, result) {
28
29
  const blindNote = r.blindSurface !== undefined && r.blindSurface !== false ? ' blindSurface: the surface could not be read, an empty result is not a true zero.' : '';
29
30
  return `${operation}: ${scanned}${groups}.${blindNote}${partial ? ' PARTIAL: the scan or the group list was cut short (hasMore / groupsTruncated); these are not tenant-wide totals, narrow the filter.' : ''}`;
30
31
  }
31
- /** `autotask_aggregate`: counts, totals and group-by sums over one `<resource>.query` surface. */
32
- export function buildAggregate(ctx) {
33
- return {
34
- title: 'Total, count or group-by sum over a query surface',
35
- input: closedInput(AGGREGATE_INPUT),
36
- run: async (raw, signal) => {
37
- const args = raw;
38
- const helper = 'operations.aggregate';
39
- assertOperationPermitted(helper, ctx.record(helper), ctx.config, ctx.log);
40
- const target = ctx.record(args.operation);
41
- if (target === undefined) {
42
- throw new AutotaskConfigError(`aggregate: unknown operation "${args.operation}": it is not in the capability registry (find the exact key with autotask_catalog)`, { operation: helper });
43
- }
44
- assertOperationPermitted(args.operation, target, ctx.config, ctx.log);
45
- assertFilterShape(args.filter, helper);
46
- const filter = stripUntrustedDeep(args.filter);
47
- // The SDK's positional form: [operation, filter] and the opts bag only when a fold or cap was set.
48
- const opts = {};
49
- for (const key of ['groupBy', 'sum', 'top', 'limit'])
50
- if (args[key] !== undefined)
51
- opts[key] = args[key];
52
- const answer = await ctx.ops.invokeRead(ctx.client, helper, Object.keys(opts).length > 0 ? [args.operation, filter, opts] : [args.operation, filter], undefined, signal);
53
- if (answer === undefined)
54
- return abortedAnswer(ctx.correlationId);
55
- return ok(summaryLine(args.operation, answer), { operation: args.operation, result: markResult(answer, ctx.untrustedFields(args.operation)) });
56
- },
57
- };
32
+ /**
33
+ * Counts, totals and group-by sums over one `<resource>.query` surface: `autotask_query` with
34
+ * group_by / sum / count (#228). `extra` rides the data.
35
+ */
36
+ export async function runAggregate(ctx, operation, rawFilter, opts, signal, extra = {}) {
37
+ const helper = 'operations.aggregate';
38
+ assertOperationPermitted(helper, ctx.record(helper), ctx.config, ctx.log);
39
+ const target = ctx.record(operation);
40
+ if (target === undefined) {
41
+ throw new AutotaskConfigError(`aggregate: unknown operation "${operation}": it is not in the capability registry (find the exact key with autotask_catalog)`, { operation: helper });
42
+ }
43
+ assertOperationPermitted(operation, target, ctx.config, ctx.log);
44
+ assertFilterShape(rawFilter, helper);
45
+ // the conditions array (a params bag's own filter when the caller sent the bag form)
46
+ const conditions = Array.isArray(rawFilter) ? rawFilter : (rawFilter.filter ?? []);
47
+ const filter = stripUntrustedDeep(conditions);
48
+ // The SDK's positional form: [operation, filter] and the opts bag only when a fold or cap was set.
49
+ const bag = {};
50
+ for (const key of ['groupBy', 'sum', 'top', 'limit', 'names'])
51
+ if (opts[key] !== undefined)
52
+ bag[key] = opts[key];
53
+ const answer = await ctx.ops.invokeRead(ctx.client, helper, Object.keys(bag).length > 0 ? [operation, filter, bag] : [operation, filter], undefined, signal).catch((err) => {
54
+ // node-autotask #280: a bad filter / groupBy / sum name is a zero-wire CONFIG_ERROR; point at the scoped field catalog
55
+ if (!(err instanceof AutotaskConfigError) || !err.message.includes('is not a field of'))
56
+ throw err;
57
+ throw new AutotaskConfigError(err.message, { operation: helper, suggestedAction: `Use exact vendor field names: ${scopedCatalogCall(operation.slice(0, operation.indexOf('.')))}.` });
58
+ });
59
+ if (answer === undefined)
60
+ return abortedAnswer(ctx.correlationId);
61
+ return ok(summaryLine(operation, answer), { ...extra, operation, result: markResult(answer, ctx.untrustedFields(operation)) });
58
62
  }
59
63
  //# sourceMappingURL=aggregate.js.map
@@ -9,7 +9,6 @@ const CAPABILITY_IDS = {
9
9
  autotask_read: 'autotask.generic.read',
10
10
  autotask_resolve: 'autotask.discovery.resolve',
11
11
  autotask_query: 'autotask.discovery.query',
12
- autotask_fetch_many: 'autotask.discovery.fetch_many',
13
12
  autotask_tenant_status: 'autotask.identity.tenant_status',
14
13
  autotask_ticket_full_context: 'autotask.ticket.full_context',
15
14
  autotask_company_full_profile: 'autotask.company.full_profile',
@@ -19,6 +19,11 @@ export interface CatalogRow {
19
19
  reach: string | null;
20
20
  reachReason: string | null;
21
21
  boundedAlternative: string | null;
22
+ /** node-autotask 0.38.0 (#277): a repeated date-window helper, left out of default listings (still callable by key). */
23
+ hidden?: boolean;
24
+ hiddenReason?: string;
25
+ /** node-autotask 0.38.0: superseded (time_entries.recentTickets). */
26
+ deprecated?: boolean;
22
27
  }
23
28
  export interface MetaToolSpec {
24
29
  name: string;
@@ -56,6 +61,8 @@ export declare function recordById(operation: string): CapabilityRecord | undefi
56
61
  * name the deployment does not serve.
57
62
  */
58
63
  export declare const TOOL_NAME_BY_OP: Readonly<Record<string, string>>;
64
+ /** The tool that serves one registry key: the table above, else `autotask_query` for any `<resource>.query` read (#228). */
65
+ export declare function toolForOp(operation: string): string | undefined;
59
66
  /** The served tool for this row's operation in a deployment serving `served`, or `null`. */
60
67
  export declare function servedToolName(row: CatalogRow, served: ReadonlySet<string>): string | null;
61
68
  /** The registry keys projected onto an MCP tool name (the reverse of {@link TOOL_NAME_BY_OP}); empty when none. */
@@ -109,7 +116,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
109
116
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
110
117
  * describes the SDK behind it.
111
118
  */
112
- export declare const BUILT_AGAINST_PLAN_HASH = "5cd6c6679384651e7f67a0135915bcc031ef542640d9ebb6992b729ca42efe37";
119
+ export declare const BUILT_AGAINST_PLAN_HASH = "9bb87de7bcbda60f6c44955dba52b8e61e9c5547750ea8acacfcb6f261d7734f";
113
120
  /**
114
121
  * Fail fast when the installed SDK's plan has moved out from under this surface.
115
122
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -51,7 +51,6 @@ export const TOOL_NAME_BY_OP = {
51
51
  'threshold_information.get': 'autotask_tenant_status',
52
52
  'operations.resolveAny': 'autotask_resolve',
53
53
  'operations.searchAcrossResources': 'autotask_resolve',
54
- 'operations.fetchByIds': 'autotask_fetch_many',
55
54
  'operations.relationshipMap': 'autotask_describe',
56
55
  'companies.resolve': 'autotask_resolve',
57
56
  'companies.search': 'autotask_resolve',
@@ -67,7 +66,7 @@ export const TOOL_NAME_BY_OP = {
67
66
  'service_calls.query': 'autotask_query',
68
67
  'contracts.query': 'autotask_query',
69
68
  'time_off_requests.query': 'autotask_query',
70
- 'operations.aggregate': 'autotask_aggregate',
69
+ 'operations.aggregate': 'autotask_query',
71
70
  'tickets.fullContext': 'autotask_ticket_full_context',
72
71
  'companies.fullProfile': 'autotask_company_full_profile',
73
72
  'projects.getContext': 'autotask_project_get_context',
@@ -82,9 +81,13 @@ export const TOOL_NAME_BY_OP = {
82
81
  'invoices.markupXml': 'autotask_invoice_get',
83
82
  'invoices.pdf': 'autotask_invoice_get',
84
83
  };
84
+ /** The tool that serves one registry key: the table above, else `autotask_query` for any `<resource>.query` read (#228). */
85
+ export function toolForOp(operation) {
86
+ return TOOL_NAME_BY_OP[operation] ?? (operation.endsWith('.query') && recordById(operation)?.effect === 'read' ? 'autotask_query' : undefined);
87
+ }
85
88
  /** The served tool for this row's operation in a deployment serving `served`, or `null`. */
86
89
  export function servedToolName(row, served) {
87
- const tool = TOOL_NAME_BY_OP[row.operation] ?? (row.effect === 'read' ? undefined : 'autotask_write');
90
+ const tool = toolForOp(row.operation) ?? (row.effect === 'read' ? undefined : 'autotask_write');
88
91
  return tool !== undefined && served.has(tool) ? tool : null;
89
92
  }
90
93
  /** The registry keys projected onto an MCP tool name (the reverse of {@link TOOL_NAME_BY_OP}); empty when none. */
@@ -119,11 +122,9 @@ export const TOOL_DESCRIPTIONS = {
119
122
  autotask_guide: 'Step-by-step playbooks for multi-step Autotask tasks. No args: the index; {skill} returns one playbook, {skill, section} one section. Static text, no vendor call. Use it first when a task needs several tools.',
120
123
  autotask_catalog: '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.',
121
124
  autotask_describe: 'One operation\'s (registry key or tool name) exact input/output contract, examples, errors, policy verdict and playbooks (operation), or one resource\'s relationship map: parent, children, lookup keys (resource). No vendor call.',
122
- autotask_read: '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); roll-ups: autotask_aggregate; slim: includeFields, noteChars.',
123
- autotask_resolve: 'Find the record behind an identifier: id or name, ticket number, CI serial, contact email (by), staff by email, user name or name (resource resources, include_api_users); one resource or a sweep. Exact by default; contains: companies, tickets, sweep.',
124
- autotask_query: 'List records with a filter: tickets, time entries, service calls, contracts or time-off requests. Scope by company/contract ids (unscoped = tenant walk); roll-ups: autotask_aggregate. default 50, max 2500; latest N. Slim: include_fields, note_chars.',
125
- autotask_aggregate: 'Totals, counts and group-by sums (hours by ticket) over any <resource>.query table without reading rows. No groupBy or sum = one count call. hasMore or groupsTruncated = a partial answer, never a tenant total.',
126
- autotask_fetch_many: 'Fetch up to 500 records by numeric id across mixed resources in one call: items: [{resource, id, fields?}]. Answers data.results[{ok, operation, result|error}], ok, failed (same as autotask_read); a miss is a typed NOT_FOUND slot.',
125
+ autotask_read: '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.',
126
+ autotask_resolve: 'Find the record for an identifier: id or name, ticket number, CI serial, contact email (by), staff by email, user name or name (resource resources, include_api_users); one resource or a sweep. Exact, then contains on a miss.',
127
+ autotask_query: 'List records or totals with a filter: tickets, time entries, service calls, contracts, time off, more. Totals, no rows: group_by, sum, count (hours logged by ticket, how many match). By id: id in. Scope by company ids (unscoped = tenant walk).',
127
128
  autotask_tenant_status: 'Identity (API user + impersonated user), Autotask API version, this server\'s package/SDK version and profile (server), request thresholds, and the time zone of the people asking, with local and UTC time (time). Use it first to check credentials.',
128
129
  autotask_ticket_full_context: 'One ticket with its company, contact, assigned resource, project, notes, time entries and attachment metadata. Missing parts degrade to typed absent; a missing ticket is not found.',
129
130
  autotask_company_full_profile: '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.',
@@ -173,7 +174,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
173
174
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
174
175
  * describes the SDK behind it.
175
176
  */
176
- export const BUILT_AGAINST_PLAN_HASH = '5cd6c6679384651e7f67a0135915bcc031ef542640d9ebb6992b729ca42efe37';
177
+ export const BUILT_AGAINST_PLAN_HASH = '9bb87de7bcbda60f6c44955dba52b8e61e9c5547750ea8acacfcb6f261d7734f';
177
178
  /**
178
179
  * Fail fast when the installed SDK's plan has moved out from under this surface.
179
180
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -1,7 +1,7 @@
1
1
  /** The default tool ops: the bounded query walk and the abort-signal wrapper (issue #91). */
2
2
  import { AutotaskClient, camelKey } from 'node-autotask';
3
3
  import { AutotaskConfigError } from 'node-autotask';
4
- import { fetchByIds, fetchMany, invokeDelete, invokeRead, invokeWrite, relationshipMap, resolveAny, searchAcrossResources } from 'node-autotask/operations';
4
+ import { invokeDelete, invokeRead, invokeWrite, relationshipMap, resolveAny, searchAcrossResources } from 'node-autotask/operations';
5
5
  /** The real SDK bindings (the production default; tests substitute a fixture-backed bundle). */
6
6
  /**
7
7
  * The SDK operations bundle (the production default; tests substitute a fixture-backed bundle).
@@ -17,8 +17,6 @@ export const defaultToolOps = {
17
17
  invokeDelete: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeDelete(client, operation, args, opts ?? {})),
18
18
  resolveAny: (client, identifier, opts, signal) => withSignal(client, signal, () => resolveAny(client, identifier, opts ?? {})),
19
19
  searchAcrossResources: (client, query, opts, signal) => withSignal(client, signal, () => searchAcrossResources(client, query, opts ?? {})),
20
- fetchMany: (client, items, opts, signal) => withSignal(client, signal, () => fetchMany(client, items, opts ?? {})),
21
- fetchByIds: (client, items, opts, signal) => withSignal(client, signal, () => fetchByIds(client, items, opts ?? {})),
22
20
  relationshipMap: (client, resourceKey) => relationshipMap(client, resourceKey),
23
21
  hourlyBudget: (client) => Promise.resolve(client.hourlyBudget()),
24
22
  queryWalk: (client, operation, params, maxRows, signal) => withSignal(client, signal, () => walkQuery(client, operation, params, maxRows)),
@@ -45,6 +43,8 @@ export async function walkQuery(client, operation, params, maxRows) {
45
43
  bag.includeFields = params.includeFields;
46
44
  if (params.via !== undefined)
47
45
  bag.via = params.via;
46
+ if (params.names !== undefined)
47
+ bag.names = params.names;
48
48
  const items = [];
49
49
  for await (const item of holder.query(bag)) {
50
50
  if (items.length >= maxRows)
@@ -24,6 +24,8 @@ export interface QueryWalkParams {
24
24
  filter?: unknown;
25
25
  includeFields?: readonly string[];
26
26
  via?: 'post' | 'get';
27
+ /** `false` skips the SDK's default row labels (as on the list path). */
28
+ names?: boolean;
27
29
  }
28
30
  /** One walked query result: the items and whether the row cap stopped the walk. */
29
31
  export interface QueryWalkResult {
@@ -44,20 +46,6 @@ export interface ToolOps {
44
46
  resources?: readonly string[];
45
47
  limit?: number;
46
48
  }, signal?: AbortSignal): Promise<unknown>;
47
- fetchMany(client: ToolClient, items: readonly Readonly<{
48
- resource: string;
49
- id: number;
50
- fields?: string[];
51
- }>[], opts?: {
52
- maxItems?: number;
53
- }, signal?: AbortSignal): Promise<unknown>;
54
- fetchByIds(client: ToolClient, items: readonly Readonly<{
55
- resource: string;
56
- id: number | string;
57
- fields?: string[];
58
- }>[], opts?: {
59
- maxItems?: number;
60
- }, signal?: AbortSignal): Promise<unknown>;
61
49
  /** Zero wire by construction — no signal needed. */
62
50
  relationshipMap(client: ToolClient, resourceKey: string): Promise<unknown>;
63
51
  /**
@@ -56,6 +56,14 @@ export declare function abortedAnswer(corr: string): ToolReturn;
56
56
  * in a 4xx body; the error wrap value-scrubs them before the envelope crosses.
57
57
  */
58
58
  export declare function requestSecrets(ctx: ToolContext): string[];
59
+ /**
60
+ * The default `maxBytes` of a served fieldCatalog result (node-autotask 0.39.0 #282). The cap bounds the
61
+ * SDK result; the MCP message is about 3x it: the field names are wrapped as untrusted content (measured
62
+ * 1.4x on a five-resource compact catalog), and the envelope crosses twice (structuredContent plus its
63
+ * escaped JSON copy in the text block, 2.1x). 30,000 keeps the message near 90 KB, under ~96 KB; 65,536
64
+ * would serve about 190 KB. A caller that needs more passes its own maxBytes.
65
+ */
66
+ export declare const FIELD_CATALOG_MAX_BYTES = 30000;
59
67
  /**
60
68
  * A vendor TimeEntries continuation URL (#224; mirrors the SDK's cursorToPath + entryCursorPath, tighter):
61
69
  * printable ASCII with no whitespace, absolute and already in canonical form, on this request's own Autotask origin, no userinfo, no