// src/index.ts
import { McpAgent } from 'agents/mcp';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
interface Env {
OSE_DATA: R2Bucket;
CACHE: KVNamespace;
OSE_MCP: DurableObjectNamespace;
}
interface CompendiumEntry {
_id: string;
name: string;
type: string;
system?: any;
description?: string;
[key: string]: any;
}
// Database parser for NeDB format (used by Foundry VTT)
class NeDBParser {
static parseFile(content: string): CompendiumEntry[] {
const lines = content.split('\n').filter(line => line.trim());
const entries: CompendiumEntry[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
entries.push(entry);
} catch (e) {
// Skip malformed lines
}
}
return entries;
}
}
export class OldSchoolEssentialsMCP extends McpAgent<Env> {
public server: McpServer;
constructor(state: DurableObjectState, env: Env) {
super(state, env);
this.server = new McpServer({
name: "old-school-essentials",
version: "1.0.0",
description: "MCP server for querying Old School Essentials rules and compendium data"
});
}
async init() {
this.server.tool("search_rules", {
description: "Search for rules, classes, spells, monsters, or items in the Old School Essentials compendium",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query"
},
type: {
type: "string",
enum: ["all", "class", "spell", "monster", "item", "rule", "table"],
description: "The type of content to search for"
},
limit: {
type: "number",
description: "Maximum number of results to return",
default: 10
}
},
required: ["query"]
}
}, async (args) => {
const result = await this.searchRules(args);
return { content: [{ type: "text", text: result }] };
});
this.server.tool("get_entry", {
description: "Get a specific entry by its ID",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "The unique ID of the entry"
},
pack: {
type: "string",
description: "The pack/compendium name containing the entry"
}
},
required: ["id", "pack"]
}
}, async (args) => {
const result = await this.getEntry(args);
return { content: [{ type: "text", text: result }] };
});
this.server.tool("list_packs", {
description: "List all available compendium packs",
inputSchema: {
type: "object",
properties: {}
}
}, async () => {
const result = await this.listPacks();
return { content: [{ type: "text", text: result }] };
});
this.server.tool("get_class_details", {
description: "Get detailed information about a character class including abilities, saving throws, and progression",
inputSchema: {
type: "object",
properties: {
className: {
type: "string",
description: "The name of the character class"
}
},
required: ["className"]
}
}, async (args) => {
const result = await this.getClassDetails(args);
return { content: [{ type: "text", text: result }] };
});
this.server.tool("get_spell_details", {
description: "Get detailed information about a spell including level, range, duration, and effects",
inputSchema: {
type: "object",
properties: {
spellName: {
type: "string",
description: "The name of the spell"
}
},
required: ["spellName"]
}
}, async (args) => {
const result = await this.getSpellDetails(args);
return { content: [{ type: "text", text: result }] };
});
this.server.tool("get_monster_stats", {
description: "Get monster statistics including AC, HD, attacks, and special abilities",
inputSchema: {
type: "object",
properties: {
monsterName: {
type: "string",
description: "The name of the monster"
}
},
required: ["monsterName"]
}
}, async (args) => {
const result = await this.getMonsterStats(args);
return { content: [{ type: "text", text: result }] };
});
}
// Helper method to load pack data from R2
private async loadPackData(packName: string): Promise<CompendiumEntry[]> {
const cacheKey = `pack:${packName}`;
// Check cache first
const cached = await this.env.CACHE.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
try {
// Load from R2
const object = await this.env.OSE_DATA.get(`packs/${packName}.db`);
if (!object) {
throw new Error(`Pack ${packName} not found`);
}
const text = await object.text();
const entries = NeDBParser.parseFile(text);
// Cache for 1 hour
await this.env.CACHE.put(cacheKey, JSON.stringify(entries), {
expirationTtl: 3600
});
return entries;
} catch (error) {
console.error(`Error loading pack ${packName}:`, error);
return [];
}
}
// Tool implementations
private async searchRules(args: any): Promise<string> {
const { query, type = "all", limit = 10 } = args;
const searchQuery = query.toLowerCase();
// Define which packs to search based on type
const packsToSearch: string[] = [];
switch (type) {
case "class":
packsToSearch.push("classes");
break;
case "spell":
packsToSearch.push("spells-cleric", "spells-mu");
break;
case "monster":
packsToSearch.push("monsters");
break;
case "item":
packsToSearch.push("items", "armor", "weapons");
break;
case "rule":
packsToSearch.push("rules");
break;
case "table":
packsToSearch.push("rollable-tables");
break;
default:
// Search all packs
packsToSearch.push(
"classes", "spells-cleric", "spells-mu", "monsters",
"items", "armor", "weapons", "rules", "rollable-tables"
);
}
const results: any[] = [];
for (const pack of packsToSearch) {
const entries = await this.loadPackData(pack);
for (const entry of entries) {
if (results.length >= limit) break;
// Search in name and description
const nameMatch = entry.name?.toLowerCase().includes(searchQuery);
const descMatch = entry.description?.toLowerCase().includes(searchQuery);
const contentMatch = entry.system?.description?.value?.toLowerCase().includes(searchQuery);
if (nameMatch || descMatch || contentMatch) {
results.push({
id: entry._id,
name: entry.name,
type: entry.type,
pack: pack,
preview: entry.description || entry.system?.description?.value?.substring(0, 200) + "..."
});
}
}
}
return JSON.stringify({ results, count: results.length }, null, 2);
}
private async getEntry(args: any): Promise<string> {
const { id, pack } = args;
const entries = await this.loadPackData(pack);
const entry = entries.find(e => e._id === id);
if (!entry) {
throw new Error(`Entry ${id} not found in pack ${pack}`);
}
return JSON.stringify(entry, null, 2);
}
private async listPacks(): Promise<string> {
// This would ideally be loaded from module.json
// For now, return a hardcoded list based on typical OSE structure
const packs = [
{ name: "classes", label: "Character Classes", type: "Actor" },
{ name: "spells-cleric", label: "Cleric Spells", type: "Item" },
{ name: "spells-mu", label: "Magic-User Spells", type: "Item" },
{ name: "monsters", label: "Monsters", type: "Actor" },
{ name: "items", label: "Items", type: "Item" },
{ name: "armor", label: "Armor", type: "Item" },
{ name: "weapons", label: "Weapons", type: "Item" },
{ name: "rules", label: "Rules", type: "JournalEntry" },
{ name: "rollable-tables", label: "Tables", type: "RollTable" }
];
return JSON.stringify({ packs }, null, 2);
}
private async getClassDetails(args: any): Promise<string> {
const { className } = args;
const entries = await this.loadPackData("classes");
const classEntry = entries.find(e =>
e.name?.toLowerCase() === className.toLowerCase()
);
if (!classEntry) {
throw new Error(`Class ${className} not found`);
}
// Extract relevant class information
const details = {
name: classEntry.name,
description: classEntry.system?.description?.value,
hitDice: classEntry.system?.hp?.hd,
requirements: classEntry.system?.requirements,
primeRequisite: classEntry.system?.primeRequisite,
experienceBonus: classEntry.system?.xpBonus,
savingThrows: classEntry.system?.saves,
abilities: classEntry.system?.abilities,
restrictions: classEntry.system?.restrictions,
equipment: classEntry.system?.equipment,
languages: classEntry.system?.languages
};
return JSON.stringify(details, null, 2);
}
private async getSpellDetails(args: any): Promise<string> {
const { spellName } = args;
// Search in both cleric and magic-user spell lists
const packs = ["spells-cleric", "spells-mu"];
for (const pack of packs) {
const entries = await this.loadPackData(pack);
const spell = entries.find(e =>
e.name?.toLowerCase() === spellName.toLowerCase()
);
if (spell) {
const details = {
name: spell.name,
level: spell.system?.level,
school: spell.system?.school,
range: spell.system?.range,
duration: spell.system?.duration,
description: spell.system?.description?.value,
components: spell.system?.components,
castingTime: spell.system?.castingTime,
save: spell.system?.save
};
return JSON.stringify(details, null, 2);
}
}
throw new Error(`Spell ${spellName} not found`);
}
private async getMonsterStats(args: any): Promise<string> {
const { monsterName } = args;
const entries = await this.loadPackData("monsters");
const monster = entries.find(e =>
e.name?.toLowerCase() === monsterName.toLowerCase()
);
if (!monster) {
throw new Error(`Monster ${monsterName} not found`);
}
const stats = {
name: monster.name,
armorClass: monster.system?.ac?.value,
hitDice: monster.system?.hp?.hd,
hitPoints: monster.system?.hp?.value,
movement: monster.system?.movement,
attacks: monster.system?.attacks,
damage: monster.system?.damage,
save: monster.system?.saves,
morale: monster.system?.morale,
alignment: monster.system?.alignment,
numberAppearing: monster.system?.numberAppearing,
treasureType: monster.system?.treasureType,
specialAbilities: monster.system?.specialAbilities,
description: monster.system?.description?.value
};
return JSON.stringify(stats, null, 2);
}
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/sse" || url.pathname === "/sse/message") {
return OldSchoolEssentialsMCP.serveSSE("/sse").fetch(request, env, ctx);
}
if (url.pathname === "/mcp") {
return OldSchoolEssentialsMCP.serve("/mcp").fetch(request, env, ctx);
}
return new Response("Not found", { status: 404 });
}
} satisfies ExportedHandler<Env>;