| |
| |
| |
| import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; |
| import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; |
| import { |
| forceCenter, |
| forceCollide, |
| forceLink, |
| forceManyBody, |
| } from "d3-force-3d"; |
| import ForceGraph, { type LinkObject, type NodeObject } from "force-graph"; |
| import "./global.css"; |
| import "./mcp-app.css"; |
|
|
| |
| |
| |
|
|
| |
| function getCSSColor(varName: string): string { |
| return ( |
| getComputedStyle(document.documentElement) |
| .getPropertyValue(varName) |
| .trim() || "#000" |
| ); |
| } |
|
|
| |
| type NodeState = "default" | "expanded" | "error"; |
|
|
| interface NodeData extends NodeObject { |
| url: string; |
| title: string; |
| state: NodeState; |
| errorMessage?: string; |
| } |
|
|
| interface LinkData extends LinkObject { |
| source: string | NodeData; |
| target: string | NodeData; |
| } |
|
|
| interface GraphData { |
| nodes: NodeData[]; |
| links: LinkData[]; |
| } |
|
|
| type PageInfo = { url: string; title: string }; |
| type ToolResponse = { |
| page: PageInfo; |
| links: PageInfo[]; |
| error: string | null; |
| }; |
|
|
| |
| |
| |
| const graphData: GraphData = { nodes: [], links: [] }; |
| let selectedNodeUrl: string | null = null; |
| let initialUrl: string | null = null; |
|
|
| |
| const container = document.getElementById("graph")!; |
| const popup = document.getElementById("popup")!; |
| const popupTitle = popup.querySelector(".popup-title")!; |
| const popupError = popup.querySelector(".popup-error")! as HTMLElement; |
| const openBtn = document.getElementById("open-btn")!; |
| const expandBtn = document.getElementById("expand-btn")!; |
| const zoomInBtn = document.getElementById("zoom-in")!; |
| const zoomOutBtn = document.getElementById("zoom-out")!; |
| const resetBtn = document.getElementById("reset-graph")!; |
|
|
| |
| |
| |
| const graph = new ForceGraph<NodeData, LinkData>(container) |
| .nodeId("url") |
| .nodeLabel("title") |
| .nodeColor((node: NodeData) => { |
| switch (node.state) { |
| case "expanded": |
| return getCSSColor("--node-expanded"); |
| case "error": |
| return getCSSColor("--node-error"); |
| default: |
| return getCSSColor("--node-default"); |
| } |
| }) |
| .nodeVal(8) |
| .linkDirectionalArrowLength(6) |
| .linkDirectionalArrowRelPos(1) |
| .linkColor(() => getCSSColor("--link-color")) |
| .onNodeClick(handleNodeClick) |
| .onBackgroundClick(() => hidePopup()) |
| |
| .d3Force("charge", forceManyBody().strength(-80)) |
| .d3Force("link", forceLink().distance(60)) |
| .d3Force("collide", forceCollide(12)) |
| .d3Force("center", forceCenter()) |
| .d3VelocityDecay(0.3) |
| .cooldownTime(Infinity) |
| .d3AlphaMin(0) |
| .d3Force("ambient", () => { |
| for (const node of graphData.nodes) { |
| if (node.vx !== undefined && node.vy !== undefined) { |
| node.vx += (Math.random() - 0.5) * 0.1; |
| node.vy += (Math.random() - 0.5) * 0.1; |
| } |
| } |
| }) |
| .graphData(graphData); |
|
|
| |
| function handleResize() { |
| const { width, height } = container.getBoundingClientRect(); |
| graph.width(width).height(height); |
| } |
| window.addEventListener("resize", handleResize); |
| handleResize(); |
|
|
| |
| |
| |
| function addNode( |
| url: string, |
| title: string, |
| state: NodeState = "default", |
| initialPos?: { x: number; y: number }, |
| ): boolean { |
| const existing = graphData.nodes.find((n) => n.url === url); |
| if (existing) { |
| return false; |
| } |
| const node: NodeData = { url, title, state }; |
| if (initialPos) { |
| |
| node.x = initialPos.x + (Math.random() - 0.5) * 20; |
| node.y = initialPos.y + (Math.random() - 0.5) * 20; |
| } |
| graphData.nodes.push(node); |
| return true; |
| } |
|
|
| function updateNodeTitle(url: string, title: string): void { |
| const node = graphData.nodes.find((n) => n.url === url); |
| if (node) { |
| node.title = title; |
| } |
| } |
|
|
| function setNodeState( |
| url: string, |
| state: NodeState, |
| errorMessage?: string, |
| ): void { |
| const node = graphData.nodes.find((n) => n.url === url); |
| if (node) { |
| node.state = state; |
| node.errorMessage = errorMessage; |
| } |
| } |
|
|
| function addEdge(sourceUrl: string, targetUrl: string): boolean { |
| const existing = graphData.links.find((l) => { |
| const src = |
| typeof l.source === "string" ? l.source : (l.source as NodeData).url; |
| const tgt = |
| typeof l.target === "string" ? l.target : (l.target as NodeData).url; |
| return src === sourceUrl && tgt === targetUrl; |
| }); |
| if (existing) { |
| return false; |
| } |
| graphData.links.push({ source: sourceUrl, target: targetUrl }); |
| return true; |
| } |
|
|
| function updateGraph(): void { |
| graph.graphData({ nodes: [...graphData.nodes], links: [...graphData.links] }); |
| } |
|
|
| |
| |
| |
| function showPopup(node: NodeData, x: number, y: number): void { |
| popupTitle.textContent = node.title; |
|
|
| if (node.state === "error") { |
| popupError.textContent = node.errorMessage || "Failed to load page"; |
| popupError.style.display = "block"; |
| expandBtn.style.display = "none"; |
| } else { |
| popupError.style.display = "none"; |
| expandBtn.style.display = "inline-block"; |
|
|
| if (node.state === "expanded") { |
| expandBtn.setAttribute("disabled", "true"); |
| expandBtn.textContent = "Expanded"; |
| } else { |
| expandBtn.removeAttribute("disabled"); |
| expandBtn.textContent = "Expand"; |
| } |
| } |
|
|
| popup.style.display = "block"; |
| const rect = popup.getBoundingClientRect(); |
| const gap = 15; |
|
|
| |
| const left = |
| x < window.innerWidth / 2 |
| ? x + gap |
| : x - rect.width - gap; |
|
|
| const top = |
| y < window.innerHeight / 2 |
| ? y + gap |
| : y - rect.height - gap; |
|
|
| popup.style.left = `${left}px`; |
| popup.style.top = `${top}px`; |
| } |
|
|
| function hidePopup(): void { |
| popup.style.display = "none"; |
| selectedNodeUrl = null; |
| } |
|
|
| |
| |
| |
| function handleNodeClick(node: NodeData, event: MouseEvent): void { |
| |
| if (selectedNodeUrl === node.url) { |
| hidePopup(); |
| return; |
| } |
| selectedNodeUrl = node.url; |
| showPopup(node, event.clientX, event.clientY); |
| } |
|
|
| |
| document.addEventListener("keydown", (e) => { |
| if (e.key === "Escape" && popup.style.display === "block") { |
| hidePopup(); |
| } |
| }); |
|
|
| |
| const ZOOM_FACTOR = 1.5; |
| zoomInBtn.addEventListener("click", () => { |
| const currentZoom = graph.zoom(); |
| graph.zoom(currentZoom * ZOOM_FACTOR, 200); |
| }); |
|
|
| zoomOutBtn.addEventListener("click", () => { |
| const currentZoom = graph.zoom(); |
| graph.zoom(currentZoom / ZOOM_FACTOR, 200); |
| }); |
|
|
| |
| |
| |
| const app = new App({ name: "Wiki Explorer", version: "1.0.0" }); |
|
|
| |
| resetBtn.addEventListener("click", async () => { |
| if (!initialUrl) return; |
|
|
| |
| const result = await app.callServerTool({ |
| name: "get-first-degree-links", |
| arguments: { url: initialUrl }, |
| }); |
|
|
| |
| graphData.nodes = []; |
| graphData.links = []; |
| addNode(initialUrl, initialUrl, "default", { x: 0, y: 0 }); |
| graph.warmupTicks(100); |
| handleToolResultData(result); |
| graph.centerAt(0, 0, 500); |
| }); |
|
|
| |
| openBtn.addEventListener("click", async () => { |
| if (selectedNodeUrl) { |
| await app.openLink({ url: selectedNodeUrl }); |
| hidePopup(); |
| } |
| }); |
|
|
| |
| expandBtn.addEventListener("click", async () => { |
| if (!selectedNodeUrl) return; |
|
|
| const sourceUrl = selectedNodeUrl; |
| expandBtn.setAttribute("disabled", "true"); |
| expandBtn.textContent = "Loading..."; |
|
|
| try { |
| const result = await app.callServerTool({ |
| name: "get-first-degree-links", |
| arguments: { url: sourceUrl }, |
| }); |
|
|
| graph.warmupTicks(0); |
| handleToolResultData(result); |
| } catch (e) { |
| console.error("Expand error:", e); |
| setNodeState(sourceUrl, "error", "Request failed"); |
| updateGraph(); |
| } finally { |
| expandBtn.removeAttribute("disabled"); |
| expandBtn.textContent = "Expand"; |
| hidePopup(); |
| } |
| }); |
|
|
| |
| app.ontoolinput = (params) => { |
| const args = params.arguments as { url?: string } | undefined; |
| if (args?.url) { |
| initialUrl = args.url; |
| addNode(args.url, args.url, "default", { x: 0, y: 0 }); |
| graph.warmupTicks(100); |
| updateGraph(); |
| |
| graph.centerAt(0, 0, 500); |
| } |
| }; |
|
|
| |
| app.ontoolresult = (result) => { |
| graph.warmupTicks(100); |
| handleToolResultData(result); |
| }; |
|
|
| function handleToolResultData(result: CallToolResult): void { |
| if (result.isError) { |
| console.error("Tool result error:", result); |
| return; |
| } |
|
|
| const response = result.structuredContent as unknown as ToolResponse; |
| const { page, links, error } = response; |
|
|
| |
| addNode(page.url, page.title); |
| updateNodeTitle(page.url, page.title); |
|
|
| if (error) { |
| setNodeState(page.url, "error", error); |
| } else { |
| |
| const sourceNode = graphData.nodes.find((n) => n.url === page.url); |
| const sourcePos = sourceNode |
| ? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 } |
| : undefined; |
|
|
| |
| for (const link of links) { |
| addNode(link.url, link.title, "default", sourcePos); |
| addEdge(page.url, link.url); |
| } |
| setNodeState(page.url, "expanded"); |
| } |
|
|
| updateGraph(); |
| } |
|
|
| app.onerror = (err) => { |
| console.error("[Wiki Explorer] App error:", err); |
| }; |
|
|
| function handleHostContextChanged(ctx: McpUiHostContext) { |
| if (ctx.safeAreaInsets) { |
| document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`; |
| document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`; |
| document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; |
| document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; |
| } |
| } |
|
|
| app.onhostcontextchanged = handleHostContextChanged; |
|
|
| |
| app.connect().then(() => { |
| const ctx = app.getHostContext(); |
| if (ctx) { |
| handleHostContextChanged(ctx); |
| } |
| }); |
|
|