Skip to content

Commit 067641b

Browse files
Add wiki-explorer-server example
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 <[email protected]>
1 parent f06340b commit 067641b

File tree

11 files changed

+3756
-2820
lines changed

11 files changed

+3756
-2820
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Example: Wiki Explorer
2+
3+
Visualizes Wikipedia link graphs using a force-directed layout. Explore how Wikipedia pages are connected by expanding nodes to reveal first-degree links.
4+
5+
<table>
6+
<tr>
7+
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/01-zoomed.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/01-zoomed.png" alt="Initial view" width="100%"></a></td>
8+
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/02-pop-up.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/02-pop-up.png" alt="Hover state" width="100%"></a></td>
9+
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/03-expanded-graph.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/03-expanded-graph.png" alt="Low retention hover" width="100%"></a></td>
10+
</tr>
11+
</table>
12+
13+
## Features
14+
15+
- **Force-directed graph visualization**: Interactive graph powered by [`force-graph`](https://github.com/vasturiano/force-graph)
16+
- **Node expansion**: Click any node to expand and see all pages it links to
17+
- **Visual state tracking**: Nodes change color based on state (blue = default, green = expanded, red = error)
18+
- **Direct page access**: Open any Wikipedia page in your browser
19+
20+
## Running
21+
22+
1. Install dependencies:
23+
24+
```bash
25+
npm install
26+
```
27+
28+
2. Build and start the server:
29+
30+
```bash
31+
npm run start # for Streamable HTTP transport
32+
# OR
33+
npm run dev # development mode with hot reload
34+
```
35+
36+
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
37+
38+
### Tool Input
39+
40+
To test the example, call the `get-first-degree-links` tool with a Wikipedia URL:
41+
42+
```json
43+
{
44+
"url": "https://en.wikipedia.org/wiki/Graph_theory"
45+
}
46+
```
47+
48+
Click nodes in the graph to **Open** (view in browser) or **Expand** (visualize linked pages).
49+
50+
## Architecture
51+
52+
### Server (`server.ts`)
53+
54+
MCP server that fetches Wikipedia pages and extracts internal links.
55+
56+
Exposes one tool:
57+
58+
- `get-first-degree-links` - Returns links to other Wikipedia pages from a given page
59+
60+
### App (`src/mcp-app.ts`)
61+
62+
Vanilla TypeScript app using force-graph for visualization that:
63+
64+
- Receives tool inputs via the MCP App SDK
65+
- Renders an interactive force-directed graph
66+
- Supports node expansion to explore link relationships
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Wiki Explorer</title>
7+
</head>
8+
<body>
9+
<div id="graph"></div>
10+
<div id="popup">
11+
<div class="popup-title"></div>
12+
<div class="popup-error"></div>
13+
<div class="popup-buttons">
14+
<button id="open-btn">Open</button>
15+
<button id="expand-btn">Expand</button>
16+
</div>
17+
</div>
18+
<div id="controls">
19+
<button id="reset-graph" title="Reset graph">&#x21BA;</button>
20+
<div id="zoom-controls">
21+
<button id="zoom-in" title="Zoom in">+</button>
22+
<button id="zoom-out" title="Zoom out"></button>
23+
</div>
24+
</div>
25+
<script type="module" src="/src/mcp-app.ts"></script>
26+
</body>
27+
</html>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "wiki-explorer-server",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "INPUT=mcp-app.html vite build",
8+
"watch": "INPUT=mcp-app.html vite build --watch",
9+
"serve": "bun server.ts",
10+
"start": "NODE_ENV=development npm run build && npm run serve",
11+
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
12+
},
13+
"dependencies": {
14+
"@modelcontextprotocol/ext-apps": "../..",
15+
"@modelcontextprotocol/sdk": "^1.22.0",
16+
"cheerio": "^1.0.0",
17+
"zod": "^3.25.0"
18+
},
19+
"devDependencies": {
20+
"@types/cors": "^2.8.19",
21+
"@types/express": "^5.0.0",
22+
"@types/node": "^22.0.0",
23+
"bun": "^1.3.2",
24+
"concurrently": "^9.2.1",
25+
"cors": "^2.8.5",
26+
"express": "^5.1.0",
27+
"force-graph": "^1.49.0",
28+
"typescript": "^5.9.3",
29+
"vite": "^6.0.0",
30+
"vite-plugin-singlefile": "^2.3.0"
31+
}
32+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3+
import type {
4+
CallToolResult,
5+
ReadResourceResult,
6+
} from "@modelcontextprotocol/sdk/types.js";
7+
import * as cheerio from "cheerio";
8+
import cors from "cors";
9+
import express, { type Request, type Response } from "express";
10+
import fs from "node:fs/promises";
11+
import path from "node:path";
12+
import { z } from "zod";
13+
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
14+
15+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
16+
const DIST_DIR = path.join(import.meta.dirname, "dist");
17+
18+
type PageInfo = { url: string; title: string };
19+
20+
// Helper to derive title from Wikipedia URL
21+
function extractTitleFromUrl(url: string): string {
22+
try {
23+
const urlObj = new URL(url);
24+
const path = urlObj.pathname;
25+
const title = path.replace("/wiki/", "");
26+
return decodeURIComponent(title).replace(/_/g, " ");
27+
} catch {
28+
return url; // Fallback to URL if parsing fails
29+
}
30+
}
31+
32+
// Wikipedia namespace prefixes to exclude from link extraction
33+
const EXCLUDED_PREFIXES = [
34+
"Wikipedia:",
35+
"Help:",
36+
"File:",
37+
"Special:",
38+
"Talk:",
39+
"Template:",
40+
"Category:",
41+
"Portal:",
42+
"Draft:",
43+
"Module:",
44+
"MediaWiki:",
45+
"User:",
46+
"Main_Page",
47+
];
48+
49+
// Extract wiki links from HTML, filtering out special pages and self-links
50+
function extractWikiLinks(pageUrl: URL, html: string): PageInfo[] {
51+
const $ = cheerio.load(html);
52+
53+
return [
54+
...new Set(
55+
$('a[href^="/wiki/"]')
56+
.map((_, el) => $(el).attr("href"))
57+
.get()
58+
.filter(
59+
(href): href is string =>
60+
href !== undefined &&
61+
href !== pageUrl.pathname &&
62+
!href.includes("#") &&
63+
!EXCLUDED_PREFIXES.some((prefix) => href.includes(prefix)),
64+
),
65+
),
66+
].map((href) => ({
67+
url: `${pageUrl.origin}${href}`,
68+
title: extractTitleFromUrl(`${pageUrl.origin}${href}`),
69+
}));
70+
}
71+
72+
const server = new McpServer({
73+
name: "Wiki Explorer",
74+
version: "1.0.0",
75+
});
76+
77+
// Register the get-first-degree-links tool and its associated UI resource
78+
{
79+
const resourceUri = "ui://wiki-explorer/mcp-app.html";
80+
81+
server.registerTool(
82+
"get-first-degree-links",
83+
{
84+
title: "Get First-Degree Links",
85+
description:
86+
"Returns all Wikipedia pages that the given page links to directly.",
87+
inputSchema: z.object({
88+
url: z.string().url().describe("Wikipedia page URL"),
89+
}),
90+
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
91+
},
92+
async ({ url }): Promise<CallToolResult> => {
93+
let title = url;
94+
95+
try {
96+
if (!url.match(/^https?:\/\/[a-z]+\.wikipedia\.org\/wiki\//)) {
97+
throw new Error("Not a valid Wikipedia URL");
98+
}
99+
100+
title = extractTitleFromUrl(url);
101+
102+
const response = await fetch(url);
103+
104+
if (!response.ok) {
105+
throw new Error(
106+
response.status === 404
107+
? "Page not found"
108+
: `Fetch failed: ${response.status}`,
109+
);
110+
}
111+
112+
const html = await response.text();
113+
const links = extractWikiLinks(new URL(url), html);
114+
115+
const result = { page: { url, title }, links, error: null };
116+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
117+
} catch (err) {
118+
const error = err instanceof Error ? err.message : String(err);
119+
const result = { page: { url, title }, links: [], error };
120+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
121+
}
122+
},
123+
);
124+
125+
server.registerResource(
126+
resourceUri,
127+
resourceUri,
128+
{},
129+
async (): Promise<ReadResourceResult> => {
130+
const html = await fs.readFile(
131+
path.join(DIST_DIR, "mcp-app.html"),
132+
"utf-8",
133+
);
134+
135+
return {
136+
contents: [
137+
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
138+
],
139+
};
140+
},
141+
);
142+
}
143+
144+
const app = express();
145+
app.use(cors());
146+
app.use(express.json());
147+
148+
app.post("/mcp", async (req: Request, res: Response) => {
149+
try {
150+
const transport = new StreamableHTTPServerTransport({
151+
sessionIdGenerator: undefined,
152+
enableJsonResponse: true,
153+
});
154+
res.on("close", () => {
155+
transport.close();
156+
});
157+
158+
await server.connect(transport);
159+
160+
await transport.handleRequest(req, res, req.body);
161+
} catch (error) {
162+
console.error("Error handling MCP request:", error);
163+
if (!res.headersSent) {
164+
res.status(500).json({
165+
jsonrpc: "2.0",
166+
error: { code: -32603, message: "Internal server error" },
167+
id: null,
168+
});
169+
}
170+
}
171+
});
172+
173+
const httpServer = app.listen(PORT, () => {
174+
console.log(`Wiki Explorer server listening on http://localhost:${PORT}/mcp`);
175+
});
176+
177+
function shutdown() {
178+
console.log("\nShutting down...");
179+
httpServer.close(() => {
180+
console.log("Server closed");
181+
process.exit(0);
182+
});
183+
}
184+
185+
process.on("SIGINT", shutdown);
186+
process.on("SIGTERM", shutdown);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
declare module "d3-force-3d" {
2+
interface Force {
3+
(alpha: number): void;
4+
initialize?: (nodes: unknown[], ...args: unknown[]) => void;
5+
}
6+
7+
interface ManyBodyForce extends Force {
8+
strength(value: number): ManyBodyForce;
9+
}
10+
11+
interface LinkForce extends Force {
12+
distance(value: number): LinkForce;
13+
}
14+
15+
interface CollideForce extends Force {
16+
radius(value: number): CollideForce;
17+
}
18+
19+
interface CenterForce extends Force {
20+
x(value: number): CenterForce;
21+
y(value: number): CenterForce;
22+
}
23+
24+
export function forceManyBody(): ManyBodyForce;
25+
export function forceLink(): LinkForce;
26+
export function forceCollide(radius?: number): CollideForce;
27+
export function forceCenter(x?: number, y?: number): CenterForce;
28+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
* {
2+
box-sizing: border-box;
3+
margin: 0;
4+
padding: 0;
5+
}
6+
7+
html, body {
8+
font-family: system-ui, -apple-system, sans-serif;
9+
font-size: 1rem;
10+
width: 100%;
11+
height: 100%;
12+
overflow: hidden;
13+
}
14+
15+
:root {
16+
--bg-color: #ffffff;
17+
--text-color: #333333;
18+
--border-color: #dddddd;
19+
--node-default: #4a90d9;
20+
--node-expanded: #2ecc71;
21+
--node-error: #e74c3c;
22+
--link-color: rgba(128, 128, 128, 0.5);
23+
}
24+
25+
@media (prefers-color-scheme: dark) {
26+
:root {
27+
--bg-color: #1e1e1e;
28+
--text-color: #e0e0e0;
29+
--border-color: #444444;
30+
}
31+
}
32+
33+
body {
34+
background-color: var(--bg-color);
35+
color: var(--text-color);
36+
}

0 commit comments

Comments
 (0)