import { createHash, randomUUID } from 'node:crypto'; import { z } from 'zod'; import * as frames from 'skylight-mcp/dist/tools/frames.js'; import * as settings from 'skylight-mcp/dist/tools/settings.js'; import * as calendars from 'skylight-mcp/dist/tools/calendars.js'; import * as members from 'skylight-mcp/dist/tools/members.js'; import * as events from 'skylight-mcp/dist/tools/events.js'; import * as lists from 'skylight-mcp/dist/tools/lists.js'; import * as chores from 'skylight-mcp/dist/tools/chores.js'; import * as meals from 'skylight-mcp/dist/tools/meals.js'; import * as tasks from 'skylight-mcp/dist/tools/tasks.js'; import * as rewards from 'skylight-mcp/dist/tools/rewards.js'; import * as messages from 'skylight-mcp/dist/tools/messages.js'; import * as ai from 'skylight-mcp/dist/tools/ai.js'; import * as photos from 'skylight-mcp/dist/tools/photos.js'; import * as health from 'skylight-mcp/dist/tools/health.js'; import { s3Upload } from 'skylight-mcp/dist/s3-upload.js'; import { inventory } from './inventory.js'; const uploadOperations = new Set(['skylight_upload_photo', 'skylight_import_events_from_photo', 'skylight_set_member_avatar']); const mime = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', heic: 'image/heic', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime' }; const maxFileBytes = 20 * 1024 * 1024; const fileSchema = z.object({ name: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_. -]{0,127}$/), base64: z.string().min(4).max(Math.ceil(maxFileBytes / 3) * 4) }).strict(); const hash = value => createHash('sha256').update(value).digest('hex'); function canonical(value) { if (Array.isArray(value)) return value.map(canonical); if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])); return value; } function previewArgs(value, key = '') { if (/password|secret|token/i.test(key)) return '[stored credential]'; if (key === 'file') return { name: value.name, bytes: Buffer.from(value.base64, 'base64').length, sha256: hash(Buffer.from(value.base64, 'base64')) }; if (Array.isArray(value)) return value.map(v => previewArgs(v)); if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, previewArgs(v, k)])); return typeof value === 'string' ? value.replace(/\{\{secret:[^}]+\}\}/g, '[stored credential]') : value; } function decodeFile(file, imagesOnly) { const ext = file.name.split('.').at(-1).toLowerCase(); if (!mime[ext] || (imagesOnly && !mime[ext].startsWith('image/'))) throw new Error('Unsupported file type'); if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(file.base64)) throw new Error('Use canonical base64 file content'); const bytes = Buffer.from(file.base64, 'base64'); if (!bytes.length || bytes.length > maxFileBytes) throw new Error('File must contain 1 byte to 20 MiB'); return { bytes, ext, contentType: mime[ext] }; } function result(value) { return { content: [{ type: 'text', text: JSON.stringify(value) }] }; } // This adapts only file transport. The upstream API contract and multipart S3 signer remain unchanged. async function upload(client, operation, args, uploadToS3) { const { bytes, ext, contentType } = decodeFile(args.file, operation !== 'skylight_upload_photo'); const frame = args.frameId; if (operation === 'skylight_set_member_avatar') { const formData = new FormData(); formData.append('profile_picture', new Blob([bytes], { type: contentType }), args.file.name); return result(await client.request('PUT', `/frames/${frame}/categories/${args.id}`, { formData })); } const doc = await client.request('GET', '/messages/cloud_upload_credentials'); const cc = doc.data?.attributes ?? doc.data ?? doc; if (!cc.credentials || !/^[a-z0-9][a-z0-9.-]+$/.test(cc.bucket ?? '') || !/^[a-z]{2}(?:-[a-z]+)+-\d$/.test(cc.region ?? '') || typeof cc.key_prefix !== 'string') throw new Error('Unexpected upload credential schema'); const key = `${cc.key_prefix}${randomUUID()}.${ext}`; const etag = await uploadToS3({ creds: cc.credentials, region: cc.region, bucket: cc.bucket, key, body: bytes, contentType }); if (operation === 'skylight_upload_photo') { const frameIds = args.frame_ids?.length ? args.frame_ids : [frame]; const registered = await client.request('POST', '/messages/uploads', { body: { file_upload: { bucket: cc.bucket, key, etag }, frame_ids: frameIds, ...(args.caption !== undefined ? { caption: args.caption } : {}), ext } }); return result({ message_ids: registered.data?.message_ids ?? [], frame_ids: frameIds, status: 'processing' }); } return result(await client.request('POST', `/frames/${frame}/auto_creation_intents`, { body: { ext, engine: 'event_importer', ...(args.category_ids ? { category_ids: args.category_ids } : {}), created_via: 'app_photo_picker' } })); } export function createOperations(client, account, uploadToS3 = s3Upload) { const registry = new Map(); const server = { registerTool(name, definition, handler) { registry.set(name, { definition, handler }); } }; for (const module of [frames, settings, calendars, members, events, lists, chores, meals, tasks, rewards, messages, ai, photos, health]) { for (const [name, register] of Object.entries(module)) if (/^register.*Tools$/.test(name)) { if (name === 'registerHealthcheckTools') register(server, async () => client, () => account); else register(server, async () => client); } } for (const name of uploadOperations) { const tool = registry.get(name); tool.definition = { ...tool.definition, inputSchema: tool.definition.inputSchema.omit({ image_path: true }).extend({ file: fileSchema }), description: tool.definition.description.replace(/from a local file/g, 'from supplied file bytes') + ' Kody transport: supply file:{name,base64}, not image_path. Maximum 20 MiB; no access to your local filesystem.' }; tool.handler = args => upload(client, name, args, uploadToS3); } const clearList = registry.get('skylight_clear_list'); clearList.definition = { ...clearList.definition, inputSchema: clearList.definition.inputSchema.extend({ item_ids: z.array(z.string()).describe('Exact current item IDs from skylight_get_list_items, reviewed before clearing.') }), description: clearList.definition.description + ' Requires the exact reviewed item_ids; aborts if list membership has changed.' }; clearList.handler = async args => { const path = `/frames/${args.frameId}/lists/${args.listId}/list_items`; const current = await client.request('GET', path); const ids = (current?.data ?? []).map(item => String(item.id)).sort(); const approved = [...new Set(args.item_ids)].sort(); if (JSON.stringify(ids) !== JSON.stringify(approved)) throw new Error('List contents changed. Read the current items and review a new preview.'); if (ids.length) await client.request('DELETE', `${path}/bulk_destroy`, { body: { ids } }); return result({ cleared: args.listId, removed: ids.length }); }; return async function dispatch(input = {}) { const operation = input.operation ?? 'inventory'; if (operation === 'inventory') return inventory(client, input); if (operation === 'tools') return [...registry].filter(([name, t]) => (!input.name || name === input.name) && (!input.mode || (input.mode === 'read') === (t.definition.annotations?.readOnlyHint === true))).map(([name, t]) => ({ name, description: t.definition.description, readOnly: t.definition.annotations?.readOnlyHint === true, destructive: t.definition.annotations?.destructiveHint === true, inputSchema: z.toJSONSchema(t.definition.inputSchema, { unrepresentable: 'any' }), writeProtocol: t.definition.annotations?.readOnlyHint === true ? undefined : 'Preview first, then confirm:true with matching previewToken. Deletion requires explicit user approval; bulk edits require a reviewed change set.' })); const tool = registry.get(operation); if (!tool) throw new Error('Unknown operation. Call tools for operation names and schemas.'); // Reject unrecognized fields, rather than silently dropping a misspelled edit. const args = tool.definition.inputSchema.strict().parse(input.args ?? {}); if (tool.definition.annotations?.readOnlyHint === true) return tool.handler(args); if ('frameId' in tool.definition.inputSchema.shape && !args.frameId) throw new Error('Writes require an explicit frameId'); if (operation === 'skylight_link_apple_calendar' && !/^\{\{secret:[A-Za-z0-9_-]+(?:\|scope=user)?\}\}$/.test(args.app_specific_password)) throw new Error('Use a Kody secret placeholder for the Apple app-specific password; never pass plaintext'); if (uploadOperations.has(operation)) decodeFile(args.file, operation !== 'skylight_upload_photo'); // The same confirmation controls every write, including upstream tools without their own gate. if ('confirm' in args) delete args.confirm; const previewToken = hash(JSON.stringify(canonical({ version: 1, operation, args }))); if (input.confirm !== true || input.dryRun === true) return { dryRun: true, operation, args: previewArgs(args), previewToken, destructive: tool.definition.annotations?.destructiveHint === true, bulk: ['all', 'future', 'this_and_future'].includes(args.apply_to) || Object.values(args).some(v => Array.isArray(v) && v.length > 1), note: 'No network requests made. Reuse these exact arguments and previewToken with confirm:true after the requested action is authorized.' }; if (input.previewToken !== previewToken) throw new Error('Missing or mismatched previewToken. Preview the exact operation and arguments first.'); // Preserve upstream occurrence and file-upload confirmation gates. return tool.handler({ ...args, ...('confirm' in tool.definition.inputSchema.shape ? { confirm: true } : {}) }); }; }