@zerospacegg/anrubic
Version:
Anrubic - ZeroSpace.gg MCP Server for AI agents to access game data
878 lines (797 loc) • 25.3 kB
text/typescript
#!/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 iolin data using new API pattern
import type { Entity } from "@zerospacegg/iolin";
import { allEntities } from "@zerospacegg/iolin/meta/all";
import type { Summary } from "@zerospacegg/iolin/meta/search-index";
import { getIndexedEntities, getSearchIndex } from "@zerospacegg/iolin/meta/search-index";
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");
// TODO: Import clean mechanics service after porting to new iolin
// import { mechanicsService } from "./mechanics-service.js";
// Tagged entities mapping (tag -> entity IDs)
type TaggedEntities = Record<string, string[]>;
let taggedEntitiesCache: TaggedEntities | null = null;
// Lore index cache (independent of iolin)
let loreIndexCache: LoreIndex | null = 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(): Promise<TaggedEntities> {
return {};
}
// Deprecated - use mechanicsService instead
async function getLoreIndex(): Promise<LoreIndex | null> {
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, LoreIndex, 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: any[] = [];
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]: [string, any]) => ({
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: any) =>
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: Set<string>;
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: string) => 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: string) =>
entities.find(
(entity: any) => 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: Entity[] = 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: any) => entity.type === "building");
if (faction) {
buildingData = buildingData.filter((entity: any) => 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: any) => entity.type === type);
}
if (faction) {
entities = entities.filter((entity: any) => entity.faction === faction);
}
const ids = entities.map((entity: any) => 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: any) => entity.type === type);
}
// Filter by faction if specified
if (faction) {
entities = entities.filter((entity: any) => entity.faction === faction);
}
// Separate entities with and without lore
const entitiesWithLore = entities.filter((entity: any) => entity.hasLore === true);
const entitiesWithoutLore = entities.filter((entity: any) => entity.hasLore !== true);
// Group entities with lore by type
const loreByType: Record<string, any[]> = entitiesWithLore.reduce(
(acc: Record<string, any[]>, entity: any) => {
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: any, type: string) => {
acc[type] = loreByType[type].length;
return acc;
}, {}),
};
// Format entities with lore
const entitiesData = entitiesWithLore
.map((entity: any) => ({
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: any, b: any) => a.name.localeCompare(b.name));
const response = {
summary,
entitiesByType: Object.keys(loreByType).reduce((acc: any, type: string) => {
acc[type] = loreByType[type]
.map((entity: any) => ({
id: entity.id,
name: entity.name,
faction: entity.faction,
subtype: entity.subtype,
tier: entity.tier,
hasLore: entity.hasLore,
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
return acc;
}, {}),
allEntitiesWithLore: entitiesData,
entitiesWithoutLore: entitiesWithoutLore
.map((entity: any) => ({
id: entity.id,
name: entity.name,
faction: entity.faction,
type: entity.type,
subtype: entity.subtype,
tier: entity.tier,
}))
.sort((a: any, b: any) => 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);
});