Skip to content

Commit c9f8c8e

Browse files
committed
feat(pdf-server): Add widget interaction tools
- get-document-info: Get title, current page, total pages, zoom level - go-to-page: Navigate to a specific page - get-page-text: Extract text from a page - search-text: Search for text across the document - set-zoom: Adjust zoom level
1 parent e18d51b commit c9f8c8e

File tree

1 file changed

+249
-0
lines changed

1 file changed

+249
-0
lines changed

examples/pdf-server/src/mcp-app.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
1010
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
11+
import { z } from "zod";
1112
import * as pdfjsLib from "pdfjs-dist";
1213
import { TextLayer } from "pdfjs-dist";
1314
import "./global.css";
@@ -786,6 +787,254 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
786787

787788
app.onhostcontextchanged = handleHostContextChanged;
788789

790+
// Register tools for model interaction
791+
app.registerTool(
792+
"get-document-info",
793+
{
794+
title: "Get Document Info",
795+
description:
796+
"Get information about the current PDF document including title, current page, total pages, and zoom level",
797+
},
798+
async () => {
799+
if (!pdfDocument) {
800+
return {
801+
content: [
802+
{ type: "text" as const, text: "Error: No document loaded" },
803+
],
804+
isError: true,
805+
};
806+
}
807+
const info = {
808+
title: pdfTitle || "Untitled",
809+
url: pdfUrl,
810+
currentPage,
811+
totalPages,
812+
scale,
813+
displayMode: currentDisplayMode,
814+
};
815+
return {
816+
content: [{ type: "text" as const, text: JSON.stringify(info, null, 2) }],
817+
structuredContent: info,
818+
};
819+
},
820+
);
821+
822+
app.registerTool(
823+
"go-to-page",
824+
{
825+
title: "Go to Page",
826+
description: "Navigate to a specific page in the document",
827+
inputSchema: z.object({
828+
page: z.number().int().positive().describe("Page number (1-indexed)"),
829+
}),
830+
},
831+
async (args) => {
832+
if (!pdfDocument) {
833+
return {
834+
content: [
835+
{ type: "text" as const, text: "Error: No document loaded" },
836+
],
837+
isError: true,
838+
};
839+
}
840+
if (args.page < 1 || args.page > totalPages) {
841+
return {
842+
content: [
843+
{
844+
type: "text" as const,
845+
text: `Error: Page ${args.page} out of range (1-${totalPages})`,
846+
},
847+
],
848+
isError: true,
849+
};
850+
}
851+
currentPage = args.page;
852+
await renderPage();
853+
updateControls();
854+
return {
855+
content: [
856+
{
857+
type: "text" as const,
858+
text: `Navigated to page ${currentPage}/${totalPages}`,
859+
},
860+
],
861+
};
862+
},
863+
);
864+
865+
app.registerTool(
866+
"get-page-text",
867+
{
868+
title: "Get Page Text",
869+
description: "Extract text content from a specific page",
870+
inputSchema: z.object({
871+
page: z
872+
.number()
873+
.int()
874+
.positive()
875+
.optional()
876+
.describe("Page number (1-indexed). Defaults to current page."),
877+
}),
878+
},
879+
async (args) => {
880+
if (!pdfDocument) {
881+
return {
882+
content: [
883+
{ type: "text" as const, text: "Error: No document loaded" },
884+
],
885+
isError: true,
886+
};
887+
}
888+
const pageNum = args.page ?? currentPage;
889+
if (pageNum < 1 || pageNum > totalPages) {
890+
return {
891+
content: [
892+
{
893+
type: "text" as const,
894+
text: `Error: Page ${pageNum} out of range (1-${totalPages})`,
895+
},
896+
],
897+
isError: true,
898+
};
899+
}
900+
try {
901+
const page = await pdfDocument.getPage(pageNum);
902+
const textContent = await page.getTextContent();
903+
const pageText = (textContent.items as Array<{ str?: string }>)
904+
.map((item) => item.str || "")
905+
.join("");
906+
return {
907+
content: [{ type: "text" as const, text: pageText }],
908+
structuredContent: { page: pageNum, text: pageText },
909+
};
910+
} catch (err) {
911+
return {
912+
content: [
913+
{
914+
type: "text" as const,
915+
text: `Error extracting text: ${err instanceof Error ? err.message : String(err)}`,
916+
},
917+
],
918+
isError: true,
919+
};
920+
}
921+
},
922+
);
923+
924+
app.registerTool(
925+
"search-text",
926+
{
927+
title: "Search Text",
928+
description: "Search for text in the document and return matching pages",
929+
inputSchema: z.object({
930+
query: z.string().describe("Text to search for"),
931+
maxResults: z
932+
.number()
933+
.int()
934+
.positive()
935+
.optional()
936+
.describe("Maximum number of results to return (default: 10)"),
937+
}),
938+
},
939+
async (args) => {
940+
if (!pdfDocument) {
941+
return {
942+
content: [
943+
{ type: "text" as const, text: "Error: No document loaded" },
944+
],
945+
isError: true,
946+
};
947+
}
948+
const maxResults = args.maxResults ?? 10;
949+
const results: Array<{ page: number; context: string }> = [];
950+
const query = args.query.toLowerCase();
951+
952+
for (let i = 1; i <= totalPages && results.length < maxResults; i++) {
953+
try {
954+
const page = await pdfDocument.getPage(i);
955+
const textContent = await page.getTextContent();
956+
const pageText = (textContent.items as Array<{ str?: string }>)
957+
.map((item) => item.str || "")
958+
.join("");
959+
960+
const lowerText = pageText.toLowerCase();
961+
const index = lowerText.indexOf(query);
962+
if (index !== -1) {
963+
// Extract context around the match
964+
const start = Math.max(0, index - 50);
965+
const end = Math.min(pageText.length, index + query.length + 50);
966+
const context = pageText.slice(start, end);
967+
results.push({ page: i, context: `...${context}...` });
968+
}
969+
} catch (err) {
970+
log.error(`Error searching page ${i}:`, err);
971+
}
972+
}
973+
974+
if (results.length === 0) {
975+
return {
976+
content: [
977+
{
978+
type: "text" as const,
979+
text: `No matches found for "${args.query}"`,
980+
},
981+
],
982+
structuredContent: { query: args.query, results: [] },
983+
};
984+
}
985+
986+
const summary = results
987+
.map((r) => `Page ${r.page}: ${r.context}`)
988+
.join("\n\n");
989+
return {
990+
content: [
991+
{
992+
type: "text" as const,
993+
text: `Found ${results.length} match(es) for "${args.query}":\n\n${summary}`,
994+
},
995+
],
996+
structuredContent: { query: args.query, results },
997+
};
998+
},
999+
);
1000+
1001+
app.registerTool(
1002+
"set-zoom",
1003+
{
1004+
title: "Set Zoom",
1005+
description: "Set the zoom level for the document",
1006+
inputSchema: z.object({
1007+
scale: z
1008+
.number()
1009+
.min(0.25)
1010+
.max(4)
1011+
.describe("Zoom scale (0.25 to 4, where 1 = 100%)"),
1012+
}),
1013+
},
1014+
async (args) => {
1015+
if (!pdfDocument) {
1016+
return {
1017+
content: [
1018+
{ type: "text" as const, text: "Error: No document loaded" },
1019+
],
1020+
isError: true,
1021+
};
1022+
}
1023+
scale = args.scale;
1024+
await renderPage();
1025+
zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
1026+
requestFitToContent();
1027+
return {
1028+
content: [
1029+
{
1030+
type: "text" as const,
1031+
text: `Zoom set to ${Math.round(scale * 100)}%`,
1032+
},
1033+
],
1034+
};
1035+
},
1036+
);
1037+
7891038
// Connect to host
7901039
app.connect().then(() => {
7911040
log.info("Connected to host");

0 commit comments

Comments
 (0)