iit-mcp-autotask 0.4.1 → 0.4.3
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 +1 -1
- package/capabilities.json +2 -2
- package/dist/policy.js +7 -1
- package/dist/tools/catalog-data.d.ts +1 -1
- package/dist/tools/catalog-data.js +10 -10
- package/dist/tools/composites.js +36 -5
- package/dist/tools/dispatch.js +10 -4
- package/dist/tools/meta.js +1 -1
- package/dist/tools/resolves.js +61 -9
- package/dist/tools/schemas.d.ts +5 -1
- package/dist/tools/schemas.js +10 -4
- package/dist/tools/server.d.ts +2 -2
- package/dist/tools/server.js +5 -4
- package/package.json +2 -2
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ miss never fails the call (typed `absent`); only a missing root record fails the
|
|
|
32
32
|
| C — dedicated workflow (23) | `autotask_ticket_query` · `autotask_ticket_full_context` · `autotask_ticket_sla_health_check` · `autotask_ticket_find_by_ticket_number` · `autotask_company_full_profile` · `autotask_timeEntry_query` · `autotask_timeEntry_create` · `autotask_quote_get` · `autotask_invoice_get` · `autotask_invoice_markup` · `autotask_configurationItem_profile` · `autotask_configurationItems_find_by_serial` · `autotask_contact_full_profile` · `autotask_contact_find_by_email` · `autotask_project_get_context` · `autotask_serviceCall_query` · `autotask_serviceCall_full_profile` · `autotask_contract_full_profile` · `autotask_contract_query` · `autotask_resource_workload` · `autotask_timeOffRequest_query` · `autotask_company_unbilled_work` · `autotask_aggregate` (names verbatim from the SDK manifest where they exist — case inconsistency included) | Bounded filtered lists, one-call composites, natural-key lookups, the single dedicated write |
|
|
33
33
|
|
|
34
34
|
Machine-readable catalogue: [`capabilities.json`](capabilities.json) · server metadata: [`server.json`](server.json) ·
|
|
35
|
-
per-workflow mapping: [`docs/capability-matrix.md`](docs/capability-matrix.md) · full design: [`docs/architecture.md`](docs/architecture.md).
|
|
35
|
+
per-workflow mapping: [`docs/capability-matrix.md`](docs/capability-matrix.md) · full design: [`docs/architecture.md`](docs/architecture.md) · contract P&L manual recipe (the `autotask_contract_profitability` gap until it ships): [`docs/contract-economics.md`](docs/contract-economics.md).
|
|
36
36
|
|
|
37
37
|
### The 5-META generic layer
|
|
38
38
|
|
package/capabilities.json
CHANGED
|
@@ -539,12 +539,12 @@
|
|
|
539
539
|
},
|
|
540
540
|
{
|
|
541
541
|
"id": "autotask.contract.full_profile",
|
|
542
|
-
"description": "One contract + its services
|
|
542
|
+
"description": "One contract + its services + billing rules + recent charges (50 newest) + service units in one call: parts degrade to typed absent, never a failure; the contract itself missing → NOT_FOUND. Deeper machinery (bundles, adjustments, exclusions): autotask_read; contract list: autotask_contract_query.",
|
|
543
543
|
"mcpPrimitive": "tool",
|
|
544
544
|
"toolName": "autotask_contract_full_profile",
|
|
545
545
|
"type": "read",
|
|
546
546
|
"layer": "C",
|
|
547
|
-
"backingOperation": "contracts.get + contract_services.query + contract_billing_rules.query",
|
|
547
|
+
"backingOperation": "contracts.get + contract_services.query + contract_billing_rules.query + contract_charges.query + contract_service_units.query",
|
|
548
548
|
"annotations": {
|
|
549
549
|
"readOnly": true,
|
|
550
550
|
"idempotent": true,
|
package/dist/policy.js
CHANGED
|
@@ -49,7 +49,13 @@ export function secretReadsPermitted(config) {
|
|
|
49
49
|
export function policyAllows(operation, record, config) {
|
|
50
50
|
if (config.AUTOTASK_READ_ONLY || config.AUTOTASK_WRITE_POLICY === 'deny')
|
|
51
51
|
return record?.effect === 'read';
|
|
52
|
-
|
|
52
|
+
// An allow-list is a positive grant. An operation with no registry classification
|
|
53
|
+
// is not positively granted, so it remains denied instead of falling through to
|
|
54
|
+
// the generic unknown-operation error. `all` deliberately preserves its existing
|
|
55
|
+
// open surface; the dispatcher still rejects unknown operations before the SDK.
|
|
56
|
+
if (record === undefined)
|
|
57
|
+
return config.AUTOTASK_WRITE_POLICY !== 'allow_list';
|
|
58
|
+
if (!isWriteEffect(record))
|
|
53
59
|
return true;
|
|
54
60
|
switch (config.AUTOTASK_WRITE_POLICY) {
|
|
55
61
|
case 'allow_list':
|
|
@@ -144,7 +144,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
|
|
|
144
144
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
145
145
|
* describes the SDK behind it.
|
|
146
146
|
*/
|
|
147
|
-
export declare const BUILT_AGAINST_PLAN_HASH = "
|
|
147
|
+
export declare const BUILT_AGAINST_PLAN_HASH = "6547153895b65c577ef59ced6c06361d0763d1299d6048531b8ffa94f77821d3";
|
|
148
148
|
/**
|
|
149
149
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
150
150
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
|
@@ -250,15 +250,15 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
250
250
|
autotask_write: `Route any effect=write registry op by its EXACT key, dry-run-first (dry_run false applies — mutating request is never issued before then). The 3 approval-gated writes also need confirm === the op key (BOTH modes). Read/delete ops refused pre-request. Write-policy gated (POLICY_DENIED pre-request).`,
|
|
251
251
|
autotask_delete: `Route any effect=destructive registry op by its EXACT key, dry-run-first (dry_run false applies). confirm === the op key REQUIRED for every delete. Read/write ops refused. Write-policy gated (POLICY_DENIED). Many entities have no vendor delete (deletable: false) — a vendor limitation, not a refusal.`,
|
|
252
252
|
autotask_resolve_any: `Resolve a bare id or name against several resources at once (default the five workflow resources: companies, tickets, contacts, configuration items, projects) — when you have a value but not the resource. Read-only; a miss is reported per resource, never thrown; returns compact records.`,
|
|
253
|
-
autotask_search_across_resources: `Free-text search across several resources (default the five workflow resources) — find a record you cannot name. Bounded: limit default 25, max 500 per resource; resources without a search helper are skipped and reported, never errored; returns compact matches. Read-only.`,
|
|
254
|
-
autotask_ticket_resolve: `Resolve a Ticket from a numeric id (first) or an exact title
|
|
255
|
-
autotask_contact_resolve: `Resolve a Contact from a numeric id (tried first) or exact firstName + lastName
|
|
256
|
-
autotask_configurationItems_resolve: `Resolve a Configuration Item from a numeric id or exact referenceTitle
|
|
257
|
-
autotask_fetch_many: `Batch up to 500 fetches across MIXED resources: items [{ resource, id, fields? }] — numeric ids (string identifiers: resolve_any).
|
|
253
|
+
autotask_search_across_resources: `Free-text search across several resources (default the five workflow resources) — find a record you cannot name. Bounded: limit default 25, max 500 per resource; resources without a search helper are skipped and reported, never errored; returns compact matches with their human reference. Read-only.`,
|
|
254
|
+
autotask_ticket_resolve: `Resolve a Ticket from a numeric id (first) or an exact title; the record carries its ticketNumber — cite that, never the bare id. NOT tried: the ticket number (autotask_ticket_find_by_ticket_number) or a contains match (autotask_ticket_query). Read-only. A miss is a typed NOT_FOUND.`,
|
|
255
|
+
autotask_contact_resolve: `Resolve a Contact from a numeric id (tried first) or exact firstName + lastName; the record carries the name — cite that, never the bare id. NOT tried: an email (autotask_contact_find_by_email) or a partial match (autotask_read contacts.query). Read-only. Sensitive. A miss is a typed NOT_FOUND.`,
|
|
256
|
+
autotask_configurationItems_resolve: `Resolve a Configuration Item from a numeric id or exact referenceTitle; the record carries its referenceTitle — cite that, never the bare id. NOT tried: a serial (autotask_configurationItems_find_by_serial) or a partial match (autotask_read). Read-only. Sensitive. A miss is a typed NOT_FOUND.`,
|
|
257
|
+
autotask_fetch_many: `Batch up to 500 fetches across MIXED resources: items [{ resource, id, fields? }] — numeric ids (string identifiers: resolve_any). A miss is a typed error row in its slot. Parent-scoped children not fetchable; fields = exact vendor names; records carry their human reference; >500 → CONFIG_ERROR.`,
|
|
258
258
|
autotask_read_batch: `Up to 10 reads in one call: items [{ operation, args? }], the autotask_read shape, read-only. Per-item results (input order): ok + result, or the typed error in its slot — a refused/failed item never fails the batch. Over the remaining local budget -> budget + budgetWarning (warn only — still runs).`,
|
|
259
259
|
autotask_ticket_query: `Query Ticket records with a JSON filter. Zone quirk (ww29): count/query returns 0/empty even when data exists — verify presence with get(id), not count. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
|
|
260
260
|
autotask_ticket_full_context: `Full context of ONE ticket: ticket + company + contact + assigned resource + project (a missing part = typed absent, never a failure) + notes, time entries, attachment metadata. Long text truncated at textLimit (default 500). Missing → NOT_FOUND. SLA: autotask_ticket_sla_health_check.`,
|
|
261
|
-
autotask_ticket_sla_health_check: `The SLA health/results for
|
|
261
|
+
autotask_ticket_sla_health_check: `The SLA health/results for ONE ticket, by numeric id — not the ticket itself (the record: autotask_ticket_resolve; the full context: autotask_ticket_full_context). Report the ticketNumber, never the bare id.`,
|
|
262
262
|
autotask_ticket_find_by_ticket_number: `You have the human ticket number (the ticketNumber string, e.g. T20260101.12345), not the numeric id or title. Exact match; a miss is a typed NOT_FOUND. Returns the ticket record — its full context: autotask_ticket_full_context.`,
|
|
263
263
|
autotask_company_full_profile: `ONE company's full picture: company + locations + contacts + notes + open tickets + projects. openTickets: open = status != 5 (ww29 picklist lacks Closed); always filtered (ww29 tickets surface blind unfiltered) — empty = a TRUE zero. CIs: autotask_read configuration_items.query. Missing → NOT_FOUND`,
|
|
264
264
|
autotask_timeEntry_query: `Query TimeEntry records with a JSON filter — the MSP billing view. Period presets ride autotask_read time_entries.<helper>. Zone (ww29): getPosted/getUnposted 500 — use postedWithin. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
|
|
@@ -273,15 +273,15 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
273
273
|
autotask_project_get_context: `One project with its primary context: project + company + opportunity + lead resource. Project phase/task lists do NOT exist on this surface (no such vendor operations) — do not attempt them; charges/notes ride the generic layer (autotask_read). The project record alone: autotask_project_resolve.`,
|
|
274
274
|
autotask_serviceCall_query: `Query ServiceCall records with a JSON filter — the on-site visit queue. Full profile (linked tickets, tasks, assignments): autotask_serviceCall_full_profile. Assignment writes ride autotask_write. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
|
|
275
275
|
autotask_serviceCall_full_profile: `One service call + its linked tickets + its tasks (incl. resource assignments). Parts degrade to typed absent (no tasks → NO_TASKS on the assignments part); the service call itself missing → NOT_FOUND. Prefer this before on-site-visit work (assignment writes ride autotask_write).`,
|
|
276
|
-
autotask_contract_full_profile: `One contract + its services
|
|
276
|
+
autotask_contract_full_profile: `One contract + its services + billing rules + recent charges (50 newest) + service units in one call: parts degrade to typed absent, never a failure; the contract itself missing → NOT_FOUND. Deeper machinery (bundles, adjustments, exclusions): autotask_read; contract list: autotask_contract_query.`,
|
|
277
277
|
autotask_contract_query: `Query Contract records with a JSON filter — e.g. active contracts for a company (the SLA & recurring-service view). One contract with its services/units/billing rules: autotask_contract_full_profile. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
|
|
278
278
|
autotask_resource_workload: `One resource's availability picture: open assigned tickets + service-call assignments + time-off balances + pending time-off requests. Parts degrade to typed absent; resource missing → NOT_FOUND. Approve/reject: autotask_write. The request list: autotask_timeOffRequest_query. Sensitive.`,
|
|
279
279
|
autotask_timeOffRequest_query: `Query TimeOffRequest records with a JSON filter. Approve/reject ride autotask_write (time_off_requests.approve / .reject — dry-run-first; reject takes a reason). Availability: autotask_resource_workload. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
|
|
280
280
|
autotask_company_unbilled_work: `A company's unbilled work: posted billing items, the time behind them, per-ticket rollup, the unposted section, totals + hours + warnings. Arguments: id + closed bag (see the server instructions). A filtered empty part is a TRUE zero; shape problems → typed CONFIG_ERROR; missing company → NOT_FOUND.`,
|
|
281
281
|
autotask_aggregate: `Fold a query surface into groups/sums/totals or count it (the count endpoint). operation = an EXACT plan .query op (others refused); filter (grammar; empty array = unfiltered: a 0 on a blind surface is marked blind: true); group_by / sum / top / limit: server instructions. Read op; zero mutating.`,
|
|
282
282
|
autotask_resource_who_am_i: `Identify the authenticated integration by its Resource record (name, zone, tenant). Call first to verify credentials and scope before other work. No arguments; returns the compact ResourceSummary, or null when the configured user cannot be matched. Sensitive.`,
|
|
283
|
-
autotask_company_resolve: `Resolve a Company from a numeric id (tried first) or a companyName — exact by default, or fuzzy candidates via match:'contains' (+ include_family).
|
|
284
|
-
autotask_project_resolve: `Resolve a Project from a numeric id (
|
|
283
|
+
autotask_company_resolve: `Resolve a Company from a numeric id (tried first) or a companyName — exact by default, or fuzzy candidates via match:'contains' (+ include_family). The record carries companyName — cite that, never the bare id. Other forms (e.g. email) NOT tried; a miss is a typed NOT_FOUND. Read-only. Sensitive.`,
|
|
284
|
+
autotask_project_resolve: `Resolve a Project from a numeric id (first) or an exact projectName; the record carries its human projectNumber — cite that, never a bare id. Other forms are NOT tried; a miss is a typed NOT_FOUND. By number: autotask_read projects.query filter projectNumber eq. Read-only. Sensitive.`,
|
|
285
285
|
autotask_relationship_map: `ONE resource's relationship surface in a ZERO-wire call: parent, children, natural lookup keys, pinned blind surfaces, create required sets + quirks. Call BEFORE composing multi-resource workflows. Unknown key → typed RESOURCE_UNKNOWN.`,
|
|
286
286
|
autotask_threshold_information: `Request-threshold status: the TENANT-wide vendor count (model) plus the budget block — the LOCAL per-credential hourly window of this client (perHour, used, remaining; null with a budgetNote when the limiter is off). Read-only, no arguments; the budget read is zero wire.`,
|
|
287
287
|
};
|
|
@@ -339,7 +339,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
|
|
|
339
339
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
340
340
|
* describes the SDK behind it.
|
|
341
341
|
*/
|
|
342
|
-
export const BUILT_AGAINST_PLAN_HASH = '
|
|
342
|
+
export const BUILT_AGAINST_PLAN_HASH = '6547153895b65c577ef59ced6c06361d0763d1299d6048531b8ffa94f77821d3';
|
|
343
343
|
/**
|
|
344
344
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
345
345
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
package/dist/tools/composites.js
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Parts run as bounded fan-outs over ONE pooled tenant client: the client's own concurrency
|
|
15
15
|
* guard (the agent profile) bounds the in-flight vendor requests; the part list is small and
|
|
16
|
-
* static per tool.
|
|
16
|
+
* static per tool. Wire budget per call: the contract profile is the widest — 1 root read + 4
|
|
17
|
+
* parts in one parallel fan-out (contract_services.query / contract_billing_rules.query /
|
|
18
|
+
* contract_charges.query / contract_service_units.query, each limit 50 → one page), EXCEPT the
|
|
19
|
+
* charges part, which rides the SDK's count-anchored `latest` 50 (the vendor has no sort
|
|
20
|
+
* parameter; newest-first = id-newest, tracking the vendor's createDate/datePurchased) — 1 count
|
|
21
|
+
* + at most 6 page POSTs while the matching set is <= 2500 rows (the SDK's full-set mode); above
|
|
22
|
+
* that the SDK spends its own id-threshold count-probe budget before a <= 6-page walk, and a
|
|
23
|
+
* blind surface takes the plain bounded walk. Common case: at most 11 vendor requests per call.
|
|
17
24
|
*/
|
|
18
25
|
import { NotFoundError } from 'node-autotask';
|
|
19
26
|
import { TICKET_FULL_CONTEXT_INPUT, COMPANY_FULL_PROFILE_INPUT, CONFIGURATION_ITEM_PROFILE_INPUT, PROJECT_GET_CONTEXT_INPUT, QUOTE_GET_INPUT, INVOICE_GET_INPUT, INVOICE_MARKUP_INPUT, CONTACT_FULL_PROFILE_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, RESOURCE_WORKLOAD_INPUT, UNBILLED_WORK_INPUT, AGGREGATE_INPUT, COMPOSITE_OUTPUT, resultOutput, aggregateOutput, unbilledWorkOutput, closedInput, } from './schemas.js';
|
|
@@ -25,6 +32,11 @@ import { assertSecretReadPermitted, assertWritePermitted } from '../policy.js';
|
|
|
25
32
|
const TICKET_STATUS_CLOSED = 5;
|
|
26
33
|
/** The cap on a fan-out `in` filter (the task-id join between two parts). */
|
|
27
34
|
const IN_FILTER_CAP = 100;
|
|
35
|
+
/** The charges part's recent-N cap (issue #49): the N newest contract charges, newest first —
|
|
36
|
+
* served through the SDK's count-anchored `latest` (the vendor has no sort parameter). */
|
|
37
|
+
const CHARGES_RECENT_CAP = 50;
|
|
38
|
+
/** The units part's row cap (issue #49). */
|
|
39
|
+
const SERVICE_UNITS_CAP = 50;
|
|
28
40
|
/** Run one part read: a miss/fault degrades to `{ value: null, absent: code }`, never a throw. */
|
|
29
41
|
async function part(ctx, op, args, signal) {
|
|
30
42
|
try {
|
|
@@ -370,12 +382,13 @@ function registerServiceCallFullProfile(server, ctx) {
|
|
|
370
382
|
},
|
|
371
383
|
});
|
|
372
384
|
}
|
|
373
|
-
// ---- autotask_contract_full_profile: the contract, its services, its billing rules
|
|
385
|
+
// ---- autotask_contract_full_profile: the contract, its services, its billing rules,
|
|
386
|
+
// ---- its recent charges (issue #49) and its service units (issue #49) ----
|
|
374
387
|
function registerContractFullProfile(server, ctx) {
|
|
375
388
|
const contractInput = closedInput(CONTRACT_FULL_PROFILE_INPUT);
|
|
376
389
|
registerServerComposite(ctx, server, {
|
|
377
390
|
tool: 'autotask_contract_full_profile',
|
|
378
|
-
title: 'Get a contract with its services and
|
|
391
|
+
title: 'Get a contract with its services, billing rules, recent charges and service units',
|
|
379
392
|
description: ctx.description('autotask_contract_full_profile', 'contracts.get'),
|
|
380
393
|
input: contractInput,
|
|
381
394
|
rootOp: 'contracts.get',
|
|
@@ -383,16 +396,34 @@ function registerContractFullProfile(server, ctx) {
|
|
|
383
396
|
const id = args.id;
|
|
384
397
|
// The declared per-part limits (QUERY_LIMIT: 1-2500, default 50 — schema-validated at the
|
|
385
398
|
// protocol layer); each part reads its own declared argument, not one shared phantom key.
|
|
399
|
+
// The two issue #49 parts carry NO input knobs (the closed bag is unchanged): the charges
|
|
400
|
+
// part is the FIXED recent-N and the units part the fixed row cap.
|
|
386
401
|
const serviceLimit = args.service_limit ?? 50;
|
|
387
402
|
const billingRuleLimit = args.billing_rule_limit ?? 50;
|
|
388
403
|
const root = await c.ops.invokeRead(c.client, 'contracts.get', [id], undefined, signal);
|
|
389
404
|
if (root === undefined)
|
|
390
405
|
return { root: null, rootOp: 'contracts.get', parts: {} };
|
|
391
|
-
const [services, billingRules] = await Promise.all([
|
|
406
|
+
const [services, billingRules, charges, units] = await Promise.all([
|
|
392
407
|
part(c, 'contract_services.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: serviceLimit }], signal),
|
|
393
408
|
part(c, 'contract_billing_rules.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: billingRuleLimit }], signal),
|
|
409
|
+
// The recent charges (issue #49): the vendor has NO sort parameter — the SDK's
|
|
410
|
+
// count-anchored `latest` (the N highest-id rows, id-descending) is the only ordering
|
|
411
|
+
// channel; the id order tracks the vendor's createDate/datePurchased, so newest-first
|
|
412
|
+
// is id-newest-first. limit === latest is the legal coexistence (limit >= latest) and
|
|
413
|
+
// keeps the walk window explicit (1 count + at most 6 page POSTs).
|
|
414
|
+
part(c, 'contract_charges.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: CHARGES_RECENT_CAP, latest: CHARGES_RECENT_CAP }], signal),
|
|
415
|
+
part(c, 'contract_service_units.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: SERVICE_UNITS_CAP }], signal),
|
|
394
416
|
]);
|
|
395
|
-
return {
|
|
417
|
+
return {
|
|
418
|
+
root,
|
|
419
|
+
rootOp: 'contracts.get',
|
|
420
|
+
parts: {
|
|
421
|
+
services: ['contract_services.query', services],
|
|
422
|
+
billing_rules: ['contract_billing_rules.query', billingRules],
|
|
423
|
+
charges: ['contract_charges.query', charges],
|
|
424
|
+
units: ['contract_service_units.query', units],
|
|
425
|
+
},
|
|
426
|
+
};
|
|
396
427
|
},
|
|
397
428
|
});
|
|
398
429
|
}
|
package/dist/tools/dispatch.js
CHANGED
|
@@ -134,6 +134,11 @@ async function modeCall(ctx, mode, raw, signal) {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
const rec = ctx.record(args.operation);
|
|
137
|
+
// In an allow-listed deployment this is deliberately before the registry error:
|
|
138
|
+
// a newly named operation has no positive grant and receives the stable policy
|
|
139
|
+
// contract, without reaching an SDK or vendor dispatch. Other policies keep the
|
|
140
|
+
// existing unknown-operation diagnostic below.
|
|
141
|
+
assertWritePermitted(args.operation, rec, ctx.config, ctx.log);
|
|
137
142
|
if (rec === undefined) {
|
|
138
143
|
throw new AutotaskConfigError(`invoke: unknown operation "${args.operation}" — it is not in the capability registry (list the exact keys with autotask_catalog)`, { operation: args.operation });
|
|
139
144
|
}
|
|
@@ -141,7 +146,6 @@ async function modeCall(ctx, mode, raw, signal) {
|
|
|
141
146
|
throw crossModeRefusal(rec, args.operation, mode);
|
|
142
147
|
// This server's own policy gates — BEFORE any request is built (the SDK's governor runs
|
|
143
148
|
// inside the invoke; the policy is this deployment's authority, applied at the boundary):
|
|
144
|
-
assertWritePermitted(args.operation, rec, ctx.config, ctx.log);
|
|
145
149
|
assertSecretReadPermitted(args.operation, ctx.config, ctx.log);
|
|
146
150
|
// Issue #1: strip ONE exact outer <untrusted_content> wrapper from the string inputs before
|
|
147
151
|
// the call (a no-op for args that carry no wrapper; inner text untouched).
|
|
@@ -242,11 +246,13 @@ export function registerTimeEntryCreate(server, ctx) {
|
|
|
242
246
|
record = await ctx.ops.invokeRead(ctx.client, 'time_entries.get', [id], undefined, signal);
|
|
243
247
|
}
|
|
244
248
|
catch (err) {
|
|
245
|
-
// A FAILED read-back never turns a successful write into an error: the
|
|
246
|
-
//
|
|
249
|
+
// A FAILED read-back never turns a successful write into an error: the note carries
|
|
250
|
+
// the typed error's machine code only (no vendor body text) and NO bare numeric id —
|
|
251
|
+
// the created id rides `result` in structuredContent, and the user-facing line cites
|
|
252
|
+
// the linked ticket's human ticketNumber instead (issue #45).
|
|
247
253
|
record = null;
|
|
248
254
|
const code = err?.code;
|
|
249
|
-
note = ` the stored record read-back failed (${typeof code === 'string' ? code : 'UNKNOWN'}) — the entry was created
|
|
255
|
+
note = ` the stored record read-back failed (${typeof code === 'string' ? code : 'UNKNOWN'}) — the entry was created; its id rides the result envelope (report the linked ticket's ticketNumber, never a bare id).`;
|
|
250
256
|
}
|
|
251
257
|
if (record === undefined)
|
|
252
258
|
return abortedAnswer(ctx.correlationId);
|
package/dist/tools/meta.js
CHANGED
|
@@ -226,7 +226,7 @@ function registerCrossResource(server, ctx) {
|
|
|
226
226
|
}));
|
|
227
227
|
const fetchInput = closedInput(FETCH_MANY_INPUT);
|
|
228
228
|
server.registerTool(FETCH_MANY_TOOL, {
|
|
229
|
-
title: 'Fetch many records by numeric id',
|
|
229
|
+
title: 'Fetch many records by numeric id (records return their human reference)',
|
|
230
230
|
description: ctx.description(FETCH_MANY_TOOL),
|
|
231
231
|
inputSchema: fetchInput.input,
|
|
232
232
|
outputSchema: FETCH_MANY_OUTPUT,
|
package/dist/tools/resolves.js
CHANGED
|
@@ -7,18 +7,68 @@ import { assertSecretReadPermitted, assertWritePermitted } from '../policy.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* The five R4 resolves, in core-tier order (the catalog's order). `field` = the resource's
|
|
9
9
|
* primary text field(s) (from the registry records' purpose text); `getOp` = the numeric-id
|
|
10
|
-
* fetch named in the miss's suggestedAction; `
|
|
11
|
-
* (
|
|
10
|
+
* fetch named in the miss's suggestedAction; `humanFields` / `human` = the HUMAN reference the
|
|
11
|
+
* answer cites (issue #45 — the user-facing key; the numeric id is the vendor's internal key for
|
|
12
|
+
* get/update/delete only). `blind` = the company blind-surface clause (companies the API user
|
|
13
|
+
* does not own are invisible to this surface).
|
|
14
|
+
*
|
|
15
|
+
* `humanFields` (the static field names, named in the miss's suggestedAction and in the
|
|
16
|
+
* no-human-field line) documents what `human` looks for; `human` extracts the value from the
|
|
17
|
+
* record ALREADY IN HAND (zero wire — the vendor record carries it).
|
|
12
18
|
*/
|
|
13
19
|
const RESOLVES = [
|
|
14
20
|
// The order is the approved surface order (architecture §2.2 = the REGISTERED_TOOL_NAMES
|
|
15
21
|
// constant): company, configurationItems, contact, project, ticket.
|
|
16
|
-
{ tool: 'autotask_company_resolve', operation: 'companies.resolve', title: 'Resolve a company by id or primary text', noun: 'company', field: 'companyName', getOp: 'companies.get', blind: true },
|
|
17
|
-
{ tool: 'autotask_configurationItems_resolve', operation: 'configuration_items.resolve', title: 'Resolve a configuration item by id or primary text', noun: 'configuration item', field: 'referenceTitle', getOp: 'configuration_items.get' },
|
|
18
|
-
{ tool: 'autotask_contact_resolve', operation: 'contacts.resolve', title: 'Resolve a contact by id or primary text', noun: 'contact', field: 'firstName + lastName', getOp: 'contacts.get' },
|
|
19
|
-
{ tool: 'autotask_project_resolve', operation: 'projects.resolve', title: 'Resolve a project by id or primary text', noun: 'project', field: 'projectName', getOp: 'projects.get' },
|
|
20
|
-
{ tool: 'autotask_ticket_resolve', operation: 'tickets.resolve', title: 'Resolve a ticket by id or primary text', noun: 'ticket', field: 'title', getOp: 'tickets.get' },
|
|
22
|
+
{ tool: 'autotask_company_resolve', operation: 'companies.resolve', title: 'Resolve a company by id or primary text', noun: 'company', field: 'companyName', getOp: 'companies.get', humanFields: ['companyName'], human: (r) => humanField(r, 'companyName'), blind: true },
|
|
23
|
+
{ tool: 'autotask_configurationItems_resolve', operation: 'configuration_items.resolve', title: 'Resolve a configuration item by id or primary text', noun: 'configuration item', field: 'referenceTitle', getOp: 'configuration_items.get', humanFields: ['referenceTitle'], human: (r) => humanField(r, 'referenceTitle') },
|
|
24
|
+
{ tool: 'autotask_contact_resolve', operation: 'contacts.resolve', title: 'Resolve a contact by id or primary text', noun: 'contact', field: 'firstName + lastName', getOp: 'contacts.get', humanFields: ['name'], human: humanName },
|
|
25
|
+
{ tool: 'autotask_project_resolve', operation: 'projects.resolve', title: 'Resolve a project by id or primary text', noun: 'project', field: 'projectName', getOp: 'projects.get', humanFields: ['projectNumber', 'projectName'], human: (r) => humanField(r, 'projectNumber') ?? humanField(r, 'projectName') },
|
|
26
|
+
{ tool: 'autotask_ticket_resolve', operation: 'tickets.resolve', title: 'Resolve a ticket by id or primary text', noun: 'ticket', field: 'title', getOp: 'tickets.get', humanFields: ['ticketNumber'], human: (r) => humanField(r, 'ticketNumber') },
|
|
21
27
|
];
|
|
28
|
+
/** The record as a field bag (a resolve hit is a vendor record; anything else carries no human field). */
|
|
29
|
+
function asRecord(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
|
|
31
|
+
}
|
|
32
|
+
/** One non-empty (whitespace-trimmed) string field of the record, or `undefined` — the vendor answers
|
|
33
|
+
* partial records, and the human-key fields are typed `string` in the vendor model (a non-string value is
|
|
34
|
+
* not read as a human reference; the no-usable-field line then says so). */
|
|
35
|
+
function stringField(record, field) {
|
|
36
|
+
const value = record[field];
|
|
37
|
+
if (typeof value !== 'string')
|
|
38
|
+
return undefined;
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** The human reference carried by `field` (issue #45), or `undefined` when the record omits it. */
|
|
43
|
+
function humanField(record, field) {
|
|
44
|
+
const value = stringField(record, field);
|
|
45
|
+
return value === undefined ? undefined : { value, label: field };
|
|
46
|
+
}
|
|
47
|
+
/** A contact's human reference: `firstName + lastName` (either part alone is still the person). */
|
|
48
|
+
function humanName(record) {
|
|
49
|
+
const parts = [stringField(record, 'firstName'), stringField(record, 'lastName')].filter((part) => part !== undefined);
|
|
50
|
+
return parts.length === 0 ? undefined : { value: parts.join(' '), label: 'name' };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The resolve success line (issue #45). The user-facing key is the HUMAN reference, so:
|
|
54
|
+
* - a numeric identifier is paired with the record's human field —
|
|
55
|
+
* `Resolved ticket "T20260914.0038" (id 48213).`;
|
|
56
|
+
* - an identifier that is not the human field names it in a trailing clause —
|
|
57
|
+
* `Resolved ticket "Printer on fire" (ticketNumber T20260914.0038).`;
|
|
58
|
+
* - a record carrying no USABLE human field (absent, empty, whitespace-only, or not a string) keeps the
|
|
59
|
+
* pre-#45 text and SAYS SO — naming the field(s) it looked for — so a caller never reads the number
|
|
60
|
+
* back as the record's name.
|
|
61
|
+
*/
|
|
62
|
+
function resolveText(spec, identifier, record) {
|
|
63
|
+
const human = spec.human(asRecord(record));
|
|
64
|
+
if (human === undefined)
|
|
65
|
+
return `Resolved ${spec.noun} "${identifier}" (the record carries no usable ${spec.humanFields.join(' or ')}).`;
|
|
66
|
+
if (typeof identifier === 'number')
|
|
67
|
+
return `Resolved ${spec.noun} "${human.value}" (id ${identifier}).`;
|
|
68
|
+
if (identifier === human.value)
|
|
69
|
+
return `Resolved ${spec.noun} "${identifier}".`;
|
|
70
|
+
return `Resolved ${spec.noun} "${identifier}" (${human.label} ${human.value}).`;
|
|
71
|
+
}
|
|
22
72
|
/**
|
|
23
73
|
* The typed NOT_FOUND served when the pinned SDK answers `null` (the unambiguous complete
|
|
24
74
|
* non-match; ambiguity and truncation THROW and are not caught here). Both resolve paths share
|
|
@@ -32,7 +82,9 @@ function exactMiss(spec, identifier, fuzzy = false) {
|
|
|
32
82
|
: 'For a contains match use autotask_resolve_any or autotask_search_across_resources (both in this surface)';
|
|
33
83
|
return new NotFoundError(`resolve: no ${spec.noun} exactly matches "${identifier}" — matching is EXACT on ${spec.field}${blind}`, {
|
|
34
84
|
operation: spec.operation,
|
|
35
|
-
|
|
85
|
+
// Issue #45: the numeric id is the internal key — the recovery names the human field the
|
|
86
|
+
// record carries, so the answer never reads back a bare id.
|
|
87
|
+
suggestedAction: `${contains}; or fetch by numeric id via ${spec.getOp} — then cite the record's ${spec.humanFields.join(' or ')}, never the bare id.`,
|
|
36
88
|
});
|
|
37
89
|
}
|
|
38
90
|
/** Register the five Layer-B resolves. */
|
|
@@ -97,7 +149,7 @@ export function registerResolveTools(server, ctx) {
|
|
|
97
149
|
return abortedAnswer(ctx.correlationId);
|
|
98
150
|
if (result === null)
|
|
99
151
|
throw exactMiss(spec, args.identifier, fuzzy);
|
|
100
|
-
return ok(
|
|
152
|
+
return ok(resolveText(spec, args.identifier, result), fuzzy ? { match: 'exact', record: markResult(result, fields) } : { record: markResult(result, fields) });
|
|
101
153
|
}));
|
|
102
154
|
}
|
|
103
155
|
}
|
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -385,7 +385,11 @@ export declare function queryEnvelope(fields?: readonly string[]): z.ZodType;
|
|
|
385
385
|
/** The five bounded query tools' output: the typed envelope under `result` (mirrors `resultOutput`). */
|
|
386
386
|
export declare function queryEnvelopeOutput(fields?: readonly string[]): z.ZodType;
|
|
387
387
|
/** A composite answer (SDK or server-built): the root record + one entry per part; a part miss
|
|
388
|
-
* is `null + absent`, never a tool failure — only a missing ROOT fails the composite.
|
|
388
|
+
* is `null + absent`, never a tool failure — only a missing ROOT fails the composite.
|
|
389
|
+
* The part names are tool-specific (this shared envelope stays a record): the contract
|
|
390
|
+
* profile serves `services` / `billing_rules` / `charges` (the recent-50 via the SDK's
|
|
391
|
+
* count-anchored `latest`, newest first — issue #49) / `units` (issue #49), every one riding
|
|
392
|
+
* the same value/absent envelope. */
|
|
389
393
|
export declare const COMPOSITE_OUTPUT: z.ZodObject<{
|
|
390
394
|
root: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
|
|
391
395
|
parts: z.ZodRecord<z.ZodString, z.ZodObject<{
|
package/dist/tools/schemas.js
CHANGED
|
@@ -23,7 +23,7 @@ import { AutotaskConfigError } from 'node-autotask';
|
|
|
23
23
|
// Shared fragments
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
/** A bare vendor numeric id. */
|
|
26
|
-
export const ID = z.number().int().nonnegative().describe(
|
|
26
|
+
export const ID = z.number().int().nonnegative().describe("The vendor numeric id — an internal key; an answer cites the record's human reference (e.g. ticketNumber, projectNumber), not this id.");
|
|
27
27
|
/** A bare id or exact name (the helper-tier identifier contract: numeric id tried first). */
|
|
28
28
|
export const IDENTIFIER = z.union([
|
|
29
29
|
z.number().int().nonnegative().describe('Numeric id — the direct fetch, tried first.'),
|
|
@@ -220,7 +220,7 @@ export const FETCH_MANY_INPUT = z.object({
|
|
|
220
220
|
items: z
|
|
221
221
|
.array(z.object({
|
|
222
222
|
resource: z.string().min(1).describe('The plan resource key (e.g. tickets) — top-level resources only.'),
|
|
223
|
-
id: z.number().int().nonnegative().describe('The vendor numeric id (numeric ids ONLY — string identifiers are autotask_resolve_any territory).'),
|
|
223
|
+
id: z.number().int().nonnegative().describe('The vendor numeric id (numeric ids ONLY — string identifiers are autotask_resolve_any territory); a fetched record carries its human reference.'),
|
|
224
224
|
fields: INCLUDE_FIELDS,
|
|
225
225
|
}))
|
|
226
226
|
.min(1)
|
|
@@ -408,6 +408,8 @@ export const CONTRACT_FULL_PROFILE_INPUT = z.object({
|
|
|
408
408
|
id: ID.describe('The numeric contract id.'),
|
|
409
409
|
service_limit: QUERY_LIMIT,
|
|
410
410
|
billing_rule_limit: QUERY_LIMIT,
|
|
411
|
+
// issue #49: the two new parts (charges — the FIXED recent-50 via the SDK's `latest`; units —
|
|
412
|
+
// the fixed 50-row cap) carry NO input keys: the closed bag above is unchanged.
|
|
411
413
|
});
|
|
412
414
|
export const RESOURCE_WORKLOAD_INPUT = z.object({
|
|
413
415
|
id: ID.describe('The numeric resource id.'),
|
|
@@ -488,7 +490,7 @@ export const AGGREGATE_INPUT = z.object({
|
|
|
488
490
|
export const TIME_ENTRY_CREATE_INPUT = z.object({
|
|
489
491
|
data: z
|
|
490
492
|
.record(z.string(), z.unknown())
|
|
491
|
-
.describe('The TimeEntry record payload (vendor model fields — the landed shape: resourceID + one of ticketID / taskID / internalAllocationCodeID + dateWorked + hoursWorked + summaryNotes; ticket entries also need roleID; service-ticket entries need startDateTime + endDateTime; a completed ticket is refused; the hours field is hoursWorked, not hours; the allocation/billing code must be an active GENERAL billing code (billingCodeType 0 + isActive true, discover via billing_codes.query) or pass an explicit active general billingCodeID). The zone wall: ww29/australia-nz refuses all time-entry writes for an API-only user (vendor 500) — a real (non-API-only) resource is required.'),
|
|
493
|
+
.describe('The TimeEntry record payload (vendor model fields — the landed shape: resourceID + one of ticketID / taskID / internalAllocationCodeID + dateWorked + hoursWorked + summaryNotes; ticket entries also need roleID; service-ticket entries need startDateTime + endDateTime; a completed ticket is refused; the hours field is hoursWorked, not hours; the allocation/billing code must be an active GENERAL billing code (billingCodeType 0 + isActive true, discover via billing_codes.query) or pass an explicit active general billingCodeID). The zone wall: ww29/australia-nz refuses all time-entry writes for an API-only user (vendor 500) — a real (non-API-only) resource is required. Report the entry by the linked ticket\'s ticketNumber (resolve the linked ticketID), never by the bare id.'),
|
|
492
494
|
dry_run: z.boolean().optional().describe('Omit or pass true to travel the dry-run path first (simulated: true, the mutating request is never issued); pass false to apply.'),
|
|
493
495
|
});
|
|
494
496
|
// ---------------------------------------------------------------------------
|
|
@@ -588,7 +590,11 @@ export function queryEnvelopeOutput(fields = []) {
|
|
|
588
590
|
return z.object({ result: queryEnvelope(fields) });
|
|
589
591
|
}
|
|
590
592
|
/** A composite answer (SDK or server-built): the root record + one entry per part; a part miss
|
|
591
|
-
* is `null + absent`, never a tool failure — only a missing ROOT fails the composite.
|
|
593
|
+
* is `null + absent`, never a tool failure — only a missing ROOT fails the composite.
|
|
594
|
+
* The part names are tool-specific (this shared envelope stays a record): the contract
|
|
595
|
+
* profile serves `services` / `billing_rules` / `charges` (the recent-50 via the SDK's
|
|
596
|
+
* count-anchored `latest`, newest first — issue #49) / `units` (issue #49), every one riding
|
|
597
|
+
* the same value/absent envelope. */
|
|
592
598
|
export const COMPOSITE_OUTPUT = z.object({
|
|
593
599
|
root: dataField('The root record (a missing root fails the call with the typed vendor error).'),
|
|
594
600
|
parts: z
|
package/dist/tools/server.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { Config } from '../config.js';
|
|
|
24
24
|
import type { Logger } from '../logger.js';
|
|
25
25
|
/** The wire identity of this server (the initialize answer + the `/metadata` payload). */
|
|
26
26
|
export declare const SERVER_NAME = "iit-mcp-autotask";
|
|
27
|
-
export declare const SERVER_VERSION = "0.4.
|
|
27
|
+
export declare const SERVER_VERSION = "0.4.3";
|
|
28
28
|
export declare const SERVER_TITLE = "Autotask MCP Server";
|
|
29
29
|
/**
|
|
30
30
|
* The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
|
|
@@ -62,7 +62,7 @@ export declare const SERVER_INSTRUCTIONS: string;
|
|
|
62
62
|
/** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
|
|
63
63
|
export declare const SERVER_IDENTITY: {
|
|
64
64
|
readonly name: "iit-mcp-autotask";
|
|
65
|
-
readonly version: "0.4.
|
|
65
|
+
readonly version: "0.4.3";
|
|
66
66
|
readonly mcpProtocolBaseline: "2026-07-28";
|
|
67
67
|
readonly legacyProtocolFallback: "2025-11-25";
|
|
68
68
|
};
|
package/dist/tools/server.js
CHANGED
|
@@ -27,7 +27,7 @@ import { UNTRUSTED_NOTE } from './catalog-data.js';
|
|
|
27
27
|
import { registerTools } from './register.js';
|
|
28
28
|
/** The wire identity of this server (the initialize answer + the `/metadata` payload). */
|
|
29
29
|
export const SERVER_NAME = 'iit-mcp-autotask';
|
|
30
|
-
export const SERVER_VERSION = '0.4.
|
|
30
|
+
export const SERVER_VERSION = '0.4.3';
|
|
31
31
|
export const SERVER_TITLE = 'Autotask MCP Server';
|
|
32
32
|
/**
|
|
33
33
|
* The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
|
|
@@ -78,6 +78,7 @@ Lookup protocol (usage rules — follow them):
|
|
|
78
78
|
2. Queues, status, priority, ticket type, issue type and source are picklists, not records; their labels need the field catalog with labels (SDK 0.9.0 line), not a name search.
|
|
79
79
|
3. After any write, read the stored record back (see #19) and report it.
|
|
80
80
|
4. An empty or null result is not an answer until the documented fallbacks have been tried.
|
|
81
|
+
5. Report every record by its HUMAN reference, never a bare numeric id (issue #45): a ticket by ticketNumber, a project by projectNumber (projectName when the record carries none), a company by companyName, a contact by name, a configuration item by referenceTitle. The record you hold carries it — the numeric id is an internal key for get/update/delete only. An error that names numeric resourceIds is the same case, but read it carefully: the array carries NO label saying what those ids are. The SDK stamps them across entity types — the failed record's own id, the candidate ids of a RESOLUTION_AMBIGUOUS, a company id, a billing-code id, a staff resource id. Resolve them only against the entity the error's own message, suggestedAction, or (for a plain <resource>.get miss on the operation's own record) its operation key NAMES (resources for a staff id — assignedResourceID / owner / resourceID, and operations.expandResourceNames resolves RESOURCES ids ONLY, never a ticket/company/billing-code id; billing_codes for an allocation code; companies for a company id); when none of those names the entity, ask the user instead of guessing. Resolve many ids in ONE bounded call (autotask_fetch_many items [{ resource, id }] caps at 500 per call — chunk past that; or autotask_read <resource>.query with filter [{ field: 'id', op: 'in', value: [...] }]) — never one resolve per row.
|
|
81
82
|
|
|
82
83
|
Query options (the bounded query tools — the five *query tools + autotask_read query/count ops):
|
|
83
84
|
- latest: N returns only the N newest records (highest id), no paging — always pair it with a filter, ideally a narrow date window (the vendor has no sort; an unfiltered latest on a blind surface returns an empty stamped result, and a very large match set can fail with LATEST_THRESHOLD_UNRESOLVED or LATEST_WINDOW_INCOMPLETE, whose suggestedAction says how to narrow).
|
|
@@ -109,11 +110,11 @@ Rate budget and low-budget behaviour: autotask_threshold_information serves two
|
|
|
109
110
|
|
|
110
111
|
autotask_read query params: latest (the N newest rows by id), noteChars (cap free-text fields), and fieldsPreset: 'billing_join' on billing_items.query (the 14 billing join/measure columns; not combinable with includeFields). For query/count ops args[0].filter must be a conditions array or { filter: [...] } — a bare condition/group object or any other shape is refused before dispatch (refused rather than risk an unfiltered query).
|
|
111
112
|
|
|
112
|
-
Recipe — autotask_company_unbilled_work: id + the optional closed bag — source ('posted_uninvoiced' default, the billing items with no invoiceID; 'invoice' — needs invoice_id; 'date_range' — needs date_from + date_to; 'tickets' — needs non-empty ticket_ids), include_parent (default true — ONE level only), date_from / date_to (YYYY-MM-DD; narrows the billing items' itemDate in every source), include_notes (default false — the note fields are EXCLUDED; when true they are capped at note_chars, default 400, a cut value ending with the … [+M chars truncated] marker), rollup ('ticket' default — per-ticket rows; 'entry' — the capped per-entry list), limit (the per-part record cap, default 25, clamped 1..500). Wire: 6(+1) bounded reads; the unposted anti-join + totals + warnings are client-side folds (zero wire).
|
|
113
|
+
Recipe — autotask_company_unbilled_work: id + the optional closed bag — source ('posted_uninvoiced' default, the billing items with no invoiceID; 'invoice' — needs invoice_id; 'date_range' — needs date_from + date_to; 'tickets' — needs non-empty ticket_ids), include_parent (default true — ONE level only), date_from / date_to (YYYY-MM-DD; narrows the billing items' itemDate in every source), include_notes (default false — the note fields are EXCLUDED; when true they are capped at note_chars, default 400, a cut value ending with the … [+M chars truncated] marker), rollup ('ticket' default — per-ticket rows; 'entry' — the capped per-entry list), limit (the per-part record cap, default 25, clamped 1..500). Wire: 6(+1) bounded reads; the unposted anti-join + totals + warnings are client-side folds (zero wire). Per-ticket rollup rows carry the numeric ticketId ONLY — resolve the distinct ids in ONE bounded call (autotask_read tickets.query with filter [{ field: 'id', op: 'in', value: [...] }], include_fields ['id','ticketNumber'], or autotask_fetch_many) when the answer must name the tickets; report the ticketNumber, never the bare id (rule 5).
|
|
113
114
|
|
|
114
|
-
Recipe — autotask_aggregate: operation (the EXACT registry query op — a plan resource's .query ONLY; a special op, a .count row or an unknown resource is refused pre-request, naming autotask_catalog), filter (the query-bag condition grammar — 13 operators; an empty array = the unfiltered call: a 0 on a plan-pinned blind surface is marked blind: true, never a silent absence; a filtered 0 on an unfiltered-blind surface is a true zero) + the optional closed bag: group_by (vendor field names — the group key is the field tuple), sum (numeric vendor fields), top (default 25, clamped 1..100 — the group-row cap), limit (default 2500 — the total-record cap for the walk modes; the house hard max). NEITHER group_by NOR sum = COUNT mode (exactly one POST /query/count — no walk, no GETs; the count endpoint is not cached); a group_by or sum = WALK mode (/query pages of min(500, limit), <= ceil(limit/500) pages + the 1000-page guard; the queries are not cached). The fold is client-side: groups carry count + the requested sums; totalGroups + groupsTruncated say when top cut the groups; hasMore + totalScanned say when the walk truncated — groups/sums cover the returned rows ONLY (no silent partial sums); a mid-walk RATE_LIMITED propagates (the partial fold is discarded).
|
|
115
|
+
Recipe — autotask_aggregate: operation (the EXACT registry query op — a plan resource's .query ONLY; a special op, a .count row or an unknown resource is refused pre-request, naming autotask_catalog), filter (the query-bag condition grammar — 13 operators; an empty array = the unfiltered call: a 0 on a plan-pinned blind surface is marked blind: true, never a silent absence; a filtered 0 on an unfiltered-blind surface is a true zero) + the optional closed bag: group_by (vendor field names — the group key is the field tuple), sum (numeric vendor fields), top (default 25, clamped 1..100 — the group-row cap), limit (default 2500 — the total-record cap for the walk modes; the house hard max). NEITHER group_by NOR sum = COUNT mode (exactly one POST /query/count — no walk, no GETs; the count endpoint is not cached); a group_by or sum = WALK mode (/query pages of min(500, limit), <= ceil(limit/500) pages + the 1000-page guard; the queries are not cached). The fold is client-side: groups carry count + the requested sums; totalGroups + groupsTruncated say when top cut the groups; hasMore + totalScanned say when the walk truncated — groups/sums cover the returned rows ONLY (no silent partial sums); a mid-walk RATE_LIMITED propagates (the partial fold is discarded). Group/sum rows carry the vendor fields you grouped by — when a grouped field is a numeric FK (e.g. ticketID, companyID), resolve the distinct ids in ONE bounded call (the owning resource's query with filter [{ field: 'id', op: 'in', value: [...] }], or autotask_fetch_many) before reporting them (rule 5).
|
|
115
116
|
|
|
116
|
-
Recipe — autotask_timeEntry_create: data is required (the TimeEntry payload — vendor model fields); vendor field chain (a bare payload burns vendor 500s, one per real wire dial): link the entry to one of ticketID / taskID / internalAllocationCodeID; entries on Tickets also require roleID; entries on Service tickets require startDateTime + endDateTime; the hours field is hoursWorked (NOT hours) — at least one of hoursWorked or (startDateTime + endDateTime) is required; the linked ticket must be open (a completed ticket is refused); the allocation code must be an active GENERAL billing code (billingCodeType 0 + isActive true — discover one via billing_codes.query) or pass an explicit active general billingCodeID. On apply (dry_run: false) the tool reads back the created record (live: the write clears the SDK read cache) and returns it as record next to result.id; a dry run returns no record; a read-back failure
|
|
117
|
+
Recipe — autotask_timeEntry_create: data is required (the TimeEntry payload — vendor model fields); vendor field chain (a bare payload burns vendor 500s, one per real wire dial): link the entry to one of ticketID / taskID / internalAllocationCodeID; entries on Tickets also require roleID; entries on Service tickets require startDateTime + endDateTime; the hours field is hoursWorked (NOT hours) — at least one of hoursWorked or (startDateTime + endDateTime) is required; the linked ticket must be open (a completed ticket is refused); the allocation code must be an active GENERAL billing code (billingCodeType 0 + isActive true — discover one via billing_codes.query) or pass an explicit active general billingCodeID. On apply (dry_run: false) the tool reads back the created record (live: the write clears the SDK read cache) and returns it as record next to result.id; a dry run returns no record; a read-back failure leaves record null, adds a note naming the typed error code and never fails the write (the created id rides result). Report the entry by the LINKED TICKET's human ticketNumber — autotask_ticket_resolve on the linked ticketID (NOT autotask_ticket_find_by_ticket_number, which takes the ticketNumber string, not the id) — never by the created entry's bare id.
|
|
117
118
|
|
|
118
119
|
Writes: dry-run-first — omit dry_run or pass true → the pinned DryRunResult envelope (simulated: true — the mutating request is never issued); pass dry_run: false to apply. The 3 approval-gated writes (configuration_items.moveConfigurationItem, contacts.moveToCompany, resources.transferOwnership) additionally require confirm === the operation key, in BOTH dry-run and apply mode. Deletes (all 54) require confirm === the operation key in BOTH modes — the deliberate act is the call, not the delete. time_entries.create reached through the generic autotask_write returns only { id } on apply — autotask_timeEntry_create is the path that returns the stored record.
|
|
119
120
|
`.trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Max Soukhomlinov",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@modelcontextprotocol/server": "^2.2.0",
|
|
50
|
-
"node-autotask": "0.
|
|
50
|
+
"node-autotask": "0.11.0",
|
|
51
51
|
"zod": "^4.4.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
package/server.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "MCP server for the Autotask (Datto) PSA API, powered by the node-autotask SDK",
|
|
5
5
|
"mcpProtocolBaseline": "2026-07-28",
|
|
6
6
|
"legacyProtocolFallback": "2025-11-25",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"capabilitiesManifest": "capabilities.json",
|
|
30
30
|
"sdk": {
|
|
31
31
|
"package": "node-autotask",
|
|
32
|
-
"versionRange": "0.
|
|
32
|
+
"versionRange": "0.11.0"
|
|
33
33
|
},
|
|
34
34
|
"vendorExtensions": false
|
|
35
35
|
}
|