iit-mcp-autotask 0.38.0 → 0.38.2
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 +6 -0
- package/dist/tools/dispatch.js +13 -5
- package/dist/tools/lists.d.ts +6 -0
- package/dist/tools/lists.js +19 -5
- package/dist/tools/schemas.d.ts +5 -2
- package/dist/tools/schemas.js +29 -13
- package/dist/tools/server-version.d.ts +1 -1
- package/dist/tools/server-version.js +1 -1
- package/dist/tools/server.d.ts +1 -1
- package/dist/tools/server.js +2 -2
- package/dist/tools/write-guidance.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/company-360.md +1 -1
- package/skills/slim-reads.md +4 -4
- package/skills/unbilled-work-review.md +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,12 @@ examples; `full: true` serves the complete record, `summary: true` about 2 KB),
|
|
|
52
52
|
filter `{field: "ticket.companyID", op: "eq", value: "<id>"}` (also `task.<field>`; `project.<field>`
|
|
53
53
|
where the entity has the FK) scopes them through one parent read of at most 300 ids (more is a typed
|
|
54
54
|
refusal; no parent match is an empty result with a `relationNote`), in rows, counts and totals.
|
|
55
|
+
On a relation target itself (`ticket.<field>` on `tickets`, `task.<field>` on `tasks`,
|
|
56
|
+
`project.<field>` on `projects`) the prefix is dropped: the own field (0.38.1, #234). Rows on
|
|
57
|
+
`tickets` and `time_entries` without `include_fields` carry default columns (18 / 13,
|
|
58
|
+
`DEFAULT_COLUMNS` in `src/tools/lists.ts`; 0.38.1, #235): name any other field (`description`,
|
|
59
|
+
`resolution`, `internalNotes`) in `include_fields`. `output_path` and `autotask_read` of
|
|
60
|
+
`<resource>.query` keep the full row.
|
|
55
61
|
The separate `autotask_aggregate` and `autotask_fetch_many` tools are removed (#228, 0.37.0); a
|
|
56
62
|
mixed-resource batch by id is `autotask_read` `operations.fetchByIds`. The served filter
|
|
57
63
|
schema declares each condition as `{field, op, value}` strings, so a client that builds one type
|
package/dist/tools/dispatch.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { AutotaskConfigError } from 'node-autotask';
|
|
15
15
|
import { stripUntrustedDeep } from 'node-autotask/untrusted';
|
|
16
|
-
import { assertFilterShape, QUERY_LIMIT } from './schemas.js';
|
|
16
|
+
import { assertFilterShape, normalizeFilter, ownField, QUERY_LIMIT } from './schemas.js';
|
|
17
17
|
import { recordById, toolForOp, untrustedFieldsOf } from './catalog-data.js';
|
|
18
18
|
import { AUTOTASK_BASE_PATH } from '../base-hosts.js';
|
|
19
19
|
import { releaseClientOnError } from './context.js';
|
|
@@ -298,14 +298,22 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
|
|
|
298
298
|
if (given2 !== null && typeof given2 === 'object' && !Array.isArray(given2) && typeof given2.latest === 'string' && /^\d{1,9}$/.test(given2.latest)) {
|
|
299
299
|
cleanArgs[0] = { ...given2, latest: Number(given2.latest) };
|
|
300
300
|
}
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
assertFilterShape(
|
|
301
|
+
const given3 = cleanArgs[0];
|
|
302
|
+
if (given3 !== null && typeof given3 === 'object' && !Array.isArray(given3)) {
|
|
303
|
+
assertFilterShape(given3.filter, operation);
|
|
304
|
+
// #234: ticket.<field> on tickets (task., project. likewise) is the own field
|
|
305
|
+
const resource = operation.slice(0, operation.lastIndexOf('.'));
|
|
306
|
+
const bag = { ...given3 };
|
|
307
|
+
if (bag.filter !== undefined)
|
|
308
|
+
bag.filter = normalizeFilter(bag.filter, resource);
|
|
309
|
+
if (Array.isArray(bag.includeFields))
|
|
310
|
+
bag.includeFields = bag.includeFields.map((n) => (typeof n === 'string' ? ownField(resource, n) : n));
|
|
304
311
|
// Slim-read default: a bare *.query read is bounded like autotask_query (explicit limit honoured).
|
|
305
312
|
if (operation.endsWith('.query') && !('limit' in bag)) {
|
|
306
313
|
const latest = bag.latest;
|
|
307
|
-
|
|
314
|
+
bag.limit = Math.max(QUERY_LIMIT.parse(undefined), typeof latest === 'number' ? latest : 0);
|
|
308
315
|
}
|
|
316
|
+
cleanArgs[0] = bag;
|
|
309
317
|
}
|
|
310
318
|
}
|
|
311
319
|
// #56: the contracts.profitability window default. NO opts bag (or a bag with neither date): the
|
package/dist/tools/lists.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { ToolContext } from './context.js';
|
|
2
2
|
import type { BuiltTool } from './surface.js';
|
|
3
|
+
/**
|
|
4
|
+
* #235: the columns a tickets / time_entries row carries when include_fields is absent. A full row is
|
|
5
|
+
* about 4 KB (107 keys with labels) / 2 KB, so 12 tickets cost 50 KB; these cost about a quarter.
|
|
6
|
+
* output_path (a file, not context) and autotask_read of <resource>.query keep the full row.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_COLUMNS: Readonly<Record<string, readonly string[]>>;
|
|
3
9
|
export declare function buildQuery(ctx: ToolContext): BuiltTool;
|
package/dist/tools/lists.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { AutotaskConfigError } from 'node-autotask';
|
|
12
12
|
import { rewriteFieldNameRefusal } from './field-pointer.js';
|
|
13
|
-
import { QUERY_INPUT, QUERY_LIMIT, assertFilterShape, closedInput, normalizeFilter } from './schemas.js';
|
|
13
|
+
import { QUERY_INPUT, QUERY_LIMIT, assertFilterShape, closedInput, normalizeFilter, ownField } from './schemas.js';
|
|
14
14
|
import { abortedAnswer, InputInvalidError } from './dispatch.js';
|
|
15
15
|
import { runAggregate } from './aggregate.js';
|
|
16
16
|
import { markResult, markServerField, ok, sanitiseVendorOutput } from './results.js';
|
|
@@ -26,13 +26,24 @@ const NOUNS = {
|
|
|
26
26
|
contracts: 'contract(s)',
|
|
27
27
|
time_off_requests: 'time-off request(s)',
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* #235: the columns a tickets / time_entries row carries when include_fields is absent. A full row is
|
|
31
|
+
* about 4 KB (107 keys with labels) / 2 KB, so 12 tickets cost 50 KB; these cost about a quarter.
|
|
32
|
+
* output_path (a file, not context) and autotask_read of <resource>.query keep the full row.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_COLUMNS = {
|
|
35
|
+
tickets: ['id', 'ticketNumber', 'title', 'status', 'priority', 'issueType', 'subIssueType', 'ticketType', 'queueID', 'companyID', 'contactID', 'assignedResourceID', 'contractID', 'createDate', 'lastActivityDate', 'completedDate', 'dueDateTime', 'estimatedHours'],
|
|
36
|
+
time_entries: ['id', 'ticketID', 'taskID', 'resourceID', 'dateWorked', 'hoursWorked', 'hoursToBill', 'isNonBillable', 'billingCodeID', 'contractID', 'roleID', 'timeEntryType', 'summaryNotes'],
|
|
37
|
+
};
|
|
29
38
|
export function buildQuery(ctx) {
|
|
30
39
|
return {
|
|
31
40
|
title: 'Query Autotask records with a bounded filter',
|
|
32
41
|
input: closedInput(QUERY_INPUT),
|
|
33
42
|
run: async (raw, signal) => {
|
|
34
43
|
const parsed = raw;
|
|
35
|
-
|
|
44
|
+
// #234: ticket.<field> on tickets (task., project. likewise) is the own field, everywhere a field is named
|
|
45
|
+
const own = (names) => names?.map((n) => ownField(parsed.resource, n));
|
|
46
|
+
const args = { ...parsed, filter: normalizeFilter(parsed.filter, parsed.resource), group_by: own(parsed.group_by), sum: own(parsed.sum), include_fields: own(parsed.include_fields) };
|
|
36
47
|
const operation = `${args.resource}.query`;
|
|
37
48
|
const noun = NOUNS[args.resource] ?? `${args.resource} row(s)`;
|
|
38
49
|
const rec = ctx.record(operation);
|
|
@@ -65,8 +76,11 @@ export function buildQuery(ctx) {
|
|
|
65
76
|
if (args.filter !== undefined)
|
|
66
77
|
bag.filter = args.filter;
|
|
67
78
|
bag.limit = args.limit ?? QUERY_LIMIT.parse(undefined);
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
// #235: no include_fields on tickets / time_entries sends the default columns
|
|
80
|
+
const defaults = args.include_fields === undefined ? DEFAULT_COLUMNS[args.resource] : undefined;
|
|
81
|
+
const includeFields = args.include_fields ?? defaults;
|
|
82
|
+
if (includeFields !== undefined)
|
|
83
|
+
bag.includeFields = [...includeFields];
|
|
70
84
|
if (args.latest !== undefined)
|
|
71
85
|
bag.latest = args.latest;
|
|
72
86
|
if (args.note_chars !== undefined)
|
|
@@ -91,7 +105,7 @@ export function buildQuery(ctx) {
|
|
|
91
105
|
const n = v.count ?? v.total ?? (Array.isArray(v.items) ? v.items.length : undefined);
|
|
92
106
|
const capped = result.noteCharsTruncated;
|
|
93
107
|
// #231: note_chars capping is reported here and by noteCharsTruncated, never as a top-level truncated flag (rows: hasMore)
|
|
94
|
-
const countText = `${typeof n === 'number' ? `${n} ${noun}` : noun} returned.${typeof capped === 'number' ? ` ${capped} text field(s) cut by note_chars.` : ''}`;
|
|
108
|
+
const countText = `${typeof n === 'number' ? `${n} ${noun}` : noun} returned.${typeof capped === 'number' ? ` ${capped} text field(s) cut by note_chars.` : ''}${defaults !== undefined ? ' Default columns: pass include_fields for any other field.' : ''}`;
|
|
95
109
|
const marked = (wrapperDisabled ? result : markResult(result, fields));
|
|
96
110
|
// #227: the server-side enrich is gone; the SDK labels every row by default (<fk>Name, <picklist>Label; names: false skips them).
|
|
97
111
|
// #11 N4: format 'tsv' serves the table as result.tsv and OMITS items (the size saving). The
|
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -45,11 +45,14 @@ export declare const COMPANY_PROFILE_PART_LIMIT_DEFAULT = 10;
|
|
|
45
45
|
* {@link normalizeFilter} drops before dispatch.
|
|
46
46
|
*/
|
|
47
47
|
export declare const FILTER_OPS: readonly ["eq", "noteq", "gt", "gte", "lt", "lte", "beginsWith", "endsWith", "contains", "exist", "notExist", "in", "notIn"];
|
|
48
|
+
/** #234: a `<self>.<field>` name on its own resource without the prefix; any other name unchanged. */
|
|
49
|
+
export declare function ownField(resource: string, name: string): string;
|
|
48
50
|
/**
|
|
49
51
|
* #231: drop the empty `value` a strict form-filler sends on exist/notExist (the advertised schema
|
|
50
|
-
* requires one on every condition)
|
|
52
|
+
* requires one on every condition). #234: a `<self>.<field>` condition on `resource` names the own
|
|
53
|
+
* field ({@link ownField}). Everything else passes through unchanged, groups and the bag form included.
|
|
51
54
|
*/
|
|
52
|
-
export declare function normalizeFilter(filter: unknown): unknown;
|
|
55
|
+
export declare function normalizeFilter(filter: unknown, resource?: string): unknown;
|
|
53
56
|
/**
|
|
54
57
|
* The pre-dispatch filter-shape gate (#18): accepts the two top-level shapes the pinned SDK
|
|
55
58
|
* understands — a conditions array, or a params bag whose `filter` field is a conditions array
|
package/dist/tools/schemas.js
CHANGED
|
@@ -83,15 +83,30 @@ const FILTER = z
|
|
|
83
83
|
type: 'array',
|
|
84
84
|
// op keeps type string beside the enum: a Table client types each column from `type` (#173)
|
|
85
85
|
items: { type: 'object', properties: { field: { type: 'string' }, op: { type: 'string', enum: FILTER_OPS }, value: { type: 'string' } }, required: ['field', 'op', 'value'] },
|
|
86
|
-
// one
|
|
86
|
+
// one filter copied verbatim by form-fillers: the ticket relation on time_entries, the own fields on tickets (#234)
|
|
87
87
|
examples: [[{ field: 'ticket.companyID', op: 'eq', value: '123' }, { field: 'ticket.createDate', op: 'gte', value: '2026-10-01T00:00:00Z' }]],
|
|
88
88
|
})
|
|
89
|
-
.describe('Conditions; write every value as a string ("29", "true"; exist/notExist: ""; in/notIn: "1,2,3"). Dates: UTC ISO-8601. An unknown field name (also group_by, sum) is a pre-query CONFIG_ERROR.
|
|
89
|
+
.describe('Conditions; write every value as a string ("29", "true"; exist/notExist: ""; in/notIn: "1,2,3"). Dates: UTC ISO-8601. An unknown field name (also group_by, sum) is a pre-query CONFIG_ERROR. ticket.<field> scopes time entries by their ticket; on tickets use the plain field.');
|
|
90
|
+
/**
|
|
91
|
+
* #234: the SDK's relation targets. On the target resource itself `<self>.` can only mean an own
|
|
92
|
+
* field (none of them carries a `<self>ID`), so `ticket.companyID` on tickets is `companyID`; the SDK
|
|
93
|
+
* would refuse it as an unknown relation.
|
|
94
|
+
*/
|
|
95
|
+
const SELF_RELATIONS = new Set(['tickets', 'tasks', 'projects']);
|
|
96
|
+
/** #234: a `<self>.<field>` name on its own resource without the prefix; any other name unchanged. */
|
|
97
|
+
export function ownField(resource, name) {
|
|
98
|
+
const prefix = `${resource.slice(0, -1)}.`;
|
|
99
|
+
return SELF_RELATIONS.has(resource) && name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
|
100
|
+
}
|
|
90
101
|
/**
|
|
91
102
|
* #231: drop the empty `value` a strict form-filler sends on exist/notExist (the advertised schema
|
|
92
|
-
* requires one on every condition)
|
|
103
|
+
* requires one on every condition). #234: a `<self>.<field>` condition on `resource` names the own
|
|
104
|
+
* field ({@link ownField}). Everything else passes through unchanged, groups and the bag form included.
|
|
93
105
|
*/
|
|
94
|
-
export function normalizeFilter(filter) {
|
|
106
|
+
export function normalizeFilter(filter, resource = '') {
|
|
107
|
+
if (filter !== null && typeof filter === 'object' && !Array.isArray(filter) && Array.isArray(filter.filter)) {
|
|
108
|
+
return { ...filter, filter: normalizeFilter(filter.filter, resource) };
|
|
109
|
+
}
|
|
95
110
|
if (!Array.isArray(filter))
|
|
96
111
|
return filter;
|
|
97
112
|
return filter.map((c) => {
|
|
@@ -99,13 +114,14 @@ export function normalizeFilter(filter) {
|
|
|
99
114
|
return c;
|
|
100
115
|
const cond = c;
|
|
101
116
|
if (Array.isArray(cond.items))
|
|
102
|
-
return { ...cond, items: normalizeFilter(cond.items) };
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
return { ...cond, items: normalizeFilter(cond.items, resource) };
|
|
118
|
+
const out = { ...cond };
|
|
119
|
+
// a UDF name is a label, never a vendor field: "ticket.X" stays the UDF it names (the SDK refuses a dotted one)
|
|
120
|
+
if (typeof cond.field === 'string' && cond.udf !== true)
|
|
121
|
+
out.field = ownField(resource, cond.field);
|
|
122
|
+
if ((cond.op === 'exist' || cond.op === 'notExist') && cond.value === '')
|
|
123
|
+
delete out.value;
|
|
124
|
+
return out;
|
|
109
125
|
});
|
|
110
126
|
}
|
|
111
127
|
/**
|
|
@@ -287,10 +303,10 @@ export const QUERY_INPUT = z.object({
|
|
|
287
303
|
filter: FILTER,
|
|
288
304
|
limit: z.number().int().min(1).max(2500).optional().describe('Rows returned, 1-2500 (default 50; hasMore flags a cut); with group_by/sum/count: rows scanned (default 2500).'),
|
|
289
305
|
group_by: z.array(z.string().min(1)).max(8).optional().describe('Totals instead of rows: vendor fields to group by (a date takes @month, @week or @year).'),
|
|
290
|
-
sum: z.array(z.string().min(1)).max(8).optional().describe('Numeric fields to total per group.
|
|
306
|
+
sum: z.array(z.string().min(1)).max(8).optional().describe('Numeric fields to total per group. Time: sum and report both hoursWorked (worked) and hoursToBill (billable; can differ).'),
|
|
291
307
|
count: z.boolean().optional().describe('true: count the matching rows, no rows returned.'),
|
|
292
308
|
top: z.number().int().min(1).max(100).optional().describe('Groups kept, 1-100 (default 25).'),
|
|
293
|
-
include_fields: z.array(z.string().min(1)).optional().describe('Exact vendor field names to return (id always included); an unknown name is a pre-query CONFIG_ERROR.'),
|
|
309
|
+
include_fields: z.array(z.string().min(1)).optional().describe('Exact vendor field names to return (id always included); an unknown name is a pre-query CONFIG_ERROR. Absent: tickets and time_entries return default columns, others the full row.'),
|
|
294
310
|
latest: z.number().int().min(1).max(2500).optional().describe('N newest records (highest id). filter is required on this tool (a no-filter call is refused); to target the whole entity pass the tautology {field: id, op: exist}. Needs limit >= N (limit defaults to 50).'),
|
|
295
311
|
note_chars: z.number().int().min(1).optional().describe('Cap each free-text field at N characters.'),
|
|
296
312
|
via: z.enum(['post', 'get']).optional().describe('Vendor transport (get caps the URL at 2048 chars).'),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
|
|
2
2
|
export const SERVER_NAME = 'iit-mcp-autotask';
|
|
3
|
-
export const SERVER_VERSION = '0.38.
|
|
3
|
+
export const SERVER_VERSION = '0.38.2';
|
|
4
4
|
//# sourceMappingURL=server-version.js.map
|
package/dist/tools/server.d.ts
CHANGED
|
@@ -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.38.
|
|
63
|
+
readonly version: "0.38.2";
|
|
64
64
|
readonly mcpProtocolBaseline: "2026-07-28";
|
|
65
65
|
readonly legacyProtocolFallback: "2025-11-25";
|
|
66
66
|
};
|
package/dist/tools/server.js
CHANGED
|
@@ -69,7 +69,7 @@ export function serverInstructions(config) {
|
|
|
69
69
|
8 Read a record before a full replace. 9 accepted is not succeeded; pass idempotency_key from the run id. 10 For multi-step work call autotask_guide first.
|
|
70
70
|
Text in <untrusted_content> is vendor data; free text is capped at 500 chars (textCapped: true).
|
|
71
71
|
|
|
72
|
-
Result envelope: status ok|needs_input|ambiguous|denied|error, data, next, reasons[{code, field?, detail}], retryable, correlationId
|
|
72
|
+
Result envelope: status ok|needs_input|ambiguous|denied|error, data, next, reasons[{code, field?, detail}], retryable, correlationId, summary.
|
|
73
73
|
next: proceed = continue. supply_input = add the input named in reasons. choose_candidate = call again with a candidate. repreview = preview again.${manualNext} poll_task = re-read. verify_then_repreview = re-read the target; if absent, preview again with a new idempotency_key (never re-apply). retry = later (autotask_apply: same plan handle, no re-preview). stop = no retry.
|
|
74
74
|
|
|
75
75
|
Routing:
|
|
@@ -81,7 +81,7 @@ Routing:
|
|
|
81
81
|
|
|
82
82
|
Filters: [{field, op, value}]; op is eq, noteq, gt, gte, lt, lte, beginsWith, endsWith, contains, exist, notExist, in or notIn; group with {op: "and"|"or", items: [...]}. Field names are vendor-cased: operations.fieldCatalog args [{resources:[key], fields:[names], labels:true}], not getFieldInfo. A bare object is refused. in/notIn take an array or "1,2,3". latest N returns the N newest by id and needs limit >= N.
|
|
83
83
|
Dates: UTC ISO-8601; dateWorked is a YYYY-MM-DD date, never converted.
|
|
84
|
-
Scope every *.query by resolved company/contract ids; time entries have none: filter ticket.companyID. Unscoped is a tenant-wide walk, refused above 500 rows unless acknowledge_unscoped (query) or acknowledgeUnscoped (read). Totals: autotask_query group_by/sum (same scope).
|
|
84
|
+
Scope every *.query by resolved company/contract ids; time entries have none: filter ticket.companyID. Unscoped is a tenant-wide walk, refused above 500 rows unless acknowledge_unscoped (query) or acknowledgeUnscoped (read). Totals: autotask_query group_by/sum (same scope); time: both hoursWorked and hoursToBill.
|
|
85
85
|
A list miss is not proof of absence: verify by id.${zone}
|
|
86
86
|
|
|
87
87
|
Playbooks (autotask_guide {skill}):
|
|
@@ -184,7 +184,7 @@ export function fieldSemanticsNote(c) {
|
|
|
184
184
|
const fields = c.fields.filter((f) => typeof f === 'string').sort();
|
|
185
185
|
if (fields.join() !== HOURS_PAIR.join())
|
|
186
186
|
return undefined;
|
|
187
|
-
return '
|
|
187
|
+
return 'Summarising time, report both: hoursWorked is the time worked; hoursToBill is the billable quantity derived from it (rounding, billing rules), so it can differ and stays set on isNonBillable entries. Name each field; never sum one as the other.';
|
|
188
188
|
}
|
|
189
189
|
/**
|
|
190
190
|
* One sentence per SDK `metadata.caveats` kind (closed set; one template each). Unknown kinds,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.2",
|
|
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.39.
|
|
56
|
+
"node-autotask": "0.39.1",
|
|
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.38.
|
|
3
|
+
"version": "0.38.2",
|
|
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",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"capabilitiesManifest": "capabilities.json",
|
|
62
62
|
"sdk": {
|
|
63
63
|
"package": "node-autotask",
|
|
64
|
-
"versionRange": "0.39.
|
|
64
|
+
"versionRange": "0.39.1"
|
|
65
65
|
},
|
|
66
66
|
"vendorExtensions": false,
|
|
67
67
|
"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/company-360.md
CHANGED
|
@@ -28,7 +28,7 @@ eval: evals/company-360.json
|
|
|
28
28
|
Company activity in a period ("tickets for <company> created in the last 4 weeks with status, hours, contract and resources"): step 1, then instead of steps 2-3. `<from>` is a UTC instant (`2026-10-01T00:00:00Z`; a local day starts at its UTC offset, see `autotask_tenant_status` time).
|
|
29
29
|
|
|
30
30
|
4. `autotask_query {resource: "tickets", filter: [{field: "companyID", op: "eq", value: "<id>"}, {field: "createDate", op: "gte", value: "<from>"}], include_fields: ["id", "ticketNumber", "title", "status", "issueType", "contractID", "assignedResourceID"], limit: 500}`. Tickets *created* in the period filter on `createDate`; tickets *worked on* in the period filter on `lastActivityDate` instead. Keep the ticket ids.
|
|
31
|
-
5. Hours
|
|
31
|
+
5. Hours by ticket and resource: `autotask_query {resource: "time_entries", filter: [{field: "ticket.companyID", op: "eq", value: "<id>"}, {field: "ticket.createDate", op: "gte", value: "<from>"}], group_by: ["ticketID", "resourceID"], sum: ["hoursWorked", "hoursToBill"], top: 100}`. Report both sums: `hoursWorked` is the time worked; `hoursToBill` is the billable quantity derived from it (rounding, billing rules; it stays set on non-billable entries), so the two can differ. Time entries carry no companyID: `ticket.<field>` relation filters (`ticket.companyID`, `ticket.createDate`, `ticket.lastActivityDate`, any ticket field) scope them by their ticket (one parent read, at most 300 tickets; more is a typed refusal: narrow the `ticket.*` conditions or use `ticketID in` with step 4's ids). For hours *worked* in the period instead, replace `ticket.createDate` with `{field: "dateWorked", op: "gte", value: "<from date>"}` (a plain YYYY-MM-DD; it does not narrow the parent read). Never a date alone (a tenant walk). Labels (`statusLabel`, `contractIDName`, `resourceIDName`) come on the rows and group keys. `hasMore` or `groupsTruncated` means partial: say so.
|
|
32
32
|
|
|
33
33
|
## Guardrails
|
|
34
34
|
|
package/skills/slim-reads.md
CHANGED
|
@@ -8,14 +8,14 @@ when_not_to_use: the filter behaviour (filter-semantics); records by id (autotas
|
|
|
8
8
|
ops: ["autotask_query:tickets.query", "autotask_query:time_entries.query", "autotask_query:service_calls.query", "autotask_query:contracts.query", "autotask_query:time_off_requests.query"]
|
|
9
9
|
inputs: {"type": "object", "required": [], "properties": {}}
|
|
10
10
|
outcomes: ["ok", "error"]
|
|
11
|
-
hazards: ["
|
|
11
|
+
hazards: ["without include_fields, tickets and time_entries return default columns and other resources the full vendor row (~40 fields per contract, long notes)", "an unknown include_fields name is a zero-query-wire CONFIG_ERROR"]
|
|
12
12
|
related: ["filter-semantics"]
|
|
13
13
|
eval: evals/slim-reads.json
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# Slim reads
|
|
17
17
|
|
|
18
|
-
Without include_fields
|
|
18
|
+
Without include_fields, autotask_query on tickets and time_entries returns default columns (a full ticket row is ~4 KB, the default ~1 KB) and every other resource the FULL vendor row. A field outside the defaults (description, resolution, internalNotes) needs include_fields.
|
|
19
19
|
|
|
20
20
|
## Inputs
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ No inputs: this is a reference page about autotask_query read cost, not a proced
|
|
|
23
23
|
|
|
24
24
|
## Steps
|
|
25
25
|
|
|
26
|
-
1. Name the columns you need on autotask_query: include_fields: ["id", "dateWorked", "hoursWorked", "summaryNotes"] (exact vendor casing; id is always included).
|
|
26
|
+
1. Name the columns you need on autotask_query: include_fields: ["id", "dateWorked", "hoursWorked", "hoursToBill", "summaryNotes"] (exact vendor casing; id is always included). Summarising time, report both hours: hoursWorked is the time worked, hoursToBill the billable quantity (it can differ).
|
|
27
27
|
2. Cap free text: note_chars: 200 truncates each free-text field (internalNotes, summaryNotes, description) with a marker.
|
|
28
28
|
3. Need status or priority names? autotask_read operations.fieldCatalog with resources: [key] and fields: [names] returns picklist labels by default on this server (labels: false opts out); without fields the rows are compact and carry no picklist values: resolve codes once, no second call.
|
|
29
29
|
4. Need names for company, resource or ticket ids, or status labels? They come on the rows by default (companyIDName, statusLabel); names: false skips them.
|
|
@@ -36,7 +36,7 @@ No inputs: this is a reference page about autotask_query read cost, not a proced
|
|
|
36
36
|
|
|
37
37
|
## Verify
|
|
38
38
|
|
|
39
|
-
- The rows carry only the requested columns plus id.
|
|
39
|
+
- The rows carry only the requested (or default) columns plus id.
|
|
40
40
|
- Free-text fields end with the truncation marker when longer than note_chars.
|
|
41
41
|
|
|
42
42
|
## Failure and rollback
|
|
@@ -25,7 +25,7 @@ eval: evals/unbilled-work-review.json
|
|
|
25
25
|
1. A name: `autotask_resolve {identifier: "<company>", resource: "companies"}`; keep `data.record.id`.
|
|
26
26
|
2. `autotask_read {operation: "companies.unbilledWork", args: [<id>, {source: "posted_uninvoiced", includeParent: true, rollup: "ticket", limit: 50}]}`. With a window use `source: "date_range"` plus `dateFrom` and `dateTo`. Other sources: `invoice` (with `invoiceId`), `tickets` (with `ticketIds`). Each part takes a limit in the second positional arg: the pinned SDK's absent-limit runtime default is 25 (at most 500) — pass it explicitly to go deeper, e.g. args: [id, {limit: 500}] for the full per-part sample. The answer is `data.results[0].result`.
|
|
27
27
|
3. Name the tickets of the rollup in one call: `autotask_query {resource: "tickets", filter: [{field: "id", op: "in", value: "<id>,<id>"}], include_fields: ["id", "ticketNumber", "title"], limit: 500}`. An id missing from the rows may be hidden from list queries (a blind surface): read it with `autotask_read {operation: "tickets.get", args: [<id>]}` before calling it missing.
|
|
28
|
-
4. Optional hours per ticket over the window, scoped to this company's rollup tickets (time entries carry no companyID; an unscoped total walks the whole tenant): `autotask_query {resource: "time_entries", filter: [{field: "ticketID", op: "in", value: [<rollup ticket ids>]}, {field: "dateWorked", op: "gte", value: "<dateFrom>"}], group_by: ["ticketID"], sum: ["hoursWorked"], top: 25}`.
|
|
28
|
+
4. Optional hours per ticket over the window, scoped to this company's rollup tickets (time entries carry no companyID; an unscoped total walks the whole tenant): `autotask_query {resource: "time_entries", filter: [{field: "ticketID", op: "in", value: [<rollup ticket ids>]}, {field: "dateWorked", op: "gte", value: "<dateFrom>"}], group_by: ["ticketID"], sum: ["hoursWorked", "hoursToBill"], top: 25}`. Report both: `hoursWorked` is the time worked; `hoursToBill` is the billable quantity derived from it (rounding, billing rules; it stays set on non-billable entries), so the two can differ.
|
|
29
29
|
|
|
30
30
|
## Guardrails
|
|
31
31
|
|