Skip to content
Merged
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
1 change: 1 addition & 0 deletions examples/pdf-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ bun examples/pdf-server/main.ts --stdio ./papers/

- `--debug` — Enable verbose server-side logging.
- `--enable-interact` — Enable the `interact` tool in HTTP mode (see [Deployment](#deployment)). Not needed for stdio.
- `--split-app-tools` — Register the view's actions as separate app tools (`navigate`, `search`, `add_annotations`, ...) instead of the default single `interact` app tool. Hosts that ask the user to approve each app tool separately show one approval card per tool, so the default keeps that to one approval; split tools can still be useful for hosts that pick tools by name.
- `--writeable-uploads-root` — Allow saving annotated PDFs back to files under client roots named `uploads` (Claude Desktop mounts attachments there; writes are refused by default).

## Deployment
Expand Down
20 changes: 17 additions & 3 deletions examples/pdf-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ function parseArgs(): {
useClientRoots: boolean;
enableInteract: boolean;
debug: boolean;
splitAppTools: boolean;
} {
const args = process.argv.slice(2);
const urls: string[] = [];
let stdio = false;
let useClientRoots = false;
let enableInteract = false;
let debug = false;
let splitAppTools = false;

for (const arg of args) {
if (arg === "--stdio") {
Expand All @@ -118,6 +120,11 @@ function parseArgs(): {
enableInteract = true;
} else if (arg === "--debug") {
debug = true;
} else if (arg === "--split-app-tools") {
// Register the view's actions as separate app tools instead of the
// default single `interact` app tool. Hosts that ask the user to
// approve each app tool separately then show one card per tool.
splitAppTools = true;
} else if (arg === "--writeable-uploads-root") {
// Claude Desktop mounts attachments under a dir root named "uploads";
// by default we refuse to write there. This flag opts back in.
Expand All @@ -144,11 +151,13 @@ function parseArgs(): {
useClientRoots,
enableInteract,
debug,
splitAppTools,
};
}

async function main() {
const { urls, stdio, useClientRoots, enableInteract, debug } = parseArgs();
const { urls, stdio, useClientRoots, enableInteract, debug, splitAppTools } =
parseArgs();

// Register local files in whitelist
for (const url of urls) {
Expand All @@ -175,7 +184,12 @@ async function main() {
if (stdio) {
// stdio → client is local (e.g. Claude Desktop), roots are safe
await startStdioServer(() =>
createServer({ enableInteract: true, useClientRoots: true, debug }),
createServer({
enableInteract: true,
useClientRoots: true,
debug,
splitAppTools,
}),
);
} else {
// HTTP → client is remote, only honour roots with explicit opt-in
Expand All @@ -186,7 +200,7 @@ async function main() {
);
}
await startStreamableHTTPServer(() =>
createServer({ useClientRoots, enableInteract, debug }),
createServer({ useClientRoots, enableInteract, debug, splitAppTools }),
);
}
}
Expand Down
47 changes: 47 additions & 0 deletions examples/pdf-server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1815,3 +1815,50 @@ describe("interact tool", () => {
}, 15_000);
});
});

// The served view is the built bundle, which not every CI job builds
// (npm-publish's test job runs the unit suite without building examples).
const viewIsBuilt = fs.existsSync(
path.join(import.meta.dir, "dist", "mcp-app.html"),
);

describe.skipIf(!viewIsBuilt)("splitAppTools view config injection", () => {
async function readViewHtml(options?: {
splitAppTools?: boolean;
}): Promise<string> {
const server = createServer(options);
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
try {
const res = await client.readResource({
uri: "ui://pdf-viewer/mcp-app.html",
});
const first = res.contents[0] as { text?: string };
return first.text ?? "";
} finally {
await client.close();
await server.close();
}
}

it("serves the view unmodified by default", async () => {
const html = await readViewHtml();
expect(html).toContain("<head");
// The bundled view code mentions PDF_APP_CONFIG (it reads the config),
// so assert specifically that the server injected nothing.
expect(html).not.toContain(
"globalThis.PDF_APP_CONFIG = { splitAppTools: true }",
);
});

it("injects the split-app-tools config into <head> when enabled", async () => {
const html = await readViewHtml({ splitAppTools: true });
const idx = html.indexOf(
"globalThis.PDF_APP_CONFIG = { splitAppTools: true }",
);
expect(idx).toBeGreaterThan(-1);
// Injected right after <head>, before any module script runs.
expect(idx).toBeLessThan(html.indexOf("<script type"));
});
});
40 changes: 38 additions & 2 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,17 +1263,30 @@ export interface CreateServerOptions {
* in a floating bubble). Toggled by the `--debug` CLI flag.
*/
debug?: boolean;

/**
* Register the view's actions as separate app tools (navigate, search,
* add_annotations, ...) instead of the default single `interact` app
* tool. Hosts that ask the user to approve each app tool separately
* show one approval card per tool, so the single-tool default keeps
* that to one; pass the `--split-app-tools` CLI flag to get the
* per-action tools back. The choice is injected into the served view
* HTML, where src/mcp-app.ts reads it before connecting.
*/
splitAppTools?: boolean;
}

// Module-level singletons so they survive across createServer() calls — in
// stateless HTTP deployments a fresh server is created per request, and
// per-instance caches are discarded immediately.
const sharedPdfCache = createPdfCache();
let cachedAppHtml: string | undefined;
let cachedSplitAppHtml: string | undefined;

export function createServer(options: CreateServerOptions = {}): McpServer {
const { enableInteract = false, useClientRoots = false } = options;
const debug = options.debug ?? false;
const splitAppTools = options.splitAppTools ?? false;
const disableInteract = !enableInteract;
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });

Expand Down Expand Up @@ -2423,7 +2436,7 @@ Example — add a signature image and a stamp, then screenshot to verify:

**TEXT/SCREENSHOTS**:
• get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages.
• get_screenshot: capture a single page as PNG image. Requires \`page\`.
• get_screenshot: capture a single page as a JPEG image. Requires \`page\`.
• get_viewer_state: snapshot of the live viewer — JSON {currentPage, pageCount, zoom, displayMode, selectedAnnotationIds, selection:{text,contextBefore,contextAfter,boundingRect}|null}. Use this to read what the user has selected or which page they're on.

**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.
Expand Down Expand Up @@ -2917,12 +2930,35 @@ Example — add a signature image and a stamp, then screenshot to verify:
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
));
// The view decides its app-tool surface (single `interact` vs one
// tool per action) at load time, before connect(), so the choice
// rides in the served HTML rather than in a tool result. The
// injected variant is cached like the base HTML — the bundle is
// several MB and stateless HTTP mode reads it per request.
let servedHtml = html;
if (splitAppTools) {
if (cachedSplitAppHtml === undefined) {
const injected = html.replace(
/<head(\s[^>]*)?>/i,
(headTag) =>
`${headTag}\n <script>globalThis.PDF_APP_CONFIG = { splitAppTools: true };</script>`,
);
if (injected === html) {
console.error(
"[pdf-server] --split-app-tools: no <head> tag found in the view HTML; " +
"falling back to the single interact app tool",
);
}
cachedSplitAppHtml = injected;
}
servedHtml = cachedSplitAppHtml;
}
return {
contents: [
{
uri: RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: html,
text: servedHtml,
_meta: {
ui: {
permissions: { clipboardWrite: {} },
Expand Down
Loading
Loading