diff --git a/.changeset/fm-mcp-connected-files-timeout.md b/.changeset/fm-mcp-connected-files-timeout.md new file mode 100644 index 00000000..24aaac4f --- /dev/null +++ b/.changeset/fm-mcp-connected-files-timeout.md @@ -0,0 +1,5 @@ +--- +"@proofkit/typegen": patch +--- + +Fix FM MCP connected-files timeout and URL cache normalization. diff --git a/.changeset/fm-mcp-request-options.md b/.changeset/fm-mcp-request-options.md new file mode 100644 index 00000000..5462890e --- /dev/null +++ b/.changeset/fm-mcp-request-options.md @@ -0,0 +1,7 @@ +--- +"@proofkit/fmdapi": patch +"@proofkit/typegen": patch +"@proofkit/cli": patch +--- + +Fix FM MCP request options, client identity root, and CLI token persistence. diff --git a/.changeset/fm-mcp-typegen-authorization.md b/.changeset/fm-mcp-typegen-authorization.md new file mode 100644 index 00000000..c430906c --- /dev/null +++ b/.changeset/fm-mcp-typegen-authorization.md @@ -0,0 +1,7 @@ +--- +"@proofkit/fmdapi": patch +"@proofkit/typegen": patch +"@proofkit/cli": patch +--- + +Handle FileMaker bridge session authorization and clarify FM MCP authorization prompts. diff --git a/.changeset/short-typegen-fm-mcp-idle.md b/.changeset/short-typegen-fm-mcp-idle.md new file mode 100644 index 00000000..24e9d78e --- /dev/null +++ b/.changeset/short-typegen-fm-mcp-idle.md @@ -0,0 +1,6 @@ +--- +"@proofkit/fmdapi": patch +"@proofkit/typegen": patch +--- + +Send FM MCP authorization idle timeout seconds from typegen. diff --git a/.changeset/typegen-friendly-authorization-error.md b/.changeset/typegen-friendly-authorization-error.md new file mode 100644 index 00000000..8966712a --- /dev/null +++ b/.changeset/typegen-friendly-authorization-error.md @@ -0,0 +1,5 @@ +--- +"@proofkit/typegen": patch +--- + +Show friendly CLI error when FileMaker authorization denied. diff --git a/.changeset/typegen-ui-fm-mcp-memory.md b/.changeset/typegen-ui-fm-mcp-memory.md new file mode 100644 index 00000000..35348d66 --- /dev/null +++ b/.changeset/typegen-ui-fm-mcp-memory.md @@ -0,0 +1,5 @@ +--- +"@proofkit/typegen": patch +--- + +Support FM MCP bridge auth reuse in typegen UI. diff --git a/apps/docs/.env.schema b/apps/docs/.env.schema index 404494ab..ad1be5f0 100644 --- a/apps/docs/.env.schema +++ b/apps/docs/.env.schema @@ -10,5 +10,5 @@ NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_CRjEA3E6xegbZegA9ZjsCREfuuR8XdTJ72CkBeukd5 # type=url @required @public NEXT_PUBLIC_POSTHOG_HOST=https://p.proof.sh -# @type=string @required @sensitive +# @type=string @required=false @sensitive PROOFKIT_MANIFEST_REVALIDATE_SECRET= diff --git a/apps/docs/env.d.ts b/apps/docs/env.d.ts index aa61c0c9..f3a2cd53 100644 --- a/apps/docs/env.d.ts +++ b/apps/docs/env.d.ts @@ -7,25 +7,25 @@ /* eslint-disable */ export type CoercedEnvSchema = { /** - * **NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN** - * type=string @required @public - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M29%2022h-5a2.003%202.003%200%200%201-2-2v-6a2%202%200%200%201%202-2h5v2h-5v6h5ZM18%2012h-4V8h-2v14h6a2.003%202.003%200%200%200%202-2v-6a2%202%200%200%200-2-2m-4%208v-6h4v6Zm-6-8H3v2h5v2H4a2%202%200%200%200-2%202v2a2%202%200%200%200%202%202h6v-8a2%202%200%200%200-2-2m0%208H4v-2h4Z%22%2F%3E%3C%2Fsvg%3E) + * **NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN** + * type=string @required @public + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M29%2022h-5a2.003%202.003%200%200%201-2-2v-6a2%202%200%200%201%202-2h5v2h-5v6h5ZM18%2012h-4V8h-2v14h6a2.003%202.003%200%200%200%202-2v-6a2%202%200%200%200-2-2m-4%208v-6h4v6Zm-6-8H3v2h5v2H4a2%202%200%200%200-2%202v2a2%202%200%200%200%202%202h6v-8a2%202%200%200%200-2-2m0%208H4v-2h4Z%22%2F%3E%3C%2Fsvg%3E) */ NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN: string; - + /** - * **NEXT_PUBLIC_POSTHOG_HOST** - * type=url @required @public - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M29%2022h-5a2.003%202.003%200%200%201-2-2v-6a2%202%200%200%201%202-2h5v2h-5v6h5ZM18%2012h-4V8h-2v14h6a2.003%202.003%200%200%200%202-2v-6a2%202%200%200%200-2-2m-4%208v-6h4v6Zm-6-8H3v2h5v2H4a2%202%200%200%200-2%202v2a2%202%200%200%200%202%202h6v-8a2%202%200%200%200-2-2m0%208H4v-2h4Z%22%2F%3E%3C%2Fsvg%3E) + * **NEXT_PUBLIC_POSTHOG_HOST** + * type=url @required @public + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M29%2022h-5a2.003%202.003%200%200%201-2-2v-6a2%202%200%200%201%202-2h5v2h-5v6h5ZM18%2012h-4V8h-2v14h6a2.003%202.003%200%200%200%202-2v-6a2%202%200%200%200-2-2m-4%208v-6h4v6Zm-6-8H3v2h5v2H4a2%202%200%200%200-2%202v2a2%202%200%200%200%202%202h6v-8a2%202%200%200%200-2-2m0%208H4v-2h4Z%22%2F%3E%3C%2Fsvg%3E) */ NEXT_PUBLIC_POSTHOG_HOST: string; - + /** - * **PROOFKIT_MANIFEST_REVALIDATE_SECRET** 🔐 _sensitive_ - * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M29%2022h-5a2.003%202.003%200%200%201-2-2v-6a2%202%200%200%201%202-2h5v2h-5v6h5ZM18%2012h-4V8h-2v14h6a2.003%202.003%200%200%200%202-2v-6a2%202%200%200%200-2-2m-4%208v-6h4v6Zm-6-8H3v2h5v2H4a2%202%200%200%200-2%202v2a2%202%200%200%200%202%202h6v-8a2%202%200%200%200-2-2m0%208H4v-2h4Z%22%2F%3E%3C%2Fsvg%3E) + * **PROOFKIT_MANIFEST_REVALIDATE_SECRET** 🔐 _sensitive_ + * ![icon](data:image/svg+xml;utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23808080%22%20d%3D%22M29%2022h-5a2.003%202.003%200%200%201-2-2v-6a2%202%200%200%201%202-2h5v2h-5v6h5ZM18%2012h-4V8h-2v14h6a2.003%202.003%200%200%200%202-2v-6a2%202%200%200%200-2-2m-4%208v-6h4v6Zm-6-8H3v2h5v2H4a2%202%200%200%200-2%202v2a2%202%200%200%200%202%202h6v-8a2%202%200%200%200-2-2m0%208H4v-2h4Z%22%2F%3E%3C%2Fsvg%3E) */ - PROOFKIT_MANIFEST_REVALIDATE_SECRET: string; - + PROOFKIT_MANIFEST_REVALIDATE_SECRET?: string; + }; type _CoercedEnvSchema_e5eaaa81 = CoercedEnvSchema; diff --git a/apps/docs/src/app/api/proofkit/manifest/revalidate/route.ts b/apps/docs/src/app/api/proofkit/manifest/revalidate/route.ts index fe2c6f9a..b4a20245 100644 --- a/apps/docs/src/app/api/proofkit/manifest/revalidate/route.ts +++ b/apps/docs/src/app/api/proofkit/manifest/revalidate/route.ts @@ -10,7 +10,8 @@ const getBearerToken = (request: Request) => { }; export const POST = (request: Request): Response => { - if (getBearerToken(request) !== ENV.PROOFKIT_MANIFEST_REVALIDATE_SECRET) { + const secret = ENV.PROOFKIT_MANIFEST_REVALIDATE_SECRET; + if (!secret || getBearerToken(request) !== secret) { return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); } diff --git a/apps/docs/tests/manifest-revalidate.test.ts b/apps/docs/tests/manifest-revalidate.test.ts index af383787..8046c9e1 100644 --- a/apps/docs/tests/manifest-revalidate.test.ts +++ b/apps/docs/tests/manifest-revalidate.test.ts @@ -1,20 +1,22 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const revalidateTagMock = vi.fn(); +const envMock = vi.hoisted(() => ({ + ENV: { + PROOFKIT_MANIFEST_REVALIDATE_SECRET: "secret-123" as string | undefined, + }, +})); vi.mock("next/cache", () => ({ revalidateTag: revalidateTagMock, })); -vi.mock("varlock/env", () => ({ - ENV: { - PROOFKIT_MANIFEST_REVALIDATE_SECRET: "secret-123", - }, -})); +vi.mock("varlock/env", () => envMock); describe("manifest revalidation", () => { afterEach(() => { vi.clearAllMocks(); + envMock.ENV.PROOFKIT_MANIFEST_REVALIDATE_SECRET = "secret-123"; }); it("rejects missing bearer token", async () => { @@ -26,6 +28,23 @@ describe("manifest revalidation", () => { expect(revalidateTagMock).not.toHaveBeenCalled(); }); + it("rejects when revalidation secret is not configured", async () => { + envMock.ENV.PROOFKIT_MANIFEST_REVALIDATE_SECRET = undefined; + const { POST } = await import("@/app/api/proofkit/manifest/revalidate/route"); + + const response = await POST( + new Request("https://proofkit.test/api/proofkit/manifest/revalidate", { + method: "POST", + headers: { + authorization: "Bearer secret-123", + }, + }), + ); + + expect(response.status).toBe(401); + expect(revalidateTagMock).not.toHaveBeenCalled(); + }); + it("revalidates manifest cache tag", async () => { const { POST } = await import("@/app/api/proofkit/manifest/revalidate/route"); diff --git a/packages/cli/src/core/context.ts b/packages/cli/src/core/context.ts index ef37bfa7..e778c677 100644 --- a/packages/cli/src/core/context.ts +++ b/packages/cli/src/core/context.ts @@ -164,6 +164,13 @@ export interface FileMakerBootstrapArtifacts { export interface FileMakerService { readonly detectLocalFmMcp: (baseUrl?: string) => Eff; + readonly authorizeLocalFmMcp: (input: { + baseUrl: string; + fileName: string; + interactive: boolean; + clientName: string; + clientDescription: string; + }) => Eff<{ sessionToken: string }, CliError>; readonly installLocalWebViewerAddon: () => Eff; readonly validateHostedServerUrl: ( serverUrl: string, diff --git a/packages/cli/src/core/resolveInitRequest.ts b/packages/cli/src/core/resolveInitRequest.ts index 464daaf1..72d5ada6 100644 --- a/packages/cli/src/core/resolveInitRequest.ts +++ b/packages/cli/src/core/resolveInitRequest.ts @@ -479,6 +479,7 @@ function resolveFileMakerInputs({ flags, appType, nonInteractive, + projectName, }: { prompt: PromptService; console: ConsoleService; @@ -486,6 +487,7 @@ function resolveFileMakerInputs({ flags: CliFlags; appType: AppType; nonInteractive: boolean; + projectName: string; }) { return Effect.gen(function* () { if (flags.dataSource !== "filemaker") { @@ -544,6 +546,16 @@ function resolveFileMakerInputs({ yield* fileMakerService.installLocalWebViewerAddon(); const selectedFile = localFmMcp.healthy ? yield* resolveLocalFmMcpFile(localFmMcp.connectedFiles) : undefined; if (localFmMcp.healthy && selectedFile) { + if (!nonInteractive) { + yield* fileMakerService.authorizeLocalFmMcp({ + baseUrl: localFmMcp.baseUrl, + fileName: selectedFile, + interactive: true, + clientName: `ProofKit CLI (${projectName})`, + clientDescription: + "ProofKit CLI wants to read layouts from your FileMaker file to help set up your project.", + }); + } console.info(`Using ProofKit plugin file: ${selectedFile}`); return { fileMaker: { @@ -749,6 +761,8 @@ export const resolveInitRequest = (name?: string, rawFlags?: CliFlags) => ); } + const [scopedAppName, appDir] = parseNameAndPath(projectName); + const { fileMaker, skipFileMakerSetup } = yield* resolveFileMakerInputs({ prompt, console, @@ -756,10 +770,9 @@ export const resolveInitRequest = (name?: string, rawFlags?: CliFlags) => flags: { ...flags, dataSource }, appType, nonInteractive, + projectName: scopedAppName, }); - const [scopedAppName, appDir] = parseNameAndPath(projectName); - return { projectName, scopedAppName, diff --git a/packages/cli/src/services/live.ts b/packages/cli/src/services/live.ts index 8706a213..ca5b54e9 100644 --- a/packages/cli/src/services/live.ts +++ b/packages/cli/src/services/live.ts @@ -364,6 +364,55 @@ const fileMakerService = { cause, }), }), + authorizeLocalFmMcp: ({ + baseUrl, + fileName, + interactive, + clientName, + clientDescription, + }: { + baseUrl: string; + fileName: string; + interactive: boolean; + clientName: string; + clientDescription: string; + }) => + Effect.tryPromise({ + try: async () => { + if (!interactive) { + throw new Error("interactive authorization disabled"); + } + const sessionToken = `pk_${randomUUID().replaceAll("-", "")}`; + const response = await postJson<{ status?: unknown; error?: unknown }>( + `${baseUrl}/authorizeSession`, + { + sessionId: sessionToken, + fileName, + clientName, + clientDescription, + }, + { timeout: 125_000 }, + ); + if (response.status >= 200 && response.status < 300 && response.data?.status === "approved") { + return { sessionToken }; + } + const status = response.data?.status; + let reason = "authorization failed"; + if (typeof response.data?.error === "string") { + reason = response.data.error; + } else if (status === "rejected") { + reason = "authorization rejected"; + } else if (status === "timeout") { + reason = "authorization timed out"; + } + throw new Error(reason); + }, + catch: (cause) => + new FileMakerSetupError({ + message: `Not authorized to connect to FileMaker file "${fileName}".`, + cause, + }), + }), installLocalWebViewerAddon: () => Effect.tryPromise({ try: async () => { diff --git a/packages/cli/tests/resolve-init.test.ts b/packages/cli/tests/resolve-init.test.ts index 19f102f7..916b1ce4 100644 --- a/packages/cli/tests/resolve-init.test.ts +++ b/packages/cli/tests/resolve-init.test.ts @@ -332,6 +332,13 @@ describe("resolveInitRequest", () => { success: [], note: [], }; + const tracker = { + commands: [], + gitInits: 0, + codegens: 0, + filemakerBootstraps: 0, + localFmMcpAuthorizations: [], + }; const request = await Effect.runPromise( resolveInitRequest("demo", { @@ -349,6 +356,7 @@ describe("resolveInitRequest", () => { packageManager: "pnpm", nonInteractive: false, console: consoleTranscript, + tracker, fileMaker: { localFmMcp: { healthy: true, @@ -363,6 +371,12 @@ describe("resolveInitRequest", () => { mode: "local-fm-mcp", fileName: "LocalFile.fmp12", }); + expect(tracker.localFmMcpAuthorizations).toEqual([ + { + clientName: "ProofKit CLI (demo)", + clientDescription: "ProofKit CLI wants to read layouts from your FileMaker file to help set up your project.", + }, + ]); expect(consoleTranscript.info).toContain("Using ProofKit plugin file: LocalFile.fmp12"); }); diff --git a/packages/cli/tests/test-layer.ts b/packages/cli/tests/test-layer.ts index e3d7d42b..ec9594a4 100644 --- a/packages/cli/tests/test-layer.ts +++ b/packages/cli/tests/test-layer.ts @@ -66,6 +66,7 @@ export function makeTestLayer(options: { codegens: number; filemakerBootstraps: number; addonInstalls?: number; + localFmMcpAuthorizations?: { clientName: string; clientDescription: string }[]; }; fileMaker?: { localFmMcp?: @@ -423,6 +424,15 @@ export function makeTestLayer(options: { } return Effect.void; }, + authorizeLocalFmMcp: (input) => { + tracker?.localFmMcpAuthorizations?.push({ + clientName: input.clientName, + clientDescription: input.clientDescription, + }); + return Effect.succeed({ + sessionToken: "test-session-token", + }); + }, validateHostedServerUrl: (serverUrl: string) => { if (options.failures?.validateHostedServerUrl) { return Effect.fail(options.failures.validateHostedServerUrl as FileMakerSetupError); diff --git a/packages/fmdapi/src/adapters/fm-mcp.ts b/packages/fmdapi/src/adapters/fm-mcp.ts index e88376d7..f7a774e4 100644 --- a/packages/fmdapi/src/adapters/fm-mcp.ts +++ b/packages/fmdapi/src/adapters/fm-mcp.ts @@ -21,6 +21,37 @@ import type { } 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") */ @@ -29,19 +60,112 @@ export interface FmMcpAdapterOptions { connectedFileName: string; /** Name of the FM script that executes Data API calls. Defaults to "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; constructor(options: FmMcpAdapterOptions) { this.baseUrl = options.baseUrl.replace(TRAILING_SLASHES_REGEX, ""); this.connectedFileName = options.connectedFileName; this.scriptName = options.scriptName ?? "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 => { + if (this.pendingAuthorization) { + return this.pendingAuthorization; + } + this.pendingAuthorization = this.requestAuthorization().finally(() => { + this.pendingAuthorization = undefined; + }); + return this.pendingAuthorization; + }; + + protected requestAuthorization = async (): Promise => { + 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 => { + 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; @@ -49,7 +173,7 @@ export class FmMcpAdapter implements Adapter { timeout?: number; fetchOptions?: RequestInit; }): Promise => { - const { action = "read", layout, body, fetchOptions = {} } = params; + const { action = "read", layout, body } = params; // Normalize underscore-prefixed keys to match FM script expectations const normalizedBody: Record = { ...body } as Record; @@ -73,39 +197,14 @@ export class FmMcpAdapter implements Adapter { version: "vLatest", }); - const controller = new AbortController(); - let timeout: NodeJS.Timeout | null = null; - if (params.timeout) { - timeout = setTimeout(() => controller.abort(), params.timeout); - } - - const headers = new Headers(fetchOptions?.headers); - headers.set("Content-Type", "application/json"); - - let res: Response; - try { - res = await fetch(`${this.baseUrl}/callScript`, { - ...fetchOptions, - method: "POST", - headers, - body: JSON.stringify({ - connectedFileName: this.connectedFileName, - scriptName: this.scriptName, - data: scriptParam, - }), - signal: controller.signal, - }); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } - - if (!res.ok) { - throw new FileMakerError(String(res.status), `FM MCP request failed (${res.status}): ${await res.text()}`); - } + const raw = await this.postCallScript({ + data: scriptParam, + errorMessage: "FM MCP request failed", + fetchOptions: params.fetchOptions, + scriptName: this.scriptName, + timeout: params.timeout, + }); - const raw = await res.json(); // The /callScript response wraps the script result as a string or object let scriptResult: unknown; try { @@ -130,6 +229,59 @@ export class FmMcpAdapter implements Adapter { return respData.response; }; + protected postCallScript = async (params: { + data: string | undefined; + errorMessage: string; + fetchOptions?: RequestInit; + scriptName: string; + timeout?: number; + }): Promise> => { + 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; + }; + list = async (opts: ListOptions): Promise => { return (await this.request({ body: opts.data, @@ -198,21 +350,13 @@ export class FmMcpAdapter implements Adapter { }; executeScript = async (opts: ExecuteScriptOptions): Promise => { - const res = await fetch(`${this.baseUrl}/callScript`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - connectedFileName: this.connectedFileName, - scriptName: opts.script, - data: opts.scriptParam, - }), + const raw = await this.postCallScript({ + data: opts.scriptParam, + errorMessage: "FM MCP executeScript failed", + fetchOptions: opts.fetch, + scriptName: opts.script, + timeout: opts.timeout, }); - - if (!res.ok) { - throw new FileMakerError(String(res.status), `FM MCP executeScript failed (${res.status}): ${await res.text()}`); - } - - const raw = await res.json(); return { scriptResult: typeof raw.result === "string" ? raw.result : JSON.stringify(raw.result), } as ScriptResponse; diff --git a/packages/fmdapi/tests/fm-mcp-adapter.test.ts b/packages/fmdapi/tests/fm-mcp-adapter.test.ts index 88e828dd..a23d6023 100644 --- a/packages/fmdapi/tests/fm-mcp-adapter.test.ts +++ b/packages/fmdapi/tests/fm-mcp-adapter.test.ts @@ -201,6 +201,83 @@ describe("FmMcpAdapter", () => { const client = createClient(); await expect(client.list()).rejects.toBeInstanceOf(FileMakerError); }); + + it("authorizes after session 401 and retries once", async () => { + const spy = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: "session_not_authorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ status: "approved" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify( + successEnvelope({ + data: [], + dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 }, + }), + ), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", spy); + + const adapter = new FmMcpAdapter({ + baseUrl: "http://localhost:3000", + connectedFileName: "MyFile", + sessionId: "test-session", + clientName: "Typegen Test", + clientDescription: "Test description", + idleTimeoutSeconds: 120, + }); + const client = createClient(adapter); + await expect(client.list()).resolves.toHaveProperty("data"); + + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls[1][0]).toBe("http://localhost:3000/authorizeSession"); + const authorizeBody = JSON.parse(spy.mock.calls[1][1]?.body as string); + expect(authorizeBody).toMatchObject({ + sessionId: "test-session", + fileName: "MyFile", + clientName: "Typegen Test", + clientDescription: "Test description", + idleTimeoutSeconds: 120, + }); + const headers = spy.mock.calls[0][1]?.headers as Headers; + expect(headers.get("X-ProofKit-Session")).toBe("test-session"); + expect(headers.get("X-ProofKit-Client")).toBe("Typegen Test"); + }); + + it("fails unauthorized request when interactive authorization is disabled", async () => { + const spy = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ code: "session_not_authorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient( + new FmMcpAdapter({ + baseUrl: "http://localhost:3000", + connectedFileName: "MyFile", + disableInteractiveAuthorization: true, + }), + ); + + await expect(client.list()).rejects.toThrow( + 'Not authorized to connect to FileMaker file "MyFile": interactive authorization disabled', + ); + expect(spy).toHaveBeenCalledTimes(1); + }); }); describe("string result parsing", () => { diff --git a/packages/typegen/src/cli-errors.ts b/packages/typegen/src/cli-errors.ts new file mode 100644 index 00000000..bb77ac91 --- /dev/null +++ b/packages/typegen/src/cli-errors.ts @@ -0,0 +1,13 @@ +const fmMcpAuthorizationPrefix = "Not authorized to connect to FileMaker file "; + +export function getFriendlyTypegenError(error: unknown): string | undefined { + if (!(error instanceof Error && error.message.startsWith(fmMcpAuthorizationPrefix))) { + return undefined; + } + + return [ + "FileMaker authorization denied.", + error.message, + "Open FileMaker and approve the connection request, then run typegen again.", + ].join("\n"); +} diff --git a/packages/typegen/src/cli.ts b/packages/typegen/src/cli.ts index c7f6bb51..89bddfc5 100644 --- a/packages/typegen/src/cli.ts +++ b/packages/typegen/src/cli.ts @@ -7,6 +7,7 @@ import chalk from "chalk"; import { config } from "dotenv"; import fs from "fs-extra"; import { parse } from "jsonc-parser"; +import { getFriendlyTypegenError } from "./cli-errors"; import { typegenConfig } from "./types"; const defaultConfigPaths = ["proofkit-typegen.config.jsonc", "proofkit-typegen.config.json"]; @@ -91,6 +92,11 @@ async function runCodegen({ configLocation, resetOverrides = false }: ConfigArgs postGenerateCommand: configParsed.data.postGenerateCommand, configPath: configLocation, }).catch((err: unknown) => { + const friendlyError = getFriendlyTypegenError(err); + if (friendlyError) { + console.error(chalk.red(friendlyError)); + return process.exit(1); + } console.error(err); return process.exit(1); }); diff --git a/packages/typegen/src/constants.ts b/packages/typegen/src/constants.ts index 95eff295..cce23b95 100644 --- a/packages/typegen/src/constants.ts +++ b/packages/typegen/src/constants.ts @@ -33,6 +33,7 @@ export const defaultEnvNames = { db: "FM_DATABASE", fmMcpBaseUrl: "FM_MCP_BASE_URL", fmMcpConnectedFileName: "FM_CONNECTED_FILE_NAME", + fmMcpPersistentToken: "FM_MCP_PERSISTENT_TOKEN", }; export const defaultFmMcpBaseUrl = "http://127.0.0.1:1365"; diff --git a/packages/typegen/src/fmMcpSession.ts b/packages/typegen/src/fmMcpSession.ts new file mode 100644 index 00000000..8434b952 --- /dev/null +++ b/packages/typegen/src/fmMcpSession.ts @@ -0,0 +1,31 @@ +import { randomUUID } from "node:crypto"; + +export interface FmMcpSessionKey { + cwd: string; + baseUrl: string; + connectedFileName: string; + clientName: string; +} + +const fmMcpSessionIds = new Map(); + +const randomSessionId = () => { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + return randomUUID(); +}; + +export const getFmMcpSessionId = (key: FmMcpSessionKey, explicitSessionId?: string) => { + if (explicitSessionId) { + return explicitSessionId; + } + const serializedKey = JSON.stringify(key); + const existing = fmMcpSessionIds.get(serializedKey); + if (existing) { + return existing; + } + const sessionId = randomSessionId(); + fmMcpSessionIds.set(serializedKey, sessionId); + return sessionId; +}; diff --git a/packages/typegen/src/getEnvValues.ts b/packages/typegen/src/getEnvValues.ts index db08211d..5b6ba82d 100644 --- a/packages/typegen/src/getEnvValues.ts +++ b/packages/typegen/src/getEnvValues.ts @@ -15,6 +15,7 @@ export interface EnvValues { password: string | undefined; fmMcpBaseUrl: string | undefined; fmMcpConnectedFileName: string | undefined; + fmMcpPersistentToken: string | undefined; } type StandardAuth = @@ -35,6 +36,7 @@ export type EnvValidationResult = mode: "fmMcp"; baseUrl: string; connectedFileName: string; + persistentToken?: string; } | { success: false; @@ -44,7 +46,7 @@ export type EnvValidationResult = interface EnvValidationOptions { fmMcp?: boolean; allowClarisId?: boolean; - fmMcpConfig?: { baseUrl?: string; connectedFileName?: string }; + fmMcpConfig?: { baseUrl?: string; connectedFileName?: string; persistentToken?: string }; } /** @@ -96,9 +98,14 @@ export function getEnvValues(envNames?: EnvNames): EnvValues { envNames?.fmMcp && "connectedFileName" in envNames.fmMcp ? getEnvName(envNames.fmMcp.connectedFileName, defaultEnvNames.fmMcpConnectedFileName) : defaultEnvNames.fmMcpConnectedFileName; + const fmMcpPersistentTokenEnvName = + envNames?.fmMcp && "persistentToken" in envNames.fmMcp + ? getEnvName(envNames.fmMcp.persistentToken, defaultEnvNames.fmMcpPersistentToken) + : defaultEnvNames.fmMcpPersistentToken; const fmMcpBaseUrl = process.env[fmMcpBaseUrlEnvName]; const fmMcpConnectedFileName = process.env[fmMcpConnectedFileNameEnvName]; + const fmMcpPersistentToken = process.env[fmMcpPersistentTokenEnvName]; return { server, @@ -110,6 +117,7 @@ export function getEnvValues(envNames?: EnvNames): EnvValues { password, fmMcpBaseUrl, fmMcpConnectedFileName, + fmMcpPersistentToken, }; } @@ -132,17 +140,20 @@ export function validateEnvValues( password, fmMcpBaseUrl, fmMcpConnectedFileName, + fmMcpPersistentToken, } = envValues; if (options?.fmMcp) { const resolvedBaseUrl = options.fmMcpConfig?.baseUrl || fmMcpBaseUrl || defaultFmMcpBaseUrl; const resolvedConnectedFileName = options.fmMcpConfig?.connectedFileName || fmMcpConnectedFileName; + const resolvedPersistentToken = options.fmMcpConfig?.persistentToken || fmMcpPersistentToken; return { success: true, mode: "fmMcp", baseUrl: resolvedBaseUrl, connectedFileName: resolvedConnectedFileName ?? "", + persistentToken: resolvedPersistentToken, }; } diff --git a/packages/typegen/src/server/app.ts b/packages/typegen/src/server/app.ts index 92bbe11e..8c9d62d7 100644 --- a/packages/typegen/src/server/app.ts +++ b/packages/typegen/src/server/app.ts @@ -8,13 +8,36 @@ import { parse } from "jsonc-parser"; import z from "zod/v4"; import { generateTypedClients } from "../typegen"; import { typegenConfig, typegenConfigSingle, typegenConfigSingleForValidation } from "../types"; -import { createClientFromConfig, createDataApiClient, createOdataClientFromConfig } from "./createDataApiClient"; +import { + createClientFromConfig, + createDataApiClient, + createOdataClientFromConfig, + fmMcpUiIdleTimeoutSeconds, + getFmMcpUiClientIdentity, +} from "./createDataApiClient"; export interface ApiContext { cwd: string; configPath: string; } +const debugLogsEnabled = process.env.PROOFKIT_TYPEGEN_UI_DEBUG === "true" || process.env.DEBUG === "proofkit:typegen"; + +function logServer(message: string) { + console.log(`[typegen-ui] ${message}`); +} + +function debugServer(message: string, value?: unknown) { + if (!debugLogsEnabled) { + return; + } + if (value === undefined) { + console.log(`[typegen-ui:debug] ${message}`); + return; + } + console.log(`[typegen-ui:debug] ${message}:`, JSON.stringify(value, null, 2)); +} + /** * Flattens a nested layout/folder structure into a flat list with full paths */ @@ -71,6 +94,7 @@ export function createApiApp(context: ApiContext) { // GET /api/config .get("/config", (c) => { + logServer(`GET /config ${context.configPath}`); const { configPath, cwd } = context; const fullPath = path.resolve(cwd, configPath); @@ -99,7 +123,7 @@ export function createApiApp(context: ApiContext) { postGenerateCommand: parsed.postGenerateCommand, }); } catch (err) { - console.log("error from get config", err); + logServer(`failed to read config: ${err instanceof Error ? err.message : String(err)}`); return c.json( { error: err instanceof Error ? err.message : "Failed to read config", @@ -121,7 +145,8 @@ export function createApiApp(context: ApiContext) { async (c) => { try { const data = c.req.valid("json"); - console.log("[Server POST /config] Received data:", JSON.stringify(data, null, 2)); + logServer(`saving config to ${context.configPath}`); + debugServer("POST /config received", data); // Transform validated data using runtime schema (applies transforms) const transformedData = { @@ -134,11 +159,11 @@ export function createApiApp(context: ApiContext) { : { ...(config as Record), type: "fmdapi" as const }; // Parse with runtime schema to apply transforms const parsed = typegenConfigSingle.parse(configWithType); - console.log("[Server POST /config] After parse, config:", JSON.stringify(parsed, null, 2)); + debugServer("POST /config parsed entry", parsed); return parsed; }), }; - console.log("[Server POST /config] Transformed data:", JSON.stringify(transformedData, null, 2)); + debugServer("POST /config transformed", transformedData); // Validate with Zod (data is already { config: [...], postGenerateCommand?: string }) const validation = typegenConfig.safeParse(transformedData); @@ -174,17 +199,19 @@ export function createApiApp(context: ApiContext) { const fullPath = path.resolve(context.cwd, context.configPath); // Add $schema at the top of the config const configData = validation.data as Record; - console.log("[Server POST /config] Validation data to write:", JSON.stringify(configData, null, 2)); + debugServer("POST /config validated", configData); const { $schema: _, ...rest } = configData; const configWithSchema = { $schema: "https://proofkit.proof.sh/typegen-config-schema.json", ...rest, }; const jsonContent = `${JSON.stringify(configWithSchema, null, 2)}\n`; - console.log("[Server POST /config] Final JSON content:\n", jsonContent); + debugServer("POST /config final JSON", configWithSchema); await fs.ensureDir(path.dirname(fullPath)); await fs.writeFile(fullPath, jsonContent, "utf8"); + const configCount = Array.isArray(validation.data.config) ? validation.data.config.length : 1; + logServer(`config saved (${configCount} connection${configCount === 1 ? "" : "s"})`); const response = z .object({ @@ -235,6 +262,7 @@ export function createApiApp(context: ApiContext) { ), async (c, next) => { const rawData = c.req.valid("json"); + logServer("POST /run starting typegen"); // Transform validated data using runtime schema (applies transforms) const configArray = Array.isArray(rawData.config) ? rawData.config : [rawData.config]; const transformedConfig = configArray.map((config) => { @@ -270,7 +298,10 @@ export function createApiApp(context: ApiContext) { await generateTypedClients(config, { cwd: context.cwd, postGenerateCommand, + fmMcpClientIdentity: getFmMcpUiClientIdentity(context.cwd), + fmMcpIdleTimeoutSeconds: fmMcpUiIdleTimeoutSeconds, }); + logServer("POST /run finished typegen"); await next(); }, @@ -279,6 +310,7 @@ export function createApiApp(context: ApiContext) { .get("/layouts", zValidator("query", z.object({ configIndex: z.coerce.number() })), async (c) => { const input = c.req.valid("query"); const configIndex = input.configIndex; + logServer(`GET /layouts config=${configIndex}`); const result = await createDataApiClient(context, configIndex); @@ -327,6 +359,7 @@ export function createApiApp(context: ApiContext) { // Flatten the nested layout/folder structure into a flat list with full paths const flatLayouts = flattenLayouts(layouts); + logServer(`GET /layouts config=${configIndex} returned ${flatLayouts.length} layout${flatLayouts.length === 1 ? "" : "s"}`); return c.json({ layouts: flatLayouts }); } catch (err) { @@ -487,6 +520,7 @@ export function createApiApp(context: ApiContext) { async (c) => { try { const rawData = c.req.valid("json"); + logServer("POST /test-connection"); // Transform validated data using runtime schema (applies transforms) const configWithType = "type" in rawData.config && rawData.config.type @@ -497,7 +531,7 @@ export function createApiApp(context: ApiContext) { // Validate config type if (config.type === "fmdapi") { // Create client from config - const clientResult = await createClientFromConfig(config); + const clientResult = await createClientFromConfig(config, { projectRoot: context.cwd }); // Check if client creation failed if ("error" in clientResult) { diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index 2febbcd0..49407a67 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -4,6 +4,7 @@ import fs from "fs-extra"; import { parse } from "jsonc-parser"; import type { z } from "zod/v4"; import { defaultEnvNames, defaultFmMcpBaseUrl } from "../constants"; +import { getFmMcpSessionId } from "../fmMcpSession"; import { rethrowMissingDependency } from "../optionalDeps"; import { typegenConfig, type typegenConfigSingle } from "../types"; import type { ApiContext } from "./app"; @@ -61,6 +62,103 @@ interface ClarisIdAuth { type SupportedAuth = ApiKeyAuth | UsernameAuth | ClarisIdAuth; +const trailingSlashesRegex = /\/+$/; +const defaultAuthorizeTimeoutMs = 125_000; +const connectedFilesTimeoutMs = 5000; +export const fmMcpUiIdleTimeoutSeconds = 900; + +const normalizeFmMcpBaseUrl = (baseUrl: string) => { + const trimmedBaseUrl = baseUrl.trim().replace(trailingSlashesRegex, ""); + try { + const url = new URL(trimmedBaseUrl); + url.pathname = url.pathname.replace(trailingSlashesRegex, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(trailingSlashesRegex, ""); + } catch { + return trimmedBaseUrl; + } +}; + +const getStatusReason = (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"; +}; + +const authorizeFmMcpSession = async (options: { + baseUrl: string; + connectedFileName: string; + sessionId: string; + clientName: string; + clientDescription: string; + idleTimeoutSeconds: number; + authorizationTimeoutMs?: number; + disableInteractiveAuthorization?: boolean; +}) => { + if (options.disableInteractiveAuthorization) { + throw new Error("interactive authorization disabled"); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.authorizationTimeoutMs ?? defaultAuthorizeTimeoutMs); + try { + const res = await fetch(`${options.baseUrl.replace(trailingSlashesRegex, "")}/authorizeSession`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: options.sessionId, + fileName: options.connectedFileName, + clientName: options.clientName, + clientDescription: options.clientDescription, + idleTimeoutSeconds: options.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 : getStatusReason(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); + } +}; + +const getProjectName = (cwd: string) => { + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as { name?: unknown }; + if (typeof packageJson.name === "string" && packageJson.name.trim() !== "") { + return packageJson.name; + } + } catch { + // Fall back to folder name when the UI server runs outside a package root. + } + return path.basename(cwd); +}; + +export const getFmMcpUiClientIdentity = (cwd: string) => { + const projectName = getProjectName(cwd); + return { + clientName: `ProofKit Typegen UI (${projectName})`, + clientDescription: + "ProofKit Typegen UI wants to read layout metadata from your FileMaker file to help configure generated field names and field types.", + }; +}; + type EnvVarsResult = | CreateClientError | { @@ -310,6 +408,7 @@ export async function createOdataClientFromConfig( export async function createClientFromConfig( config: FmdapiConfig, + options?: { projectRoot?: string }, ): Promise | CreateClientError> { let deps: Awaited>; try { @@ -335,9 +434,84 @@ export async function createClientFromConfig( config.envNames?.fmMcp?.connectedFileName, defaultEnvNames.fmMcpConnectedFileName, ); + const persistentTokenEnvName = getEnvName( + config.envNames?.fmMcp?.persistentToken, + defaultEnvNames.fmMcpPersistentToken, + ); + + const baseUrl = normalizeFmMcpBaseUrl(fmMcpObj?.baseUrl || process.env[baseUrlEnvName] || defaultFmMcpBaseUrl); + let connectedFileName = fmMcpObj?.connectedFileName || process.env[connectedFileNameEnvName]; + const persistentToken = fmMcpObj?.persistentToken || process.env[persistentTokenEnvName]; + const projectRoot = options?.projectRoot ?? process.cwd(); + const fmMcpClientIdentity = getFmMcpUiClientIdentity(projectRoot); - const baseUrl = fmMcpObj?.baseUrl || process.env[baseUrlEnvName] || defaultFmMcpBaseUrl; - const connectedFileName = fmMcpObj?.connectedFileName || process.env[connectedFileNameEnvName]; + if (!connectedFileName) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), connectedFilesTimeoutMs); + try { + const res = await fetch(`${baseUrl.replace(trailingSlashesRegex, "")}/connectedFiles`, { + signal: controller.signal, + }); + clearTimeout(timeout); + if (!res.ok) { + return { + error: "Failed to discover connected FileMaker files", + statusCode: 400, + kind: "connection_error", + suspectedField: "server", + message: `Could not read connected files from ${baseUrl}`, + }; + } + const connectedFiles = (await res.json()) as unknown; + if (!(Array.isArray(connectedFiles) && connectedFiles.every((fileName) => typeof fileName === "string"))) { + return { + error: "Invalid connected files response", + statusCode: 400, + kind: "connection_error", + suspectedField: "server", + message: "FM MCP server returned invalid connected files", + }; + } + if (connectedFiles.length === 1) { + connectedFileName = connectedFiles[0]; + } else if (connectedFiles.length > 1) { + return { + error: "Multiple connected FileMaker files found", + statusCode: 400, + kind: "missing_env", + details: { connectedFiles }, + suspectedField: "db", + message: `Set connectedFileName in your fmMcp config or ${connectedFileNameEnvName} env var`, + }; + } else { + return { + error: "No connected FileMaker files found", + statusCode: 400, + kind: "missing_env", + suspectedField: "db", + message: "Connect a FileMaker file to the FM MCP server", + }; + } + } catch (err) { + clearTimeout(timeout); + if (err instanceof Error && err.name === "AbortError") { + return { + error: "Timed out discovering connected FileMaker files", + statusCode: 400, + kind: "connection_error", + suspectedField: "server", + message: `Timed out reading connected files from ${baseUrl}`, + }; + } + return { + error: err instanceof Error ? err.message : "Failed to reach FM MCP server", + statusCode: 400, + kind: "connection_error", + suspectedField: "server", + message: `Could not reach FM MCP server at ${baseUrl}`, + }; + } + } if (!connectedFileName) { return { @@ -351,18 +525,57 @@ export async function createClientFromConfig( } try { + const resolvedConnectedFileName = connectedFileName; + const clientName = fmMcpObj?.clientName ?? fmMcpClientIdentity.clientName; + const clientDescription = fmMcpObj?.clientDescription ?? fmMcpClientIdentity.clientDescription; + const sessionKey = { + cwd: projectRoot, + baseUrl, + connectedFileName: resolvedConnectedFileName, + clientName, + }; + const sessionId = getFmMcpSessionId(sessionKey, persistentToken ?? fmMcpObj?.sessionId); + const authorize = () => + authorizeFmMcpSession({ + baseUrl, + connectedFileName: resolvedConnectedFileName, + sessionId, + clientName, + clientDescription, + idleTimeoutSeconds: fmMcpUiIdleTimeoutSeconds, + authorizationTimeoutMs: fmMcpObj?.authorizationTimeoutMs, + disableInteractiveAuthorization: fmMcpObj?.disableInteractiveAuthorization, + }); const client = DataApi({ adapter: new FmMcpAdapter({ baseUrl, - connectedFileName, + connectedFileName: resolvedConnectedFileName, scriptName: fmMcpObj?.scriptName ?? config.webviewerScriptName, + sessionId, + clientName, + clientDescription, + idleTimeoutSeconds: fmMcpUiIdleTimeoutSeconds, + authorizationTimeoutMs: fmMcpObj?.authorizationTimeoutMs, + disableInteractiveAuthorization: fmMcpObj?.disableInteractiveAuthorization, }), layout: "", }); + const clientWithLayouts = { + ...client, + layouts: async () => { + await authorize(); + return { + layouts: config.layouts.map((layout) => ({ + name: layout.layoutName, + table: layout.layoutName, + })), + }; + }, + }; return { - client: client as CreateClientResult["client"], + client: clientWithLayouts as CreateClientResult["client"], server: baseUrl, - db: connectedFileName, + db: resolvedConnectedFileName, authType: "fmMcp", }; } catch (err) { @@ -473,7 +686,7 @@ export async function createDataApiClient( }; } - const result = await createClientFromConfig(config); + const result = await createClientFromConfig(config, { projectRoot: context.cwd }); if ("error" in result) { return result; diff --git a/packages/typegen/src/typegen.ts b/packages/typegen/src/typegen.ts index 913354a5..b999aded 100644 --- a/packages/typegen/src/typegen.ts +++ b/packages/typegen/src/typegen.ts @@ -9,16 +9,65 @@ import type { z } from "zod/v4"; import { buildLayoutClient } from "./buildLayoutClient"; import { buildOverrideFile, buildSchema } from "./buildSchema"; import { commentHeader, defaultEnvNames, overrideCommentHeader } from "./constants"; +import { getFmMcpSessionId } from "./fmMcpSession"; import { formatAndSaveSourceFiles, runPostGenerateCommand } from "./formatting"; import { getEnvValues, validateAndLogEnvValues } from "./getEnvValues"; import { rethrowMissingDependency } from "./optionalDeps"; import { type BuildSchemaArgs, typegenConfig, type typegenConfigSingle } from "./types"; type GlobalOptions = Omit, "config">; +interface FmMcpClientIdentity { + clientName: string; + clientDescription: string; +} + +const typegenCliIdleTimeoutSeconds = 120; +const connectedFilesTimeoutMs = 5000; +const trailingSlashesRegex = /\/+$/; + +const normalizeFmMcpBaseUrl = (baseUrl: string) => { + const trimmedBaseUrl = baseUrl.trim().replace(trailingSlashesRegex, ""); + try { + const url = new URL(trimmedBaseUrl); + url.pathname = url.pathname.replace(trailingSlashesRegex, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(trailingSlashesRegex, ""); + } catch { + return trimmedBaseUrl; + } +}; + +const getProjectName = (cwd: string) => { + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as PackageJson; + if (typeof packageJson.name === "string" && packageJson.name.trim() !== "") { + return packageJson.name; + } + } catch { + // Fall back to folder name when typegen runs outside a package root. + } + return path.basename(cwd); +}; + +const getFmMcpClientIdentity = (cwd: string) => { + const projectName = getProjectName(cwd); + return { + clientName: `ProofKit Typegen (${projectName})`, + clientDescription: + "ProofKit Typegen wants to read layout metadata from your FileMaker file to generate correct field names and field types into your codebase.", + }; +}; export const generateTypedClients = async ( config: z.infer["config"], - options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string }, + options?: GlobalOptions & { + resetOverrides?: boolean; + cwd?: string; + configPath?: string; + fmMcpClientIdentity?: FmMcpClientIdentity; + fmMcpIdleTimeoutSeconds?: number; + }, ): Promise< | { successCount: number; @@ -80,6 +129,8 @@ export const generateTypedClients = async ( cwd, configPath: options?.configPath, configIndex: isConfigArray ? configIndex : undefined, + fmMcpClientIdentity: options?.fmMcpClientIdentity, + fmMcpIdleTimeoutSeconds: options?.fmMcpIdleTimeoutSeconds, }); if (result) { totalSuccessCount += result.successCount; @@ -110,7 +161,14 @@ export const generateTypedClients = async ( const generateTypedClientsSingle = async ( config: Extract, { type: "fmdapi" }>, - options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string; configIndex?: number }, + options?: GlobalOptions & { + resetOverrides?: boolean; + cwd?: string; + configPath?: string; + configIndex?: number; + fmMcpClientIdentity?: FmMcpClientIdentity; + fmMcpIdleTimeoutSeconds?: number; + }, ) => { const { envNames, @@ -165,7 +223,11 @@ const generateTypedClientsSingle = async ( const validationResult = validateAndLogEnvValues(envValues, envNames, { fmMcp: isFmMcpMode, fmMcpConfig: isFmMcpMode - ? { baseUrl: fmMcpObj?.baseUrl, connectedFileName: fmMcpObj?.connectedFileName } + ? { + baseUrl: fmMcpObj?.baseUrl, + connectedFileName: fmMcpObj?.connectedFileName, + persistentToken: fmMcpObj?.persistentToken, + } : undefined, }); @@ -179,15 +241,23 @@ const generateTypedClientsSingle = async ( let auth: { apiKey: OttoAPIKey } | { username: string; password: string } | undefined; let fmMcpBaseUrl: string | undefined; let fmMcpConnectedFileName: string | undefined; + let fmMcpPersistentToken: string | undefined; + let fmMcpSessionId: string | undefined; if (validationResult.mode === "fmMcp") { - fmMcpBaseUrl = validationResult.baseUrl; + fmMcpBaseUrl = normalizeFmMcpBaseUrl(validationResult.baseUrl); fmMcpConnectedFileName = validationResult.connectedFileName; + fmMcpPersistentToken = validationResult.persistentToken; // Auto-discover connectedFileName if not provided if (!fmMcpConnectedFileName) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), connectedFilesTimeoutMs); try { - const res = await fetch(`${fmMcpBaseUrl}/connectedFiles`); + const res = await fetch(`${fmMcpBaseUrl}/connectedFiles`, { + signal: controller.signal, + }); + clearTimeout(timeout); if (res.ok) { const files = (await res.json()) as string[]; if (files.length === 1) { @@ -252,12 +322,31 @@ const generateTypedClientsSingle = async ( console.log(chalk.red(`ERROR: Failed to auto-discover connected files from ${fmMcpBaseUrl}/connectedFiles`)); return; } - } catch (_err) { + } catch (err) { + clearTimeout(timeout); + if (err instanceof Error && err.name === "AbortError") { + console.log(chalk.red(`ERROR: Timed out reading connected files from ${fmMcpBaseUrl}/connectedFiles`)); + return; + } console.log(chalk.red(`ERROR: Could not reach FM MCP server at ${fmMcpBaseUrl}`)); console.log(chalk.yellow("Ensure the FM MCP server is running and accessible.")); return; } } + if (!fmMcpConnectedFileName) { + console.log(chalk.red("ERROR: Missing connected FileMaker file name for FM MCP mode.")); + return; + } + const sessionClientIdentity = options?.fmMcpClientIdentity ?? getFmMcpClientIdentity(cwd); + fmMcpSessionId = getFmMcpSessionId( + { + cwd, + baseUrl: fmMcpBaseUrl, + connectedFileName: fmMcpConnectedFileName, + clientName: fmMcpObj?.clientName ?? sessionClientIdentity.clientName, + }, + fmMcpPersistentToken ?? fmMcpObj?.sessionId, + ); } else { server = validationResult.server; db = validationResult.db; @@ -300,6 +389,7 @@ const generateTypedClientsSingle = async ( let successCount = 0; let errorCount = 0; let totalCount = 0; + const fmMcpClientIdentity = options?.fmMcpClientIdentity ?? getFmMcpClientIdentity(cwd); for await (const item of layouts) { totalCount++; @@ -310,6 +400,12 @@ const generateTypedClientsSingle = async ( baseUrl: fmMcpBaseUrl as string, connectedFileName: fmMcpConnectedFileName as string, scriptName: fmMcpObj?.scriptName ?? config.webviewerScriptName, + sessionId: fmMcpSessionId, + clientName: fmMcpObj?.clientName ?? fmMcpClientIdentity.clientName, + clientDescription: fmMcpObj?.clientDescription ?? fmMcpClientIdentity.clientDescription, + idleTimeoutSeconds: options?.fmMcpIdleTimeoutSeconds ?? typegenCliIdleTimeoutSeconds, + authorizationTimeoutMs: fmMcpObj?.authorizationTimeoutMs, + disableInteractiveAuthorization: fmMcpObj?.disableInteractiveAuthorization, }), layout: item.layoutName, }); diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts index dbbd4f9c..c3b8f4bf 100644 --- a/packages/typegen/src/types.ts +++ b/packages/typegen/src/types.ts @@ -41,6 +41,7 @@ export const envNamesBase = z .object({ baseUrl: z.string().optional(), connectedFileName: z.string().optional(), + persistentToken: z.string().optional(), }) .optional(), }) @@ -74,6 +75,7 @@ const envNames = envNamesBase ? { baseUrl: val.fmMcp.baseUrl === "" ? undefined : val.fmMcp.baseUrl, connectedFileName: val.fmMcp.connectedFileName === "" ? undefined : val.fmMcp.connectedFileName, + persistentToken: val.fmMcp.persistentToken === "" ? undefined : val.fmMcp.persistentToken, } : undefined, }; @@ -210,6 +212,27 @@ const fmMcpFieldObject = z.object({ description: "Name of the connected FileMaker file. If not provided, it will be auto-discovered from the FM MCP server's /connectedFiles endpoint and written back to your config. Can also be set via FM_CONNECTED_FILE_NAME env var.", }), + sessionId: z.string().optional().meta({ + description: + "Session ID sent to the FileMaker bridge for authorization. Defaults to FM_MCP_SESSION_ID or a random ID.", + }), + persistentToken: z.string().optional().meta({ + description: + "Persistent token value to use as the FileMaker bridge session ID. If also registered in FileMaker, requests are already authorized. Prefer envNames.fmMcp.persistentToken for secrets.", + }), + clientName: z.string().optional().meta({ + description: + 'Client name shown in FileMaker authorization prompts. Defaults to FM_MCP_CLIENT_NAME or "ProofKit Typegen".', + }), + clientDescription: z.string().optional().meta({ + description: "Client description shown in FileMaker authorization prompts.", + }), + authorizationTimeoutMs: z.number().positive().optional().meta({ + description: "Timeout for FileMaker bridge authorization requests. Defaults to 125000 ms.", + }), + disableInteractiveAuthorization: z.boolean().optional().meta({ + description: "If true, fail on unauthorized bridge requests instead of opening an authorization prompt.", + }), }); const fmMcpField = z diff --git a/packages/typegen/tests/cli-errors.test.ts b/packages/typegen/tests/cli-errors.test.ts new file mode 100644 index 00000000..f460d21a --- /dev/null +++ b/packages/typegen/tests/cli-errors.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getFriendlyTypegenError } from "../src/cli-errors"; + +describe("getFriendlyTypegenError", () => { + it("formats FileMaker authorization denial without a stack trace", () => { + const error = new Error('Not authorized to connect to FileMaker file "Foxtail_Demo": authorization rejected'); + + expect(getFriendlyTypegenError(error)).toMatchInlineSnapshot(` + "FileMaker authorization denied. + Not authorized to connect to FileMaker file "Foxtail_Demo": authorization rejected + Open FileMaker and approve the connection request, then run typegen again." + `); + }); + + it("ignores unrelated errors", () => { + expect(getFriendlyTypegenError(new Error("Unexpected failure"))).toBeUndefined(); + }); +}); diff --git a/packages/typegen/tests/getEnvValues.test.ts b/packages/typegen/tests/getEnvValues.test.ts index 2b3347f3..cba2399c 100644 --- a/packages/typegen/tests/getEnvValues.test.ts +++ b/packages/typegen/tests/getEnvValues.test.ts @@ -17,6 +17,7 @@ describe("getEnvValues + validateEnvValues", () => { "FM_PASSWORD", "FM_MCP_BASE_URL", "FM_CONNECTED_FILE_NAME", + "FM_MCP_PERSISTENT_TOKEN", "CUSTOM_SERVER", "CUSTOM_DB", "CUSTOM_KEY", @@ -24,6 +25,7 @@ describe("getEnvValues + validateEnvValues", () => { "CUSTOM_CLARIS_PASS", "CUSTOM_HTTP_URL", "CUSTOM_HTTP_FILE", + "CUSTOM_HTTP_TOKEN", ]) { originalEnv[key] = process.env[key]; delete process.env[key]; @@ -60,6 +62,7 @@ describe("getEnvValues + validateEnvValues", () => { it("validates fmMcp mode with default env names", () => { process.env.FM_MCP_BASE_URL = "http://127.0.0.1:1365"; process.env.FM_CONNECTED_FILE_NAME = "MyFile"; + process.env.FM_MCP_PERSISTENT_TOKEN = "persistent-token"; const envValues = getEnvValues(); const result = validateEnvValues(envValues, undefined, { fmMcp: true }); @@ -69,6 +72,7 @@ describe("getEnvValues + validateEnvValues", () => { expect(result.mode).toBe("fmMcp"); expect(result.baseUrl).toBe("http://127.0.0.1:1365"); expect(result.connectedFileName).toBe("MyFile"); + expect(result.persistentToken).toBe("persistent-token"); } }); @@ -120,17 +124,23 @@ describe("getEnvValues + validateEnvValues", () => { it("uses config values over env vars for fmMcp", () => { process.env.FM_MCP_BASE_URL = "http://env-url:9999"; process.env.FM_CONNECTED_FILE_NAME = "EnvFile"; + process.env.FM_MCP_PERSISTENT_TOKEN = "env-token"; const envValues = getEnvValues(); const result = validateEnvValues(envValues, undefined, { fmMcp: true, - fmMcpConfig: { baseUrl: "http://config-url:1234", connectedFileName: "ConfigFile" }, + fmMcpConfig: { + baseUrl: "http://config-url:1234", + connectedFileName: "ConfigFile", + persistentToken: "config-token", + }, }); expect(result.success).toBe(true); if (result.success && result.mode === "fmMcp") { expect(result.baseUrl).toBe("http://config-url:1234"); expect(result.connectedFileName).toBe("ConfigFile"); + expect(result.persistentToken).toBe("config-token"); } }); @@ -142,6 +152,7 @@ describe("getEnvValues + validateEnvValues", () => { process.env.CUSTOM_CLARIS_PASS = "claris-pass"; process.env.CUSTOM_HTTP_URL = "http://127.0.0.1:1365"; process.env.CUSTOM_HTTP_FILE = "CustomFile"; + process.env.CUSTOM_HTTP_TOKEN = "CustomToken"; const envValues = getEnvValues({ server: "CUSTOM_SERVER", @@ -151,7 +162,11 @@ describe("getEnvValues + validateEnvValues", () => { clarisIdUsername: "CUSTOM_CLARIS_USER", clarisIdPassword: "CUSTOM_CLARIS_PASS", }, - fmMcp: { baseUrl: "CUSTOM_HTTP_URL", connectedFileName: "CUSTOM_HTTP_FILE" }, + fmMcp: { + baseUrl: "CUSTOM_HTTP_URL", + connectedFileName: "CUSTOM_HTTP_FILE", + persistentToken: "CUSTOM_HTTP_TOKEN", + }, }); expect(envValues.server).toBe("https://custom.example.com"); @@ -161,5 +176,6 @@ describe("getEnvValues + validateEnvValues", () => { expect(envValues.clarisIdPassword).toBe("claris-pass"); expect(envValues.fmMcpBaseUrl).toBe("http://127.0.0.1:1365"); expect(envValues.fmMcpConnectedFileName).toBe("CustomFile"); + expect(envValues.fmMcpPersistentToken).toBe("CustomToken"); }); }); diff --git a/packages/typegen/tests/typegen.test.ts b/packages/typegen/tests/typegen.test.ts index 94118371..b77168bc 100644 --- a/packages/typegen/tests/typegen.test.ts +++ b/packages/typegen/tests/typegen.test.ts @@ -619,4 +619,98 @@ describe("typegen unit tests", () => { expect(scriptParam.action).toBe("metaData"); expect(scriptParam.layouts).toBe("FmMcpLayout"); }); + + it("requests FileMaker bridge authorization after unauthorized fmMcp metadata request", async () => { + process.env.FM_MCP_BASE_URL = "http://127.0.0.1:1365"; + process.env.FM_CONNECTED_FILE_NAME = "TestFile"; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: "session_not_authorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ status: "approved" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { + messages: [{ code: "0" }], + response: mockLayoutMetadata["basic-layout"], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + envNames: undefined, + layouts: [{ layoutName: "FmMcpLayout", schemaName: "fmMcpAuthSchema" }], + path: "unit-typegen-output/fm-mcp-auth", + validator: false, + fmMcp: { + enabled: true, + sessionId: "typegen-session", + clientName: "Typegen Config Test", + clientDescription: "Typegen config authorization", + authorizationTimeoutMs: 10_000, + }, + }; + + await generateTypedClients(config, { cwd: import.meta.dirname }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][0]).toBe("http://127.0.0.1:1365/authorizeSession"); + const authorizeBody = JSON.parse(String(fetchMock.mock.calls[1][1]?.body ?? "{}")); + expect(authorizeBody).toMatchObject({ + sessionId: "typegen-session", + fileName: "TestFile", + clientName: "Typegen Config Test", + clientDescription: "Typegen config authorization", + idleTimeoutSeconds: 120, + }); + }); + + it("uses fmMcp persistent token env var as session id", async () => { + process.env.FM_MCP_BASE_URL = "http://127.0.0.1:1365"; + process.env.FM_CONNECTED_FILE_NAME = "TestFile"; + process.env.TYPEGEN_PERSISTENT_TOKEN = "registered-persistent-token"; + + const fetchMock = vi.fn( + createLayoutMetadataMock({ + FmMcpLayout: mockLayoutMetadata["basic-layout"], + }), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + envNames: { + fmMcp: { + persistentToken: "TYPEGEN_PERSISTENT_TOKEN", + }, + }, + layouts: [{ layoutName: "FmMcpLayout", schemaName: "fmMcpPersistentTokenSchema" }], + path: "unit-typegen-output/fm-mcp-persistent-token", + validator: false, + fmMcp: { + enabled: true, + }, + }; + + await generateTypedClients(config, { cwd: import.meta.dirname }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Headers; + expect(headers.get("X-ProofKit-Session")).toBe("registered-persistent-token"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/typegen/typegen.schema.json b/packages/typegen/typegen.schema.json index d786f394..dcf2e757 100644 --- a/packages/typegen/typegen.schema.json +++ b/packages/typegen/typegen.schema.json @@ -162,7 +162,7 @@ "description": "If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", "allOf": [ { - "$ref": "#/definitions/__schema20" + "$ref": "#/definitions/__schema26" } ] }, @@ -173,7 +173,7 @@ "description": "If true (default), field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", "allOf": [ { - "$ref": "#/definitions/__schema21" + "$ref": "#/definitions/__schema27" } ] }, @@ -182,7 +182,7 @@ "description": "Required array of tables to generate. Only the tables specified here will be downloaded and generated. Each table can have field-level overrides for excluding fields, renaming variables, and overriding field types.", "allOf": [ { - "$ref": "#/definitions/__schema22" + "$ref": "#/definitions/__schema28" } ] }, @@ -190,7 +190,7 @@ "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", "allOf": [ { - "$ref": "#/definitions/__schema30" + "$ref": "#/definitions/__schema36" } ] } @@ -228,6 +228,12 @@ }, "password": { "type": "string" + }, + "clarisIdUsername": { + "type": "string" + }, + "clarisIdPassword": { + "type": "string" } }, "additionalProperties": false @@ -240,6 +246,9 @@ }, "connectedFileName": { "type": "string" + }, + "persistentToken": { + "type": "string" } }, "additionalProperties": false @@ -317,7 +326,7 @@ ] }, "scriptName": { - "description": "The FM script the HTTP proxy calls to execute Data API operations. Overrides webviewerScriptName for the proxy call. Defaults to \"execute_data_api\".", + "description": "The FM script the FM MCP bridge calls to execute Data API operations. Overrides webviewerScriptName for the bridge call. Defaults to \"execute_data_api\".", "allOf": [ { "$ref": "#/definitions/__schema17" @@ -325,7 +334,7 @@ ] }, "baseUrl": { - "description": "Base URL of the local FM MCP server. Defaults to \"http://127.0.0.1:1365\". Can also be set via FM_HTTP_BASE_URL env var.", + "description": "Base URL of the local FM MCP server. Defaults to \"http://127.0.0.1:1365\". Can also be set via FM_MCP_BASE_URL env var.", "allOf": [ { "$ref": "#/definitions/__schema18" @@ -339,6 +348,54 @@ "$ref": "#/definitions/__schema19" } ] + }, + "sessionId": { + "description": "Session ID sent to the FileMaker bridge for authorization. Defaults to FM_MCP_SESSION_ID or a random ID.", + "allOf": [ + { + "$ref": "#/definitions/__schema20" + } + ] + }, + "persistentToken": { + "description": "Persistent token value to use as the FileMaker bridge session ID. If also registered in FileMaker, requests are already authorized. Prefer envNames.fmMcp.persistentToken for secrets.", + "allOf": [ + { + "$ref": "#/definitions/__schema21" + } + ] + }, + "clientName": { + "description": "Client name shown in FileMaker authorization prompts. Defaults to FM_MCP_CLIENT_NAME or \"ProofKit Typegen\".", + "allOf": [ + { + "$ref": "#/definitions/__schema22" + } + ] + }, + "clientDescription": { + "description": "Client description shown in FileMaker authorization prompts.", + "allOf": [ + { + "$ref": "#/definitions/__schema23" + } + ] + }, + "authorizationTimeoutMs": { + "description": "Timeout for FileMaker bridge authorization requests. Defaults to 125000 ms.", + "allOf": [ + { + "$ref": "#/definitions/__schema24" + } + ] + }, + "disableInteractiveAuthorization": { + "description": "If true, fail on unauthorized bridge requests instead of opening an authorization prompt.", + "allOf": [ + { + "$ref": "#/definitions/__schema25" + } + ] } }, "additionalProperties": false @@ -357,13 +414,32 @@ "type": "string" }, "__schema20": { - "type": "boolean" + "type": "string" }, "__schema21": { + "type": "string" + }, + "__schema22": { + "type": "string" + }, + "__schema23": { + "type": "string" + }, + "__schema24": { + "type": "number", + "exclusiveMinimum": 0 + }, + "__schema25": { + "type": "boolean" + }, + "__schema26": { + "type": "boolean" + }, + "__schema27": { "default": true, "type": "boolean" }, - "__schema22": { + "__schema28": { "type": "array", "items": { "type": "object", @@ -376,7 +452,7 @@ "description": "Override the generated TypeScript variable name. The original entity set name is still used for the OData path.", "allOf": [ { - "$ref": "#/definitions/__schema23" + "$ref": "#/definitions/__schema29" } ] }, @@ -384,7 +460,7 @@ "description": "Field-specific overrides as an array", "allOf": [ { - "$ref": "#/definitions/__schema24" + "$ref": "#/definitions/__schema30" } ] }, @@ -392,7 +468,7 @@ "description": "If undefined, the top-level setting will be used. If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", "allOf": [ { - "$ref": "#/definitions/__schema27" + "$ref": "#/definitions/__schema33" } ] }, @@ -400,7 +476,7 @@ "description": "If undefined, the top-level setting will be used. If true, field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", "allOf": [ { - "$ref": "#/definitions/__schema28" + "$ref": "#/definitions/__schema34" } ] }, @@ -408,7 +484,7 @@ "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", "allOf": [ { - "$ref": "#/definitions/__schema29" + "$ref": "#/definitions/__schema35" } ] } @@ -417,10 +493,10 @@ "additionalProperties": false } }, - "__schema23": { + "__schema29": { "type": "string" }, - "__schema24": { + "__schema30": { "type": "array", "items": { "type": "object", @@ -433,7 +509,7 @@ "description": "If true, this field will be excluded from generation", "allOf": [ { - "$ref": "#/definitions/__schema25" + "$ref": "#/definitions/__schema31" } ] }, @@ -441,7 +517,7 @@ "description": "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list", "allOf": [ { - "$ref": "#/definitions/__schema26" + "$ref": "#/definitions/__schema32" } ] } @@ -450,10 +526,10 @@ "additionalProperties": false } }, - "__schema25": { + "__schema31": { "type": "boolean" }, - "__schema26": { + "__schema32": { "type": "string", "enum": [ "text", @@ -465,16 +541,16 @@ "list" ] }, - "__schema27": { + "__schema33": { "type": "boolean" }, - "__schema28": { + "__schema34": { "type": "boolean" }, - "__schema29": { + "__schema35": { "type": "boolean" }, - "__schema30": { + "__schema36": { "default": true, "type": "boolean" } diff --git a/packages/typegen/web/src/components/ConfigEditor.tsx b/packages/typegen/web/src/components/ConfigEditor.tsx index 8e2a51a4..ae0a2674 100644 --- a/packages/typegen/web/src/components/ConfigEditor.tsx +++ b/packages/typegen/web/src/components/ConfigEditor.tsx @@ -24,6 +24,7 @@ export function ConfigEditor({ index, onRemove }: ConfigEditorProps) { const { control, formState: { errors }, + getValues, setValue, watch, } = useFormContext<{ config: SingleConfig[] }>(); @@ -43,6 +44,10 @@ export function ConfigEditor({ index, onRemove }: ConfigEditorProps) { control, name: `config.${index}.webviewerScriptName` as const, }); + const fmMcp = useWatch({ + control, + name: `config.${index}.fmMcp` as const, + }); const [usingWebviewer, setUsingWebviewer] = useState(!!webviewerScriptName); const [showRemoveDialog, setShowRemoveDialog] = useState(false); const { runTypegen, isRunning } = useRunTypegen(); @@ -61,6 +66,18 @@ export function ConfigEditor({ index, onRemove }: ConfigEditorProps) { } }; + const handleFmMcpToggle = (checked: boolean) => { + const existingFmMcp = getValues(`config.${index}.fmMcp` as const); + setValue( + `config.${index}.fmMcp` as const, + { ...existingFmMcp, enabled: checked }, + { + shouldDirty: true, + shouldTouch: true, + }, + ); + }; + const handleRunTypegen = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -330,6 +347,65 @@ export function ConfigEditor({ index, onRemove }: ConfigEditorProps) { )} )} + + {configType === "fmdapi" && ( +
+ + + {!!fmMcp && fmMcp.enabled !== false && ( +
+ ( + + Bridge URL + + + + + + )} + /> + + ( + + + File Name + + + + + + + )} + /> + + ( + + Script Name + + + + + + )} + /> +
+ )} +
+ )} diff --git a/packages/typegen/web/src/components/EnvVarDialog.tsx b/packages/typegen/web/src/components/EnvVarDialog.tsx index 34a858d9..2d7ae5c5 100644 --- a/packages/typegen/web/src/components/EnvVarDialog.tsx +++ b/packages/typegen/web/src/components/EnvVarDialog.tsx @@ -53,6 +53,11 @@ export function EnvVarDialog({ index }: EnvVarDialogProps) { control, name: `config.${index}.envNames.auth` as const, }); + const config = useWatch({ + control, + name: `config.${index}` as const, + }); + const isFmMcp = config?.type === "fmdapi" && !!config.fmMcp && config.fmMcp.enabled !== false; // Determine the actual env names to use (from form or defaults) const apiKeyEnvName = @@ -162,16 +167,17 @@ export function EnvVarDialog({ index }: EnvVarDialogProps) { const hasAuth = hasApiKeyAuth || hasClarisIdAuth || hasUsernamePasswordAuth; const hasUndefinedValues = - (!serverLoading && (serverValue === undefined || serverValue === null || serverValue === "")) || - (!dbLoading && (dbValue === undefined || dbValue === null || dbValue === "")) || - !( - apiKeyLoading || - clarisIdUsernameLoading || - clarisIdPasswordLoading || - usernameLoading || - passwordLoading || - hasAuth - ); + !isFmMcp && + ((!serverLoading && (serverValue === undefined || serverValue === null || serverValue === "")) || + (!dbLoading && (dbValue === undefined || dbValue === null || dbValue === "")) || + !( + apiKeyLoading || + clarisIdUsernameLoading || + clarisIdPasswordLoading || + usernameLoading || + passwordLoading || + hasAuth + )); // Initialize auth fields if not already set useEffect(() => { @@ -201,6 +207,8 @@ export function EnvVarDialog({ index }: EnvVarDialogProps) { authTypeLabel = "API Key"; } else if (testData?.authType === "clarisId") { authTypeLabel = "Claris ID"; + } else if (testData?.authType === "fmMcp") { + authTypeLabel = "FileMaker Bridge"; } return ( @@ -403,7 +411,7 @@ export function EnvVarDialog({ index }: EnvVarDialogProps) { {errorDetails.details?.missing && (
-
Missing environment variables:
+
Missing connection values:
    {errorDetails.details.missing.server && (
  • Server ({errorDetails.suspectedField === "server" && "⚠️"})
  • @@ -420,6 +428,7 @@ export function EnvVarDialog({ index }: EnvVarDialogProps) { {errorDetails.details.missing.clarisIdPassword && (
  • Claris ID Password ({errorDetails.suspectedField === "auth" && "⚠️"})
  • )} + {errorDetails.details.missing.connectedFileName &&
  • Connected File Name
  • }
)} diff --git a/packages/typegen/web/src/hooks/useTestConnection.ts b/packages/typegen/web/src/hooks/useTestConnection.ts index aff98a19..e7686f83 100644 --- a/packages/typegen/web/src/hooks/useTestConnection.ts +++ b/packages/typegen/web/src/hooks/useTestConnection.ts @@ -24,7 +24,7 @@ export interface TestConnectionResult { ok: boolean; server?: string; db?: string; - authType?: "apiKey" | "username" | "clarisId"; + authType?: "apiKey" | "username" | "clarisId" | "fmMcp"; error?: string; statusCode?: number; details?: { @@ -34,7 +34,9 @@ export interface TestConnectionResult { auth?: boolean; password?: boolean; clarisIdPassword?: boolean; + connectedFileName?: boolean; }; + connectedFiles?: string[]; }; kind?: "missing_env" | "adapter_error" | "connection_error" | "unknown"; suspectedField?: "server" | "db" | "auth"; @@ -59,6 +61,8 @@ export function useTestConnection(configIndex: number, options?: { enabled?: boo server: envNames.server, db: envNames.db, auth: envNames.auth, + fmMcp: envNames.fmMcp, + configFmMcp: config?.type === "fmdapi" ? config.fmMcp : undefined, }) : "";