# datahandler MCP connector — handoff for the ChatGPT skill agent

Live since 2026-10-06 (php-datahandler v1.0.60, MCP server `datahandler-epic-mcp` 1.5.0).

## Identity

| item | value |
|---|---|
| MCP endpoint | `https://api.datahandlerllc.com/v1/mcp` (Streamable HTTP, JSON-RPC 2.0, POST only) |
| server name / version | `datahandler-epic-mcp` / `1.6.2` (from `initialize` → `serverInfo`; 1.6.2 hotfixed onto the live v1.0.62 box 2026-10-13 16:35Z: every timeout on the MCP path is 600 s; manual at 18 chapters, engine 1.35.0) |
| suggested display name | **DataHandler Epic + Heat Applied** |
| protected-resource metadata | `https://api.datahandlerllc.com/.well-known/oauth-protected-resource` |
| authorization server | `https://www.datahandlerllc.com` (`/.well-known/oauth-authorization-server`); endpoints `/oauth/authorize/`, `/oauth/token/`, `/oauth/register/`, `/oauth/revoke/` |
| auth | OAuth 2.1, PKCE S256 required, Dynamic Client Registration and Client ID Metadata Documents (`https://chatgpt.com/oauth/client.json`) both accepted, `token_endpoint_auth_method: none`, scope `mcp`, refresh tokens rotate |
| tool schemas | `docs/mcp-tools.json` in php-datahandler (exact `tools/list` output, 11 tools) |

Unauthenticated requests get `401` with `WWW-Authenticate: Bearer resource_metadata=…`, which is what ChatGPT's connector platform expects to start the flow. Note the bare host `datahandlerllc.com` redirects (301) to `www.`; use `www.` in any hard-coded URL.

## Connection flow (what the user sees)

1. User adds the connector in ChatGPT with the MCP endpoint above. ChatGPT discovers the authorization server and registers (or uses its CIMD client).
2. ChatGPT opens the consent page `https://www.datahandlerllc.com/oauth/authorize/…`. There is no datahandler login. The page shows the requesting app and an **optional** block "Also connect your Heat Applied account" with `Heat Applied email` and `Heat Applied password`.
3. If the user fills the Heat Applied fields and clicks consent, datahandler mints a per-connection Heat Applied API key for that account and stores it sealed. The password is never stored. A bad login re-renders the page with "Email / password combination is not valid." and the grant is not issued until they fix it or clear the fields.
4. If the fields are left empty, consent completes without Heat Applied. The Epic tools work; the four `*_api_*` tools answer `NOT_CONNECTED` with the instruction to disconnect and reconnect, filling in the Heat Applied fields.
5. Revoking the connector in ChatGPT (or `/oauth/revoke/`) deletes the Heat Applied key upstream. If the user deletes the key from their Heat Applied account instead, the next call returns `UPSTREAM_KEY_REVOKED` with the same reconnect instruction.

Staff accounts connect the same way as customers; the assistant then acts with that staff member's own Heat Applied permissions (admin-console policies intersected with the connector scope). Payment-method changes, key minting and deletes other than tickets stay out of reach for everyone.

Throttling: 5 failed Heat Applied logins per 15 minutes per email and per IP on our side; Heat Applied adds its own limiter.

## Tools (11)

Epic (existing): `list_fonts`, `run_epic`, `export_fonts`, `get_epic_capabilities`, `get_artifacts`, `get_epic_manual`, `list_pantone_colors`.

`run_epic` (server 1.6.1): pass `artifact_delivery: "reference"` for vectorization and any run with large outputs, then fetch with `get_artifacts` (delivery `resource`) or `resources/read`. Inline delivery is downgraded to reference automatically when the decoded outputs exceed 8 MiB; the result says so in `artifact_delivery` and `delivery_note`. Raw `stdout` over 64 KiB is returned parsed in `stdout_json` only (JSON) or truncated (plain), flagged by `stdout_omitted`. The text block is a one-line summary; read `structuredContent`.

