@formio/mcp
Advanced tools
| export interface BrowserEnvironment { | ||
| env: Record<string, string | undefined>; | ||
| hasContainerMarker: boolean; | ||
| } | ||
| export interface BrowserAvailabilityOptions { | ||
| publishedLoginEndpoint?: boolean; | ||
| } | ||
| export declare function browserlessReason({ env, hasContainerMarker }: BrowserEnvironment, { publishedLoginEndpoint }?: BrowserAvailabilityOptions): string | null; | ||
| export declare function currentBrowserEnvironment(): BrowserEnvironment; |
| import fs from 'fs'; | ||
| const CONTAINER_MARKER = '/.dockerenv'; | ||
| // A devcontainer, a Codespace, or any workspace opened over VS Code Remote: the | ||
| // editor forwards the ports its workspace listens on automatically, and the | ||
| // browser is on the user's own machine. Auto-forwarding sets neither | ||
| // FORMIO_AUTH_HOST nor FORMIO_AUTH_PORT, so without these markers the container | ||
| // check blocked precisely the users the published-endpoint exemption was written | ||
| // to spare — and blocked them before the port was bound, so the login URL they | ||
| // could have opened was never printed. | ||
| const EDITOR_FORWARDED_MARKERS = ['CODESPACES', 'REMOTE_CONTAINERS', 'VSCODE_IPC_HOOK_CLI']; | ||
| function hasDisplay(env) { | ||
| return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); | ||
| } | ||
| function hasEditorForwardedPorts(env) { | ||
| return EDITOR_FORWARDED_MARKERS.some((marker) => Boolean(env[marker])); | ||
| } | ||
| // Returns a human-readable reason when the host cannot present a browser to the | ||
| // user, or null when it can. Deliberately biased towards "a browser exists": | ||
| // a false negative costs one login timeout, while a false positive would block | ||
| // a desktop user who has no API key. | ||
| export function browserlessReason({ env, hasContainerMarker }, { publishedLoginEndpoint = false } = {}) { | ||
| if (env.CI && env.CI !== 'false' && env.CI !== '0') { | ||
| return 'this looks like a CI runner (CI is set)'; | ||
| } | ||
| // Everything below this line asks one question: can THIS host open a browser? | ||
| // Publishing the login endpoint — or running under an editor that forwards | ||
| // ports for you — answers a different and sufficient one: the browser is on | ||
| // the user's machine and can reach us, so that settles all of them at once. A | ||
| // container, a remote shell, and a host with no display server are the same | ||
| // situation once the page is reachable, and this is the remedy the error text | ||
| // itself recommends. CI is the exception above: nobody is watching a runner, | ||
| // whatever it publishes. | ||
| if (publishedLoginEndpoint || hasEditorForwardedPorts(env)) { | ||
| return null; | ||
| } | ||
| // Displayless, like the remote-shell check below it. A container is evidence | ||
| // that the browser is probably elsewhere, never that it certainly is: a dev | ||
| // container started with the host's X socket shared in (docker run -e DISPLAY=:0 | ||
| // -v /tmp/.X11-unix:/tmp/.X11-unix) opens the user's own browser and carries | ||
| // none of the editor-forwarding markers above. Answering here before the display | ||
| // was consulted failed that login outright — and failed it before the port was | ||
| // bound, so the login URL the caller prints never reached the user either. | ||
| if ((hasContainerMarker || env.container) && !hasDisplay(env)) { | ||
| return 'this looks like a container with no display'; | ||
| } | ||
| if ((env.SSH_CONNECTION || env.SSH_TTY) && !hasDisplay(env)) { | ||
| return 'this looks like a remote shell (SSH with no display)'; | ||
| } | ||
| // A missing DISPLAY is deliberately NOT a reason on its own. An agent started | ||
| // from a systemd user unit, or in a tmux session that predates the graphical | ||
| // login, inherits no display variable and still has a browser one loopback | ||
| // away — and failing here happens before the port is bound, so the login URL | ||
| // this module's caller always prints never reaches the user. The environments | ||
| // where the browser really is elsewhere each have their own signal above. | ||
| return null; | ||
| } | ||
| export function currentBrowserEnvironment() { | ||
| return { | ||
| env: process.env, | ||
| hasContainerMarker: fs.existsSync(CONTAINER_MARKER), | ||
| }; | ||
| } |
| export interface ProjectCommandOptions { | ||
| cacheDir?: string; | ||
| env?: NodeJS.ProcessEnv; | ||
| cwd?: string; | ||
| } | ||
| export interface ProjectCommandResult { | ||
| exitCode: number; | ||
| stdout: string; | ||
| stderr: string; | ||
| } | ||
| export declare const EXIT_OK = 0; | ||
| export declare const EXIT_NOT_CONFIGURED = 1; | ||
| export declare const EXIT_FAILED = 2; | ||
| export declare function isProjectCommand(args: string[]): boolean; | ||
| export declare function runProjectCommand(args: string[], options?: ProjectCommandOptions): ProjectCommandResult; |
| import path from 'path'; | ||
| import { DEFAULT_BASE_URL, normalizeHttpUrl, readHttpUrlEnv } from '../config.js'; | ||
| import { ProjectMapUnreadableError, readProjectEntry, writeProjectEntry } from '../project-map.js'; | ||
| import { resolveProject } from '../project-resolver.js'; | ||
| // Three outcomes, three codes. "Nothing is mapped for this directory" is an | ||
| // answer to the question asked; "this command could not run" is not, and a | ||
| // caller that cannot tell them apart interviews the user and then calls | ||
| // project_set, which fails again for the same unreported reason. Documented for | ||
| // the skills, which branch on the code rather than on a substring of the | ||
| // message. | ||
| export const EXIT_OK = 0; | ||
| export const EXIT_NOT_CONFIGURED = 1; | ||
| export const EXIT_FAILED = 2; | ||
| const USAGE = [ | ||
| 'Usage:', | ||
| ' formio-mcp project set --project-url <url> [--base-url <url>] [--cwd <absolute path>]', | ||
| ' formio-mcp project get [--cwd <absolute path>]', | ||
| ].join('\n'); | ||
| export function isProjectCommand(args) { | ||
| return args[0] === 'project'; | ||
| } | ||
| // Only `--flag value` pairs are recognized; anything else is a usage error | ||
| // rather than a silently ignored token. | ||
| function parseFlags(args) { | ||
| return args.reduce((flags, token, index) => { | ||
| if (!token.startsWith('--')) { | ||
| const flagBefore = args[index - 1]; | ||
| if (flagBefore?.startsWith('--')) { | ||
| return flags; | ||
| } | ||
| throw new Error(`Unexpected argument: ${token}`); | ||
| } | ||
| const value = args[index + 1]; | ||
| if (value === undefined || value.startsWith('--')) { | ||
| throw new Error(`${token} requires a value.`); | ||
| } | ||
| return { ...flags, [token.slice(2)]: value }; | ||
| }, {}); | ||
| } | ||
| function resolveCwd(flag, fallback) { | ||
| const value = flag ?? fallback; | ||
| if (!path.isAbsolute(value)) { | ||
| throw new Error(`--cwd must be an absolute path (received: ${value}).`); | ||
| } | ||
| return value; | ||
| } | ||
| // Notes travel in the result like everything else. Writing them straight to | ||
| // process.stderr would leave the one part of this command's outcome that no | ||
| // caller and no test can see — and this module injects env, cwd and cacheDir | ||
| // precisely so every outcome is observable from the returned object. | ||
| function ok(stdout, notes = []) { | ||
| return { exitCode: EXIT_OK, stdout, stderr: notes.join('\n') }; | ||
| } | ||
| // The command ran and the answer is "nothing here" — the one non-zero outcome a | ||
| // caller should respond to by interviewing the user. | ||
| function notConfigured(stderr) { | ||
| return { exitCode: EXIT_NOT_CONFIGURED, stdout: '', stderr }; | ||
| } | ||
| // The command could not answer: a usage error, a malformed URL, a relative | ||
| // --cwd, an unreadable map. Interviewing on this hides the cause and repeats the | ||
| // failure through project_set. | ||
| function fail(stderr) { | ||
| return { exitCode: EXIT_FAILED, stdout: '', stderr }; | ||
| } | ||
| function runSet(flags, context) { | ||
| if (!flags['project-url']) { | ||
| return fail(`--project-url is required.\n\n${USAGE}`); | ||
| } | ||
| const projectUrl = normalizeHttpUrl(flags['project-url'], 'projectUrl'); | ||
| const cwd = resolveCwd(flags.cwd, context.cwd); | ||
| // Same precedence as the project_set tool, and deliberately identical: the | ||
| // flag, then the base URL already mapped for this directory, then the | ||
| // environment. The mapping outranks the environment because it is the more | ||
| // specific answer for this directory and the one the server honours at resolve | ||
| // time; a re-set without --base-url must not revert a self-hosted directory to | ||
| // whatever global the shell happens to export. | ||
| // Falsy, not nullish, at every link: an empty FORMIO_BASE_URL is a prompt the | ||
| // user cleared, not a deployment. A nullish chain would stop there, hand the | ||
| // rewrite an empty string, and drop the mapped base URL just the same. | ||
| // | ||
| // The environment link is read through readHttpUrlEnv, which drops an | ||
| // unusable value instead of throwing: this command runs in whatever shell the | ||
| // agent inherited, and a FORMIO_BASE_URL exported from an unexpanded manifest | ||
| // variable would otherwise fail the very invocation formio-mcp-setup runs — | ||
| // for a user who supplied no base URL of their own and cannot see why. The | ||
| // flag stays strict, because that one is the user's own typing. | ||
| // | ||
| // The mapped link is read the same tolerant way, and for a sharper reason: this | ||
| // rewrite is the documented repair for a directory whose mapping the resolver | ||
| // now refuses, so failing on the stored value made the repair report the very | ||
| // error it was run to clear — and named it "baseUrl", as though the caller had | ||
| // typed it. | ||
| const notes = []; | ||
| const onIgnored = (message) => notes.push(message); | ||
| const mappedBaseUrl = readHttpUrlEnv({ | ||
| raw: readProjectEntry(cwd, context.cacheDir)?.env.FORMIO_BASE_URL, | ||
| name: `FORMIO_BASE_URL mapped for ${cwd}`, | ||
| onIgnored, | ||
| }); | ||
| const declaredBaseUrl = flags['base-url'] || | ||
| mappedBaseUrl || | ||
| readHttpUrlEnv({ | ||
| raw: context.env.FORMIO_BASE_URL, | ||
| name: 'FORMIO_BASE_URL', | ||
| onIgnored, | ||
| }); | ||
| const baseUrl = declaredBaseUrl ? normalizeHttpUrl(declaredBaseUrl, 'baseUrl') : undefined; | ||
| writeProjectEntry(cwd, { | ||
| FORMIO_PROJECT_URL: projectUrl, | ||
| ...(baseUrl && { FORMIO_BASE_URL: baseUrl }), | ||
| }, context.cacheDir); | ||
| return ok([ | ||
| `Project set for ${cwd}`, | ||
| `Project URL: ${projectUrl}`, | ||
| ...(baseUrl ? [`Base URL: ${baseUrl}`] : []), | ||
| ].join('\n'), notes); | ||
| } | ||
| function runGet(flags, context) { | ||
| const cwd = resolveCwd(flags.cwd, context.cwd); | ||
| // Read exactly as getConfig reads it, or this command answers a question about | ||
| // a server that does not exist: the server drops an unusable FORMIO_PROJECT_URL | ||
| // and resolves from the mapping, so printing the literal here and naming the | ||
| // environment as the winning source contradicts what the next tool call does. | ||
| // The whole point of `project get` is to report what resolves and which source | ||
| // won, so the two readings must not diverge. | ||
| const notes = []; | ||
| const onIgnored = (message) => notes.push(message); | ||
| const envProjectUrl = readHttpUrlEnv({ | ||
| raw: context.env.FORMIO_PROJECT_URL, | ||
| name: 'FORMIO_PROJECT_URL', | ||
| onIgnored, | ||
| }); | ||
| const envBaseUrl = readHttpUrlEnv({ | ||
| raw: context.env.FORMIO_BASE_URL, | ||
| name: 'FORMIO_BASE_URL', | ||
| onIgnored, | ||
| }); | ||
| // Carried through even though nothing resolves from it: a configured default is | ||
| // a suggestion, and the caller most likely to have one (a desktop host that | ||
| // prompted for it) is exactly the caller who should be told the value exists | ||
| // rather than that nothing is configured. | ||
| const defaultProjectUrl = readHttpUrlEnv({ | ||
| raw: context.env.FORMIO_DEFAULT_PROJECT_URL, | ||
| name: 'FORMIO_DEFAULT_PROJECT_URL', | ||
| onIgnored, | ||
| }); | ||
| const resolution = resolveOrNull(cwd, context, { | ||
| baseConfig: { baseUrl: envBaseUrl, projectUrl: envProjectUrl, defaultProjectUrl }, | ||
| onNote: (message) => notes.push(message), | ||
| }); | ||
| if (!resolution) { | ||
| // The resolver's own error carries the offer, but not this command's shape: | ||
| // it names the project_set tool, and a shell caller has the bin instead. Same | ||
| // suggestion, in the vocabulary of the caller who will act on it. | ||
| const offer = defaultProjectUrl | ||
| ? ` A default is configured (FORMIO_DEFAULT_PROJECT_URL): ${defaultProjectUrl} — confirm it with the user before persisting it.` | ||
| : ''; | ||
| return notConfigured(`No Form.io project is configured for ${cwd}. Run: formio-mcp project set --project-url <url> --cwd ${cwd}${offer}`); | ||
| } | ||
| const { config: resolved, sources } = resolution; | ||
| // Reports which side of the resolver's precedence supplied each URL. Without | ||
| // it, an environment value silently overriding a mapping that looks correct on | ||
| // disk is undiagnosable. | ||
| // | ||
| // Two answers, not one: the base URL resolves on its own terms — a pinned | ||
| // project can be paired with a base URL that came from the mapping — so a | ||
| // single "Source:" naming only where the project came from misattributes the | ||
| // other line. The provenance is reported by the resolver rather than inferred | ||
| // by comparing values here: an inferred answer credits the mapping whenever it | ||
| // happens to hold the same string that won, and https://api.form.io is the | ||
| // value most likely to be on both sides. | ||
| const describe = (source, variable) => { | ||
| if (source === 'environment') { | ||
| return `this shell’s environment (${variable}), which takes precedence over the mapping`; | ||
| } | ||
| if (source === 'mapping') { | ||
| return `the working-directory mapping for ${cwd}`; | ||
| } | ||
| return `the default (${DEFAULT_BASE_URL}), because neither the environment nor the mapping supplied one for this project`; | ||
| }; | ||
| const projectSource = describe(sources.projectUrl, 'FORMIO_PROJECT_URL'); | ||
| const baseSource = describe(sources.baseUrl, 'FORMIO_BASE_URL'); | ||
| // Collapsed on the rendered clauses, not on the source enums: two values can | ||
| // both come from `environment` and still come from *different variables*, and | ||
| // printing the project's clause alone then credits the base URL to | ||
| // FORMIO_PROJECT_URL — the attribution DEPLOYMENT.md tells the agent to branch | ||
| // on. Identical strings are the only case where one clause says everything. | ||
| const source = projectSource === baseSource | ||
| ? projectSource | ||
| : `project URL from ${projectSource}; base URL from ${baseSource}`; | ||
| // This command runs in the caller's shell, not in the MCP server's process. A | ||
| // plugin- or bundle-launched server carries its own env block, so what it | ||
| // resolves can differ from what is printed here — and the difference is | ||
| // invisible from this side. Say so rather than let the output be read as the | ||
| // server's answer. Kept whenever the mapping supplied any part of the answer, | ||
| // including a base URL under a pinned project. | ||
| const caveat = [sources.projectUrl, sources.baseUrl].includes('mapping') | ||
| ? [ | ||
| `Note: the MCP server’s own environment is not visible from this shell. A FORMIO_PROJECT_URL set there takes precedence over this mapping, and a FORMIO_BASE_URL set there applies only where no base URL is mapped.`, | ||
| ] | ||
| : []; | ||
| return ok([ | ||
| `Project URL: ${resolved.projectUrl}`, | ||
| `Base URL: ${resolved.baseUrl}`, | ||
| `Source: ${source}`, | ||
| ...caveat, | ||
| ].join('\n'), notes); | ||
| } | ||
| // The resolver signals "nothing configured" by throwing, which is the right | ||
| // shape for a tool handler and the wrong one for a reporting command. An | ||
| // unreadable map is a different answer than an unmapped directory, though: | ||
| // reporting it as "nothing configured" sends the caller to `project set`, whose | ||
| // rewrite is what destroys the other mappings. It travels to the caller instead, | ||
| // where runProjectCommand's catch turns it into EXIT_FAILED — a code the caller | ||
| // can act on, rather than the EXIT_NOT_CONFIGURED an unmapped directory returns. | ||
| function resolveOrNull(cwd, context, { baseConfig, onNote }) { | ||
| try { | ||
| return resolveProject(cwd, baseConfig, { cacheDir: context.cacheDir, onNote }); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof ProjectMapUnreadableError) { | ||
| throw error; | ||
| } | ||
| return null; | ||
| } | ||
| } | ||
| export function runProjectCommand(args, options = {}) { | ||
| const context = { | ||
| env: options.env ?? process.env, | ||
| cwd: options.cwd ?? process.cwd(), | ||
| cacheDir: options.cacheDir, | ||
| }; | ||
| const subcommand = args[1]; | ||
| try { | ||
| const flags = parseFlags(args.slice(2)); | ||
| if (subcommand === 'set') { | ||
| return runSet(flags, context); | ||
| } | ||
| if (subcommand === 'get') { | ||
| return runGet(flags, context); | ||
| } | ||
| return fail(`Unknown project subcommand: ${subcommand ?? '(none)'}\n\n${USAGE}`); | ||
| } | ||
| catch (error) { | ||
| // Everything that throws is a failure to answer, never an answer of | ||
| // "nothing is mapped": an unreadable map, a relative --cwd, a malformed | ||
| // stored URL. EXIT_FAILED keeps them out of the interview path. | ||
| return fail(error instanceof Error ? error.message : String(error)); | ||
| } | ||
| } |
+24
-0
| import express from 'express'; | ||
| import { exec } from 'child_process'; | ||
| import { browserlessReason, currentBrowserEnvironment } from './browser-availability.js'; | ||
| import { formioRawFetch } from './formio-client.js'; | ||
@@ -112,3 +113,26 @@ const DEFAULT_AUTH_HOST = '127.0.0.1'; | ||
| } | ||
| // Checked before anything is bound or launched: on a host with no browser the | ||
| // login can never complete, and the whole login timeout would be spent | ||
| // discovering that. | ||
| function assertBrowserAvailable(config) { | ||
| if (config.forceBrowser) { | ||
| return; | ||
| } | ||
| // Configuring both a host and a port is the user saying the login page is | ||
| // reachable from their machine. Ignoring that would make the remedy this very | ||
| // error recommends return the identical error — and would newly block | ||
| // devcontainer and Codespaces users whose ports are forwarded. | ||
| const publishedLoginEndpoint = Boolean(config.authHost && config.authPort); | ||
| const reason = browserlessReason(currentBrowserEnvironment(), { publishedLoginEndpoint }); | ||
| if (!reason) { | ||
| return; | ||
| } | ||
| throw new Error(`Cannot complete the Form.io browser login: ${reason}. ` + | ||
| `Set FORMIO_API_KEY to authenticate without a browser. ` + | ||
| `If the host running your browser can reach this machine, set both FORMIO_AUTH_HOST=0.0.0.0 and ` + | ||
| `FORMIO_AUTH_PORT to a published port so the login page is reachable from it. ` + | ||
| `Set FORMIO_FORCE_BROWSER=1 to attempt the browser login anyway.`); | ||
| } | ||
| export async function authenticate(config, options) { | ||
| assertBrowserAvailable(config); | ||
| const app = express(); | ||
@@ -115,0 +139,0 @@ app.use(express.json()); |
+11
-0
@@ -0,4 +1,8 @@ | ||
| export declare const DEFAULT_BASE_URL = "https://api.form.io"; | ||
| export declare function stripTrailingSlashes(url: string): string; | ||
| export declare function normalizeHttpUrl(input: string, label: string): string; | ||
| export interface FormioConfig { | ||
| baseUrl?: string; | ||
| projectUrl?: string; | ||
| defaultProjectUrl?: string; | ||
| apiKey?: string; | ||
@@ -10,2 +14,3 @@ loginFormUrl?: string; | ||
| authTimeoutMs?: number; | ||
| forceBrowser?: boolean; | ||
| } | ||
@@ -17,1 +22,7 @@ export interface ResolvedFormioConfig extends FormioConfig { | ||
| export declare function getConfig(): FormioConfig; | ||
| export interface ReadHttpUrlEnvOptions { | ||
| raw: string | undefined; | ||
| name: string; | ||
| onIgnored?: (message: string) => void; | ||
| } | ||
| export declare function readHttpUrlEnv({ raw, name, onIgnored, }: ReadHttpUrlEnvOptions): string | undefined; |
+78
-22
@@ -0,28 +1,70 @@ | ||
| export const DEFAULT_BASE_URL = 'https://api.form.io'; | ||
| // Form.io URLs are compared and concatenated in several places, so they are | ||
| // stored without a trailing slash wherever they enter the process. | ||
| export function stripTrailingSlashes(url) { | ||
| return url.replace(/\/+$/, ''); | ||
| } | ||
| // Shared by the project_set tool and the bin's project command, so one | ||
| // definition of "a usable Form.io URL" serves both entry points. | ||
| // | ||
| // What comes back is what new URL() made of the input, never the raw input. | ||
| // Everything downstream compares these strings — the pinned project against the | ||
| // mapped one, the token cache against its key — and concatenates them into | ||
| // request URLs, so anything the parser considers insignificant has to be gone by | ||
| // then. Whitespace around a pasted URL passes validation but breaks fetch; | ||
| // host case is significant to string equality and to no one else, so | ||
| // https://Examples.form.io and https://examples.form.io must not be two | ||
| // deployments, two cache entries, or two projects. The parser normalizes both. | ||
| export function normalizeHttpUrl(input, label) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(input.trim()); | ||
| } | ||
| catch { | ||
| throw new Error(`${label} must be a valid URL, got: ${input}`); | ||
| } | ||
| if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | ||
| throw new Error(`${label} must use http or https, got: ${parsed.protocol}`); | ||
| } | ||
| return stripTrailingSlashes(parsed.href); | ||
| } | ||
| // One behavior for every agent: no environment variable switches the defaults, | ||
| // because a server that reads its own launch mode cannot be packaged for hosts | ||
| // that have no way to set it. | ||
| export function getConfig() { | ||
| const apiKey = process.env.FORMIO_API_KEY; | ||
| const loginFormUrl = process.env.FORMIO_LOGIN_FORM; | ||
| // Standalone-use fallback: when the server is launched outside the plugin | ||
| // (e.g. via .mcp.json), FORMIO_PROJECT_URL lets users skip project_set | ||
| // entirely. Plugin context leaves this unset — the SessionStart hook | ||
| // drives per-cwd project_set instead. | ||
| const pluginContext = process.env.FORMIO_PLUGIN_CONTEXT === '1'; | ||
| // Plugin always collects FORMIO_BASE_URL via user-config, so require it | ||
| // there. Standalone falls back to the hosted cloud default. | ||
| const baseUrl = pluginContext | ||
| ? process.env.FORMIO_BASE_URL | ||
| : process.env.FORMIO_BASE_URL || 'https://api.form.io'; | ||
| if (!baseUrl) { | ||
| throw new Error('FORMIO_BASE_URL is required'); | ||
| } | ||
| // standalone mcp server (outside of claude plugin) needs to set project url from process.env | ||
| // Validated for the same reason the suggested project is: both plugin | ||
| // manifests set this from a host variable, and an unsubstituted | ||
| // "${FORMIO_BASE_URL}" is truthy. Taken raw it keys the token cache and builds | ||
| // the portal-login URL, surfacing much later as an opaque "Failed to parse | ||
| // URL" out of fetch. | ||
| // | ||
| // Deliberately not required here. Clients and directory crawlers launch the | ||
| // server with no configuration to read tools/list, so failing at startup made | ||
| // it look like a server with no tools. resolveProjectConfig raises the error | ||
| // instead, at the point the project URL is actually needed and with guidance | ||
| // the caller can act on. | ||
| const projectUrl = pluginContext ? null : process.env.FORMIO_PROJECT_URL; | ||
| // Left undefined when the environment supplies nothing usable, rather than | ||
| // defaulted here. resolveProjectConfig applies DEFAULT_BASE_URL last, so a | ||
| // deployment mapped for the directory still outranks silence from the | ||
| // environment — which a pre-filled default made indistinguishable from an | ||
| // explicit FORMIO_BASE_URL=https://api.form.io. | ||
| const baseUrl = readHttpUrlEnv({ raw: process.env.FORMIO_BASE_URL, name: 'FORMIO_BASE_URL' }); | ||
| // Deliberately optional. Clients and directory crawlers launch the server with | ||
| // no configuration to read tools/list, so failing at startup made it look like | ||
| // a server with no tools. resolveProjectConfig raises the error instead, at | ||
| // the point the project URL is actually needed and with guidance the caller | ||
| // can act on. | ||
| // | ||
| // Anything unusable — empty because the user cleared an optional prompt, or a | ||
| // literal the client never substituted — is dropped rather than kept, because | ||
| // this field PINS the server: kept, it would resolve to nothing on every call | ||
| // and no project_set mapping could redirect it. | ||
| const projectUrl = readHttpUrlEnv({ | ||
| raw: process.env.FORMIO_PROJECT_URL, | ||
| name: 'FORMIO_PROJECT_URL', | ||
| }); | ||
| return { | ||
| baseUrl: baseUrl.replace(/\/+$/, ''), | ||
| projectUrl: projectUrl?.replace(/\/+$/, ''), | ||
| baseUrl, | ||
| projectUrl, | ||
| defaultProjectUrl: readHttpUrlEnv({ | ||
| raw: process.env.FORMIO_DEFAULT_PROJECT_URL, | ||
| name: 'FORMIO_DEFAULT_PROJECT_URL', | ||
| }), | ||
| apiKey: apiKey || undefined, | ||
@@ -34,4 +76,18 @@ loginFormUrl: loginFormUrl || undefined, | ||
| authTimeoutMs: toMilliseconds(parsePositiveInt(process.env.FORMIO_AUTH_TIMEOUT)), | ||
| forceBrowser: process.env.FORMIO_FORCE_BROWSER === '1', | ||
| }; | ||
| } | ||
| export function readHttpUrlEnv({ raw, name, onIgnored = (message) => process.stderr.write(`${message}\n`), }) { | ||
| if (!raw) { | ||
| return undefined; | ||
| } | ||
| try { | ||
| return normalizeHttpUrl(raw, name); | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| onIgnored(`Ignoring ${name}: ${message}`); | ||
| return undefined; | ||
| } | ||
| } | ||
| function parsePositiveInt(raw) { | ||
@@ -38,0 +94,0 @@ if (!raw) { |
| export interface ProjectEntry { | ||
| env: Record<string, string>; | ||
| } | ||
| export declare class ProjectMapUnreadableError extends Error { | ||
| constructor(filePath: string, cause: unknown); | ||
| } | ||
| export declare function projectMapPath(cacheDir?: string): string; | ||
| export declare function readProjectEntry(cwd: string, cacheDir?: string): ProjectEntry | null; | ||
| export declare function writeProjectEntry(cwd: string, env: Record<string, string>, cacheDir?: string): void; |
+86
-6
@@ -6,25 +6,105 @@ import fs from 'fs'; | ||
| const PROJECTS_FILE = 'projects.json'; | ||
| // A map that exists but cannot be read is not an empty map. Reporting it as one | ||
| // made every directory look unmapped, and the documented recovery — interview, | ||
| // then project_set — wrote a fresh single-entry file over every other mapping. | ||
| // Callers get a distinguishable failure so the file survives for repair. | ||
| export class ProjectMapUnreadableError extends Error { | ||
| constructor(filePath, cause) { | ||
| super(`Cannot read the Form.io project map at ${filePath}: ${cause instanceof Error ? cause.message : String(cause)}. ` + | ||
| `Repair or delete the file, then map this directory again with project_set.`); | ||
| this.name = 'ProjectMapUnreadableError'; | ||
| } | ||
| } | ||
| function describe(value) { | ||
| if (value === null) { | ||
| return 'null'; | ||
| } | ||
| return Array.isArray(value) ? 'an array' : `a ${typeof value}`; | ||
| } | ||
| // Exported so a caller that rejects an entry's CONTENTS can name the same file | ||
| // this module names. The URL-shape rules live in the resolver rather than here: | ||
| // writeProjectEntry validates the entry it is about to overwrite, and that | ||
| // rewrite is the documented repair for a mapping holding an unusable URL — so a | ||
| // check that fails the read must not also fail the fix. | ||
| export function projectMapPath(cacheDir = DEFAULT_CACHE_DIR) { | ||
| return path.join(cacheDir, PROJECTS_FILE); | ||
| } | ||
| function readMap(cacheDir) { | ||
| const filePath = path.join(cacheDir, PROJECTS_FILE); | ||
| const filePath = projectMapPath(cacheDir); | ||
| let raw; | ||
| try { | ||
| return JSON.parse(fs.readFileSync(filePath, 'utf-8')); | ||
| raw = fs.readFileSync(filePath, 'utf-8'); | ||
| } | ||
| catch { | ||
| return {}; | ||
| catch (error) { | ||
| // No file yet is the ordinary first-run state; anything else (a permission | ||
| // error, an unreadable device) is a real failure and must not read as empty. | ||
| if (error.code === 'ENOENT') { | ||
| return {}; | ||
| } | ||
| throw new ProjectMapUnreadableError(filePath, error); | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } | ||
| catch (error) { | ||
| throw new ProjectMapUnreadableError(filePath, error); | ||
| } | ||
| // Valid JSON of the wrong shape is unreadable for the same reason a syntax | ||
| // error is: nothing can be keyed off it. `null` would throw a bare TypeError | ||
| // that callers catching only this error swallow into "no project configured", | ||
| // and an array or a scalar reads as unmapped and is then written over. | ||
| if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { | ||
| throw new ProjectMapUnreadableError(filePath, new Error(`expected an object mapping directories to entries, found ${describe(parsed)}`)); | ||
| } | ||
| return parsed; | ||
| } | ||
| function writeMap(cacheDir, data) { | ||
| fs.mkdirSync(cacheDir, { recursive: true }); | ||
| fs.writeFileSync(path.join(cacheDir, PROJECTS_FILE), JSON.stringify(data), { | ||
| fs.writeFileSync(projectMapPath(cacheDir), JSON.stringify(data), { | ||
| mode: 0o600, | ||
| }); | ||
| } | ||
| // Per entry, not per file. The top-level shape check above says the file is a | ||
| // map; it says nothing about what a directory maps to, and every caller then | ||
| // reaches straight for `entry.env.FORMIO_BASE_URL`. A hand-edited entry that is a | ||
| // string, or an object with no `env`, used to surface as a bare TypeError | ||
| // reported as a generic failure — the exact outcome ProjectMapUnreadableError | ||
| // replaced for the file as a whole. Checked lazily, for the one directory being | ||
| // asked about: the map is shared, so a malformed entry for someone else's | ||
| // workspace must not fail this one's lookup. | ||
| function validateEntry(cacheDir, cwd, value) { | ||
| const invalid = (reason) => { | ||
| throw new ProjectMapUnreadableError(projectMapPath(cacheDir), new Error(`the entry for ${cwd} ${reason}`)); | ||
| }; | ||
| if (typeof value !== 'object' || value === null || Array.isArray(value)) { | ||
| return invalid(`should be an object with an env block, found ${describe(value)}`); | ||
| } | ||
| const { env } = value; | ||
| if (typeof env !== 'object' || env === null || Array.isArray(env)) { | ||
| return invalid(`has no usable env block: found ${describe(env)}`); | ||
| } | ||
| const nonString = Object.entries(env).filter(([, item]) => typeof item !== 'string'); | ||
| if (nonString.length > 0) { | ||
| return invalid(`has non-string environment values: ${nonString.map(([name, item]) => `${name} is ${describe(item)}`).join(', ')}`); | ||
| } | ||
| return value; | ||
| } | ||
| export function readProjectEntry(cwd, cacheDir = DEFAULT_CACHE_DIR) { | ||
| const map = readMap(cacheDir); | ||
| return map[cwd] ?? null; | ||
| const entry = map[cwd]; | ||
| return entry === undefined ? null : validateEntry(cacheDir, cwd, entry); | ||
| } | ||
| export function writeProjectEntry(cwd, env, cacheDir = DEFAULT_CACHE_DIR) { | ||
| const map = readMap(cacheDir); | ||
| // Validated before the rewrite for the same reason the file is: a write is how | ||
| // the surviving mappings get destroyed, and an entry nobody can read is one | ||
| // the user may still want back. Only this directory's entry is checked — | ||
| // mapping /a must not be blocked by whatever /b holds, and /b travels through | ||
| // verbatim. | ||
| if (map[cwd] !== undefined) { | ||
| validateEntry(cacheDir, cwd, map[cwd]); | ||
| } | ||
| map[cwd] = { env }; | ||
| writeMap(cacheDir, map); | ||
| } |
| import { z } from 'zod'; | ||
| import { FormioConfig, ResolvedFormioConfig } from './config.js'; | ||
| export declare function buildCwdSchema(): z.ZodString | z.ZodOptional<z.ZodString>; | ||
| export declare const cwdSchema: z.ZodString | z.ZodOptional<z.ZodString>; | ||
| export declare function resolveProjectConfig(cwd: string | undefined, baseConfig: FormioConfig): ResolvedFormioConfig; | ||
| export declare const cwdSchema: z.ZodOptional<z.ZodString>; | ||
| export type ProjectUrlSource = 'environment' | 'mapping'; | ||
| export type BaseUrlSource = 'environment' | 'mapping' | 'default'; | ||
| export interface ProjectResolution { | ||
| config: ResolvedFormioConfig; | ||
| sources: { | ||
| projectUrl: ProjectUrlSource; | ||
| baseUrl: BaseUrlSource; | ||
| }; | ||
| } | ||
| export interface ResolveProjectOptions { | ||
| cacheDir?: string; | ||
| onNote?: (message: string) => void; | ||
| } | ||
| export declare function resolveProjectConfig(cwd: string | undefined, baseConfig: FormioConfig, options?: ResolveProjectOptions): ResolvedFormioConfig; | ||
| export declare function resolveProject(cwd: string | undefined, baseConfig: FormioConfig, { cacheDir, onNote, }?: ResolveProjectOptions): ProjectResolution; |
+167
-47
| import path from 'path'; | ||
| import { z } from 'zod'; | ||
| import { readProjectEntry } from './project-map.js'; | ||
| function isPluginContext() { | ||
| return process.env.FORMIO_PLUGIN_CONTEXT === '1'; | ||
| import { DEFAULT_BASE_URL, normalizeHttpUrl, stripTrailingSlashes, } from './config.js'; | ||
| import { ProjectMapUnreadableError, projectMapPath, readProjectEntry } from './project-map.js'; | ||
| const CWD_DESCRIPTION = "User's current working directory as an absolute path. Selects the Form.io project mapped to that directory in ~/.formio/projects.json — call project_set to create the mapping. Pass it on every call whenever you know it: omitting it resolves against the MCP server's own working directory, which is fixed at spawn and may be a different directory mapped to a different project. Only a FORMIO_PROJECT_URL set in the server environment makes it unnecessary, because that pin takes precedence over every mapping."; | ||
| // One schema for every client. Requiredness cannot live here: whether a cwd is | ||
| // needed depends on the environment the server was launched with, and this | ||
| // schema is built once at module load. resolveProjectConfig raises the error | ||
| // instead, where both the environment and the map are known. | ||
| export const cwdSchema = z | ||
| .string() | ||
| .min(1, 'cwd must not be empty') | ||
| .refine((value) => path.isAbsolute(value), { | ||
| message: 'cwd must be an absolute path', | ||
| }) | ||
| .optional() | ||
| .describe(CWD_DESCRIPTION); | ||
| function missingProjectError({ cwd, mapCwd, suggested }) { | ||
| // Which directory was searched is the whole answer when no cwd was passed: the | ||
| // server's own is not the user's, so "nothing is configured" without it sends | ||
| // the caller to project_set, which writes a mapping the next cwd-passing call | ||
| // will not find — and the loop repeats with the cause never named. | ||
| const where = cwd | ||
| ? ` for cwd=${cwd}` | ||
| : ` for ${mapCwd}, the MCP server's own working directory, which is the only directory searched because no cwd argument was passed`; | ||
| const how = cwd | ||
| ? `project_set with cwd=${cwd} and the project URL` | ||
| : "project_set with cwd set to the user's current working directory and the project URL — and pass that same cwd on every Form.io tool call"; | ||
| // A configured default is offered here rather than applied during resolution: | ||
| // the agent must confirm it and persist it, so nothing is written to a project | ||
| // the user did not choose for this directory. | ||
| const offer = suggested | ||
| ? ` A default is configured (FORMIO_DEFAULT_PROJECT_URL): ${suggested} — the suggested project. Confirm it with the user before using it, then persist it with the same call.` | ||
| : ''; | ||
| return new Error(`No Form.io project is configured${where}. Ask the user for their Project URL and Base URL, then call ${how} (pass baseUrl too — it defaults to ${DEFAULT_BASE_URL}, which is wrong for a self-hosted deployment and is what the login URL is built from).${offer} Setting FORMIO_PROJECT_URL and FORMIO_BASE_URL in the server environment works as well.`); | ||
| } | ||
| const PLUGIN_CWD_DESCRIPTION = "User's current working directory as an absolute path. Required — the tool looks up the mapped Form.io project from ~/.formio/projects.json[cwd]. Call project_set first if the cwd is not yet mapped."; | ||
| const STANDALONE_CWD_DESCRIPTION = 'Optional and ignored. The per-directory project map applies only when running as the Claude Code plugin; here the project comes from FORMIO_PROJECT_URL. Accepted so the same call works in either mode.'; | ||
| // The per-cwd map is only consulted in plugin context, so requiring cwd | ||
| // elsewhere made callers invent a value that could not change the result — and | ||
| // pointed them at project_set, which is not even registered outside the plugin. | ||
| // Exported so tests can build a schema after changing the environment. | ||
| export function buildCwdSchema() { | ||
| if (isPluginContext()) { | ||
| return z | ||
| .string() | ||
| .min(1, 'cwd is required') | ||
| .refine((value) => path.isAbsolute(value), { | ||
| message: 'cwd must be an absolute path', | ||
| }) | ||
| .describe(PLUGIN_CWD_DESCRIPTION); | ||
| } | ||
| return z.string().optional().describe(STANDALONE_CWD_DESCRIPTION); | ||
| // Ordered candidates in, the winner and its provenance out. Precedence is stated | ||
| // once, at the call site, as the order of the list. | ||
| function chooseBaseUrl(candidates) { | ||
| const chosen = candidates.find(([, value]) => Boolean(value)); | ||
| return chosen?.[1] | ||
| ? { baseUrl: chosen[1], baseUrlSource: chosen[0] } | ||
| : { baseUrl: DEFAULT_BASE_URL, baseUrlSource: 'default' }; | ||
| } | ||
| // Built once at module load, which is correct because the plugin sets | ||
| // FORMIO_PLUGIN_CONTEXT before the server process starts. | ||
| export const cwdSchema = buildCwdSchema(); | ||
| export function resolveProjectConfig(cwd, baseConfig) { | ||
| const pluginContext = isPluginContext(); | ||
| // Plugin context: the hook drives per-cwd project_set, so the map is | ||
| // authoritative and cwd must be usable. Standalone: the map is at best stale | ||
| // leftover from prior plugin use in this cwd, so the environment wins and cwd | ||
| // is never read — validating a value we are about to ignore would only | ||
| // produce confusing failures. | ||
| if (pluginContext) { | ||
| if (typeof cwd !== 'string' || cwd.length === 0) { | ||
| throw new Error('cwd is required and must be a non-empty string.'); | ||
| // An unreadable project map is a real problem the caller has to hear about — | ||
| // reporting it as "nothing configured" sends them to project_set, whose rewrite | ||
| // is what destroys the surviving mappings. But that is only true where the map | ||
| // is the source of the project. When a pinned launch consults it purely as a | ||
| // base-URL fallback, an unreadable file means "no mapped base URL", which is the | ||
| // same answer as no mapping at all: resolution continues to the documented | ||
| // default rather than failing a call that never needed the file. The reason is | ||
| // still said out loud, because a broken map that nothing depends on today breaks | ||
| // every unpinned directory tomorrow. | ||
| // The two mapped values that are URLs. getConfig validates every URL it reads | ||
| // from the environment for exactly one reason — taken raw, an unusable value keys | ||
| // the token cache and builds the portal-login URL, and only surfaces much later as | ||
| // an opaque "Failed to parse URL" out of fetch — and a value read from | ||
| // ~/.formio/projects.json reaches the same places. That file is hand-editable and | ||
| // predates the validation, so the same rule applies to both sides. | ||
| const MAPPED_URL_KEYS = ['FORMIO_PROJECT_URL', 'FORMIO_BASE_URL']; | ||
| // Reported as an unreadable ENTRY, not as an unmapped directory: the value is | ||
| // there and it is wrong, so answering "nothing is configured" sends the caller to | ||
| // interview the user and call project_set, which is the rewrite that destroys the | ||
| // surviving mappings. The same distinction ProjectMapUnreadableError already draws | ||
| // for the file as a whole, drawn one level down. | ||
| function normalizeMappedUrls(env, { mapCwd, cacheDir }) { | ||
| return Object.fromEntries(Object.entries(env).map(([key, value]) => { | ||
| if (!MAPPED_URL_KEYS.includes(key)) { | ||
| return [key, value]; | ||
| } | ||
| if (!path.isAbsolute(cwd)) { | ||
| throw new Error(`cwd must be an absolute path (received: ${cwd}).`); | ||
| try { | ||
| return [key, normalizeHttpUrl(value, key)]; | ||
| } | ||
| catch (error) { | ||
| throw new ProjectMapUnreadableError(projectMapPath(cacheDir), new Error(`the entry for ${mapCwd} holds an unusable ${key}: ${error instanceof Error ? error.message : String(error)}`)); | ||
| } | ||
| })); | ||
| } | ||
| function readMappedEnv({ mapCwd, cacheDir, tolerateUnreadable, onNote, }) { | ||
| try { | ||
| const env = readProjectEntry(mapCwd, cacheDir)?.env; | ||
| // Normalized inside the same try: an entry whose URL is unusable is as good | ||
| // as unreadable, so a pin that consults the map purely as a base-URL fallback | ||
| // tolerates it on exactly the terms below. | ||
| return env && normalizeMappedUrls(env, { mapCwd, cacheDir }); | ||
| } | ||
| const mappedEnv = pluginContext && cwd ? readProjectEntry(cwd)?.env : undefined; | ||
| const mapped = mappedEnv?.FORMIO_PROJECT_URL; | ||
| const projectUrl = mapped ?? baseConfig.projectUrl; | ||
| catch (error) { | ||
| if (!tolerateUnreadable || !(error instanceof ProjectMapUnreadableError)) { | ||
| throw error; | ||
| } | ||
| onNote(`${error.message}\nContinuing with the pinned FORMIO_PROJECT_URL.`); | ||
| return undefined; | ||
| } | ||
| } | ||
| // What every tool handler needs. `project get` needs the provenance too, and | ||
| // takes resolveProject below. | ||
| export function resolveProjectConfig(cwd, baseConfig, options = {}) { | ||
| return resolveProject(cwd, baseConfig, options).config; | ||
| } | ||
| // Precedence: an explicit FORMIO_PROJECT_URL from the environment wins, then the | ||
| // per-cwd mapping, then an actionable error. Environment-first keeps a pinned | ||
| // launch (CI, a hosted runner, an .mcp.json with an explicit project) | ||
| // deterministic even when a stale mapping exists for the same directory. | ||
| // Precedence stays defined here for every caller. | ||
| export function resolveProject(cwd, baseConfig, { cacheDir, onNote = (message) => process.stderr.write(`${message}\n`), } = {}) { | ||
| if (cwd && !path.isAbsolute(cwd)) { | ||
| throw new Error(`cwd must be an absolute path (received: ${cwd}).`); | ||
| } | ||
| const envProjectUrl = baseConfig.projectUrl; | ||
| // The same key project_set writes under. A client that cannot supply a cwd | ||
| // gets the server's own process cwd on the write side, so reading only when a | ||
| // cwd was passed produced a mapping that reported success and could never be | ||
| // read back: the next tool call said "no project configured", whose remedy is | ||
| // project_set, which writes the identical unreadable entry again. | ||
| const mapCwd = cwd || process.cwd(); | ||
| // Read only where it can change the answer. A pin carrying its own base URL | ||
| // needs nothing from the map, and reading it there turned an unreadable | ||
| // ~/.formio/projects.json into a hard failure of a launch that never depended | ||
| // on the file — exactly the determinism this module promises above. | ||
| const mappedEnv = envProjectUrl && baseConfig.baseUrl | ||
| ? undefined | ||
| : readMappedEnv({ mapCwd, cacheDir, tolerateUnreadable: Boolean(envProjectUrl), onNote }); | ||
| // Falsy, not nullish, and deliberately the same test as the line above: an | ||
| // empty FORMIO_PROJECT_URL is an unanswered prompt, not a pinned project, and | ||
| // treating it as one would discard the mapping and leave project_set with no | ||
| // way to fix it. | ||
| const projectUrl = envProjectUrl || mappedEnv?.FORMIO_PROJECT_URL; | ||
| if (!projectUrl) { | ||
| throw new Error(pluginContext | ||
| ? `No Form.io project is mapped for cwd=${cwd}. Call project_set with projectUrl and cwd=${cwd}, or set the FORMIO_PROJECT_URL environment variable, before invoking Form.io tools.` | ||
| : 'No Form.io project is configured. Set the FORMIO_PROJECT_URL environment variable before invoking Form.io tools.'); | ||
| throw missingProjectError({ | ||
| cwd: cwd || undefined, | ||
| mapCwd, | ||
| suggested: baseConfig.defaultProjectUrl, | ||
| }); | ||
| } | ||
| const baseUrl = mappedEnv?.FORMIO_BASE_URL ?? baseConfig.baseUrl; | ||
| if (!baseUrl) { | ||
| throw new Error('baseUrl is missing on config. getConfig() should always populate it.'); | ||
| // Said out loud for the same reason project_set warns on the write side: the | ||
| // server's process cwd is fixed at spawn and, for a plugin- or desktop-launched | ||
| // server, is not where the user is. Nothing else can surface this — an omitted | ||
| // cwd and a cwd that happens to match are indistinguishable from here — so a | ||
| // resolution against the wrong directory's project is otherwise silent. | ||
| if (!cwd && !envProjectUrl) { | ||
| onNote(`No cwd argument was passed, so the project was resolved from the mapping for ${mapCwd}, the MCP server's own working directory. Pass cwd on every Form.io tool call to target the user's directory.`); | ||
| } | ||
| // Which side wins depends on which side supplied the project. When the mapping | ||
| // did, its base URL travels with it and outranks the environment global. When | ||
| // the environment pinned the project, an explicit FORMIO_BASE_URL is part of | ||
| // that pin and stands — and a pin carrying no base URL may borrow the mapped | ||
| // one, but ONLY when the mapping names the very project that was pinned. | ||
| // A base URL belongs to a deployment, not to a directory: lending | ||
| // https://forms.mysite.com to a pinned https://examples.form.io would send the | ||
| // portal login to a self-hosted host for a hosted project and cache the token | ||
| // under that host's key — the same silent wrong-host failure as defaulting a | ||
| // self-hosted pin to api.form.io, arrived at from the other side. | ||
| // getConfig leaves baseUrl undefined when the environment named none, so the | ||
| // default is applied last, once both have been consulted. | ||
| const mappedProjectUrl = mappedEnv?.FORMIO_PROJECT_URL; | ||
| const mappedBaseAppliesToPin = envProjectUrl !== undefined && | ||
| mappedProjectUrl !== undefined && | ||
| stripTrailingSlashes(envProjectUrl) === stripTrailingSlashes(mappedProjectUrl); | ||
| const borrowableMappedBaseUrl = !envProjectUrl || mappedBaseAppliesToPin ? mappedEnv?.FORMIO_BASE_URL : undefined; | ||
| const { baseUrl, baseUrlSource } = chooseBaseUrl(envProjectUrl | ||
| ? [ | ||
| ['environment', baseConfig.baseUrl], | ||
| ['mapping', borrowableMappedBaseUrl], | ||
| ] | ||
| : [ | ||
| ['mapping', borrowableMappedBaseUrl], | ||
| ['environment', baseConfig.baseUrl], | ||
| ]); | ||
| return { | ||
| ...baseConfig, | ||
| baseUrl: baseUrl.replace(/\/+$/, ''), | ||
| projectUrl: projectUrl.replace(/\/+$/, ''), | ||
| config: { | ||
| ...baseConfig, | ||
| baseUrl: stripTrailingSlashes(baseUrl), | ||
| projectUrl: stripTrailingSlashes(projectUrl), | ||
| }, | ||
| sources: { | ||
| projectUrl: envProjectUrl ? 'environment' : 'mapping', | ||
| baseUrl: baseUrlSource, | ||
| }, | ||
| }; | ||
| } |
+1
-0
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { FormioConfig } from './config.js'; | ||
| export declare const SERVER_VERSION: string; | ||
| export declare const SERVER_INSTRUCTIONS: string; | ||
| export declare function createServer(config?: FormioConfig): McpServer; |
+15
-2
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { readFileSync } from 'fs'; | ||
| import { createRequire } from 'module'; | ||
| import { getConfig } from './config.js'; | ||
| import { DEFAULT_BASE_URL, getConfig } from './config.js'; | ||
| import { registerAllTools } from './tools/index.js'; | ||
@@ -21,7 +21,20 @@ // Read from package.json rather than repeating the version here: clients show | ||
| export const SERVER_VERSION = readPackageVersion(); | ||
| // Surfaced at initialize, so this is the only configuration guidance an agent | ||
| // receives when the server is used stand-alone with no skills installed. It | ||
| // names no client, skill, or plugin: one server, one behaviour, every host. | ||
| export const SERVER_INSTRUCTIONS = [ | ||
| 'Every Form.io tool here operates on one active project, and the server starts with none.', | ||
| 'Before the first project-scoped call, ask the user for two things in a single round: the Project URL (the full URL of their Form.io project) and the Base URL (the deployment hosting it).', | ||
| `There are exactly three valid shapes for that pair. On Form.io's hosted cloud the Base URL is ALWAYS ${DEFAULT_BASE_URL} and the Project URL is the project name as a sub-domain of form.io — a project named examples is https://examples.form.io. On a deployment the customer hosts, the Base URL is that deployment's host, often a sub-domain of their own domain (https://forms.mysite.com), and the Project URL is EITHER a sibling sub-domain of the same parent domain (https://myproject.mysite.com) OR a sub-directory of the deployment (https://forms.mysite.com/myproject), depending on how that deployment routes projects.`, | ||
| 'Three rules follow. A *.form.io host is never a Base URL. https://api.form.io/<project> is not a hosted Project URL. And a Project URL whose host differs from the Base URL host is normal in the sub-domain shape, so never build a Project URL by appending a name to the Base URL, and never derive a Base URL from a Project URL that has no path — ask for it.', | ||
| `The Base URL defaults to ${DEFAULT_BASE_URL}, which is correct for the hosted cloud and wrong for a self-hosted or on-premise deployment — it builds the login URL and keys the cached token, so ask for it rather than assuming.`, | ||
| "Persist both with project_set, passing the cwd argument set to the user's current working directory. Every tool resolves its project from that mapping on each call, so it takes effect immediately with no restart.", | ||
| 'If FORMIO_DEFAULT_PROJECT_URL is set, it is a suggestion rather than a setting: offer it as the recommended answer, confirm it with the user, and persist it with project_set. It changes nothing on its own. FORMIO_PROJECT_URL is the opposite — it pins the server, and project_set cannot redirect it.', | ||
| 'Authentication is implicit: the first authenticated call opens a browser portal login when no valid token is cached.', | ||
| ].join(' '); | ||
| export function createServer(config) { | ||
| const resolvedConfig = config ?? getConfig(); | ||
| const server = new McpServer({ name: 'formio-mcp', version: SERVER_VERSION }); | ||
| const server = new McpServer({ name: 'formio-mcp', version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS }); | ||
| registerAllTools(server, resolvedConfig); | ||
| return server; | ||
| } |
+26
-4
| #!/usr/bin/env node | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import { isProjectCommand, runProjectCommand } from './cli/project-command.js'; | ||
| import { getConfig } from './config.js'; | ||
| import { createServer } from './server.js'; | ||
| const config = getConfig(); | ||
| const server = createServer(config); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| const args = process.argv.slice(2); | ||
| // A project can be configured before any client has connected. Only an explicit | ||
| // `project` invocation takes this path; with no arguments the stdio transport | ||
| // starts exactly as it always has. | ||
| if (isProjectCommand(args)) { | ||
| const result = runProjectCommand(args); | ||
| if (result.stdout) { | ||
| process.stdout.write(`${result.stdout}\n`); | ||
| } | ||
| if (result.stderr) { | ||
| process.stderr.write(`${result.stderr}\n`); | ||
| } | ||
| // Not process.exit: every documented invocation of this command is through a | ||
| // pipe (an agent's shell tool), and a piped stdout is asynchronous on macOS. | ||
| // Exiting here can truncate or drop the output the caller is parsing, which | ||
| // reads as "no project configured". Setting the code lets Node flush and exit | ||
| // on its own once the write completes. | ||
| process.exitCode = result.exitCode; | ||
| } | ||
| else { | ||
| const config = getConfig(); | ||
| const server = createServer(config); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } |
@@ -31,9 +31,6 @@ import { registerActionCreateTool } from './action_create.js'; | ||
| registerProjectImportTool(server, config); | ||
| // project_set is only useful when the SessionStart/PreToolUse hook drives | ||
| // per-cwd project mapping — i.e. plugin context. Standalone .mcp.json users | ||
| // bind the server to a project via FORMIO_PROJECT_URL instead, so exposing | ||
| // project_set there just invites drift between the env and the map. | ||
| if (process.env.FORMIO_PLUGIN_CONTEXT === '1') { | ||
| registerProjectSetTool(server, { cwd: options.cwd }); | ||
| } | ||
| // The already-validated base URL, not a second read of the environment: one | ||
| // unusable FORMIO_BASE_URL has to be dropped once, in getConfig, or the tool | ||
| // that repairs a directory's mapping is the one it breaks. | ||
| registerProjectSetTool(server, { cwd: options.cwd, baseUrl: () => config.baseUrl }); | ||
| registerRoleCreateTool(server, config); | ||
@@ -40,0 +37,0 @@ registerRoleListTool(server, config); |
| import { z } from 'zod'; | ||
| import { normalizeHttpUrl, readHttpUrlEnv } from '../config.js'; | ||
| import { toMcpStructuredResult } from '../mcp-responses.js'; | ||
@@ -6,21 +7,16 @@ import { projectMappingShape } from '../output-schemas.js'; | ||
| import { readProjectEntry, writeProjectEntry } from '../project-map.js'; | ||
| function normalizeHttpUrl(input, label) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(input); | ||
| } | ||
| catch { | ||
| throw new Error(`${label} must be a valid URL, got: ${input}`); | ||
| } | ||
| if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | ||
| throw new Error(`${label} must use http or https, got: ${parsed.protocol}`); | ||
| } | ||
| return input.replace(/\/+$/, ''); | ||
| } | ||
| export function registerProjectSetTool(server, options = {}) { | ||
| const getServerCwd = options.cwd ?? (() => process.cwd()); | ||
| // Fallback base URL when the caller does not pass one explicitly: the plugin | ||
| // user-config sets FORMIO_BASE_URL in the server env (one global value). An | ||
| // explicit baseUrl argument lets each cwd map to its own deployment. | ||
| const getEnvBaseUrl = options.baseUrl ?? (() => process.env.FORMIO_BASE_URL); | ||
| // Last-resort base URL: FORMIO_BASE_URL in the server environment is one | ||
| // global value, so it only applies to a directory with no mapped base URL of | ||
| // its own. An explicit baseUrl argument lets each cwd map to its own | ||
| // deployment. | ||
| // | ||
| // Read through readHttpUrlEnv, never raw. Every shipped manifest sets this | ||
| // from a host variable, and a client that does not expand it passes the | ||
| // literal "${FORMIO_BASE_URL}" — truthy, so taken raw it reached | ||
| // normalizeHttpUrl and threw out of the handler. The first project_set in a | ||
| // fresh directory then failed, leaving no way to map any project at all. | ||
| const getEnvBaseUrl = options.baseUrl ?? | ||
| (() => readHttpUrlEnv({ raw: process.env.FORMIO_BASE_URL, name: 'FORMIO_BASE_URL' })); | ||
| server.registerTool('project_set', { | ||
@@ -32,2 +28,4 @@ description: [ | ||
| 'Every Form.io tool resolves its project URL from this map on each call, so the new mapping takes effect immediately for subsequent tool calls from the same cwd.', | ||
| 'Pass baseUrl unless the project is on the Form.io hosted cloud. It builds the portal-login URL and keys the cached token, and it falls back to https://api.form.io — so omitting it on a self-hosted or on-premise deployment sends the login to the wrong host. Ask the user for it in the same round as the project URL rather than assuming the default.', | ||
| 'One exception: a FORMIO_PROJECT_URL set in the MCP server environment takes precedence over every mapping. When the server was launched pinned to a project that way, writing a mapping here will not redirect it — change the launch configuration instead.', | ||
| ].join(' '), | ||
@@ -37,3 +35,3 @@ inputSchema: { | ||
| .url({ protocol: /^https?$/ }) | ||
| .describe('Full URL of the Form.io project to activate, e.g. https://api.form.io/my-project'), | ||
| .describe('Full URL of the Form.io project to activate. On the Form.io hosted cloud it is the project name as a sub-domain of form.io, e.g. https://examples.form.io. On a customer-hosted deployment it is either a sibling sub-domain of that customer’s domain, e.g. https://myproject.mysite.com, or a sub-directory of the deployment, e.g. https://forms.mysite.com/myproject — whichever that deployment uses.'), | ||
| cwd: z | ||
@@ -46,3 +44,3 @@ .string() | ||
| .optional() | ||
| .describe('Deployment URL for the Form.io Enterprise Server that hosts this project, e.g. https://api.form.io. Persisted per-cwd alongside the project URL so each directory can target a different deployment. Falls back to the global FORMIO_BASE_URL when omitted.'), | ||
| .describe('Deployment URL for the Form.io Enterprise Server that hosts this project — https://api.form.io for the hosted cloud, or the customer’s own platform host such as https://forms.mysite.com. Never a project’s own sub-domain, and never carrying a path. Persisted per-cwd alongside the project URL so each directory can target a different deployment. When omitted it falls back to the base URL already mapped for this directory, and only then to the global FORMIO_BASE_URL — so changing a directory’s deployment requires passing it explicitly.'), | ||
| }, | ||
@@ -54,10 +52,37 @@ outputSchema: projectMappingShape, | ||
| const normalized = normalizeHttpUrl(projectUrl, 'projectUrl'); | ||
| const resolvedBase = baseUrlArg ?? getEnvBaseUrl(); | ||
| const baseUrl = resolvedBase ? normalizeHttpUrl(resolvedBase, 'baseUrl') : undefined; | ||
| const entryCwd = cwd ?? getServerCwd(); | ||
| const existing = readProjectEntry(entryCwd); | ||
| const previousMapped = existing?.env.FORMIO_PROJECT_URL; | ||
| const previousBase = existing?.env.FORMIO_BASE_URL; | ||
| // Read tolerantly, exactly like the environment global below it. A stored | ||
| // base URL is data rather than the caller's typing, and this call is the | ||
| // documented repair for a directory whose mapping the resolver now refuses: | ||
| // normalizing it strictly made the repair fail with the very error it was | ||
| // called to clear, leaving no way to fix that directory at all. | ||
| const previousBase = readHttpUrlEnv({ | ||
| raw: existing?.env.FORMIO_BASE_URL, | ||
| name: `FORMIO_BASE_URL mapped for ${entryCwd}`, | ||
| }); | ||
| // Precedence: the explicit argument, then the base URL already mapped for | ||
| // this directory, then the environment global. The mapping outranks the | ||
| // global deliberately — it is the more specific answer for THIS directory | ||
| // and the one resolveProjectConfig honours at resolve time. Environment | ||
| // first would make the fallback unreachable in every plugin install, where | ||
| // the manifests always set FORMIO_BASE_URL (defaulted to api.form.io): a | ||
| // re-point at a sibling project would silently move a self-hosted | ||
| // directory to the hosted cloud, which is what this order prevents. To | ||
| // change a directory's deployment, pass baseUrl. | ||
| // Falsy, not nullish: FORMIO_BASE_URL arrives from a host prompt the user | ||
| // may have cleared, and an empty string is not a deployment. Stopping the | ||
| // chain there would drop the mapped base URL exactly as omitting it did. | ||
| const resolvedBase = baseUrlArg || previousBase || getEnvBaseUrl(); | ||
| const baseUrl = resolvedBase ? normalizeHttpUrl(resolvedBase, 'baseUrl') : undefined; | ||
| // The server's process cwd is fixed at spawn; for a plugin-launched server | ||
| // it is not the user's directory. Keying there still beats refusing — some | ||
| // clients have no cwd to pass — but the caller has to be told, or the next | ||
| // call that does pass a cwd misses the mapping and loops. | ||
| const serverCwdWarning = cwd | ||
| ? '' | ||
| : ` Warning: no cwd argument was passed, so this mapping is keyed to the MCP server's own working directory. If that is not the user's directory, call project_set again with cwd set to it.`; | ||
| if (previousMapped === normalized && previousBase === baseUrl) { | ||
| const message = `Active project is already ${normalized} and persisted for ${entryCwd}; no change`; | ||
| const message = `Active project is already ${normalized} and persisted for ${entryCwd}; no change${serverCwdWarning}`; | ||
| return toMcpStructuredResult({ | ||
@@ -77,5 +102,6 @@ ok: true, | ||
| writeProjectEntry(entryCwd, env); | ||
| const message = previousMapped | ||
| const message = (previousMapped | ||
| ? `Active project set to ${normalized} (was ${previousMapped}; persisted for ${entryCwd})` | ||
| : `Active project set to ${normalized}; mapping persisted for ${entryCwd}`; | ||
| : `Active project set to ${normalized}; mapping persisted for ${entryCwd}`) + | ||
| serverCwdWarning; | ||
| return toMcpStructuredResult({ | ||
@@ -82,0 +108,0 @@ ok: true, |
+1
-1
| { | ||
| "name": "@formio/mcp", | ||
| "version": "0.8.4", | ||
| "version": "0.9.0", | ||
| "mcpName": "io.form/formio-mcp", | ||
@@ -5,0 +5,0 @@ "description": "Form.io MCP Server", |
+39
-13
| ## Formio MCP server | ||
| [](https://www.npmjs.com/package/@formio/mcp) | ||
| [](https://smithery.ai/servers/formio/mcp) | ||
| [](https://smithery.ai/servers/formio/mcp) | ||
| [](https://registry.modelcontextprotocol.io/v0/servers?search=io.form/formio-mcp) | ||
@@ -22,3 +22,3 @@ | ||
| | --- | --- | --- | | ||
| | stdio | `npx -y @formio/mcp` (or `node dist/stdio.js`) | Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Cline — anything that speaks MCP over stdio | | ||
| | stdio | `npx -y @formio/mcp` (or `node dist/stdio.js`) | Claude Code, Claude Desktop, Cursor, VS Code, Codex, Windsurf, Cline — anything that speaks MCP over stdio | | ||
@@ -29,3 +29,3 @@ There is no HTTP or SSE transport. The server's only HTTP listener is the temporary browser-login page described under [Authentication](#authentication), which carries no MCP traffic. | ||
| The same stdio entry works everywhere; only the file the config goes in changes — `.mcp.json` in a project for Claude Code, `claude_desktop_config.json` for Claude Desktop, and each editor's own MCP settings elsewhere: | ||
| The same stdio entry works everywhere, but the file it goes in **and the key it goes under** both vary by client — `.mcp.json` under `mcpServers` for Claude Code, `.cursor/mcp.json` under `mcpServers` for Cursor, `.vscode/mcp.json` under **`servers`** for VS Code, and `.codex/config.toml` as TOML for Codex. There is no universal `.mcp.json`. The [root README](https://github.com/formio/ai#manual-configuration) carries the full per-client table; the JSON `mcpServers` shape is: | ||
@@ -46,3 +46,3 @@ ```json | ||
| Standalone (non-plugin) mode needs `FORMIO_PROJECT_URL` before any tool that reaches Form.io will work; `FORMIO_BASE_URL` is optional and defaults to `https://api.form.io`, so set it when self-hosting. In plugin mode the plugin manages both, via Claude Code's user-config plus the per-cwd `~/.formio/projects.json` mapping. | ||
| Every tool that reaches Form.io needs a project: either `FORMIO_PROJECT_URL` in the environment, or a per-directory mapping written by the `project_set` tool (the environment wins when both exist). `FORMIO_BASE_URL` is optional and defaults to `https://api.form.io`, so set it when self-hosting. The Claude Code plugin collects both through user-config and the per-cwd `~/.formio/projects.json` mapping; other clients set the environment or call `project_set`. | ||
@@ -62,3 +62,3 @@ The server starts without either one, so a client can connect and list the tools before anything is configured — the project URL is only demanded at the point a tool needs it, and `hello` works regardless. | ||
| Wired into a client — the same `mcpServers` entry as [Connect a client](#connect-a-client), with `command` and `args` pointed at Docker — that becomes: | ||
| Wired into a client — the same entry as [Connect a client](#connect-a-client), with `command` and `args` pointed at Docker — that becomes (shown in the JSON `mcpServers` shape; VS Code uses `servers` and Codex uses TOML, as noted there): | ||
@@ -168,3 +168,3 @@ ```json | ||
| **5. Open the Tools tab** for the tools this server exposes. A standalone server lists 19 — every tool below except `project_set`, which only registers in plugin context. | ||
| **5. Open the Tools tab** for the tools this server exposes. Every server lists all 20 — including `project_set`, which is registered for every client. | ||
@@ -222,3 +222,3 @@  | ||
| | `project_import` | Import a template JSON — additively merges roles, resources, forms, and actions in one call. **Same-machine-name items are overwritten in place; everything else is preserved.** | | ||
| | `project_set` | Plugin-mode only — persist a per-cwd Project URL mapping in `~/.formio/projects.json`. Never exposed standalone (the standalone server binds to `FORMIO_PROJECT_URL` via env instead). | | ||
| | `project_set` | Persist a per-cwd Project URL mapping in `~/.formio/projects.json`, so one server can serve several workspaces. Registered in every client. An explicit `FORMIO_PROJECT_URL` in the server environment takes precedence over the mapping. | | ||
@@ -283,5 +283,6 @@ ### Diagnostic | ||
| | --- | :-: | --- | --- | --- | --- | | ||
| | `FORMIO_PROJECT_URL` | yes\* | — | Full URL of your Form.io project. In plugin mode, only used as the pre-filled default offered when prompting for an unmapped cwd. | `https://myproject.form.io` | `https://forms.example.com/myproject` | | ||
| | `FORMIO_BASE_URL` | no\*\* | `https://api.form.io` | Full base URL of your Form.io deployment. Set it when self-hosting. | `https://api.form.io` | `https://forms.example.com` | | ||
| | `FORMIO_API_KEY` | no | `undefined` | Long-lived project API key. When set, the server skips the browser login flow. | `CHANGEME` | `CHANGEME` | | ||
| | `FORMIO_PROJECT_URL` | yes\* | — | Full URL of your Form.io project. Takes precedence over any per-directory mapping written by `project_set`. Self-hosted, it is a sub-directory of the deployment or a sub-domain of your own domain (`https://myproject.example.com`), depending on how that deployment routes projects. | `https://myproject.form.io` | `https://forms.example.com/myproject` | | ||
| | `FORMIO_DEFAULT_PROJECT_URL` | no | — | A project URL to **offer**, not to apply. When set and the working directory has no mapping, the server names it as the suggested project so the agent can confirm it and persist it with `project_set`. It never changes what a tool resolves — the opposite of `FORMIO_PROJECT_URL`, which pins the server and cannot be redirected by `project_set`. | | ||
| | `FORMIO_BASE_URL` | no | `https://api.form.io` | Full base URL of your Form.io deployment — always `https://api.form.io` on the hosted cloud, never a project's `*.form.io` sub-domain. Set it when self-hosting. | `https://api.form.io` | `https://forms.example.com` | | ||
| | `FORMIO_API_KEY` | no | `undefined` | Long-lived project API key. When set, the server skips the browser login flow — the only way to authenticate on a host with no browser. | `CHANGEME` | `CHANGEME` | | ||
| | `FORMIO_LOGIN_FORM` | no | Auto-resolved | Override the portal login form URL used by the JWT login flow. | `https://formio.form.io/user/login` | `https://forms.example.com/formio/user/login` | | ||
@@ -292,6 +293,31 @@ | `FORMIO_AUTH_HOST` | no | `127.0.0.1` | Bind address for the browser-login page. `0.0.0.0` makes it reachable from outside a container. | | | | ||
| | `FORMIO_INSECURE_TLS` | no | `undefined` | Set to `1` to skip TLS verification. Local development only — never against production. | | | | ||
| | `FORMIO_PLUGIN_CONTEXT` | no | `0` | Set by the plugin manifest. When `1`, the server enables `project_set` and reads `FORMIO_PROJECT_URL` from `~/.formio/projects.json` per cwd instead of env. | | | | ||
| | `FORMIO_FORCE_BROWSER` | no | `0` | Set to `1` to attempt the browser login even where the server detects no browser (CI, a container, SSH with no display). | | | | ||
| \* Standalone only, where the server refuses to start without it. In plugin context, `FORMIO_PROJECT_URL` is captured per-cwd by the `project_set` tool and persisted to `~/.formio/projects.json`. The `verify-project-url` `SessionStart`/`PreToolUse` hook offers `formio_default_project_url` (from plugin user-config) as the default the first time you enter a workspace. | ||
| <sub>\* Not at startup — the server starts, lists every tool, and answers `hello` without it; only the tools that read or write Form.io data error, naming `project_set` and this variable. The alternative is the `project_set` tool, which maps a working directory to a project in `~/.formio/projects.json`. Resolution order: `FORMIO_PROJECT_URL`, then the mapping for the caller's `cwd`, then the error. Map a directory before any client connects with `npx -y @formio/mcp project set --project-url <url> --base-url <url> --cwd <path>`; `project get --cwd <path>` prints what resolves and which source won. It exits `0` when it resolved, `1` when nothing is mapped for that directory, and `2` when the command could not answer (a usage error, a malformed URL, an unreadable `~/.formio/projects.json`) — so a caller can tell "nothing here yet" from "this failed".</sub> | ||
| \*\* Reversed in plugin context: the plugin always collects `FORMIO_BASE_URL` through user-config, so it is required there and the hosted-cloud default does not apply. | ||
| --- | ||
| ## Privacy Policy | ||
| Form.io's privacy policy covers the Form.io Services this server talks to: **https://form.io/privacy** | ||
| What the server itself does with data, which is the part the policy above cannot describe: | ||
| **Where your data goes.** Only to the Form.io deployment you configure. Every request targets `FORMIO_BASE_URL` / `FORMIO_PROJECT_URL` — your own SaaS project or your self-hosted server. The server sends nothing to Form.io when you are self-hosted, and there is no telemetry, analytics, or usage reporting of any kind. | ||
| **What is stored on your machine.** Two files under `~/.formio/`, both written with mode `0600`: | ||
| | File | Contents | Written when | | ||
| | --- | --- | --- | | ||
| | `mcp-tokens.json` | The JWT from the browser login, keyed by `FORMIO_BASE_URL` | You sign in through the browser | | ||
| | `projects.json` | A per-directory map of project and base URLs | `project_set` runs | | ||
| Form data and submissions are never written to disk — they pass through in memory to answer a tool call. | ||
| **Credentials.** `FORMIO_API_KEY`, when set, is read from the environment and sent to your deployment as an authentication header; it is never written to disk. The cached JWT is valid for roughly seven days, after which the server re-authenticates. Delete `~/.formio/mcp-tokens.json` to sign out immediately. | ||
| **Third parties.** The server contacts no third-party service. One exception is worth naming: the browser sign-in page is rendered from a local page that loads styling and the Form.io renderer from `cdn.form.io`, `cdn.jsdelivr.net`, and `fonts.googleapis.com`, so those hosts see your browser's IP address while that page is open. Set `FORMIO_API_KEY` to skip the browser flow entirely and avoid it. | ||
| **Retention.** The files above persist until you delete them. Data held in your Form.io project is governed by your own deployment's retention rules, and by the policy linked above for Form.io-hosted projects. | ||
| Questions about data handling: support@form.io |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
196263
29.56%94
4.44%3513
25.37%316
9.34%