85 lines
11 KiB
JavaScript
85 lines
11 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,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));
|
||
}
|