Keeping vectorization output small (engine side, per cli-dev 2026-10-10): the `--report` JSON is opt-in per run, so omit it when only the PDF is wanted. Traced raster at upscale emits one line segment per pixel step, so a 2 MB PDF can carry an 11 MB report; `--simplify=0.35` (curve fit, default off) cuts point counts 5 to 15 times and keeps the `objects[]` contract. Do not pass `--simplify` on `--extract-objects` runs, where callers expect exact source geometry.

`list_pantone_colors` (server 1.6.0) also matches colours: pass one of `rgb` (`[r,g,b]` or `{r,g,b}`, 0..255), `cmyk` (`[c,m,y,k]` or `{c,m,y,k}`, percent 0..100) or `hex` (`"#RRGGBB"`) and the 3 closest Pantone colours come back (`limit` up to 25), each with `delta_e` (CIEDE2000; 0 identical, under 2 very close, over 10 a different colour) and `rank`; `input` echoes the sRGB that was searched. CMYK is converted without an ICC profile, so treat that match as a starting point. Filters `is_special` / `is_semiopaque` apply in this mode too.

Heat Applied direct API (new):

| tool | input | use |
|---|---|---|
| `get_api_connection` | none | Is this session linked? Returns `connected`, `email`, `user`, `scope[]`, `how_to_connect`, `spec{revision,operations,synced_at,…}`. Call first. |
| `list_api_operations` | `tag?`, `query?`, `include_blocked?` | Discover operations. Tags: Artwork, Colors, Die-Cut Nesting Requests, Die-Cut Nesting Templates, Fonts, Invoices, Orders, Payment Methods, Pricing, Shipments, Shipping, Status, Team Events, Team Rosters. |
| `get_api_operation` | `operation` | Full parameters, request body schema per content type, response schemas. Result under `operation`. |
| `call_api_operation` | `operation`, `path_params`, `query`, `headers`, `body`, `files[]`, `confirm`, `max_inline_bytes` | Execute as the connected account. |

Every result carries `schema_version`, `ok`, `request_id`, `warnings[]`, and on failure `error{code,message,retryable,next_action,details}` in `structuredContent`; the text content block is a one-line summary, not JSON.

`operation` accepts an operation id (`getOrders`, `getArtworkByUuid`, `postOrders`) or `"METHOD /path"` (`"GET /v1/orders/{uuid}"`). Item routes are `<listId>By<Param>`.

Guard rails the agent should expect:

| error code | meaning |
|---|---|
| `NOT_CONNECTED` | no Heat Applied key on this grant; tell the user to reconnect with their Heat Applied login |
| `CONFIRMATION_REQUIRED` | `POST /v1/orders` places a real order and charges the account; re-call with `confirm: true` only after the user explicitly agrees |
| `OPERATION_BLOCKED` | payment-method changes and production-side routes are not available through the connector; send the user to the Heat Applied website |
| `UPSTREAM_KEY_REVOKED` | the key died upstream; reconnect |
| `UPSTREAM_UNAVAILABLE` | transport failure, retryable |
| `UNKNOWN_OPERATION` | bad selector; `details.candidates` lists near matches |

Heat Applied HTTP errors (403 permission, 404 not found, 422 validation) are **returned**, not thrown: `ok: true`, `http_ok: false`, `http_status`, and the JSON error body in `body`.

## Upload format (sending files to Heat Applied)

For multipart operations (artwork upload, die-cut nesting files) pass:

```json
{
  "operation": "postArtwork",
  "body": { "name": "Front print" },
  "files": [
    { "field": "file", "name": "front.pdf", "base64": "<base64 bytes>", "mime_type": "application/pdf" }
  ]
}
```

`body` keys become form fields, `files[]` become file parts. Decoded total is capped at 50 MB (`UPLOAD_TOO_LARGE`). JSON operations take `body` as the JSON document. GET/DELETE take no body.

## Download format (receiving files from Heat Applied)

`delivery` in the result says where the response went:

| delivery | what you get |
|---|---|
| `inline_json` | parsed JSON in `body` |
| `inline_text` | text in `body` |
| `empty` | no body (204) |
| `artifact` | binary, or anything over `max_inline_bytes` (default 256 KiB, max 2 MiB): `body` is null, `artifact{artifact_id, name, mime_type, bytes, resource_uri}` is set, and the result also carries a `resource_link` content block |

