iit-mcp-autotask 0.11.0 → 0.11.1

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 (75) hide show
  1. package/README.md +7 -1
  2. package/dist/main.js +7 -0
  3. package/dist/moves.d.ts +5 -0
  4. package/dist/moves.js +84 -0
  5. package/dist/tools/context/client.d.ts +23 -0
  6. package/dist/tools/context/client.js +96 -0
  7. package/dist/tools/context/derive.d.ts +9 -0
  8. package/dist/tools/context/derive.js +36 -0
  9. package/dist/tools/context/ops.d.ts +29 -0
  10. package/dist/tools/context/ops.js +70 -0
  11. package/dist/tools/context/pool.d.ts +5 -0
  12. package/dist/tools/context/pool.js +9 -0
  13. package/dist/tools/context/scope.d.ts +24 -0
  14. package/dist/tools/context/scope.js +165 -0
  15. package/dist/tools/context/types.d.ts +150 -0
  16. package/dist/tools/context/types.js +2 -0
  17. package/dist/tools/context.d.ts +6 -199
  18. package/dist/tools/context.js +5 -393
  19. package/dist/tools/results/envelope.d.ts +54 -0
  20. package/dist/tools/results/envelope.js +44 -0
  21. package/dist/tools/results/errors.d.ts +165 -0
  22. package/dist/tools/results/errors.js +402 -0
  23. package/dist/tools/results/untrusted.d.ts +124 -0
  24. package/dist/tools/results/untrusted.js +238 -0
  25. package/dist/tools/results.d.ts +19 -185
  26. package/dist/tools/results.js +3 -675
  27. package/dist/tools/schemas.d.ts +1 -1
  28. package/dist/tools/server.d.ts +2 -2
  29. package/dist/tools/server.js +1 -1
  30. package/dist/tools/write/apply-phases.d.ts +95 -0
  31. package/dist/tools/write/apply-phases.js +431 -0
  32. package/dist/tools/write/apply.d.ts +4 -0
  33. package/dist/tools/write/apply.js +41 -0
  34. package/dist/tools/write/common.d.ts +23 -0
  35. package/dist/tools/write/common.js +33 -0
  36. package/dist/tools/write/facts.d.ts +56 -0
  37. package/dist/tools/write/facts.js +98 -0
  38. package/dist/tools/write/outcome.d.ts +27 -0
  39. package/dist/tools/write/outcome.js +87 -0
  40. package/dist/tools/write/parse.d.ts +14 -0
  41. package/dist/tools/write/parse.js +69 -0
  42. package/dist/tools/write/payload.d.ts +44 -0
  43. package/dist/tools/write/payload.js +118 -0
  44. package/dist/tools/write/preview.d.ts +3 -0
  45. package/dist/tools/write/preview.js +175 -0
  46. package/dist/tools/write/snapshot.d.ts +55 -0
  47. package/dist/tools/write/snapshot.js +237 -0
  48. package/dist/tools/write.d.ts +28 -33
  49. package/dist/tools/write.js +5 -1154
  50. package/dist/write/store/audit-chain.d.ts +5 -0
  51. package/dist/write/store/audit-chain.js +23 -0
  52. package/dist/write/store/audit-log.d.ts +13 -0
  53. package/dist/write/store/audit-log.js +55 -0
  54. package/dist/write/store/constants.d.ts +34 -0
  55. package/dist/write/store/constants.js +35 -0
  56. package/dist/write/store/files.d.ts +44 -0
  57. package/dist/write/store/files.js +157 -0
  58. package/dist/write/store/ledger.d.ts +18 -0
  59. package/dist/write/store/ledger.js +153 -0
  60. package/dist/write/store/lock.d.ts +97 -0
  61. package/dist/write/store/lock.js +446 -0
  62. package/dist/write/store/op-lock.d.ts +13 -0
  63. package/dist/write/store/op-lock.js +175 -0
  64. package/dist/write/store/plan-store.d.ts +10 -0
  65. package/dist/write/store/plan-store.js +102 -0
  66. package/dist/write/store/plans.d.ts +5 -0
  67. package/dist/write/store/plans.js +19 -0
  68. package/dist/write/store/types.d.ts +273 -0
  69. package/dist/write/store/types.js +2 -0
  70. package/dist/write/store/unresolved.d.ts +9 -0
  71. package/dist/write/store/unresolved.js +45 -0
  72. package/dist/write/store.d.ts +6 -267
  73. package/dist/write/store.js +34 -1008
  74. package/package.json +1 -1
  75. package/server.json +1 -1
package/README.md CHANGED
@@ -155,7 +155,13 @@ What the policy can and cannot do:
155
155
  original (the vendor ignores an update of `ticketID`): the new record gets a new id, create date and
156
156
  creator, and notifications may fire. A move into another company or contract always needs a signed
157
157
  approval grant; an unfinished earlier move of the same source blocks another until its ledger entry ages
