- Add `.gitignore` files to all 19 examples (`node_modules/`, `dist/`) - Replace custom `log` objects with standard `console` methods - Add `ontoolcancelled` handler to all `basic-server-*` examples - Add host styling integration (`applyDocumentTheme`, `applyHostFonts`, `applyHostStyleVariables`) to non-React `basic-server-*` examples - Add section headers for better code organization in feature examples - Update READMEs with Build System section explaining single-file bundling These changes make examples more consistent and easier to adapt. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
/**
|
|
* Wiki Explorer - Force-directed graph visualization of Wikipedia link networks
|
|
*/
|
|
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";
|
|
|
|
// =============================================================================
|
|
// Helpers & Types
|
|
// =============================================================================
|
|
|
|
// Helper to resolve CSS variables for canvas rendering
|
|
function getCSSColor(varName: string): string {
|
|
return (
|
|
getComputedStyle(document.documentElement)
|
|
.getPropertyValue(varName)
|
|
.trim() || "#000"
|
|
);
|
|
}
|
|
|
|
// Types
|
|
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;
|
|
};
|
|
|
|
// =============================================================================
|
|
// Graph State & DOM References
|
|
// =============================================================================
|
|
const graphData: GraphData = { nodes: [], links: [] };
|
|
let selectedNodeUrl: string | null = null;
|
|
let initialUrl: string | null = null;
|
|
|
|
// DOM elements
|
|
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")!;
|
|
|
|
// =============================================================================
|
|
// Force-Graph Initialization
|
|
// =============================================================================
|
|
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())
|
|
// Configure forces for better node spreading
|
|
.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);
|
|
|
|
// Handle window resize
|
|
function handleResize() {
|
|
const { width, height } = container.getBoundingClientRect();
|
|
graph.width(width).height(height);
|
|
}
|
|
window.addEventListener("resize", handleResize);
|
|
handleResize();
|
|
|
|
// =============================================================================
|
|
// Graph Data Management
|
|
// =============================================================================
|
|
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) {
|
|
// Small random jitter so nodes don't stack exactly
|
|
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] });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Popup Management
|
|
// =============================================================================
|
|
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;
|
|
|
|
// Place popup on opposite side of cursor from screen center
|
|
const left =
|
|
x < window.innerWidth / 2
|
|
? x + gap // cursor on left half → popup to right
|
|
: x - rect.width - gap; // cursor on right half → popup to left
|
|
|
|
const top =
|
|
y < window.innerHeight / 2
|
|
? y + gap // cursor on top half → popup below
|
|
: y - rect.height - gap; // cursor on bottom half → popup above
|
|
|
|
popup.style.left = `${left}px`;
|
|
popup.style.top = `${top}px`;
|
|
}
|
|
|
|
function hidePopup(): void {
|
|
popup.style.display = "none";
|
|
selectedNodeUrl = null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// UI Event Handlers
|
|
// =============================================================================
|
|
function handleNodeClick(node: NodeData, event: MouseEvent): void {
|
|
// Toggle popup if clicking same node
|
|
if (selectedNodeUrl === node.url) {
|
|
hidePopup();
|
|
return;
|
|
}
|
|
selectedNodeUrl = node.url;
|
|
showPopup(node, event.clientX, event.clientY);
|
|
}
|
|
|
|
// Close popup on Escape key
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && popup.style.display === "block") {
|
|
hidePopup();
|
|
}
|
|
});
|
|
|
|
// Zoom controls
|
|
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);
|
|
});
|
|
|
|
// =============================================================================
|
|
// MCP Apps SDK Integration
|
|
// =============================================================================
|
|
const app = new App({ name: "Wiki Explorer", version: "1.0.0" });
|
|
|
|
// Reset button - clears graph and reloads from initial URL
|
|
resetBtn.addEventListener("click", async () => {
|
|
if (!initialUrl) return;
|
|
|
|
// Fetch fresh data first, then clear and repopulate
|
|
const result = await app.callServerTool({
|
|
name: "get-first-degree-links",
|
|
arguments: { url: initialUrl },
|
|
});
|
|
|
|
// Clear current graph and repopulate with result
|
|
graphData.nodes = [];
|
|
graphData.links = [];
|
|
addNode(initialUrl, initialUrl, "default", { x: 0, y: 0 });
|
|
graph.warmupTicks(100);
|
|
handleToolResultData(result);
|
|
graph.centerAt(0, 0, 500);
|
|
});
|
|
|
|
// Open button - opens the Wikipedia page in browser
|
|
openBtn.addEventListener("click", async () => {
|
|
if (selectedNodeUrl) {
|
|
await app.openLink({ url: selectedNodeUrl });
|
|
hidePopup();
|
|
}
|
|
});
|
|
|
|
// Expand button - fetches and displays linked pages
|
|
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();
|
|
}
|
|
});
|
|
|
|
// Handle tool input - create initial node with URL as placeholder title
|
|
app.ontoolinput = (params) => {
|
|
const args = params.arguments as { url?: string } | undefined;
|
|
if (args?.url) {
|
|
initialUrl = args.url; // Store for reset functionality
|
|
addNode(args.url, args.url, "default", { x: 0, y: 0 });
|
|
graph.warmupTicks(100);
|
|
updateGraph();
|
|
// Center on the new node
|
|
graph.centerAt(0, 0, 500);
|
|
}
|
|
};
|
|
|
|
// Handle tool result - update node and add linked pages (host-initiated, initial load)
|
|
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;
|
|
|
|
// Ensure the source node exists
|
|
addNode(page.url, page.title);
|
|
updateNodeTitle(page.url, page.title);
|
|
|
|
if (error) {
|
|
setNodeState(page.url, "error", error);
|
|
} else {
|
|
// Get source node position so new nodes appear nearby
|
|
const sourceNode = graphData.nodes.find((n) => n.url === page.url);
|
|
const sourcePos = sourceNode
|
|
? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 }
|
|
: undefined;
|
|
|
|
// Add all linked nodes and edges
|
|
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;
|
|
|
|
// Connect to host
|
|
app.connect().then(() => {
|
|
const ctx = app.getHostContext();
|
|
if (ctx) {
|
|
handleHostContextChanged(ctx);
|
|
}
|
|
});
|