# Heat Applied direct API through the MCP connector

Status: built 2026-10-06, local only (not deployed). Needs: `oauth_upstream`
table (db-admin, `sql/oauth_schema.sql`), heatapplied's
`POST/DELETE /v1/auth/keys` live, our server key widened for it.

## What it does

The consent page of the datahandler OAuth server gets two optional fields,
Heat Applied email and password. When the person fills them in and clicks
"I consent", the server POSTs them once to heatapplied, which verifies the
login and mints a scoped API key for that account. The key is sealed and
stored against the OAuth grant; the password is never stored. From then on
the connector's MCP session can call the Heat Applied REST API as that
person through four generic tools. One app, one consent, no second
connector.

```
consent POST  ha_email + ha_password  ──►  POST /v1/auth/keys (our server key)
                                            ◄── 201 {key_id, key, scope[], user{}}
              oauth_upstream row {request_id, key_enc, ...}
token exchange                        ──►  row.grant_id = grant.id
tools/call    call_api_operation      ──►  Authorization: Bearer <that key>
grant revoked / upstream 401          ──►  DELETE /v1/auth/keys/{key_id}, row revoked
```

## Pieces

| piece | file |
|---|---|
| consent fields | `ui/html/pages/oauth/authorize*.html` (`ha_email`, `ha_password`) |
| consent handling | `src/DataHandler/Controller/OAuth.php` (`consentDecision`, `tokenFromCode`) |
| provisioning client | `src/DataHandler/Model/OAuth/HeatappliedProvisioner.php` |
| lifecycle + throttle | `src/DataHandler/Model/OAuth/UpstreamConnections.php` |
| sealed storage | `src/DataHandler/Model/OAuth/UpstreamCipher.php`, model `Model/Object/OAuth/Upstream.php`, table `oauth_upstream` |
| OpenAPI index | `src/DataHandler/Model/Mcp/OpenApiIndex.php` |
| MCP tools | `src/DataHandler/Model/Mcp/DirectApi.php`, wired in `Controller/API/Endpoint/V1/Mcp.php` (server 1.5.0) |
| spec mirror cron | `app/cron/SyncHeatappliedOpenApi.php` (daily 04:30, also PostInstall); seed `app/data/heatapplied-openapi.json` |
| tests | `McpDirectApiTest` (67), `OAuthUpstreamTest` (29 + DB lifecycle once the table exists), `OAuthServerTest` consent additions |

## Tools (all under `tools/call`, same Base shape as the Epic tools)

### get_api_connection
No input. `connected`, `email`, `user{uuid,email,root_uuid,type}`, `scope[]`,
`connected_at`, `how_to_connect` (when not connected), `spec{revision,
title, version, operations, synced_at, stale, origin}`.

### list_api_operations
`tag?`, `query?`, `include_blocked?`. Returns `operations[]` summary rows:
`operation_id`, `aliases[]`, `method`, `path`, `summary`, `tags`,
`path_params[]`, `query_params[]` (`*` = required), `request_content_types`,
`binary_response`, `mutating`, `blocked`, `blocked_reason`,
`requires_confirmation`.

### get_api_operation
`operation` (operation_id or `"METHOD /path"`). Adds `parameters[]`,
`request_body{required, content{type:{schema}}}`, `responses{code:{description,
content_types}}` with `$ref`s inlined.

