iit-mcp-autotask 0.11.2 → 0.12.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 +5 -5
- package/capabilities.json +2 -2
- package/dist/tools/catalog-data.d.ts +3 -1
- package/dist/tools/catalog-data.js +4 -2
- package/dist/tools/meta.d.ts +1 -1
- package/dist/tools/meta.js +15 -6
- package/dist/tools/resolves.js +28 -4
- package/dist/tools/schemas.d.ts +3 -1
- package/dist/tools/schemas.js +4 -3
- package/dist/tools/server-version.d.ts +3 -0
- package/dist/tools/server-version.js +4 -0
- package/dist/tools/server.d.ts +3 -3
- package/dist/tools/server.js +2 -2
- package/dist/tools/write/preview.js +26 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/log-time-entry.md +3 -2
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@ packaged playbooks, strong security boundaries, and predictable per-tenant in-pr
|
|
|
9
9
|
client + SDK-owned TTL caches).
|
|
10
10
|
|
|
11
11
|
> **Package status (2026-10-22):** the runtime dependency `node-autotask` is published to public npm
|
|
12
|
-
> at `0.
|
|
12
|
+
> at `0.24.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.
|
|
14
|
+
> (node-autotask pinned exactly at `0.24.0`) — never a `file:`, `git:`, `link:` or workspace reference
|
|
15
15
|
> to a local checkout.
|
|
16
16
|
|
|
17
17
|
> **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
|
|
@@ -452,7 +452,7 @@ No aliases: old names fail as unknown tools.
|
|
|
452
452
|
|-------|-------|
|
|
453
453
|
| `catalog`, `describe {operation}`, `read {operation,args}`, `fetch_many`, `ticket_full_context`, `company_full_profile` | unchanged (core) |
|
|
454
454
|
| `read_batch {items}` / `relationship_map {resource_key}` | `read {items}` / `describe {resource}` |
|
|
455
|
-
| `resource_who_am_i`, `version`, `threshold_information` | `tenant_status {parts}` (`identity`, `version`, `thresholds`) |
|
|
455
|
+
| `resource_who_am_i`, `version`, `threshold_information` | `tenant_status {parts}` (`identity` + `impersonationResourceId`, `version`, `thresholds`, `server`) |
|
|
456
456
|
| `resolve_any` | `resolve {identifier, resources?}` |
|
|
457
457
|
| `search_across_resources {query,resources,limit}` | `resolve {identifier, match: "contains", resources, limit}` |
|
|
458
458
|
| `company_` / `configurationItems_` / `contact_` / `project_` / `ticket_resolve` | `resolve {identifier, resource}` |
|
|
@@ -769,7 +769,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
|
|
|
769
769
|
|
|
770
770
|
## SDK dependency
|
|
771
771
|
|
|
772
|
-
`iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.
|
|
772
|
+
`iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.24.0`** (an exact pin —
|
|
773
773
|
no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
|
|
774
774
|
`/pool`) — the only **vendor** dependency; the other two runtime dependencies are
|
|
775
775
|
`@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
|
|
@@ -779,7 +779,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
|
|
|
779
779
|
reviewed against, or the plan-hash boot check refuses to start.
|
|
780
780
|
|
|
781
781
|
**Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
|
|
782
|
-
public registry at `0.
|
|
782
|
+
public registry at `0.24.0` (published 2026-10-23); the
|
|
783
783
|
exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
|
|
784
784
|
tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
|
|
785
785
|
freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
|
package/capabilities.json
CHANGED
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
168
|
"id": "autotask.identity.tenant_status",
|
|
169
|
-
"description": "
|
|
169
|
+
"description": "Identity (API user + impersonated user), Autotask API version, this server's package/SDK version and profile (server), and request thresholds (tenant count + local hourly budget). Use it first to check credentials.",
|
|
170
170
|
"mcpPrimitive": "tool",
|
|
171
171
|
"toolName": "autotask_tenant_status",
|
|
172
172
|
"type": "read",
|
|
@@ -380,7 +380,7 @@
|
|
|
380
380
|
"autotask_describe",
|
|
381
381
|
"autotask_read"
|
|
382
382
|
],
|
|
383
|
-
"backingRegistryOperations":
|
|
383
|
+
"backingRegistryOperations": 1217,
|
|
384
384
|
"note": "autotask_catalog finds the exact operation key (ranked search), autotask_describe serves its contract, autotask_read runs any effect=read key. Writes go through the operator profile (autotask_write then autotask_apply); authenticate.* is never served."
|
|
385
385
|
},
|
|
386
386
|
"schemas": {}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { CapabilityRecord } from 'node-autotask/capabilities';
|
|
2
|
+
/** The installed SDK's own version (the running process's, read from its package.json). */
|
|
3
|
+
export declare const SDK_VERSION: string;
|
|
2
4
|
export interface CatalogRow {
|
|
3
5
|
operation: string;
|
|
4
6
|
/** True for the 10 core-profile backing operations (dedicated tools). */
|
|
@@ -100,7 +102,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
|
|
|
100
102
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
101
103
|
* describes the SDK behind it.
|
|
102
104
|
*/
|
|
103
|
-
export declare const BUILT_AGAINST_PLAN_HASH = "
|
|
105
|
+
export declare const BUILT_AGAINST_PLAN_HASH = "479d7826acf548a8713c6891fe6cc5ea6ebb7c287896c932aac70834860a206f";
|
|
104
106
|
/**
|
|
105
107
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
106
108
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
|
@@ -19,6 +19,8 @@ import { ConfigError } from '../config.js';
|
|
|
19
19
|
const require = createRequire(import.meta.url);
|
|
20
20
|
/** The installed `node-autotask` package directory (the data files ship at its root). */
|
|
21
21
|
const SDK_PACKAGE_DIR = dirname(require.resolve('node-autotask/package.json'));
|
|
22
|
+
/** The installed SDK's own version (the running process's, read from its package.json). */
|
|
23
|
+
export const SDK_VERSION = JSON.parse(readFileSync(join(SDK_PACKAGE_DIR, 'package.json'), 'utf8')).version;
|
|
22
24
|
function loadSdkJson(name) {
|
|
23
25
|
return JSON.parse(readFileSync(join(SDK_PACKAGE_DIR, name), 'utf8'));
|
|
24
26
|
}
|
|
@@ -113,7 +115,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
113
115
|
autotask_resolve: 'Find the record behind an identifier: id or name, ticket number, configuration item serial number, contact email (by), in one resource or swept across several. Exact by default; contains for companies or a sweep (compact; limit = total).',
|
|
114
116
|
autotask_query: 'List records with a filter: tickets, time entries, service calls, contracts or time-off requests (by status, company, date). limit default 50, max 2500; latest N, tsv, output_path. Slim: include_fields [names], note_chars 200.',
|
|
115
117
|
autotask_fetch_many: 'Fetch up to 500 records by numeric id across mixed resources in one call: items: [{resource, id, fields?}]. Answers data.results[{ok, operation, result|error}], ok, failed (same as autotask_read); a miss is a typed NOT_FOUND slot.',
|
|
116
|
-
autotask_tenant_status: '
|
|
118
|
+
autotask_tenant_status: 'Identity (API user + impersonated user), Autotask API version, this server\'s package/SDK version and profile (server), and request thresholds (tenant count + local hourly budget). Use it first to check credentials.',
|
|
117
119
|
autotask_ticket_full_context: 'One ticket with its company, contact, assigned resource, project, notes, time entries and attachment metadata. Missing parts degrade to typed absent; a missing ticket is not found.',
|
|
118
120
|
autotask_company_full_profile: 'One company with locations, contacts, notes, open tickets and projects, 10 rows per part (capped: true = more exist; raise the limit). fields {part:[names]} trims columns. Open = status not 5. Missing parts degrade to typed absent.',
|
|
119
121
|
autotask_project_get_context: 'One project with its company, opportunity and lead resource. Project phases and task lists do not exist on this API; charges and notes go through autotask_read.',
|
|
@@ -162,7 +164,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
|
|
|
162
164
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
163
165
|
* describes the SDK behind it.
|
|
164
166
|
*/
|
|
165
|
-
export const BUILT_AGAINST_PLAN_HASH = '
|
|
167
|
+
export const BUILT_AGAINST_PLAN_HASH = '479d7826acf548a8713c6891fe6cc5ea6ebb7c287896c932aac70834860a206f';
|
|
166
168
|
/**
|
|
167
169
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
168
170
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
package/dist/tools/meta.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare function paramNames(operation: string, rec: unknown): Record<stri
|
|
|
12
12
|
export declare function buildDescribe(ctx: ToolContext): BuiltTool;
|
|
13
13
|
/** `autotask_read`: one read by exact key, or up to 10 (`items`) with per-item results. */
|
|
14
14
|
export declare function buildRead(ctx: ToolContext): BuiltTool;
|
|
15
|
-
/** `autotask_tenant_status`: identity / version / thresholds; each part degrades to typed absent. */
|
|
15
|
+
/** `autotask_tenant_status`: identity / version / thresholds / server; each part degrades to typed absent. */
|
|
16
16
|
export declare function buildTenantStatus(ctx: ToolContext): BuiltTool;
|
|
17
17
|
/** `autotask_fetch_many`: up to 500 records by numeric id across mixed resources. */
|
|
18
18
|
export declare function buildFetchMany(ctx: ToolContext): BuiltTool;
|
package/dist/tools/meta.js
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* fail-closed refusal, then the work, with the SDK error contract mapped on failure.
|
|
17
17
|
*/
|
|
18
18
|
import { AutotaskConfigError } from 'node-autotask';
|
|
19
|
-
import { CATALOG, TOOL_NAME_BY_OP, opsForTool, recordById, servedToolName, untrustedFieldsOf } from './catalog-data.js';
|
|
19
|
+
import { CATALOG, SDK_VERSION, TOOL_NAME_BY_OP, opsForTool, recordById, servedToolName, untrustedFieldsOf } from './catalog-data.js';
|
|
20
20
|
import { releaseClientOnError } from './context.js';
|
|
21
21
|
import { abortedAnswer, InputInvalidError, readOneItem, requestSecrets } from './dispatch.js';
|
|
22
22
|
import { crossResourceUntrusted, markResult, ok, typedErrorPayload } from './results.js';
|
|
23
23
|
import { bm25Rank } from './rank.js';
|
|
24
24
|
import { playbooksFor } from './guide.js';
|
|
25
|
+
import { SERVER_NAME, SERVER_VERSION } from './server-version.js';
|
|
25
26
|
import { CATALOG_INPUT, DESCRIBE_INPUT, COMPANY_FULL_PROFILE_INPUT, CONFIGURATION_ITEM_PROFILE_INPUT, CONTACT_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, FETCH_MANY_INPUT, INVOICE_GET_INPUT, PROJECT_GET_CONTEXT_INPUT, QUERY_INPUT, QUOTE_GET_INPUT, RESOLVE_INPUT, RESOURCE_WORKLOAD_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, TICKET_FULL_CONTEXT_INPUT, QUERY_LIMIT, READ_INPUT, TENANT_STATUS_INPUT, TENANT_STATUS_PARTS, closedInput, } from './schemas.js';
|
|
26
27
|
import { refusalReason } from '../policy.js';
|
|
27
28
|
/** Search text per catalog row (key words + purpose), built once. */
|
|
@@ -100,9 +101,9 @@ const TOOL_INPUT = {
|
|
|
100
101
|
};
|
|
101
102
|
/** Tools whose argument SHAPE (not just casing) differs from the SDK call. */
|
|
102
103
|
const SHAPE_NOTES = {
|
|
103
|
-
autotask_resolve: 'flat args, not the SDK identifier + opts bag: identifier -> identifier (positional in every key); by picks the key (ticket_number -> tickets.findByTicketNumber, serial -> configuration_items.findBySerial, email -> contacts.findByEmail, id_or_name -> <resource>.resolve); no resource -> operations.resolveAny (match exact) or operations.searchAcrossResources (match contains), resource + match contains -> companies.search; the opts bag is built from expand, include_family (includeFamily), limit and resources',
|
|
104
|
+
autotask_resolve: 'flat args, not the SDK identifier + opts bag: identifier -> identifier (positional in every key); by picks the key (ticket_number -> tickets.findByTicketNumber, serial -> configuration_items.findBySerial, email -> contacts.findByEmail, id_or_name -> <resource>.resolve); no resource -> operations.resolveAny (match exact) or operations.searchAcrossResources (match contains), resource companies + match contains -> companies.search; resource tickets + match contains -> tickets.search (title contains; company_id -> companyID); the opts bag is built from expand, include_family (includeFamily), limit and resources',
|
|
104
105
|
autotask_fetch_many: 'SDK items is the same [{resource, id, fields?}] array; the tool wraps it as items and sets no opts (the 500-item cap is fixed)',
|
|
105
|
-
autotask_tenant_status: 'parts [identity, version, thresholds] picks which of resources.whoAmI, version.get, threshold_information.get run (default all
|
|
106
|
+
autotask_tenant_status: 'parts [identity, version, thresholds, server] picks which of resources.whoAmI, version.get, threshold_information.get run, plus the zero-wire server part (default all four); identity also returns impersonationResourceId; none takes args',
|
|
106
107
|
autotask_contact_full_profile: 'takes only id (+ ticket_limit), no parentId: the tool resolves the contact by id alone',
|
|
107
108
|
autotask_invoice_get: 'format selects the key: record -> invoices.get, html -> invoices.markupHtml, xml -> invoices.markupXml, pdf -> invoices.pdf; output_path is tool-only (writes the export to a file)',
|
|
108
109
|
};
|
|
@@ -367,17 +368,25 @@ const STATUS_OPS = {
|
|
|
367
368
|
version: 'version.get',
|
|
368
369
|
thresholds: 'threshold_information.get',
|
|
369
370
|
};
|
|
370
|
-
/** `autotask_tenant_status`: identity / version / thresholds; each part degrades to typed absent. */
|
|
371
|
+
/** `autotask_tenant_status`: identity / version / thresholds / server; each part degrades to typed absent. */
|
|
371
372
|
export function buildTenantStatus(ctx) {
|
|
372
373
|
return {
|
|
373
|
-
title: 'Show the Autotask tenant identity, API version and
|
|
374
|
+
title: 'Show the Autotask tenant identity, API version, thresholds and server version',
|
|
374
375
|
input: closedInput(TENANT_STATUS_INPUT),
|
|
375
376
|
run: async (raw, signal) => {
|
|
376
377
|
const args = raw;
|
|
377
378
|
const parts = [...new Set(args.parts)];
|
|
378
379
|
const data = {};
|
|
379
380
|
let firstError;
|
|
381
|
+
// whoAmI is the API user; the resource entries are logged as is the impersonation id (null: none). Zero wire, so present even when identity degrades.
|
|
382
|
+
if (parts.includes('identity'))
|
|
383
|
+
data.impersonationResourceId = ctx.poolConfig?.impersonationResourceId ?? null;
|
|
380
384
|
for (const part of parts) {
|
|
385
|
+
// Zero wire: this package's version (the same constant as serverInfo and /metadata), kept apart from the vendor `version` part.
|
|
386
|
+
if (part === 'server') {
|
|
387
|
+
data.server = { name: SERVER_NAME, version: SERVER_VERSION, sdkVersion: SDK_VERSION, profile: ctx.config.AUTOTASK_PROFILE };
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
381
390
|
const op = STATUS_OPS[part];
|
|
382
391
|
try {
|
|
383
392
|
const value = await ctx.ops.invokeRead(ctx.client, op, [], undefined, signal);
|
|
@@ -403,7 +412,7 @@ export function buildTenantStatus(ctx) {
|
|
|
403
412
|
}
|
|
404
413
|
}
|
|
405
414
|
// Nothing answered: the call failed, with the first part's typed error (never an empty ok).
|
|
406
|
-
if (firstError !== undefined &&
|
|
415
|
+
if (firstError !== undefined && parts.filter((p) => p !== 'server').every((p) => data[p]?.absent !== undefined))
|
|
407
416
|
throw firstError;
|
|
408
417
|
const identity = data.identity;
|
|
409
418
|
return ok(parts.includes('identity') && identity === null ? 'No Resource matched the configured user.' : `Tenant status returned (${parts.join(', ')}).`, data);
|
package/dist/tools/resolves.js
CHANGED
|
@@ -72,7 +72,7 @@ const COMPACT_FIELDS = {
|
|
|
72
72
|
configuration_items: ['referenceTitle'],
|
|
73
73
|
contacts: ['firstName', 'lastName'],
|
|
74
74
|
projects: ['projectNumber', 'projectName'],
|
|
75
|
-
tickets: ['ticketNumber'],
|
|
75
|
+
tickets: ['ticketNumber', 'title', 'status', 'assignedResourceID'],
|
|
76
76
|
};
|
|
77
77
|
/** One sweep match: compact by default, the full record under `record` when `expand` (as before). */
|
|
78
78
|
function matchRow(resource, record, expand) {
|
|
@@ -108,7 +108,11 @@ function resolveText(resource, identifier, record) {
|
|
|
108
108
|
function exactMiss(resource, identifier) {
|
|
109
109
|
const spec = RESOURCES[resource];
|
|
110
110
|
const blind = spec.blind ? '; a miss is not proof of absence (companies the API user does not own are a blind surface)' : '';
|
|
111
|
-
const contains = resource === 'companies'
|
|
111
|
+
const contains = resource === 'companies'
|
|
112
|
+
? 'For a contains match pass match: "contains"'
|
|
113
|
+
: resource === 'tickets'
|
|
114
|
+
? 'For a title search pass match: "contains" (optionally company_id), or find it by activity with autotask_read time_entries.recentTickets'
|
|
115
|
+
: 'For a contains match omit resource and pass match: "contains" (a sweep)';
|
|
112
116
|
return new NotFoundError(`resolve: no ${spec.noun} exactly matches "${identifier}" — matching is EXACT on ${spec.field}${blind}`, {
|
|
113
117
|
operation: `${resource}.resolve`,
|
|
114
118
|
suggestedAction: `${contains}; or fetch by numeric id via autotask_read ${spec.getOp} — then cite the record's ${spec.humanFields.join(' or ')}, never the bare id.`,
|
|
@@ -126,8 +130,11 @@ function validateCombination(a) {
|
|
|
126
130
|
if (a.match === 'contains')
|
|
127
131
|
throw new InputInvalidError(`resolve: by "${a.by}" is an exact natural-key lookup — match "contains" does not apply`, 'match');
|
|
128
132
|
}
|
|
129
|
-
if (a.match === 'contains' && a.resource !== undefined && a.resource !== 'companies') {
|
|
130
|
-
throw new InputInvalidError('resolve: match "contains" applies to companies or to a sweep (omit resource) only', 'match');
|
|
133
|
+
if (a.match === 'contains' && a.resource !== undefined && a.resource !== 'companies' && a.resource !== 'tickets') {
|
|
134
|
+
throw new InputInvalidError('resolve: match "contains" applies to companies, tickets (title) or to a sweep (omit resource) only', 'match');
|
|
135
|
+
}
|
|
136
|
+
if (a.company_id !== undefined && !(a.match === 'contains' && a.resource === 'tickets')) {
|
|
137
|
+
throw new InputInvalidError('resolve: company_id scopes a tickets title search only (resource "tickets", match "contains")', 'company_id');
|
|
131
138
|
}
|
|
132
139
|
if (a.include_family && !(a.match === 'contains' && a.resource === 'companies')) {
|
|
133
140
|
throw new InputInvalidError('resolve: include_family applies to resource "companies" with match "contains" only', 'include_family');
|
|
@@ -210,6 +217,23 @@ export function buildResolve(ctx) {
|
|
|
210
217
|
throw new NotFoundError(`resolve: no ${RESOURCES[resource].noun} with ${a.by} "${a.identifier}"`, { operation });
|
|
211
218
|
return ok(resolveText(resource, a.identifier, record), { resource, by: a.by, match: 'exact', ref: refOf(resource, record), record: markResult(record, ctx.untrustedFields(operation)) });
|
|
212
219
|
}
|
|
220
|
+
// ---- tickets contains (#162): a title search, optionally scoped to one company, compact rows ----
|
|
221
|
+
if (a.match === 'contains' && resource === 'tickets') {
|
|
222
|
+
gate('tickets.search');
|
|
223
|
+
const opts = { limit: a.limit, ...(a.company_id !== undefined ? { companyID: a.company_id } : {}), ...(a.expand ? {} : { includeFields: ['ticketNumber', 'title', 'status', 'assignedResourceID', 'companyID'] }) };
|
|
224
|
+
const found = await ctx.ops.invokeRead(ctx.client, 'tickets.search', [String(a.identifier), opts], undefined, signal);
|
|
225
|
+
if (found === undefined)
|
|
226
|
+
return abortedAnswer(ctx.correlationId);
|
|
227
|
+
const rows = (Array.isArray(found) ? found : []).map((t) => (a.expand ? { resource, ref: refOf('tickets', t), record: t } : { ...matchRow('tickets', t, false), ...(asRecord(t).companyID !== undefined ? { companyID: asRecord(t).companyID } : {}) }));
|
|
228
|
+
const miss = rows.length === 0 ? ' No title contains that text: the work may use other words; list the tickets the resource logged time on with autotask_read time_entries.recentTickets [from, to, {resourceID, companyID?}] to find it by activity.' : '';
|
|
229
|
+
return ok(`Ticket title search "${a.identifier}"${a.company_id !== undefined ? ` (company ${a.company_id})` : ''}: ${rows.length} match(es).${miss}`, {
|
|
230
|
+
resource,
|
|
231
|
+
match: 'contains',
|
|
232
|
+
count: rows.length,
|
|
233
|
+
truncated: rows.length >= a.limit,
|
|
234
|
+
matches: markResult(rows, fields),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
213
237
|
// ---- companies contains (issue #9) ----
|
|
214
238
|
if (a.match === 'contains') {
|
|
215
239
|
gate('companies.search');
|
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -244,6 +244,7 @@ export declare const RESOLVE_INPUT: z.ZodObject<{
|
|
|
244
244
|
exact: "exact";
|
|
245
245
|
contains: "contains";
|
|
246
246
|
}>>;
|
|
247
|
+
company_id: z.ZodOptional<z.ZodNumber>;
|
|
247
248
|
expand: z.ZodDefault<z.ZodBoolean>;
|
|
248
249
|
include_family: z.ZodDefault<z.ZodBoolean>;
|
|
249
250
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
@@ -317,9 +318,10 @@ export declare const FETCH_MANY_INPUT: z.ZodObject<{
|
|
|
317
318
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
318
319
|
}, z.core.$strip>>;
|
|
319
320
|
}, z.core.$strip>;
|
|
320
|
-
export declare const TENANT_STATUS_PARTS: readonly ["identity", "version", "thresholds"];
|
|
321
|
+
export declare const TENANT_STATUS_PARTS: readonly ["identity", "version", "thresholds", "server"];
|
|
321
322
|
export declare const TENANT_STATUS_INPUT: z.ZodObject<{
|
|
322
323
|
parts: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
324
|
+
server: "server";
|
|
323
325
|
identity: "identity";
|
|
324
326
|
version: "version";
|
|
325
327
|
thresholds: "thresholds";
|
package/dist/tools/schemas.js
CHANGED
|
@@ -211,7 +211,8 @@ export const RESOLVE_INPUT = z.object({
|
|
|
211
211
|
resource: WF.optional().describe('Resource to resolve in; omit to sweep several.'),
|
|
212
212
|
resources: z.array(WF).min(1).optional().describe('Sweep only: resources to try (default all five).'),
|
|
213
213
|
by: z.enum(['id_or_name', 'ticket_number', 'serial', 'email']).default('id_or_name').describe('Identifier kind; non-default needs resource.'),
|
|
214
|
-
match: z.enum(['exact', 'contains']).default('exact').describe('contains: companies or a sweep
|
|
214
|
+
match: z.enum(['exact', 'contains']).default('exact').describe('contains: companies, tickets (title) or a sweep.'),
|
|
215
|
+
company_id: z.number().int().positive().optional().describe('tickets + contains only: scope the title search to one company id.'),
|
|
215
216
|
expand: z.boolean().default(false).describe('Full record instead of the compact summary (sweep and typed).'),
|
|
216
217
|
include_family: z.boolean().default(false).describe('Companies contains only: parent and child ids per match.'),
|
|
217
218
|
limit: z.number().int().min(1).max(50).default(10).describe('Total matches across resources, 1-50 (companies: max 10).'),
|
|
@@ -294,9 +295,9 @@ export const FETCH_MANY_INPUT = z.object({
|
|
|
294
295
|
.min(1)
|
|
295
296
|
.describe('Up to 500 { resource, id } across resources; a miss is typed in its slot.'),
|
|
296
297
|
});
|
|
297
|
-
export const TENANT_STATUS_PARTS = ['identity', 'version', 'thresholds'];
|
|
298
|
+
export const TENANT_STATUS_PARTS = ['identity', 'version', 'thresholds', 'server'];
|
|
298
299
|
export const TENANT_STATUS_INPUT = z.object({
|
|
299
|
-
parts: z.array(z.enum(TENANT_STATUS_PARTS)).min(1).default([...TENANT_STATUS_PARTS]).describe('identity, version
|
|
300
|
+
parts: z.array(z.enum(TENANT_STATUS_PARTS)).min(1).default([...TENANT_STATUS_PARTS]).describe('identity (API user + the impersonation resource id this server acts as), version (the Autotask API build), thresholds, server (this package + SDK version, profile); default all.'),
|
|
300
301
|
});
|
|
301
302
|
/**
|
|
302
303
|
* The per-call name options on the four SDK-backed composites (issue #20 item 4). `names`
|
package/dist/tools/server.d.ts
CHANGED
|
@@ -27,8 +27,8 @@ import { type Config } from '../config.js';
|
|
|
27
27
|
import { type TokenStore } from '../http-guard.js';
|
|
28
28
|
import type { WriteRuntime } from '../write/runtime.js';
|
|
29
29
|
/** The wire identity of this server (the initialize answer + the `/metadata` payload). */
|
|
30
|
-
|
|
31
|
-
export
|
|
30
|
+
import { SERVER_NAME, SERVER_VERSION } from './server-version.js';
|
|
31
|
+
export { SERVER_NAME, SERVER_VERSION };
|
|
32
32
|
export declare const SERVER_TITLE = "Autotask MCP Server";
|
|
33
33
|
/**
|
|
34
34
|
* The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
|
|
@@ -60,7 +60,7 @@ export declare function serverInstructions(config: Pick<Config, 'AUTOTASK_PROFIL
|
|
|
60
60
|
/** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
|
|
61
61
|
export declare const SERVER_IDENTITY: {
|
|
62
62
|
readonly name: "iit-mcp-autotask";
|
|
63
|
-
readonly version: "0.
|
|
63
|
+
readonly version: "0.12.0";
|
|
64
64
|
readonly mcpProtocolBaseline: "2026-07-28";
|
|
65
65
|
readonly legacyProtocolFallback: "2025-11-25";
|
|
66
66
|
};
|
package/dist/tools/server.js
CHANGED
|
@@ -28,8 +28,8 @@ import { skillsFor } from './guide.js';
|
|
|
28
28
|
import { registerTools } from './register.js';
|
|
29
29
|
import { configForToken } from '../http-guard.js';
|
|
30
30
|
/** The wire identity of this server (the initialize answer + the `/metadata` payload). */
|
|
31
|
-
|
|
32
|
-
export
|
|
31
|
+
import { SERVER_NAME, SERVER_VERSION } from './server-version.js';
|
|
32
|
+
export { SERVER_NAME, SERVER_VERSION };
|
|
33
33
|
export const SERVER_TITLE = 'Autotask MCP Server';
|
|
34
34
|
/**
|
|
35
35
|
* The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
|
|
@@ -102,8 +102,31 @@ async function dryRun(r, a) {
|
|
|
102
102
|
}
|
|
103
103
|
return { ok: dry };
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* #163: who a time entry is for. A preview-only read (never in the plan hash): the resource's name and
|
|
107
|
+
* the entry's role echoed so the operator sees the human, not just an id. A miss is typed absent, never a failure.
|
|
108
|
+
*/
|
|
109
|
+
async function entryResource(r, a) {
|
|
110
|
+
if (r.op !== 'time_entries.create')
|
|
111
|
+
return undefined;
|
|
112
|
+
const data = a.parsed.data;
|
|
113
|
+
const id = data?.resourceID;
|
|
114
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const roleID = typeof data?.roleID === 'number' ? data.roleID : null;
|
|
117
|
+
try {
|
|
118
|
+
const res = (await r.ctx.ops.invokeRead(r.ctx.client, 'resources.get', [id], undefined, r.signal));
|
|
119
|
+
if (res === undefined || res === null)
|
|
120
|
+
return { id, roleID, name: null };
|
|
121
|
+
const name = typeof res.name === 'string' ? res.name : [res.firstName, res.lastName].filter((x) => typeof x === 'string' && x !== '').join(' ');
|
|
122
|
+
return { id, roleID, name: cleanUntrustedText(name, false).slice(0, 100) || null };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { id, roleID, name: null, absent: true };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
105
128
|
/** The policy decision over the derived facts, its audit row and the preview the answers carry. */
|
|
106
|
-
function evaluatePhase(r, a, snap, dry) {
|
|
129
|
+
function evaluatePhase(r, a, snap, dry, resource) {
|
|
107
130
|
const { ctx, w, store, loaded, args, op, audit } = r;
|
|
108
131
|
const { record, shape, parsed } = a;
|
|
109
132
|
const now = w.runtime.now();
|
|
@@ -120,6 +143,7 @@ function evaluatePhase(r, a, snap, dry) {
|
|
|
120
143
|
diff: markedDiff(dry.diff),
|
|
121
144
|
warnings: (dry.warnings ?? []).slice(0, 20).map((x) => String(x)),
|
|
122
145
|
impact: honestImpact(dry.impact, shape),
|
|
146
|
+
...(resource !== undefined ? { resource } : {}),
|
|
123
147
|
...(shape === 'move' ? { move: moveSummary(record, parsed.targets, snap.current, dry.data, facts) } : {}),
|
|
124
148
|
};
|
|
125
149
|
return { now, tenantId, facts, decision, planHash, preview };
|
|
@@ -217,7 +241,7 @@ export function buildWrite(ctx) {
|
|
|
217
241
|
const dry = await dryRun(r, admitted.ok);
|
|
218
242
|
if ('stop' in dry)
|
|
219
243
|
return dry.stop;
|
|
220
|
-
return persist(r, admitted.ok, snap.ok, evaluatePhase(r, admitted.ok, snap.ok, dry.ok));
|
|
244
|
+
return persist(r, admitted.ok, snap.ok, evaluatePhase(r, admitted.ok, snap.ok, dry.ok, await entryResource(r, admitted.ok)));
|
|
221
245
|
},
|
|
222
246
|
};
|
|
223
247
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Max Soukhomlinov",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/server": "^2.2.0",
|
|
56
|
-
"node-autotask": "0.
|
|
56
|
+
"node-autotask": "0.24.0",
|
|
57
57
|
"zod": "^4.6.7"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
package/server.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"capabilitiesManifest": "capabilities.json",
|
|
64
64
|
"sdk": {
|
|
65
65
|
"package": "node-autotask",
|
|
66
|
-
"versionRange": "0.
|
|
66
|
+
"versionRange": "0.24.0"
|
|
67
67
|
},
|
|
68
68
|
"vendorExtensions": false,
|
|
69
69
|
"credentialModelNote": "per-request; server-held under stdio or, on HTTP, only behind AUTOTASK_HTTP_AUTH=tokens; operator plans and the ledger are keyed by principal and tenant id, never by credential, and an apply uses the credential on the apply request"
|
package/skills/log-time-entry.md
CHANGED
|
@@ -5,7 +5,7 @@ version: 1
|
|
|
5
5
|
profile: operator
|
|
6
6
|
description: Log time on one ticket: resolve it, preview the time entry, apply that plan, confirm the stored entry.
|
|
7
7
|
when_not_to_use: editing or deleting time entries; moving an entry to another ticket (time_entries.moveToTicket via autotask_write, args [sourceId, toTicketId]; an update of ticketID does not persist); bulk imports; time reports (use autotask_query time_entries)
|
|
8
|
-
ops: ["autotask_resolve:tickets.findByTicketNumber", "autotask_read:billing_codes.query", "autotask_write:time_entries.create", "autotask_apply", "autotask_query:time_entries.query"]
|
|
8
|
+
ops: ["autotask_resolve:tickets.findByTicketNumber", "autotask_resolve:tickets.search", "autotask_read:billing_codes.query", "autotask_write:time_entries.create", "autotask_apply", "autotask_query:time_entries.query"]
|
|
9
9
|
inputs: {"type": "object", "required": ["ticket", "resourceID", "dateWorked", "hoursWorked", "summaryNotes", "run_id"], "properties": {"ticket": {"type": ["string", "integer"], "description": "Ticket number (T20260101.0001) or numeric ticket id"}, "resourceID": {"type": "integer", "description": "Numeric id of the resource who did the work"}, "roleID": {"type": "integer", "description": "Numeric role id, when the tenant needs one"}, "dateWorked": {"type": "string", "description": "YYYY-MM-DD"}, "startDateTime": {"type": "string", "description": "ISO 8601 UTC start; Service tickets need it"}, "endDateTime": {"type": "string", "description": "ISO 8601 UTC end; Service tickets need it"}, "hoursWorked": {"type": "number", "description": "Hours, e.g. 1.5"}, "billingCodeID": {"type": "integer", "description": "Active general allocation code (work type)"}, "summaryNotes": {"type": "string", "description": "What was done"}, "run_id": {"type": "string", "description": "The caller's run id: the idempotency key"}}}
|
|
10
10
|
outcomes: ["ok", "needs_input", "needs_approval", "denied", "error"]
|
|
11
11
|
hazards: ["writes one time entry to the PSA", "zone ww29: an API-only user cannot log time (a 500 business-rule error, failed)", "Service tickets refuse hours-only entries: send startDateTime and endDateTime", "ww29 (verified live 2026-10-18): ticketed entries require a roleID and an active general billingCodeID - the vendor refuses missing ones with a deterministic 500 (failed, nothing written)"]
|
|
@@ -23,7 +23,7 @@ eval: evals/log-time-entry.json
|
|
|
23
23
|
|
|
24
24
|
## Steps
|
|
25
25
|
|
|
26
|
-
1. A ticket number: `autotask_resolve {identifier: "<ticket>", resource: "tickets", by: "ticket_number"}`; keep `data.record.id`. A numeric id skips this step.
|
|
26
|
+
1. A ticket number: `autotask_resolve {identifier: "<ticket>", resource: "tickets", by: "ticket_number"}`; keep `data.record.id`. A numeric id skips this step. No ticket named: `autotask_resolve {identifier: "<title words>", resource: "tickets", match: "contains", company_id: <id>}` (compact rows). Zero rows is not proof of absence: `autotask_read {operation: "time_entries.recentTickets", args: [from, to, {resourceID, companyID}]}` lists the tickets the resource logged time on: pick one.
|
|
27
27
|
2. No `billingCodeID` in the task: `autotask_read {operation: "billing_codes.query", args: [{filter: [{field: "isActive", op: "eq", value: true}, {field: "useType", op: "eq", value: 1}], limit: 10}]}`; take `id` from a returned record (never from result prose). None: return `needs_input` for `billingCodeID`. The answer is `data.results[0].result`.
|
|
28
28
|
3. Preview: `autotask_write {operation: "time_entries.create", args: [{resourceID, ticketID: <id>, roleID?, billingCodeID, dateWorked, startDateTime?, endDateTime?, hoursWorked, summaryNotes}], idempotency_key: "<run_id>:time-entry"}`. Nothing is written. Read `status`:
|
|
29
29
|
- `ok`: keep `plan`; go to step 4.
|
|
@@ -70,6 +70,7 @@ The entry read back in step 5 has the ticket id, resource, date and hours of the
|
|
|
70
70
|
| Tool | Operation | Effect | Required args |
|
|
71
71
|
|---|---|---|---|
|
|
72
72
|
| autotask_resolve | tickets.findByTicketNumber | read | value |
|
|
73
|
+
| autotask_resolve | tickets.search | read | query |
|
|
73
74
|
| autotask_read | billing_codes.query | read | - |
|
|
74
75
|
| autotask_write | time_entries.create | write | data |
|
|
75
76
|
| autotask_apply | - | - | - |
|