iit-mcp-autotask 0.38.2 → 0.38.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -51,6 +51,12 @@ 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).
57
+ if (args.filter === undefined || (Array.isArray(args.filter) && args.filter.length === 0)) {
58
+ throw new InputInvalidError('query: filter is missing or empty; call again with a scope condition such as {field: "companyID", op: "eq", value: "<id>"} (time_entries: ticket.companyID), or {field: "id", op: "exist"} for the whole entity', 'filter');
59
+ }
54
60
  // #228: totals mode (group_by / sum / count) is the SDK's operations.aggregate over the same filter
55
61
  if (args.group_by !== undefined || args.sum !== undefined || args.count === true) {
56
62
  const clash = ['latest', 'include_fields', 'note_chars', 'via', 'format', 'output_path', 'acknowledge_unscoped', 'untrusted_wrapper'].find((k) => args[k] !== undefined);
@@ -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.'),
@@ -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.3";
@@ -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.3';
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.3";
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.38.2",
3
+ "version": "0.38.3",
4
4
  "description": "MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK",
5
5
  "license": "MIT",
6
6
  "author": "Max Soukhomlinov",
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.3",
4
4
  "description": "MCP server for the Autotask (Datto) PSA API, powered by the node-autotask SDK",
5
5
  "mcpProtocolBaseline": "2026-07-28",
6
6
  "legacyProtocolFallback": "2025-11-25",