iit-mcp-autotask 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -68,7 +68,7 @@ AUTOTASK_PROFILE=readonly
68
68
  # The kill file: while it exists every preview and apply is denied (default <state dir>/KILL).
69
69
  # AUTOTASK_KILL_FILE=/var/lib/autotask-mcp/KILL
70
70
  # Optional toolsets on top of core (core is always on): a comma list of service, records, finance,
71
- # or all. An unknown name fails boot. Unset = core only (10 tools).
71
+ # or all. An unknown name fails boot. Unset = core only (11 tools).
72
72
  # AUTOTASK_TOOLSETS=service,records,finance
73
73
  # IANA time zone of the people asking (their local "today"), default UTC; not the tenant's or
74
74
  # the server's zone. Autotask stores UTC; the zone is reported by
package/README.md CHANGED
@@ -19,7 +19,7 @@ client + SDK-owned TTL caches).
19
19
 
20
20
  ## Tool surface
21
21
 
22
- The **core** toolset (10 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
22
+ The **core** toolset (11 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
23
23
  `finance` (or `all`, 18 tools); the operator profile adds two. The whole tool surface (20 tools) is
24
24
  listed below. Every other operation in the SDK's 1,216-operation registry stays
25
25
  reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
@@ -27,8 +27,8 @@ its contract, `autotask_read` runs it.
27
27
 
28
28
  | Toolset | Tools |
29
29
  |---------|-------|
30
- | core (always) | `autotask_guide` · `autotask_catalog` · `autotask_describe` · `autotask_read` · `autotask_resolve` · `autotask_query` · `autotask_fetch_many` · `autotask_tenant_status` · `autotask_ticket_full_context` · `autotask_company_full_profile` |
31
- | `service` | `autotask_project_get_context` · `autotask_service_call_full_profile` · `autotask_resource_workload` |
30
+ | core (always) | `autotask_guide` · `autotask_catalog` · `autotask_describe` · `autotask_read` · `autotask_resolve` · `autotask_query` · `autotask_fetch_many` · `autotask_tenant_status` · `autotask_ticket_full_context` · `autotask_company_full_profile` · `autotask_resource_workload` |
31
+ | `service` | `autotask_project_get_context` · `autotask_service_call_full_profile` |
32
32
  | `records` | `autotask_contact_full_profile` · `autotask_configuration_item_profile` · `autotask_contract_full_profile` |
33
33
  | `finance` | `autotask_quote_get` · `autotask_invoice_get` (`format`: `record`, `html`, `xml`, `pdf`) |
34
34
  | `operator` (operator profile) | `autotask_write` (preview, changes nothing) · `autotask_apply` (execute the previewed plan) |
@@ -70,7 +70,7 @@ vendor, so loading a playbook costs no wire and adds no injection surface.
70
70
  - `{skill, section}`: one of its `## ` sections.
71
71
 
72
72
  Skills: `ticket-triage`, `company-360`, `unbilled-work-review`, `contract-profitability`,
73
- `invoice-export`, `filter-semantics`, `slim-reads`, and on the operator profile `log-time-entry` and `crm-onboarding`.
73
+ `invoice-export`, `filter-semantics`, `slim-reads`, and on the operator profile `log-time-entry` and `crm-management`.
74
74
 
75
75
  The playbooks are discoverable in both directions:
76
76
 
package/capabilities.json CHANGED
@@ -3,9 +3,9 @@
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": 10,
6
+ "readonlyCore": 11,
7
7
  "readonlyAll": 18,
8
- "operatorCore": 12,
8
+ "operatorCore": 13,
9
9
  "operatorAll": 20
10
10
  },
11
11
  "profiles": {
@@ -27,12 +27,12 @@
27
27
  "autotask_fetch_many",
28
28
  "autotask_tenant_status",
29
29
  "autotask_ticket_full_context",
30
- "autotask_company_full_profile"
30
+ "autotask_company_full_profile",
31
+ "autotask_resource_workload"
31
32
  ],
32
33
  "service": [
33
34
  "autotask_project_get_context",
34
- "autotask_service_call_full_profile",
35
- "autotask_resource_workload"
35
+ "autotask_service_call_full_profile"
36
36
  ],
37
37
  "records": [
38
38
  "autotask_contact_full_profile",
@@ -211,14 +211,14 @@
211
211
  }
212
212
  },
