PDF Service & MCP — Operations Workbook

Covers two containers on vps-i1 that form the PDF pipeline:

ContainerPortPurpose
pdf-service127.0.0.1:8100:8000FastAPI — accepts Markdown, renders via Gotenberg, uploads to Wasabi S3, tracks jobs in Supabase
p24-infra-mcp127.0.0.1:8101:8000FastMCP HTTP server — wraps pdf-service as MCP tools for Claude agents
gotenberginternalChromium PDF renderer (upstream dependency of pdf-service)

Public endpoints (Caddy TLS):

URLService
(no public URL — internal use only)pdf-service
https://mcp.vps-i1.infra.zintegrowana.online/mcpp24-infra-mcp
https://mcp.vps-i1.infra.zintegrowana.online/healthp24-infra-mcp health check

pdf-service

Overview

REST API that converts Markdown to PDF. Every job is tracked in dev_r_pdf_jobs. Output stored in Wasabi S3 bucket p24-infra.

Source: infra-src/gotenberg/pdf-service/
Auth: X-API-Key header — PDF_SERVICE_API_KEY
Swagger UI: http://localhost:8100/docs (on vps-i1, not public)

API endpoints

MethodPathDescription
POST/v1/md-renderSubmit Markdown for PDF rendering (multipart)
GET/v1/jobs/{job_id}Poll job status
GET/healthHealth + Gotenberg reachability
GET/metricsPrometheus metrics

Submit a job

# On vps-i1 (key auto-loaded from .claude-env)
JOB=$(curl -s -X POST http://localhost:8100/v1/md-render \
  -H "X-API-Key: $PDF_SERVICE_API_KEY" \
  -F "project=dev" \
  -F "requested_by=ops" \
  -F "file=@/tmp/test.md;type=text/markdown")
 
