Home  ›  Guides  ›  MCP use cases

MCP use cases

Four architectures on pdfnative-mcp v1.8.0 (requires pdfnative ≥ 1.9.0 — pins ^1.9.0, Node ≥ 22): one shared HTTP server whose outputs are files, a token-frugal typesetting loop, multilingual PDF/A notices in every script the engine shapes, and an operator font sandbox whose results are public URLs.

pdfnative-mcp v1.8.0 exposes the pdfnative ≥ 1.9.0 engine to conversational assistants as 28 tools, seven prompts and fourteen operator variables, over stdio or Streamable HTTP (MCP 2026-07-28 with automatic legacy fallback), on Node ≥ 22. This guide is the MCP companion of the ecosystem use cases: four architectures assembled from inputs the MCP guide documents, each with its building blocks, one load-bearing call and its limits.

Case 1 — One shared server, sandboxed files, bearer token#

The default MCP deployment is one stdio process per host: every desktop and every agent runtime spawns its own npx pdfnative-mcp, every generated PDF travels back as base64 inside the JSON-RPC response, and nothing is shared. For a team — or a fleet of agents — that is N processes, N separate caches, and each PDF as base64 (about 4/3 of its size) in the model context. Four operator variables turn the same binary into one shared server whose outputs are files the clients reference instead of bytes they carry.

Architecture: several MCP clients — desktop hosts and agent runtimes — reach one pdfnative-mcp process through an SSH tunnel or a reverse proxy the operator runs, because the server binds 127.0.0.1 only and never a public interface. Every POST to /mcp passes the loopback Host and Origin guard and the bearer-token gate configured with PDFNATIVE_MCP_HTTP_TOKEN before it reaches the stateless MCP handler; GET and DELETE answer 405. Tools called with outputMode file write inside the PDFNATIVE_MCP_OUTPUT_DIR sandbox, and the result returns a resource_link and the byte count instead of the PDF; the client reads the file back on demand through resources/read as pdfnative://output/{+path}. An opt-in PDFNATIVE_MCP_CACHE_DIR serves repeated base64-mode and read-only calls from a SHA-256 keyed cache namespaced by the tool API version and the pinned creation instant.

The building blocks, all in the server's environment variables:

# One shared secret: keep it in your secret store and give the same value to every client.
TOKEN="$(openssl rand -hex 24)"
PDFNATIVE_MCP_PORT=3000 \
PDFNATIVE_MCP_HTTP_TOKEN="$TOKEN" \
PDFNATIVE_MCP_OUTPUT_DIR=/srv/pdfnative/out \
PDFNATIVE_MCP_CACHE_DIR=/srv/pdfnative/cache \
npx -y pdfnative-mcp
# stderr: [pdfnative-mcp] ready (HTTP transport, MCP 2026-07-28 + legacy) on http://127.0.0.1:3000/mcp — bearer token required
# every client request: Authorization: Bearer $TOKEN
{ "tool": "generate_basic_pdf", "arguments": {
  "title": "Invoice INV-2026-0042",
  "outputMode": "file",
  "outputPath": "acme/2026/INV-2026-0042.pdf",
  "creationDate": "2026-09-01T00:00:00Z",
  "blocks": [
    { "type": "heading", "text": "Invoice INV-2026-0042", "level": 1 },
    { "type": "paragraph", "text": "Total due: 1 234,56 EUR" }
  ]
}}

The result is one text line (generate_basic_pdf: wrote <n> bytes to <path>), a resource_link whose URI is pdfnative://output/acme/2026/INV-2026-0042.pdf, and structuredContent: { mode: "file", sizeBytes, filePath } — no PDF bytes. A client that needs the document later calls resources/read with that URI and receives it as a base64 blob, or hands the URI to a human as a stable reference.

What you gain, concretely:

Honest limits: the server binds 127.0.0.1 only and never a public interface — remote clients reach it through an SSH tunnel or a reverse proxy you operate, and that proxy is also where TLS terminates, because the server speaks plain HTTP in-process. There is one shared token and no per-user identity: sub-folders in outputPath organise tenants but do not isolate them, since every client holding the token can list and read every resource. Serving is stateless: GET and DELETE /mcp answer 405, so there is no SSE resumability. And file-mode calls are deliberately never cached — the filesystem side effect is part of the contract.

Case 2 — Token-frugal typesetting loop: preview, produce, read back#

An assistant that lays out a report by rendering it, reading the PDF back and adjusting is paying for the bytes twice on every iteration. Since v1.6.0 the pagination question can be answered without rendering anything, and since v1.7.0 the answer accounts for typography too: inspect_layout accepts the same blocks, the same embedFonts and the same typography object as generate_basic_pdf, runs the builder's own pagination planner, and returns where every block lands. With verbosity: "summary" and a fields projection the answer is a handful of tokens.

The building blocks: inspect_layout with typography, embedFonts, verbosity: "summary" and fields; the typography object (twelve keys, all off by default — splitParagraphs with orphans / widows, keepHeadingsWithNext, opticalMargins, kerning, fontFeatures, …) on the nine document tools; the block-level paragraph.align, paragraph.keepWithNext, paragraph.splittable and heading.keepWithNext on generate_basic_pdf and inspect_layout; the typography prompt as the one-screen summary; and inspect_pdf with fields: ["pageCount"] to confirm the result. A placeholder-free example lives in the server repository as examples/typography-report.json.

{ "tool": "inspect_layout", "arguments": {
  "title": "Annual report",
  "embedFonts": true,
  "typography": {
    "splitParagraphs": true, "orphans": 3, "widows": 3,
    "keepHeadingsWithNext": { "minLines": 3 },
    "opticalMargins": true, "kerning": true
  },
  "blocks": [
    { "type": "heading", "text": "Results", "level": 1 },
    { "type": "paragraph", "align": "justify", "text": "The year in one long paragraph that may now continue on the next page, never leaving fewer than three lines on either side of the break." },
    { "type": "heading", "text": "Figures", "level": 2, "keepWithNext": true },
    { "type": "paragraph", "text": "The table below summarises the year.", "keepWithNext": true },
    { "type": "table", "headers": ["Quarter", "Revenue"], "rows": [["Q1", "120"], ["Q2", "180"]] }
  ],
  "verbosity": "summary",
  "fields": ["totalPages"]
}}

The structured result is { "totalPages": 1 } — or, without fields, the per-page list of blocks with their type, x, top, width and height, so the assistant can see that the heading did move with its table before spending a build. When the layout is right, call generate_basic_pdf with identical arguments (the same typography, the same embedFonts): the preview and the build share one planner, so the page count matches. Then inspect_pdf with verbosity: "summary" and fields: ["pageCount"] confirms it on the produced file for a few tokens more. The same options exist on the CLI (render --split-paragraphs, --keep-headings-with-next, --kerning, --font-features) and on the React Document root as the typography prop.

What you gain, concretely:

Honest limits: kerning, fontFeatures and the narrow no-break space of the 'fr' punctuation preset need embedFonts: true — base-14 Helvetica has no GPOS / GSUB tables and no U+202F glyph, so kerning and features do nothing there and 'fr' degrades to 'fr-CA'. No hyphenation dictionary is installed: hyphenationLanguage is accepted and has no effect; soft hyphens (U+00AD) in long words are honoured. tnum and lnum change nothing on the bundled Noto Sans (diagnostic TYPOGRAPHY_FEATURE_INEFFECTIVE, an error under strict: true). And the catalogue is large: tools/list is about 357 kB (under a 384 KiB budget) because the typography fragment and the colour schemas are inlined in every tool that carries them — hosts should honour its 24 h public cache hint rather than refetch it per session.

Case 3 — Multilingual notices in every script from a conversation#

A safety notice, a consent form or a receipt that must exist in the reader's own script is usually a font-procurement project before it is a document project. add_international_text removes the procurement: it routes each run of text to the bundled Noto face that covers it, shapes it with the engine's script shapers, always embeds the fonts, and can claim PDF/A — from a single tool call inside a conversation.

The building blocks: add_international_text with a lang array covering all 32 scripts of the engine it was built on — including, since v1.7.0, lo (Lao, dedicated shaper), nod (Tai Tham) and cjm (Cham) through the Universal Shaping Engine, khb (New Tai Lue) and tdd (Tai Le), and, since v1.8.0, the five Indic scripts gu, pa, kn, ml and or (Gujarati, Gurmukhi, Kannada, Malayalam, Odia) — plus the latin aliases ha, yo, ig and sw whose tone marks are anchored; pdfA: "pdfa2b" with strict: true so a diagnostic fails the call instead of shipping a wrong claim; creationDate for byte-identical output; and extract_text, which returns the /ActualText of tagged output so complex scripts round-trip exactly. The strings below are copied from the server's examples/scripts-lao-tai-cham.json.

{ "tool": "add_international_text", "arguments": {
  "title": "Safety notice — three scripts of mainland South-East Asia",
  "lang": ["lo", "nod", "cjm", "latin"],
  "pdfA": "pdfa2b",
  "strict": true,
  "creationDate": "2026-09-01T00:00:00Z",
  "paragraphs": [
    "Lao — ສະບາຍດີຊາວໂລກ",
    "Tai Tham (Lanna) — ᨣᩤᩴᨾᩮᩬᩥᨦ",
    "Cham — ꨀꨇꩉ ꨌꩌ"
  ]
}}

lang is an array so each run is routed to the font that covers it — latin carries the English labels and is added automatically under a PDF/A claim. The claim is pdfa2b here; pdfa2u passes too since pdfnative 1.9.0 (see the limits). Feed the result to extract_text and the three paragraphs come back in logical order, because the tagged output carries /ActualText for every marked-content span.

What you gain, concretely:

Honest limits: a house typeface is an operator decision, not a call-time one — since v1.8.0 PDFNATIVE_MCP_FONT_DIR registers the operator's .ttf / .otf files at boot and font: "<slug>" names one on generate_basic_pdf, add_international_text and inspect_layout (Case 4); a language code outside the closed lang enum still needs the CLI (render --font-file) or the library. Every script passes PDF/A-2u since pdfnative 1.9.0 (every shaper's glyph is named in ToUnicode, and PDFA_GLYPH_WITHOUT_UNICODE names any glyph that still is not). Text extraction from an untagged PDF returns the logical string since pdfnative 1.9.0 (every reordered run, right-to-left runs included, carries its text as /ActualText, tagged or not) — except one Thai above-vowel + tone-mark pair, which still reads in drawn order untagged. And annotate_pdf takes { "type": "link" } since v1.8.0 — a /Link annotation with a /URI action on a 0-based page, a rect and an http:, https: or mailto: url of at most 2048 characters — so a link on a page the server did not build no longer means the link block of generate_basic_pdf and a rebuild.

Case 4 — Operator font sandbox and public result URLs#

A brand typeface and a link a person can open are the two things a generated document most often lacks when it leaves a conversation: the bundled Noto faces are not the house font, and a filePath on the server's disk means nothing to the human who asked for the PDF. Since v1.8.0 two operator knobs close both gaps without widening the tool surface: PDFNATIVE_MCP_FONT_DIR registers the operator's .ttf / .otf files at boot, so an agent names a face by its slug; PDFNATIVE_MCP_OUTPUT_BASE_URL maps the file sandbox of Case 1 to a public base URL, so every file-mode result carries url. The agent sees a slug and a URL — never a path, never bytes it did not ask for.

