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:
parent
402bf48145
commit
366c5d5a6b
4 changed files with 3772 additions and 2820 deletions
28
examples/wiki-explorer-server/src/d3-force-3d.d.ts
vendored
Normal file
28
examples/wiki-explorer-server/src/d3-force-3d.d.ts
vendored
Normal 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;
|
||||
}
|
||||
36
examples/wiki-explorer-server/src/global.css
Normal file
36
examples/wiki-explorer-server/src/global.css
Normal 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);
|
||||
}
|
||||
147
examples/wiki-explorer-server/src/mcp-app.css
Normal file
147
examples/wiki-explorer-server/src/mcp-app.css
Normal 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;
|
||||
}
|
||||
370
examples/wiki-explorer-server/src/mcp-app.ts
Normal file
370
examples/wiki-explorer-server/src/mcp-app.ts
Normal 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));
|
||||
Loading…
Reference in a new issue