iit-mcp-autotask 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,21 +3,21 @@
3
3
  MCP server for the Autotask (Datto) PSA domain, built on the [`node-autotask`](https://www.npmjs.com/package/node-autotask) SDK.
4
4
 
5
5
  It is an **AI-facing capability layer over the SDK** — not a raw SDK passthrough: a minimal, task-oriented
6
- tool surface (41 tools) anchored to ten confirmed MSP workflows, composites that collapse multi-read
6
+ tool surface (42 tools) anchored to ten confirmed MSP workflows, composites that collapse multi-read
7
7
  context into one call, a generic capability layer for the long tail, bounded responses, predictable
8
8
  schemas, strong security boundaries, and predictable per-tenant in-process state (pooled client +
9
9
  SDK-owned TTL caches).
10
10
 
11
- > **Package status (2026-10-12):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.10.0`; the committed `pnpm-lock.yaml` pins that
11
+ > **Package status (2026-10-16):** the runtime dependency `node-autotask` is published to public npm
12
+ > at `0.13.0`; 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.10.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.13.0`) — never a `file:`, `git:`, `link:` or workspace reference
15
15
  > to a local checkout.
16
16
 
17
17
  ## Tool surface
18
18
 
19
- 41 tools: **13 platform/discovery** (Layer A) + **5 workflow-resource resolves** (Layer B) +
20
- **23 dedicated workflow tools** (Layer C). Every other operation in the SDK's 1,212-operation
19
+ 42 tools: **13 platform/discovery** (Layer A) + **5 workflow-resource resolves** (Layer B) +
20
+ **24 dedicated workflow tools** (Layer C). Every other operation in the SDK's 1,213-operation
21
21
  registry stays reachable through the **generic capability layer** — never pre-registered.
22
22
 
23
23
  Composites bound **each part independently**: every part reads its own declared limit argument
@@ -29,17 +29,17 @@ miss never fails the call (typed `absent`); only a missing root record fails the
29
29
  |-------|-------|------|
30
30
  | A — platform & discovery (13) | `autotask_catalog` · `autotask_describe` · `autotask_read` · `autotask_write` · `autotask_delete` · `autotask_resource_who_am_i` · `autotask_version` · `autotask_threshold_information` · `autotask_resolve_any` · `autotask_search_across_resources` · `autotask_fetch_many` · `autotask_read_batch` · `autotask_relationship_map` | The five **META** dispatchers + tenant identity + cross-resource helpers |
31
31
  | B — workflow resolves (5) | `autotask_company_resolve` · `autotask_configurationItems_resolve` · `autotask_contact_resolve` · `autotask_project_resolve` · `autotask_ticket_resolve` | Numeric-id-or-exact-name resolution for the five SDK workflow resources |
32
- | C — dedicated workflow (23) | `autotask_ticket_query` · `autotask_ticket_full_context` · `autotask_ticket_sla_health_check` · `autotask_ticket_find_by_ticket_number` · `autotask_company_full_profile` · `autotask_timeEntry_query` · `autotask_timeEntry_create` · `autotask_quote_get` · `autotask_invoice_get` · `autotask_invoice_markup` · `autotask_configurationItem_profile` · `autotask_configurationItems_find_by_serial` · `autotask_contact_full_profile` · `autotask_contact_find_by_email` · `autotask_project_get_context` · `autotask_serviceCall_query` · `autotask_serviceCall_full_profile` · `autotask_contract_full_profile` · `autotask_contract_query` · `autotask_resource_workload` · `autotask_timeOffRequest_query` · `autotask_company_unbilled_work` · `autotask_aggregate` (names verbatim from the SDK manifest where they exist — case inconsistency included) | Bounded filtered lists, one-call composites, natural-key lookups, the single dedicated write |
32
+ | C — dedicated workflow (24) | `autotask_ticket_query` · `autotask_ticket_full_context` · `autotask_ticket_sla_health_check` · `autotask_ticket_find_by_ticket_number` · `autotask_company_full_profile` · `autotask_timeEntry_query` · `autotask_timeEntry_create` · `autotask_quote_get` · `autotask_invoice_get` · `autotask_invoice_markup` · `autotask_configurationItem_profile` · `autotask_configurationItems_find_by_serial` · `autotask_contact_full_profile` · `autotask_contact_find_by_email` · `autotask_project_get_context` · `autotask_serviceCall_query` · `autotask_serviceCall_full_profile` · `autotask_contract_full_profile` · `autotask_contract_query` · `autotask_resource_workload` · `autotask_timeOffRequest_query` · `autotask_company_unbilled_work` · `autotask_contract_profitability` · `autotask_aggregate` (names verbatim from the SDK manifest where they exist — case inconsistency included) | Bounded filtered lists, one-call composites, natural-key lookups, the single dedicated write |
33
33
 
34
34
  Machine-readable catalogue: [`capabilities.json`](capabilities.json) · server metadata: [`server.json`](server.json) ·
35
- per-workflow mapping: [`docs/capability-matrix.md`](docs/capability-matrix.md) · full design: [`docs/architecture.md`](docs/architecture.md).
35
+ per-workflow mapping: [`docs/capability-matrix.md`](docs/capability-matrix.md) · full design: [`docs/architecture.md`](docs/architecture.md) · contract P&L (`autotask_contract_profitability` first; the manual recipe behind it): [`docs/contract-economics.md`](docs/contract-economics.md).
36
36
 
37
37
  ### The 5-META generic layer
38
38
 
39
39
  The five META tools are the SDK's own live-verified governance layer, adopted verbatim (a recorded
40
40
  decision — see `docs/architecture.md` §2.5):
41
41
 
42
- - `autotask_catalog` — list registry operations (bounded: limit default 41 / max 500, tier + effect + `q` (key+purpose substring) + `resource` filters; `compact` reduced rows). Policy-refused writes are hidden by default (the output carries `hidden_refused: N`); `show_refused: true` lists them with their `policyReason`.
42
+ - `autotask_catalog` — list registry operations (bounded: limit default 42 / max 500, tier + effect + `q` (key+purpose substring) + `resource` filters; `compact` reduced rows). Policy-refused writes are hidden by default (the output carries `hidden_refused: N`); `show_refused: true` lists them with their `policyReason`.
43
43
  - `autotask_describe` — exact input/output schema + examples + error codes for one operation by key.
44
44
  - `autotask_read` — route any `effect=read` operation by **exact registry key** (the 363 metadata-trio
45
45
  reads with no dedicated tool ride here). Read-only by construction; a write/delete routed here is
