Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/curvy-pets-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@formio/mcp': patch
'@formio/ai': patch
---

Fixed issues with baseURL not getting set correctly.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"husky": "^9.1.7",
"prettier": "^3.5.3",
"tsx": "^4.19.3",
"turbo": "^2.3.3",
"turbo": "^2.9.18",
"typescript-eslint": "^8.30.1"
},
"packageManager": "pnpm@10.33.2",
Expand Down
30 changes: 30 additions & 0 deletions packages/mcp-server/src/__tests__/insecure-tls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { isInsecureTlsEnabled } from '../formio-client.js';

describe('isInsecureTlsEnabled', () => {
it('returns true for "true"', () => {
expect(isInsecureTlsEnabled('true')).toBe(true);
});

it('returns true for "1"', () => {
expect(isInsecureTlsEnabled('1')).toBe(true);
});

it('returns true for mixed/upper case "TRUE"', () => {
expect(isInsecureTlsEnabled('TRUE')).toBe(true);
expect(isInsecureTlsEnabled('True')).toBe(true);
});

it('trims surrounding whitespace', () => {
expect(isInsecureTlsEnabled(' true ')).toBe(true);
expect(isInsecureTlsEnabled(' 1 ')).toBe(true);
});

it('returns false for falsy/other values', () => {
expect(isInsecureTlsEnabled(undefined)).toBe(false);
expect(isInsecureTlsEnabled('')).toBe(false);
expect(isInsecureTlsEnabled('0')).toBe(false);
expect(isInsecureTlsEnabled('false')).toBe(false);
expect(isInsecureTlsEnabled('yes')).toBe(false);
});
});
22 changes: 22 additions & 0 deletions packages/mcp-server/src/__tests__/project-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ describe('resolveProjectConfig', () => {
expect(cfg.apiKey).toBe('abc');
});

it('prefers the mapped base URL over baseConfig.baseUrl in plugin context', () => {
writeProjectEntry('/workspace/pkg-a', {
FORMIO_PROJECT_URL: 'https://mapped.form.io/mapped',
FORMIO_BASE_URL: 'https://mapped.form.io',
});

const cfg = resolveProjectConfig('/workspace/pkg-a', baseConfig);

expect(cfg.baseUrl).toBe('https://mapped.form.io');
expect(cfg.projectUrl).toBe('https://mapped.form.io/mapped');
});

it('falls back to baseConfig.baseUrl when the mapped entry has no base URL', () => {
writeProjectEntry('/workspace/pkg-a', {
FORMIO_PROJECT_URL: 'https://api.form.io/mapped',
});

const cfg = resolveProjectConfig('/workspace/pkg-a', baseConfig);

expect(cfg.baseUrl).toBe('https://api.form.io');
});

