npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

iit-mcp-autotask

v0.58.4

Published

MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK

Readme

iit-mcp-autotask

MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK.

It is an AI-facing capability layer over the SDK — not a raw SDK passthrough: a small, task-oriented tool surface chosen by profile and toolsets, composites that collapse multi-read context into one call, a generic read layer for the long tail, one result envelope for every tool, packaged playbooks, strong security boundaries, and predictable per-tenant in-process state (pooled client + SDK-owned TTL caches).

Tool surface

The core toolset is always served; AUTOTASK_TOOLSETS adds service, records and finance (or all); the operator profile adds a few more. The whole tool surface is listed below (exact per-selection counts: the generated capabilities.json). Every other operation in the SDK's operation registry stays reachable through the generic layer: autotask_catalog finds the key, autotask_describe serves its contract (slim by default: transport and retry detail dropped, long text cut, the first two examples; full: true serves the complete record, summary: true about 2 KB), autotask_read runs it.

| Toolset | Tools | |---------|-------| | core (always) | autotask_guide · autotask_catalog · autotask_describe · autotask_read · autotask_resolve · autotask_query · autotask_tenant_status · autotask_ticket_full_context · autotask_company_full_profile · autotask_resource_workload | | service | autotask_project_get_context · autotask_service_call_full_profile | | records | autotask_contact_full_profile · autotask_configuration_item_profile · autotask_contract_full_profile | | finance | autotask_quote_get · autotask_invoice_get (format: record, html, xml, pdf) · autotask_revenue_rollup · autotask_portfolio_margin · autotask_recurring_revenue · autotask_ar_aging · autotask_wip_hours · autotask_non_chargeable_work | | operator (operator profile) | autotask_write (preview, changes nothing) · autotask_apply (execute the previewed plan) · autotask_apply_destructive (execute a plan that hits a hard rule) |

  • autotask_resolve turns any identifier into a record: an id or name, by: "ticket_number", "serial" or "email", in one resource or swept across the five workflow resources; the default match: "auto" tries exact, then contains on a miss for companies, tickets (title) or a sweep (one hit: ok with matched: "contains"; several: ambiguous), match: "exact" has no fallback, match: "contains" searches only. resource: "resources" finds staff by email, user name or name (active only; API users, which carry their owner's email, are excluded unless include_api_users: true; by: "email" narrows to the email field; a numeric identifier is looked up as a Resource id); compact rows carry apiUser and the licence label when the tenant's labels tell it, never an email. Every match carries ref, its human reference, as safe text: control and markup characters (< > { } [ ] \ " | \) dropped, whitespace collapsed, at most 80 characters (a cut ends with …), never wrapped; still vendor-authored data (#230). A sweep returns compact matches (resource, ref, id, the human fields) unless expand: true(the record underrecord, without its [REDACTED], null and empty fields; expand: "full"keeps them);limitcaps the TOTAL matches across resources (resource order, then the SDK's order) and a contains sweep reportstruncatedand per-resourcecounts {returned, found}`.
  • autotask_query is the bounded filtered list over resource: any resource with a <resource>.query read (tickets, time_entries, service_calls, contracts, time_off_requests, companies, billing_items, ...; limit default 50, max 2500). With group_by, sum or count: true it answers totals instead of rows (the SDK's operations.aggregate; top 1-100 groups, limit up to 2,500 scanned rows; hasMore / groupsTruncated mean partial). filter is not schema-required (a form-filler dead-ends on a required Table, #241) but a missing or empty one is refused (INPUT_INVALID, rows and totals); {field: "id", op: "exist"} targets the whole entity. Records by id: filter: [{field: "id", op: "in", value: "1,2,3"}] (in / notIn take an array or a comma string). Time entries have no companyID: the relation filter {field: "ticket.companyID", op: "eq", value: "<id>"} (also task.<field>; project.<field> where the entity has the FK) scopes them through one parent read of at most 300 ids (more is a typed refusal; no parent match is an empty result with a relationNote), in rows, counts and totals. On a relation target itself (ticket.<field> on tickets, task.<field> on tasks, project.<field> on projects) the prefix is dropped: the own field (0.38.1, #234). Rows on tickets and time_entries without include_fields carry default columns (18 / 13, DEFAULT_COLUMNS in src/tools/lists.ts; 0.38.1, #235): name any other field (description, resolution, internalNotes) in include_fields. output_path and autotask_read of <resource>.query keep the full row. The separate autotask_aggregate and autotask_fetch_many tools are removed (#228, 0.37.0); a mixed-resource batch by id is autotask_read operations.fetchByIds. The served filter schema declares each condition as {field, op, value} strings, so a client that builds one type per array column writes "29", "true", "2026-10-22"; numbers, in arrays and and/or groups are still accepted and passed through unchanged.
  • autotask_read runs any read by exact key, or up to 10 at once (items). Unbilled work (companies.unbilledWork), contract profitability (contracts.profitability), aggregates (operations.aggregate) and SLA health (tickets.slaHealthCheck) run here; autotask_guide holds their recipes. Its inputs are generic (operation, args, items): no operation-specific fields, because form-filling clients (Copilot Studio) fill every optional input they are shown. A client that cannot send a mixed-type array asks for a time entry window over a resource with autotask_query time_entries (resourceID eq, dateWorked gte/lte, every value a string: the served filter schema types value as a string). autotask_read answers ONE envelope: data = { results: Slot[], ok, failed, budget?, budgetWarning? }, an array in input order even for a single read (data.results[0].result). A slot is { ok: true, operation, result, windowNote?, scopeNote? } or { ok: false, operation, error }. A single read that fails still answers the whole-call error envelope.
  • autotask_guide serves the packaged playbooks — the server's skill layer (below).

autotask_guide: the packaged playbooks (the server's skill layer)

The outcome playbooks ship with the server — skills/*.md, part of the npm tarball — and are served by autotask_guide as static text: it never interpolates API data and never calls the vendor, so loading a playbook costs no wire and adds no injection surface.

  • No args: the index (one line per skill).
  • {skill}: one playbook in full (frontmatter included).
  • {skill, section}: one of its ## sections.

Skills: ticket-triage, company-360, unbilled-work-review, contract-profitability, revenue-rollup, portfolio-margin, recurring-revenue, ar-aging, wip-hours, non-chargeable-work, invoice-export, filter-semantics, slim-reads, and on the operator profile log-time-entry and crm-management.

The playbooks are discoverable in both directions:

  • The guide is the only tool that carries _meta: { "anthropic/alwaysLoad": true }, so hosts that defer tool loading still surface it — it is the entry point to the recipes.
  • Each skill's frontmatter ops list (<tool>:<operation> entries) feeds a reverse index: autotask_describe for an operation answers with the playbooks[] that use it, so a model that lands on an operation is pointed at the recipe.

Composites bound each part independently (every part reads its own declared limit argument; autotask_company_full_profile defaults its contact, note, open-ticket and project parts to 10 rows each, capped: true says more exist; fields projects columns per part); a part miss never fails the call (typed absent); only a missing root record fails the composite.

Every result is one envelope (structuredContent, mirrored as JSON in the first text block):

{ status: ok|needs_input|ambiguous|denied|error,
  data, next: proceed|supply_input|choose_candidate|...|retry|stop,
  reasons: [{ code, field?, detail }], retryable, correlationId, summary }
// status ok omits next, reasons, retryable and correlationId

isError is set exactly for denied and error; on an error data is the typed error payload (code, category, message, suggestedAction, ...). Reason codes come from a fixed list (src/tools/reasons.ts). Vendor free text is wrapped in <untrusted_content>, stripped of every control (except newline and tab), format and default-ignorable character (bidi, zero-width, tag characters, variation selectors) and capped at 500 characters (textCapped: true on the envelope; row cuts are hasMore); the wrapper is the injection control — the angle-bracket escape earlier builds added was removed in 0.9.0 (issue #70): it was pure cost for LLM consumers and broke read-modify-write round-trips, so wrapped text carries raw &/</> and is data, never instructions. User-defined field values get the same treatment; output_path files keep the full text. Never copy a truncated read value into a write, or its wrapper tags, or a typed HTML entity sequence (&lt; &gt; &amp;): the write is refused (INPUT_INVALID). Since 0.9.0 read results carry vendor text exactly as stored, so the gate is a copy-detect that cannot tell a genuinely typed sequence from one the vendor value really contains — if the stored value carries such a sequence, leave the field out (the update keeps the record's own value) or send a new value. One round-trip boundary to know: a vendor value that literally stores the wrapper tags in this server's exact spelling gets its one outer pair dropped on the way back out (the documented "one outer pair dropped" rule applies to writes too) — such a value cannot round-trip its tags; leave the field out or send a new value if the tags must be preserved.

Machine-readable catalogue (generated from the surface): capabilities.json · server metadata: server.json · per-workflow mapping: docs/capability-matrix.md · full design: docs/architecture.md · contract P&L (the manual recipe behind contracts.profitability): docs/contract-economics.md · company-location fields on writes: docs/company-location-fields.md.

Profiles and writes

| Variable | Default | Effect | |----------|---------|--------| | AUTOTASK_PROFILE | readonly | readonly: reads only, no state. operator: adds autotask_write, autotask_apply and autotask_apply_destructive; see Operator profile. On HTTP it requires AUTOTASK_HTTP_AUTH=tokens or gateway. | | AUTOTASK_TOOLSETS | — (core only) | comma list of service, records, finance, or all; an unknown name fails boot |

Every write and delete is refused on the read path (autotask_read) before any request (POLICY_DENIED, status denied), on every profile. authenticate.* returns a live vendor token (credential-minting) and is never served, on any profile, through any tool. The 0.5.x variables AUTOTASK_READ_ONLY, AUTOTASK_WRITE_POLICY, AUTOTASK_WRITE_ALLOW and AUTOTASK_SECRET_READS were removed: they are ignored (the server logs a warning naming any unrecognised AUTOTASK_* variable at boot), so the profile governs.

Operator profile (writes)

Writes are two calls. autotask_write {operation, args, reason?} previews: it reads the target live, runs the SDK dry run, evaluates the policy and returns a decision, a diff and, when allowed, a plan handle (pln_..., valid 15 minutes). Nothing is written. autotask_apply (or autotask_apply_destructive for a plan that hits a hard rule; the preview's apply names it) with {plan, operation, targets, summary} executes exactly that plan (the last three copied verbatim from the preview's approval, or PLAN_MISMATCH), after checking that nothing it depends on has changed. Targets are numeric ids, never names (resolve a name with autotask_resolve first). Applying the same plan again replays the recorded outcome instead of writing twice.

An operator deployment needs:

| Variable | Meaning | |----------|---------| | AUTOTASK_PROFILE=operator (or an operator token) | the profile | | AUTOTASK_WRITE | which writes are served and how, per operation; unlisted is read-only. stdio: confirm, allow, unattended or deny. HTTP: allow or deny only (grammar in docs/write-gate.md) | | AUTOTASK_STATE_DIR | mode 0700; holds encrypted plans, the idempotency ledger, the append-only JSONL audit log, the primary lock and the holder registry (many processes may share one dir — see below) | | AUTOTASK_RUN_SCOPE | {"companyIds":[...]} or {"all":true}: the run scope on stdio and in gateway mode (one scope shared by every gateway principal). In tokens mode each operator token carries runScope instead | | AUTOTASK_PRINCIPAL | stdio: the name plans and the audit log are bound to (default stdio) | | AUTOTASK_KILL_FILE | optional: while this file exists every preview and apply is denied (default <state dir>/KILL) |

What the write access can and cannot do (full reference: docs/write-gate.md):

  • AUTOTASK_WRITE lists operations, families (tickets.*) or *, each with a mode (stdio: confirm, allow, unattended or deny; HTTP: allow or deny). Unlisted writes are OP_NOT_ALLOWED. AUTOTASK_DEDUP names duplicate-check fields for creates. A bad entry fails boot; the former gate mode is refused.
  • The transport decides who asks the person. stdio: this server, through MCP form elicitation: confirm asks for every write, allow only for hard rules (SDK requiresApproval or sensitive operations, deletes, webhook writes, moves across companies or contracts), unattended never (hard rules waived, with a boot warning). The form's remember choice (once, this operation, or all) skips later forms for the rest of the process, at most 8 hours; the kill file (at the first preview or apply that sees it) and any policy change end it. A client without form support gets GATE_UNSUPPORTED, decline or dismissal GATE_DECLINED. HTTP: the server never asks; every admitted write runs (approverId: client) and the MCP client or gateway owns approval, gating by tool name: hard-rule plans apply only through autotask_apply_destructive. confirm and unattended refuse boot on HTTP.
  • Move a time entry or ticket charge with time_entries.moveToTicket or ticket_charges.moveToTicket, args [sourceId, toTicketId]. The SDK copies, verifies, then deletes the source; the new record gets a new id and history, and notifications may fire. Billed, attached, approved, closed-target and old sources are refused by the SDK. Follow data.move.manualAction when a person must delete the source. A partial or unknown move is never blindly retried: check both tickets in Autotask. The server retains write-ahead/replay protection, but no separate move recovery snapshot or moves list/resolve command. Deletes preview through autotask_write.
  • The company scope is read from the live records the write touches (the target, its parent, the ticket, project, task or contract it references, and any companyID it sets), never from names or titles. A reference that cannot be read is refused.

Every string in a write payload is NFC-normalised after the unsafe-character strip and before the plan hash and the send (issue 59): the vendor stores a precomposed é (U+00E9) unchanged, but a decomposed e plus a combining acute (U+0301) as a different character (a spacing acute, U+00B4), so decomposed input would not round-trip. The plan hash binds the normalised payload and the preview diff shows exactly what is sent. A string that still carries a combining mark with no precomposed form after NFC (for example a lone U+0301) is refused INPUT_INVALID: send the precomposed character.

Outcomes the caller acts on: ok; needs_input/supply_input (missing names the field); denied/stop; STATE_DRIFT, PLAN_EXPIRED or PLAN_MISMATCH → repreview; a failed write: WRITE_FAILED with stop (a deterministic rejection; it replays, fix the input and preview the corrected payload) or RATE_LIMITED with retry (nothing was written: apply the same plan again after the wait); a vendor that accepted an update but kept the old value (FIELD_NOT_PERSISTED with stop: the apply reads the stored record back and compares every field you sent; the write itself is succeeded, data.record shows what is stored and data.notPersisted names the fields — the apply re-reads twice, after waiting 0.75 s, then a further 1.5 s, before concluding, because the vendor read path can lag its write; re-read the record before repeating the update; a replay answers the same); an unknown outcome (WRITE_OUTCOME_UNKNOWN, verify_then_repreview): an identical plan is never sent again, even when previewed under a new handle. Re-read the target and verify in Autotask. A changed payload or changed stable vendor facts creates a different plan; it still waits for the 10-minute target cooldown after an unknown outcome. Payload field names are matched case-insensitively, and keys that differ only by case, or are not plain ASCII field names, are refused. STATE_UNAVAILABLE means the state dir could not be checked (retry) or the server's own registration in the state dir was removed (stop; see below). Use a separate Autotask API user for operator deployments, and run the live write leg on throwaway data before relying on it (B7 in the 0.6.0 plan).

Upgrade note (#250): stop all processes sharing a state directory before upgrading and restart all on the same version. Mixed old/new versions are unsupported. Old pending plans must be previewed again. Existing ledger entries and historical chained audit lines remain on disk; new audit lines are plain JSONL. Old move snapshot files are inert and are not automatically deleted.

The state lock: recovery

Many processes may share a state dir (issue #68): every process registers its boot in a holder registry (<state dir>/holders/<boot> plus a 5 s lease) before any other state work, and a per-operation lock (<state dir>/op.lock) serialises the short shared-state critical sections — the boot ledger rewrite, an apply's claim (the fresh ledger scan + the write-ahead sending line), the apply's result append and every audit append. The dir still names ONE primary in <state dir>/lock (host, pid and a per-boot id): a boot that finds a LIVE primary (same host, a fresh lease) no longer refuses — it boots as a SECONDARY and shares the dir under the op lock.

The primary keeps the #55 recovery machinery unchanged. The primary refreshes <state dir>/lock.lease every 5 s, and a lease older than 15 s (3 refresh cycles) is expired. The succession rules:

  • A live primary is never taken: a FRESH lease vetoes any takeover, whatever the pid probe says. A secondary boot under a live primary simply registers and shares; it never touches the lock.
  • After a crash, a SIGKILL or an OOM kill the next boot takes the lock over by itself when the old holder is gone, including a container where node is PID 1 again (the boot id tells the runs apart).
  • A container replacement needs no manual unlock when the replacement container keeps the same hostname — a fixed hostname in the compose / docker run config: in the replacement container the old holder's pid can still "answer" (the new pid namespace recycled the number), but its lease is expired 15 s after the old container stopped, so the next boot takes the lock over on its own. With a new hostname (the docker default: the container id) the R6 host refusal applies and the operator deletes <state dir>/lock (the case below). Every takeover is appended to the audit.jsonl log as a lock_takeover line, and /ready reports the lock's age, holder id, lease freshness and the holder count, so an orchestrator can tell a stale lock from a live one.

Boots sweep the dead registrations, and a sending ledger entry becomes unknown only when its origin is not a live holder: a live peer's in-flight write stays sending (an apply of the same key replays it, it is never re-sent), while a dead peer's — or a pre-0.8.0, unstamped — entry becomes unknown exactly as in the single-process case.

Cases that need an operator:

  • locked by host H (pid N); one container per state dir: the lock belongs to another host or container, whose pids mean nothing here, so it is never taken over automatically. Make sure the server on H is stopped (or the container is gone), then delete <state dir>/lock and start again. After replacing a container on a persistent volume this happens once, unless the container keeps a fixed hostname.
  • lock.lease exists but is not a readable lease: the lease file is corrupt (our writes are atomic renames, so this is not a crash remnant of our own). Make sure no server is running on this state dir, delete <state dir>/lock.lease, and start again.
  • another process is taking over the lock (<state dir>/lock.takeover): a boot crashed in the middle of a takeover, a disk error hit mid-create, or two boots raced and the wait ran out. Make sure no other server is starting on this state dir, delete <state dir>/lock.takeover, and start again.
  • <state dir>/lock disappeared or is unreadable / names no pid: the lock file is gone or corrupt (our writes are atomic, so this is not a crash remnant of our own). Make sure no server is running on this state dir, delete <state dir>/lock (and lock.lease), and start again.
  • the op lock could not be taken at boot: …: the per-operation lock was already stuck when this boot started (the op.lock case below, seen at boot). Make sure no server is running on this state dir, delete <state dir>/op.lock (and op.lock.lease), and start again.
  • op.lock names no pid / a peer process holds <state dir>/op.lock; the wait ran out: the per-operation lock is stuck — a boot crashed mid-operation and the 10 s wait ran out, or the file is corrupt. Make sure no server is running on this state dir, delete <state dir>/op.lock (and op.lock.lease), and start again.

Never delete a running server's registration (<state dir>/holders/<boot>) while it is running: writes stop (STATE_UNAVAILABLE) the moment the server notices the file is gone, and it stops refreshing the registration's lease. Deleting the primary lock out from under a running primary is not a stop signal (the next boot simply takes the lock over) — the registration is the stop signal. Keep the rest of the state dir; audit.jsonl is the write history.

Query results: TSV, output_path and the saved-file shape

Name and label siblings are enabled by default on SDK-backed reads and the four SDK composites. A supported per-call names: false disables them; composite max_name_reads still lowers the SDK name-read budget.

autotask_query always wraps untrusted text and uses the SDK's default transport and labels; via, names, and untrusted_wrapper are refused as unknown inputs.

autotask_query answers data.result with the SDK's bounded-query envelope:

{ items, fetched, hasMore, pagesWalked }   // + blindSurface / emptyReason / noteCharsTruncated when stamped

items holds the vendor records (free text wrapped in <untrusted_content> markers), fetched is items.length, hasMore flags a truncated walk — the limit or the 1000-page guard on a normal query, or the output_path 10,000-row cap — and pagesWalked counts the pages fetched. count is never served — the vendor documents it as the CURRENT page's count, so a total rides <resource>.count.

Saved files are the host's job. Unless you ask for output_path (below), this server never writes a result file on its own: when a result is too large, the MCP host persists the oversized tool result, and the file it writes holds this same envelope as JSON — so a saved result can be read back with exactly the fields above.

Two per-call options change the result form:

| Option | Effect | |--------|--------| | format: 'tsv' | serves the table as data.result.tsv — a header row of column names plus one tab-separated line per record — with result.columns and result.rowCount, and omits result.items (that omission is the size saving; the JSON form stays the default). Inside a value a literal backslash is written as \\, a tab as \t, a newline as \n and a carriage return as \r; the <untrusted_content> markers survive inside cells. | | output_path: '<relative path>' | stdio transport only: walks the FULL match set with the resource's own query iterator, writes the envelope JSON to a file under AUTOTASK_OUTPUT_DIR, and returns a summary (outputPath, columns, rowCount, the first rows, bytes) instead of the table. |

output_path is deliberately strict: the deployment sets an absolute AUTOTASK_OUTPUT_DIR that already exists and is not the filesystem root (a configured root that is missing is refused — the server never creates it). When it is unset the root defaults to output/ under AUTOTASK_STATE_DIR (the existing, operator-configured state dir; the server creates that one subdirectory and nothing above it; with neither variable set the feature refuses). format: 'tsv' with output_path writes the TSV table to the file (the result carries the path, never the table); latest and note_chars still cannot combine with it. And the value must be a path relative to that root that names a file (a trailing separator, an absolute path, a NUL byte, and a target that is an existing directory are all refused). An existing file is never overwritten: the call is refused before any vendor request, naming the path (pass a new path or remove the old file). Nothing outside the root is ever created, file or directory: the deepest existing ancestor of the target is canonicalised (realpath) and checked against the resolved root before any directory is created, so a symlinked parent — even with a deep sub-path — is refused with zero filesystem effect; only then are the missing directories made and the resolved parent re-verified. The write itself re-enforces both rules at write time (validation and the write are separate steps): it re-resolves the parent — which must still be inside the root — and opens with O_EXCL | O_NOFOLLOW, so anything swapped in at the target after validation (a symlink, a file, a directory) is refused, never followed or truncated. A dangling symlinked parent is the same typed refusal, and every filesystem failure crosses as that typed refusal naming the error code, never an OS message (those embed the caller's path). The root itself may be a symlink — containment is then evaluated against its canonical target. Under stdio the caller already owns the filesystem, so this is containment (a caller path can never make the server create or write outside the root), not a defence against a same-user process racing the server. A hard link at the target path is an existing entry, so O_EXCL refuses it too.

The walk stops at 10,000 rows with an explicit truncation note in the summary (hasMore: true then means the cap, not only the query limit). The call is refused outright on the http transport (the caller is a request, not the process owner), and it cannot be combined with latest or note_chars — one result form per call.

The invoice export is the second output_path file kind (issue #54): autotask_invoice_get with format html/xml/pdf writes the RAW vendor document (UTF-8 text for html/xml, the decoded bytes for pdf) under the same root rules — never a JSON envelope, never a cleaned form — and the result carries only outputPath, bytes and encoding. Without output_path the export crosses in-band as the base64 of the raw document (result.data decodes to the exact vendor markup); a pdf past the vendor 1 MiB inline limit is truncated and carries no data (and no file).

Labels come with the rows (node-autotask 0.38.0, #227): query rows, group_by keys and fetched records carry SDK label siblings by default: picklists as <field>Label (statusLabel), ids as <field>Name (companyIDName, contractIDName, resourceIDName, ticketIDName, ...; assignedResourceIDName replaces the removed assignedResourceName alias). Each name is capped at 200 characters and wrapped once as untrusted content. Sibling keys are present with null for absent or unresolved values; companyID: 0 resolves the tenant owner company, while other foreign-key zero values remain null. A names note says when the SDK's cold-read cap left something unlabelled; names: false turns labels off. The server-side enrich parameter is removed (0.37.0): a call that still sends it is refused as an unknown input; read the SDK label instead (companyIDName for the old companyName, resourceIDName for resourceName, ticketIDName for ticketName).

Transports

| Transport | For | Caller auth | Autotask credential | |-----------|-----|-------------|---------------------| | http (Streamable HTTP, /mcp; the CLI default, on 127.0.0.1) | a gateway / federation, remote MCP clients | AUTOTASK_HTTP_AUTH=gateway (the gateway authenticates and rate-limits) or tokens (this process checks bearer tokens and rate-limits per principal); required off loopback | per request: X-Autotask-* headers (injected by the gateway); a server-held tuple only with tokens | | stdio (--transport stdio) | Claude Code, Claude Desktop, VS Code | none — the process boundary is the trust boundary | environment: AUTOTASK_* variables |

/mcp validates Host and Origin before the MCP handler (403 on a disallowed value; a request without an Origin passes) and caps request bodies at 1 MiB (413). On loopback the localhost names apply; off loopback AUTOTASK_HTTP_ALLOWED_HOSTS is required.

Deployment

Gateway (http, container)

Run behind the MCP gateway on a private, unpublished network (the gateway terminates TLS). The gateway owns client authentication, rate limiting, per-tenant tool filtering and credential injection into the X-Autotask-* headers. The server is tenant-agnostic: one deployment serves every Autotask tenant; the zone host and identity travel per request.

docker build -t iit-mcp-autotask .
docker run -d --name iit-mcp-autotask \
  -p 8080:8080 \
  -e AUTOTASK_HTTP_ALLOWED_HOSTS=mcp.internal.example \
  -e AUTOTASK_ALLOWED_BASE_HOSTS=*.autotask.net \
  -e AUTOTASK_BASE_URL=https://webservices6.autotask.net \
  -e AUTOTASK_TOOLSETS=all \
  iit-mcp-autotask

The image binds 0.0.0.0 with AUTOTASK_HTTP_AUTH=gateway; AUTOTASK_HTTP_ALLOWED_HOSTS (the Host names the gateway uses to reach it) is required at run time. Env-only configuration: no credentials are baked into the image. SIGTERM reaches the node process directly and triggers graceful shutdown. The HEALTHCHECK probes /health (liveness only — it never calls Autotask).

Without a gateway use tokens mode: AUTOTASK_HTTP_AUTH=tokens and AUTOTASK_HTTP_TOKENS_FILE, a JSON file mapping the lowercase hex SHA-256 of each bearer token to { "principal": "...", "profile": "readonly" | "operator", "toolsets": [...], "runScope": {...} } (an operator token needs runScope: {"companyIds":[...]} or {"all":true}). Each token sees its own tool list; a missing or unknown token is 401; each principal gets AUTOTASK_HTTP_RATE_PER_MIN requests a minute (default 60, then 429 with Retry-After). The static bearer map does not implement the MCP authorization spec's RFC 9728 metadata (deviation D-14). For an operator deployment use a separate Autotask API user from the read deployments.

An optional token field "writeOperations": ["time_entries.updateReviewFields", "ticket_notes.create"] restricts that token to these exact served write keys, intersected with AUTOTASK_WRITE. It cannot grant globally denied operations or upgrade a readonly profile. Omit it to preserve existing authority; [] denies all writes. Wildcards, unknown/read operations and duplicates refuse boot. Catalog, describe, preview and apply share this restriction; changing it invalidates pending plans. Successful historical replays only return their recorded result. These limits do not change read access or company scope.

Local (stdio)

Needs Node 24+ and your Autotask zone origin. The CLI defaults to http, so local clients pass --transport stdio.

From npm:

claude mcp add autotask \
  --env AUTOTASK_API_INTEGRATION_CODE=... \
  --env AUTOTASK_USERNAME=... \
  --env AUTOTASK_SECRET=... \
  --env AUTOTASK_BASE_URL=https://webservices6.autotask.net \
  -- npx -y iit-mcp-autotask --transport stdio

or in .mcp.json / Claude Desktop config:

{
  "mcpServers": {
    "autotask": {
      "command": "npx",
      "args": ["-y", "iit-mcp-autotask", "--transport", "stdio"],
      "env": {
        "AUTOTASK_API_INTEGRATION_CODE": "...",
        "AUTOTASK_USERNAME": "...",
        "AUTOTASK_SECRET": "...",
        "AUTOTASK_BASE_URL": "https://webservices6.autotask.net",
        "AUTOTASK_TOOLSETS": "all"
      }
    }
  }
}

npx fetches the package on first run and caches it; pin with iit-mcp-autotask@<version>. From a clone: pnpm i && pnpm build, then point the client at node /abs/path/iit-mcp-autotask/dist/main.js --transport stdio.

Endpoints

| Endpoint | Purpose | Auth | |----------|---------|------| | POST /mcp | the canonical MCP endpoint (Streamable HTTP transport); Host/Origin validated, 1 MiB body cap | a bearer token in tokens mode; per-request X-Autotask-* headers for tool calls | | GET /health | process alive — pre-auth, no vendor call (container healthcheck) | none | | GET /ready | config valid, SDK plan-hash pin checked, server initialised, client factory ready — no live vendor call (credentials are request-scoped). In an operator deployment the payload also carries lock (the state lock's age, holder id, lease freshness and the holder count — see "The state lock: recovery") | none | | GET /doctor | zero-wire credential/config preflight: per credential field present/len/prefix2/shapeOk (AUTOTASK_SECRET's prefix is masked to [REDACTED] — no character of a secret is ever emitted; identifiers keep their format prefix), the base-url + allow-list resolution (default_only / allow_list / default_and_list / any), the SDK plan-hash gate status and the host-policy mode — no vendor call, by construction | Host/Origin validated; a bearer token in tokens mode | | GET /metadata | server identity + the MCP protocol era pair (baseline 2026-07-28 + legacy fallback 2025-11-25) + capability-manifest pointer + the deployment block (profile, toolsets, caller auth, rate limiting) — the served payload is the repo's server.json + build stamp | Host/Origin validated; a bearer token in tokens mode |

Configuration

Copy .env.example. Every variable is Zod-validated at boot; the process exits non-zero on an invalid config (fail-fast) instead of failing at the first tool call.

| Variable | Required | Default | Meaning | |----------|----------|---------|---------| | MCP_TRANSPORT | no | http | stdio or http; the CLI --transport flag overrides | | AUTOTASK_PROFILE | no | readonly | readonly or operator (see Profiles and writes) | | AUTOTASK_WRITE | operator | — | per-operation mode: stdio confirm/allow/unattended/deny, HTTP allow/deny; on stdio hard rules ask a person unless unattended, on HTTP the client gates (see docs/write-gate.md) | | AUTOTASK_DEDUP | no | — | create duplicate-check fields, e.g. companies.create:companyName | | AUTOTASK_EXPENSE_REPORT_SUBMITTER_PIN | no | — | one resource id admitted for a company-less expense_reports.create (see write-gate.md) | | AUTOTASK_STATE_DIR | operator | — | the operator state dir (0700) | | AUTOTASK_RUN_SCOPE | operator on stdio or gateway | — | {"companyIds":[...]} or {"all":true} | | AUTOTASK_PRINCIPAL | no | stdio | the stdio operator principal | | AUTOTASK_KILL_FILE | no | <state dir>/KILL | the kill switch file | | AUTOTASK_USER_TIMEZONE | no | UTC | IANA time zone of the people asking (their local "today"), not the Autotask tenant's or the server's zone; region/city names only (e.g. Australia/Melbourne), kept as written with only the case of a listed name fixed (Europe/Kyiv stays Europe/Kyiv); utc, Etc/UTC and GMT mean UTC; fixed offsets (+10:00, Etc/GMT-10) and names without a region (EST, Japan) fail boot. Autotask stores UTC; the zone is told to clients (autotask_tenant_status time part: zone, local time, UTC time, offset; one line in the server instructions when not UTC) so "today" and "yesterday" become UTC bounds in the right zone for moment fields such as ticket creation and due times. Time entries are calendar days: dateWorked and time_entries.dateWorkedWindow take plain YYYY-MM-DD, not UTC bounds. Filter values are never converted. | | AUTOTASK_TOOLSETS | no | — | service, records, finance (comma list) or all; core is always served | | AUTOTASK_API_INTEGRATION_CODE | stdio: yes · http: no (discouraged default) | — | the ApiIntegrationCode credential (stdio env; http: optional discouraged server default, presented only to the default origin) | | AUTOTASK_USERNAME | stdio: yes · http: no (discouraged default) | — | the UserName credential (same semantics) | | AUTOTASK_SECRET | stdio: yes · http: no (discouraged default) | — | the Secret credential (same semantics) | | AUTOTASK_IMPERSONATION_RESOURCE_ID | no | — | act as another resource in the tenant (ImpersonationResourceId header) | | AUTOTASK_BASE_URL | stdio: yes · http: no (default origin) | — | the zone origin, e.g. https://webservices6.autotask.net (zone lives in the host; base path /atservicesrest is fixed — never configurable) | | AUTOTASK_ALLOWED_BASE_HOSTS | http: unless AUTOTASK_ALLOW_ANY_BASE_HOST=true | — | comma-separated exact hostnames or *.suffix subdomain suffixes a caller may name in X-Autotask-Base-Url (no scheme/port/path). Unset + default origin set → only that origin accepted. Unset + no default → boot refused unless the lab-only flag. Recommended production value: *.autotask.net. Literal private-IP origins are always refused. | | AUTOTASK_ALLOW_ANY_BASE_HOST | no | false | lab-only opt-in to boot with neither allow-list nor default origin; warn-logged at boot (decision D-4); refused with the operator profile. Not set in production. | | AUTOTASK_CACHE | no | on | the single off switch for the SDK's per-tenant in-process caches (see Per-tenant pool & cache): on (default) sends no cache block — the SDK's default-on caches apply to each pooled tenant client (read ~5 min + the reference tables at 1 h, field metadata ~5 min); off sends cache: false (every read re-dials the vendor) | | AUTOTASK_OUTPUT_DIR | no | unset | the ONE directory the output_path option may write into (the bounded query tools and the invoice export; stdio transport only). Unset = defaults to output/ under AUTOTASK_STATE_DIR (the feature refuses when neither is set); must be absolute, must already EXIST (the server never creates the root) and must not be the filesystem root. A caller path is relative to this root, and the root and target are canonically resolved (realpath) so a traversal or symlink escape is refused — with nothing created outside the root. See Query results | | AUTOTASK_RATE_LIMIT_PER_HOUR | no | 8000 (agent profile) | override the SDK's local hourly budget (never above the vendor cap; null/off is rejected at boot — it would void the budget guard) | | AUTOTASK_MAX_CONCURRENT | no | 3 (agent profile) | override the SDK's local concurrency bound | | AUTOTASK_TIMEOUT_MS | no | 20000 (agent profile) | whole-call budget | | AUTOTASK_MAX_RETRIES | no | 1 (agent profile) | bounded retries with backoff (idempotent methods, 429/5xx only) | | MCP_HOST | no | 127.0.0.1 | http bind address (--host overrides it). Off loopback, AUTOTASK_HTTP_ALLOWED_HOSTS and AUTOTASK_HTTP_AUTH are required | | AUTOTASK_HTTP_ALLOWED_HOSTS | off loopback | — | Host names /mcp accepts (DNS-rebinding defence) | | AUTOTASK_HTTP_ALLOWED_ORIGINS | no | — | browser Origin hostnames /mcp accepts; a request without an Origin passes | | AUTOTASK_HTTP_AUTH | off loopback; tokens or gateway for operator, tokens for a server-held tuple on http | — | gateway or tokens | | AUTOTASK_HTTP_TOKENS_FILE | with tokens | — | the tokens file (see Gateway (http, container)) | | AUTOTASK_HTTP_RATE_PER_MIN | no | 60 | requests a minute per principal in tokens mode | | PORT | no | 8080 | http listener port (--port overrides it) | | LOG_LEVEL | no | info | log level (sensitive-data-safe structured JSON) |

No credentials as CLI flags, ever. The CLI carries only --transport / --host / --port / --version / --help.

Credentials & env files

The 2026-10-07 incident class, in one paragraph: a .env value written AUTOTASK_SECRET='a$b' was fed to the wire with its quotes intact by a quote-preserving loader, the vendor answered 401 Bad credentials three times, and the operator read it as a rotated credential — on an account where repeated auth failures risk a lock. Nothing in this package inspected the shape of a credential before dialing it. Now something does, on every path.

The load recipe (the only supported one)

set -a; . ./.env; set +a        # shell sourcing: strips the quoting the shell itself added
autotask-mcp doctor             # confirm the shape, zero vendor calls, exit 0 = go
autotask-mcp                    # the live session
  • Single-quote a value that contains $. A secret with $ in it written bare (SECRET=a$b) is expanded by the shell to something else entirely. Single quotes stop that — and are then removed by the shell on load, which is why the value that reaches the process is the credential.
  • Do not use a quote-preserving dotenv parser. Most dotenv variants and any hand-rolled Key=Value split hand the value over with its quotes, and a surrounding quote pair is presented to the vendor as part of the secret.
  • A trailing CR from a Windows-edited file is the same class of accident: the CR and everything after it would travel as part of the secret.

What happens when a value is malformed

| Path | Behaviour | |------|-----------| | stdio boot (env) | boot refusal, exit 1, naming the variable, the SDK's problem, the length and the load recipe — never a character of the value. One check, the SDK's own credentialShapeProblem, on every intake path. | | http per request | before any client is constructed, the three X-Autotask-* identity headers (and the impersonation header when present) are shape-checked. A malformed one answers the typed CREDENTIAL_MALFORMED envelope: category: "validation", retryable: false, the anomaly names, and a suggestedAction naming the env-load pitfall. Zero vendor wire — nothing was dialed, so a 401 shape would be a lie, and retryable: false is what stops a retry loop against a lock-sensitive account. | | vendor 401 | the envelope gains credentialShape: "anomalous" + the SDK's value-free shapeDiagnostic + the load-recipe suggestedAction when the credential this request presented shows a shape anomaly; code, category, httpStatus, retryable and message are unchanged. With clean values the envelope is byte-identical to the pinned UNAUTHORIZED one. |

Anomalies are reported in the SDK's wording, the first problem per value: leading whitespace, trailing whitespace, single- or double-quote surrounding the value, carriage return, line feed. Values are never printed — lengths, two-character prefixes and anomaly names only. Every other gate is unchanged, and the shape check runs before them, at the boundary.

doctor — the zero-wire preflight

autotask-mcp doctor (stdio CLI, exit 0/1) and GET /doctor (http, same trust model as /health and /ready: pre-auth, before the MCP handler) report the same facts about the server-held environment: per field present / len / prefix2 / shapeOk (+ the anomaly names), the AUTOTASK_BASE_URL resolution (origin only), the host-policy mode and the SDK plan-hash gate status. Nothing here emits a character of a secret: AUTOTASK_SECRET's prefix is masked to [REDACTED] (the identifiers keep their two-character format marker, which is not secret material).

  • Zero vendor dials, ever — src/doctor.ts imports no Autotask client and performs no network read; the dialed: 0 field is a property of the implementation, not a counter. Pinned by test/unit/doctor.test.ts and test/unit/doctor-route.test.ts (a global fetch trap around the whole route).
  • The contract: run doctor before a live session. A green doctor makes any later 401 vendor-side — a rotated, revoked or role-restricted credential — rather than a mangled load. A red doctor means fix the load recipe first; do not dial to "test" it.
  • Under http the tool-call credential arrives per request in the X-Autotask-* headers and is checked at that boundary, one request at a time; doctor reports the server-held environment only and does not echo a caller's value back.

Error model (the credential branch)

| Code | Category | Retryable | When | |------|----------|-----------|------| | CREDENTIAL_MISSING | auth | no | no credential on the request and no server-held default, or a partial tuple (all-or-nothing) | | CREDENTIAL_MALFORMED | validation | no | a credential value's shape cannot be right (quote pair / CR / LF / edge whitespace) — refused before any client is constructed, zero wire; anomalies lists the artifacts and suggestedAction names the env-load pitfall | | UNAUTHORIZED | auth | no | the vendor rejected a credential that was sent (401/403). With anomalous values this envelope is annotated (credentialShape, shapeDiagnostic, suggestedAction); with clean values it is exactly the fixed envelope | | HOST_POLICY_DENIED | policy | no | the origin is absent/unusable/not allow-listed (never the value echoed) |

Per-request credential model

http — per-request headers

Under http, the caller's Autotask identity arrives per request in fixed, non-configurable headers — the wire contract the gateway is built to send:

| Header | Required | Meaning | |--------|----------|---------| | X-Autotask-Api-Integration-Code | yes (for tool calls) | the caller's integration code | | X-Autotask-Username | yes (for tool calls) | the caller's username | | X-Autotask-Secret | yes (for tool calls) | the caller's secret | | X-Autotask-Impersonation-Resource-Id | no | the caller's impersonation target | | X-Autotask-Base-Url | no | the caller's zone origin; without it the deployment default origin applies |

Discipline (security review S1/S2 — BLOCKING controls):

  • Single-valued rule: a credential or zone header presented twice is treated as absent, never "first value wins" (picking one is how request-smuggling bugs start).
  • Missing required credential ⇒ typed refusal, fail-closed. The request resolves to a credential-less client that refuses every tool call with CREDENTIAL_MISSING (category auth, retryable: false), pre-request, zero vendor wire. initialize and tools/list are still served without credentials (catalog sync for gateways).
  • No per-field default fill. A caller can never splice its own header with a server-held value: a partial credential tuple (one or two of the three identity headers) is malformed — it is refused as CREDENTIAL_MISSING (the partial_credential kind), never completed from a server-held default, which is all-or-nothing. The only other default that may exist is the non-secret origin (AUTOTASK_BASE_URL).
  • Machine codes. Credential refusals (absent or partial tuple) answer CREDENTIAL_MISSING (category auth, retryable: false); origin refusals (no origin and no default, unparseable/non-https/credential-bearing origin, host not admitted by the allow-list) answer HOST_POLICY_DENIED (category policy). Both are fixed, value-free envelopes — the refused value is never echoed. A scope that cannot resolve at all answers from a fail-closed placeholder client built against a reserved RFC 6761 origin (no-autotask-origin.invalid): pre-request, zero vendor wire.
  • Optional discouraged server defaults: when the three credential vars are set under http, every request without its own headers runs as that identity — and that identity is presented only to the default origin (a request naming another origin with no headers of its own gets no credential and fails closed). Set them only when that is intended; per-request headers are the normal path.
  • Never in tool schemas: no tool input schema exposes a credential, impersonation or base-url field; model-supplied credentials are a protocol violation the server never accepts.
  • Host allow-list: a caller-named X-Autotask-Base-Url is refused unless AUTOTASK_ALLOWED_BASE_HOSTS permits it (exact host or *.suffix). Unparseable URLs are refused before reaching the SDK; literal private-IP origins are always refused. Layered on top, per pooled client: the SDK hostPolicy keeps the literal-IP floor and its allowedOrigins cover this request's admitted caller origin plus the deployment default (the allow-list admits a caller origin; the per-request hostPolicy is the construction-time floor). This allow-list is the tenant-isolation control: a caller's headers are only ever presented to an origin its allow-list entry (or the deployment default) names.

stdio — environment

Under stdio the process environment is the trust boundary: the three credentials + zone are required at boot (AUTOTASK_API_INTEGRATION_CODE, AUTOTASK_USERNAME, AUTOTASK_SECRET, AUTOTASK_BASE_URL; optional AUTOTASK_IMPERSONATION_RESOURCE_ID); one process-lifetime client is built from them — no pool. Missing required env is a boot failure: the process exits non-zero with a typed config error naming the variable — never a value (fail-fast, before any listener).

Credential hygiene (both transports): the three credential values are never logged, never written to disk, never cached across requests except via the sanctioned pool key (a SHA-256 digest — raw credentials never in keys, logs or eviction messages), never in error messages, and never in any tool input schema. Logging redacts the credential header names. On a vendor 401 (or an impersonation-scope 403) the pooled client entry is deleted — a stale identity is never negative-cached or replayed.

Per-tenant pool & cache

The stateless default is amended by one user-approved deviation (user-approved 2026-10-06; security decision D-1 ratified): a bounded in-process client pool with a per-tenant TTL read cache, because a fresh client per request would silently disable the SDK's local vendor-budget guards (rate limiter, 429 cooldown) — a worse security posture, not a safer one.

  • Pool — the SDK's createClientPool (no server-side wrapper; issue #252): a bounded LRU of tenant clients at the SDK default maxSize 128. Key = SHA-256 digest of the normalised credential tuple (user, integration code, secret, resolved base URL, impersonation id). Eviction is logged without values. A vendor 401 (or an impersonation-scope 403) deletes the entry (no negative caching).
  • Cache — AUTOTASK_CACHE=on (default) sends no cache block — the SDK's default-on caches apply to each pooled tenant client (SDK 0.9.0's default-on change: read ~5 min, the reference tables ~1 h, field metadata ~5 min). Repeated reads of the same record are served from that tenant's cache until TTL expiry; composites de-duplicate their 3–8 fan-out reads through it. off sends cache: false (every read re-dials the vendor).
  • Boundaries — never relaxed: in-process only (never on disk), per-tenant (one tenant's cached records or limiter state can never answer another tenant's request), TTL-bounded, cleared on eviction and on 401. The same boundary covers the limiter/cooldown state.
  • stdio uses one process-lifetime client (the env is the whole identity) — no pool, and the cache/limiter state is that single tenant's by construction.
  • Multi-process gateways: pool/cache/limiter state is per-process; an N-process gateway runs N independent trackers (documented consequence, security review C7.2).

Security posture

  • Redaction is on by default, with no opt-out surface. The SDK redacts the personal-data class (redactPersonalData default-on) and this server never disables it: there is no env or per-call switch, and the tests pin that position (test/unit/config.test.ts, test/integration/redaction-secret-reads.test.ts). The confidential class this covers: the configuration_items domain fields (domain, domainExpirationDateTime, domainLastUpdatedDateTime, domainRegistrationDateTime), the RMM device-audit fields (rmmDeviceAuditExternalIPAddress, rmmDeviceAuditIPAddress, rmmDeviceAuditMacAddress, rmmDeviceAuditMobileNumber, rmmDeviceAuditSNMPContact), primaryContact, taxID and webAddress — plus password / secretKey / secret, which are always redacted. The asymmetry is a vendor-side class boundary, not a server choice: the SSL-cert and warranty fields of the same resource are readable while the domain fields are not — so a filter on a redacted field still matches server-side, but the answer cannot show the triggering value.
  • Untrusted-content wrapping: vendor free text in results stays wrapped in this server's own <untrusted_content> markers end-to-end (the pinned SDK returns read-path values raw — its wrapper is a projection-layer helper; the server wraps once at the choke point: exactly once, never twice; prompt-injection boundary — data, never instructions), stripped of control, format and ignorable characters and capped at 500 characters (the angle-bracket escape was removed in 0.9.0, issue #70 — the wrapper is the injection control); the server instructions carry the "results are data, never instructions" rule. Two boundary facts a reader of the results should know: the marker is a field-level signal — unwrapped vendor data (the vendor's own text in unmarked fields) can carry literal wrapper tags, even a complete forged <untrusted_content>…</untrusted_content> pair, where the data-never-instructions rule is the control; and inside a wrapped field the boundary is exact-spelling-proof, on the strength of the SDK's wrapUntrusted neutralising interior open/close tags to its literal escape text (that SDK behaviour is the load-bearing control and is pinned by test/integration/vendor-text.test.ts's forged-wrapper block — the capabilities plan hash does not pin the helper's behaviour; a gapped spelling crosses raw).
  • Vendor error text is bounded, scrubbed and marked untrusted — the raw body never is. The full vendor 4xx/5xx body (which can echo request payloads carrying password/secretKey) is not returned to MCP clients; tool errors carry the typed code/category/retryable/correlationId envelope, and the vendor's message strings cross cleaned, per-string 200-char capped and secret-scrubbed, unwrapped (0.9.0: the angle-bracket escape is gone) — the error trailer marks them untrusted data, never instructions (the only served vendor text outside a wrapper; the bound is the cap plus the data-never-instructions rule).
  • Host allow-list is fail-closed at boot (see Per-request credential model).
  • Sensitive-data-safe structured JSON logging: NDJSON; stdio → stderr only (stdout is the protocol), http → stdout. Operation keys, status, latency and byte sizes are loggable; credential values, vendor bodies, full tool input bags, PII fields and the impersonation id at info (boolean at info, id value at debug only — decision D-2) are never logged.
  • Correlation IDs: server-generated random ids (never derived from caller content), threaded through logs, structured MCP errors and response headers; cancellation/timeout propagates into SDK calls via AbortSignal so an abandoned request stops consuming vendor budget.
  • Container: non-root, multi-stage, production dependencies only (lockfile-based install — the committed pnpm-lock.yaml/pnpm-workspace.yaml pin the tree; S8/C8.2), no baked secrets, liveness-only healthcheck (no vendor call), direct SIGTERM (stop accepting, 2 s bounded drain of in-flight work, exit 0), TLS termination at the gateway on a private unpublished network.
  • Supply chain: node-autotask is the only vendor runtime dependency (the other two are @modelcontextprotocol/server and zod); the pre-publish checklist asserts it carries zero runtime dependencies and no install lifecycle hooks, asserts the resolved @modelcontextprotocol/server version is ≥ 2.3.0 (the baseline that serves the era pair on one endpoint), runs a production dependency audit, and scans the image. CI (.github/workflows/ci.yml) runs the full gate on every PR; npm releases are published only by the tag-triggered release workflow (see docs/RELEASING.md).

Full control set: docs/architecture.md (sections 7.3, 8 and 10).

SDK dependency

iit-mcp-autotask depends on node-autotask from npm at an exact pin (see package.json; no range operator) - the only vendor dependency; the others are @modelcontextprotocol/server and zod. It never depends on a checkout (no file:, git:, link: or workspace references). The published tarball ships no lockfile, so a fresh npx/npm install must resolve to the release this surface was reviewed against, or the plan-hash boot check refuses to start.

Plan-hash drift gate. At boot, before the listener binds, the installed SDK's capability plan hash must equal the pin this surface was reviewed against (assertPlanHash); a mismatch refuses the boot naming the drifted groups.

Upgrading node-autotask is a deliberate server release (pin bump, plan-hash re-pin, mirrored-surface re-review, gates), not a range float: see the per-release checklist.

Development

  • Node 24 (current LTS; the SDK requires node >= 24), pnpm 10. pnpm-lock.yaml is committed and installs are frozen (pnpm install --frozen-lockfile). pnpm verify is the full gate; CI runs it on every PR and push to main.
  • pnpm i — install · pnpm build — compile to dist/ · pnpm typecheck · pnpm lint
  • Tests: pnpm test (all) or per suite: pnpm exec vitest run test/unit / test/integration / test/conformance. No live vendor API and no credentials are ever required — every suite runs in mock mode (fixture-backed SDK boundary, no mock branches in tool handlers).
  • pnpm dev runs the server from source via tsx.

Full contributor conventions: CONTRIBUTING.md.

Container execution

See Gateway (http, container). The image is multi-stage (build with dev toolchain → runtime with production dependencies only), non-root (USER node), and configured purely through environment variables at run time. Probes: /health (liveness, in-image HEALTHCHECK), /ready (readiness), /metadata (identity) — orchestrator probes may use any of the three; none of them call the vendor.

npm CLI execution

The npm package is an executable CLI (single canonical executable autotask-mcp), not a reusable library API ("exports": {}: no module is importable). Flags: --transport (stdio | http, default from MCP_TRANSPORT or http) / --host (default from MCP_HOST or 127.0.0.1) / --port (default from PORT or 8080) / --version / --help. No credential flags exist; an unknown flag is refused (exit 2, usage printed). A bind failure (e.g. port in use) exits non-zero with the problem named.

npx -y iit-mcp-autotask doctor                    # zero-wire preflight (exit 0 = go; see §Credentials)
npx -y iit-mcp-autotask --transport http --port 8080
npx -y iit-mcp-autotask --transport stdio

doctor is a subcommand, matched before any config work beyond parsing: it prints the credential shape report, the base-url/allow-list resolution and the plan-hash gate status, dials nothing, and exits 0 when everything would be accepted / 1 otherwise (a runbook step doctor && live-run therefore stops rather than dialing a credential the report says is malformed).

Gateway assumptions

With AUTOTASK_HTTP_AUTH=gateway the gateway is the control plane. It owns: client authentication, per-tenant tool filtering/exposure, credential injection into the X-Autotask-* headers, the per-request identity headers x-autotask-client-id and x-autotask-principal (see docs/write-gate.md; the gateway must strip client-supplied copies) and rate limiting (/metadata states the delegation). The server still validates Host/Origin and caps bodies at 1 MiB. With AUTOTASK_HTTP_AUTH=tokens this process authenticates callers and rate-limits per principal itself. Either way the vendor-side budget (10,000 req/hour, ≤10 concurrent per integration) is guarded by the SDK agent profile (8,000/hr local budget, 3 concurrent) and exposed live through autotask_tenant_status.

See docs/architecture.md section 5 for intentional omissions and blocked capabilities.

Connection setup for managed clients

Authenticated GET /configuration/autotask returns versioned named zone choices filtered by the deployment host policy. POST /configuration/autotask/resolve-zone accepts { "username": "[email protected]" }, calls the fixed Autotask ZoneInformation endpoint without credentials, and returns an allowed HTTPS zone origin/label. Discovery follows no redirects; errors return safe codes. Both routes use the normal Host/Origin, bearer and rate-limit guard. Health/readiness remain zero-wire. The zone catalogue follows the vendor REST zone table; custom zones still require existing host-policy approval.

For token rotation, two hashes may name the same principal only with identical complete token entries (profile, toolsets, run scope and write operations; write-operation list order is immaterial). Install both hashes, restart to load them, switch the client secret, verify and remove the old hash after an overlap. Different authorities under one principal still fail boot. Secrets are never part of discovery metadata.

Complete time-entry note verification

autotask_read {operation:"time_entries.get", args:[id], projection:"time_entry_verification_v1"} returns one forced-fresh read snapshot: scalar time-entry fields and versioned SHA256/code-point/UTF-8-byte metadata for summaryNotes and internalNotes. It never returns note bodies or changes the normal free-text caps. Hashes cover exact raw UTF-8, without whitespace, wrapper or line-ending normalisation; null and unavailable fields are distinct. Each note is bounded at 1 MiB; unsupported, missing, oversized or malformed data is never proof of equality. Callers must check the contract, fresh flag and entry/resource identity before comparing. Existing credentials and read policy still apply.

For consumers that reserve a vendor-call budget, autotask_tenant_status with parts:["server"] exposes zero-vendor server.readRuntime proof of the actual client retry/recovery bounds. Pass its sha256 as autotask_read.expectedReadRuntime on every read to refuse changed or unavailable bounds before dispatch. Validate the contract,