iit-mcp-autotask 0.29.0 → 0.30.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/capabilities.json CHANGED
@@ -340,7 +340,7 @@
340
340
  },
341
341
  {
342
342
  "id": "autotask.generic.write",
343
- "description": "Preview a create, update or delete by exact key and target ids (never names): validates, reads the target, checks the policy and returns a plan handle and diff. Changes nothing; autotask_apply executes the plan.",
343
+ "description": "Preview any write (create, update, delete or a composite helper) by exact key, args in declaration order, ids never names: validates, reads the target, returns a plan and diff. A composite op echoes its args only, no field diff.",
344
344
  "mcpPrimitive": "tool",
345
345
  "toolName": "autotask_write",
346
346
  "type": "write",
@@ -378,7 +378,7 @@
378
378
  "autotask_describe",
379
379
  "autotask_read"
380
380
  ],
381
- "backingRegistryOperations": 1278,
381
+ "backingRegistryOperations": 1279,
382
382
  "note": "autotask_catalog finds the exact operation key (ranked search), autotask_describe serves its contract, autotask_read runs any effect=read key. Writes go through the operator profile (autotask_write then autotask_apply); authenticate.* is never served."
383
383
  },
384
384
  "schemas": {}
@@ -12,13 +12,12 @@ const EXACT = /^[A-Za-z][A-Za-z0-9_]*\.[A-Za-z][A-Za-z0-9]*$/;
12
12
  const FIELD = /^[A-Za-z][A-Za-z0-9_]*$/;
13
13
  const LIMIT = /^([1-9]\d{0,5})\/(h|d)$/;
14
14
  const TRUSTED = /^[A-Za-z0-9._:-]{1,200}$/;
15
- /** Operations the preview/apply path can serve: real writes with a snapshot-able shape. */
15
+ /** Operations the preview/apply path serves: every write and destructive record the hard rules do not refuse. */
16
16
  const servable = () => {
17
17
  const out = new Map();
18
18
  for (const r of RECORDS) {
19
- const shape = writeShape(r);
20
- if (hardRuleFor(r.id, r).never === undefined && shape !== undefined)
21
- out.set(r.id, shape);
19
+ if (hardRuleFor(r.id, r).never === undefined)
20
+ out.set(r.id, writeShape(r));
22
21
  }
23
22
  return out;
24
23
  };
@@ -55,9 +54,9 @@ function parseWrite(raw, ops, problems) {
55
54
  continue;
56
55
  }
57
56
  if (exact && !ops.has(pattern)) {
58
- const rec = recordById(pattern);
59
- const never = hardRuleFor(pattern, rec).never;
60
- problems.push(`${pattern}: ${never ?? 'the argument shape is not supported by preview/apply'}`);
57
+ // every write is servable, so this is a read, a credential-minting op or an unknown key
58
+ const never = hardRuleFor(pattern, recordById(pattern)).never;
59
+ problems.push(`${pattern}: ${never ?? 'not a served write'}`);
61
60
  continue;
62
61
  }
63
62
  if (exact && mode === 'allow' && hardRuleFor(pattern, recordById(pattern)).approval === 'always') {
@@ -102,7 +102,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
102
102
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
103
103
  * describes the SDK behind it.
104
104
  */
105
- export declare const BUILT_AGAINST_PLAN_HASH = "7004015258f8bd5b6a0fd1b27fca39511af3c4b1c190804af8a615f8741b5670";
105
+ export declare const BUILT_AGAINST_PLAN_HASH = "48ee23d80fad38531486c52af40f388df810d0ee4b8d368f7d18628e276578d0";
106
106
  /**
107
107
  * Fail fast when the installed SDK's plan has moved out from under this surface.
108
108
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -126,7 +126,7 @@ export const TOOL_DESCRIPTIONS = {
126
126
  autotask_contract_full_profile: 'One contract with services, billing rules, the 50 newest charges and service units. Monthly P&L: autotask_read (guide: contract-profitability).',
127
127
  autotask_quote_get: 'One quote with its line items in one call. A list of quotes: autotask_read quotes.query.',
128
128
  autotask_invoice_get: 'One invoice: the record (line data rides on it), or the html, xml or pdf export (format; in-band as a base64 document, or raw to output_path on stdio). A list: autotask_read invoices.query.',
129
- autotask_write: 'Preview a create, update or delete by exact key and target ids (never names): validates, reads the target, checks the policy and returns a plan handle and diff. Changes nothing; autotask_apply executes the plan.',
129
+ autotask_write: 'Preview any write (create, update, delete or a composite helper) by exact key, args in declaration order, ids never names: validates, reads the target, returns a plan and diff. A composite op echoes its args only, no field diff.',
130
130
  autotask_apply: 'Execute exactly one previewed plan (plan handle from autotask_write). Re-applying is safe: a recorded outcome replays. After an unknown outcome re-read the target; never re-apply blindly.',
131
131
  };
132
132
  /** The served description for one registered tool. */
@@ -164,7 +164,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
164
164
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
165
165
  * describes the SDK behind it.
166
166
  */
167
- export const BUILT_AGAINST_PLAN_HASH = '7004015258f8bd5b6a0fd1b27fca39511af3c4b1c190804af8a615f8741b5670';
167
+ export const BUILT_AGAINST_PLAN_HASH = '48ee23d80fad38531486c52af40f388df810d0ee4b8d368f7d18628e276578d0';
168
168
  /**
169
169
  * Fail fast when the installed SDK's plan has moved out from under this surface.
170
170
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -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.29.0";
3
+ export declare const SERVER_VERSION = "0.30.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.29.0';
3
+ export const SERVER_VERSION = '0.30.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.29.0";
63
+ readonly version: "0.30.0";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
@@ -79,6 +79,8 @@ export interface Sent {
79
79
  correlationId?: string;
80
80
  manualAction?: Record<string, unknown>;
81
81
  } | undefined;
82
+ /** The generic `call` shape's own SDK result (there is no shaped outcome to report instead). */
83
+ callResult?: unknown;
82
84
  }
