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 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.23.0`; the committed `pnpm-lock.yaml` pins that
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.23.0`) — never a `file:`, `git:`, `link:` or workspace reference
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.23.0`** (an exact pin —
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.23.0` (published 2026-10-21); the
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": "Who this integration is (identity), the API version, and request thresholds: the tenant-wide vendor count plus this client's local hourly budget. Use it first to check credentials.",
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": 1216,
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 = "95746a69c86daeb7a5d10fa77dd92189c87c8145c547d06275fd0b517b913c9b";
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: 'Who this integration is (identity), the API version, and request thresholds: the tenant-wide vendor count plus this client\'s local hourly budget. Use it first to check credentials.',
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 = '95746a69c86daeb7a5d10fa77dd92189c87c8145c547d06275fd0b517b913c9b';
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).
@@ -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;
@@ -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 three); none takes args',
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 thresholds',
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 && Object.values(data).every((v) => v?.absent !== 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);
@@ -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' ? 'For a contains match pass match: "contains"' : 'For a contains match omit resource and pass match: "contains" (a sweep)';
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');
@@ -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";
@@ -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 only.'),
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 and/or thresholds (default all).'),
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`
@@ -0,0 +1,3 @@
1
+ /** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
2
+ export declare const SERVER_NAME = "iit-mcp-autotask";
3
+ export declare const SERVER_VERSION = "0.12.0";
@@ -0,0 +1,4 @@
1
+ /** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
2
+ export const SERVER_NAME = 'iit-mcp-autotask';
3
+ export const SERVER_VERSION = '0.12.0';
4
+ //# sourceMappingURL=server-version.js.map
@@ -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
- export declare const SERVER_NAME = "iit-mcp-autotask";
31
- export declare const SERVER_VERSION = "0.11.2";
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.11.2";
63
+ readonly version: "0.12.0";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
@@ -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
- export const SERVER_NAME = 'iit-mcp-autotask';
32
- export const SERVER_VERSION = '0.11.2';
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.11.2",
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.23.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.11.2",
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.23.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"
@@ -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 | - | - | - |