41 lines
5.5 KiB
JavaScript

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} 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.5.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 short/long-rest resource resets. Long rest also restores HP and clears temporary HP. Hit dice and edition/class-specific rules 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('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));
}