UNPKG

@proofkit/fmdapi

Version:

FileMaker Data API client

369 lines (335 loc) • 12 kB
import type { CreateResponse, DeleteResponse, GetResponse, LayoutMetadataResponse, RawFMResponse, ScriptResponse, UpdateResponse, } from "../client-types.js"; import { FileMakerError } from "../client-types.js"; import type { Adapter, CreateOptions, DeleteOptions, ExecuteScriptOptions, FindOptions, GetOptions, LayoutMetadataOptions, ListOptions, UpdateOptions, } from "./core.js"; const TRAILING_SLASHES_REGEX = /\/+$/; const DEFAULT_AUTHORIZE_TIMEOUT_MS = 125_000; const DEFAULT_IDLE_TIMEOUT_SECONDS = 3600; const SESSION_HEADER_NAME = "X-ProofKit-Session"; const CLIENT_HEADER_NAME = "X-ProofKit-Client"; const envValue = (name: string): string | undefined => { if (typeof process === "undefined") { return undefined; } return process.env[name]; }; const randomSessionId = (): string => { if (globalThis.crypto?.randomUUID) { return globalThis.crypto.randomUUID(); } return Math.random().toString(36).slice(2); }; const statusReason = (status: unknown): string => { if (status === "rejected") { return "authorization rejected"; } if (status === "timeout") { return "authorization timed out"; } if (status === "file_not_connected") { return "file not connected"; } return typeof status === "string" ? status : "authorization failed"; }; export interface FmMcpAdapterOptions { /** Base URL of the local FM MCP server (e.g. "http://localhost:3000") */ baseUrl: string; /** Name of the connected FileMaker file */ connectedFileName: string; /** Name of the FM script that executes Data API calls. Defaults to "PK_execute_data_api" */ scriptName?: string; /** Session ID sent to the bridge. Defaults to FM_MCP_SESSION_ID or a random ID. */ sessionId?: string; /** Client name shown in FileMaker authorization prompts. Defaults to FM_MCP_CLIENT_NAME or "ProofKit Typegen". */ clientName?: string; /** Client description shown in FileMaker authorization prompts. */ clientDescription?: string; /** Idle timeout requested for authorized sessions. Defaults to 3600 seconds. */ idleTimeoutSeconds?: number; /** Timeout for /authorizeSession. Defaults to 125 seconds. */ authorizationTimeoutMs?: number; /** If true, do not open FileMaker interactive authorization after a 401. */ disableInteractiveAuthorization?: boolean; } export class FmMcpAdapter implements Adapter { protected baseUrl: string; protected connectedFileName: string; protected scriptName: string; protected sessionId: string; protected clientName: string; protected clientDescription: string; protected idleTimeoutSeconds: number; protected authorizationTimeoutMs: number; protected disableInteractiveAuthorization: boolean; protected pendingAuthorization?: Promise<void>; constructor(options: FmMcpAdapterOptions) { this.baseUrl = options.baseUrl.replace(TRAILING_SLASHES_REGEX, ""); this.connectedFileName = options.connectedFileName; this.scriptName = options.scriptName ?? "PK_execute_data_api"; this.sessionId = options.sessionId ?? envValue("FM_MCP_SESSION_ID") ?? randomSessionId(); this.clientName = options.clientName ?? envValue("FM_MCP_CLIENT_NAME") ?? "ProofKit Typegen"; this.clientDescription = options.clientDescription ?? envValue("FM_MCP_CLIENT_DESCRIPTION") ?? "ProofKit Typegen is requesting FileMaker bridge access."; this.idleTimeoutSeconds = Math.min(options.idleTimeoutSeconds ?? DEFAULT_IDLE_TIMEOUT_SECONDS, 3600); this.authorizationTimeoutMs = options.authorizationTimeoutMs ?? DEFAULT_AUTHORIZE_TIMEOUT_MS; this.disableInteractiveAuthorization = options.disableInteractiveAuthorization ?? envValue("FM_MCP_DISABLE_INTERACTIVE_AUTHORIZATION") === "true"; } protected sessionHeaders = (): Headers => { const headers = new Headers(); headers.set(SESSION_HEADER_NAME, this.sessionId); headers.set(CLIENT_HEADER_NAME, this.clientName); return headers; }; protected ensureAuthorized = (): Promise<void> => { if (this.pendingAuthorization) { return this.pendingAuthorization; } this.pendingAuthorization = this.requestAuthorization().finally(() => { this.pendingAuthorization = undefined; }); return this.pendingAuthorization; }; protected requestAuthorization = async (): Promise<void> => { if (this.disableInteractiveAuthorization) { throw new Error("interactive authorization disabled"); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.authorizationTimeoutMs); try { const res = await fetch(`${this.baseUrl}/authorizeSession`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: this.sessionId, fileName: this.connectedFileName, clientName: this.clientName, clientDescription: this.clientDescription, idleTimeoutSeconds: this.idleTimeoutSeconds, }), signal: controller.signal, }); const payload = (await res.json().catch(() => null)) as { status?: unknown; error?: unknown } | null; if (res.ok && payload?.status === "approved") { return; } const reason = typeof payload?.error === "string" ? payload.error : statusReason(payload?.status); throw new Error(reason); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new Error("authorization timed out"); } throw err; } finally { clearTimeout(timeout); } }; protected isUnauthorizedSession = async (res: Response): Promise<boolean> => { if (res.status !== 401) { return false; } const payload = (await res .clone() .json() .catch(() => null)) as { code?: unknown } | null; return payload?.code === "session_not_authorized"; }; protected request = async (params: { layout: string; body: object; action?: "read" | "metaData" | "create" | "update" | "delete"; timeout?: number; fetchOptions?: RequestInit; }): Promise<unknown> => { const { action = "read", layout, body } = params; // Normalize underscore-prefixed keys to match FM script expectations const normalizedBody: Record<string, unknown> = { ...body } as Record<string, unknown>; if ("_offset" in normalizedBody) { normalizedBody.offset = normalizedBody._offset; normalizedBody._offset = undefined; } if ("_limit" in normalizedBody) { normalizedBody.limit = normalizedBody._limit; normalizedBody._limit = undefined; } if ("_sort" in normalizedBody) { normalizedBody.sort = normalizedBody._sort; normalizedBody._sort = undefined; } const scriptParam = JSON.stringify({ ...normalizedBody, layouts: layout, action, version: "vLatest", }); const raw = await this.postCallScript({ data: scriptParam, errorMessage: "FM MCP request failed", fetchOptions: params.fetchOptions, scriptName: this.scriptName, timeout: params.timeout, }); // The /callScript response wraps the script result as a string or object let scriptResult: unknown; try { scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : (raw.result ?? raw); } catch (err) { throw new FileMakerError( "500", `FM MCP response parse failed: ${err instanceof Error ? err.message : String(err)}`, ); } const respData = scriptResult as RawFMResponse; const errorCode = respData.messages?.[0]?.code; if (errorCode !== undefined && errorCode !== "0") { throw new FileMakerError( errorCode, `Filemaker Data API failed with (${errorCode}): ${JSON.stringify(respData, null, 2)}`, ); } return respData.response; }; protected postCallScript = async (params: { data: string | undefined; errorMessage: string; fetchOptions?: RequestInit; scriptName: string; timeout?: number; }): Promise<Record<string, unknown>> => { const { fetchOptions = {} } = params; const controller = new AbortController(); const timeout = params.timeout ? setTimeout(() => controller.abort(), params.timeout) : undefined; const headers = new Headers(this.sessionHeaders()); new Headers(fetchOptions.headers).forEach((value, key) => { headers.set(key, value); }); headers.set("Content-Type", "application/json"); const postCallScript = () => fetch(`${this.baseUrl}/callScript`, { ...fetchOptions, method: "POST", headers, body: JSON.stringify({ connectedFileName: this.connectedFileName, scriptName: params.scriptName, data: params.data, }), signal: controller.signal, }); let res: Response; try { res = await postCallScript(); if (await this.isUnauthorizedSession(res)) { try { await this.ensureAuthorized(); } catch (err) { const reason = err instanceof Error ? err.message : "authorization failed"; throw new Error(`Not authorized to connect to FileMaker file "${this.connectedFileName}": ${reason}`); } res = await postCallScript(); } } finally { if (timeout) { clearTimeout(timeout); } } if (!res.ok) { throw new FileMakerError(String(res.status), `${params.errorMessage} (${res.status}): ${await res.text()}`); } return (await res.json()) as Record<string, unknown>; }; list = async (opts: ListOptions): Promise<GetResponse> => { return (await this.request({ body: opts.data, layout: opts.layout, timeout: opts.timeout, fetchOptions: opts.fetch, })) as GetResponse; }; get = async (opts: GetOptions): Promise<GetResponse> => { return (await this.request({ body: opts.data, layout: opts.layout, timeout: opts.timeout, fetchOptions: opts.fetch, })) as GetResponse; }; find = async (opts: FindOptions): Promise<GetResponse> => { return (await this.request({ body: opts.data, layout: opts.layout, timeout: opts.timeout, fetchOptions: opts.fetch, })) as GetResponse; }; create = async (opts: CreateOptions): Promise<CreateResponse> => { return (await this.request({ action: "create", body: opts.data, layout: opts.layout, timeout: opts.timeout, fetchOptions: opts.fetch, })) as CreateResponse; }; update = async (opts: UpdateOptions): Promise<UpdateResponse> => { return (await this.request({ action: "update", body: opts.data, layout: opts.layout, timeout: opts.timeout, fetchOptions: opts.fetch, })) as UpdateResponse; }; delete = async (opts: DeleteOptions): Promise<DeleteResponse> => { return (await this.request({ action: "delete", body: opts.data, layout: opts.layout, timeout: opts.timeout, fetchOptions: opts.fetch, })) as DeleteResponse; }; layoutMetadata = async (opts: LayoutMetadataOptions): Promise<LayoutMetadataResponse> => { return (await this.request({ action: "metaData", layout: opts.layout, body: {}, timeout: opts.timeout, fetchOptions: opts.fetch, })) as LayoutMetadataResponse; }; executeScript = async (opts: ExecuteScriptOptions): Promise<ScriptResponse> => { const raw = await this.postCallScript({ data: opts.scriptParam, errorMessage: "FM MCP executeScript failed", fetchOptions: opts.fetch, scriptName: opts.script, timeout: opts.timeout, }); return { scriptResult: typeof raw.result === "string" ? raw.result : JSON.stringify(raw.result), } as ScriptResponse; }; containerUpload = (): Promise<never> => { throw new Error("Container upload is not supported via FM MCP adapter"); }; }