← back to Filemaker Mcp

src/fm-client.js

171 lines

// FileMaker Data API client for FileMaker Cloud.
// Reads/writes go THROUGH a layout. Session tokens are per-file and last 15 min
// of inactivity, so we cache one per database and refresh a minute early.

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { getIdToken } from './auth.js';

const __dir = dirname(fileURLToPath(import.meta.url));
const CONFIG = JSON.parse(readFileSync(join(__dir, '..', 'config.json'), 'utf8'));

const HOST = () => {
  const h = process.env.FM_CLOUD_HOST;
  if (!h) throw new Error('FM_CLOUD_HOST not set.');
  return h;
};
const base = (db) => `https://${HOST()}/fmi/data/vLatest/databases/${encodeURIComponent(db)}`;
const isReadOnly = () => process.env.FM_READONLY === '1';

// --- whitelist guard: a file not in config.json is rejected ---
export function assertAllowed(db) {
  if (!CONFIG.databases[db]) {
    const allowed = Object.keys(CONFIG.databases).join(', ');
    throw new Error(`Database "${db}" is not in the connector whitelist. Allowed: ${allowed}`);
  }
  return CONFIG.databases[db];
}
export function resolveLayout(db, layout) {
  const cfg = assertAllowed(db);
  const lay = layout || cfg.defaultLayout;
  if (!lay) throw new Error(`No layout given and no defaultLayout configured for "${db}". Run \`fm layouts ${db}\` and set it in config.json.`);
  return lay;
}
export function listDatabases() {
  return Object.entries(CONFIG.databases).map(([name, c]) => ({ name, ...c }));
}

// --- session cache ---
const sessions = new Map(); // db -> { token, exp }

async function getSession(db) {
  const now = Date.now();
  const s = sessions.get(db);
  if (s && now < s.exp) return s.token;

  const idToken = await getIdToken();
  const res = await fetch(`${base(db)}/sessions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `FMID ${idToken}` },
    body: '{}',
  });
  const json = await res.json().catch(() => ({}));
  const code = json?.messages?.[0]?.code;
  if (!res.ok || (code && code !== '0')) {
    throw new Error(`FileMaker session failed for "${db}": HTTP ${res.status} ${JSON.stringify(json.messages || json)}`);
  }
  const token = json.response.token;
  sessions.set(db, { token, exp: now + 14 * 60 * 1000 });
  return token;
}

async function fm(db, path, { method = 'GET', body } = {}) {
  const token = await getSession(db);
  const res = await fetch(`${base(db)}${path}`, {
    method,
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  const msg = json?.messages?.[0];
  if (msg && msg.code !== '0') {
    const err = new Error(`FileMaker [${msg.code}] ${msg.message} (${db}${path})`);
    err.fmCode = msg.code;
    throw err;
  }
  if (!res.ok) throw new Error(`FileMaker HTTP ${res.status} (${db}${path})`);
  return json.response;
}

// ---------------- read ops ----------------

export async function ping() {
  // Proves Cognito auth + a Data API session against the first whitelisted db.
  const db = Object.keys(CONFIG.databases)[0];
  await getSession(db);
  return { ok: true, host: HOST(), database: db };
}

export function layouts(db) {
  assertAllowed(db);
  return fm(db, `/layouts`).then((r) => r.layouts);
}

export function fieldMetadata(db, layout) {
  const lay = resolveLayout(db, layout);
  return fm(db, `/layouts/${encodeURIComponent(lay)}`).then((r) => r);
}

export async function getRecord(db, layout, recordId) {
  const lay = resolveLayout(db, layout);
  const r = await fm(db, `/layouts/${encodeURIComponent(lay)}/records/${encodeURIComponent(recordId)}`);
  return r.data?.[0] || null;
}

export async function listRecords(db, layout, { limit = 20, offset = 1 } = {}) {
  const lay = resolveLayout(db, layout);
  const r = await fm(db, `/layouts/${encodeURIComponent(lay)}/records?_limit=${limit}&_offset=${offset}`);
  return { records: r.data || [], dataInfo: r.dataInfo };
}

export async function findRecords(db, layout, query, { limit = 20, offset = 1, sort } = {}) {
  const lay = resolveLayout(db, layout);
  const body = { query: Array.isArray(query) ? query : [query], limit: String(limit), offset: String(offset) };
  if (sort) body.sort = sort;
  const r = await fm(db, `/layouts/${encodeURIComponent(lay)}/_find`, { method: 'POST', body });
  return { records: r.data || [], dataInfo: r.dataInfo };
}

// ---------------- write ops (gated) ----------------

function assertWritable(db) {
  if (isReadOnly()) throw new Error('Writes are disabled (FM_READONLY=1).');
  const cfg = assertAllowed(db);
  if (cfg.writable === false) throw new Error(`Database "${db}" is marked read-only in config.json.`);
}

// Update with a built-in dry-run gate: dryRun:true (default) returns the
// before→after diff WITHOUT committing. A real write requires dryRun:false.
export async function updateRecord(db, layout, recordId, fieldData, { dryRun = true } = {}) {
  assertWritable(db);
  const lay = resolveLayout(db, layout);
  const current = await getRecord(db, layout, recordId);
  if (!current) throw new Error(`Record ${recordId} not found in ${db}/${lay}.`);

  const before = current.fieldData || {};
  const diff = {};
  for (const [k, v] of Object.entries(fieldData)) {
    if (String(before[k] ?? '') !== String(v ?? '')) diff[k] = { from: before[k], to: v };
  }

  if (dryRun) {
    return { committed: false, dryRun: true, database: db, layout: lay, recordId, changes: diff,
      note: Object.keys(diff).length ? 'Call again with dryRun:false to commit.' : 'No changes — values already match.' };
  }
  if (!Object.keys(diff).length) {
    return { committed: false, dryRun: false, recordId, changes: {}, note: 'No changes to commit.' };
  }
  await fm(db, `/layouts/${encodeURIComponent(lay)}/records/${encodeURIComponent(recordId)}`,
    { method: 'PATCH', body: { fieldData } });
  return { committed: true, dryRun: false, database: db, layout: lay, recordId, changes: diff };
}

export async function deleteRecord(db, layout, recordId) {
  assertWritable(db);
  const lay = resolveLayout(db, layout);
  await fm(db, `/layouts/${encodeURIComponent(lay)}/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
  return { deleted: true, database: db, recordId };
}

export async function createRecord(db, layout, fieldData, { dryRun = true } = {}) {
  assertWritable(db);
  const lay = resolveLayout(db, layout);
  if (dryRun) {
    return { committed: false, dryRun: true, database: db, layout: lay, fieldData,
      note: 'Call again with dryRun:false to create this record.' };
  }
  const r = await fm(db, `/layouts/${encodeURIComponent(lay)}/records`, { method: 'POST', body: { fieldData } });
  return { committed: true, dryRun: false, database: db, layout: lay, recordId: r.recordId, modId: r.modId };
}