node-autotask 0.21.0 → 0.24.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/ARCHITECTURE.md +44 -21
- package/CHANGELOG.md +44 -0
- package/MCP_TOOL_CATALOG.json +90 -10
- package/MCP_TOOL_MANIFEST.md +24 -20
- package/MCP_TOOL_OVERRIDES.json +6 -6
- package/N8N_MCP_MANIFEST.json +65 -10
- package/N8N_MCP_MANIFEST.md +10 -8
- package/README.md +9 -9
- package/capabilities.json +3247 -7575
- package/capabilities.schema.json +0 -12
- package/dist/capabilities.cjs +2 -2
- package/dist/capabilities.d.cts +0 -2
- package/dist/capabilities.d.ts +0 -2
- package/dist/capabilities.js +1 -1
- package/dist/{chunk-CBUY3MKB.cjs → chunk-4VAC2Z55.cjs} +3246 -7574
- package/dist/{chunk-KD6LOSAK.js → chunk-BBQBWVBE.js} +3496 -3082
- package/dist/{chunk-JRSU6PKP.js → chunk-ICCU2S62.js} +2 -2
- package/dist/{chunk-GBVUXAUA.js → chunk-IOVFTIW6.js} +2257 -1426
- package/dist/{chunk-QWAWNQEZ.cjs → chunk-LVBFLSPL.cjs} +3044 -2630
- package/dist/{chunk-PMPFQDVM.cjs → chunk-SOHQ6SDI.cjs} +135 -135
- package/dist/{chunk-HEW4WJLC.cjs → chunk-SW66OWM5.cjs} +2645 -1814
- package/dist/{chunk-DM67SXCO.js → chunk-WJD26CPN.js} +3246 -7574
- package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
- package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
- package/dist/{index-ByWYfNog.d.cts → index-C0OwQhon.d.cts} +486 -962
- package/dist/{index-DSnuyiTr.d.ts → index-CZ94y401.d.ts} +486 -962
- package/dist/index.cjs +91 -62
- package/dist/index.d.cts +360 -5
- package/dist/index.d.ts +360 -5
- package/dist/index.js +74 -45
- package/dist/operations/index.cjs +462 -462
- package/dist/operations/index.d.cts +78 -84
- package/dist/operations/index.d.ts +78 -84
- package/dist/operations/index.js +382 -382
- package/dist/pool.cjs +8 -8
- package/dist/pool.d.cts +2 -2
- package/dist/pool.d.ts +2 -2
- package/dist/pool.js +4 -4
- package/dist/resources/index.cjs +4 -4
- package/dist/resources/index.d.cts +2 -2
- package/dist/resources/index.d.ts +2 -2
- package/dist/resources/index.js +3 -3
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/API.md +170 -49
- package/docs/N8N-ADAPTER.md +3 -3
- package/examples/mcp/errors.ts +135 -0
- package/examples/mcp/manifest.ts +168 -0
- package/examples/mcp/register.ts +66 -0
- package/examples/mcp/schemas.ts +203 -0
- package/examples/mcp/tools-meta.ts +120 -0
- package/examples/mcp/tools-mode.ts +177 -0
- package/examples/mcp/tools-read.ts +264 -0
- package/examples/mcp/types.ts +34 -0
- package/examples/mcp-register.ts +9 -1074
- package/package.json +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools-mode.ts — the autotask_read / autotask_write / autotask_delete mode
|
|
3
|
+
* tools. The ONLY write governor is `invoke` in `node-autotask/operations`;
|
|
4
|
+
* this module adds no validator and no governor of its own.
|
|
5
|
+
*/
|
|
6
|
+
import * as z from 'zod/v4';
|
|
7
|
+
import { AutotaskConfigError, ConfirmationRequiredError } from 'node-autotask';
|
|
8
|
+
import type { AutotaskError } from 'node-autotask';
|
|
9
|
+
import type { CapabilityRecord } from 'node-autotask/capabilities';
|
|
10
|
+
import { invokeDelete, invokeRead, invokeWrite, needsConfirmation, validateParamsBag } from 'node-autotask/operations';
|
|
11
|
+
import { deepMarkUntrusted, stripUntrustedDeep } from 'node-autotask/untrusted';
|
|
12
|
+
import { CATALOG, CROSS_RESOURCE_UNTRUSTED, MODE_ANNOTATIONS, RECORD_BY_ID } from './manifest.js';
|
|
13
|
+
import type { ModeKind } from './manifest.js';
|
|
14
|
+
import { DRY_OUTPUT, closedInput, invokeOutput } from './schemas.js';
|
|
15
|
+
import { closedArgsError, dryResult, errorContent, ok } from './errors.js';
|
|
16
|
+
import type { ToolContext } from './types.js';
|
|
17
|
+
|
|
18
|
+
/** The mode tools refuse these BEFORE any request — the mirror the DRY-mode handler runs.
|
|
19
|
+
* FULL100 F3: the refusals are TYPED (machine code in the served payload) — the
|
|
20
|
+
* previous bare `Error`s served untyped failures on a surface whose error
|
|
21
|
+
* contract is the SDK AutotaskError family. */
|
|
22
|
+
function governorRefusal(rec: CapabilityRecord, operation: string, dryRun: boolean | undefined, confirm: string | undefined): AutotaskError | null {
|
|
23
|
+
if (!isWrite(rec) && dryRun === true) {
|
|
24
|
+
return new AutotaskConfigError(
|
|
25
|
+
`invoke: "${operation}" is a read — dryRun is not applicable (reads issue their request as-is)`,
|
|
26
|
+
{ operation },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (needsConfirmation(rec) && confirm !== operation) {
|
|
30
|
+
return new ConfirmationRequiredError(
|
|
31
|
+
`invoke: "${operation}" is ${rec.effect}${(rec.flags ?? []).includes('requiresApproval') ? ' approval-gated' : ''} and requires its confirmation flag: pass confirm: "${operation}"`,
|
|
32
|
+
{ operation },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
// Query ops are NOT refused: the SDK governor routes them to queryBounded
|
|
36
|
+
// (server-side pagination) — one call, one bounded result.
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isWrite(rec: CapabilityRecord): boolean {
|
|
41
|
+
return rec.effect !== 'read';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerModeTools({ server, client, dry, untrustedWrap }: ToolContext): void {
|
|
45
|
+
// ==========================================================================
|
|
46
|
+
// MODE TOOLS (R1) — the registry call path split into read/write/delete
|
|
47
|
+
// modes (the autotask_invoke catch-all is gone): one shared handler core
|
|
48
|
+
// per tool — record lookup -> cross-mode refusal (the mirror of the SDK
|
|
49
|
+
// router's message) -> the governor's refusals -> the DRY-mode answer or
|
|
50
|
+
// the live dispatch through the SDK's mode-guarded wrapper (ONE
|
|
51
|
+
// implementation decides, same as today's single invoke dispatch). Their
|
|
52
|
+
// names/titles/descriptions/annotations are owned by
|
|
53
|
+
// scripts/build-tool-catalog.mjs (verbatim-checked by catalog:check).
|
|
54
|
+
// ==========================================================================
|
|
55
|
+
|
|
56
|
+
/** The registry `effect` that each mode routes (the A2 predicate, 1:1). */
|
|
57
|
+
const MODE_EFFECT: Record<ModeKind, string> = { read: 'read', write: 'write', delete: 'destructive' };
|
|
58
|
+
const EFFECT_MODE: Record<CapabilityRecord['effect'], ModeKind> = { read: 'read', write: 'write', destructive: 'delete' };
|
|
59
|
+
const MODE_TOOL_NAME: Record<ModeKind, string> = { read: 'autotask_read', write: 'autotask_write', delete: 'autotask_delete' };
|
|
60
|
+
const MODE_SDK_WRAPPER: Record<ModeKind, typeof invokeRead> = { read: invokeRead, write: invokeWrite, delete: invokeDelete };
|
|
61
|
+
|
|
62
|
+
/** Cross-mode refusal — the SAME message the SDK router throws (pre-request, zero wire). */
|
|
63
|
+
function crossModeRefusal(rec: CapabilityRecord, operation: string, mode: ModeKind): AutotaskConfigError {
|
|
64
|
+
const actual = EFFECT_MODE[rec.effect];
|
|
65
|
+
const sdk = { read: 'invokeRead', write: 'invokeWrite', delete: 'invokeDelete' }[actual];
|
|
66
|
+
const subject = rec.effect === 'write' ? 'a write' : rec.effect === 'destructive' ? 'destructive' : 'a read';
|
|
67
|
+
const target =
|
|
68
|
+
actual === 'read'
|
|
69
|
+
? `Route it through the read mode (${sdk} / ${MODE_TOOL_NAME[actual]}).`
|
|
70
|
+
: actual === 'write'
|
|
71
|
+
? `Route it through the write mode (${sdk} / ${MODE_TOOL_NAME[actual]} — dry-run first).`
|
|
72
|
+
: `Deletes have their own tool (${MODE_TOOL_NAME[actual]}) and require confirm: "${operation}".`;
|
|
73
|
+
return new AutotaskConfigError(
|
|
74
|
+
`invoke: "${operation}" is ${subject} (effect=${rec.effect}) — refused on the ${mode} path (${MODE_TOOL_NAME[mode]} routes effect=${MODE_EFFECT[mode]} operations only). ${target}`,
|
|
75
|
+
{ operation },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const registerModeTool = (mode: ModeKind) => {
|
|
80
|
+
const meta = CATALOG.metaTools.find((m) => m.name === MODE_TOOL_NAME[mode])!;
|
|
81
|
+
const modeInput = closedInput(
|
|
82
|
+
z.object({
|
|
83
|
+
operation: z.string().min(1).describe('The exact registry key (see autotask_catalog). Exact registry key — never a tool name, never fuzzy.'),
|
|
84
|
+
args: z.array(z.unknown()).default([]).describe('The method positional args, in order (e.g. [id, data] for update; [parentId, id, data] for a parent-scoped op).'),
|
|
85
|
+
...(mode === 'read'
|
|
86
|
+
? {}
|
|
87
|
+
: {
|
|
88
|
+
dry_run: z.boolean().optional().describe(mode === 'write'
|
|
89
|
+
? 'Writes only: omit or pass true to travel the dry-run path first (simulated: true, the mutating request is never issued); pass false to apply.'
|
|
90
|
+
: 'Deletes travel dry-run-first: omit or pass true for the pinned envelope; pass false to apply.'),
|
|
91
|
+
confirm: z.string().optional().describe(mode === 'write'
|
|
92
|
+
? 'For the 3 approval-gated writes: must equal the operation key exactly, or the call is refused (in BOTH dry-run and apply mode).'
|
|
93
|
+
: 'REQUIRED for every delete: must equal the operation key exactly (in BOTH dry-run and apply mode).'),
|
|
94
|
+
}),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
server.registerTool(
|
|
98
|
+
MODE_TOOL_NAME[mode],
|
|
99
|
+
{
|
|
100
|
+
title: meta.title,
|
|
101
|
+
description: meta.description,
|
|
102
|
+
inputSchema: modeInput.input, // issue #3: closed top level
|
|
103
|
+
outputSchema: z.union([invokeOutput(), DRY_OUTPUT]),
|
|
104
|
+
annotations: MODE_ANNOTATIONS[mode],
|
|
105
|
+
_meta: { backingOperation: `— (META tool; dispatches through node-autotask/operations invoke${mode.charAt(0).toUpperCase()}${mode.slice(1)} — the single write governor)` },
|
|
106
|
+
},
|
|
107
|
+
async (raw, extra) => {
|
|
108
|
+
// Issue #3: the closed top-level argument set — an unknown key is
|
|
109
|
+
// refused with the typed contract BEFORE the registry lookup (it is
|
|
110
|
+
// never silently dropped; the value is never echoed).
|
|
111
|
+
const closedErr = closedArgsError(raw, modeInput.validKeys);
|
|
112
|
+
if (closedErr !== null) return closedErr;
|
|
113
|
+
const args = raw as { operation: string; args: unknown[]; dry_run?: boolean; confirm?: string };
|
|
114
|
+
// ONE implementation decides whether a LIVE call is allowed: the SDK
|
|
115
|
+
// router (the mode-guarded wrapper). In DRY mode the same pre-request
|
|
116
|
+
// checks run against the same registry data + predicates, then the
|
|
117
|
+
// call stops before any request — it is reported, never issued.
|
|
118
|
+
const rec = RECORD_BY_ID.get(args.operation);
|
|
119
|
+
if (rec === undefined) {
|
|
120
|
+
return errorContent(new AutotaskConfigError(
|
|
121
|
+
`invoke: unknown operation "${args.operation}" — it is not in the capability registry (list the exact keys with autotask_catalog)`,
|
|
122
|
+
{ operation: args.operation },
|
|
123
|
+
), untrustedWrap);
|
|
124
|
+
}
|
|
125
|
+
const refusal =
|
|
126
|
+
EFFECT_MODE[rec.effect] !== mode
|
|
127
|
+
? crossModeRefusal(rec, args.operation, mode)
|
|
128
|
+
: governorRefusal(rec, args.operation, mode === 'read' ? undefined : args.dry_run, args.confirm);
|
|
129
|
+
if (refusal !== null) {
|
|
130
|
+
return errorContent(refusal, untrustedWrap);
|
|
131
|
+
}
|
|
132
|
+
// Issue #3: the CLOSED params bag (args[0] of the query-family
|
|
133
|
+
// verbs) — the SAME SDK check the live dispatcher runs, so DRY mode
|
|
134
|
+
// refuses identically (a probe must be refused, not reported).
|
|
135
|
+
try {
|
|
136
|
+
validateParamsBag(rec, args.args);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return errorContent(err, untrustedWrap);
|
|
139
|
+
}
|
|
140
|
+
if (dry || client === null) {
|
|
141
|
+
return dryResult(args.operation);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// extra.mcpReq.signal: the MCP request's cancellation reaches every SDK request (and retry wait) of this call.
|
|
145
|
+
// Issue #1 decisions: echoed values on writes — strip ONE exact
|
|
146
|
+
// outer <untrusted_content> wrapper from the string inputs on the
|
|
147
|
+
// write and filter args before the SDK call (inner text untouched).
|
|
148
|
+
// The mode tools route every effect, so the strip runs on all three
|
|
149
|
+
// (a no-op for args that carry no wrapper).
|
|
150
|
+
const cleanArgs = stripUntrustedDeep(args.args) as unknown[];
|
|
151
|
+
const result = await client.withSignal(extra.mcpReq.signal, () =>
|
|
152
|
+
MODE_SDK_WRAPPER[mode](client, args.operation, cleanArgs, mode === 'read' ? {} : { dryRun: args.dry_run, confirm: args.confirm }),
|
|
153
|
+
);
|
|
154
|
+
const simulated = (result as { simulated?: boolean } | null)?.simulated === true;
|
|
155
|
+
// Issue #1: wrap the SDK result's externally-sourced free text —
|
|
156
|
+
// envelope-shaped results are walked deeply (items[] arrays and the
|
|
157
|
+
// dry-run echo of the submitted body).
|
|
158
|
+
const projected =
|
|
159
|
+
untrustedWrap && result !== null && result !== undefined && typeof result === 'object' && !Array.isArray(result)
|
|
160
|
+
? deepMarkUntrusted(result, CROSS_RESOURCE_UNTRUSTED)
|
|
161
|
+
: result;
|
|
162
|
+
return ok(simulated ? `Dry run of "${args.operation}" — nothing was written (simulated: true).` : `Executed "${args.operation}".`, {
|
|
163
|
+
operation: args.operation,
|
|
164
|
+
simulated,
|
|
165
|
+
result: projected,
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return errorContent(err, untrustedWrap);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
registerModeTool('read');
|
|
175
|
+
registerModeTool('write');
|
|
176
|
+
registerModeTool('delete');
|
|
177
|
+
}
|
|
@@ -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
|
+
}
|