From a3f53899af9efcc275ac8ad97215f6af8b63d65d Mon Sep 17 00:00:00 2001 From: SudNitro23 Date: Tue, 16 Jun 2026 10:36:51 +0100 Subject: [PATCH] PWC-8: Inline Nitro PDF viewer in Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render PDFs inside the Claude conversation using Nitro's nitro-pdf-reader web component, folded into one self-contained MCP-App HTML — no browser popup, no network, no second login. - view_pdf tool mounts the viewer as an MCP-App resource; PDF bytes stream over the MCP transport via get_pdf_for_viewer (the sandbox CSP blocks all outbound network). - scripts/inline-viewer.mjs bundles the multi-file public-reader build into one HTML: app chunks + worker inlined, Pdfium WASM fed via emscripten wasmBinary, Kendo CSS + IBM Plex fonts inlined as data: URLs. - scripts/viewer-bridge.js connects via @modelcontextprotocol/ext-apps, loads the file, reroutes Download through the host, and adds a widescreen toggle. - save_pdf_edits is a stub pending a component save event. - Remove the legacy popup/companion editor and its express/open deps. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + CLAUDE.md | 12 + docs/claude-mcp-app-build-guide.md | 121 + node-version/.prettierignore | 4 + node-version/build.mjs | 18 + node-version/eslint.config.ts | 2 +- node-version/package-lock.json | 33 +- node-version/package.json | 3 +- node-version/scripts/inline-viewer.mjs | 279 + node-version/scripts/viewer-bridge.js | 240 + node-version/src/assets/mcp-app.html | 119148 ++++++++++++++++++++++ node-version/src/tools/index.ts | 2 + node-version/src/tools/viewer.ts | 174 + 13 files changed, 120036 insertions(+), 4 deletions(-) create mode 100644 docs/claude-mcp-app-build-guide.md create mode 100644 node-version/.prettierignore create mode 100644 node-version/scripts/inline-viewer.mjs create mode 100644 node-version/scripts/viewer-bridge.js create mode 100644 node-version/src/assets/mcp-app.html create mode 100644 node-version/src/tools/viewer.ts diff --git a/.gitignore b/.gitignore index 0e7722f..4a58a83 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ tmp*.py node-version/dist/ node-version/node_modules/ node-version/coverage/ + + +# Local test documents +samples/ diff --git a/CLAUDE.md b/CLAUDE.md index 7bacaca..50a507b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,20 @@ MCP server connecting Claude Desktop to Nitro's Document Intelligence Platform A - `src/handlers/` — `PlatformHandler` (operations) and `FilesHandler` (local I/O) - `src/tools/` — MCP tool implementations - `src/auth/` — PKCE auth flow and token management +- `src/assets/mcp-app.html` — prebuilt single-file Nitro PDF viewer (generated by `scripts/inline-viewer.mjs`, copied to `dist/assets/` by `build.mjs`) +- `scripts/` — viewer build pipeline (`inline-viewer.mjs`, `viewer-bridge.js`) - `tests/` — Vitest test suite +## Inline PDF Viewer + +`view_pdf` renders a PDF in Nitro's reader directly inside the Claude conversation (MCP-App iframe — no browser popup, no network, no second login). Implementation: `src/tools/viewer.ts`. + +- The viewer is the `nitro-pdf-reader` Angular web component from the frontend repo (`apps/public-reader`, `nx build public-reader`). +- The MCP-App sandbox has no origin and blocks all network, so `scripts/inline-viewer.mjs` folds the multi-file build into ONE self-contained HTML (`src/assets/mcp-app.html`, ~26 MB): app chunks collapsed via esbuild, worker as Blob URL, Pdfium WASM injected via emscripten `wasmBinary` (sandbox CSP blocks `fetch()` of even `data:` URLs), Kendo CSS + IBM Plex fonts inlined as `data:` URLs. +- `scripts/viewer-bridge.js` (bundled into the HTML) connects via `@modelcontextprotocol/ext-apps`, receives `{filePath}` from the tool result, calls `get_pdf_for_viewer` over the MCP transport, and assigns the bytes to the component's `file` property. +- To rebuild the viewer after a frontend change: `node scripts/inline-viewer.mjs /dist/apps/public-reader/app/en` +- `save_pdf_edits` is a stub — round-trip (viewer edits back to Claude) is pending the component exposing a save event. + ## Commands Run from the repo root using the `n:` namespace (aliased from `node-version/Taskfile.yml`): diff --git a/docs/claude-mcp-app-build-guide.md b/docs/claude-mcp-app-build-guide.md new file mode 100644 index 0000000..4fb3dd6 --- /dev/null +++ b/docs/claude-mcp-app-build-guide.md @@ -0,0 +1,121 @@ +# Building for Claude's Inline MCP-App Sandbox + +A guide for frontend developers integrating a viewer/editor component into Claude Desktop via the MCP-App extension API. + +--- + +## What is the MCP-App Sandbox? + +Claude Desktop supports a feature called **MCP Apps** (`@modelcontextprotocol/ext-apps`). An MCP tool can register a UI resource (`text/html;profile=mcp-app`) and Claude will render it inline in the conversation — no browser popup, no second login. + +The renderer is a sandboxed iframe. The sandbox has one hard constraint: + +> **All outbound network is blocked by CSP.** No CDN fetches, no API calls, no localhost, no external fonts or stylesheets. + +Everything the viewer needs must be **self-contained inside the HTML string** that the MCP server returns. + +--- + +## The Single-File Requirement + +The MCP resource API returns a single string of HTML. There is no concept of sibling files — you cannot reference `main.js`, `styles.css`, or a `.wasm` file sitting next to the HTML. If your build output is a folder of files, none of those files are reachable from the iframe. + +**This means:** +- All JavaScript must be inlined into `', + ); + // Drop the dev-harness script (it sets `showOpenButton = true` for local + // file picking). In Claude the file arrives over MCP — no Open button. + html = html.replace(/` : '') + + `` + + ``; + html = html.replace('', () => `${scripts}`); + + // Inline the IBM Plex woff2 fonts as data: URLs (with gstatic fallback) so the + // reader's typography survives the sandbox CSP instead of degrading to system fonts. + const fontResult = await _inlineFonts(html); + html = fontResult.html; + console.log(`fonts: inlined ${fontResult.count}/${fontResult.total} woff2 as data: URLs`); + + fs.writeFileSync(OUT, html); + console.log(`\n✅ wrote ${OUT}`); + console.log(` final size: ${_mb(html.length)}`); + + // ── Structural validation (no Claude reload needed) ── + const leftovers = []; + if (/ + + Public PDF Reader (dev) + + + + + + + + + + + + diff --git a/node-version/src/tools/index.ts b/node-version/src/tools/index.ts index aba3063..af6d0a8 100644 --- a/node-version/src/tools/index.ts +++ b/node-version/src/tools/index.ts @@ -6,6 +6,7 @@ import { register as registerFileManagement } from './fileManagement.js'; import { register as registerGenerations } from './generations.js'; import { register as registerPii } from './pii.js'; import { register as registerTransformations } from './transformations.js'; +import { register as registerViewer } from './viewer.js'; export function registerAll(server: McpServer, context: AppContext): void { registerFileManagement(server, context); @@ -14,4 +15,5 @@ export function registerAll(server: McpServer, context: AppContext): void { registerGenerations(server, context); registerPii(server, context); registerTransformations(server, context); + registerViewer(server, context); } diff --git a/node-version/src/tools/viewer.ts b/node-version/src/tools/viewer.ts new file mode 100644 index 0000000..860adb3 --- /dev/null +++ b/node-version/src/tools/viewer.ts @@ -0,0 +1,174 @@ +/* + viewer.ts — Render a PDF in the Nitro reader, inline inside Claude. + + The viewer is Nitro's `nitro-pdf-reader` web component (frontend repo, + public-reader app), folded into ONE self-contained HTML by + `scripts/inline-viewer.mjs` and served as an MCP-App resource. The sandbox + has no origin and blocks all network, so everything (Pdfium WASM, worker, + Kendo theme, fonts) is inlined and the PDF bytes travel over the MCP + transport, never HTTP: + 1. `view_pdf` is an MCP-App tool. Its result carries `_meta.ui.resourceUri`, + so the host mounts the viewer, and `content[0].text` is JSON + `{ filePath, filename }` which the viewer reads on launch. + 2. The viewer calls `get_pdf_for_viewer` back through the MCP bridge + (see `scripts/viewer-bridge.js`) to receive the PDF bytes as base64. + 3. `save_pdf_edits` is a stub awaiting round-trip support (the component + does not yet expose a save event). +*/ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, +} from '@modelcontextprotocol/ext-apps/server'; +import type { AppContext } from '../context.js'; +import { NON_DESTRUCTIVE } from './annotations.js'; + +const RESOURCE_URI = 'ui://nitro-pdf-viewer/app'; + +// The viewer's @font-face rules reference IBM Plex (Sans/Mono) woff2 files on +// fonts.gstatic.com. The inliner embeds those as data: URLs (offline-first) but +// keeps the gstatic URL as a fallback src. Allowlisting the origin here maps to +// the sandbox CSP font-src directive, so the fallback can still load if the host +// blocks data: fonts. resourceDomains → img-/script-/style-/font-/media-src. +const _VIEWER_CSP = { + resourceDomains: ['https://fonts.gstatic.com', 'https://fonts.googleapis.com'], +} as const; + +// Minimum inline height (px). The viewer auto-reports its content height to the +// host via `sendSizeChanged`; giving the flex shell a min-height floors that +// reported value so the PDF page area gets a usable amount of vertical space. +const MIN_VIEWER_HEIGHT_PX = 720; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Injected to floor the inline viewport height. The nitro-pdf-reader element +// uses height:100vh; without a floor, the bridge's auto-resize can settle on a +// tiny height before content lays out. Target the element + document. +const _HEIGHT_OVERRIDE_STYLE = ``; + +// Load the prebuilt single-file viewer (built by scripts/inline-viewer.mjs). +// Copied from src/assets/ to dist/assets/ by build.mjs, so resolve against both. +function _loadViewerHtml(): string { + const candidates = [ + path.resolve(__dirname, 'assets', 'mcp-app.html'), + path.resolve(__dirname, '..', 'assets', 'mcp-app.html'), + path.resolve(__dirname, '..', 'src', 'assets', 'mcp-app.html'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + const html = fs.readFileSync(candidate, 'utf-8'); + // Inject the height override last in so it wins over bundle CSS. + return html.includes('') + ? html.replace('', `${_HEIGHT_OVERRIDE_STYLE}`) + : _HEIGHT_OVERRIDE_STYLE + html; + } + } + return '

