New:Socket for Asana Is Now Available.Learn more
Get Started

@formio/mcp

Package Overview
Dependencies
Maintainers
7
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@formio/mcp - npm Package Compare versions

Comparing version
0.11.0
to
0.12.0
+55
dist/pair-rule.d.ts
/**
* The pair rule, stated once for every reader and writer.
*
* A record holds a project and its deployment as a PAIR, and two pairs are not
* configurations at all: a project URL that is the hosted cloud's own API root (the
* deployment URL pasted where a project URL belongs), and a pair that collapses onto
* one server (the Open Source install, which has no project layer). Both writers
* refuse them before anything is recorded; the resolver refuses them at the point of
* use, because a hand-written formio.json, a hand-edited mapping entry, and the
* environment never pass through a writer.
*
* One function answers for all of them. The check written once for writes and again
* for reads is how a derived collapse escaped the read-side copy.
*/
export type PairValidity = 'ok' | 'not-a-project-url'
/** A form.io host that is not a project: the apex, the site, the portal, or a path. */
| 'not-a-hosted-project' | 'open-source-deployment' | 'hosted-project-foreign-deployment'
/** A project whose deployment is derivable, paired with a different one. */
| 'underivable-mismatch' | 'api-root-deployment';
/**
* Which HALF of the pair a verdict is about.
*
* It decides what a reader does with a record it cannot use: a verdict about the
* deployment leaves a usable project behind, and for the one shape where the right
* deployment is knowable the reader supplies it rather than failing. A verdict about
* the project leaves nothing to stand on.
*/
export declare function faultedHalf(validity: Exclude<PairValidity, 'ok'>): 'project' | 'deployment';
/** Said the same way by every message that refuses this pair. */
export declare const ENTERPRISE_ONLY = "The Form.io Agentic Coding tools are built for the Form.io Enterprise Server, and this configuration names an Open Source one. A Base URL identical to the Project URL means a server with no project layer: Open Source serves one set of forms at its own root, while every tool here addresses a project UNDER a deployment \u2014 project roles, actions, stages, imports and exports have no counterpart there. Point this directory at a project on a Form.io Enterprise deployment, or at a project on the hosted cloud (https://<project>.form.io, served by https://api.form.io).";
/** Said the same way wherever a form.io host that is not a project is offered as one. */
export declare const NOT_A_HOSTED_PROJECT = "On Form.io's hosted cloud a Project URL is the project's own name as a sub-domain of form.io and nothing more \u2014 https://examples.form.io, with no path after it. form.io itself, www.form.io and portal.form.io are not projects, and a project name appended as a path (https://examples.form.io/myproject) is not one either: the project is already named by the sub-domain. Ask the user which project, and record that.";
/** Said the same way by every message that refuses the API root as a project URL. */
export declare const API_ROOT_NOT_A_PROJECT = "the Form.io hosted cloud's API root \u2014 the Base URL every project on it shares \u2014 not a project URL. On the hosted cloud a Project URL is the project's own name as a sub-domain of form.io, e.g. https://examples.form.io, and its Base URL is derived from that. Ask the user which project, and record that.";
/** Said the same way wherever a derivable deployment is contradicted by a recorded one. */
export declare const DEPLOYMENT_IS_DERIVED = "A project addressed as a sub-directory is served by its parent path, so its deployment is read off the project URL rather than recorded \u2014 and a different value cannot be right. Record the project URL alone; its deployment is derived.";
/** Said the same way wherever a customer project is paired with the hosted cloud. */
export declare const API_ROOT_IS_NOT_YOUR_DEPLOYMENT = "https://api.form.io is the Form.io hosted cloud, which serves only the projects on it \u2014 the ones addressed as a sub-domain of form.io. A project on any other domain is served by its own deployment, so this value would send the portal login and the cached token to a deployment you do not use. Ask the user for the deployment that hosts this project, or record the project URL alone where its deployment can be derived.";
/** Said the same way wherever a hosted project is paired with something else. */
export declare const HOSTED_CLOUD_DEPLOYMENT = "A project on a form.io host is served by https://api.form.io and by nothing else \u2014 that is what makes the Project URL the whole configuration for a hosted project \u2014 and a *.form.io host is never a Base URL. Record the project URL alone; its deployment is derived.";
export declare function deriveBaseUrl(projectUrl: string): string | undefined;
/**
* Whether a normalized pair is a configuration this toolset serves.
*
* `baseUrl` is the EFFECTIVE deployment — the recorded one, or the derived one where
* the record holds none — because every verdict here is about what the tools would
* target, not about what happens to be written down. https://api.form.io derives
* itself, so a record holding it as the project with no deployment beside it
* collapses exactly as a recorded pair does.
*
* The API-root question is asked first: that host IS a form.io host, so it derives
* itself and the pair collapses — but "you are on the Open Source server" is the
* wrong diagnosis for it, and it is the likeliest mistake on this surface.
*/
export declare function classifyPair(projectUrl: string, baseUrl: string | undefined): PairValidity;
import { DEFAULT_BASE_URL, stripTrailingSlashes } from './config.js';
/**
* Which HALF of the pair a verdict is about.
*
* It decides what a reader does with a record it cannot use: a verdict about the
* deployment leaves a usable project behind, and for the one shape where the right
* deployment is knowable the reader supplies it rather than failing. A verdict about
* the project leaves nothing to stand on.
*/
export function faultedHalf(validity) {
return validity === 'hosted-project-foreign-deployment' ||
validity === 'api-root-deployment' ||
validity === 'underivable-mismatch'
? 'deployment'
: 'project';
}
/** Said the same way by every message that refuses this pair. */
export const ENTERPRISE_ONLY = 'The Form.io Agentic Coding tools are built for the Form.io Enterprise Server, and this configuration names an Open Source one. A Base URL identical to the Project URL means a server with no project layer: Open Source serves one set of forms at its own root, while every tool here addresses a project UNDER a deployment — project roles, actions, stages, imports and exports have no counterpart there. Point this directory at a project on a Form.io Enterprise deployment, or at a project on the hosted cloud (https://<project>.form.io, served by https://api.form.io).';
/** Said the same way wherever a form.io host that is not a project is offered as one. */
export const NOT_A_HOSTED_PROJECT = `On Form.io's hosted cloud a Project URL is the project's own name as a sub-domain of form.io and nothing more — https://examples.form.io, with no path after it. form.io itself, www.form.io and portal.form.io are not projects, and a project name appended as a path (https://examples.form.io/myproject) is not one either: the project is already named by the sub-domain. Ask the user which project, and record that.`;
/** Said the same way by every message that refuses the API root as a project URL. */
export const API_ROOT_NOT_A_PROJECT = "the Form.io hosted cloud's API root — the Base URL every project on it shares — not a project URL. On the hosted cloud a Project URL is the project's own name as a sub-domain of form.io, e.g. https://examples.form.io, and its Base URL is derived from that. Ask the user which project, and record that.";
/** Said the same way wherever a derivable deployment is contradicted by a recorded one. */
export const DEPLOYMENT_IS_DERIVED = `A project addressed as a sub-directory is served by its parent path, so its deployment is read off the project URL rather than recorded — and a different value cannot be right. Record the project URL alone; its deployment is derived.`;
/** Said the same way wherever a customer project is paired with the hosted cloud. */
export const API_ROOT_IS_NOT_YOUR_DEPLOYMENT = `${DEFAULT_BASE_URL} is the Form.io hosted cloud, which serves only the projects on it — the ones addressed as a sub-domain of form.io. A project on any other domain is served by its own deployment, so this value would send the portal login and the cached token to a deployment you do not use. Ask the user for the deployment that hosts this project, or record the project URL alone where its deployment can be derived.`;
/** Said the same way wherever a hosted project is paired with something else. */
export const HOSTED_CLOUD_DEPLOYMENT = `A project on a form.io host is served by ${DEFAULT_BASE_URL} and by nothing else — that is what makes the Project URL the whole configuration for a hosted project — and a *.form.io host is never a Base URL. Record the project URL alone; its deployment is derived.`;
// A project URL's host tells us whether DEFAULT_BASE_URL can possibly be right.
// The hosted cloud is the only deployment whose base URL is a constant, and it
// is api.form.io for every project on it — so a project sub-domain of form.io
// implies it, and nothing else does.
// The host, in the one form every comparison here uses. `https://api.form.io./p` is
// the same host as `https://api.form.io/p` — the trailing root dot is legal, resolves
// identically, and compared exactly it slipped past every rule in this module: the API
// root was recorded as a project, and as a customer project's deployment.
function hostOf(url) {
return url.hostname.replace(/\.$/, '');
}
// The hosts on form.io that are not projects. A project URL is a project's own
// sub-domain, so the apex is not one, and neither are the sub-domains Form.io serves
// its own site and portal from — pasted into a project prompt they were accepted and
// resolved to api.form.io, surfacing later as unexplained 404s. `api` is absent here
// deliberately: it is the API root, and it has its own diagnosis.
const RESERVED_FORMIO_HOSTS = ['form.io', 'www.form.io', 'portal.form.io'];
function isFormioHost(url) {
const host = hostOf(url);
return host === 'form.io' || host.endsWith('.form.io');
}
// A project on the hosted cloud is a project's own sub-domain and NOTHING else: no
// path, and not one of the hosts Form.io serves its own site, portal and API from.
// The path clause matters as much as the host one — `https://examples.form.io/myproject`
// was accepted, derived https://api.form.io, and then addressed every request at
// .../myproject/..., which is the unexplained-404 failure the API-root refusal exists
// to prevent. The server's own guidance says a project name is never appended to a URL.
function isHostedCloudProject(projectUrl) {
return (isFormioHost(projectUrl) &&
!RESERVED_FORMIO_HOSTS.includes(hostOf(projectUrl)) &&
// `new URL('https://examples.form.io').pathname` is '/', never '' — so this
// compares what a bare host actually yields. Written as `=== ''` it rejected
// every legitimate hosted project.
stripTrailingSlashes(projectUrl.pathname) === '');
}
// The hosted cloud's API root, identified by HOST rather than by the exact string
// DEFAULT_BASE_URL. http://api.form.io and https://api.form.io/<name> are the same
// mistake — the deployment URL offered where a project URL belongs — and an exact
// compare accepted both, recording the deployment root as the active project and
// producing the unexplained 404s this refusal exists to prevent. Compared as a
// whole hostname, never as a suffix, so a lookalike host is a different deployment.
function isApiRootHost(url) {
return hostOf(url) === hostOf(new URL(DEFAULT_BASE_URL));
}
// A sub-directory-routed project URL is its deployment plus exactly ONE
// segment — the project's name — so the deployment is the project URL's parent,
// not its origin. Those coincide only for a single-segment path: a deployment
// mounted at https://forms.mysite.com/one serves project `two` at
// https://forms.mysite.com/one/two, and flattening that to the origin would
// build the portal login and ${baseUrl}/current against a host root that serves
// neither. Returns undefined when there is no path to take a parent of.
function deriveBaseUrlFromProjectPath(projectUrl) {
const segments = projectUrl.pathname.split('/').filter(Boolean);
if (segments.length === 0) {
return undefined;
}
const parentPath = segments.slice(0, -1).join('/');
return stripTrailingSlashes(`${projectUrl.origin}${parentPath ? `/${parentPath}` : ''}`);
}
// The base URL a project URL names by itself, or undefined where it names none.
//
// Three shapes and no fourth: the hosted cloud is one constant deployment, a project
// addressed as a sub-directory is served by its parent path, and a path-less project
// URL on a customer domain names no deployment anywhere — its deployment is a sibling
// sub-domain, so it has to be supplied rather than guessed. Exported because the
// reader and the writers ask the same question and must get the same answer.
export function deriveBaseUrl(projectUrl) {
let parsed;
try {
parsed = new URL(projectUrl);
}
catch {
return undefined;
}
if (isHostedCloudProject(parsed)) {
return DEFAULT_BASE_URL;
}
return deriveBaseUrlFromProjectPath(parsed);
}
/**
* Whether a normalized pair is a configuration this toolset serves.
*
* `baseUrl` is the EFFECTIVE deployment — the recorded one, or the derived one where
* the record holds none — because every verdict here is about what the tools would
* target, not about what happens to be written down. https://api.form.io derives
* itself, so a record holding it as the project with no deployment beside it
* collapses exactly as a recorded pair does.
*
* The API-root question is asked first: that host IS a form.io host, so it derives
* itself and the pair collapses — but "you are on the Open Source server" is the
* wrong diagnosis for it, and it is the likeliest mistake on this surface.
*/
export function classifyPair(projectUrl, baseUrl) {
let parsedProject;
try {
parsedProject = new URL(projectUrl);
}
catch {
// Not a URL at all. Every caller normalizes before reaching here, so this is the
// shape no verdict of this module is about; the caller's own URL validation owns it.
return 'ok';
}
if (isApiRootHost(parsedProject)) {
return 'not-a-project-url';
}
// A form.io host that is not a project: the apex, the site, the portal, or a
// project sub-domain carrying a path. Excluding these from `isHostedCloudProject`
// stopped them DERIVING api.form.io, but nothing refused them — so they were still
// recorded as the active project, and the path-less ones were then described to the
// user as "a project URL that carries no path on a customer domain", which is false
// of a form.io host and invites a Base URL that is accepted and equally wrong.
if (isFormioHost(parsedProject) && !isHostedCloudProject(parsedProject)) {
return 'not-a-hosted-project';
}
// WHEREVER the deployment is derivable, the derivation IS the definition — so a
// recorded value that differs from it cannot be right, and left in place it becomes
// the portal-login URL and the token-cache key for a deployment the user does not
// use. This was enforced for a hosted project and for nothing else, so the OTHER
// derivable shape — a project addressed as a sub-directory — accepted any
// deployment at all, silently, with the writer and the reader agreeing on it.
//
// Asked BEFORE the collapse below, because one wrong value satisfies both: a
// project recorded as its own deployment. "You are on the Open Source server" is
// impossible where the deployment is knowable, and that verdict faults the PROJECT
// half — so asked in the other order this one value failed every tool call for the
// directory, while every other wrong deployment resolved to the derived one. The
// narrower, knowable diagnosis wins wherever both apply.
// Asked FIRST, and of the recorded values themselves. Asked after the derivation
// questions below, it became unreachable for every project URL that carries a
// path — because a derived deployment is always a strictly shorter parent, so the
// two can never be equal there — and an Open Source install mounted at a sub-path,
// an ordinary deployment shape, was diagnosed as a derivation mismatch. The remedy
// for THAT verdict says "record the project URL alone", which succeeds and stores a
// host root serving nothing.
// Deployment-half verdicts first, but ONLY where they are the better diagnosis:
// a hosted project paired with anything (including itself) is a wrong deployment
// for a project the server can serve, never an Open Source install — that verdict
// is impossible for a form.io host and faults the project half, which failed every
// call for the directory rather than deriving the deployment already known.
// Compared against the one deployment that serves a hosted project. The spelling
// variant that used to defeat this — a trailing root dot — is normalized where URLs
// enter the process, so one comparison is enough here; a different SCHEME is a
// different endpoint and belongs on the refusing side of this line.
if (baseUrl && isHostedCloudProject(parsedProject) && baseUrl !== DEFAULT_BASE_URL) {
return 'hosted-project-foreign-deployment';
}
// Only for a project the hosted cloud does NOT serve — for one it does, api.form.io
// is the right answer, and the branch above has already accepted it.
if (baseUrl && !isHostedCloudProject(parsedProject) && isApiRootHost(new URL(baseUrl))) {
return 'api-root-deployment';
}
// Then the collapse, asked of the RECORDED values. Asked after the derivation
// question below it became unreachable for every project URL carrying a path —
// a derived deployment is always a strictly shorter parent, so the two can never be
// equal there — and an Open Source install mounted at a sub-path, an ordinary
// shape, was diagnosed as a derivation mismatch whose remedy ("record the project
// URL alone") succeeds and stores a host root that serves nothing.
if (baseUrl && projectUrl === baseUrl) {
return 'open-source-deployment';
}
const derivable = deriveBaseUrl(projectUrl);
if (baseUrl && derivable && baseUrl !== derivable) {
return 'underivable-mismatch';
}
return 'ok';
}
/**
* What a `project_set` / `project set` call should write for a directory.
*
* One rule: a record holds a project and its deployment as a PAIR. A write leaves a
* complete record or it fails.
*
* That is the whole of it, and it is worth saying why, because the previous design
* was several times this size. It allowed a record to hold half a configuration — the
* base-URL repair wrote a deployment with no project, since the project lived in a
* committed file or the environment — and nothing in such a record said which project
* the deployment belonged to. Answering that at read time took a stored pairing, a
* re-point guard, a carry rule and two shape-aware gates, all of which had to agree
* on every path. Three consecutive reviews found them disagreeing: a deployment
* carried onto the wrong project, a value the resolver refused right after a writer
* reported it, a `base-url-unresolved` remedy that looped forever.
*
* None of those questions exist here. The pair IS the interpretation.
*/
export interface ProjectEntryPlanRequest {
cwd: string;
/** What the caller passed. Validated strictly — this is their own typing. */
requested: {
projectUrl?: string;
baseUrl?: string;
};
/**
* What the record being written holds today, read as it is on disk — the mapping
* entry's env, since the mapping is the only record this server writes.
*/
record: {
projectUrl?: string;
baseUrl?: string;
};
/**
* Where the project lives when this record has none, for a write that cannot reach
* it. The committed file carries its path, because the refusal that names it
* instructs an edit to that exact file.
*/
elsewhere?: {
committed?: {
projectUrl: string;
filePath: string;
};
environment?: string;
};
}
/** The entry to write, in the shape `writeProjectEntry` takes. */
export interface PlannedEntry {
env: Record<string, string>;
}
export interface PlannedWrite {
outcome: 'write' | 'unchanged';
cwd: string;
entry: PlannedEntry;
projectUrl: string;
baseUrl: string;
/**
* What the mapping held before, raw, for a caller that reports "was X".
*
* Carried on the plan rather than recomputed by each writer, though both hold the
* record they read. The plan is the one place that decides what this call DID; two
* callers deriving that themselves is the same rule in two places, which is the
* shape every defect on this surface has had — and the derivation is not quite
* trivial, since the raw value here is deliberately the unnormalized one.
*/
previousProjectUrl?: string;
/**
* A deployment this write DROPPED because the record's stored one cannot serve the
* project being recorded. Reported so the writer can say the stale value is gone
* rather than leaving the change silent.
*/
droppedBaseUrl?: string;
/** Whether this call named the project, as opposed to amending the pair. */
setAProject: boolean;
}
/** Which record holds the project, for a caller naming the write that reaches it. */
export type ProjectRecord = 'committed' | 'mapping' | 'environment';
export type ProjectEntryPlan = {
outcome: 'no-values';
cwd: string;
} | {
outcome: 'project-required';
cwd: string;
}
/**
* A project URL that names no deployment arrived without one.
*
* The record's own deployment is carried when there IS one that could not be
* adopted — which happens when the stored project URL is unusable, so nothing says
* that deployment belongs to the project now being recorded. Adopting it would
* carry a deployment across a project change, which is the failure the pair rule
* exists to prevent; hiding it asks the user for a value sitting on disk in the
* very entry they are repairing. Named, and left for them to confirm.
*/
| {
outcome: 'base-url-required';
cwd: string;
projectUrl: string;
recordedBaseUrl?: string;
/** Which of the three reasons the recorded deployment was not adopted. */
strandedReason?: 'cannot-serve' | 'no-project' | 'unusable-project' | 'different-project';
unusableRecordedProjectUrl?: string;
}
/** A deployment alone, for a project this call cannot write beside. */
| {
outcome: 'wrong-record';
cwd: string;
record: Exclude<ProjectRecord, 'mapping'>;
projectUrl: string;
/** The committed file holding the project, when that is the record. */
filePath?: string;
}
/** The deployment and the project are the same server — an Open Source install. */
| {
outcome: 'open-source-deployment';
cwd: string;
url: string;
}
/** The hosted cloud's API root, offered where a project URL belongs. */
| {
outcome: 'not-a-project-url';
cwd: string;
url: string;
}
/** A form.io host that is not a project: the apex, the site, the portal, or a path. */
| {
outcome: 'not-a-hosted-project';
cwd: string;
url: string;
}
/**
* A deployment that cannot serve the project it was offered for: a hosted-cloud
* project paired with anything but https://api.form.io, or a customer project
* paired with the hosted cloud.
*/
| {
outcome: 'hosted-project-foreign-deployment';
cwd: string;
projectUrl: string;
baseUrl: string;
} | {
outcome: 'api-root-deployment';
cwd: string;
projectUrl: string;
baseUrl: string;
} | {
outcome: 'underivable-mismatch';
cwd: string;
projectUrl: string;
baseUrl: string;
} | PlannedWrite;
export declare function planProjectEntry({ cwd, requested, record, elsewhere, }: ProjectEntryPlanRequest): ProjectEntryPlan;
import { InvalidRequestedUrlError, normalizeHttpUrl, readHttpUrlEnv } from './config.js';
import { classifyPair, deriveBaseUrl } from './pair-rule.js';
export function planProjectEntry({ cwd, requested, record, elsewhere = {}, }) {
if (!requested.projectUrl && !requested.baseUrl) {
return { outcome: 'no-values', cwd };
}
// The stored project is hand-editable DATA, not the caller's typing, and this write
// is what repairs an entry the resolver refuses. Read tolerantly so it cannot fail
// on the value it is replacing; an unusable one is not a project, so it also cannot
// stand in for one a deployment-only call is missing.
const previousProjectUrl = record.projectUrl;
const recordProjectUrl = readHttpUrlEnv({
raw: previousProjectUrl,
name: `the project URL recorded for ${cwd}`,
onIgnored: () => { },
});
const recordBaseUrl = readHttpUrlEnv({
raw: record.baseUrl,
name: `the base URL recorded for ${cwd}`,
onIgnored: () => { },
});
// Marked as the caller's own typing, so the entry points can answer it as a value
// to re-ask for rather than as a command that could not run.
const requestedUrl = (raw, label) => {
try {
return normalizeHttpUrl(raw, label);
}
catch (error) {
// Names the value AS TYPED. The underlying message reports only what the parser
// made of it ("got: htps:"), which tells a reader the scheme was wrong but not
// which of their two arguments carried it.
throw new InvalidRequestedUrlError(`${error instanceof Error ? error.message : String(error)} (${label} was ${raw})`);
}
};
const requestedProjectUrl = requested.projectUrl
? requestedUrl(requested.projectUrl, 'projectUrl')
: undefined;
const requestedBaseUrl = requested.baseUrl
? requestedUrl(requested.baseUrl, 'baseUrl')
: undefined;
// A deployment alone amends the pair where the project already is. The mapping is
// the only record this write can reach, so a project held anywhere else is a
// different write — named, not attempted, because writing the deployment here would
// put it in one record and its project in another.
if (!requestedProjectUrl) {
// The pair this call WOULD form, judged before any remedy is named. The
// deferral below tells the caller to record the offered deployment beside a
// project held elsewhere — so if that pair is one the resolver refuses, the
// instruction is to write a record that breaks the directory (a committed edit
// making every later call fail) or to run a command this same writer rejects.
// Returning here without asking was the one path around the chokepoint, and it
// was the path whose answer a human carries out by hand.
// WHICH project this call would defer to, decided once. The branches below and
// the pair check above each worked it out for themselves and disagreed: the check
// asked only when this record had no project, while the committed branch fires
// whenever the committed file names a DIFFERENT one — so a directory holding both
// a mapping project and a committed project skipped the check entirely and was
// told to hand-write a pair that makes every later call fail. One expression, used
// by both, cannot drift.
const deferral = elsewhere.committed && elsewhere.committed.projectUrl !== recordProjectUrl
? { projectUrl: elsewhere.committed.projectUrl, record: 'committed' }
: !recordProjectUrl && elsewhere.environment
? { projectUrl: elsewhere.environment, record: 'environment' }
: undefined;
const elsewhereProject = deferral?.projectUrl;
if (requestedBaseUrl && elsewhereProject) {
// EVERY non-ok verdict, not the two that were in front of me: a project URL
// that is the API root fell through, so the deferral named a command the
// writer refuses and an edit that leaves the file unusable — with the actual
// fault, the project URL, never mentioned.
const offered = classifyPair(elsewhereProject, requestedBaseUrl);
if (offered === 'not-a-project-url' || offered === 'not-a-hosted-project') {
return { outcome: offered, cwd, url: elsewhereProject };
}
if (offered === 'open-source-deployment') {
return { outcome: 'open-source-deployment', cwd, url: elsewhereProject };
}
if (offered !== 'ok') {
return {
outcome: offered,
cwd,
projectUrl: elsewhereProject,
baseUrl: requestedBaseUrl,
};
}
}
// A committed file GOVERNS the directory, so a deployment supplied with no project
// answers a report about ITS project. Amending this record instead would record that
// deployment for a project the directory does not resolve — UNLESS both records name
// the same project, where there is nothing ambiguous to refuse and the mapping is
// this project's own fallback if that file goes away.
if (deferral?.record === 'committed' && elsewhere.committed) {
return {
outcome: 'wrong-record',
cwd,
record: 'committed',
projectUrl: elsewhere.committed.projectUrl,
filePath: elsewhere.committed.filePath,
};
}
if (!recordProjectUrl) {
return elsewhere.environment
? { outcome: 'wrong-record', cwd, record: 'environment', projectUrl: elsewhere.environment }
: { outcome: 'project-required', cwd };
}
}
const projectUrl = (requestedProjectUrl ?? recordProjectUrl);
// Derived at save time, from the project it is about to be stored beside. The one
// shape that derives nothing has to arrive with its deployment: recording the
// project alone would leave a record naming a project and no deployment, and the
// caller answering that report already holds the project URL, so asking for both in
// the call still asks the user for one value.
// The record's own deployment survives a call that does not change its project —
// an idempotent re-set, or a user re-confirming what is already there. Ignoring it
// asked for a Base URL the record already held, and for the one shape that derives
// nothing that refusal was a hard error. A call that changes the project keeps
// nothing: the deployment belonged to the project being replaced.
const keptBaseUrl = recordProjectUrl === projectUrl ? recordBaseUrl : undefined;
let baseUrl = requestedBaseUrl ?? keptBaseUrl ?? deriveBaseUrl(projectUrl);
// A KEPT deployment that the pair rule rejects is stale data, not an answer the
// caller just gave — so it is dropped and the derived value used, exactly as the
// reader does with the same record. Refusing instead produced a remedy that named
// the very call that had just failed: re-recording a hosted project whose stored
// deployment was foreign inherited that value, was refused, and the refusal said
// "call project_set again with projectUrl alone", which is what had been called.
// Only a value the CALLER supplied is worth refusing, because only that one is
// something they can correct.
// The project half, judged on its own, before anything about deployments. A
// verdict that faults the PROJECT does not depend on which deployment is or is not
// beside it, and asking it only once a deployment exists left the shapes that
// derive nothing to be diagnosed by the base-URL branch instead.
const projectHalf = classifyPair(projectUrl, undefined);
if (projectHalf === 'not-a-project-url' || projectHalf === 'not-a-hosted-project') {
return { outcome: projectHalf, cwd, url: projectUrl };
}
let droppedBaseUrl;
if (baseUrl && !requestedBaseUrl && keptBaseUrl && classifyPair(projectUrl, baseUrl) !== 'ok') {
droppedBaseUrl = keptBaseUrl;
baseUrl = deriveBaseUrl(projectUrl);
}
if (!baseUrl) {
// A stored deployment that could not be adopted is named rather than dropped in
// silence. The commonest way here is repairing an entry whose stored project URL
// is unusable: it is not a project, so it cannot vouch for the deployment beside
// it, and the refusal would otherwise demand a value the user can see on disk.
// Why the deployment sitting in this record was not adopted — and whether the
// caller could answer with it after all. That second question is asked of the
// PAIR RULE rather than inferred from how the value came to be stranded: a
// directory moving from the hosted cloud to a self-hosted deployment strands
// https://api.form.io, which is a perfectly ordinary "recorded for a different
// project" — and also a value that cannot serve the new one, so inviting the user
// to re-supply it names an answer refused every time it is given.
const stranded = droppedBaseUrl ?? (!keptBaseUrl && recordBaseUrl ? recordBaseUrl : undefined);
const reason = stranded && classifyPair(projectUrl, stranded) !== 'ok'
? 'cannot-serve'
: !previousProjectUrl
? 'no-project'
: !recordProjectUrl
? 'unusable-project'
: 'different-project';
return {
outcome: 'base-url-required',
cwd,
projectUrl,
...(stranded ? { recordedBaseUrl: stranded, strandedReason: reason } : {}),
...(stranded && reason === 'unusable-project'
? { unusableRecordedProjectUrl: previousProjectUrl }
: {}),
};
}
// The pair rule, asked of the pair about to be recorded — the same classification
// the resolver applies at the point of use, so a write can never record what the
// next read refuses. 'not-a-project-url' is the hosted cloud's own API root pasted
// where a project URL belongs; 'open-source-deployment' is a pair collapsed onto
// one server, which names an install with no project layer. Refused where the pair
// is formed, rather than left to surface later as unexplained 404s.
const validity = classifyPair(projectUrl, baseUrl);
if (validity === 'not-a-project-url' || validity === 'not-a-hosted-project') {
return { outcome: validity, cwd, url: projectUrl };
}
if (validity === 'open-source-deployment') {
return { outcome: 'open-source-deployment', cwd, url: projectUrl };
}
// Refused rather than corrected: this value is a user's live answer, and a write is
// where a wrong one is worth teaching. The reader, which meets the same pair already
// on disk, supplies the derived deployment instead — the right value is knowable
// there, so failing every tool call over it would be gratuitous.
if (validity === 'hosted-project-foreign-deployment' ||
validity === 'api-root-deployment' ||
validity === 'underivable-mismatch') {
return { outcome: validity, cwd, projectUrl, baseUrl };
}
const entry = {
env: { FORMIO_PROJECT_URL: projectUrl, FORMIO_BASE_URL: baseUrl },
};
return {
outcome: recordProjectUrl === projectUrl && recordBaseUrl === baseUrl ? 'unchanged' : 'write',
cwd,
entry,
projectUrl,
baseUrl,
...(previousProjectUrl ? { previousProjectUrl } : {}),
...(droppedBaseUrl ? { droppedBaseUrl } : {}),
setAProject: Boolean(requestedProjectUrl),
};
}
import { FormioConfig } from './config.js';
import { BaseUrlSource, ProjectUrlSource } from './project-resolver.js';
/**
* What resolves for a working directory, and where each half of it came from.
*
* Two callers ask that question and they ask it from different places: the
* `project get` CLI subcommand, which runs in the user's shell, and the
* `project_get` tool, which runs inside the server the answer is about. The
* resolution and the report have to be identical — a preflight that disagrees
* with the tools it precedes is worse than no preflight — so both come from here
* and only the vocabulary of the remedies differs.
*/
export type ProjectReportStatus = 'ok' | 'not-configured' | 'base-url-unresolved';
/**
* How a caller tells its reader to fix what the report found.
*
* The facts are the same either way; the instruction is not. A shell caller is
* told to run a command, and an agent holding an open connection to this server
* is told to call `project_set` — sending it to npx instead would spawn a second
* copy of the very server that just answered.
*/
export interface ProjectRemedies {
/** Nothing is mapped for this directory: how to record a project URL. */
setProject: (cwd: string) => string[];
/**
* The project resolved and its deployment did not: how to record a base URL.
*
* Takes the project's SOURCE as well as the directory, because the remedy is
* about a record that has to exist. "Add a baseUrl key beside projectUrl in the
* committed formio.json" is only an instruction where a committed file names
* the project; said to a directory whose project comes from the environment it
* describes a file with nothing to add the key beside, and the file it produces
* is not half-configured but UNUSABLE — findCommittedConfig claims any file
* naming either key and then throws on one with no projectUrl. That turned the
* one answer with a named remedy into the one every skill relays and stops on.
*/
setBaseUrl: (options: {
cwd: string;
projectUrlSource: ProjectUrlSource;
projectUrl: string;
/**
* The committed file holding the project, when that is the record. The remedy
* there is an edit to that exact file — this server never writes a committed
* file — so the instruction has to name its path: the upward walk means the
* governing file is usually not in the directory the caller is standing in.
*/
committedFilePath?: string;
}) => string[];
/**
* What to say about the environment the answer was NOT read from. The CLI runs
* in a shell that cannot see the server's environment block; the tool IS the
* server, so it has nothing to disclaim.
*/
environmentCaveat: (cwd: string) => string[];
/**
* How to NAME the environment a value DID come from, as a noun phrase.
*
* Not the same question as the caveat above, and getting it from there was
* wrong in one direction: the two readers stand in different processes. A shell
* caller reads its own variables, so "this shell's environment" tells it exactly
* where to look. An agent calling the tool does not — the server's env block is
* written in a launch configuration, and telling that agent its project came
* from "this shell" sends it hunting for a variable no shell it can reach has.
*/
environmentLocation: string;
}
/**
* How to NAME the environment as the record that holds the project, as a noun phrase.
*
* Only the environment needs naming this way: both readers answer the committed and
* mapping records with their own wording and return before reaching here. It exists
* because the environment clause differs per reader — the two stand in different
* processes, so telling an agent its project came from "this shell" sends it hunting
* for a variable no shell it can reach holds (see `environmentLocation`).
*
* Typed to the one source it serves rather than accepting all three and branching:
* the other two branches were unreachable, and an unreachable branch is a wording
* nobody reads and nobody updates.
*/
export declare function environmentRecordName(environmentLocation: string): string;
/**
* How every report spells "this directory has a project and no deployment".
*
* Named because two readers print it and a writer now prints it too, and a caller
* matching on the line has one string to match rather than three spellings of it.
*/
export declare const BASE_URL_NOT_DETERMINED = "could not be determined.";
export interface ProjectReportRequest {
cwd: string;
baseConfig: FormioConfig;
cacheDir?: string;
remedies: ProjectRemedies;
/**
* The caller's own notes array, appended to IN PLACE — an ignored environment
* variable the caller already collected, plus everything resolution emits.
*
* Caller-owned rather than returned, because resolution can throw after emitting a
* note and the caller has to render both: the note is often the cause of the very
* failure it accompanies.
*/
notes: string[];
/**
* Whether `cwd` above is the directory the CALLER named, or a fallback.
*
* It changes what the unmapped answer has to say, because that answer's remedy
* names a directory to record the project under. The CLI's fallback is the shell
* it runs in, which IS the user's directory, so it defaults to true. The tool's
* fallback is the server's own process cwd — fixed at spawn, and for a plugin-
* or desktop-launched server nowhere near the user — so recording a project
* there leaves every later call, which does pass a cwd, resolving nothing. Said
* out loud for the same reason missingProjectError says it.
*/
cwdWasNamed?: boolean;
}
/**
* The next call, as a call rather than as a sentence.
*
* Both readers already name the write in their own vocabulary, and a shell reader can
* paste theirs. An agent cannot: it had to parse an English clause back into arguments,
* which is a step that can go wrong silently and that no test could execute. Carried
* structurally, the report's own remedy is runnable — and therefore testable — by the
* caller it is written for.
*
* `supply` names the arguments the USER answers; everything in `arguments` the report
* already knows.
*
* Absent where no call fixes the state: a deployment missing from a committed
* formio.json is recorded by editing that file — this server never writes one — and
* the message names the exact file and key instead.
*/
export interface RemedyCall {
tool: 'project_set';
arguments: Record<string, string>;
supply: string[];
}
export interface ProjectReport {
status: ProjectReportStatus;
cwd: string;
projectUrl?: string;
baseUrl?: string;
projectUrlSource?: ProjectUrlSource;
baseUrlSource?: BaseUrlSource;
/** Layers that could have supplied a URL and lost to a higher one, in precedence order. */
shadowed: string[];
/**
* Values that lost to nothing: a deployment recorded with no project beside it, so
* nothing says which project it serves and it cannot be read. Reported apart from
* `shadowed` because the reader's next move differs — a shadowed value sits in the
* wrong record, an unpaired one sits in an incomplete one.
*/
unpaired: string[];
/** The full human-readable report, remedies included. */
message: string;
/** The same remedy as a call, for a caller that acts rather than reads. */
remedy?: RemedyCall;
notes: string[];
}
export declare function reportProject({ cwd, baseConfig, cacheDir, remedies, notes, cwdWasNamed, }: ProjectReportRequest): ProjectReport;
import { COMMITTED_CONFIG_FILE } from './committed-config.js';
import { BASE_URL_UNDERIVABLE, PROJECT_URL_GUIDANCE, normalizeHttpUrl, } from './config.js';
import { ProjectNotConfiguredError, resolveProject, } from './project-resolver.js';
/**
* How to NAME the environment as the record that holds the project, as a noun phrase.
*
* Only the environment needs naming this way: both readers answer the committed and
* mapping records with their own wording and return before reaching here. It exists
* because the environment clause differs per reader — the two stand in different
* processes, so telling an agent its project came from "this shell" sends it hunting
* for a variable no shell it can reach holds (see `environmentLocation`).
*
* Typed to the one source it serves rather than accepting all three and branching:
* the other two branches were unreachable, and an unreachable branch is a wording
* nobody reads and nobody updates.
*/
export function environmentRecordName(environmentLocation) {
return `FORMIO_PROJECT_URL in ${environmentLocation}`;
}
/**
* How every report spells "this directory has a project and no deployment".
*
* Named because two readers print it and a writer now prints it too, and a caller
* matching on the line has one string to match rather than three spellings of it.
*/
export const BASE_URL_NOT_DETERMINED = 'could not be determined.';
// A losing record's values are never validated — a record that cannot win takes no
// part in the answer, so failing the whole resolution over one would fail a directory
// for a value nothing reads. But they are ECHOED here, so an unusable one is labelled
// rather than printed as though it were a URL somebody could act on.
function describeCandidate(value) {
try {
normalizeHttpUrl(value, 'value');
return value;
}
catch {
return `${value} (not a usable URL)`;
}
}
// 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,
// which turns it into that caller's own failure shape.
function resolveOrNull(cwd, baseConfig, { cacheDir, onNote, onUnpaired, }) {
// A relative cwd is rejected by resolveProject itself, with a plain Error that the
// catch below passes straight through — "nothing is mapped" is only ever the
// ProjectNotConfiguredError. A second copy of that check here said the same thing
// in a second place, which is the shape every defect on this surface has had.
try {
return resolveProject(cwd, baseConfig, { cacheDir, onNote });
}
catch (error) {
// ONLY "nothing is configured here" becomes a report. Everything else travels to
// the caller, which turns it into that caller's own failure shape.
//
// Catching by exclusion — "anything that is not one of these two record errors means
// nothing is mapped" — made every error added afterwards silently become an
// interview, which writes a record the real problem still shadows. Naming the one
// answer instead means a new failure surfaces as a failure by default.
if (error instanceof ProjectNotConfiguredError) {
// A deployment found with no project beside it is reported on this status too.
// Hardcoding an empty list here was the one answer that accounted for it
// nowhere — and its own remedy overwrites the entry holding it.
onUnpaired(error.unpaired);
return null;
}
throw error;
}
}
export function reportProject({ cwd, baseConfig, cacheDir, remedies, notes, cwdWasNamed = true, }) {
// The caller's own array, appended to in place. A note is often the CAUSE of the
// failure it accompanies, and resolution can THROW after emitting one — an ignored
// formio.json on the walk, then an unreadable map — so a private copy here left the
// caller rendering the second problem with no sight of the first.
const unconfiguredUnpaired = [];
const resolution = resolveOrNull(cwd, baseConfig, {
cacheDir,
onNote: (message) => notes.push(message),
onUnpaired: (values) => unconfiguredUnpaired.push(...values),
});
if (!resolution) {
return {
status: 'not-configured',
cwd,
// No call is offered for a directory the caller did not name: this answer is
// about the server's own working directory, the message says to call again with
// the user's cwd BEFORE recording anything, and a remedy carrying this directory
// as an argument contradicts that — the write succeeds, and every later call,
// which does pass the user's cwd, resolves nothing. The schema tells agents to
// act on `remedy` rather than parse the message, so the warning has to be
// structural rather than prose.
...(cwdWasNamed
? { remedy: { tool: 'project_set', arguments: { cwd }, supply: ['projectUrl'] } }
: {}),
shadowed: [],
unpaired: unconfiguredUnpaired,
notes: [...notes],
message: [
`No Form.io project is configured for ${cwd}, and no ${COMMITTED_CONFIG_FILE} was found by walking up from it.`,
// Which directory was searched is the whole answer when the caller named
// none: the remedy below records the project under it, and recording it
// under the wrong one is invisible — the write succeeds, and the next
// call, which does name a directory, resolves nothing again.
...(cwdWasNamed
? []
: [
`That is the MCP server's own working directory, and it is the only directory searched because no cwd argument was passed. If it is not where the user is, call this again with cwd set to their directory BEFORE recording anything — a project recorded here would not be found from theirs.`,
]),
// Named only where the caller chose the directory. Otherwise the one next
// step is the warning above — call again with the user's own cwd — and a
// fully-specified call naming THIS directory contradicts it in the channel
// the skills actually relay.
// Named before the remedy, because that remedy REPLACES the entry holding
// it: a value overwritten without ever being shown is one the user cannot
// get back or account for.
...(unconfiguredUnpaired.length
? [``, `Unpaired: ${unconfiguredUnpaired.join(' ')}`, ``]
: []),
...(cwdWasNamed ? remedies.setProject(cwd) : []),
``,
PROJECT_URL_GUIDANCE,
]
.filter(Boolean)
.join('\n'),
};
}
const { config: resolved, sources } = resolution;
const committedPath = resolution.committedFilePath;
// The caller named no directory, so this answer is about the fallback one. The
// unmapped branch above already says it as part of its remedy; a RESOLVED
// answer needs it just as much, because the tool's fallback is the server's own
// process cwd — fixed at spawn, and for a plugin- or desktop-launched server
// nowhere near the user. Reported confidently and silently, it names a project
// for a directory nobody asked about, and the next call, which does pass a cwd,
// targets a different one.
//
// Except where the directory decided nothing. A project from the environment
// resolves identically for EVERY directory, so the fallback is not part of that
// answer — which is why resolveProject suppresses its own copy of this note on
// the same condition. Said anyway, it hangs a caution on an answer that is
// correct everywhere, and the skills relay notes to the user as causes.
//
// A HALF-configured answer is the exception to that exception, whatever supplied
// the project: its remedy records a base URL as a per-directory mapping, so the
// directory is part of that answer even when the project is not. Silent, the
// remedy named the server's own directory as though the caller had chosen it, and
// the next call — which does pass a cwd — reaches the same status again with the
// mapping stranded where nothing reads it.
const baseUrlIsUnresolved = sources.baseUrl === 'unresolved';
if (!cwdWasNamed && (sources.projectUrl !== 'environment' || baseUrlIsUnresolved)) {
notes.push(`No cwd argument was passed, so this answer is about ${cwd}, the MCP server's own working directory. Pass cwd on every Form.io tool call to target the user's directory.`);
}
// Every layer that COULD have supplied the project, in precedence order, so a
// losing one can be reported rather than silently omitted. "My project_set did
// nothing" is otherwise unanswerable from this output.
const shadowed = [];
if (sources.projectUrl !== 'committed' && resolution.candidates.committed) {
shadowed.push(`a committed ${COMMITTED_CONFIG_FILE} naming ${describeCandidate(resolution.candidates.committed)}`);
}
// The mapping's project URL is the one echoed value that is raw, hand-editable disk
// content — committed and environment values are normalized before they reach
// here — so it is the one most likely to need the label, and it was the one value
// not getting it: an unusable project URL read as a real alternative target beside
// a labelled sibling.
if (sources.projectUrl !== 'mapping' && resolution.candidates.mapping) {
shadowed.push(`the working-directory mapping naming ${describeCandidate(resolution.candidates.mapping)}`);
}
if (sources.projectUrl !== 'environment' && resolution.candidates.environment) {
shadowed.push(`FORMIO_PROJECT_URL in ${remedies.environmentLocation} naming ${describeCandidate(resolution.candidates.environment)}`);
}
// A deployment recorded with NO project beside it is a different thing from a
// shadowed one, and saying "overridden by the record above" about it is simply
// false: nothing outranked it, and nothing could — it names no project, so nothing
// says which project it serves and it cannot be read at all. Reported on its own
// line, because the reader's next move differs: a shadowed value is in the wrong
// RECORD, an unpaired one is an incomplete record.
const unpaired = [];
// A base URL in the WINNING record that went unused was not outranked — nothing
// outranks the record that won. It was rejected by the pair rule, and the note the
// resolver emitted says so, names the record, and names the value. Listing it here
// as "overridden by the source above" told a second, false story — and only for the
// committed record, since a winning mapping or environment record is excluded by
// the project-source gates below. The note is the one account, for all three.
//
// A base URL in a LOSING record is a different thing: it belongs to that record's
// project, so it is reported as shadowed along with it rather than as a competing
// value of its own — unless that record holds no project, which makes it unpaired.
if (sources.projectUrl !== 'mapping' && resolution.baseUrlCandidates.mapping) {
(resolution.candidates.mapping ? shadowed : unpaired).push(resolution.candidates.mapping
? `the mapped base URL naming ${describeCandidate(resolution.baseUrlCandidates.mapping)}`
: `${cwd} has ${describeCandidate(resolution.baseUrlCandidates.mapping)} mapped as a deployment with no project beside it`);
}
if (sources.projectUrl !== 'environment' && resolution.baseUrlCandidates.environment) {
(resolution.candidates.environment ? shadowed : unpaired).push(resolution.candidates.environment
? `FORMIO_BASE_URL in ${remedies.environmentLocation} naming ${describeCandidate(resolution.baseUrlCandidates.environment)}`
: `FORMIO_BASE_URL in ${remedies.environmentLocation} names ${describeCandidate(resolution.baseUrlCandidates.environment)} with no FORMIO_PROJECT_URL beside it`);
}
// Half-configured is its own answer, and its own status. The project URL
// resolved and its deployment did not — a path-less customer project names no
// host to derive one from — so reporting the api.form.io default here would
// present a guess as configuration. Not "not-configured": the remedy is the
// base URL alone, not the project interview that status sends the caller into.
if (baseUrlIsUnresolved) {
return {
status: 'base-url-unresolved',
cwd,
// The same write the prose names, as arguments. A deployment goes beside its
// project, so which remedy this is depends on the record that holds it — and
// for a committed file there is no call to carry: the fix is an edit to that
// file, which the message names by path and key. project_set writes only the
// machine-local mapping.
// Omitted for a committed project (no call performs a file edit) and for a
// directory the caller did not name (the message says to call again with the
// user's cwd first, and a remedy naming this one contradicts it).
...(sources.projectUrl !== 'committed' && cwdWasNamed
? {
remedy: {
tool: 'project_set',
arguments: sources.projectUrl === 'mapping'
? { cwd }
: { cwd, projectUrl: resolved.projectUrl },
supply: ['baseUrl'],
},
}
: {}),
projectUrl: resolved.projectUrl,
projectUrlSource: sources.projectUrl,
baseUrlSource: sources.baseUrl,
shadowed,
unpaired,
notes: [...notes],
message: [
`Project URL: ${resolved.projectUrl}`,
`Base URL: ${BASE_URL_NOT_DETERMINED}`,
`Directory: ${cwd}`,
``,
`The project is configured — only its Base URL is missing: ${BASE_URL_UNDERIVABLE}.`,
// A record holds a project and its deployment together. An entry holding only a
// deployment — the shape an earlier release wrote for a project kept elsewhere —
// names no project, so nothing says which project it serves and it cannot be
// read. Named here because it IS the value the user is being asked for again,
// and the remedy below replaces the entry that strands it.
// The Unpaired line below states WHAT is stranded. This says what the remedy
// does about it, which is the part that differs by record: a mapping write
// replaces the entry, while an edit to a committed file never touches the map —
// and claiming otherwise left the entry in place after the user had been told
// it was gone.
...(!resolution.candidates.mapping &&
resolution.baseUrlCandidates.mapping &&
// Gated with the remedy it describes. Unconditional, it promised a write
// "below" in the same message that omits every remedy and warns against
// recording anything under this directory.
cwdWasNamed
? [
``,
sources.projectUrl === 'committed'
? `The ${COMMITTED_CONFIG_FILE} edit below leaves that entry alone; remove it by recording a project for this directory, or leave it — it changes nothing.`
: `The write below records the pair for this directory and replaces that entry.`,
]
: []),
// The remedy below records the Base URL under a directory, so the caller
// has to know when that directory is a fallback rather than one they chose —
// exactly as the unmapped branch says it. The project may resolve the same
// everywhere; the record about to be written does not.
...(cwdWasNamed
? []
: [
`That directory is the MCP server's own working directory, and it is the only directory this answer is about because no cwd argument was passed. If it is not where the user is, call this again with cwd set to their directory BEFORE recording anything — a Base URL recorded here would not be found from theirs.`,
]),
// Gated for the same reason, and on the same condition, as the structured
// remedy above: a remedy that records a deployment under a directory the
// caller did not choose is the write this answer just warned against.
...(cwdWasNamed
? remedies.setBaseUrl({
cwd,
projectUrlSource: sources.projectUrl,
projectUrl: resolved.projectUrl,
...(committedPath ? { committedFilePath: committedPath } : {}),
})
: []),
// "Why is the base URL I recorded not in effect?" is exactly the question this
// answer provokes, and the losing record is the answer to it. Computed for every
// status but rendered only into the resolved one, it was missing from the report
// that most needs it.
...(shadowed.length
? [``, `Shadowed: ${shadowed.join('; ')} — overridden by the record above.`]
: []),
...(unpaired.length
? [
``,
`Unpaired: ${unpaired.join('; ')} — nothing says which project it serves, so it is ignored.`,
]
: []),
``,
`This blocks JWT authentication, which builds the portal-login URL from the Base URL and keys the cached token by it. An API key needs no Base URL and is unaffected.`,
].join('\n'),
};
}
// 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 `${remedies.environmentLocation} (${variable}), the weakest source — a committed ${COMMITTED_CONFIG_FILE} or the working-directory mapping overrides it`;
}
if (source === 'mapping') {
return `the working-directory mapping for ${cwd}`;
}
if (source === 'committed') {
// Named by path, not by layer: the upward walk means the governing file is
// usually not in the directory the caller is standing in, so "a committed
// file" leaves "why this project?" unanswered.
return `the committed ${COMMITTED_CONFIG_FILE} at ${committedPath ?? '(unknown path)'}`;
}
// One wording for every derivation: a form.io host implies api.form.io, and a
// sub-directory project implies its parent. Both are read off the project URL,
// which is what makes the project URL the single configuration.
return `the project URL it was derived from — the base URL is not configured separately unless it cannot be derived`;
};
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 a reader of this output branches 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}`;
// Kept whenever the mapping supplied any part of the answer, including a base
// URL under a pinned project. Whether it says anything at all is the caller's
// decision, because what it discloses is true of a shell and false of the
// server itself.
const caveat = [sources.projectUrl, sources.baseUrl].includes('mapping')
? remedies.environmentCaveat(cwd)
: [];
return {
status: 'ok',
cwd,
projectUrl: resolved.projectUrl,
baseUrl: resolved.baseUrl,
projectUrlSource: sources.projectUrl,
baseUrlSource: sources.baseUrl,
shadowed,
unpaired,
notes: [...notes],
message: [
`Project URL: ${resolved.projectUrl}`,
`Base URL: ${resolved.baseUrl}`,
`Source: ${source}`,
...(shadowed.length
? [`Shadowed: ${shadowed.join('; ')} — overridden by the source above.`]
: []),
...(unpaired.length
? [
`Unpaired: ${unpaired.join('; ')} — nothing says which project it serves, so it is ignored.`,
]
: []),
...caveat,
].join('\n'),
};
}
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { FormioConfig } from '../config.js';
export interface ProjectGetOptions {
cwd?: () => string;
}
export declare function registerProjectGetTool(server: McpServer, config: FormioConfig, options?: ProjectGetOptions): void;
import { toMcpError, toMcpStructuredResult } from '../mcp-responses.js';
import { projectResolutionShape } from '../output-schemas.js';
import { cwdSchema } from '../project-resolver.js';
import { reportProject } from '../project-report.js';
import { local } from '../tool-annotations.js';
import { TOOL_REMEDIES } from './project-remedies.js';
export function registerProjectGetTool(server, config, options = {}) {
const getServerCwd = options.cwd ?? (() => process.cwd());
server.registerTool('project_get', {
description: [
'Report which Form.io project the given working directory resolves to, which deployment hosts it, and which layer supplied each — the preflight to run before the first tool call that reads from or writes to a deployment.',
'Answers from inside this server, using the same resolver every other tool uses, so what it reports is what the next call targets. There is no need to run any shell command to ask this.',
'Branch on `status`. "ok" means both URLs resolved and you may proceed. "not-configured" means nothing is mapped for this directory: relay the message, ask the user for the single value it names, record it with project_set, and call this again. "base-url-unresolved" means the project IS recorded and only its deployment is missing — ask for the Base URL alone and do NOT re-ask for the Project URL.',
'Reads only. It resolves and reports; project_set is what records a choice.',
].join(' '),
inputSchema: { cwd: cwdSchema },
outputSchema: projectResolutionShape,
annotations: local('Report the project this directory resolves to', true),
}, async ({ cwd }) => {
// Owned here so a note survives a report that cannot answer: an "Ignoring
// <path>" note emitted while walking is often the first half of the story a
// later throw finishes, and the catch below renders both.
const notes = [];
try {
const report = reportProject({
notes,
// The server's own process cwd is fixed at spawn and may be mapped to a
// different project, which is why cwd is asked for on every call. It is
// still the documented fallback: project_set writes under the same key.
cwd: cwd ?? getServerCwd(),
baseConfig: config,
remedies: TOOL_REMEDIES,
// So the unmapped answer can say which directory it actually searched.
// Its remedy names a cwd to record the project under, and the server's
// own is the one directory recording it under would not help.
cwdWasNamed: cwd !== undefined,
});
return toMcpStructuredResult({
status: report.status,
cwd: report.cwd,
...(report.projectUrl ? { projectUrl: report.projectUrl } : {}),
...(report.baseUrl ? { baseUrl: report.baseUrl } : {}),
...(report.projectUrlSource ? { projectUrlSource: report.projectUrlSource } : {}),
...(report.baseUrlSource ? { baseUrlSource: report.baseUrlSource } : {}),
shadowed: report.shadowed,
unpaired: report.unpaired,
...(report.remedy ? { remedy: report.remedy } : {}),
message: report.message,
notes: report.notes,
},
// Notes lead the message, exactly as the CLI prints them. They are not
// colour: an "Ignoring FORMIO_BASE_URL: …" note is the CAUSE of a
// base-url-unresolved answer, and the server's-own-directory note is the
// reason an `ok` answer may be about the wrong project. Left in
// structuredContent alone they vanish in every client that surfaces only
// text, which showed the user a bare "could not be determined" about a
// variable that had just been discarded unread.
[...report.notes, report.message].filter(Boolean).join('\n'));
}
catch (error) {
// "Could not answer at all" — an unreadable map, a formio.json that will
// not parse. Deliberately NOT a "not-configured" status: that status
// sends the caller to project_set, whose rewrite is what destroys the
// other mappings in a file that is merely unreadable.
//
// The notes lead it, exactly as they lead a successful answer: reported
// alone, the second problem hid the first.
return toMcpError(error, notes);
}
});
}
import { ProjectRemedies } from '../project-report.js';
export declare const TOOL_REMEDIES: ProjectRemedies;
import { COMMITTED_CONFIG_FILE } from '../committed-config.js';
import { environmentRecordName } from '../project-report.js';
/**
* The tool vocabulary for every remedy a project report can carry, shared by the reader
* and the writer.
*
* Both tools answer the same question about the same directory — project_get before a
* call, project_set after a write — so they have to name the same next step in the same
* words. Two copies drifted the moment one was edited.
*/
// Named once: the remedies below read it as prose AND hand it to
// environmentRecordName, and two spellings of the same place would have the report
// name one environment and its remedy another.
const ENVIRONMENT_LOCATION = "the MCP server's own environment";
// The tool vocabulary for every remedy this report can carry.
//
// A caller reading these already holds an open connection to this server, so
// every fix is a tool call. The CLI subcommand answering the same question names
// runnable shell commands instead — see cli/project-command.ts — because its
// reader is a shell. Naming a command here would tell an agent to spawn a second
// copy of the server that just answered it.
export const TOOL_REMEDIES = {
setProject: (cwd) => [
`Ask the user for the Project URL, then call project_set with projectUrl set to it and cwd set to ${cwd}.`,
`To record it with the code instead — versioned in the repository and shared with everyone who clones it — write a committed ${COMMITTED_CONFIG_FILE} in the application's own folder holding {"projectUrl": "<url>"}. This server reads that file and never writes it.`,
],
setBaseUrl: ({ cwd, projectUrlSource, projectUrl, committedFilePath }) => {
// The deployment goes in the record that holds the project, so which remedy this
// is depends on where that project is. Naming the mapping call for a project held
// elsewhere named a call that fails — and a committed file is a record this
// server reads and never writes, so its remedy is the edit, named file and key.
if (projectUrlSource === 'mapping') {
return [
`Ask the user for the Base URL alone, then call project_set with baseUrl set to it and cwd set to ${cwd}. Leave projectUrl out: this directory's own record already holds ${projectUrl}.`,
];
}
if (projectUrlSource === 'committed') {
return [
`Ask the user for the Base URL alone, then add "baseUrl": "<their answer>" beside "projectUrl" in ${committedFilePath ?? `the committed ${COMMITTED_CONFIG_FILE}`} — the committed file that holds this project, versioned with the code, so everyone who clones the repository resolves the same pair. Edit that file directly: project_set writes only this machine's mapping, and a mapping under that file does not take effect.`,
];
}
return [
`Ask the user for the Base URL alone, then call project_set with cwd set to ${cwd}, projectUrl set to ${projectUrl}, and baseUrl set to what they gave you. ${projectUrl} comes from ${environmentRecordName(ENVIRONMENT_LOCATION)}, which project_set cannot write, so the pair is recorded in this directory's mapping — which then governs it. You are not asking the user for the project: you already have it.`,
];
},
// Nothing to disclaim. The CLI has to warn that the server's own environment is
// invisible from the shell it runs in; this answer comes from inside that
// server, resolved exactly as the next tool call will resolve it.
environmentCaveat: () => [],
// And when a value DID come from the environment, it is that block it came
// from — not the reader's shell, which has no such variable. Named "this
// shell's environment" here, the report told an agent to go looking for a
// FORMIO_PROJECT_URL that only the server's launch configuration holds.
environmentLocation: ENVIRONMENT_LOCATION,
};
export { BASE_URL_UNDERIVABLE } from './config.js';
/**
* The explanations a write refusal carries, said once.
*
* The two writers — the `project_set` tool and the `project set` subcommand — differ
* in vocabulary (arguments versus flags) and in failure shape (a thrown error versus
* an exit code), so they cannot share a whole message. What they must never differ on
* is WHY a value was refused: those sentences were copied between the two files and
* had already drifted apart in wording, which is the same "one rule, two copies"
* shape that produced every behaviour defect on this surface.
*
* Each caller wraps these in its own vocabulary and names its own next step.
*/
/** Why a deployment cannot be recorded into the mapping while a committed file holds the project. */
export declare const COMMITTED_IS_HAND_AUTHORED = "edit that file directly \u2014 this server reads a committed formio.json and never writes one";
/**
* The deployment a record holds that this write could not adopt.
*
* Returned as a clause rather than a whole sentence so each writer keeps its own
* instruction. Empty when there is nothing stranded, so a caller can concatenate it
* unconditionally.
*/
export declare function strandedBaseUrlClause({ recordedBaseUrl, strandedReason, unusableRecordedProjectUrl, }: {
recordedBaseUrl?: string;
strandedReason?: 'cannot-serve' | 'no-project' | 'unusable-project' | 'different-project';
unusableRecordedProjectUrl?: string;
}): string;
import { COMMITTED_CONFIG_FILE } from './committed-config.js';
export { BASE_URL_UNDERIVABLE } from './config.js';
/**
* The explanations a write refusal carries, said once.
*
* The two writers — the `project_set` tool and the `project set` subcommand — differ
* in vocabulary (arguments versus flags) and in failure shape (a thrown error versus
* an exit code), so they cannot share a whole message. What they must never differ on
* is WHY a value was refused: those sentences were copied between the two files and
* had already drifted apart in wording, which is the same "one rule, two copies"
* shape that produced every behaviour defect on this surface.
*
* Each caller wraps these in its own vocabulary and names its own next step.
*/
/** Why a deployment cannot be recorded into the mapping while a committed file holds the project. */
export const COMMITTED_IS_HAND_AUTHORED = `edit that file directly — this server reads a committed ${COMMITTED_CONFIG_FILE} and never writes one`;
/**
* The deployment a record holds that this write could not adopt.
*
* Returned as a clause rather than a whole sentence so each writer keeps its own
* instruction. Empty when there is nothing stranded, so a caller can concatenate it
* unconditionally.
*/
export function strandedBaseUrlClause({ recordedBaseUrl, strandedReason, unusableRecordedProjectUrl, }) {
if (!recordedBaseUrl) {
return '';
}
// Said as the planner decided it. Guessing between these — "it was recorded for a
// different project" for an entry that names NO project — tells the user a false
// fact about a record they are looking at.
const why = strandedReason === 'cannot-serve'
? `it cannot serve this project, so it was set aside`
: strandedReason === 'no-project'
? `that entry names no project at all, so nothing says which project it serves`
: strandedReason === 'unusable-project'
? `the project URL recorded beside it (${unusableRecordedProjectUrl}) is not a usable URL, so nothing says that deployment serves the project you are recording`
: `it was recorded for a different project, so it does not carry over`;
// The invitation applies only where re-supplying the value could work. For
// 'cannot-serve' the pair rule rejects that exact value, so "pass it explicitly to
// confirm it" is an instruction that is refused every time it is followed.
const invitation = strandedReason === 'cannot-serve'
? ' Supply the deployment that does serve this project instead.'
: ' If it IS the right deployment, pass it explicitly to confirm it.';
return ` This directory's mapping already holds ${recordedBaseUrl} as a deployment, but ${why}.${invitation}`;
}
+1
-0

