node-autotask 0.21.0 → 0.23.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.
Files changed (56) hide show
  1. package/ARCHITECTURE.md +44 -21
  2. package/CHANGELOG.md +31 -0
  3. package/MCP_TOOL_CATALOG.json +69 -10
  4. package/MCP_TOOL_MANIFEST.md +20 -17
  5. package/MCP_TOOL_OVERRIDES.json +6 -6
  6. package/N8N_MCP_MANIFEST.json +20 -4
  7. package/N8N_MCP_MANIFEST.md +6 -5
  8. package/README.md +9 -9
  9. package/capabilities.json +2898 -7412
  10. package/capabilities.schema.json +0 -12
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.d.cts +0 -2
  13. package/dist/capabilities.d.ts +0 -2
  14. package/dist/capabilities.js +1 -1
  15. package/dist/{chunk-KD6LOSAK.js → chunk-B5SNZ3TZ.js} +3473 -3084
  16. package/dist/{chunk-QWAWNQEZ.cjs → chunk-D5TVBI4U.cjs} +3021 -2632
  17. package/dist/{chunk-CBUY3MKB.cjs → chunk-JUO3URUI.cjs} +2897 -7411
  18. package/dist/{chunk-HEW4WJLC.cjs → chunk-N27NUB4R.cjs} +2466 -1745
  19. package/dist/{chunk-DM67SXCO.js → chunk-OGNUI3UR.js} +2897 -7411
  20. package/dist/{chunk-GBVUXAUA.js → chunk-PVUUOM5G.js} +2092 -1371
  21. package/dist/{chunk-PMPFQDVM.cjs → chunk-TTQB537V.cjs} +135 -135
  22. package/dist/{chunk-JRSU6PKP.js → chunk-VYFO2N5F.js} +2 -2
  23. package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
  24. package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
  25. package/dist/{index-DSnuyiTr.d.ts → index-DBoX-Dnk.d.ts} +430 -957
  26. package/dist/{index-ByWYfNog.d.cts → index-NC-x-bYl.d.cts} +430 -957
  27. package/dist/index.cjs +91 -62
  28. package/dist/index.d.cts +360 -5
  29. package/dist/index.d.ts +360 -5
  30. package/dist/index.js +74 -45
  31. package/dist/operations/index.cjs +462 -462
  32. package/dist/operations/index.d.cts +78 -84
  33. package/dist/operations/index.d.ts +78 -84
  34. package/dist/operations/index.js +382 -382
  35. package/dist/pool.cjs +8 -8
  36. package/dist/pool.d.cts +2 -2
  37. package/dist/pool.d.ts +2 -2
  38. package/dist/pool.js +4 -4
  39. package/dist/resources/index.cjs +4 -4
  40. package/dist/resources/index.d.cts +2 -2
  41. package/dist/resources/index.d.ts +2 -2
  42. package/dist/resources/index.js +3 -3
  43. package/dist/types/index.d.cts +1 -1
  44. package/dist/types/index.d.ts +1 -1
  45. package/docs/API.md +133 -41
  46. package/docs/N8N-ADAPTER.md +3 -3
  47. package/examples/mcp/errors.ts +135 -0
  48. package/examples/mcp/manifest.ts +168 -0
  49. package/examples/mcp/register.ts +66 -0
  50. package/examples/mcp/schemas.ts +203 -0
  51. package/examples/mcp/tools-meta.ts +120 -0
  52. package/examples/mcp/tools-mode.ts +177 -0
  53. package/examples/mcp/tools-read.ts +264 -0
  54. package/examples/mcp/types.ts +34 -0
  55. package/examples/mcp-register.ts +9 -1074
  56. package/package.json +1 -1
