Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions examples/wiki-explorer-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Example: Wiki Explorer

Visualizes Wikipedia link graphs using a force-directed layout. Explore how Wikipedia pages are connected by expanding nodes to reveal first-degree links.

<table>
<tr>
<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>
<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>
<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>
</tr>
</table>

## Features

- **Force-directed graph visualization**: Interactive graph powered by [`force-graph`](https://github.com/vasturiano/force-graph)
- **Node expansion**: Click any node to expand and see all pages it links to
- **Visual state tracking**: Nodes change color based on state (blue = default, green = expanded, red = error)
- **Direct page access**: Open any Wikipedia page in your browser

## Running

1. Install dependencies:

```bash
npm install
```

2. Build and start the server:

```bash
npm run start:http # for Streamable HTTP transport
# OR
npm run start:stdio # for stdio transport
```

3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.

### Tool Input

To test the example, call the `get-first-degree-links` tool with a Wikipedia URL:

```json
{
"url": "https://en.wikipedia.org/wiki/Graph_theory"
}
```

Click nodes in the graph to **Open** (view in browser) or **Expand** (visualize linked pages).

## Architecture

### Server (`server.ts`)

MCP server that fetches Wikipedia pages and extracts internal links.

Exposes one tool:

- `get-first-degree-links` - Returns links to other Wikipedia pages from a given page

### App (`src/mcp-app.ts`)

Vanilla TypeScript app using force-graph for visualization that:

- Receives tool inputs via the MCP App SDK
- Renders an interactive force-directed graph
- Supports node expansion to explore link relationships
27 changes: 27 additions & 0 deletions examples/wiki-explorer-server/mcp-app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wiki Explorer</title>
</head>
<body>
<div id="graph"></div>
<div id="popup">
<div class="popup-title"></div>
<div class="popup-error"></div>
<div class="popup-buttons">
<button id="open-btn">Open</button>
<button id="expand-btn">Expand</button>
</div>
</div>
<div id="controls">
<button id="reset-graph" title="Reset graph">&#x21BA;</button>
<div id="zoom-controls">
<button id="zoom-in" title="Zoom in">+</button>
<button id="zoom-out" title="Zoom out">−</button>
</div>
</div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
35 changes: 35 additions & 0 deletions examples/wiki-explorer-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "wiki-explorer-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "INPUT=mcp-app.html vite build",
"watch": "INPUT=mcp-app.html vite build --watch",
"serve:http": "bun server.ts",
"serve:stdio": "bun server.ts --stdio",
"start": "npm run start:http",
"start:http": "NODE_ENV=development npm run build && npm run serve:http",
"start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio",
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0",
"cheerio": "^1.0.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"bun": "^1.3.2",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"force-graph": "^1.49.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}
199 changes: 199 additions & 0 deletions examples/wiki-explorer-server/server.ts
Original file line number Diff line number Diff line change
@@ -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);
28 changes: 28 additions & 0 deletions examples/wiki-explorer-server/src/d3-force-3d.d.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions examples/wiki-explorer-server/src/global.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading