@usefillo/mcp 0.2.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 +64 -0
- package/dist/index.js +574 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fillo
|
|
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,64 @@
|
|
|
1
|
+
# @usefillo/mcp
|
|
2
|
+
|
|
3
|
+
The [Fillo](https://fillo.so) MCP server. It gives a coding agent the full Fillo
|
|
4
|
+
loop — provision a workspace, scaffold a form into the host repo, publish it, and
|
|
5
|
+
query its responses — without leaving the session, authenticated exactly like a
|
|
6
|
+
human CLI user.
|
|
7
|
+
|
|
8
|
+
### 📚 Full documentation → **[fillo.so/docs](https://fillo.so/docs)**
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Claude Code:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
claude mcp add fillo -- npx -y @usefillo/mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Cursor / VS Code / any MCP client: run `npx -y @usefillo/mcp` over stdio. Set
|
|
19
|
+
`FILLO_API` to point at a non-production deployment.
|
|
20
|
+
|
|
21
|
+
## Credentials
|
|
22
|
+
|
|
23
|
+
The server reads the same credentials the CLI writes to `~/.fillo/config.json`,
|
|
24
|
+
or from the environment:
|
|
25
|
+
|
|
26
|
+
- `FILLO_TOKEN` — a `fcli_…` login token (from `npx @usefillo/cli login`).
|
|
27
|
+
Authenticated tools (`fillo_list_forms`, publishing to a claimed workspace).
|
|
28
|
+
- `FILLO_PK` — a `pk_…` publishable key. `fillo_provision_workspace` mints one
|
|
29
|
+
and saves it for you.
|
|
30
|
+
- `FILLO_API_KEY` — a `fsk_…` workspace API key, minted in **Settings →
|
|
31
|
+
Connections** of a claimed workspace. Required by the response tools.
|
|
32
|
+
- `FILLO_API` — overrides the origin (default `https://fillo.so`).
|
|
33
|
+
- `FILLO_CONFIG_DIR` — overrides the config directory (default `~/.fillo`).
|
|
34
|
+
|
|
35
|
+
The server never prints login tokens, API keys, or claim tokens into the
|
|
36
|
+
transcript. The `pk_` publishable key is safe to surface (it lives in browser
|
|
37
|
+
code), so `fillo_provision_workspace` returns it for you to wire into the app's
|
|
38
|
+
public env.
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | Auth | What it does |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `fillo_provision_workspace` | none (needs an email) | Create an unclaimed preview workspace, return its `pk_` key and caps, and email its claim link. |
|
|
45
|
+
| `fillo_whoami` | login token or `pk_` | Report the active credential and workspace. |
|
|
46
|
+
| `fillo_push_form` | login token or `pk_` | Create or update a form from a schema + handle. |
|
|
47
|
+
| `fillo_list_forms` | login token | List the workspace's forms. |
|
|
48
|
+
| `fillo_get_form` | none (published) | Fetch a published form's schema, theme, and capabilities. |
|
|
49
|
+
| `fillo_search_examples` | none | Search the curated Fillo example library. |
|
|
50
|
+
| `fillo_docs` | none | Fetch a Fillo docs page as Markdown by topic. |
|
|
51
|
+
| `fillo_list_responses` | `fsk_` API key | List a form's responses (claimed workspaces only). |
|
|
52
|
+
| `fillo_get_response` | `fsk_` API key | Fetch one response (claimed workspaces only). |
|
|
53
|
+
| `fillo_claim_status` | `pk_` | Report the provisioned workspace's caps and claim deadline. |
|
|
54
|
+
|
|
55
|
+
No destructive tools. Every tool is a thin wrapper over Fillo's public HTTP API —
|
|
56
|
+
the server never touches the database and imports no app code, so workspace
|
|
57
|
+
scoping, rate limits, and validation stay in one place.
|
|
58
|
+
|
|
59
|
+
## Links
|
|
60
|
+
|
|
61
|
+
- **Docs:** [fillo.so/docs](https://fillo.so/docs)
|
|
62
|
+
- **Website:** [fillo.so](https://fillo.so)
|
|
63
|
+
|
|
64
|
+
MIT licensed.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
var DEFAULT_API = "https://fillo.so";
|
|
12
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
13
|
+
var CLIENT_VERSION = `@usefillo/mcp@${packageVersion()}`;
|
|
14
|
+
function packageVersion() {
|
|
15
|
+
try {
|
|
16
|
+
const pkg = JSON.parse(
|
|
17
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
18
|
+
);
|
|
19
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
20
|
+
} catch {
|
|
21
|
+
return "0.0.0";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function apiOrigin() {
|
|
25
|
+
return (process.env.FILLO_API ?? DEFAULT_API).replace(/\/$/, "");
|
|
26
|
+
}
|
|
27
|
+
function configDir() {
|
|
28
|
+
return process.env.FILLO_CONFIG_DIR?.trim() || join(homedir(), ".fillo");
|
|
29
|
+
}
|
|
30
|
+
function configPath() {
|
|
31
|
+
return join(configDir(), "config.json");
|
|
32
|
+
}
|
|
33
|
+
function readConfig() {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
36
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
37
|
+
const record = parsed;
|
|
38
|
+
const provision = record.provision && typeof record.provision === "object" && !Array.isArray(record.provision) ? record.provision : void 0;
|
|
39
|
+
return {
|
|
40
|
+
...typeof record.token === "string" ? { token: record.token } : {},
|
|
41
|
+
...typeof record.tokenApi === "string" ? { tokenApi: record.tokenApi } : {},
|
|
42
|
+
...typeof record.pk === "string" ? { pk: record.pk } : {},
|
|
43
|
+
...typeof record.apiKey === "string" ? { apiKey: record.apiKey } : {},
|
|
44
|
+
...provision ? { provision } : {}
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeConfig(next) {
|
|
51
|
+
const dir = configDir();
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
53
|
+
try {
|
|
54
|
+
chmodSync(dir, 448);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 384 });
|
|
58
|
+
try {
|
|
59
|
+
chmodSync(configPath(), 384);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function resolveToken() {
|
|
64
|
+
const fromEnv = process.env.FILLO_TOKEN?.trim();
|
|
65
|
+
if (fromEnv) return fromEnv;
|
|
66
|
+
const cfg = readConfig();
|
|
67
|
+
if (!cfg.token) return void 0;
|
|
68
|
+
const boundTo = cfg.tokenApi?.replace(/\/$/, "");
|
|
69
|
+
if (!boundTo) return apiOrigin() === DEFAULT_API ? cfg.token : void 0;
|
|
70
|
+
return boundTo === apiOrigin() ? cfg.token : void 0;
|
|
71
|
+
}
|
|
72
|
+
function resolvePk() {
|
|
73
|
+
return process.env.FILLO_PK?.trim() || readConfig().pk;
|
|
74
|
+
}
|
|
75
|
+
function resolveApiKey() {
|
|
76
|
+
return process.env.FILLO_API_KEY?.trim() || readConfig().apiKey;
|
|
77
|
+
}
|
|
78
|
+
function resolveProvision() {
|
|
79
|
+
const provision = readConfig().provision;
|
|
80
|
+
if (!provision) return void 0;
|
|
81
|
+
if (provision.api && provision.api.replace(/\/$/, "") !== apiOrigin()) return void 0;
|
|
82
|
+
return provision;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/result.ts
|
|
86
|
+
function ok(summary, data) {
|
|
87
|
+
return build(summary, data, false);
|
|
88
|
+
}
|
|
89
|
+
function fail(summary, data) {
|
|
90
|
+
return build(summary, data, true);
|
|
91
|
+
}
|
|
92
|
+
function build(summary, data, isError) {
|
|
93
|
+
const content = [{ type: "text", text: summary }];
|
|
94
|
+
if (data !== void 0) content.push({ type: "text", text: JSON.stringify(data) });
|
|
95
|
+
return isError ? { content, isError: true } : { content };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/tools/claim-status.ts
|
|
99
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
100
|
+
function registerClaimStatus(server) {
|
|
101
|
+
server.registerTool(
|
|
102
|
+
"fillo_claim_status",
|
|
103
|
+
{
|
|
104
|
+
title: "Check a preview workspace's claim deadline",
|
|
105
|
+
description: "Report an unclaimed preview workspace's response cap and claim deadline so you can tell the user the real date to claim it by. Uses the caps returned when fillo_provision_workspace ran on this machine. If none is recorded, it says so. The claim link is emailed (never printed); the developer claims by opening that email and signing in.",
|
|
106
|
+
inputSchema: {}
|
|
107
|
+
},
|
|
108
|
+
async () => {
|
|
109
|
+
const provision = resolveProvision();
|
|
110
|
+
if (!provision) {
|
|
111
|
+
if (resolvePk()) {
|
|
112
|
+
return ok(
|
|
113
|
+
"A publishable key is configured, but no provisioning record is stored on this machine, so the claim deadline is not known here. If you provisioned elsewhere, the claim link was emailed then; open it and sign in to claim.",
|
|
114
|
+
{ mode: "publishable-key", api: apiOrigin() }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return fail(
|
|
118
|
+
"No provisioned workspace is recorded on this machine. Run fillo_provision_workspace first, or open the claim link Fillo emailed when it was provisioned."
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const expiresMs = provision.expiresAt ? Date.parse(provision.expiresAt) : Number.NaN;
|
|
122
|
+
const daysLeft = Number.isFinite(expiresMs) ? Math.max(0, Math.round((expiresMs - Date.now()) / DAY_MS)) : void 0;
|
|
123
|
+
const expired = Number.isFinite(expiresMs) && expiresMs <= Date.now();
|
|
124
|
+
return ok(
|
|
125
|
+
expired ? `This preview workspace's ${provision.expiresAt} hold has passed. The claim link Fillo emailed to ${provision.email ?? "the provisioning address"} may no longer work \u2014 provision a fresh workspace if needed.` : `Unclaimed preview workspace: up to ${provision.responseCap ?? "a capped number of"} responses, hold ${provision.expiresAt ? `ends ${provision.expiresAt}` : "active"}${daysLeft !== void 0 ? ` (~${daysLeft} day${daysLeft === 1 ? "" : "s"} left)` : ""}. Claim it by opening the link Fillo emailed to ${provision.email ?? "the provisioning address"} and signing in.`,
|
|
126
|
+
{
|
|
127
|
+
mode: "provisional",
|
|
128
|
+
api: apiOrigin(),
|
|
129
|
+
organizationId: provision.organizationId,
|
|
130
|
+
claimLinkEmailedTo: provision.email,
|
|
131
|
+
responseCap: provision.responseCap,
|
|
132
|
+
expiresAt: provision.expiresAt,
|
|
133
|
+
daysLeft,
|
|
134
|
+
expired
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/tools/docs.ts
|
|
142
|
+
import { z } from "zod";
|
|
143
|
+
|
|
144
|
+
// src/http.ts
|
|
145
|
+
async function filloFetch(path, init = {}) {
|
|
146
|
+
const query = init.searchParams?.toString();
|
|
147
|
+
const url = `${apiOrigin()}${path}${query ? `?${query}` : ""}`;
|
|
148
|
+
const headers = { "X-Fillo-Client": CLIENT_VERSION };
|
|
149
|
+
if (init.body !== void 0) headers["Content-Type"] = "application/json";
|
|
150
|
+
if (init.token) headers.Authorization = `Bearer ${init.token}`;
|
|
151
|
+
const res = await fetch(url, {
|
|
152
|
+
method: init.method ?? "GET",
|
|
153
|
+
headers,
|
|
154
|
+
...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
|
|
155
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
156
|
+
});
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
let json;
|
|
159
|
+
try {
|
|
160
|
+
json = text ? JSON.parse(text) : void 0;
|
|
161
|
+
} catch {
|
|
162
|
+
json = void 0;
|
|
163
|
+
}
|
|
164
|
+
return { status: res.status, ok: res.ok, text, json };
|
|
165
|
+
}
|
|
166
|
+
function apiErrorMessage(res, fallback) {
|
|
167
|
+
const message = res.json && typeof res.json === "object" ? res.json.error : void 0;
|
|
168
|
+
return typeof message === "string" && message ? message : `${fallback} (HTTP ${res.status}).`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/tools/docs.ts
|
|
172
|
+
var TOPICS = [
|
|
173
|
+
"embed",
|
|
174
|
+
"authoring",
|
|
175
|
+
"reference",
|
|
176
|
+
"styling",
|
|
177
|
+
"troubleshooting",
|
|
178
|
+
"prefill",
|
|
179
|
+
"webhooks",
|
|
180
|
+
"custom-ui",
|
|
181
|
+
"api"
|
|
182
|
+
];
|
|
183
|
+
function registerDocs(server) {
|
|
184
|
+
server.registerTool(
|
|
185
|
+
"fillo_docs",
|
|
186
|
+
{
|
|
187
|
+
title: "Read a Fillo documentation page",
|
|
188
|
+
description: "Fetch a Fillo documentation page as Markdown by topic, straight from the live site so it is never stale. No credential needed. Topics: embed (install + render), authoring (defineForm / JSX), reference (schema/field reference), styling, troubleshooting, prefill, webhooks, custom-ui, api (the read/management API). Read the relevant page before implementing.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
topic: z.enum(TOPICS).describe("Which docs page to fetch.")
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
async ({ topic }) => {
|
|
194
|
+
const res = await filloFetch(`/docs/${topic}.md`);
|
|
195
|
+
if (!res.ok || !res.text) {
|
|
196
|
+
return fail(`Couldn't fetch the "${topic}" docs page (HTTP ${res.status}).`);
|
|
197
|
+
}
|
|
198
|
+
return ok(res.text);
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/tools/get-form.ts
|
|
204
|
+
import { z as z2 } from "zod";
|
|
205
|
+
function registerGetForm(server) {
|
|
206
|
+
server.registerTool(
|
|
207
|
+
"fillo_get_form",
|
|
208
|
+
{
|
|
209
|
+
title: "Get a published form's schema",
|
|
210
|
+
description: "Fetch a published form's schema, theme, capabilities, and closed flag by form id or slug. No credential needed \u2014 only published forms are served (drafts return not-found). Use this to verify what went live after fillo_push_form, or to read an existing form before editing it.",
|
|
211
|
+
inputSchema: {
|
|
212
|
+
form: z2.string().describe("Form id or slug (the trailing id of a /f/<slug> URL also works).")
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
async ({ form }) => {
|
|
216
|
+
const res = await filloFetch(`/api/v1/forms/${encodeURIComponent(form)}`);
|
|
217
|
+
if (res.status === 404) {
|
|
218
|
+
return fail(
|
|
219
|
+
`No published form "${form}". It may be a draft (publish it first) or the id/slug may be wrong.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!res.ok || typeof res.json?.id !== "string") {
|
|
223
|
+
return fail(apiErrorMessage(res, "Couldn't fetch the form"));
|
|
224
|
+
}
|
|
225
|
+
const title = res.json.schema && typeof res.json.schema.title === "string" ? res.json.schema.title : void 0;
|
|
226
|
+
return ok(
|
|
227
|
+
`Form "${res.json.id}"${title ? ` \u2014 "${title}"` : ""}${res.json.closed ? " (closed to new responses)" : ""}.`,
|
|
228
|
+
res.json
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/tools/get-response.ts
|
|
235
|
+
import { z as z3 } from "zod";
|
|
236
|
+
var NEEDS_KEY = "Reading a response needs a workspace API key (`fsk_\u2026`). This works only on a CLAIMED workspace: claim it, then mint a key in Settings \u2192 Connections and set FILLO_API_KEY.";
|
|
237
|
+
function registerGetResponse(server) {
|
|
238
|
+
server.registerTool(
|
|
239
|
+
"fillo_get_response",
|
|
240
|
+
{
|
|
241
|
+
title: "Get one response",
|
|
242
|
+
description: "Fetch a single response by id: its answer data, meta, form version, and file references (id, name, size \u2014 file bytes stay in the customer's storage). Needs a workspace API key (`fsk_\u2026`) in FILLO_API_KEY on a CLAIMED workspace. A withheld/quarantined or cross-workspace id returns not-found. Get ids from fillo_list_responses.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
id: z3.string().describe("Response id (e.g. from fillo_list_responses).")
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
async ({ id }) => {
|
|
248
|
+
const apiKey = resolveApiKey();
|
|
249
|
+
if (!apiKey) return fail(NEEDS_KEY);
|
|
250
|
+
const res = await filloFetch(`/api/v1/manage/responses/${encodeURIComponent(id)}`, {
|
|
251
|
+
token: apiKey
|
|
252
|
+
});
|
|
253
|
+
if (res.status === 401) return fail(NEEDS_KEY);
|
|
254
|
+
if (res.status === 403) {
|
|
255
|
+
return fail("This API key is missing the responses:read scope. Mint a key with read access in Settings \u2192 Connections.");
|
|
256
|
+
}
|
|
257
|
+
if (res.status === 404) {
|
|
258
|
+
return fail(`No response "${id}" in this key's workspace (it may be withheld, deleted, or in another workspace).`);
|
|
259
|
+
}
|
|
260
|
+
if (!res.ok || typeof res.json?.id !== "string") {
|
|
261
|
+
return fail(apiErrorMessage(res, "Couldn't fetch the response"));
|
|
262
|
+
}
|
|
263
|
+
const fileCount = Array.isArray(res.json.files) ? res.json.files.length : 0;
|
|
264
|
+
return ok(
|
|
265
|
+
`Response "${res.json.id}" on form "${res.json.formId}"` + (fileCount ? ` with ${fileCount} file reference${fileCount === 1 ? "" : "s"}.` : "."),
|
|
266
|
+
res.json
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/tools/list-forms.ts
|
|
273
|
+
function registerListForms(server) {
|
|
274
|
+
server.registerTool(
|
|
275
|
+
"fillo_list_forms",
|
|
276
|
+
{
|
|
277
|
+
title: "List the workspace's forms",
|
|
278
|
+
description: "List every form in the signed-in workspace with its id, name, status (draft/published), and hosted URL. Needs a login token (FILLO_TOKEN or `npx @usefillo/cli login`); a `pk_` publishable key is not enough. If no token is set, run `fillo login` first.",
|
|
279
|
+
inputSchema: {}
|
|
280
|
+
},
|
|
281
|
+
async () => {
|
|
282
|
+
const token = resolveToken();
|
|
283
|
+
if (!token) {
|
|
284
|
+
return fail(
|
|
285
|
+
"Listing forms needs a login token. Set FILLO_TOKEN or run `npx @usefillo/cli login`, then retry."
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const res = await filloFetch("/api/v1/cli/forms", { token });
|
|
289
|
+
if (res.status === 401) {
|
|
290
|
+
return fail("Login token is invalid or expired. Run `npx @usefillo/cli login`, or set a fresh FILLO_TOKEN.");
|
|
291
|
+
}
|
|
292
|
+
if (!res.ok || !Array.isArray(res.json?.forms)) {
|
|
293
|
+
return fail(apiErrorMessage(res, "Couldn't list forms"));
|
|
294
|
+
}
|
|
295
|
+
const forms = res.json.forms;
|
|
296
|
+
return ok(
|
|
297
|
+
forms.length ? `${forms.length} form${forms.length === 1 ? "" : "s"}: ` + forms.map((f) => `${f.name ?? "Untitled"} (${f.status ?? "?"})`).join(", ") : "No forms in this workspace yet.",
|
|
298
|
+
{ forms: res.json.forms }
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// src/tools/list-responses.ts
|
|
305
|
+
import { z as z4 } from "zod";
|
|
306
|
+
var NEEDS_KEY2 = "Reading responses needs a workspace API key (`fsk_\u2026`). This works only on a CLAIMED workspace: claim it, then mint a key in Settings \u2192 Connections and set FILLO_API_KEY. A `pk_` key or login token cannot read responses.";
|
|
307
|
+
function registerListResponses(server) {
|
|
308
|
+
server.registerTool(
|
|
309
|
+
"fillo_list_responses",
|
|
310
|
+
{
|
|
311
|
+
title: "List a form's responses",
|
|
312
|
+
description: "List a form's accepted responses (keyset-paginated), newest first. Needs a workspace API key (`fsk_\u2026`) in FILLO_API_KEY \u2014 available only on a CLAIMED workspace (claim, then mint one in Settings \u2192 Connections). Filters use the responses-grid grammar: `range`, `q` (full-text), `source`, `respondent`, and repeated `where` clauses of the form `fieldId:op:value` (e.g. score:eq:10). Withheld/quarantined rows are never returned. Follow `nextCursor` to page.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
form: z4.string().describe("Form id or slug to read responses from."),
|
|
315
|
+
range: z4.string().optional().describe("Date range filter (grid grammar)."),
|
|
316
|
+
q: z4.string().optional().describe("Full-text search across answers."),
|
|
317
|
+
source: z4.string().optional().describe("Filter by response source."),
|
|
318
|
+
respondent: z4.string().optional().describe("Filter by respondent id."),
|
|
319
|
+
where: z4.array(z4.string()).optional().describe("Field filters, each `fieldId:op:value`, e.g. ['score:eq:10']."),
|
|
320
|
+
cursor: z4.string().optional().describe("Opaque cursor from a prior page's nextCursor."),
|
|
321
|
+
limit: z4.number().int().min(1).max(100).optional().describe("Page size (default server-set).")
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
async ({ form, range, q, source, respondent, where, cursor, limit }) => {
|
|
325
|
+
const apiKey = resolveApiKey();
|
|
326
|
+
if (!apiKey) return fail(NEEDS_KEY2);
|
|
327
|
+
const searchParams = new URLSearchParams();
|
|
328
|
+
if (range) searchParams.set("range", range);
|
|
329
|
+
if (q) searchParams.set("q", q);
|
|
330
|
+
if (source) searchParams.set("source", source);
|
|
331
|
+
if (respondent) searchParams.set("respondent", respondent);
|
|
332
|
+
for (const clause of where ?? []) searchParams.append("where", clause);
|
|
333
|
+
if (cursor) searchParams.set("cursor", cursor);
|
|
334
|
+
if (limit) searchParams.set("limit", String(limit));
|
|
335
|
+
const res = await filloFetch(
|
|
336
|
+
`/api/v1/manage/forms/${encodeURIComponent(form)}/responses`,
|
|
337
|
+
{ token: apiKey, searchParams }
|
|
338
|
+
);
|
|
339
|
+
if (res.status === 401) return fail(NEEDS_KEY2);
|
|
340
|
+
if (res.status === 403) {
|
|
341
|
+
return fail("This API key is missing the responses:read scope. Mint a key with read access in Settings \u2192 Connections.");
|
|
342
|
+
}
|
|
343
|
+
if (res.status === 404) {
|
|
344
|
+
return fail(`No form "${form}" in this key's workspace. Check the id, or the key may belong to another workspace.`);
|
|
345
|
+
}
|
|
346
|
+
if (!res.ok || !Array.isArray(res.json?.data)) {
|
|
347
|
+
return fail(apiErrorMessage(res, "Couldn't list responses"));
|
|
348
|
+
}
|
|
349
|
+
const rows = res.json.data;
|
|
350
|
+
return ok(
|
|
351
|
+
`${rows.length} response${rows.length === 1 ? "" : "s"} on this page` + (res.json.nextCursor ? " (more available \u2014 follow nextCursor)." : "."),
|
|
352
|
+
res.json
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/tools/provision.ts
|
|
359
|
+
import { z as z5 } from "zod";
|
|
360
|
+
function registerProvisionWorkspace(server) {
|
|
361
|
+
server.registerTool(
|
|
362
|
+
"fillo_provision_workspace",
|
|
363
|
+
{
|
|
364
|
+
title: "Provision a Fillo workspace",
|
|
365
|
+
description: "Create an unclaimed preview Fillo workspace so you can take a form live during integration before the developer signs up. No credential needed, but an email is REQUIRED \u2014 Fillo emails the private claim link to that inbox (it is never returned here). Returns a `pk_` publishable key (safe for browser/public env such as NEXT_PUBLIC_FILLO_KEY) plus the caps: up to N responses and a hold window. The key is saved locally so fillo_push_form and fillo_claim_status can use it. Next: push a form with fillo_push_form, then tell the user to open the emailed link and sign in to claim the workspace before the hold expires. Rate limited to 5/hour per network and 3/hour per email; a repeat email returns a collision error \u2014 reuse the emailed link.",
|
|
366
|
+
inputSchema: {
|
|
367
|
+
email: z5.string().email().describe("Where Fillo emails the private claim link. Ask the developer for theirs.")
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
async ({ email }) => {
|
|
371
|
+
const res = await filloFetch("/api/v1/workspaces/provision", {
|
|
372
|
+
method: "POST",
|
|
373
|
+
body: { email, source: "mcp" }
|
|
374
|
+
});
|
|
375
|
+
if (!res.ok || typeof res.json?.key !== "string") {
|
|
376
|
+
return fail(apiErrorMessage(res, "Couldn't provision a workspace"));
|
|
377
|
+
}
|
|
378
|
+
const key = res.json.key;
|
|
379
|
+
const organizationId = typeof res.json.organizationId === "string" ? res.json.organizationId : void 0;
|
|
380
|
+
const responseCap = typeof res.json.limits?.responses === "number" ? res.json.limits.responses : void 0;
|
|
381
|
+
const expiresAt = typeof res.json.limits?.expiresAt === "string" ? res.json.limits.expiresAt : void 0;
|
|
382
|
+
const emailedTo = typeof res.json.claim?.email === "string" ? res.json.claim.email : email;
|
|
383
|
+
writeConfig({
|
|
384
|
+
...readConfig(),
|
|
385
|
+
pk: key,
|
|
386
|
+
provision: { organizationId, email: emailedTo, responseCap, expiresAt, api: apiOrigin() }
|
|
387
|
+
});
|
|
388
|
+
return ok(
|
|
389
|
+
`Provisioned an unclaimed Fillo workspace. Publishable key returned below \u2014 put it in the app's public env (e.g. NEXT_PUBLIC_FILLO_KEY). The claim link was emailed to ${emailedTo}. Push a form with fillo_push_form, then have the developer open that email and sign in to claim the workspace` + (expiresAt ? ` before ${expiresAt}.` : "."),
|
|
390
|
+
{
|
|
391
|
+
publishableKey: key,
|
|
392
|
+
organizationId,
|
|
393
|
+
responseCap,
|
|
394
|
+
expiresAt,
|
|
395
|
+
claimLinkEmailedTo: emailedTo
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/tools/push-form.ts
|
|
403
|
+
import { z as z6 } from "zod";
|
|
404
|
+
function registerPushForm(server) {
|
|
405
|
+
server.registerTool(
|
|
406
|
+
"fillo_push_form",
|
|
407
|
+
{
|
|
408
|
+
title: "Create or update a Fillo form",
|
|
409
|
+
description: 'Create or update a form from a FormSchema plus a stable handle (an idempotent id \u2014 reuse it to update the same form). Needs a credential: a login token (FILLO_TOKEN / `fillo login`) publishes directly; a `pk_` publishable key (from fillo_provision_workspace) takes the form live on an unclaimed preview workspace, or stages a draft for review once the workspace is claimed. If neither is set, run fillo_provision_workspace or `fillo login` first. The server validates the schema and returns the form id, status, and hosted URL; embed it with <FilloForm formId="\u2026" />. Handle: letters, digits, dashes, max 64 chars.',
|
|
410
|
+
inputSchema: {
|
|
411
|
+
handle: z6.string().describe("Stable idempotent form id (letters, digits, dashes, max 64). Reuse to update."),
|
|
412
|
+
schema: z6.record(z6.string(), z6.unknown()).describe("The FormSchema object: title, pages (with blocks/fields), and settings."),
|
|
413
|
+
theme: z6.record(z6.string(), z6.unknown()).optional().describe("Optional theme tokens (colorScheme, primary, background, text, radius, fontFamily).")
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
async ({ handle, schema, theme }) => {
|
|
417
|
+
const token = resolveToken();
|
|
418
|
+
if (token) {
|
|
419
|
+
const res = await filloFetch("/api/v1/cli/forms", {
|
|
420
|
+
method: "POST",
|
|
421
|
+
token,
|
|
422
|
+
body: { handle, schema, theme: theme ?? null, publish: true }
|
|
423
|
+
});
|
|
424
|
+
if (!res.ok || typeof res.json?.formId !== "string") {
|
|
425
|
+
return fail(apiErrorMessage(res, "Couldn't push the form"));
|
|
426
|
+
}
|
|
427
|
+
const { formId, slug, url, updated } = res.json;
|
|
428
|
+
return ok(
|
|
429
|
+
`${updated ? "Updated" : "Published"} form "${formId}"${url ? `, live at ${url}` : ""}. Embed it with <FilloForm formId="${formId}" />.`,
|
|
430
|
+
{ mode: "token", formId, slug, url, status: "published", updated: !!updated }
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
const pk = resolvePk();
|
|
434
|
+
if (pk) {
|
|
435
|
+
const res = await filloFetch("/api/v1/forms/sync", {
|
|
436
|
+
method: "POST",
|
|
437
|
+
body: { key: pk, id: handle, schema, theme: theme ?? null }
|
|
438
|
+
});
|
|
439
|
+
if (!res.ok || typeof res.json?.formId !== "string" || res.json?.syncError) {
|
|
440
|
+
const syncError = res.json?.syncError;
|
|
441
|
+
const message = syncError && typeof syncError.message === "string" && syncError.message || apiErrorMessage(res, "Couldn't sync the form");
|
|
442
|
+
return fail(message, syncError?.code ? { code: syncError.code } : void 0);
|
|
443
|
+
}
|
|
444
|
+
const { formId, slug, status, staged, warning } = res.json;
|
|
445
|
+
const live = status === "published" && slug ? `${apiOrigin()}/f/${slug}` : void 0;
|
|
446
|
+
const label = status === "published" ? `Live at ${live}` : staged ? "Staged as a draft for dashboard review" : "Saved as a draft";
|
|
447
|
+
return ok(
|
|
448
|
+
`Synced form "${formId}" (${status ?? "draft"}). ${label}. Embed it with <FilloForm formId="${formId}" />.` + (warning ? ` Note: ${warning}` : ""),
|
|
449
|
+
{ mode: "publishable-key", formId, slug, status, staged: !!staged, url: live, warning }
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return fail(
|
|
453
|
+
"No credential to push with. Run fillo_provision_workspace for a preview workspace, or set FILLO_TOKEN (or `npx @usefillo/cli login`) to publish to an existing account."
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/tools/search-examples.ts
|
|
460
|
+
import { z as z7 } from "zod";
|
|
461
|
+
function registerSearchExamples(server) {
|
|
462
|
+
server.registerTool(
|
|
463
|
+
"fillo_search_examples",
|
|
464
|
+
{
|
|
465
|
+
title: "Search Fillo form examples",
|
|
466
|
+
description: "Search Fillo's curated example library (templates, implementations, and style recipes) for a use case before authoring a form from scratch. No credential needed. Returns full schema and code so you can adapt the closest match to the host app's routes, layout, and visual style rather than guessing. Always prefer adapting an example over inventing a schema.",
|
|
467
|
+
inputSchema: {
|
|
468
|
+
q: z7.string().describe("What you need, e.g. 'contact form with file upload' or 'NPS survey'."),
|
|
469
|
+
kind: z7.enum(["template", "implementation", "style"]).optional().describe("Restrict to one kind of example."),
|
|
470
|
+
framework: z7.string().optional().describe("Restrict to a framework, e.g. 'react' or 'dom'."),
|
|
471
|
+
capability: z7.string().optional().describe("Restrict to a capability, e.g. 'uploads' or 'conditional'."),
|
|
472
|
+
limit: z7.number().int().min(1).max(12).optional().describe("Max results (1\u201312, default 5).")
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
async ({ q, kind, framework, capability, limit }) => {
|
|
476
|
+
const searchParams = new URLSearchParams({ q: q ?? "", detail: "full" });
|
|
477
|
+
if (kind) searchParams.set("kind", kind);
|
|
478
|
+
if (framework) searchParams.set("framework", framework);
|
|
479
|
+
if (capability) searchParams.set("capability", capability);
|
|
480
|
+
if (limit) searchParams.set("limit", String(limit));
|
|
481
|
+
const res = await filloFetch("/api/v1/agent-examples/search", { searchParams });
|
|
482
|
+
if (!res.ok || !res.json) {
|
|
483
|
+
return fail(apiErrorMessage(res, "Couldn't search examples"));
|
|
484
|
+
}
|
|
485
|
+
const results = Array.isArray(res.json.results) ? res.json.results : res.json;
|
|
486
|
+
const count = Array.isArray(results) ? results.length : void 0;
|
|
487
|
+
return ok(
|
|
488
|
+
count === void 0 ? "Example search results below." : `${count} example${count === 1 ? "" : "s"} for "${q ?? ""}".`,
|
|
489
|
+
res.json
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/tools/whoami.ts
|
|
496
|
+
function registerWhoami(server) {
|
|
497
|
+
server.registerTool(
|
|
498
|
+
"fillo_whoami",
|
|
499
|
+
{
|
|
500
|
+
title: "Show the active Fillo credential",
|
|
501
|
+
description: "Report which Fillo credential is active and what it can reach. With a login token (FILLO_TOKEN or `fillo login`) it confirms the signed-in workspace. With only a `pk_` publishable key (from fillo_provision_workspace) it reports the unclaimed preview workspace's caps and claim state. If nothing is set up, it says exactly what to do: run fillo_provision_workspace, or set FILLO_TOKEN / run `fillo login`. Never prints token material.",
|
|
502
|
+
inputSchema: {}
|
|
503
|
+
},
|
|
504
|
+
async () => {
|
|
505
|
+
const token = resolveToken();
|
|
506
|
+
if (token) {
|
|
507
|
+
const res = await filloFetch("/api/v1/cli/whoami", { token });
|
|
508
|
+
if (res.status === 401) {
|
|
509
|
+
return fail("Login token is invalid or expired. Run `npx @usefillo/cli login`, or set a fresh FILLO_TOKEN.");
|
|
510
|
+
}
|
|
511
|
+
if (!res.ok) return fail(apiErrorMessage(res, "whoami failed"));
|
|
512
|
+
const workspace = typeof res.json?.workspace === "string" ? res.json.workspace : void 0;
|
|
513
|
+
return ok(
|
|
514
|
+
workspace ? `Signed in with a login token. Workspace: ${workspace}.` : "Signed in with a login token.",
|
|
515
|
+
{ mode: "token", workspace, api: apiOrigin() }
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
const pk = resolvePk();
|
|
519
|
+
if (pk) {
|
|
520
|
+
const provision = resolveProvision();
|
|
521
|
+
if (provision) {
|
|
522
|
+
return ok(
|
|
523
|
+
`A publishable key for an unclaimed preview workspace is configured. The claim link was emailed to ${provision.email ?? "the provisioning address"}` + (provision.expiresAt ? `; claim it before ${provision.expiresAt}.` : "."),
|
|
524
|
+
{
|
|
525
|
+
mode: "provisional",
|
|
526
|
+
api: apiOrigin(),
|
|
527
|
+
organizationId: provision.organizationId,
|
|
528
|
+
claimLinkEmailedTo: provision.email,
|
|
529
|
+
responseCap: provision.responseCap,
|
|
530
|
+
expiresAt: provision.expiresAt
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return ok(
|
|
535
|
+
"A publishable key is configured, but no provisioning record was found on this machine. A `pk_` key resolves and syncs forms; claim state is only visible from the browser that provisioned it, or with a login token after claiming. Sign in and set FILLO_TOKEN to see the live workspace.",
|
|
536
|
+
{ mode: "publishable-key", api: apiOrigin() }
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return fail(
|
|
540
|
+
"No Fillo credential is set up. Run fillo_provision_workspace to start a preview workspace, or set FILLO_TOKEN (or run `npx @usefillo/cli login`) to use an existing account."
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/tools/index.ts
|
|
547
|
+
function registerTools(server) {
|
|
548
|
+
registerProvisionWorkspace(server);
|
|
549
|
+
registerWhoami(server);
|
|
550
|
+
registerPushForm(server);
|
|
551
|
+
registerListForms(server);
|
|
552
|
+
registerGetForm(server);
|
|
553
|
+
registerSearchExamples(server);
|
|
554
|
+
registerDocs(server);
|
|
555
|
+
registerListResponses(server);
|
|
556
|
+
registerGetResponse(server);
|
|
557
|
+
registerClaimStatus(server);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/index.ts
|
|
561
|
+
async function main() {
|
|
562
|
+
const version = CLIENT_VERSION.split("@").pop() ?? "0.0.0";
|
|
563
|
+
const server = new McpServer({ name: "fillo", version });
|
|
564
|
+
registerTools(server);
|
|
565
|
+
const transport = new StdioServerTransport();
|
|
566
|
+
await server.connect(transport);
|
|
567
|
+
process.stderr.write(`Fillo MCP server ready (${CLIENT_VERSION}) \u2192 ${apiOrigin()}
|
|
568
|
+
`);
|
|
569
|
+
}
|
|
570
|
+
main().catch((error) => {
|
|
571
|
+
process.stderr.write(`Fillo MCP server failed to start: ${error instanceof Error ? error.message : String(error)}
|
|
572
|
+
`);
|
|
573
|
+
process.exit(1);
|
|
574
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usefillo/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Fillo MCP server — provision, scaffold, publish, and query forms from your coding agent.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"forms",
|
|
10
|
+
"fillo",
|
|
11
|
+
"agent"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://fillo.so",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"fillo-mcp": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
+
"zod": "^4.4.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.10.0",
|
|
33
|
+
"tsup": "^8.4.0",
|
|
34
|
+
"typescript": "^5.8.3"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"test": "node --test test/*.test.mjs",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|