### call_api_operation
`operation`, `path_params` (object by name, or array in path order),
`query`, `headers` (Accept / If-* / X-* only), `body` (JSON, or form fields for
multipart), `files[{field,name,base64|artifact_id,mime_type?}]` (a retained
artifact's id in place of bytes; see MCP-EXTENSIONS.md "Artifact input"), `confirm`,
`max_inline_bytes` (1 KiB to 2 MiB, default 256 KiB).

Result: `operation_id`, `method`, `url`, `http_status`, `http_ok`,
`content_type`, `delivery` (`inline_json` | `inline_text` | `artifact` |
`empty`), `body`, `artifact` (Artifact + `resource_uri`), `duration_ms`,
`response_headers` (content-disposition, x-request-id, ratelimit-*,
retry-after, location, etag).

Delivery rules: JSON/text up to the cap comes back inline in `body`.
Anything else, or anything larger, is retained in the artifact store under
the caller's owner and returned as a `resource_link` content block plus
`artifact`; fetch it later with `get_artifacts` or `resources/read`.

HTTP 4xx/5xx from Heat Applied are returned (ok:true, http_ok:false, body
included), not thrown. Thrown ToolErrors:

| code | when | retryable |
|---|---|---|
| NOT_CONNECTED | grant has no Heat Applied key | no |
| UNKNOWN_OPERATION | selector matches nothing (candidates in details) | no |
| OPERATION_BLOCKED | payment-method writes, staff-only ops, operator policy | no |
| CONFIRMATION_REQUIRED | `POST /v1/orders` (and policy entries) without `confirm:true` | no |
| UPSTREAM_KEY_REVOKED | Heat Applied answered 401; row marked rejected, reconnect | no |
| UPSTREAM_UNAVAILABLE | transport failure | yes |
| UPLOAD_TOO_LARGE | decoded files exceed `maxApiUploadBytes` | no |
| SPEC_UNAVAILABLE | neither mirror nor seed parses | yes |

## Operation identity

heatapplied's published document reuses operationIds between list and item
routes (`getArtwork` for both `GET /v1/artwork` and `GET /v1/artwork/{uuid}`)
and repeats `{uuid}` inside nested paths. The index keys by `METHOD /path`;
the shortest path keeps the bare id and the others get `By` + parameter
names (`getArtworkByUuid`). `"METHOD /path"` is always accepted as the
selector. Paths with a repeated parameter name require positional
`path_params`.

## Blocklist and confirmations

Blocked (belt and braces; the minted key's scope excludes them too):
`POST /v1/payment-methods/banks`, `…/banks/setup`, `…/cards`,
`GET …/cards/setup`, `POST …/{uuid}/verify`, `DELETE /v1/payment-methods/{uuid}`.
Staff-only operations such as `POST /v1/artwork/{uuid}/production` are not
blocked; the upstream key decides (unblocked 2026-10-21). Confirmation: `POST /v1/orders`.
app.json `epic.directApi {blocked:[], confirm:[]}` extends both with ids or
`"METHOD /path"`.

## Security notes

- The upstream key is sealed with AES-256-CBC + HMAC-SHA256 under keys
  HKDF-derived from the app encrypter's key, fresh IV per row; a tampered row
  opens to null. `key_enc` is hidden from model serialisation.
- The consent page is a password oracle for heatapplied, so failures are
  throttled on our side (5 per 15 min per email and per IP, file-based under
  `<tmp>/oauth-upstream`) on top of heatapplied's own limiter.
- Consent-time rows that never reach a token exchange are swept after an
  hour (upstream key deleted too). A re-submitted consent page abandons the
  previous row before minting again, so one grant never holds two live keys.
- Revoking the grant (`/oauth/revoke`) deletes the upstream key. A 401 from
  Heat Applied (customer deleted the key) marks the row rejected without
  calling upstream.
- Authorization / Host / Cookie / Content-Type headers cannot be overridden
  through `headers`. Path values are URL-encoded and may not contain `/`.
- Nothing logs the key: audit lines carry the email, request uuid and the
  upstream key id only.

## Config (app.json `epic`)

| key | default |
|---|---|
| heatappliedApiBase | host of `pantoneUrl`, else `https://api.heatapplied.com` |
| heatappliedKey | `pantoneKey` (heatapplied widens it with `*:/v1/auth/keys`) |
| heatappliedTest {base, key} | from `pantoneTest` when not live |
| openApiUrl | `https://docs.heatapplied.com/api/openapi.json` |
| openApiFile / openApiSeed | `<app tmp>/heatapplied/openapi.json` / `<repo>/app/data/heatapplied-openapi.json` |
| maxApiInlineBytes / maxApiUploadBytes / apiTimeout | 262144 / 52428800 / 120 |
| directApi {blocked[], confirm[]} | none |

## Outstanding

- heatapplied-dev: confirm the `label` field name, staging URL for an
  integration run, fix duplicate operationIds / repeated `{uuid}` / untyped
  download responses in the published document (we cope either way).
- Integration test against staging with a real login before deploy.
- Deploy order: db-admin DDL → heatapplied endpoint live → this deploy.
