iit-mcp-autotask 0.38.3 → 0.38.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,9 +9,9 @@ packaged playbooks, strong security boundaries, and predictable per-tenant in-pr
9
9
  client + SDK-owned TTL caches).
10
10
 
11
11
  > **Package status (2026-10-29):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.38.0`; the committed `pnpm-lock.yaml` pins that
12
+ > at `0.39.1`; the committed `pnpm-lock.yaml` pins that
13
13
  > release with its integrity hash, so installs resolve deterministically. The dependency is always npm
14
- > (node-autotask pinned exactly at `0.38.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.39.1`) — never a `file:`, `git:`, `link:` or workspace reference
15
15
  > to a local checkout.
16
16
 
17
17
  > **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
@@ -780,7 +780,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
780
780
 
781
781
  ## SDK dependency
782
782
 
783
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.38.0`** (an exact pin —
783
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.39.1`** (an exact pin —
784
784
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
785
785
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
786
786
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
@@ -22,20 +22,6 @@ export declare class InputInvalidError extends AutotaskConfigError {
22
22
  readonly field?: string;
23
23
  constructor(message: string, field?: string, operation?: string);
24
24
  }
25
- /**
26
- * A typed read-path answer for a WELL-FORMED but incomplete input (issue #56: one of the two
27
- * profitability window dates without the other). Unlike the thrown SDK/config errors (which the
28
- * frame maps to the `error` envelope), this one answers the NON-error `needs_input` envelope
29
- * (`next: supply_input`) — the read path's needs_input, the same shape the write path answers for
30
- * its missing inputs. In the `items` form of `autotask_read` it rides the per-item slot as a typed
31
- * payload (code `INPUT_MISSING`), like any other slot error.
32
- */
33
- export declare class NeedsInputError extends Error {
34
- readonly code: "INPUT_MISSING";
35
- readonly field: string;
36
- readonly missing: readonly string[];
37
- constructor(message: string, field: string, missing: readonly string[]);
38
- }
39
25
  /**
40
26
  * The shared call frame, run by EVERY registered handler:
41
27
  * 1. the closed top-level argument set (issue #3: an unknown key is refused with the typed
@@ -93,7 +79,7 @@ export interface ReadOutcome {
93
79
  * One registry read, through every gate in order: the unknown-operation refusal, the deployment
94
80
  * policy (reads only; credential-minting never), the ONE-level `<untrusted_content>` strip of the
95
81
  * inputs, the query/count filter-shape gate, the `contracts.profitability` window default (issue
96
- * #56: both dates absent → the last full calendar month, UTC; exactly one → needs_input), the
82
+ * #56: both dates absent → the last full calendar month, UTC; exactly one → INPUT_INVALID), the
97
83
  * governed invoke (the SDK's single read governor) and the untrusted projection of the result.
98
84
  * Errors are THROWN — the frame wraps them once, the `items` form maps them per slot.
99
85
  */
@@ -17,10 +17,10 @@ import { assertFilterShape, normalizeFilter, ownField, QUERY_LIMIT } from './sch
17
17
  import { recordById, toolForOp, untrustedFieldsOf } from './catalog-data.js';
18
18
  import { AUTOTASK_BASE_PATH } from '../base-hosts.js';
19
19
  import { releaseClientOnError } from './context.js';
20
- import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, markServerField, outcome, refusalContent } from './results.js';
20
+ import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, markServerField, refusalContent } from './results.js';
21
21
  import { lastFullCalendarMonth, lastFullCalendarMonthLabel } from './profitability-window.js';
22
22
  import { assertOperationPermitted } from '../policy.js';
23
- import { ACK_KEY, guardUnscopedWalk, ScopeRequiredError } from './scope-guard.js';
23
+ import { ACK_KEY, guardUnscopedWalk } from './scope-guard.js';
24
24
  import { rewriteFieldNameRefusal } from './field-pointer.js';
25
25
  /** The MCP request's AbortSignal, when the transport provides one (architecture 7.6). */
26
26
  function mcpSignal(extra) {
@@ -36,24 +36,6 @@ export class InputInvalidError extends AutotaskConfigError {
36
36
  this.field = field;
37
37
  }
38
38
  }
39
- /**
40
- * A typed read-path answer for a WELL-FORMED but incomplete input (issue #56: one of the two
41
- * profitability window dates without the other). Unlike the thrown SDK/config errors (which the
42
- * frame maps to the `error` envelope), this one answers the NON-error `needs_input` envelope
43
- * (`next: supply_input`) — the read path's needs_input, the same shape the write path answers for
44
- * its missing inputs. In the `items` form of `autotask_read` it rides the per-item slot as a typed
45
- * payload (code `INPUT_MISSING`), like any other slot error.
46
- */
47
- export class NeedsInputError extends Error {
48
- code = 'INPUT_MISSING';
49
- field;
50
- missing;
51
- constructor(message, field, missing) {
52
- super(message);
53
- this.field = field;
54
- this.missing = missing;
55
- }
56
- }
57
39
  /**
58
40
  * The shared call frame, run by EVERY registered handler:
59
41
  * 1. the closed top-level argument set (issue #3: an unknown key is refused with the typed
@@ -93,16 +75,6 @@ async function frame(ctx, input, rawArgs, extra, work) {
93
75
  }
94
76
  catch (err) {
95
77
  releaseClientOnError(ctx, err);
96
- // The read path's needs_input (issue #56): a well-formed but incomplete input answers the
97
- // NON-error needs_input envelope (next: supply_input) — never the error mapping.
98
- if (err instanceof NeedsInputError) {
99
- return outcome('needs_input', 'supply_input', { code: 'INPUT_MISSING', field: err.field, detail: err.message }, null, { missing: [...err.missing] });
100
- }
101
- // The unscoped-walk refusal (#197): a well-formed read that needs a scope filter answers the
102
- // NON-error needs_input envelope (reason SCOPE_REQUIRED), like the half profitability window.
103
- if (err instanceof ScopeRequiredError) {
104
- return outcome('needs_input', 'supply_input', { code: 'SCOPE_REQUIRED', field: 'filter', detail: `${err.message} ${err.suggestedAction}` }, { code: err.code, operation: err.operation, count: err.count, threshold: err.threshold, suggestedAction: err.suggestedAction }, { missing: ['/args/0/filter'] });
105
- }
106
78
  // V23/FT-5: the request's own credential values ride into the error wrap so a vendor body
107
79
  // that echoes them is value-scrubbed before it crosses.
108
80
  return errorContent(err, ctx.correlationId, requestSecrets(ctx));
@@ -268,7 +240,7 @@ function annotateCatalogRows(result) {
268
240
  * One registry read, through every gate in order: the unknown-operation refusal, the deployment
269
241
  * policy (reads only; credential-minting never), the ONE-level `<untrusted_content>` strip of the
270
242
  * inputs, the query/count filter-shape gate, the `contracts.profitability` window default (issue
271
- * #56: both dates absent → the last full calendar month, UTC; exactly one → needs_input), the
243
+ * #56: both dates absent → the last full calendar month, UTC; exactly one → INPUT_INVALID), the
272
244
  * governed invoke (the SDK's single read governor) and the untrusted projection of the result.
273
245
  * Errors are THROWN — the frame wraps them once, the `items` form maps them per slot.
274
246
  */
@@ -284,6 +256,8 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
284
256
  // pre-request (zero wire on a refused shape).
285
257
  if (operation.endsWith('.query') || operation.endsWith('.count')) {
286
258
  // No params bag at all (args omitted / []): the same bounded default (#83); the SDK's own bag-less form is unbounded.
259
+ // #243: a missing or [] filter stays accepted here (limit 50; the walk guard above 500 rows): autotask_read is the generic
260
+ // path and this bounded default is its contract; autotask_query refuses the missing filter because it is the guided surface.
287
261
  if (operation.endsWith('.query') && cleanArgs[0] === undefined)
288
262
  cleanArgs[0] = { limit: QUERY_LIMIT.parse(undefined) };
289
263
  const given = cleanArgs[0];
@@ -316,9 +290,20 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
316
290
  cleanArgs[0] = bag;
317
291
  }
318
292
  }
293
+ // #238: operations.aggregate [operation, filter, {groupBy, sum}] gets the own-field alias like the *.query bag and autotask_query
294
+ if (operation === 'operations.aggregate' && typeof cleanArgs[0] === 'string') {
295
+ const resource = cleanArgs[0].slice(0, cleanArgs[0].lastIndexOf('.'));
296
+ if (cleanArgs[1] !== undefined)
297
+ cleanArgs[1] = normalizeFilter(cleanArgs[1], resource);
298
+ const b = cleanArgs[2];
299
+ if (b !== null && typeof b === 'object' && !Array.isArray(b)) {
300
+ const own = (v) => (Array.isArray(v) ? v.map((n) => (typeof n === 'string' ? ownField(resource, n) : n)) : v);
301
+ cleanArgs[2] = { ...b, ...('groupBy' in b ? { groupBy: own(b.groupBy) } : {}), ...('sum' in b ? { sum: own(b.sum) } : {}) };
302
+ }
303
+ }
319
304
  // #56: the contracts.profitability window default. NO opts bag (or a bag with neither date): the
320
305
  // last full calendar month (UTC) fills BOTH days, and the outcome carries the transparency note.
321
- // EXACTLY ONE date: needs_input — a half window is never sent (the caller supplies both or
306
+ // EXACTLY ONE date: INPUT_INVALID — a half window is never sent (the caller supplies both or
322
307
  // neither). BOTH dates: the caller's window passes through (the SDK validates format, order and
323
308
  // the 36-month cap). The fill rides the closed opts bag BEFORE the SDK's validation — it adds
324
309
  // the two dates and never renames a key. A null/array/scalar second arg is not an opts bag: the
@@ -347,7 +332,9 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
347
332
  const hasFrom = typeof bag.dateFrom === 'string' && bag.dateFrom.length > 0;
348
333
  const hasTo = typeof bag.dateTo === 'string' && bag.dateTo.length > 0;
349
334
  if (hasFrom !== hasTo) {
350
- throw new NeedsInputError('contracts.profitability: pass both dateFrom and dateTo, or neither — one day without the other is not a window (with neither, the window defaults to the last full calendar month, UTC)', hasFrom ? 'args[1].dateTo' : 'args[1].dateFrom', ['/args/1/dateFrom', '/args/1/dateTo']);
335
+ // #243: a recoverable INPUT_INVALID error (next supply_input), not needs_input: rule 3 reads that as "stop".
336
+ // Within the 200-char message bound (MAX_ERROR_MESSAGE_CHARS): a longer one is cut mid-sentence.
337
+ throw new InputInvalidError(`contracts.profitability: pass both dateFrom and dateTo, or neither. Call again with ${hasFrom ? 'dateTo' : 'dateFrom'} set too, or drop ${hasFrom ? 'dateFrom' : 'dateTo'} (with neither, the window is the last full calendar month, UTC).`, hasFrom ? 'args[1].dateTo' : 'args[1].dateFrom');
351
338
  }
352
339
  if (!hasFrom && !hasTo) {
353
340
  const now = new Date();
@@ -53,9 +53,10 @@ export function buildQuery(ctx) {
53
53
  }
54
54
  // #241: filter is optional in the advertised schema (a form-filler dead-ends on a required Table), so a missing or
55
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).
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
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');
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');
59
60
  }
60
61
  // #228: totals mode (group_by / sum / count) is the SDK's operations.aggregate over the same filter
61
62
  if (args.group_by !== undefined || args.sum !== undefined || args.count === true) {
@@ -251,7 +251,7 @@ function toolServed(ctx, tool) {
251
251
  /** Defaults this server applies before the SDK sees the call (dispatch.ts readOneItem); the registry record does not know them. */
252
252
  const SERVER_DEFAULTS = {
253
253
  'operations.fieldCatalog': `via autotask_read: compact rows unless fields is non-empty (then full rows, labels true) or labels is true (full rows), maxBytes ${FIELD_CATALOG_MAX_BYTES} (truncated + truncation when cut); any value you pass wins`,
254
- 'contracts.profitability': 'via autotask_read, dateFrom and dateTo are optional: with neither (or no opts bag) the window defaults to the last full calendar month (UTC) and the result carries a windowNote; exactly one date is refused (needs_input)',
254
+ 'contracts.profitability': 'via autotask_read, dateFrom and dateTo are optional: with neither (or no opts bag) the window defaults to the last full calendar month (UTC) and the result carries a windowNote; exactly one date is refused (INPUT_INVALID: pass both or neither)',
255
255
  };
256
256
  /** AC27 (#183): a vendor entity with no create in the registry (live case: invoices) says so on its reads, mirroring
257
257
  * the served no-DELETE `deleteBoundary` data; a "raise an invoice" question gets answered, not hunted. Zero wire.
@@ -86,6 +86,9 @@ export function classifyError(payload) {
86
86
  if (INPUT_CODES.has(code) || payload.category === 'validation') {
87
87
  return { status: 'error', next: 'supply_input', code: 'INPUT_INVALID', retryable: false };
88
88
  }
89
+ // #243: the unscoped-walk refusal (scope-guard.ts) is a recoverable error: add the scope (or acknowledge) and call again
90
+ if (code === 'SCOPE_REQUIRED')
91
+ return { status: 'error', next: 'supply_input', code: 'SCOPE_REQUIRED', retryable: false };
89
92
  // SDK 0.37.0: a budgeted helper (operations.revenueRollup, the time_entries countOnly walk) refused before or
90
93
  // during the walk; the same input fails again, a narrower window or scope, or a raised maxVendorCalls, may not
91
94
  if (code === 'BUDGET_EXCEEDED')
@@ -425,7 +425,7 @@ export function buildResolve(ctx) {
425
425
  if (found === undefined)
426
426
  return abortedAnswer(ctx.correlationId);
427
427
  const rows = found.map((t) => (a.expand ? { resource, ref: refOf('tickets', t), record: slimRecord(t, a.expand) } : ticketRow(t)));
428
- const miss = rows.length === 0 ? ' No title contains that text: the work may use other words; find it by activity: autotask_query {resource: "time_entries", filter: [resourceID eq, dateWorked gte], group_by: ["ticketID"], sum: ["hoursWorked"]}.' : '';
428
+ const miss = rows.length === 0 ? ' No title contains that text: the work may use other words; find it by activity: autotask_query {resource: "time_entries", filter: [resourceID eq, dateWorked gte], group_by: ["ticketID"], sum: ["hoursWorked", "hoursToBill"]}.' : '';
429
429
  return ok(`Ticket title search "${a.identifier}"${a.company_id !== undefined ? ` (company ${a.company_id})` : ''}: ${rows.length} match(es).${miss}`, {
430
430
  resource,
431
431
  match: 'contains',
@@ -5,7 +5,7 @@
5
5
  * not the caller's scope, and a 2,500-row page costs about a megabyte (the #233 incident). When a
6
6
  * walk is both unscoped (no `eq`/`in` condition on an id field) and large (rows walked, `min(limit, latest)`, over
7
7
  * {@link UNSCOPED_WALK_THRESHOLD}), the guard spends ONE `<resource>.count` with the same filter:
8
- * a count over the threshold is refused with the typed `SCOPE_REQUIRED` outcome (needs_input) naming
8
+ * a count over the threshold is refused with the typed `SCOPE_REQUIRED` refusal (a recoverable error, next supply_input; #243) naming
9
9
  * the count and the fix; `acknowledgeUnscoped: true` overrides.
10
10
  *
11
11
  * Fail OPEN: a count that cannot be read (a vendor error, an unreadable shape) lets the walk run and
@@ -19,9 +19,10 @@ import type { ToolContext } from './context.js';
19
19
  export declare const UNSCOPED_WALK_THRESHOLD = 500;
20
20
  /** The bag key that overrides the guard (autotask_read args[0]); autotask_query spells it `acknowledge_unscoped`. */
21
21
  export declare const ACK_KEY = "acknowledgeUnscoped";
22
- /** Typed refusal of an unscoped walk: answers the needs_input envelope (reason SCOPE_REQUIRED) or a typed slot error. */
22
+ /** Typed refusal of an unscoped walk: an error envelope (reason SCOPE_REQUIRED, next supply_input) or a typed slot error. */
23
23
  export declare class ScopeRequiredError extends Error {
24
24
  readonly code: "SCOPE_REQUIRED";
25
+ readonly field = "filter";
25
26
  readonly count: number;
26
27
  readonly threshold: number;
27
28
  readonly operation: string;
@@ -4,21 +4,24 @@ import { RATE_CODES } from './reasons.js';
4
4
  export const UNSCOPED_WALK_THRESHOLD = 500;
5
5
  /** The bag key that overrides the guard (autotask_read args[0]); autotask_query spells it `acknowledge_unscoped`. */
6
6
  export const ACK_KEY = 'acknowledgeUnscoped';
7
- /** Typed refusal of an unscoped walk: answers the needs_input envelope (reason SCOPE_REQUIRED) or a typed slot error. */
7
+ /** Typed refusal of an unscoped walk: an error envelope (reason SCOPE_REQUIRED, next supply_input) or a typed slot error. */
8
8
  export class ScopeRequiredError extends Error {
9
9
  code = 'SCOPE_REQUIRED';
10
+ field = 'filter';
10
11
  count;
11
12
  threshold;
12
13
  operation;
13
14
  suggestedAction;
14
15
  details;
15
16
  constructor(operation, count, ackName) {
16
- super(`${operation}: no id-field filter and ${count} rows match (over ${UNSCOPED_WALK_THRESHOLD}): a tenant-wide walk in vendor id order, not your scope.`);
17
+ const suggestedAction = `Filter on resolved ids (companyID, contractID, ticketID, resourceID with op eq or in), or autotask_query group_by, sum or count for totals, or pass ${ackName}: true to walk it anyway.`;
18
+ // within the 200-char message bound (MAX_ERROR_MESSAGE_CHARS) whatever the resource name: data.operation names the op
19
+ super(`${count} rows match with no id filter (over ${UNSCOPED_WALK_THRESHOLD}). Filter on companyID, contractID, ticketID or resourceID (eq or in; time entries: ticket.companyID) and call again, or pass ${ackName}: true.`);
17
20
  this.operation = operation;
18
21
  this.count = count;
19
22
  this.threshold = UNSCOPED_WALK_THRESHOLD;
20
23
  this.details = { count, threshold: UNSCOPED_WALK_THRESHOLD };
21
- this.suggestedAction = `Filter on resolved ids (companyID, contractID, ticketID, resourceID with op eq or in), or autotask_query group_by, sum or count for totals, or pass ${ackName}: true to walk it anyway.`;
24
+ this.suggestedAction = suggestedAction;
22
25
  }
23
26
  }
24
27
  /**
@@ -1,3 +1,3 @@
1
1
  /** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
2
2
  export declare const SERVER_NAME = "iit-mcp-autotask";
3
- export declare const SERVER_VERSION = "0.38.3";
3
+ export declare const SERVER_VERSION = "0.38.4";
@@ -1,4 +1,4 @@
1
1
  /** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
2
2
  export const SERVER_NAME = 'iit-mcp-autotask';
3
- export const SERVER_VERSION = '0.38.3';
3
+ export const SERVER_VERSION = '0.38.4';
4
4
  //# sourceMappingURL=server-version.js.map
@@ -60,7 +60,7 @@ export declare function serverInstructions(config: Pick<Config, 'AUTOTASK_PROFIL
60
60
  /** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
61
61
  export declare const SERVER_IDENTITY: {
62
62
  readonly name: "iit-mcp-autotask";
63
- readonly version: "0.38.3";
63
+ readonly version: "0.38.4";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
@@ -79,7 +79,7 @@ Routing:
79
79
  - Who am I, version, budget, clock: autotask_tenant_status.
80
80
  - Anything else: autotask_catalog q, then autotask_describe operation, then autotask_read.
81
81
 
82
- Filters: [{field, op, value}]; op is eq, noteq, gt, gte, lt, lte, beginsWith, endsWith, contains, exist, notExist, in or notIn; group with {op: "and"|"or", items: [...]}. Field names are vendor-cased: operations.fieldCatalog args [{resources:[key], fields:[names], labels:true}], not getFieldInfo. A bare object is refused. in/notIn take an array or "1,2,3". latest N returns the N newest by id and needs limit >= N.
82
+ Filters: [{field, op, value}]; op is eq, noteq, gt, gte, lt, lte, beginsWith, endsWith, contains, exist, notExist, in or notIn; group with {op: "and"|"or", items: [...]}. Field names are vendor-cased: operations.fieldCatalog args [{resources:[key], fields:[names], labels:true}], not getFieldInfo. A bare object is refused. in/notIn take an array or "1,2,3".
83
83
  Dates: UTC ISO-8601; dateWorked is a YYYY-MM-DD date, never converted.
84
84
  Scope every *.query by resolved company/contract ids; time entries have none: filter ticket.companyID. Unscoped is a tenant-wide walk, refused above 500 rows unless acknowledge_unscoped (query) or acknowledgeUnscoped (read). Totals: autotask_query group_by/sum (same scope); time: both hoursWorked and hoursToBill.
85
85
  A list miss is not proof of absence: verify by id.${zone}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.38.3",
3
+ "version": "0.38.4",
4
4
  "description": "MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK",
5
5
  "license": "MIT",
6
6
  "author": "Max Soukhomlinov",
package/server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.38.3",
3
+ "version": "0.38.4",
4
4
  "description": "MCP server for the Autotask (Datto) PSA API, powered by the node-autotask SDK",
5
5
  "mcpProtocolBaseline": "2026-07-28",
6
6
  "legacyProtocolFallback": "2025-11-25",
@@ -6,7 +6,7 @@ profile: readonly
6
6
  description: Monthly P&L of one contract: revenue, costs, profit, margin, coverage gaps and warnings.
7
7
  when_not_to_use: unbilled work of a company (unbilled-work-review); the contract record alone (autotask_contract_full_profile)
8
8
  ops: ["autotask_query:contracts.query", "autotask_read:contracts.profitability", "autotask_query:tickets.query"]
9
- inputs: {"type": "object", "required": ["contract"], "properties": {"contract": {"type": ["string", "integer"], "description": "Contract name or numeric id"}, "dateFrom": {"type": "string", "description": "YYYY-MM-DD window start. Pass both dateFrom and dateTo, or neither: with neither, the server defaults the window to the last full calendar month (UTC); exactly one is refused (needs_input)."}, "dateTo": {"type": "string", "description": "YYYY-MM-DD window end, at most 36 months after dateFrom (the both-or-neither rule of dateFrom applies)"}}}
9
+ inputs: {"type": "object", "required": ["contract"], "properties": {"contract": {"type": ["string", "integer"], "description": "Contract name or numeric id"}, "dateFrom": {"type": "string", "description": "YYYY-MM-DD window start. Pass both dateFrom and dateTo, or neither: with neither, the server defaults the window to the last full calendar month (UTC); exactly one is refused (INPUT_INVALID)."}, "dateTo": {"type": "string", "description": "YYYY-MM-DD window end, at most 36 months after dateFrom (the both-or-neither rule of dateFrom applies)"}}}
10
10
  outcomes: ["ok", "needs_input", "ambiguous", "error"]
11
11
  hazards: ["money is in the internal currency", "annual units count in their start month"]
12
12
  related: ["unbilled-work-review"]
@@ -18,7 +18,7 @@ eval: evals/contract-profitability.json
18
18
  ## Inputs
19
19
 
20
20
  - `contract` (name or id) from the caller's task; a missing `contract` is `needs_input` for it.
21
- - The window: `dateFrom` and `dateTo` (YYYY-MM-DD), both or neither. With neither, the read path defaults the window to the last full calendar month (UTC) and names it in the result's `windowNote`; exactly one side is refused (`needs_input` for both days). The usual 12-month window is passed as both days.
21
+ - The window: `dateFrom` and `dateTo` (YYYY-MM-DD), both or neither. With neither, the read path defaults the window to the last full calendar month (UTC) and names it in the result's `windowNote`; exactly one side is refused (`INPUT_INVALID` naming the missing day). The usual 12-month window is passed as both days.
22
22
 
23
23
  ## Steps
24
24
 
@@ -39,7 +39,7 @@ Profit equals revenue minus `total_cost` for each month you report; the contract
39
39
  ## Failure and rollback
40
40
 
41
41
  - `INPUT_INVALID` (window inverted or over 36 months): fix the dates and call again.
42
- - `needs_input` for the window (exactly one of `dateFrom`/`dateTo`): pass both days, or neither (the default last full calendar month, UTC).
42
+ - `INPUT_INVALID` on `args[1].dateFrom` or `args[1].dateTo` (exactly one of them given): pass both days, or neither (the default last full calendar month, UTC), and call again.
43
43
  - `TARGET_NOT_FOUND` for the contract: `needs_input` for `contract`.
44
44
  - Other `error`: return it unchanged.
45
45