iit-mcp-autotask 0.14.0 → 0.15.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/README.md CHANGED
@@ -40,17 +40,18 @@ its contract, `autotask_read` runs it.
40
40
  `include_api_users: true`; `by: "email"` narrows to the email field; a numeric identifier is looked up
41
41
  as a Resource id); compact rows carry `apiUser` and the licence label when the tenant's labels tell it, never an email. Every match carries `ref`, its human reference. A sweep returns compact matches (`resource`, `ref`, `id`, the human fields) unless `expand: true` (the full record under `record`); `limit` caps the TOTAL matches across resources (resource order, then the SDK's order) and a contains sweep reports `truncated` and per-resource `counts {returned, found}`.
42
42
  - `autotask_query` is the bounded filtered list over `resource`: `tickets`, `time_entries`,
43
- `service_calls`, `contracts`, `time_off_requests` (limit default 50, max 2500).
43
+ `service_calls`, `contracts`, `time_off_requests` (limit default 50, max 2500). The served filter
44
+ schema declares each condition as `{field, op, value}` strings, so a client that builds one type
45
+ per array column writes `"29"`, `"true"`, `"2026-10-22"`; numbers, `in` arrays and and/or groups are
46
+ still accepted and passed through unchanged.
44
47
  - `autotask_read` runs any read by exact key, or up to 10 at once (`items`). Unbilled work
45
48
  (`companies.unbilledWork`), contract profitability (`contracts.profitability`), aggregates
46
49
  (`operations.aggregate`) and SLA health (`tickets.slaHealthCheck`) run here; `autotask_guide`
47
- holds their recipes. The two time-entry window keys (`time_entries.dateWorkedWindow`,
48
- `time_entries.postedWithin`) also take flat named inputs instead of `args`, for clients that cannot
49
- send a mixed-type array (Copilot Studio): `{operation, from, to, posted?, limit?, ticketID?,
50
- resourceID?}`, `from`/`to` as inclusive `YYYY-MM-DD` dateWorked days (never UTC-converted),
51
- `ticketID`/`resourceID` as numbers, no `tz` (it does not shift dateWorked). The SDK's
52
- `foldFlattenedOpts` maps them onto its named invoke form, so the result and `truncated` flag are
53
- the positional call's. `autotask_read` and `autotask_fetch_many` answer ONE envelope (the next minor,
50
+ holds their recipes. Its inputs are generic (`operation`, `args`, `items`): no operation-specific
51
+ fields, because form-filling clients (Copilot Studio) fill every optional input they are shown. A
52
+ client that cannot send a mixed-type array asks for a time entry window over a resource with
53
+ `autotask_query` `time_entries` (`resourceID` eq, `dateWorked` gte/lte, every value a string: the
54
+ served filter schema types `value` as a string). `autotask_read` and `autotask_fetch_many` answer ONE envelope (the next minor,
54
55
  breaking): `data = { results: Slot[], ok, failed, budget?, budgetWarning? }`, an array in input
55
56
  order even for a single read (`data.results[0].result`). A slot is `{ ok: true, operation, result,
56
57
  windowNote? }` or `{ ok: false, operation, error }`; fetch_many slots also echo `resource` and `id`
