iit-mcp-autotask 0.13.1 → 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,11 +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. `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,
48
55
  breaking): `data = { results: Slot[], ok, failed, budget?, budgetWarning? }`, an array in input
49
56
  order even for a single read (`data.results[0].result`). A slot is `{ ok: true, operation, result,
50
57
  windowNote? }` or `{ ok: false, operation, error }`; fetch_many slots also echo `resource` and `id`
@@ -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('.');
@@ -198,6 +206,12 @@ export async function readOneItem(ctx, operation, args, signal) {
198
206
  // No params bag at all (args omitted / []): the same bounded default (#83); the SDK's own bag-less form is unbounded.
199
207
  if (operation.endsWith('.query') && cleanArgs[0] === undefined)
200
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
+ }
201
215
  const bag = cleanArgs[0];
202
216
  if (bag !== null && typeof bag === 'object' && !Array.isArray(bag)) {
203
217
  assertFilterShape(bag.filter, operation);
@@ -24,7 +24,7 @@ import { crossResourceUntrusted, honestReadError, markResult, ok, typedErrorPayl
24
24
  import { bm25Rank } from './rank.js';
25
25
  import { playbooksFor } from './guide.js';
26
26
  import { SERVER_NAME, SERVER_VERSION } from './server-version.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, 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';
28
28
  import { refusalReason } from '../policy.js';
29
29
  /** Search text per catalog row (key words + purpose), built once. */
30
30
  const ROW_TEXT = CATALOG.catalog.map((r) => `${r.operation} ${r.operation.replace(/[._]/g, ' ')} ${r.purpose ?? ''}`);
@@ -250,6 +250,13 @@ async function guidanceFor(ctx, rec, reachable) {
250
250
  example: `autotask_read {operation:"${rec.id}", args:[{filter:[{field:"id",op:"gt",value:0}],limit:10}]}`,
251
251
  };
252
252
  }
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
255
+ return {
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.',
258
+ };
259
+ }
253
260
  if (op === 'moveToTicket') {
254
261
  // the move ops (#107): zero wire, a pointer to the governed path
255
262
  return {
@@ -311,7 +318,7 @@ async function describeRecord(ctx, operation, viaTool) {
311
318
  export function buildRead(ctx) {
312
319
  return {
313
320
  title: 'Run Autotask read operations by exact key',
314
- input: closedInput(READ_INPUT),
321
+ input: closedInput(READ_INPUT, readIgnoredKeys),
315
322
  run: async (raw, signal) => {
316
323
  const args = raw;
317
324
  if ((args.operation === undefined) === (args.items === undefined)) {
@@ -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
@@ -210,13 +214,24 @@ export declare const DESCRIBE_INPUT: z.ZodObject<{
210
214
  resource: z.ZodOptional<z.ZodString>;
211
215
  }, z.core.$strip>;
212
216
  export declare const READ_MAX_ITEMS = 10;
217
+ /**
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).
225
+ */
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[];
213
228
  export declare const READ_INPUT: z.ZodObject<{
214
- operation: z.ZodOptional<z.ZodString>;
215
- args: z.ZodDefault<z.ZodArray<z.ZodUnknown>>;
216
- items: z.ZodOptional<z.ZodArray<z.ZodObject<{
229
+ operation: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
230
+ args: z.ZodPreprocess<z.ZodDefault<z.ZodArray<z.ZodUnknown>>, unknown>;
231
+ items: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
217
232
  operation: z.ZodString;
218
233
  args: z.ZodDefault<z.ZodArray<z.ZodUnknown>>;
219
- }, z.core.$strict>>>;
234
+ }, z.core.$strict>>>, unknown>;
220
235
  }, z.core.$strip>;
221
236
  export declare const RESOLVE_INPUT: z.ZodObject<{
222
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,10 +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;
218
+ /**
219
+ * An autotask_read input sent as null or '' is absent: form-filling clients (Copilot Studio) can
220
+ * send every unset optional that way. The advertised schema stays the plain type (no null union).
221
+ */
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
+ }
206
239
  export const READ_INPUT = z.object({
207
- operation: OP_KEY.optional().describe('Exact read key, e.g. tickets.get (autotask_catalog finds it).'),
208
- args: ARGS,
209
- items: 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.'),
240
+ operation: unset(OP_KEY.optional()).describe('Exact read key, e.g. tickets.get (autotask_catalog finds it).'),
241
+ args: unset(ARGS),
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.'),
210
243
  });
211
244
  export const RESOLVE_INPUT = z.object({
212
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.13.1";
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.13.1';
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.13.1";
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.13.1",
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.13.1",
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",
@@ -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") and use autotask_query time_entries with a dateWorked gte/lte pair in place of dateWorkedWindow. The server and SDK pass 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