158
- out (14 days). Billed, attached, approved, closed-target and older-than-14-days sources are refused.
158
+ out (14 days) or a person clears it: in a terminal, as the server's OS user, with `AUTOTASK_STATE_DIR` set,
159
+ `autotask-mcp moves list` shows the blocked sources and `autotask-mcp moves resolve <resource>:<id>` (after you
160
+ checked both tickets in Autotask; you type the source id, and the copy's id when the ledger names one) closes the
161
+ ledger lines as `MOVE_RESOLVED` and audits it (`move_resolved`). It refuses within 10 minutes of the move being sent
162
+ (its engine may still be running) and, while the engine may still run, when the sending server is still registered or alive. A plan previewed
163
+ before the resolve cannot apply after it. It is not an
164
+ MCP tool; like the approvals CLI it is a guardrail, not a boundary against an agent with a same-user shell. Billed, attached, approved, closed-target and older-than-14-days sources are refused.
159
165
  - Hard rules no policy overrides: `authenticate.*` is never served; operations the SDK flags
160
166
  `requiresApproval`, and non-read sensitive ones (webhooks, `resources.transferOwnership`), always
161
167
  need approval; destructive and account-wide operations need it, and `allowUnattended` cannot waive
package/dist/main.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { createMcpHandler } from '@modelcontextprotocol/server';
13
13
  import { ConfigError, loadConfig, serverCredentialDefaults } from './config.js';
14
14
  import { runApprovals } from './approvals.js';
15
+ import { runMoves } from './moves.js';
15
16
  import { runDoctor } from './doctor.js';
16
17
  import { createLogger } from './logger.js';
17
18
  import { assertPlanHash } from './tools/catalog-data.js';
@@ -66,6 +67,8 @@ Usage: autotask-mcp [flags]
66
67
  approvals list|show|approve|reject <handle>
67
68
  approvalMode external: a human decides a pending write (run it
68
69
  yourself in a terminal; see the README); keygen <id> --ops <op,..|*>
70
+ moves list | resolve <resource>:<id>
71
+ a human clears an unfinished move's MOVE_UNRESOLVED block (terminal only)
69
72
  --transport <stdio|http> the transport (default: $MCP_TRANSPORT, else http)
70
73
  --host <address> the http bind address (default: $MCP_HOST, else 127.0.0.1;
71
74
  off loopback, AUTOTASK_HTTP_ALLOWED_HOSTS and AUTOTASK_HTTP_AUTH
@@ -125,6 +128,10 @@ function main() {
125
128
  process.exitCode = runApprovals(process.argv.slice(2), process.env, (t) => process.stdout.write(`${t}\n`));
126
129
  return;
127
130
  }
131
+ if (process.argv[2] === 'moves') {
132
+ process.exitCode = runMoves(process.argv.slice(2), process.env, (t) => process.stdout.write(`${t}\n`));
133
+ return;
134
+ }
128
135
  const args = parseCliArgs(process.argv.slice(2));
129
136
  // --version / --help predates the logger and the config: they print to stdout and exit 0.
130
137
  // (Safe on stdout: no stdio MCP session starts before this point.)
@@ -0,0 +1,5 @@
1
+ import { type Prompter } from './approvals.js';
2
+ /** Run a `moves` subcommand; returns the process exit code. `argv` is everything after `autotask-mcp`. */
3
+ export declare function runMoves(argv: string[], env: Record<string, string | undefined>, out: (t: string) => void, prompter?: () => Prompter & {
4
+ close(): void;
5
+ }, now?: () => number): number;
package/dist/moves.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * moves.ts — the `autotask-mcp moves` subcommands (issue #131): a person clears the MOVE_UNRESOLVED block on ONE move
3
+ * source after checking both tickets in Autotask. Run in a terminal as the server's OS user with AUTOTASK_STATE_DIR set.
4
+ *
5
+ * `list` only scans `ledger.jsonl` (it never opens the store: that registers a holder, rewrites the ledger and prunes).
6
+ * `resolve` shows the retained lines, takes the typed confirmation on /dev/tty, and ONLY THEN opens the store, calls
7
+ * `resolveMove` (ledger lines and the audit line under the op lock) and closes it: the blocking /dev/tty read must never
8
+ * sit between a lock and its heartbeat. It prints ids and codes only, never vendor text.
9
+ *
10
+ * Guardrail, not a boundary: an agent with a same-user shell can drive a pty or edit `ledger.jsonl` itself. Nothing on the
11
+ * MCP surface reaches this.
12
+ */
13
+ import { userInfo } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { ApprovalsError, ttyPrompter } from './approvals.js';
16
+ import { createLogger } from './logger.js';
17
+ import { openStateStore, unresolvedMoves } from './write/store.js';
18
+ const MOVES_USAGE = `usage: autotask-mcp moves list | moves resolve <time_entries|ticket_charges>:<id>
19
+ Run it yourself, in a terminal, as the server's OS user, with AUTOTASK_STATE_DIR set to the value the server runs with.`;
20
+ const SOURCE = /^(time_entries|ticket_charges):([1-9]\d{0,14})$/;
21
+ const fail = (message) => {
22
+ throw new ApprovalsError(message);
23
+ };
24
+ const describe = (m, now) => `${m.targetKey} ${m.state}${m.code !== undefined ? ` ${m.code}` : ''} source ${m.sourceId ?? m.targetKey.split(':')[1]}${m.newId !== undefined ? ` new ${m.newId}` : ''} sent ${Math.round((now - m.sentAt) / 60_000)} min ago`;
25
+ /** Run a `moves` subcommand; returns the process exit code. `argv` is everything after `autotask-mcp`. */
26
+ export function runMoves(argv, env, out, prompter = ttyPrompter, now = Date.now) {
27
+ try {
28
+ const [cmd, sub, source, ...extra] = argv;
29
+ if (cmd !== 'moves' || sub === undefined || extra.length > 0)
30
+ fail(MOVES_USAGE);
31
+ const dir = env.AUTOTASK_STATE_DIR;
32
+ if (dir === undefined)
33
+ fail('AUTOTASK_STATE_DIR must be set to the value the server runs with');
34
+ const ledgerPath = join(dir, 'ledger.jsonl');
35
+ if (sub === 'list') {
36
+ const rows = unresolvedMoves(ledgerPath);
37
+ out(rows.length === 0 ? 'No unfinished moves are retained.' : rows.map((m) => describe(m, now())).join('\n'));
38
+ return 0;
39
+ }
40
+ const m = sub === 'resolve' && source !== undefined ? SOURCE.exec(source) : null;
41
+ if (m === null)
42
+ fail(MOVES_USAGE);
43
+ const [resource, id] = [m[1], Number(m[2])];
44
+ const rows = unresolvedMoves(ledgerPath, `${resource}:${id}`);
45
+ if (rows.length === 0) {
46
+ out(`Nothing to resolve: no unfinished move of ${resource}:${id} is retained.`);
47
+ return 0;
48
+ }
49
+ const p = prompter();
50
+ try {
51
+ p.say(`Unfinished move(s) of ${resource}:${id}:\n${rows.map((r) => ` ${describe(r, now())}`).join('\n')}`);
52
+ p.say('Before resolving, check BOTH tickets in Autotask: the source may still exist, and a copy may exist on the target (a second move would make another). Resolving lets the agent move this source again.');
53
+ if (p.ask(`Type ${resource}:${id} to resolve it: `) !== `${resource}:${id}`)
54
+ fail('not resolved: the typed text did not match');
55
+ for (const newId of new Set(rows.flatMap((r) => (r.newId === undefined ? [] : [r.newId])))) {
56
+ if (p.ask(`The ledger names a copy, new id ${newId}. Look at it in Autotask, then type its id: `) !== String(newId))
57
+ fail('not resolved: the typed copy id did not match');
58
+ }
59
+ }
60
+ finally {
61
+ p.close();
62
+ }
63
+ const store = openStateStore(dir, { log: createLogger('error'), now });
64
+ try {
65
+ const r = store.resolveMove(resource, id, userInfo().username, rows.map((x) => ({ idemKey: x.idemKey, state: x.state, ...(x.newId !== undefined ? { newId: x.newId } : {}) })), now());
66
+ if (!r.ok)
67
+ fail(`not resolved (${r.reason}): ${r.detail}`);
68
+ out(`Resolved ${resource}:${id} (${r.ok ? r.resolved : 0} ledger line(s) closed as MOVE_RESOLVED, audited). The agent may now move it again.`);
69
+ }
70
+ finally {
71
+ store.close();
72
+ }
73
+ return 0;
74
+ }
75
+ catch (err) {
76
+ if (err instanceof ApprovalsError) {
77
+ out(err.message);
78
+ return 1;
79
+ }
80
+ out(`error: ${err instanceof Error ? err.message : String(err)}`);
81
+ return 1;
82
+ }
83
+ }
84
+ //# sourceMappingURL=moves.js.map
@@ -0,0 +1,23 @@
1
+ import type { AutotaskConfig } from 'node-autotask';
2
+ import type { Config } from '../../config.js';
3
+ import type { Logger } from '../../logger.js';
4
+ import type { ToolClient } from './types.js';
5
+ /** The reserved placeholder origin (RFC 6761: nothing in the real world resolves it). */
6
+ export declare const PLACEHOLDER_ORIGIN = "https://no-autotask-origin.invalid";
7
+ export declare const PLACEHOLDER_BASE_URL = "https://no-autotask-origin.invalid/atservicesrest";
8
+ /** Sentinel credential values a placeholder client is constructed with (never real, never dialled). */
9
+ export declare const SENTINEL_CREDENTIAL = "unresolved-request-scope";
10
+ /**
11
+ * The deployment-wide SDK options every client this factory builds gets (the agent profile +
12
+ * the env overrides + the cache preset + the literal-IP host floor + the audit sink).
13
+ * Precedence: explicit env > `agent` profile > SDK default (the profile fills only what is
14
+ * left unset).
15
+ */
16
+ export declare function buildSdkBaseOptions(config: Config, log: Logger): Omit<AutotaskConfig, 'apiIntegrationCode' | 'userName' | 'secret'>;
17
+ /** A fail-closed placeholder client: constructed but never dialled (context refuses pre-request). */
18
+ export declare function buildPlaceholderClient(baseOptions: ReturnType<typeof buildSdkBaseOptions>): ToolClient;
19
+ /** The one process-lifetime client of the stdio transport (the environment IS the identity). */
20
+ export declare function buildStdioClient(config: Config, log: Logger): {
21
+ client: ToolClient;
22
+ origin: string;
23
+ };
@@ -0,0 +1,96 @@
1
+ /** SDK client construction: the base options, the placeholder client and the stdio client (issue #91). */
2
+ import { AutotaskClient } from 'node-autotask';
3
+ import { serverCredentialDefaults } from '../../config.js';
4
+ import { normalizeBaseUrl } from '../../base-hosts.js';
5
+ import { cacheOptionsForPreset } from '../catalog-data.js';
6
+ // ---------------------------------------------------------------------------
7
+ // SDK client construction
8
+ // ---------------------------------------------------------------------------
9
+ /** The reserved placeholder origin (RFC 6761: nothing in the real world resolves it). */
10
+ export const PLACEHOLDER_ORIGIN = 'https://no-autotask-origin.invalid';
11
+ export const PLACEHOLDER_BASE_URL = `${PLACEHOLDER_ORIGIN}/atservicesrest`;
12
+ /** Sentinel credential values a placeholder client is constructed with (never real, never dialled). */
13
+ export const SENTINEL_CREDENTIAL = 'unresolved-request-scope';
14
+ /**
15
+ * The deployment-wide SDK options every client this factory builds gets (the agent profile +
16
+ * the env overrides + the cache preset + the literal-IP host floor + the audit sink).
17
+ * Precedence: explicit env > `agent` profile > SDK default (the profile fills only what is
18
+ * left unset).
19
+ */
20
+ export function buildSdkBaseOptions(config, log) {
21
+ const defaultBase = config.AUTOTASK_BASE_URL;
22
+ const defaultBaseUrl = defaultBase === undefined ? undefined : normalizeBaseUrl(defaultBase)?.baseUrl;
23
+ return {
24
+ // The vendor budget guard: never the `library` defaults, never an operator-tuned value
25
+ // above the vendor cap (config validation enforces the cap).
26
+ profile: 'agent',
27
+ ...(config.AUTOTASK_RATE_LIMIT_PER_HOUR !== undefined ? { rateLimit: { perHour: config.AUTOTASK_RATE_LIMIT_PER_HOUR } } : {}),
28
+ ...(config.AUTOTASK_MAX_CONCURRENT !== undefined ? { maxConcurrent: config.AUTOTASK_MAX_CONCURRENT } : {}),
29
+ ...(config.AUTOTASK_TIMEOUT_MS !== undefined ? { timeoutMs: config.AUTOTASK_TIMEOUT_MS } : {}),
30
+ ...(config.AUTOTASK_MAX_RETRIES !== undefined ? { maxRetries: config.AUTOTASK_MAX_RETRIES } : {}),
31
+ // The literal-IP SSRF floor: a LITERAL private/loopback origin is refused at construction
32
+ // even if the deployment allow-list named it (the private bound wins — security S2/C2.4).
33
+ hostPolicy: {
34
+ blockPrivateHosts: true,
35
+ ...(defaultBaseUrl !== undefined ? { allowedOrigins: [defaultBaseUrl] } : {}),
36
+ },
37
+ // The SCOPING Stage-4 cache deviation (user-approved): `on` sends no cache blocks (the SDK
38
+ // default-on caches) UNLESS the AUTOTASK_FIELD_CACHE_TTL_MS override is set — then the
39
+ // scalar fieldCache block rides (the TTL pass-through); `off` opts every channel out
40
+ // explicitly (every read re-dials the vendor) and is never touched by the override.
41
+ ...cacheOptionsForPreset(config.AUTOTASK_CACHE_PRESET, config.AUTOTASK_FIELD_CACHE_TTL_MS),
42
+ // One bounded line per settled request (the SDK stamps the correlation id and redacts the
43
+ // body at the wire; the `path` carries no query values by construction). Only the event's
44
+ // own shape metadata is forwarded — never bodies or payload values (security S9).
45
+ onAudit: (event) => {
46
+ const e = event;
47
+ log.info('autotask request', {
48
+ ...(typeof e.correlationId === 'string' ? { correlationId: e.correlationId } : {}),
49
+ ...(typeof e.operation === 'string' ? { operation: e.operation } : {}),
50
+ ...(typeof e.method === 'string' ? { method: e.method } : {}),
51
+ ...(typeof e.path === 'string' ? { path: e.path } : {}),
52
+ ...(typeof e.status === 'number' ? { status: e.status } : {}),
53
+ ...(typeof e.durationMs === 'number' ? { durationMs: e.durationMs } : {}),
54
+ ...(typeof e.errorCode === 'string' ? { errorCode: e.errorCode } : {}),
55
+ ...(e.notSent === true ? { notSent: true } : {}),
56
+ });
57
+ },
58
+ };
59
+ }
60
+ /** A fail-closed placeholder client: constructed but never dialled (context refuses pre-request). */
61
+ export function buildPlaceholderClient(baseOptions) {
62
+ // The placeholders keep the literal-IP floor only: their reserved origin must survive
63
+ // construction so the sentinel is what a dialled placeholder would present, unmistakably.
64
+ const client = new AutotaskClient({
65
+ ...baseOptions,
66
+ apiIntegrationCode: SENTINEL_CREDENTIAL,
67
+ userName: SENTINEL_CREDENTIAL,
68
+ secret: SENTINEL_CREDENTIAL,
69
+ baseUrl: PLACEHOLDER_BASE_URL,
70
+ hostPolicy: { blockPrivateHosts: true },
71
+ });
72
+ return client;
73
+ }
74
+ /** The one process-lifetime client of the stdio transport (the environment IS the identity). */
75
+ export function buildStdioClient(config, log) {
76
+ const baseOptions = buildSdkBaseOptions(config, log);
77
+ const defaults = serverCredentialDefaults(config);
78
+ if (defaults === undefined || config.AUTOTASK_BASE_URL === undefined) {
79
+ // Unreachable: loadConfig requires all four under stdio. The line after it is unreachable;
80
+ // it exists so the narrowing is a type fact rather than a comment.
81
+ throw new Error('stdio client built without a complete credential tuple — loadConfig guarantees this cannot happen');
82
+ }
83
+ const normalized = normalizeBaseUrl(config.AUTOTASK_BASE_URL);
84
+ if (normalized === undefined)
85
+ throw new Error('AUTOTASK_BASE_URL failed normalization after boot validation — this cannot happen');
86
+ const client = new AutotaskClient({
87
+ ...baseOptions,
88
+ apiIntegrationCode: defaults.apiIntegrationCode,
89
+ userName: defaults.userName,
90
+ secret: defaults.secret,
91
+ ...(defaults.impersonationResourceId !== undefined ? { impersonationResourceId: defaults.impersonationResourceId } : {}),
92
+ baseUrl: normalized.baseUrl,
93
+ });
94
+ return { client, origin: normalized.origin };
95
+ }
96
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,9 @@
1
+ import type { ToolDeps, ToolContext } from './types.js';
2
+ /** Derive the context the registrations share. */
3
+ export declare function deriveToolContext(deps: ToolDeps, served?: ReadonlySet<string>): ToolContext;
4
+ /**
5
+ * A vendor 401 (or impersonation-scope 401/403) deletes the pooled entry so a stale identity is
6
+ * not negative-cached and replayed (security S1/C1.8, S3/C3.5): the next request with the same
7
+ * tuple recreates the client and starts fresh. Safe to call with a non-pooled client (no-op).
8
+ */
9
+ export declare function releaseClientOnError(deps: ToolDeps, err: unknown): void;
@@ -0,0 +1,36 @@
1
+ /** Deriving a tool context from the deps, and releasing a client on error (issue #91). */
2
+ import { isAutotaskError } from 'node-autotask';
3
+ import { recordById, toolDescription, untrustedFieldsOf } from '../catalog-data.js';
4
+ /** Derive the context the registrations share. */
5
+ export function deriveToolContext(deps, served = new Set()) {
6
+ return {
7
+ ...deps,
8
+ served,
9
+ writable: new Set(deps.write === undefined ? [] : Object.keys(deps.write.runtime.loaded.policy.ops)),
10
+ record: recordById,
11
+ untrustedFields: (operation) => {
12
+ const rec = recordById(operation);
13
+ return rec === undefined ? [] : untrustedFieldsOf(rec);
14
+ },
15
+ description: toolDescription,
16
+ };
17
+ }
18
+ /**
19
+ * A vendor 401 (or impersonation-scope 401/403) deletes the pooled entry so a stale identity is
20
+ * not negative-cached and replayed (security S1/C1.8, S3/C3.5): the next request with the same
21
+ * tuple recreates the client and starts fresh. Safe to call with a non-pooled client (no-op).
22
+ */
23
+ export function releaseClientOnError(deps, err) {
24
+ if (deps.pool === undefined || deps.poolConfig === undefined)
25
+ return;
26
+ const code = err?.code;
27
+ const name = err?.name;
28
+ if (isAutotaskError(err) &&
29
+ ((code === 'UNAUTHORIZED' || code === 'FORBIDDEN') ||
30
+ name === 'UnauthorizedError' ||
31
+ name === 'ForbiddenError')) {
32
+ const removed = deps.pool.delete(deps.poolConfig);
33
+ deps.log.info('deleted pooled client after credential refusal', { removed, poolSize: deps.pool.size });
34
+ }
35
+ }
36
+ //# sourceMappingURL=derive.js.map
@@ -0,0 +1,29 @@
1
+ import type { ToolClient, QueryWalkParams, QueryWalkResult, ToolOps } from './types.js';
2
+ /** The real SDK bindings (the production default; tests substitute a fixture-backed bundle). */
3
+ /**
4
+ * The SDK operations bundle (the production default; tests substitute a fixture-backed bundle).
5
+ *
6
+ * (Historical, FT-4: before node-autotask 0.9.0 the invoke router resolved `client['authenticate']` to the
7
+ * convenience METHOD, so `authenticate.get` answered CONFIG_ERROR and this bundle special-cased it to
8
+ * `authenticateResource.get()`. 0.9.0 routes it to the resource holder (#88), so every read now goes through
9
+ * `invokeRead` and its governor; `authenticate.*` is refused by the deployment policy before it.)
10
+ */
11
+ export declare const defaultToolOps: ToolOps;
12
+ /**
13
+ * Walk one `*.query` operation with the resource's OWN iterator (`client[<resource>].query(...)` —
14
+ * the public AsyncIterable over the SDK's internal page fetcher; see {@link ToolOps.queryWalk}).
15
+ * The page size is the vendor maximum (500) and `maxRows` caps the TOTAL rows.
16
+ *
17
+ * Truncation honesty: the iterator is advanced ONE record past the cap purely to learn whether more
18
+ * records exist; that record is DISCARDED, so a data set of exactly `maxRows` rows reports
19
+ * `truncated: false` and the page count is untouched.
20
+ */
21
+ export declare function walkQuery(client: ToolClient, operation: string, params: QueryWalkParams, maxRows: number): Promise<QueryWalkResult>;
22
+ /**
23
+ * Propagate the MCP request's cancellation into the SDK's in-flight request (architecture 7.6:
24
+ * an abandoned request stops consuming vendor budget). The SDK's `withSignal` returns
25
+ * `undefined` when the signal has already aborted or no transport is attached — the SDK's own
26
+ * handler then answers the typed cancellation; the handler's generic catch would otherwise
27
+ * re-report it as a tool error, so this wrapper throws the SDK's typed `AbortError` instead.
28
+ */
29
+ export declare function withSignal(client: ToolClient, signal: AbortSignal | undefined, fn: () => Promise<unknown>): Promise<unknown>;
@@ -0,0 +1,70 @@
1
+ /** The default tool ops: the bounded query walk and the abort-signal wrapper (issue #91). */
2
+ import { AutotaskClient, camelKey } from 'node-autotask';
3
+ import { AutotaskConfigError } from 'node-autotask';
4
+ import { fetchByIds, fetchMany, invokeDelete, invokeRead, invokeWrite, relationshipMap, resolveAny, searchAcrossResources } from 'node-autotask/operations';
5
+ /** The real SDK bindings (the production default; tests substitute a fixture-backed bundle). */
6
+ /**
7
+ * The SDK operations bundle (the production default; tests substitute a fixture-backed bundle).
8
+ *
9
+ * (Historical, FT-4: before node-autotask 0.9.0 the invoke router resolved `client['authenticate']` to the
10
+ * convenience METHOD, so `authenticate.get` answered CONFIG_ERROR and this bundle special-cased it to
11
+ * `authenticateResource.get()`. 0.9.0 routes it to the resource holder (#88), so every read now goes through
12
+ * `invokeRead` and its governor; `authenticate.*` is refused by the deployment policy before it.)
13
+ */
14
+ export const defaultToolOps = {
15
+ invokeRead: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeRead(client, operation, args, opts ?? {})),
16
+ invokeWrite: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeWrite(client, operation, args, opts ?? {})),
17
+ invokeDelete: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeDelete(client, operation, args, opts ?? {})),
18
+ resolveAny: (client, identifier, opts, signal) => withSignal(client, signal, () => resolveAny(client, identifier, opts ?? {})),
19
+ searchAcrossResources: (client, query, opts, signal) => withSignal(client, signal, () => searchAcrossResources(client, query, opts ?? {})),
20
+ fetchMany: (client, items, opts, signal) => withSignal(client, signal, () => fetchMany(client, items, opts ?? {})),
21
+ fetchByIds: (client, items, opts, signal) => withSignal(client, signal, () => fetchByIds(client, items, opts ?? {})),
22
+ relationshipMap: (client, resourceKey) => relationshipMap(client, resourceKey),
23
+ hourlyBudget: (client) => Promise.resolve(client.hourlyBudget()),
24
+ queryWalk: (client, operation, params, maxRows, signal) => withSignal(client, signal, () => walkQuery(client, operation, params, maxRows)),
25
+ freshen: (client) => client.http.readCache?.clear(),
26
+ };
27
+ /**
28
+ * Walk one `*.query` operation with the resource's OWN iterator (`client[<resource>].query(...)` —
29
+ * the public AsyncIterable over the SDK's internal page fetcher; see {@link ToolOps.queryWalk}).
30
+ * The page size is the vendor maximum (500) and `maxRows` caps the TOTAL rows.
31
+ *
32
+ * Truncation honesty: the iterator is advanced ONE record past the cap purely to learn whether more
33
+ * records exist; that record is DISCARDED, so a data set of exactly `maxRows` rows reports
34
+ * `truncated: false` and the page count is untouched.
35
+ */
36
+ export async function walkQuery(client, operation, params, maxRows) {
37
+ const resourceKey = operation.slice(0, operation.indexOf('.'));
38
+ const holder = client[camelKey(resourceKey)];
39
+ if (holder === undefined || typeof holder.query !== 'function') {
40
+ throw new AutotaskConfigError(`output_path: the client exposes no query iterator for "${resourceKey}" (operation ${operation}) — nothing was written`, { operation });
41
+ }
42
+ const bag = { maxRecords: 500 };
43
+ if (params.filter !== undefined)
44
+ bag.filter = params.filter;
45
+ if (params.includeFields !== undefined)
46
+ bag.includeFields = params.includeFields;
47
+ if (params.via !== undefined)
48
+ bag.via = params.via;
49
+ const items = [];
50
+ for await (const item of holder.query(bag)) {
51
+ if (items.length >= maxRows)
52
+ return { items, truncated: true };
53
+ items.push(item);
54
+ }
55
+ return { items, truncated: false };
56
+ }
57
+ /**
58
+ * Propagate the MCP request's cancellation into the SDK's in-flight request (architecture 7.6:
59
+ * an abandoned request stops consuming vendor budget). The SDK's `withSignal` returns
60
+ * `undefined` when the signal has already aborted or no transport is attached — the SDK's own
61
+ * handler then answers the typed cancellation; the handler's generic catch would otherwise
62
+ * re-report it as a tool error, so this wrapper throws the SDK's typed `AbortError` instead.
63
+ */
64
+ export function withSignal(client, signal, fn) {
65
+ const c = client;
66
+ if (signal === undefined || typeof c.withSignal !== 'function')
67
+ return fn();
68
+ return c.withSignal(signal, fn);
69
+ }
70
+ //# sourceMappingURL=ops.js.map
@@ -0,0 +1,5 @@
1
+ /** The per-tenant client pool (issue #91). */
2
+ import { type ClientPool } from 'node-autotask/pool';
3
+ import type { Logger } from '../../logger.js';
4
+ /** The process-lifetime bounded pool of the http transport (security D-1, ratified). */
5
+ export declare function createTenantPool(log: Logger): ClientPool;
@@ -0,0 +1,9 @@
1
+ /** The per-tenant client pool (issue #91). */
2
+ import { createClientPool } from 'node-autotask/pool';
3
+ /** The process-lifetime bounded pool of the http transport (security D-1, ratified). */
4
+ export function createTenantPool(log) {
5
+ const pool = createClientPool({ maxSize: 128 });
6
+ log.info('tenant client pool ready', { maxSize: 128 });
7
+ return pool;
8
+ }
9
+ //# sourceMappingURL=pool.js.map
@@ -0,0 +1,24 @@
1
+ /** HTTP per-request credential and scope resolution (issue #91). */
2
+ import type { ClientPool } from 'node-autotask/pool';
3
+ import type { Config } from '../../config.js';
4
+ import type { Logger } from '../../logger.js';
5
+ import type { HttpScopeResolver } from './types.js';
6
+ import { buildSdkBaseOptions } from './client.js';
7
+ /**
8
+ * Build the per-request scope resolver for the http transport.
9
+ *
10
+ * The effective policy (mirrors the security S2/C2.2 layering):
11
+ * - a caller-named origin is refused unless `AUTOTASK_ALLOWED_BASE_HOSTS` permits it;
12
+ * - allow-list unset + default origin set -> the default origin only is accepted;
13
+ * - neither set -> boot is refused unless the explicit lab-only `AUTOTASK_ALLOW_ANY_BASE_HOST`
14
+ * (config validation makes that mode reachable by typing it out, never by forgetting);
15
+ * - the default origin is operator truth: never checked against the allow-list, but it must
16
+ * itself pass the https + private-IP bounds (enforced at boot / at construction).
17
+ *
18
+ * Credentials are all-or-nothing: a request presenting 1 or 2 of the three identity headers is
19
+ * malformed and resolves to the `partial_credential` refusal (there is no per-field default
20
+ * fill); a request presenting none uses the server-held default tuple ONLY when it is set AND
21
+ * the request did not name a different origin (the default identity is only ever presented to
22
+ * the default origin — a caller cannot aim it at a host it controls).
23
+ */
24
+ export declare function createHttpScopeResolver(config: Config, log: Logger, pool: ClientPool, baseOptions: ReturnType<typeof buildSdkBaseOptions>): HttpScopeResolver;
@@ -0,0 +1,165 @@
1
+ import { serverCredentialDefaults } from '../../config.js';
2
+ import { baseHostAllowed, normalizeBaseUrl } from '../../base-hosts.js';
3
+ import { CREDENTIAL_LOAD_HINT, detectCredentialShape } from '../../credential.js';
4
+ import { buildSdkBaseOptions, buildPlaceholderClient } from './client.js';
5
+ /**
6
+ * Build the per-request scope resolver for the http transport.
7
+ *
8
+ * The effective policy (mirrors the security S2/C2.2 layering):
9
+ * - a caller-named origin is refused unless `AUTOTASK_ALLOWED_BASE_HOSTS` permits it;
10
+ * - allow-list unset + default origin set -> the default origin only is accepted;
11
+ * - neither set -> boot is refused unless the explicit lab-only `AUTOTASK_ALLOW_ANY_BASE_HOST`
12
+ * (config validation makes that mode reachable by typing it out, never by forgetting);
13
+ * - the default origin is operator truth: never checked against the allow-list, but it must
14
+ * itself pass the https + private-IP bounds (enforced at boot / at construction).
15
+ *
16
+ * Credentials are all-or-nothing: a request presenting 1 or 2 of the three identity headers is
17
+ * malformed and resolves to the `partial_credential` refusal (there is no per-field default
18
+ * fill); a request presenting none uses the server-held default tuple ONLY when it is set AND
19
+ * the request did not name a different origin (the default identity is only ever presented to
20
+ * the default origin — a caller cannot aim it at a host it controls).
21
+ */
22
+ export function createHttpScopeResolver(config, log, pool, baseOptions) {
23
+ const defaultBase = config.AUTOTASK_BASE_URL;
24
+ const defaultNormalized = defaultBase === undefined ? undefined : normalizeBaseUrl(defaultBase);
25
+ const defaultOrigin = defaultNormalized?.origin;
26
+ const placeholders = {
27
+ no_credential: buildPlaceholderClient(baseOptions),
28
+ partial_credential: buildPlaceholderClient(baseOptions),
29
+ no_origin: buildPlaceholderClient(baseOptions),
30
+ bad_origin: buildPlaceholderClient(baseOptions),
31
+ origin_not_allowed: buildPlaceholderClient(baseOptions),
32
+ // Unused: the malformed path builds its placeholder lazily (the strategy name carries the
33
+ // anomaly names, so there is one placeholder per anomaly set, not one for the kind).
34
+ malformed_credential: buildPlaceholderClient(baseOptions),
35
+ };
36
+ /**
37
+ * The malformed-credential placeholders (issue #4b): one per anomaly SET, built lazily and cached,
38
+ * because the strategy name carries the anomaly names and the anomaly vocabulary is small and
39
+ * bounded — a fresh client per malformed request would let a caller allocate one per request. The
40
+ * refusal itself is decided by `tools/results.ts` from the anomaly names in the strategy name.
41
+ */
42
+ const malformedPlaceholders = new Map();
43
+ /**
44
+ * The pre-dispatch credential shape gate, on the values THIS request will present.
45
+ *
46
+ * It runs before any client is constructed (so before any dial, and before the origin checks —
47
+ * a malformed credential is refused whatever origin it named). Both the per-request headers and
48
+ * the server-held defaults win the same treatment: whichever value would be presented is the one
49
+ * judged.
50
+ */
51
+ function refuseMalformedCredentials(values) {
52
+ const anomalies = new Set();
53
+ for (const [, value] of values) {
54
+ if (value === undefined || value.trim() === '')
55
+ continue;
56
+ for (const anomaly of detectCredentialShape(value))
57
+ anomalies.add(anomaly);
58
+ }
59
+ if (anomalies.size === 0)
60
+ return undefined;
61
+ const list = [...anomalies];
62
+ log.warn('refused a malformed Autotask credential before dialing', {
63
+ source: 'request headers (or the server-held defaults)',
64
+ anomalies: list,
65
+ hint: CREDENTIAL_LOAD_HINT,
66
+ });
67
+ const id = list.join(',');
68
+ let client = malformedPlaceholders.get(id);
69
+ if (client === undefined) {
70
+ client = { __malformedAnomalies: list };
71
+ malformedPlaceholders.set(id, client);
72
+ }
73
+ return { client, refusal: { kind: 'malformed_credential', anomalies: list } };
74
+ }
75
+ return {
76
+ resolve(scope) {
77
+ // ---- origin ----
78
+ const headerBase = scope.baseUrl;
79
+ const headerNormalized = headerBase === undefined ? undefined : normalizeBaseUrl(headerBase);
80
+ const callerNamed = headerBase !== undefined && (headerNormalized === undefined || headerNormalized.origin !== defaultOrigin);
81
+ const base = headerBase ?? defaultBase;
82
+ const baseNormalized = base === undefined ? undefined : (headerBase !== undefined ? headerNormalized : defaultNormalized);
83
+ // ---- pre-dispatch credential SHAPE gate (issue #4b) ----
84
+ // Runs first: a malformed value is refused before any client is constructed and before the
85
+ // origin policy is consulted, because the shape is decidable without a dial and a malformed
86
+ // credential is refused whatever origin it named. Both sources are judged: the request's own
87
+ // headers, and the server-held defaults a header-less request would run as.
88
+ const defaultsForShape = serverCredentialDefaults(config);
89
+ const malformed = refuseMalformedCredentials([
90
+ ['X-Autotask-Api-Integration-Code', scope.apiIntegrationCode ?? defaultsForShape?.apiIntegrationCode],
91
+ ['X-Autotask-UserName', scope.userName ?? defaultsForShape?.userName],
92
+ ['X-Autotask-Secret', scope.secret ?? defaultsForShape?.secret],
93
+ ['X-Autotask-Impersonation-Resource-Id', scope.impersonationResourceId ?? defaultsForShape?.impersonationResourceId],
94
+ ]);
95
+ if (malformed !== undefined)
96
+ return malformed;
97
+ // ---- credentials (all-or-nothing) ----
98
+ const present = [scope.apiIntegrationCode, scope.userName, scope.secret].filter((v) => v !== undefined).length;
99
+ let creds;
100
+ if (present === 3) {
101
+ creds = {
102
+ apiIntegrationCode: scope.apiIntegrationCode,
103
+ userName: scope.userName,
104
+ secret: scope.secret,
105
+ ...(scope.impersonationResourceId !== undefined ? { impersonationResourceId: scope.impersonationResourceId } : {}),
106
+ };
107
+ }
108
+ else if (present > 0) {
109
+ // A partial tuple is malformed: fail closed, no default fill.
110
+ log.warn('request refused: partial credential tuple', { presented: present });
111
+ return { client: placeholders.partial_credential, refusal: { kind: 'partial_credential' } };
112
+ }
113
+ else {
114
+ const defaults = serverCredentialDefaults(config);
115
+ // The default identity is only ever presented to the DEFAULT origin.
116
+ if (defaults !== undefined && !callerNamed) {
117
+ creds = defaults;
118
+ }
119
+ }
120
+ // ---- refusals, in precedence order ----
121
+ if (creds === undefined) {
122
+ return { client: placeholders.no_credential, refusal: { kind: 'no_credential' } };
123
+ }
124
+ if (base === undefined || baseNormalized === undefined) {
125
+ const kind = headerBase !== undefined && headerNormalized === undefined ? 'bad_origin' : 'no_origin';
126
+ return { client: placeholders[kind], refusal: { kind } };
127
+ }
128
+ if (callerNamed) {
129
+ const allowed = config.AUTOTASK_ALLOWED_BASE_HOSTS === undefined
130
+ ? config.AUTOTASK_ALLOW_ANY_BASE_HOST // unset list + no default origin: only the explicit lab opt-in admits
131
+ : baseHostAllowed(headerBase, config.AUTOTASK_ALLOWED_BASE_HOSTS);
132
+ if (!allowed) {
133
+ // The name of the policy is the only diagnostic: the refused value is never echoed.
134
+ log.warn('request refused: caller-named origin not allowed', { allowAny: config.AUTOTASK_ALLOW_ANY_BASE_HOST, impersonated: creds.impersonationResourceId !== undefined });
135
+ return { client: placeholders.origin_not_allowed, refusal: { kind: 'origin_not_allowed' } };
136
+ }
137
+ }
138
+ // ---- the pooled tenant client (D-1: the single sanctioned in-process state) ----
139
+ // Security S2/C2.2 layering (FT-1): the server allow-list is what ADMITS the caller
140
+ // origin; the SDK hostPolicy adds only the literal-IP floor. The client's allowedOrigins
141
+ // must therefore cover every origin the server admits for THIS request — the caller's own
142
+ // (allow-listed) origin plus the deployment default when set. Deriving it from the
143
+ // deployment-wide default alone 500'd an allow-listed NON-default caller origin at client
144
+ // construction (HostPolicyDeniedError out of the per-request factory).
145
+ const allowedOrigins = [baseNormalized.origin];
146
+ if (defaultOrigin !== undefined && defaultOrigin !== baseNormalized.origin)
147
+ allowedOrigins.push(defaultOrigin);
148
+ const poolConfig = {
149
+ apiIntegrationCode: creds.apiIntegrationCode,
150
+ userName: creds.userName,
151
+ secret: creds.secret,
152
+ ...(creds.impersonationResourceId !== undefined ? { impersonationResourceId: creds.impersonationResourceId } : {}),
153
+ baseUrl: baseNormalized.baseUrl,
154
+ ...baseOptions,
155
+ hostPolicy: {
156
+ ...(baseOptions.hostPolicy ?? {}),
157
+ allowedOrigins,
158
+ },
159
+ };
160
+ const client = pool.get(poolConfig);
161
+ return { client, origin: baseNormalized.origin, poolConfig };
162
+ },
163
+ };
164
+ }
165
+ //# sourceMappingURL=scope.js.map