Skip to content

operations.js

114 lines · 9.7 KB · JavaScript
Raw
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 } : {}) });
  };
}