@@ -0,0 +1,264 @@
1
+ /**
2
+ * tools-read.ts — the ten registry-backed core read tools (R2/R3/R4), their
3
+ * live dispatch (`coreReadCall`) and the untrusted-content projection
4
+ * (issue #1).
5
+ */
6
+ import * as z from 'zod/v4';
7
+ import { AutotaskConfigError } from 'node-autotask';
8
+ import type { AutotaskClient } from 'node-autotask';
9
+ import type { CapabilityRecord } from 'node-autotask/capabilities';
10
+ import { resolveAny, searchAcrossResources } from 'node-autotask/operations';
11
+ import { deepMarkUntrusted, markUntrusted } from 'node-autotask/untrusted';
12
+ import { CATALOG, RECORD_BY_ID, annotationsFor, manifestDescription, untrustedFieldsOf } from './manifest.js';
13
+ import type { CoreToolSpec } from './manifest.js';
14
+ import { DRY_OUTPUT, EXPAND, IDENTIFIER, LIMIT, RESOURCES, closedInput, hitsOutput, modelOutput, oneOutput, resolveAnyOutput } from './schemas.js';
15
+ import { closedArgsError, dryResult, errorContent, ok } from './errors.js';
16
+ import type { ToolContext } from './types.js';
17
+
18
+ export function registerReadTools({ server, client, dry, untrustedWrap }: ToolContext): void {
19
+ // ==========================================================================
20
+ // CORE READ TOOLS (R2 / R3 / R4) — the ten registry-backed core tools.
21
+ // Descriptions are the manifest's (manifestDescription); annotations derive
22
+ // from the registry record. Every handler: live call, or the DRY-mode
23
+ // validate-and-report answer. No tool is backed by queryAll/listPages.
24
+ // ==========================================================================
25
+
26
+ const registerCoreRead = (spec: CoreToolSpec, schema: z.ZodType, output: (rec: CapabilityRecord) => z.ZodType, text: (v: unknown) => string) => {
27
+ const opId = spec.backingOperation!;
28
+ const rec = RECORD_BY_ID.get(opId);
29
+ if (rec === undefined) throw new Error(`mcp-register: core tool "${spec.tool}" has no registry record for "${opId}"`);
30
+ // Issue #3: closed top level — the advertised schema carries
31
+ // additionalProperties: false and an unknown key is refused with the
32
+ // typed contract (never silently dropped), DRY and live alike.
33
+ const coreInput = closedInput(schema);
34
+ server.registerTool(
35
+ spec.tool,
36
+ {
37
+ title: titleFor(spec.tool, rec),
38
+ description: manifestDescription(spec.tool, opId),
39
+ inputSchema: coreInput.input,
40
+ outputSchema: z.union([output(rec), DRY_OUTPUT]),
41
+ annotations: annotationsFor(rec),
42
+ _meta: { backingOperation: opId, requirement: spec.requirement },
43
+ },
44
+ async (args, extra) => {
45
+ const closedErr = closedArgsError(args, coreInput.validKeys);
46
+ if (closedErr !== null) return closedErr;
47
+ if (dry || client === null) return dryResult(opId);
48
+ try {
49
+ const value = await client.withSignal(extra.mcpReq.signal, () => coreReadCall(client, opId, args as Record<string, unknown>));
50
+ // Issue #1: the projection layer wraps the externally-sourced free
51
+ // text AFTER the call (SDK primitives keep returning raw values) —
52
+ // compact AND expand: true alike (the expansion is a no-op for the
53
+ // five workflow resources, so one path covers both). The short
54
+ // summary text never echoes values, so it stays unwrapped.
55
+ const projected = projectUntrustedResult(opId, value, untrustedWrap ? untrustedFieldsOf(rec) : []);
56
+ return ok(text(projected), { ...(projected as Record<string, unknown>) });
57
+ } catch (err) {
58
+ return errorContent(err, untrustedWrap);
59
+ }
60
+ },
61
+ );
62
+ };
63
+
64
+ // R2 — identify itself to the tenant.
65
+ registerCoreRead(
66
+ coreSpec('autotask_resource_who_am_i')!,
67
+ z.object({}),
68
+ oneOutput,
69
+ (v) => (v === null ? 'No Resource matched the configured user.' : 'Authenticated integration identified.'),
70
+ );
71
+ registerCoreRead(
72
+ coreSpec('autotask_version')!,
73
+ z.object({}),
74
+ modelOutput,
75
+ () => 'API version in use returned.',
76
+ );
77
+ registerCoreRead(
78
+ coreSpec('autotask_threshold_information')!,
79
+ z.object({}),
80
+ modelOutput,
81
+ () => 'Request-threshold status returned.',
82
+ );
83
+
84
+ // R3 — find a record it cannot name.
85
+ registerCoreRead(
86
+ coreSpec('autotask_resolve_any')!,
87
+ z.object({ identifier: IDENTIFIER, resources: RESOURCES }),
88
+ resolveAnyOutput,
89
+ (v) => `${(v as { matches: string[] }).matches.length} resource(s) matched the identifier.`,
90
+ );
91
+ registerCoreRead(
92
+ coreSpec('autotask_search_across_resources')!,
93
+ z.object({ query: z.string().min(1).describe('The free text to search for.'), resources: RESOURCES, limit: LIMIT }),
94
+ hitsOutput,
95
+ (v) => `${(v as { results: Array<{ matches: unknown[] }> }).results.reduce((n, row) => n + row.matches.length, 0)} match(es) across ${(v as { results: unknown[] }).results.length} resource(s).`,
96
+ );
97
+
98
+ // R4 — one resolve per workflow resource.
99
+ for (const spec of CATALOG.coreProfile.tools.filter((t) => t.requirement === 'R4')) {
100
+ registerCoreRead(
101
+ spec,
102
+ z.object({ identifier: IDENTIFIER, expand: EXPAND }),
103
+ oneOutput,
104
+ (v) => (v === null ? 'No record matched the identifier.' : 'Record resolved (id + primary fields).'),
105
+ );
106
+ }
107
+ }
108
+
109
+ /** Look up a core spec by tool name (the generated core profile is the source). */
110
+ function coreSpec(tool: string): CoreToolSpec | undefined {
111
+ return CATALOG.coreProfile.tools.find((t) => t.tool === tool);
112
+ }
113
+
114
+ /** A short human-readable display name for a registry-backed core tool. */
115
+ function titleFor(_tool: string, rec: CapabilityRecord): string {
116
+ switch (rec.id) {
117
+ case 'resources.whoAmI': return 'Identify the Integration (whoAmI)';
118
+ case 'version.get': return 'API Version';
119
+ case 'threshold_information.get': return 'Request-Threshold Status';
120
+ case 'operations.resolveAny': return 'Resolve Any Workflow Record';
121
+ case 'operations.searchAcrossResources': return 'Search Across Workflow Resources';
122
+ default:
123
+ // The five R4 workflow resolves (operation verb `resolve`):
124
+ return `Resolve ${entityLabel(rec.resource)}`;
125
+ }
126
+ }
127
+
128
+ /** The entity label for the five workflow resources (the plan apiEntity names). */
129
+ function entityLabel(resource: string): string {
130
+ const labels: Record<string, string> = {
131
+ companies: 'Company',
132
+ configuration_items: 'ConfigurationItem',
133
+ contacts: 'Contact',
134
+ projects: 'Project',
135
+ tickets: 'Ticket',
136
+ };
137
+ return labels[resource] ?? resource;
138
+ }
139
+
140
+ /**
141
+ * Issue #1 projection: wrap the externally-sourced free text of a helper
142
+ * tool result (the marker the LLM sees around ticket titles, notes, names,
143
+ * UDF values — never as instructions). Applied AFTER the SDK call returns
144
+ * (SDK primitives keep returning raw values — projection layer only) and to
145
+ * BOTH the compact and the `expand: true` result (for the five workflow
146
+ * resources the Summary is a structural alias of the full record, so one
147
+ * path covers both). `fields` empty (or wrapping disabled upstream) → the
148
+ * value passes through unchanged.
149
+ *
150
+ * Only the DATA payload is wrapped, never the server-generated envelope
151
+ * (the miss note, the match counts):
152
+ * - R4 resolve / whoAmI: the single `record`;
153
+ * - resolveAny: each `results[].value`;
154
+ * - searchAcrossResources: each `results[].matches[]` row.
155
+ * (The mode tools' `result` is envelope-shaped and is walked deeply instead
156
+ * — see the mode-tool handler.)
157
+ */
158
+ export function projectUntrustedResult(opId: string, value: unknown, fields: readonly string[]): unknown {
159
+ if (!Array.isArray(fields) || fields.length === 0) return value;
160
+ const v = value as Record<string, unknown> | null;
161
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return value;
162
+ switch (opId) {
163
+ case 'operations.resolveAny':
164
+ return {
165
+ ...v,
166
+ results: (Array.isArray(v.results) ? v.results : []).map((row) =>
167
+ row !== null && typeof row === 'object' && !Array.isArray(row) && 'value' in row
168
+ ? { ...(row as Record<string, unknown>), value: deepMarkUntrusted((row as Record<string, unknown>).value, fields) }
169
+ : row,
170
+ ),
171
+ };
172
+ case 'operations.searchAcrossResources':
173
+ return {
174
+ ...v,
175
+ results: (Array.isArray(v.results) ? v.results : []).map((r) =>
176
+ r !== null && typeof r === 'object' && !Array.isArray(r)
177
+ ? {
178
+ ...(r as Record<string, unknown>),
179
+ matches: (Array.isArray((r as Record<string, unknown>).matches) ? ((r as Record<string, unknown>).matches as unknown[]) : []).map(
180
+ (m) => (m !== null && typeof m === 'object' && !Array.isArray(m) ? markUntrusted(m as Record<string, unknown>, fields) : m),
181
+ ),
182
+ }
183
+ : r,
184
+ ),
185
+ };
186
+ default:
187
+ // R4 resolve / whoAmI: the single `record` (absent/null records pass
188
+ // through; non-record records are opaque).
189
+ if ('record' in v) {
190
+ return {
191
+ ...v,
192
+ record:
193
+ v.record !== null && typeof v.record === 'object' && !Array.isArray(v.record)
194
+ ? markUntrusted(v.record as Record<string, unknown>, fields)
195
+ : v.record,
196
+ };
197
+ }
198
+ return value;
199
+ }
200
+ }
201
+
202
+ /** The live-mode dispatch for the ten registry-backed core tools (helper tier only). Exported as the
203
+ * test seam (S12-E2E D1: the R4 miss-shape contract is pinned without a live tenant). */
204
+ export async function coreReadCall(client: AutotaskClient, opId: string, args: Record<string, unknown>): Promise<unknown> {
205
+ switch (opId) {
206
+ case 'resources.whoAmI': {
207
+ const record = await client.resources.whoAmI();
208
+ return { found: record !== null, record };
209
+ }
210
+ case 'version.get':
211
+ return { model: await client.version.get() };
212
+ case 'threshold_information.get':
213
+ return { model: await client.thresholdInformation.get() };
214
+ case 'operations.resolveAny':
215
+ return resolveAny(client, args.identifier as number | string, { resources: args.resources as string[] | undefined });
216
+ case 'operations.searchAcrossResources':
217
+ return searchAcrossResources(client, args.query as string, {
218
+ resources: args.resources as string[] | undefined,
219
+ limit: args.limit as number | undefined,
220
+ });
221
+ default:
222
+ break;
223
+ }
224
+ // The five R4 workflow resolves share the same shape (helper tier, one call):
225
+ // route through the client accessor named by the resource key.
226
+ const resource = opId.split('.')[0]!;
227
+ const holder = (client as unknown as Record<string, {
228
+ resolveDetailed: (id: number | string) => Promise<{ value: unknown; triedFields?: string[] }>;
229
+ } | undefined>)[camelAccessor(resource)];
230
+ if (holder === undefined) {
231
+ // FULL100 F6 (second-pass MINOR): typed, not bare — the served-surface
232
+ // contract (F3) is that every isError the MCP layer serves carries a
233
+ // machine code; this branch is unreachable on the current registry
234
+ // (every read op's resource has a client accessor — sweep-verified) but
235
+ // must not regress the contract if the registry ever outgrows the client.
236
+ throw new AutotaskConfigError(
237
+ `mcp-register: the client exposes no accessor for resource "${resource}" (the SDK governor would refuse this dispatch too)`,
238
+ { operation: opId, suggestedAction: 'Rebuild the generated surface (npm run mcp:project) or the registry drifted from the client.' },
239
+ );
240
+ }
241
+ // resolveDetailed (not the plain resolve): a STRING miss returns value:null with the
242
+ // triedFields the exact match was run against — the tool carries that as the typed
243
+ // `miss` payload (a miss is never a bare zero). A numeric id miss still propagates
244
+ // the vendor's own typed NOT_FOUND (a miss that carries a fault is not a miss).
245
+ void args.expand; // the Summary is a structural alias of the full record (no-op, as before)
246
+ const res = await holder.resolveDetailed(args.identifier as number | string);
247
+ if (res.value === null) {
248
+ const tried = res.triedFields ?? [];
249
+ return {
250
+ found: false,
251
+ record: null,
252
+ miss: {
253
+ triedFields: tried,
254
+ note: `Exact match only against the primary text field(s): ${tried.join(', ') || 'id'}. No record matched — other identifier forms (e.g. an email address) are not tried by resolve; use the resource's dedicated lookup op where one exists (see autotask_catalog).`,
255
+ },
256
+ };
257
+ }
258
+ return { found: true, record: res.value };
259
+ }
260
+
261
+ /** snake_case resource key -> the client accessor name (the SDK's camelKey rule). */
262
+ function camelAccessor(resource: string): string {
263
+ return resource.split('_').map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))).join('');
264
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * types.ts — the shared shapes of the MCP reference registration: the
3
+ * structured tool result, the caller-facing deps, and the context handed to
4
+ * each tool group.
5
+ */
6
+ import type { McpServer } from '@modelcontextprotocol/server';
7
+ import type { AutotaskClient } from 'node-autotask';
8
+
9
+ export type ToolReturn =
10
+ | { content: Array<{ type: 'text'; text: string }>; structuredContent: Record<string, unknown> }
11
+ | { content: Array<{ type: 'text'; text: string }>; isError: true };
12
+
13
+ export interface CoreToolDeps {
14
+ /** null in DRY mode (no credentials — the client refuses to construct fail-closed). */
15
+ client: AutotaskClient | null;
16
+ /** True when no credentials are present: registry-backed tools validate + report, never issue. */
17
+ dry: boolean;
18
+ /**
19
+ * Issue #1: untrusted-content wrapping for helper-tool results. Default
20
+ * (absent): ON unless `AUTOTASK_UNTRUSTED_WRAP=off` — the explicit env
21
+ * switch (option beats env). Disabling logs a warning on stderr (the stdio
22
+ * protocol rides stdout; the tag name is fixed).
23
+ */
24
+ untrustedWrap?: boolean;
25
+ }
26
+
27
+ /** What each tool group (`registerXxxTools`) receives: the server plus the resolved deps. */
28
+ export interface ToolContext {
29
+ server: McpServer;
30
+ client: AutotaskClient | null;
31
+ dry: boolean;
32
+ /** Resolved (option, then env) — never undefined here. */
33
+ untrustedWrap: boolean;
34
+ }