83
85
  /** `apply_start`, then the one SDK write under the cap. Never returns a ToolReturn: the outcome is always a ledger entry. */
84
86
  export declare function send(a: ApplyCtx, s: {
@@ -97,4 +99,5 @@ export declare function respond(a: ApplyCtx, r: {
97
99
  entry: LedgerEntry;
98
100
  error: unknown;
99
101
  moveResult: Sent['moveResult'];
102
+ callResult?: unknown;
100
103
  }): Promise<ToolReturn>;
@@ -235,6 +235,7 @@ export async function send(a, s) {
235
235
  let entry;
236
236
  let error;
237
237
  let moveResult;
238
+ let callResult;
238
239
  try {
239
240
  // ponytail: past the cap the write keeps running and its late answer is not recorded; the
240
241
  // outcome stays unknown (cooldown, re-read) rather than racing a second ledger update.
@@ -266,6 +267,8 @@ export async function send(a, s) {
266
267
  moveResult = m === null ? undefined : { status: String(m.status), correlationId: typeof m.correlationId === 'string' ? m.correlationId : undefined, ...(m.manualAction !== undefined ? { manualAction: { action: m.manualAction.action, uiLink: m.manualAction.uiLink, reason: m.manualAction.reason } } : {}) };
267
268
  }
268
269
  else {
270
+ if (shape === 'call')
271
+ callResult = result;
269
272
  const id = result?.id;
270
273
  const createdId = typeof id === 'number' || typeof id === 'string' ? id : undefined;
271
274
  entry = {
@@ -303,7 +306,7 @@ export async function send(a, s) {
303
306
  finally {
304
307
  clearTimeout(timer);
305
308
  }
306
- return { entry, error, moveResult };
309
+ return { entry, error, moveResult, ...(shape === 'call' ? { callResult: callResult ?? null } : {}) };
307
310
  }
308
311
  /** Record the outcome (under the op lock) and run the post-write standing check. Throws a ConfigError by design; never caught here. */
309
312
  export function recordResult(a, entry, confirmation) {
@@ -344,13 +347,16 @@ export function recordResult(a, entry, confirmation) {
344
347
  /** Answer from the recorded entry: failures, moves, and the read-backs of creates and updates. */
345
348
  export async function respond(a, r) {
346
349
  const { ctx, store, plan } = a;
347
- const { record, shape, entry, error, moveResult } = r;
350
+ const { record, shape, entry, error, moveResult, callResult } = r;
348
351
  if (entry.state !== 'succeeded') {
349
352
  const data = error === undefined ? {} : { error: honestError(typedErrorPayload(error, ctx.correlationId, requestSecrets(ctx)), error, entry) };
350
353
  return outcomeAnswer(plan, entry, { replayed: false, data });
351
354
  }
352
355
  if (shape === 'move')
353
356
  return outcomeAnswer(plan, entry, { replayed: false, ...(moveResult !== undefined ? { data: { move: moveResult } } : {}) });
357
+ // The generic shape answers the operation's own result (the ledger records ids and the outcome, never a payload)
358
+ if (shape === 'call')
359
+ return outcomeAnswer(plan, entry, { replayed: false, result: callResult ?? null });
354
360
  // A create reads its stored record back (one governed get); a failed read-back never fails the write.
355
361
  if (shape.startsWith('create') && entry.outcome?.id !== undefined) {
356
362
  try {
@@ -56,7 +56,7 @@ export function buildApply(ctx) {
56
56
  return claimed;
57
57
  const sent = await send(a, { confirmation, record, shape, snap, base: claimed.ok.base });
58
58
  recordResult(a, sent.entry, confirmation);
59
- return respond(a, { record, shape, entry: sent.entry, error: sent.error, moveResult: sent.moveResult });
59
+ return respond(a, { record, shape, entry: sent.entry, error: sent.error, moveResult: sent.moveResult, callResult: sent.callResult });
60
60
  });
61
61
  },
62
62
  };
@@ -14,9 +14,10 @@ export declare function notPersistedAnswer(plan: PlanRecord, entry: LedgerEntry,
14
14
  record?: unknown;
15
15
  unrecorded?: boolean;
16
16
  }): ToolReturn;
17
- /** The envelope for a recorded (or just recorded) outcome. */
17
+ /** The envelope for a recorded (or just recorded) outcome. `result` overrides the ledger id a success reports. */
18
18
  export declare function outcomeAnswer(plan: PlanRecord, entry: LedgerEntry, extra: {
19
19
  replayed: boolean;
20
20
  data?: Record<string, unknown>;
21
21
  summary?: string;
22
+ result?: unknown;
22
23
  }): ToolReturn;
@@ -30,7 +30,7 @@ export function notPersistedAnswer(plan, entry, fields, extra) {
30
30
  notPersisted: fields,
31
31
  }, { plan: plan.handle, planHash: plan.planHash, ...(extra.replayed ? { replayed: true } : {}), summary: `${plan.op} reached the vendor (HTTP success) but ${fields.join(', ')} did not change.${extra.unrecorded === true ? ' This miss could not be recorded (the state lock was busy): a replay of this plan will answer plain ok, so re-read the record before relying on it.' : ''}` });
32
32
  }
33
- /** The envelope for a recorded (or just recorded) outcome. */
33
+ /** The envelope for a recorded (or just recorded) outcome. `result` overrides the ledger id a success reports. */
34
34
  export function outcomeAnswer(plan, entry, extra) {
35
35
  const meta = { plan: plan.handle, planHash: plan.planHash, ...(extra.replayed ? { replayed: true } : {}) };
36
36
  const base = { operation: plan.op, targets: plan.targets, outcome: entry.state === 'sending' ? 'unknown' : entry.state, ...extra.data };
@@ -48,7 +48,9 @@ export function outcomeAnswer(plan, entry, extra) {
48
48
  });
49
49
  }
50
50
  if (entry.state === 'succeeded') {
51
- return answer('ok', 'proceed', [], { ...base, result: entry.outcome?.id === undefined ? {} : { id: entry.outcome.id } }, { ...meta, summary: extra.summary ?? `${plan.op} succeeded${extra.replayed ? ' (recorded outcome replayed; nothing was sent again)' : ''}.` });
51
+ // a generic `call` op reports its own SDK result; a shaped one reports the ledger id
52
+ const result = 'result' in extra ? extra.result : entry.outcome?.id === undefined ? {} : { id: entry.outcome.id };
53
+ return answer('ok', 'proceed', [], { ...base, result }, { ...meta, summary: extra.summary ?? `${plan.op} succeeded${extra.replayed ? ' (recorded outcome replayed; nothing was sent again)' : ''}.` });
52
54
  }
53
55
  if (entry.outcome?.code === 'MOVE_RESOLVED') {
54
56
  // issue #131: a person cleared this move by hand; a copy may exist, so never say "nothing was written"
@@ -3,12 +3,29 @@ import type { Reason } from '../reasons.js';
3
3
  import type { WriteShape } from '../../write/hard-rules.js';
4
4
  import type { PlanTargets } from '../../write/store.js';
5
5
  export declare const SHAPE_ARGS: Record<WriteShape, string[]>;
6
+ /** The argument names of the generic `call` shape that name the record's own id (the snapshot's target). */
7
+ export declare const CALL_ID_ARGS: ReadonlySet<string>;
8
+ /** The argument names of the generic `call` shape that name a company the write touches (a scope fact). */
9
+ export declare const CALL_COMPANY_ARGS: ReadonlySet<string>;
10
+ /** A positive integer id, or a canonical integer string (the registry accepts both forms for ids). */
11
+ export declare function asId(value: unknown): number | undefined;
6
12
  export interface ParsedArgs {
7
13
  args: unknown[];
8
14
  targets: PlanTargets;
9
15
  data?: Record<string, unknown>;
10
16
  }
17
+ /**
18
+ * The generic `call` shape (issue #191): the record's own declared arguments, in registry order, sent
19
+ * to the SDK op unchanged. There is no field-level snapshot to validate against, so the arguments are
20
+ * checked by the SDK's OWN pre-request validator (`validateDispatchArgs`: arity, required args, loose
21
+ * types, zero wire) and by the same payload hygiene the data object gets. Ids stay ids where the
22
+ * registry names one; an object argument is a payload and its references are scope facts.
23
+ */
24
+ export declare function parseCallArgs(record: CapabilityRecord, raw: unknown[]): ParsedArgs | Reason;
11
25
  /** Validate the positional args against the shape; ids are positive integers, never names. */
12
26
  export declare function parseArgs(shape: WriteShape, record: CapabilityRecord, raw: unknown[]): ParsedArgs | Reason;
13
- /** The ledger's key for a target: the record for an update or delete, the payload for a create. */
27
+ /**
28
+ * The ledger's key for a target: the record for an update or delete, the payload for a create, and for
29
+ * the generic `call` shape the record (or parent) it names — or, when it names none, the arguments.
30
+ */
14
31
  export declare function targetKeyOf(shape: WriteShape, targets: PlanTargets, args: unknown[]): string;
@@ -1,4 +1,5 @@
1
1
  /** Argument parsing and the target key of a write (issue #89). */
2
+ import { validateDispatchArgs } from 'node-autotask/operations';
2
3
  import { stripUntrustedDeep } from 'node-autotask/untrusted';
3
4
  import { hashOf } from '../../write/canonical.js';
4
5
  import { reason } from './common.js';
@@ -14,9 +15,89 @@ export const SHAPE_ARGS = {
14
15
  delete: ['id'],
15
16
  delete_child: ['parentId', 'id'],
16
17
  move: ['sourceId', 'toTicketId'],
18
+ // the generic shape takes the record's own declared arguments, in the registry's order (below)
19
+ call: [],
17
20
  };
21
+ /** The argument names of the generic `call` shape that name the record's own id (the snapshot's target). */
22
+ export const CALL_ID_ARGS = new Set(['id', 'sourceId', 'sourceContactId']);
23
+ /** The argument names of the generic `call` shape that name a company the write touches (a scope fact). */
24
+ export const CALL_COMPANY_ARGS = new Set(['companyID', 'companyId', 'destinationCompanyId', 'destinationCompanyID']);
25
+ /** A positive integer id, or a canonical integer string (the registry accepts both forms for ids). */
26
+ export function asId(value) {
27
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
28
+ return value;
29
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
30
+ const n = Number(value);
31
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * The generic `call` shape (issue #191): the record's own declared arguments, in registry order, sent
37
+ * to the SDK op unchanged. There is no field-level snapshot to validate against, so the arguments are
38
+ * checked by the SDK's OWN pre-request validator (`validateDispatchArgs`: arity, required args, loose
39
+ * types, zero wire) and by the same payload hygiene the data object gets. Ids stay ids where the
40
+ * registry names one; an object argument is a payload and its references are scope facts.
41
+ */
42
+ export function parseCallArgs(record, raw) {
43
+ const names = Object.keys(record.inputSchema);
44
+ const stripped = stripUntrustedDeep(raw);
45
+ if (stripped.length > names.length) {
46
+ return reason('INPUT_INVALID', `${record.id} takes at most ${names.length} arg(s): [${names.join(', ')}]`, 'args');
47
+ }
48
+ const required = names.filter((n) => record.inputSchema[n]?.required === true);
49
+ if (stripped.length < required.length) {
50
+ return reason('INPUT_INVALID', `${record.id} requires ${required.length} arg(s): [${required.join(', ')}] (declared: [${names.join(', ')}])`, 'args');
51
+ }
52
+ const out = [];
53
+ const targets = { resource: record.resource };
54
+ for (const [i, name] of names.entries()) {
55
+ const value = stripped[i];
56
+ if (value === undefined) {
57
+ out.push(undefined);
58
+ continue;
59
+ }
60
+ const where = `args.${i}`;
61
+ const payload = value !== null && typeof value === 'object' && !Array.isArray(value);
62
+ if (payload) {
63
+ const dup = caseDuplicate(value, where);
64
+ if (dup !== undefined)
65
+ return reason('INPUT_INVALID', `${dup}: payload keys are plain ASCII field names (letters, digits, underscore), one key per field whatever its case`, dup);
66
+ const copied = sanitisedArtefact(value, where);
67
+ if (copied !== undefined)
68
+ return reason('INPUT_INVALID', `${copied} carries a copied-artefact class: a typed HTML entity sequence (&lt; &gt; &amp;), a truncation marker (server or note_chars form), or a wrapper tag in this server's exact spelling. Leave it out or send a new value.`, copied);
69
+ }
70
+ const cleaned = cleanPayload(value);
71
+ if (payload) {
72
+ const leftover = leftoverCombiningMark(cleaned, where);
73
+ if (leftover !== undefined) {
74
+ return reason('INPUT_INVALID', `${leftover} carries a combining mark with no precomposed form (for example a lone U+0301); the vendor stores such a character as a different one, so the write would not round-trip. Send the precomposed character instead`, leftover);
75
+ }
76
+ }
77
+ out.push(cleaned);
78
+ const id = asId(cleaned);
79
+ if (id !== undefined && CALL_ID_ARGS.has(name))
80
+ targets.id = id;
81
+ else if (id !== undefined && name === 'parentId')
82
+ targets.parentId = id;
83
+ }
84
+ // a trailing absent option is simply absent (a stored `undefined` would read back as null)
85
+ let last = out.length - 1;
86
+ while (last >= 0 && out[last] === undefined)
87
+ last--;
88
+ const args = out.slice(0, last + 1);
89
+ try {
90
+ validateDispatchArgs(record, args);
91
+ }
92
+ catch (err) {
93
+ return reason('INPUT_INVALID', `${record.id}: ${err.message}`, 'args');
94
+ }
95
+ return { args, targets };
96
+ }
18
97
  /** Validate the positional args against the shape; ids are positive integers, never names. */
19
98
  export function parseArgs(shape, record, raw) {
99
+ if (shape === 'call')
100
+ return parseCallArgs(record, raw);
20
101
  const names = SHAPE_ARGS[shape];
21
102
  if (raw.length !== names.length)
22
103
  return reason('INPUT_INVALID', `${record.id} takes ${names.length} arg(s): [${names.join(', ')}]`, 'args');
@@ -60,10 +141,20 @@ export function parseArgs(shape, record, raw) {
60
141
  }
61
142
  return data === undefined ? { args: out, targets } : { args: out, targets, data };
62
143
  }
63
- /** The ledger's key for a target: the record for an update or delete, the payload for a create. */
144
+ /**
145
+ * The ledger's key for a target: the record for an update or delete, the payload for a create, and for
146
+ * the generic `call` shape the record (or parent) it names — or, when it names none, the arguments.
147
+ */
64
148
  export function targetKeyOf(shape, targets, args) {
65
149
  if (shape.startsWith('create'))
66
150
  return `${targets.resource}:create:${hashOf(args).slice(7, 23)}`;
151
+ if (shape === 'call') {
152
+ if (targets.id !== undefined)
153
+ return `${targets.resource}:${targets.parentId === undefined ? '' : `${targets.parentId}/`}${targets.id}`;
154
+ if (targets.parentId !== undefined)
155
+ return `${targets.resource}:parent:${targets.parentId}`;
156
+ return `${targets.resource}:call:${hashOf(args).slice(7, 23)}`;
157
+ }
67
158
  return `${targets.resource}:${targets.parentId === undefined ? '' : `${targets.parentId}/`}${targets.id}`;
68
159
  }
69
160
  //# sourceMappingURL=parse.js.map
@@ -11,6 +11,17 @@ import { parseArgs } from './parse.js';
11
11
  import { honestImpact } from './payload.js';
12
12
  import { takeSnapshot, moveSummary } from './snapshot.js';
13
13
  import { unresolvedReason, markedDiff, deriveFacts } from './facts.js';
14
+ /**
15
+ * The generic `call` shape has no field-level snapshot (issue #191): no vendor reads establish what the
16
+ * op will change, so the preview states the arguments it will send — unchanged — and an impact that
17
+ * claims nothing it cannot know. It is one call, and this server cannot undo it.
18
+ */
19
+ // A composite op decides its own steps, so the preview must not claim a record
20
+ // count or a single-record scope: `affected: null` + scope 'composite' is the
21
+ // honest answer (operations.recovery can delete up to `limit` rows;
22
+ // resources.transferOwnership moves a whole book of work).
23
+ const CALL_IMPACT = { affected: null, scope: 'composite', reversible: false };
24
+ const CALL_WARNING = 'this operation has no field-level snapshot: the preview echoes the arguments as they will be sent and the scope reads behind them; autotask_apply sends exactly these arguments to the SDK operation. The blast radius is the op own: it can affect more than one record - check the operation arguments';
14
25
  /** Blocked state, the hard rules and the AUTOTASK_WRITE row, then the args: each answers before any read. */
15
26
  function admit(r) {
16
27
  const { ctx, store, loaded, args, op, audit } = r;
@@ -80,7 +91,11 @@ async function snapshot(r, a) {
80
91
  /** The SDK dry run: its validation and the diff (no vendor write is issued). */
81
92
  async function dryRun(r, a) {
82
93
  const { ctx, op, audit, signal } = r;
83
- const { record, parsed } = a;
94
+ const { record, parsed, shape } = a;
95
+ // The generic shape has no dry run to ask for (the op decides its own steps): the preview answers
96
+ // from the arguments and the snapshot, and the apply is the one call that sends them.
97
+ if (shape === 'call')
98
+ return { ok: { wouldApply: true, warnings: [CALL_WARNING] } };
84
99
  const confirm = record.effect === 'destructive' || record.flags.includes('requiresApproval') ? { confirm: op } : {};
85
100
  const invoke = record.effect === 'destructive' ? ctx.ops.invokeDelete : ctx.ops.invokeWrite;
86
101
  let dry;
@@ -143,7 +158,8 @@ function evaluatePhase(r, a, snap, dry, resource) {
143
158
  hazards: decision.hazards,
144
159
  diff: markedDiff(dry.diff),
145
160
  warnings: (dry.warnings ?? []).slice(0, 20).map((x) => String(x)),
146
- impact: honestImpact(dry.impact, shape),
161
+ impact: shape === 'call' ? CALL_IMPACT : honestImpact(dry.impact, shape),
162
+ ...(shape === 'call' ? { dispatch: 'sdk-op', args: parsed.args } : {}),
147
163
  ...(resource !== undefined ? { resource } : {}),
148
164
  ...(shape === 'move' ? { move: moveSummary(record, parsed.targets, snap.current, dry.data, facts) } : {}),
149
165
  };
@@ -3,7 +3,7 @@ import type { ToolContext } from '../context.js';
3
3
  import type { Reason } from '../reasons.js';
4
4
  import type { WriteFacts } from '../../write/evaluate.js';
5
5
  import type { PlanTargets } from '../../write/store.js';
6
- import type { ParsedArgs } from './parse.js';
6
+ import { type ParsedArgs } from './parse.js';
7
7
  /** id references that lead to a record's company when it has no companyID of its own. */
8
8
  export declare const ANCHORS: ReadonlyArray<[string, string]>;
9
9
  /**
@@ -36,6 +36,15 @@ export interface Snapshot {
36
36
  duplicateIds: unknown[];
37
37
  };
38
38
  }
39
+ /**
40
+ * The generic `call` snapshot (issue #191): there is no known target/data signature, so the record's
41
+ * own declared arguments decide what is read. A named id (`id`, `sourceId`, `sourceContactId`) is the
42
+ * record's target, read and anchored; `parentId` is the parent record; a named company id is a scope
43
+ * fact; any object argument is a payload, and its references are scope facts (exactly as a create's
44
+ * data). An argument set that names no record yields no scope facts, and a company-scoped run scope
45
+ * then refuses the write (a write whose company cannot be established is refused).
46
+ */
47
+ export declare function takeCallSnapshot(ctx: ToolContext, record: CapabilityRecord, parsed: ParsedArgs, signal: AbortSignal | undefined): Promise<Snapshot | Reason>;
39
48
  export declare function takeSnapshot(ctx: ToolContext, record: CapabilityRecord, parsed: ParsedArgs, duplicateFields: readonly string[] | undefined, signal: AbortSignal | undefined): Promise<Snapshot | Reason>;
40
49
  /** The preview block of a move (ids, fact names and field NAMES only: note text never rides it). */
41
50
  export declare function moveSummary(record: CapabilityRecord, t: PlanTargets, current: Record<string, unknown> | undefined, payload: unknown, facts: WriteFacts): Record<string, unknown>;
@@ -1,7 +1,9 @@
1
1
  /** The pre-write snapshot (the before state), the stable facts and the forced-duplicate filters (issue #89). */
2
2
  import { relationshipMap } from 'node-autotask/operations';
3
3
  import { hashOf } from '../../write/canonical.js';
4
+ import { writeShape } from '../../write/hard-rules.js';
4
5
  import { Cancelled, reason, isNotFound } from './common.js';
6
+ import { CALL_COMPANY_ARGS, CALL_ID_ARGS, asId } from './parse.js';
5
7
  import { ci } from './payload.js';
6
8
  // ---------------------------------------------------------------------------
7
9
  // The snapshot: fresh reads, the companies the write touches, the duplicate query
@@ -45,15 +47,16 @@ export function hasSimpleGet(resource, ctx) {
45
47
  export function getIds(ctx, resource, parentId, id) {
46
48
  return parentId !== undefined && !hasSimpleGet(resource, ctx) ? [parentId, id] : [id];
47
49
  }
48
- export async function takeSnapshot(ctx, record, parsed, duplicateFields, signal) {
49
- ctx.ops.freshen(ctx.client);
50
+ /** The reader for one snapshot. Only the write's own target record is hashed in full; a referenced record is hashed by its stable facts. */
51
+ function createReader(ctx, targetKey, signal) {
50
52
  const reads = {};
53
+ const hashed = {};
54
+ const companies = new Set();
55
+ const unresolved = [];
51
56
  // The write's own TARGET record is hashed in full (an update/delete must notice any change to it);
52
57
  // a record that is only REFERENCED (the ticket a time entry is logged on) is hashed by its stable
53
58
  // facts only, so a volatile stamp the first of two outstanding writes moves (lastActivityDate,
54
59
  // modified stamps) does not read as drift for the second (issue #108).
55
- const targetKey = parsed.targets.id === undefined ? undefined : `${record.resource}:${getIds(ctx, record.resource, parsed.targets.parentId, parsed.targets.id).join('/')}`;
56
- const hashed = {};
57
60
  const get = async (resource, ids) => {
58
61
  const key = `${resource}:${ids.join('/')}`;
59
62
  if (!(key in reads)) {
@@ -65,8 +68,6 @@ export async function takeSnapshot(ctx, record, parsed, duplicateFields, signal)
65
68
  }
66
69
  return reads[key];
67
70
  };
68
- const companies = new Set();
69
- const unresolved = [];
70
71
  // The positional ids for one reference's get; a parent-scoped get needs the parent id from the same record.
71
72
  const idsFor = async (resource, id, rec) => {
72
73
  if (hasSimpleGet(resource, ctx))
@@ -116,6 +117,107 @@ export async function takeSnapshot(ctx, record, parsed, duplicateFields, signal)
116
117
  await anchor(res, child, depth + 1, false);
117
118
  }
118
119
  };
120
+ return { reads, hashed, companies, unresolved, get, idsFor, anchor };
121
+ }
122
+ /**
123
+ * The generic `call` snapshot (issue #191): there is no known target/data signature, so the record's
124
+ * own declared arguments decide what is read. A named id (`id`, `sourceId`, `sourceContactId`) is the
125
+ * record's target, read and anchored; `parentId` is the parent record; a named company id is a scope
126
+ * fact; any object argument is a payload, and its references are scope facts (exactly as a create's
127
+ * data). An argument set that names no record yields no scope facts, and a company-scoped run scope
128
+ * then refuses the write (a write whose company cannot be established is refused).
129
+ */
130
+ export async function takeCallSnapshot(ctx, record, parsed, signal) {
131
+ ctx.ops.freshen(ctx.client);
132
+ const { targets } = parsed;
133
+ const names = Object.keys(record.inputSchema);
134
+ const parent = await parentOf(record.resource);
135
+ // `parentId` names the parent record when the resource HAS a parent (a child collection); on a
136
+ // resource with no parent it is the record's own id (inventory_products.stockedItemsAdd's parentId
137
+ // IS the product). Either way it is the row this preview reads and hashes in full.
138
+ const selfId = targets.id ?? (parent === undefined ? targets.parentId : undefined);
139
+ const r = createReader(ctx, selfId === undefined ? undefined : `${record.resource}:${selfId}`, signal);
140
+ let current;
141
+ if (selfId !== undefined) {
142
+ const simple = hasSimpleGet(record.resource, ctx);
143
+ const key = `${record.resource}:${selfId}`;
144
+ try {
145
+ if (!simple && ctx.record(`${record.resource}.query`) !== undefined) {
146
+ // a parent-scoped get needs a parent the arguments do not name (a contact's companyID): read the
147
+ // row by id, as the move path does, and let the row's own references anchor it
148
+ const found = await ctx.ops.invokeRead(ctx.client, `${record.resource}.query`, [{ filter: [{ field: 'id', op: 'eq', value: selfId }], limit: 1 }], undefined, signal);
149
+ if (found === undefined)
150
+ throw new Cancelled();
151
+ const row = (found.items ?? [])[0];
152
+ if (row === null || typeof row !== 'object')
153
+ return reason('INPUT_INVALID', `${record.resource} ${selfId} does not exist`, 'args.0');
154
+ r.reads[key] = row;
155
+ r.hashed[key] = row;
156
+ current = row;
157
+ }
158
+ else if (simple) {
159
+ current = await r.get(record.resource, getIds(ctx, record.resource, targets.parentId, selfId));
160
+ }
161
+ else {
162
+ return reason('INPUT_INVALID', `${record.resource} ${selfId}: this resource's get needs a parent id and the arguments do not name one`, 'args.0');
163
+ }
164
+ }
165
+ catch (err) {
166
+ if (!isNotFound(err))
167
+ throw err;
168
+ return reason('INPUT_INVALID', `${record.resource} ${selfId} does not exist`, 'args.0');
169
+ }
170
+ await r.anchor(record.resource, current, 0, false);
171
+ }
172
+ for (const [i, name] of names.entries()) {
173
+ const value = parsed.args[i];
174
+ if (value === undefined)
175
+ continue;
176
+ const id = asId(value);
177
+ if (name === 'parentId') {
178
+ if (parent === undefined)
179
+ continue; // consumed as the record's own id above
180
+ const ids = id === undefined ? undefined : await r.idsFor(parent.resource, id, current ?? {});
181
+ if (ids === undefined)
182
+ r.unresolved.push(`${record.resource}.parent`);
183
+ else {
184
+ try {
185
+ await r.anchor(parent.resource, await r.get(parent.resource, ids), 1, false);
186
+ }
187
+ catch (err) {
188
+ if (!isNotFound(err))
189
+ throw err;
190
+ r.unresolved.push(`${record.resource}.${parent.field}`);
191
+ }
192
+ }
193
+ continue;
194
+ }
195
+ if (CALL_ID_ARGS.has(name))
196
+ continue;
197
+ if (CALL_COMPANY_ARGS.has(name)) {
198
+ if (id !== undefined)
199
+ r.companies.add(id);
200
+ continue;
201
+ }
202
+ if (value !== null && typeof value === 'object' && !Array.isArray(value))
203
+ await r.anchor(record.resource, value, 0, true);
204
+ }
205
+ return {
206
+ hash: hashOf({ reads: r.hashed }),
207
+ companyIds: [...r.companies].sort((a, b) => a - b),
208
+ unresolved: [...new Set(r.unresolved)],
209
+ ...(current !== undefined ? { current } : {}),
210
+ duplicates: 0,
211
+ inputs: { reads: r.hashed, duplicateIds: [] },
212
+ };
213
+ }
214
+ export async function takeSnapshot(ctx, record, parsed, duplicateFields, signal) {
215
+ if (writeShape(record) === 'call')
216
+ return takeCallSnapshot(ctx, record, parsed, signal);
217
+ ctx.ops.freshen(ctx.client);
218
+ const targetKey = parsed.targets.id === undefined ? undefined : `${record.resource}:${getIds(ctx, record.resource, parsed.targets.parentId, parsed.targets.id).join('/')}`;
219
+ const r = createReader(ctx, targetKey, signal);
220
+ const { get, idsFor, anchor, reads, hashed, unresolved, companies } = r;
119
221
  const { targets, data } = parsed;
120
222
  let current;
121
223
  if (targets.id !== undefined) {
@@ -18,8 +18,12 @@ export function evaluate(loaded, facts, runScope, counters) {
18
18
  return deny('OP_NOT_ALLOWED', `AUTOTASK_WRITE denies ${facts.op}`, hard.hazards);
19
19
  if (runScope === undefined)
20
20
  return deny('TARGET_NOT_IN_SCOPE', 'this principal has no run scope', hard.hazards);
21
- if (!inScope(facts, runScope))
22
- return deny('TARGET_NOT_IN_SCOPE', "the target's company (read from the current record) is outside the write scope of this run", hard.hazards);
21
+ if (!inScope(facts, runScope)) {
22
+ const detail = facts.companyIds.length === 0
23
+ ? 'this write names no record whose company could be read, so it may lie outside the write scope of this run (a run scope of {"all":true} admits it)'
24
+ : "the target's company (read from the current record) is outside the write scope of this run";
25
+ return deny('TARGET_NOT_IN_SCOPE', detail, hard.hazards);
26
+ }
23
27
  if (facts.blocked !== undefined)
24
28
  return deny('MOVE_UNRESOLVED', facts.blocked, hard.hazards);
25
29
  if (facts.duplicates > 0)
@@ -8,9 +8,12 @@
8
8
  * - `destructive` and account-wide operations (the tenant configuration resources in
9
9
  * {@link ACCOUNT_WIDE_RESOURCES}) carry their hazard in the preview; AUTOTASK_WRITE decides their mode.
10
10
  *
11
- * The preview/apply path also takes only the argument shapes it can snapshot (a target id, an
12
- * optional parent id, a data object): {@link writeShape} names them; any other operation shape
13
- * (the SDK's composite helpers such as `tickets.changeStatus`) is refused when AUTOTASK_WRITE resolves.
11
+ * The preview/apply path serves every write. A record whose positional arguments are one of the six
12
+ * CRUD shapes or the move shape gets a field-level snapshot (the target record, the parent, the data
13
+ * object); every other record (the SDK's composite helpers: `tickets.changeStatus`,
14
+ * `time_entries.approve`, the `*createIfNotExists` family, ...) takes the generic `call` shape, which
15
+ * echoes the caller's arguments and sends exactly them. {@link writeShape} never returns undefined
16
+ * for a non-read record, so AUTOTASK_WRITE can name every write.
14
17
  */
15
18
  import type { CapabilityRecord } from 'node-autotask/capabilities';
16
19
  /**
@@ -29,13 +32,22 @@ export interface HardRule {
29
32
  }
30
33
  /** The hard rule for one registry record. */
31
34
  export declare function hardRuleFor(operation: string, record: CapabilityRecord | undefined): HardRule;
32
- /** The argument shapes the preview/apply path snapshots: the positional names, in order. */
33
- export type WriteShape = 'create' | 'create_child' | 'update' | 'update_child' | 'delete' | 'delete_child' | 'move';
35
+ /**
36
+ * The argument shapes the preview/apply path knows: the positional names, in order.
37
+ * `call` is the generic fallback for every other argument signature (the SDK's composite helpers):
38
+ * it has no field-level snapshot, so it previews the arguments and impact instead.
39
+ */
40
+ export type WriteShape = 'create' | 'create_child' | 'update' | 'update_child' | 'delete' | 'delete_child' | 'move' | 'call';
34
41
  /**
35
42
  * The only operations that may take the `move` shape (issue #107): the SDK's create-verify-delete move
36
- * engine. An allowlist, not the signature alone: a destructive op with no flags needs no person by hard
37
- * rule, so a future SDK op that happened to share the signature must not become servable as a move.
43
+ * engine. An allowlist, not the signature alone: a future SDK op that happened to share the signature
44
+ * must not be driven as a move (it takes the generic `call` shape instead, which never snapshots or
45
+ * recovers a source record).
38
46
  */
39
47
  export declare const MOVE_OPS: readonly string[];
40
- /** The write shape of a record, or `undefined` when the preview/apply path cannot snapshot it. */
41
- export declare function writeShape(record: CapabilityRecord): WriteShape | undefined;
48
+ /**
49
+ * The write shape of a record. Every non-read record has one: the six CRUD shapes, the move shape for
50
+ * {@link MOVE_OPS}, and `call` for every other signature (so no write silently drops out of
51
+ * AUTOTASK_WRITE, issue #191).
52
+ */
53
+ export declare function writeShape(record: CapabilityRecord): WriteShape;
@@ -57,8 +57,9 @@ export function hardRuleFor(operation, record) {
57
57
  }
58
58
  /**
59
59
  * The only operations that may take the `move` shape (issue #107): the SDK's create-verify-delete move
60
- * engine. An allowlist, not the signature alone: a destructive op with no flags needs no person by hard
61
- * rule, so a future SDK op that happened to share the signature must not become servable as a move.
60
+ * engine. An allowlist, not the signature alone: a future SDK op that happened to share the signature
61
+ * must not be driven as a move (it takes the generic `call` shape instead, which never snapshots or
62
+ * recovers a source record).
62
63
  */
63
64
  export const MOVE_OPS = ['time_entries.moveToTicket', 'ticket_charges.moveToTicket'];
64
65
  const SHAPES = {
@@ -70,9 +71,13 @@ const SHAPES = {
70
71
  'delete:parentId,id': 'delete_child',
71
72
  'moveToTicket:sourceId,toTicketId': 'move',
72
73
  };
73
- /** The write shape of a record, or `undefined` when the preview/apply path cannot snapshot it. */
74
+ /**
75
+ * The write shape of a record. Every non-read record has one: the six CRUD shapes, the move shape for
76
+ * {@link MOVE_OPS}, and `call` for every other signature (so no write silently drops out of
77
+ * AUTOTASK_WRITE, issue #191).
78
+ */
74
79
  export function writeShape(record) {
75
80
  const shape = SHAPES[`${record.operation}:${Object.keys(record.inputSchema).join(',')}`];
76
- return shape === 'move' && !MOVE_OPS.includes(record.id) ? undefined : shape;
81
+ return shape === 'move' && !MOVE_OPS.includes(record.id) ? 'call' : (shape ?? 'call');
77
82
  }
78
83
  //# sourceMappingURL=hard-rules.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.29.0",
3
+ "version": "0.30.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",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/server": "^2.2.0",
56
- "node-autotask": "0.29.0",
56
+ "node-autotask": "0.30.0",
57
57
  "zod": "^4.6.7"
58
58
  },
59
59
  "devDependencies": {
package/server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.29.0",
3
+ "version": "0.30.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",
@@ -61,7 +61,7 @@
61
61
  "capabilitiesManifest": "capabilities.json",
62
62
  "sdk": {
63
63
  "package": "node-autotask",
64
- "versionRange": "0.29.0"
64
+ "versionRange": "0.30.0"
65
65
  },
66
66
  "vendorExtensions": false,
67
67
  "credentialModelNote": "per-request; server-held under stdio or, on HTTP, only behind AUTOTASK_HTTP_AUTH=tokens; operator plans and the ledger are keyed by principal and tenant id, never by credential, and an apply uses the credential on the apply request"