diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 45e0c7458..b5e63f3e5 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -76,6 +76,7 @@ import { getAnnotationPreview, getAnnotationColor, } from "./annotation-panel.js"; +import { resolveToolName } from "./tool-names.js"; import "./global.css"; import "./mcp-app.css"; @@ -706,6 +707,16 @@ const app = new App( { autoResize: false }, ); +/** + * Name to use for `app.callServerTool` when calling one of this server's own + * tools. Hosts that aggregate servers may qualify tool names (for example + * `pdf-server-save_pdf`); the prefix is recovered from the name the host + * invoked `display_pdf` under. See tool-names.ts and ext-apps issue #753. + */ +function toolName(baseName: string): string { + return resolveToolName(baseName, app.getHostContext()?.toolInfo?.tool?.name); +} + // UI State functions function showLoading(text: string) { loadingTextEl.textContent = text; @@ -2575,7 +2586,7 @@ async function handleGetViewerState(requestId: string): Promise { }; await app.callServerTool({ - name: "submit_viewer_state", + name: toolName("submit_viewer_state"), arguments: { requestId, state: JSON.stringify(state, null, 2) }, }); } @@ -2662,7 +2673,7 @@ async function handleGetPages(cmd: { // Submit results back to server try { await app.callServerTool({ - name: "submit_page_data", + name: toolName("submit_page_data"), arguments: { requestId: cmd.requestId, pages: results }, }); log.info( @@ -3182,7 +3193,7 @@ async function savePdf(): Promise { const base64 = uint8ArrayToBase64(pdfBytes); const result = await app.callServerTool({ - name: "save_pdf", + name: toolName("save_pdf"), arguments: { url: pdfUrl, data: base64 }, }); @@ -4339,7 +4350,7 @@ async function fetchChunk( const request = (async (): Promise => { try { const result = await app.callServerTool({ - name: "read_pdf_bytes", + name: toolName("read_pdf_bytes"), arguments: { url, offset: begin, byteCount: end - begin }, }); @@ -4946,7 +4957,7 @@ async function processCommands(commands: PdfCommand[]): Promise { log.error("get_pages failed — submitting empty result:", err); await app .callServerTool({ - name: "submit_page_data", + name: toolName("submit_page_data"), arguments: { requestId: cmd.requestId, pages: [] }, }) .catch(() => {}); @@ -4959,7 +4970,7 @@ async function processCommands(commands: PdfCommand[]): Promise { const pdfBytes = await getAnnotatedPdfBytes(); const base64 = uint8ArrayToBase64(pdfBytes); await app.callServerTool({ - name: "submit_save_data", + name: toolName("submit_save_data"), arguments: { requestId: cmd.requestId, data: base64 }, }); log.info(`save_as: submitted ${pdfBytes.length} bytes`); @@ -4967,7 +4978,7 @@ async function processCommands(commands: PdfCommand[]): Promise { log.error("save_as: failed to build bytes — submitting error:", err); await app .callServerTool({ - name: "submit_save_data", + name: toolName("submit_save_data"), arguments: { requestId: cmd.requestId, error: err instanceof Error ? err.message : String(err), @@ -4984,7 +4995,7 @@ async function processCommands(commands: PdfCommand[]): Promise { log.error("get_viewer_state failed — submitting error:", err); await app .callServerTool({ - name: "submit_viewer_state", + name: toolName("submit_viewer_state"), arguments: { requestId: cmd.requestId, error: err instanceof Error ? err.message : String(err), @@ -5058,7 +5069,7 @@ async function pollLoop(): Promise { while (polling && viewUUID) { try { const result = await app.callServerTool({ - name: "poll_pdf_commands", + name: toolName("poll_pdf_commands"), arguments: { viewUUID }, }); if (result.isError) { diff --git a/examples/pdf-server/src/tool-names.test.ts b/examples/pdf-server/src/tool-names.test.ts new file mode 100644 index 000000000..86c3295e0 --- /dev/null +++ b/examples/pdf-server/src/tool-names.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { resolveToolName } from "./tool-names"; + +describe("resolveToolName", () => { + test("applies the host's prefix to other tools", () => { + expect(resolveToolName("save_pdf", "pdf-server-display_pdf")).toBe( + "pdf-server-save_pdf", + ); + expect(resolveToolName("poll_pdf_commands", "a__b__display_pdf")).toBe( + "a__b__poll_pdf_commands", + ); + }); + + test("keeps the bare name when the host does not rewrite names", () => { + expect(resolveToolName("save_pdf", "display_pdf")).toBe("save_pdf"); + }); + + test("keeps the bare name when there is no host context", () => { + expect(resolveToolName("save_pdf", undefined)).toBe("save_pdf"); + expect(resolveToolName("save_pdf", null)).toBe("save_pdf"); + expect(resolveToolName("save_pdf", "")).toBe("save_pdf"); + }); + + test("keeps the bare name when the invoked tool is not the entry tool", () => { + expect(resolveToolName("save_pdf", "other-server-get-time")).toBe( + "save_pdf", + ); + }); +}); diff --git a/examples/pdf-server/src/tool-names.ts b/examples/pdf-server/src/tool-names.ts new file mode 100644 index 000000000..2f669c40b --- /dev/null +++ b/examples/pdf-server/src/tool-names.ts @@ -0,0 +1,27 @@ +/** + * Resolve the name a widget must use to call one of this server's own tools. + * + * A host that aggregates several MCP servers behind one session (mcphub, for + * example) can qualify tool names to avoid collisions, so `save_pdf` becomes + * `pdf-server-save_pdf`. `callServerTool` must then use the qualified name, but + * the widget only knows the bare one. The host does tell us the name it + * invoked the entry tool under (`toolInfo.tool.name`), so the prefix it added + * to the entry tool can be recovered and applied to every other tool. + * + * Falls back to the bare name when the host does not rewrite names, or when + * the invoked name is not this server's entry tool. + * + * See https://github.com/modelcontextprotocol/ext-apps/issues/753 + */ + +/** The only tool that mounts the viewer (the one with `_meta.ui.resourceUri`). */ +export const ENTRY_TOOL = "display_pdf"; + +export function resolveToolName( + baseName: string, + invokedName: string | undefined | null, + entryTool: string = ENTRY_TOOL, +): string { + if (!invokedName || !invokedName.endsWith(entryTool)) return baseName; + return invokedName.slice(0, invokedName.length - entryTool.length) + baseName; +}