# Epic MCP extensions — export_fonts, get_epic_capabilities, get_artifacts, get_epic_manual

Implements the "Epic MCP Extension Specification" (2026-09-08) on the existing
authenticated endpoint. One endpoint, all tools via `tools/call`:

    POST https://api.datahandlerllc.com/v1/mcp     (OAuth 2.1 scope `mcp`, or an API secret key)

Published schemas: `docs/mcp-tools.json` (regenerate with
`php index.php --test=McpEndpointTest --dump-tools=docs/mcp-tools.json`).
Server: `src/DataHandler/Controller/API/Endpoint/V1/Mcp.php`;
models in `src/DataHandler/Model/Epic/`; tests `McpEndpointTest` (207 checks).

## Shared contract

Every new tool returns the typed payload in `structuredContent` plus a short
text block (never binary):

    Base     {schema_version:1, ok, request_id:"req_…", warnings:[], error:Error|null}
    Error    {code, message, retryable, next_action, details?}
    Artifact {artifact_id:"art_…", name, mime_type, size_bytes, sha256, expires_at}

Tool-level failure: `isError:true`, `ok:false`, `error` set, the tool's payload
fields empty (`fonts:[]`, `artifacts:[]`, `manifest_artifact_id:null`).
Schema violations (unexpected fields, wrong types, empty arrays) are JSON-RPC
`-32602`, never a tool-level error.

Artifacts are retained immutably in `epic.artifactDir` (default
`<app tmp>/mcp-artifacts`) for `epic.artifactTtl` seconds (default 86400),
scoped to the authenticated key's user. Ids are 128-bit random. Another
user's id is `ARTIFACT_NOT_FOUND`, never "expired" or "forbidden". After
expiry the bytes are deleted and a metadata tombstone keeps
`ARTIFACT_EXPIRED` reportable to the owner for seven days.

`epic-artifact://<id>` is an MCP resource: `resources/read` returns the bytes
as a `blob` for the owner; `resources/list` lists the owner's live artifacts.

## list_fonts (unchanged shape, three optional siblings)

    {fonts:[...],                       // as before, but see visibility
     catalog_revision:"cat_…",           // digest of names+sizes+mtimes+visibility
     font_metadata:[{filename, sha256, size_bytes, export_status, policy_reference}],
     visibility:{filter, source:"heatapplied", origin:"mirror"|"seed"|null,
                 visibility_revision:"vis_…", visible, synced_at, stale, missing:[...]}}

Since 1.4.0 `fonts[]` is heatapplied's **designer-visible** set, not the whole
store. heatapplied owns the font concept: the canonical store (s3 `font/`,
mirrored hourly to `epic.fonts` by SyncFonts) deliberately keeps every font ever
published so existing artwork never breaks, and curation lives only in its
`is_designer_visible` flag, published at `GET /v1/fonts` with the same contract
as the Pantone endpoint. The daily `SyncFontList` cron (04:25 UTC, plus
PostInstall) mirrors that list into `epic.fontListFile`; `Model\Epic\FontCatalog`
filters the directory through it, so hidden fonts vanish from `list_fonts` and
are refused by `export_fonts` (FONT_NOT_FOUND) while their files stay on disk.

`visibility` says where the filter came from. `origin:"mirror"` is the daily pull;
`origin:"seed"` is the committed snapshot `app/data/visible-fonts.json` (899
fonts exported by heatapplied on 2026-09-09), used until the endpoint has been
mirrored on this box; `filter:false` means no list could be read and the whole
directory is being served, which is a degraded state. `visible` counts the list,
`fonts[]` may be shorter when `missing[]` names visible fonts with no file on
this box. `stale` flips after 48 h without a successful check. `catalog_revision`
covers the visibility revision, so a hide/unhide invalidates a pinned export.
The same block appears under `get_epic_capabilities.fonts.visibility`.

Bootstrap: `php index.php --cron=SyncFontList --import=FILE` loads a local
export (endpoint shape, or heatapplied's `{generated_at, count, fonts}` export
shape) into the mirror; that is how the seed was produced.

## export_fonts (P0)

    {"fonts":["Rockwell Extra Bold-Regular.ttf"], "catalog_revision":"cat_…"?}

Atomic. Validates count (≤16), names, revision, presence and policy for every
font before writing anything; then retains one `font/ttf` artifact per font
plus `epic-fonts-manifest.json` (`{schema_version, catalog_revision,
fonts:[{filename,family,style,format,sha256,size_bytes,policy_reference}]}`).
Bytes are the exact catalog files (sha256 equals the catalog's).

Errors: `LIMIT_EXCEEDED`, `INVALID_FONT_NAME`, `CATALOG_REVISION_MISMATCH`,
`FONT_NOT_FOUND`, `FONT_EXPORT_NOT_ALLOWED`, `FONT_READ_FAILED` (only this one
is retryable). `details.filenames` names the offending fonts.

Font policy (`epic.fontPolicy` in app.json, optional):

    {"default":"allowed|denied|unknown", "allow":[globs], "deny":[globs], "reference":"label"}

Deny beats allow; absent block = everything allowed (the catalog mirrors the
public heatapplied-public/font/ store, which is the operator's redistribution
decision). `policy_reference` is `fontpolicy/v1/<reference>/<rule>`.

## get_epic_capabilities (P0)

No arguments. Runs the deployed jar's `--version` and `--help=compile` (cached
one hour, keyed on the jar's size+mtime) and parses the compile / native-AI
block from that text; nothing is assumed. Current deployment reports:
engine 1.0.0 (2026-09-08 builds, `ai_private` DEFAULT TRUE): compile
supported, request schema [1], unwarped live text supported, warped text
outlined, lab mapping supported, native_ai not experimental, `default_on`
true, `any_font` true (allowed_fonts null), layer_types text/shape/path,
arc, `unlimited_warped_layers` / `unlimited_spot_colors` true, bend_max 100
/ bend_min null, stroke and rotation/shear/flip on text unsupported with
`unsupported_handling` `soft_degrade_with_warning` (the PDF ships without
private data plus a warnings[] entry; `ai_private:true` makes them hard
errors). Only the `ai_private` option's own text (up to the LAYERS section)
is consulted, so the EXPERIMENTAL tag on `variables_xml` is not attributed
to it. Earlier 1.0.0 builds ("text layers only", "Bungee-Regular only",
"at most one warped layer", "one shared spot color") still parse to the
strict limits with `unsupported_handling` `hard_error`.

`artifacts.resource_delivery` and `packaging.zip_only_intake` default to
`unknown` and stay so until verified in the real ChatGPT runtime; set
`epic.resourceDelivery` / `epic.zipOnlyIntake` to `"supported"` after that
test. `capability_revision` is a digest of the whole capability body.

Errors: `ENGINE_UNAVAILABLE` (jar missing), `CAPABILITY_UNAVAILABLE`
(engine reports no version; retryable).

## get_artifacts (P1)

    {"artifact_ids":["art_…"], "delivery":"resource"|"base64"}   // default resource

Atomic: any missing/expired/unreadable id fails the whole call with no
artifacts. `resource` returns `resource_uri` per artifact plus MCP
`resource_link` content blocks; `base64` returns padded base64 in
`structuredContent` only, aggregate decoded size ≤ `max_base64_bytes`
(`epic.maxBase64Bytes`, default 8 MiB) else `DELIVERY_LIMIT_EXCEEDED`.
Errors: `ARTIFACT_NOT_FOUND`, `ARTIFACT_EXPIRED`, `ARTIFACT_READ_FAILED`
(retryable), `DELIVERY_LIMIT_EXCEEDED`, `DELIVERY_UNAVAILABLE`.
Retrieval never runs Epic; hashes are re-verified on read.

## get_epic_manual

The Epic CLI manual (`docs/epic-manual/`: `MANUAL.md` index + `manual/NN-slug.md`
chapters + the companions `MANUAL_MCP.md` (slug `mcp`) and `AGENT_GUIDE.md`
(slug `agent-guide`), copied verbatim from the java-epic-cli repository, see
`docs/epic-manual/SOURCE.md`) served in pieces so an agent reads only what it needs:

    {}                                          → mode "index": chapters[{id,slug,title,summary,uri,size_bytes}], companions[…]
    {"chapter":"7"|"07-compile"|"compile"|"index"} → mode "chapter": chapter{…, headings[], content (verbatim markdown)}
    {"chapter":"7","section":"layers"}          → same, content = that heading's section only
    {"query":"fonts-dir precedence","max_results":8} → mode "search": matches[{chapter_id,slug,title,heading,uri,score,snippet}]

Chapter references accept the number, slug, slug without number, a unique
title substring, a companion slug or filename, or the `epic-manual://` URI. Search is AND over
whitespace-split terms per section, heading hits weighted; no hits is
`ok:true` with empty `matches` and a warning. `manual_revision` (`man_…`) is a
digest of every served file and also appears in `get_epic_capabilities`
under `manual` (with `chapters` and the `companions` slugs present).

Errors: `MANUAL_UNAVAILABLE` (directory missing), `CHAPTER_NOT_FOUND`
(`details.available`), `SECTION_NOT_FOUND` (`details.headings`),
`MANUAL_READ_FAILED` (retryable). `section` without `chapter`, `query`
combined with `chapter`, or `max_results` outside 1..`epic.maxManualMatches`
(default 20) are `-32602`.

Every chapter is also an MCP resource `epic-manual://<slug>` (`index` for
the table), `text/markdown`, listed by `resources/list` for every
authenticated caller (the manual is not tenant-scoped) and advertised as the
template `epic-manual://{chapter_slug}`. Config: `epic.manualDir` (default
`<repo>/docs/epic-manual`). To refresh the manual, replace the files and
redeploy; nothing is cached.

## list_pantone_colors

The Pantone spot-colour catalogue. heatapplied owns the concept and the
database; its API `GET /v1/colors/pantone` (Bearer key scoped to that one
route) is the source of truth. The `SyncPantone` cron (daily at 04:20 from
`app.crontab`, and once from `PostInstall` so a fresh blue-green box has it
before serving traffic) mirrors it into `epic.pantoneFile` with a conditional
GET (`If-None-Match` → 304 on unchanged days) and an atomic rename, so a tool
call never talks to heatapplied and a failed pull never damages the mirror.
`Model\Epic\PantoneCatalog` reads that file.

    {}                                        → whole catalogue, paged: total, offset, limit (default 100), count, next_offset, colors[]
    {"pms":["185 C","Cool Gray 9 C"]}         → exact lookups; unknown codes in missing[] + a warning, never guessed
    {"query":"185"}                           → codes starting with it first, then substring of code or name
    {"query":"#E4002B"}                       → exact hex match
    {"is_special":true} / {"is_semiopaque":false} → filters, combinable with query or pms

Rows are heatapplied's verbatim: `id` (the colour id its pricing/order APIs
accept as `color_ids`), `name`, `pms`, `hex`, `rgb{r,g,b}`, `cmyk{c,m,y,k}`,
`is_special`, `is_semiopaque`, `updated_at`. `pantone_revision` (`pan_…`)
derives from heatapplied's `revision` fingerprint and the count, so it moves
on inserts, updates and deletes; `source_revision`, `source_updated_at` and
`synced_at` (last successful check, a 304 counts) are passed through, and
`stale:true` plus a warning appear when the mirror has not been confirmed
for over 48 hours (the tool still answers). `get_epic_capabilities` carries
the same under `pantone` (`tool`, `pantone_revision`, `colors`, `synced_at`,
`stale`, `source:"heatapplied"`).