it('strips a trailing slash from the mapped project URL', () => {
writeProjectEntry('/workspace/pkg-a', {
FORMIO_PROJECT_URL: 'https://api.form.io/mapped/',
Expand Down
128 changes: 127 additions & 1 deletion packages/mcp-server/src/__tests__/project_set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import os from 'os';
import { readProjectEntry } from '../project-map.js';
import { registerProjectSetTool } from '../tools/project_set.js';

async function createTestClient(options?: { cwd?: () => string }) {
async function createTestClient(options?: {
cwd?: () => string;
baseUrl?: () => string | undefined;
}) {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerProjectSetTool(server, options);

Expand Down Expand Up @@ -128,6 +131,129 @@ describe('project_set tool', () => {
expect(mtimeAfter).toBe(mtimeBefore);
});

it('persists FORMIO_BASE_URL alongside FORMIO_PROJECT_URL when a base URL is available', async () => {
const { client } = await createTestClient({
cwd: () => cwd,
baseUrl: () => 'https://api.form.io',
});

await client.callTool({
name: 'project_set',
arguments: { projectUrl: 'https://api.form.io/next' },
});

expect(readProjectEntry(cwd)).toEqual({
env: {
FORMIO_PROJECT_URL: 'https://api.form.io/next',
FORMIO_BASE_URL: 'https://api.form.io',
},
});
});

it('persists an explicit baseUrl argument, overriding the env global', async () => {
const { client } = await createTestClient({
cwd: () => cwd,
baseUrl: () => 'https://global.form.io',
});

await client.callTool({
name: 'project_set',
arguments: {
projectUrl: 'https://enterprise.form.io/next',
baseUrl: 'https://enterprise.form.io',
},
});

expect(readProjectEntry(cwd)).toEqual({
env: {
FORMIO_PROJECT_URL: 'https://enterprise.form.io/next',
FORMIO_BASE_URL: 'https://enterprise.form.io',
},
});
});

it('strips a trailing slash from an explicit baseUrl argument', async () => {
const { client } = await createTestClient({ cwd: () => cwd });

await client.callTool({
name: 'project_set',
arguments: {
projectUrl: 'https://enterprise.form.io/next',
baseUrl: 'https://enterprise.form.io/',
},
});

expect(readProjectEntry(cwd)).toEqual({
env: {
FORMIO_PROJECT_URL: 'https://enterprise.form.io/next',
FORMIO_BASE_URL: 'https://enterprise.form.io',
},
});
});

it('rejects an invalid baseUrl and does not write', async () => {
const { client } = await createTestClient({ cwd: () => cwd });

const result = await client.callTool({
name: 'project_set',
arguments: {
projectUrl: 'https://api.form.io/next',
baseUrl: 'not a url',
},
});

expect(result.isError).toBe(true);
expect(readProjectEntry(cwd)).toBeNull();
});

it('strips a trailing slash from the base URL before persisting', async () => {
const { client } = await createTestClient({
cwd: () => cwd,
baseUrl: () => 'https://api.form.io/',
});

await client.callTool({
name: 'project_set',
arguments: { projectUrl: 'https://api.form.io/next' },
});

expect(readProjectEntry(cwd)).toEqual({
env: {
FORMIO_PROJECT_URL: 'https://api.form.io/next',
FORMIO_BASE_URL: 'https://api.form.io',
},
});
});

it('rewrites the entry when only the base URL changed for the same project URL', async () => {
const first = await createTestClient({
cwd: () => cwd,
baseUrl: () => 'https://old.form.io',
});
await first.client.callTool({
name: 'project_set',
arguments: { projectUrl: 'https://api.form.io/same' },
});

const second = await createTestClient({
cwd: () => cwd,
baseUrl: () => 'https://new.form.io',
});
const result = await second.client.callTool({
name: 'project_set',
arguments: { projectUrl: 'https://api.form.io/same' },
});

const [first0] = result.content as Array<{ type: string; text: string }>;
expect(first0.text).not.toContain('no change');
expect(readProjectEntry(cwd)).toEqual({
env: {
FORMIO_PROJECT_URL: 'https://api.form.io/same',
FORMIO_BASE_URL: 'https://new.form.io',
},
});
});

it('persists under the explicit cwd argument when provided, ignoring server cwd', async () => {
const serverCwd = '/workspace/server-root';
const userCwd = '/workspace/server-root/packages/inner';
Expand Down
13 changes: 12 additions & 1 deletion packages/mcp-server/src/formio-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import { getAuthHeader } from './auth-header.js';
import { ensureAuthenticated, invalidateJwtCache } from './ensure-auth.js';
import { clearToken } from './token-cache.js';

if (process.env.FORMIO_INSECURE_TLS === 'true') {
// Accept the common truthy spellings ("true", "TRUE", "1") so a self-signed
// deployment (e.g. a local Form.io Enterprise server) is not rejected over a
// trivial casing/format mismatch in the env value.
export function isInsecureTlsEnabled(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === '1';
}

if (isInsecureTlsEnabled(process.env.FORMIO_INSECURE_TLS)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}

Expand Down
8 changes: 5 additions & 3 deletions packages/mcp-server/src/project-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,21 @@ export function resolveProjectConfig(cwd: string, baseConfig: FormioConfig): Res
// authoritative. Standalone context: the map is at best stale leftover from
// prior plugin use in this cwd — ignore it so the user's .mcp.json env wins.
const pluginContext = process.env.FORMIO_PLUGIN_CONTEXT === '1';
const mapped = pluginContext ? readProjectEntry(cwd)?.env.FORMIO_PROJECT_URL : undefined;
const mappedEnv = pluginContext ? readProjectEntry(cwd)?.env : undefined;
const mapped = mappedEnv?.FORMIO_PROJECT_URL;
const projectUrl = mapped ?? baseConfig.projectUrl;
if (!projectUrl) {
throw new Error(
`No Form.io project is mapped for cwd=${cwd}. Call project_set with projectUrl and cwd=${cwd}, or set the FORMIO_PROJECT_URL environment variable, before invoking Form.io tools.`
);
}
if (!baseConfig.baseUrl) {
const baseUrl = mappedEnv?.FORMIO_BASE_URL ?? baseConfig.baseUrl;
if (!baseUrl) {
throw new Error('baseUrl is missing on config. getConfig() should always populate it.');
}
return {
...baseConfig,
baseUrl: baseConfig.baseUrl,
baseUrl: baseUrl.replace(/\/+$/, ''),
projectUrl: projectUrl.replace(/\/+$/, ''),
};
}
32 changes: 25 additions & 7 deletions packages/mcp-server/src/tools/project_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { readProjectEntry, writeProjectEntry } from '../project-map.js';

function normalizeProjectUrl(input: string): string {
function normalizeHttpUrl(input: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(input);
} catch {
throw new Error(`projectUrl must be a valid URL, got: ${input}`);
throw new Error(`${label} must be a valid URL, got: ${input}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`projectUrl must use http or https, got: ${parsed.protocol}`);
throw new Error(`${label} must use http or https, got: ${parsed.protocol}`);
}
return input.replace(/\/+$/, '');
}

export interface ProjectSetOptions {
cwd?: () => string;
baseUrl?: () => string | undefined;
}

export function registerProjectSetTool(server: McpServer, options: ProjectSetOptions = {}) {
const getServerCwd = options.cwd ?? (() => process.cwd());
// Fallback base URL when the caller does not pass one explicitly: the plugin
// user-config sets FORMIO_BASE_URL in the server env (one global value). An
// explicit baseUrl argument lets each cwd map to its own deployment.
const getEnvBaseUrl = options.baseUrl ?? (() => process.env.FORMIO_BASE_URL);
server.tool(
'project_set',
[
Expand All @@ -41,14 +46,23 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt
.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."
),
baseUrl: z
.url({ protocol: /^https?$/ })
.optional()
.describe(
'Deployment URL for the Form.io Enterprise Server that hosts this project, e.g. https://api.form.io. Persisted per-cwd alongside the project URL so each directory can target a different deployment. Falls back to the global FORMIO_BASE_URL when omitted.'
),
},
async ({ projectUrl, cwd }) => {
const normalized = normalizeProjectUrl(projectUrl);
async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => {
const normalized = normalizeHttpUrl(projectUrl, 'projectUrl');
const resolvedBase = baseUrlArg ?? getEnvBaseUrl();
const baseUrl = resolvedBase ? normalizeHttpUrl(resolvedBase, 'baseUrl') : undefined;
const entryCwd = cwd ?? getServerCwd();
const existing = readProjectEntry(entryCwd);
const previousMapped = existing?.env.FORMIO_PROJECT_URL;
const previousBase = existing?.env.FORMIO_BASE_URL;

if (previousMapped === normalized) {
if (previousMapped === normalized && previousBase === baseUrl) {
return {
content: [
{
Expand All @@ -59,7 +73,11 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt
};
}

writeProjectEntry(entryCwd, { FORMIO_PROJECT_URL: normalized });
const env: Record<string, string> = { FORMIO_PROJECT_URL: normalized };
if (baseUrl) {
env.FORMIO_BASE_URL = baseUrl;
}
writeProjectEntry(entryCwd, env);
const message = previousMapped
? `Active project set to ${normalized} (was ${previousMapped}; persisted for ${entryCwd})`
: `Active project set to ${normalized}; mapping persisted for ${entryCwd}`;
Expand Down
4 changes: 2 additions & 2 deletions plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ The plugin prompts for `FORMIO_BASE_URL` and the default `FORMIO_PROJECT_URL` on

| Name | Required | Default | Purpose |
| --- | :-: | --- | --- |
| `FORMIO_BASE_URL` | yes | — | Full base URL of your Form.io deployment (e.g. `https://api.form.io`). Set via plugin user-config. |
| `FORMIO_BASE_URL` | yes\* | — | Full base URL of your Form.io deployment (e.g. `https://api.form.io`). Set via plugin user-config. In plugin mode, only the global fallback the `verify-project-url` hook offers as the default base URL when prompting for an unmapped cwd. |
| `FORMIO_PROJECT_URL` | yes\* | — | Full URL of the Form.io project the MCP server should target. In plugin mode, only used as the pre-filled default the `verify-project-url` hook offers when prompting for an unmapped cwd. |
| `FORMIO_API_KEY` | no | `undefined` | Long-lived project API key. When set, the server skips the browser login flow and attaches `x-token`. |
| `FORMIO_LOGIN_FORM` | no | Auto-resolved | Override the portal login form URL. |

\* The `verify-project-url` hook persists per-cwd mappings to `~/.formio/projects.json` via the `project_set` MCP tool (offering the plugin user-config `formio_default_project_url` as the default). Once a directory is mapped, the MCP server resolves `FORMIO_PROJECT_URL` from that file instead of env.
\* The `verify-project-url` hook persists per-cwd mappings to `~/.formio/projects.json` via the `project_set` MCP tool — prompting for **both** the project URL (default: `formio_default_project_url`) and the deployment/base URL (default: `formio_base_url`). Once a directory is mapped, the MCP server resolves `FORMIO_PROJECT_URL` **and** `FORMIO_BASE_URL` from that file per-cwd, falling back to the global env values only when an entry omits them.

### Authentication modes

Expand Down
6 changes: 3 additions & 3 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
{
"matcher": "*",
"type": "command",
"command": "FORMIO_DEFAULT_PROJECT_URL=\"${user_config.formio_default_project_url}\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/verify-project-url.mjs\""
"command": "FORMIO_DEFAULT_PROJECT_URL=\"${user_config.formio_default_project_url}\" FORMIO_DEFAULT_BASE_URL=\"${user_config.formio_base_url}\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/verify-project-url.mjs\""
}
]
}
Expand All @@ -17,7 +17,7 @@
{
"matcher": "*",
"type": "command",
"command": "FORMIO_DEFAULT_PROJECT_URL=\"${user_config.formio_default_project_url}\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/verify-project-url.mjs\""
"command": "FORMIO_DEFAULT_PROJECT_URL=\"${user_config.formio_default_project_url}\" FORMIO_DEFAULT_BASE_URL=\"${user_config.formio_base_url}\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/verify-project-url.mjs\""
}
]
}
Expand All @@ -28,7 +28,7 @@
"hooks": [
{
"type": "command",
"command": "FORMIO_DEFAULT_PROJECT_URL=\"${user_config.formio_default_project_url}\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/verify-project-url.mjs\""
"command": "FORMIO_DEFAULT_PROJECT_URL=\"${user_config.formio_default_project_url}\" FORMIO_DEFAULT_BASE_URL=\"${user_config.formio_base_url}\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/verify-project-url.mjs\""
}
]
}
Expand Down
Loading
Loading