iit-mcp-autotask 0.38.2 → 0.38.4

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 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.38.0`; the committed `pnpm-lock.yaml` pins that
12
+ > at `0.39.1`; 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.38.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.39.1`) — 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
@@ -47,7 +47,9 @@ examples; `full: true` serves the complete record, `summary: true` about 2 KB),
47
47
  `time_off_requests`, `companies`, `billing_items`, ...; limit default 50, max 2500). With
48
48
  `group_by`, `sum` or `count: true` it answers totals instead of rows (the SDK's
49
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"}]`
50
+ `groupsTruncated` mean partial). `filter` is not schema-required (a form-filler dead-ends on a
51
+ required Table, #241) but a missing or empty one is refused (`INPUT_INVALID`, rows and totals);
52
+ `{field: "id", op: "exist"}` targets the whole entity. Records by id: `filter: [{field: "id", op: "in", value: "1,2,3"}]`
51
53
  (`in` / `notIn` take an array or a comma string). Time entries have no `companyID`: the relation
52
54
  filter `{field: "ticket.companyID", op: "eq", value: "<id>"}` (also `task.<field>`; `project.<field>`
53
55
  where the entity has the FK) scopes them through one parent read of at most 300 ids (more is a typed
@@ -778,7 +780,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
778
780
 
779
781
  ## SDK dependency
780
782
 
781
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.38.0`** (an exact pin —
783
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.39.1`** (an exact pin —
782
784
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
783
785
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
784
786
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
@@ -22,20 +22,6 @@ export declare class InputInvalidError extends AutotaskConfigError {
22
22
  readonly field?: string;
23
23
  constructor(message: string, field?: string, operation?: string);
24
24
  }
25
- /**
26
- * A typed read-path answer for a WELL-FORMED but incomplete input (issue #56: one of the two
27
- * profitability window dates without the other). Unlike the thrown SDK/config errors (which the
28
- * frame maps to the `error` envelope), this one answers the NON-error `needs_input` envelope
29
- * (`next: supply_input`) — the read path's needs_input, the same shape the write path answers for
30
- * its missing inputs. In the `items` form of `autotask_read` it rides the per-item slot as a typed
31
- * payload (code `INPUT_MISSING`), like any other slot error.
32
- */
33
- export declare class NeedsInputError extends Error {
34
- readonly code: "INPUT_MISSING";
35
- readonly field: string;
36
- readonly missing: readonly string[];
37
- constructor(message: string, field: string, missing: readonly string[]);
38
- }
39
25
  /**
40
26
  * The shared call frame, run by EVERY registered handler:
41
27
  * 1. the closed top-level argument set (issue #3: an unknown key is refused with the typed
@@ -93,7 +79,7 @@ export interface ReadOutcome {
93
79
  * One registry read, through every gate in order: the unknown-operation refusal, the deployment
94
80
  * policy (reads only; credential-minting never), the ONE-level `<untrusted_content>` strip of the
95
81
  * inputs, the query/count filter-shape gate, the `contracts.profitability` window default (issue
96
- * #56: both dates absent → the last full calendar month, UTC; exactly one → needs_input), the
82
+ * #56: both dates absent → the last full calendar month, UTC; exactly one → INPUT_INVALID), the
97
83
  * governed invoke (the SDK's single read governor) and the untrusted projection of the result.
98
84
  * Errors are THROWN — the frame wraps them once, the `items` form maps them per slot.
99
85
  */
@@ -17,10 +17,10 @@ import { assertFilterShape, normalizeFilter, ownField, QUERY_LIMIT } from './sch
17
17
  import { recordById, toolForOp, untrustedFieldsOf } from './catalog-data.js';
18
18
  import { AUTOTASK_BASE_PATH } from '../base-hosts.js';
19
19
  import { releaseClientOnError } from './context.js';
20
- import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, markServerField, outcome, refusalContent } from './results.js';
20
+ import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, markServerField, refusalContent } from './results.js';
21
21
  import { lastFullCalendarMonth, lastFullCalendarMonthLabel } from './profitability-window.js';
22
22
  import { assertOperationPermitted } from '../policy.js';
23
- import { ACK_KEY, guardUnscopedWalk, ScopeRequiredError } from './scope-guard.js';
23
+ import { ACK_KEY, guardUnscopedWalk } from './scope-guard.js';
24
24
  import { rewriteFieldNameRefusal } from './field-pointer.js';
25
25
  /** The MCP request's AbortSignal, when the transport provides one (architecture 7.6). */
26
26
  function mcpSignal(extra) {
@@ -36,24 +36,6 @@ export class InputInvalidError extends AutotaskConfigError {
36
36
  this.field = field;
37
37
  }
38
38
  }
39
- /**
40
- * A typed read-path answer for a WELL-FORMED but incomplete input (issue #56: one of the two
41
- * profitability window dates without the other). Unlike the thrown SDK/config errors (which the
42
- * frame maps to the `error` envelope), this one answers the NON-error `needs_input` envelope
43
- * (`next: supply_input`) — the read path's needs_input, the same shape the write path answers for
44
- * its missing inputs. In the `items` form of `autotask_read` it rides the per-item slot as a typed
45
- * payload (code `INPUT_MISSING`), like any other slot error.
46
- */
47
- export class NeedsInputError extends Error {
48
- code = 'INPUT_MISSING';
49
- field;
50
- missing;
51
- constructor(message, field, missing) {
52
- super(message);
53
- this.field = field;
54
- this.missing = missing;
55
- }
56
- }
57
39
  /**
58
40
  * The shared call frame, run by EVERY registered handler:
59
41
  * 1. the closed top-level argument set (issue #3: an unknown key is refused with the typed
@@ -93,16 +75,6 @@ async function frame(ctx, input, rawArgs, extra, work) {
93
75
  }
94
76
  catch (err) {
95
77
  releaseClientOnError(ctx, err);
96
- // The read path's needs_input (issue #56): a well-formed but incomplete input answers the
97
- // NON-error needs_input envelope (next: supply_input) — never the error mapping.
98
- if (err instanceof NeedsInputError) {
99
- return outcome('needs_input', 'supply_input', { code: 'INPUT_MISSING', field: err.field, detail: err.message }, null, { missing: [...err.missing] });
100
- }
101
- // The unscoped-walk refusal (#197): a well-formed read that needs a scope filter answers the
102
- // NON-error needs_input envelope (reason SCOPE_REQUIRED), like the half profitability window.
103
- if (err instanceof ScopeRequiredError) {
104
- return outcome('needs_input', 'supply_input', { code: 'SCOPE_REQUIRED', field: 'filter', detail: `${err.message} ${err.suggestedAction}` }, { code: err.code, operation: err.operation, count: err.count, threshold: err.threshold, suggestedAction: err.suggestedAction }, { missing: ['/args/0/filter'] });
105
- }
106
78
  // V23/FT-5: the request's own credential values ride into the error wrap so a vendor body
107
79
  // that echoes them is value-scrubbed before it crosses.
108
80
  return errorContent(err, ctx.correlationId, requestSecrets(ctx));
@@ -268,7 +240,7 @@ function annotateCatalogRows(result) {
268
240
  * One registry read, through every gate in order: the unknown-operation refusal, the deployment
269
241
  * policy (reads only; credential-minting never), the ONE-level `<untrusted_content>` strip of the
270
242
  * inputs, the query/count filter-shape gate, the `contracts.profitability` window default (issue
271
- * #56: both dates absent → the last full calendar month, UTC; exactly one → needs_input), the
243
+ * #56: both dates absent → the last full calendar month, UTC; exactly one → INPUT_INVALID), the
272
244
  * governed invoke (the SDK's single read governor) and the untrusted projection of the result.
273
245
  * Errors are THROWN — the frame wraps them once, the `items` form maps them per slot.
274
246
  */
@@ -284,6 +256,8 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
284
256
  // pre-request (zero wire on a refused shape).
285
257
  if (operation.endsWith('.query') || operation.endsWith('.count')) {
286
258
  // No params bag at all (args omitted / []): the same bounded default (#83); the SDK's own bag-less form is unbounded.
259
+ // #243: a missing or [] filter stays accepted here (limit 50; the walk guard above 500 rows): autotask_read is the generic
260
+ // path and this bounded default is its contract; autotask_query refuses the missing filter because it is the guided surface.
287
261
  if (operation.endsWith('.query') && cleanArgs[0] === undefined)
288
262
  cleanArgs[0] = { limit: QUERY_LIMIT.parse(undefined) };
289
263
  const given = cleanArgs[0];
@@ -316,9 +290,20 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
316
290
  cleanArgs[0] = bag;
317
291
  }
318
292
  }
