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:

TermWhat it means here
Host / clientThe app you chat with - Claude Desktop, Claude Code, an IDE plugin, your own program.
ServerA small program exposing tools. The PageWright MCP server is one; you register its launcher, pagewright-mcp. It is not a web server.
TransportHow 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:

  1. The document language has a published JSON Schema. The assistant can look up exactly what a table accepts instead of guessing.
  2. Layout is deterministic and fast. A page renders in well under a second, so an author-check-fix loop actually converges.
  3. 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.
  4. 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

ComponentRequired?Without it
PageWright, installedyesThe 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 clientyesClaude Desktop, Claude Code, or any client that speaks MCP over stdio.
Java 8 or lateronly for check_conformanceEvery 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

macOSWindows
The server - register this/Applications/Appligent/PageWright/MCP/pagewright-mcpC:\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/pagewrightappC:\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:

OSPath
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:

  1. $APLAYOUT_APP, if you set it
  2. the install folder - /Applications/Appligent on macOS (or /usr/local/appligent), C:\Program Files\Appligent on Windows
  3. 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.

VariableDefaultPurpose
APLAYOUT_APPthe installed engine (2.7)The engine binary.
APLAYOUT_APDOCLIBapdoclibapp beside the engineAppligent's PDF server, which draws the page images.
APLAYOUT_HOMEthe folder above the server's ownSupplies aplayout.schema.json, sample/ and the sample descriptions.
APLAYOUT_MCP_WORKSPACEaplayout-mcp in your temporary folderWhere renders happen. Every path a spec references must resolve inside it.
VERAPDFthe installed veraPDFThe conformance checker.
JAVA_HOMEthe Java your system findsThe 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.

ToolTakes a specWhat 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:

FieldMeaning
schema_validtrue, false, or null - null means the check could not run, which is not the same as valid.
validationPresent when invalid: error_count plus up to 40 errors, each a pointer and a message.
missing_assetsReferenced files that do not exist. The engine draws placeholders and says nothing.
hintPlain-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
ParameterDefaultMeaning
spec | sample - Exactly one.
return_imagestrueRasterize pages and attach them as real image content.
first_page11-based page to start rasterizing from - how you see page 7 of a long document.
pages2How many pages from first_page. Capped at 4.
dpi80Clamped to 40-150. 80 is legible and cheap.
keep_pdffalseKeep the PDF and return an artifact token for get_artifact.
imposefalseAlso 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:

FragmentReturns
"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:

  1. search_corpus before writing anything. A working spec beats a guessed one. The corpus demonstrates every feature the engine has.
  2. get_schema for the block types you need - the index fragment first, so you spend bytes deliberately.
  3. 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.
  4. render_spec with return_images, and actually look. Geometry metadata says "fine" on a surprising number of real visual defects. A picture does not.
  5. 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).
  6. 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:

keythe choice it makes
role on a containerwhether a tinted box is an <Aside> a reader can skip, or nothing at all
outline: false on a headingwhether a card title is a tagged heading without becoming a bookmark
sectionTagshow many <Sect> wrappers a reader walks through before reaching content
rowGroups on a tablewhether 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:

RefusedReason given
/etc/passwdabsolute path
~/secrets.pdfhome-relative path
file://..., http://..., any schemeURIs are not fetched by this server; upload the file with put_asset instead
a\x00bpath contains a NUL byte
../../elsewhereresolves 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:

LimitValue
Child CPU150 s (RLIMIT_CPU; macOS)
Child output file size256 MB (RLIMIT_FSIZE; macOS)
"This is probably not deliberate" page count500
Input spec8 MB
Uploaded asset16 MB
Spec walk depth60 levels
Render / validate / veraPDF / raster timeouts120 s / 60 s / 180 s / 60 s
Page images per render4, at 40-150 dpi
Any single tool result200 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

SymptomCauseFix
engine not foundthe engine is not where the installer put itReinstall PageWright, or set APLAYOUT_APP to pagewrightapp.
Tools appear, every render failssomething is missingRun --selftest; the failing line names it.
An update does not seem to applythe server found a different engineRead engine in engine_info. See 2.7.
check_conformance says Java is missingno Java 8 or laterInstall a free Java (Eclipse Temurin, Amazon Corretto, Microsoft's OpenJDK) and ask again.
check_conformance says veraPDF was not foundveraPDF is not where the installer put itReinstall PageWright, or set VERAPDF.
No page imagesAppligent's PDF server (apdoclibapp) was not foundRead images_note; reinstall PageWright, or set APLAYOUT_APDOCLIB.
schema_valid: nullthe engine's check could not runFix the engine first (engine_info). Null is not valid - nothing was checked.
Document renders but looks wrongtolerant parseRead schema_valid. If false, the engine ignored keys you thought you set.
An image is a grey boxwrong asset nameRead missing_assets. Upload with put_asset, reference as assets/<name>.
Client shows no tools, or hangs at startupsomething wrote to stdoutSee below.
Config edited, nothing changedconfig is read at launchRestart 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 declaresFlavour checked
pdfua: 2ua2 only - a UA-2 file legitimately fails ua1 (PDF 2.0 header, pdfuaid:part=2)
pdfua: 1 / true, or tagged: trueua1
pdfa: "2b", "2A", "PDF/A-3u" ...that level, case- and prefix-normalized
pdfa: true2a when tagged, else 2u
any *a conformance levelalso ua1 - an *a level forces tagging, so it is a UA-1 candidate even if tagged was never written
any of the above plus attachmentspromoted: 2*->3*, 4->4f
pdfxnot 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.

Back to top