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.
- package/README.md +42 -27
- package/capabilities.json +8 -42
- package/dist/mocks/fixture-ops.d.ts +2 -16
- package/dist/mocks/fixture-ops.js +0 -2
- package/dist/tools/aggregate.d.ts +15 -3
- package/dist/tools/aggregate.js +39 -35
- package/dist/tools/capabilities-manifest.js +0 -1
- package/dist/tools/catalog-data.d.ts +8 -1
- package/dist/tools/catalog-data.js +10 -9
- package/dist/tools/context/ops.js +3 -3
- package/dist/tools/context/types.d.ts +2 -14
- package/dist/tools/dispatch.d.ts +8 -0
- package/dist/tools/dispatch.js +39 -19
- package/dist/tools/lists.d.ts +6 -0
- package/dist/tools/lists.js +52 -169
- package/dist/tools/meta.d.ts +0 -2
- package/dist/tools/meta.js +67 -58
- package/dist/tools/rank.d.ts +7 -1
- package/dist/tools/rank.js +5 -7
- package/dist/tools/resolves.d.ts +14 -1
- package/dist/tools/resolves.js +121 -38
- package/dist/tools/results/envelope.d.ts +2 -1
- package/dist/tools/results/envelope.js +2 -2
- package/dist/tools/results/untrusted.js +2 -2
- package/dist/tools/schemas.d.ts +37 -72
- package/dist/tools/schemas.js +74 -108
- package/dist/tools/scope-guard.js +4 -2
- package/dist/tools/server-version.d.ts +1 -1
- package/dist/tools/server-version.js +1 -1
- package/dist/tools/server.d.ts +2 -2
- package/dist/tools/server.js +10 -9
- package/dist/tools/surface.js +1 -4
- package/dist/tools/write-guidance.d.ts +6 -1
- package/dist/tools/write-guidance.js +21 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/company-360.md +12 -5
- package/skills/contract-profitability.md +3 -3
- package/skills/filter-semantics.md +4 -4
- package/skills/slim-reads.md +7 -7
- 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.
|
|
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.
|
|
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 (
|
|
23
|
-
`finance` (or `all`,
|
|
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,
|
|
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` · `
|
|
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: "
|
|
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
|
|
42
|
-
- `autotask_query` is the bounded filtered list over `resource`:
|
|
43
|
-
`
|
|
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`
|
|
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 }
|
|
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
|
-
(`
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
`
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`
|
|
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.
|
|
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":
|
|
7
|
-
"readonlyAll":
|
|
8
|
-
"operatorCore":
|
|
9
|
-
"operatorAll":
|
|
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);
|
|
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
|
|
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
|
|
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' | '
|
|
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
|
|
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
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
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>;
|
package/dist/tools/aggregate.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tools/aggregate.ts — `
|
|
3
|
-
* `operations.aggregate`,
|
|
4
|
-
* call to a tool
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 = "
|
|
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': '
|
|
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 =
|
|
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);
|
|
123
|
-
autotask_resolve: 'Find the record
|
|
124
|
-
autotask_query: 'List records with a filter: tickets, time entries, service calls, contracts
|
|
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 = '
|
|
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 {
|
|
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
|
/**
|
package/dist/tools/dispatch.d.ts
CHANGED
|
@@ -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
|