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 +6 -4
- package/dist/tools/dispatch.d.ts +1 -15
- package/dist/tools/dispatch.js +20 -33
- package/dist/tools/lists.js +7 -0
- package/dist/tools/meta.js +1 -1
- package/dist/tools/reasons.js +3 -0
- package/dist/tools/resolves.js +1 -1
- package/dist/tools/schemas.d.ts +9 -8
- package/dist/tools/schemas.js +12 -10
- package/dist/tools/scope-guard.d.ts +3 -2
- package/dist/tools/scope-guard.js +6 -3
- package/dist/tools/server-version.d.ts +1 -1
- package/dist/tools/server-version.js +1 -1
- package/dist/tools/server.d.ts +1 -1
- package/dist/tools/server.js +1 -1
- package/package.json +1 -1
- package/server.json +1 -1
- package/skills/contract-profitability.md +3 -3
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.
|
|
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.
|
|
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).
|
|
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.
|
|
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
|
package/dist/tools/dispatch.d.ts
CHANGED
|
@@ -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 →
|
|
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
|
*/
|
package/dist/tools/dispatch.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 →
|
|
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:
|
|
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
|
-
|
|
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();
|
package/dist/tools/lists.js
CHANGED
|
@@ -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);
|
package/dist/tools/meta.js
CHANGED
|
@@ -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 (
|
|
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.
|
package/dist/tools/reasons.js
CHANGED
|
@@ -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')
|
package/dist/tools/resolves.js
CHANGED
|
@@ -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',
|
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -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):
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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>>;
|
package/dist/tools/schemas.js
CHANGED
|
@@ -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):
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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'
|
|
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
|
-
*
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
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`
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
24
|
+
this.suggestedAction = suggestedAction;
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
@@ -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.
|
|
3
|
+
export const SERVER_VERSION = '0.38.4';
|
|
4
4
|
//# sourceMappingURL=server-version.js.map
|
package/dist/tools/server.d.ts
CHANGED
|
@@ -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.
|
|
63
|
+
readonly version: "0.38.4";
|
|
64
64
|
readonly mcpProtocolBaseline: "2026-07-28";
|
|
65
65
|
readonly legacyProtocolFallback: "2025-11-25";
|
|
66
66
|
};
|
package/dist/tools/server.js
CHANGED
|
@@ -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".
|
|
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
package/server.json
CHANGED
|
@@ -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 (
|
|
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 (`
|
|
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
|
-
- `
|
|
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
|
|