293
+ // #238: operations.aggregate [operation, filter, {groupBy, sum}] gets the own-field alias like the *.query bag and autotask_query
294
+ if (operation === 'operations.aggregate' && typeof cleanArgs[0] === 'string') {
295
+ const resource = cleanArgs[0].slice(0, cleanArgs[0].lastIndexOf('.'));
296
+ if (cleanArgs[1] !== undefined)
297
+ cleanArgs[1] = normalizeFilter(cleanArgs[1], resource);
298
+ const b = cleanArgs[2];
299
+ if (b !== null && typeof b === 'object' && !Array.isArray(b)) {
300
+ const own = (v) => (Array.isArray(v) ? v.map((n) => (typeof n === 'string' ? ownField(resource, n) : n)) : v);
301
+ cleanArgs[2] = { ...b, ...('groupBy' in b ? { groupBy: own(b.groupBy) } : {}), ...('sum' in b ? { sum: own(b.sum) } : {}) };
302
+ }
303
+ }
319
304
  // #56: the contracts.profitability window default. NO opts bag (or a bag with neither date): the
320
305
  // last full calendar month (UTC) fills BOTH days, and the outcome carries the transparency note.
321
- // EXACTLY ONE date: needs_input — a half window is never sent (the caller supplies both or
306
+ // EXACTLY ONE date: INPUT_INVALID — a half window is never sent (the caller supplies both or
322
307
  // neither). BOTH dates: the caller's window passes through (the SDK validates format, order and
