From 8493beffd9d96d9566c5087bf0263072e367317e Mon Sep 17 00:00:00 2001
From: Travis Tidwell
Date: Fri, 22 May 2026 10:17:12 -0500
Subject: [PATCH 1/4] Adding a formio-sdk skill.
---
CLAUDE.md | 2 +
.../.openspec.yaml | 2 +
.../2026-05-22-add-formio-sdk-skill/design.md | 150 ++++++
.../proposal.md | 29 ++
.../specs/api-skills-validation/spec.md | 51 ++
.../specs/formio-sdk-skill/spec.md | 202 ++++++++
.../2026-05-22-add-formio-sdk-skill/tasks.md | 206 ++++++++
openspec/specs/api-skills-validation/spec.md | 50 ++
openspec/specs/formio-sdk-skill/spec.md | 202 ++++++++
.../skills-validator-formio-sdk.test.ts | 457 ++++++++++++++++++
packages/mcp-server/src/skills-validator.ts | 446 +++++++++++++++++
plugin/skills/formio-sdk/SKILL.md | 102 ++++
plugin/skills/formio-sdk/references/auth.md | 131 +++++
plugin/skills/formio-sdk/references/files.md | 127 +++++
plugin/skills/formio-sdk/references/forms.md | 137 ++++++
.../skills/formio-sdk/references/plugins.md | 171 +++++++
.../skills/formio-sdk/references/projects.md | 103 ++++
.../skills/formio-sdk/references/rendering.md | 272 +++++++++++
plugin/skills/formio-sdk/references/roles.md | 103 ++++
plugin/skills/formio-sdk/references/setup.md | 116 +++++
.../formio-sdk/references/submissions.md | 127 +++++
.../formio-sdk/references/utils-conditions.md | 121 +++++
.../formio-sdk/references/utils-evaluator.md | 113 +++++
.../references/utils-form-traversal.md | 128 +++++
.../formio-sdk/references/utils-jsonlogic.md | 121 +++++
.../formio-sdk/references/utils-logic.md | 143 ++++++
.../references/utils-mask-sanitize.md | 96 ++++
.../formio-sdk/references/utils-misc.md | 131 +++++
28 files changed, 4039 insertions(+)
create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml
create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md
create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/proposal.md
create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/specs/api-skills-validation/spec.md
create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/specs/formio-sdk-skill/spec.md
create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md
create mode 100644 openspec/specs/formio-sdk-skill/spec.md
create mode 100644 packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts
create mode 100644 plugin/skills/formio-sdk/SKILL.md
create mode 100644 plugin/skills/formio-sdk/references/auth.md
create mode 100644 plugin/skills/formio-sdk/references/files.md
create mode 100644 plugin/skills/formio-sdk/references/forms.md
create mode 100644 plugin/skills/formio-sdk/references/plugins.md
create mode 100644 plugin/skills/formio-sdk/references/projects.md
create mode 100644 plugin/skills/formio-sdk/references/rendering.md
create mode 100644 plugin/skills/formio-sdk/references/roles.md
create mode 100644 plugin/skills/formio-sdk/references/setup.md
create mode 100644 plugin/skills/formio-sdk/references/submissions.md
create mode 100644 plugin/skills/formio-sdk/references/utils-conditions.md
create mode 100644 plugin/skills/formio-sdk/references/utils-evaluator.md
create mode 100644 plugin/skills/formio-sdk/references/utils-form-traversal.md
create mode 100644 plugin/skills/formio-sdk/references/utils-jsonlogic.md
create mode 100644 plugin/skills/formio-sdk/references/utils-logic.md
create mode 100644 plugin/skills/formio-sdk/references/utils-mask-sanitize.md
create mode 100644 plugin/skills/formio-sdk/references/utils-misc.md
diff --git a/CLAUDE.md b/CLAUDE.md
index 5b3b397..fe9c896 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,8 @@ The repository ships a Claude skills library at [plugin/skills/](plugin/skills/)
The library's default "build me an app" entry point is [`skills/formio-application/`](skills/formio-application/) — a framework-agnostic orchestrator that runs the full pipeline from plain-language intent through `formio-resource-planner`, the `project_import` MCP tool, and handoff to a framework-specific scaffolding skill. Today the only framework implementor is [`skills/formio-angular/`](skills/formio-angular/) (with its sub-skill [`skills/formio-angular/resources/`](skills/formio-angular/resources/) for per-resource NgModule work); `formio-angular` is no longer a top-level build-an-app skill — it is the Angular-specific implementor that `formio-application` delegates to. Future framework skills (`formio-react`, etc.) add themselves as rows in `skills/formio-application/FRAMEWORK.md`'s registry table; no other change to the orchestrator is required.
+The library also ships [`skills/formio-sdk/`](plugin/skills/formio-sdk/) — a source-derived reference for the `@formio/js` SDK and `@formio/js/utils` Utilities, authored directly from the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) rather than from drift-prone online docs. Reference docs cover SDK setup, auth, forms, submissions, projects, roles, files, plugins, VanillaJS rendering (`Formio.createForm`), and the Utils surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask/sanitize, misc). The skill mandates ESM imports (`import { Formio } from '@formio/js'`, `import { Utils } from '@formio/js/utils'`) and explicit Hosted-vs-SaaS URL configuration; the validator enforces both.
+
The router skill's `description` follows a three-clause template: capability statement, a "Use when the user asks to …" trigger clause, and a "Not for: …" negative-trigger clause disambiguating from `formio-application` (orchestrator) and `formio-resource-planner` (planner). Each reference document includes a `## MCP Tool Preference` section instructing Claude to prefer the MCP server's first-party tools (`form_*`, `role_*`, `project_*`, `authenticate`) when they cover the requested operation.
Authentication: the MCP server uses a browser-based portal-login flow — a short-lived local Express server renders the Form.io portal login form and captures the returned JWT via a `/callback` endpoint; `formioFetch` then attaches `x-jwt-token` on every request. Skills do NOT use PKCE or API-key auth.
diff --git a/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml
new file mode 100644
index 0000000..a5aaf30
--- /dev/null
+++ b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-tdd
+created: 2026-05-22
diff --git a/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md
new file mode 100644
index 0000000..bb2ca26
--- /dev/null
+++ b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md
@@ -0,0 +1,150 @@
+## Context
+
+`plugin/skills/` already hosts source-derived skills for REST endpoints (`formio-api`), app orchestration (`formio-application`), schema planning (`formio-resource-planner`), and framework implementors (`formio-angular`). What is missing is a skill for the runtime JavaScript surface: the `Formio` SDK (CRUD, auth, projects, files, plugins) and the `Utils` namespace (formula evaluation, condition logic, component traversal, masks, sanitization, date helpers, i18n, JSONLogic).
+
+The canonical sources live outside this repo at the Form.io source repository (kept outside this repo at authoring time):
+
+- SDK core: `packages/core/src/sdk/Formio.ts`, `Plugins.ts`, `index.ts`
+- SDK renderer extensions: `packages/formio.js/src/Formio.js` (re-exports + adds icon/template/CDN/license/plugin glue)
+- Utils core: `packages/core/src/utils/` (`Evaluator.ts`, `conditions.ts`, `logic.ts`, `formUtil/`, `jsonlogic/`, `mask.ts`, `sanitize.ts`, `date.ts`, `dom.ts`, `i18n.ts`, `jwtDecode.ts`, `unwind.ts`, `utils.ts`)
+- Utils renderer extensions: `packages/formio.js/src/utils/` (`Evaluator.js`, `formUtils.js`, `builder.js`, `calendarUtils.js`, `ChoicesWrapper.js`, `conditionOperators/`, `i18n.js`, `utils.js`, `index.js`)
+
+Existing online documentation for these surfaces is known to drift from source; the skill MUST be derived from the source tree, not from `help.form.io` or third-party blog posts.
+
+The skill must fit the established library conventions:
+
+- Single activatable `SKILL.md` with three-clause description (capability / "Use when…" / "Not for: …").
+- One reference document per capability group under `references/`, no YAML frontmatter on references.
+- Per-reference `## MCP Tool Preference` block where applicable (CRUD methods that overlap `form_*` / `project_*` / `role_*` tools should defer to MCP first).
+- Strict terminology: `baseUrl` → `FORMIO_BASE_URL`; `projectUrl` → `FORMIO_PROJECT_URL`.
+- Validator coverage in `packages/mcp-server/src/skills-validator.ts` with Vitest enforcement under `pnpm test`.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Teach Claude the actual runtime API surface of `@formio/js` and `@formio/js/utils` as it exists in the Form.io source HEAD, with executable examples.
+- Make canonical import statements (`import { Formio } from '@formio/js'` and `import { Utils } from '@formio/js/utils'`) non-negotiable — examples MUST use them; deep imports (`@formio/core`, `@formio/js/lib/...`) MUST NOT appear.
+- Make Hosted-vs-SaaS URL configuration the first thing the skill asserts. Every code example MUST be preceded (in its reference doc) by an explicit `setBaseUrl` + `setProjectUrl` pair appropriate to the environment.
+- Provide enough worked examples that an agent can implement common flows (login, list submissions, create form, upload file, evaluate condition, traverse components, run JSONLogic) without reading source.
+- Enforce skill structure mechanically via the existing validator infrastructure.
+
+**Non-Goals:**
+
+- Vendoring or building against the Form.io source tree at runtime — source is consulted at authoring time only.
+- Documenting the Form.io REST endpoints themselves — that is `formio-api`'s job. References cross-link there for endpoint shape.
+- Documenting Angular/React framework wrappers — those are owned by `formio-angular` and future framework skills.
+- Eval harness in the first iteration — add later under `plugin/skills/formio-sdk/evals/` once the skill stabilizes.
+
+## Decisions
+
+### Decision 1: Single skill, multiple references — NOT a per-method skill
+
+Adopt the `formio-api` shape: one activatable `SKILL.md` plus reference documents under `references/`. Alternative considered: separate `formio-sdk` and `formio-utils` skills. Rejected because the SDK and Utils are imported from the same package and agents routinely need both in the same edit; splitting forces double-activation and risks divergent guidance on imports / URL config.
+
+### Decision 2: Reference layout — capability-grouped, source-anchored
+
+References will be grouped by user-facing capability, not by source file. Proposed initial set (subject to confirmation while reading source):
+
+SDK references:
+
+- `references/setup.md` — `setBaseUrl`, `setProjectUrl`, Hosted vs SaaS, token storage, plugins registration
+- `references/auth.md` — `Formio.login`, `Formio.logout`, `Formio.currentUser`, `Formio.ssoInit`, JWT handling, OAuth helpers
+- `references/forms.md` — form CRUD via `new Formio(url).loadForm()/saveForm()/deleteForm()`, `loadForms`
+- `references/submissions.md` — submission CRUD, `loadSubmissions` with query, `availableActions`
+- `references/projects.md` — `loadProject`, `saveProject`, project-level helpers
+- `references/roles.md` — `loadRoles`, role assignment
+- `references/files.md` — `Formio.uploadFile`, `downloadFile`, provider config
+- `references/plugins.md` — `Formio.registerPlugin`, plugin lifecycle hooks (`preRequest`, `request`, `wrapRequestPromise`, etc.)
+- `references/rendering.md` — VanillaJS form rendering via `Formio.createForm(element, formSrc, options)`; covers prefill, events (`change`, `submit`, `error`, `nextPage`, `prevPage`), wizard, builder (`Formio.builder`), PDF (`Formio.createForm` with PDF source), read-only mode, custom templates/icons, and offline/local-JSON form sources. Behavior coverage is drawn from `formio.github.io/formio.js/app/examples` but ALL examples in this reference use ESM `import { Formio } from '@formio/js'` — never `` inside a fenced block
+- **THEN** `validateFormioSdkSkill` SHALL emit a `formio_sdk.forbidden_script_tag` issue
diff --git a/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md
new file mode 100644
index 0000000..c78b21d
--- /dev/null
+++ b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md
@@ -0,0 +1,206 @@
+## 1. Validator scaffold (`validateFormioSdkSkill`)
+
+
+### Red
+
+- [x] 1.1 Write failing Vitest test: `validateFormioSdkSkill` exists, is exported from `packages/mcp-server/src/skills-validator.ts`, and returns `[]` when `plugin/skills/formio-sdk/` is absent from a temp library fixture
+- [x] 1.2 Write failing Vitest test: when `plugin/skills/formio-sdk/` exists but `SKILL.md` is absent, `validateFormioSdkSkill` emits exactly one issue with `category: "formio_sdk"` and `rule: "skill_missing"`
+- [x] 1.3 Write failing Vitest test: `validateLibrary` invokes `validateFormioSdkSkill` and propagates its issues into the aggregated result (assert the new issue surfaces through the public entry point)
+
+### Green
+
+- [x] 1.4 Add a `validateFormioSdkSkill(libraryDir)` export with the minimal logic needed to pass 1.1–1.2 (existence check + `skill_missing` emission)
+- [x] 1.5 Wire `validateFormioSdkSkill` into `validateLibrary` so 1.3 passes
+
+### Refactor
+
+- [x] 1.6 Review implementation and refactor as needed
+
+## 2. Frontmatter + three-clause description rules
+
+
+### Red
+
+- [x] 2.1 Write failing test: empty `SKILL.md` (no YAML frontmatter) emits `formio_sdk.frontmatter_missing`
+- [x] 2.2 Write failing test: frontmatter with `name: formio-sdk` but a `description` that lacks `Use when the user asks to` emits `formio_sdk.description_clause` with `clause: "trigger"`
+- [x] 2.3 Write failing test: description with the trigger clause but no `Not for:` clause emits `formio_sdk.description_clause` with `clause: "negative"`
+- [x] 2.4 Write failing test: `Not for:` clause that omits the literal `formio-api` emits `formio_sdk.description_clause` with `clause: "negative"` whose payload names `formio-api`
+- [x] 2.5 Write failing test: description with all three clauses and all four sibling skill names returns no `description_clause` issues
+
+### Green
+
+- [x] 2.6 Implement frontmatter parsing and the three-clause description checks against the failing tests
+- [x] 2.7 Implement the sibling-name check covering `formio-api`, `formio-application`, `formio-resource-planner`, and `formio-angular`
+
+### Refactor
+
+- [x] 2.8 Review implementation and refactor as needed
+
+## 3. Canonical-import + forbidden-import enforcement
+
+
+### Red
+
+- [x] 3.1 Write failing test: a `SKILL.md` that lacks `import { Formio } from '@formio/js'` emits `formio_sdk.canonical_import_missing` with `which: "sdk"`
+- [x] 3.2 Write failing test: a `SKILL.md` that lacks `import { Utils } from '@formio/js/utils'` emits `formio_sdk.canonical_import_missing` with `which: "utils"`
+- [x] 3.3 Write failing test: a reference doc with a fenced block containing `import { Formio } from '@formio/core'` emits `formio_sdk.forbidden_import` with `import_path: "@formio/core"`
+- [x] 3.4 Write failing test: a fenced block containing `import x from '@formio/js/lib/Formio'` emits `formio_sdk.forbidden_import` with `import_path` beginning `@formio/js/lib/`
+- [x] 3.5 Write failing test: a fenced JS block containing `const { Formio } = require('@formio/js');` emits `formio_sdk.forbidden_import` with `import_path: "@formio/js"`
+- [x] 3.6 Write failing test: prose mention of `@formio/core` OUTSIDE any code fence emits zero `formio_sdk.forbidden_import` issues
+- [x] 3.7 Write failing test: a fenced block containing `` emits `formio_sdk.forbidden_script_tag`
+- [x] 3.8 Write failing test: prose mention of `` inside a fenced block
+- **THEN** `validateFormioSdkSkill` SHALL emit a `formio_sdk.forbidden_script_tag` issue
diff --git a/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts b/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts
new file mode 100644
index 0000000..649c1b7
--- /dev/null
+++ b/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts
@@ -0,0 +1,457 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ SDK_CANONICAL_SDK_IMPORT,
+ SDK_CANONICAL_UTILS_IMPORT,
+ SDK_HOSTED_BASE_URL_LITERAL,
+ SDK_HOSTED_PROJECT_URL_LITERAL,
+ SDK_REQUIRED_REFERENCES,
+ SDK_SAAS_BASE_URL_LITERAL,
+ SDK_SAAS_PROJECT_URL_LITERAL,
+ SDK_SKILL_DIR,
+ SKILL_FILENAME,
+ REFERENCES_DIRNAME,
+ validateFormioSdkSkill,
+ validateFormioSdkSkillContent,
+ validateFormioSdkDescription,
+ validateFormioSdkCanonicalImports,
+ validateFormioSdkForbiddenImports,
+ validateFormioSdkScriptTags,
+ validateFormioSdkUrlConfigSkill,
+ validateFormioSdkUrlConfigReference,
+ validateFormioSdkReferenceLayout,
+ validateFormioSdkNavigationTable,
+ validateFormioSdkRenderingReference,
+ validateLibrary,
+} from '../skills-validator.js';
+
+const GOOD_DESCRIPTION =
+ 'Source-derived skill teaching @formio/js and @formio/js/utils. Use when the user asks to call SDK methods or invoke Utils helpers. Not for: REST endpoint shape (see formio-api), building an app (see formio-application), planning resources (see formio-resource-planner), or Angular wrappers (see formio-angular).';
+
+function navTable(): string {
+ const rows = SDK_REQUIRED_REFERENCES.map((r) => `| Something | [${r}](./references/${r}) |`).join(
+ '\n'
+ );
+ return `| Intent | Reference |\n| --- | --- |\n${rows}\n`;
+}
+
+function makeSkillSource(overrides: { description?: string; navTable?: string } = {}): string {
+ const description = overrides.description ?? GOOD_DESCRIPTION;
+ const nav = overrides.navTable ?? navTable();
+ return `---
+name: ${SDK_SKILL_DIR}
+description: ${JSON.stringify(description)}
+---
+
+# Form.io SDK
+
+## Imports
+
+\`\`\`ts
+${SDK_CANONICAL_SDK_IMPORT};
+${SDK_CANONICAL_UTILS_IMPORT};
+\`\`\`
+
+## URL Configuration
+
+### Hosted
+
+\`\`\`ts
+${SDK_CANONICAL_SDK_IMPORT};
+Formio.${SDK_HOSTED_BASE_URL_LITERAL};
+Formio.${SDK_HOSTED_PROJECT_URL_LITERAL};
+\`\`\`
+
+### SaaS
+
+\`\`\`ts
+${SDK_CANONICAL_SDK_IMPORT};
+Formio.${SDK_SAAS_BASE_URL_LITERAL};
+Formio.${SDK_SAAS_PROJECT_URL_LITERAL};
+\`\`\`
+
+## Navigation
+
+${nav}
+`;
+}
+
+function makeReferenceSource(opts: { utils?: boolean; sourcedFrom?: string } = {}): string {
+ const sourcedFrom = opts.sourcedFrom ?? 'packages/core/src/sdk/Formio.ts';
+ const urlConfig = opts.utils
+ ? ''
+ : `## URL Configuration
+
+\`\`\`ts
+${SDK_CANONICAL_SDK_IMPORT};
+Formio.${SDK_HOSTED_BASE_URL_LITERAL};
+Formio.${SDK_HOSTED_PROJECT_URL_LITERAL};
+Formio.${SDK_SAAS_BASE_URL_LITERAL};
+Formio.${SDK_SAAS_PROJECT_URL_LITERAL};
+\`\`\`
+
+`;
+ const importLine = opts.utils ? SDK_CANONICAL_UTILS_IMPORT : SDK_CANONICAL_SDK_IMPORT;
+ return `## Overview
+
+Sourced from \`${sourcedFrom}\`.
+
+## Imports
+
+\`\`\`ts
+${importLine};
+\`\`\`
+
+${urlConfig}## API
+
+- \`thing()\` — does a thing.
+
+## Examples
+
+\`\`\`ts
+${importLine};
+\`\`\`
+`;
+}
+
+function writeSdkSkill(
+ root: string,
+ opts: {
+ skillContents?: string;
+ skipSkill?: boolean;
+ referenceContents?: Partial>;
+ skipReferences?: string[];
+ } = {}
+) {
+ const skillRoot = path.join(root, SDK_SKILL_DIR);
+ fs.mkdirSync(path.join(skillRoot, REFERENCES_DIRNAME), { recursive: true });
+ if (!opts.skipSkill) {
+ fs.writeFileSync(path.join(skillRoot, SKILL_FILENAME), opts.skillContents ?? makeSkillSource());
+ }
+ const skipped = new Set(opts.skipReferences ?? []);
+ for (const ref of SDK_REQUIRED_REFERENCES) {
+ if (skipped.has(ref)) continue;
+ const override = opts.referenceContents?.[ref];
+ const body =
+ override ??
+ makeReferenceSource({
+ utils: ref.startsWith('utils-'),
+ sourcedFrom: ref.startsWith('utils-')
+ ? 'packages/core/src/utils/utils.ts'
+ : 'packages/core/src/sdk/Formio.ts',
+ });
+ // rendering.md requires Formio.createForm call + form.on('submit') + submission =
+ const finalBody =
+ ref === 'rendering.md'
+ ? body +
+ `\n\`\`\`ts
+${SDK_CANONICAL_SDK_IMPORT};
+const form = await Formio.createForm(document.getElementById('formio'), 'https://forms.mysite.com/myproject/myform');
+form.on('submit', (submission) => console.log(submission));
+form.submission = { data: { name: 'prefilled' } };
+\`\`\`\n`
+ : body;
+ fs.writeFileSync(path.join(skillRoot, REFERENCES_DIRNAME, ref), finalBody);
+ }
+}
+
+describe('validateFormioSdkSkill — scaffold', () => {
+ let tmpDir: string;
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-skill-'));
+ });
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('returns [] when plugin/skills/formio-sdk/ is absent', () => {
+ expect(validateFormioSdkSkill(tmpDir)).toEqual([]);
+ });
+
+ it('emits formio_sdk.skill_missing when skill dir exists but SKILL.md is absent', () => {
+ fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true });
+ const issues = validateFormioSdkSkill(tmpDir);
+ expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true);
+ });
+
+ it('validateLibrary invokes validateFormioSdkSkill and surfaces its issues', () => {
+ fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true });
+ const issues = validateLibrary(tmpDir);
+ expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true);
+ });
+
+ it('passes on a well-formed scaffolded skill', () => {
+ writeSdkSkill(tmpDir);
+ const issues = validateFormioSdkSkill(tmpDir);
+ if (issues.length > 0) console.error(JSON.stringify(issues, null, 2));
+ expect(issues).toEqual([]);
+ });
+});
+
+describe('validateFormioSdkSkill — frontmatter + description clauses', () => {
+ it('flags empty frontmatter as formio_sdk.frontmatter_missing', () => {
+ const issues = validateFormioSdkSkillContent(
+ 'formio-sdk/SKILL.md',
+ '# heading only, no frontmatter\n'
+ );
+ expect(issues.some((i) => i.rule === 'formio_sdk.frontmatter_missing')).toBe(true);
+ });
+
+ it('flags description missing "Use when the user asks to" as trigger', () => {
+ const issues = validateFormioSdkDescription('SKILL.md', {
+ name: SDK_SKILL_DIR,
+ description:
+ 'Mentions @formio/js and @formio/js/utils. Not for: formio-api formio-application formio-resource-planner formio-angular',
+ });
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('trigger')
+ )
+ ).toBe(true);
+ });
+
+ it('flags description missing "Not for:" as negative', () => {
+ const issues = validateFormioSdkDescription('SKILL.md', {
+ name: SDK_SKILL_DIR,
+ description: 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things.',
+ });
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('negative')
+ )
+ ).toBe(true);
+ });
+
+ it('flags description that omits formio-api in the Not for clause', () => {
+ const issues = validateFormioSdkDescription('SKILL.md', {
+ name: SDK_SKILL_DIR,
+ description:
+ 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things. Not for: formio-application, formio-resource-planner, formio-angular.',
+ });
+ expect(
+ issues.some(
+ (i) =>
+ i.rule === 'formio_sdk.description_clause' &&
+ i.message.includes('negative') &&
+ i.message.includes('formio-api')
+ )
+ ).toBe(true);
+ });
+
+ it('returns no description_clause issues for a full three-clause description', () => {
+ const issues = validateFormioSdkDescription('SKILL.md', {
+ name: SDK_SKILL_DIR,
+ description: GOOD_DESCRIPTION,
+ });
+ expect(issues.filter((i) => i.rule === 'formio_sdk.description_clause')).toEqual([]);
+ });
+});
+
+describe('validateFormioSdkSkill — canonical + forbidden imports + script tags', () => {
+ it('emits canonical_import_missing sdk when SDK import is absent', () => {
+ const source = `---
+name: ${SDK_SKILL_DIR}
+description: ${JSON.stringify(GOOD_DESCRIPTION)}
+---
+
+\`\`\`ts
+${SDK_CANONICAL_UTILS_IMPORT};
+\`\`\`
+`;
+ const issues = validateFormioSdkCanonicalImports('SKILL.md', source);
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"sdk"')
+ )
+ ).toBe(true);
+ });
+
+ it('emits canonical_import_missing utils when Utils import is absent', () => {
+ const source = `\`\`\`ts\n${SDK_CANONICAL_SDK_IMPORT};\n\`\`\`\n`;
+ const issues = validateFormioSdkCanonicalImports('SKILL.md', source);
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"utils"')
+ )
+ ).toBe(true);
+ });
+
+ it('flags @formio/core import inside fenced block', () => {
+ const source = `\`\`\`ts\nimport { Formio } from '@formio/core';\n\`\`\`\n`;
+ const issues = validateFormioSdkForbiddenImports('ref.md', source);
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/core')
+ )
+ ).toBe(true);
+ });
+
+ it('flags @formio/js/lib deep import', () => {
+ const source = `\`\`\`ts\nimport x from '@formio/js/lib/Formio';\n\`\`\`\n`;
+ const issues = validateFormioSdkForbiddenImports('ref.md', source);
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/js/lib/')
+ )
+ ).toBe(true);
+ });
+
+ it('flags require() of @formio/js', () => {
+ const source = `\`\`\`js\nconst { Formio } = require('@formio/js');\n\`\`\`\n`;
+ const issues = validateFormioSdkForbiddenImports('ref.md', source);
+ expect(
+ issues.some(
+ (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('"@formio/js"')
+ )
+ ).toBe(true);
+ });
+
+ it('does not flag @formio/core mentioned in prose outside a code fence', () => {
+ const source = 'The renderer extends @formio/core SDK methods.\n';
+ expect(validateFormioSdkForbiddenImports('ref.md', source)).toEqual([]);
+ });
+
+ it('flags \n\`\`\`\n`;
+ const issues = validateFormioSdkScriptTags('ref.md', source);
+ expect(issues.some((i) => i.rule === 'formio_sdk.forbidden_script_tag')).toBe(true);
+ });
+
+ it('does not flag world
', {});
+console.log(safe.toString()); // "Hello world
"
+```
+
+### Allow extra tags / attributes
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const safe = Utils.sanitize('Hi', {
+ addTags: ['article'],
+ addAttr: ['data-tag'],
+});
+```
+
+### Manipulate the DOM via helpers
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const banner = document.createElement('div');
+banner.className = 'banner';
+banner.textContent = 'Saved!';
+Utils.dom.prependTo(banner, document.getElementById('formio')!);
+
+setTimeout(() => {
+ Utils.dom.removeChildFrom(banner, document.getElementById('formio')!);
+}, 2000);
+```
diff --git a/plugin/skills/formio-sdk/references/utils-misc.md b/plugin/skills/formio-sdk/references/utils-misc.md
new file mode 100644
index 0000000..62318ec
--- /dev/null
+++ b/plugin/skills/formio-sdk/references/utils-misc.md
@@ -0,0 +1,131 @@
+## Overview
+
+Miscellaneous helpers exported from `@formio/js/utils`: date utilities, i18n, JWT decode, submission unwind/rewind, deep clone, and class override. Sourced from `packages/core/src/utils/date.ts`, `packages/core/src/utils/i18n.ts`, `packages/core/src/utils/jwtDecode.ts`, `packages/core/src/utils/unwind.ts`, `packages/core/src/utils/fastCloneDeep.ts`, `packages/core/src/utils/override.ts`, and `packages/formio.js/src/utils/i18n.js` in the Form.io source code.
+
+## Imports
+
+```ts
+import { Utils } from '@formio/js/utils';
+```
+
+## API
+
+Date (`Utils.date`):
+
+- `Utils.date.dayjs` — the `dayjs` instance with `utc`, `timezone`, `advancedFormat`, and `customParseFormat` plugins pre-loaded.
+- `Utils.date.currentTimezone(): string` — browser/Node timezone via `dayjs.tz.guess()`.
+- `Utils.date.convertFormatToMoment(format: string): string` — translate Angular date format tokens (`MM/dd/yyyy`) into moment/dayjs tokens (`MM/DD/YYYY`).
+
+i18n (`Utils.i18n`):
+
+- `Utils.i18n.t(key: string, options?): string` — translation marker used by Form.io's translation tooling; in the renderer the live translator is exposed on the `Form` instance as `form.i18next`.
+- `Utils.i18n.i18nConfig` — default i18n configuration (separators, default language, baseline resources).
+- `Utils.i18n.coreEnTranslation` — the English translation dictionary shipped with `@formio/core`.
+
+The renderer also exports the `I18n` class (`packages/formio.js/src/utils/i18n.js`):
+
+- `new I18n(languages?: object)` — manage a language dictionary at runtime.
+- `setLanguages(languages)` / `changeLanguage(language)` / `t(key, defaultValue?)`.
+
+JWT (`Utils.jwtDecode`):
+
+- `Utils.jwtDecode(token: string, options?: { header?: boolean }): object` — decode the payload (default) or the header (`{ header: true }`).
+
+Submission unwind / rewind (`Utils.unwind`, `Utils.rewind`) — deprecated but still exported:
+
+- `Utils.unwind(form, submission): Submission[]` — explode nested array data into one submission per row.
+- `Utils.rewind(submissions): Submission` — fold rows back into a nested submission.
+
+Cloning and override:
+
+- `Utils.fastCloneDeep(obj: any): any` — `JSON.parse(JSON.stringify(obj))` with error handling; returns `null` on failure.
+- `Utils.override(classObj: any, extenders: any): void` — replace prototype methods/properties on a class. Each entry in `extenders` is either a function (replaces the method) or a property descriptor.
+
+## Examples
+
+### Decode a JWT payload
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const claims = Utils.jwtDecode(localStorage.getItem('myapp.jwt') ?? '');
+console.log(claims.user, claims.exp);
+```
+
+### Translate an Angular date format
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const dayjsFormat = Utils.date.convertFormatToMoment('MM/dd/yyyy h:mm a');
+console.log(dayjsFormat); // "MM/DD/YYYY h:mm a"
+```
+
+### Format the current time in the browser's timezone
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const now = Utils.date.dayjs().tz(Utils.date.currentTimezone()).format('YYYY-MM-DD HH:mm z');
+console.log(now);
+```
+
+### Switch language at runtime via the renderer's I18n
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const i18n = new Utils.I18n({
+ en: { hello: 'Hello' },
+ fr: { hello: 'Bonjour' },
+});
+i18n.changeLanguage('fr');
+console.log(i18n.t('hello')); // "Bonjour"
+```
+
+### Deep-clone a submission safely
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const draft = Utils.fastCloneDeep(submission);
+if (draft) {
+ draft.data.firstName = 'Edited';
+}
+```
+
+### Override a component class method
+
+```ts
+import { Utils } from '@formio/js/utils';
+import { Formio } from '@formio/js';
+
+const TextField = Formio.Components.components.textfield;
+
+Utils.override(TextField, {
+ getValue(this: any) {
+ const v = this._origGetValue();
+ return typeof v === 'string' ? v.trim() : v;
+ },
+ _origGetValue: TextField.prototype.getValue,
+});
+```
+
+### Unwind a submission with a nested array
+
+```ts
+import { Utils } from '@formio/js/utils';
+
+const submission = {
+ data: {
+ customer: 'Acme',
+ items: [
+ { sku: 'A1', qty: 2 },
+ { sku: 'B2', qty: 5 },
+ ],
+ },
+};
+
+const rows = Utils.unwind(form, submission);
+console.log(rows.length); // 2 (one per item)
+```
From db6d9638d7ba7daf3f8da2423dc8b9323b0042a2 Mon Sep 17 00:00:00 2001
From: Travis Tidwell
Date: Wed, 27 May 2026 13:45:40 -0500
Subject: [PATCH 2/4] Updated skill to resolve code review comments.
---
CLAUDE.md | 4 +-
.../2026-04-16-add-user-auth/design.md | 2 +-
.../2026-04-16-add-user-auth/proposal.md | 2 +-
.../specs/token-validation/spec.md | 10 +-
.../archive/2026-04-16-add-user-auth/tasks.md | 2 +-
.../specs/formio-application-skill/spec.md | 4 +-
.../specs/formio-application-skill/spec.md | 4 +-
openspec/specs/token-validation/spec.md | 6 +-
.../__tests__/formio-angular-layout.test.ts | 273 ------
.../formio-application-layout.test.ts | 475 ---------
.../src/__tests__/skills-library.test.ts | 393 --------
.../skills-validator-formio-sdk.test.ts | 457 ---------
packages/mcp-server/src/skills-validator.ts | 922 ------------------
packages/mcp-server/tsconfig.build.json | 2 +-
.../formio-api/references/platform-auth.md | 4 +-
.../formio-api/references/runtime-auth.md | 8 +-
plugin/skills/formio-sdk/SKILL.md | 8 +-
plugin/skills/formio-sdk/references/auth.md | 10 +-
18 files changed, 33 insertions(+), 2553 deletions(-)
delete mode 100644 packages/mcp-server/src/__tests__/formio-angular-layout.test.ts
delete mode 100644 packages/mcp-server/src/__tests__/formio-application-layout.test.ts
delete mode 100644 packages/mcp-server/src/__tests__/skills-library.test.ts
delete mode 100644 packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts
delete mode 100644 packages/mcp-server/src/skills-validator.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index fe9c896..5d75feb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,13 +18,13 @@ The repository ships a Claude skills library at [plugin/skills/](plugin/skills/)
The library's default "build me an app" entry point is [`skills/formio-application/`](skills/formio-application/) — a framework-agnostic orchestrator that runs the full pipeline from plain-language intent through `formio-resource-planner`, the `project_import` MCP tool, and handoff to a framework-specific scaffolding skill. Today the only framework implementor is [`skills/formio-angular/`](skills/formio-angular/) (with its sub-skill [`skills/formio-angular/resources/`](skills/formio-angular/resources/) for per-resource NgModule work); `formio-angular` is no longer a top-level build-an-app skill — it is the Angular-specific implementor that `formio-application` delegates to. Future framework skills (`formio-react`, etc.) add themselves as rows in `skills/formio-application/FRAMEWORK.md`'s registry table; no other change to the orchestrator is required.
-The library also ships [`skills/formio-sdk/`](plugin/skills/formio-sdk/) — a source-derived reference for the `@formio/js` SDK and `@formio/js/utils` Utilities, authored directly from the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) rather than from drift-prone online docs. Reference docs cover SDK setup, auth, forms, submissions, projects, roles, files, plugins, VanillaJS rendering (`Formio.createForm`), and the Utils surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask/sanitize, misc). The skill mandates ESM imports (`import { Formio } from '@formio/js'`, `import { Utils } from '@formio/js/utils'`) and explicit Hosted-vs-SaaS URL configuration; the validator enforces both.
+The library also ships [`skills/formio-sdk/`](plugin/skills/formio-sdk/) — a source-derived reference for the `@formio/js` SDK and `@formio/js/utils` Utilities, authored directly from the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) rather than from drift-prone online docs. Reference docs cover SDK setup, auth, forms, submissions, projects, roles, files, plugins, VanillaJS rendering (`Formio.createForm`), and the Utils surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask/sanitize, misc). The skill mandates ESM imports (`import { Formio } from '@formio/js'`, `import { Utils } from '@formio/js/utils'`) and explicit Hosted-vs-SaaS URL configuration.
The router skill's `description` follows a three-clause template: capability statement, a "Use when the user asks to …" trigger clause, and a "Not for: …" negative-trigger clause disambiguating from `formio-application` (orchestrator) and `formio-resource-planner` (planner). Each reference document includes a `## MCP Tool Preference` section instructing Claude to prefer the MCP server's first-party tools (`form_*`, `role_*`, `project_*`, `authenticate`) when they cover the requested operation.
Authentication: the MCP server uses a browser-based portal-login flow — a short-lived local Express server renders the Form.io portal login form and captures the returned JWT via a `/callback` endpoint; `formioFetch` then attaches `x-jwt-token` on every request. Skills do NOT use PKCE or API-key auth.
-Skills are validated by [packages/mcp-server/src/skills-validator.ts](packages/mcp-server/src/skills-validator.ts); `pnpm test` fails if the router's frontmatter drifts, the router description is missing its trigger or negative-trigger clause, any required reference file is missing or empty, any reference doc drifts from the required heading layout, the canonical portal-login JWT auth paragraph is missing (except in `server-status.md`), or scope-consistency rules are violated. Terminology is strict: `baseUrl`/`base_url` refers only to `FORMIO_BASE_URL`; `projectUrl`/`project_url` refers only to `FORMIO_PROJECT_URL`.
+Skill authoring conventions (not enforced by automated tests): the router's frontmatter and three-clause description, required reference files present and non-empty, the required reference-doc heading layout, the canonical portal-login JWT auth paragraph (except in `server-status.md`), and scope consistency. Terminology is strict: `baseUrl`/`base_url` refers only to `FORMIO_BASE_URL`; `projectUrl`/`project_url` refers only to `FORMIO_PROJECT_URL`.
## Iterating on skills
diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/design.md b/openspec/changes/archive/2026-04-16-add-user-auth/design.md
index 19a1737..f16bff8 100644
--- a/openspec/changes/archive/2026-04-16-add-user-auth/design.md
+++ b/openspec/changes/archive/2026-04-16-add-user-auth/design.md
@@ -56,7 +56,7 @@ Structure: `{ "": "" }`
**Why `~/.formio/`**: Follows the convention of CLI tools storing config in a dotfile directory in the user's home. Keeps it separate from project-level config.
-### 4. Token validation via `GET {projectUrl}/current`
+### 4. Token validation via `GET {baseUrl}/current`
On startup (after reading config and cache), send a request to the `/current` endpoint. If it returns 200, the token is valid. If 401, clear the cached token and trigger the login flow (or fail in API key mode).
diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md b/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md
index 57b02f1..26bdec7 100644
--- a/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md
+++ b/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md
@@ -6,7 +6,7 @@ The MCP server currently authenticates all API requests using a shared admin API
- Add an authentication module that spins up an ephemeral Express server, renders the project's login form via the Form.io SDK, captures the user's JWT on successful login, and shuts down
- Add token caching to disk (`~/.formio/mcp-tokens.json`) keyed by project URL so users don't re-authenticate on every MCP server restart
-- Add a startup token validation step that hits `GET {projectUrl}/current` to check if a cached or provided token is still valid
+- Add a startup token validation step that hits `GET {baseUrl}/current` to check if a cached or provided token is still valid
- **BREAKING**: `FORMIO_API_KEY` becomes optional instead of required — if not provided, the server triggers the browser login flow
- Add `FORMIO_LOGIN_FORM` optional env var to override the default login form URL (`{projectUrl}/user/login`)
- Modify `formioFetch` to send `x-jwt-token` header when using a user JWT, falling back to `x-token` when using an API key
diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md b/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md
index d81f3cc..c7a4bfe 100644
--- a/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md
+++ b/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md
@@ -2,31 +2,31 @@
### Requirement: Token is validated on startup via GET /current
-On startup, the MCP server SHALL validate any available token (cached JWT or API key) by sending a request to `GET {projectUrl}/current` with the appropriate auth header. A 200 response means the token is valid.
+On startup, the MCP server SHALL validate any available token (cached JWT or API key) by sending a request to `GET {baseUrl}/current` with the appropriate auth header. A 200 response means the token is valid.
#### Scenario: Valid JWT token on startup
- **WHEN** the server starts with a cached JWT that is still valid
-- **THEN** `GET {projectUrl}/current` returns 200
+- **THEN** `GET {baseUrl}/current` returns 200
- **AND** the server proceeds without triggering the login flow
#### Scenario: Valid API key on startup
- **WHEN** the server starts with `FORMIO_API_KEY` set to a valid key
-- **THEN** `GET {projectUrl}/current` with `x-token` header returns 200
+- **THEN** `GET {baseUrl}/current` with `x-token` header returns 200
- **AND** the server proceeds normally
#### Scenario: Expired JWT triggers login flow
- **WHEN** the server starts with a cached JWT that has expired
-- **THEN** `GET {projectUrl}/current` returns 401
+- **THEN** `GET {baseUrl}/current` returns 401
- **AND** the cached token is cleared
- **AND** the login flow is triggered
#### Scenario: Invalid API key fails with error
- **WHEN** the server starts with `FORMIO_API_KEY` set to an invalid key
-- **THEN** `GET {projectUrl}/current` returns 401
+- **THEN** `GET {baseUrl}/current` returns 401
- **AND** the server throws an error indicating the API key is invalid
#### Scenario: No token and no API key triggers login flow
diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md b/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md
index 48c7116..b8516d4 100644
--- a/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md
+++ b/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md
@@ -69,7 +69,7 @@
### Green
-- [x] 4.5 Create `src/token-validation.ts` with `validateToken(config)` that sends `GET {projectUrl}/current` with the appropriate auth header and returns a boolean
+- [x] 4.5 Create `src/token-validation.ts` with `validateToken(config)` that sends `GET {baseUrl}/current` with the appropriate auth header and returns a boolean
### Refactor
diff --git a/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md b/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md
index c4bf396..639537a 100644
--- a/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md
+++ b/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md
@@ -10,7 +10,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s
- Returns a JSON-serialized text content block whose payload is `{ authenticated: boolean, cached: boolean, projectUrl: string, userEmail?: string }`. The JWT MUST NOT appear in the return payload.
- Reads the project URL from the server's `FormioConfig`. The agent does NOT pass a URL.
- `cached: true` when the JWT was already present before the call; `cached: false` when the call triggered a fresh login.
-- `userEmail` is best-effort — populated from a `GET {projectUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted.
+- `userEmail` is best-effort — populated from a `GET {baseUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted.
- Is the tool Step 4 of `formio-application` calls explicitly to trigger authentication. It is also available to any other skill that wants to pre-authenticate before a sensitive sequence.
#### Scenario: Tool is registered and callable
@@ -33,7 +33,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s
#### Scenario: Current-user email included when available
-- **WHEN** a successful `authenticate` call finishes and `GET {projectUrl}/current` returns a submission with an email field
+- **WHEN** a successful `authenticate` call finishes and `GET {baseUrl}/current` returns a submission with an email field
- **THEN** the payload contains `userEmail: `
#### Scenario: Current-user fetch failure is swallowed
diff --git a/openspec/specs/formio-application-skill/spec.md b/openspec/specs/formio-application-skill/spec.md
index faafda5..66b1115 100644
--- a/openspec/specs/formio-application-skill/spec.md
+++ b/openspec/specs/formio-application-skill/spec.md
@@ -283,7 +283,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s
- Returns a JSON-serialized text content block whose payload is `{ authenticated: boolean, cached: boolean, projectUrl: string, userEmail?: string }`. The JWT MUST NOT appear in the return payload.
- Reads the project URL from the server's `FormioConfig`. The agent does NOT pass a URL.
- `cached: true` when the JWT was already present before the call; `cached: false` when the call triggered a fresh login.
-- `userEmail` is best-effort — populated from a `GET {projectUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted.
+- `userEmail` is best-effort — populated from a `GET {baseUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted.
- Is the tool Step 4 of `formio-application` calls explicitly to trigger authentication. It is also available to any other skill that wants to pre-authenticate before a sensitive sequence.
#### Scenario: Tool is registered and callable
@@ -306,7 +306,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s
#### Scenario: Current-user email included when available
-- **WHEN** a successful `authenticate` call finishes and `GET {projectUrl}/current` returns a submission with an email field
+- **WHEN** a successful `authenticate` call finishes and `GET {baseUrl}/current` returns a submission with an email field
- **THEN** the payload contains `userEmail: `
#### Scenario: Current-user fetch failure is swallowed
diff --git a/openspec/specs/token-validation/spec.md b/openspec/specs/token-validation/spec.md
index d81f3cc..66ba2d2 100644
--- a/openspec/specs/token-validation/spec.md
+++ b/openspec/specs/token-validation/spec.md
@@ -13,20 +13,20 @@ On startup, the MCP server SHALL validate any available token (cached JWT or API
#### Scenario: Valid API key on startup
- **WHEN** the server starts with `FORMIO_API_KEY` set to a valid key
-- **THEN** `GET {projectUrl}/current` with `x-token` header returns 200
+- **THEN** `GET {baseUrl}/current` with `x-token` header returns 200
- **AND** the server proceeds normally
#### Scenario: Expired JWT triggers login flow
- **WHEN** the server starts with a cached JWT that has expired
-- **THEN** `GET {projectUrl}/current` returns 401
+- **THEN** `GET {baseUrl}/current` returns 401
- **AND** the cached token is cleared
- **AND** the login flow is triggered
#### Scenario: Invalid API key fails with error
- **WHEN** the server starts with `FORMIO_API_KEY` set to an invalid key
-- **THEN** `GET {projectUrl}/current` returns 401
+- **THEN** `GET {baseUrl}/current` returns 401
- **AND** the server throws an error indicating the API key is invalid
#### Scenario: No token and no API key triggers login flow
diff --git a/packages/mcp-server/src/__tests__/formio-angular-layout.test.ts b/packages/mcp-server/src/__tests__/formio-angular-layout.test.ts
deleted file mode 100644
index eb4d6ac..0000000
--- a/packages/mcp-server/src/__tests__/formio-angular-layout.test.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import matter from 'gray-matter';
-import { describe, expect, it } from 'vitest';
-
-const REPO_ROOT = path.resolve(__dirname, '../../../..');
-const SKILLS_DIR = path.join(REPO_ROOT, 'plugin/skills');
-
-const PARENT_SKILL = path.join(SKILLS_DIR, 'formio-angular/SKILL.md');
-const PARENT_SETUP = path.join(SKILLS_DIR, 'formio-angular/SETUP.md');
-const PARENT_CONFIG = path.join(SKILLS_DIR, 'formio-angular/CONFIG.md');
-const PARENT_AUTH = path.join(SKILLS_DIR, 'formio-angular/AUTH.md');
-const SUB_SKILL = path.join(SKILLS_DIR, 'formio-angular/resources/SKILL.md');
-const SUB_EVALS = path.join(SKILLS_DIR, 'formio-angular/resources/evals');
-
-const OLD_SKILL_DIR = path.join(SKILLS_DIR, 'formio-resource-angular');
-
-// Trigger-surface assertions for formio-angular + formio-angular-resources live
-// in formio-application-layout.test.ts now — after the `add-formio-application-orchestrator`
-// change demoted these two skills to framework-explicit triggers only.
-// This file retains only the infrastructure assertions (file layout, symlinks,
-// sibling-doc content) that don't depend on trigger-surface wording.
-
-function exists(p: string): boolean {
- try {
- fs.lstatSync(p);
- return true;
- } catch {
- return false;
- }
-}
-
-function readFrontmatter(file: string): Record {
- const raw = fs.readFileSync(file, 'utf8');
- return matter(raw).data as Record;
-}
-
-function readBody(file: string): string {
- const raw = fs.readFileSync(file, 'utf8');
- return matter(raw).content;
-}
-
-describe('formio-angular skill layout', () => {
- it('parent SKILL.md and sibling reference docs exist, old resource-angular dir removed', () => {
- expect(exists(PARENT_SKILL)).toBe(true);
- expect(exists(PARENT_SETUP)).toBe(true);
- expect(exists(PARENT_CONFIG)).toBe(true);
- expect(exists(PARENT_AUTH)).toBe(true);
- expect(exists(SUB_SKILL)).toBe(true);
- expect(exists(OLD_SKILL_DIR)).toBe(false);
- });
-});
-
-describe('formio-angular sub-skill frontmatter', () => {
- it('sub-skill name is formio-angular-resources', () => {
- const fm = readFrontmatter(SUB_SKILL);
- expect(fm.name).toBe('formio-angular-resources');
- });
-
- it('sub-skill description contains Not-for clause naming parent formio-angular', () => {
- const fm = readFrontmatter(SUB_SKILL);
- const description = String(fm.description ?? '');
- expect(description).toMatch(/not for:/i);
- expect(description).toContain('formio-angular');
- });
-});
-
-describe('formio-angular parent frontmatter', () => {
- it('parent name and description are set', () => {
- const fm = readFrontmatter(PARENT_SKILL);
- expect(fm.name).toBe('formio-angular');
- expect(String(fm.description ?? '').length).toBeGreaterThan(0);
- });
-
- it('parent description negatives point at formio-angular-resources', () => {
- const fm = readFrontmatter(PARENT_SKILL);
- const description = String(fm.description ?? '');
- expect(description).toContain('formio-angular-resources');
- expect(description).toMatch(/not for:/i);
- });
-
- it('parent body names the four phases in SETUP, CONFIG, AUTH, Resources order', () => {
- const body = readBody(PARENT_SKILL);
- const setupIdx = body.search(/\bSETUP\b/);
- const configIdx = body.search(/\bCONFIG\b/);
- const authIdx = body.search(/\bAUTH\b/);
- const resourcesIdx = body.search(/formio-angular-resources/);
- expect(setupIdx).toBeGreaterThan(-1);
- expect(configIdx).toBeGreaterThan(-1);
- expect(authIdx).toBeGreaterThan(-1);
- expect(resourcesIdx).toBeGreaterThan(-1);
- expect(setupIdx).toBeLessThan(configIdx);
- expect(configIdx).toBeLessThan(authIdx);
- expect(authIdx).toBeLessThan(resourcesIdx);
- });
-});
-
-describe('formio-angular SETUP.md', () => {
- it('has no YAML frontmatter', () => {
- const raw = fs.readFileSync(PARENT_SETUP, 'utf8');
- expect(raw.startsWith('---')).toBe(false);
- });
-
- it('captures Project URL and Base URL', () => {
- const raw = fs.readFileSync(PARENT_SETUP, 'utf8');
- expect(raw).toMatch(/Project URL/);
- expect(raw).toMatch(/Base URL/);
- });
-
- it('names FORMIO_PROJECT_URL and FORMIO_BASE_URL explicitly', () => {
- const raw = fs.readFileSync(PARENT_SETUP, 'utf8');
- expect(raw).toContain('FORMIO_PROJECT_URL');
- expect(raw).toContain('FORMIO_BASE_URL');
- });
-
- it('batches the URL questions into a single AskUserQuestion', () => {
- const raw = fs.readFileSync(PARENT_SETUP, 'utf8');
- expect(raw).toMatch(/AskUserQuestion/);
- expect(raw).toMatch(/(single|one|batch)/i);
- });
-});
-
-describe('formio-angular CONFIG.md', () => {
- it('has no frontmatter', () => {
- const raw = fs.readFileSync(PARENT_CONFIG, 'utf8');
- expect(raw.startsWith('---')).toBe(false);
- });
-
- it('references canonical external URLs', () => {
- const raw = fs.readFileSync(PARENT_CONFIG, 'utf8');
- expect(raw).toContain('https://help.form.io/developers/introduction/application');
- expect(raw).toContain('https://github.com/formio/angular-demo/blob/master/src/app/config.ts');
- expect(raw).toContain(
- 'https://github.com/formio/angular-demo/blob/master/src/app/app.module.ts'
- );
- });
-
- it('contains a config.ts code template with FormioAppConfig', () => {
- const raw = fs.readFileSync(PARENT_CONFIG, 'utf8');
- expect(raw).toContain('AppConfig');
- expect(raw).toContain('FormioAppConfig');
- expect(raw).toContain('appUrl');
- expect(raw).toContain('apiUrl');
- });
-
- it('guides AppModule wiring with provider token and FormioModule import', () => {
- const raw = fs.readFileSync(PARENT_CONFIG, 'utf8');
- expect(raw).toContain('provide: FormioAppConfig');
- expect(raw).toContain('useValue: AppConfig');
- expect(raw).toContain('FormioModule');
- expect(raw).toContain('@formio/angular');
- });
-
- it('describes preview-then-approve gate', () => {
- const raw = fs.readFileSync(PARENT_CONFIG, 'utf8');
- expect(raw).toMatch(/preview/i);
- expect(raw).toMatch(/approv/i);
- });
-
- it('describes skip-if-already-wired detection', () => {
- const raw = fs.readFileSync(PARENT_CONFIG, 'utf8');
- expect(raw).toMatch(/skip/i);
- expect(raw).toMatch(/(already|existing)/i);
- });
-});
-
-describe('formio-angular AUTH.md', () => {
- it('has no frontmatter', () => {
- const raw = fs.readFileSync(PARENT_AUTH, 'utf8');
- expect(raw.startsWith('---')).toBe(false);
- });
-
- it('references canonical external URLs', () => {
- const raw = fs.readFileSync(PARENT_AUTH, 'utf8');
- expect(raw).toContain(
- 'https://help.form.io/developers/introduction/application#user-authentication'
- );
- expect(raw).toContain(
- 'https://github.com/formio/angular-demo/blob/master/src/app/auth/auth.module.ts'
- );
- expect(raw).toContain(
- 'https://github.com/formio/angular-demo/blob/master/src/app/app.module.ts#L71'
- );
- });
-
- it('documents template.json extraction rules', () => {
- const raw = fs.readFileSync(PARENT_AUTH, 'utf8');
- expect(raw).toMatch(/template\.json/);
- expect(raw).toMatch(/user resource/i);
- expect(raw).toMatch(/login form/i);
- expect(raw).toMatch(/register form/i);
- expect(raw).toMatch(/role/i);
- });
-
- it('contains an auth.module.ts template using FormioAuthConfig', () => {
- const raw = fs.readFileSync(PARENT_AUTH, 'utf8');
- expect(raw).toContain('FormioAuthConfig');
- expect(raw).toContain('@formio/angular/auth');
- });
-
- it('documents no-template fallback TODO pointing at api skill references', () => {
- const raw = fs.readFileSync(PARENT_AUTH, 'utf8');
- expect(raw).toMatch(/TODO/);
- expect(raw).toContain('runtime-auth');
- expect(raw).toContain('platform-auth');
- });
-
- it('describes preview gate and skip-if-already-wired', () => {
- const raw = fs.readFileSync(PARENT_AUTH, 'utf8');
- expect(raw).toMatch(/preview/i);
- expect(raw).toMatch(/approv/i);
- expect(raw).toMatch(/skip/i);
- expect(raw).toMatch(/AuthModule/);
- });
-});
-
-describe('formio-angular eval-artifact paths', () => {
- it('evals dir contains no "formio-resource-angular" references', () => {
- const files = ['grade.py', 'README.md', 'evals.json'];
- for (const f of files) {
- const raw = fs.readFileSync(path.join(SUB_EVALS, f), 'utf8');
- expect(raw, `${f} must not reference old skill name`).not.toMatch(/formio-resource-angular/);
- }
- });
-
- it('grade.py references the renamed .eval-artifacts path', () => {
- const raw = fs.readFileSync(path.join(SUB_EVALS, 'grade.py'), 'utf8');
- expect(raw).toContain('formio-angular-resources');
- });
-});
-
-describe('repo-wide skill-name references', () => {
- it('CLAUDE.md names formio-angular and has no formio-resource-angular references', () => {
- const raw = fs.readFileSync(path.join(REPO_ROOT, 'CLAUDE.md'), 'utf8');
- expect(raw).toContain('formio-angular');
- expect(raw).not.toMatch(/formio-resource-angular/);
- expect(raw).toMatch(/skills\/formio-angular\/resources/);
- });
-
- it('formio-resource-planner skill has no formio-resource-angular references', () => {
- const plannerDir = path.join(SKILLS_DIR, 'formio-resource-planner');
- const stack = [plannerDir];
- const offenders: string[] = [];
- while (stack.length > 0) {
- const current = stack.pop()!;
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
- const full = path.join(current, entry.name);
- if (entry.isDirectory()) {
- stack.push(full);
- continue;
- }
- if (!entry.isFile()) continue;
- if (!/\.(md|json|py|ts|txt)$/.test(entry.name)) continue;
- const raw = fs.readFileSync(full, 'utf8');
- if (raw.includes('formio-resource-angular')) offenders.push(full);
- }
- }
- expect(offenders, `files still reference old skill name: ${offenders.join(', ')}`).toEqual([]);
- });
-});
-
-describe('formio-angular SKILL.md files parse as valid markdown+frontmatter', () => {
- it('both parent and sub-skill parse cleanly', () => {
- for (const file of [PARENT_SKILL, SUB_SKILL]) {
- const raw = fs.readFileSync(file, 'utf8');
- const parsed = matter(raw);
- expect(parsed.data, `${file} must have frontmatter`).toBeDefined();
- expect(typeof parsed.data.name).toBe('string');
- expect(typeof parsed.data.description).toBe('string');
- expect(parsed.content.length).toBeGreaterThan(0);
- }
- });
-});
diff --git a/packages/mcp-server/src/__tests__/formio-application-layout.test.ts b/packages/mcp-server/src/__tests__/formio-application-layout.test.ts
deleted file mode 100644
index d615d75..0000000
--- a/packages/mcp-server/src/__tests__/formio-application-layout.test.ts
+++ /dev/null
@@ -1,475 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import matter from 'gray-matter';
-import { describe, expect, it } from 'vitest';
-
-const REPO_ROOT = path.resolve(__dirname, '../../../..');
-const SKILLS_DIR = path.join(REPO_ROOT, 'plugin/skills');
-
-const APP_SKILL = path.join(SKILLS_DIR, 'formio-application/SKILL.md');
-const APP_INTENT = path.join(SKILLS_DIR, 'formio-application/INTENT.md');
-const APP_DEPLOYMENT = path.join(SKILLS_DIR, 'formio-application/DEPLOYMENT.md');
-const APP_MCP_CONFIG = path.join(SKILLS_DIR, 'formio-application/MCP_CONFIG.md');
-const APP_IMPORT = path.join(SKILLS_DIR, 'formio-application/IMPORT.md');
-const APP_FRAMEWORK = path.join(SKILLS_DIR, 'formio-application/FRAMEWORK.md');
-
-const ANGULAR_SKILL = path.join(SKILLS_DIR, 'formio-angular/SKILL.md');
-const ANGULAR_SUB_SKILL = path.join(SKILLS_DIR, 'formio-angular/resources/SKILL.md');
-const PLANNER_SKILL = path.join(SKILLS_DIR, 'formio-resource-planner/SKILL.md');
-
-// Plain-language build-new triggers formio-application MUST claim.
-const APP_BUILD_NEW_TRIGGERS = ['build me an app', 'create a crm', 'i need a tool to track'];
-// Plain-language extend triggers formio-application MUST claim.
-const APP_EXTEND_TRIGGERS = ['also track', 'add a way to see'];
-
-// Generic phrases formio-angular MUST NOT contain (they now belong to formio-application).
-const ANGULAR_FORBIDDEN_GENERIC = [
- 'build me an app',
- 'build me a tool',
- 'spin up an app',
- 'i need a tool to track',
- 'task manager',
- 'help desk',
- 'crm',
- 'booking system',
-];
-
-// Angular-explicit triggers formio-angular MUST claim.
-const ANGULAR_REQUIRED_EXPLICIT = ['build it in angular', 'angular front-end', 'use angular'];
-
-// Generic extend phrases formio-angular-resources MUST NOT contain.
-const ANGULAR_SUB_FORBIDDEN_GENERIC = [
- 'also track',
- 'also let',
- 'add a way to see',
- 'each x should have a list of y',
-];
-
-// Angular-explicit extend triggers formio-angular-resources MUST claim.
-const ANGULAR_SUB_REQUIRED_EXPLICIT = [
- 'add an angular module',
- 'regenerate the angular',
- 'in my angular app',
-];
-
-function exists(p: string): boolean {
- try {
- fs.lstatSync(p);
- return true;
- } catch {
- return false;
- }
-}
-
-function readFrontmatter(file: string): Record {
- const raw = fs.readFileSync(file, 'utf8');
- return matter(raw).data as Record;
-}
-
-function readBody(file: string): string {
- const raw = fs.readFileSync(file, 'utf8');
- return matter(raw).content;
-}
-
-function sliceStepSection(body: string, stepNumber: number): string {
- const startRe = new RegExp(`^#+\\s+Step ${stepNumber}\\b`, 'm');
- const startMatch = startRe.exec(body);
- if (!startMatch) return '';
- const start = startMatch.index;
- const afterStart = body.slice(start + startMatch[0].length);
- const endRe = new RegExp(`^#+\\s+Step ${stepNumber + 1}\\b`, 'm');
- const endMatch = endRe.exec(afterStart);
- const end = endMatch ? start + startMatch[0].length + endMatch.index : body.length;
- return body.slice(start, end);
-}
-
-describe('formio-application skill layout', () => {
- it('parent SKILL.md and sibling docs exist', () => {
- expect(exists(APP_SKILL)).toBe(true);
- expect(exists(APP_INTENT)).toBe(true);
- expect(exists(APP_DEPLOYMENT)).toBe(true);
- expect(exists(APP_IMPORT)).toBe(true);
- expect(exists(APP_FRAMEWORK)).toBe(true);
- });
-
- it('sibling docs have no YAML frontmatter', () => {
- for (const f of [APP_INTENT, APP_DEPLOYMENT, APP_IMPORT, APP_FRAMEWORK]) {
- const raw = fs.readFileSync(f, 'utf8');
- expect(raw.startsWith('---'), `${f} must not have frontmatter`).toBe(false);
- }
- });
-});
-
-describe('formio-application frontmatter', () => {
- it('name is formio-application', () => {
- const fm = readFrontmatter(APP_SKILL);
- expect(fm.name).toBe('formio-application');
- });
-
- it('description claims plain-language build-new triggers', () => {
- const fm = readFrontmatter(APP_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- for (const phrase of APP_BUILD_NEW_TRIGGERS) {
- expect(description, `must contain "${phrase}"`).toContain(phrase);
- }
- });
-
- it('description claims plain-language extend triggers', () => {
- const fm = readFrontmatter(APP_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- for (const phrase of APP_EXTEND_TRIGGERS) {
- expect(description, `must contain "${phrase}"`).toContain(phrase);
- }
- });
-
- it('description contains Not-for clauses naming sibling skills', () => {
- const fm = readFrontmatter(APP_SKILL);
- const description = String(fm.description ?? '');
- expect(description).toMatch(/not for:/i);
- expect(description).toContain('formio-angular');
- expect(description).toContain('formio-angular-resources');
- expect(description).toContain('formio-resource-planner');
- expect(description).toContain('formio-api');
- });
-});
-
-describe('formio-application body', () => {
- it('names the six steps in order via section headers', () => {
- const body = readBody(APP_SKILL);
- const intentIdx = body.search(/^#+\s+Step 1[^\n]*Intent/im);
- const planIdx = body.search(/^#+\s+Step 2[^\n]*Plan/im);
- const deploymentIdx = body.search(/^#+\s+Step 3[^\n]*Deployment/im);
- const mcpConfigIdx = body.search(/^#+\s+Step 4[^\n]*MCP Config/im);
- const importIdx = body.search(/^#+\s+Step 5[^\n]*Import/im);
- const frameworkIdx = body.search(/^#+\s+Step 6[^\n]*Framework/im);
- for (const [name, idx] of [
- ['Step 1 — Intent', intentIdx],
- ['Step 2 — Plan', planIdx],
- ['Step 3 — Deployment', deploymentIdx],
- ['Step 4 — MCP Config', mcpConfigIdx],
- ['Step 5 — Import', importIdx],
- ['Step 6 — Framework', frameworkIdx],
- ] as const) {
- expect(idx, `body must have "${name}" section header`).toBeGreaterThan(-1);
- }
- expect(intentIdx).toBeLessThan(planIdx);
- expect(planIdx).toBeLessThan(deploymentIdx);
- expect(deploymentIdx).toBeLessThan(mcpConfigIdx);
- expect(mcpConfigIdx).toBeLessThan(importIdx);
- expect(importIdx).toBeLessThan(frameworkIdx);
- });
-
- it('references the five sibling docs by relative link', () => {
- const body = readBody(APP_SKILL);
- expect(body).toContain('INTENT.md');
- expect(body).toContain('DEPLOYMENT.md');
- expect(body).toContain('MCP_CONFIG.md');
- expect(body).toContain('IMPORT.md');
- expect(body).toContain('FRAMEWORK.md');
- });
-
- it('description mentions .mcp.json and the restart pause', () => {
- const fm = readFrontmatter(APP_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- expect(description).toContain('.mcp.json');
- expect(description).toMatch(/(restart|reconnect)/);
- });
-
- it('Step 2 (Plan) invokes formio-resource-planner in both modes', () => {
- const body = readBody(APP_SKILL);
- const step2 = sliceStepSection(body, 2).toLowerCase();
- expect(step2.length, 'Step 2 section must exist').toBeGreaterThan(0);
- expect(step2).toContain('formio-resource-planner');
- expect(step2).toMatch(/delta/);
- expect(step2).toMatch(/(additive|merge)/);
- });
-
- it('Step 5 (Import) runs on both branches with implicit auth', () => {
- const body = readBody(APP_SKILL);
- const step5 = sliceStepSection(body, 5).toLowerCase();
- expect(step5.length, 'Step 5 section must exist').toBeGreaterThan(0);
- expect(step5).toContain('project_import');
- expect(step5).toMatch(/additive/);
- expect(step5).toMatch(/(implicit|first authenticated|portal.login)/);
- });
-
- it('Step 4 section documents halt + restart + modify-existing skip', () => {
- const body = readBody(APP_SKILL);
- const step4 = sliceStepSection(body, 4).toLowerCase();
- expect(step4.length, 'Step 4 section must exist').toBeGreaterThan(0);
- expect(step4).toContain('mcp_config.md');
- expect(step4).toMatch(/(halt|stop|pause)/);
- expect(step4).toMatch(/(restart|reconnect)/);
- expect(step4).toMatch(/(modify-existing|skipped)/);
- });
-});
-
-describe('INTENT.md content', () => {
- it('names AskUserQuestion and covers both branches', () => {
- const raw = fs.readFileSync(APP_INTENT, 'utf8');
- expect(raw).toContain('AskUserQuestion');
- expect(raw.toLowerCase()).toMatch(/build/);
- expect(raw.toLowerCase()).toMatch(/(modify|extend)/);
- });
-
- it('documents modify-existing skips only Deployment + MCP Config (still runs planner + import)', () => {
- const raw = fs.readFileSync(APP_INTENT, 'utf8').toLowerCase();
- expect(raw).toMatch(/skip/);
- expect(raw).toMatch(/(deployment|step 3)/);
- expect(raw).toMatch(/(mcp config|mcp_config|mcp-config|step 4)/);
- // Modify-existing MUST still mention planner + import
- expect(raw).toContain('formio-resource-planner');
- expect(raw).toContain('project_import');
- expect(raw).toMatch(/delta/);
- expect(raw).toMatch(/additive/);
- });
-});
-
-describe('DEPLOYMENT.md content', () => {
- it('batches the URL interview and names env-var terms', () => {
- const raw = fs.readFileSync(APP_DEPLOYMENT, 'utf8');
- expect(raw).toContain('AskUserQuestion');
- expect(raw.toLowerCase()).toMatch(/(batched|single|one)/);
- expect(raw).toContain('FORMIO_PROJECT_URL');
- expect(raw).toContain('FORMIO_BASE_URL');
- });
-
- it('contains plain-language URL descriptions with examples', () => {
- const raw = fs.readFileSync(APP_DEPLOYMENT, 'utf8');
- expect(raw).toMatch(/Project URL/);
- expect(raw).toMatch(/Base URL/);
- expect(raw).toMatch(/https:\/\/[\w.-]*form\.io/);
- });
-
- it('names the next consumer of captured URLs (Step 4 / MCP_CONFIG.md)', () => {
- const raw = fs.readFileSync(APP_DEPLOYMENT, 'utf8');
- expect(raw).toMatch(/(Step 4|MCP Config|MCP_CONFIG\.md)/);
- });
-});
-
-describe('IMPORT.md content', () => {
- it('covers import flow and error branches', () => {
- const raw = fs.readFileSync(APP_IMPORT, 'utf8');
- expect(raw).toContain('project_import');
- expect(raw.toLowerCase()).toMatch(/merge/);
- expect(raw.toLowerCase()).toMatch(/(auth|401|403)/);
- expect(raw.toLowerCase()).toMatch(/(not found|404)/);
- expect(raw.toLowerCase()).toMatch(/(validation|400)/);
- });
-
- it('describes browser login and headless fallback', () => {
- const raw = fs.readFileSync(APP_IMPORT, 'utf8');
- expect(raw.toLowerCase()).toMatch(/browser/);
- expect(raw.toLowerCase()).toMatch(/(headless|portal.login|print)/);
- });
-
- it('Step 5 describes implicit portal-login on first authenticated call', () => {
- const raw = fs.readFileSync(APP_IMPORT, 'utf8').toLowerCase();
- expect(raw).toMatch(/(implicit|first authenticated|portal.login|browser)/);
- });
-});
-
-describe('MCP_CONFIG.md content', () => {
- it('exists and has no frontmatter', () => {
- expect(exists(APP_MCP_CONFIG)).toBe(true);
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8');
- expect(raw.startsWith('---')).toBe(false);
- });
-
- it('names both env-var keys', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8');
- expect(raw).toContain('FORMIO_PROJECT_URL');
- expect(raw).toContain('FORMIO_BASE_URL');
- });
-
- it('documents the FORMIO_BASE_URL ↔ FORMIO_BASE_URL mapping', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8');
- expect(raw).toContain('FORMIO_BASE_URL');
- expect(raw).toContain('FORMIO_BASE_URL');
- expect(raw.toLowerCase()).toMatch(/(map|same concept|two names)/);
- });
-
- it('documents collision handling (preserve command/args, preserve other env, preserve other servers)', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8').toLowerCase();
- expect(raw).toMatch(/preserve/);
- expect(raw).toMatch(/command/);
- expect(raw).toMatch(/args/);
- expect(raw).toMatch(/(unrelated|other) (mcp|server)/);
- });
-
- it('documents npm-based default command and placeholder warning', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8');
- expect(raw).toContain('npx');
- expect(raw.toLowerCase()).toMatch(/@formio\/mcp/);
- expect(raw.toLowerCase()).toMatch(/(placeholder|aspirational|not.*published|until.*publish)/);
- });
-
- it('documents the approval gate (preview, approval, write, then restart)', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8').toLowerCase();
- expect(raw).toMatch(/preview/);
- expect(raw).toMatch(/approv/);
- expect(raw).toMatch(/(restart|reconnect)/);
- });
-
- it('documents the skip rule for matching existing entries', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8').toLowerCase();
- expect(raw).toMatch(/skip/);
- expect(raw).toMatch(/match/);
- });
-
- it('mentions both "restart Claude Code" and "/mcp" reconnect paths', () => {
- const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8');
- expect(raw.toLowerCase()).toMatch(/restart claude code/);
- expect(raw).toContain('/mcp');
- });
-});
-
-describe('FRAMEWORK.md content', () => {
- it('contains a registry table with Angular row', () => {
- const raw = fs.readFileSync(APP_FRAMEWORK, 'utf8');
- expect(raw).toMatch(/\| Framework \|/);
- expect(raw).toContain('formio-angular');
- expect(raw).toContain('formio-angular-resources');
- expect(raw.toLowerCase()).toMatch(/angular\.json/);
- });
-
- it('documents single-row silent routing and multi-row picker', () => {
- const raw = fs.readFileSync(APP_FRAMEWORK, 'utf8').toLowerCase();
- expect(raw).toMatch(/(silent|directly|without asking)/);
- expect(raw).toMatch(/(askuserquestion|picker|ask the user)/);
- });
-
- it('documents how to add a new framework row', () => {
- const raw = fs.readFileSync(APP_FRAMEWORK, 'utf8').toLowerCase();
- expect(raw).toMatch(/add/);
- expect(raw).toMatch(/(row|entry|new framework)/);
- });
-});
-
-describe('formio-angular demotion', () => {
- it('description drops generic build-an-app phrases', () => {
- const fm = readFrontmatter(ANGULAR_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- for (const phrase of ANGULAR_FORBIDDEN_GENERIC) {
- expect(description, `must not contain "${phrase}"`).not.toContain(phrase);
- }
- });
-
- it('description claims Angular-explicit triggers', () => {
- const fm = readFrontmatter(ANGULAR_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- for (const phrase of ANGULAR_REQUIRED_EXPLICIT) {
- expect(description, `must contain "${phrase}"`).toContain(phrase);
- }
- });
-
- it('description points at formio-application in Not-for clause', () => {
- const fm = readFrontmatter(ANGULAR_SKILL);
- const description = String(fm.description ?? '');
- expect(description).toContain('formio-application');
- expect(description).toMatch(/not for:/i);
- });
-});
-
-describe('formio-angular body post-demotion', () => {
- it('body does not describe an Inference phase', () => {
- const body = readBody(ANGULAR_SKILL);
- expect(body).not.toMatch(/Phase 0 — Inference/);
- expect(body).not.toMatch(/## Phase 0 — Inference/);
- });
-
- it('body documents SETUP handoff from formio-application', () => {
- const body = readBody(ANGULAR_SKILL);
- expect(body).toContain('formio-application');
- expect(body.toLowerCase()).toMatch(/handoff|handed/);
- });
-
- it("body states Import is not this skill's responsibility", () => {
- const body = readBody(ANGULAR_SKILL).toLowerCase();
- expect(body).toContain('project_import');
- expect(body).toMatch(/(not this skill|lives in formio-application|is formio-application)/);
- });
-});
-
-describe('formio-angular-resources demotion', () => {
- it('description drops generic extend phrases', () => {
- const fm = readFrontmatter(ANGULAR_SUB_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- for (const phrase of ANGULAR_SUB_FORBIDDEN_GENERIC) {
- expect(description, `must not contain "${phrase}"`).not.toContain(phrase);
- }
- });
-
- it('description claims Angular-explicit extend triggers', () => {
- const fm = readFrontmatter(ANGULAR_SUB_SKILL);
- const description = String(fm.description ?? '').toLowerCase();
- for (const phrase of ANGULAR_SUB_REQUIRED_EXPLICIT) {
- expect(description, `must contain "${phrase}"`).toContain(phrase);
- }
- });
-
- it('description points at formio-application in Not-for clause', () => {
- const fm = readFrontmatter(ANGULAR_SUB_SKILL);
- const description = String(fm.description ?? '');
- expect(description).toContain('formio-application');
- expect(description).toMatch(/not for:/i);
- });
-});
-
-describe('formio-resource-planner disk-write documentation', () => {
- it('Phase B section documents writing template.json to cwd', () => {
- const body = readBody(PLANNER_SKILL).toLowerCase();
- expect(body).toMatch(/template\.json/);
- expect(body).toMatch(/(write tool|cwd|working directory)/);
- expect(body).toMatch(/template-.*timestamp/);
- });
-
- it('Phase B guidance mentions both standalone and orchestrator invocation', () => {
- const body = readBody(PLANNER_SKILL).toLowerCase();
- expect(body).toMatch(/(standalone|directly)/);
- expect(body).toMatch(/formio-application/);
- });
-});
-
-describe('CLAUDE.md updates', () => {
- it('names formio-application as build-an-app entry point', () => {
- const raw = fs.readFileSync(path.join(REPO_ROOT, 'CLAUDE.md'), 'utf8');
- expect(raw).toContain('formio-application');
- expect(raw).toMatch(/skills\/formio-application/);
- });
-});
-
-describe('YAML frontmatter regression guard', () => {
- it('all SKILL.md files under formio-application and formio-angular parse cleanly', () => {
- const files = [APP_SKILL, ANGULAR_SKILL, ANGULAR_SUB_SKILL];
- for (const file of files) {
- const raw = fs.readFileSync(file, 'utf8');
- const parsed = matter(raw);
- expect(parsed.data, `${file} must have frontmatter`).toBeDefined();
- expect(typeof parsed.data.name).toBe('string');
- expect(typeof parsed.data.description).toBe('string');
- expect(parsed.content.length).toBeGreaterThan(0);
- }
- });
-});
-
-describe('trigger-surface non-overlap', () => {
- it('formio-application claims generic phrases that formio-angular does not', () => {
- const appDesc = String(readFrontmatter(APP_SKILL).description ?? '').toLowerCase();
- const angularDesc = String(readFrontmatter(ANGULAR_SKILL).description ?? '').toLowerCase();
- for (const phrase of APP_BUILD_NEW_TRIGGERS) {
- expect(appDesc).toContain(phrase);
- expect(angularDesc, `formio-angular must not claim "${phrase}"`).not.toContain(phrase);
- }
- });
-
- it('formio-angular claims framework-explicit phrases that formio-application does not', () => {
- const appDesc = String(readFrontmatter(APP_SKILL).description ?? '').toLowerCase();
- const angularDesc = String(readFrontmatter(ANGULAR_SKILL).description ?? '').toLowerCase();
- for (const phrase of ANGULAR_REQUIRED_EXPLICIT) {
- expect(angularDesc).toContain(phrase);
- }
- // formio-application should not claim the specific "build it in Angular" phrasing.
- expect(appDesc).not.toContain('build it in angular');
- });
-});
diff --git a/packages/mcp-server/src/__tests__/skills-library.test.ts b/packages/mcp-server/src/__tests__/skills-library.test.ts
deleted file mode 100644
index 9d0ebd2..0000000
--- a/packages/mcp-server/src/__tests__/skills-library.test.ts
+++ /dev/null
@@ -1,393 +0,0 @@
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import {
- CANONICAL_AUTH_PARAGRAPH,
- MCP_PREFERENCE_FALLBACK_SENTENCE,
- MCP_PREFERENCE_HEADING,
- REFERENCES_DIRNAME,
- REQUIRED_REFERENCE_GROUPS,
- REQUIRED_REFERENCE_HEADINGS,
- ROUTER_DIR,
- SKILL_FILENAME,
- type ReferenceGroup,
- GROUP_SCOPE,
- validateLibrary,
- validateNoRandomIdSuffixes,
- validateReferenceContent,
- validateRequiredFiles,
- validateRouterLinks,
- validateRouterSkillContent,
-} from '../skills-validator.js';
-
-function makeReferenceBody(
- group: ReferenceGroup,
- overrides: { omit?: string[]; extra?: string; rootUrlBlock?: string } = {}
-) {
- const omit = new Set(overrides.omit ?? []);
- const scope = GROUP_SCOPE[group];
- const rootUrl = scope === 'platform' ? '${FORMIO_BASE_URL}' : '${FORMIO_PROJECT_URL}';
- const sections: string[] = [];
- if (!omit.has('Overview')) sections.push('## Overview\n\nExample overview.');
- if (!omit.has('Root URL'))
- sections.push(
- overrides.rootUrlBlock ?? `## Root URL\n\nAll endpoints rooted at \`${rootUrl}\`.`
- );
- if (!omit.has('Authentication')) {
- // server-status skips canonical auth paragraph
- if (group === 'server-status') {
- sections.push('## Authentication\n\nNo auth required — public health endpoints.');
- } else {
- sections.push(`## Authentication\n\n${CANONICAL_AUTH_PARAGRAPH}`);
- }
- }
- if (!omit.has('MCP Tool Preference')) {
- sections.push(`${MCP_PREFERENCE_HEADING}\n\n${MCP_PREFERENCE_FALLBACK_SENTENCE}`);
- }
- if (!omit.has('Endpoints')) {
- const path = scope === 'pdf' ? `${rootUrl}/pdf-proxy/file` : `${rootUrl}/form`;
- sections.push(`## Endpoints\n\n### GET ${path}\n\nList.`);
- }
- return sections.join('\n\n') + (overrides.extra ? `\n\n${overrides.extra}` : '') + '\n';
-}
-
-function makeRouterSource(
- overrides: {
- linksToAllGroups?: boolean;
- extraHeadings?: string;
- descriptionOverride?: string;
- } = {}
-) {
- const description =
- overrides.descriptionOverride ??
- 'Comprehensive Form.io API reference. Use when the user asks about any Form.io REST endpoint. Not for: building an app (see formio-application).';
- const links =
- (overrides.linksToAllGroups ?? true)
- ? REQUIRED_REFERENCE_GROUPS.map((g) => `- [${g}](./${REFERENCES_DIRNAME}/${g}.md)`).join('\n')
- : '';
- return `---
-name: formio-api
-description: "${description.replace(/"/g, '\\"')}"
----
-
-# Form.io API
-
-${links}
-
-${overrides.extraHeadings ?? ''}
-`;
-}
-
-function writeLibrary(root: string) {
- fs.mkdirSync(path.join(root, ROUTER_DIR, REFERENCES_DIRNAME), { recursive: true });
- fs.writeFileSync(path.join(root, ROUTER_DIR, SKILL_FILENAME), makeRouterSource());
- for (const group of REQUIRED_REFERENCE_GROUPS) {
- fs.writeFileSync(
- path.join(root, ROUTER_DIR, REFERENCES_DIRNAME, `${group}.md`),
- makeReferenceBody(group)
- );
- }
-}
-
-describe('skills-validator — required files', () => {
- let tmpDir: string;
-
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-lib-'));
- });
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- });
-
- it('passes when router SKILL.md and all 17 reference files exist', () => {
- writeLibrary(tmpDir);
- expect(validateRequiredFiles(tmpDir)).toEqual([]);
- });
-
- it('fails when router SKILL.md is missing', () => {
- writeLibrary(tmpDir);
- fs.rmSync(path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME));
- const issues = validateRequiredFiles(tmpDir);
- expect(
- issues.some((i) => i.rule === 'library.required_file' && i.message.includes('router'))
- ).toBe(true);
- });
-
- it('fails when a required reference file is missing', () => {
- writeLibrary(tmpDir);
- fs.rmSync(path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'project-forms.md'));
- const issues = validateRequiredFiles(tmpDir);
- expect(issues.some((i) => i.message.includes('project-forms.md'))).toBe(true);
- });
-
- it('fails when a required reference file is empty', () => {
- writeLibrary(tmpDir);
- fs.writeFileSync(path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'pdf-api.md'), '');
- const issues = validateRequiredFiles(tmpDir);
- expect(issues.some((i) => i.message.includes('pdf-api.md') && /empty/.test(i.message))).toBe(
- true
- );
- });
-});
-
-describe('skills-validator — router links', () => {
- let tmpDir: string;
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'router-links-'));
- });
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- });
-
- it('passes when router links to every reference group', () => {
- writeLibrary(tmpDir);
- expect(validateRouterLinks(tmpDir)).toEqual([]);
- });
-
- it('fails when a required group link is absent from router', () => {
- writeLibrary(tmpDir);
- fs.writeFileSync(
- path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME),
- makeRouterSource({ linksToAllGroups: false })
- );
- const issues = validateRouterLinks(tmpDir);
- expect(issues.some((i) => i.rule === 'index.missing_link')).toBe(true);
- });
-
- it('fails when router contains endpoint headings', () => {
- writeLibrary(tmpDir);
- fs.writeFileSync(
- path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME),
- makeRouterSource({ extraHeadings: '### GET ${FORMIO_PROJECT_URL}/form\n\nNo.' })
- );
- const issues = validateRouterLinks(tmpDir);
- expect(issues.some((i) => i.rule === 'index.no_endpoint_docs')).toBe(true);
- });
-
- it('fails when router links to a non-existent reference file', () => {
- writeLibrary(tmpDir);
- const routerPath = path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME);
- const orig = fs.readFileSync(routerPath, 'utf8');
- fs.writeFileSync(
- routerPath,
- `${orig}\n- [missing](./${REFERENCES_DIRNAME}/missing-group.md)\n`
- );
- const issues = validateRouterLinks(tmpDir);
- expect(issues.some((i) => i.rule === 'index.broken_link')).toBe(true);
- });
-});
-
-describe('skills-validator — router skill content', () => {
- it('passes with valid router frontmatter + trigger clauses', () => {
- const source = makeRouterSource();
- expect(validateRouterSkillContent('formio-api/SKILL.md', source)).toEqual([]);
- });
-
- it('fails when router description lacks "use when" trigger', () => {
- const source = makeRouterSource({
- descriptionOverride: 'Reference. Not for: building an app (see formio-application).',
- });
- const issues = validateRouterSkillContent('formio-api/SKILL.md', source);
- expect(issues.some((i) => i.rule === 'description.trigger_phrase')).toBe(true);
- });
-
- it('fails when router description lacks "not for:" clause', () => {
- const source = makeRouterSource({
- descriptionOverride: 'Reference. Use when the user asks about any Form.io endpoint.',
- });
- const issues = validateRouterSkillContent('formio-api/SKILL.md', source);
- expect(issues.some((i) => i.rule === 'description.negative_trigger')).toBe(true);
- });
-
- it('fails when router frontmatter.name is wrong', () => {
- const source = `---
-name: formio-wrong
-description: "Use when testing. Not for: production."
----
-body`;
- const issues = validateRouterSkillContent('formio-api/SKILL.md', source);
- expect(issues.some((i) => i.rule === 'frontmatter.name')).toBe(true);
- });
-
- it('fails when router has extra frontmatter keys', () => {
- const source = `---
-name: formio-api
-description: "Use when asking about Form.io. Not for: plan."
-scope: project
----
-body`;
- const issues = validateRouterSkillContent('formio-api/SKILL.md', source);
- expect(issues.some((i) => i.rule === 'frontmatter.keys')).toBe(true);
- });
-});
-
-describe('skills-validator — reference content', () => {
- it('passes with a well-formed reference doc (no frontmatter)', () => {
- const source = makeReferenceBody('project-forms');
- expect(validateReferenceContent('ref.md', source, 'project-forms')).toEqual([]);
- });
-
- it('fails when reference doc has frontmatter', () => {
- const source = `---\nname: something\n---\n\n${makeReferenceBody('project-forms')}`;
- const issues = validateReferenceContent('ref.md', source, 'project-forms');
- expect(issues.some((i) => i.rule === 'reference.no_frontmatter')).toBe(true);
- });
-
- it('fails when a required heading is missing', () => {
- const source = makeReferenceBody('project-forms', { omit: ['Overview'] });
- const issues = validateReferenceContent('ref.md', source, 'project-forms');
- expect(issues.some((i) => i.rule === 'headings.missing')).toBe(true);
- });
-
- it('fails when required headings are out of order', () => {
- const body = `## MCP Tool Preference\n\n${MCP_PREFERENCE_FALLBACK_SENTENCE}\n\n## Overview\n\ntext\n\n## Root URL\n\n\`\${FORMIO_PROJECT_URL}\`\n\n## Authentication\n\n${CANONICAL_AUTH_PARAGRAPH}\n\n## Endpoints\n\n### GET \${FORMIO_PROJECT_URL}/x\n\n.`;
- const issues = validateReferenceContent('ref.md', body, 'project-forms');
- expect(issues.some((i) => i.rule === 'headings.order')).toBe(true);
- });
-
- it('requires canonical auth paragraph (except server-status)', () => {
- const source = `## Overview\n\nx\n\n## Root URL\n\nx\n\n## Authentication\n\nnope\n\n## MCP Tool Preference\n\n${MCP_PREFERENCE_FALLBACK_SENTENCE}\n\n## Endpoints\n\n### GET x\n\n.`;
- const issues = validateReferenceContent('ref.md', source, 'project-forms');
- expect(issues.some((i) => i.rule === 'auth.canonical_paragraph')).toBe(true);
- });
-
- it('allows server-status without canonical auth paragraph', () => {
- const source = makeReferenceBody('server-status');
- const issues = validateReferenceContent('ref.md', source, 'server-status');
- expect(issues.every((i) => i.rule !== 'auth.canonical_paragraph')).toBe(true);
- });
-
- it('flags forbidden legacy-auth tokens', () => {
- const source = makeReferenceBody('project-forms', { extra: 'Include x-token header.' });
- const issues = validateReferenceContent('ref.md', source, 'project-forms');
- expect(issues.some((i) => i.rule === 'forbidden.legacy_auth')).toBe(true);
- });
-
- it('flags unresolved Postman project placeholder for project-scope refs', () => {
- const source = makeReferenceBody('project-forms', {
- extra: 'Path: {{baseUrl}}/{{projectName}}/form (needs substitution).',
- });
- const issues = validateReferenceContent('ref.md', source, 'project-forms');
- expect(issues.some((i) => i.rule === 'placeholder.project')).toBe(true);
- });
-
- it('flags bare {{baseUrl}}/ placeholder for platform-scope refs', () => {
- const source = makeReferenceBody('platform-auth', {
- extra: 'Path: {{baseUrl}}/user (needs substitution).',
- });
- const issues = validateReferenceContent('ref.md', source, 'platform-auth');
- expect(issues.some((i) => i.rule === 'placeholder.platform')).toBe(true);
- });
-
- it('flags pdf endpoints that do not begin with ${FORMIO_PROJECT_URL}/pdf-proxy', () => {
- const body = makeReferenceBody('pdf-api', {
- omit: ['Endpoints'],
- extra: '## Endpoints\n\n### GET ${FORMIO_PROJECT_URL}/file\n\nWrong path.',
- });
- // Re-inject endpoints after omit
- const source = body.replace('## Endpoints\n\n', '## Endpoints\n\n');
- const issues = validateReferenceContent('ref.md', source, 'pdf-api');
- expect(issues.some((i) => i.rule === 'pdf.proxy_path')).toBe(true);
- });
-
- it('flags terminology misuse of baseUrl for project endpoint', () => {
- const source = makeReferenceBody('project-forms', {
- extra: 'The baseUrl is the project endpoint here.',
- });
- const issues = validateReferenceContent('ref.md', source, 'project-forms');
- expect(issues.some((i) => i.rule === 'terminology.baseUrl_for_project')).toBe(true);
- });
-
- it('flags terminology misuse of projectUrl for platform endpoint', () => {
- const source = makeReferenceBody('platform-auth', {
- extra: 'The projectUrl is the platform deployment URL.',
- });
- const issues = validateReferenceContent('ref.md', source, 'platform-auth');
- expect(issues.some((i) => i.rule === 'terminology.projectUrl_for_platform')).toBe(true);
- });
-});
-
-describe('skills-validator — validateNoRandomIdSuffixes', () => {
- it('flags random-id integer suffix in example titles', () => {
- const body = '```json\n{"title": "My Form 42"}\n```';
- // code fences are stripped before scanning; regex runs against original body
- const issues = validateNoRandomIdSuffixes('ref.md', body);
- expect(issues.length).toBeGreaterThan(0);
- });
-
- it('flags random-id suffix in slug-style fields', () => {
- const body = '```json\n{"name": "myform-99"}\n```';
- const issues = validateNoRandomIdSuffixes('ref.md', body);
- expect(issues.length).toBeGreaterThan(0);
- });
-});
-
-describe('skills-validator — validateLibrary integration', () => {
- let tmpDir: string;
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-integration-'));
- });
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- });
-
- it('passes on a well-formed library', () => {
- writeLibrary(tmpDir);
- expect(validateLibrary(tmpDir)).toEqual([]);
- });
-
- it('collects issues across required-files + router + every reference', () => {
- writeLibrary(tmpDir);
- // Break: delete a reference + corrupt router + corrupt one reference
- fs.rmSync(path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'runtime-reports.md'));
- fs.writeFileSync(
- path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME),
- makeRouterSource({ descriptionOverride: 'missing triggers' })
- );
- fs.writeFileSync(
- path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'project-forms.md'),
- '## Overview\n\nmissing auth\n'
- );
- const issues = validateLibrary(tmpDir);
- const rules = new Set(issues.map((i) => i.rule));
- expect(rules.has('library.required_file')).toBe(true);
- expect(rules.has('description.trigger_phrase')).toBe(true);
- expect(rules.has('headings.missing')).toBe(true);
- });
-});
-
-describe('skills-validator — REQUIRED_REFERENCE_GROUPS invariants', () => {
- it('has 17 groups', () => {
- expect(REQUIRED_REFERENCE_GROUPS.length).toBe(17);
- });
-
- it('every group has a scope mapping', () => {
- for (const group of REQUIRED_REFERENCE_GROUPS) {
- expect(GROUP_SCOPE[group]).toBeDefined();
- }
- });
-
- it('REQUIRED_REFERENCE_HEADINGS includes MCP Tool Preference after Authentication', () => {
- const authIdx = REQUIRED_REFERENCE_HEADINGS.indexOf('## Authentication');
- const mcpIdx = REQUIRED_REFERENCE_HEADINGS.indexOf(MCP_PREFERENCE_HEADING);
- const endpointsIdx = REQUIRED_REFERENCE_HEADINGS.indexOf('## Endpoints');
- expect(authIdx).toBeGreaterThanOrEqual(0);
- expect(mcpIdx).toBe(authIdx + 1);
- expect(endpointsIdx).toBe(mcpIdx + 1);
- });
-});
-
-describe('skills-validator — real library on disk', () => {
- const REPO_ROOT = path.resolve(__dirname, '../../../..');
- const LIBRARY_DIR = path.join(REPO_ROOT, 'plugin/skills');
-
- it('real plugin/skills library validates cleanly', () => {
- const issues = validateLibrary(LIBRARY_DIR);
- // Print any issues for debugging
- if (issues.length > 0) {
- console.error('Library validation issues:', JSON.stringify(issues, null, 2));
- }
- expect(issues).toEqual([]);
- });
-});
diff --git a/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts b/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts
deleted file mode 100644
index 649c1b7..0000000
--- a/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts
+++ /dev/null
@@ -1,457 +0,0 @@
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import {
- SDK_CANONICAL_SDK_IMPORT,
- SDK_CANONICAL_UTILS_IMPORT,
- SDK_HOSTED_BASE_URL_LITERAL,
- SDK_HOSTED_PROJECT_URL_LITERAL,
- SDK_REQUIRED_REFERENCES,
- SDK_SAAS_BASE_URL_LITERAL,
- SDK_SAAS_PROJECT_URL_LITERAL,
- SDK_SKILL_DIR,
- SKILL_FILENAME,
- REFERENCES_DIRNAME,
- validateFormioSdkSkill,
- validateFormioSdkSkillContent,
- validateFormioSdkDescription,
- validateFormioSdkCanonicalImports,
- validateFormioSdkForbiddenImports,
- validateFormioSdkScriptTags,
- validateFormioSdkUrlConfigSkill,
- validateFormioSdkUrlConfigReference,
- validateFormioSdkReferenceLayout,
- validateFormioSdkNavigationTable,
- validateFormioSdkRenderingReference,
- validateLibrary,
-} from '../skills-validator.js';
-
-const GOOD_DESCRIPTION =
- 'Source-derived skill teaching @formio/js and @formio/js/utils. Use when the user asks to call SDK methods or invoke Utils helpers. Not for: REST endpoint shape (see formio-api), building an app (see formio-application), planning resources (see formio-resource-planner), or Angular wrappers (see formio-angular).';
-
-function navTable(): string {
- const rows = SDK_REQUIRED_REFERENCES.map((r) => `| Something | [${r}](./references/${r}) |`).join(
- '\n'
- );
- return `| Intent | Reference |\n| --- | --- |\n${rows}\n`;
-}
-
-function makeSkillSource(overrides: { description?: string; navTable?: string } = {}): string {
- const description = overrides.description ?? GOOD_DESCRIPTION;
- const nav = overrides.navTable ?? navTable();
- return `---
-name: ${SDK_SKILL_DIR}
-description: ${JSON.stringify(description)}
----
-
-# Form.io SDK
-
-## Imports
-
-\`\`\`ts
-${SDK_CANONICAL_SDK_IMPORT};
-${SDK_CANONICAL_UTILS_IMPORT};
-\`\`\`
-
-## URL Configuration
-
-### Hosted
-
-\`\`\`ts
-${SDK_CANONICAL_SDK_IMPORT};
-Formio.${SDK_HOSTED_BASE_URL_LITERAL};
-Formio.${SDK_HOSTED_PROJECT_URL_LITERAL};
-\`\`\`
-
-### SaaS
-
-\`\`\`ts
-${SDK_CANONICAL_SDK_IMPORT};
-Formio.${SDK_SAAS_BASE_URL_LITERAL};
-Formio.${SDK_SAAS_PROJECT_URL_LITERAL};
-\`\`\`
-
-## Navigation
-
-${nav}
-`;
-}
-
-function makeReferenceSource(opts: { utils?: boolean; sourcedFrom?: string } = {}): string {
- const sourcedFrom = opts.sourcedFrom ?? 'packages/core/src/sdk/Formio.ts';
- const urlConfig = opts.utils
- ? ''
- : `## URL Configuration
-
-\`\`\`ts
-${SDK_CANONICAL_SDK_IMPORT};
-Formio.${SDK_HOSTED_BASE_URL_LITERAL};
-Formio.${SDK_HOSTED_PROJECT_URL_LITERAL};
-Formio.${SDK_SAAS_BASE_URL_LITERAL};
-Formio.${SDK_SAAS_PROJECT_URL_LITERAL};
-\`\`\`
-
-`;
- const importLine = opts.utils ? SDK_CANONICAL_UTILS_IMPORT : SDK_CANONICAL_SDK_IMPORT;
- return `## Overview
-
-Sourced from \`${sourcedFrom}\`.
-
-## Imports
-
-\`\`\`ts
-${importLine};
-\`\`\`
-
-${urlConfig}## API
-
-- \`thing()\` — does a thing.
-
-## Examples
-
-\`\`\`ts
-${importLine};
-\`\`\`
-`;
-}
-
-function writeSdkSkill(
- root: string,
- opts: {
- skillContents?: string;
- skipSkill?: boolean;
- referenceContents?: Partial>;
- skipReferences?: string[];
- } = {}
-) {
- const skillRoot = path.join(root, SDK_SKILL_DIR);
- fs.mkdirSync(path.join(skillRoot, REFERENCES_DIRNAME), { recursive: true });
- if (!opts.skipSkill) {
- fs.writeFileSync(path.join(skillRoot, SKILL_FILENAME), opts.skillContents ?? makeSkillSource());
- }
- const skipped = new Set(opts.skipReferences ?? []);
- for (const ref of SDK_REQUIRED_REFERENCES) {
- if (skipped.has(ref)) continue;
- const override = opts.referenceContents?.[ref];
- const body =
- override ??
- makeReferenceSource({
- utils: ref.startsWith('utils-'),
- sourcedFrom: ref.startsWith('utils-')
- ? 'packages/core/src/utils/utils.ts'
- : 'packages/core/src/sdk/Formio.ts',
- });
- // rendering.md requires Formio.createForm call + form.on('submit') + submission =
- const finalBody =
- ref === 'rendering.md'
- ? body +
- `\n\`\`\`ts
-${SDK_CANONICAL_SDK_IMPORT};
-const form = await Formio.createForm(document.getElementById('formio'), 'https://forms.mysite.com/myproject/myform');
-form.on('submit', (submission) => console.log(submission));
-form.submission = { data: { name: 'prefilled' } };
-\`\`\`\n`
- : body;
- fs.writeFileSync(path.join(skillRoot, REFERENCES_DIRNAME, ref), finalBody);
- }
-}
-
-describe('validateFormioSdkSkill — scaffold', () => {
- let tmpDir: string;
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-skill-'));
- });
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- });
-
- it('returns [] when plugin/skills/formio-sdk/ is absent', () => {
- expect(validateFormioSdkSkill(tmpDir)).toEqual([]);
- });
-
- it('emits formio_sdk.skill_missing when skill dir exists but SKILL.md is absent', () => {
- fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true });
- const issues = validateFormioSdkSkill(tmpDir);
- expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true);
- });
-
- it('validateLibrary invokes validateFormioSdkSkill and surfaces its issues', () => {
- fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true });
- const issues = validateLibrary(tmpDir);
- expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true);
- });
-
- it('passes on a well-formed scaffolded skill', () => {
- writeSdkSkill(tmpDir);
- const issues = validateFormioSdkSkill(tmpDir);
- if (issues.length > 0) console.error(JSON.stringify(issues, null, 2));
- expect(issues).toEqual([]);
- });
-});
-
-describe('validateFormioSdkSkill — frontmatter + description clauses', () => {
- it('flags empty frontmatter as formio_sdk.frontmatter_missing', () => {
- const issues = validateFormioSdkSkillContent(
- 'formio-sdk/SKILL.md',
- '# heading only, no frontmatter\n'
- );
- expect(issues.some((i) => i.rule === 'formio_sdk.frontmatter_missing')).toBe(true);
- });
-
- it('flags description missing "Use when the user asks to" as trigger', () => {
- const issues = validateFormioSdkDescription('SKILL.md', {
- name: SDK_SKILL_DIR,
- description:
- 'Mentions @formio/js and @formio/js/utils. Not for: formio-api formio-application formio-resource-planner formio-angular',
- });
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('trigger')
- )
- ).toBe(true);
- });
-
- it('flags description missing "Not for:" as negative', () => {
- const issues = validateFormioSdkDescription('SKILL.md', {
- name: SDK_SKILL_DIR,
- description: 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things.',
- });
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('negative')
- )
- ).toBe(true);
- });
-
- it('flags description that omits formio-api in the Not for clause', () => {
- const issues = validateFormioSdkDescription('SKILL.md', {
- name: SDK_SKILL_DIR,
- description:
- 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things. Not for: formio-application, formio-resource-planner, formio-angular.',
- });
- expect(
- issues.some(
- (i) =>
- i.rule === 'formio_sdk.description_clause' &&
- i.message.includes('negative') &&
- i.message.includes('formio-api')
- )
- ).toBe(true);
- });
-
- it('returns no description_clause issues for a full three-clause description', () => {
- const issues = validateFormioSdkDescription('SKILL.md', {
- name: SDK_SKILL_DIR,
- description: GOOD_DESCRIPTION,
- });
- expect(issues.filter((i) => i.rule === 'formio_sdk.description_clause')).toEqual([]);
- });
-});
-
-describe('validateFormioSdkSkill — canonical + forbidden imports + script tags', () => {
- it('emits canonical_import_missing sdk when SDK import is absent', () => {
- const source = `---
-name: ${SDK_SKILL_DIR}
-description: ${JSON.stringify(GOOD_DESCRIPTION)}
----
-
-\`\`\`ts
-${SDK_CANONICAL_UTILS_IMPORT};
-\`\`\`
-`;
- const issues = validateFormioSdkCanonicalImports('SKILL.md', source);
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"sdk"')
- )
- ).toBe(true);
- });
-
- it('emits canonical_import_missing utils when Utils import is absent', () => {
- const source = `\`\`\`ts\n${SDK_CANONICAL_SDK_IMPORT};\n\`\`\`\n`;
- const issues = validateFormioSdkCanonicalImports('SKILL.md', source);
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"utils"')
- )
- ).toBe(true);
- });
-
- it('flags @formio/core import inside fenced block', () => {
- const source = `\`\`\`ts\nimport { Formio } from '@formio/core';\n\`\`\`\n`;
- const issues = validateFormioSdkForbiddenImports('ref.md', source);
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/core')
- )
- ).toBe(true);
- });
-
- it('flags @formio/js/lib deep import', () => {
- const source = `\`\`\`ts\nimport x from '@formio/js/lib/Formio';\n\`\`\`\n`;
- const issues = validateFormioSdkForbiddenImports('ref.md', source);
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/js/lib/')
- )
- ).toBe(true);
- });
-
- it('flags require() of @formio/js', () => {
- const source = `\`\`\`js\nconst { Formio } = require('@formio/js');\n\`\`\`\n`;
- const issues = validateFormioSdkForbiddenImports('ref.md', source);
- expect(
- issues.some(
- (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('"@formio/js"')
- )
- ).toBe(true);
- });
-
- it('does not flag @formio/core mentioned in prose outside a code fence', () => {
- const source = 'The renderer extends @formio/core SDK methods.\n';
- expect(validateFormioSdkForbiddenImports('ref.md', source)).toEqual([]);
- });
-
- it('flags \n\`\`\`\n`;
- const issues = validateFormioSdkScriptTags('ref.md', source);
- expect(issues.some((i) => i.rule === 'formio_sdk.forbidden_script_tag')).toBe(true);
- });
-
- it('does not flag world', {});
+ const html = safe.toString();
+ expect(html.toLowerCase()).not.toContain('