@formio/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/auth-header.d.ts +2 -0
- package/dist/auth-header.js +9 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +148 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +29 -0
- package/dist/ensure-auth.d.ts +4 -0
- package/dist/ensure-auth.js +56 -0
- package/dist/formio-client.d.ts +11 -0
- package/dist/formio-client.js +60 -0
- package/dist/mcp-responses.d.ts +13 -0
- package/dist/mcp-responses.js +12 -0
- package/dist/project-map.d.ts +5 -0
- package/dist/project-map.js +29 -0
- package/dist/project-resolver.d.ts +4 -0
- package/dist/project-resolver.js +35 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +9 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +8 -0
- package/dist/token-cache.d.ts +3 -0
- package/dist/token-cache.js +34 -0
- package/dist/token-validation.d.ts +2 -0
- package/dist/token-validation.js +6 -0
- package/dist/tools/action-schema.d.ts +39 -0
- package/dist/tools/action-schema.js +44 -0
- package/dist/tools/action_create.d.ts +3 -0
- package/dist/tools/action_create.js +37 -0
- package/dist/tools/action_delete.d.ts +3 -0
- package/dist/tools/action_delete.js +22 -0
- package/dist/tools/action_get.d.ts +3 -0
- package/dist/tools/action_get.js +20 -0
- package/dist/tools/action_list.d.ts +3 -0
- package/dist/tools/action_list.js +19 -0
- package/dist/tools/action_type_get.d.ts +3 -0
- package/dist/tools/action_type_get.js +29 -0
- package/dist/tools/action_types_list.d.ts +3 -0
- package/dist/tools/action_types_list.js +19 -0
- package/dist/tools/action_update.d.ts +3 -0
- package/dist/tools/action_update.js +25 -0
- package/dist/tools/form_create.d.ts +3 -0
- package/dist/tools/form_create.js +38 -0
- package/dist/tools/form_get.d.ts +3 -0
- package/dist/tools/form_get.js +25 -0
- package/dist/tools/form_list.d.ts +3 -0
- package/dist/tools/form_list.js +37 -0
- package/dist/tools/form_update.d.ts +3 -0
- package/dist/tools/form_update.js +39 -0
- package/dist/tools/hello.d.ts +2 -0
- package/dist/tools/hello.js +6 -0
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.js +44 -0
- package/dist/tools/project_export.d.ts +3 -0
- package/dist/tools/project_export.js +17 -0
- package/dist/tools/project_import.d.ts +3 -0
- package/dist/tools/project_import.js +23 -0
- package/dist/tools/project_set.d.ts +5 -0
- package/dist/tools/project_set.js +54 -0
- package/dist/tools/role-schema.d.ts +7 -0
- package/dist/tools/role-schema.js +10 -0
- package/dist/tools/role_create.d.ts +3 -0
- package/dist/tools/role_create.js +24 -0
- package/dist/tools/role_list.d.ts +3 -0
- package/dist/tools/role_list.js +20 -0
- package/dist/tools/role_update.d.ts +3 -0
- package/dist/tools/role_update.js +30 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Form.io LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
## Formio MCP server
|
|
2
|
+
|
|
3
|
+
The MCP server (`@formio/mcp`) is independently usable from any MCP-aware client. From a clone of this repo:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm install
|
|
7
|
+
pnpm --filter @formio/mcp dev
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The server starts on port 3000. Override with the `PORT` env var.
|
|
11
|
+
|
|
12
|
+
### Transports
|
|
13
|
+
|
|
14
|
+
| Transport | Endpoint | Compatible with |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| Streamable HTTP | `POST /mcp` | Claude Code, VS Copilot, modern MCP clients |
|
|
17
|
+
| SSE | `GET /sse` + `POST /messages` | Claude Desktop, legacy MCP clients |
|
|
18
|
+
| stdio | `node dist/stdio.js` | `.mcp.json` spawn-mode clients |
|
|
19
|
+
|
|
20
|
+
### Connect to Claude Code
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"formio-mcp": {
|
|
26
|
+
"type": "http",
|
|
27
|
+
"url": "http://localhost:3000/mcp"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Connect to Claude Desktop
|
|
34
|
+
|
|
35
|
+
`claude_desktop_config.json`:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"formio-mcp": {
|
|
41
|
+
"url": "http://localhost:3000/sse"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Spawn via `.mcp.json` (stdio)
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"formio-mcp": {
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": ["-y", "@formio/mcp"],
|
|
55
|
+
"env": {
|
|
56
|
+
"FORMIO_BASE_URL": "https://api.form.io",
|
|
57
|
+
"FORMIO_PROJECT_URL": "https://your-project.form.io"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
In standalone (non-plugin) mode, `FORMIO_BASE_URL` and `FORMIO_PROJECT_URL` are required env vars. In plugin mode, the plugin manages both via Claude Code's user-config + per-cwd `~/.formio/projects.json` mapping.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## MCP server tools
|
|
69
|
+
|
|
70
|
+
The bundled `@formio/mcp` server exposes these tools. Skills prefer these over raw HTTP whenever an operation is covered.
|
|
71
|
+
|
|
72
|
+
### Forms
|
|
73
|
+
|
|
74
|
+
| Tool | Purpose |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `form_create` | Create a new form. Use the `formio-form` skill first to build the JSON definition. |
|
|
77
|
+
| `form_get` | Fetch a single form definition by ID or path. |
|
|
78
|
+
| `form_list` | List forms with optional filtering and pagination. |
|
|
79
|
+
| `form_update` | Update an existing form. Call `form_get` first, edit with `formio-form`, then update. |
|
|
80
|
+
|
|
81
|
+
### Roles
|
|
82
|
+
|
|
83
|
+
| Tool | Purpose |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `role_create` | Create a new project role. |
|
|
86
|
+
| `role_list` | List all project roles. |
|
|
87
|
+
| `role_update` | Full-replacement update of a role. Include all fields you want preserved. |
|
|
88
|
+
|
|
89
|
+
### Actions
|
|
90
|
+
|
|
91
|
+
| Tool | Purpose |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `action_types_list` | List all action types available on the server. |
|
|
94
|
+
| `action_type_get` | Get an action type's settings schema. |
|
|
95
|
+
| `action_create` | Attach a new action to a form. |
|
|
96
|
+
| `action_list` | List actions on a form. |
|
|
97
|
+
| `action_get` | Get a single action by ID. |
|
|
98
|
+
| `action_update` | Update an action. |
|
|
99
|
+
| `action_delete` | Detach an action from a form. |
|
|
100
|
+
|
|
101
|
+
### Project
|
|
102
|
+
|
|
103
|
+
| Tool | Purpose |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `project_export` | Export the project's complete template (roles, resources, forms, actions) as a portable JSON document. Use before `project_import` to snapshot. |
|
|
106
|
+
| `project_import` | Import a template JSON — additively merges roles, resources, forms, and actions in one call. **Same-machine-name items are overwritten in place; everything else is preserved.** |
|
|
107
|
+
| `project_set` | Plugin-mode only — persist a per-cwd Project URL mapping in `~/.formio/projects.json`. Never exposed standalone (the standalone server binds to `FORMIO_PROJECT_URL` via env instead). |
|
|
108
|
+
|
|
109
|
+
### Diagnostic
|
|
110
|
+
|
|
111
|
+
| Tool | Purpose |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `hello` | Smoke-test tool. Returns a static greeting; useful for verifying MCP wiring before any authenticated call. |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Authentication
|
|
118
|
+
|
|
119
|
+
The MCP server supports two authentication modes:
|
|
120
|
+
|
|
121
|
+
- **JWT mode (default).** A short-lived local Express server renders the Form.io portal login form; the user signs in once, the JWT comes back via a `/callback` endpoint, and `formioFetch` attaches `x-jwt-token` on every subsequent request. The flow is implicit — the **first authenticated tool call** triggers it on a cache miss. No explicit `authenticate` tool exists.
|
|
122
|
+
- **API-key mode.** Set `FORMIO_API_KEY`. All requests attach `x-token`; the browser flow is skipped entirely.
|
|
123
|
+
|
|
124
|
+
### Login-form auto-resolution
|
|
125
|
+
|
|
126
|
+
When `FORMIO_LOGIN_FORM` is unset, the server probes these candidates on the first login attempt and caches the first one that responds (1.5-second timeout per candidate):
|
|
127
|
+
|
|
128
|
+
1. `${FORMIO_BASE_URL}/formio/user/login` (portal-base)
|
|
129
|
+
2. `${FORMIO_PROJECT_URL}/admin/login` (project admin)
|
|
130
|
+
3. `${FORMIO_PROJECT_URL}/user/login` (project user)
|
|
131
|
+
|
|
132
|
+
The probe runs lazily — only when the local auth page is actually served.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Environment variables
|
|
137
|
+
|
|
138
|
+
| Name | Required | Default | Purpose | Hosted SaaS example | Self-hosted example |
|
|
139
|
+
| --- | :-: | --- | --- | --- | --- |
|
|
140
|
+
| `FORMIO_BASE_URL` | yes | — | Full base URL of your Form.io deployment. | `https://api.form.io` | `https://forms.example.com` |
|
|
141
|
+
| `FORMIO_PROJECT_URL` | yes\* | — | Full URL of your Form.io project. In plugin mode, only used as the pre-filled default offered when prompting for an unmapped cwd. | `https://myproject.form.io` | `https://forms.example.com/myproject` |
|
|
142
|
+
| `FORMIO_API_KEY` | no | `undefined` | Long-lived project API key. When set, the server skips the browser login flow. | `CHANGEME` | `CHANGEME` |
|
|
143
|
+
| `FORMIO_LOGIN_FORM` | no | Auto-resolved | Override the portal login form URL used by the JWT login flow. | `https://formio.form.io/user/login` | `https://forms.example.com/formio/user/login` |
|
|
144
|
+
| `FORMIO_PLUGIN_CONTEXT` | no | `0` | Set by the plugin manifest. When `1`, the server enables `project_set` and reads `FORMIO_PROJECT_URL` from `~/.formio/projects.json` per cwd instead of env. | | |
|
|
145
|
+
|
|
146
|
+
\* In plugin context, `FORMIO_PROJECT_URL` is captured per-cwd by the `project_set` tool and persisted to `~/.formio/projects.json`. The `verify-project-url` `SessionStart`/`PreToolUse` hook offers `formio_default_project_url` (from plugin user-config) as the default the first time you enter a workspace.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ResolvedFormioConfig } from './config.js';
|
|
2
|
+
export interface AuthenticateOptions {
|
|
3
|
+
onReady?: (port: number) => void;
|
|
4
|
+
}
|
|
5
|
+
export declare function resetLoginFormCache(): void;
|
|
6
|
+
export declare function authenticate(config: ResolvedFormioConfig, options?: AuthenticateOptions): Promise<string>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { formioRawFetch } from './formio-client.js';
|
|
4
|
+
function buildLoginPage(loginFormUrl) {
|
|
5
|
+
const domain = new URL(loginFormUrl).hostname;
|
|
6
|
+
return `<!DOCTYPE html>
|
|
7
|
+
<html>
|
|
8
|
+
<head>
|
|
9
|
+
<title>Form.IO Login</title>
|
|
10
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,700">
|
|
11
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap/dist/css/bootstrap.min.css">
|
|
12
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
|
|
13
|
+
<link rel="stylesheet" href="https://cdn.form.io/js/5.2.2/formio.full.min.css">
|
|
14
|
+
<style>
|
|
15
|
+
body { font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; background: #fff; margin: 0; padding: 2rem 1rem; color: #333; }
|
|
16
|
+
.logo-wrap { text-align: center; margin-bottom: 1rem; }
|
|
17
|
+
.logo-wrap img { max-width: 300px; height: auto; }
|
|
18
|
+
.page-title { text-align: center; margin-bottom: 2rem; font-weight: 300; color: #333; }
|
|
19
|
+
.auth-card { max-width: 480px; margin: 0 auto; background: #fff; padding: 2rem; border: 1px solid #e5e5e5; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
|
20
|
+
.auth-title { text-align: center; margin-bottom: 1.5rem; font-size: 1.1rem; letter-spacing: 0.5px; color: #555; }
|
|
21
|
+
.btn-primary, .btn-success { background-color: #67b346; border-color: #67b346; }
|
|
22
|
+
.btn-primary:hover, .btn-success:hover { background-color: #57a036; border-color: #57a036; }
|
|
23
|
+
#status { text-align: center; margin-top: 1rem; color: #666; font-size: 0.9rem; }
|
|
24
|
+
</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<div class="logo-wrap">
|
|
28
|
+
<img src="https://portal.form.io/template/images/formio-logo-with-slogan.png" alt="Form.io">
|
|
29
|
+
</div>
|
|
30
|
+
<h2 class="page-title">Logging in to ${domain}</h2>
|
|
31
|
+
<div class="auth-card">
|
|
32
|
+
<h4 class="auth-title">RETURNING USER LOGIN</h4>
|
|
33
|
+
<div id="formio"></div>
|
|
34
|
+
<div id="status"></div>
|
|
35
|
+
</div>
|
|
36
|
+
<script src="https://cdn.form.io/js/5.2.2/formio.form.min.js"></script>
|
|
37
|
+
<script>
|
|
38
|
+
var statusEl = document.getElementById('status');
|
|
39
|
+
Formio.createForm(document.getElementById('formio'), '${loginFormUrl}').then(function(form) {
|
|
40
|
+
form.on('submit', function(submission) {
|
|
41
|
+
var token = Formio.getToken();
|
|
42
|
+
statusEl.innerHTML = token ? 'Token captured, completing login...' : 'Error: No token received from Form.io SDK';
|
|
43
|
+
if (!token) return;
|
|
44
|
+
fetch('/callback', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ token: token })
|
|
48
|
+
}).then(function() {
|
|
49
|
+
statusEl.innerHTML = 'Login successful. You can close this tab.';
|
|
50
|
+
}).catch(function(err) {
|
|
51
|
+
statusEl.innerHTML = 'Error sending token: ' + err.message;
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}).catch(function(err) {
|
|
55
|
+
statusEl.innerHTML = 'Error loading form: ' + err.message;
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>`;
|
|
60
|
+
}
|
|
61
|
+
const LOGIN_FORM_PROBE_TIMEOUT_MS = 1500;
|
|
62
|
+
const resolvedLoginFormCache = new Map();
|
|
63
|
+
function loginFormCacheKey(config) {
|
|
64
|
+
return `${config.baseUrl ?? ''}|${config.projectUrl}`;
|
|
65
|
+
}
|
|
66
|
+
export function resetLoginFormCache() {
|
|
67
|
+
resolvedLoginFormCache.clear();
|
|
68
|
+
}
|
|
69
|
+
async function checkLoginForm(loginFormUrl, config) {
|
|
70
|
+
const url = new URL(loginFormUrl);
|
|
71
|
+
url.searchParams.set('select', '_id');
|
|
72
|
+
const loginForm = await formioRawFetch(url, config, {
|
|
73
|
+
signal: AbortSignal.timeout(LOGIN_FORM_PROBE_TIMEOUT_MS),
|
|
74
|
+
}).catch(() => null);
|
|
75
|
+
return loginForm?._id ? true : false;
|
|
76
|
+
}
|
|
77
|
+
async function resolveDefaultLoginFormUrl(config) {
|
|
78
|
+
const cacheKey = loginFormCacheKey(config);
|
|
79
|
+
const cached = resolvedLoginFormCache.get(cacheKey);
|
|
80
|
+
if (cached) {
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
const candidates = [];
|
|
84
|
+
if (config.baseUrl) {
|
|
85
|
+
candidates.push(`${config.baseUrl}/formio/user/login`);
|
|
86
|
+
}
|
|
87
|
+
candidates.push(`${config.projectUrl}/admin/login`);
|
|
88
|
+
candidates.push(`${config.projectUrl}/user/login`);
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
if (await checkLoginForm(candidate, config)) {
|
|
91
|
+
resolvedLoginFormCache.set(cacheKey, candidate);
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return candidates[0];
|
|
96
|
+
}
|
|
97
|
+
export async function authenticate(config, options) {
|
|
98
|
+
const app = express();
|
|
99
|
+
app.use(express.json());
|
|
100
|
+
let resolveJwt;
|
|
101
|
+
let rejectJwt;
|
|
102
|
+
const jwtPromise = new Promise((resolve, reject) => {
|
|
103
|
+
resolveJwt = resolve;
|
|
104
|
+
rejectJwt = reject;
|
|
105
|
+
});
|
|
106
|
+
app.get('/', async (_req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const loginFormUrl = config.loginFormUrl ?? (await resolveDefaultLoginFormUrl(config));
|
|
109
|
+
res.send(buildLoginPage(loginFormUrl));
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
113
|
+
res.status(500).send(`Login form resolution failed: ${message}`);
|
|
114
|
+
rejectJwt(err instanceof Error ? err : new Error(message));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
app.post('/callback', (req, res) => {
|
|
118
|
+
const token = req.body.token;
|
|
119
|
+
if (!token) {
|
|
120
|
+
process.stderr.write('Auth callback received but token is empty\n');
|
|
121
|
+
res.status(400).send('No token received');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
process.stderr.write('Auth callback received token successfully\n');
|
|
125
|
+
res.send('Login successful. You can close this tab.');
|
|
126
|
+
resolveJwt(token);
|
|
127
|
+
});
|
|
128
|
+
const server = app.listen(0, '127.0.0.1', () => {
|
|
129
|
+
const addr = server.address();
|
|
130
|
+
if (addr && typeof addr !== 'string') {
|
|
131
|
+
const port = addr.port;
|
|
132
|
+
const loginUrl = `http://127.0.0.1:${port}/`;
|
|
133
|
+
const openCmd = process.platform === 'darwin'
|
|
134
|
+
? 'open'
|
|
135
|
+
: process.platform === 'win32'
|
|
136
|
+
? 'start'
|
|
137
|
+
: 'xdg-open';
|
|
138
|
+
exec(`${openCmd} "${loginUrl}"`);
|
|
139
|
+
options?.onReady?.(port);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
return await jwtPromise;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
server.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface FormioConfig {
|
|
2
|
+
baseUrl?: string;
|
|
3
|
+
projectUrl?: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
loginFormUrl?: string;
|
|
6
|
+
jwt?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ResolvedFormioConfig extends FormioConfig {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
projectUrl: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function getConfig(): FormioConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function getConfig() {
|
|
2
|
+
const apiKey = process.env.FORMIO_API_KEY;
|
|
3
|
+
const loginFormUrl = process.env.FORMIO_LOGIN_FORM;
|
|
4
|
+
// Standalone-use fallback: when the server is launched outside the plugin
|
|
5
|
+
// (e.g. via .mcp.json), FORMIO_PROJECT_URL lets users skip project_set
|
|
6
|
+
// entirely. Plugin context leaves this unset — the SessionStart hook
|
|
7
|
+
// drives per-cwd project_set instead.
|
|
8
|
+
const pluginContext = process.env.FORMIO_PLUGIN_CONTEXT === '1';
|
|
9
|
+
// Plugin always collects FORMIO_BASE_URL via user-config, so require it
|
|
10
|
+
// there. Standalone falls back to the hosted cloud default.
|
|
11
|
+
const baseUrl = pluginContext
|
|
12
|
+
? process.env.FORMIO_BASE_URL
|
|
13
|
+
: process.env.FORMIO_BASE_URL || 'https://api.form.io';
|
|
14
|
+
if (!baseUrl) {
|
|
15
|
+
throw new Error('FORMIO_BASE_URL is required');
|
|
16
|
+
}
|
|
17
|
+
// standalone mcp server (outside of claude plugin) needs to set project url from process.env
|
|
18
|
+
const projectUrl = pluginContext ? null : process.env.FORMIO_PROJECT_URL;
|
|
19
|
+
if (!projectUrl && !pluginContext) {
|
|
20
|
+
throw new Error('FORMIO_PROJECT_URL is required');
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
24
|
+
projectUrl: projectUrl?.replace(/\/+$/, ''),
|
|
25
|
+
apiKey: apiKey || undefined,
|
|
26
|
+
loginFormUrl: loginFormUrl || undefined,
|
|
27
|
+
jwt: undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readToken, saveToken, clearToken } from './token-cache.js';
|
|
2
|
+
import { validateToken } from './token-validation.js';
|
|
3
|
+
import { authenticate } from './auth.js';
|
|
4
|
+
// Keyed by baseUrl: one JWT is valid for every project on the same Form.io
|
|
5
|
+
// deployment, so caching per-project would over-partition.
|
|
6
|
+
const jwtCache = new Map();
|
|
7
|
+
const pendingAuthByBaseUrl = new Map();
|
|
8
|
+
async function runAuthFlow(config) {
|
|
9
|
+
// API key mode: tool calls themselves will validate the key
|
|
10
|
+
if (config.apiKey) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
// JWT mode: check cache
|
|
14
|
+
const cachedToken = await readToken(config.baseUrl);
|
|
15
|
+
if (cachedToken) {
|
|
16
|
+
config.jwt = cachedToken;
|
|
17
|
+
const valid = await validateToken(config);
|
|
18
|
+
if (valid) {
|
|
19
|
+
jwtCache.set(config.baseUrl, cachedToken);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// Expired — clear and re-auth
|
|
23
|
+
await clearToken(config.baseUrl);
|
|
24
|
+
config.jwt = undefined;
|
|
25
|
+
}
|
|
26
|
+
// No valid token — login
|
|
27
|
+
const jwt = await authenticate(config);
|
|
28
|
+
config.jwt = jwt;
|
|
29
|
+
await saveToken(config.baseUrl, jwt);
|
|
30
|
+
jwtCache.set(config.baseUrl, jwt);
|
|
31
|
+
}
|
|
32
|
+
export async function ensureAuthenticated(config) {
|
|
33
|
+
// Short-circuit: already authenticated in this process
|
|
34
|
+
const cached = jwtCache.get(config.baseUrl);
|
|
35
|
+
if (cached) {
|
|
36
|
+
config.jwt = cached;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Single-flight per baseUrl: reuse an in-flight auth promise if one exists
|
|
40
|
+
const existing = pendingAuthByBaseUrl.get(config.baseUrl);
|
|
41
|
+
if (existing) {
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
const pending = runAuthFlow(config).finally(() => {
|
|
45
|
+
pendingAuthByBaseUrl.delete(config.baseUrl);
|
|
46
|
+
});
|
|
47
|
+
pendingAuthByBaseUrl.set(config.baseUrl, pending);
|
|
48
|
+
return pending;
|
|
49
|
+
}
|
|
50
|
+
export function resetAuthState() {
|
|
51
|
+
jwtCache.clear();
|
|
52
|
+
pendingAuthByBaseUrl.clear();
|
|
53
|
+
}
|
|
54
|
+
export function invalidateJwtCache(baseUrl) {
|
|
55
|
+
jwtCache.delete(baseUrl);
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ResolvedFormioConfig } from './config.js';
|
|
2
|
+
export declare const MONGO_ID_PATTERN: RegExp;
|
|
3
|
+
export declare function isMongoId(value: string): boolean;
|
|
4
|
+
export interface FormioFetchOptions {
|
|
5
|
+
method?: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
responseType?: 'text' | 'json';
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
export declare function formioRawFetch(url: URL, config: ResolvedFormioConfig, options?: FormioFetchOptions): Promise<unknown>;
|
|
11
|
+
export declare function formioFetch(path: string, params: Record<string, string | undefined>, config: ResolvedFormioConfig, options?: FormioFetchOptions): Promise<unknown>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getAuthHeader } from './auth-header.js';
|
|
2
|
+
import { ensureAuthenticated, invalidateJwtCache } from './ensure-auth.js';
|
|
3
|
+
import { clearToken } from './token-cache.js';
|
|
4
|
+
if (process.env.FORMIO_INSECURE_TLS === 'true') {
|
|
5
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
6
|
+
}
|
|
7
|
+
export const MONGO_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
|
|
8
|
+
export function isMongoId(value) {
|
|
9
|
+
return MONGO_ID_PATTERN.test(value);
|
|
10
|
+
}
|
|
11
|
+
function formatApiError(status, url) {
|
|
12
|
+
return `Form.io API error: ${status} | URL: ${url.toString()}`;
|
|
13
|
+
}
|
|
14
|
+
function buildFetchInit(config, options) {
|
|
15
|
+
const hasBody = options?.body !== undefined;
|
|
16
|
+
const headers = {
|
|
17
|
+
...getAuthHeader(config),
|
|
18
|
+
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
|
19
|
+
};
|
|
20
|
+
const init = { headers };
|
|
21
|
+
if (options?.method) {
|
|
22
|
+
init.method = options.method;
|
|
23
|
+
}
|
|
24
|
+
if (hasBody) {
|
|
25
|
+
init.body = JSON.stringify(options.body);
|
|
26
|
+
}
|
|
27
|
+
if (options?.signal) {
|
|
28
|
+
init.signal = options.signal;
|
|
29
|
+
}
|
|
30
|
+
return init;
|
|
31
|
+
}
|
|
32
|
+
export async function formioRawFetch(url, config, options) {
|
|
33
|
+
const response = await fetch(url, buildFetchInit(config, options));
|
|
34
|
+
const parseResponse = (res) => options?.responseType === 'text' ? res.text() : res.json();
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
if (response.status === 401 && config.jwt) {
|
|
37
|
+
invalidateJwtCache(config.baseUrl);
|
|
38
|
+
await clearToken(config.baseUrl);
|
|
39
|
+
config.jwt = undefined;
|
|
40
|
+
await ensureAuthenticated(config);
|
|
41
|
+
const retryResponse = await fetch(url, buildFetchInit(config, options));
|
|
42
|
+
if (!retryResponse.ok) {
|
|
43
|
+
throw new Error(formatApiError(retryResponse.status, url));
|
|
44
|
+
}
|
|
45
|
+
return parseResponse(retryResponse);
|
|
46
|
+
}
|
|
47
|
+
throw new Error(formatApiError(response.status, url));
|
|
48
|
+
}
|
|
49
|
+
return parseResponse(response);
|
|
50
|
+
}
|
|
51
|
+
export async function formioFetch(path, params, config, options) {
|
|
52
|
+
await ensureAuthenticated(config);
|
|
53
|
+
const base = config.projectUrl.replace(/\/*$/, '/');
|
|
54
|
+
const url = new URL(path.replace(/^\//, ''), base);
|
|
55
|
+
const entries = Object.entries(params).filter((entry) => entry[1] !== undefined);
|
|
56
|
+
for (const [key, value] of entries) {
|
|
57
|
+
url.searchParams.set(key, value);
|
|
58
|
+
}
|
|
59
|
+
return formioRawFetch(url, config, options);
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function toMcpTextResult(data) {
|
|
2
|
+
return {
|
|
3
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
4
|
+
};
|
|
5
|
+
}
|
|
6
|
+
export function toMcpError(error) {
|
|
7
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: 'text', text: message }],
|
|
10
|
+
isError: true,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export interface ProjectEntry {
|
|
2
|
+
env: Record<string, string>;
|
|
3
|
+
}
|
|
4
|
+
export declare function readProjectEntry(cwd: string, cacheDir?: string): ProjectEntry | null;
|
|
5
|
+
export declare function writeProjectEntry(cwd: string, env: Record<string, string>, cacheDir?: string): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.formio');
|
|
5
|
+
const PROJECTS_FILE = 'projects.json';
|
|
6
|
+
function readMap(cacheDir) {
|
|
7
|
+
const filePath = path.join(cacheDir, PROJECTS_FILE);
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function writeMap(cacheDir, data) {
|
|
16
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(cacheDir, PROJECTS_FILE), JSON.stringify(data), {
|
|
18
|
+
mode: 0o600,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function readProjectEntry(cwd, cacheDir = DEFAULT_CACHE_DIR) {
|
|
22
|
+
const map = readMap(cacheDir);
|
|
23
|
+
return map[cwd] ?? null;
|
|
24
|
+
}
|
|
25
|
+
export function writeProjectEntry(cwd, env, cacheDir = DEFAULT_CACHE_DIR) {
|
|
26
|
+
const map = readMap(cacheDir);
|
|
27
|
+
map[cwd] = { env };
|
|
28
|
+
writeMap(cacheDir, map);
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { readProjectEntry } from './project-map.js';
|
|
4
|
+
export const cwdSchema = z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1, 'cwd is required')
|
|
7
|
+
.refine((value) => path.isAbsolute(value), {
|
|
8
|
+
message: 'cwd must be an absolute path',
|
|
9
|
+
})
|
|
10
|
+
.describe("User's current working directory as an absolute path. Required — the tool looks up the mapped Form.io project from ~/.formio/projects.json[cwd]. Call project_set first if the cwd is not yet mapped.");
|
|
11
|
+
export function resolveProjectConfig(cwd, baseConfig) {
|
|
12
|
+
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
13
|
+
throw new Error('cwd is required and must be a non-empty string.');
|
|
14
|
+
}
|
|
15
|
+
if (!path.isAbsolute(cwd)) {
|
|
16
|
+
throw new Error(`cwd must be an absolute path (received: ${cwd}).`);
|
|
17
|
+
}
|
|
18
|
+
// Plugin context: the hook drives per-cwd project_set, so the map is
|
|
19
|
+
// authoritative. Standalone context: the map is at best stale leftover from
|
|
20
|
+
// prior plugin use in this cwd — ignore it so the user's .mcp.json env wins.
|
|
21
|
+
const pluginContext = process.env.FORMIO_PLUGIN_CONTEXT === '1';
|
|
22
|
+
const mapped = pluginContext ? readProjectEntry(cwd)?.env.FORMIO_PROJECT_URL : undefined;
|
|
23
|
+
const projectUrl = mapped ?? baseConfig.projectUrl;
|
|
24
|
+
if (!projectUrl) {
|
|
25
|
+
throw new Error(`No Form.io project is mapped for cwd=${cwd}. Call project_set with projectUrl and cwd=${cwd}, or set the FORMIO_PROJECT_URL environment variable, before invoking Form.io tools.`);
|
|
26
|
+
}
|
|
27
|
+
if (!baseConfig.baseUrl) {
|
|
28
|
+
throw new Error('baseUrl is missing on config. getConfig() should always populate it.');
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
...baseConfig,
|
|
32
|
+
baseUrl: baseConfig.baseUrl,
|
|
33
|
+
projectUrl: projectUrl.replace(/\/+$/, ''),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { getConfig } from './config.js';
|
|
3
|
+
import { registerAllTools } from './tools/index.js';
|
|
4
|
+
export function createServer(config) {
|
|
5
|
+
const resolvedConfig = config ?? getConfig();
|
|
6
|
+
const server = new McpServer({ name: 'formio-mcp', version: '0.1.0' });
|
|
7
|
+
registerAllTools(server, resolvedConfig);
|
|
8
|
+
return server;
|
|
9
|
+
}
|
package/dist/stdio.d.ts
ADDED
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { getConfig } from './config.js';
|
|
4
|
+
import { createServer } from './server.js';
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
const server = createServer(config);
|
|
7
|
+
const transport = new StdioServerTransport();
|
|
8
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function saveToken(baseUrl: string, jwt: string, cacheDir?: string): Promise<void>;
|
|
2
|
+
export declare function readToken(baseUrl: string, cacheDir?: string): Promise<string | null>;
|
|
3
|
+
export declare function clearToken(baseUrl: string, cacheDir?: string): Promise<void>;
|