Add wiki-explorer-server example (#122)

Interactive Wikipedia link graph visualization using force-graph with:
- Force-directed graph layout for exploring page connections
- Node expansion to reveal first-degree Wikipedia links
- Visual state tracking (blue=default, green=expanded, red=error)
- Direct browser access to Wikipedia pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-11 06:58:05 -06:00 • committed by GitHub
commit 366c5d5a6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 3772 additions and 2820 deletions

View file

@ -0,0 +1,28 @@
declare module "d3-force-3d" {
interface Force {
(alpha: number): void;
initialize?: (nodes: unknown[], ...args: unknown[]) => void;
}
interface ManyBodyForce extends Force {
strength(value: number): ManyBodyForce;
}
interface LinkForce extends Force {
distance(value: number): LinkForce;
}
interface CollideForce extends Force {
radius(value: number): CollideForce;
}
interface CenterForce extends Force {
x(value: number): CenterForce;
y(value: number): CenterForce;
}
export function forceManyBody(): ManyBodyForce;
export function forceLink(): LinkForce;
export function forceCollide(radius?: number): CollideForce;
export function forceCenter(x?: number, y?: number): CenterForce;
}

View file

@ -0,0 +1,36 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
width: 100%;
height: 100%;
overflow: hidden;
}
:root {
--bg-color: #ffffff;
--text-color: #333333;
--border-color: #dddddd;
--node-default: #4a90d9;
--node-expanded: #2ecc71;
--node-error: #e74c3c;
--link-color: rgba(128, 128, 128, 0.5);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1e1e1e;
--text-color: #e0e0e0;
--border-color: #444444;
}
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}

View file

@ -0,0 +1,147 @@
#graph {
width: 100%;
height: 100vh;
}
#popup {
display: none;
position: fixed;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
min-width: 200px;
max-width: 400px;
}
.popup-title {
font-weight: 600;
margin-bottom: 12px;
word-wrap: break-word;
color: var(--text-color);
}
.popup-error {
display: none;
color: var(--node-error);
font-size: 14px;
padding: 8px 0;
border-top: 1px solid var(--border-color);
margin-bottom: 8px;
}
.popup-buttons {
display: flex;
gap: 8px;
}
.popup-buttons button {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.popup-buttons button:hover {
opacity: 0.9;
}
#open-btn {
background-color: var(--node-default);
color: white;
}
#expand-btn {
background-color: var(--node-expanded);
color: white;
}
/* Disabled state for buttons */
.popup-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Controls container */
#controls {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px;
z-index: 100;
}
/* Reset button */
#reset-graph {
width: 40px;
height: 40px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: background-color 0.15s;
}
#reset-graph:hover {
background: var(--border-color);
}
#reset-graph:active {
background: var(--border-color);
opacity: 0.8;
}
/* Zoom controls */
#zoom-controls {
display: flex;
flex-direction: column;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
#zoom-controls button {
width: 40px;
height: 40px;
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 20px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s;
}
#zoom-controls button:hover {
background: var(--border-color);
}
#zoom-controls button:active {
background: var(--border-color);
opacity: 0.8;
}
#zoom-in {
border-radius: 4px 4px 0 0;
border-bottom: none;
}
#zoom-out {
border-radius: 0 0 4px 4px;
}

View file

@ -0,0 +1,370 @@
/**
* Wiki Explorer - Force-directed graph visualization of Wikipedia link networks
*/
import { App, PostMessageTransport } 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";
// 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
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")!;
// Initialize force-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())
// 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();
// Node management functions
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;
}
// 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);
});
// Initialize App SDK
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.sendOpenLink({ 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 ||
!result.content?.[0] ||
result.content[0].type !== "text"
) {
console.error("Tool result error:", result);
return;
}
try {
const response: ToolResponse = JSON.parse(result.content[0].text);
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();
} catch (e) {
console.error("Failed to parse tool result:", e);
}
}
app.onerror = (err) => {
console.error("[Wiki Explorer] App error:", err);
};
// Connect to host
app.connect(new PostMessageTransport(window.parent));