@@ -45,2 +45,3 @@ import { ResolvedFormioConfig } from './config.js';

export declare function resetLoginFormCache(): void;
export declare function resolveDefaultLoginFormUrl(config: ResolvedFormioConfig): Promise<string>;
export declare function authenticate(config: ResolvedFormioConfig, options?: AuthenticateOptions): Promise<string>;

@@ -138,3 +138,8 @@ import express from 'express';

}
async function resolveDefaultLoginFormUrl(config) {
// Exported for the same reason `resetLoginFormCache` is: the URL this returns is the
// page a user types credentials into, and which host it names is the difference
// between logging in to their deployment and logging in to one they do not use.
// Reaching it through `authenticate` means standing up a local server and a browser
// launch, so the one behaviour worth pinning would go untested.
export async function resolveDefaultLoginFormUrl(config) {
const cacheKey = loginFormCacheKey(config);

@@ -141,0 +146,0 @@ const cached = resolvedLoginFormCache.get(cacheKey);

+277
-342

@@ -1,7 +0,9 @@

import fs from 'fs';
import path from 'path';
import { PROJECT_URL_GUIDANCE, normalizeHttpUrl, readHttpUrlEnv } from '../config.js';
import { COMMITTED_CONFIG_FILE, CommittedConfigUnusableError, findCommittedConfig, planCommittedConfigWrite, } from '../committed-config.js';
import { ProjectMapUnreadableError, readProjectEntry, writeProjectEntry } from '../project-map.js';
import { derivesOwnBaseUrl, resolveProject, usableEnvironmentBaseUrl, } from '../project-resolver.js';
import { InvalidRequestedUrlError, readHttpUrlEnv } from '../config.js';
import { COMMITTED_CONFIG_FILE, findCommittedConfig } from '../committed-config.js';
import { readProjectEntryForWrite, unusableRecordProjectUrl, writeProjectEntry, } from '../project-map.js';
import { planProjectEntry } from '../project-entry-plan.js';
import { API_ROOT_IS_NOT_YOUR_DEPLOYMENT, DEPLOYMENT_IS_DERIVED, NOT_A_HOSTED_PROJECT, API_ROOT_NOT_A_PROJECT, ENTERPRISE_ONLY, HOSTED_CLOUD_DEPLOYMENT, } from '../pair-rule.js';
import { BASE_URL_UNDERIVABLE, COMMITTED_IS_HAND_AUTHORED, strandedBaseUrlClause, } from '../write-refusals.js';
import { BASE_URL_NOT_DETERMINED, environmentRecordName, reportProject, } from '../project-report.js';
import { PROJECT_CLI, projectCommand } from '../cli-launch.js';

@@ -31,8 +33,10 @@ // Four outcomes, four codes. "Nothing is mapped for this directory" is an

'Usage:',
` ${PROJECT_CLI} set [--project-url <url>] [--base-url <url>] [--cwd <absolute path>] [--scope user|repo]`,
` ${PROJECT_CLI} set [--project-url <url>] [--base-url <url>] [--cwd <absolute path>]`,
` ${PROJECT_CLI} get [--cwd <absolute path>]`,
'',
'Scopes:',
' user (default) the machine-local mapping in ~/.formio/projects.json',
` repo a committed ${COMMITTED_CONFIG_FILE}, versioned with the code and shared with everyone who clones it`,
`set writes the machine-local mapping in ~/.formio/projects.json. To record the project`,
`with the code instead — versioned, and shared with everyone who clones it — write a`,
`committed ${COMMITTED_CONFIG_FILE} in the application's own folder: a JSON object holding`,
`{"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived. This command`,
`reads that file and never writes it.`,
].join('\n');

@@ -42,9 +46,12 @@ export function isProjectCommand(args) {

}
// Only `--flag value` pairs are recognized; anything else is a usage error
// rather than a silently ignored token.
function parseFlags(args) {
// Only KNOWN `--flag value` pairs are recognized; anything else is a usage error
// rather than a silently ignored token. An unknown flag must fail, not fall away:
// a caller passing a flag this command no longer takes — `--scope repo`, from a
// release that had a committed-file writer — would otherwise have its write land
// in a record it did not choose, and be told it succeeded.
function parseFlags(args, known) {
return args.reduce((flags, token, index) => {
if (!token.startsWith('--')) {
const flagBefore = args[index - 1];
if (flagBefore?.startsWith('--')) {
if (flagBefore?.startsWith('--') && known.includes(flagBefore.slice(2))) {
return flags;

@@ -54,2 +61,5 @@ }

}
if (!known.includes(token.slice(2))) {
throw new Error(`Unknown flag: ${token}\n\n${USAGE}`);
}
const value = args[index + 1];

@@ -108,4 +118,14 @@ if (value === undefined || value.startsWith('--')) {

// failure through project_set.
function fail(stderr) {
return { exitCode: EXIT_FAILED, stdout: '', stderr };
//
// Notes travel with it for the same reason the other two outcomes keep them: an
// "Ignoring FORMIO_PROJECT_URL: ..." note is the CAUSE of the required-project
// failure below — a host that never expanded its manifest variable — so dropping
// it here printed "no project mapped yet" about a directory whose project had
// just been discarded unread.
function fail(stderr, notes = []) {
return {
exitCode: EXIT_FAILED,
stdout: '',
stderr: [...notes, stderr].filter(Boolean).join('\n'),
};
}

@@ -116,120 +136,217 @@ function runSet(flags, context) {

}
const scope = flags.scope ?? 'user';
if (scope !== 'user' && scope !== 'repo') {
return fail(`--scope must be one of: user, repo. Received: ${scope}\n\n${USAGE}`);
}
const cwd = resolveCwd(flags.cwd, context.cwd);
if (scope === 'repo') {
return writeCommittedScope(flags, cwd);
const notes = context.notes;
const onIgnored = (message) => notes.push(message);
const mapped = readProjectEntryForWrite(cwd, context.cacheDir);
// See project_set: an entry that EXISTS and cannot be honoured is not an absent one,
// and treating it as absent made this command diagnose the environment as holding a
// project this directory's own record governs.
const unusableEntry = unusableRecordProjectUrl(mapped, cwd);
// Walked ONCE, for both the plan and the note below. A file too broken to read
// throws out of here into runProjectCommand's catch, which is right: that is a
// command that could not answer, not a directory with no project.
const committed = findCommittedConfig(cwd, { onNote: onIgnored });
const plan = planProjectEntry({
cwd,
requested: { projectUrl: flags['project-url'], baseUrl: flags['base-url'] },
record: {
projectUrl: mapped.status === 'usable' ? mapped.entry.env.FORMIO_PROJECT_URL : undefined,
baseUrl: mapped.status === 'usable' ? mapped.entry.env.FORMIO_BASE_URL : undefined,
},
elsewhere: {
// Where the project lives when this mapping has none. A file too broken to read
// throws out of here into runProjectCommand's catch, which is right: that is a
// command that could not answer, not a directory with no project.
committed,
// Read tolerantly — an unusable value is not a project, and failing here would
// block the call that records a usable one.
environment: readHttpUrlEnv({
raw: context.env.FORMIO_PROJECT_URL,
name: 'FORMIO_PROJECT_URL',
onIgnored,
}),
},
});
// Only where the mapping is the record that WOULD govern. A committed formio.json
// outranks it, so a broken entry beneath one decides nothing — and naming the
// mapping as "the record that governs this directory" there is the same
// wrong-record diagnosis this guard exists to stop, one layer up. The plan's own
// wrong-record branch answers that case, in the committed file's vocabulary.
if (unusableEntry !== undefined && !flags['project-url'] && !committed?.projectUrl) {
return notConfigured(`The mapping for ${cwd} holds an unusable value, so it is the record that governs this directory and it cannot answer with a project: ${unusableEntry} Nothing else supplies the project while that entry is on record. Run: ${projectCommand(`set --project-url <project_url> --cwd ${cwd}`)}\n\n${USAGE}`, notes);
}
const mappedProjectUrl = readProjectEntry(cwd, context.cacheDir)?.env.FORMIO_PROJECT_URL;
// A committed formio.json configures the project exactly as the mapping does,
// and the base-URL error does not say which one supplied it — it asks for the
// deployment alone, by design. So "this directory has a project" is a question
// about every source, not about the map: consulting only the map made the
// remedy the server prints for a repo-scoped project answer "no project mapped
// yet" for a directory whose project it had just printed. A file too broken to
// read throws out of here into runProjectCommand's catch, which is right — that
// is a command that could not answer, not a directory with no project.
const committedProjectUrl = findCommittedConfig(cwd)?.projectUrl;
// --project-url is required only where NOTHING configures a project. Wherever
// one is on record, either flag alone is a valid partial update, which is what
// makes the base-URL error's own remedy — `project set --base-url <url>` — a
// command the user can actually run.
if (!flags['project-url'] && !mappedProjectUrl && !committedProjectUrl) {
return fail(`--project-url is required for ${cwd}, which has no project mapped yet.\n\n${USAGE}`);
if (plan.outcome === 'no-values') {
return fail(`Pass at least one of --project-url or --base-url.\n\n${USAGE}`, notes);
}
const normalizedMapped = mappedProjectUrl
? // Re-normalized rather than passed through: the stored value is
// hand-editable and predates this validation, and it is about to be
// rewritten as though freshly supplied.
normalizeHttpUrl(mappedProjectUrl, `FORMIO_PROJECT_URL mapped for ${cwd}`)
: undefined;
// Undefined when only a committed file names the project. Nothing is written to
// the mapping in that case: copying the committed value in would make a second
// record of the project that goes stale the moment the tracked file changes,
// and this call was asked for a deployment, not for a project.
const projectUrl = flags['project-url']
? normalizeHttpUrl(flags['project-url'], 'projectUrl')
: normalizedMapped;
// What this directory will resolve to once the write lands — the value the
// derivation questions below are about, whichever record holds it. Committed
// first, matching the order resolveProjectConfig reads them in: a committed
// formio.json outranks the mapping, so where both name a project the mapping is
// not what governs this directory.
const effectiveProjectUrl = (committedProjectUrl ?? projectUrl);
const repointed = Boolean(normalizedMapped) && projectUrl !== normalizedMapped;
// 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,
// Exit 1, not 2. A named value is missing and the message says which — the same
// answer `project get` gives for an unconfigured directory, and callers branch on the
// code: 1 means act on this message, 2 means relay it and stop. Reporting a missing
// value as 2 told every skill to abandon the step it was in the middle of.
if (plan.outcome === 'project-required') {
return notConfigured(`--project-url is required for ${cwd}, which has no project mapped yet.\n\n${USAGE}`, notes);
}
// A record holds a project and its deployment together, so the one project URL that
// names no deployment cannot be recorded alone.
if (plan.outcome === 'base-url-required') {
return notConfigured(`--base-url is required alongside ${plan.projectUrl}: ${BASE_URL_UNDERIVABLE}.${strandedBaseUrlClause(plan)} Ask the user for it and run: ${projectCommand(`set --project-url ${plan.projectUrl} --base-url <base_url> --cwd ${cwd}`)}\n\n${USAGE}`, notes);
}
// The deployment goes where the project is. Writing it into the mapping while the
// project lives elsewhere would split one configuration across two records. The
// committed file is a record this command reads and never writes, so the remedy
// there is the edit, named file and key.
if (plan.outcome === 'wrong-record') {
return notConfigured(plan.record === 'committed'
? `${plan.projectUrl} is recorded in the committed ${COMMITTED_CONFIG_FILE} at ${plan.filePath}, not in this directory's mapping, so a base URL alone has no project to be recorded beside. Add "baseUrl": "${flags['base-url']}" beside "projectUrl" in that file — ${COMMITTED_IS_HAND_AUTHORED}.\n\n${USAGE}`
: `${plan.projectUrl} is recorded in FORMIO_PROJECT_URL in the environment, not in this directory's mapping, so a base URL alone has no project to be recorded beside. Record the pair where the project is: ${projectCommand(`set --project-url ${plan.projectUrl} --base-url ${flags['base-url']} --cwd ${cwd}`)}\n\n${USAGE}`, notes);
}
// Not a shape this toolset serves. Refused before anything is written, because the
// failure it prevents is a string of unexplained 404s much later. Exit 1, like every
// refusal that names the value it needs: the caller is mid-interview, and the remedy
// is to ask the user which project and run this command again.
if (plan.outcome === 'not-a-hosted-project') {
return notConfigured(`${plan.url} is not a Form.io project URL. ${NOT_A_HOSTED_PROJECT}`, notes);
}
if (plan.outcome === 'not-a-project-url') {
return notConfigured(`${plan.url} is ${API_ROOT_NOT_A_PROJECT}`, notes);
}
// Refused at the point a user typed it. The reader, meeting the same pair already on
// disk, uses the derived deployment instead and says which value it set aside.
if (plan.outcome === 'underivable-mismatch') {
return notConfigured(`${plan.baseUrl} is not the deployment for ${plan.projectUrl}. ${DEPLOYMENT_IS_DERIVED} Run: ${projectCommand(`set --project-url ${plan.projectUrl} --cwd ${cwd}`)}\n\n${USAGE}`, notes);
}
if (plan.outcome === 'api-root-deployment') {
return notConfigured(`${plan.baseUrl} is not the deployment for ${plan.projectUrl}. ${API_ROOT_IS_NOT_YOUR_DEPLOYMENT}\n\n${USAGE}`, notes);
}
if (plan.outcome === 'hosted-project-foreign-deployment') {
return notConfigured(`${plan.baseUrl} is not the deployment for ${plan.projectUrl}. ${HOSTED_CLOUD_DEPLOYMENT} Run: ${projectCommand(`set --project-url ${plan.projectUrl} --cwd ${cwd}`)}\n\n${USAGE}`, notes);
}
// Exit 1 for the same reason: this is the user typing the wrong URL back, and the
// remedy is to re-ask — reporting it as 2 told every caller to relay and stop, which
// abandoned the very interview the refusal exists to redirect.
if (plan.outcome === 'open-source-deployment') {
return notConfigured(`${plan.url} is both the Project URL and the Base URL. ${ENTERPRISE_ONLY}`, notes);
}
if (plan.outcome === 'write') {
writeProjectEntry({ cwd, env: plan.entry.env, cacheDir: context.cacheDir });
}
// A committed file GOVERNS this directory, whether or not it names the same project:
// it supplies the pair that resolves, so a mapping written under one is the fallback
// if it goes away and not what takes effect now. Silence here reads as "your project
// is now X".
const shadowedByCommitted = committed?.projectUrl;
// What resolves is ASKED OF THE READER — the same reportProject that answers
// `project get`, over the state this write just produced. Printed from the plan
// instead, this command exited 0 naming a Base URL that the very next `project get`
// reported as "could not be determined": the two halves of one tool disagreeing about
// one directory, in the channel a user reads.
const settled = reportProject({
cwd,
baseConfig: {
projectUrl: readHttpUrlEnv({
raw: context.env.FORMIO_PROJECT_URL,
name: 'FORMIO_PROJECT_URL',
}),
},
cacheDir: context.cacheDir,
remedies: CLI_REMEDIES,
notes,
});
// A mapped base URL belongs to the project it was mapped WITH. Re-pointing the
// directory at a project that names its own deployment must drop it, or one
// deployment answers for another — and, because the mapping outranks
// derivation, answers for this directory forever. The same one-value-answering-
// a-per-project-question failure the environment link below is gated against,
// reached through the mapping instead. A re-set that leaves the project alone
// keeps it: there it is this project's own explicitly recorded deployment.
const carriedBaseUrl = repointed && derivesOwnBaseUrl(effectiveProjectUrl) ? undefined : mappedBaseUrl;
//
// The environment link is reached only for a project URL that derives no
// deployment of its own. One global answering a per-project question, written
// into this mapping, would replace the derivation and then outrank it for this
// directory forever.
const declaredBaseUrl = flags['base-url'] ||
carriedBaseUrl ||
usableEnvironmentBaseUrl({
projectUrl: effectiveProjectUrl,
read: () => readHttpUrlEnv({
raw: context.env.FORMIO_BASE_URL,
name: 'FORMIO_BASE_URL',
onIgnored,
}),
onNote: onIgnored,
});
const baseUrl = declaredBaseUrl ? normalizeHttpUrl(declaredBaseUrl, 'baseUrl') : undefined;
writeProjectEntry(cwd, {
...(projectUrl && { FORMIO_PROJECT_URL: projectUrl }),
...(baseUrl && { FORMIO_BASE_URL: baseUrl }),
}, context.cacheDir);
return ok([
projectUrl ? `Project set for ${cwd}` : `Base URL set for ${cwd}`,
`Project URL: ${effectiveProjectUrl}`,
// A mapping write under a committed file naming a different project still
// belongs on disk — it is the fallback if that file goes away — but it does
// not take effect now, and silence here reads as "your project is now X".
...(committedProjectUrl && projectUrl && committedProjectUrl !== projectUrl
// Emitted twice — once on this command's own walk and once on the report's — and a
// user told twice that the same file was passed over reads it as two files.
const deduped = notes.filter((note, index) => notes.indexOf(note) === index);
notes.length = 0;
notes.push(...deduped);
const block = [
plan.outcome === 'unchanged'
? `No change for ${cwd}`
: // A record that does not take effect is described as RECORDED, never as set.
shadowedByCommitted
? `Recorded for ${cwd}`
: plan.setAProject
? `Project set for ${cwd}`
: `Base URL set for ${cwd}`,
// The stale value is gone, and silence about it would leave the user thinking
// their recorded deployment survived this write.
...(plan.droppedBaseUrl
? [
`Replaced ${plan.droppedBaseUrl}, which was recorded as this project's deployment and cannot serve it.`,
]
: []),
`Project URL: ${settled.projectUrl}`,
`Base URL: ${settled.baseUrl ?? BASE_URL_NOT_DETERMINED}`,
// A committed file governs this directory whether or not it names the same project,
// so a mapping write under one does not take effect — the pair the resolver
// reports comes from that file, not from what was just written. Said for every
// such write, because a caller cannot be left to discover it from a later report,
// and said whether or not this call changed anything.
...(shadowedByCommitted
? [
``,
`Note: the committed ${COMMITTED_CONFIG_FILE} names ${committedProjectUrl}, which outranks the mapping just written (${projectUrl}). ${committedProjectUrl} stays the active project for this directory until that file changes.`,
shadowedByCommitted !== plan.projectUrl
? `Note: the committed ${COMMITTED_CONFIG_FILE} names ${shadowedByCommitted}, which outranks this directory's mapping (${plan.projectUrl}). ${shadowedByCommitted} is the active project until that file changes; what was recorded here is the fallback if it goes away.`
: `Note: the committed ${COMMITTED_CONFIG_FILE} governs this directory, so it supplies the pair that resolves — this mapping does not take effect while that file is there, and is the fallback if it goes away. To change what resolves, edit that file directly; this command reads a committed file and never writes one.`,
]
: []),
...(baseUrl ? [`Base URL: ${baseUrl}`] : []),
...(projectUrl
? []
: [
``,
`The project stays where it is recorded — the committed ${COMMITTED_CONFIG_FILE} — and only the deployment was added to this directory's mapping.`,
]),
].join('\n'), notes);
].join('\n');
// The write landed and the directory still cannot serve a call: the committed file
// that governs it supplies no deployment, and nothing this command can write will.
// Exit 0 sent the caller onward, so `project set` succeeded and the very next
// `project get` reported the same directory as unserviceable. 3 is the code that
// already means exactly this — the project resolved and its deployment did not — and
// it is the only non-ok status reachable here, since the write itself put a project
// on record. The block above still prints: the record is half the answer, and the
// reader's message, which names the file and the key to edit, is the other half.
if (settled.status !== 'ok') {
return {
exitCode: EXIT_BASE_URL_UNRESOLVED,
stdout: block,
stderr: [...notes, settled.message].filter(Boolean).join('\n'),
};
}
return ok(block, notes);
}
// Named once, for the same reason the tool names its own: the remedies read it
// as prose and hand it to environmentRecordName.
const ENVIRONMENT_LOCATION = 'this shell’s environment';
// The CLI vocabulary for every remedy this report can carry: runnable commands,
// because the reader is a shell. The tool that answers the same question from
// inside the server names `project_set` instead — see tools/project_get.ts.
const CLI_REMEDIES = {
setProject: (cwd) => [
`Run: ${projectCommand(`set --project-url <url> --cwd ${cwd}`)}`,
`Or record it with the code, versioned and shared with everyone who clones the repository: write a committed ${COMMITTED_CONFIG_FILE} in the application's own folder holding {"projectUrl": "<url>"} — this command reads that file and never writes it.`,
],
setBaseUrl: ({ cwd, projectUrlSource, projectUrl, committedFilePath }) => {
// The deployment goes in the record that holds the project, so which remedy this
// is depends on where that project is. Printing the mapping's command for a
// project held elsewhere named a call that fails — and a committed file is a
// record this command reads and never writes, so its remedy is the edit, named
// file and key.
if (projectUrlSource === 'mapping') {
return [
`Run: ${projectCommand(`set --base-url <base_url> --cwd ${cwd}`)}`,
`That updates this directory's own record, which already holds ${projectUrl}.`,
];
}
if (projectUrlSource === 'committed') {
return [
`Add "baseUrl": "<base_url>" beside "projectUrl" in ${committedFilePath ?? `the committed ${COMMITTED_CONFIG_FILE}`} — the committed file that holds this project, versioned with the code, so everyone who clones the repository resolves the same pair. Edit it directly: this command reads a committed file and never writes one, and a mapping written under it does not take effect.`,
];
}
return [
`Run: ${projectCommand(`set --project-url ${projectUrl} --base-url <base_url> --cwd ${cwd}`)}`,
`${projectUrl} comes from ${environmentRecordName(ENVIRONMENT_LOCATION)}, which this command cannot write, so the pair is recorded in this directory's mapping — which then governs it.`,
];
},
// 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.
environmentCaveat: () => [
`Note: the MCP server’s own environment is not visible from this shell, so a FORMIO_PROJECT_URL or FORMIO_BASE_URL set there is not listed above. Neither can override this mapping — the environment is the weakest source — so what resolves here is what the server resolves.`,
],
// This reader's own variables, which is why it can be told to look at them.
environmentLocation: ENVIRONMENT_LOCATION,
};
function runGet(flags, context) {

@@ -243,3 +360,3 @@ const cwd = resolveCwd(flags.cwd, context.cwd);

// won, so the two readings must not diverge.
const notes = [];
const notes = context.notes;
const onIgnored = (message) => notes.push(message);

@@ -256,149 +373,31 @@ const envProjectUrl = readHttpUrlEnv({

});
const resolution = resolveOrNull(cwd, context, {
baseConfig: { baseUrl: envBaseUrl, projectUrl: envProjectUrl },
onNote: (message) => notes.push(message),
});
if (!resolution) {
return notConfigured([
`No Form.io project is configured for ${cwd}, and no ${COMMITTED_CONFIG_FILE} was found by walking up from it.`,
`Run: ${projectCommand(`set --project-url <url> --cwd ${cwd}`)}`,
`Or record it with the code, versioned and shared with everyone who clones the repository:`,
` ${projectCommand(`set --project-url <url> --scope repo --cwd ${cwd}`)}`,
``,
PROJECT_URL_GUIDANCE,
]
.filter(Boolean)
.join('\n'), notes);
// Caught here rather than in runProjectCommand's catch, which cannot see the notes
// this function collected. A could-not-answer failure keeps them for the same
// reason the other three outcomes do: an "Ignoring FORMIO_PROJECT_URL: ..." note is
// often the CAUSE, and a launch whose host never expanded that variable is exactly
// the one likely to also have an unreadable map — reported alone, the second
// problem hid the first.
let report;
try {
report = reportProject({
cwd,
baseConfig: { baseUrl: envBaseUrl, projectUrl: envProjectUrl },
cacheDir: context.cacheDir,
remedies: CLI_REMEDIES,
notes,
});
}
const { config: resolved, sources } = resolution;
const committedPath = resolution.committedFilePath;
// Every layer that COULD have supplied the project, in precedence order, so a
// losing one can be reported rather than silently omitted. "My project_set did
// nothing" is otherwise unanswerable from this output.
const shadowed = [];
if (sources.projectUrl !== 'committed' && resolution.candidates.committed) {
shadowed.push(`a committed ${COMMITTED_CONFIG_FILE} naming ${resolution.candidates.committed}`);
catch (error) {
return fail(error instanceof Error ? error.message : String(error), notes);
}
if (sources.projectUrl !== 'mapping' && resolution.candidates.mapping) {
shadowed.push(`the working-directory mapping naming ${resolution.candidates.mapping}`);
if (report.status === 'not-configured') {
return notConfigured(report.message, report.notes);
}
if (sources.projectUrl !== 'environment' && resolution.candidates.environment) {
shadowed.push(`FORMIO_PROJECT_URL in this shell naming ${resolution.candidates.environment}`);
if (report.status === 'base-url-unresolved') {
return baseUrlUnresolved(report.message, report.notes);
}
// The base URL needs the same report, for the same reason. A mapped deployment
// silently overriding a committed one is otherwise invisible in this output,
// and "my formio.json baseUrl did nothing" then has no answer here.
if (sources.baseUrl !== 'committed' && resolution.baseUrlCandidates.committed) {
shadowed.push(`the baseUrl in the committed ${COMMITTED_CONFIG_FILE} naming ${resolution.baseUrlCandidates.committed}`);
}
if (sources.baseUrl !== 'mapping' && resolution.baseUrlCandidates.mapping) {
shadowed.push(`the mapped base URL naming ${resolution.baseUrlCandidates.mapping}`);
}
if (sources.baseUrl !== 'environment' && resolution.baseUrlCandidates.environment) {
shadowed.push(`FORMIO_BASE_URL in this shell naming ${resolution.baseUrlCandidates.environment}`);
}
// Half-configured is its own answer, and its own exit code. The project URL
// resolved and its deployment did not — a path-less customer project names no
// host to derive one from — so printing the api.form.io default here would
// present a guess as configuration. Not a `1`: the remedy is the base URL
// alone, not the project interview a `1` sends the caller into. And not a `2`
// either, which is what it used to return: a `2` means the command could not
// answer, and every skill's preflight responds to one by relaying and stopping.
if (sources.baseUrl === 'unresolved') {
return baseUrlUnresolved([
`Project URL: ${resolved.projectUrl}`,
`Base URL: could not be determined.`,
``,
`The project is configured — only its Base URL is missing. A project URL with no path names its deployment nowhere: the deployment is a sibling sub-domain of the same parent domain, so it must be supplied rather than derived.`,
`Run: ${projectCommand(`set --base-url <base_url> --cwd ${cwd}`)}`,
`Or add a "baseUrl" key beside "projectUrl" in the committed ${COMMITTED_CONFIG_FILE}, which records it with the code.`,
``,
`This blocks JWT authentication, which builds the portal-login URL from the Base URL and keys the cached token by it. An API key needs no Base URL and is unaffected.`,
].join('\n'), notes);
}
// 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}), the weakest source — a committed ${COMMITTED_CONFIG_FILE} or the working-directory mapping overrides it`;
}
if (source === 'mapping') {
return `the working-directory mapping for ${cwd}`;
}
if (source === 'committed') {
// Named by path, not by layer: the upward walk means the governing file is
// usually not in the directory the caller is standing in, so "a committed
// file" leaves "why this project?" unanswered.
return `the committed ${COMMITTED_CONFIG_FILE} at ${committedPath ?? '(unknown path)'}`;
}
// One wording for every derivation: a form.io host implies api.form.io, and a
// sub-directory project implies its parent. Both are read off the project URL,
// which is what makes the project URL the single configuration.
return `the project URL it was derived from — the base URL is not configured separately unless it cannot be derived`;
};
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 a reader of this output branches 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, so a FORMIO_PROJECT_URL or FORMIO_BASE_URL set there is not listed above. Neither can override this mapping — the environment is the weakest source — so what resolves here is what the server resolves.`,
]
: [];
return ok([
`Project URL: ${resolved.projectUrl}`,
`Base URL: ${resolved.baseUrl}`,
`Source: ${source}`,
...(shadowed.length
? [`Shadowed: ${shadowed.join('; ')} — overridden by the source above.`]
: []),
...caveat,
].join('\n'), notes);
return ok(report.message, report.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) {
// Both "a record exists and cannot be used" errors travel to the caller,
// where runProjectCommand turns them into EXIT_FAILED. Reporting either as
// "nothing configured" would send the caller to `project set`, which writes a
// record the broken one then shadows — the symptom clears and the cause does
// not, which the precedence order then hides.
if (error instanceof ProjectMapUnreadableError ||
error instanceof CommittedConfigUnusableError) {
throw error;
}
return null;
}
}
export function runProjectCommand(args, options = {}) {
const notes = [];
const context = {

@@ -408,11 +407,11 @@ env: options.env ?? process.env,

cacheDir: options.cacheDir,
notes,
};
const subcommand = args[1];
try {
const flags = parseFlags(args.slice(2));
if (subcommand === 'set') {
return runSet(flags, context);
return runSet(parseFlags(args.slice(2), ['project-url', 'base-url', 'cwd']), context);
}
if (subcommand === 'get') {
return runGet(flags, context);
return runGet(parseFlags(args.slice(2), ['cwd']), context);
}

@@ -422,78 +421,14 @@ return fail(`Unknown project subcommand: ${subcommand ?? '(none)'}\n\n${USAGE}`);

catch (error) {
// Everything that throws is a failure to answer, never an answer of
// A URL the caller typed wrong is an answer to act on: ask the user again. It is
// the same situation as the API-root and Open Source refusals, which are exit 1
// for exactly this reason — reporting it as 2 tells every caller to relay and
// stop, abandoning the round that was about to supply the right value.
if (error instanceof InvalidRequestedUrlError) {
return notConfigured(`${error.message}. Ask the user for the URL again and run the command with the corrected value.\n\n${USAGE}`, notes);
}
// Everything else 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));
return fail(error instanceof Error ? error.message : String(error), notes);
}
}
// `--scope repo` writes the committed file rather than the machine-local mapping.
//
// Where it writes is the whole subtlety, and planCommittedConfigWrite owns the
// rule: an amendment lands on the governing file wherever the walk found it, and
// a write recording a DIFFERENT project lands in the directory the caller named,
// because rewriting an ancestor would re-point every sibling folder beside it.
function writeCommittedScope(flags, cwd) {
// Normalized before the placement question is asked: "is this the same project
// the governing file already names?" is a comparison between normalized URLs,
// and an unusable value has to fail as a usage error rather than decide a path.
const requestedProjectUrl = flags['project-url']
? normalizeHttpUrl(flags['project-url'], 'projectUrl')
: undefined;
const { filePath: target, shadows } = planCommittedConfigWrite({
startDir: cwd,
projectUrl: requestedProjectUrl,
});
// Read-modify-write, preserving unknown keys: the file is hand-edited and may
// carry a $schema or a convention key that this command has no business
// discarding.
let existing = {};
if (fs.existsSync(target)) {
try {
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
existing = parsed;
}
}
catch {
// A file too broken to parse is still the file to replace; the values below
// are what the caller asked to record, and refusing here would leave them
// with no way to repair it through this command.
existing = {};
}
}
const projectUrl = requestedProjectUrl ??
(typeof existing.projectUrl === 'string'
? normalizeHttpUrl(existing.projectUrl, `projectUrl in ${target}`)
: undefined);
if (!projectUrl) {
return fail(`--project-url is required for ${target}, which records no project yet.\n\n${USAGE}`);
}
const baseUrl = flags['base-url']
? normalizeHttpUrl(flags['base-url'], 'baseUrl')
: typeof existing.baseUrl === 'string'
? normalizeHttpUrl(existing.baseUrl, `baseUrl in ${target}`)
: undefined;
const next = {
...existing,
projectUrl,
...(baseUrl ? { baseUrl } : {}),
};
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`);
return ok([
`Wrote ${target}`,
`Project URL: ${projectUrl}`,
...(baseUrl ? [`Base URL: ${baseUrl}`] : []),
``,
`This file is committed with the code and takes precedence over the machine-local mapping, so everyone who clones this repository resolves the same project.`,
// Said out loud because the caller asked to record a project and got a file
// in a directory they may not have expected: the ancestor was left alone on
// purpose, so the folders beside this one keep the project it names.
...(shadows
? [
``,
`${shadows.filePath} still names ${shadows.projectUrl} and was left unchanged, so it keeps governing every other directory under it. The new file takes precedence for ${cwd} and everything beneath it. To re-point the whole tree instead, run the same command with --cwd ${path.dirname(shadows.filePath)}.`,
]
: []),
].join('\n'));
}

@@ -15,10 +15,1 @@ export declare const COMMITTED_CONFIG_FILE = "formio.json";

export declare function findCommittedConfig(startDir: string, { onNote }?: FindCommittedConfigOptions): CommittedProjectConfig | undefined;
export interface CommittedWriteRequest {
startDir: string;
projectUrl?: string;
}
export interface CommittedWritePlan {
filePath: string;
shadows?: CommittedProjectConfig;
}
export declare function planCommittedConfigWrite({ startDir, projectUrl, }: CommittedWriteRequest): CommittedWritePlan;

@@ -11,9 +11,17 @@ import fs from 'fs';

export class CommittedConfigUnusableError extends Error {
// Carried as a field, not only inside the message. `project set --scope repo` is
// the documented repair for this file, so the writer has to be able to land on
// the exact path the walk rejected rather than parse it back out of prose.
// Carried as a field, not only inside the message, so a caller that acts on the
// exact path the walk rejected does not have to parse it back out of prose.
filePath;
constructor(filePath, reason) {
// The repair is an edit to the file itself, and the message says exactly what a
// usable one holds. This server never writes a committed file: the file lives in
// the user's repository and is designed for hand authorship and review, and the
// reader below tolerates a formio.json this server did not define — a writer
// landing on such a file either claims it or refuses with nothing runnable to
// offer. An edit is an instruction both a human with an editor and an agent with
// file tools can carry out.
super(`The committed Form.io configuration at ${filePath} cannot be used: ${reason}. ` +
`Fix that file — it takes precedence over the working-directory mapping, so writing a mapping will not override it.`);
`Fix that file — it takes precedence over the working-directory mapping, so writing a mapping will not override it. ` +
`A usable one is a JSON object holding {"projectUrl": "<the project's full URL>"}, plus "baseUrl" only when the deployment cannot be derived from the project URL. ` +
`Edit it directly (ask the user first — this server does not own a committed file), or remove it to let the working-directory mapping govern.`);
this.name = 'CommittedConfigUnusableError';

@@ -23,7 +31,8 @@ this.filePath = filePath;

}
// `formio.json` is a name a Form.io user is likely to already be using for
// something else — an exported form, a project template, a CLI config — and none
// of those documents configure this server. Treating one as a broken
// configuration failed EVERY tool call for that directory, with no source able to
// override it and no repair short of overwriting a file this server does not own.
// No Form.io artifact of this toolset uses the name `formio.json` for anything
// else (exports live in template.json), but the file sits in the user's own
// repository, where a file by that name from some other origin can still exist —
// and treating one as a broken configuration failed EVERY tool call for that
// directory, with no source able to override it and no repair short of
// overwriting a file this server does not own.
//

@@ -101,5 +110,5 @@ // So the question asked first is "is this file addressed to us?", answered by the

const found = readOne(candidate, onNote);
// Only a file addressed to this server ends the walk. One that is not — an
// exported form that happens to be called formio.json — is passed over, so
// a configuration above it still governs.
// Only a file addressed to this server ends the walk. One that is not — a
// stray formio.json this server did not define — is passed over, so a
// configuration above it still governs.
if (found) {

@@ -120,38 +129,1 @@ return found;

}
// Where `project set --scope repo` writes.
//
// Two rules, and the second is what makes per-folder targeting possible at all.
// A write that AMENDS what a file already says — a base URL for the project it
// already names, or the same project again — lands on that file wherever the walk
// found it, so a second `project set` updates the first one's file rather than
// shadowing it from a deeper directory. A write that records a DIFFERENT project
// lands in the directory the caller named: rewriting an ancestor there would
// silently re-point every sibling folder beneath it, which is the opposite of
// what "set the project for this folder" asks for, and it made the per-folder
// targeting the read side advertises impossible to create.
export function planCommittedConfigWrite({ startDir, projectUrl, }) {
const dir = path.resolve(startDir);
const here = path.join(dir, COMMITTED_CONFIG_FILE);
let found;
try {
found = findCommittedConfig(dir);
}
catch (error) {
// A broken file is still the file to rewrite; repairing it is the point — and
// the file to repair is the one the walk found, which the error names. Falling
// back to <startDir>/formio.json instead created a SECOND file below an
// unusable ancestor and left that ancestor governing every sibling directory,
// after CommittedConfigUnusableError had told the user it was the thing to fix.
if (error instanceof CommittedConfigUnusableError) {
return { filePath: error.filePath };
}
throw error;
}
if (!found || found.filePath === here) {
return { filePath: here };
}
if (!projectUrl || projectUrl === found.projectUrl) {
return { filePath: found.filePath };
}
return { filePath: here, shadows: found };
}
export declare const DEFAULT_BASE_URL = "https://api.form.io";
export declare const PROJECT_URL_GUIDANCE: string;
export declare const BASE_URL_UNDERIVABLE = "a project URL that carries no path on a customer domain names its deployment nowhere \u2014 there the deployment is a sibling sub-domain of the same parent domain \u2014 so it cannot be derived";
export declare const BASE_URL_UNRESOLVED_GUIDANCE: string;
export declare function stripTrailingSlashes(url: string): string;
/**
* A URL the CALLER just typed, as opposed to one read from a record.
*
* The distinction decides the exit code, and the codes are a contract the skills
* branch on: a mistyped Project URL is the user typing the wrong thing, so the remedy
* is to re-ask — the same reasoning that already puts the API-root and Open Source
* refusals on exit 1. A malformed value read from disk is a different thing entirely
* and stays "could not answer", because no answer from the user fixes a broken record.
*/
export declare class InvalidRequestedUrlError extends Error {
constructor(message: string);
}
export declare function normalizeHttpUrl(input: string, label: string): string;

@@ -21,2 +34,4 @@ export interface FormioConfig {

cwd?: string;
projectUrlSource?: 'committed' | 'mapping' | 'environment';
committedFilePath?: string;
}

@@ -23,0 +38,0 @@ export declare function getConfig(): FormioConfig;

@@ -16,11 +16,16 @@ export const DEFAULT_BASE_URL = 'https://api.form.io';

].join(' ');
// Why a base URL sometimes has to be asked for. Carried ONLY by the message raised
// when it cannot be derived, which is the one place a reader can act on it.
// Deliberately names no example base URL. A reader who reaches this message has a
// project URL that is NOT on a form.io host — that is why it could not be
// derived — so citing api.form.io here would offer the one value that is certainly
// wrong for them, which is the failure the unresolved state exists to prevent.
// WHY the one project-URL shape that derives nothing has to be asked about, in one
// clause. Every message that refuses or reports that shape composes this rather than
// paraphrasing it: the writers' refusals, the half-configured report, and the
// authentication error below all said it in slightly different words, which is the
// same rule expressed in four places.
export const BASE_URL_UNDERIVABLE = 'a project URL that carries no path on a customer domain names its deployment nowhere — there the deployment is a sibling sub-domain of the same parent domain — so it cannot be derived';
// The read path's fuller version. Deliberately names no example base URL: a reader
// who reaches this message has a project URL that is NOT on a form.io host — that is
// why it could not be derived — so citing api.form.io here would offer the one value
// that is certainly wrong for them, which is the failure the unresolved state exists
// to prevent.
export const BASE_URL_UNRESOLVED_GUIDANCE = [
'A Base URL is the deployment hosting a project, and it is normally derived from the project URL rather than supplied — a project addressed as a sub-directory is served by its parent path, so https://forms.mysite.com/one/two is served by https://forms.mysite.com/one.',
'It cannot be derived from a project URL that carries no path on a customer domain: there the deployment is a sibling sub-domain of the same parent domain, and nothing in the project URL names it. Ask the user for it.',
`But ${BASE_URL_UNDERIVABLE}. Ask the user for it.`,
].join(' ');

@@ -43,2 +48,17 @@ // Form.io URLs are compared and concatenated in several places, so they are

// deployments, two cache entries, or two projects. The parser normalizes both.
/**
* A URL the CALLER just typed, as opposed to one read from a record.
*
* The distinction decides the exit code, and the codes are a contract the skills
* branch on: a mistyped Project URL is the user typing the wrong thing, so the remedy
* is to re-ask — the same reasoning that already puts the API-root and Open Source
* refusals on exit 1. A malformed value read from disk is a different thing entirely
* and stays "could not answer", because no answer from the user fixes a broken record.
*/
export class InvalidRequestedUrlError extends Error {
constructor(message) {
super(message);
this.name = 'InvalidRequestedUrlError';
}
}
export function normalizeHttpUrl(input, label) {

@@ -55,2 +75,14 @@ let parsed;

}
// `api.form.io.` and `api.form.io` are the same host — the trailing root dot is
// legal, resolves identically, and is invisible to a user. Removed HERE, at the one
// place every URL enters, so every downstream comparison sees one spelling: the
// pair rule, the token-cache key, and string equality between records. Handled
// instead inside individual rules, it fixed those rules and left the others
// treating one deployment as two.
//
// The scheme is deliberately NOT normalized: http and https to the same host are
// different endpoints, and one of them sends credentials in plaintext.
if (parsed.hostname.endsWith('.') && parsed.hostname !== '.') {
parsed.hostname = parsed.hostname.replace(/\.$/, '');
}
return stripTrailingSlashes(parsed.href);

@@ -71,6 +103,8 @@ }

// 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.
// defaulted here. There is no default to apply: resolution DERIVES the base URL
// from the project URL — DEFAULT_BASE_URL for a form.io host, the parent path for
// a sub-directory project — and leaves it unresolved for the one shape that
// derives nothing. A value pre-filled here would be indistinguishable from an
// explicit FORMIO_BASE_URL=https://api.form.io, which is a candidate the resolver
// ranks and gates rather than a fallback it reaches for.
const baseUrl = readHttpUrlEnv({ raw: process.env.FORMIO_BASE_URL, name: 'FORMIO_BASE_URL' });

@@ -77,0 +111,0 @@ // Deliberately optional. Clients and directory crawlers launch the server with

@@ -26,3 +26,3 @@ export declare function toMcpTextResult(data: unknown): {

};
export declare function toMcpError(error: unknown): {
export declare function toMcpError(error: unknown, notes?: readonly string[]): {
content: {

@@ -29,0 +29,0 @@ type: "text";

@@ -24,8 +24,12 @@ export function toMcpTextResult(data) {

}
export function toMcpError(error) {
// `notes` lead the message for the same reason a successful answer's do: a note is
// often the CAUSE of the failure being reported — an ignored formio.json on the walk,
// an environment variable a host never expanded — and a failure rendered alone hides
// the first half of the story.
export function toMcpError(error, notes = []) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: message }],
content: [{ type: 'text', text: [...notes, message].filter(Boolean).join('\n') }],
isError: true,
};
}

@@ -219,2 +219,3 @@ /**

export declare const projectMappingShape: {
ok: z.ZodBoolean;
cwd: z.ZodString;

@@ -224,4 +225,33 @@ projectUrl: z.ZodString;

changed: z.ZodBoolean;
ok: z.ZodBoolean;
message: z.ZodString;
};
/**
* What `project_get` reports: the resolved configuration, where each half came
* from, and which of the three answers this is.
*
* `status` carries what the CLI's exit codes carry, and for the same reason —
* callers branch on the outcome, and a substring of the message is not a
* contract. `ok` is the only status with both URLs; `base-url-unresolved` has a
* project and no deployment; `not-configured` has neither.
*/
export declare const projectResolutionShape: {
status: z.ZodEnum<{
ok: "ok";
"not-configured": "not-configured";
"base-url-unresolved": "base-url-unresolved";
}>;
cwd: z.ZodString;
projectUrl: z.ZodOptional<z.ZodString>;
baseUrl: z.ZodOptional<z.ZodString>;
projectUrlSource: z.ZodOptional<z.ZodString>;
baseUrlSource: z.ZodOptional<z.ZodString>;
shadowed: z.ZodArray<z.ZodString>;
unpaired: z.ZodArray<z.ZodString>;
message: z.ZodString;
remedy: z.ZodOptional<z.ZodObject<{
tool: z.ZodString;
arguments: z.ZodRecord<z.ZodString, z.ZodString>;
supply: z.ZodArray<z.ZodString>;
}, z.core.$strip>>;
notes: z.ZodArray<z.ZodString>;
};

@@ -161,8 +161,72 @@ /**

...acknowledgementShape,
// Overridden: for a writer, "it worked" is not "the write reached disk" — a record
// can land and leave the directory no more usable than before, which is the answer
// the caller has to act on.
ok: z
.boolean()
.describe('True when the directory is ready for a deployment call. False means the record WAS written and the directory still resolves no Base URL, because a committed formio.json governs it and supplies none — `message` then carries the report naming the file and the key to add. Do not retry this call; make that edit'),
cwd: z.string().describe('Working directory the mapping is keyed against'),
projectUrl: z.string().describe('Project URL now active for that directory'),
baseUrl: z.string().optional().describe('Deployment URL persisted alongside the project URL'),
projectUrl: z
.string()
.describe('Project URL now ACTIVE for that directory — what the next tool call will target. Usually the project this call just recorded; where a committed formio.json governs the directory, it is the project THAT file names, because the mapping written here takes effect only if that file goes away. The message says so when the two differ'),
baseUrl: z
.string()
.optional()
.describe("The deployment that serves `projectUrl`, from the same record — never one record's project beside another record's deployment"),
changed: z
.boolean()
.describe('False when the requested mapping was already in place and nothing was written'),
.describe('False when the requested mapping was already in place and nothing was written. True means the RECORD changed, which is not necessarily the same as the ACTIVE project changing — a committed formio.json still outranks it'),
};
/**
* What `project_get` reports: the resolved configuration, where each half came
* from, and which of the three answers this is.
*
* `status` carries what the CLI's exit codes carry, and for the same reason —
* callers branch on the outcome, and a substring of the message is not a
* contract. `ok` is the only status with both URLs; `base-url-unresolved` has a
* project and no deployment; `not-configured` has neither.
*/
export const projectResolutionShape = {
status: z
.enum(['ok', 'not-configured', 'base-url-unresolved'])
.describe('Which of the three answers this is: "ok" — both URLs resolved; "not-configured" — nothing is mapped for this directory, so ask the user for a Project URL and record it with project_set; "base-url-unresolved" — the project is recorded and its deployment could not be derived, so ask for the Base URL alone and record it with project_set. Treat anything other than "ok" as blocking.'),
cwd: z.string().describe('Working directory the resolution was performed for'),
projectUrl: z
.string()
.optional()
.describe('Project URL that resolves for that directory; absent when status is not-configured'),
baseUrl: z
.string()
.optional()
.describe('Deployment hosting that project; absent unless status is ok'),
projectUrlSource: z
.string()
.optional()
.describe('Which layer supplied the project URL: committed (a formio.json found by walking up), mapping (the per-directory record project_set writes), or environment (FORMIO_PROJECT_URL, the weakest source)'),
baseUrlSource: z
.string()
.optional()
.describe('Which layer supplied the base URL: committed, mapping, environment, derived (read off the project URL), or unresolved'),
shadowed: z
.array(z.string())
.describe('Layers that could have supplied a URL and were overridden, in precedence order'),
unpaired: z
.array(z.string())
.describe('Values that were overridden by nothing: a deployment recorded with no project beside it, so nothing says which project it serves and it cannot be read. Separate from `shadowed` because the fix differs — a shadowed value is in the wrong record, an unpaired one is in an incomplete record'),
message: z.string().describe('The full human-readable report, including what to do next'),
remedy: z
.object({
tool: z.string().describe('The tool to call — always project_set'),
arguments: z
.record(z.string(), z.string())
.describe('Arguments this report already knows: the cwd, and the project where one applies'),
supply: z
.array(z.string())
.describe('The arguments to ask the USER for — one value, added to the arguments above'),
})
.optional()
.describe('The same remedy the message states, as a call: ask the user for the argument named in `supply`, add it to `arguments`, and call the tool. Absent when the status is "ok" — and absent when no call fixes the state: a deployment missing from a committed formio.json is recorded by editing that file, whose path and key the message names, because this server never writes a committed file. Acting on this rather than parsing the message is what keeps a caller from composing a call the server would refuse — which write reaches the record holding the project depends on that record'),
notes: z
.array(z.string())
.describe('Anything set aside while resolving — an unreadable mapping a committed formio.json made irrelevant, a stored value that is not a URL, or the directory this answer is about when no cwd was passed. A URL the server dropped as unusable at startup is on its stderr rather than here, because it was never a candidate for this resolution'),
};

@@ -5,6 +5,49 @@ export interface ProjectEntry {

export declare class ProjectMapUnreadableError extends Error {
constructor(filePath: string, cause: unknown);
/** The file and cause, so a caller on a path with a DIFFERENT remedy can say its own. */
readonly filePath: string;
readonly reason: string;
constructor(filePath: string, cause: unknown, cwd?: string, scope?: 'file' | 'entry');
}
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;
/**
* What a WRITER sees: an entry that is usable, one that exists and is not, or none.
*
* A reader must fail loudly on a malformed entry — it is configuration that exists and
* cannot be honoured. A writer is replacing that entry, so the same failure would block
* the very repair its error message names. But "unusable" is NOT "absent", and
* collapsing the two gave the writer a second model of precedence: with this directory's
* own entry present and broken, the writer concluded the mapping held no project and
* deferred to the environment, so the reader said "your record is broken, replace it"
* and the writer said "your project comes from the environment" about one state. The
* broken entry is still the record that governs this directory; what it cannot do is
* answer with a value.
*
* File-level damage still throws for both: nothing can be written into a map that cannot
* be parsed without discarding every other directory's mapping.
*/
export type ProjectEntryForWrite = {
status: 'absent';
} | {
status: 'usable';
entry: ProjectEntry;
} | {
status: 'unusable';
reason: string;
};
export declare function readProjectEntryForWrite(cwd: string, cacheDir?: string): ProjectEntryForWrite;
/**
* Why this directory's own entry cannot answer with a project, or undefined if it can.
*
* Structural damage is caught by validateEntry above; a value that is simply not an
* http(s) URL is not, because a record's URLs are validated only where that record wins
* precedence. Both are "present and unusable" to a writer, and both must stop it
* concluding that some other record holds the project.
*/
export declare function unusableRecordProjectUrl(mapped: ProjectEntryForWrite, cwd: string): string | undefined;
export interface ProjectEntryWrite {
cwd: string;
env: Record<string, string>;
cacheDir?: string;
}
export declare function writeProjectEntry({ cwd, env, cacheDir, }: ProjectEntryWrite): void;
import fs from 'fs';
import path from 'path';
import os from 'os';
import { projectCommand } from './cli-launch.js';
import { readHttpUrlEnv } from './config.js';
const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.formio');

@@ -11,6 +13,28 @@ const PROJECTS_FILE = 'projects.json';

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.`);
/** The file and cause, so a caller on a path with a DIFFERENT remedy can say its own. */
filePath;
reason;
// The remedy names BOTH vocabularies, for the same reason the unset-project error
// does: this string reaches an agent holding MCP tools AND a shell reader running
// `project get` — which is what formio-mcp-setup runs, before any tool exists to
// call.
//
// `scope` decides whether a human has to act first, and the two answers are genuinely
// different. Damage to the FILE — unparseable, or not a map at all — stops every
// writer too, so the command below cannot run until somebody repairs or removes it.
// Damage to ONE ENTRY does not: a writer replaces that entry in place, and telling
// the reader to delete the file would destroy every other directory's mapping to fix
// one. Saying "repair first" for both sent readers to do exactly that.
constructor(filePath, cause, cwd, scope = 'file') {
const remap = cwd
? `map ${cwd} again: call project_set with cwd set to it, or run: ${projectCommand(`set --project-url <project_url> --cwd ${cwd}`)}`
: `map the directory again with project_set, or run: ${projectCommand('set --project-url <project_url> --cwd <absolute path>')}`;
const how = scope === 'entry'
? `Only this directory's entry is affected and the rest of the file is intact, so do NOT delete it — that would discard every other directory's mapping. Replace the entry instead: ${remap}.`
: `Repair or delete that file FIRST — no command can write it while it cannot be read, so nothing below will run until it is. Then ${remap}.`;
const reason = cause instanceof Error ? cause.message : String(cause);
super(`Cannot read the Form.io project map at ${filePath}: ${reason}. ` + how);
this.name = 'ProjectMapUnreadableError';
this.filePath = filePath;
this.reason = reason;
}

@@ -25,10 +49,27 @@ }

// 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.
// this module names. The URL-shape rules live in the resolver rather than here,
// and deliberately not in writeProjectEntry either: replacing an entry is the
// documented repair for a mapping holding an unusable URL, so validating the entry
// being replaced would fail the one write that fixes it (see writeProjectEntry).
export function projectMapPath(cacheDir = DEFAULT_CACHE_DIR) {
return path.join(cacheDir, PROJECTS_FILE);
}
function readMap(cacheDir) {
// `cwd` is carried only so the error can print the command that maps it again.
/**
* Every key normalized as the file is loaded.
*
* Normalizing only the keys this release writes strands the ones earlier releases wrote
* raw: a directory whose cwd carried a trailing slash keeps its visible entry and reports
* that nothing is configured, and the next write lands on the normalized twin, leaving a
* dead key beside a live one that nothing ever cleans up. Doing it on load makes the
* migration invisible and idempotent — every key is an absolute directory path, so
* resolving one that is already resolved changes nothing.
*
* A collision (both `/dir` and `/dir/` on disk) keeps the LAST one, which is the same
* rule object-literal parsing already applied to duplicate keys in the file.
*/
function normalizeKeys(map) {
return Object.fromEntries(Object.entries(map).map(([key, entry]) => [mapKey(key), entry]));
}
function readMap(cacheDir, cwd) {
const filePath = projectMapPath(cacheDir);

@@ -45,3 +86,3 @@ let raw;

}
throw new ProjectMapUnreadableError(filePath, error);
throw new ProjectMapUnreadableError(filePath, error, cwd);
}

@@ -53,3 +94,3 @@ let parsed;

catch (error) {
throw new ProjectMapUnreadableError(filePath, error);
throw new ProjectMapUnreadableError(filePath, error, cwd);
}

@@ -61,5 +102,5 @@ // Valid JSON of the wrong shape is unreadable for the same reason a syntax

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)}`));
throw new ProjectMapUnreadableError(filePath, new Error(`expected an object mapping directories to entries, found ${describe(parsed)}`), cwd);
}
return parsed;
return normalizeKeys(parsed);
}

@@ -82,3 +123,3 @@ function writeMap(cacheDir, data) {

const invalid = (reason) => {
throw new ProjectMapUnreadableError(projectMapPath(cacheDir), new Error(`the entry for ${cwd} ${reason}`));
throw new ProjectMapUnreadableError(projectMapPath(cacheDir), new Error(`the entry for ${cwd} ${reason}`), cwd, 'entry');
};

@@ -98,19 +139,90 @@ if (typeof value !== 'object' || value === null || Array.isArray(value)) {

}
/**
* The key one directory has, whatever the caller spelled.
*
* The map is keyed by absolute path, and `/repo` and `/repo/` are the same folder — kept
* raw they were two independent records, so configuring a directory through one spelling
* left the other reporting nothing configured for the directory just configured, and a
* caller whose cwd sometimes carries a slash silently maintained two. `findCommittedConfig`
* resolves its path already, so only the mapping was exposed.
*/
function mapKey(cwd) {
return path.resolve(cwd);
}
export function readProjectEntry(cwd, cacheDir = DEFAULT_CACHE_DIR) {
const map = readMap(cacheDir);
const entry = map[cwd];
return entry === undefined ? null : validateEntry(cacheDir, cwd, entry);
const key = mapKey(cwd);
const map = readMap(cacheDir, key);
const entry = map[key];
return entry === undefined ? null : validateEntry(cacheDir, key, 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]);
export function readProjectEntryForWrite(cwd, cacheDir = DEFAULT_CACHE_DIR) {
const key = mapKey(cwd);
const map = readMap(cacheDir, key);
const entry = map[key];
if (entry === undefined) {
return { status: 'absent' };
}
map[cwd] = { env };
try {
return { status: 'usable', entry: validateEntry(cacheDir, key, entry) };
}
catch (error) {
return { status: 'unusable', reason: error instanceof Error ? error.message : String(error) };
}
}
/**
* Why this directory's own entry cannot answer with a project, or undefined if it can.
*
* Structural damage is caught by validateEntry above; a value that is simply not an
* http(s) URL is not, because a record's URLs are validated only where that record wins
* precedence. Both are "present and unusable" to a writer, and both must stop it
* concluding that some other record holds the project.
*/
export function unusableRecordProjectUrl(mapped, cwd) {
if (mapped.status === 'unusable') {
return mapped.reason;
}
if (mapped.status === 'absent') {
return undefined;
}
const raw = mapped.entry.env.FORMIO_PROJECT_URL;
if (raw === undefined) {
return undefined;
}
let reason;
// Named for where the value LIVES. `readHttpUrlEnv` builds its message around the name
// it is given, and passing the variable's name for a value read out of the mapping
// file produced "Ignoring FORMIO_PROJECT_URL: …" about a record on disk — sending a
// caller to hunt for an environment variable that need not exist, and calling the
// value ignored when it is the reason the call is being refused. In this repository a
// FORMIO_* name means the environment variable and nothing else.
readHttpUrlEnv({
raw,
name: `the project URL recorded for ${cwd}`,
onIgnored: (message) => (reason = message),
});
// The recorded value is quoted back VERBATIM. This entry is the only place it exists,
// the repair replaces it, and a caller told only that the scheme is wrong cannot give
// the user back the project they had been targeting.
return reason === undefined ? undefined : `${reason} The recorded value is ${raw}.`;
}
export function writeProjectEntry({ cwd, env, cacheDir = DEFAULT_CACHE_DIR, }) {
// The key everything else uses. Passed the raw cwd, an unreadable-map failure printed
// a re-map command naming a directory spelled differently from the key the write
// itself would have used.
const key = mapKey(cwd);
// Re-read rather than take a map the caller already read. The file is SHARED —
// every directory on this machine is a key in it — so the window between the
// caller's read and this write is a window in which another process's mapping can
// be written and then silently dropped by writing back a stale snapshot. Reading
// here makes that window as small as the runtime allows. The cost is one read of a
// small file; the alternative trades it for lost entries nobody can attribute.
const map = readMap(cacheDir, key);
// This directory's own entry is NOT validated first. It is what the write replaces,
// and a malformed one holds nothing worth preserving — validating it made the repair
// this error names impossible to perform, which is the whole reason entry-level damage
// is reported differently from damage to the file. The FILE is still validated by
// readMap above, because a write that cannot read it would destroy every other
// directory's mapping; those entries travel through verbatim.
map[key] = { env };
writeMap(cacheDir, map);
}
import { z } from 'zod';
import { FormioConfig, ResolvedFormioConfig } from './config.js';
export declare const cwdSchema: z.ZodOptional<z.ZodString>;
export declare class ProjectNotConfiguredError extends Error {
/**
* Values the resolver set aside on the way to "nothing is configured".
*
* A directory can hold a deployment with no project beside it — the shape an
* earlier release wrote — and that record answers for nothing, so resolution ends
* here. Reported so the caller can say what is there: the remedy for this status
* REPLACES that entry, and a value overwritten without ever being named is a value
* the user cannot get back or account for.
*/
readonly unpaired: string[];
constructor(message: string, unpaired?: string[]);
}
export declare function baseUrlWriteCommand({ source, cwd, projectUrl, }: {
source: Exclude<ProjectUrlSource, 'committed'>;
cwd: string;
projectUrl: string;
}): string;
export declare function requireBaseUrl(config: ResolvedFormioConfig): string;

@@ -29,10 +47,3 @@ export type ProjectUrlSource = 'committed' | 'mapping' | 'environment';

}
export declare function derivesOwnBaseUrl(projectUrl: string): boolean;
export interface EnvironmentBaseUrl {
projectUrl: string;
read: () => string | undefined;
onNote?: (message: string) => void;
}
export declare function usableEnvironmentBaseUrl({ projectUrl, read, onNote, }: EnvironmentBaseUrl): string | undefined;
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;
import path from 'path';
import { z } from 'zod';
import { BASE_URL_UNRESOLVED_GUIDANCE, DEFAULT_BASE_URL, PROJECT_URL_GUIDANCE, normalizeHttpUrl, stripTrailingSlashes, } from './config.js';
import { ProjectMapUnreadableError, projectMapPath, readProjectEntry } from './project-map.js';
import { BASE_URL_UNRESOLVED_GUIDANCE, PROJECT_URL_GUIDANCE, normalizeHttpUrl, stripTrailingSlashes, } from './config.js';
import { ProjectMapUnreadableError, projectMapPath, readProjectEntry, } from './project-map.js';
import { projectCommand } from './cli-launch.js';
import { COMMITTED_CONFIG_FILE, findCommittedConfig, } from './committed-config.js';
import { API_ROOT_NOT_A_PROJECT, ENTERPRISE_ONLY, API_ROOT_IS_NOT_YOUR_DEPLOYMENT, DEPLOYMENT_IS_DERIVED, HOSTED_CLOUD_DEPLOYMENT, NOT_A_HOSTED_PROJECT, classifyPair, deriveBaseUrl, faultedHalf, } from './pair-rule.js';
import { COMMITTED_CONFIG_FILE, CommittedConfigUnusableError, findCommittedConfig, } from './committed-config.js';
const CWD_DESCRIPTION = "User's current working directory as an absolute path. Selects the Form.io project that directory resolves to, by scope, narrowest first: a committed formio.json found by walking up from it, then the working-directory mapping project_set writes, then FORMIO_PROJECT_URL in the environment, which is the weakest of the three. 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 resolve to a different project. No environment variable removes the need for it — the environment is the source a file or a mapping overrides, not the one that overrides them.";

@@ -20,3 +21,25 @@ // One schema for every client. Requiredness cannot live here: whether a cwd is

.describe(CWD_DESCRIPTION);
function missingProjectError({ cwd, mapCwd }) {
// The one throw that means "nothing is configured here" rather than "this could not be
// answered". Given a class so a reporting caller can catch exactly it: catching by
// exclusion turned every OTHER error — including ones added later — into a false
// "nothing configured", whose remedy is an interview that writes a record the real
// problem still shadows.
export class ProjectNotConfiguredError extends Error {
/**
* Values the resolver set aside on the way to "nothing is configured".
*
* A directory can hold a deployment with no project beside it — the shape an
* earlier release wrote — and that record answers for nothing, so resolution ends
* here. Reported so the caller can say what is there: the remedy for this status
* REPLACES that entry, and a value overwritten without ever being named is a value
* the user cannot get back or account for.
*/
unpaired;
constructor(message, unpaired = []) {
super(message);
this.name = 'ProjectNotConfiguredError';
this.unpaired = unpaired;
}
}
function missingProjectError({ cwd, mapCwd, unpaired }) {
// Which directory was searched is the whole answer when no cwd was passed: the

@@ -43,6 +66,13 @@ // server's own is not the user's, so "nothing is configured" without it sends

// if and when it becomes real.
return new Error(`No Form.io project is configured${where}, and no ${COMMITTED_CONFIG_FILE} was found by walking up from it. ` +
// Named in the message as well as carried structurally: this status's remedy
// replaces the entry that strands it.
const stranded = unpaired.length ? `${unpaired.join(' ')} ` : '';
return new ProjectNotConfiguredError(`No Form.io project is configured${where}, and no ${COMMITTED_CONFIG_FILE} was found by walking up from it. ` +
stranded +
`Ask the user for their Project URL, then call ${how}, or run: ${projectCommand(`set --project-url <project_url> --cwd ${cwd ?? mapCwd}`)}. ` +
`To record the target with the code instead — versioned, and shared with everyone who clones it — add a ${COMMITTED_CONFIG_FILE} holding {"projectUrl": "..."} in the application's own folder, or run the same command with --scope repo. ` +
`${PROJECT_URL_GUIDANCE} FORMIO_PROJECT_URL in the server environment supplies one too, but it is the weakest source: a ${COMMITTED_CONFIG_FILE} or a mapping overrides it.`);
`To record the target with the code instead — versioned, and shared with everyone who clones it — add a ${COMMITTED_CONFIG_FILE} holding {"projectUrl": "..."} in the application's own folder. ` +
`${PROJECT_URL_GUIDANCE} FORMIO_PROJECT_URL in the server environment supplies one too, but it is the weakest source: a ${COMMITTED_CONFIG_FILE} or a mapping overrides it.`,
// Carried structurally as well as in the prose: the reporting caller renders its
// own message and would otherwise have to parse this one back out.
unpaired);
}

@@ -58,2 +88,17 @@ // The base URL is required only to authenticate, so the demand for one is raised

// would then re-persist whatever the environment holds.
// The write that records a deployment beside the project, per record that project_set
// can write. A deployment belongs in the record that holds its project, so one command
// cannot answer for both — printed for an environment project, the mapping's own
// `--base-url` call is refused, and a refused remedy costs more than none. The
// committed file is not here at all: this server never writes one, so its remedy is an
// edit the caller makes to the file itself.
//
// Shared by every message that names this write: the report's two vocabularies and the
// authentication error below, which is raised nowhere near a report.
export function baseUrlWriteCommand({ source, cwd, projectUrl, }) {
if (source === 'mapping') {
return projectCommand(`set --base-url <base_url> --cwd ${cwd}`);
}
return projectCommand(`set --project-url ${projectUrl} --base-url <base_url> --cwd ${cwd}`);
}
export function requireBaseUrl(config) {

@@ -63,207 +108,148 @@ if (config.baseUrl) {

}
// Named for the record that holds this project, exactly as the report names it. One
// command for every record is a command that fails for two of the three, and this
// error reaches a caller who has no report in front of them.
const cwd = config.cwd ?? process.cwd();
const source = config.projectUrlSource ?? 'mapping';
const write = source === 'mapping'
? `Set it with project_set (pass baseUrl alongside the cwd), or run: ${baseUrlWriteCommand({ source, cwd, projectUrl: config.projectUrl })}`
: source === 'committed'
? `Record it beside the project, in the committed ${COMMITTED_CONFIG_FILE} that holds it: add "baseUrl": "<base_url>" beside "projectUrl" in ${config.committedFilePath ?? `that file`} — edit it directly; this server reads a committed file and never writes one, and a mapping written under it does not take effect`
: `${config.projectUrl} comes from FORMIO_PROJECT_URL in the environment, which project_set cannot write, so record the pair for this directory: call project_set with cwd ${cwd}, projectUrl ${config.projectUrl} and that baseUrl, or run: ${baseUrlWriteCommand({ source, cwd, projectUrl: config.projectUrl })}`;
throw new Error(`The Base URL for ${config.projectUrl} cannot be determined, so JWT authentication cannot proceed. ` +
`${BASE_URL_UNRESOLVED_GUIDANCE} ` +
`Guessing one would build the portal-login URL and key the cached token against a deployment you do not use. ` +
`Set it with project_set (pass baseUrl alongside the cwd), or run: ${projectCommand(`set --base-url <base_url> --cwd ${config.cwd ?? process.cwd()}`)}. ` +
`The project itself is configured — only its Base URL is missing, so do not ask for the Project URL again. ` +
`${write}. ` +
`The project itself is configured — only its Base URL is missing, so do not ask the user for the Project URL again. ` +
`An API key needs no Base URL and is unaffected.`);
}
// A project URL's host tells us whether DEFAULT_BASE_URL can possibly be right.
// The hosted cloud is the only deployment whose base URL is a constant, and it
// is api.form.io for every project on it — so a project sub-domain of form.io
// implies it, and nothing else does.
function isHostedCloudProject(projectUrl) {
return projectUrl.hostname === 'form.io' || projectUrl.hostname.endsWith('.form.io');
}
// A sub-directory-routed project URL is its deployment plus exactly ONE
// segment — the project's name — so the deployment is the project URL's parent,
// not its origin. Those coincide only for a single-segment path: a deployment
// mounted at https://forms.mysite.com/one serves project `two` at
// https://forms.mysite.com/one/two, and flattening that to the origin would
// build the portal login and ${baseUrl}/current against a host root that serves
// neither. Returns undefined when there is no path to take a parent of.
function deriveBaseUrlFromProjectPath(projectUrl) {
const segments = projectUrl.pathname.split('/').filter(Boolean);
if (segments.length === 0) {
return undefined;
}
const parentPath = segments.slice(0, -1).join('/');
return stripTrailingSlashes(`${projectUrl.origin}${parentPath ? `/${parentPath}` : ''}`);
}
// Whether a project URL names its own deployment, and so needs nothing recorded.
// Asked by the two WRITERS — the project_set tool and `project set` — before they
// fall back to a global FORMIO_BASE_URL. That global is one value answering a
// per-project question: written into a mapping for a project that derives its own
// deployment, it replaces a per-project-correct answer with a stale one that then
// outranks derivation for that directory forever. api.form.io is the value most
// likely to be exported, which makes the failure a portal login sent to a
// deployment the user does not use — the exact substitution the shape rules exist
// to prevent.
// 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 committed file names the project, an
// unreadable map cannot change the answer, so resolution continues 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.
//
// Answered by asking chooseBaseUrl itself, so "derivable" cannot drift from what
// resolution actually derives.
export function derivesOwnBaseUrl(projectUrl) {
return chooseBaseUrl(projectUrl, []).baseUrlSource === 'derived';
}
// Whether FORMIO_BASE_URL may answer for THIS project. Asked by the read path and
// by both writers, so what resolution honours and what a `project set` persists
// cannot drift.
//
// FORMIO_BASE_URL is one global answering a per-project question, and the value
// most likely to be left over in a shell is https://api.form.io — so ranking it
// above derivation sent the portal login and the token-cache key to a deployment
// the user does not use. Suppressing it outright is not the answer either: a
// deployment whose API root is NOT the project URL's parent — project
// https://forms.mysite.com/myproject served by https://forms.mysite.com/api — has
// no other way to say so on a launch configured purely by environment, with no
// writable ~/.formio and no committed file.
//
// So the environment is honoured exactly where it can only be talking about this
// project:
// - a hosted-cloud project ignores it always. Its deployment is api.form.io for
// every project on it, and a *.form.io host is never a base URL, so nothing
// the variable holds can be a correction.
// - a project that derives its own deployment honours it only when the two share
// an ORIGIN. Same host is a statement about this deployment's own layout; a
// different host is the stale global this gate exists to reject.
// - a project that derives nothing honours it unconditionally. That shape names
// no deployment at all, and the environment is the only source a container or
// CI launch has.
export function usableEnvironmentBaseUrl({ projectUrl, read, onNote, }) {
let parsed;
// The VALUES in an entry are deliberately not validated here. A record's URLs are
// checked only where that record wins precedence — see validateWinningRecord —
// because an unusable value in a record that can never win must not fail a
// resolution it takes no part in.
function readMappedEntry({ mapCwd, cacheDir, tolerateUnreadable, onNote, }) {
try {
parsed = new URL(projectUrl);
return readProjectEntry(mapCwd, cacheDir) ?? undefined;
}
catch {
return read();
}
if (isHostedCloudProject(parsed)) {
return undefined;
}
const value = read();
if (!value) {
return undefined;
}
const derived = deriveBaseUrlFromProjectPath(parsed);
if (!derived) {
// Nothing in a path-less customer-domain project URL names its deployment —
// that is a sibling sub-domain — so a global on another host is exactly what
// this shape needs, and cannot be checked against the project URL. One thing
// can still be said about it: a *.form.io host is never a base URL for such a
// project (api.form.io serves the hosted cloud, whose projects take the
// branch above). Left accepted, the value most likely to be stale in a shell
// became the portal-login URL and the token-cache key for a deployment the
// user does not use — the failure this whole precedence exists to prevent.
let parsedValue;
try {
parsedValue = new URL(value);
catch (error) {
if (!tolerateUnreadable || !(error instanceof ProjectMapUnreadableError)) {
throw error;
}
catch {
return value;
}
if (!isHostedCloudProject(parsedValue)) {
return value;
}
onNote?.(`Ignoring FORMIO_BASE_URL (${value}): a form.io host is never the deployment of ${projectUrl}, whose own deployment is a sibling sub-domain that nothing in the project URL names. Record the right one for this directory with project_set.`);
// Its OWN sentence, not the fatal message. That one says "repair or delete that
// file FIRST — nothing below will run until it is" and names a project_set write:
// here nothing needs repairing for this answer to be right, that write cannot run
// while the file is unreadable, and it would not take effect if it could, because
// the committed file governs. A remedy that is impossible, ineffective, and aimed
// at a record this report calls shadowed is worse than no remedy.
onNote(`Ignoring the unreadable Form.io project map at ${error.filePath}: ${error.reason}. ` +
`The committed ${COMMITTED_CONFIG_FILE} names the project for this directory and supplies the pair that resolves, so the map cannot change this answer and nothing needs repairing for it. ` +
`Repair or remove that file when you next need a mapping for a directory this one does not govern.`);
return undefined;
}
if (sameOrigin(value, parsed)) {
return value;
}
onNote?.(`Ignoring FORMIO_BASE_URL (${value}): it is on another host than ${projectUrl}, whose own deployment is ${derived}. One global cannot answer for every project. Record ${value} for this directory with project_set if it is right.`);
return undefined;
}
function sameOrigin(candidate, projectUrl) {
try {
return new URL(candidate).origin === projectUrl.origin;
// Which record a note or a refusal is about, by the name its reader can act on: a
// committed file by path (the upward walk means it is usually not the directory the
// caller named), the mapping by the directory it is keyed to, the environment by the
// variables themselves.
function recordName({ source, mapCwd, committedFilePath, }) {
if (source === 'committed') {
return `the committed ${COMMITTED_CONFIG_FILE} at ${committedFilePath ?? '(unknown path)'}`;
}
catch {
return false;
if (source === 'mapping') {
return `the mapping for ${mapCwd}`;
}
return 'FORMIO_BASE_URL in the environment';
}
// Ordered candidates in, the winner and its provenance out. Precedence is stated
// once, at the call site, as the order of the list.
// The record precedence picked, held to the pair rule at the point of USE. Both
// writers refuse an API-root project URL and a pair that collapses onto one server,
// but a hand-written formio.json, a hand-edited mapping entry, and the environment
// never pass through a writer — and the collapse is about the EFFECTIVE deployment,
// so a derived one (https://api.form.io derives itself) collapses exactly as a
// recorded one does.
//
// The no-candidate case is decided by the shape of the project URL rather than
// by a constant. Three outcomes, and the third is the point: a path-less
// customer project URL leaves the base URL UNRESOLVED instead of silently
// becoming api.form.io. The deployment is a sibling sub-domain of the same
// parent domain, and nothing in the project URL names it — the one thing the
// server's own guidance already says must be asked for rather than derived.
function chooseBaseUrl(resolvedProjectUrl, candidates) {
const chosen = candidates.find(([, value]) => Boolean(value));
if (chosen?.[1]) {
return { baseUrl: chosen[1], baseUrlSource: chosen[0] };
}
let parsed;
try {
parsed = new URL(resolvedProjectUrl);
}
catch {
// Unparseable project URLs are rejected upstream by normalizeHttpUrl; if one
// reaches here it is not a deployment we can name, so say so rather than
// guess.
return { baseUrl: undefined, baseUrlSource: 'unresolved' };
}
// Reported as DERIVED rather than defaulted. The hosted cloud is the one
// deployment whose base URL is a constant, so naming it from a form.io host is a
// derivation from the project URL like any other — and calling it a default
// invited the reading the shape rules exist to remove, that the server guessed.
if (isHostedCloudProject(parsed)) {
return { baseUrl: DEFAULT_BASE_URL, baseUrlSource: 'derived' };
}
const derived = deriveBaseUrlFromProjectPath(parsed);
return derived
? { baseUrl: derived, baseUrlSource: 'derived' }
: { baseUrl: undefined, baseUrlSource: 'unresolved' };
}
// 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];
}
// Returns undefined only for the environment, whose unusable pair is a suggestion to
// ignore with a note rather than configuration to fail on; the caller then falls
// through, and the environment is the last record, so what follows is the interview.
function validateWinningRecord({ source, record, mapCwd, cacheDir, committedFilePath, onNote, }) {
// The mapping is the one record whose URLs reach here unvalidated: committed values
// are normalized by the file's own reader, and environment values by getConfig /
// the CLI's own read. Reported as an unusable 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 — hiding the cause the note names.
const normalize = (value, key) => {
try {
return [key, normalizeHttpUrl(value, key)];
return 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)}`));
throw new ProjectMapUnreadableError(projectMapPath(cacheDir), new Error(`the entry for ${mapCwd} holds an unusable ${key}: ${error instanceof Error ? error.message : String(error)}`), mapCwd, 'entry');
}
}));
}
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 projectUrl = source === 'mapping'
? normalize(record.projectUrl, 'FORMIO_PROJECT_URL')
: stripTrailingSlashes(record.projectUrl);
const recordBaseUrl = source === 'mapping' && record.baseUrl
? normalize(record.baseUrl, 'FORMIO_BASE_URL')
: record.baseUrl && stripTrailingSlashes(record.baseUrl);
const derived = deriveBaseUrl(projectUrl);
const validity = classifyPair(projectUrl, recordBaseUrl || derived);
if (validity === 'ok') {
return {
source,
projectUrl,
...(recordBaseUrl ? { baseUrl: recordBaseUrl } : {}),
...(recordBaseUrl ? {} : derived ? { derived } : {}),
};
}
catch (error) {
if (!tolerateUnreadable || !(error instanceof ProjectMapUnreadableError)) {
throw error;
}
onNote(`${error.message}\nContinuing with the committed ${COMMITTED_CONFIG_FILE}, which supplies both URLs, so the map cannot change the answer.`);
return undefined;
// A deployment that cannot serve this project leaves a usable PROJECT behind, and
// for a hosted-cloud project the right deployment is knowable — it is the derived
// one — so the reader supplies it rather than failing every tool call over a value
// it can work out itself. The writers refuse this pair at the point a user types
// it; here it is already on disk or in the environment, so the answer is to set it
// aside and say which value was ignored, in which record. Silent, the same stale
// value goes on being invisible; fatal, a directory whose correct target is certain
// stops working.
if (faultedHalf(validity) === 'deployment') {
onNote(`Ignoring the Base URL recorded in ${recordName({ source, mapCwd, committedFilePath })} (${recordBaseUrl}): ${validity === 'api-root-deployment'
? API_ROOT_IS_NOT_YOUR_DEPLOYMENT
: validity === 'underivable-mismatch'
? DEPLOYMENT_IS_DERIVED
: HOSTED_CLOUD_DEPLOYMENT}${derived
? ` Resolving ${projectUrl} on ${derived} instead.`
: ` This project's deployment cannot be derived, so it is now unresolved and has to be supplied.`} Remove that value to stop this notice.`);
return { source, projectUrl, ...(derived ? { derived } : {}) };
}
if (source === 'committed') {
throw new CommittedConfigUnusableError(committedFilePath ?? COMMITTED_CONFIG_FILE, validity === 'not-a-hosted-project'
? `its projectUrl is ${projectUrl}, which is not a Form.io project URL. ${NOT_A_HOSTED_PROJECT}`
: validity === 'not-a-project-url'
? `its projectUrl is ${projectUrl}, which is ${API_ROOT_NOT_A_PROJECT}`
: `it records ${projectUrl} as both the projectUrl and the baseUrl. ${ENTERPRISE_ONLY}`);
}
if (source === 'mapping') {
throw new ProjectMapUnreadableError(projectMapPath(cacheDir), new Error(validity === 'not-a-hosted-project'
? `the entry for ${mapCwd} records ${projectUrl} as FORMIO_PROJECT_URL, which is not a Form.io project URL. ${NOT_A_HOSTED_PROJECT}`
: validity === 'not-a-project-url'
? `the entry for ${mapCwd} records ${projectUrl} as FORMIO_PROJECT_URL, which is ${API_ROOT_NOT_A_PROJECT}`
: `the entry for ${mapCwd} records ${projectUrl} as both FORMIO_PROJECT_URL and FORMIO_BASE_URL. ${ENTERPRISE_ONLY}`), mapCwd, 'entry');
}
// Names every variable the record loses, not just the offending one: the whole
// record is discarded, so a FORMIO_BASE_URL set beside a rejected project URL is
// discarded with it and would otherwise be reported nowhere at all.
onNote(validity === 'not-a-hosted-project'
? `Ignoring FORMIO_PROJECT_URL (${projectUrl}): it is not a Form.io project URL. ${NOT_A_HOSTED_PROJECT}`
: validity === 'not-a-project-url'
? `Ignoring FORMIO_PROJECT_URL (${projectUrl}): it is ${API_ROOT_NOT_A_PROJECT}${recordBaseUrl
? ` FORMIO_BASE_URL (${recordBaseUrl}) is part of the same record and is ignored with it.`
: ''}`
: `Ignoring FORMIO_PROJECT_URL and FORMIO_BASE_URL: both name ${projectUrl}, which makes the project its own deployment. ${ENTERPRISE_ONLY}`);
return undefined;
}

@@ -299,11 +285,13 @@ // What every tool handler needs. `project get` needs the provenance too, and

const committed = findCommittedConfig(mapCwd, { onNote });
// An unreadable map is tolerated ONLY when nothing is left for it to decide —
// a committed file supplying BOTH URLs. Otherwise it fails, and the reordering
// is why: the mapping now outranks the environment for both halves, so skipping
// it could resolve a lower-precedence value and target a project the unreadable
// entry would have overridden. Under the old environment-first order the map
// was strictly lower for the project URL, which made skipping safe; it is not
// safe any more.
const committedIsComplete = Boolean(committed?.projectUrl && committed?.baseUrl);
const mappedEnv = readMappedEnv({
// An unreadable map is tolerated exactly where it cannot change the answer, which
// under the pairing rule is wherever a committed file names a project: precedence
// picks one WHOLE record, so a committed project takes its deployment from its own
// file or from derivation and never consults the map. Asking for both halves was the
// pre-pairing question, and it failed every call for the shape the docs recommend
// most — a committed file naming a project whose deployment derives.
//
// The reason is still said out loud, because a broken map that nothing depends on
// today breaks every unpinned directory tomorrow.
const committedIsComplete = Boolean(committed?.projectUrl);
const mappedEntry = readMappedEntry({
mapCwd,

@@ -314,17 +302,70 @@ cacheDir,

});
// Falsy, not nullish: an empty FORMIO_PROJECT_URL is an unanswered prompt, not
// a pinned project.
const projectCandidates = [
['committed', committed?.projectUrl],
['mapping', mappedEnv?.FORMIO_PROJECT_URL],
['environment', envProjectUrl || undefined],
const mappedEnv = mappedEntry?.env;
// One record supplies BOTH halves. Precedence picks the record, narrowest scope
// first; the base URL is that record's own, or derived from that record's project
// URL when it names none.
//
// Halves are never combined across records. A base URL in a losing record belongs to
// that record's project, and pairing it with a winning record's project is what made
// "which project is this deployment for?" a question at read time — a question that
// needed a stored pairing to answer and produced, across three reviews, a deployment
// carried onto the wrong project, a writer reporting a value the reader refused, and
// a remedy that looped. Keeping each record whole removes the question.
//
// Falsy, not nullish: an empty FORMIO_PROJECT_URL is an unanswered prompt, not a
// pinned project.
const records = [
['committed', { projectUrl: committed?.projectUrl, baseUrl: committed?.baseUrl }],
['mapping', { projectUrl: mappedEnv?.FORMIO_PROJECT_URL, baseUrl: mappedEnv?.FORMIO_BASE_URL }],
['environment', { projectUrl: envProjectUrl || undefined, baseUrl: baseConfig.baseUrl }],
];
const chosenProject = projectCandidates.find(([, value]) => Boolean(value));
if (!chosenProject?.[1]) {
// Validity is asked only of the record that WINS, in that record's own repair
// vocabulary. A committed file or a mapping entry holding a pair the writers refuse
// is configuration that exists and cannot be honoured, so it fails loudly, naming
// the record — falling through would silently target whatever weaker source is
// around, which for these two records is exactly the wrong-project failure the
// precedence order exists to prevent. The environment is different in kind: it is a
// suggestion, read tolerantly everywhere else, so an unusable pair there is ignored
// with a note and resolution falls through to the interview.
let winner;
for (const [source, record] of records) {
if (!record.projectUrl) {
continue;
}
const validated = validateWinningRecord({
source,
record: record,
mapCwd,
cacheDir,
committedFilePath: committed?.filePath,
onNote,
});
if (validated) {
winner = validated;
break;
}
}
if (!winner) {
throw missingProjectError({
cwd: cwd || undefined,
mapCwd,
// Each value carries its own consequence. A blanket "the write below replaces
// that entry" was appended to the joined list, which is true of the mapping and
// false of the environment — a variable no write can touch, reported as
// replaced and then reported again, unchanged, forever.
unpaired: [
...(mappedEnv?.FORMIO_BASE_URL
? [
`${mapCwd} has ${mappedEnv.FORMIO_BASE_URL} mapped as a deployment with no project beside it, so nothing says which project it serves. Recording a project for this directory replaces that entry.`,
]
: []),
...(baseConfig.baseUrl
? [
`FORMIO_BASE_URL in the environment names ${baseConfig.baseUrl} with no usable FORMIO_PROJECT_URL beside it, so nothing says which project it serves. No write can change an environment variable: unset it, or set FORMIO_PROJECT_URL beside it, to stop this notice.`,
]
: []),
],
});
}
const [projectUrlSource, projectUrl] = chosenProject;
const { source: projectUrlSource, projectUrl: normalizedProjectUrl } = winner;
// Said out loud for the same reason project_set warns on the write side: the

@@ -336,31 +377,8 @@ // server's process cwd is fixed at spawn and, for a plugin- or desktop-launched

}
const normalizedProjectUrl = stripTrailingSlashes(projectUrl);
// The base URL walks the identical order. Before this it resolved
// mapping-first while the project URL resolved environment-first, so one pair
// resolved in two directions; the borrow-the-mapped-base-URL special case
// existed only to paper over that.
//
// With ONE exception, and it is the same exception the writers already make:
// FORMIO_BASE_URL is a single global answering a per-project question, so it is
// offered only for a project URL that names no deployment of its own. Ranked
// above derivation it beat the per-project-correct answer whenever the variable
// merely existed — api.form.io being the value most likely to be left over in a
// shell — which sent the portal login and the token-cache key to a deployment
// the user does not use. derivesOwnBaseUrl guarded that on the write side only,
// so the read path did it anyway and the guard bought nothing. The committed
// file and the mapping keep their rank: both are per-directory statements
// somebody wrote about THIS project, not one value standing in for every one.
//
// The shadowed global is still reported through baseUrlCandidates below, or
// "my FORMIO_BASE_URL did nothing" has no answer in `project get`'s output.
const environmentBaseUrl = usableEnvironmentBaseUrl({
projectUrl: normalizedProjectUrl,
read: () => baseConfig.baseUrl,
onNote,
});
const { baseUrl, baseUrlSource } = chooseBaseUrl(normalizedProjectUrl, [
['committed', committed?.baseUrl],
['mapping', mappedEnv?.FORMIO_BASE_URL],
['environment', environmentBaseUrl],
]);
const baseUrl = winner.baseUrl ?? winner.derived;
const baseUrlSource = winner.baseUrl
? projectUrlSource
: winner.derived
? 'derived'
: 'unresolved';
return {

@@ -372,2 +390,6 @@ config: {

cwd: mapCwd,
projectUrlSource,
...(projectUrlSource === 'committed' && committed?.filePath
? { committedFilePath: committed.filePath }
: {}),
},

@@ -374,0 +396,0 @@ sources: {

@@ -7,4 +7,4 @@ import fs from 'fs/promises';

import { stripRevisions } from './helpers.js';
import { requireBaseUrl } from '../project-resolver.js';
import { projectCommand } from '../cli-launch.js';
import { baseUrlWriteCommand, requireBaseUrl } from '../project-resolver.js';
import { COMMITTED_CONFIG_FILE } from '../committed-config.js';
// ─── License detection ──────────────────────────────────────────────────────

@@ -19,2 +19,13 @@ // Resolves the deployment's Security Module flag (`sac`) from the anonymous

// nothing to ask; reporting `false` would be a claim about a probe that never ran.
// How to record the deployment, named for the record that holds this project — the
// same split `requireBaseUrl` makes. A committed file is edited by hand; the mapping
// and the environment have commands, and they are different commands.
function baseUrlRemedy(cfg) {
const cwd = cfg.cwd ?? process.cwd();
if (cfg.projectUrlSource === 'committed') {
return `Add "baseUrl": "<base_url>" beside "projectUrl" in ${cfg.committedFilePath ?? `the committed ${COMMITTED_CONFIG_FILE}`} — edit it directly; this server reads a committed file and never writes one`;
}
const source = cfg.projectUrlSource ?? 'mapping';
return `Set it with project_set (pass baseUrl alongside the cwd${source === 'environment' ? ', with the projectUrl' : ''}), or run: ${baseUrlWriteCommand({ source, cwd, projectUrl: cfg.projectUrl })}`;
}
export async function checkRevisionsLicensed(cfg) {

@@ -158,3 +169,8 @@ if (!cfg.baseUrl)

`This one is needed however you authenticate: the probe is an ANONYMOUS request to the deployment, so an API key does not exempt it. ` +
`Set it with project_set (pass baseUrl alongside the cwd), or run: ${projectCommand(`set --base-url <base_url> --cwd ${cfg.cwd ?? process.cwd()}`)}. ` +
// The write that reaches the record holding THIS project. The mapping's own
// `--base-url` call is refused where the project lives in a committed file
// or the environment, so naming it unconditionally named a command the
// writer rejects — the failure `baseUrlWriteCommand` exists to prevent, in
// the one message that did not use it.
`${baseUrlRemedy(cfg)}. ` +
`The project itself is configured — only its Base URL is missing, so do not ask for the Project URL again.`);

@@ -161,0 +177,0 @@ }

@@ -18,5 +18,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

`The Base URL is the deployment hosting the project. It is DERIVED rather than defaulted: ${DEFAULT_BASE_URL} for a project on a form.io host, and for a sub-directory Project URL the value left by dropping its final path segment. For a path-less Project URL on any other host it cannot be derived at all — that shape names no deployment — so it stays unresolved and the first call that needs it fails asking for it alone. Wait for that failure rather than pre-empting it: the Base URL builds the portal-login URL and keys the cached token, so a guessed one sends the login to a deployment the user does not use.`,
"Persist the Project URL with project_set, passing the cwd argument set to the user's current working directory. Every tool resolves its project on each call, so it takes effect immediately with no restart.",
`There are three places a project can come from, and they resolve narrowest-scope-first: a committed ${COMMITTED_CONFIG_FILE} holding {"projectUrl": "...", "baseUrl": "..."} found by walking up from the working directory, then the per-directory mapping project_set writes, then FORMIO_PROJECT_URL and FORMIO_BASE_URL in the environment, which are the weakest.`,
`Prefer the committed ${COMMITTED_CONFIG_FILE} when the target belongs to the application being built: it is tracked in version control, so it survives a clone and is visible in review. Write it with project_set's scope argument set to "repo", which records it in the application's own folder. The per-directory mapping is the right choice when the target belongs to this machine rather than to the code.`,
"Persist the Project URL with project_set, passing the cwd argument set to the user's current working directory. Every tool resolves its project on each call, so it needs no restart — but it governs only where the mapping is the record that wins: under a committed formio.json the mapping is the fallback, and project_set reports the pair that actually resolves plus an `ok` saying whether the directory can now serve a call.",
'Before asking for anything, ask the server: call project_get with cwd set to the user\'s current working directory and branch on the `status` it returns. "ok" means both URLs resolved and you may proceed. "not-configured" means nothing is recorded for that directory — ask for the single value the message names and record it with project_set. "base-url-unresolved" means the project IS recorded and only its deployment is missing — ask the user for the Base URL alone, then do what the report names, which is NOT always a call: where a committed formio.json holds the project the remedy is an edit adding a "baseUrl" key to that file, this server never writes one, and a project_set carrying a baseUrl alone is refused there. A project and its deployment are recorded together in ONE record, so which call that is depends on which record holds the project, and the report names it rather than leaving you to compose one; it already carries the Project URL, so do not re-ask the user for it. A call that fails outright rather than returning a status could not answer at all — an unreadable map, a formio.json that will not parse — so relay it rather than interviewing, because project_set would fail for the same unreported reason.',
`A project and its deployment are recorded TOGETHER, in one record: precedence picks the record and both values come from it, and a write leaves that record holding both — a Project URL that names no deployment (a path-less URL on a customer domain) has to be recorded with its Base URL in the same call. A Base URL identical to the Project URL is refused: that names an Open Source server, and these tools address a project under a deployment. There are three places a project can come from, and they resolve narrowest-scope-first: a committed ${COMMITTED_CONFIG_FILE} holding {"projectUrl": "...", "baseUrl": "..."} found by walking up from the working directory, then the per-directory mapping project_set writes, then FORMIO_PROJECT_URL and FORMIO_BASE_URL in the environment, which are the weakest.`,
`Prefer the committed ${COMMITTED_CONFIG_FILE} when the target belongs to the application being built: it is tracked in version control, so it survives a clone and is visible in review. Write that file yourself, in the application's own folder — a JSON object holding {"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived; this server reads it and never writes it. The per-directory mapping, written with project_set, is the right choice when the target belongs to this machine rather than to the code.`,
'FORMIO_PROJECT_URL no longer pins the server: a committed file or a per-directory mapping overrides it, so project_set can redirect a directory whose environment names a different project. There is no variable that merely offers a project — the environment is already the weakest source, so a project set there suggests without pinning.',

@@ -23,0 +24,0 @@ 'Authentication is implicit: the first authenticated call opens a browser portal login when no valid token is cached.',

@@ -16,2 +16,3 @@ import { registerActionCreateTool } from './action_create.js';

import { registerProjectExportTool } from './project_export.js';
import { registerProjectGetTool } from './project_get.js';
import { registerProjectImportTool } from './project_import.js';

@@ -31,7 +32,17 @@ import { registerProjectSetTool } from './project_set.js';

registerProjectExportTool(server, config);
// Reports what the tools around it will resolve, so it takes the same config
// and the same cwd fallback project_set writes under.
registerProjectGetTool(server, config, { cwd: options.cwd });
registerProjectImportTool(server, config);
// 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 });
// The environment project comes from the already-validated config, not from a
// second read of the environment: an unusable FORMIO_PROJECT_URL has to be
// dropped once, in getConfig, or the tool that repairs a directory's mapping is
// the one it breaks — and a tool reading process.env itself re-emits the
// warning getConfig already made on every call, while its view of what is
// configured drifts from the view the resolver uses on the next one.
registerProjectSetTool(server, {
cwd: options.cwd,
projectUrl: () => config.projectUrl,
baseUrl: () => config.baseUrl,
});
registerRoleCreateTool(server, config);

@@ -38,0 +49,0 @@ registerRoleListTool(server, config);

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export interface ProjectSetOptions {
cwd?: () => string;
projectUrl?: () => string | undefined;
/**
* The environment's deployment, for the REPORT this tool appends when a write cannot
* leave the directory serviceable.
*
* That report is the reader's, and it has to be the report the reader would actually
* give: built from the project alone, its shadowed and unpaired lines could describe
* the same directory differently from project_get's, which is the disagreement this
* tool asking the reader was meant to end.
*/
baseUrl?: () => string | undefined;
}
export declare function registerProjectSetTool(server: McpServer, options?: ProjectSetOptions): void;
import { z } from 'zod';
import { normalizeHttpUrl, readHttpUrlEnv } from '../config.js';
import { readHttpUrlEnv } from '../config.js';
import { planProjectEntry } from '../project-entry-plan.js';
import { API_ROOT_IS_NOT_YOUR_DEPLOYMENT, DEPLOYMENT_IS_DERIVED, NOT_A_HOSTED_PROJECT, API_ROOT_NOT_A_PROJECT, ENTERPRISE_ONLY, HOSTED_CLOUD_DEPLOYMENT, } from '../pair-rule.js';
import { BASE_URL_UNDERIVABLE, COMMITTED_IS_HAND_AUTHORED, strandedBaseUrlClause, } from '../write-refusals.js';
import { cwdSchema } from '../project-resolver.js';
import { reportProject } from '../project-report.js';
import { toMcpStructuredResult } from '../mcp-responses.js';
import { projectMappingShape } from '../output-schemas.js';
import { local } from '../tool-annotations.js';
import { readProjectEntry, writeProjectEntry } from '../project-map.js';
import { COMMITTED_CONFIG_FILE, findCommittedConfig, planCommittedConfigWrite, } from '../committed-config.js';
import { derivesOwnBaseUrl, usableEnvironmentBaseUrl } from '../project-resolver.js';
import fs from 'fs';
import path from 'path';
import { readProjectEntryForWrite, unusableRecordProjectUrl, writeProjectEntry, } from '../project-map.js';
import { COMMITTED_CONFIG_FILE, findCommittedConfig } from '../committed-config.js';
import { TOOL_REMEDIES } from './project-remedies.js';
export function registerProjectSetTool(server, options = {}) {
const getServerCwd = options.cwd ?? (() => process.cwd());
// 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. A host that sets this from its own
// variable — the .mcpb desktop bundle does — passes the literal
// "${FORMIO_BASE_URL}" when it does not expand it: 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.
// The weakest source of a project, and the one this writer only READS. It is
// consulted for a single question — does anything configure a project for this
// directory? — because the base-URL repair deliberately arrives with no
// projectUrl, and a launch configured purely by environment is exactly where
// that repair is most likely to be needed: FORMIO_PROJECT_URL answers for every
// directory and the mapping answers for none. Read tolerantly, like the base
// URL below: an unusable value is not a project, and throwing here would fail
// the one call that can map a usable one.
const getEnvProjectUrl = options.projectUrl ??
(() => readHttpUrlEnv({ raw: process.env.FORMIO_PROJECT_URL, name: 'FORMIO_PROJECT_URL' }));
const getEnvBaseUrl = options.baseUrl ??

@@ -30,238 +33,268 @@ (() => readHttpUrlEnv({ raw: process.env.FORMIO_BASE_URL, name: 'FORMIO_BASE_URL' }));

"The chosen URL is persisted to ~/.formio/projects.json keyed by the cwd argument when provided (or the MCP server process cwd otherwise). Pass the `cwd` argument whenever you know the user's current working directory — the server process cwd is fixed at spawn and may not match where the user actually is.",
'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.',
'Every Form.io tool resolves its project on each call, so a mapping written here needs no restart. It takes effect only where the mapping is the record that WINS, though: under a committed formio.json the mapping is the fallback if that file goes away, and this call reports the pair that actually resolves rather than the one it recorded. Read `ok` and `projectUrl` on the result rather than assuming the write governs.',
'You normally pass only projectUrl. The base URL — which builds the portal-login URL and keys the cached token — is derived from it — https://api.form.io for a project on a form.io host, and the parent path for a project addressed as a sub-directory — so there is nothing to supply. Pass baseUrl ONLY when the server reports that it cannot be determined, which happens for a project URL that carries no path on a customer domain: there the deployment is a sibling sub-domain and nothing in the project URL names it. Do not ask the user for a base URL before the server says it needs one.',
`Pass scope to choose WHERE the choice is recorded. The default "user" writes the machine-local mapping in ~/.formio/projects.json, which is keyed by absolute path and therefore does not survive a clone. "repo" instead writes a committed ${COMMITTED_CONFIG_FILE} — versioned with the code, visible in a diff, and shared with everyone who clones the repository. Use "repo" when the target is a property of the application being built; use the default when it is a property of this machine. "repo" REQUIRES an absolute cwd: that file is found by walking up, so one written into the wrong directory governs every directory beneath it, and the server process cwd is not the user's.`,
`This tool writes the machine-local mapping, which is keyed by absolute path and therefore does not survive a clone. To record the target with the code instead — versioned, visible in a diff, and shared with everyone who clones the repository — write a committed ${COMMITTED_CONFIG_FILE} yourself, in the application's own folder: a JSON object holding {"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived. This server reads that file and never writes it.`,
`Resolution is by scope, narrowest first, and precedence runs: a committed ${COMMITTED_CONFIG_FILE}, then the working-directory mapping, then FORMIO_PROJECT_URL in the environment, which is the weakest of the three. So a mapping written here DOES override an environment value, and a committed file overrides both.`,
].join(' '),
inputSchema: {
// Strict: an argument this tool does not take is REFUSED, not silently dropped.
// `scope` was removed with the committed-file writer, and the previous release's
// own documentation still names it — stripped, that call would write the
// machine-local mapping and report success for a committed write that never
// happened. The CLI whitelists its flags for exactly this reason.
inputSchema: z.strictObject({
projectUrl: z
.url({ protocol: /^https?$/ })
.optional()
.describe('Full URL of the Form.io project to activate. Optional only when this cwd already has a project mapped, in which case omitting it updates the baseUrl alone and leaves the project unchanged — that is how the "Base URL cannot be determined" error is repaired without re-asking for a project URL it did not request. Required when nothing is mapped yet. 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
.string()
.optional()
.describe('User\'s current working directory to key the persisted mapping against. Pass whenever known (e.g. from UserPromptSubmit hook context). Falls back to the MCP server\'s process.cwd() when omitted — except with scope "repo", which requires an absolute path here and refuses without one.'),
scope: z
.enum(['user', 'repo'])
.optional()
.describe(`Where to record the project. "user" (default) writes the machine-local mapping in ~/.formio/projects.json. "repo" writes a committed ${COMMITTED_CONFIG_FILE}, versioned with the code and taking precedence over the mapping. Which file: the nearest existing one at or above cwd when this call amends what it already says — the same project again, or a base URL for it — and a new file in cwd when it records a DIFFERENT project, because rewriting an ancestor would re-point every folder beside this one. "repo" requires an absolute cwd.`),
.describe('Full URL of the Form.io project to activate. Optional when THIS DIRECTORY\'S OWN MAPPING already holds a project: omitting it then updates that record\'s baseUrl alone, which is how the "Base URL cannot be determined" error is repaired without re-asking for a project URL. Where the project is held by another record, a baseUrl alone is refused rather than split from its project — for a committed formio.json the deployment is added to that file by hand (this server never writes one), and for FORMIO_PROJECT_URL in the environment the call must carry BOTH projectUrl and baseUrl, which records the pair here. Required when nothing configures a project at all. On the Form.io hosted cloud it is the project name as a sub-domain of form.io, e.g. https://examples.form.io — never https://api.form.io, which is the Base URL every hosted project shares. 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.'),
// The SAME schema every reader validates against. A write must not accept
// what a read cannot key on.
cwd: cwdSchema.describe("User's current working directory to key the persisted mapping against, as an absolute path. Pass whenever known (e.g. from UserPromptSubmit hook context). Falls back to the MCP server's process.cwd() when omitted, which is fixed at spawn and may not be where the user is."),
baseUrl: z
.url({ protocol: /^https?$/ })
.optional()
.describe('Deployment URL for the Form.io Enterprise Server that hosts this project. It builds the portal-login URL and keys the cached token, so a wrong one fails at login rather than on the request. Usually omitted: it is derived from projectUrl wherever it can be. Supply it when the server reports that it cannot be determined — a project URL with no path on a customer domain, whose deployment is a sibling sub-domain. It MAY carry a path of its own when the deployment is mounted at a sub-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.'),
},
.describe('Deployment URL for the Form.io Enterprise Server that hosts this project. It builds the portal-login URL and keys the cached token, so a wrong one fails at login rather than on the request. Usually omitted: it is derived from projectUrl wherever it can be. Supply it when the server reports that it cannot be determined — a project URL with no path on a customer domain, whose deployment is a sibling sub-domain. It MAY carry a path of its own when the deployment is mounted at a sub-path. Never pass it for a project on a form.io host: those are served by https://api.form.io and by nothing else, so any other value is refused. Persisted per-cwd alongside the project URL, and bound to the project recorded with it, so each directory can target a different deployment and no deployment answers for another project. When omitted and this call does not change the project, the base URL already mapped for this directory is kept — but a call that re-points the directory to a different project keeps nothing, because that value belonged to the project being replaced.'),
}),
outputSchema: projectMappingShape,
// Writes only to the local project map — no Form.io request involved.
annotations: local('Set the active project', false),
}, async ({ projectUrl, cwd, baseUrl: baseUrlArg, scope }) => {
}, async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => {
const entryCwd = cwd ?? getServerCwd();
if (scope === 'repo') {
// The user-scope branch below can key a mapping to the server's own
// process cwd and warn about it, because a mapping is read back by that
// one exact path. A committed file is not: it is found by walking UP, so
// one written into the server's directory — arbitrary for a plugin- or
// desktop-launched server, often a home directory — governs every
// non-git directory beneath it, and no warning undoes that. Refuse.
if (!cwd) {
throw new Error("cwd is required with scope \"repo\". It decides which directory the committed formio.json is written into, and that file governs every directory beneath it — the MCP server's own working directory is fixed at spawn and is usually not the user's. Pass the user's current working directory as an absolute path.");
}
// path.resolve inside the writer would silently re-base a relative value
// on that same server directory, which is the identical misplacement
// arrived at through a value the caller did supply.
if (!path.isAbsolute(cwd)) {
throw new Error(`cwd must be an absolute path with scope "repo" (received: ${cwd}). A relative value would be resolved against the MCP server's own working directory, not the user's.`);
}
return writeCommittedScope({ projectUrl, baseUrl: baseUrlArg, cwd });
const mapped = readProjectEntryForWrite(entryCwd);
// An entry that EXISTS and cannot be honoured is not an absent one. A record's
// URLs are validated where that record WINS — inside the resolver — so a mapping
// entry holding a value that is not an http(s) URL parses cleanly here and is
// fatal there. Softened to `undefined` on this side, the plan below concluded the
// mapping had no project and deferred to the environment, so the reader said
// "this directory's own record is broken, replace it" while the writer said "your
// project comes from the environment" about one state — and the URL the user
// actually intended, readable only from that entry, was overwritten without ever
// being shown to them.
const unusableEntry = unusableRecordProjectUrl(mapped, entryCwd);
// Walked ONCE. The plan needs it to decide whether a deployment-only call has a
// project to be recorded beside, and the result message needs it to say whether
// what was just written takes effect; two walks could disagree only if the tree
// changed mid-call, but they also read the same file twice for one answer.
// With onNote, so a formio.json passed over on the walk is reported here as it
// is by project_get. Silent, a caller that ran this first saw a clean success
// and no hint that a file they expected to govern had been skipped.
const walkNotes = [];
// Every refusal carries them too, not just the success paths: the note that a
// formio.json was passed over is often the CAUSE of the refusal being read.
// A refusal that names a directory the caller did not choose has to say so.
// The success paths already warn; the refusals did not, so one of them told an
// agent to record the pair under the server's own spawn directory — a write
// that succeeds, is read by nothing, and returns the next call to this same
// refusal. project_get answers the identical state by omitting its remedy and
// saying to call again with the user's cwd; this is that answer, in the
// vocabulary of a writer.
// Which directory a refusal may tell the caller to record under. Where the
// caller named none, this answer is about the server's own spawn directory —
// so naming it as the place to write contradicts the warning appended below,
// and the write it invites is one nothing later reads. project_get answers the
// same state by omitting its remedy; this is that answer in a writer's
// vocabulary.
const recordUnder = cwd ? `cwd ${entryCwd}` : "cwd set to the user's own directory";
const fallbackCwdWarning = cwd
? ''
: ` Note: no cwd argument was passed, so ${entryCwd} is the MCP server's own working directory rather than the user's. Call project_set again with cwd set to the user's directory BEFORE recording anything — a record written here would not be found from theirs.`;
// Annotated on the variable so TypeScript narrows after a call: an arrow
// returning `never` only terminates control flow for the checker when the
// binding itself declares that type.
const refuse = (message) => {
throw new Error([...walkNotes, message + fallbackCwdWarning].join('\n'));
};
const committed = findCommittedConfig(entryCwd, {
onNote: (message) => walkNotes.push(message),
});
const plan = planProjectEntry({
cwd: entryCwd,
requested: { projectUrl, baseUrl: baseUrlArg },
record: {
projectUrl: mapped.status === 'usable' ? mapped.entry.env.FORMIO_PROJECT_URL : undefined,
baseUrl: mapped.status === 'usable' ? mapped.entry.env.FORMIO_BASE_URL : undefined,
},
// Where the project lives when this mapping has none.
elsewhere: {
committed,
environment: getEnvProjectUrl(),
},
});
// Before any outcome that names another record: a write carrying no project URL
// has nothing to replace this entry with, and every diagnosis downstream would be
// about a record that does not govern.
// Only where the mapping is the record that WOULD govern. A committed formio.json
// outranks it, so a broken entry beneath one decides nothing — and naming the
// mapping as "the record that governs this directory" there is the same
// wrong-record diagnosis this guard exists to stop, one layer up. The plan's own
// wrong-record branch answers that case, in the committed file's vocabulary.
if (unusableEntry !== undefined && !projectUrl && !committed?.projectUrl) {
refuse(`The mapping for ${entryCwd} holds an unusable value, so it is the record that governs this directory and it cannot answer with a project: ${unusableEntry} Nothing else supplies the project while that entry is on record. Call project_set again with cwd ${entryCwd} and projectUrl set to the project this directory should target, which replaces it. Add baseUrl only if the server then reports it cannot be determined.`);
}
const existing = readProjectEntry(entryCwd);
const previousMapped = existing?.env.FORMIO_PROJECT_URL;
// A committed formio.json configures the project exactly as the mapping
// does, and requireBaseUrl's remedy — "pass baseUrl alongside the cwd" —
// deliberately does not re-ask for a project URL. So the question is
// whether ANY source has one, not whether this map does: asking only the
// map made that remedy fail with "projectUrl is required" for a directory
// whose project the server had just named.
const committedProjectUrl = findCommittedConfig(entryCwd)?.projectUrl;
if (!projectUrl && !baseUrlArg) {
throw new Error('Pass at least one of projectUrl or baseUrl. With a project already mapped for this cwd, either one alone is a valid update.');
if (plan.outcome === 'no-values') {
refuse('Pass at least one of projectUrl or baseUrl. With a project already mapped for this cwd, either one alone is a valid update.');
}
// Required only where nothing configures a project at all.
if (!projectUrl && !previousMapped && !committedProjectUrl) {
throw new Error(`projectUrl is required for ${entryCwd}, which has no project mapped yet. Ask the user for their Project URL and call project_set again.`);
if (plan.outcome === 'project-required') {
refuse(`projectUrl is required for ${entryCwd}, which has no project mapped yet. Ask the user for their Project URL and call project_set again.`);
}
const normalizedPrevious = previousMapped
? // Re-normalized rather than passed through: the stored value is
// hand-editable and predates this validation, and it is about to be
// rewritten as though freshly supplied.
normalizeHttpUrl(previousMapped, `FORMIO_PROJECT_URL mapped for ${entryCwd}`)
: undefined;
// Undefined when only a committed file names the project. Nothing is
// written to the mapping in that case: copying the committed value in would
// make a second record that goes stale the moment the tracked file changes,
// and this call was asked for a deployment, not for a project.
const normalized = projectUrl
? normalizeHttpUrl(projectUrl, 'projectUrl')
: normalizedPrevious;
// What this directory will resolve to once the write lands, whichever record
// holds it — the value the derivation questions below are about. Committed
// first, because that is the order resolveProjectConfig reads them in: a
// committed formio.json outranks the mapping, so when both name a project
// the mapping is not what governs this directory. Preferring the mapping
// here reported a project that does not resolve and asked the base-URL
// questions against it.
const effectiveProjectUrl = (committedProjectUrl ?? normalized);
// A mapping write that lands under a committed file naming a DIFFERENT
// project is still worth making — it is what this directory falls back to
// if that file is removed — but it does not take effect now, and the caller
// has to be told rather than left to wonder why their forms are elsewhere.
const shadowedByCommitted = Boolean(committedProjectUrl) && Boolean(normalized) && committedProjectUrl !== normalized
? ` Note: the committed ${COMMITTED_CONFIG_FILE} for this directory names ${committedProjectUrl}, which outranks the mapping — that stays the active project until the file changes.`
: '';
const repointed = Boolean(normalizedPrevious) && normalized !== normalizedPrevious;
// 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 wherever a host exports a
// FORMIO_BASE_URL: a re-point at a sibling project would silently move a
// self-hosted directory to whatever that global names, 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.
//
// The global link is reached only for a project URL that derives no
// deployment of its own — see derivesOwnBaseUrl. Otherwise the global would
// be written in place of the derivation and outrank it here forever.
//
// The MAPPED link is dropped on the same terms when the directory is being
// re-pointed: that value belongs to the project it was recorded with, so
// carrying it onto a project that names its own deployment leaves one
// deployment answering for another — and, since the mapping outranks
// derivation, answering forever. A re-set that leaves the project alone
// keeps it, because there it is this project's own explicit answer.
const carriedBase = repointed && derivesOwnBaseUrl(effectiveProjectUrl) ? undefined : previousBase;
const resolvedBase = baseUrlArg ||
carriedBase ||
usableEnvironmentBaseUrl({ projectUrl: effectiveProjectUrl, read: 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.
// A record holds a project and its deployment together, so the one project URL
// that names no deployment cannot be recorded alone.
if (plan.outcome === 'base-url-required') {
refuse(`baseUrl is required alongside ${plan.projectUrl}: ${BASE_URL_UNDERIVABLE}.${strandedBaseUrlClause(plan)} Ask the user for the Base URL alone, then call project_set again with both projectUrl and baseUrl.`);
}
// The deployment goes where the project is. Writing it into the mapping while
// the project lives elsewhere would split one configuration across two records.
// The committed file is a record this server reads and never writes, so the
// remedy there is the edit, named file and key.
if (plan.outcome === 'wrong-record') {
refuse(plan.record === 'committed'
? `${plan.projectUrl} is recorded in the committed ${COMMITTED_CONFIG_FILE} at ${plan.filePath}, not in this directory's mapping, so a baseUrl alone has no project to be recorded beside. Add "baseUrl": "<that value>" beside "projectUrl" in that file — ${COMMITTED_IS_HAND_AUTHORED}.`
: `${plan.projectUrl} comes from FORMIO_PROJECT_URL in the environment, so a baseUrl alone has no project to be recorded beside. Call project_set again with ${recordUnder} and BOTH projectUrl ${plan.projectUrl} and that baseUrl, which records the pair in that directory's mapping.`);
}
// Not a shape this toolset serves. Refused before anything is written, because
// the failure it prevents is a string of unexplained 404s much later.
if (plan.outcome === 'not-a-hosted-project') {
refuse(`${plan.url} is not a Form.io project URL. ${NOT_A_HOSTED_PROJECT}`);
}
if (plan.outcome === 'not-a-project-url') {
refuse(`${plan.url} is ${API_ROOT_NOT_A_PROJECT}`);
}
if (plan.outcome === 'open-source-deployment') {
refuse(`${plan.url} is both the Project URL and the Base URL. ${ENTERPRISE_ONLY}`);
}
if (plan.outcome === 'underivable-mismatch') {
refuse(`${plan.baseUrl} is not the deployment for ${plan.projectUrl}. ${DEPLOYMENT_IS_DERIVED} Call project_set again with projectUrl alone.`);
}
if (plan.outcome === 'api-root-deployment') {
refuse(`${plan.baseUrl} is not the deployment for ${plan.projectUrl}. ${API_ROOT_IS_NOT_YOUR_DEPLOYMENT}`);
}
if (plan.outcome === 'hosted-project-foreign-deployment') {
refuse(`${plan.baseUrl} is not the deployment for ${plan.projectUrl}. ${HOSTED_CLOUD_DEPLOYMENT} Call project_set again with projectUrl alone.`);
}
// 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 (normalizedPrevious === normalized && previousBase === baseUrl) {
// Which record holds the project is the difference between a true report
// and a false one. "persisted for <cwd>" is a statement about the mapping,
// and where only a committed formio.json names the project the mapping
// holds nothing — deliberately, so the two records cannot disagree — so
// saying it there told the user the mapping held a project it does not.
const message = (normalized
? `Active project is already ${effectiveProjectUrl} and persisted for ${entryCwd}; no change`
: `Active project is already ${effectiveProjectUrl}, recorded in the committed ${COMMITTED_CONFIG_FILE} rather than in the mapping for ${entryCwd}${baseUrl ? `, whose Base URL ${baseUrl} is already mapped there` : ''}; no change`) +
shadowedByCommitted +
serverCwdWarning;
return toMcpStructuredResult({
ok: true,
message,
cwd: entryCwd,
projectUrl: effectiveProjectUrl,
...(baseUrl && { baseUrl }),
changed: false,
}, message);
// A mapping written under a committed file naming a different project still
// belongs on disk — it is the fallback if that file goes away — but it does not
// take effect now.
const committedProjectUrl = committed?.projectUrl;
// Every committed file GOVERNS, whether or not it names the same project: it
// supplies the pair that resolves, so a mapping written under one is a fallback
// and not what takes effect. Turning this on the file DISAGREEING was the gap —
// a committed file naming the same project left this false, and the deployment
// sentence was written in the active voice about a repair that had not landed.
const shadowed = Boolean(committedProjectUrl);
// What RESOLVES is ASKED OF THE READER — the same reportProject that answers
// project_get, over the state this write just produced. It is the only thing in
// this result a caller does not already know, and the one thing this tool has no
// business deciding for itself.
//
// Deciding it locally gave this writer a second, simpler model of precedence,
// and it was wrong in two ways. It compared only the PROJECT halves, so a
// committed file naming the same project as the write left the just-written
// deployment reported as active while the committed record supplies none — the
// caller is told the repair landed and the next authenticated call fails. And it
// echoed the committed file's recorded deployment without the pair rule, so it
// could report a pair `classifyPair` refuses while the resolver derived a
// different one. Both vanish when the answer has one source.
//
// Asking the RESOLVER directly was still two answers, because the prose kept
// quoting the plan while only the structured half asked: one result claimed a
// Base URL was set and the other carried none, in exactly the case the
// accompanying note was about. Everything the caller reads now comes from here.
//
// Called AFTER the write, so it describes the state the caller is being told
// about. The environment is not passed a base URL because it cannot win here: a
// project is on record for this directory either way, and the mapping and the
// committed file both outrank it.
const settle = () => {
// Kept apart from walkNotes so the shared ones can be dropped: this report
// walks the tree a second time and re-emits every note the write already
// collected, and a caller told twice that the same file was passed over reads
// it as two files.
const reportNotes = [];
const keepNotes = () => {
walkNotes.push(...reportNotes.filter((note) => !walkNotes.includes(note)));
};
let report;
try {
report = reportProject({
cwd: entryCwd,
baseConfig: { projectUrl: getEnvProjectUrl(), baseUrl: getEnvBaseUrl() },
remedies: TOOL_REMEDIES,
notes: reportNotes,
cwdWasNamed: Boolean(cwd),
});
}
catch (error) {
// NOT swallowed. A committed file is checked for shape where it is read and
// for validity only where it wins precedence, so a file holding a URL the
// pair rule refuses parses cleanly here and fails inside the resolver — and
// that failure is the one fact the caller has to act on. Described as "what
// was written" instead, this returned a success naming a pair the governing
// file contradicts, and the next call failed with the reason discarded.
keepNotes();
refuse(error instanceof Error ? error.message : String(error));
}
keepNotes();
return report;
};
// A committed file governs this directory whether or not it names the same project,
// so a mapping write under one does not take effect — the pair project_get reports
// comes from that file. Said for every such write, because a caller cannot be left
// to discover it from a later report.
const shadowedByCommitted = committedProjectUrl
? committedProjectUrl !== plan.projectUrl
? ` Note: the committed ${COMMITTED_CONFIG_FILE} for this directory names ${committedProjectUrl}, which outranks the mapping — that is the active project until the file changes, and what was recorded here is the fallback if it goes away.`
: ` Note: the committed ${COMMITTED_CONFIG_FILE} governs this directory, so it supplies the pair that resolves — this mapping does not take effect while that file is there. To change what resolves, edit that file directly; this server reads a committed file and never writes one.`
: '';
if (plan.outcome !== 'unchanged') {
writeProjectEntry({ cwd: entryCwd, env: plan.entry.env });
}
// FORMIO_PROJECT_URL is omitted where only the committed file names the
// project: writing a copy of it here would make a second record that goes
// stale the moment the tracked file changes.
const env = {
...(normalized && { FORMIO_PROJECT_URL: normalized }),
...(baseUrl && { FORMIO_BASE_URL: baseUrl }),
};
writeProjectEntry(entryCwd, env);
const message = (normalized
? previousMapped
? `Active project set to ${normalized} (was ${previousMapped}; persisted for ${entryCwd})`
: `Active project set to ${normalized}; mapping persisted for ${entryCwd}`
: `Base URL ${baseUrl} persisted for ${entryCwd}; the project stays recorded in the committed ${COMMITTED_CONFIG_FILE} as ${effectiveProjectUrl}`) +
const settled = settle();
// A record that does not take effect is described as RECORDED, never as set: the
// mapping belongs on disk — it is the fallback if the committed file goes away —
// but the deployment sentence used to be written in the active voice regardless,
// so a repair that could not land was reported as landed.
const verb = (active, recorded) => (shadowed ? recorded : active);
// Where the write takes effect these are the same pair; where it does not, the
// prose is about the RECORD and the structured result about what RESOLVES, and
// the note between them says which is which.
const written = shadowed ? plan : settled;
const message = [...walkNotes, ''].join('\n').trimStart() +
(plan.outcome === 'unchanged'
? `${verb('Active project is already', 'Mapping already records')} ${written.projectUrl} on ${written.baseUrl}, persisted for ${entryCwd}; no change`
: plan.setAProject
? plan.previousProjectUrl
? `${verb('Active project set to', 'Recorded')} ${written.projectUrl} on ${written.baseUrl} (was ${plan.previousProjectUrl}; persisted for ${entryCwd})`
: `${verb('Active project set to', 'Recorded')} ${written.projectUrl} on ${written.baseUrl}; mapping persisted for ${entryCwd}`
: `${verb(`Base URL for ${written.projectUrl} set to ${written.baseUrl}`, `Recorded ${written.baseUrl} as the Base URL for ${written.projectUrl}`)}; persisted for ${entryCwd}`) +
(plan.droppedBaseUrl
? ` Replaced ${plan.droppedBaseUrl}, which was recorded as this project's deployment and cannot serve it.`
: '') +
shadowedByCommitted +
serverCwdWarning;
// The write landed and the directory still cannot serve a call — the committed
// file that governs it supplies no deployment, and nothing this tool can write
// will. `ok` is what says so. Left true, this result told the caller the repair
// landed and sent them straight to an authenticated call that fails for a reason
// it already had in hand; raised to isError instead it would have taken the
// resolved pair and `changed` down with it, which is the rest of the answer. So
// the outcome stays a result, and carries the reader's own message — which names
// the file and the key to edit — appended to what was written.
const serviceable = settled.status === 'ok';
const fullMessage = serviceable ? message : [message, '', settled.message].join('\n');
return toMcpStructuredResult({
ok: true,
message,
ok: serviceable,
message: fullMessage,
cwd: entryCwd,
projectUrl: effectiveProjectUrl,
...(baseUrl && { baseUrl }),
changed: true,
}, message);
// Resolved after the write, so it describes the state being reported.
projectUrl: settled.projectUrl,
...(settled.baseUrl ? { baseUrl: settled.baseUrl } : {}),
changed: plan.outcome !== 'unchanged',
}, fullMessage);
});
}
// The tool half of `project set --scope repo`. Kept beside the mapping writer
// rather than shared with the CLI's copy because the two return different shapes —
// a structured MCP result versus a shell result — while the placement rule they
// both obey lives in planCommittedConfigWrite.
function writeCommittedScope({ projectUrl, baseUrl, cwd, }) {
// Normalized before the placement question is asked: "is this the same project
// the governing file already names?" is a comparison between normalized URLs.
const requestedProjectUrl = projectUrl ? normalizeHttpUrl(projectUrl, 'projectUrl') : undefined;
const { filePath: target, shadows } = planCommittedConfigWrite({
startDir: cwd,
projectUrl: requestedProjectUrl,
});
// Read-modify-write so a hand-added key ($schema, a convention comment) is not
// discarded by a tool that only owns two fields.
let existing = {};
if (fs.existsSync(target)) {
try {
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
existing = parsed;
}
}
catch {
existing = {};
}
}
const resolvedProjectUrl = requestedProjectUrl ??
(typeof existing.projectUrl === 'string'
? normalizeHttpUrl(existing.projectUrl, `projectUrl in ${target}`)
: undefined);
if (!resolvedProjectUrl) {
throw new Error(`projectUrl is required for ${target}, which records no project yet. Ask the user for their Project URL and call project_set again with scope "repo".`);
}
const resolvedBaseUrl = baseUrl
? normalizeHttpUrl(baseUrl, 'baseUrl')
: typeof existing.baseUrl === 'string'
? normalizeHttpUrl(existing.baseUrl, `baseUrl in ${target}`)
: undefined;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${JSON.stringify({ ...existing, projectUrl: resolvedProjectUrl, ...(resolvedBaseUrl ? { baseUrl: resolvedBaseUrl } : {}) }, null, 2)}\n`);
const message = `Wrote ${target} (${COMMITTED_CONFIG_FILE}, committed scope): project ${resolvedProjectUrl}` +
`${resolvedBaseUrl ? ` on ${resolvedBaseUrl}` : ''}. ` +
`This file is versioned with the code and takes precedence over the working-directory mapping, so everyone who clones this repository resolves the same project.` +
// The ancestor was left alone on purpose, so the folders beside this one keep
// the project it names. Unsaid, the caller cannot tell this write from one
// that re-pointed the whole tree.
(shadows
? ` ${shadows.filePath} still names ${shadows.projectUrl} and was left unchanged, so it keeps governing every other directory under it; the new file takes precedence for ${cwd} and everything beneath it. To re-point the whole tree instead, call project_set again with cwd set to ${path.dirname(shadows.filePath)}.`
: '');
return toMcpStructuredResult({
ok: true,
message,
cwd,
projectUrl: resolvedProjectUrl,
...(resolvedBaseUrl && { baseUrl: resolvedBaseUrl }),
changed: true,
}, message);
}
{
"name": "@formio/mcp",
"version": "0.11.0",
"version": "0.12.0",
"mcpName": "io.form/formio-mcp",

@@ -5,0 +5,0 @@ "description": "Form.io MCP Server",

@@ -22,3 +22,3 @@ ## Formio MCP server

| --- | --- | --- |
| stdio | `npx -y @formio/mcp@0.11.0` (or `node dist/stdio.js`) | Claude Code, Claude Desktop, Cursor, VS Code, Codex, Windsurf, Cline — anything that speaks MCP over stdio |
| stdio | `npx -y @formio/mcp@0.12.0` (or `node dist/stdio.js`) | Claude Code, Claude Desktop, Cursor, VS Code, Codex, Windsurf, Cline — anything that speaks MCP over stdio |

@@ -36,3 +36,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.

"command": "npx",
"args": ["-y", "@formio/mcp@0.11.0"],
"args": ["-y", "@formio/mcp@0.12.0"],
"env": {

@@ -87,3 +87,3 @@ "FORMIO_PROJECT_URL": "https://your-project.form.io"

Two container-specific notes regardless of auth mode. Prefer `FORMIO_PROJECT_URL` over the `project_set` tool: `project_set` persists its per-directory mapping to `~/.formio/projects.json`, which lives inside the container and is discarded when it exits. And the `cwd` argument every tool takes refers to a path *inside* the container, not on your host.
Two container-specific notes regardless of auth mode. Prefer the `FORMIO_PROJECT_URL` environment variable over the `project_set` tool: `project_set` persists its per-directory mapping to `~/.formio/projects.json`, which lives inside the container and is discarded when it exits. And the `cwd` argument every tool takes refers to a path *inside* the container, not on your host.

@@ -167,3 +167,3 @@ To reuse a token across container runs, mount the cache directory — it must be writable, since the server rewrites the file when a token is refreshed or cleared:

**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.
**5. Open the Tools tab** for the tools this server exposes. Every server lists all 21 — including `project_set` and `project_get`, which are registered for every client.

@@ -221,3 +221,4 @@ ![Tools tab listing hello, form_create, form_get, form_list and the rest](https://raw.githubusercontent.com/formio/ai/main/packages/mcp-server/docs/images/inspector-5-tools.jpg)

| `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` | Persist a Project URL for a directory — in `~/.formio/projects.json` by default, or in a committed `formio.json` with `scope: "repo"`, so the target travels with the code. One server can serve several workspaces. Registered in every client. A mapping written here overrides `FORMIO_PROJECT_URL` in the server environment, which is the weakest source. |
| `project_get` | Report which project a directory resolves to, which deployment hosts it, and which layer supplied each. The preflight to run before the first call that reads or writes — it answers from inside the server, with the same resolver every other tool uses, so no shell command is needed to ask it. Returns a `status` of `ok`, `not-configured`, or `base-url-unresolved`. |
| `project_set` | Persist a Project URL for a directory, in `~/.formio/projects.json`. To record the target with the code instead, write a committed `formio.json` in the application's own folder — the server reads that file and never writes it. One server can serve several workspaces. Registered in every client. A mapping written here overrides `FORMIO_PROJECT_URL` in the server environment, which is the weakest source. |

@@ -239,3 +240,3 @@ ### Diagnostic

The JWT is cached in `~/.formio/mcp-tokens.json` (mode `0600`), keyed by `FORMIO_BASE_URL` — one token covers every project on the same deployment. Tokens are valid for roughly seven days; on a cache hit the server checks expiry locally, then revalidates against the server, and falls back to a fresh login if either check fails.
The JWT is cached in `~/.formio/mcp-tokens.json` (mode `0600`), keyed by the resolved Base URL — one token covers every project on the same deployment. Tokens are valid for roughly seven days; on a cache hit the server checks expiry locally, then revalidates against the server, and falls back to a fresh login if either check fails.

@@ -295,3 +296,3 @@ > **What the agent is granted.** JWT mode hands the agent **the JWT of whoever logs in**, so the agent acts with that person's permissions for the token's lifetime — sign in as an administrator and the agent inherits administrator access to the deployment. An API key is scoped to its project instead. Prefer API-key mode for unattended or shared environments, and sign in as a least-privileged user when using JWT mode.

<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 runs by scope, narrowest first: a committed `formio.json` found by walking up from the caller's `cwd`, then the mapping for that `cwd`, then `FORMIO_PROJECT_URL` in the environment as the weakest source, then the error. Map a directory before any client connects with `npx -y @formio/mcp@0.11.0 project set --project-url <url> --cwd <path>` — the deployment is derived from the project URL wherever it can be, so add `--base-url <url>` only when the server says it cannot be determined. `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, `2` when the command could not answer (a usage error, a malformed URL, an unreadable `~/.formio/projects.json`), and `3` when a project resolved but its Base URL could not be determined — so a caller can tell "nothing here yet" from "this failed" from "half configured, and here is the one value missing".</sub>
<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 runs by scope, narrowest first: a committed `formio.json` found by walking up from the caller's `cwd`, then the mapping for that `cwd`, then `FORMIO_PROJECT_URL` in the environment as the weakest source, then the error. Map a directory before any client connects with `npx -y @formio/mcp@0.12.0 project set --project-url <url> --cwd <path>` — the deployment is derived from the project URL wherever it can be, so add `--base-url <url>` only when the server says it cannot be determined. `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, `2` when the command could not answer (a usage error, a malformed URL, an unreadable `~/.formio/projects.json`), and `3` when a project resolved but its Base URL could not be determined — so a caller can tell "nothing here yet" from "this failed" from "half configured, and here is the one value missing". `project set --cwd <path>` exits `0` when the directory is ready to serve a call, `1` when a named value is still missing, `2` when the command could not answer, and `3` when the record WAS written and the directory still resolves no Base URL — a committed `formio.json` governs it and supplies none, so the remedy is an edit to that file rather than another write.</sub>

@@ -306,3 +307,3 @@ ---

**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.
**Where your data goes.** Only to the Form.io deployment you configure. Every request targets the Project URL and Base URL that resolve for your working directory — 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.

@@ -313,3 +314,3 @@ **What is stored on your machine.** Two files under `~/.formio/`, both written with mode `0600`:

| --- | --- | --- |
| `mcp-tokens.json` | The JWT from the browser login, keyed by `FORMIO_BASE_URL` | You sign in through the browser |
| `mcp-tokens.json` | The JWT from the browser login, keyed by the resolved Base URL | You sign in through the browser |
| `projects.json` | A per-directory map of project and base URLs | `project_set` runs |

@@ -316,0 +317,0 @@