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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions openspec/specs/formio-client/spec.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
## Purpose

Defines the HTTP client every tool goes through: how a request to the Form.io API is authenticated, and how errors are handled — including re-authentication on a 401.

## Requirements

### Requirement: Authenticated requests to Form.io API

The `formioFetch` function SHALL send requests with the appropriate auth header based on the config's auth mode. If `config.jwt` is set, it SHALL use `x-jwt-token`. If `config.apiKey` is set, it SHALL use `x-token`. The function SHALL support GET, POST, and PUT requests with JSON body serialization.
Expand Down Expand Up @@ -69,3 +67,50 @@ The `formioFetch` function SHALL throw a descriptive error when the API returns

- **WHEN** the Form.io API responds with status 400
- **THEN** `formioFetch` throws an error containing the status code

### Requirement: Every request stays under the Project URL

`formioFetch` SHALL build the request URL from the resolved Project URL and the given path, and SHALL refuse to send it unless the built URL has the same origin as the Project URL and its pathname begins with the Project URL's pathname followed by `/` (or equals it). A refusal SHALL throw an error naming the path it was given and the Project URL it must stay under, and no request SHALL be made. The check SHALL run after query parameters are applied and before any auth header is built, so it holds for every caller, including any caller that does not pass through the tool-argument rule.

#### Scenario: A path under a hosted project is sent

- **WHEN** `formioFetch` is called with path `user/login` and Project URL `https://examples.form.io`
- **THEN** the request is sent to `https://examples.form.io/user/login`

#### Scenario: A path under a sub-directory project is sent

- **WHEN** `formioFetch` is called with path `form/65a1b2c3d4e5f60718293a4b` and Project URL `https://forms.mysite.com/myproject`
- **THEN** the request is sent to `https://forms.mysite.com/myproject/form/65a1b2c3d4e5f60718293a4b`

#### Scenario: A path that resolves to another origin is refused

- **WHEN** `formioFetch` is called with path `https://example.com/x` and Project URL `https://examples.form.io`
- **THEN** it throws naming the path and `https://examples.form.io`
- **AND** no request is sent

#### Scenario: A path that resolves outside a sub-directory project is refused

- **WHEN** `formioFetch` is called with path `../otherproject/form` and Project URL `https://forms.mysite.com/myproject`
- **THEN** it throws naming the path and `https://forms.mysite.com/myproject`
- **AND** no request is sent

#### Scenario: A sibling path that shares the project's prefix is refused

- **WHEN** the built URL is `https://forms.mysite.com/myproject2/form` and the Project URL is `https://forms.mysite.com/myproject`
- **THEN** it throws and no request is sent

### Requirement: Redirects are reported, not followed

Every request that carries a credential — `formioFetch`, `formioRawFetch`, and token validation — SHALL be sent with redirects not followed, so a 3xx response never re-sends the request, with its `x-jwt-token` or `x-token` header, to the location it names. A 3xx response SHALL be reported as an error naming the status, the request URL, and the location it pointed to.

#### Scenario: A redirect is not followed

- **WHEN** a request under the Project URL receives `302` with `Location: https://other.example/`
- **THEN** exactly one request is sent
- **AND** the error names `302` and `https://other.example/`

#### Scenario: Token validation does not follow a redirect

- **WHEN** `validateToken` receives a `302`
- **THEN** it returns false and sends no second request

41 changes: 38 additions & 3 deletions openspec/specs/lazy-auth/spec.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
## Purpose

Defines when the server authenticates: not at startup, but at the first Form.io API call — with the JWT reused afterwards, concurrent calls sharing one flow, 401s re-entering the same gate, failures surfacing as tool errors rather than crashes, and API-key mode bypassing the flow entirely.

## Requirements

### Requirement: Authentication is deferred until the first Form.io API call