Errors: `PANTONE_UNAVAILABLE` (retryable; mirror missing or corrupt).
`pms` with `query`/`offset`, an empty `pms`, `limit` outside
1..`epic.maxPantonePerPage` (default 500), a negative `offset`, or a
non-boolean filter are `-32602`.

Config: `epic.pantoneUrl` + `epic.pantoneKey` (live), `epic.pantoneTest`
`{url,key}` used instead when `__LIVE__` is false (dev/staging talk to
heatapplied's test-DB endpoint), `epic.pantoneFile` (default
`<app tmp>/pantone/pantone.json`). Manual refresh:
`php index.php --cron=SyncPantone -V` (`--force` ignores the ETag,
`--dry-run` validates without writing). Tests: `SyncPantoneTest` (offline
transport fakes + one dry-run against the configured source) and the
`testPantone` block of `McpEndpointTest` (fixture mirror).

## run_epic (backward compatible)

Optional `artifact_delivery:"inline"|"reference"` (default inline).
Existing `ok/exit_code/stdout/stdout_json/stderr/missing_outputs/artifacts`
are unchanged. Every run now also gets `job_id`. Outputs are retained in both
modes; `artifact_refs` (Artifact[]) and `artifacts_expires_at` appear only
when retention succeeded. In reference mode `artifacts` is `[]`; if retention
fails, `artifact_refs:[]` plus an explicit `retention_error`
(`ARTIFACT_STORE_FAILED`, retryable) while `exit_code` keeps the engine's
real result.

## Config summary (app.json `epic`)

| key | default |
|---|---|
| artifactDir | `<app tmp>/mcp-artifacts` |
| artifactTtl | 86400 |
| maxBase64Bytes | 8388608 |
| maxFontsPerExport / maxArtifactsPerGet | 16 / 16 |
| fontPolicy | absent → all allowed |
| resourceDelivery / zipOnlyIntake | `unknown` |
| manualDir / maxManualMatches | `<repo>/docs/epic-manual` / 20 |
| pantoneUrl / pantoneKey / pantoneTest | none (required for SyncPantone) / test-DB override when not live |
| pantoneFile / maxPantonePerPage | `<app tmp>/pantone/pantone.json` / 500 |
| fontListUrl / fontListKey / fontListTest | derived from pantoneUrl (`…/v1/colors/pantone` → `…/v1/fonts`) / pantoneKey / pantoneTest; set explicitly to override |
| fontListFile / fontListSeed | `<app tmp>/fonts/visible.json` / `<repo>/app/data/visible-fonts.json` |

## Not done / outstanding

- Resource delivery has not been exercised from the ChatGPT runtime; the
  capability answer says `unknown` on purpose until it is.
- ZIP-only Design Lab intake: no integration test, reported `unknown`.
- Native live Illustrator Rockwell arches remain an engine feature; nothing
  here advertises them (the test asserts the word "Rockwell" never appears in
  the capability result).