JOB_ID=$(echo "$JOB" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo "Job: $JOB_ID"
 
# Poll
for i in $(seq 1 15); do
  sleep 2
  RESULT=$(curl -s http://localhost:8100/v1/jobs/$JOB_ID -H "X-API-Key: $PDF_SERVICE_API_KEY")
  STATUS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
  echo "$i: $STATUS"
  [ "$STATUS" = "done" ] || [ "$STATUS" = "error" ] && break
done
 
echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('output_url',''))"

Wasabi S3 layout

s3://p24-infra/{project}/{YYYY}/{MM}/{filename}.md   ← source
s3://p24-infra/{project}/{YYYY}/{MM}/{filename}.pdf  ← rendered output

Pre-signed URLs expire in 24 hours. Refresh by polling GET /v1/jobs/{job_id} again.

Supabase job table

-- Recent jobs
SELECT id, project_name, filename, status, requested_by, created_at, error_message
FROM dev_r_pdf_jobs
ORDER BY created_at DESC
LIMIT 20;
 
-- Reset stuck jobs (> 10 min in processing)
UPDATE dev_r_pdf_jobs
SET status = 'pending', started_at = NULL
WHERE status = 'processing'
  AND started_at < NOW() - INTERVAL '10 minutes';

Prometheus metrics

Scraped from /metrics via job pdf_service. Alert rules: monitoring/prometheus/rules/pdf-service.yml.

MetricDescription
pdf_conversions_total{status,type}Counter per conversion outcome
pdf_conversion_duration_secondsHistogram of conversion time
pdf_gotenberg_healthy0/1 — Gotenberg reachability

Alerts

AlertSeverityCondition
PdfServiceDowncriticalNo scrape for 2 min
PdfServiceHighErrorRatewarningError rate > 10% over 5 min
PdfServiceSlowConversionswarningp95 latency > 30s over 5 min
PdfServiceGotenbergUnhealthywarningGotenberg reports unhealthy for 5 min

Troubleshooting

SymptomAction
401 UnauthorizedCheck PDF_SERVICE_API_KEY in monitoring/.env on vps-i1
413 File too largeMarkdown > 1 MB — split the document
422 UnprocessableFile is not valid UTF-8
status: error in jobCheck error_message in Supabase; often Gotenberg or Wasabi issue
Job stuck in processingRun the reset query above
Gotenberg unhealthydocker compose restart gotenberg in /opt/p24-infra/monitoring/

p24-infra-mcp (MCP server)

Overview

HTTP MCP server built with FastMCP. Exposes pdf-service functionality as Claude MCP tools. Claude Code clients connect via .mcp.json using the MCP Streamable HTTP transport.

Source: infra-src/p24-infra-mcp/
Auth: X-Api-Key header (same PDF_SERVICE_API_KEY — no new secret needed)
Upstream: calls pdf-service internally at http://pdf-service:8000

MCP tools

ToolDescription
render_markdown_to_pdfSubmits Markdown, polls until done, returns 24h pre-signed URL
get_pdf_job_statusReturns {status, output_url?, error_message?} for a job ID
list_pdf_jobsLists recent jobs from Supabase; optional project filter, max 100

render_markdown_to_pdf parameters

ParameterRequiredDefaultNotes
contentyesMarkdown text (not a file path)
projectyesS3 prefix slug: p24-infra, dev, etc.
filenamenodocumentBase name without extension
requested_bynomcp-clientCaller label for Supabase traceability

Client configuration

Claude Code reads .mcp.json. The entry is present in:

  • Local (Windows): C:\Users\konar\.claude\.mcp.json
  • Repo root: C:\code_2026\p24-infra\.mcp.json
  • vps-i1: /home/claude-runner/.claude/.mcp.json
  • vps-h1: /home/claude-runner/.claude/.mcp.json

PDF_SERVICE_API_KEY is sourced from /home/claude-runner/.claude-env on both VPSes (auto-loaded by .bashrc). On local, it is in .env.local.

{
  "mcpServers": {
    "p24-infra-tools": {
      "type": "http",
      "url": "https://mcp.vps-i1.infra.zintegrowana.online/mcp",
      "headers": {
        "X-Api-Key": "${PDF_SERVICE_API_KEY}"
      }
    }
  }
}

Health check

curl https://mcp.vps-i1.infra.zintegrowana.online/health
# → {"status": "ok"}

Verify auth rejection

curl -s https://mcp.vps-i1.infra.zintegrowana.online/mcp \
  -H "Content-Type: application/json" \
  -d '{}' | head -c 80
# → {"error":"Unauthorized"}  (401)

Logs

# On vps-i1
cd /opt/p24-infra/monitoring
docker compose logs --tail=50 p24-infra-mcp

Restart / redeploy

# Pull latest code and rebuild
cd /opt/p24-infra
git pull
cd monitoring
docker compose up -d --build p24-infra-mcp

Troubleshooting

SymptomAction
401 Unauthorized from MCPWrong or missing PDF_SERVICE_API_KEY in client .mcp.json
Invalid Host headerMCP_PUBLIC_HOST env var not set / wrong; container uses mcp.vps-i1.infra.zintegrowana.online
TLS error on new deployRestart Caddy to trigger cert issuance: docker compose restart caddy
Tool hangs (no response in 60s)pdf-service or Gotenberg unhealthy; check their logs
RuntimeError: Task group not initializedMCP app mounted incorrectly inside another Starlette app — lifespan skipped. See server.py for correct raw-ASGI middleware pattern.

FastMCP notes (version 1.27.2)

  • Lifespan: streamable_http_app() contains an internal AnyIO task group initialized by its own lifespan. Do not mount it as a sub-app inside another Starlette instance — that skips the lifespan. Instead, add routes/middleware on top of the returned app directly.
  • DNS rebinding protection: TransportSecuritySettings blocks non-localhost Host headers by default. allowed_hosts must include the public hostname.
  • Transport: Streamable HTTP at /mcp (same pattern as WAHA MCP on this infra).

Security

VectorMitigation
API key brute-forcesecrets.compare_digest — constant-time, prevents timing attacks
Path traversal in filenameos.path.basename strips directories; re.sub(r"[^\w\-]", "_") removes special chars; max 200 chars
Oversized payloadContent > 1 MB rejected with HTTP 413 before any processing
Markdown XSS → PDFGotenberg runs Chromium with JS disabled for PDF conversion
Credential exposureKeys only in monitoring/.env on server, never in git; SOPS-encrypted for reprovisioning
Pre-signed URL leakageURLs expire in 24h; S3 key (not URL) is what’s stored in Supabase
Gotenberg exposurePort 3000 is internal-only, not exposed on host
MCP authX-Api-Key ASGI middleware wraps all routes including /mcp; /health is auth-exempt

Compliance

CheckStatus
dev_r_services rowpdf-service row exists, compliance_workbook=yes
Workbook URLdocs/pdf-service-operations.md
dev_r_pdf_jobs migrationmonitoring/supabase/migrations/011_dev_r_pdf_jobs.sql
RLS enabledALTER TABLE dev_r_pdf_jobs ENABLE ROW LEVEL SECURITY
grafana_readonly grantGRANT SELECT ON dev_r_pdf_jobs TO grafana_readonly
Healthcheck/health endpoint, Docker HEALTHCHECK in compose ✓
Prometheus scrape/metrics endpoint + alert rules ✓
TestsUnit tests in infra-src/gotenberg/pdf-service/tests/ + infra-src/p24-infra-mcp/tests/