Skip to main content
Glama
character.ts16.2 kB
import { z } from 'zod'; import { FoundryClient } from '../foundry-client.js'; import { Logger } from '../logger.js'; import { SystemRegistry } from '../systems/system-registry.js'; import { detectGameSystem, type GameSystem } from '../utils/system-detection.js'; export interface CharacterToolsOptions { foundryClient: FoundryClient; logger: Logger; systemRegistry?: SystemRegistry; } export class CharacterTools { private foundryClient: FoundryClient; private logger: Logger; private systemRegistry: SystemRegistry | null; private cachedGameSystem: GameSystem | null = null; constructor({ foundryClient, logger, systemRegistry }: CharacterToolsOptions) { this.foundryClient = foundryClient; this.logger = logger.child({ component: 'CharacterTools' }); this.systemRegistry = systemRegistry || null; } /** * Get or detect the game system (cached) */ private async getGameSystem(): Promise<GameSystem> { if (!this.cachedGameSystem) { this.cachedGameSystem = await detectGameSystem(this.foundryClient, this.logger); } return this.cachedGameSystem; } /** * Tool: get-character * Retrieve detailed information about a specific character */ getToolDefinitions() { return [ { name: 'get-character', description: 'Retrieve character information optimized for minimal token usage. Returns: full stats (abilities, skills, saves, AC, HP), action names, active effects/conditions (name only), and ALL items with minimal metadata (name, type, equipped status) without descriptions. PF2e-specific: includes traits arrays for items/actions, action costs, rarity, and level. D&D 5e-specific: includes attunement status. Perfect for filtering (e.g., "deviant" trait feats, "fire" trait spells in PF2e), checking equipment, or identifying what to investigate further. Use get-character-entity to fetch full details for specific items, actions, spells, or effects.', inputSchema: { type: 'object', properties: { identifier: { type: 'string', description: 'Character name or ID to look up', }, }, required: ['identifier'], }, }, { name: 'get-character-entity', description: 'Retrieve full details for a specific entity from a character. Works for items (feats, equipment, spells), actions (strikes, special abilities), or effects/conditions. Returns complete description and all system data. Use this after get-character when you need detailed information about a specific entity.', inputSchema: { type: 'object', properties: { characterIdentifier: { type: 'string', description: 'Character name or ID', }, entityIdentifier: { type: 'string', description: 'Entity name or ID (can be item ID, action name, spell name, or effect name)', }, }, required: ['characterIdentifier', 'entityIdentifier'], }, }, { name: 'list-characters', description: 'List all available characters with basic information', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'Optional filter by character type (e.g., "character", "npc")', }, }, }, }, ]; } async handleGetCharacter(args: any): Promise<any> { const schema = z.object({ identifier: z.string().min(1, 'Character identifier cannot be empty'), }); const { identifier } = schema.parse(args); this.logger.info('Getting character information', { identifier }); try { const characterData = await this.foundryClient.query('foundry-mcp-bridge.getCharacterInfo', { characterName: identifier, }); this.logger.debug('Successfully retrieved character data', { characterId: characterData.id, characterName: characterData.name }); // Format the response for Claude return await this.formatCharacterResponse(characterData); } catch (error) { this.logger.error('Failed to get character information', error); throw new Error(`Failed to retrieve character "${identifier}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } async handleGetCharacterEntity(args: any): Promise<any> { const schema = z.object({ characterIdentifier: z.string().min(1, 'Character identifier cannot be empty'), entityIdentifier: z.string().min(1, 'Entity identifier cannot be empty'), }); const { characterIdentifier, entityIdentifier } = schema.parse(args); this.logger.info('Getting character entity', { characterIdentifier, entityIdentifier }); try { // First get the character const characterData = await this.foundryClient.query('foundry-mcp-bridge.getCharacterInfo', { characterName: characterIdentifier, }); // Try to find the entity in different collections let entity = null; let entityType = null; // 1. Try to find as an item (by ID or name) entity = characterData.items?.find((i: any) => i.id === entityIdentifier || i.name.toLowerCase() === entityIdentifier.toLowerCase() ); if (entity) { entityType = 'item'; } // 2. Try to find as an action (by name) if (!entity && characterData.actions) { entity = characterData.actions.find((a: any) => a.name.toLowerCase() === entityIdentifier.toLowerCase() ); if (entity) { entityType = 'action'; } } // 3. Try to find as an effect (by name) if (!entity && characterData.effects) { entity = characterData.effects.find((e: any) => e.name.toLowerCase() === entityIdentifier.toLowerCase() ); if (entity) { entityType = 'effect'; } } if (!entity) { throw new Error(`Entity "${entityIdentifier}" not found on character "${characterIdentifier}". Tried items, actions, and effects.`); } this.logger.debug('Successfully retrieved entity', { entityType, entityName: entity.name }); // Return full entity details based on type if (entityType === 'item') { return { entityType: 'item', id: entity.id, name: entity.name, type: entity.type, description: entity.system?.description?.value || entity.system?.description || '', traits: entity.system?.traits?.value || [], rarity: entity.system?.traits?.rarity || 'common', level: entity.system?.level?.value ?? entity.system?.level, actionType: entity.system?.actionType?.value, actions: entity.system?.actions?.value, quantity: entity.system?.quantity || 1, equipped: entity.system?.equipped, attunement: entity.system?.attunement, hasImage: !!entity.img, // Include full system data for advanced use cases system: entity.system, }; } else if (entityType === 'action') { return { entityType: 'action', name: entity.name, type: entity.type, itemId: entity.itemId, traits: entity.traits || [], variants: entity.variants || [], ready: entity.ready, description: entity.description || 'Action from character strikes/abilities', }; } else if (entityType === 'effect') { return { entityType: 'effect', id: entity.id, name: entity.name, description: entity.description || entity.name, traits: entity.traits || [], duration: entity.duration, // Include full effect data ...entity, }; } return entity; } catch (error) { this.logger.error('Failed to get character entity', error); throw new Error(`Failed to retrieve entity "${entityIdentifier}" from character "${characterIdentifier}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } async handleListCharacters(args: any): Promise<any> { const schema = z.object({ type: z.string().optional(), }); const { type } = schema.parse(args); this.logger.info('Listing characters', { type }); try { const actors = await this.foundryClient.query('foundry-mcp-bridge.listActors', { type }); this.logger.debug('Successfully retrieved character list', { count: actors.length }); // Format the response for Claude return { characters: actors.map((actor: any) => ({ id: actor.id, name: actor.name, type: actor.type, hasImage: !!actor.img, })), total: actors.length, filtered: type ? `Filtered by type: ${type}` : 'All characters', }; } catch (error) { this.logger.error('Failed to list characters', error); throw new Error(`Failed to list characters: ${error instanceof Error ? error.message : 'Unknown error'}`); } } private async formatCharacterResponse(characterData: any): Promise<any> { const response: any = { id: characterData.id, name: characterData.name, type: characterData.type, basicInfo: this.extractBasicInfo(characterData), stats: await this.extractStats(characterData), items: this.formatItems(characterData.items || []), effects: this.formatEffects(characterData.effects || []), hasImage: !!characterData.img, }; // Add actions with minimal data (name, traits, action cost only - no variants) if (characterData.actions && characterData.actions.length > 0) { response.actions = this.formatActions(characterData.actions); } // Exclude itemVariants and itemToggles - these are verbose and can be fetched via get-character-entity if needed return response; } private formatActions(actions: any[]): any[] { // Return minimal action data - just enough to identify and filter return actions.map(action => { const formatted: any = { name: action.name, type: action.type, }; // Include traits if present (for filtering, e.g., "fire" attacks, "concentrate" actions) if (action.traits && action.traits.length > 0) { formatted.traits = action.traits; } // Include action cost (e.g., 1, 2, 3 actions, reaction, free) if (action.actions !== undefined) { formatted.actionCost = action.actions; } // Include itemId for cross-referencing with items if (action.itemId) { formatted.itemId = action.itemId; } return formatted; }); } private extractBasicInfo(characterData: any): any { const system = characterData.system || {}; // Extract common fields that exist across different game systems const basicInfo: any = {}; // D&D 5e / PF2e common fields if (system.attributes) { if (system.attributes.hp) { basicInfo.hitPoints = { current: system.attributes.hp.value, max: system.attributes.hp.max, temp: system.attributes.hp.temp || 0, }; } if (system.attributes.ac) { basicInfo.armorClass = system.attributes.ac.value; } } // Level information if (system.details?.level?.value) { basicInfo.level = system.details.level.value; } else if (system.level) { basicInfo.level = system.level; } // Class information if (system.details?.class) { basicInfo.class = system.details.class; } // Race/ancestry information if (system.details?.race) { basicInfo.race = system.details.race; } else if (system.details?.ancestry) { basicInfo.ancestry = system.details.ancestry; } return basicInfo; } private async extractStats(characterData: any): Promise<any> { // Try using system adapter if available if (this.systemRegistry) { try { const gameSystem = await this.getGameSystem(); const adapter = this.systemRegistry.getAdapter(gameSystem); if (adapter) { this.logger.debug('Using system adapter for character stats extraction', { system: gameSystem }); return adapter.extractCharacterStats(characterData); } } catch (error) { this.logger.warn('Failed to use system adapter, falling back to legacy extraction', { error }); } } // Legacy extraction (backwards compatibility) const system = characterData.system || {}; const stats: any = {}; // Ability scores (D&D 5e style) if (system.abilities) { stats.abilities = {}; for (const [key, ability] of Object.entries(system.abilities)) { if (typeof ability === 'object' && ability !== null) { stats.abilities[key] = { score: (ability as any).value || 10, modifier: (ability as any).mod || 0, }; } } } // Skills if (system.skills) { stats.skills = {}; for (const [key, skill] of Object.entries(system.skills)) { if (typeof skill === 'object' && skill !== null) { stats.skills[key] = { value: (skill as any).value || 0, proficient: (skill as any).proficient || false, ability: (skill as any).ability || '', }; } } } // Saves if (system.saves) { stats.saves = {}; for (const [key, save] of Object.entries(system.saves)) { if (typeof save === 'object' && save !== null) { stats.saves[key] = { value: (save as any).value || 0, proficient: (save as any).proficient || false, }; } } } return stats; } private formatItems(items: any[]): any[] { // Return ALL items with minimal data return items.map(item => { // Return minimal data - just enough to identify and filter items const formattedItem: any = { id: item.id, name: item.name, type: item.type, }; // Include quantity if present if (item.system?.quantity !== undefined && item.system.quantity !== 1) { formattedItem.quantity = item.system.quantity; } // Include traits for PF2e items (feats, equipment, spells, etc.) if (item.system?.traits?.value) { formattedItem.traits = Array.isArray(item.system.traits.value) ? item.system.traits.value : []; } // Include rarity for PF2e items if (item.system?.traits?.rarity) { formattedItem.rarity = item.system.traits.rarity; } // Include level for PF2e items (feats, spells, etc.) if (item.system?.level?.value !== undefined) { formattedItem.level = item.system.level.value; } else if (item.system?.level !== undefined) { formattedItem.level = item.system.level; } // Include action cost for PF2e feats/actions if (item.system?.actionType?.value) { formattedItem.actionType = item.system.actionType.value; } // Include equipped status for equippable items if (item.system?.equipped !== undefined) { formattedItem.equipped = item.system.equipped; } // Include attuned status for D&D 5e magic items if (item.system?.attunement !== undefined) { formattedItem.attunement = item.system.attunement; } return formattedItem; }); } private formatEffects(effects: any[]): any[] { return effects.map(effect => ({ id: effect.id, name: effect.name, disabled: effect.disabled, duration: effect.duration ? { type: effect.duration.type, remaining: effect.duration.remaining, } : null, hasIcon: !!effect.icon, })); } private truncateText(text: string, maxLength: number): string { if (!text || text.length <= maxLength) { return text; } return text.substring(0, maxLength - 3) + '...'; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adambdooley/foundry-vtt-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server