@@ -447,7 +447,7 @@ Full control set: `.squad/security.md` (S1–S13, blocking B1–B9, decisions D-
447
447
 
448
448
  ## SDK dependency
449
449
 
450
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.10.0`** (an exact pin —
450
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.13.0`** (an exact pin —
451
451
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
452
452
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
453
453
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
@@ -457,7 +457,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
457
457
  reviewed against, or the plan-hash boot check refuses to start.
458
458
 
459
459
  **Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
460
- public registry at `0.10.0` (published 2026-10-11); the
460
+ public registry at `0.13.0` (published 2026-10-16); the
461
461
  exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
462
462
  tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
463
463
  freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
@@ -550,7 +550,7 @@ physical assets (Autotask models them as Configuration Items — covered).
550
550
 
551
551
  | # | Item | Status |
552
552
  |---|------|--------|
553
- | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,212-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
553
+ | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,213-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
554
554
  | B2 | **Time-entry writes on ww29/australia-nz for API-only users**: vendor 500 (live-verified). `autotask_timeEntry_create` works where the API user is a real resource; the wall is documented in the tool description so agents don't loop. | zone limitation |
555
555
  | B3 | **`time_entries.getPosted`/`getUnposted` 500 on ww29** (no posted-state filter on the zone's entity). Reachable via the generic layer; `postedWithin` is the zone-robust alternative. | zone limitation |
556
556
  | B4 | **CI create on tenants without products** (ww29 product wall: `products.count = 0`): `configuration_items.create` requires an existing `productID`. No dedicated CI-create in v1; the wall is documented on the generic side. | zone limitation |
package/capabilities.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "version": "0.3.0",
3
3
  "domain": "autotask",
4
- "description": "Machine-readable capability catalogue for iit-mcp-autotask: the 41 exposed MCP tools only. Intentionally omitted SDK operations do NOT appear here; they remain reachable through the generic capability layer (autotask_catalog / autotask_describe / autotask_read / autotask_write / autotask_delete) and are documented in docs/capability-matrix.md.",
5
- "exposedToolCount": 41,
4
+ "description": "Machine-readable capability catalogue for iit-mcp-autotask: the 42 exposed MCP tools only. Intentionally omitted SDK operations do NOT appear here; they remain reachable through the generic capability layer (autotask_catalog / autotask_describe / autotask_read / autotask_write / autotask_delete) and are documented in docs/capability-matrix.md.",
5
+ "exposedToolCount": 42,
6
6
  "layerLegend": {
7
7
  "A": "platform & discovery (13: 5 META dispatchers + 3 identity + 5 cross-resource helpers)",
8
8
  "B": "workflow-resource resolves (5: the SDK's R4 core tier)",
9
- "C": "dedicated workflow tools (23: 1:1 registry reads, composites, one write)"
9
+ "C": "dedicated workflow tools (24: 1:1 registry reads, composites, one write)"
10
10
  },
11
11
  "naming": "Tool names for 1:1 registry operations are the SDK manifest names verbatim, case inconsistency included (a recorded decision; SDK finding F2). Server-built composites carry the approved architecture names (docs/architecture.md section 3.3); per the Lead-ratified rename (2026-10-06, Description phase) the service-call composite is `autotask_serviceCall_full_profile`, so the W8 pair (`autotask_serviceCall_query` + `autotask_serviceCall_full_profile`) now follows the manifest's camelCase entity convention.",
12
12
  "capabilities": [
13
13
  {
14
14
  "id": "autotask.platform.catalog",
15
- "description": "List capability registry rows: key, tool, purpose, effect, policy verdict (reachable/policyReason). Filters: q (key+purpose substring), resource, tier, effect; compact: key/tool/effect/reachable. Refused writes hidden by default (hidden_refused; show_refused lists them). limit default 41, max 500.",
15
+ "description": "List capability registry rows: key, tool, purpose, effect, policy verdict (reachable/policyReason). Filters: q (key+purpose substring), resource, tier, effect; compact: key/tool/effect/reachable. Refused writes hidden by default (hidden_refused; show_refused lists them). limit default 42, max 500.",
16
16
  "mcpPrimitive": "tool",
17
17
  "toolName": "autotask_catalog",
18
18
  "type": "read",
@@ -539,12 +539,12 @@
539
539
  },
540
540
  {
541
541
  "id": "autotask.contract.full_profile",
542
- "description": "One contract + its services (+ units) + its billing rules in a single call: parts degrade to typed absent, never a failure; the contract itself missing → NOT_FOUND. Deeper machinery (bundles, adjustments, exclusions, charges): autotask_read; a contract list: autotask_contract_query.",
542
+ "description": "One contract + services + billing rules + recent charges (50 newest) + service units in one call: parts degrade to typed absent; contract missing → NOT_FOUND. P&L / profitable?: autotask_contract_profitability. Deeper (bundles, adjustments, exclusions): autotask_read; list: autotask_contract_query.",
543
543
  "mcpPrimitive": "tool",
544
544
  "toolName": "autotask_contract_full_profile",
545
545
  "type": "read",
546
546
  "layer": "C",
547
- "backingOperation": "contracts.get + contract_services.query + contract_billing_rules.query",
547
+ "backingOperation": "contracts.get + contract_services.query + contract_billing_rules.query + contract_charges.query + contract_service_units.query",
548
548
  "annotations": {
549
549
  "readOnly": true,
550
550
  "idempotent": true,
@@ -613,6 +613,20 @@
613
613
  "destructive": false
614
614
  }
615
615
  },
616
+ {
617
+ "id": "autotask.contract.profitability",
618
+ "description": "Contract P&L by month: profit, margin, revenue (recurring/T&M/charges), break-even, unbilled/unposted time, Block Hours included hours, warnings. Args: id, date_from/date_to (default last 12 months), include_rows. Internal-currency billing-period (start-month) money; parts degrade to typed absent.",
619
+ "mcpPrimitive": "tool",
620
+ "toolName": "autotask_contract_profitability",
621
+ "type": "read",
622
+ "layer": "C",
623
+ "backingOperation": "contracts.profitability",
624
+ "annotations": {
625
+ "readOnly": true,
626
+ "idempotent": true,
627
+ "destructive": false
628
+ }
629
+ },
616
630
  {
617
631
  "id": "autotask.discovery.aggregate",
618
632
  "description": "Fold a query surface into groups/sums/totals or count it (the count endpoint). operation = an EXACT plan .query op (others refused); filter (grammar; empty array = unfiltered: a 0 on a blind surface is marked blind: true); group_by / sum / top / limit: server instructions. Read op; zero mutating.",
@@ -637,8 +651,8 @@
637
651
  "autotask_write",
638
652
  "autotask_delete"
639
653
  ],
640
- "backingRegistryOperations": 1212,
641
- "projectedSdkTools": 849,
654
+ "backingRegistryOperations": 1213,
655
+ "projectedSdkTools": 850,
642
656
  "note": "The five effect-split SDK META dispatchers are the v1 generic layer (decision, architecture section 2.5): registered exact registry operation keys only, effect-segregated dispatch, dry-run-first writes, confirm-governed deletes, pre-request cross-mode refusal. The ~1,145 non-exposed operations remain discoverable (catalog/describe) and reachable (read/write/delete) — nothing is a silent gap."
643
657
  },
644
658
  "schemas": {}
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * `defaultToolOps` binds the real `node-autotask/operations` in exactly one
6
6
  * place (`tools/context.ts`); this bundle implements the same `ToolOps`
7
- * interface against in-memory fixtures. The 41 tool handlers are driven
7
+ * interface against in-memory fixtures. The 42 tool handlers are driven
8
8
  * unchanged through the same MCP protocol path — no handler carries a mock
9
9
  * branch, so a test that passes here proves the handler layer, the shared
10
10
  * call frame, the policy gates and the error mapping; the vendor itself is
@@ -4,7 +4,7 @@
4
4
  * Mirrors the production per-request factory glue exactly (the header read,
5
5
  * the scope resolution, the deps bundle, the per-serving-unit `McpServer`)
6
6
  * with ONE substitution: the `ToolOps` bundle is injectable. `mockOps` mode
7
- * drives all 41 tools through the real MCP protocol path against a
7
+ * drives all 42 tools through the real MCP protocol path against a
8
8
  * fixture-backed ops bundle (zero vendor calls by construction); `wire` mode
9
9
  * runs the production `createMcpServerFactory` with the real SDK ops bundle
10
10
  * and the global-fetch wire mock (the real `AutotaskClient` + pool run
@@ -59,7 +59,7 @@ export declare function recordById(operation: string): CapabilityRecord | undefi
59
59
  * `autotask_read` / `autotask_write` / `autotask_delete` by exact key, never a dead name).
60
60
  */
61
61
  export declare const TOOL_NAME_BY_OP: Readonly<Record<string, string>>;
62
- /** Every tool name this server registers (Layer A 13 + Layer B 5 + Layer C 23 = 41). */
62
+ /** Every tool name this server registers (Layer A 13 + Layer B 5 + Layer C 24 = 42). */
63
63
  export declare const REGISTERED_TOOL_NAMES: readonly string[];
64
64
  /** The META tools' names (the five platform dispatchers). */
65
65
  export declare const META_TOOL_NAMES: readonly string[];
@@ -81,7 +81,7 @@ export declare const UNTRUSTED_NOTE = "Externally-sourced free text in the resul
81
81
  export declare function untrustedFieldsOf(rec: CapabilityRecord): string[];
82
82
  /**
83
83
  * The per-tool description budget (issue #10 item (a): every served tool description
84
- * <= ~300 chars — the cap pin in test/unit/catalog-data.test.ts enforces it over all 41).
84
+ * <= ~300 chars — the cap pin in test/unit/catalog-data.test.ts enforces it over all 42).
85
85
  */
86
86
  export declare const MAX_DESCRIPTION_CHARS = 300;
87
87
  /**
@@ -99,11 +99,11 @@ export declare const MAX_DESCRIPTION_CHARS = 300;
99
99
  * thin-composite closed bags) rides the `instructions`' recipes and the tool input schemas
100
100
  * (autotask_describe's surface).
101
101
  *
102
- * 40 of the 41 tools are pinned here (the census at the pinned base showed 38 over the
102
+ * 41 of the 42 tools are pinned here (the census at the pinned base showed 38 over the
103
103
  * budget); the four formerly manifest-composed tools (who_am_i, company_resolve,
104
104
  * project_resolve, relationship_map) gained entries so the budget holds for them. Tools
105
105
  * ABSENT from this map serve the manifest-composed text (override ?? purpose + the
106
- * mechanical bounds sentence) unchanged — 1 of the 41 (version).
106
+ * mechanical bounds sentence) unchanged — 1 of the 42 (version).
107
107
  */
108
108
  export declare const TOOL_DESCRIPTIONS: Readonly<Record<string, string>>;
109
109
  /**
@@ -144,7 +144,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
144
144
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
145
145
  * describes the SDK behind it.
146
146
  */
147
- export declare const BUILT_AGAINST_PLAN_HASH = "4c8d9f800e97c350acc681ade183f67474c044b674494eb78cd0f14a933dc1fc";
147
+ export declare const BUILT_AGAINST_PLAN_HASH = "bd88648453f58ede018062ce66acefacb672fecaa450af89f421057809971e14";
148
148
  /**
149
149
  * Fail fast when the installed SDK's plan has moved out from under this surface.
150
150
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -9,7 +9,7 @@
9
9
  * - `MCP_TOOL_OVERRIDES.json` — the curation deltas (ALL `field: description`): when-to-call
10
10
  * hints and the zone-labelled wall pins. Applied exactly as the SDK's projector applies them,
11
11
  * so the served descriptions equal the manifest's (the drift gate moves with the plan hash);
12
- * - the compiled capability registry (`node-autotask/capabilities`) — the 1,212-operation
12
+ * - the compiled capability registry (`node-autotask/capabilities`) — the 1,213-operation
13
13
  * records the SDK's own invoke governor resolves against.
14
14
  *
15
15
  * The two JSON files are NOT behind the package's `exports` map (only `.` / `./capabilities` /
@@ -51,7 +51,7 @@ export function recordById(operation) {
51
51
  return RECORD_BY_ID.get(operation);
52
52
  }
53
53
  // ---------------------------------------------------------------------------
54
- // The 41-tool surface: server tool names per backing operation
54
+ // The 42-tool surface: server tool names per backing operation
55
55
  // ---------------------------------------------------------------------------
56
56
  /**
57
57
  * The server tool that serves each backing operation.
@@ -103,9 +103,10 @@ export const TOOL_NAME_BY_OP = {
103
103
  'resources.get': 'autotask_resource_workload',
104
104
  'time_off_requests.query': 'autotask_timeOffRequest_query',
105
105
  'companies.unbilledWork': 'autotask_company_unbilled_work',
106
+ 'contracts.profitability': 'autotask_contract_profitability',
106
107
  'operations.aggregate': 'autotask_aggregate',
107
108
  };
108
- /** Every tool name this server registers (Layer A 13 + Layer B 5 + Layer C 23 = 41). */
109
+ /** Every tool name this server registers (Layer A 13 + Layer B 5 + Layer C 24 = 42). */
109
110
  export const REGISTERED_TOOL_NAMES = [
110
111
  // Layer A — platform & discovery (13)
111
112
  'autotask_catalog',
@@ -127,7 +128,7 @@ export const REGISTERED_TOOL_NAMES = [
127
128
  'autotask_contact_resolve',
128
129
  'autotask_project_resolve',
129
130
  'autotask_ticket_resolve',
130
- // Layer C — dedicated workflow tools (23)
131
+ // Layer C — dedicated workflow tools (24)
131
132
  'autotask_ticket_query',
132
133
  'autotask_ticket_full_context',
133
134
  'autotask_ticket_sla_health_check',
@@ -150,6 +151,7 @@ export const REGISTERED_TOOL_NAMES = [
150
151
  'autotask_resource_workload',
151
152
  'autotask_timeOffRequest_query',
152
153
  'autotask_company_unbilled_work',
154
+ 'autotask_contract_profitability',
153
155
  'autotask_aggregate',
154
156
  ];
155
157
  const REGISTERED = new Set(REGISTERED_TOOL_NAMES);
@@ -219,7 +221,7 @@ function boundsSentence(rec) {
219
221
  }
220
222
  /**
221
223
  * The per-tool description budget (issue #10 item (a): every served tool description
222
- * <= ~300 chars — the cap pin in test/unit/catalog-data.test.ts enforces it over all 41).
224
+ * <= ~300 chars — the cap pin in test/unit/catalog-data.test.ts enforces it over all 42).
223
225
  */
224
226
  export const MAX_DESCRIPTION_CHARS = 300;
225
227
  /**
@@ -237,14 +239,14 @@ export const MAX_DESCRIPTION_CHARS = 300;
237
239
  * thin-composite closed bags) rides the `instructions`' recipes and the tool input schemas
238
240
  * (autotask_describe's surface).
239
241
  *
240
- * 40 of the 41 tools are pinned here (the census at the pinned base showed 38 over the
242
+ * 41 of the 42 tools are pinned here (the census at the pinned base showed 38 over the
241
243
  * budget); the four formerly manifest-composed tools (who_am_i, company_resolve,
242
244
  * project_resolve, relationship_map) gained entries so the budget holds for them. Tools
243
245
  * ABSENT from this map serve the manifest-composed text (override ?? purpose + the
244
- * mechanical bounds sentence) unchanged — 1 of the 41 (version).
246
+ * mechanical bounds sentence) unchanged — 1 of the 42 (version).
245
247
  */
246
248
  export const TOOL_DESCRIPTIONS = {
247
- autotask_catalog: `List capability registry rows: key, tool, purpose, effect, policy verdict (reachable/policyReason). Filters: q (key+purpose substring), resource, tier, effect; compact: key/tool/effect/reachable. Refused writes hidden by default (hidden_refused; show_refused lists them). limit default 41, max 500.`,
249
+ autotask_catalog: `List capability registry rows: key, tool, purpose, effect, policy verdict (reachable/policyReason). Filters: q (key+purpose substring), resource, tier, effect; compact: key/tool/effect/reachable. Refused writes hidden by default (hidden_refused; show_refused lists them). limit default 42, max 500.`,
248
250
  autotask_describe: `The exact input + output schema, examples, error codes and this deployment's policy verdict (reachable/why_not) for ONE operation. operation = the EXACT registry key (e.g. companies.query — never a tool name, never fuzzy). Call after autotask_catalog names the key, or before a mode-tool call.`,
249
251
  autotask_read: `Route any effect=read registry op by its EXACT key (never a tool name) — incl. the catalog-only field/entity/UDF-info reads. args positional; query/count ops: args[0] is the params object (autotask_describe first). Write/delete ops are refused pre-request. Prefer the dedicated tool when one exists.`,
250
252
  autotask_write: `Route any effect=write registry op by its EXACT key, dry-run-first (dry_run false applies — mutating request is never issued before then). The 3 approval-gated writes also need confirm === the op key (BOTH modes). Read/delete ops refused pre-request. Write-policy gated (POLICY_DENIED pre-request).`,
@@ -273,11 +275,12 @@ export const TOOL_DESCRIPTIONS = {
273
275
  autotask_project_get_context: `One project with its primary context: project + company + opportunity + lead resource. Project phase/task lists do NOT exist on this surface (no such vendor operations) — do not attempt them; charges/notes ride the generic layer (autotask_read). The project record alone: autotask_project_resolve.`,
274
276
  autotask_serviceCall_query: `Query ServiceCall records with a JSON filter — the on-site visit queue. Full profile (linked tickets, tasks, assignments): autotask_serviceCall_full_profile. Assignment writes ride autotask_write. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
275
277
  autotask_serviceCall_full_profile: `One service call + its linked tickets + its tasks (incl. resource assignments). Parts degrade to typed absent (no tasks → NO_TASKS on the assignments part); the service call itself missing → NOT_FOUND. Prefer this before on-site-visit work (assignment writes ride autotask_write).`,
276
- autotask_contract_full_profile: `One contract + its services (+ units) + its billing rules in a single call: parts degrade to typed absent, never a failure; the contract itself missing → NOT_FOUND. Deeper machinery (bundles, adjustments, exclusions, charges): autotask_read; a contract list: autotask_contract_query.`,
278
+ autotask_contract_full_profile: `One contract + services + billing rules + recent charges (50 newest) + service units in one call: parts degrade to typed absent; contract missing → NOT_FOUND. P&L / profitable?: autotask_contract_profitability. Deeper (bundles, adjustments, exclusions): autotask_read; list: autotask_contract_query.`,
277
279
  autotask_contract_query: `Query Contract records with a JSON filter — e.g. active contracts for a company (the SLA & recurring-service view). One contract with its services/units/billing rules: autotask_contract_full_profile. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
278
280
  autotask_resource_workload: `One resource's availability picture: open assigned tickets + service-call assignments + time-off balances + pending time-off requests. Parts degrade to typed absent; resource missing → NOT_FOUND. Approve/reject: autotask_write. The request list: autotask_timeOffRequest_query. Sensitive.`,
279
281
  autotask_timeOffRequest_query: `Query TimeOffRequest records with a JSON filter. Approve/reject ride autotask_write (time_off_requests.approve / .reject — dry-run-first; reject takes a reason). Availability: autotask_resource_workload. Bounded: limit default 50, max 2500. per-call options: see the server instructions.`,
280
282
  autotask_company_unbilled_work: `A company's unbilled work: posted billing items, the time behind them, per-ticket rollup, the unposted section, totals + hours + warnings. Arguments: id + closed bag (see the server instructions). A filtered empty part is a TRUE zero; shape problems → typed CONFIG_ERROR; missing company → NOT_FOUND.`,
283
+ autotask_contract_profitability: `Contract P&L by month: profit, margin, revenue (recurring/T&M/charges), break-even, unbilled/unposted time, Block Hours included hours, warnings. Args: id, date_from/date_to (default last 12 months), include_rows. Internal-currency billing-period (start-month) money; parts degrade to typed absent.`,
281
284
  autotask_aggregate: `Fold a query surface into groups/sums/totals or count it (the count endpoint). operation = an EXACT plan .query op (others refused); filter (grammar; empty array = unfiltered: a 0 on a blind surface is marked blind: true); group_by / sum / top / limit: server instructions. Read op; zero mutating.`,
282
285
  autotask_resource_who_am_i: `Identify the authenticated integration by its Resource record (name, zone, tenant). Call first to verify credentials and scope before other work. No arguments; returns the compact ResourceSummary, or null when the configured user cannot be matched. Sensitive.`,
283
286
  autotask_company_resolve: `Resolve a Company from a numeric id (tried first) or a companyName — exact by default, or fuzzy candidates via match:'contains' (+ include_family). The record carries companyName — cite that, never the bare id. Other forms (e.g. email) NOT tried; a miss is a typed NOT_FOUND. Read-only. Sensitive.`,
@@ -339,7 +342,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
339
342
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
340
343
  * describes the SDK behind it.
341
344
  */
342
- export const BUILT_AGAINST_PLAN_HASH = '4c8d9f800e97c350acc681ade183f67474c044b674494eb78cd0f14a933dc1fc';
345
+ export const BUILT_AGAINST_PLAN_HASH = 'bd88648453f58ede018062ce66acefacb672fecaa450af89f421057809971e14';
343
346
  /**
344
347
  * Fail fast when the installed SDK's plan has moved out from under this surface.
345
348
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import type { ToolContext } from './context.js';
3
3
  /** Register the whole composite surface. */
4
4
  /**
5
- * The 13 composite tool names. The served `tools/list` order is the approved surface order
5
+ * The 14 composite tool names. The served `tools/list` order is the approved surface order
6
6
  * (architecture §2.3 — the `REGISTERED_TOOL_NAMES` constant); `register.ts` drives the
7
7
  * registration one tool at a time through {@link registerCompositeTool}.
8
8
  */
@@ -11,3 +11,12 @@ export declare const COMPOSITE_TOOL_NAMES: readonly string[];
11
11
  export declare function registerCompositeTools(server: McpServer, ctx: ToolContext): void;
12
12
  /** Register ONE composite tool by name (FT-2: per-tool order, driven by `register.ts`). */
13
13
  export declare function registerCompositeTool(server: McpServer, ctx: ToolContext, tool: string): void;
14
+ /**
15
+ * The profitability window: the caller's days, else the default — 12 calendar months ending
16
+ * date_to (default today, UTC): from the 1st of the month 11 months before date_to's month. An
17
+ * inverted window is refused pre-request (the typed CONFIG_ERROR, zero wire).
18
+ */
19
+ export declare function profitabilityWindow(dateFrom: string | undefined, dateTo: string | undefined, now?: Date): {
20
+ dateFrom: string;
21
+ dateTo: string;
22
+ };
@@ -13,10 +13,17 @@
13
13
  *
14
14
  * Parts run as bounded fan-outs over ONE pooled tenant client: the client's own concurrency
15
15
  * guard (the agent profile) bounds the in-flight vendor requests; the part list is small and
16
- * static per tool.
16
+ * static per tool. Wire budget per call: the contract profile is the widest — 1 root read + 4
17
+ * parts in one parallel fan-out (contract_services.query / contract_billing_rules.query /
18
+ * contract_charges.query / contract_service_units.query, each limit 50 → one page), EXCEPT the
19
+ * charges part, which rides the SDK's count-anchored `latest` 50 (the vendor has no sort
20
+ * parameter; newest-first = id-newest, tracking the vendor's createDate/datePurchased) — 1 count
21
+ * + at most 6 page POSTs while the matching set is <= 2500 rows (the SDK's full-set mode); above
22
+ * that the SDK spends its own id-threshold count-probe budget before a <= 6-page walk, and a
23
+ * blind surface takes the plain bounded walk. Common case: at most 11 vendor requests per call.
17
24
  */
18
- import { NotFoundError } from 'node-autotask';
19
- import { TICKET_FULL_CONTEXT_INPUT, COMPANY_FULL_PROFILE_INPUT, CONFIGURATION_ITEM_PROFILE_INPUT, PROJECT_GET_CONTEXT_INPUT, QUOTE_GET_INPUT, INVOICE_GET_INPUT, INVOICE_MARKUP_INPUT, CONTACT_FULL_PROFILE_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, RESOURCE_WORKLOAD_INPUT, UNBILLED_WORK_INPUT, AGGREGATE_INPUT, COMPOSITE_OUTPUT, resultOutput, aggregateOutput, unbilledWorkOutput, closedInput, } from './schemas.js';
25
+ import { AutotaskConfigError, NotFoundError } from 'node-autotask';
26
+ import { TICKET_FULL_CONTEXT_INPUT, COMPANY_FULL_PROFILE_INPUT, CONFIGURATION_ITEM_PROFILE_INPUT, PROJECT_GET_CONTEXT_INPUT, QUOTE_GET_INPUT, INVOICE_GET_INPUT, INVOICE_MARKUP_INPUT, CONTACT_FULL_PROFILE_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, RESOURCE_WORKLOAD_INPUT, UNBILLED_WORK_INPUT, CONTRACT_PROFITABILITY_INPUT, AGGREGATE_INPUT, COMPOSITE_OUTPUT, resultOutput, aggregateOutput, unbilledWorkOutput, contractProfitabilityOutput, closedInput, } from './schemas.js';
20
27
  import { annotationsFor, untrustedFieldsOf } from './catalog-data.js';
21
28
  import { runTool, abortedAnswer } from './dispatch.js';
22
29
  import { markResult, ok } from './results.js';
@@ -25,6 +32,11 @@ import { assertSecretReadPermitted, assertWritePermitted } from '../policy.js';
25
32
  const TICKET_STATUS_CLOSED = 5;
26
33
  /** The cap on a fan-out `in` filter (the task-id join between two parts). */
27
34
  const IN_FILTER_CAP = 100;
35
+ /** The charges part's recent-N cap (issue #49): the N newest contract charges, newest first —
36
+ * served through the SDK's count-anchored `latest` (the vendor has no sort parameter). */
37
+ const CHARGES_RECENT_CAP = 50;
38
+ /** The units part's row cap (issue #49). */
39
+ const SERVICE_UNITS_CAP = 50;
28
40
  /** Run one part read: a miss/fault degrades to `{ value: null, absent: code }`, never a throw. */
29
41
  async function part(ctx, op, args, signal) {
30
42
  try {
@@ -59,7 +71,7 @@ function registerSdkComposite(ctx, server, spec) {
59
71
  const rec = ctx.record(spec.operation);
60
72
  if (rec === undefined)
61
73
  throw new Error(`composites: no registry record for ${spec.operation}`);
62
- const fields = untrustedFieldsOf(rec);
74
+ const fields = [...new Set([...untrustedFieldsOf(rec), ...(spec.extraUntrusted ?? [])])].sort();
63
75
  const output = (spec.outputSchema ?? ((f) => resultOutput('The composite (the SDK runs the bounded fan-out; a part miss is null + the typed absent reason).', f)))(fields);
64
76
  server.registerTool(spec.tool, {
65
77
  title: spec.title,
@@ -79,7 +91,9 @@ function registerSdkComposite(ctx, server, spec) {
79
91
  // default off) is the DEFAULT lever: it applies only when the caller left `names` ABSENT,
80
92
  // so a per-call names: false overrides the env opt-in OFF. Absent both keys and the env
81
93
  // unset leaves the bag byte-identical to the pre-names surface (issue #20 AC 4).
82
- if (opts.names === undefined && ctx.config.AUTOTASK_COMPOSITE_NAMES === 'on')
94
+ // Only the tools that declare `names` take it: the closed SDK bags of the others (unbilled
95
+ // work, profitability) refuse an unknown key.
96
+ if (opts.names === undefined && ctx.config.AUTOTASK_COMPOSITE_NAMES === 'on' && spec.input.validKeys.includes('names'))
83
97
  opts.names = true;
84
98
  // A missing root fails the call (the typed vendor error); per-part misses degrade inside the SDK.
85
99
  // The positional list is built CONDITIONALLY: the operation's declared arity is the contract
@@ -122,7 +136,7 @@ function registerServerComposite(ctx, server, spec) {
122
136
  }
123
137
  /** Register the whole composite surface. */
124
138
  /**
125
- * The 13 composite tool names. The served `tools/list` order is the approved surface order
139
+ * The 14 composite tool names. The served `tools/list` order is the approved surface order
126
140
  * (architecture §2.3 — the `REGISTERED_TOOL_NAMES` constant); `register.ts` drives the
127
141
  * registration one tool at a time through {@link registerCompositeTool}.
128
142
  */
@@ -139,6 +153,7 @@ export const COMPOSITE_TOOL_NAMES = [
139
153
  'autotask_contract_full_profile',
140
154
  'autotask_resource_workload',
141
155
  'autotask_company_unbilled_work',
156
+ 'autotask_contract_profitability',
142
157
  'autotask_aggregate',
143
158
  ];
144
159
  /** Register the whole composite surface (group form; the ORDERED form is per-tool). */
@@ -173,6 +188,8 @@ export function registerCompositeTool(server, ctx, tool) {
173
188
  return registerResourceWorkload(server, ctx);
174
189
  case 'autotask_company_unbilled_work':
175
190
  return registerUnbilledWork(server, ctx);
191
+ case 'autotask_contract_profitability':
192
+ return registerContractProfitability(server, ctx);
176
193
  case 'autotask_aggregate':
177
194
  return registerAggregateTool(server, ctx);
178
195
  }
@@ -370,12 +387,13 @@ function registerServiceCallFullProfile(server, ctx) {
370
387
  },
371
388
  });
372
389
  }
373
- // ---- autotask_contract_full_profile: the contract, its services, its billing rules ----
390
+ // ---- autotask_contract_full_profile: the contract, its services, its billing rules,
391
+ // ---- its recent charges (issue #49) and its service units (issue #49) ----
374
392
  function registerContractFullProfile(server, ctx) {
375
393
  const contractInput = closedInput(CONTRACT_FULL_PROFILE_INPUT);
376
394
  registerServerComposite(ctx, server, {
377
395
  tool: 'autotask_contract_full_profile',
378
- title: 'Get a contract with its services and billing rules',
396
+ title: 'Get a contract with its services, billing rules, recent charges and service units',
379
397
  description: ctx.description('autotask_contract_full_profile', 'contracts.get'),
380
398
  input: contractInput,
381
399
  rootOp: 'contracts.get',
@@ -383,16 +401,34 @@ function registerContractFullProfile(server, ctx) {
383
401
  const id = args.id;
384
402
  // The declared per-part limits (QUERY_LIMIT: 1-2500, default 50 — schema-validated at the
385
403
  // protocol layer); each part reads its own declared argument, not one shared phantom key.
404
+ // The two issue #49 parts carry NO input knobs (the closed bag is unchanged): the charges
405
+ // part is the FIXED recent-N and the units part the fixed row cap.
386
406
  const serviceLimit = args.service_limit ?? 50;
387
407
  const billingRuleLimit = args.billing_rule_limit ?? 50;
388
408
  const root = await c.ops.invokeRead(c.client, 'contracts.get', [id], undefined, signal);
389
409
  if (root === undefined)
390
410
  return { root: null, rootOp: 'contracts.get', parts: {} };
391
- const [services, billingRules] = await Promise.all([
411
+ const [services, billingRules, charges, units] = await Promise.all([
392
412
  part(c, 'contract_services.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: serviceLimit }], signal),
393
413
  part(c, 'contract_billing_rules.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: billingRuleLimit }], signal),
414
+ // The recent charges (issue #49): the vendor has NO sort parameter — the SDK's
415
+ // count-anchored `latest` (the N highest-id rows, id-descending) is the only ordering
416
+ // channel; the id order tracks the vendor's createDate/datePurchased, so newest-first
417
+ // is id-newest-first. limit === latest is the legal coexistence (limit >= latest) and
418
+ // keeps the walk window explicit (1 count + at most 6 page POSTs).
419
+ part(c, 'contract_charges.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: CHARGES_RECENT_CAP, latest: CHARGES_RECENT_CAP }], signal),
420
+ part(c, 'contract_service_units.query', [{ filter: [{ field: 'contractID', op: 'eq', value: id }], limit: SERVICE_UNITS_CAP }], signal),
394
421
  ]);
395
- return { root, rootOp: 'contracts.get', parts: { services: ['contract_services.query', services], billing_rules: ['contract_billing_rules.query', billingRules] } };
422
+ return {
423
+ root,
424
+ rootOp: 'contracts.get',
425
+ parts: {
426
+ services: ['contract_services.query', services],
427
+ billing_rules: ['contract_billing_rules.query', billingRules],
428
+ charges: ['contract_charges.query', charges],
429
+ units: ['contract_service_units.query', units],
430
+ },
431
+ };
396
432
  },
397
433
  });
398
434
  }
@@ -465,6 +501,74 @@ function registerUnbilledWork(server, ctx) {
465
501
  ]),
466
502
  });
467
503
  }
504
+ // ---- autotask_contract_profitability: the SDK's contract P&L composite (<=21 bounded reads) ----
505
+ /** A Date's UTC calendar day (YYYY-MM-DD). */
506
+ function utcDay(d) {
507
+ return d.toISOString().slice(0, 10);
508
+ }
509
+ /**
510
+ * The profitability window: the caller's days, else the default — 12 calendar months ending
511
+ * date_to (default today, UTC): from the 1st of the month 11 months before date_to's month. An
512
+ * inverted window is refused pre-request (the typed CONFIG_ERROR, zero wire).
513
+ */
514
+ export function profitabilityWindow(dateFrom, dateTo, now = new Date()) {
515
+ const to = dateTo ?? utcDay(now);
516
+ const [y, m] = to.split('-').map(Number);
517
+ const from = dateFrom ?? utcDay(new Date(Date.UTC(y, m - 1 - 11, 1)));
518
+ if (to < from) {
519
+ throw new AutotaskConfigError(`profitability: date_to (${to}) must be >= date_from (${from})${dateTo === undefined ? ' — date_to defaulted to today (UTC)' : ''}`, {
520
+ operation: 'contracts.profitability',
521
+ });
522
+ }
523
+ return { dateFrom: from, dateTo: to };
524
+ }
525
+ /** The reads behind the profitability result's customer + part containers (their untrusted stamps). */
526
+ const PROFITABILITY_PART_OPS = [
527
+ 'companies.get',
528
+ 'contract_services.query',
529
+ 'contract_service_units.query',
530
+ 'contract_charges.query',
531
+ 'contract_role_costs.query',
532
+ 'contract_rates.query',
533
+ 'contract_blocks.query',
534
+ 'billing_items.query',
535
+ 'invoices.query',
536
+ 'time_entries.query',
537
+ 'tickets.query',
538
+ 'resources.query',
539
+ 'expense_items.query',
540
+ 'configuration_items.query',
541
+ 'subscriptions.query',
542
+ ];
543
+ function registerContractProfitability(server, ctx) {
544
+ registerSdkComposite(ctx, server, {
545
+ tool: 'autotask_contract_profitability',
546
+ operation: 'contracts.profitability',
547
+ title: "Get a contract's profitability (monthly P&L) in one call",
548
+ input: closedInput(CONTRACT_PROFITABILITY_INPUT),
549
+ outputSchema: contractProfitabilityOutput,
550
+ // The registry stamp covers the contract record only; the result also carries the customer,
551
+ // the part rows (include_rows) and vendor text in the folds: highest_cost_tickets[].title,
552
+ // open_renewal_items[].label (ticket title / subscriptionName / contractName),
553
+ // contract_type.label (the tenant picklist label) and the part notes (an absent part's note
554
+ // quotes the vendor error message).
555
+ extraUntrusted: [...PROFITABILITY_PART_OPS.flatMap((op) => ctx.untrustedFields(op)), 'title', 'label', 'note'],
556
+ // opts is REQUIRED by the SDK (dateFrom + dateTo), so the bag always travels.
557
+ opts: (a) => ({
558
+ ...profitabilityWindow(a.date_from, a.date_to),
559
+ ...bagOf([
560
+ ['includeRows', a.include_rows],
561
+ ['labourCostMethod', a.labour_cost_method],
562
+ ['labourRatePerHour', a.labour_rate_per_hour],
563
+ ['includeRecurringCosts', a.include_recurring_costs],
564
+ ['includeUnbilledRevenue', a.include_unbilled_revenue],
565
+ ['externalMonthlyCosts', a.external_monthly_costs],
566
+ ['staleAfterDays', a.stale_after_days],
567
+ ['limit', a.limit],
568
+ ]),
569
+ }),
570
+ });
571
+ }
468
572
  // ---- autotask_aggregate: count / group / sum over ONE query surface (the SDK folds client-side) ----
469
573
  function registerAggregateTool(server, ctx) {
470
574
  const aggregateInput = closedInput(AGGREGATE_INPUT);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * tools/context.ts — what the tool registrations share, and the ONLY place the SDK is reached.
3
3
  *
4
- * One thin client/ops factory stands between the 41 tool handlers and `node-autotask`:
4
+ * One thin client/ops factory stands between the 42 tool handlers and `node-autotask`:
5
5
  *
6
6
  * - `ToolClient` is the client token (a real `AutotaskClient` in production; a fixture-backed
7
7
  * token in tests). Handlers never call SDK methods directly.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * tools/context.ts — what the tool registrations share, and the ONLY place the SDK is reached.
3
3
  *
4
- * One thin client/ops factory stands between the 41 tool handlers and `node-autotask`:
4
+ * One thin client/ops factory stands between the 42 tool handlers and `node-autotask`:
5
5
  *
6
6
  * - `ToolClient` is the client token (a real `AutotaskClient` in production; a fixture-backed
7
7
  * token in tests). Handlers never call SDK methods directly.
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * tools/register.ts — `registerTools`: derives the per-server context from the deps bundle once,
3
- * then registers the whole 41-tool surface in layer order (the registration order is the
3
+ * then registers the whole 42-tool surface in layer order (the registration order is the
4
4
  * `tools/list` order the model sees):
5
5
  *
6
6
  * - Layer A (13): catalog, describe, read/write/delete (the mode tools), who_am_i, version,
7
7
  * threshold_information, resolve_any, search_across_resources, fetch_many, read_batch,
8
8
  * relationship_map;
9
9
  * - Layer B (5): the dedicated workflow-resource resolves;
10
- * - Layer C (23): the five bounded queries + the SLA check + the three find-by lookups, the five
11
- * SDK-backed composites (the unbilled-work composite included), the aggregate helper, the seven
12
- * server-built composites/reads, and the one dedicated write (`autotask_time_entry_create`).
10
+ * - Layer C (24): the five bounded queries + the SLA check + the three find-by lookups, the six
11
+ * SDK-backed composites (the unbilled-work and profitability composites included), the
12
+ * aggregate helper, the seven server-built composites/reads, and the one dedicated write (`autotask_time_entry_create`).
13
13
  *
14
14
  * Under a read-only deployment the write/delete mode tools are simply absent from the surface
15
15
  * (the deployment's policy IS the surface — see `registerModeTools`). The dedicated write
@@ -6,10 +6,10 @@ import { registerListTool } from './lists.js';
6
6
  import { COMPOSITE_TOOL_NAMES, registerCompositeTool } from './composites.js';
7
7
  import { registerTimeEntryCreate } from './dispatch.js';
8
8
  /**
9
- * Layer C in the approved order: the constant's last 23 entries (Layer A's 13 + Layer B's 5
9
+ * Layer C in the approved order: the constant's last 24 entries (Layer A's 13 + Layer B's 5
10
10
  * precede them in `REGISTERED_TOOL_NAMES`).
11
11
  */
12
- const LAYER_C_TOOLS = REGISTERED_TOOL_NAMES.slice(-23);
12
+ const LAYER_C_TOOLS = REGISTERED_TOOL_NAMES.slice(-24);
13
13
  /**
14
14
  * Register the full tool surface on one serving unit's server.
15
15
  *
@@ -306,6 +306,24 @@ export declare const UNBILLED_WORK_INPUT: z.ZodObject<{
306
306
  }>>;
307
307
  limit: z.ZodOptional<z.ZodNumber>;
308
308
  }, z.core.$strip>;
309
+ export declare const CONTRACT_PROFITABILITY_INPUT: z.ZodObject<{
310
+ id: z.ZodNumber;
311
+ date_from: z.ZodOptional<z.ZodString>;
312
+ date_to: z.ZodOptional<z.ZodString>;
313
+ include_rows: z.ZodOptional<z.ZodBoolean>;
314
+ labour_cost_method: z.ZodOptional<z.ZodEnum<{
315
+ billing_item_our_cost: "billing_item_our_cost";
316
+ contract_role_cost: "contract_role_cost";
317
+ resource_internal_cost: "resource_internal_cost";
318
+ provided: "provided";
319
+ }>>;
320
+ labour_rate_per_hour: z.ZodOptional<z.ZodNumber>;
321
+ include_recurring_costs: z.ZodOptional<z.ZodBoolean>;
322
+ include_unbilled_revenue: z.ZodOptional<z.ZodBoolean>;
323
+ external_monthly_costs: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
324
+ stale_after_days: z.ZodOptional<z.ZodNumber>;
325
+ limit: z.ZodOptional<z.ZodNumber>;
326
+ }, z.core.$strip>;
309
327
  export declare const AGGREGATE_INPUT: z.ZodObject<{
310
328
  operation: z.ZodString;
311
329
  filter: z.ZodUnknown;
@@ -385,7 +403,11 @@ export declare function queryEnvelope(fields?: readonly string[]): z.ZodType;
385
403
  /** The five bounded query tools' output: the typed envelope under `result` (mirrors `resultOutput`). */
386
404
  export declare function queryEnvelopeOutput(fields?: readonly string[]): z.ZodType;
387
405
  /** A composite answer (SDK or server-built): the root record + one entry per part; a part miss
388
- * is `null + absent`, never a tool failure — only a missing ROOT fails the composite. */
406
+ * is `null + absent`, never a tool failure — only a missing ROOT fails the composite.
407
+ * The part names are tool-specific (this shared envelope stays a record): the contract
408
+ * profile serves `services` / `billing_rules` / `charges` (the recent-50 via the SDK's
409
+ * count-anchored `latest`, newest first — issue #49) / `units` (issue #49), every one riding
410
+ * the same value/absent envelope. */
389
411
  export declare const COMPOSITE_OUTPUT: z.ZodObject<{
390
412
  root: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
391
413
  parts: z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -450,6 +472,14 @@ export declare function aggregateOutput(): z.ZodType;
450
472
  export declare function unbilledWorkRoot(fields?: readonly string[]): z.ZodType;
451
473
  /** The unbilled-work tool's output: the typed root under `result` (the served `parts` object is the closed status vocabulary). */
452
474
  export declare function unbilledWorkOutput(fields?: readonly string[]): z.ZodType;
475
+ /**
476
+ * The contract profitability root (d.ts-transcribed, pinned 0.13.0 `ContractsProfitabilityResult`):
477
+ * the folds are typed; the vendor records (contract, customer, part rows) stay `dataField` + the
478
+ * untrusted stamp by design (OQ-3).
479
+ */
480
+ export declare function contractProfitabilityRoot(fields?: readonly string[]): z.ZodType;
481
+ /** The contract profitability tool's output: the typed root under `result`. */
482
+ export declare function contractProfitabilityOutput(fields?: readonly string[]): z.ZodType;
453
483
  /** A cross-resource result; `truncated` names resources whose scan hit its cap (undecided). */
454
484
  export declare const RESOLVE_ANY_OUTPUT: z.ZodObject<{
455
485
  identifier: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
@@ -175,7 +175,7 @@ function untrustedField(schema, fields) {
175
175
  // Layer A — platform & discovery inputs (12)
176
176
  // ---------------------------------------------------------------------------
177
177
  export const CATALOG_INPUT = z.object({
178
- limit: z.number().int().min(1).max(500).default(41).describe('Maximum rows per page (1-500, default 41 — the whole served surface on one default page).'),
178
+ limit: z.number().int().min(1).max(500).default(42).describe('Maximum rows per page (1-500, default 42 — the whole served surface on one default page).'),
179
179
  offset: z.number().int().min(0).default(0).describe('Row offset — how to page past the limit.'),
180
180
  tier: z.enum(['core', 'extended', 'advanced']).optional().describe('Filter rows by plan tier.'),
181
181
  effect: z.enum(['read', 'write', 'destructive']).optional().describe('Filter rows by effect.'),
@@ -408,6 +408,8 @@ export const CONTRACT_FULL_PROFILE_INPUT = z.object({
408
408
  id: ID.describe('The numeric contract id.'),
409
409
  service_limit: QUERY_LIMIT,
410
410
  billing_rule_limit: QUERY_LIMIT,
411
+ // issue #49: the two new parts (charges — the FIXED recent-50 via the SDK's `latest`; units —
412
+ // the fixed 50-row cap) carry NO input keys: the closed bag above is unchanged.
411
413
  });
412
414
  export const RESOURCE_WORKLOAD_INPUT = z.object({
413
415
  id: ID.describe('The numeric resource id.'),
@@ -454,6 +456,41 @@ export const UNBILLED_WORK_INPUT = z.object({
454
456
  .optional()
455
457
  .describe('The per-part record cap (default 25, clamped 1..500).'),
456
458
  });
459
+ /** A calendar day (YYYY-MM-DD). */
460
+ const DAY = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD');
461
+ export const CONTRACT_PROFITABILITY_INPUT = z.object({
462
+ id: ID.describe('The numeric contract id.'),
463
+ date_from: DAY.optional().describe('Window start (YYYY-MM-DD, UTC day). Default: the 1st of the month 11 months before date_to (12 calendar months).'),
464
+ date_to: DAY.optional().describe('Window end (YYYY-MM-DD, UTC day, >= date_from; window at most 36 months). Default: today (UTC).'),
465
+ include_rows: z
466
+ .boolean()
467
+ .optional()
468
+ .describe('Return the raw rows behind every part (default false — each part carries its stamps + rows_fetched instead of items; every computed figure is returned either way).'),
469
+ labour_cost_method: z
470
+ .enum(['billing_item_our_cost', 'contract_role_cost', 'resource_internal_cost', 'provided'])
471
+ .optional()
472
+ .describe('The labour-cost ladder rung rows start from (default "billing_item_our_cost"; a row without a rate falls down the ladder). "provided" requires labour_rate_per_hour.'),
473
+ labour_rate_per_hour: z
474
+ .number()
475
+ .min(0)
476
+ .optional()
477
+ .describe('A caller-supplied labour cost rate: REQUIRED with labour_cost_method "provided"; otherwise the final-rung fallback.'),
478
+ include_recurring_costs: z.boolean().optional().describe('Include contract charge + service-unit costs (default true).'),
479
+ include_unbilled_revenue: z.boolean().optional().describe('Include the expected-but-unbilled revenue section (default true).'),
480
+ external_monthly_costs: z
481
+ .array(z.number().min(0))
482
+ .max(36)
483
+ .optional()
484
+ .describe('Caller-supplied per-month tooling costs, indexed by month from date_from (not vendor data — reported as caller_supplied and counted in total_cost).'),
485
+ stale_after_days: z.number().int().min(1).optional().describe('The open-ticket staleness horizon in days (default 30).'),
486
+ limit: z
487
+ .number()
488
+ .int()
489
+ .min(1)
490
+ .max(500)
491
+ .optional()
492
+ .describe('The per-part record cap (default 50, clamped 1..500).'),
493
+ });
457
494
  export const AGGREGATE_INPUT = z.object({
458
495
  operation: z
459
496
  .string()
@@ -588,7 +625,11 @@ export function queryEnvelopeOutput(fields = []) {
588
625
  return z.object({ result: queryEnvelope(fields) });
589
626
  }
590
627
  /** A composite answer (SDK or server-built): the root record + one entry per part; a part miss
591
- * is `null + absent`, never a tool failure — only a missing ROOT fails the composite. */
628
+ * is `null + absent`, never a tool failure — only a missing ROOT fails the composite.
629
+ * The part names are tool-specific (this shared envelope stays a record): the contract
630
+ * profile serves `services` / `billing_rules` / `charges` (the recent-50 via the SDK's
631
+ * count-anchored `latest`, newest first — issue #49) / `units` (issue #49), every one riding
632
+ * the same value/absent envelope. */
592
633
  export const COMPOSITE_OUTPUT = z.object({
593
634
  root: dataField('The root record (a missing root fails the call with the typed vendor error).'),
594
635
  parts: z
@@ -708,6 +749,179 @@ export function unbilledWorkRoot(fields = []) {
708
749
  export function unbilledWorkOutput(fields = []) {
709
750
  return z.object({ result: unbilledWorkRoot(fields) });
710
751
  }
752
+ /** One labour-cost rate-ladder bucket (entries / hours / cost carried by that rung). */
753
+ const RATE_BUCKET = z.object({ entries: z.number(), hours: z.number(), cost: z.number() });
754
+ /**
755
+ * One profitability part container (d.ts-transcribed, pinned 0.13.0 `ProfitabilityPart` /
756
+ * `ProfitabilityPartDigest`): the digest (the default — the stamps + `rows_fetched`, no items)
757
+ * or, with include_rows, the full container (the stamps + the vendor rows). A faulted part is
758
+ * status 'absent' + the typed code — the part degraded, the call did not.
759
+ */
760
+ function profitabilityPartSchema(fields) {
761
+ const stamps = {
762
+ capped: z.boolean().describe('True when the page came back full (the cap rows) — more may exist.'),
763
+ hasMore: z.boolean().describe("The part's hasMore."),
764
+ note: untrustedField(z.string(), fields).optional().describe('An honest note (skipped reason / join-set truncation / blind-list quirk / true zero / per-item misses); an absent part\'s note quotes the vendor error text.'),
765
+ blindSurface: BLIND_SURFACE_SCHEMA.optional(),
766
+ emptyReason: EMPTY_REASON_SCHEMA.optional(),
767
+ absent: z.string().optional().describe("status 'absent' only: the typed error code of the read that faulted."),
768
+ status: z.enum(['ok', 'blind', 'skipped', 'absent']).describe('The part status (the closed SDK vocabulary).'),
769
+ };
770
+ return z.union([
771
+ z.object({ ...stamps, rows_fetched: z.number().describe('The rows the part read (include_rows false — the default).') }),
772
+ z.object({ ...stamps, items: z.array(dataField("The part's vendor records (include_rows true; untrusted by construction).", fields)) }),
773
+ ]);
774
+ }
775
+ /** One month of the contract P&L (d.ts-transcribed, pinned 0.13.0 `ProfitabilityMonthlyRow`). */
776
+ const PROFITABILITY_MONTH = z.object({
777
+ month: z.string().describe('YYYY-MM (the billing period; annual units are attributed to their start month).'),
778
+ revenue_invoiced: z.number(),
779
+ revenue_by_type: z.object({ recurring: z.number(), labour: z.number(), charges: z.number(), expenses: z.number(), other: z.number() }),
780
+ revenue_voided_excluded: z.number(),
781
+ revenue_posted_uninvoiced: z.number(),
782
+ revenue_unresolved_invoice: z.number(),
783
+ revenue_expected: z.number().nullable(),
784
+ revenue_expected_unbilled: z.number(),
785
+ unposted_time: z
786
+ .object({ entries: z.number(), hours: z.number(), value: z.number().nullable(), absent: z.string().optional() })
787
+ .describe('Billable time not yet posted; value is priced on Time & Materials contracts only (otherwise null + absent).'),
788
+ labour_hours: z.number(),
789
+ labour_cost: z.object({ total: z.number(), unresolvedHours: z.number(), rateSources: z.record(z.string(), RATE_BUCKET) }),
790
+ tooling_cost: z
791
+ .object({ vendor: z.number().nullable(), caller_supplied: z.number() })
792
+ .describe('vendor = CI subscription cost, REPORTED only (excluded from total_cost); caller_supplied = external_monthly_costs (included).'),
793
+ recurring_cost: z.number(),
794
+ expense_cost: z.number(),
795
+ total_cost: z.number(),
796
+ profit: z.number(),
797
+ margin: z.number().nullable(),
798
+ break_even_hours: z.number().nullable(),
799
+ coverage_gaps: z.array(z.string()).describe('Why the month may be incomplete (absent / truncated / capped parts). Empty = complete.'),
800
+ });
801
+ /**
802
+ * The contract profitability root (d.ts-transcribed, pinned 0.13.0 `ContractsProfitabilityResult`):
803
+ * the folds are typed; the vendor records (contract, customer, part rows) stay `dataField` + the
804
+ * untrusted stamp by design (OQ-3).
805
+ */
806
+ export function contractProfitabilityRoot(fields = []) {
807
+ const part = profitabilityPartSchema(fields);
808
+ const partStatus = (...s) => z.enum(s);
809
+ return z.object({
810
+ contract: dataField('The contract record (the root — a missing contract fails the call).', fields),
811
+ customer: dataField('The customer company, or null when unread.', fields).nullable(),
812
+ customerAbsent: z.string().optional().describe('The typed code when the customer read faulted.'),
813
+ contract_type: z.object({ id: z.number().nullable(), label: untrustedField(z.string(), fields).nullable().describe('The tenant picklist label.'), absent: z.string().optional() }),
814
+ included_hours: z
815
+ .object({
816
+ value: z.object({ purchased: z.number(), approved: z.number(), remaining: z.number() }).nullable(),
817
+ absent: z.string().optional(),
818
+ })
819
+ .describe('Block Hours contracts only; otherwise null + the typed reason.'),
820
+ period: z.object({ dateFrom: z.string(), dateTo: z.string(), months: z.array(z.string()), asOf: z.string() }),
821
+ currency: z.object({ companyCurrencyID: z.number().nullable(), note: z.string() }).describe('Money figures are in the internal currency.'),
822
+ monthly: z.array(PROFITABILITY_MONTH),
823
+ activity_categories: z.array(z.object({
824
+ ticketCategory: z.number().nullable(),
825
+ ticketCount: z.number(),
826
+ entryCount: z.number(),
827
+ hours: z.number(),
828
+ cost: z.number(),
829
+ sample_ticketNumbers: z.array(z.number()),
830
+ })),
831
+ highest_cost_tickets: z.array(z.object({
832
+ ticketId: z.number(),
833
+ ticketNumber: z.number().nullable(),
834
+ title: untrustedField(z.string(), fields).describe('The vendor ticket title (untrusted by construction).'),
835
+ status: z.number().nullable(),
836
+ hours: z.number(),
837
+ cost: z.number(),
838
+ lastActivityDate: z.string().nullable(),
839
+ })),
840
+ possible_miscoding: z.array(z.object({
841
+ entryId: z.number(),
842
+ reason: z.enum(['no_contract_on_entry', 'ticket_of_other_contract', 'ticket_not_in_fetched_set']),
843
+ ticketId: z.number().nullable(),
844
+ contractId: z.number().nullable(),
845
+ hours: z.number(),
846
+ })),
847
+ open_renewal_items: z.array(z.object({
848
+ kind: z.enum(['ticket', 'subscription', 'contract_end', 'included_hours_burn']),
849
+ id: z.number().nullable(),
850
+ label: untrustedField(z.string(), fields).describe('The ticket title / subscriptionName / contractName (untrusted by construction).'),
851
+ date: z.string().nullable(),
852
+ detail: z.string(),
853
+ })),
854
+ data_quality_warnings: z.array(z.object({
855
+ code: z.enum([
856
+ 'miscoded_time_out',
857
+ 'cross_contract_ticket_time',
858
+ 'duplicate_entries',
859
+ 'zero_negative_hours',
860
+ 'time_outside_ticket_period',
861
+ 'missing_cost_rate',
862
+ 'recurring_cost_drift',
863
+ 'expected_unbilled_revenue',
864
+ 'cross_contract_tickets',
865
+ 'open_stale_tickets',
866
+ 'estimate_vs_actual',
867
+ ]),
868
+ count: z.number(),
869
+ sample_ids: z.array(z.union([z.number(), z.string()])),
870
+ note: z.string().optional(),
871
+ })),
872
+ costRateResolution: z.object({
873
+ method: z.enum(['billing_item_our_cost', 'contract_role_cost', 'resource_internal_cost', 'provided']),
874
+ sources: z.record(z.string(), RATE_BUCKET),
875
+ unresolvedHours: z.number(),
876
+ notes: z.array(z.string()),
877
+ }),
878
+ contractServices: part,
879
+ contractServiceUnits: part,
880
+ contractCharges: part,
881
+ contractRoleCosts: part,
882
+ contractRates: part,
883
+ contractBlocks: part,
884
+ billingItems: part,
885
+ invoices: part,
886
+ timeEntries: part,
887
+ ticketsActivity: part,
888
+ ticketsOpen: part,
889
+ resources: part,
890
+ expenses: part,
891
+ miscodingProbe: part,
892
+ configurationItems: part,
893
+ subscriptions: part,
894
+ parts: z
895
+ .object({
896
+ contract: z.literal('ok'),
897
+ customer: partStatus('ok', 'skipped', 'absent'),
898
+ contractServices: partStatus('ok', 'blind', 'absent'),
899
+ contractServiceUnits: partStatus('ok', 'blind', 'absent'),
900
+ contractCharges: partStatus('ok', 'blind', 'skipped', 'absent'),
901
+ contractRoleCosts: partStatus('ok', 'blind', 'absent'),
902
+ contractRates: partStatus('ok', 'blind', 'absent'),
903
+ contractBlocks: partStatus('ok', 'blind', 'skipped', 'absent'),
904
+ billingItems: partStatus('ok', 'blind', 'absent'),
905
+ invoices: partStatus('ok', 'skipped'),
906
+ timeEntries: partStatus('ok', 'blind', 'absent'),
907
+ ticketsActivity: partStatus('ok', 'blind', 'absent'),
908
+ ticketsOpen: partStatus('ok', 'blind', 'skipped', 'absent'),
909
+ resources: partStatus('ok', 'skipped'),
910
+ expenses: partStatus('ok', 'blind', 'skipped', 'absent'),
911
+ miscodingProbe: partStatus('ok', 'blind', 'skipped', 'absent'),
912
+ configurationItems: partStatus('ok', 'blind', 'skipped', 'absent'),
913
+ subscriptions: partStatus('ok', 'blind', 'skipped', 'absent'),
914
+ })
915
+ .describe('Per-part status (the closed SDK vocabulary).'),
916
+ wires: z
917
+ .object({ issued: z.number(), requests: z.number(), budget: z.number(), note: z.string().optional() })
918
+ .describe('issued = logical part reads; requests = raw HTTP requests.'),
919
+ });
920
+ }
921
+ /** The contract profitability tool's output: the typed root under `result`. */
922
+ export function contractProfitabilityOutput(fields = []) {
923
+ return z.object({ result: contractProfitabilityRoot(fields) });
924
+ }
711
925
  /** A cross-resource result; `truncated` names resources whose scan hit its cap (undecided). */
712
926
  export const RESOLVE_ANY_OUTPUT = z.object({
713
927
  identifier: z.union([z.string(), z.number()]),
@@ -24,7 +24,7 @@ import type { Config } from '../config.js';
24
24
  import type { Logger } from '../logger.js';
25
25
  /** The wire identity of this server (the initialize answer + the `/metadata` payload). */
26
26
  export declare const SERVER_NAME = "iit-mcp-autotask";
27
- export declare const SERVER_VERSION = "0.4.2";
27
+ export declare const SERVER_VERSION = "0.5.0";
28
28
  export declare const SERVER_TITLE = "Autotask MCP Server";
29
29
  /**
30
30
  * The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
@@ -56,13 +56,13 @@ export declare const MCP_PROTOCOL_LEGACY_FALLBACK = "2025-11-25";
56
56
  * the SDK-mirrored sentence), (ii) the "start here" discovery recipes, (iii) the owner's
57
57
  * 4-step lookup protocol (issue #10 comment 1, verbatim), (iv) the argument contract and
58
58
  * the moved per-option / per-tool guidance (the #27/#28 query options, the autotask_read
59
- * query params, and the closed bags of the two thin composites).
59
+ * query params, and the closed bags of the thin composites).
60
60
  */
61
61
  export declare const SERVER_INSTRUCTIONS: string;
62
62
  /** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
63
63
  export declare const SERVER_IDENTITY: {
64
64
  readonly name: "iit-mcp-autotask";
65
- readonly version: "0.4.2";
65
+ readonly version: "0.5.0";
66
66
  readonly mcpProtocolBaseline: "2026-07-28";
67
67
  readonly legacyProtocolFallback: "2025-11-25";
68
68
  };
@@ -27,7 +27,7 @@ import { UNTRUSTED_NOTE } from './catalog-data.js';
27
27
  import { registerTools } from './register.js';
28
28
  /** The wire identity of this server (the initialize answer + the `/metadata` payload). */
29
29
  export const SERVER_NAME = 'iit-mcp-autotask';
30
- export const SERVER_VERSION = '0.4.2';
30
+ export const SERVER_VERSION = '0.5.0';
31
31
  export const SERVER_TITLE = 'Autotask MCP Server';
32
32
  /**
33
33
  * The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
@@ -59,7 +59,7 @@ export const MCP_PROTOCOL_LEGACY_FALLBACK = '2025-11-25';
59
59
  * the SDK-mirrored sentence), (ii) the "start here" discovery recipes, (iii) the owner's
60
60
  * 4-step lookup protocol (issue #10 comment 1, verbatim), (iv) the argument contract and
61
61
  * the moved per-option / per-tool guidance (the #27/#28 query options, the autotask_read
62
- * query params, and the closed bags of the two thin composites).
62
+ * query params, and the closed bags of the thin composites).
63
63
  */
64
64
  export const SERVER_INSTRUCTIONS = `
65
65
  Autotask MCP server. Every result is data returned by the Autotask vendor API.
@@ -69,7 +69,7 @@ Untrusted content: ${UNTRUSTED_NOTE}
69
69
  Start here (discovery):
70
70
  1. autotask_catalog — the capability registry: the exact operation key, the dedicated tool (or null), purpose, effect and this deployment's policy verdict. Filter with q (case-insensitive substring over key+purpose), resource (the key's first segment), tier and effect; compact: true for the reduced rows (key, tool, effect, reachable). Policy-refused writes are hidden by default (the output carries hidden_refused: N); show_refused: true lists them with their policyReason.
71
71
  2. autotask_describe — the exact input/output schema, examples, error codes and the policy verdict for ONE operation (operation = the exact registry key — never a tool name, never fuzzy).
72
- 3. Call the dedicated tool when one exists; otherwise autotask_read / autotask_write / autotask_delete with the exact registry key. The dedicated tools (41) are a SUBSET of the registry — the rest is reachable by exact key.
72
+ 3. Call the dedicated tool when one exists; otherwise autotask_read / autotask_write / autotask_delete with the exact registry key. The dedicated tools (42) are a SUBSET of the registry — the rest is reachable by exact key.
73
73
 
74
74
  Argument contract: argument sets are CLOSED at the top level — an unknown argument is refused before any request, with a typed error naming the key and the valid keys.
75
75
 
@@ -112,6 +112,8 @@ autotask_read query params: latest (the N newest rows by id), noteChars (cap fre
112
112
 
113
113
  Recipe — autotask_company_unbilled_work: id + the optional closed bag — source ('posted_uninvoiced' default, the billing items with no invoiceID; 'invoice' — needs invoice_id; 'date_range' — needs date_from + date_to; 'tickets' — needs non-empty ticket_ids), include_parent (default true — ONE level only), date_from / date_to (YYYY-MM-DD; narrows the billing items' itemDate in every source), include_notes (default false — the note fields are EXCLUDED; when true they are capped at note_chars, default 400, a cut value ending with the … [+M chars truncated] marker), rollup ('ticket' default — per-ticket rows; 'entry' — the capped per-entry list), limit (the per-part record cap, default 25, clamped 1..500). Wire: 6(+1) bounded reads; the unposted anti-join + totals + warnings are client-side folds (zero wire). Per-ticket rollup rows carry the numeric ticketId ONLY — resolve the distinct ids in ONE bounded call (autotask_read tickets.query with filter [{ field: 'id', op: 'in', value: [...] }], include_fields ['id','ticketNumber'], or autotask_fetch_many) when the answer must name the tickets; report the ticketNumber, never the bare id (rule 5).
114
114
 
115
+ Recipe — autotask_contract_profitability: id + an optional window — date_from / date_to (YYYY-MM-DD, UTC days; default: the 12 calendar months ending today — the 1st of the month 11 months back through today; one side omitted defaults the same way; date_to < date_from is refused pre-request; at most 36 months) + the optional closed bag: include_rows (default false — each part carries its status stamps + rows_fetched, NOT its rows; true returns the raw rows and a much larger result), labour_cost_method / labour_rate_per_hour ('provided' needs the rate), include_recurring_costs, include_unbilled_revenue, external_monthly_costs (caller tooling costs per month from date_from — counted in total_cost), stale_after_days, limit (per-part cap, default 50). Reading it: money is in the INTERNAL currency (currency.note), per billing period — annual/periodic service units count in their START month, so that month spikes while the rest of the term shows none. Check every month's coverage_gaps and the parts status map before quoting a figure: a faulted part is status 'absent' + its code (the call still answers) and the affected months name it. unposted_time.value is priced on Time & Materials contracts only (otherwise null + absent). included_hours is Block Hours only (otherwise null + the typed reason). tooling_cost.vendor (CI subscriptions) is reported separately and EXCLUDED from total_cost/profit/margin; labour_cost.unresolvedHours > 0 means total_cost is a lower bound (missing_cost_rate warning). Report the contract by contractName and tickets by ticketNumber (rule 5).
116
+
115
117
  Recipe — autotask_aggregate: operation (the EXACT registry query op — a plan resource's .query ONLY; a special op, a .count row or an unknown resource is refused pre-request, naming autotask_catalog), filter (the query-bag condition grammar — 13 operators; an empty array = the unfiltered call: a 0 on a plan-pinned blind surface is marked blind: true, never a silent absence; a filtered 0 on an unfiltered-blind surface is a true zero) + the optional closed bag: group_by (vendor field names — the group key is the field tuple), sum (numeric vendor fields), top (default 25, clamped 1..100 — the group-row cap), limit (default 2500 — the total-record cap for the walk modes; the house hard max). NEITHER group_by NOR sum = COUNT mode (exactly one POST /query/count — no walk, no GETs; the count endpoint is not cached); a group_by or sum = WALK mode (/query pages of min(500, limit), <= ceil(limit/500) pages + the 1000-page guard; the queries are not cached). The fold is client-side: groups carry count + the requested sums; totalGroups + groupsTruncated say when top cut the groups; hasMore + totalScanned say when the walk truncated — groups/sums cover the returned rows ONLY (no silent partial sums); a mid-walk RATE_LIMITED propagates (the partial fold is discarded). Group/sum rows carry the vendor fields you grouped by — when a grouped field is a numeric FK (e.g. ticketID, companyID), resolve the distinct ids in ONE bounded call (the owning resource's query with filter [{ field: 'id', op: 'in', value: [...] }], or autotask_fetch_many) before reporting them (rule 5).
116
118
 
117
119
  Recipe — autotask_timeEntry_create: data is required (the TimeEntry payload — vendor model fields); vendor field chain (a bare payload burns vendor 500s, one per real wire dial): link the entry to one of ticketID / taskID / internalAllocationCodeID; entries on Tickets also require roleID; entries on Service tickets require startDateTime + endDateTime; the hours field is hoursWorked (NOT hours) — at least one of hoursWorked or (startDateTime + endDateTime) is required; the linked ticket must be open (a completed ticket is refused); the allocation code must be an active GENERAL billing code (billingCodeType 0 + isActive true — discover one via billing_codes.query) or pass an explicit active general billingCodeID. On apply (dry_run: false) the tool reads back the created record (live: the write clears the SDK read cache) and returns it as record next to result.id; a dry run returns no record; a read-back failure leaves record null, adds a note naming the typed error code and never fails the write (the created id rides result). Report the entry by the LINKED TICKET's human ticketNumber — autotask_ticket_resolve on the linked ticketID (NOT autotask_ticket_find_by_ticket_number, which takes the ticketNumber string, not the id) — never by the created entry's bare id.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -47,18 +47,18 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@modelcontextprotocol/server": "^2.2.0",
50
- "node-autotask": "0.10.0",
51
- "zod": "^4.4.3"
50
+ "node-autotask": "0.13.0",
51
+ "zod": "^4.6.7"
52
52
  },
53
53
  "devDependencies": {
54
- "@eslint/js": "^9.17.0",
55
- "@types/node": "^24.0.0",
56
- "eslint": "^9.17.0",
57
- "globals": "^15.14.0",
54
+ "@eslint/js": "^10.0.1",
55
+ "@types/node": "^24.19.1",
56
+ "eslint": "^10.12.0",
57
+ "globals": "^17.13.0",
58
58
  "rimraf": "^6.0.1",
59
59
  "tsx": "^4.19.0",
60
- "typescript": "^5.7.0",
61
- "typescript-eslint": "^8.18.0",
62
- "vitest": "^4.1.11"
60
+ "typescript": "^6.0.3",
61
+ "typescript-eslint": "^8.71.2",
62
+ "vitest": "^5.0.4"
63
63
  }
64
64
  }
package/server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-autotask",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -29,7 +29,7 @@
29
29
  "capabilitiesManifest": "capabilities.json",
30
30
  "sdk": {
31
31
  "package": "node-autotask",
32
- "versionRange": "0.10.0"
32
+ "versionRange": "0.13.0"
33
33
  },
34
34
  "vendorExtensions": false
35
35
  }