@@ -33,8 +33,7 @@ export interface QueryWalkResult {
33
33
  /** The SDK operations the handler layer drives, bound in ONE place (the mock seam). */
34
34
  export interface ToolOps {
35
35
  /** The MCP request's AbortSignal, when the transport provides one: an abandoned request stops consuming vendor budget. */
36
- /** `args` is positional, or (#173, the window keys) the SDK's named form keyed by the record's inputSchema names. */
37
- invokeRead(client: ToolClient, operation: string, args?: readonly unknown[] | Record<string, unknown>, opts?: InvokeOptions, signal?: AbortSignal): Promise<unknown>;
36
+ invokeRead(client: ToolClient, operation: string, args?: readonly unknown[], opts?: InvokeOptions, signal?: AbortSignal): Promise<unknown>;
38
37
  invokeWrite(client: ToolClient, operation: string, args?: readonly unknown[], opts?: InvokeOptions, signal?: AbortSignal): Promise<unknown>;
39
38
  invokeDelete(client: ToolClient, operation: string, args?: readonly unknown[], opts?: InvokeOptions, signal?: AbortSignal): Promise<unknown>;
40
39
  resolveAny(client: ToolClient, identifier: number | string, opts?: {
@@ -78,4 +78,4 @@ export interface ReadOutcome {
78
78
  * governed invoke (the SDK's single read governor) and the untrusted projection of the result.
79
79
  * Errors are THROWN — the frame wraps them once, the `items` form maps them per slot.
80
80
  */
81
- export declare function readOneItem(ctx: ToolContext, operation: string, args: unknown[] | Record<string, unknown>, signal: AbortSignal | undefined): Promise<ReadOutcome>;
81
+ export declare function readOneItem(ctx: ToolContext, operation: string, args: unknown[], signal: AbortSignal | undefined): Promise<ReadOutcome>;
@@ -65,11 +65,19 @@ export class NeedsInputError extends Error {
65
65
  export async function runTool(ctx, input, rawArgs, extra, work) {
66
66
  return stamp(ctx.correlationId, await frame(ctx, input, rawArgs, extra, work));
67
67
  }
68
+ /** `raw` without the keys `ignored` names (a copy; `raw` itself unchanged), or `raw` as is when there are none. */
69
+ function dropKeys(raw, ignored) {
70
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
71
+ return raw;
72
+ const keys = ignored(raw);
73
+ return keys.length === 0 ? raw : Object.fromEntries(Object.entries(raw).filter(([k]) => !keys.includes(k)));
74
+ }
68
75
  async function frame(ctx, input, rawArgs, extra, work) {
69
- const closed = closedArgsError(rawArgs ?? {}, input.validKeys, ctx.correlationId);
76
+ const raw = dropKeys(rawArgs ?? {}, input.ignored);
77
+ const closed = closedArgsError(raw, input.validKeys, ctx.correlationId);
70
78
  if (closed !== null)
71
79
  return closed;
72
- const parsed = input.schema.safeParse(rawArgs ?? {});
80
+ const parsed = input.schema.safeParse(raw);
73
81
  if (!parsed.success) {
74
82
  const issue = parsed.error.issues[0];
75
83
  const field = issue === undefined ? undefined : issue.path.map(String).join('.');
@@ -191,15 +199,19 @@ export async function readOneItem(ctx, operation, args, signal) {
191
199
  }
192
200
  assertOperationPermitted(operation, rec, ctx.config, ctx.log);
193
201
  // Issue #1: strip ONE exact outer <untrusted_content> wrapper from the string inputs.
194
- const stripped = stripUntrustedDeep(args);
195
- // A named-form object (#173) only arrives for the window keys, which no positional branch below touches.
196
- const cleanArgs = Array.isArray(stripped) ? stripped : [];
202
+ const cleanArgs = stripUntrustedDeep(args);
197
203
  // #18: the *.query / *.count ops take a params bag whose `filter` top-level shape is refused
198
204
  // pre-request (zero wire on a refused shape).
199
205
  if (operation.endsWith('.query') || operation.endsWith('.count')) {
200
206
  // No params bag at all (args omitted / []): the same bounded default (#83); the SDK's own bag-less form is unbounded.
201
207
  if (operation.endsWith('.query') && cleanArgs[0] === undefined)
202
208
  cleanArgs[0] = { limit: QUERY_LIMIT.parse(undefined) };
209
+ const given = cleanArgs[0];
210
+ // #173: a digit-string limit (a client that writes every value as text, e.g. Copilot Studio's
211
+ // limit: "5") is that number; the SDK still range-checks it.
212
+ if (given !== null && typeof given === 'object' && !Array.isArray(given) && typeof given.limit === 'string' && /^\d{1,9}$/.test(given.limit)) {
213
+ cleanArgs[0] = { ...given, limit: Number(given.limit) };
214
+ }
203
215
  const bag = cleanArgs[0];
204
216
  if (bag !== null && typeof bag === 'object' && !Array.isArray(bag)) {
205
217
  assertFilterShape(bag.filter, operation);
@@ -251,7 +263,7 @@ export async function readOneItem(ctx, operation, args, signal) {
251
263
  cleanArgs[0] = { ...bag, labels: true };
252
264
  }
253
265
  }
254
- const result = await ctx.ops.invokeRead(ctx.client, operation, Array.isArray(stripped) ? cleanArgs : stripped, undefined, signal);
266
+ const result = await ctx.ops.invokeRead(ctx.client, operation, cleanArgs, undefined, signal);
255
267
  if (result === undefined)
256
268
  return { aborted: true, result: undefined };
257
269
  const post = READ_POSTPROCESS[operation];
@@ -16,16 +16,15 @@
16
16
  * fail-closed refusal, then the work, with the SDK error contract mapped on failure.
17
17
  */
18
18
  import { AutotaskConfigError } from 'node-autotask';
19
- import { foldFlattenedOpts } from 'node-autotask/operations';
20
19
  import { CATALOG, SDK_VERSION, TOOL_NAME_BY_OP, opsForTool, recordById, servedToolName, untrustedFieldsOf } from './catalog-data.js';
21
20
  import { releaseClientOnError } from './context.js';
22
- import { abortedAnswer, InputInvalidError, NeedsInputError, readOneItem, requestSecrets } from './dispatch.js';
21
+ import { abortedAnswer, InputInvalidError, readOneItem, requestSecrets } from './dispatch.js';
23
22
  import { zoneClock } from './clock.js';
24
23
  import { crossResourceUntrusted, honestReadError, markResult, ok, typedErrorPayload } from './results.js';
25
24
  import { bm25Rank } from './rank.js';
26
25
  import { playbooksFor } from './guide.js';
27
26
  import { SERVER_NAME, SERVER_VERSION } from './server-version.js';
28
- import { CATALOG_INPUT, DESCRIBE_INPUT, COMPANY_FULL_PROFILE_INPUT, CONFIGURATION_ITEM_PROFILE_INPUT, CONTACT_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, FETCH_MANY_INPUT, INVOICE_GET_INPUT, PROJECT_GET_CONTEXT_INPUT, QUERY_INPUT, QUOTE_GET_INPUT, RESOLVE_INPUT, RESOURCE_WORKLOAD_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, TICKET_FULL_CONTEXT_INPUT, QUERY_LIMIT, READ_INPUT, TENANT_STATUS_INPUT, TENANT_STATUS_PARTS, WINDOW_KEYS, WINDOW_OPS, closedInput, } from './schemas.js';
27
+ import { CATALOG_INPUT, DESCRIBE_INPUT, COMPANY_FULL_PROFILE_INPUT, CONFIGURATION_ITEM_PROFILE_INPUT, CONTACT_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, FETCH_MANY_INPUT, INVOICE_GET_INPUT, PROJECT_GET_CONTEXT_INPUT, QUERY_INPUT, QUOTE_GET_INPUT, RESOLVE_INPUT, RESOURCE_WORKLOAD_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, TICKET_FULL_CONTEXT_INPUT, QUERY_LIMIT, READ_INPUT, TENANT_STATUS_INPUT, TENANT_STATUS_PARTS, readIgnoredKeys, closedInput, } from './schemas.js';
29
28
  import { refusalReason } from '../policy.js';
30
29
  /** Search text per catalog row (key words + purpose), built once. */
31
30
  const ROW_TEXT = CATALOG.catalog.map((r) => `${r.operation} ${r.operation.replace(/[._]/g, ' ')} ${r.purpose ?? ''}`);
@@ -251,11 +250,11 @@ async function guidanceFor(ctx, rec, reachable) {
251
250
  example: `autotask_read {operation:"${rec.id}", args:[{filter:[{field:"id",op:"gt",value:0}],limit:10}]}`,
252
251
  };
253
252
  }
254
- if (WINDOW_OPS.includes(rec.id)) {
255
- // #173: the flat named form, for clients that cannot send the mixed positional [from, to, opts]
253
+ if (rec.id === 'time_entries.dateWorkedWindow' || rec.id === 'time_entries.postedWithin') {
254
+ // #173: the positional [from, to, opts] mixes types; the same window as one uniform-string filter
256
255
  return {
257
- namedInputs: 'from, to (YYYY-MM-DD dateWorked days, inclusive; never UTC-converted), posted, limit, ticketID, resourceID (numbers) as top-level autotask_read fields instead of args; same result and truncated flag. tz is not offered: it does not shift dateWorked.',
258
- example: `autotask_read {operation:"${rec.id}", from:"2026-10-20", to:"2026-10-22", resourceID:123}`,
256
+ exampleArgs: ['2026-10-20', '2026-10-22', { resourceID: 123 }],
257
+ sameWindowAsQuery: 'autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"123"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}: the same rows up to limit (the window caps at 500; dateWorked is a date, both days inclusive), without the posted counts, for a client that cannot send a mixed-type array.',
259
258
  };
260
259
  }
261
260
  if (op === 'moveToTicket') {
@@ -315,41 +314,18 @@ async function describeRecord(ctx, operation, viaTool) {
315
314
  ...(names !== undefined ? { paramNames: names } : {}),
316
315
  });
317
316
  }
318
- /**
319
- * #173: the window keys' flat named inputs, folded by the SDK (`foldFlattenedOpts`) into its named
320
- * invoke form `{ from, to, opts }`; undefined when none is given (the positional call, unchanged).
321
- * Refused before any request: a named input on any other key or in the items form, together with
322
- * positional args, or without from and to.
323
- */
324
- function windowNamedArgs(ctx, args) {
325
- const named = Object.fromEntries(WINDOW_KEYS.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
326
- const first = Object.keys(named)[0];
327
- if (first === undefined)
328
- return undefined;
329
- const op = args.operation;
330
- if (op === undefined || !WINDOW_OPS.includes(op)) {
331
- throw new InputInvalidError(`read: ${first} is a named input of ${WINDOW_OPS.join(' and ')} only (with operation); other reads take args`, first);
332
- }
333
- if (args.args.length > 0)
334
- throw new InputInvalidError(`read: ${op} takes named inputs or args, not both`, 'args');
335
- const missing = ['from', 'to'].filter((k) => named[k] === undefined);
336
- if (missing.length > 0)
337
- throw new NeedsInputError(`read: ${op} needs both from and to (YYYY-MM-DD) with its named inputs`, missing[0], missing.map((k) => `/${k}`));
338
- return foldFlattenedOpts(ctx.record(op), named);
339
- }
340
317
  /** `autotask_read`: one read by exact key, or up to 10 (`items`) with per-item results. */
341
318
  export function buildRead(ctx) {
342
319
  return {
343
320
  title: 'Run Autotask read operations by exact key',
344
- input: closedInput(READ_INPUT),
321
+ input: closedInput(READ_INPUT, readIgnoredKeys),
345
322
  run: async (raw, signal) => {
346
323
  const args = raw;
347
324
  if ((args.operation === undefined) === (args.items === undefined)) {
348
325
  throw new InputInvalidError('read: pass exactly one of operation (with args) or items', 'operation');
349
326
  }
350
- const named = windowNamedArgs(ctx, args); // before the branch: a named input beside items is refused, never ignored
351
327
  if (args.operation !== undefined) {
352
- const out = await readOneItem(ctx, args.operation, named ?? args.args, signal);
328
+ const out = await readOneItem(ctx, args.operation, args.args, signal);
353
329
  if (out.aborted)
354
330
  return abortedAnswer(ctx.correlationId);
355
331
  // The server-side note (issue #56: the profitability window default applied) rides the
@@ -48,14 +48,18 @@ export interface ClosedInput {
48
48
  input: StandardSchemaWithJSON<Record<string, unknown>, Record<string, unknown>>;
49
49
  schema: z.ZodType;
50
50
  validKeys: readonly string[];
51
+ /** The raw top-level keys to drop before the closed-key check (none for most tools). */
52
+ ignored: (raw: Record<string, unknown>) => readonly string[];
51
53
  }
52
54
  /**
53
55
  * Wrap a zod object schema so its top level is CLOSED and the SDK never rejects it:
54
56
  * - the ADVERTISED wire schema is the object's JSON Schema with `additionalProperties: false`;
55
57
  * - `validate` returns the raw arguments unchanged — the call frame runs the closed-key check and
56
58
  * the zod parse, so every refusal is an envelope (M1), never the SDK's plain-text error.
59
+ * `ignored(raw)` names raw keys dropped before the closed-key check (never advertised, never named
60
+ * as valid keys).
57
61
  */
58
- export declare function closedInput(schema: z.ZodType): ClosedInput;
62
+ export declare function closedInput(schema: z.ZodType, ignored?: ClosedInput['ignored']): ClosedInput;
59
63
  /**
60
64
  * The envelope as served: every field typed only. The `status` / `next` values are listed once,
61
65
  * in the server instructions (that is the byte budget: the zod form with enums and bounds costs
@@ -211,12 +215,16 @@ export declare const DESCRIBE_INPUT: z.ZodObject<{
211
215
  }, z.core.$strip>;
212
216
  export declare const READ_MAX_ITEMS = 10;
213
217
  /**
214
- * #173: the two time-entry window keys also take flat, typed named inputs (the SDK's own names,
215
- * as its MCP projection serves them), because a client that cannot send a mixed-type array cannot
216
- * send their positional `[from, to, opts]`. `tz` is left out: it does not shift a dateWorked day.
218
+ * 0.15.0: autotask_read carries no operation-specific inputs. The six flat window inputs of 0.14.x
219
+ * are gone from the served schema (a form-filling client fills every optional it is shown, e.g. a
220
+ * stray limit: 5 on a resources.query), but a client still holding the 0.14.x tool list may send
221
+ * them. On any other read they are dropped (they never meant anything there). Beside a window key
222
+ * (top level or in items) a set one is NOT dropped, so the closed-key check refuses it naming the
223
+ * key: dropping a resourceID or ticketID there would silently widen the window. null or '' is unset.
224
+ * ponytail: drop this list once no 0.14.x tool list can be cached (a later minor).
217
225
  */
218
- export declare const WINDOW_OPS: readonly string[];
219
- export declare const WINDOW_KEYS: readonly ["from", "to", "posted", "limit", "ticketID", "resourceID"];
226
+ export declare const READ_RETIRED_KEYS: readonly ["from", "to", "posted", "limit", "ticketID", "resourceID"];
227
+ export declare function readIgnoredKeys(raw: Record<string, unknown>): readonly string[];
220
228
  export declare const READ_INPUT: z.ZodObject<{
221
229
  operation: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
222
230
  args: z.ZodPreprocess<z.ZodDefault<z.ZodArray<z.ZodUnknown>>, unknown>;
@@ -224,12 +232,6 @@ export declare const READ_INPUT: z.ZodObject<{
224
232
  operation: z.ZodString;
225
233
  args: z.ZodDefault<z.ZodArray<z.ZodUnknown>>;
226
234
  }, z.core.$strict>>>, unknown>;
227
- from: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
228
- to: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
229
- posted: z.ZodPreprocess<z.ZodOptional<z.ZodBoolean>, unknown>;
230
- limit: z.ZodPreprocess<z.ZodOptional<z.ZodNumber>, unknown>;
231
- ticketID: z.ZodPreprocess<z.ZodOptional<z.ZodNumber>, unknown>;
232
- resourceID: z.ZodPreprocess<z.ZodOptional<z.ZodNumber>, unknown>;
233
235
  }, z.core.$strip>;
234
236
  export declare const RESOLVE_INPUT: z.ZodObject<{
235
237
  identifier: z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>;
@@ -65,8 +65,18 @@ const COMPANY_PROFILE_PART_LIMIT_LOCATIONS = z
65
65
  * The JSON filter (pass-through for WELL-FORMED shapes: the condition grammar — 13 operators +
66
66
  * and/or — is validated by the SDK pre-request; unsupported TOP-LEVEL shapes are refused by this
67
67
  * server pre-request, see {@link assertFilterShape}).
68
+ *
69
+ * #173: the ADVERTISED schema types a condition's `value` as a string, so a client that builds one
70
+ * column type per array (Copilot Studio's Table) gets a uniform string column instead of inferring
71
+ * one from mixed values; the runtime stays pass-through (numbers, booleans, in/notIn arrays and
72
+ * and/or groups are accepted unchanged, nothing is converted). Only field/op/value are advertised:
73
+ * a form-filling client fills every property it is shown, and a filled `udf` or `items` would change
74
+ * the query.
68
75
  */
69
- const FILTER = z.unknown().describe('Array of {field, op, value} conditions (grammar: server instructions). Field names are pre-validated against the field catalog (0.20.0): an unknown name is a CONFIG_ERROR with the field-catalog pointer — zero QUERY wire (a cold field cache makes one vendor metadata GET first); one udf:true condition per query is exempt. When a client cannot send mixed-type arrays (Copilot Studio), write every value as a string: string dates and ids work; other types are not verified on every field.');
76
+ const FILTER = z
77
+ .unknown()
78
+ .meta({ type: 'array', items: { type: 'object', properties: { field: { type: 'string' }, op: { type: 'string' }, value: { type: 'string' } } } })
79
+ .describe('Conditions {field, op, value} (grammar: server instructions); write every value as a string: "29", "true", "2026-10-22" (numbers also work; in/notIn take an array, and/or groups take items). Field names are pre-validated against the field catalog: an unknown name is a CONFIG_ERROR with the field-catalog pointer, zero QUERY wire (a cold field cache makes one vendor metadata GET first); one udf:true condition per query is exempt.');
70
80
  /**
71
81
  * The pre-dispatch filter-shape gate (#18): accepts the two top-level shapes the pinned SDK
72
82
  * understands — a conditions array, or a params bag whose `filter` field is a conditions array
@@ -100,8 +110,10 @@ export function assertEnrichApplicable(resource, keys, operation) {
100
110
  * - the ADVERTISED wire schema is the object's JSON Schema with `additionalProperties: false`;
101
111
  * - `validate` returns the raw arguments unchanged — the call frame runs the closed-key check and
102
112
  * the zod parse, so every refusal is an envelope (M1), never the SDK's plain-text error.
113
+ * `ignored(raw)` names raw keys dropped before the closed-key check (never advertised, never named
114
+ * as valid keys).
103
115
  */
104
- export function closedInput(schema) {
116
+ export function closedInput(schema, ignored = () => []) {
105
117
  const advertised = z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' });
106
118
  const validKeys = Object.keys(advertised.properties ?? {});
107
119
  const input = {
@@ -119,7 +131,7 @@ export function closedInput(schema) {
119
131
  },
120
132
  },
121
133
  };
122
- return { input, schema, validKeys };
134
+ return { input, schema, validKeys, ignored };
123
135
  }
124
136
  // ---------------------------------------------------------------------------
125
137
  // The served output schema: the compact result envelope (one per tool)
@@ -203,30 +215,31 @@ export const DESCRIBE_INPUT = z.object({
203
215
  });
204
216
  const READ_ITEM = z.strictObject({ operation: OP_KEY.describe('Exact read key.'), args: ARGS });
205
217
  export const READ_MAX_ITEMS = 10;
206
- /**
207
- * #173: the two time-entry window keys also take flat, typed named inputs (the SDK's own names,
208
- * as its MCP projection serves them), because a client that cannot send a mixed-type array cannot
209
- * send their positional `[from, to, opts]`. `tz` is left out: it does not shift a dateWorked day.
210
- */
211
- export const WINDOW_OPS = ['time_entries.dateWorkedWindow', 'time_entries.postedWithin'];
212
- export const WINDOW_KEYS = ['from', 'to', 'posted', 'limit', 'ticketID', 'resourceID'];
213
218
  /**
214
219
  * An autotask_read input sent as null or '' is absent: form-filling clients (Copilot Studio) can
215
- * send every unset optional that way, e.g. args: null beside the named window inputs. The advertised schema stays the plain scalar (no null union).
220
+ * send every unset optional that way. The advertised schema stays the plain type (no null union).
216
221
  */
217
222
  const unset = (schema) => z.preprocess((v) => (v === null || v === '' ? undefined : v), schema);
223
+ /**
224
+ * 0.15.0: autotask_read carries no operation-specific inputs. The six flat window inputs of 0.14.x
225
+ * are gone from the served schema (a form-filling client fills every optional it is shown, e.g. a
226
+ * stray limit: 5 on a resources.query), but a client still holding the 0.14.x tool list may send
227
+ * them. On any other read they are dropped (they never meant anything there). Beside a window key
228
+ * (top level or in items) a set one is NOT dropped, so the closed-key check refuses it naming the
229
+ * key: dropping a resourceID or ticketID there would silently widen the window. null or '' is unset.
230
+ * ponytail: drop this list once no 0.14.x tool list can be cached (a later minor).
231
+ */
232
+ export const READ_RETIRED_KEYS = ['from', 'to', 'posted', 'limit', 'ticketID', 'resourceID'];
233
+ const WINDOW_OPS = ['time_entries.dateWorkedWindow', 'time_entries.postedWithin'];
234
+ export function readIgnoredKeys(raw) {
235
+ const items = Array.isArray(raw.items) ? raw.items : [];
236
+ const window = WINDOW_OPS.includes(raw.operation) || items.some((i) => typeof i === 'object' && i !== null && WINDOW_OPS.includes(i.operation));
237
+ return READ_RETIRED_KEYS.filter((k) => k in raw && (!window || raw[k] === null || raw[k] === ''));
238
+ }
218
239
  export const READ_INPUT = z.object({
219
240
  operation: unset(OP_KEY.optional()).describe('Exact read key, e.g. tickets.get (autotask_catalog finds it).'),
220
241
  args: unset(ARGS),
221
242
  items: unset(z.array(READ_ITEM).min(1).max(READ_MAX_ITEMS).optional()).describe('Up to 10 reads in one call, each { operation, args }; use instead of operation.'),
222
- // One description for the set: every served byte counts against the readonly-core budget. The SDK
223
- // refuses a from/to that is not a real YYYY-MM-DD day, naming it, before any request.
224
- from: unset(z.string().optional()).describe('time_entries.dateWorkedWindow/postedWithin only, instead of args: from, to are YYYY-MM-DD dateWorked days (inclusive); also posted, limit, ticketID, resourceID.'),
225
- to: unset(z.string().optional()),
226
- posted: unset(z.boolean().optional()),
227
- limit: unset(z.number().int().min(1).optional()),
228
- ticketID: unset(z.number().int().nonnegative().optional()),
229
- resourceID: unset(z.number().int().nonnegative().optional()),
230
243
  });
231
244
  export const RESOLVE_INPUT = z.object({
232
245
  identifier: z.union([z.number().int().nonnegative(), z.string().min(1).max(200)]).describe('Numeric id, name, ticket number, serial or email. resources: numeric identifiers are looked up as Resource ids.'),
@@ -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.14.0";
3
+ export declare const SERVER_VERSION = "0.15.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.14.0';
3
+ export const SERVER_VERSION = '0.15.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.14.0";
63
+ readonly version: "0.15.0";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.14.0",
3
+ "version": "0.15.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.14.0",
3
+ "version": "0.15.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",
@@ -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", "autotask_read:time_entries.dateWorkedWindow", "autotask_read:time_entries.postedWithin"]
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"]
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)"]
@@ -28,7 +28,7 @@ No inputs: this is a reference page about the filter behaviour of autotask_query
28
28
  3. tickets.search is a vendor contains on the TITLE only: no free-text search, no matched-field reporting, and vendor contains is looser than a literal substring (case/token matching can return MORE hits than a local title-substring check). The per-field contains of tickets.query is the precise tool.
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
- 6. Some clients (Copilot Studio) fail locally, before any request, when an array mixes value types: a number and a string in one filter, or dateWorkedWindow args (strings plus an object). For such a client, write every filter value as a string ("<resource id>", "2026-10-22", "true"). For a time entry window use the named form instead of args, all flat scalars: `autotask_read {operation:"time_entries.dateWorkedWindow", from:"2026-10-20", to:"2026-10-22", resourceID:<resource id>}` (also posted, limit, ticketID; resourceID and ticketID are numbers; the same for time_entries.postedWithin). It returns the same rows, counts and truncated flag as the positional call; from and to are inclusive YYYY-MM-DD dateWorked days, never UTC-converted, and there is no tz input (tz does not shift dateWorked). The server and SDK pass filter values through unchanged; string dates and ids are confirmed to work, other types are not verified on every field.
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
32
 
33
33
  ## Guardrails
34
34
 
@@ -68,6 +68,4 @@ No inputs: this is a reference page about the filter behaviour of autotask_query
68
68
  | autotask_query | contracts.query | read | - |
69
69
  | autotask_query | time_off_requests.query | read | - |
70
70
  | autotask_read | operations.fieldCatalog | read | - |
71
- | autotask_read | time_entries.dateWorkedWindow | read | from, to |
72
- | autotask_read | time_entries.postedWithin | read | from, to |
73
71
  <!-- generated:end -->