import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
// ── Types ──────────────────────────────────────────────────────────────
interface Env {
MCP_OBJECT: DurableObjectNamespace;
}
interface WikiSearchItem {
title: string;
snippet: string;
pageid: number;
}
interface WikiSearchResponse {
query?: {
search?: WikiSearchItem[];
};
}
interface WikiPage {
title: string;
pageid: number;
missing?: boolean;
extract?: string;
}
interface WikiPageResponse {
query?: {
pages?: WikiPage[];
};
}
interface WikiItemMapping {
[itemId: string]: string; // id -> name
}
interface PriceData {
high?: number;
highTime?: number;
low?: number;
lowTime?: number;
}
interface PriceResponse {
data?: {
[itemId: string]: PriceData;
};
}
// ── Constants ──────────────────────────────────────────────────────────
const WIKI_API = "https://oldschool.runescape.wiki/api.php";
const PRICES_API = "https://prices.runescape.wiki/api/v1/osrs";
const USER_AGENT = "osrs-wiki-mcp/2.0 (Cloudflare Workers; github.com/your-repo)";
const CORS_OPTIONS = {
origin: "*",
methods: "GET, POST, OPTIONS",
headers: "Content-Type, Authorization",
};
const ICON = {
src: "https://oldschool.runescape.wiki/images/thumb/b/bc/Old_School_RuneScape_Wiki_logo.png/284px-Old_School_RuneScape_Wiki_logo.png",
mimeType: "image/png" as const,
sizes: ["284x270"],
};
// ── Helpers ─────────────────────────────────────────────────────────────
function pageUrl(title: string): string {
return `https://oldschool.runescape.wiki/w/${encodeURIComponent(title.replace(/ /g, "_"))}`;
}
function stripHtml(text: string): string {
return text.replace(/<[^>]+>/g, "").replace(/"/g, '"').replace(/&/g, "&");
}
async function wikiFetch<T>(params: Record<string, string>): Promise<T> {
const url = `${WIKI_API}?${new URLSearchParams({ format: "json", ...params })}`;
const res = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (!res.ok) throw new Error(`Wiki API returned ${res.status}`);
return res.json() as Promise<T>;
}
async function pricesFetch<T>(path: string): Promise<T> {
const res = await fetch(`${PRICES_API}/${path}`, {
headers: { "User-Agent": USER_AGENT },
});
if (!res.ok) throw new Error(`Prices API returned ${res.status}`);
return res.json() as Promise<T>;
}
// ── Item mapping cache (in-memory per DO instance) ──────────────────────
let itemMappingCache: WikiItemMapping | null = null;
let itemMappingExpiry = 0;
const CACHE_TTL = 1000 * 60 * 60; // 1 hour
async function getItemMapping(): Promise<WikiItemMapping> {
if (itemMappingCache && Date.now() < itemMappingExpiry) {
return itemMappingCache;
}
const data = await pricesFetch<WikiItemMapping>("mapping");
// The mapping endpoint returns an array of {id, name, ...}
const mapping: WikiItemMapping = {};
if (Array.isArray(data)) {
for (const item of data as Array<{ id: number; name: string }>) {
mapping[String(item.id)] = item.name;
}
}
itemMappingCache = mapping;
itemMappingExpiry = Date.now() + CACHE_TTL;
return mapping;
}
async function findItemId(name: string): Promise<string | null> {
const mapping = await getItemMapping();
const lower = name.toLowerCase();
for (const [id, itemName] of Object.entries(mapping)) {
if (itemName.toLowerCase() === lower) return id;
}
// Partial match fallback
for (const [id, itemName] of Object.entries(mapping)) {
if (itemName.toLowerCase().includes(lower)) return id;
}
return null;
}
// ── MCP Agent ───────────────────────────────────────────────────────────
export class OSRSWikiMCP extends McpAgent {
server = new McpServer({
name: "osrs-wiki-mcp",
title: "OSRS Wiki",
version: "2.0.0",
websiteUrl: "https://oldschool.runescape.wiki/",
icons: [ICON],
});
// Player data cache: { username: { data, fetchedAt } }
playerDataCache: Record<string, { data: Record<string, unknown>; fetchedAt: number }> = {};
async fetchPlayerData(
username: string,
forceRefresh = false
): Promise<{ data: Record<string, unknown> | null; message?: string }> {
const now = Date.now();
const cache = this.playerDataCache[username];
if (cache && !forceRefresh && now - cache.fetchedAt < 3600_000) {
return { data: cache.data };
}
const url = `https://sync.runescape.wiki/runelite/player/${encodeURIComponent(username)}/STANDARD`;
try {
const resp = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (!resp.ok) {
return { data: null, message: `WikiSync API returned ${resp.status}` };
}
const data = (await resp.json()) as Record<string, unknown>;
if (!data || Object.keys(data).length === 0) {
return {
data: null,
message:
"No player data found. Ensure the username is correct and you have the WikiSync plugin installed in RuneLite.",
};
}
this.playerDataCache[username] = { data, fetchedAt: now };
return { data };
} catch (err) {
return { data: null, message: `Error: ${err instanceof Error ? err.message : "Unknown error"}` };
}
}
async init() {
this.server.tool(
"search",
"Search the Old School RuneScape Wiki for articles matching a query",
{
query: z.string().describe("Search query (e.g. 'dragon scimitar', 'Zulrah')"),
limit: z.number().min(1).max(50).default(10).describe("Max results (1-50)"),
},
async ({ query, limit }) => {
const data = await wikiFetch<WikiSearchResponse>({
action: "query",
list: "search",
srsearch: query,
srlimit: String(limit),
});
const results = data.query?.search ?? [];
if (!results.length) {
return { content: [{ type: "text", text: `No results found for "${query}"` }] };
}
const lines = results.map((item, i) => {
const snippet = stripHtml(item.snippet);
return `${i + 1}. **${item.title}**\n ${snippet}\n ${pageUrl(item.title)}`;
});
return {
content: [{ type: "text", text: `Found ${results.length} results:\n\n${lines.join("\n\n")}` }],
};
}
);
this.server.tool(
"summary",
"Get the introductory summary of an OSRS Wiki page",
{
title: z.string().describe("Exact page title (e.g. 'Abyssal whip', 'Farming')"),
},
async ({ title }) => {
const data = await wikiFetch<WikiPageResponse>({
action: "query",
prop: "extracts",
exintro: "1",
explaintext: "1",
formatversion: "2",
titles: title,
});
const page = data.query?.pages?.[0];
if (!page || page.missing) {
return { content: [{ type: "text", text: `Page not found: "${title}"` }] };
}
const extract = page.extract?.trim();
if (!extract) {
return { content: [{ type: "text", text: `No summary available for "${page.title}"` }] };
}
return {
content: [{ type: "text", text: `# ${page.title}\n\n${extract}\n\n${pageUrl(page.title)}` }],
};
}
);
this.server.tool(
"price",
"Look up the current Grand Exchange price for an item",
{
item: z.string().describe("Item name (e.g. 'Abyssal whip', 'Dragon bones')"),
},
async ({ item }) => {
const itemId = await findItemId(item);
if (!itemId) {
return { content: [{ type: "text", text: `Item not found: "${item}". Try the exact in-game name.` }] };
}
const data = await pricesFetch<PriceResponse>(`latest?id=${itemId}`);
const price = data.data?.[itemId];
if (!price) {
return { content: [{ type: "text", text: `No price data available for "${item}"` }] };
}
const mapping = await getItemMapping();
const name = mapping[itemId] ?? item;
const lines = [`# ${name} — Grand Exchange Price`];
if (price.high != null) {
const ago = price.highTime ? ` (${formatTimeAgo(price.highTime)})` : "";
lines.push(`Buy (instant): ${price.high.toLocaleString()} gp${ago}`);
}
if (price.low != null) {
const ago = price.lowTime ? ` (${formatTimeAgo(price.lowTime)})` : "";
lines.push(`Sell (instant): ${price.low.toLocaleString()} gp${ago}`);
}
lines.push("", pageUrl(name));
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
this.server.tool(
"player",
"Fetch RuneLite player data via the WikiSync plugin (requires RuneLite client)",
{
username: z.string().describe("RuneLite username"),
forceRefresh: z.boolean().default(false).describe("Force refresh cached data"),
},
async ({ username, forceRefresh }) => {
if (!username.trim()) {
return { content: [{ type: "text", text: "Please provide a RuneLite username." }] };
}
const { data, message } = await this.fetchPlayerData(username, forceRefresh);
if (!data) {
return { content: [{ type: "text", text: message ?? "No player data found." }] };
}
return {
content: [{
type: "text",
text: `# ${username} — Player Data (via WikiSync)\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``,
}],
};
}
);
}
}
function formatTimeAgo(unixSeconds: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSeconds;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
// ── Worker entrypoint ───────────────────────────────────────────────────
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
// Health check / landing
if (url.pathname === "/" || url.pathname === "") {
return Response.json({
name: "osrs-wiki-mcp",
version: "2.0.0",
transports: {
"streamable-http": "/mcp",
sse: "/sse",
},
});
}
if (url.pathname === "/mcp") {
return OSRSWikiMCP.serve("/mcp", {
binding: "MCP_OBJECT",
corsOptions: CORS_OPTIONS,
}).fetch(request, env, ctx);
}
if (url.pathname.startsWith("/sse")) {
return OSRSWikiMCP.serveSSE("/sse", {
binding: "MCP_OBJECT",
corsOptions: CORS_OPTIONS,
}).fetch(request, env, ctx);
}
return Response.json({ error: "Not found" }, { status: 404 });
},
};