85 lines
11 KiB
JavaScript
Raw Permalink 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,portraitSchema} 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});
/** Only compact, embedded JPEG or WebP portrait bytes are accepted; no remote URL fetches. */
function checkedPortrait(dataUrl){
const valid=portraitSchema.parse(dataUrl);
const match=/^data:image\/(webp|jpeg);base64,([A-Za-z0-9+/]+={0,2})$/.exec(valid);
if(!match)throw new Error('Upload a compressed JPEG or WebP portrait data URL.');
const binary=Buffer.from(match[2],'base64');
if(binary.length<16||binary.length>240000||binary.toString('base64')!==match[2])throw new Error('Portrait encoding is invalid or exceeds 240 KB.');
const mime=match[1],jpeg=mime==='jpeg';
if(jpeg){
if(binary.length<4||binary[0]!==255||binary[1]!==216||binary[2]!==255||binary.at(-2)!==255||binary.at(-1)!==217)throw new Error('JPEG image signature is invalid.');
}else{
if(binary.toString('ascii',0,4)!=='RIFF'||binary.toString('ascii',8,12)!=='WEBP'||binary.readUInt32LE(4)!==binary.length-8||!['VP8 ','VP8L','VP8X'].includes(binary.toString('ascii',12,16)))throw new Error('WebP image signature is invalid.');
}
return {dataUrl:valid,mimeType:'image/'+mime,bytes:binary.length};
}
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('upload_character_portrait','Upload or replace a saved character portrait. Pass an already cropped, compressed JPEG or WebP as a base64 data URL (data:image/webp;base64,... or data:image/jpeg;base64,...), maximum 240 KB. This tool cannot access chat attachments automatically; the calling client must provide encoded image bytes. Call get_character first for the current revision. Only your own character can be changed.',{id,expected_revision:revision,image_data_url:portraitSchema},write,async({id,expected_revision,image_data_url})=>{
allowed();
const portrait=checkedPortrait(image_data_url);
const row=await get(id);
if(row.revision!==expected_revision)throw new Error('Revision conflict. Reload the character before changing its portrait.');
const character=characterSchema.parse({...row.character,portrait:portrait.dataUrl});
const saved=await storage.put(sub,id,character,expected_revision);
// Never echo large image strings as a tool result.
return {id:saved.id,revision:saved.revision,name:saved.character.name,portrait:{present:true,mime_type:portrait.mimeType,bytes:portrait.bytes}};
});
tool('remove_character_portrait','Remove the portrait from your own saved character. Requires the current sheet revision; does not change other fields.',{id,expected_revision:revision},write,async({id,expected_revision})=>{
allowed();
const row=await get(id);
if(row.revision!==expected_revision)throw new Error('Revision conflict. Reload the character before removing its portrait.');
const character=characterSchema.parse({...row.character,portrait:null});
const saved=await storage.put(sub,id,character,expected_revision);
return {id:saved.id,revision:saved.revision,name:saved.character.name,portrait:{present:false}};
});
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));
}