Two doors onto one press.

Packages and templates share assemble, scheduler, then press. Bearer POST /v1/render or OAuth on /mcp. The press only compiles typst-package.

MCP onto the same press

OAuth agents call https://pdf.railman.io/mcp with the same package and template shapes as POST /v1/render. A third client path, not a third press.

Endpoint

https://pdf.railman.io/mcp

Streamable HTTP MCP with OAuth. Agents discover the authorization server from the protected-resource challenge — not from an API key. Sign-in uses magic link (Cloudflare Email Sending). Same free-tier quota and wallet credits as /v1/render.

Dashboard MCP

Discovery

https://pdf.railman.io/.well-known/oauth-authorization-server

OAuth authorization server metadata. Issuer is https://pdf.railman.io; basePath /api/auth.

Discovery

https://pdf.railman.io/.well-known/oauth-protected-resource

Protected resource metadata for this MCP endpoint.

Connect an agent

Point the client at the URL. OAuth + PKCE handles consent (/consent). There is no static client admin UI — dynamic registration and CIMD are the doors. CIMD metadata is fetched through the isolated egress worker.

Cursor mcp.json

{
  "mcpServers": {
    "pdf-railman": {
      "url": "https://pdf.railman.io/mcp"
    }
  }
}

Claude Desktop / Claude Code

{
  "mcpServers": {
    "pdf-railman": {
      "type": "http",
      "url": "https://pdf.railman.io/mcp"
    }
  }
}

Codex config.toml

[mcp_servers.pdf-railman]
url = "https://pdf.railman.io/mcp"

# Then: codex mcp login pdf-railman

OpenCode opencode.json

{
  "mcp": {
    "servers": {
      "pdf-railman": {
        "type": "remote",
        "url": "https://pdf.railman.io/mcp"
      }
    }
  }
}

Antigravity mcp_config.json

{
  "mcpServers": {
    "pdf-railman": {
      "serverUrl": "https://pdf.railman.io/mcp"
    }
  }
}

MCP Inspector

npx @modelcontextprotocol/inspector https://pdf.railman.io/mcp

Scopes

Narrowing is allowed; widening returns invalid_target. Tools without a listed scope are open to any authenticated MCP session.

pdf:render

Create PDF jobs (`render_pdf`, `render_template`). Same quota and wallet as API keys.

pdf:read

Read your jobs, catalogs, and usage (`get_job`, `list_jobs`, `list_templates`, `list_library`, `get_usage`).

Tools

Render tools mirror the HTTP envelope. Jobs from MCP stamp caller_kind=mcp. Successful renders may return pdfBase64 in-band — never storage paths. Dashboard Fill is a session path; this door runs the API transformer.

ToolScopeBehavior
render_pdfpdf:renderCompile a bounded Typst package into a PDF job. Same body as POST /v1/render with kind typst-package.
render_templatepdf:renderCompile a published template ref with JSON data. Assembled in web (transformer runs); press still compiles typst-package.
list_templatespdf:readList your published templates (drafts never appear).
list_librarypdf:readList public library templates (e.g. railman/invoice).
get_jobpdf:readRead one of your PDF jobs (public shape only — never r2Key or input snapshots).
list_jobspdf:readList your recent PDF jobs.
get_usagepdf:readRead remaining credits, UTC period, and debit totals for the authenticated caller. Same numbers as GET /v1/usage.
get_fonts—List available font profiles and license notes.

Example calls

After OAuth, tools/call arguments match the public render shapes. wait defaults true (up to 25s), same as HTTP.

tools/call · render_template

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "render_template",
    "arguments": {
      "template": "railman/invoice@1",
      "data": {
        "client": {
          "address": "88 Market Street, San Francisco, CA",
          "email": "ap@acme.example",
          "name": "Acme Studio"
        },
        "currency": "USD",
        "dueDate": "2026-09-16",
        "invoiceNumber": "INV-1042",
        "issueDate": "2026-08-17",
        "items": [
          {
            "description": "Typst press retainer",
            "quantity": 1,
            "unitPrice": 1200
          },
          {
            "description": "Extra compile hours",
            "quantity": 4,
            "unitPrice": 85
          }
        ],
        "notes": "Net 30. Wire details on file.",
        "seller": {
          "address": "Railman, SNRK",
          "email": "billing@railman.io",
          "name": "PDF Railman"
        },
        "shareUrl": "https://pdf.railman.io/library/railman/invoice",
        "taxRate": 0
      },
      "wait": true
    }
  }
}

tools/call · render_pdf

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "render_pdf",
    "arguments": {
      "kind": "typst-package",
      "package": {
        "main": "main.typ",
        "files": [
          {
            "path": "main.typ",
            "content": "= Hello\n#let data = json(\"data.json\")\n#data.title\n"
          },
          {
            "path": "data.json",
            "content": "{\"title\":\"Q3\"}"
          }
        ]
      },
      "wait": true
    }
  }
}

tools/call · get_usage

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_usage",
    "arguments": {}
  }
}

Same press, different door

  • Bearer /v1/* still uses API keys — mint those on Keys.

  • Session Fill on the dashboard skips the transformer and downloads via a signed link. MCP/API run the transformer for U-JSON → S-JSON.

  • Credits debit in the scheduler only. Publish a template before render_template can resolve it.

  • Jobs history (any caller) lives on Jobs.

PDF Railman

Fill, then generate. Invoice, quote, and receipt need no account.