UNPKG

@zerospacegg/anrubic

Version:

Anrubic - ZeroSpace.gg MCP Server for AI agents to access game data

744 lines 29.8 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { allEntities } from "@zerospacegg/iolin/meta/all"; import { tags as tagsInfo } from "@zerospacegg/iolin/meta/tags"; import createDebug from "debug"; // Debug namespaces const debug = createDebug("anrubic:server"); const debugLore = createDebug("anrubic:lore"); const debugPkl = createDebug("anrubic:pkl"); const debugError = createDebug("anrubic:error"); let taggedEntitiesCache = null; // Lore index cache (independent of iolin) let loreIndexCache = null; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // TODO: Implement tag-to-entity mapping in new iolin API async function getTaggedEntities() { return {}; } // Deprecated - use mechanicsService instead async function getLoreIndex() { if (!loreIndexCache) { try { // Try dist/ first (production), then fall back to dev path const prodLorePath = path.join(__dirname, "zerospace_lore.md"); const devLorePath = path.join(__dirname, "..", "..", "docs", "zerospace_lore.md"); let loreFilePath = prodLorePath; try { fs.accessSync(prodLorePath); debugLore(`Using production lore file: ${prodLorePath}`); } catch { loreFilePath = devLorePath; debugLore(`Using development lore file: ${devLorePath}`); } debugLore(`Loading lore index from: ${loreFilePath}`); const startTime = Date.now(); loreIndexCache = buildLoreIndex(loreFilePath); const buildTime = Date.now() - startTime; debugLore(`Lore index built in ${buildTime}ms (${loreIndexCache.metadata.totalChunks} chunks, ${loreIndexCache.metadata.totalTokens} tokens)`); } catch (error) { debugError("Failed to load lore index:", error); return null; } } return loreIndexCache; } // TODO: Import Pkl evaluator after porting to new iolin // import { // executeCombatAnalysis, // executeEmperorCalculation, // executeUnitComparison, // findCounters, // getPklHealth, // initializePkl, // isPklAvailable, // } from "./pkl-evaluator.js"; // Import lore indexer (independent of iolin) import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { buildLoreIndex, searchLore } from "./lore-indexer.js"; // Create the MCP server const server = new Server({ name: "anrubic", version: "1.0.0", }, { capabilities: { tools: {}, }, }); // Initialize Pkl on startup // TODO: PKL replaced with TypeScript in new iolin // let pklInitialized = false; // async function ensurePklInitialized() { // if (!pklInitialized) { // const result = await initializePkl(); // pklInitialized = true; // debug("New iolin TypeScript system ready!"); // } // } server.setRequestHandler(ListToolsRequestSchema, async () => { // TODO: No PKL initialization needed with new iolin const basicTools = [ { name: "get_tags", description: "Get all available tags for filtering and discovery", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "search_entities", description: "Search for game entities (units, buildings, etc.) by name or description", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search term to find entities", }, limit: { type: "number", description: "Maximum number of results to return (default: 10)", default: 10, }, }, required: ["query"], }, }, { name: "find_by_tags", description: "Find entities that match specific tags", inputSchema: { type: "object", properties: { tags: { type: "array", items: { type: "string" }, description: "Array of tags to search for", }, matchAll: { type: "boolean", description: "Whether to match all tags (true) or any tag (false)", default: true, }, limit: { type: "number", description: "Maximum number of results to return (default: 20)", default: 20, }, }, required: ["tags"], }, }, { name: "fetch_entity_by_id", description: "Fetch a specific entity by its ID", inputSchema: { type: "object", properties: { id: { type: "string", description: "Entity ID to fetch", }, }, required: ["id"], }, }, { name: "compare_units", description: "Compare two units side by side with basic stats", inputSchema: { type: "object", properties: { unitA: { type: "string", description: "Name or ID of first unit", }, unitB: { type: "string", description: "Name or ID of second unit", }, }, required: ["unitA", "unitB"], }, }, { name: "get_faction_data", description: "Get comprehensive data for a specific faction", inputSchema: { type: "object", properties: { faction: { type: "string", description: "Faction name (e.g., 'grell', 'protectorate', 'legion')", }, }, required: ["faction"], }, }, { name: "get_tech_tree", description: "Get tech tree and building requirements for a faction", inputSchema: { type: "object", properties: { faction: { type: "string", description: "Faction name (optional, returns all if not specified)", }, }, }, }, { name: "all_ids", description: "Get all entity IDs, optionally filtered by type and/or faction", inputSchema: { type: "object", properties: { type: { type: "string", description: "Entity type filter (e.g., 'unit', 'building')", }, faction: { type: "string", description: "Faction filter", }, }, }, }, { name: "check_lore", description: "Check which entities have lore data available and get lore statistics", inputSchema: { type: "object", properties: { includePreview: { type: "boolean", description: "Include short lore previews in results (default: false)", default: false, }, type: { type: "string", description: "Filter by entity type (e.g., 'faction', 'unit', 'building')", }, faction: { type: "string", description: "Filter by faction name", }, }, }, }, { name: "search_lore", description: "Search ZeroSpace lore for specific topics, characters, or concepts", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query - can be single words, phrases, or concepts (e.g., 'blood magic', 'Leviathan', 'ancient history')", }, limit: { type: "number", description: "Maximum number of results to return (default: 5)", default: 5, }, }, required: ["query"], }, }, { name: "get_lore_stats", description: "Get statistics about the lore database for debugging and exploration", inputSchema: { type: "object", properties: {}, required: [], }, }, // TODO: Re-enable after fixing mechanics in iolin itself // { // name: "get_mechanics", // description: "Get ZeroSpace game mechanics documentation and explanations", // inputSchema: { // type: "object", // properties: { // term: { // type: "string", // description: "Optional specific mechanic term to retrieve (e.g., 'ABES', 'Biomass')", // }, // }, // required: [], // }, // }, ]; // TODO: Add TypeScript-powered combat tools from new iolin later const pklTools = []; return { tools: [...basicTools, ...pklTools], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { // TODO: No PKL initialization needed with new iolin const { name, arguments: args } = request.params; try { switch (name) { case "get_tags": { const tagList = Object.entries(tagsInfo).map(([tag, info]) => ({ tag, label: info.label, description: info.description, slug: info.slug, })); return { content: [ { type: "text", text: JSON.stringify(tagList, null, 2), }, ], }; } case "search_entities": { const schema = z.object({ query: z.string(), limit: z.number().optional().default(10), }); const { query, limit } = schema.parse(args); const entities = allEntities(); const results = entities .filter((entity) => entity.name.toLowerCase().includes(query.toLowerCase()) || (entity.description && entity.description.toLowerCase().includes(query.toLowerCase()))) .slice(0, limit); return { content: [ { type: "text", text: JSON.stringify({ query, totalMatches: results.length, results, }, null, 2), }, ], }; } case "find_by_tags": { const schema = z.object({ tags: z.array(z.string()), matchAll: z.boolean().optional().default(true), limit: z.number().optional().default(20), }); const { tags, matchAll, limit } = schema.parse(args); // Get the tagged entities mapping const taggedEntities = await getTaggedEntities(); // Use the tagged entities mapping instead of checking entity.tags let matchingEntityIds; if (matchAll) { // For matchAll=true, find entities that have ALL tags const tagEntitySets = tags.map((tag) => new Set(taggedEntities[tag] || [])); if (tagEntitySets.length === 0) { matchingEntityIds = new Set(); } else { // Start with first tag's entities matchingEntityIds = new Set(tagEntitySets[0]); // Intersect with each subsequent tag's entities for (let i = 1; i < tagEntitySets.length; i++) { matchingEntityIds = new Set([...matchingEntityIds].filter((id) => tagEntitySets[i].has(id))); } } } else { // For matchAll=false, find entities that have ANY tag matchingEntityIds = new Set(); for (const tag of tags) { const entityIdsForTag = taggedEntities[tag] || []; entityIdsForTag.forEach((id) => matchingEntityIds.add(id)); } } // Convert to array and slice for limit const matchingIds = Array.from(matchingEntityIds).slice(0, limit); // Get the actual entities const allEntitiesMap = allEntities(); const results = matchingIds.map((id) => allEntitiesMap.find(e => e.id === id)).filter(Boolean); return { content: [ { type: "text", text: JSON.stringify({ searchTags: tags, matchAll, totalMatches: matchingEntityIds.size, resultsReturned: results.length, results, }, null, 2), }, ], }; } case "fetch_entity_by_id": { const schema = z.object({ id: z.string(), }); const { id } = schema.parse(args); // Try to get full entity data first, fallback to summary if not found const fullEntity = allEntities().find(e => e.id === id); if (fullEntity) { return { content: [ { type: "text", text: JSON.stringify(fullEntity, null, 2), }, ], }; } // Fallback to summary data if full entity not found const entity = allEntities().find(e => e.id === id); if (!entity) { return { content: [ { type: "text", text: JSON.stringify({ error: `Entity not found: ${id}` }, null, 2), }, ], }; } return { content: [ { type: "text", text: JSON.stringify(entity, null, 2), }, ], }; } case "compare_units": { const schema = z.object({ unitA: z.string(), unitB: z.string(), }); const { unitA, unitB } = schema.parse(args); // Simple comparison using JSON data const entities = allEntities(); const findUnit = (name) => entities.find((entity) => entity.name.toLowerCase() === name.toLowerCase() || entity.id === name); const unit1 = findUnit(unitA); const unit2 = findUnit(unitB); if (!unit1 || !unit2) { return { content: [ { type: "text", text: JSON.stringify({ error: "One or both units not found", unitA: unit1 ? "found" : "not found", unitB: unit2 ? "found" : "not found", }, null, 2), }, ], }; } return { content: [ { type: "text", text: JSON.stringify({ comparison: "basic", unitA: unit1, unitB: unit2, note: "PKL system replaced with TypeScript - basic comparison only", }, null, 2), }, ], }; } case "get_faction_data": { const schema = z.object({ faction: z.string(), }); const { faction } = schema.parse(args); const entities = allEntities(); const factionUnits = entities.filter((e) => e.id.includes("/unit/") && e.faction === faction); const factionBuildings = entities.filter((e) => e.id.includes("/building/") && e.faction === faction); if (factionUnits.length === 0 && factionBuildings.length === 0) { const availableFactions = [...new Set(entities.map((e) => e.faction).filter(Boolean))]; return { content: [ { type: "text", text: `No data found for faction "${faction}". Available factions: ${availableFactions.join(", ")}`, }, ], }; } return { content: [ { type: "text", text: JSON.stringify({ faction, units: factionUnits, buildings: factionBuildings, totalEntities: factionUnits.length + factionBuildings.length, }, null, 2), }, ], }; } case "get_tech_tree": { const schema = z.object({ faction: z.string().optional(), }); const { faction } = schema.parse(args); const entities = allEntities(); let buildingData = entities.filter((entity) => entity.type === "building"); if (faction) { buildingData = buildingData.filter((entity) => entity.faction === faction); } return { content: [ { type: "text", text: JSON.stringify({ faction: faction || "all", buildings: buildingData, }, null, 2), }, ], }; } case "all_ids": { const schema = z.object({ type: z.string().optional(), faction: z.string().optional(), }); const { type, faction } = schema.parse(args); let entities = allEntities(); if (type) { entities = entities.filter((entity) => entity.type === type); } if (faction) { entities = entities.filter((entity) => entity.faction === faction); } const ids = entities.map((entity) => entity.id); return { content: [ { type: "text", text: JSON.stringify({ filters: { type, faction }, totalIds: ids.length, ids, }, null, 2), }, ], }; } case "check_lore": { const schema = z.object({ includePreview: z.boolean().optional().default(false), type: z.string().optional(), faction: z.string().optional(), }); const { includePreview, type, faction } = schema.parse(args); let entities = allEntities(); // Filter by type if specified if (type) { entities = entities.filter((entity) => entity.type === type); } // Filter by faction if specified if (faction) { entities = entities.filter((entity) => entity.faction === faction); } // Separate entities with and without lore const entitiesWithLore = entities.filter((entity) => entity.hasLore === true); const entitiesWithoutLore = entities.filter((entity) => entity.hasLore !== true); // Group entities with lore by type const loreByType = entitiesWithLore.reduce((acc, entity) => { if (!acc[entity.type]) { acc[entity.type] = []; } acc[entity.type].push(entity); return acc; }, {}); // Create summary const summary = { totalEntities: entities.length, entitiesWithLore: entitiesWithLore.length, entitiesWithoutLore: entitiesWithoutLore.length, coverage: entities.length > 0 ? Math.round((entitiesWithLore.length / entities.length) * 100) : 0, typeBreakdown: Object.keys(loreByType).reduce((acc, type) => { acc[type] = loreByType[type].length; return acc; }, {}), }; // Format entities with lore const entitiesData = entitiesWithLore .map((entity) => ({ id: entity.id, name: entity.name, faction: entity.faction, type: entity.type, subtype: entity.subtype, tier: entity.tier, slug: entity.slug, hasLore: entity.hasLore, })) .sort((a, b) => a.name.localeCompare(b.name)); const response = { summary, entitiesByType: Object.keys(loreByType).reduce((acc, type) => { acc[type] = loreByType[type] .map((entity) => ({ id: entity.id, name: entity.name, faction: entity.faction, subtype: entity.subtype, tier: entity.tier, hasLore: entity.hasLore, })) .sort((a, b) => a.name.localeCompare(b.name)); return acc; }, {}), allEntitiesWithLore: entitiesData, entitiesWithoutLore: entitiesWithoutLore .map((entity) => ({ id: entity.id, name: entity.name, faction: entity.faction, type: entity.type, subtype: entity.subtype, tier: entity.tier, })) .sort((a, b) => a.name.localeCompare(b.name)), }; return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } case "search_lore": { const loreIndex = await getLoreIndex(); if (!loreIndex) { return { content: [ { type: "text", text: "Lore index not available - could not load lore data", }, ], }; } const schema = z.object({ query: z.string(), limit: z.number().optional().default(5), }); const { query, limit } = schema.parse(args); const results = searchLore(loreIndex, query, limit); if (results.length === 0) { return { content: [ { type: "text", text: `No lore found for query: "${query}"`, }, ], }; } const response = { query, totalResults: results.length, results: results.map((result) => ({ title: result.chunk.title, section: result.chunk.section, subsection: result.chunk.subsection, score: Math.round(result.score * 100) / 100, matchedTokens: result.matchedTokens, lineRange: `${result.chunk.startLine}-${result.chunk.endLine}`, content: result.chunk.content, context: result.context, })), }; return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } case "get_lore_stats": { const loreIndex = await getLoreIndex(); if (!loreIndex) { return { content: [ { type: "text", text: "ERROR: Lore index not available", }, ], }; } return { content: [ { type: "text", text: JSON.stringify(loreIndex.metadata, null, 2), }, ], }; } // TODO: Re-enable after porting mechanics service to new iolin // case "get_mechanics": { // try { // const { term } = request.params.arguments as { term?: string }; // // ... (mechanics service implementation) // } catch (error) { // debugError("Error in get_mechanics handler:", error); // return { content: [{ type: "text", text: `ERROR: Mechanics service not available` }] }; // } // } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], }; } }); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); debug("Anrubic MCP server running with Pkl integration support!"); } main().catch((error) => { debugError("Failed to start server:", error); process.exit(1); }); //# sourceMappingURL=index.js.map