PageWright MCP Server Handbook
Driving the layout engine from an LLM client.
Version 1.0 · October 2026 · 22 pages
# The PageWright™ MCP Server - A Handbook
Driving the layout engine from Claude, or any other MCP client: what the server is, how to install and register it, the eleven tools it exposes, and the workflow that keeps a generated document honest.
Laid out by PageWright itself. For authoring guidance see the Users Guide; for every spec key and its type, the JSON Spec Language Reference.
Drive the PageWright PDF engine by describing what you want.
This handbook is written for two readers at once. If you have never used an MCP server before, start at Part 1 and work forward - nothing is assumed. If you already know MCP and want the full capability surface, jump to Part 4.
Every number, output and screenshot in this document was produced by running the server, not typed from memory. See Keeping this handbook true.
# Part 1 - What this is
1.1 The idea in one paragraph
PageWright is a layout engine: you hand it a JSON description of a document and it hands back a PDF - paginated, typeset, tagged for accessibility, and validated against PDF/A or PDF/UA if you asked for that. It is a command-line program. This MCP server puts that program within reach of an AI assistant, so instead of writing the JSON yourself you can say "build me a two-page quarterly report with a bar chart and a table, make it PDF/UA-1, and show me the pages" - and the assistant writes the spec, validates it, renders it, looks at the result, and fixes what is wrong before handing it to you.
1.2 What MCP is, if you have never met it
MCP - the Model Context Protocol - is a standard way for an AI assistant to use a program on your computer.
Before MCP, an assistant could only work with what you pasted into the chat. MCP gives it tools: named operations it can call, with typed arguments, that do real work and return real results. Think of it as a driver interface. Your operating system does not know how your printer works; it knows the printer driver interface, and the driver knows the printer. MCP plays the same role between an AI client and any capability you want to expose to it.
Three words you will see:
| Term | What it means here |
| Host / client | The app you chat with - Claude Desktop, Claude Code, an IDE plugin, your own program. |
| Server | A small program exposing tools. The PageWright MCP server is one; you register its launcher, pagewright-mcp. It is not a web server. |
| Transport | How the two talk. This server uses stdio - plain pipes, on your machine only. |
1.3 What this server is - and is not
It is a small Python program - installed with PageWright, together with its own Python - that your AI client launches as a child process when it starts. The two exchange JSON messages over standard input and output. It runs as you, on your machine.
It is not a network service. There is no port, no URL, no account, no telemetry, and nothing leaves your computer. If your client is a local app, the whole loop is local.
It is thin. It adds no engine behavior. Everything it does, pagewrightapp already did on the command line; the server's contribution is packaging, guard rails and honest reporting. It can be deleted without a trace.
1.4 The mental model
you Claude Desktop / Claude Code your machine
| (the MCP client)
| "build me a
| tagged report" |
+--------------------------->
| launches once, at startup
| +--------------------------+
+----->| pagewright-mcp |
| | (stdio, eleven tools) |
| +-----------+--------------+
| | runs as subprocesses
| +-> pagewrightapp (the engine, and its schema check)
| +-> apdoclibapp (page images)
| +-> veraPDF (conformance; needs Java)
| |
|<-----------------+ JSON + page images
<---------------------------+
"here's page 1, and
the spec had one error"1.5 What makes PageWright worth driving this way
Four properties, and they compound:
- The document language has a published JSON Schema. The assistant can look up exactly what a table accepts instead of guessing.
- Layout is deterministic and fast. A page renders in well under a second, so an author-check-fix loop actually converges.
- The engine reports where every block landed. Page and rectangle, keyed by JSON Pointer - so "why did this spill onto page 3?" has a factual answer.
- There is a corpus of worked samples demonstrating every feature, searchable in place.
1.6 The one thing to understand before you start
The engine parses tolerantly, on purpose. A spec with a misspelled key and an invented block type renders happily - exit 0, nothing on stderr:
{"blocks": [{"type": "paragraph", "texx": "oops"}, {"type": "nonesuch"}]}-> wrote out.pdf (1 page)
Unknown keys are ignored; unknown blocks are dropped. Silently. So "it rendered" proves nothing, and an assistant that only renders will confidently hand you a document you did not ask for.
This is the single fact the server is built around. Every tool that accepts a spec runs the engine's own schema check over it too, and reports schema_valid alongside whatever else it did - whether you asked for validation or not. Part 3.2 shows this happening for real, with pictures.
Note: Validation is the gate. The render is the demonstration.
# Part 2 - Setup
2.1 What you need
| Component | Required? | Without it |
| PageWright, installed | yes | The macOS or Windows installer puts the engine, this server, the Python it runs on and the sample corpus in place. Nothing else is downloaded. |
| An MCP client | yes | Claude Desktop, Claude Code, or any client that speaks MCP over stdio. |
| Java 8 or later | only for check_conformance | Every other tool works. check_conformance says that Java is missing and names free builds to install. |
The server brings its own Python (3.14) with the one package it needs, the MCP SDK; it does not use, or disturb, any Python you have. Page images are drawn by Appligent's own PDF server, and specs are checked by the engine itself. The one program it runs that is not Appligent's is veraPDF, the PDF Association's reference validator for PDF/A and PDF/UA. It is installed with PageWright, but it is a Java program and Java is not included. Any Java 8 or later works, and several are free: Eclipse Temurin (adoptium.net), Amazon Corretto, or Microsoft's build of OpenJDK. A missing dependency is reported by name, never as silence.
2.2 Where it is installed
| macOS | Windows | |
| The server - register this | /Applications/Appligent/PageWright/MCP/pagewright-mcp | C:\Program Files\Appligent\PageWright MCP\pagewright-mcp.cmd |
| Its Python | /Applications/Appligent/PageWright/MCP/python/ | C:\Program Files\Appligent\PageWright MCP\python\ |
| The engine | /Applications/Appligent/pagewrightapp | C:\Program Files\Appligent\pagewrightapp.exe |
| veraPDF | /Applications/Appligent/PageWright/veraPDF/ | C:\Program Files\Appligent\veraPDF\ |
The server needs no settings. It finds the engine, Appligent's PDF server and veraPDF where the installer put them, and the schema and the sample corpus beside itself. 2.8 lists the variables that point it somewhere else, if you ever want that.
2.3 Prove it works before involving a client
The server can exercise every one of its tools against the real engine with no MCP client in the picture at all. Always do this first - it separates "the server is broken" from "my client configuration is wrong", which are very different afternoons.
On macOS, in Terminal:
/Applications/Appligent/PageWright/MCP/pagewright-mcp --selftest
On Windows, in a Command Prompt:
"C:\Program Files\Appligent\PageWright MCP\pagewright-mcp.cmd" --selftest
It prints a line per check and ends with ALL OK. It covers the tool registry, engine discovery, both validation verdicts, rendering, image attachment, the tolerant-parse trap, path containment on every spec-taking tool, the asset round-trip, missing-asset reporting, every schema fragment shape, corpus search hits, deep nesting, font grouping, and conformance flavour resolution. Without Java, the one conformance check is reported as skip, with the reason - an installation without Java is working as shipped.
If something is missing, the failing line names it.
2.4 Register with Claude Desktop
Edit - creating it if absent:
| OS | Path |
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
On macOS:
{
"mcpServers": {
"pagewright": {
"command": "/Applications/Appligent/PageWright/MCP/pagewright-mcp"
}
}
}On Windows, register the server's Python and the server itself - a client cannot always start a .cmd file directly:
{
"mcpServers": {
"pagewright": {
"command": "C:\\Program Files\\Appligent\\PageWright MCP\\python\\python.exe",
"args": ["-E", "-s", "C:\\Program Files\\Appligent\\PageWright MCP\\server\\aplayout_mcp.py"]
}
}
}Then restart Claude Desktop - the config is read once, at launch. Use absolute paths: the client does not inherit your shell or your PATH. (-E -s keep any Python settings of your own from reaching the server's Python.)
2.5 Register with Claude Code
Everything after -- is the command to run. On macOS:
claude mcp add pagewright -- /Applications/Appligent/PageWright/MCP/pagewright-mcp
On Windows:
claude mcp add pagewright -- "C:\Program Files\Appligent\PageWright MCP\python\python.exe" -E -s "C:\Program Files\Appligent\PageWright MCP\server\aplayout_mcp.py"
Check it registered with /mcp in an interactive session.
2.6 Register with any other MCP client
Anything that speaks MCP over stdio works. The client needs exactly two things:
- command - on macOS the launcher, /Applications/Appligent/PageWright/MCP/pagewright-mcp; on Windows the server's python.exe
- args - none on macOS; on Windows -E -s and the path of server\aplayout_mcp.py
No environment is needed. There is no HTTP endpoint and no authentication step, because there is no network hop.
2.7 Which engine did it find?
The server uses the engine the installer put beside it, and engine_info says which binary that is - read its engine field if a result ever surprises you. The search order, first that exists:
- $APLAYOUT_APP, if you set it
- the install folder - /Applications/Appligent on macOS (or /usr/local/appligent), C:\Program Files\Appligent on Windows
- whatever pagewrightapp resolves to on your PATH
The server does not merely find the binary: engine_info executes it and confirms it responds (engine_runs), so a damaged, quarantined or wrong-architecture engine is caught there rather than on your first render.
One case is reported loudly: if APLAYOUT_APP is set but is not an executable file, the server falls back to the search above and returns a configuration_warning saying so, rather than silently using a different binary than the one you named.
2.8 Configuration reference
None of these is needed for the installed server; each exists to point it somewhere else.
| Variable | Default | Purpose |
| APLAYOUT_APP | the installed engine (2.7) | The engine binary. |
| APLAYOUT_APDOCLIB | apdoclibapp beside the engine | Appligent's PDF server, which draws the page images. |
| APLAYOUT_HOME | the folder above the server's own | Supplies aplayout.schema.json, sample/ and the sample descriptions. |
| APLAYOUT_MCP_WORKSPACE | aplayout-mcp in your temporary folder | Where renders happen. Every path a spec references must resolve inside it. |
| VERAPDF | the installed veraPDF | The conformance checker. |
| JAVA_HOME | the Java your system finds | The Java veraPDF runs on. |
2.9 First contact
Ask your assistant:
Note: "Call engine_info and tell me what it says."
A healthy installation looks like this - real output, trimmed only of absolute paths:
{
"server_version": "1.2.0",
"engine": ".../pagewrightapp",
"engine_runs": true,
"engine_usage_line": "usage: .../pagewrightapp <spec.json> <out.pdf> [-map <map.json>] ...",
"validator": ".../pagewrightapp -checkschema (the engine's own)",
"page_images": ".../apdoclibapp",
"verapdf": ".../veraPDF/verapdf",
"java": ".../zulu-17.jdk/Contents/Home",
"corpus_samples": 281,
"corpus_described": 281,
"block_types_in_schema": 46
}java names the Java veraPDF will run on - or says NOT FOUND, and that check_conformance needs Java 8 or later. Everything else works without it.
# Part 3 - Your first documents
3.1 Just ask
You do not call tools yourself - you describe what you want and the assistant chooses. Prompts that work well:
Note: "Find a sample that uses a sankey chart and show me how it's built."
Note: "Build a one-page tagged report titled Quarterly Summary with a bar chart of revenue by region and a table underneath. Validate it, render it, and show me the page."
Note: "This spec puts the appendix on page 4 and I want it on 3. Use the layout map to work out why."
Note: "Take my logo - here it is - and put it in the header of that report."
Note: "Is this document really PDF/UA-1? Prove it."
3.2 Worked example 1 - the trap the server exists to catch
This is not a contrived illustration. It is what happened when this handbook's example spec was written, before anyone checked it.
The spec - a heading, a paragraph, and a bar chart:
{
"title": "Quarterly Summary",
"tagged": true,
"lang": "en-US",
"blocks": [
{"type": "heading", "level": 1, "text": "Quarterly Summary"},
{"type": "paragraph", "text": "Revenue grew in every region."},
{"type": "chart", "chartType": "bar", "height": 200,
"categories": ["North", "South", "East", "West"],
"series": [{"name": "Revenue", "values": [42, 31, 58, 27]}]}
]
}Reasonable-looking. values is what most charting libraries call that key. PageWright calls it points.
What render_spec reported:
{
"ok": true,
"pages": 1,
"seconds": 0.835,
"engine_errors": [
"aplayout: \"values\" is not a key this accepts (at /blocks/2/series/0/values)"
],
"engine_warnings": [],
"schema_valid": false
}Read that again. The render succeeded: ok, one page, in under a second. The engine checks every spec against the schema as it renders, and it printed one line about the key - then drew the document anyway, because tolerance is its job. An integration that checks ok, or the exit status, says the document is fine.
What it actually produced: a chart frame with its axes defaulted to 0.0-1.0, a legend faithfully reading "Revenue", and not one bar. The data was silently dropped, because values is not a key the engine knows.
What told the truth was schema_valid: false, and the error beside it named the exact spot:
{
"pointer": "/blocks/2/series/0/values",
"message": "\"values\" is not a key this accepts"
}Corrected - points, as [x, y] pairs:
{"type": "chart", "chartType": "bar", "height": 200,
"series": [{"name": "Revenue", "points": [[1, 42], [2, 31], [3, 58], [4, 27]]}]}{"ok": true, "pages": 1, "seconds": 0.775, "engine_errors": [], "schema_valid": true}Four bars, axis to 60. The ok, pages and seconds fields are nearly identical between the broken document and the correct one. schema_valid - and the engine's one line about it - is the entire difference.
Takeaway. When your assistant reports a successful render, the question to ask is not "did it render?" but "was it valid?". The server always answers both; make sure the answer to the second one gets read.
3.3 Worked example 2 - learn a feature from the corpus
The fastest way to use an unfamiliar feature is to read a spec that already uses it. Ask for vertical Chinese and search_corpus returns, genuinely:
{
"match_quality": "strong",
"matches": [
{"name": "vertical-cjk", "score": 18.8, "title": "Vertical Chinese in a horizontal page",
"features": ["writing:vertical-lr", "writing:vertical-rl"], "standards": ["tagged"]},
{"name": "vertical-book", "score": 15.8, "title": "结构力学讲义",
"features": ["writing:vertical-rl"], "standards": ["tagged"]},
{"name": "vertical-align", "score": 9.5, "title": "Vertical Justification"}
]
}Each carries the prose description from the sample catalog, so the assistant can tell vertical-cjk (vertical text inside an ordinary horizontal page) from vertical-book (the whole page axis turned, right-edge binding, /ViewerPreferences /Direction /R2L) before opening either. Then get_sample("vertical-cjk") returns the whole working spec.
The ranking prefers use over mention. A sample that genuinely contains a sankey chart outranks a generated manual that merely documents one. Hyphens fold, so "boxplot" finds "box-plot". Large showcase documents are demoted so they cannot swamp every search.
Watch match_quality: strong, weak or poor. poor means nothing really fits - it is a signal to rephrase, not a shortlist to trust.
3.4 Worked example 3 - getting an image in
A spec may not reference a file anywhere on your disk. Every path is confined to the workspace, so there is exactly one route in: upload it.
put_asset(name="logo.png", data_base64="iVBORw0KG...")
-> {"ok": true, "reference": "assets/logo.png", "bytes": 4096}Then reference it by that name:
{"type": "image", "src": "assets/logo.png", "alt": "Company logo"}Always set alt - without it a tagged document is not accessible, and a PDF/UA claim over it is false.
A photo can go in as it came off the camera. PNG, JPEG, GIF, TIFF and WebP decode everywhere; HEIC / HEIF decodes when the engine runs on macOS, and anywhere else draws a placeholder and says so. A sideways phone photo is stood upright from its EXIF (or HEIF) orientation and its box sized that way, and an embedded ICC profile - Adobe RGB, Display P3 - is kept, so do not pre-convert either. To aim a crop, give the image "fit": "cover" and a "focus": [x, y] in fractions of the upright photo ([0.5, 0.2] keeps faces near the top in frame), plus "zoom": 1.5 to move in closer (in a data-bound template either key may be a "{{path}}" token, so each record aims its own crop). For print, "images": {"minPpi": 250} makes the engine name any image block placed below that resolution:
{"images": {"minPpi": 250},
"blocks": [{"type": "image", "src": "assets/IMG_0412.heic", "alt": "The harbor at dawn",
"width": 300, "height": 200, "fit": "cover", "focus": [0.5, 0.35]}]}That report, a refused profile and a HEIC this build cannot decode all arrive in engine_warnings, not engine_errors - the document still rendered, and the line ends with the JSON Pointer of the block to fix.
If you get the name wrong, nothing dramatic happens, which is the problem. The engine draws a grey placeholder box, the render still succeeds, and the only trace is one warning line (image "nope.png" cannot be read (not found) - a placeholder box is drawn (at /blocks/0)). The server catches it before the render, with the pointer, in a field of its own:
{
"ok": true, "pages": 1, "schema_valid": true,
"missing_assets": [{
"pointer": "/blocks/0/src",
"value": "assets/nope.png",
"note": "file not found; the engine draws a placeholder in its place."
}],
"hint": "Referenced files are missing; the engine drew placeholders in their place."
}Note ok: true and schema_valid: true. A missing image is neither a render failure nor a schema error. It is its own category, and it has its own field.
3.5 Worked example 4 - proving conformance
Claiming a standard and meeting it are different things. The corrected report from 3.2 is "tagged": true with a "lang", so check_conformance renders it and puts it through veraPDF. As written there it fails PDF/UA-1, on one rule:
{"rule": "7.3-1",
"description": "Figure tags shall include an alternative representation or replacement text ...",
"failed_checks": "1",
"example": "Figure structure element neither has an alternate description nor a replacement text"}The chart has no alt, so a screen reader would meet a picture it cannot describe. Give it one - "alt": "Bar chart: revenue by region, highest in the East" - and ask again:
{
"ok": true,
"checked": [{"flavour": "ua1", "compliant": true,
"passed_rules": "106", "failed_rules": "0",
"failures": [], "seconds": 0.77}],
"all_compliant": true,
"schema_valid": true
}106 rules passed, none failed. When rules do fail you get up to twenty of them, each identified as clause-testNumber with one concrete example. veraPDF needs Java 8 or later; without it, check_conformance says so instead (see 2.1).
The flavour comes from your spec, never from the finished PDF. veraPDF can auto-detect, and auto-detect is a trap here: it reads the file's own metadata, so a document that silently lost its declaration gets validated as whatever it now claims to be - and falls back to a lenient 1b when detection fails. A lost declaration would turn into a passing check. Resolving the flavour from the spec means you are always testing the claim you made. The full resolution table is in Appendix B.
# Part 4 - Capability reference
Eleven tools. Four of them accept a document spec, and each takes either an inline spec object or a sample name from the corpus - never both, never neither.
| Tool | Takes a spec | What it is for |
| engine_info | - | Diagnose the installation. Call it first. |
| validate_spec | [x] | Is this document what I think it is? |
| render_spec | [x] | Make the PDF, and look at it. |
| layout_map | [x] | Where did everything land? |
| check_conformance | [x] | Does it meet the standard it claims? |
| search_corpus | - | Find a worked example. |
| get_sample | - | Read that example in full. |
| get_schema | - | What keys does this block accept? |
| list_fonts | - | What can I embed? |
| put_asset | - | Get a file into the workspace. |
| get_artifact | - | Get the finished PDF out. |
Fields every spec-taking tool returns
Whatever the tool was asked to do, these ride along. They are attached by the shared intake path, so no tool can forget them:
| Field | Meaning |
| schema_valid | true, false, or null - null means the check could not run, which is not the same as valid. |
| validation | Present when invalid: error_count plus up to 40 errors, each a pointer and a message. |
| missing_assets | Referenced files that do not exist. The engine draws placeholders and says nothing. |
| hint | Plain-language next step when something is wrong. |
engine_info
Parameters: none.
Reports server_version, engine, engine_runs, engine_usage_line, aplayout_home, schema, sample_dir, workspace, asset_dir, validator, page_images, verapdf, java, corpus_samples, corpus_described, block_types_in_schema, and configuration_warning when a discovered path looks wrong.
This is the authority for every count. Nothing else - including this handbook - should be trusted over it, because it counts at call time and everything else was typed at some point in the past.
validate_spec
Parameters: spec (object) or sample (string).
Checks the spec against the published JSON Schema with the engine's own schema check - the same check the engine runs on every render - and reports the fields above. A spec it calls valid is one the engine accepts. Each error's pointer is a JSON Pointer, the same scheme layout_map uses, so the two line up.
Where a value fits none of the shapes its key allows - a style that is neither a style name nor a style object, say - the error is at that value and says so. Read the key in get_schema for the shapes it takes.
render_spec
| Parameter | Default | Meaning |
| spec | sample | - | Exactly one. |
| return_images | true | Rasterize pages and attach them as real image content. |
| first_page | 1 | 1-based page to start rasterizing from - how you see page 7 of a long document. |
| pages | 2 | How many pages from first_page. Capped at 4. |
| dpi | 80 | Clamped to 40-150. 80 is legible and cheap. |
| keep_pdf | false | Keep the PDF and return an artifact token for get_artifact. |
| impose | false | Also make the booklet print copy - the spec's impose layout, or two pages side by side: pages two to a sheet side in saddle-stitch order, to print both sides, fold and staple. The images are still the reading PDF's pages. |
Returns: ok, pages, seconds, engine_errors, engine_warnings, pdf_bytes, images_returned, image_pages, image_dpi, plus the universal fields - and the rasterized pages as image content blocks a vision-capable model can actually look at. With impose, also booklet_sheets (the print copy's sheet count; ok is false if it could not be written), and with keep_pdf a booklet_artifact token for get_artifact as well.
Notes worth knowing:
- Errors and warnings are separated by what they say, not by prefix. Almost every engine line begins aplayout:, so prefix-matching called all of them errors. Font substitutions, snapping and fallbacks land in engine_warnings; a real failure like cannot write layout map has no prefix at all and still lands in engine_errors.
- A render over 500 pages is reported with a warning rather than passed off as normal.
- If output hits the size cap it is reported as a failure, not returned truncated.
- Page images are drawn by Appligent's own PDF server. If one cannot be drawn you get images_note saying why - not silence.
layout_map
Parameters: spec | sample, and pointer_filter (keep only blocks whose pointer starts with a prefix).
Runs the engine in measure-only mode - no PDF is produced - and returns the geometry of everything:
{
"units": "pt", "origin": "top-left", "page_count": 1,
"pages": [{"width": 612.0, "height": 792.0, "label": "1", "section": 0,
"content": {"x": 54.0, "y": 54.0, "w": 504.0, "h": 684.0}}],
"blocks": [
{"path": "/blocks/0", "type": "heading",
"fragments": [{"page": 0, "x": 54.0, "y": 54.0, "w": 504.0, "h": 37.8}]},
{"path": "/blocks/2", "type": "chart",
"fragments": [{"page": 0, "x": 54.0, "y": 105.0, "w": 504.0, "h": 180.0}],
"visual": [{"page": 0, "x": 54.0, "y": 105.0, "w": 504.0, "h": 180.0}]}
]
}Three things to hold onto:
- path is an RFC 6901 JSON Pointer into your spec - the same scheme validation errors use.
- page is 0-based, while page labels are what the reader sees. Do not conflate them.
- fragments is a list because a block can split across pages. A table that breaks over three pages has three fragments.
- type is the engine's internal kind, which for a few authored types is the primitive they desugar onto - a callout reports as a container.
This is what answers "why is this on page 3?", and what lets a design tool hit-test a rendered page.
check_conformance
Parameters: spec | sample, and flavour to override the spec's own claim.
Renders, then validates with veraPDF. Per flavour you get compliant, passed_rules, failed_rules, up to 20 failures (each clause-testNumber, a description and one example), and seconds. Plus all_compliant across every flavour checked.
all_compliant can be null. If any referenced file was missing, the document that was checked is not the document you meant, so the verdict is withheld rather than reported as a pass, with a verdict_caveat saying why. A verdict reached without the document's own assets is not a verdict.
Slower than rendering - run it once at the end, not in a tight loop.
It needs Java 8 or later, which PageWright does not include. Without it the tool still runs the intake - schema_valid and the rest - and then returns ok: false with an error that says Java is missing and names free builds, before any render. Install one and ask again; the server does not need restarting.
search_corpus
Parameters: query (words or block-type names), limit (default 8, max 25).
Returns match_quality and per match: name, score, title, bytes, block_types, features, standards, description. Ask engine_info how large the corpus is.
get_sample
Parameters: name (with or without .json), max_bytes (default 120 000).
Returns the sample's full spec plus its catalog description. The cap exists because the generated guides run to megabytes and one of them would swallow your entire context. Refusal tells you the real size so you can raise the limit deliberately:
{"ok": false, "error": "sample is 494336 bytes (limit 2000)",
"description": "The PageWright technical brochure, laid out by PageWright: ...",
"hint": "raise max_bytes deliberately, or study a smaller sample"}get_schema
Parameters: fragment (default "index"), expand (default true).
The whole schema is large - 1,057,124 bytes at the time of writing, and growing with every feature. Fetching all of it to answer "what keys does a table take?" is waste, so it is served in slices:
| Fragment | Returns |
| "index" | A menu of every fragment with its live byte cost. Start here - and trust it over any figure written in prose, including the ones below |
| "doc" | Document-level properties |
| "block:<type>" | One block type, e.g. block:table |
| "defs:<name>" | One definition, e.g. defs:style |
| "full" | Everything, trimmed on the way back |
[!] The sizes in this handbook are a snapshot and go stale. An earlier revision said the schema was 291,596 bytes; it is now 1,057,124 - 3.6x that, and none of the per-fragment figures it quoted survived either. "index" computes them from the file on every call, which is why it is the first row of the table and not the last.
Fragments are self-contained: the $defs a slice references travel with it, so it validates standalone.
[!] One documented exception. The recursive block definition - about 680 KB, four-fifths of the whole schema - is replaced inside other fragments by a permissive placeholder. Expanding it would make block:table an order of magnitude bigger; dropping it would be worse than useless, because a dangling $ref makes a JSON Schema validator abort instead of validating leniently. Consequence: a fragment does not validate NESTED blocks. For whole-tree strictness, fetch each nested type separately or ask for full.
Asking for defs:block by name returns the real definition, not the placeholder - handing back a 282-byte stub with ok: true would be a silent lie.
list_fonts
Parameters: contains (case-insensitive substring), limit (default 60).
Faces grouped by family. The family value is what goes in a spec's fontFamily / embedFont:
{"ok": true, "family_count": 7, "face_count": 14,
"families": [{"family": "STSongti",
"faces": ["STSongti-SC-Black", "STSongti-SC-Bold", "STSongti-SC-Light",
"STSongti-SC-Regular", "STSongti-TC-Bold", "..."]}]}put_asset
Parameters: name (a bare filename - letters, digits, dot, dash, underscore, space; no directories), data_base64.
Returns the reference to use in a spec: assets/<name>. Cap: 16 MB.
This is the only way to get a file into a rendered document. Every path outside the workspace is refused, so a spec cannot point at a file on your disk.
get_artifact
Parameters: token (the artifact value from render_spec(keep_pdf=true), or its booklet_artifact for the print copy), max_bytes (default 12 MB - base64 inflates by a third).
Returns the PDF as base64. Tokens are unique per render, so rapid or concurrent calls never collide; they do not persist forever.
# Part 5 - The workflow that works
The server tells every connected model this sequence, and it is the one to follow:
search_corpus -> get_schema -> write the spec -> validate_spec
v
+---------------- not valid ---------+
v v valid
fix the pointers render_spec (return_images)
v
LOOK at the pages
v
layout_map (if placement is wrong)
v
check_conformance (once, at the end)Why each step earns its place:
- search_corpus before writing anything. A working spec beats a guessed one. The corpus demonstrates every feature the engine has.
- get_schema for the block types you need - the index fragment first, so you spend bytes deliberately.
- validate_spec always. Part 3.2 is why. This is not optional diligence; it is the only thing standing between you and a confidently wrong document.
- render_spec with return_images, and actually look. Geometry metadata says "fine" on a surprising number of real visual defects. A picture does not.
- layout_map when placement is the question - it gives page and rectangle instead of guesses. To centre content in a positioned box, do not compute coordinates: "verticalAlign": "middle" on the block and "blockAlign": "center" in its style (lines flush left as a group). In a data-bound template, position (field by field, or the whole box) and the transform (angle, scale, skew, mirrorH, mirrorV) may be "{{path}}" tokens so each record places its own sticker; a record with no usable x / y is reported and that block is not drawn. To place stickers relative to their card, write "childOrigin": "container" on the card's container: its positioned children's x / y then count from the card's top-left corner (the map still reports page coordinates).
- check_conformance last. It renders and runs veraPDF; it is the slowest thing here.
[!] A CLEAN check_conformance DOES NOT MEAN THE STRUCTURE IS RIGHT. veraPDF answers "is this valid PDF/UA?", which is a different and much narrower question than "will a screen reader make sense of this?". A whole class of decisions is invisible to it, and the engine now exposes each one as an explicit key precisely because no validator will ever prompt you:
| key | the choice it makes |
| role on a container | whether a tinted box is an <Aside> a reader can skip, or nothing at all |
| outline: false on a heading | whether a card title is a tagged heading without becoming a bookmark |
| sectionTags | how many <Sect> wrappers a reader walks through before reaching content |
| rowGroups on a table | whether a hierarchical schedule tags as a flat grid or with real row-group headers and derived /Headers |
Every one of those is veraPDF-clean in both settings. A document with 137 subheads tagged <P> instead of <H2> passed veraPDF with zero failures and our own structure audit with zero findings - the defect was found by a person opening the tag tree. When accessibility is the point of the document, check_conformance is a floor, not a verdict.
Addressing: one scheme
Everything that names a place in a spec names it the same way: an RFC 6901 JSON Pointer - /blocks/0, /styles/Body Text/fontSize, /styles/a~1b for a key that contains a slash. The schema check's errors, the layout map's path, missing_assets and unsafe_paths all use it, so an error and the block it is about can be matched by comparing strings.
# Part 6 - Safety, limits and confinement
6.1 One intake path
Every tool that accepts a spec goes through the same funnel: resolve -> stage in a private temp directory -> refuse unsafe paths -> validate -> report. No tool can skip a check, because no tool implements the checks.
That design is a scar. An earlier version let check_conformance do its own intake, and an adversarial audit proved it would embed a file from outside the workspace and still return a clean conformance verdict.
6.2 Workspace confinement
Every path a model-supplied spec references must resolve inside the workspace. Refused, each with a reason:
| Refused | Reason given |
| /etc/passwd | absolute path |
| ~/secrets.pdf | home-relative path |
| file://..., http://..., any scheme | URIs are not fetched by this server; upload the file with put_asset instead |
| a\x00b | path contains a NUL byte |
| ../../elsewhere | resolves outside the workspace (parent traversal or symlink escape) |
{"ok": false,
"error": "spec references paths outside the workspace; refusing",
"unsafe_paths": [{"pointer": "/blocks/0/src", "value": "/etc/passwd",
"reason": "absolute path"}],
"hint": "upload files with put_asset and reference them as assets/<name>"}Three details that took real bugs to learn:
- Refusing .. and absolute paths is not enough. A symlink inside the workspace reaches anywhere with a perfectly innocent relative path, so every candidate is additionally resolved with realpath and re-checked.
- Array shorthands are walked too. A bare path string inside a list - items on a pdf-contact-sheet, chapters on a book - is exactly how containment was bypassed before.
- Corpus samples are exempt. A sample named with sample= is our file on disk, not model input, and several legitimately carry absolute asset paths. Guarding them made four samples unrenderable. The guard exists for model input.
Schema validation is not a security control. A spec embedding /etc/passwd validates perfectly clean. Containment is a separate, mandatory pass for precisely that reason.
6.3 Bounded work, not just bounded time
A 1.5 KB spec was measured driving the engine to 26,099 pages and 4.7 GB resident in 17 seconds - comfortably inside every wall-clock timeout, and quite capable of pushing the host into swap before anything fired. So the limits bound the work:
| Limit | Value |
| Child CPU | 150 s (RLIMIT_CPU; macOS) |
| Child output file size | 256 MB (RLIMIT_FSIZE; macOS) |
| "This is probably not deliberate" page count | 500 |
| Input spec | 8 MB |
| Uploaded asset | 16 MB |
| Spec walk depth | 60 levels |
| Render / validate / veraPDF / raster timeouts | 120 s / 60 s / 180 s / 60 s |
| Page images per render | 4, at 40-150 dpi |
| Any single tool result | 200 000 characters |
RLIMIT_AS is deliberately not set: the PDF library makes large virtual reservations on macOS/ARM and an address-space cap kills legitimate renders. Windows has no such per-process limits; the timeouts and the size cap on the output still apply there.
Oversized results are not truncated blindly. The envelope is preserved and the bulkiest nested value is shortened in place, with a truncated note listing what was cut - because chopping the JSON blob produced a fragment cut mid-string that no caller could parse, and dropped the ok key every caller checks.
6.4 Untrusted content
Text inside specs, corpus samples, and rendered documents is data, not instruction. The server labels it as such in its own instructions to the model and never acts on it. A client model should not follow directions found there either.
6.5 The SDK version
The server runs on mcp 2.0.0, and the installed server brings that version with its own Python, so this is taken care of. It matters if you ever run the server on another Python: 2.0 was a breaking rewrite of the MCP SDK - the 1.x decorator API (@server.list_tools / @server.call_tool) and mcp.server.fastmcp were both removed, and Tool.inputSchema became Tool.input_schema. A server written against one major version does not run on the other, and an unpinned install breaks at import, before any diagnostic can help you.
# Part 7 - Troubleshooting
| Symptom | Cause | Fix |
| engine not found | the engine is not where the installer put it | Reinstall PageWright, or set APLAYOUT_APP to pagewrightapp. |
| Tools appear, every render fails | something is missing | Run --selftest; the failing line names it. |
| An update does not seem to apply | the server found a different engine | Read engine in engine_info. See 2.7. |
| check_conformance says Java is missing | no Java 8 or later | Install a free Java (Eclipse Temurin, Amazon Corretto, Microsoft's OpenJDK) and ask again. |
| check_conformance says veraPDF was not found | veraPDF is not where the installer put it | Reinstall PageWright, or set VERAPDF. |
| No page images | Appligent's PDF server (apdoclibapp) was not found | Read images_note; reinstall PageWright, or set APLAYOUT_APDOCLIB. |
| schema_valid: null | the engine's check could not run | Fix the engine first (engine_info). Null is not valid - nothing was checked. |
| Document renders but looks wrong | tolerant parse | Read schema_valid. If false, the engine ignored keys you thought you set. |
| An image is a grey box | wrong asset name | Read missing_assets. Upload with put_asset, reference as assets/<name>. |
| Client shows no tools, or hangs at startup | something wrote to stdout | See below. |
| Config edited, nothing changed | config is read at launch | Restart the client. |
The stdout rule
The stdio stream carries only protocol JSON. If you modify this server, never print() to stdout - a stray write with no newline prefixes the initialize response and the client waits forever, showing no tools and no error. All diagnostics go to stderr; the server's own startup warnings do exactly that.
# Appendix A - Block type names
engine_info reports 46 block type names, but they are not 46 different blocks: several names are aliases with byte-identical schema branches. There are 33 distinct block types.
admonition bar barcode bibliography callout cardGrid cardgrid chart code columns contact-sheet container diagram endnotes equation field frame glossary heading if image include index list loc lof lol lot marker math mermaid note page-break pagebreak paragraph pdf pdf-contact-sheet pre references repeat rule shape spacer svg table toc
Alias groups, verified by identical schema branches:
| These names mean the same block |
| admonition · callout · note |
| bibliography · references |
| cardGrid · cardgrid |
| code · pre |
| contact-sheet · pdf-contact-sheet |
| diagram · mermaid |
| equation · math |
| loc · lof · lol · lot · toc |
| page-break · pagebreak |
get_schema("index") lists them live with the byte cost of each.
# Appendix B - Conformance flavour resolution
Derived from the spec, never from the produced PDF.
| Spec declares | Flavour checked |
| pdfua: 2 | ua2 only - a UA-2 file legitimately fails ua1 (PDF 2.0 header, pdfuaid:part=2) |
| pdfua: 1 / true, or tagged: true | ua1 |
| pdfa: "2b", "2A", "PDF/A-3u" ... | that level, case- and prefix-normalized |
| pdfa: true | 2a when tagged, else 2u |
| any *a conformance level | also ua1 - an *a level forces tagging, so it is a UA-1 candidate even if tagged was never written |
| any of the above plus attachments | promoted: 2*->3*, 4->4f |
| pdfx | not checkable - this veraPDF build ships no PDF/X profile. Reported in notes, never silently skipped. |
Available flavours: 1a 1b 2a 2b 2u 3a 3b 3u 4 4f 4e ua1 ua2 wt1r wt1a.
# Appendix C - Keeping this handbook true
Every count that could be counted is reported by engine_info, not typed here. Corpus size, schema bytes, block-type count - all live, because a number typed into a document goes stale, and a tool description is a static string that cannot stay true at all.
The few figures written out above - 46 names / 33 distinct types, the schema's size and its block definition's, the caps in 6.3 - are constants of the server or were measured while writing this. Re-measure before quoting them anywhere that matters.
Everything in Part 3 is transcribed from real runs, including the failures.
The end-to-end check is --selftest. Run it after installing or moving anything, and before believing a client-side problem is the client's.
PageWright is an Appligent product. The engine is pagewrightapp; this server is a wrapper over it and adds no layout behavior of its own.
