Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,7 @@ tmp*.py
node-version/dist/
node-version/node_modules/
node-version/coverage/


# Local test documents
samples/
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <frontend>/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`):
Expand Down
121 changes: 121 additions & 0 deletions docs/claude-mcp-app-build-guide.md
Original file line number Diff line number Diff line change
@@ -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 `<script>` tags
- All CSS must be inlined into `<style>` tags
- All fonts must be base64 data URIs
- Any WASM must be inlined or loaded via the MCP bridge (see below)
- No CDN links of any kind

---

## How We Build for It

We use **`vite-plugin-singlefile`** — a Vite plugin that inlines all JS, CSS, and assets into a single HTML file at build time.

```ts
// vite.config.ts
import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';

export default defineConfig({
plugins: [viteSingleFile()],
build: {
rollupOptions: { input: 'index.html' },
},
});
```

The output is one `index.html` — typically 3–10 MB — with everything inlined. The browser caches it after the first load.

**For Angular:** the same principle applies. The Kendo CSS and Google Fonts currently loaded at runtime in `app.component.ts` need to be imported statically so the bundler can inline them. A thin Vite wrapper project imports the Angular Elements bundle and applies `vite-plugin-singlefile` on top.

---

## How PDF Bytes Reach the Viewer

Since the sandbox blocks all network, the viewer cannot fetch the PDF from disk or from an API. Instead, bytes flow through the **MCP bridge** — a secure channel between the iframe and the MCP server.

The viewer calls back to the server using `app.callServerTool()` from `@modelcontextprotocol/ext-apps`:

```js
import { App } from '@modelcontextprotocol/ext-apps';

const app = new App();
const result = await app.callServerTool('get_pdf_for_viewer', { filePath });
const { pdfBytes } = JSON.parse(result.content[0].text); // base64
const buffer = Uint8Array.from(atob(pdfBytes), c => c.charCodeAt(0)).buffer;

// Feed to your component:
document.querySelector('nitro-pdf-reader').file = buffer;
```

On the MCP server side, `get_pdf_for_viewer` reads the file and returns the bytes:

```ts
server.registerTool('get_pdf_for_viewer', { inputSchema: { filePath: z.string() } }, ({ filePath }) => {
const bytes = filesHandler.read(filePath);
return { content: [{ type: 'text', text: JSON.stringify({ pdfBytes: bytes.toString('base64') }) }] };
});
```

This is the only way to get data into the viewer. The MCP bridge is not blocked by the sandbox CSP.

---

## What Already Works (Our PoC)

We have a working proof-of-concept in this repo (`src/tools/inlinePdfPoc.ts`):

- `view_pdf_inline` — MCP-App tool that mounts the viewer and passes `{ filePath, filename }` to it
- `get_pdf_for_viewer` — byte bridge tool the viewer calls on load
- `save_pdf_edits` — stub for persisting edits back to disk
- `src/assets/mcp-app.html` — 3.8 MB single-file bundle (pdf.js inlined), confirmed rendering on our enterprise account

The component API contract we're targeting:

| Direction | Mechanism |
|---|---|
| File → viewer | `app.callServerTool('get_pdf_for_viewer')` → `ArrayBuffer` → `viewer.file = buffer` |
| Edits → disk | viewer fires event with modified bytes → `app.callServerTool('save_pdf_edits')` |

---

## What Needs to Change in `nitro-pdf-reader`

| Issue | Fix |
|---|---|
| Kendo CSS fetched from `static.gonitro.com` at runtime | Import statically so bundler inlines it |
| Google Fonts fetched from `fonts.googleapis.com` at runtime | Self-host the font files or import locally |
| Multi-file Angular build output | Wrap in a Vite project with `vite-plugin-singlefile` |
| WASM loaded via URL | Either inline as base64 or serve via a new `get_viewer_resource` MCP bridge tool |

The component itself (`<nitro-pdf-reader>`) does not need to change — only the build pipeline around it.

---

## References

- [`@modelcontextprotocol/ext-apps`](https://www.npmjs.com/package/@modelcontextprotocol/ext-apps) — MCP-App SDK
- [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile) — single-file bundler
- [PDF-Tools open source MCP](https://github.com/Open-Document-Alliance/PDF-Tools) — reference implementation using the same pattern
4 changes: 4 additions & 0 deletions node-version/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist/
node_modules/
coverage/
src/assets/
18 changes: 18 additions & 0 deletions node-version/build.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { build } from 'esbuild';
import { createRequire } from 'module';
import fs from 'node:fs';
import path from 'node:path';

const require = createRequire(import.meta.url);
const { version } = require('./manifest.json');
Expand All @@ -11,6 +13,22 @@ await build({
format: 'cjs',
outfile: 'dist/bundle.cjs',
define: {
'import.meta.url': '__importMetaUrl',
'process.env.MCP_SERVER_VERSION': JSON.stringify(version),
},
banner: {
js: "const __importMetaUrl = require('url').pathToFileURL(__filename).href;",
},
});


// Copy src/assets/ → dist/assets/ so the viewer HTML is available at runtime
const assetsDir = path.resolve('src', 'assets');
const distAssetsDir = path.resolve('dist', 'assets');
if (fs.existsSync(assetsDir)) {
fs.mkdirSync(distAssetsDir, { recursive: true });
for (const file of fs.readdirSync(assetsDir)) {
fs.copyFileSync(path.join(assetsDir, file), path.join(distAssetsDir, file));
}
console.log(`Copied src/assets/ → dist/assets/`);
}
2 changes: 1 addition & 1 deletion node-version/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
{
ignores: ['dist/', 'node_modules/', 'coverage/'],
ignores: ['dist/', 'node_modules/', 'coverage/', 'src/assets/'],
},
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
Expand Down
49 changes: 31 additions & 18 deletions node-version/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion node-version/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"@modelcontextprotocol/ext-apps": "^1.7.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"zod": "^3.24.3"
Expand Down
Loading
Loading