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
1 changed files with 3772 additions and 2820 deletions
199
examples/wiki-explorer-server/server.ts
Normal file
199
examples/wiki-explorer-server/server.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import * as cheerio from "cheerio";
|
||||
import cors from "cors";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
|
||||
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
|
||||
const DIST_DIR = path.join(import.meta.dirname, "dist");
|
||||
|
||||
type PageInfo = { url: string; title: string };
|
||||
|
||||
// Helper to derive title from Wikipedia URL
|
||||
function extractTitleFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname;
|
||||
const title = path.replace("/wiki/", "");
|
||||
return decodeURIComponent(title).replace(/_/g, " ");
|
||||
} catch {
|
||||
return url; // Fallback to URL if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Wikipedia namespace prefixes to exclude from link extraction
|
||||
const EXCLUDED_PREFIXES = [
|
||||
"Wikipedia:",
|
||||
"Help:",
|
||||
"File:",
|
||||
"Special:",
|
||||
"Talk:",
|
||||
"Template:",
|
||||
"Category:",
|
||||
"Portal:",
|
||||
"Draft:",
|
||||
"Module:",
|
||||
"MediaWiki:",
|
||||
"User:",
|
||||
"Main_Page",
|
||||
];
|
||||
|
||||
// Extract wiki links from HTML, filtering out special pages and self-links
|
||||
function extractWikiLinks(pageUrl: URL, html: string): PageInfo[] {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
$('a[href^="/wiki/"]')
|
||||
.map((_, el) => $(el).attr("href"))
|
||||
.get()
|
||||
.filter(
|
||||
(href): href is string =>
|
||||
href !== undefined &&
|
||||
href !== pageUrl.pathname &&
|
||||
!href.includes("#") &&
|
||||
!EXCLUDED_PREFIXES.some((prefix) => href.includes(prefix)),
|
||||
),
|
||||
),
|
||||
].map((href) => ({
|
||||
url: `${pageUrl.origin}${href}`,
|
||||
title: extractTitleFromUrl(`${pageUrl.origin}${href}`),
|
||||
}));
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "Wiki Explorer",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Register the get-first-degree-links tool and its associated UI resource
|
||||
{
|
||||
const resourceUri = "ui://wiki-explorer/mcp-app.html";
|
||||
|
||||
server.registerTool(
|
||||
"get-first-degree-links",
|
||||
{
|
||||
title: "Get First-Degree Links",
|
||||
description:
|
||||
"Returns all Wikipedia pages that the given page links to directly.",
|
||||
inputSchema: z.object({
|
||||
url: z.string().url().describe("Wikipedia page URL"),
|
||||
}),
|
||||
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
|
||||
},
|
||||
async ({ url }): Promise<CallToolResult> => {
|
||||
let title = url;
|
||||
|
||||
try {
|
||||
if (!url.match(/^https?:\/\/[a-z]+\.wikipedia\.org\/wiki\//)) {
|
||||
throw new Error("Not a valid Wikipedia URL");
|
||||
}
|
||||
|
||||
title = extractTitleFromUrl(url);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? "Page not found"
|
||||
: `Fetch failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const links = extractWikiLinks(new URL(url), html);
|
||||
|
||||
const result = { page: { url, title }, links, error: null };
|
||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
const result = { page: { url, title }, links: [], error };
|
||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
resourceUri,
|
||||
resourceUri,
|
||||
{},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await fs.readFile(
|
||||
path.join(DIST_DIR, "mcp-app.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes("--stdio")) {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Wiki Explorer server running in stdio mode");
|
||||
} else {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error("Error handling MCP request:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = app.listen(PORT, () => {
|
||||
console.log(
|
||||
`Wiki Explorer server listening on http://localhost:${PORT}/mcp`,
|
||||
);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
console.log("\nShutting down...");
|
||||
httpServer.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Loading…
Reference in a new issue