213
213
  {
214
- "id": "autotask.project.get_context",
215
- "description": "One project with its company, opportunity and lead resource. Project phases and task lists do not exist on this API; charges and notes go through autotask_read.",
214
+ "id": "autotask.resource.workload",
215
+ "description": "One resource's availability: open tickets as primary or secondary resource (de-duplicated, with counts), service-call assignments, time-off balances and time-off awaiting approval. Missing parts degrade to typed absent.",
216
216
  "mcpPrimitive": "tool",
217
- "toolName": "autotask_project_get_context",
217
+ "toolName": "autotask_resource_workload",
218
218
  "type": "read",
219
- "toolset": "service",
219
+ "toolset": "core",
220
220
  "profile": "readonly",
221
- "backingOperation": "projects.getContext",
221
+ "backingOperation": "resources.get",
222
222
  "annotations": {
223
223
  "readOnly": true,
224
224
  "idempotent": true,
@@ -227,14 +227,14 @@
227
227
  }
228
228
  },
229
229
  {
230
- "id": "autotask.service_call.full_profile",
231
- "description": "One service call with its linked tickets, tasks and resource assignments, for on-site visit work. Missing parts degrade to typed absent.",
230
+ "id": "autotask.project.get_context",
231
+ "description": "One project with its company, opportunity and lead resource. Project phases and task lists do not exist on this API; charges and notes go through autotask_read.",
232
232
  "mcpPrimitive": "tool",
233
- "toolName": "autotask_service_call_full_profile",
233
+ "toolName": "autotask_project_get_context",
234
234
  "type": "read",
235
235
  "toolset": "service",
236
236
  "profile": "readonly",
237
- "backingOperation": "service_calls.get",
237
+ "backingOperation": "projects.getContext",
238
238
  "annotations": {
239
239
  "readOnly": true,
240
240
  "idempotent": true,
@@ -243,14 +243,14 @@
243
243
  }
244
244
  },
