@proofkit/fmdapi
Version:
FileMaker Data API client
369 lines (335 loc) • 12 kB
text/typescript
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");
};
}