Fetch an artifact later with `get_artifacts` (`artifact_ids`, `delivery: "base64"` or `"resource_link"`) or MCP `resources/read` on `epic-artifact://<id>`. Artifacts are per connection and expire after the store TTL. Filenames come from `Content-Disposition` when present, otherwise `<operationId>-<timestamp>.<ext>`.

`response_headers` includes `content-disposition`, `x-request-id`, `location`, `etag`, `ratelimit-*` and `retry-after` when Heat Applied sent them.

## Acting for a customer (staff sessions)

Heat Applied added act-as-customer on 2026-10-06. A staff-connected session scopes any Artwork, Orders, Invoices, Shipments, Shipping or Pricing call to one customer account by sending `customer=<uuid or sign-in email>`:

- GET and DELETE: put it in `query`, for example `{"operation": "getOrders", "query": {"customer": "<uuid>"}}`.
- POST and PATCH: put it in `body` as the JSON field `customer`. If both are present the query string wins.
- A sub-user uuid or email resolves to its account root.
- Tickets and Auth do not take it. A ticket's own `customer` field means the affected account, which is a different thing.

Errors Heat Applied returns, passed through as `http_ok: false`: 403 when a customer-owned key sends `customer`, 404 when the target is not a customer account, 400 when the value is empty.

Resolving a customer named in chat to a uuid uses the staff directory `GET /v1/customers?query=<text>` (also `GET /v1/customers/{uuid}`, `POST`, `PATCH`, no delete). Recipe: search, take the first match's `uuid`, then call the resource with `customer=<uuid>`. The directory is in the connector catalog since 2026-10-06 evening as `getCustomers`, `getCustomersByUuid`, `postCustomers` and `patchCustomersByUuid`, and the `customer` act-as parameter is documented on each resource operation, so `get_api_operation` describes it. Permissions for the directory are the staff customer list, create and edit permissions.

## Artwork status changes (staff sessions)

Since 2026-10-06 evening `PATCH /v1/artwork/{uuid}` (`patchArtworkByUuid`) accepts `status` from staff sessions. It is the same manual status change as Admin → Artwork → Change Status, with the same downstream effects:

| status | meaning | effect |
|---|---|---|
| 1 | Needs Reviewed | hold bookkeeping |
| 2 | Pending Preflight | places the hold, creates the preflight contract and service request (ARC picks it up) |
| 3 | Preflight Approved | releases the hold, notifies the owner, and **pushes every pending order carrying the artwork into production**. Fires only on the transition into 3 |
| 4 | Preflight Failed | stamps the failure |
| 5 | Preflight Required | hold bookkeeping |
| 6 | Pending Customer Approval | hold bookkeeping |

Every change writes a status event attributed to the connected staff user, mirrors it onto the artwork's open orders, and fires the artwork preflight and approval webhooks. `name` and `description` may ride in the same body and save first. Without `customer=` a staff session reaches artwork on any account; with it the lookup is scoped. Heat Applied returns 400 for a disallowed transition, 404 when not found, 403 when a customer-owned key sends `status`.

The connector does not gate this call. Assistants send status changes only when a staff user asks or has scheduled them, and that person is accountable for the effect, so no extra confirmation step is expected. Releasing waiting orders to production on approval is the intended outcome.

Recipe: `getCustomers` with `query` → uuid → `getArtwork` with `query.customer` → `patchArtworkByUuid` with `body {"status": 2}` to submit for preflight, `{"status": 3}` to approve and release.

`POST /v1/artwork` still sets status silently at creation with no downstream effects.

## Spec freshness

The connector drives itself from Heat Applied's published OpenAPI document (`https://docs.heatapplied.com/api/openapi.json`), mirrored daily at 04:30 UTC and after each deploy. `get_api_connection.spec.revision` changes when the document changes; `stale: true` means the mirror is more than 7 days old and the agent should trust `get_api_operation` over memory.