The MCP server SHALL NOT perform any authentication work during process startup or stdio transport connection. Authentication SHALL be triggered only when a Form.io tool makes its first outbound API request.
Expand Down Expand Up @@ -92,17 +90,53 @@ When the authentication gate fails (e.g., user closes the browser, API key inval

### Requirement: API key mode bypasses the login flow

When `FORMIO_API_KEY` is set, the authentication gate SHALL validate the key on first use and set no JWT. It SHALL NOT open a browser or read/write the token cache.
`FORMIO_API_KEY` is issued by one project, so it SHALL apply only to that project: the key SHALL be in effect for a tool call when `FORMIO_PROJECT_URL` is set and the resolved Project URL is the same Project URL once both are normalized. Origins are not enough: the projects of a sub-directory deployment share one origin. When it is in effect, the authentication gate SHALL validate the key on first use and set no JWT, SHALL NOT open a browser or read/write the token cache, and requests SHALL send the `x-token` header.

When the key is set but not in effect — `FORMIO_PROJECT_URL` is unset, or the resolved project (from a committed `formio.json` or a directory mapping) is a different project — the resolved configuration SHALL carry no API key, the `x-token` header SHALL NOT be sent, and the gate SHALL run the portal-login flow exactly as it does when no key is set. The `project_get` report SHALL state that `FORMIO_API_KEY` is set but not applied to the resolved project, and name the project it applies to (or that `FORMIO_PROJECT_URL` is unset). A portal-login failure that names the API key as a remedy — no browser on the host, or the login timing out — SHALL state that reason instead of asking the user to set the key.

#### Scenario: Valid API key passes the gate

- **WHEN** `FORMIO_API_KEY` is set
- **AND** `FORMIO_PROJECT_URL` is `https://examples.form.io` and the resolved Project URL is `https://examples.form.io`
- **AND** a tool call triggers the auth gate for the first time
- **AND** `validateToken(config)` returns true
- **THEN** the gate succeeds without touching the token cache or launching a browser
- **AND** `config.jwt` remains undefined
- **AND** subsequent requests send the `x-token` header

#### Scenario: The key's own sub-directory project uses the key

- **WHEN** `FORMIO_API_KEY` is set and `FORMIO_PROJECT_URL` is `https://forms.mysite.com/myproject`
- **AND** the resolved Project URL is `https://forms.mysite.com/myproject`
- **THEN** requests send the `x-token` header and no browser is opened

#### Scenario: Another project on the same sub-directory deployment does not receive the key

- **WHEN** `FORMIO_API_KEY` is set and `FORMIO_PROJECT_URL` is `https://forms.mysite.com/myproject`
- **AND** a committed `formio.json` resolves the Project URL `https://forms.mysite.com/other`
- **THEN** no request carries the `x-token` header
- **AND** the resolution notes state the key is not applied

#### Scenario: A browserless login names the withheld key

- **WHEN** the key is set but not applied and the portal login fails because the host has no browser
- **THEN** the error states why the key was not applied
- **AND** it does not ask the user to set `FORMIO_API_KEY`

#### Scenario: A committed project on another origin does not receive the key

- **WHEN** `FORMIO_API_KEY` is set and `FORMIO_PROJECT_URL` is `https://examples.form.io`
- **AND** a committed `formio.json` resolves the Project URL `https://other.form.io`
- **THEN** no request carries the `x-token` header
- **AND** the gate runs the portal-login flow for the resolved deployment

#### Scenario: A key without FORMIO_PROJECT_URL is not applied

- **WHEN** `FORMIO_API_KEY` is set and `FORMIO_PROJECT_URL` is unset
- **AND** a directory mapping resolves a Project URL
- **THEN** no request carries the `x-token` header
- **AND** `project_get` states the key is not applied because `FORMIO_PROJECT_URL` is unset

### Requirement: The `stdio.ts` entry point does not perform authentication

The stdio entry point SHALL read configuration, create the MCP server, and connect the stdio transport in that order, with no authentication calls between those steps.
Expand All @@ -112,3 +146,4 @@ The stdio entry point SHALL read configuration, create the MCP server, and conne
- **WHEN** inspecting `packages/mcp-server/src/stdio.ts`
- **THEN** it does not import `ensureAuthenticated`, `startupAuth`, `authenticate`, `validateToken`, or `token-cache`
- **AND** the transport is connected synchronously after `createServer(config)`

61 changes: 60 additions & 1 deletion openspec/specs/project-map-routing/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ A project URL that resolves while its base URL does not SHALL fail at the point
- **WHEN** nothing configures a project
- **AND** `hello` is called
- **THEN** it succeeds

### Requirement: project_set is registered for every client

`project_set` SHALL be registered unconditionally in `registerAllTools`, with no dependence on any host-mode environment variable. It SHALL write the supplied project URL — and base URL when supplied — to `~/.formio/projects.json` under the caller's `cwd`, with file mode `0600`.
Expand Down Expand Up @@ -272,7 +273,6 @@ The schema SHALL NOT vary by host mode, and the server SHALL NOT build a differe
- **AND** a tool is called with no `cwd`
- **THEN** the call proceeds against the environment-supplied project


### Requirement: A record holds a project and its deployment as a pair

Every write SHALL leave a record holding a complete configuration. `project_set` and `project set` SHALL derive the base URL from the project URL at save time and store BOTH in the record they write, so that a record naming a project always names the deployment that serves it.
Expand Down Expand Up @@ -413,3 +413,62 @@ Every write that forms a pair SHALL refuse it before anything reaches disk, so t

- **WHEN** a write pairs `https://myproject.mysite.com` with `https://api.mysite.com`
- **THEN** it succeeds

### Requirement: A path-less customer project pairs only with a deployment on its registrable domain

A project URL with no path on a host outside `form.io` names its deployment nowhere: the deployment is a sibling sub-domain of the same parent domain. The pair rule SHALL accept such a project with a deployment only when both hosts have the same registrable domain, computed against the public suffix list so that multi-label suffixes (`co.uk`, `com.au`) are not treated as a shared parent. Where neither host's suffix is on the public suffix list — `localhost`, or a private TLD such as `internal` — there is no registrable-domain level, so the hosts SHALL be compared directly: the deployment host is the project host, the project host with its first label removed, or a host under that parent. An IP literal SHALL pair only with itself.

A deployment on another registrable domain SHALL be a deployment-half verdict, handled everywhere the existing "API root is not your deployment" verdict is: every writer (`project_set` and `project set`) SHALL refuse it before anything reaches disk, naming both hosts and the rule; the resolver SHALL set the recorded deployment aside with a note naming the record and the rule, and leave the deployment unresolved so the next call asks for it. A pair recorded by `project set --force` SHALL be honoured unchanged, as every forced pair is. Hosted-cloud projects and sub-directory projects are outside this rule: their deployment is derived, and the existing verdicts already govern a recorded value that differs.

#### Scenario: The documented sibling-sub-domain shape is accepted

- **WHEN** a write pairs `https://myproject.mysite.com` with `https://api.mysite.com`
- **THEN** it succeeds and no note or prompt is produced

#### Scenario: A deployment deeper in the same domain is accepted

- **WHEN** a write pairs `https://myproject.forms.mysite.com` with `https://forms.mysite.com`
- **THEN** it succeeds

#### Scenario: A multi-label public suffix is not a shared parent

- **WHEN** a write pairs `https://myproject.mysite.co.uk` with `https://api.mysite.co.uk`
- **THEN** it succeeds
- **AND WHEN** a write pairs `https://myproject.mysite.co.uk` with `https://api.othersite.co.uk`
- **THEN** it is refused naming both hosts

#### Scenario: A deployment on an unrelated domain is refused by a writer

- **WHEN** `project_set` is asked to pair `https://myproject.mysite.com` with `https://forms.othersite.com`
- **THEN** it fails naming both hosts and the registrable-domain rule
- **AND** nothing is recorded for that directory

#### Scenario: An unrelated deployment in a committed file is set aside at read

- **WHEN** a committed `formio.json` holds `projectUrl: "https://myproject.mysite.com"` and `baseUrl: "https://forms.othersite.com"`
- **THEN** resolution notes that the recorded Base URL was ignored, naming the file and the rule
- **AND** `project_get` reports `status: "base-url-unresolved"` for the project
- **AND** no request is sent to either host until a deployment is supplied

#### Scenario: A local single-label host pairs with its sub-domain project

- **WHEN** a write pairs `http://myproject.localhost:3000` with `http://localhost:3000`
- **THEN** it succeeds

#### Scenario: Sibling hosts under an unlisted suffix are accepted

- **WHEN** a write pairs `http://myproject.localhost:3000` with `http://api.localhost:3000`, or `https://myproject.internal` with `https://api.internal`
- **THEN** it succeeds

#### Scenario: A forced pair is honoured

- **WHEN** a mapping entry recorded by `project set --force` pairs `https://myproject.mysite.com` with `https://forms.othersite.com`
- **THEN** resolution uses both values with no note

#### Scenario: Hosted and sub-directory projects resolve as before

- **WHEN** the Project URL is `https://examples.form.io` with no recorded deployment
- **THEN** the deployment resolves to `https://api.form.io` with no note
- **AND WHEN** the Project URL is `https://forms.mysite.com/myproject` with no recorded deployment
- **THEN** the deployment resolves to `https://forms.mysite.com` with no note

85 changes: 85 additions & 0 deletions openspec/specs/tool-path-arguments/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# tool-path-arguments Specification

## Purpose

Defines the one rule every MCP tool applies to an argument before it becomes part of a request path — an allowlist of path segments — and the single-segment rule for arguments that name one resource.

## Requirements

### Requirement: Tool arguments that become request paths are validated by one rule

Every tool argument that is joined into a request path SHALL be checked by one shared rule before the handler resolves a project or makes any request. The rule SHALL be an allowlist: the value is one or more `/`-separated segments, and every segment consists only of ASCII letters, digits, `-` and `_` — the characters Form.io accepts in a form path, plus `_`. A value that is empty, begins with `/`, has an empty segment, or has a segment holding any other character — a scheme's `:`, a `.` or `..` segment, a percent-encoded `%2e%2e`, a `?` or `#`, a backslash — SHALL be refused. A refusal SHALL be returned as a tool error with `isError: true` that names the argument and the value, and SHALL say what shape the argument accepts. No request SHALL be made for a refused value.

The rule applies to: `formIdOrPath` on `form_get`, `form_revisions_list` and `form_revision_get`; `formId` on every `action_*` tool; `actionId` on `action_get`, `action_update` and `action_delete`; `actionName` on `action_type_get`; and `version` on `form_revision_get` and `form_update`. Arguments that already require a 24-character hex ObjectId — `formId` on `form_update` and `roleId` on `role_update` — SHALL keep that check, which this rule does not loosen.

#### Scenario: A form path with separators is accepted

- **WHEN** `form_get` is called with `formIdOrPath: "user/login"`
- **THEN** the request is made to `{projectUrl}/user/login`

#### Scenario: A form ObjectId is accepted

- **WHEN** `form_get` is called with `formIdOrPath: "65a1b2c3d4e5f60718293a4b"`
- **THEN** the request is made to `{projectUrl}/form/65a1b2c3d4e5f60718293a4b`

#### Scenario: A value carrying a scheme is refused

- **WHEN** `form_get` is called with `formIdOrPath: "https://example.com/x"`
- **THEN** the tool returns `isError: true` naming `formIdOrPath`
- **AND** no request is made

#### Scenario: A value beginning with a slash is refused

- **WHEN** `form_get` is called with `formIdOrPath: "//example.com/x"` or `"/user/login"`
- **THEN** the tool returns `isError: true` naming `formIdOrPath`
- **AND** no request is made

#### Scenario: A dot segment is refused

- **WHEN** `form_revisions_list` is called with `formIdOrPath: "user/../../other"`
- **THEN** the tool returns `isError: true` naming `formIdOrPath`
- **AND** no request is made

#### Scenario: A percent-encoded dot segment is refused

- **WHEN** `action_delete` is called with a valid `formId` and `actionId: "%2e%2e"`
- **THEN** the tool returns `isError: true` naming `actionId`
- **AND** no request is made

#### Scenario: A query or fragment character is refused

- **WHEN** `action_delete` is called with `formId: "65a1b2c3d4e5f60718293a4b#"` or `"65a1b2c3d4e5f60718293a4b?"`
- **THEN** the tool returns `isError: true` naming `formId`
- **AND** no request is made

#### Scenario: A revert version is checked by the same rule

- **WHEN** `form_update` is called with `revert: true` and `version: "../../export"`
- **THEN** the tool returns `isError: true` naming `version`
- **AND** no request is made

#### Scenario: A backslash or an empty segment is refused

- **WHEN** `form_get` is called with `formIdOrPath: "user\\login"` or `"user//login"`
- **THEN** the tool returns `isError: true` naming `formIdOrPath`

### Requirement: Arguments that name one resource accept a single segment

`formId` on the `action_*` tools, `actionId`, `actionName`, and `version` each name ONE resource, so in addition to the shared rule each SHALL refuse a value containing `/`. The refusal SHALL name the argument.

#### Scenario: A multi-segment action ID is refused

- **WHEN** `action_get` is called with `formId: "65a1b2c3d4e5f60718293a4b"` and `actionId: "abc/def"`
- **THEN** the tool returns `isError: true` naming `actionId`
- **AND** no request is made

#### Scenario: A revision number is accepted

- **WHEN** `form_revision_get` is called with `formIdOrPath: "user/login"` and `version: "3"`
- **THEN** the request is made to `{projectUrl}/user/login/v/3`

#### Scenario: An action type name is accepted

- **WHEN** `action_type_get` is called with a valid `formId` and `actionName: "email"`
- **THEN** the request is made to `{projectUrl}/form/{formId}/actions/email`

Loading