From 24bbc22ec3de381ff5242089bde7ff3422991865 Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Wed, 24 Sep 2025 10:18:02 +1000 Subject: [PATCH] mcp init --- packages/mcp-server/README.md | 18 ++ packages/mcp-server/SPEC.md | 144 +++++++++ packages/mcp-server/package.json | 38 +++ .../src/adapters/provider-adapter.ts | 262 +++++++++++++++ .../mcp-server/src/adapters/torii-adapter.ts | 305 ++++++++++++++++++ packages/mcp-server/src/config.ts | 60 ++++ packages/mcp-server/src/context.ts | 36 +++ packages/mcp-server/src/resources/armies.ts | 41 +++ packages/mcp-server/src/resources/battles.ts | 32 ++ packages/mcp-server/src/resources/index.ts | 19 ++ packages/mcp-server/src/resources/market.ts | 24 ++ packages/mcp-server/src/resources/players.ts | 40 +++ packages/mcp-server/src/resources/realms.ts | 41 +++ packages/mcp-server/src/resources/tiles.ts | 51 +++ packages/mcp-server/src/server.ts | 43 +++ packages/mcp-server/src/tools/combat.ts | 96 ++++++ packages/mcp-server/src/tools/index.ts | 15 + packages/mcp-server/src/tools/movement.ts | 57 ++++ packages/mcp-server/src/tools/structures.ts | 62 ++++ packages/mcp-server/src/tools/trading.ts | 107 ++++++ packages/mcp-server/src/types.ts | 84 +++++ packages/mcp-server/src/utils/logger.ts | 8 + packages/mcp-server/src/utils/mcp.ts | 33 ++ packages/mcp-server/tests/smoke.test.ts | 7 + packages/mcp-server/tsconfig.json | 9 + 25 files changed, 1632 insertions(+) create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/SPEC.md create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/adapters/provider-adapter.ts create mode 100644 packages/mcp-server/src/adapters/torii-adapter.ts create mode 100644 packages/mcp-server/src/config.ts create mode 100644 packages/mcp-server/src/context.ts create mode 100644 packages/mcp-server/src/resources/armies.ts create mode 100644 packages/mcp-server/src/resources/battles.ts create mode 100644 packages/mcp-server/src/resources/index.ts create mode 100644 packages/mcp-server/src/resources/market.ts create mode 100644 packages/mcp-server/src/resources/players.ts create mode 100644 packages/mcp-server/src/resources/realms.ts create mode 100644 packages/mcp-server/src/resources/tiles.ts create mode 100644 packages/mcp-server/src/server.ts create mode 100644 packages/mcp-server/src/tools/combat.ts create mode 100644 packages/mcp-server/src/tools/index.ts create mode 100644 packages/mcp-server/src/tools/movement.ts create mode 100644 packages/mcp-server/src/tools/structures.ts create mode 100644 packages/mcp-server/src/tools/trading.ts create mode 100644 packages/mcp-server/src/types.ts create mode 100644 packages/mcp-server/src/utils/logger.ts create mode 100644 packages/mcp-server/src/utils/mcp.ts create mode 100644 packages/mcp-server/tests/smoke.test.ts create mode 100644 packages/mcp-server/tsconfig.json diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 0000000000..bc554daac4 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,18 @@ +# Eternum MCP Server + +This package hosts the Model Context Protocol (MCP) server that surfaces Eternum's live game state and transactional tools for AI agents. + +## Getting Started + +```bash +pnpm install +pnpm --dir packages/mcp-server build +``` + +## Development Commands + +- `pnpm --dir packages/mcp-server dev` – build in watch mode. +- `pnpm --dir packages/mcp-server test` – run unit tests (Vitest). +- `pnpm --dir packages/mcp-server lint` – lint source files. + +Refer to [`SPEC.md`](./SPEC.md) for the full architecture and implementation plan. diff --git a/packages/mcp-server/SPEC.md b/packages/mcp-server/SPEC.md new file mode 100644 index 0000000000..4d01b3e884 --- /dev/null +++ b/packages/mcp-server/SPEC.md @@ -0,0 +1,144 @@ +# MCP Server Specification + +## Purpose & Scope +- Expose Eternum's live world state and transactional capabilities to AI agents via MCP. +- Reuse existing Torii SQL queries, gRPC subscriptions, provider transaction layer, and shared type definitions. +- Out of scope: UI wiring, direct LLM prompt design, secrets UX, or production infra provisioning. + +## Key Dependencies +- Runtime: `@modelcontextprotocol/sdk`, `@bibliothecadao/torii`, `@bibliothecadao/provider`, `@bibliothecadao/types`, `zod`, preferred logger (e.g., `pino` or existing telemetry), optional caching helpers (`lru-cache`, `dataloader`). +- Build/tooling: `tsup`, `typescript` (extending `packages/tsconfig.base.json`), `vitest`. +- External inputs: Torii SQL base URL, Torii gRPC endpoint, Starknet RPC, Dojo manifest path, signing key for transactional tools. + +## Package Layout +``` +packages/mcp-server/ +├── package.json +├── tsconfig.json +├── README.md +├── SPEC.md +├── src/ +│ ├── server.ts +│ ├── config.ts +│ ├── context.ts +│ ├── types.ts +│ ├── utils/ +│ │ └── logger.ts +│ ├── adapters/ +│ │ ├── torii-adapter.ts +│ │ └── provider-adapter.ts +│ ├── resources/ +│ │ ├── index.ts +│ │ ├── realms.ts +│ │ ├── armies.ts +│ │ ├── tiles.ts +│ │ ├── market.ts +│ │ ├── players.ts +│ │ └── battles.ts +│ └── tools/ +│ ├── index.ts +│ ├── trading.ts +│ ├── movement.ts +│ ├── structures.ts +│ └── combat.ts +└── tests/ + ├── torii-adapter.test.ts + ├── provider-adapter.test.ts + ├── resources.test.ts + └── tools.test.ts +``` + +## Runtime Architecture +- `ServerContext` lazily constructs shared clients (Torii SQL, Torii gRPC, provider, caches) and exposes lifecycle hooks. +- Resource registry files call `registerResource` with metadata, completions, and caching policy (TTL + subscription invalidation). +- Tool registry files call `registerTool`, leveraging provider adapter for Starknet transactions and returning MCP-compliant responses. +- Notification debouncing enabled for tools/resources/prompts list updates to minimize chatter. + +## Resources +Each resource returns JSON payloads (`mimeType: application/json`) representing agent-friendly DTOs defined in `types.ts`. + +### `eternum://realms/{realmId}` +- Data: realm metadata, owner, wonders, resources, population, connected settlements, garrisons, relics. +- Sources: `SqlApi.fetchStructureByCoord`, `SqlApi.fetchPlayerStructures`, `SqlApi.fetchGuardsByStructure`, resource unpacking utilities from core package. +- Caching: TTL ~5s with invalidation on Torii entity updates. Provide completions based on realm names/IDs. + +### `eternum://armies/{entityId}` +- Data: explorer troops, stamina, carried resources, coordinates, active orders, relics. +- Sources: `SqlApi.fetchAllArmiesMapData`, `SqlApi.fetchPlayerArmyRelics`, Torii gRPC subscription. +- Caching: TTL ~2s plus push updates. + +### `eternum://tiles/{x},{y}` and `eternum://tiles/nearby/{x},{y}` +- Data: biome, occupants, fog state, production boosts, nearby chests. +- Sources: `SqlApi.fetchTilesByCoords`, structure/army map data aggregations, chest queries for completions. + +### `eternum://market/orders` +- Data: active trade orders, pricing, liquidity summaries, pagination via query params. +- Sources: `SqlApi` trading queries, `MarketManager` helpers for price/slippage calculations. + +### `eternum://players/{address}` +- Data: player stats, structures, armies, guild membership, relic holdings. +- Sources: `SqlApi.fetchPlayerStructures`, `SqlApi.fetchAllPlayerRelics`, structure/explorer summary query. + +### `eternum://battles/logs` +- Data: chronological combat log with participants, outcome, rewards, casualties; filterable by timestamp. +- Sources: `SqlApi.fetchBattleLogs`, optional Torii subscription for live updates. + +## Tools +Handlers validate inputs with `zod`, perform optimistic checks via core managers, invoke provider adapter, and surface MCP responses (`content`, `resource_link`) with normalized error codes. + +### `create_trade_order` +- Schema: maker/taker IDs, offered/requested resources, expiration. +- Execution: provider `create_order`; optional preflight pricing via `MarketManager`. +- Response: acknowledgement + resource link to specific trade in market resource. + +### `move_army` +- Schema: explorer ID, direction path, explore flag. +- Execution: provider `explorer_move`; stamina check via `ArmyManager`. + +### `upgrade_structure` +- Schema: structure ID and optional category confirmation. +- Execution: provider `upgrade_realm` or relevant entrypoint; uses `StructureActionManager` for cost validation. + +### `explore_tile` +- Schema: explorer ID, target direction/coord. +- Execution: `explorer_move` with explore flag, optional VRF call; streams reveal updates. + +### `join_battle` +- Schema: battle ID, participant ID, role, optional steal resources. +- Execution: choose appropriate provider call (`attack_explorer_vs_guard`, etc.), ensure adjacency requirements. + +## Configuration & Secrets +- Environment-driven config validated by `config.ts` (`TORII_SQL_URL`, `TORII_GRPC_URL`, `STARKNET_RPC_URL`, `DOJO_MANIFEST_PATH`, `ACCOUNT_PRIVATE_KEY`, optional cache TTLs, `LOG_LEVEL`). +- Support custom account providers by allowing injection via context factory. +- Mask sensitive values in logs. + +## Transport Modes +- Default stdio transport for CLI/agent integration. +- Optional Streamable HTTP mode when `MCP_HTTP_PORT` set; minimal Express bootstrap with session management and DNS rebinding protection options. + +## Error Handling & Logging +- Centralized logger that tags events with request IDs. +- Resource/tool handlers wrap operations in try/catch; on failure log structured error and return `isError` with `errorId`. +- Provider adapter maps common Starknet failures to semantic error codes (`INSUFFICIENT_FUNDS`, `INVALID_TARGET`, etc.). + +## Testing Strategy +- Vitest unit tests for adapters (mock fetch/provider), resources (schema/caching), tools (calldata and response). Use fixtures for deterministic SQL responses. +- Optional integration harness against local Torii when available. + +## Workspace Integration +- Add package to `pnpm-workspace.yaml`; reuse repo lint/format tooling. +- Document usage in new README with setup commands and sample MCP client calls. +- Future: integrate with `client/apps/bot` to replace static docs with live MCP queries. + +## Delivery Milestones +1. Scaffold package with config/context skeletons and single realm resource (read-only). +2. Implement remaining resources, caching, and telemetry. +3. Add transactional tools and error normalization. +4. Expose HTTP transport and subscription handling. +5. Harden via tests/documentation, then integrate with bot agents. + +## Open Questions +- Preferred secret sourcing for signer credentials (local key vs cloud vault). +- Acceptable data staleness thresholds per resource and Torii query rate limits. +- Need for bundled prompts or higher-level agent workflows. +- Expected response format nuances for downstream consumers (pure JSON vs mixed markdown). diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 0000000000..dec26f7b3b --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@bibliothecadao/mcp-server", + "version": "0.1.0", + "description": "Model Context Protocol server exposing Eternum game data and actions", + "license": "MIT", + "author": "Bibliotheca DAO", + "type": "module", + "files": ["dist"], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup src/server.ts --dts --format esm --clean", + "dev": "tsup src/server.ts --dts --format esm --watch", + "lint": "eslint .", + "test": "vitest" + }, + "dependencies": { + "@bibliothecadao/provider": "workspace:*", + "@bibliothecadao/torii": "workspace:*", + "@bibliothecadao/types": "workspace:*", + "@modelcontextprotocol/sdk": "^0.5.0", + "lru-cache": "^11.0.1", + "pino": "^9.3.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.11.10", + "tsup": "^8.0.2", + "typescript": "^5.4.4", + "vitest": "^2.0.5" + } +} diff --git a/packages/mcp-server/src/adapters/provider-adapter.ts b/packages/mcp-server/src/adapters/provider-adapter.ts new file mode 100644 index 0000000000..3307bb7754 --- /dev/null +++ b/packages/mcp-server/src/adapters/provider-adapter.ts @@ -0,0 +1,262 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { EternumProvider } from "@bibliothecadao/provider"; +import type { + AcceptOrderProps, + AttackExplorerVsExplorerProps, + AttackExplorerVsGuardProps, + AttackGuardVsExplorerProps, + CreateOrderProps, + ExplorerMoveProps, + UpgradeRealmProps, +} from "@bibliothecadao/types"; +import type { + Account, + AccountInterface, + GetTransactionReceiptResponse, +} from "starknet"; +import { Account as StarknetAccount } from "starknet"; + +import type { Config } from "../config.js"; +import { logger } from "../utils/logger.js"; + +export interface ProviderBootstrapConfig { + manifestPath?: string; + manifestJson?: string; + rpcUrl?: string; + accountAddress?: string; + accountPrivateKey?: string; + vrfProviderAddress?: string; +} + +export interface CreateTradeOrderArgs { + makerId: number; + takerId: number; + makerResourceId: number; + makerAmount: number; + makerMaxCount?: number; + takerResourceId: number; + takerAmount: number; + expiresAt: number; + signer?: AccountInterface | Account; +} + +export interface AcceptTradeOrderArgs { + tradeId: number; + takerId: number; + takerBuysCount: number; + signer?: AccountInterface | Account; +} + +export interface MoveExplorerArgs { + explorerId: number; + directions: number[]; + explore?: boolean; + signer?: AccountInterface | Account; +} + +export interface UpgradeRealmArgs { + realmEntityId: number; + signer?: AccountInterface | Account; +} + +export interface AttackExplorerVsExplorerArgs { + aggressorId: number; + defenderId: number; + defenderDirection: number; + stealResources: { resourceId: number; amount: number }[]; + signer?: AccountInterface | Account; +} + +export interface AttackExplorerVsGuardArgs { + explorerId: number; + structureId: number; + structureDirection: number; + signer?: AccountInterface | Account; +} + +export interface AttackGuardVsExplorerArgs { + structureId: number; + guardSlot: number; + explorerId: number; + explorerDirection: number; + signer?: AccountInterface | Account; +} + +export class ProviderAdapter { + private constructor( + private readonly provider: EternumProvider, + private readonly defaultSigner: AccountInterface | Account, + ) {} + + static async createFromConfig(config: Config): Promise { + if (!config.accountAddress || !config.accountPrivateKey) { + return undefined; + } + + const bootstrapConfig: ProviderBootstrapConfig = { + manifestPath: config.dojoManifestPath, + manifestJson: config.dojoManifestJson, + rpcUrl: config.starknetRpcUrl, + accountAddress: config.accountAddress, + accountPrivateKey: config.accountPrivateKey, + vrfProviderAddress: config.vrfProviderAddress, + }; + + return ProviderAdapter.create(bootstrapConfig); + } + + static async create(config: ProviderBootstrapConfig): Promise { + if (!config.accountAddress || !config.accountPrivateKey) { + return undefined; + } + + const manifest = await ProviderAdapter.loadManifest(config); + if (!manifest) { + logger.warn("Skipping provider adapter bootstrap because manifest could not be loaded"); + return undefined; + } + + const provider = new EternumProvider(manifest, config.rpcUrl, config.vrfProviderAddress); + + const signer = new StarknetAccount({ + provider: provider.provider, + address: config.accountAddress, + signer: config.accountPrivateKey, + }); + + return new ProviderAdapter(provider, signer); + } + + private static async loadManifest(config: ProviderBootstrapConfig): Promise { + if (config.manifestJson) { + try { + return JSON.parse(config.manifestJson); + } catch (error) { + logger.error({ error }, "Failed to parse MCP_DOJO_MANIFEST_JSON"); + return undefined; + } + } + + if (config.manifestPath) { + try { + const filePath = resolve(process.cwd(), config.manifestPath); + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + logger.error({ error }, "Failed to load Dojo manifest from path"); + return undefined; + } + } + + return undefined; + } + + getSigner(): AccountInterface | Account { + return this.defaultSigner; + } + + getProvider(): EternumProvider { + return this.provider; + } + + async createTradeOrder(args: CreateTradeOrderArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: CreateOrderProps = { + maker_id: args.makerId, + taker_id: args.takerId, + maker_gives_resource_type: args.makerResourceId, + maker_gives_min_resource_amount: args.makerAmount, + maker_gives_max_count: args.makerMaxCount ?? 1, + taker_pays_resource_type: args.takerResourceId, + taker_pays_min_resource_amount: args.takerAmount, + expires_at: args.expiresAt, + signer, + }; + + return await this.provider.create_order(payload); + } + + async acceptTradeOrder(args: AcceptTradeOrderArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: AcceptOrderProps = { + trade_id: args.tradeId, + taker_id: args.takerId, + taker_buys_count: args.takerBuysCount, + signer, + }; + + return await this.provider.accept_order(payload); + } + + async moveExplorer(args: MoveExplorerArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: ExplorerMoveProps = { + explorer_id: args.explorerId, + directions: args.directions, + explore: args.explore ?? false, + signer, + }; + + return await this.provider.explorer_move(payload); + } + + async upgradeRealm(args: UpgradeRealmArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: UpgradeRealmProps = { + realm_entity_id: args.realmEntityId, + signer, + }; + + return await this.provider.upgrade_realm(payload); + } + + async attackExplorerVsExplorer(args: AttackExplorerVsExplorerArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: AttackExplorerVsExplorerProps = { + aggressor_id: args.aggressorId, + defender_id: args.defenderId, + defender_direction: args.defenderDirection, + steal_resources: args.stealResources.map((resource) => ({ + resourceId: resource.resourceId, + amount: resource.amount, + })), + signer, + }; + + return await this.provider.attack_explorer_vs_explorer(payload); + } + + async attackExplorerVsGuard(args: AttackExplorerVsGuardArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: AttackExplorerVsGuardProps = { + explorer_id: args.explorerId, + structure_id: args.structureId, + structure_direction: args.structureDirection, + signer, + }; + + return await this.provider.attack_explorer_vs_guard(payload); + } + + async attackGuardVsExplorer(args: AttackGuardVsExplorerArgs): Promise { + const signer = args.signer ?? this.defaultSigner; + + const payload: AttackGuardVsExplorerProps = { + structure_id: args.structureId, + structure_guard_slot: args.guardSlot, + explorer_id: args.explorerId, + explorer_direction: args.explorerDirection, + signer, + }; + + return await this.provider.attack_guard_vs_explorer(payload); + } +} diff --git a/packages/mcp-server/src/adapters/torii-adapter.ts b/packages/mcp-server/src/adapters/torii-adapter.ts new file mode 100644 index 0000000000..800cedbec8 --- /dev/null +++ b/packages/mcp-server/src/adapters/torii-adapter.ts @@ -0,0 +1,305 @@ +import LRUCache from "lru-cache"; + +import { SqlApi } from "@bibliothecadao/torii"; +import type { + ArmyMapDataRaw, + BattleLogEvent, + PlayerRelicsData, + PlayersData, + PlayerStructure, + StructureMapDataRaw, + Tile, + TradeEvent, +} from "@bibliothecadao/torii"; +import { StructureType } from "@bibliothecadao/types"; + +import type { + ArmySummary, + BattleLogSummary, + MarketSwapSummary, + PlayerProfile, + RealmSummary, + TileSummary, +} from "../types.js"; + +interface ToriiAdapterCacheOptions { + realmTtl?: number; + armyTtl?: number; + tileTtl?: number; + playerTtl?: number; + marketTtl?: number; + battleTtl?: number; + maxEntries?: number; +} + +export interface ToriiAdapterOptions { + cache?: ToriiAdapterCacheOptions; +} + +const DEFAULT_TTL = 5_000; +const BIGINT_ZERO = 0n; + +function hexToBigIntSafe(value?: string | null): bigint { + if (!value) return BIGINT_ZERO; + try { + return value.startsWith("0x") ? BigInt(value) : BigInt(`0x${value}`); + } catch { + return BIGINT_ZERO; + } +} + +function numericStringToNumber(value: string | number | null | undefined): number { + if (value === null || value === undefined) return 0; + if (typeof value === "number") return value; + if (value.startsWith("0x")) { + try { + return Number(BigInt(value)); + } catch { + return 0; + } + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeAddress(address: string): string { + try { + return `0x${BigInt(address).toString(16).padStart(64, "0")}`; + } catch { + return address.toLowerCase(); + } +} + +function mapStructureToRealmSummary(structure: StructureMapDataRaw): RealmSummary | null { + if (structure.structure_type !== StructureType.Realm) { + return null; + } + + return { + entityId: structure.entity_id, + realmId: structure.realm_id ?? null, + ownerAddress: structure.owner_address, + ownerName: structure.owner_name, + level: structure.level, + structureType: structure.structure_type, + coord: { x: structure.coord_x, y: structure.coord_y }, + resourcesPacked: structure.resources_packed, + raw: structure, + }; +} + +function mapArmyToSummary(army: ArmyMapDataRaw): ArmySummary { + return { + entityId: army.entity_id, + coord: { x: army.coord_x, y: army.coord_y }, + ownerAddress: army.owner_address ?? undefined, + ownerName: army.owner_name ?? undefined, + troopCategory: army.category ?? undefined, + troopTier: army.tier ?? undefined, + troopCount: hexToBigIntSafe(army.count), + staminaAmount: army.stamina_amount ? hexToBigIntSafe(army.stamina_amount) : null, + battleCooldownEnd: army.battle_cooldown_end ?? null, + raw: army, + }; +} + +function mapTileToSummary(tile: Tile): TileSummary { + return { + coord: { x: tile.col, y: tile.row }, + biome: tile.biome, + occupierId: tile.occupier_id, + occupierType: tile.occupier_type, + occupierIsStructure: tile.occupier_is_structure, + }; +} + +function mapSwapEvent(event: TradeEvent): MarketSwapSummary { + return { + takerId: event.event.takerId, + takerAddress: event.event.takerAddress, + makerId: event.event.makerId, + makerAddress: event.event.makerAddress, + resourceGivenId: event.event.resourceGiven.resourceId, + resourceGivenAmount: event.event.resourceGiven.amount, + resourceTakenId: event.event.resourceTaken.resourceId, + resourceTakenAmount: event.event.resourceTaken.amount, + timestamp: event.event.eventTime.getTime(), + raw: event, + }; +} + +function mapBattleLog(event: BattleLogEvent): BattleLogSummary { + return { + attackerId: event.attacker_id, + defenderId: event.defender_id, + attackerOwnerId: event.attacker_owner_id, + defenderOwnerId: event.defender_owner_id, + winnerId: event.winner_id, + maxReward: numericStringToNumber(event.max_reward), + success: event.success, + timestamp: numericStringToNumber(event.timestamp) * 1000, + raw: event, + }; +} + +export class ToriiAdapter { + private readonly realmCache: LRUCache; + private readonly armyCache: LRUCache; + private readonly tileCache: LRUCache; + private readonly playerCache: LRUCache; + private readonly marketCache: LRUCache; + private readonly battleCache: LRUCache; + + constructor(private readonly sqlApi: SqlApi, options?: ToriiAdapterOptions) { + const cacheConfig = options?.cache ?? {}; + const maxEntries = cacheConfig.maxEntries ?? 500; + + this.realmCache = new LRUCache({ ttl: cacheConfig.realmTtl ?? DEFAULT_TTL, max: maxEntries }); + this.armyCache = new LRUCache({ ttl: cacheConfig.armyTtl ?? DEFAULT_TTL, max: maxEntries }); + this.tileCache = new LRUCache({ ttl: cacheConfig.tileTtl ?? DEFAULT_TTL, max: maxEntries }); + this.playerCache = new LRUCache({ ttl: cacheConfig.playerTtl ?? DEFAULT_TTL, max: maxEntries }); + this.marketCache = new LRUCache({ ttl: cacheConfig.marketTtl ?? DEFAULT_TTL, max: 1 }); + this.battleCache = new LRUCache({ ttl: cacheConfig.battleTtl ?? DEFAULT_TTL, max: 1 }); + } + + async listRealmSummaries(): Promise { + const structures = await this.sqlApi.fetchAllStructuresMapData(); + const summaries: RealmSummary[] = []; + + structures.forEach((structure) => { + const summary = mapStructureToRealmSummary(structure); + if (!summary) return; + summaries.push(summary); + this.realmCache.set(summary.entityId, summary); + }); + + return summaries; + } + + async getRealmSummary(realmId: number): Promise { + const cached = this.realmCache.get(realmId); + if (cached) { + return cached; + } + + const summaries = await this.listRealmSummaries(); + return summaries.find((realm) => realm.entityId === realmId) ?? null; + } + + async listArmySummaries(): Promise { + const armies = await this.sqlApi.fetchAllArmiesMapData(); + const summaries = armies.map(mapArmyToSummary); + summaries.forEach((summary) => this.armyCache.set(summary.entityId, summary)); + return summaries; + } + + async getArmySummary(entityId: number): Promise { + const cached = this.armyCache.get(entityId); + if (cached) { + return cached; + } + + const summaries = await this.listArmySummaries(); + return summaries.find((army) => army.entityId === entityId) ?? null; + } + + async getTileSummary(x: number, y: number): Promise { + const cacheKey = `${x},${y}`; + const cached = this.tileCache.get(cacheKey); + if (cached) { + return cached; + } + + const tiles = await this.sqlApi.fetchTilesByCoords([{ col: x, row: y }]); + const tile = tiles[0]; + if (!tile) { + return null; + } + + const summary = mapTileToSummary(tile); + this.tileCache.set(cacheKey, summary); + return summary; + } + + async listMarketSwaps(): Promise { + const cacheKey = "market-swaps"; + const cached = this.marketCache.get(cacheKey); + if (cached) { + return cached; + } + + const swaps = await this.sqlApi.fetchSwapEvents([]); + const summaries = swaps.map(mapSwapEvent); + this.marketCache.set(cacheKey, summaries); + return summaries; + } + + async getPlayerProfile(address: string): Promise { + const normalized = normalizeAddress(address); + const cached = this.playerCache.get(normalized); + if (cached) { + return cached; + } + + const [structures, relics, globalDetails]: [ + PlayerStructure[], + PlayerRelicsData, + PlayersData[], + ] = await Promise.all([ + this.sqlApi.fetchPlayerStructures(address), + this.sqlApi.fetchAllPlayerRelics(address), + this.sqlApi.fetchGlobalStructureExplorerAndGuildDetails(), + ]); + + const statsRow = globalDetails.find( + (row) => normalizeAddress(row.owner_address) === normalized, + ); + + const stats = statsRow + ? { + realms: statsRow.realms_count ?? 0, + hyperstructures: statsRow.hyperstructures_count ?? 0, + banks: statsRow.bank_count ?? 0, + mines: statsRow.mine_count ?? 0, + villages: statsRow.village_count ?? 0, + explorerCount: typeof statsRow.explorer_ids === "string" + ? statsRow.explorer_ids.split(",").filter(Boolean).length + : Number(statsRow.explorer_ids ?? 0), + guildId: statsRow.guild_id, + guildName: statsRow.guild_name, + playerName: statsRow.player_name, + } + : undefined; + + const profile: PlayerProfile = { + ownerAddress: normalized, + structures, + relics, + stats, + }; + + this.playerCache.set(normalized, profile); + return profile; + } + + async listBattleLogs(afterTimestamp?: number): Promise { + const cacheKey = afterTimestamp ? `battle-${afterTimestamp}` : "battle-all"; + if (!afterTimestamp) { + const cached = this.battleCache.get(cacheKey); + if (cached) { + return cached; + } + } + + const rawEvents = await this.sqlApi.fetchBattleLogs( + afterTimestamp ? Math.floor(afterTimestamp / 1000).toString() : undefined, + ); + const summaries = rawEvents.map(mapBattleLog); + + if (!afterTimestamp) { + this.battleCache.set(cacheKey, summaries); + } + + return summaries; + } +} diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts new file mode 100644 index 0000000000..a96e3b0f22 --- /dev/null +++ b/packages/mcp-server/src/config.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +export const ConfigSchema = z + .object({ + toriiSqlUrl: z.string().url(), + toriiGrpcUrl: z.string().url().optional(), + starknetRpcUrl: z.string().url().optional(), + dojoManifestPath: z.string().optional(), + dojoManifestJson: z.string().optional(), + accountAddress: z.string().optional(), + accountPrivateKey: z.string().optional(), + vrfProviderAddress: z.string().optional(), + }) + .refine( + (value) => { + if (!value.accountAddress && !value.accountPrivateKey) { + return true; + } + return Boolean(value.accountAddress) && Boolean(value.accountPrivateKey); + }, + { + message: "Both MCP_ACCOUNT_ADDRESS and MCP_ACCOUNT_PRIVATE_KEY must be provided together", + path: ["accountPrivateKey"], + }, + ) + .refine( + (value) => { + if (!value.accountAddress) return true; + return Boolean(value.dojoManifestPath) || Boolean(value.dojoManifestJson); + }, + { + message: "When using on-chain tools you must provide MCP_DOJO_MANIFEST_PATH or MCP_DOJO_MANIFEST_JSON", + path: ["dojoManifestPath"], + }, + ); + +export type Config = z.infer; + +function formatErrors(error: z.ZodError) { + return error.errors.map((item) => `${item.path.join(".") || "root"}: ${item.message}`).join("; "); +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const parsed = ConfigSchema.safeParse({ + toriiSqlUrl: env.MCP_TORII_SQL_URL, + toriiGrpcUrl: env.MCP_TORII_GRPC_URL, + starknetRpcUrl: env.MCP_STARKNET_RPC_URL, + dojoManifestPath: env.MCP_DOJO_MANIFEST_PATH, + dojoManifestJson: env.MCP_DOJO_MANIFEST_JSON, + accountAddress: env.MCP_ACCOUNT_ADDRESS, + accountPrivateKey: env.MCP_ACCOUNT_PRIVATE_KEY, + vrfProviderAddress: env.MCP_VRF_PROVIDER_ADDRESS, + }); + + if (!parsed.success) { + throw new Error(`Invalid MCP server configuration: ${formatErrors(parsed.error)}`); + } + + return parsed.data; +} diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts new file mode 100644 index 0000000000..947338c9dc --- /dev/null +++ b/packages/mcp-server/src/context.ts @@ -0,0 +1,36 @@ +import { SqlApi } from "@bibliothecadao/torii"; + +import type { Config } from "./config.js"; +import { ProviderAdapter } from "./adapters/provider-adapter.js"; +import { ToriiAdapter } from "./adapters/torii-adapter.js"; +import { logger } from "./utils/logger.js"; + +export interface ServerContext { + config: Config; + sqlApi: SqlApi; + torii: ToriiAdapter; + provider?: ProviderAdapter; +} + +export async function createServerContext(config: Config): Promise { + const sqlApi = new SqlApi(config.toriiSqlUrl); + const torii = new ToriiAdapter(sqlApi); + + let provider: ProviderAdapter | undefined; + try { + provider = await ProviderAdapter.createFromConfig(config); + } catch (error) { + logger.error({ error }, "Failed to bootstrap provider adapter"); + } + + if (!provider && config.accountAddress) { + logger.warn("Provider adapter not initialized; transaction tools will be disabled"); + } + + return { + config, + sqlApi, + torii, + provider, + }; +} diff --git a/packages/mcp-server/src/resources/armies.ts b/packages/mcp-server/src/resources/armies.ts new file mode 100644 index 0000000000..23ff576a21 --- /dev/null +++ b/packages/mcp-server/src/resources/armies.ts @@ -0,0 +1,41 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { jsonResource } from "../utils/mcp.js"; + +function parseArmyId(uri: URL, params: Record): number { + const value = params.entityId ?? uri.pathname.split("/").pop() ?? ""; + const armyId = Number(value); + + if (!Number.isFinite(armyId)) { + throw new Error(`Invalid army id: ${value}`); + } + + return armyId; +} + +export function registerArmyResource(server: McpServer, context: ServerContext): void { + const template = new ResourceTemplate("eternum://armies/{entityId}", { list: undefined }); + + server.registerResource( + "armies", + template, + { + title: "Eternum Armies", + description: "Explorer and army snapshots", + }, + async (uri, params) => { + const armyId = parseArmyId(uri, params ?? {}); + const summary = await context.torii.getArmySummary(armyId); + + if (!summary) { + throw new Error(`Army ${armyId} not found`); + } + + return jsonResource(uri, { + army: summary, + }); + }, + ); +} diff --git a/packages/mcp-server/src/resources/battles.ts b/packages/mcp-server/src/resources/battles.ts new file mode 100644 index 0000000000..e2465f2101 --- /dev/null +++ b/packages/mcp-server/src/resources/battles.ts @@ -0,0 +1,32 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { jsonResource } from "../utils/mcp.js"; + +export function registerBattleLogResource(server: McpServer, context: ServerContext): void { + server.registerResource( + "battle-logs", + "eternum://battles/logs", + { + title: "Eternum Battle Logs", + description: "Chronological combat events", + mimeType: "application/json", + }, + async (uri) => { + const afterParam = uri.searchParams.get("after"); + const afterTimestamp = afterParam ? Number(afterParam) : undefined; + + if (afterTimestamp !== undefined && !Number.isFinite(afterTimestamp)) { + throw new Error(`Invalid 'after' query parameter: ${afterParam}`); + } + + const logs = await context.torii.listBattleLogs(afterTimestamp); + + return jsonResource(uri, { + after: afterTimestamp, + logs, + generatedAt: new Date().toISOString(), + }); + }, + ); +} diff --git a/packages/mcp-server/src/resources/index.ts b/packages/mcp-server/src/resources/index.ts new file mode 100644 index 0000000000..0a1d68652a --- /dev/null +++ b/packages/mcp-server/src/resources/index.ts @@ -0,0 +1,19 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; + +import { registerArmyResource } from "./armies.js"; +import { registerBattleLogResource } from "./battles.js"; +import { registerMarketResource } from "./market.js"; +import { registerPlayerResource } from "./players.js"; +import { registerRealmResource } from "./realms.js"; +import { registerTileResource } from "./tiles.js"; + +export function registerResources(server: McpServer, context: ServerContext): void { + registerRealmResource(server, context); + registerArmyResource(server, context); + registerTileResource(server, context); + registerMarketResource(server, context); + registerPlayerResource(server, context); + registerBattleLogResource(server, context); +} diff --git a/packages/mcp-server/src/resources/market.ts b/packages/mcp-server/src/resources/market.ts new file mode 100644 index 0000000000..a46b0c1d78 --- /dev/null +++ b/packages/mcp-server/src/resources/market.ts @@ -0,0 +1,24 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { jsonResource } from "../utils/mcp.js"; + +export function registerMarketResource(server: McpServer, context: ServerContext): void { + server.registerResource( + "market", + "eternum://market/orders", + { + title: "Eternum Market", + description: "Recent market swap events", + mimeType: "application/json", + }, + async (uri) => { + const swaps = await context.torii.listMarketSwaps(); + + return jsonResource(uri, { + swaps, + generatedAt: new Date().toISOString(), + }); + }, + ); +} diff --git a/packages/mcp-server/src/resources/players.ts b/packages/mcp-server/src/resources/players.ts new file mode 100644 index 0000000000..df6a4e9efc --- /dev/null +++ b/packages/mcp-server/src/resources/players.ts @@ -0,0 +1,40 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { jsonResource } from "../utils/mcp.js"; + +function parseAddress(uri: URL, params: Record): string { + const address = params.address ?? uri.pathname.split("/").pop() ?? ""; + + if (!address) { + throw new Error(`Missing player address in URI: ${uri.href}`); + } + + return address; +} + +export function registerPlayerResource(server: McpServer, context: ServerContext): void { + const template = new ResourceTemplate("eternum://players/{address}", { list: undefined }); + + server.registerResource( + "players", + template, + { + title: "Eternum Players", + description: "Player summaries", + }, + async (uri, params) => { + const address = parseAddress(uri, params ?? {}); + const profile = await context.torii.getPlayerProfile(address); + + if (!profile) { + throw new Error(`Player ${address} not found`); + } + + return jsonResource(uri, { + profile, + }); + }, + ); +} diff --git a/packages/mcp-server/src/resources/realms.ts b/packages/mcp-server/src/resources/realms.ts new file mode 100644 index 0000000000..6f0c3a5b0e --- /dev/null +++ b/packages/mcp-server/src/resources/realms.ts @@ -0,0 +1,41 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { jsonResource } from "../utils/mcp.js"; + +function parseRealmId(uri: URL, params: Record): number { + const value = params.realmId ?? uri.pathname.split("/").pop() ?? ""; + const realmId = Number(value); + + if (!Number.isFinite(realmId)) { + throw new Error(`Invalid realm id: ${value}`); + } + + return realmId; +} + +export function registerRealmResource(server: McpServer, context: ServerContext): void { + const template = new ResourceTemplate("eternum://realms/{realmId}", { list: undefined }); + + server.registerResource( + "realms", + template, + { + title: "Eternum Realms", + description: "Realm state summaries", + }, + async (uri, params) => { + const realmId = parseRealmId(uri, params ?? {}); + const summary = await context.torii.getRealmSummary(realmId); + + if (!summary) { + throw new Error(`Realm ${realmId} not found`); + } + + return jsonResource(uri, { + realm: summary, + }); + }, + ); +} diff --git a/packages/mcp-server/src/resources/tiles.ts b/packages/mcp-server/src/resources/tiles.ts new file mode 100644 index 0000000000..e42400e341 --- /dev/null +++ b/packages/mcp-server/src/resources/tiles.ts @@ -0,0 +1,51 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { jsonResource } from "../utils/mcp.js"; + +function parseCoords(uri: URL, params: Record): { x: number; y: number } { + const xRaw = params.x; + const yRaw = params.y ?? (params.x && params.x.includes(",") ? params.x.split(",")[1] : undefined); + + let x = Number(xRaw); + let y = Number(yRaw); + + if (!Number.isFinite(x) || !Number.isFinite(y)) { + const fallback = uri.pathname.split("/").pop() ?? ""; + const [fx, fy] = fallback.split(","); + x = Number(fx); + y = Number(fy); + } + + if (!Number.isFinite(x) || !Number.isFinite(y)) { + throw new Error(`Invalid tile coordinates in URI: ${uri.href}`); + } + + return { x, y }; +} + +export function registerTileResource(server: McpServer, context: ServerContext): void { + const template = new ResourceTemplate("eternum://tiles/{x},{y}", { list: undefined }); + + server.registerResource( + "tiles", + template, + { + title: "Eternum Tiles", + description: "Tile level information", + }, + async (uri, params) => { + const { x, y } = parseCoords(uri, params ?? {}); + const summary = await context.torii.getTileSummary(x, y); + + if (!summary) { + throw new Error(`Tile ${x},${y} not found`); + } + + return jsonResource(uri, { + tile: summary, + }); + }, + ); +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 0000000000..a73959ec81 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,43 @@ +import { pathToFileURL } from "node:url"; + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { loadConfig } from "./config.js"; +import { createServerContext } from "./context.js"; +import { registerResources } from "./resources/index.js"; +import { registerTools } from "./tools/index.js"; +import { logger } from "./utils/logger.js"; + +export async function startServer(): Promise { + const config = loadConfig(); + const context = await createServerContext(config); + + const server = new McpServer({ + name: "eternum-mcp-server", + version: "0.1.0", + }); + + registerResources(server, context); + registerTools(server, context); + + const transport = new StdioServerTransport(); + await server.connect(transport); + + logger.info("MCP server connected to stdio transport"); + + return server; +} + +async function main() { + try { + await startServer(); + } catch (error) { + logger.error({ error }, "Failed to start MCP server"); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main(); +} diff --git a/packages/mcp-server/src/tools/combat.ts b/packages/mcp-server/src/tools/combat.ts new file mode 100644 index 0000000000..a82faf473b --- /dev/null +++ b/packages/mcp-server/src/tools/combat.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { errorResponse, formatTransactionHash } from "../utils/mcp.js"; + +const stealResourceSchema = z.object({ + resourceId: z.number().int().nonnegative(), + amount: z.number().int().positive(), +}); + +const joinBattleSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("explorer_vs_explorer"), + aggressorId: z.number().int().nonnegative(), + defenderId: z.number().int().nonnegative(), + defenderDirection: z.number().int(), + stealResources: z.array(stealResourceSchema).optional().default([]), + }), + z.object({ + mode: z.literal("explorer_vs_guard"), + explorerId: z.number().int().nonnegative(), + structureId: z.number().int().nonnegative(), + structureDirection: z.number().int(), + }), + z.object({ + mode: z.literal("guard_vs_explorer"), + structureId: z.number().int().nonnegative(), + guardSlot: z.number().int().nonnegative(), + explorerId: z.number().int().nonnegative(), + explorerDirection: z.number().int(), + }), +]); + +export function registerCombatTools(server: McpServer, context: ServerContext): void { + server.registerTool( + "join_battle", + { + title: "Join Battle", + description: "Join or initiate combat", + inputSchema: joinBattleSchema, + }, + async (input) => { + if (!context.provider) { + return errorResponse("Combat tools are disabled because no provider account is configured."); + } + + const payload = joinBattleSchema.parse(input); + + try { + let receipt; + + switch (payload.mode) { + case "explorer_vs_explorer": + receipt = await context.provider.attackExplorerVsExplorer({ + aggressorId: payload.aggressorId, + defenderId: payload.defenderId, + defenderDirection: payload.defenderDirection, + stealResources: payload.stealResources ?? [], + }); + break; + case "explorer_vs_guard": + receipt = await context.provider.attackExplorerVsGuard({ + explorerId: payload.explorerId, + structureId: payload.structureId, + structureDirection: payload.structureDirection, + }); + break; + case "guard_vs_explorer": + receipt = await context.provider.attackGuardVsExplorer({ + structureId: payload.structureId, + guardSlot: payload.guardSlot, + explorerId: payload.explorerId, + explorerDirection: payload.explorerDirection, + }); + break; + default: + return errorResponse(`Unsupported combat mode: ${(payload as never).mode}`); + } + + const hash = formatTransactionHash(receipt); + + return { + content: [ + { + type: "text" as const, + text: hash ? `Combat action submitted. Transaction hash: ${hash}` : "Combat action submitted.", + }, + ], + }; + } catch (error) { + return errorResponse(`Failed to execute combat action: ${(error as Error).message}`); + } + }, + ); +} diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts new file mode 100644 index 0000000000..fb2ed2917c --- /dev/null +++ b/packages/mcp-server/src/tools/index.ts @@ -0,0 +1,15 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; + +import { registerCombatTools } from "./combat.js"; +import { registerMovementTools } from "./movement.js"; +import { registerStructureTools } from "./structures.js"; +import { registerTradingTools } from "./trading.js"; + +export function registerTools(server: McpServer, context: ServerContext): void { + registerTradingTools(server, context); + registerMovementTools(server, context); + registerStructureTools(server, context); + registerCombatTools(server, context); +} diff --git a/packages/mcp-server/src/tools/movement.ts b/packages/mcp-server/src/tools/movement.ts new file mode 100644 index 0000000000..f64ef4d4fa --- /dev/null +++ b/packages/mcp-server/src/tools/movement.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { errorResponse, formatTransactionHash } from "../utils/mcp.js"; + +const moveArmySchema = z.object({ + armyId: z.number().int().nonnegative(), + directions: z.array(z.number().int()).min(1), + explore: z.boolean().optional().default(false), +}); + +export function registerMovementTools(server: McpServer, context: ServerContext): void { + server.registerTool( + "move_army", + { + title: "Move Army", + description: "Move an explorer through the world", + inputSchema: moveArmySchema, + }, + async (input) => { + if (!context.provider) { + return errorResponse("Movement tools are disabled because no provider account is configured."); + } + + const payload = moveArmySchema.parse(input); + + const army = await context.torii.getArmySummary(payload.armyId); + if (!army) { + return errorResponse(`Army ${payload.armyId} not found or not cached.`); + } + + try { + const receipt = await context.provider.moveExplorer({ + explorerId: payload.armyId, + directions: payload.directions, + explore: payload.explore, + }); + + const hash = formatTransactionHash(receipt); + + return { + content: [ + { + type: "text" as const, + text: hash + ? `Army ${payload.armyId} movement submitted. Transaction hash: ${hash}` + : `Army ${payload.armyId} movement submitted.`, + }, + ], + }; + } catch (error) { + return errorResponse(`Failed to move army: ${(error as Error).message}`); + } + }, + ); +} diff --git a/packages/mcp-server/src/tools/structures.ts b/packages/mcp-server/src/tools/structures.ts new file mode 100644 index 0000000000..c20b6806c9 --- /dev/null +++ b/packages/mcp-server/src/tools/structures.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { StructureType } from "@bibliothecadao/types"; + +import type { ServerContext } from "../context.js"; +import { errorResponse, formatTransactionHash } from "../utils/mcp.js"; + +const upgradeStructureSchema = z.object({ + structureId: z.number().int().nonnegative(), + structureType: z.nativeEnum(StructureType).optional(), +}); + +export function registerStructureTools(server: McpServer, context: ServerContext): void { + server.registerTool( + "upgrade_structure", + { + title: "Upgrade Structure", + description: "Upgrade a realm or building", + inputSchema: upgradeStructureSchema, + }, + async (input) => { + if (!context.provider) { + return errorResponse("Structure tools are disabled because no provider account is configured."); + } + + const payload = upgradeStructureSchema.parse(input); + + const structure = await context.torii.getRealmSummary(payload.structureId); + if (!structure) { + return errorResponse(`Structure ${payload.structureId} not found or is not a realm.`); + } + + if (payload.structureType && payload.structureType !== structure.structureType) { + return errorResponse( + `Structure ${payload.structureId} type mismatch. Expected ${structure.structureType}, received ${payload.structureType}.`, + ); + } + + try { + const receipt = await context.provider.upgradeRealm({ + realmEntityId: payload.structureId, + }); + + const hash = formatTransactionHash(receipt); + + return { + content: [ + { + type: "text" as const, + text: hash + ? `Upgrade submitted for structure ${payload.structureId}. Transaction hash: ${hash}` + : `Upgrade submitted for structure ${payload.structureId}.`, + }, + ], + }; + } catch (error) { + return errorResponse(`Failed to upgrade structure: ${(error as Error).message}`); + } + }, + ); +} diff --git a/packages/mcp-server/src/tools/trading.ts b/packages/mcp-server/src/tools/trading.ts new file mode 100644 index 0000000000..1e853a0997 --- /dev/null +++ b/packages/mcp-server/src/tools/trading.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ServerContext } from "../context.js"; +import { errorResponse, formatTransactionHash } from "../utils/mcp.js"; + +const createTradeOrderSchema = z.object({ + makerId: z.number().int().nonnegative(), + takerId: z.number().int().nonnegative().default(0), + makerResourceId: z.number().int().nonnegative(), + makerAmount: z.number().int().positive(), + makerMaxCount: z.number().int().positive().optional(), + takerResourceId: z.number().int().nonnegative(), + takerAmount: z.number().int().positive(), + expiresAt: z.number().int().positive().optional(), +}); + +const acceptTradeOrderSchema = z.object({ + tradeId: z.number().int().nonnegative(), + takerId: z.number().int().nonnegative(), + takerBuysCount: z.number().int().positive().default(1), +}); + +export function registerTradingTools(server: McpServer, context: ServerContext): void { + server.registerTool( + "create_trade_order", + { + title: "Create Trade Order", + description: "Submit a marketplace order", + inputSchema: createTradeOrderSchema, + }, + async (input) => { + if (!context.provider) { + return errorResponse("Trade tools are disabled because no provider account is configured."); + } + + const payload = createTradeOrderSchema.parse(input); + + try { + const receipt = await context.provider.createTradeOrder({ + makerId: payload.makerId, + takerId: payload.takerId, + makerResourceId: payload.makerResourceId, + makerAmount: payload.makerAmount, + makerMaxCount: payload.makerMaxCount, + takerResourceId: payload.takerResourceId, + takerAmount: payload.takerAmount, + expiresAt: payload.expiresAt ?? Math.floor(Date.now() / 1000) + 3600, + }); + + const hash = formatTransactionHash(receipt); + + return { + content: [ + { + type: "text" as const, + text: hash + ? `Trade order submitted. Transaction hash: ${hash}` + : "Trade order submitted.", + }, + ], + }; + } catch (error) { + return errorResponse(`Failed to create trade order: ${(error as Error).message}`); + } + }, + ); + + server.registerTool( + "accept_trade_order", + { + title: "Accept Trade Order", + description: "Accept an existing marketplace order", + inputSchema: acceptTradeOrderSchema, + }, + async (input) => { + if (!context.provider) { + return errorResponse("Trade tools are disabled because no provider account is configured."); + } + + const payload = acceptTradeOrderSchema.parse(input); + + try { + const receipt = await context.provider.acceptTradeOrder({ + tradeId: payload.tradeId, + takerId: payload.takerId, + takerBuysCount: payload.takerBuysCount, + }); + + const hash = formatTransactionHash(receipt); + + return { + content: [ + { + type: "text" as const, + text: hash + ? `Trade order ${payload.tradeId} accepted. Transaction hash: ${hash}` + : `Trade order ${payload.tradeId} accepted.`, + }, + ], + }; + } catch (error) { + return errorResponse(`Failed to accept trade order: ${(error as Error).message}`); + } + }, + ); +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts new file mode 100644 index 0000000000..c62041580b --- /dev/null +++ b/packages/mcp-server/src/types.ts @@ -0,0 +1,84 @@ +import type { + ArmyMapDataRaw, + BattleLogEvent, + PlayerRelicsData, + PlayerStructure, + StructureMapDataRaw, + TradeEvent, +} from "@bibliothecadao/torii"; +import type { StructureType } from "@bibliothecadao/types"; + +export interface RealmSummary { + entityId: number; + realmId: number | null; + ownerAddress: string; + ownerName?: string | null; + level: number; + structureType: StructureType; + coord: { x: number; y: number }; + resourcesPacked: string; + raw: StructureMapDataRaw; +} + +export interface ArmySummary { + entityId: number; + coord: { x: number; y: number }; + ownerAddress?: string | null; + ownerName?: string | null; + troopCategory?: string | null; + troopTier?: string | null; + troopCount: bigint; + staminaAmount?: bigint | null; + battleCooldownEnd?: number | null; + raw: ArmyMapDataRaw; +} + +export interface TileSummary { + coord: { x: number; y: number }; + biome: number; + occupierId: number; + occupierType: number; + occupierIsStructure: boolean; +} + +export interface MarketSwapSummary { + takerId: number; + takerAddress: string; + makerId: number; + makerAddress: string; + resourceGivenId: number; + resourceGivenAmount: number; + resourceTakenId: number; + resourceTakenAmount: number; + timestamp: number; + raw: TradeEvent; +} + +export interface PlayerProfile { + ownerAddress: string; + structures: PlayerStructure[]; + relics: PlayerRelicsData; + stats?: { + realms: number; + hyperstructures: number; + banks: number; + mines: number; + villages: number; + explorerCount: number; + guildId?: string | null; + guildName?: string | null; + playerName?: string | null; + }; +} + +export interface BattleLogSummary { + attackerId: number; + defenderId: number; + attackerOwnerId: number; + defenderOwnerId: number | null; + winnerId: number | null; + maxReward: number; + success: number | null; + timestamp: number; + raw: BattleLogEvent; +} diff --git a/packages/mcp-server/src/utils/logger.ts b/packages/mcp-server/src/utils/logger.ts new file mode 100644 index 0000000000..0e4ffb9912 --- /dev/null +++ b/packages/mcp-server/src/utils/logger.ts @@ -0,0 +1,8 @@ +import pino from "pino"; + +const level = process.env.LOG_LEVEL ?? "info"; + +export const logger = pino({ + level, + name: "eternum-mcp-server", +}); diff --git a/packages/mcp-server/src/utils/mcp.ts b/packages/mcp-server/src/utils/mcp.ts new file mode 100644 index 0000000000..adc8258dfc --- /dev/null +++ b/packages/mcp-server/src/utils/mcp.ts @@ -0,0 +1,33 @@ +export function jsonResource(uri: URL, data: unknown) { + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(data, null, 2), + }, + ], + } as const; +} + +export function errorResponse(message: string) { + return { + isError: true as const, + content: [ + { + type: "text" as const, + text: message, + }, + ], + }; +} + +export function formatTransactionHash(receipt: unknown): string | undefined { + if (!receipt || typeof receipt !== "object") { + return undefined; + } + + const maybeReceipt = receipt as Record; + const hash = maybeReceipt["transaction_hash"] ?? maybeReceipt["transactionHash"]; + return typeof hash === "string" ? hash : undefined; +} diff --git a/packages/mcp-server/tests/smoke.test.ts b/packages/mcp-server/tests/smoke.test.ts new file mode 100644 index 0000000000..f554973c71 --- /dev/null +++ b/packages/mcp-server/tests/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it } from "vitest"; + +describe("mcp server scaffold", () => { + it("placeholder", () => { + // TODO: add real tests once implementation lands. + }); +}); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000000..aa5c63b3ef --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +}