51 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {registerCatalogueTools} from './catalogue.mjs';
import {registerCampaignTools} from './campaigns.mjs';
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {z} from 'zod';
import {randomInt,randomUUID} from 'node:crypto';
import {characterSchema,newCharacter,changeHP,applyRest,companionSchema,stableLocationSchema,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP,configureSpellSlotPool,changeSpellSlotPool} from './model.mjs';
const id=z.string().min(1).max(200),revision=z.number().int().min(1),result=data=>({content:[{type:'text',text:JSON.stringify(data)}],structuredContent:data});
export function characterMcp({storage,campaigns,catalogue,sub,scopes}){
const server=new McpServer({name:'arrowstack-character',version:'0.8.0'});
const read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false};
const write={readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false};
const allowed=()=>{if(!scopes.includes('characters:write'))throw new Error('Reconnect with characters:write permission to edit sheets.')};
const get=async id=>{const row=await storage.get(sub,id);if(!row||row.deleted)throw new Error('Character not found');return row};
const tool=(name,description,inputSchema,annotations,fn)=>server.registerTool(name,{description,inputSchema,annotations},async args=>{try{return result(await fn(args))}catch(e){return {isError:true,content:[{type:'text',text:e.status===409?e.message:e.name==='ZodError'?'Invalid character values':e.message}]}}});
const mutate=async({id,expected_revision},fn)=>{allowed();const row=await get(id);if(row.revision!==expected_revision)throw new Error('Revision conflict. Reload the character before applying this change.');const c=characterSchema.parse(row.character);fn(c);return {sheet:await storage.put(sub,id,characterSchema.parse(c),expected_revision)}};
tool('list_characters','List your saved sheets with current revision numbers.',{},read,async()=>({sheets:(await storage.list(sub)).filter(r=>!r.deleted).map(({id,character,revision})=>({id,name:character.name,className:character.className,level:character.level,revision}))}));
tool('get_character','Read a complete saved character and its revision.',{id},read,async({id})=>({sheet:await get(id)}));
tool('create_character','Create a new account-backed character. Returns its generated id and revision.',{name:z.string().min(1).max(120)},write,async({name})=>{allowed();const c=newCharacter();c.id=randomUUID();c.name=name;return {sheet:await storage.put(sub,c.id,c,0)}});
tool('update_character','Replace a complete character using the revision returned by get_character. Preserves unrelated changes by rejecting stale revisions.',{character:characterSchema,expected_revision:revision},write,async({character,expected_revision})=>{allowed();await get(character.id);return {sheet:await storage.put(sub,character.id,character,expected_revision)}});
tool('change_hit_points','Apply signed HP change: negative damages temporary HP first; positive heals up to maximum.',{id,expected_revision:revision,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>changeHP(c,a.amount)));
tool('apply_rest','Apply declared resource resets. Long rest restores configured regular spell slots; either rest restores Pact Magic slots. Hit dice and edition/class-specific exceptions remain manual.',{id,expected_revision:revision,type:z.enum(['short-rest','long-rest'])},write,async a=>mutate(a,c=>applyRest(c,a.type)));
tool('set_resource','Set remaining uses for one resource within its maximum.',{id,expected_revision:revision,resource_id:id,remaining:z.number().int().min(0).max(9999)},write,async a=>mutate(a,c=>{const r=c.resources.find(r=>r.id===a.resource_id);if(!r||a.remaining>r.max)throw new Error('Resource missing or remaining uses exceed maximum');r.current=a.remaining}));
tool('list_companions','Read horses, mounts and pets, including stats, current whereabouts and last stable.',{id},read,async({id})=>{const row=await get(id);return {revision:row.revision,companions:row.character.companions||[]}});
tool('add_companion','Add a mount or pet using complete validated companion details.',{id,expected_revision:revision,companion:companionSchema},write,async a=>mutate(a,c=>addCompanion(c,a.companion)));
tool('stable_companion','Record where and when a mount or pet was stabled, preserving the last stable after collection.',{id,expected_revision:revision,companion_id:id,stable:stableLocationSchema},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');stableCompanion(animal,a.stable)}));
tool('retrieve_companion','Mark a mount or pet as travelling with the party without forgetting its last stable.',{id,expected_revision:revision,companion_id:id},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');retrieveCompanion(animal)}));
tool('change_companion_hp','Apply damage or healing to a named companion up to its HP maximum.',{id,expected_revision:revision,companion_id:id,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');changeCompanionHP(animal,a.amount)}));
tool('list_spell_slots','Read configured regular and Pact Magic slot pools and their remaining uses.',{id},read,async({id})=>{const row=await get(id);return {revision:row.revision,spellSlots:row.character.spellSlots??{levels:[],pact:null}}});
tool('configure_spell_slots','Configure the maximum regular slots at a spell level (1–9), or Pact Magic slots (1–5). A maximum of zero removes the pool; changing a total preserves remaining slots when possible.',{id,expected_revision:revision,kind:z.enum(['standard','pact']),level:z.number().int().min(1).max(9),max:z.number().int().min(0).max(20)},write,async a=>mutate(a,c=>configureSpellSlotPool(c,a.kind,a.level,a.max)));
tool('change_spell_slots','Spend slots with a negative change or restore with a positive change. Refuses to overspend or exceed maximum.',{id,expected_revision:revision,kind:z.enum(['standard','pact']),level:z.number().int().min(1).max(9),amount:z.number().int().min(-20).max(20)},write,async a=>mutate(a,c=>changeSpellSlotPool(c,a.kind,a.level,a.amount)));
tool('delete_character','Delete a saved character across synced devices. Export a backup before deletion.',{id,expected_revision:revision},{...write,destructiveHint:true},async({id,expected_revision})=>{allowed();await get(id);return {sheet:await storage.put(sub,id,null,expected_revision,true)}});
tool('roll_dice','Roll cryptographically random dice. Does not change the character.',{count:z.number().int().min(1).max(100).default(1),sides:z.number().int().min(2).max(1000).default(20),bonus:z.number().int().min(-10000).max(10000).default(0)},{...read,idempotentHint:false},async({count,sides,bonus})=>{const rolls=Array.from({length:count},()=>randomInt(1,sides+1));return {rolls,bonus,total:rolls.reduce((a,b)=>a+b,bonus)}});
server.registerResource('characters','character://collection',{description:'Your saved character collection',mimeType:'application/json'},async uri=>({contents:[{uri:uri.href,mimeType:'application/json',text:JSON.stringify((await storage.list(sub)).filter(r=>!r.deleted))}]}));
if(catalogue)registerCatalogueTools({tool,catalogue,read});
if(campaigns)registerCampaignTools({tool,campaigns,sub,allowed,read,write});
return server;
}
export function installMcp(app,{storage,campaigns,catalogue,auth,origin}){
app.use('/mcp',(req,res,next)=>{if(req.get('origin')&&req.get('origin')!==origin)return res.sendStatus(403);next()},auth);
app.post('/mcp',async(req,res,next)=>{
const server=characterMcp({storage,campaigns,catalogue,sub:req.auth.extra.sub,scopes:req.auth.scopes});
const transport=new StreamableHTTPServerTransport({sessionIdGenerator:undefined,enableJsonResponse:true});
res.on('close',()=>{transport.close();server.close()});
try{await server.connect(transport);await transport.handleRequest(req,res,req.body)}catch(e){next(e)}
});
app.all('/mcp',(_,res)=>res.set('Allow','POST').sendStatus(405));
}