323
308
  // the 36-month cap). The fill rides the closed opts bag BEFORE the SDK's validation — it adds
324
309
  // the two dates and never renames a key. A null/array/scalar second arg is not an opts bag: the
@@ -347,7 +332,9 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
347
332
  const hasFrom = typeof bag.dateFrom === 'string' && bag.dateFrom.length > 0;
348
333
  const hasTo = typeof bag.dateTo === 'string' && bag.dateTo.length > 0;
349
334
  if (hasFrom !== hasTo) {
350
- throw new NeedsInputError('contracts.profitability: pass both dateFrom and dateTo, or neither — one day without the other is not a window (with neither, the window defaults to the last full calendar month, UTC)', hasFrom ? 'args[1].dateTo' : 'args[1].dateFrom', ['/args/1/dateFrom', '/args/1/dateTo']);
335
+ // #243: a recoverable INPUT_INVALID error (next supply_input), not needs_input: rule 3 reads that as "stop".
336
+ // Within the 200-char message bound (MAX_ERROR_MESSAGE_CHARS): a longer one is cut mid-sentence.
337
+ throw new InputInvalidError(`contracts.profitability: pass both dateFrom and dateTo, or neither. Call again with ${hasFrom ? 'dateTo' : 'dateFrom'} set too, or drop ${hasFrom ? 'dateFrom' : 'dateTo'} (with neither, the window is the last full calendar month, UTC).`, hasFrom ? 'args[1].dateTo' : 'args[1].dateFrom');
351
338
  }
352
339
  if (!hasFrom && !hasTo) {
353
340
  const now = new Date();
@@ -51,6 +51,13 @@ export function buildQuery(ctx) {
51
51
  if (rec === undefined || rec.effect !== 'read') {
52
52
  throw new InputInvalidError(`query: "${args.resource}" has no ${operation} read in the registry (find the resource with autotask_catalog)`, 'resource');
53
53
  }
54
+ // #241: filter is optional in the advertised schema (a form-filler dead-ends on a required Table), so a missing or
55
+ // empty one is refused here, rows and totals alike (unscoped totals answer the wrong scope), before any wire. An
56
+ // INPUT_INVALID error, as when the schema required it: a model retries it; needs_input reads as "stop" (rule 3). autotask_read *.query keeps its bounded no-filter default (limit 50; #83, #243).
57
+ if (args.filter === undefined || (Array.isArray(args.filter) && args.filter.length === 0)) {
58
+ // within the 200-char message bound (MAX_ERROR_MESSAGE_CHARS): a longer one is cut mid-word
59
+ throw new InputInvalidError('query: filter is missing or empty; call again with a scope such as {field: "companyID", op: "eq", value: "<id>"} (time_entries: ticket.companyID), or {field: "id", op: "exist"} for the whole entity', 'filter');
60
+ }
54
61
  // #228: totals mode (group_by / sum / count) is the SDK's operations.aggregate over the same filter
55
62
  if (args.group_by !== undefined || args.sum !== undefined || args.count === true) {
56
63
  const clash = ['latest', 'include_fields', 'note_chars', 'via', 'format', 'output_path', 'acknowledge_unscoped', 'untrusted_wrapper'].find((k) => args[k] !== undefined);
@@ -251,7 +251,7 @@ function toolServed(ctx, tool) {
251
251
  /** Defaults this server applies before the SDK sees the call (dispatch.ts readOneItem); the registry record does not know them. */
252
252
  const SERVER_DEFAULTS = {
253
253
  'operations.fieldCatalog': `via autotask_read: compact rows unless fields is non-empty (then full rows, labels true) or labels is true (full rows), maxBytes ${FIELD_CATALOG_MAX_BYTES} (truncated + truncation when cut); any value you pass wins`,
254
- 'contracts.profitability': 'via autotask_read, dateFrom and dateTo are optional: with neither (or no opts bag) the window defaults to the last full calendar month (UTC) and the result carries a windowNote; exactly one date is refused (needs_input)',
254
+ 'contracts.profitability': 'via autotask_read, dateFrom and dateTo are optional: with neither (or no opts bag) the window defaults to the last full calendar month (UTC) and the result carries a windowNote; exactly one date is refused (INPUT_INVALID: pass both or neither)',
255
255
  };
256
256
  /** AC27 (#183): a vendor entity with no create in the registry (live case: invoices) says so on its reads, mirroring
257
257
  * the served no-DELETE `deleteBoundary` data; a "raise an invoice" question gets answered, not hunted. Zero wire.
@@ -86,6 +86,9 @@ export function classifyError(payload) {
86
86
  if (INPUT_CODES.has(code) || payload.category === 'validation') {
87
87
  return { status: 'error', next: 'supply_input', code: 'INPUT_INVALID', retryable: false };
88
88
  }
89
+ // #243: the unscoped-walk refusal (scope-guard.ts) is a recoverable error: add the scope (or acknowledge) and call again
90
+ if (code === 'SCOPE_REQUIRED')
91
+ return { status: 'error', next: 'supply_input', code: 'SCOPE_REQUIRED', retryable: false };
89
92
  // SDK 0.37.0: a budgeted helper (operations.revenueRollup, the time_entries countOnly walk) refused before or
90
93
  // during the walk; the same input fails again, a narrower window or scope, or a raised maxVendorCalls, may not
91
94
  if (code === 'BUDGET_EXCEEDED')
@@ -425,7 +425,7 @@ export function buildResolve(ctx) {
425
425
  if (found === undefined)
426
426
  return abortedAnswer(ctx.correlationId);
427
427
  const rows = found.map((t) => (a.expand ? { resource, ref: refOf('tickets', t), record: slimRecord(t, a.expand) } : ticketRow(t)));
428
- const miss = rows.length === 0 ? ' No title contains that text: the work may use other words; find it by activity: autotask_query {resource: "time_entries", filter: [resourceID eq, dateWorked gte], group_by: ["ticketID"], sum: ["hoursWorked"]}.' : '';
428
+ const miss = rows.length === 0 ? ' No title contains that text: the work may use other words; find it by activity: autotask_query {resource: "time_entries", filter: [resourceID eq, dateWorked gte], group_by: ["ticketID"], sum: ["hoursWorked", "hoursToBill"]}.' : '';
429
429
  return ok(`Ticket title search "${a.identifier}"${a.company_id !== undefined ? ` (company ${a.company_id})` : ''}: ${rows.length} match(es).${miss}`, {
430
430
  resource,
431
431
  match: 'contains',
@@ -40,16 +40,17 @@ export declare const COMPANY_PROFILE_PART_LIMIT_DEFAULT = 10;
40
40
  * and/or groups are accepted unchanged). Only field/op/value are advertised: a form-filling client
41
41
  * fills every property it is shown, and a filled `udf` or `items` would change the query.
42
42
  * #231: the advertised condition is explicit for strict form-fillers (one prepared a required Table
43
- * with no rows and sent nothing): all three keys required, `op` the 13-operator enum, and an
44
- * `examples` entry (a relation filter plus a UTC date bound). exist/notExist take value "", which
45
- * {@link normalizeFilter} drops before dispatch.
43
+ * with no rows and sent nothing): `op` the 13-operator enum and an `examples` entry (a relation
44
+ * filter plus a UTC date bound). exist/notExist take value "", which {@link normalizeFilter} drops
45
+ * before dispatch. #241: `filter` and a condition's `value` are not required (Copilot Studio left the
46
+ * required Table unfilled and asked the user); the query handler refuses a missing or empty filter.
46
47
  */
47
48
  export declare const FILTER_OPS: readonly ["eq", "noteq", "gt", "gte", "lt", "lte", "beginsWith", "endsWith", "contains", "exist", "notExist", "in", "notIn"];
48
49
  /** #234: a `<self>.<field>` name on its own resource without the prefix; any other name unchanged. */
49
50
  export declare function ownField(resource: string, name: string): string;
50
51
  /**
51
52
  * #231: drop the empty `value` a strict form-filler sends on exist/notExist (the advertised schema
52
- * requires one on every condition). #234: a `<self>.<field>` condition on `resource` names the own
53
+ * required one on every condition until #241). #234: a `<self>.<field>` condition on `resource` names the own
53
54
  * field ({@link ownField}). Everything else passes through unchanged, groups and the bag form included.
54
55
  */
55
56
  export declare function normalizeFilter(filter: unknown, resource?: string): unknown;
@@ -60,9 +61,9 @@ export declare function normalizeFilter(filter: unknown, resource?: string): unk
60
61
  * the typed CONFIG_ERROR BEFORE any request is built (zero wire). node-autotask before 0.9.0
61
62
  * silently DROPPED a bare object's conditions, so an unrefused bare object queried UNFILTERED;
62
63
  * 0.9.0 refuses it too (#83), and this server's refusal stays the zero-wire pre-request
63
- * guarantee. The `filter` key itself is required by `QUERY_INPUT` — a no-filter call is refused at the
64
- * dispatch frame before this validator runs (the served `latest` description states this); the
65
- * `undefined` early return remains for direct callers of the validator.
64
+ * guarantee. The `filter` key is optional in `QUERY_INPUT` (#241): the query handler refuses a
65
+ * missing or empty one (INPUT_INVALID) before this validator runs; the `undefined` early return
66
+ * serves autotask_read, whose `<resource>.query` params bag may omit it.
66
67
  */
67
68
  export declare function assertFilterShape(filter: unknown, operation: string): void;
68
69
  /** A registered input: the StandardSchema the SDK sees, the zod object the frame parses, the key set. */
@@ -289,7 +290,7 @@ export declare const RESOLVE_INPUT: z.ZodObject<{
289
290
  }, z.core.$strip>;
290
291
  export declare const QUERY_INPUT: z.ZodObject<{
291
292
  resource: z.ZodString;
292
- filter: z.ZodUnknown;
293
+ filter: z.ZodOptional<z.ZodUnknown>;
293
294
  limit: z.ZodOptional<z.ZodNumber>;
294
295
  group_by: z.ZodOptional<z.ZodArray<z.ZodString>>;
295
296
  sum: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -72,21 +72,23 @@ const COMPANY_PROFILE_PART_LIMIT_LOCATIONS = z
72
72
  * and/or groups are accepted unchanged). Only field/op/value are advertised: a form-filling client
73
73
  * fills every property it is shown, and a filled `udf` or `items` would change the query.
74
74
  * #231: the advertised condition is explicit for strict form-fillers (one prepared a required Table
75
- * with no rows and sent nothing): all three keys required, `op` the 13-operator enum, and an
76
- * `examples` entry (a relation filter plus a UTC date bound). exist/notExist take value "", which
77
- * {@link normalizeFilter} drops before dispatch.
75
+ * with no rows and sent nothing): `op` the 13-operator enum and an `examples` entry (a relation
76
+ * filter plus a UTC date bound). exist/notExist take value "", which {@link normalizeFilter} drops
77
+ * before dispatch. #241: `filter` and a condition's `value` are not required (Copilot Studio left the
78
+ * required Table unfilled and asked the user); the query handler refuses a missing or empty filter.
78
79
  */
79
80
  export const FILTER_OPS = ['eq', 'noteq', 'gt', 'gte', 'lt', 'lte', 'beginsWith', 'endsWith', 'contains', 'exist', 'notExist', 'in', 'notIn'];
80
81
  const FILTER = z
81
82
  .unknown()
83
+ .optional()
82
84
  .meta({
83
85
  type: 'array',
84
86
  // op keeps type string beside the enum: a Table client types each column from `type` (#173)
85
- items: { type: 'object', properties: { field: { type: 'string' }, op: { type: 'string', enum: FILTER_OPS }, value: { type: 'string' } }, required: ['field', 'op', 'value'] },
87
+ items: { type: 'object', properties: { field: { type: 'string' }, op: { type: 'string', enum: FILTER_OPS }, value: { type: 'string' } }, required: ['field', 'op'] },
86
88
  // one filter copied verbatim by form-fillers: the ticket relation on time_entries, the own fields on tickets (#234)
87
89
  examples: [[{ field: 'ticket.companyID', op: 'eq', value: '123' }, { field: 'ticket.createDate', op: 'gte', value: '2026-10-01T00:00:00Z' }]],
88
90
  })
89
- .describe('Conditions; write every value as a string ("29", "true"; exist/notExist: ""; in/notIn: "1,2,3"). Dates: UTC ISO-8601. An unknown field name (also group_by, sum) is a pre-query CONFIG_ERROR. ticket.<field> scopes time entries by their ticket; on tickets use the plain field.');
91
+ .describe('Needed (missing or [] is refused). Conditions; write every value as a string ("29", "true"; exist/notExist: ""; in/notIn: "1,2,3"). Dates: UTC ISO-8601. An unknown field name (also group_by, sum) is a pre-query CONFIG_ERROR. ticket.<field> scopes time entries by their ticket; on tickets use the plain field.');
90
92
  /**
91
93
  * #234: the SDK's relation targets. On the target resource itself `<self>.` can only mean an own
92
94
  * field (none of them carries a `<self>ID`), so `ticket.companyID` on tickets is `companyID`; the SDK
@@ -100,7 +102,7 @@ export function ownField(resource, name) {
100
102
  }
101
103
  /**
102
104
  * #231: drop the empty `value` a strict form-filler sends on exist/notExist (the advertised schema
103
- * requires one on every condition). #234: a `<self>.<field>` condition on `resource` names the own
105
+ * required one on every condition until #241). #234: a `<self>.<field>` condition on `resource` names the own
104
106
  * field ({@link ownField}). Everything else passes through unchanged, groups and the bag form included.
105
107
  */
106
108
  export function normalizeFilter(filter, resource = '') {
@@ -131,9 +133,9 @@ export function normalizeFilter(filter, resource = '') {
131
133
  * the typed CONFIG_ERROR BEFORE any request is built (zero wire). node-autotask before 0.9.0
132
134
  * silently DROPPED a bare object's conditions, so an unrefused bare object queried UNFILTERED;
133
135
  * 0.9.0 refuses it too (#83), and this server's refusal stays the zero-wire pre-request
134
- * guarantee. The `filter` key itself is required by `QUERY_INPUT` — a no-filter call is refused at the
135
- * dispatch frame before this validator runs (the served `latest` description states this); the
136
- * `undefined` early return remains for direct callers of the validator.
136
+ * guarantee. The `filter` key is optional in `QUERY_INPUT` (#241): the query handler refuses a
137
+ * missing or empty one (INPUT_INVALID) before this validator runs; the `undefined` early return
138
+ * serves autotask_read, whose `<resource>.query` params bag may omit it.
137
139
  */
138
140
  export function assertFilterShape(filter, operation) {
139
141
  if (filter === undefined)
@@ -307,7 +309,7 @@ export const QUERY_INPUT = z.object({
307
309
  count: z.boolean().optional().describe('true: count the matching rows, no rows returned.'),
308
310
  top: z.number().int().min(1).max(100).optional().describe('Groups kept, 1-100 (default 25).'),
309
311
  include_fields: z.array(z.string().min(1)).optional().describe('Exact vendor field names to return (id always included); an unknown name is a pre-query CONFIG_ERROR. Absent: tickets and time_entries return default columns, others the full row.'),
310
- latest: z.number().int().min(1).max(2500).optional().describe('N newest records (highest id). filter is required on this tool (a no-filter call is refused); to target the whole entity pass the tautology {field: id, op: exist}. Needs limit >= N (limit defaults to 50).'),
312
+ latest: z.number().int().min(1).max(2500).optional().describe('N newest records (highest id). A filter is needed on this tool (a no-filter call is refused); to target the whole entity pass the tautology {field: id, op: exist}. Needs limit >= N (limit defaults to 50).'),
311
313
  note_chars: z.number().int().min(1).optional().describe('Cap each free-text field at N characters.'),
312
314
  via: z.enum(['post', 'get']).optional().describe('Vendor transport (get caps the URL at 2048 chars).'),
313
315
  format: z.enum(['json', 'tsv']).optional().describe('tsv: the table as data.tsv, items omitted.'),
@@ -5,7 +5,7 @@
5
5
  * not the caller's scope, and a 2,500-row page costs about a megabyte (the #233 incident). When a
6
6
  * walk is both unscoped (no `eq`/`in` condition on an id field) and large (rows walked, `min(limit, latest)`, over
7
7
  * {@link UNSCOPED_WALK_THRESHOLD}), the guard spends ONE `<resource>.count` with the same filter:
8
- * a count over the threshold is refused with the typed `SCOPE_REQUIRED` outcome (needs_input) naming
8
+ * a count over the threshold is refused with the typed `SCOPE_REQUIRED` refusal (a recoverable error, next supply_input; #243) naming
9
9
  * the count and the fix; `acknowledgeUnscoped: true` overrides.
10
10
  *
11
11
  * Fail OPEN: a count that cannot be read (a vendor error, an unreadable shape) lets the walk run and
@@ -19,9 +19,10 @@ import type { ToolContext } from './context.js';
19
19
  export declare const UNSCOPED_WALK_THRESHOLD = 500;
20
20
  /** The bag key that overrides the guard (autotask_read args[0]); autotask_query spells it `acknowledge_unscoped`. */
21
21
  export declare const ACK_KEY = "acknowledgeUnscoped";
22
- /** Typed refusal of an unscoped walk: answers the needs_input envelope (reason SCOPE_REQUIRED) or a typed slot error. */
22
+ /** Typed refusal of an unscoped walk: an error envelope (reason SCOPE_REQUIRED, next supply_input) or a typed slot error. */
23
23
  export declare class ScopeRequiredError extends Error {
24
24
  readonly code: "SCOPE_REQUIRED";
25
+ readonly field = "filter";
25
26
  readonly count: number;
26
27
  readonly threshold: number;
27
28
  readonly operation: string;
@@ -4,21 +4,24 @@ import { RATE_CODES } from './reasons.js';
4
4
  export const UNSCOPED_WALK_THRESHOLD = 500;
5
5
  /** The bag key that overrides the guard (autotask_read args[0]); autotask_query spells it `acknowledge_unscoped`. */
6
6
  export const ACK_KEY = 'acknowledgeUnscoped';
7
- /** Typed refusal of an unscoped walk: answers the needs_input envelope (reason SCOPE_REQUIRED) or a typed slot error. */
7
+ /** Typed refusal of an unscoped walk: an error envelope (reason SCOPE_REQUIRED, next supply_input) or a typed slot error. */
8
8
  export class ScopeRequiredError extends Error {
9
9
  code = 'SCOPE_REQUIRED';
10
+ field = 'filter';
10
11
  count;
11
12
  threshold;
12
13
  operation;
13
14
  suggestedAction;
14
15
  details;
15
16
  constructor(operation, count, ackName) {
16
- super(`${operation}: no id-field filter and ${count} rows match (over ${UNSCOPED_WALK_THRESHOLD}): a tenant-wide walk in vendor id order, not your scope.`);
17
+ const suggestedAction = `Filter on resolved ids (companyID, contractID, ticketID, resourceID with op eq or in), or autotask_query group_by, sum or count for totals, or pass ${ackName}: true to walk it anyway.`;
18
+ // within the 200-char message bound (MAX_ERROR_MESSAGE_CHARS) whatever the resource name: data.operation names the op
19
+ super(`${count} rows match with no id filter (over ${UNSCOPED_WALK_THRESHOLD}). Filter on companyID, contractID, ticketID or resourceID (eq or in; time entries: ticket.companyID) and call again, or pass ${ackName}: true.`);
17
20
  this.operation = operation;
18
21
  this.count = count;
19
22
  this.threshold = UNSCOPED_WALK_THRESHOLD;
20
23
  this.details = { count, threshold: UNSCOPED_WALK_THRESHOLD };
21
- this.suggestedAction = `Filter on resolved ids (companyID, contractID, ticketID, resourceID with op eq or in), or autotask_query group_by, sum or count for totals, or pass ${ackName}: true to walk it anyway.`;
24
+ this.suggestedAction = suggestedAction;
22
25
  }
23
26
  }
24
27
  /**
@@ -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.38.2";
3
+ export declare const SERVER_VERSION = "0.38.4";
@@ -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.38.2';
3
+ export const SERVER_VERSION = '0.38.4';
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.38.2";
63
+ readonly version: "0.38.4";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
@@ -79,7 +79,7 @@ Routing:
79
79
  - Who am I, version, budget, clock: autotask_tenant_status.
80
80
  - Anything else: autotask_catalog q, then autotask_describe operation, then autotask_read.
81
81
 
82
- Filters: [{field, op, value}]; op is eq, noteq, gt, gte, lt, lte, beginsWith, endsWith, contains, exist, notExist, in or notIn; group with {op: "and"|"or", items: [...]}. Field names are vendor-cased: operations.fieldCatalog args [{resources:[key], fields:[names], labels:true}], not getFieldInfo. A bare object is refused. in/notIn take an array or "1,2,3". latest N returns the N newest by id and needs limit >= N.
82
+ Filters: [{field, op, value}]; op is eq, noteq, gt, gte, lt, lte, beginsWith, endsWith, contains, exist, notExist, in or notIn; group with {op: "and"|"or", items: [...]}. Field names are vendor-cased: operations.fieldCatalog args [{resources:[key], fields:[names], labels:true}], not getFieldInfo. A bare object is refused. in/notIn take an array or "1,2,3".
83
83
  Dates: UTC ISO-8601; dateWorked is a YYYY-MM-DD date, never converted.
84
84
  Scope every *.query by resolved company/contract ids; time entries have none: filter ticket.companyID. Unscoped is a tenant-wide walk, refused above 500 rows unless acknowledge_unscoped (query) or acknowledgeUnscoped (read). Totals: autotask_query group_by/sum (same scope); time: both hoursWorked and hoursToBill.
85
85
  A list miss is not proof of absence: verify by id.${zone}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.38.2",
3
+ "version": "0.38.4",
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.38.2",
3
+ "version": "0.38.4",
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",
@@ -6,7 +6,7 @@ profile: readonly
6
6
  description: Monthly P&L of one contract: revenue, costs, profit, margin, coverage gaps and warnings.
7
7
  when_not_to_use: unbilled work of a company (unbilled-work-review); the contract record alone (autotask_contract_full_profile)
8
8
  ops: ["autotask_query:contracts.query", "autotask_read:contracts.profitability", "autotask_query:tickets.query"]
9
- inputs: {"type": "object", "required": ["contract"], "properties": {"contract": {"type": ["string", "integer"], "description": "Contract name or numeric id"}, "dateFrom": {"type": "string", "description": "YYYY-MM-DD window start. Pass both dateFrom and dateTo, or neither: with neither, the server defaults the window to the last full calendar month (UTC); exactly one is refused (needs_input)."}, "dateTo": {"type": "string", "description": "YYYY-MM-DD window end, at most 36 months after dateFrom (the both-or-neither rule of dateFrom applies)"}}}
9
+ inputs: {"type": "object", "required": ["contract"], "properties": {"contract": {"type": ["string", "integer"], "description": "Contract name or numeric id"}, "dateFrom": {"type": "string", "description": "YYYY-MM-DD window start. Pass both dateFrom and dateTo, or neither: with neither, the server defaults the window to the last full calendar month (UTC); exactly one is refused (INPUT_INVALID)."}, "dateTo": {"type": "string", "description": "YYYY-MM-DD window end, at most 36 months after dateFrom (the both-or-neither rule of dateFrom applies)"}}}
10
10
  outcomes: ["ok", "needs_input", "ambiguous", "error"]
11
11
  hazards: ["money is in the internal currency", "annual units count in their start month"]
12
12
  related: ["unbilled-work-review"]
@@ -18,7 +18,7 @@ eval: evals/contract-profitability.json
18
18
  ## Inputs
19
19
 
20
20
  - `contract` (name or id) from the caller's task; a missing `contract` is `needs_input` for it.
21
- - The window: `dateFrom` and `dateTo` (YYYY-MM-DD), both or neither. With neither, the read path defaults the window to the last full calendar month (UTC) and names it in the result's `windowNote`; exactly one side is refused (`needs_input` for both days). The usual 12-month window is passed as both days.
21
+ - The window: `dateFrom` and `dateTo` (YYYY-MM-DD), both or neither. With neither, the read path defaults the window to the last full calendar month (UTC) and names it in the result's `windowNote`; exactly one side is refused (`INPUT_INVALID` naming the missing day). The usual 12-month window is passed as both days.
22
22
 
23
23
  ## Steps
24
24
 
@@ -39,7 +39,7 @@ Profit equals revenue minus `total_cost` for each month you report; the contract
39
39
  ## Failure and rollback
40
40
 
41
41
  - `INPUT_INVALID` (window inverted or over 36 months): fix the dates and call again.
42
- - `needs_input` for the window (exactly one of `dateFrom`/`dateTo`): pass both days, or neither (the default last full calendar month, UTC).
42
+ - `INPUT_INVALID` on `args[1].dateFrom` or `args[1].dateTo` (exactly one of them given): pass both days, or neither (the default last full calendar month, UTC), and call again.
43
43
  - `TARGET_NOT_FOUND` for the contract: `needs_input` for `contract`.
44
44
  - Other `error`: return it unchanged.
45
45