245
245
  {
246
- "id": "autotask.resource.workload",
247
- "description": "One resource's availability: open tickets as primary or secondary resource (de-duplicated, with counts), service-call assignments, time-off balances and time-off awaiting approval. Missing parts degrade to typed absent.",
246
+ "id": "autotask.service_call.full_profile",
247
+ "description": "One service call with its linked tickets, tasks and resource assignments, for on-site visit work. Missing parts degrade to typed absent.",
248
248
  "mcpPrimitive": "tool",
249
- "toolName": "autotask_resource_workload",
249
+ "toolName": "autotask_service_call_full_profile",
250
250
  "type": "read",
251
251
  "toolset": "service",
252
252
  "profile": "readonly",
253
- "backingOperation": "resources.get",
253
+ "backingOperation": "service_calls.get",
254
254
  "annotations": {
255
255
  "readOnly": true,
256
256
  "idempotent": true,
@@ -260,7 +260,7 @@
260
260
  },
261
261
  {
262
262
  "id": "autotask.contact.full_profile",
263
- "description": "One contact with their company and open tickets (closed set from the tenant status picklist, 2-4 reads). Contains personal data. Missing parts degrade to typed absent.",
263
+ "description": "One contact with their company and open tickets (closed set from the tenant status picklist, 2-5 reads). Contains personal data. Missing parts degrade to typed absent.",
264
264
  "mcpPrimitive": "tool",
265
265
  "toolName": "autotask_contact_full_profile",
266
266
  "type": "read",
@@ -121,7 +121,7 @@ export const TOOL_DESCRIPTIONS = {
121
121
  autotask_project_get_context: 'One project with its company, opportunity and lead resource. Project phases and task lists do not exist on this API; charges and notes go through autotask_read.',
122
122
  autotask_service_call_full_profile: 'One service call with its linked tickets, tasks and resource assignments, for on-site visit work. Missing parts degrade to typed absent.',
123
123
  autotask_resource_workload: 'One resource\'s availability: open tickets as primary or secondary resource (de-duplicated, with counts), service-call assignments, time-off balances and time-off awaiting approval. Missing parts degrade to typed absent.',
124
- autotask_contact_full_profile: 'One contact with their company and open tickets (closed set from the tenant status picklist, 2-4 reads). Contains personal data. Missing parts degrade to typed absent.',
124
+ autotask_contact_full_profile: 'One contact with their company and open tickets (closed set from the tenant status picklist, 2-5 reads). Contains personal data. Missing parts degrade to typed absent.',
125
125
  autotask_configuration_item_profile: 'One configuration item with type, category, notes, related items and billing products. Zone ww29 has no configuration items: expect not found there.',
126
126
  autotask_contract_full_profile: 'One contract with services, billing rules, the 50 newest charges and service units. Monthly P&L: autotask_read (guide: contract-profitability).',
127
127
  autotask_quote_get: 'One quote with its line items in one call. A list of quotes: autotask_read quotes.query.',
@@ -1,3 +1,3 @@
1
1
  /** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
2
2
  export declare const SERVER_NAME = "iit-mcp-autotask";
3
- export declare const SERVER_VERSION = "0.33.0";
3
+ export declare const SERVER_VERSION = "0.34.0";
@@ -1,4 +1,4 @@
1
1
  /** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
2
2
  export const SERVER_NAME = 'iit-mcp-autotask';
3
- export const SERVER_VERSION = '0.33.0';
3
+ export const SERVER_VERSION = '0.34.0';
4
4
  //# sourceMappingURL=server-version.js.map
@@ -60,7 +60,7 @@ export declare function serverInstructions(config: Pick<Config, 'AUTOTASK_PROFIL
60
60
  /** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
61
61
  export declare const SERVER_IDENTITY: {
62
62
  readonly name: "iit-mcp-autotask";
63
- readonly version: "0.33.0";
63
+ readonly version: "0.34.0";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
@@ -25,9 +25,9 @@ export const SURFACE = [
25
25
  { name: 'autotask_tenant_status', toolset: 'core', profile: 'readonly', vendor: true, build: buildTenantStatus },
26
26
  { name: 'autotask_ticket_full_context', toolset: 'core', profile: 'readonly', vendor: true, build: buildTicketFullContext },
27
27
  { name: 'autotask_company_full_profile', toolset: 'core', profile: 'readonly', vendor: true, build: buildCompanyFullProfile },
28
+ { name: 'autotask_resource_workload', toolset: 'core', profile: 'readonly', vendor: true, build: buildResourceWorkload },
28
29
  { name: 'autotask_project_get_context', toolset: 'service', profile: 'readonly', vendor: true, build: buildProjectGetContext },
29
30
  { name: 'autotask_service_call_full_profile', toolset: 'service', profile: 'readonly', vendor: true, build: buildServiceCallFullProfile },
30
- { name: 'autotask_resource_workload', toolset: 'service', profile: 'readonly', vendor: true, build: buildResourceWorkload },
31
31
  { name: 'autotask_contact_full_profile', toolset: 'records', profile: 'readonly', vendor: true, build: buildContactFullProfile },
32
32
  { name: 'autotask_configuration_item_profile', toolset: 'records', profile: 'readonly', vendor: true, build: buildConfigurationItemProfile },
33
33
  { name: 'autotask_contract_full_profile', toolset: 'records', profile: 'readonly', vendor: true, build: buildContractFullProfile },
@@ -1,6 +1,7 @@
1
1
  /** `autotask_write`: the preview tool (issue #89). */
2
2
  import { MoveRefusedError } from 'node-autotask';
3
3
  import { abortedResult, cleanUntrustedText } from '../results.js';
4
+ import { assertOperationPermitted } from '../../policy.js';
4
5
  import { WRITE_INPUT, closedInput } from '../schemas.js';
5
6
  import { hashOf } from '../../write/canonical.js';
6
7
  import { evaluate } from '../../write/evaluate.js';
@@ -141,9 +142,13 @@ async function entryResource(r, a) {
141
142
  return { id, roleID, name: null, absent: true };
142
143
  }
143
144
  }
145
+ /** The response sanitiser's per-string cap (UNTRUSTED_TEXT_CAP): a longer warning would lose its tail. */
146
+ const LOCATION_WARNING_CAP = 500;
144
147
  /**
145
148
  * #183: a contact written with no `companyLocationID` while its company has active locations. One bounded
146
- * read (limit 50), only when the field is unset; a failed read adds nothing.
149
+ * read (limit 50), only when the field is unset; a failed or refused read adds nothing. The read goes through
150
+ * the same deployment gate as `autotask_read`. The sanitiser caps each warning at 500 characters, so the instruction
151
+ * comes first and only as many location ids as fit are listed. No vendor text enters the sentence: ids are numbers.
147
152
  */
148
153
  async function locationWarning(r, a, current) {
149
154
  if (r.op !== 'contacts.create' && r.op !== 'contacts.update')
@@ -154,12 +159,25 @@ async function locationWarning(r, a, current) {
154
159
  if (current?.companyLocationID != null)
155
160
  return undefined; // update on a contact that already has a location
156
161
  try {
162
+ assertOperationPermitted('company_locations.query', r.ctx.record('company_locations.query'), r.ctx.config, r.ctx.log);
157
163
  const rows = (await r.ctx.ops.invokeRead(r.ctx.client, 'company_locations.query', [{ filter: [{ field: 'companyID', op: 'eq', value: companyID }, { field: 'isActive', op: 'eq', value: true }], limit: 50 }], undefined, r.signal));
158
- const list = (Array.isArray(rows) ? rows : rows?.items) ?? [];
164
+ const raw = (Array.isArray(rows) ? rows : rows?.items) ?? [];
165
+ const list = raw.filter((l) => Number.isSafeInteger(l.id) && l.id > 0); // ids are the only thing served: no row without one is listed or counted
159
166
  if (list.length === 0)
160
167
  return undefined;
161
- const names = list.map((l) => `${String(l.id)} - ${cleanUntrustedText(String(l.name ?? ''), false).slice(0, 60)}${l.isPrimary === true ? ' (primary)' : ''}`).join(', ');
162
- return `companyLocationID is not set on this contact and the parent company ${companyID} has ${list.length} active location(s): ${names}. Set companyLocationID before applying; if the contact's address is unknown${list.length > 1 ? ', ask the user which location applies' : ' and there is one location, set it'}.`;
168
+ const total = list.length >= 50 ? 'at least 50' : String(list.length);
169
+ const head = `companyLocationID is not set on this contact and the parent company ${companyID} has ${total} active location(s). Set companyLocationID before applying; if the contact's address is unknown${list.length > 1 ? ', ask the user which location applies' : ' and there is one location, set it'}. Location ids (names: company_locations.query): `;
170
+ // ids and the primary flag only: no vendor text in server prose; the model reads names through company_locations.query
171
+ const items = list.map((l) => `${l.id}${l.isPrimary === true ? ' (primary)' : ''}`);
172
+ let text = head;
173
+ let shown = 0;
174
+ for (const it of items) {
175
+ if (text.length + it.length + 2 > LOCATION_WARNING_CAP - 40)
176
+ break; // 40 = room for the ' (first N of at least M listed)' tail
177
+ text += (shown > 0 ? ', ' : '') + it;
178
+ shown++;
179
+ }
180
+ return shown < items.length ? `${text} (first ${shown} of ${total} listed)` : text;
163
181
  }
164
182
  catch {
165
183
  return undefined;
@@ -255,7 +273,7 @@ export function buildWrite(ctx) {
255
273
  if ('stop' in dry)
256
274
  return dry.stop;
257
275
  const loc = await locationWarning(r, admitted.ok, snap.ok.current);
258
- const dried = loc === undefined ? dry.ok : { ...dry.ok, warnings: [...(dry.ok.warnings ?? []), loc] };
276
+ const dried = loc === undefined ? dry.ok : { ...dry.ok, warnings: [loc, ...(dry.ok.warnings ?? [])] };
259
277
  return persist(r, admitted.ok, snap.ok, evaluatePhase(r, admitted.ok, snap.ok, dried, await entryResource(r, admitted.ok)));
260
278
  },
261
279
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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",
package/server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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",
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: crm-onboarding
2
+ name: crm-management
3
3
  kind: write
4
4
  version: 1
5
5
  profile: operator
@@ -10,7 +10,7 @@ inputs: {"type": "object", "required": ["intent", "run_id"], "properties": {"int
10
10
  outcomes: ["ok", "needs_input", "ambiguous", "denied", "error"]
11
11
  hazards: ["writes company, location and contact records to the PSA", "every new company gets an auto-created \"Primary Address\" location that cannot be deleted through the API: rename it", "companies, resources and countries are blind surfaces on some tenants: no match is not proof of absence; owners and countries are lookups, not lists"]
12
12
  related: ["company-360"]
13
- eval: evals/crm-onboarding.json
13
+ eval: evals/crm-management.json
14
14
  ---
15
15
 
16
16
  # Create or update a company, location or contact
@@ -5,7 +5,7 @@ version: 1
5
5
  profile: readonly
6
6
  description: Filter footguns for autotask_query: strict date lt, case-insensitive contains, FK 0 vs NULL, field-name checks, all-strings values.
7
7
  when_not_to_use: the filter grammar itself (the 13 operators + and/or grouping, server instructions); the per-operation notes (autotask_describe)
8
- ops: ["autotask_query:tickets.query", "autotask_query:time_entries.query", "autotask_query:service_calls.query", "autotask_query:contracts.query", "autotask_query:time_off_requests.query", "autotask_read:operations.fieldCatalog"]
8
+ ops: ["autotask_query:tickets.query", "autotask_query:time_entries.query", "autotask_query:service_calls.query", "autotask_query:contracts.query", "autotask_query:time_off_requests.query", "autotask_read:operations.fieldCatalog", "autotask_resource_workload"]
9
9
  inputs: {"type": "object", "required": [], "properties": {}}
10
10
  outcomes: ["ok", "error"]
11
11
  hazards: ["gt/lt around a day boundary silently drops an edge day", "ticketID eq 0 finds nothing (time_entries store NULL)", "an unknown field name is a zero-query-wire CONFIG_ERROR (0.20.0 pre-validation; a cold field cache makes one vendor metadata GET first)"]
@@ -29,10 +29,11 @@ No inputs: this is a reference page about the filter behaviour of autotask_query
29
29
  4. Unassigned FKs are not uniform. tickets store 0; time_entries store NULL. Unallocated time needs notExist (ticketID eq 0 finds nothing); assignedResourceID eq 0 and notExist differ.
30
30
  5. Field names are vendor casing, and an unknown field name now fails pre-dispatch as a typed CONFIG_ERROR with the field-catalog pointer, with zero query wire (0.20.0 pre-validation), on all five autotask_query operations (tickets.query, time_entries.query, service_calls.query, contracts.query, time_off_requests.query). One udf: true condition per query is exempt from the name check. List names for the queried resource with the scoped call `autotask_read {operation:"operations.fieldCatalog", args:[{resources:["<resource>"]}]}` (add `fields: [names]` to project; a scoped call also returns picklist labels by default on this server). Never the bare `operations.fieldCatalog`: it serves the whole five-resource catalog (~85 KB) and has no row for time_entries, service_calls, contracts or time_off_requests. Do not guess names. The answer is `data.results[0].result`.
31
31
  6. Some clients (Copilot Studio) fail locally, before any request, when one array mixes value types (a number beside a string in one filter, or dateWorkedWindow args: strings plus an object). The autotask_query filter declares value as a string, so write every value as a string ("<resource id>", "2026-10-22", "true"); numbers still work for clients that send them. For a time entry window over a resource or ticket, query time entries instead of dateWorkedWindow: `autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"<resource id>"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}`: the window's rows up to limit (dateWorkedWindow caps at 500; the query defaults to 50), both days inclusive because dateWorked is a date, without the posted counts (derive them from postedDate where the zone serves it). A bare window with no narrowing is `autotask_read {operation:"time_entries.dateWorkedWindow", args:["2026-10-20","2026-10-22"]}` (all strings). The server and SDK pass filter values through unchanged; string dates, ids and booleans are confirmed on resources and time_entries, other fields are not verified.
32
+ 7. tickets.assignedResourceID is the PRIMARY owner only. Secondary assignments live in the separate ticket_secondary_resources table, which carries no status, so "how many open tickets does <resource> have" cannot be answered from tickets.query alone or from that table alone. Use `autotask_resource_workload {id}`: its open_tickets part is the de-duplicated union of primary and secondary open tickets with counts {primary, secondary, both, open}. The counts describe the returned set; when value.capped is true the answer is partial: call again with a larger ticket_limit (up to 500) before quoting counts, and say so if it is still capped. If the part is absent with CLOSED_STATUS_UNRESOLVED the tenant's closed statuses could not be read: say the count is unavailable rather than guessing.
32
33
 
33
34
  ## Guardrails
34
35
 
35
- - Apply the five rules above when composing or debugging any autotask_query filter.
36
+ - Apply the seven rules above when composing or debugging any autotask_query filter.
36
37
  - Do not "fix" a filter that already returns the right rows into a stricter spelling (a case-sensitive contains, an exclusive range, a zero-FK predicate).
37
38
  - Never guess field names: since 0.20.0 an unknown name is a typed CONFIG_ERROR with zero query wire (a cold field cache makes one vendor metadata GET first) — list names with the scoped fieldCatalog call from step 5 first.
38
39
 
@@ -68,4 +69,5 @@ No inputs: this is a reference page about the filter behaviour of autotask_query
68
69
  | autotask_query | contracts.query | read | - |
69
70
  | autotask_query | time_off_requests.query | read | - |
70
71
  | autotask_read | operations.fieldCatalog | read | - |
72
+ | autotask_resource_workload | - | - | - |
71
73
  <!-- generated:end -->