← 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 };
}