Architecture: the operator sets three environment variables read once at boot — PDFNATIVE_MCP_FONT_DIR, an absolute directory of at most 64 .ttf or .otf files of 16 MiB each, PDFNATIVE_MCP_OUTPUT_DIR, the file sandbox, and PDFNATIVE_MCP_OUTPUT_BASE_URL, the public base URL the operator serves that sandbox under. At boot pdfnative-mcp parses every font file with the engine's font compiler and registers it under the slug of its file name (Brand Sans.ttf becomes brand-sans); a slug collision, an unparsable file or an invalid base URL refuses to start, and the registry fingerprint joins the response-cache namespace. The agent calls generate_basic_pdf with font brand-sans, outputMode file and a sandbox-relative outputPath — a slug and a name, never a path or bytes. The result's text names the public URL, base plus sandbox-relative path, next to a resource_link under pdfnative://output/ and a structuredContent carrying mode, sizeBytes, filePath and url; a human opens the URL on the operator's own static file server, which pdfnative-mcp never requests. An unknown slug fails the call with FONT_NOT_FOUND listing the registered slugs. The font input exists on three tools only, and there is one font directory and one sandbox for every client.

The building blocks, all operator-side, all in the server's environment variables and all read once at boot:

# /srv/fonts holds "Brand Sans.ttf" and "Brand Serif.otf"; the operator's own static
# file server exposes /srv/pdfnative/out read-only at https://files.example.com/pdf/.
PDFNATIVE_MCP_FONT_DIR=/srv/fonts \
PDFNATIVE_MCP_OUTPUT_DIR=/srv/pdfnative/out \
PDFNATIVE_MCP_OUTPUT_BASE_URL=https://files.example.com/pdf/ \
npx -y pdfnative-mcp
# stderr: [pdfnative-mcp] 2 custom font(s) registered from PDFNATIVE_MCP_FONT_DIR: brand-sans, brand-serif
{ "tool": "generate_basic_pdf", "arguments": {
  "title": "Quarterly letter",
  "font": "brand-sans",
  "embedFonts": true,
  "outputMode": "file",
  "outputPath": "acme/2026/quarterly-letter.pdf",
  "creationDate": "2026-09-01T00:00:00Z",
  "blocks": [
    { "type": "heading", "text": "Quarterly letter", "level": 1 },
    { "type": "paragraph", "text": "Set in the operator's brand face; characters it lacks fall back to the bundled Noto Sans." }
  ]
}}

The result's text block is generate_basic_pdf: wrote <n> bytes to https://files.example.com/pdf/acme/2026/quarterly-letter.pdf — the URL, not the host's path — followed by the resource_link pdfnative://output/acme/2026/quarterly-letter.pdf; structuredContent is { mode: "file", sizeBytes, filePath, url }, filePath kept for the clients that read it before the base URL existed. The same url joins every file-mode result: parts[].url on split_pdf, url on draft_governance_issue. Ask for a face the operator did not install and the call fails as a plain isError result, never a protocol error:

generate_basic_pdf failed [FONT_NOT_FOUND]: font 'brand-mono' is unknown. Registered custom fonts: brand-sans, brand-serif.

resources/list answers with the two governance resources first — pdfnative://governance/policy (application/json) and pdfnative://governance/rules (text/markdown), listed whether or not file output is enabled — then every PDF of the sandbox as pdfnative://output/{+path}, so the letter is at once a public URL for the human and an MCP resource for the client.

What you gain, concretely:

Honest limits: font exists on three tools only — generate_basic_pdf, add_international_text and inspect_layout; the other seven document tools (add_table, add_chart, add_barcode, embed_image, add_form, add_attachment, prepare_signature_placeholder) keep the bundled faces. The server never requests the base URL: url is pure string formatting, and whether https://files.example.com/pdf/… actually serves the file, and behind which authentication, is the business of the operator's static file server, not of the MCP server. There is one font directory and one sandbox for every client, not one per tenant: every client holding the token can name every slug and list every resource. No path ever travels in a tool argument — the directory is never listed to a client, and results and errors name slugs. And the transport limits of Case 1 still hold: the HTTP server binds 127.0.0.1 only, and the bearer token must reach the clients out of band.

See also#