← Public packages
@tannerlinsley/skylight
Read and manage Skylight calendars, profiles, chores, routines, lists, meals, rewards, photos and settings.
operations.js
114 lines · 9.7 KB · JavaScriptimport { 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 } : {}) });
};
}