Viewer asset (mcp-app.html) not found.

'; +} + +export function register(server: McpServer, context: AppContext): void { + // ── The viewer UI resource ──────────────────────────────────────────────── + registerAppResource( + server, + 'Nitro PDF Viewer', + RESOURCE_URI, + { + mimeType: RESOURCE_MIME_TYPE, + description: 'Self-contained Nitro PDF reader (Pdfium WASM inlined)', + _meta: { ui: { csp: _VIEWER_CSP } }, + }, + () => ({ + contents: [ + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: _loadViewerHtml(), + // Content-item _meta.ui takes precedence over the listing-level value. + _meta: { ui: { csp: _VIEWER_CSP } }, + }, + ], + }), + ); + + // ── 1. Launch tool — mounts the viewer and hands it the file to open ────── + registerAppTool( + server, + 'view_pdf', + { + description: + 'Render a PDF in the Nitro viewer directly inside the conversation (no browser ' + + 'popup, no network, no second login). Provide the absolute filePath (use ' + + 'list_files to resolve it first). This is the preferred way to show a PDF to ' + + 'the user.', + inputSchema: { + filePath: z.string().describe('Absolute path to the PDF file, or a path starting with ~.'), + fileName: z.string().optional().describe('Display name, e.g. "contract.pdf".'), + }, + annotations: NON_DESTRUCTIVE('View PDF'), + _meta: { ui: { resourceUri: RESOURCE_URI } }, + }, + ({ filePath, fileName }) => { + const filename = fileName ?? path.basename(filePath); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ filePath, filename }) }], + }; + }, + ); + + // ── 2. Byte provider — the viewer calls this back over the MCP bridge ───── + server.registerTool( + 'get_pdf_for_viewer', + { + description: + 'Internal: returns the PDF bytes (base64) for the viewer. Called by the ' + + 'viewer itself — not normally invoked directly.', + inputSchema: { + filePath: z.string().describe('Absolute path to the PDF file, or a path starting with ~.'), + }, + annotations: NON_DESTRUCTIVE('Get PDF For Viewer'), + }, + ({ filePath }) => { + const bytes = context.filesHandler.read(filePath); + const payload = { + pdfBytes: bytes.toString('base64'), + filename: path.basename(filePath), + }; + return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }; + }, + ); + + // ── 3. Save stub — round-trip support pending (component save event) ────── + server.registerTool( + 'save_pdf_edits', + { + description: + 'Internal: persists viewer edits (e.g. page rotations). Stub — not yet implemented.', + inputSchema: { + filePath: z.string().describe('Absolute path to the PDF file.'), + rotations: z + .record(z.string(), z.number()) + .optional() + .describe('Map of page index → rotation in degrees.'), + }, + annotations: NON_DESTRUCTIVE('Save PDF Edits'), + }, + ({ filePath }) => ({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + outputFilename: path.basename(filePath), + note: 'Stub — edits are not yet persisted.', + }), + }, + ], + }), + ); +}