MCP server
The gateway can serve Model Context Protocol
servers at POST /mcp/<name>. Each server exposes a fixed set of namespaces to
an AI assistant such as Claude Desktop. The gateway generates the tools, their
parameters and their descriptions from each namespace’s schema, so you write no
server code and no tool definitions.
A server is read-only. Every tool call runs as the caller’s API key, through the
same query and document paths as /v2.
Enable it
The capability is off by default. Enable it on the gateway and declare servers
in LAYER_MCP_JSON:
LAYER_MCP_ENABLED=true
LAYER_MCP_ALLOWED_HOSTS=layer.example.com
LAYER_MCP_JSON='{
"field-records": {
"namespaces": [
{
"name": "site-photos",
"toolName": "photos",
"description": "Job-site photos, linked to the job they were taken on.",
"filters": ["job_id", "photo_class", "taken_at"],
"link": "source_url"
},
{ "name": "invoices", "link": "source_url" }
]
}
}'
| Variable | Purpose |
|---|---|
LAYER_MCP_ENABLED | true serves the declared servers. Defaults to false. |
LAYER_MCP_JSON | The servers, keyed by name. The gateway validates it at startup, and a change needs a gateway restart. |
LAYER_MCP_ALLOWED_HOSTS | Comma-separated hostnames the gateway answers MCP requests on. Defaults to localhost,127.0.0.1,::1, so a remote URL needs its hostname here. Keep the Host header intact through your proxy. |
LAYER_MCP_ALLOWED_ORIGINS | Exact origins, including the port, for browser-based clients, for example https://app.example.com:443. Native clients such as Claude Desktop send no Origin and need none. |
Wildcards and empty allowlists are refused. A request from an unlisted host or origin is rejected.
Server fields
Each server has one to 512 namespaces. Names are exact, not globs.
Set genericTools: true on the server to use generic tools even with eight
or fewer namespaces. More than eight configured namespaces selects generic
tools automatically. An optional host serves the same handler at
/<name> on that dedicated hostname; include it in the host allowlist.
The Desktop extension (0.1.3 and later) accepts either form: https://<gateway>/mcp/<name>, or https://<host>/<name> for a server’s dedicated host.
| Field | Default | Purpose |
|---|---|---|
name | required | The namespace to read. |
toolName | derived from the namespace name | The stem of the tool names: photos gives search_photos and get_photos. Tool names must be unique within a server. |
description | none | What a row is. The model reads it when choosing a tool, so say what someone would search this data for. |
filters | every supported filterable attribute | The attributes offered as search filters. [] offers none. |
where | none | A pinned filter: an object of attribute to exact value (string, boolean or number), for example {"company":"story"}. See Pinned filters. |
link | none | An attribute holding a link to the source record. It is placed next to each hit so the model can cite it. |
originals | off | Serve the retained original file on demand with fetch_original. See Original files. |
Tools
Tool discovery returns ttlMs: 0 and cacheScope: "private". A tool list
belongs to the caller’s permissions and must not be shared across users. These
cache hints let clients using MCP 2026-07-28, including current Claude Code,
load the available tools.
Every server exposes list_namespaces, which takes no arguments and returns
only namespaces the caller can read. Each entry includes name, toolName,
description, rowCount (the upstream approximate count), searchKind,
filterableAttributes (types and input schemas), groupableAttributes,
summableAttributes and extremaAttributes (the fields aggregate
takes), aggregateToolName, and linkAttribute. Counts
can lag ingestion; discovery does not scan or fetch rows.
With up to eight configured namespaces, each readable namespace also gets three tools.
| Tool | What it does |
|---|---|
search_<tool> | Searches with a query, optional filters, a limit (default 10, at most 50) and an optional cursor for the next page. |
get_<tool> | Fetches one row by id. |
aggregate_<tool> | Counts, sums, mins and maxes, optionally grouped. |
In generic mode, the server exposes just list_namespaces, search, get and aggregate.
search takes namespace alongside the usual query, filters, limit and cursor;
get takes namespace and id; aggregate takes namespace and the arguments below. Call list_namespaces first to choose a
namespace and its filter types. Namespace-specific tool names are unavailable
in this mode. Guessed namespace names still require membership in the server
and a read grant. A caller with no readable namespaces gets an empty discovery
list.
Filters take their types from the schema. Numbers take min and max, dates
take after and before, and strings and booleans take an exact value.
Search reads the live namespace schema and the gateway’s persisted embedding configuration, including declared CLIP image profiles. A vector column alone is not an embedding declaration: declare its source/model through the embedding configuration. CLIP profiles let a text query search photos in the declared embedding space without adding a full-text field.
| Schema | searchKind and ranking |
|---|---|
| Full-text only, with fuzzy search enabled | HybridText |
| Full-text only, without fuzzy search | BM25 |
| Declared embedding only | ANN/Embed, using inline Embed |
| Full-text and a declared embedding | Auto, using inline Embed; without fuzzy search, lexical expansion is disabled with fuzziness: 0 |
When both search paths are available, MCP questions keep lexical and semantic
ranking together at every query length. Short phrases such as “change order”
can retrieve an embedded page whose extracted text contains only a signature;
long questions retain lexical evidence for job names and document titles.
Text-only and embedding-only namespaces keep their respective search paths.
If the schema declares a string title,
name, or filename, search checks that label on the first 50 ranked candidates.
Title matches add a bounded contribution to the store’s relevance. $mcp_score
is the normalized store relevance plus at most 0.25 for the fraction of query
words matched in the label. Results follow that combined score, while $score
and $dist keep their store values. ANN distance uses inverse-distance relevance.
Ties retain the store order. Compound label
words such as “floorplan” match “floor plan” in a question. Every cursor page
uses the same candidate bound. If the declared label fields change, restart at
page one; a previous cursor is rejected. Use the declared job filter when the
answer needs a particular project. Document-type filters select stored labels,
which may not cover every page about that topic.
Scope a request before ranking
query ranks document content. A job, project or company name in the question
does not become an attribute filter. For a request about one project, resolve
its stored filter value first, then pass that value in filters. A display
name and a stored job code may differ. Search the name alone and inspect the
matching records, or use aggregate to discover the declared field’s values;
do not guess an identifier.
Keep the user’s question as the query when adding the constraint. For example,
if discovery declares job and doc_type, and the records identify North Wing
as job 24-021, a drawing request can use:
{"query":"North Wing drawing revision","filters":{"job":"24-021","doc_type":"drawing"},"limit":5}
Search returns applied_filters, the caller’s validated typed filters, and
states the requested scope in its text. {} means the caller supplied no
attribute constraint; server-pinned filters still apply and are not disclosed
there. Query words alone are not evidence that a hit belongs to the requested
project. If the scoped search has no matches, report that result rather than
substituting documents from other projects. A missing result does not establish
whether the source file exists upstream or has been indexed.
A document-type label is not a content guarantee. If an optional doc_type
filter returns no matches, retry the question without that filter while keeping
the exact job, project, company and other constraints. Report that the type
filter was removed; the tool does not relax filters automatically. Inspect the
returned content before calling it a match: a contract clause describing how
change orders work is not an actual change order. An empty classified search
alone does not establish that no relevant unclassified page is indexed. Keep
an explicitly requested exact-classification constraint unless the user agrees
to a broader search.
Retaining an original and ranking a document are separate. An indexed original
PDF page can have a source page link before original_blob is attached.
Converted DOCX, spreadsheet and Markdown sources do not gain original-PDF page
links just because they rank highly.
Search and get include a structured links list as well as rendered links, so
clients that consume structured content receive the same source evidence. Each
entry identifies the returned row and its sourceLink; pageLink is a PDF
page URL or null. A converted-page basis or a non-PDF original MIME prevents
a PDF page link. Legacy rows without either field need a PDF source URL.
If the schema has more than one candidate field, the canonical text field
wins; otherwise the tool returns an error naming the ambiguity.
Both tools return the rows as structured content, plus a short text rendering
with each hit’s link.
Aggregate
aggregate answers “how many” and “how much” without reading rows: a count, or
the sum, minimum or maximum of a numeric field, optionally grouped by one or two
fields, over the rows the same typed filters as search select. It uses the
store’s native aggregate_by and group_by.
| Argument | Meaning |
|---|---|
group_by | One or two fields from the namespace’s groupableAttributes (strings, booleans and integers). Omit for a single total. |
aggregates | Up to four of {"fn":"count"}, {"fn":"sum","field":…}, {"fn":"min","field":…}, {"fn":"max","field":…}. Default: one count. sum takes a field from summableAttributes; min and max take one from extremaAttributes, which adds dates. |
filters | The same typed filters as search. |
limit | Most groups returned, 1 to 1,000, default 100. |
{"name":"aggregate_documents","arguments":{"group_by":["doc_type"],
"filters":{"company":"Story Renovations"}}}
The structured content holds groups (one object per group, with the group
fields and count, sum_<field>, min_<field> or max_<field>; an ungrouped
call has one object), truncated, limit and billing. The store returns
groups in key order, so truncated: true means later groups were cut off, not
that these are the largest: add filters or raise limit. A namespace that
collapses still counts its rows (pages), not
documents, and counts says rows.
min and max are not available with group_by, because the store aggregates
only count and sum per group; the call fails with unsupported_aggregate.
Filter to one group and call again without group_by. A float or timestamp
field cannot be grouped by, since it would make a group per row.
Cost
The store bills a query at the size of the namespace it reads, whatever the
filters: about 2 GB for each query against the BCC pages, and never less than
1.28 GB. So one call is bounded and cheap to reason about. Count and sum, grouped
or not, are one store query. Each min or max is one more, and a call takes at
most two. A call makes at most three queries, and a call that is refused
(an unknown field, too many aggregates) makes none.
Each call returns the bytes the store billed, as billing.queries,
billing.billable_logical_bytes_queried and
billing.billable_logical_bytes_returned, ends its text with the same figure,
and logs it with the server and namespace. The gateway’s
billable_logical_bytes_queried metric counts them with every other query.
Paging
A search returns one page. Besides rows (or groups when the namespace
collapses), the structured content carries:
| Field | Meaning |
|---|---|
total | How many results the whole search has: rows, or groups when collapsed. Counted up to 1,000. |
total_exact | false when the search has more than 1,000 results and total is that bound. |
next_cursor | An opaque string for the next page, or null on the last page. |
offset | Where this page starts in the result list. |
snapshot | true when the page walk is pinned to a snapshot (see below). |
applied_filters | The caller’s validated typed filters, or {} if none were requested. Server pins still apply and are not included. |
links | Each returned row’s id, sourceLink and eligible PDF pageLink. A source need not have a retained original for its page link to be present. |
To read on, call the same search again with the same query and filters and
cursor set to next_cursor. limit may change between pages. The text
rendering ends with Showing 8-14 of 31. and the cursor, so a model that reads
only the text can continue. A walk reaches at most the first 1,000 results.
Every page ranks the same 1,000-result window, so approximate (vector) ranking
cannot reorder results between pages; only the page’s own rows are then read.
{"name":"search_photos","arguments":{"query":"kitchen cabinets","limit":10,
"cursor":"eyJ2IjoxLCJxIjoi..."}}
The first page records when it ran, and the cursor carries that time. Every later page excludes rows the gateway wrote after it, so rows landing during a walk neither repeat a result nor push one off a page: a walk returns each of the first page’s results once, in order, however much is written meanwhile. A fresh search, without a cursor, sees the new rows.
Two limits apply. A row overwritten after the first page carries a new write
time and drops out of the later pages of that walk. And a namespace whose schema has no
_hevlayer_upserted_at (rows written around the gateway) has no write time to
pin to, so snapshot is false and a walk there can shift when rows are
added.
A cursor belongs to the namespace, query, filters and collapse configuration
it came from. Sending it with anything else fails with cursor_mismatch; a
cursor that does not decode fails with invalid_cursor.
Errors
A failed call returns isError: true. The structured content holds
error.code, error.message and the details that name the fix, and the text
starts with the code.
error.code | When | Extra fields |
|---|---|---|
unknown_argument | An argument name the tool does not take, such as q for query or page_size for limit. | unknown, allowed, did_you_mean (the nearest accepted name, or null) |
invalid_cursor | cursor is not a cursor this server issued, or is out of range. | reason |
cursor_mismatch | cursor came from a different namespace, query or filters. | |
invalid_argument | Any other bad value: an empty query, limit outside 1 to 50, a filter of the wrong type, a group_by or sum field the namespace does not offer. | field, for an aggregate field |
unsupported_aggregate | min or max with group_by, or more than two of them. | |
tool_failed | The store or gateway failed. |
Errors are returned before any query runs, so a wrong name costs no store work.
Auth
A client connects with a Layer API key as a bearer token minted through the Pro key workflow. The key needs two entitlements:
{
"mcp.field-records": {},
"vectorstore.default": { "scopes": ["read"], "namespaces": ["site-photos", "invoices"] }
}
mcp.<name> lets the key call that server. It grants no data access, so the key
also needs read on each namespace through its vectorstore entitlement. An
admin key still needs mcp.<name> explicitly.
| Request | Result |
|---|---|
Key without mcp.<name> | 403 |
Key with mcp.<name> but no grant on a namespace | That namespace’s tools are left out of tools/list, and calling one by name is denied. |
Minted keys work in both keys and pass-through deriveFromStore inbound auth
modes. Minted verification runs before upstream bearer pass-through: the
caller keeps their namespace grants, and the configured store credential is
used upstream. Invalid, expired or revoked minted keys are rejected without
falling back to pass-through. Plain upstream bearers and declared bootstrap
keys do not grant mcp.<name>. open mode skips verification and cannot
authorize MCP, even when a minted token is supplied.
Give each person their own key, scoped to the namespaces they should see, and revoke it through the usual key workflow.
Transport
The server speaks MCP’s Streamable HTTP transport in stateless mode, with JSON responses. It keeps no session between requests, so any gateway replica can answer any call. You can drive it with plain HTTP:
curl -sS "$LAYER_GATEWAY_URL/mcp/field-records" \
-H "Authorization: Bearer $LAYER_MCP_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-06-18' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search_photos",
"arguments":{"query":"kitchen cabinets","filters":{"job_id":"2231"},"limit":10}}}'
Each tool call is written to search history, tagged
mcp.<name>.
Connect Claude Desktop
Claude Desktop’s remote connector settings take a URL and OAuth, not a bearer
token. Layer ships a Desktop Extension, hevlayer-mcp.mcpb, that asks for the
server URL and your token and forwards Desktop’s calls to the gateway.
The operator gives each person three things: the extension file, the full URL
(https://<gateway>/mcp/<name>, or https://<host>/<name> for a dedicated host), and their own API key, sent through a secure channel.
Each person then sets it up without a terminal:
- In Claude Desktop, open Settings → Extensions → Advanced settings →
Install Extension… and choose
hevlayer-mcp.mcpb. - Paste the Server URL and Access token. The token field takes the
token only, without
Bearer. - Save, enable the extension, and start a new chat.
On a Team or Enterprise account
On a Team or Enterprise plan, an Owner adds the extension to the organization once, and each member installs it from inside Claude Desktop. The upload holds only the extension: the server URL and token are not part of it. Each member enters their own when they install, so one upload serves the whole team and every member reads with their own key.
The Owner, once:
- In Claude Desktop, click your name in the lower left, then open Organization settings → Connectors → Desktop.
- Click Add custom extension and choose
hevlayer-mcp.mcpb. It appears under Custom team extensions as hev layer search. - Click … on it, then Add to team.
Each member then installs hev layer search from the organization’s extensions under Settings → Extensions, and pastes the Server URL and their own Access token as in steps 2 and 3 above.
- The organization’s Allowlist toggle is on the same Desktop tab. Turning it
on stops members installing extensions from a file and removes the ones they
already installed that way. Anyone who installed
hevlayer-mcp.mcpbby hand installs it again from the organization’s list. - Members need Claude Desktop 0.13.91 or later.
- On Enterprise, device policy overrides these settings:
isDesktopExtensionEnabledandisDesktopExtensionDirectoryEnabledmust not befalse.
See Anthropic’s desktop extension allowlist guide.
Operators build the extension from the reviewed source commit:
npm ci --ignore-scripts --prefix tools/mcp-desktop
npm test --prefix tools/mcp-desktop
npm run pack --prefix tools/mcp-desktop
Distribute tools/mcp-desktop/dist/hevlayer-mcp.mcpb (version 0.1.3)
and its SHA-256 through a secure channel. The bundle is built in CI but is not
published as a download. Claude Desktop supplies the Node runtime; users need
no separate Node installation. Remote server URLs must use HTTPS; HTTP is
accepted only on loopback. If the URL or token is invalid, the extension
stays running and answers Desktop’s initialize request with an error that
says what to fix.
Pinned filters
Set where on a namespace seed to serve one slice of a shared namespace, such
as one company’s rows. The gateway ANDs the pin into every search,
aggregate, get and fetch_original call on that tool, and into the
internal lookups behind them, so a client cannot widen or override it:
- A pinned attribute is not offered as a filter. A client that sends it gets
unknown filter, so it cannot ask for another value. getandfetch_originalanswer a record outside the pin exactly like a missing one (not found), even when the id is known.- Counts, totals and groups from
aggregatecover the pinned rows only.
{"name":"pov-bcc-photos","toolName":"story_photos","where":{"company":"story"},
"filters":["job","folder_1"],"link":"webUrl"}
The seed is rejected at startup if where is empty or holds anything but a
string, boolean or number. Discovery fails, as for filters, if a pinned
attribute is missing from the namespace, not filterable, or its value does not
match the attribute’s type. Pinned attributes cannot also be listed in filters.
A gateway that predates where rejects the seed, so roll the gateway first.
Page-anchored source links
Set "pageLink": true on a namespace seed whose rows are PDF pages with an
integer page attribute. Each hit then shows its source link and a second link
with #page=<n> appended, so a result opens the matching page. Links that
already carry a fragment, and rows without page, show the source link only.
Documents with several copies
When one document has more than one copy (a SharePoint file and the same file
attached to a REST row, merged by layer-extract
sourceRef.merge), its rows carry a
locations attribute listing every copy. A hit and a get_* result then end
with [also: clouddrive <webUrl>; rest <rest url>], and the structured result
has the full list.
Original files
A selected source original can have incorrect provider MIME metadata. A retained
PNG is returned whole with actual mimeType: "image/png", its provider
declaredMimeType, sourceFormatMismatch: true and the original sourceLink.
The attached bytes are unchanged. pages is only valid for actual PDF originals;
a PNG request with pages fails explicitly. Typed catalog references use the
same namespace grants, pins and source/version/hash binding as PDF references.
Search and get stay text-only. When a namespace seed sets originals and
its rows point at a retained original, the server adds fetch_original_<tool>
(fetch_original with namespace in generic mode). It takes the id of a
row from search or get, and optionally pages.
Warehouse original retention also supplies
stable original:<source-id> catalog IDs for unindexed selected originals. These use the same
arguments and namespace read grant. A catalog cannot bypass a pinned filter:
missing pinned attributes are denied. For an indexed row without
original_blob, the gateway can lazily bind a catalog receipt by its exact
content hash or source drive/item/version, without scanning or patching rows.
Source drive/item/version/ETag are included in the returned sourceIdentity;
unknown legacy identity remains an explicit error.
{"name":"pov-bcc-story-pages","toolName":"story_documents","link":"webUrl","pageLink":true,
"originals":{"maxBytes":700000,"maxPages":3,"maxWidth":1100}}
| Field | Default | Purpose |
|---|---|---|
maxBytes | 700000 | Largest original returned whole. Hosts cap a tool result near 1 MB and base64 adds a third. |
maxPages | 3 | Page images per call (at most 8). |
maxWidth | 1100 | Page image width in pixels (200 to 2000). |
Without pages, the result is the whole original as an embedded resource
with its actual MIME. With pages, it is those pages of the original
PDF rendered by the gateway as PNG image content, which a vision model can
read directly. Use page images for what OCR text misses: drawings, stamps,
handwriting, colored annotations, charts. Both forms put the source link and
the applicable PDF page link in the text, separately and unchanged, and
structuredContent carries the metadata only (sha256, bytes,
mimeType, declaredMimeType, sourceFormatMismatch, version, sourceLink,
pageLink), never the bytes. Legacy entries without declared MIME or mismatch
provenance return null for those optional fields.
Retention
For a tool with originals enabled, get and fetch_original read the current
document metadata from the store. A cached row from before an original was
attached cannot hide it, and a cached reference cannot serve an original that
the current row no longer retains. get still returns metadata and text only;
it does not load the original bytes. If the store cannot provide the current
row, the call reports that failure instead of claiming the original is absent.
If no original is retained, fetch_original returns a tool error with the
record’s source link so you can open the file there. A retained reference whose
file is missing from storage returns the source link too.
The bytes are in the namespace’s private blob storage, content-addressed by
SHA-256. Rows carry original_blob (blob://<namespace>/<sha256>),
original_sha256 and original_bytes. Neither the bucket, the key nor a URL is
exposed, and the tool accepts no location from the caller.
Two paths write the reference, with one format and one set of checks:
- The Pipeline. A
layer-extractPipeline retains by default: every new or re-ingested document stores its original (up tomaxBytes, 10 MiB) and stages the reference with its page rows. Nothing else has to run. Setextract.retainOriginals: falseto opt out, or an object ({maxBytes, backfill}) to tune it. Originals overmaxBytesare skipped and counted, never truncated. - A Function for existing rows. The
layer-extract originalsworker runs under a Function (groupByParent: true) and fills the reference for rows ingested earlier. It gets the bytes from a sealed manifest of cached originals when one is declared, otherwise through Layer’sclouddriveconnector with the pipeline’s Warehouse mounted on the pod. Its completion patch holds onlyoriginal_blobandoriginal_bytes, so text, vectors, other metadata and the source and page links are untouched, and a row that already has the reference completes with the same patch (idempotent). The original is attached only when its hash, size and provider version, item and drive match what the row was indexed from (rows indexed before those attributes existed are matched bywebUrland proven by hashing against theircontent_hash); a file that changed since is refused (ambiguous) and left for the pipeline to re-ingest, and a filename never selects an object.
Authorization
Every fetch passes the same checks as a read, in order, and the bytes are not read until all of them pass:
- A valid key with the
mcp.<name>entitlement. A missing, malformed, unknown or revoked key is refused before the tool runs. - A read grant on the row’s namespace.
- The row itself, read through the normal document path. An id that is missing or deleted gives a not-found error.
- The row’s stored reference must equal the address the gateway derives from the row’s own indexed hash and the granted namespace. A reference edited to name another namespace, other bytes, a bucket or a URL is refused.
- The stored bytes are hashed and must match the hash the row was indexed from, and the size and PDF header must match. Otherwise the call fails with a version mismatch.
Errors are explicit: no original retained, original missing from storage,
over maxBytes (with the advice to request pages and the source link), page out of range,
version mismatch, and render timeout (20 seconds).
Collapsed document search
Set collapse on an MCP namespace seed to use the gateway’s ranked-query
Collapse API. For example, the BCC document seed uses:
{"name":"pov-bcc-pages","toolName":"documents","link":"webUrl","collapse":{"by":"document"}}
by, expansion, and missing have the same meaning as on
ranked queries. by: "document" requires a parent ID or a
schema attribute marked document: true; an absent document-identity schema declaration fails the
search. Rows with missing values follow the gateway’s missing policy. The gateway’s expansion
default is tunable: larger windows can expose more groups at increased store
work and response size, up to the 10,000-row candidate cap. limit counts distinct groups within that expanded
candidate window; it does not guarantee a complete document search.
Collapsed searches return structuredContent.groups, in relevance order.
Each group contains key, by, and best (its best matching row, including
row ID, score, attributes and configured source link). Only that row is sent;
matching continuation pages and the gateway’s duplicate top-level rows are
omitted. The text content shows each best row’s excerpt and source link.
structuredContent.collapse retains the gateway’s read and exhausted
metadata. Paging walks groups: total counts groups and each
cursor continues at the next group. Cite the best row’s source link and use its ID with get_documents
for full details. get keeps its existing record response.
list_namespaces advertises the configured collapse object. Namespace seeds
without collapse retain flat search responses, including photo searches.
The same configuration applies when using generic search tools. Callers
cannot override collapse via tool arguments; authorization and filters still
run through the existing gateway paths.
Unavailable-source snapshots
fetch_original also accepts immutable inventory-snapshot original: IDs.
These references retain the exact inventoried revision after authenticated source
metadata returned 404 or 410. The response explicitly reports
retentionBasis=inventorySnapshot, sourceAvailability=unavailable, the recorded
observation status/time and deletionStatus=unknown. This is historical evidence,
not a live source check. SourceLink is labelled as an inventory citation.
Whole-file resources retain their actual PDF or PNG MIME type. PDF page requests work for retained PDFs; PNG page requests are rejected. Authorization, namespace binding and configured pins apply before bytes are returned. Internal S3 and proof locations are not exposed. Existing schema 1 originals remain readable; older gateway readers fail closed on snapshot schema 2. No new tool arguments or HTTP registration route are required.
Unclassified original bytes
An explicitly opted-in source original can be retained without an established
format. fetch_original returns application/octet-stream, declared MIME,
formatValidation=unverified, exact SHA/size/version and its source citation.
sourceFormatMismatch is unknown. This is a byte-fidelity proof, not a valid
container or PDF claim. Whole-file size limits, namespace grants and pins still
apply; requesting pages fails. Known corruption and error-response signatures
are refused even if an opaque catalog record was saved earlier.