Skip to content

Commit 366c5d5

Browse files
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 <[email protected]>
1 parent 402bf48 commit 366c5d5

File tree

11 files changed

+3772
-2820
lines changed

11 files changed

+3772
-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="Zoomed" 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="Pop-up" 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="Expanded graph" 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:http # for Streamable HTTP transport
32+
# OR
33+
npm run start:stdio # for stdio transport
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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:http": "bun server.ts",
10+
"serve:stdio": "bun server.ts --stdio",
11+
"start": "npm run start:http",
12+
"start:http": "NODE_ENV=development npm run build && npm run serve:http",
13+
"start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio",
14+
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'"
15+
},
16+
"dependencies": {
17+
"@modelcontextprotocol/ext-apps": "../..",
18+
"@modelcontextprotocol/sdk": "^1.22.0",
19+
"cheerio": "^1.0.0",
20+
"zod": "^3.25.0"
21+
},
22+
"devDependencies": {
23+
"@types/cors": "^2.8.19",
24+
"@types/express": "^5.0.0",
25+
"@types/node": "^22.0.0",
26+
"bun": "^1.3.2",
27+
"concurrently": "^9.2.1",
28+
"cors": "^2.8.5",
29+
"express": "^5.1.0",
30+
"force-graph": "^1.49.0",
31+
"typescript": "^5.9.3",
32+
"vite": "^6.0.0",
33+
"vite-plugin-singlefile": "^2.3.0"
34+
}
35+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4+
import type {
5+
CallToolResult,
6+
ReadResourceResult,
7+
} from "@modelcontextprotocol/sdk/types.js";
8+
import * as cheerio from "cheerio";
9+
import cors from "cors";
10+
import express, { type Request, type Response } from "express";
11+
import fs from "node:fs/promises";
12+
import path from "node:path";
13+
import { z } from "zod";
14+
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
15+
16+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
17+
const DIST_DIR = path.join(import.meta.dirname, "dist");
18+
19+
type PageInfo = { url: string; title: string };
20+
21+
// Helper to derive title from Wikipedia URL
22+
function extractTitleFromUrl(url: string): string {
23+
try {
24+
const urlObj = new URL(url);
25+
const path = urlObj.pathname;
26+
const title = path.replace("/wiki/", "");
27+
return decodeURIComponent(title).replace(/_/g, " ");
28+
} catch {
29+
return url; // Fallback to URL if parsing fails
30+
}
31+
}
32+
33+
// Wikipedia namespace prefixes to exclude from link extraction
34+
const EXCLUDED_PREFIXES = [
35+
"Wikipedia:",
36+
"Help:",
37+
"File:",
38+
"Special:",
39+
"Talk:",
40+
"Template:",
41+
"Category:",
42+
"Portal:",
43+
"Draft:",
44+
"Module:",
45+
"MediaWiki:",
46+
"User:",
47+
"Main_Page",
48+
];
49+
50+
// Extract wiki links from HTML, filtering out special pages and self-links
51+
function extractWikiLinks(pageUrl: URL, html: string): PageInfo[] {
52+
const $ = cheerio.load(html);
53+
54+
return [
55+
...new Set(
56+
$('a[href^="/wiki/"]')
57+
.map((_, el) => $(el).attr("href"))
58+
.get()
59+
.filter(
60+
(href): href is string =>
61+
href !== undefined &&
62+
href !== pageUrl.pathname &&
63+
!href.includes("#") &&
64+
!EXCLUDED_PREFIXES.some((prefix) => href.includes(prefix)),
65+
),
66+
),
67+
].map((href) => ({
68+
url: `${pageUrl.origin}${href}`,
69+
title: extractTitleFromUrl(`${pageUrl.origin}${href}`),
70+
}));
71+
}
72+
73+
const server = new McpServer({
74+
name: "Wiki Explorer",
75+
version: "1.0.0",
76+
});
77+
78+
// Register the get-first-degree-links tool and its associated UI resource
79+
{
80+
const resourceUri = "ui://wiki-explorer/mcp-app.html";
81+
82+
server.registerTool(
83+
"get-first-degree-links",
84+
{
85+
title: "Get First-Degree Links",
86+
description:
87+
"Returns all Wikipedia pages that the given page links to directly.",
88+
inputSchema: z.object({
89+
url: z.string().url().describe("Wikipedia page URL"),
90+
}),
91+
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
92+
},
93+
async ({ url }): Promise<CallToolResult> => {
94+
let title = url;
95+
96+
try {
97+
if (!url.match(/^https?:\/\/[a-z]+\.wikipedia\.org\/wiki\//)) {
98+
throw new Error("Not a valid Wikipedia URL");
99+
}
100+
101+
title = extractTitleFromUrl(url);
102+
103+
const response = await fetch(url);
104+
105+
if (!response.ok) {
106+
throw new Error(
107+
response.status === 404
108+
? "Page not found"
109+
: `Fetch failed: ${response.status}`,
110+
);
111+
}
112+
113+
const html = await response.text();
114+
const links = extractWikiLinks(new URL(url), html);
115+
116+
const result = { page: { url, title }, links, error: null };
117+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
118+
} catch (err) {
119+
const error = err instanceof Error ? err.message : String(err);
120+
const result = { page: { url, title }, links: [], error };
121+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
122+
}
123+
},
124+
);
125+
126+
server.registerResource(
127+
resourceUri,
128+
resourceUri,
129+
{},
130+
async (): Promise<ReadResourceResult> => {
131+
const html = await fs.readFile(
132+
path.join(DIST_DIR, "mcp-app.html"),
133+
"utf-8",
134+
);
135+
136+
return {
137+
contents: [
138+
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
139+
],
140+
};
141+
},
142+
);
143+
}
144+
145+
async function main() {
146+
if (process.argv.includes("--stdio")) {
147+
const transport = new StdioServerTransport();
148+
await server.connect(transport);
149+
console.error("Wiki Explorer server running in stdio mode");
150+
} else {
151+
const app = express();
152+
app.use(cors());
153+
app.use(express.json());
154+
155+
app.post("/mcp", async (req: Request, res: Response) => {
156+
try {
157+
const transport = new StreamableHTTPServerTransport({
158+
sessionIdGenerator: undefined,
159+
enableJsonResponse: true,
160+
});
161+
res.on("close", () => {
162+
transport.close();
163+
});
164+
165+
await server.connect(transport);
166+
167+
await transport.handleRequest(req, res, req.body);
168+
} catch (error) {
169+
console.error("Error handling MCP request:", error);
170+
if (!res.headersSent) {
171+
res.status(500).json({
172+
jsonrpc: "2.0",
173+
error: { code: -32603, message: "Internal server error" },
174+
id: null,
175+
});
176+
}
177+
}
178+
});
179+
180+
const httpServer = app.listen(PORT, () => {
181+
console.log(
182+
`Wiki Explorer server listening on http://localhost:${PORT}/mcp`,
183+
);
184+
});
185+
186+
function shutdown() {
187+
console.log("\nShutting down...");
188+
httpServer.close(() => {
189+
console.log("Server closed");
190+
process.exit(0);
191+
});
192+
}
193+
194+
process.on("SIGINT", shutdown);
195+
process.on("SIGTERM", shutdown);
196+
}
197+
}
198+
199+
main().catch(console.error);
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)