@formancy/mcp 0.3.0 → 0.4.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/dist/index.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import "@formancy/core";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  //#region src/tools.d.ts
3
4
  /**
@@ -71,7 +72,17 @@ export declare function diffForms(before: unknown, after: unknown): ToolResult;
71
72
  * a model that gets `expressionProblems` back is told which rule, in which
72
73
  * words, and what to write instead. And an invalid document never travels.
73
74
  */
74
- export declare function publishForm(access: ServerAccess, path: string, document: unknown): Promise<ToolResult>;
75
+ export declare function publishForm(access: ServerAccess, path: string, document: unknown,
76
+ /**
77
+ * The schema hash the edit was based on, from `propose_form_edit` or
78
+ * `get_form`. Given, the published form is checked against it first.
79
+ *
80
+ * Opt-in, and the reason is the first publish: a new form has nothing to be
81
+ * based on, and requiring this would turn it into a fetch of something that
82
+ * is not there. So the unsafe path still exists, which is why the tool's
83
+ * description points at the safe one rather than leaving an agent to guess.
84
+ */
85
+ basedOn?: string): Promise<ToolResult>;
75
86
  export declare function listForms(access: ServerAccess): Promise<ToolResult>;
76
87
  export declare function getForm(access: ServerAccess, path: string): Promise<ToolResult>;
77
88
  export declare function listSubmissions(access: ServerAccess, path: string): Promise<ToolResult>;
@@ -108,6 +119,8 @@ export declare const TOOL_DEFINITIONS: {
108
119
  readonly describe_spec: string;
109
120
  readonly validate_form: string;
110
121
  readonly diff_forms: string;
122
+ readonly check_scenarios: string;
123
+ readonly propose_form_edit: string;
111
124
  readonly publish_form: string;
112
125
  readonly list_forms: "List the forms on the formancy server, with their current version.";
113
126
  readonly get_form: "Fetch one form document from the formancy server, with its version and schema hash.";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/tools.ts","../src/server.ts"],"mappings":";;;;;;;;;;;;;;;;UAoBiB;;WAEN;;WAEA;;WAEA;;;UAIM;WACN;WACA;;WAEA,eAAe,WAAW;;;;;;;;;;wBAarB,gBAAgB;;;;;;;;;;;;;;;;;;wBA6BhB,aAAa,oBAAoB;;;;;;;wBA+CjC,UAAU,iBAAiB,iBAAiB;;;;;;;;;wBAiDtC,YACpB,QAAQ,cACR,cACA,oBACC,QAAQ;wBAgBW,UAAU,QAAQ,eAAe,QAAQ;wBAIzC,QAAQ,QAAQ,cAAc,eAAe,QAAQ;wBAIrD,gBAAgB,QAAQ,cAAc,eAAe,QAAQ;;;;;;;;;;;UClLlE;;;;;;;;;WASN,SAAS;;;;;;;;;;qBAWP;;;;;;;;;wBA4DG,wBAAwB,UAAS,mBAAwB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/tools.ts","../src/server.ts"],"mappings":";;;;;;;;;;;;;;;;;UAqBiB;;WAEN;;WAEA;;WAEA;;;UAIM;WACN;WACA;;WAEA,eAAe,WAAW;;;;;;;;;;wBAarB,gBAAgB;;;;;;;;;;;;;;;;;;wBA6BhB,aAAa,oBAAoB;;;;;;;wBA+CjC,UAAU,iBAAiB,iBAAiB;;;;;;;;;wBAiDtC,YACpB,QAAQ,cACR,cACA,mBAUA;;;;;;;;;;AAAA,mBACC,QAAQ;wBAyKW,UAAU,QAAQ,eAAe,QAAQ;wBAIzC,QAAQ,QAAQ,cAAc,eAAe,QAAQ;wBAIrD,gBAAgB,QAAQ,cAAc,eAAe,QAAQ;;;;;;;;;;;UClVlE;;;;;;;;;WASN,SAAS;;;;;;;;;;qBAWP;;;;;;;;;;;wBA6KG,wBAAwB,UAAS,mBAAwB"}
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as getForm, c as publishForm, i as diffForms, l as validateForm, n as createFormancyMcpServer, o as listForms, r as describeSpec, s as listSubmissions, t as TOOL_DEFINITIONS } from "./server-CdcuxHHN.mjs";
1
+ import { a as getForm, c as publishForm, i as diffForms, l as validateForm, n as createFormancyMcpServer, o as listForms, r as describeSpec, s as listSubmissions, t as TOOL_DEFINITIONS } from "./server-Cv8ZUKhl.mjs";
2
2
  export { TOOL_DEFINITIONS, createFormancyMcpServer, describeSpec, diffForms, getForm, listForms, listSubmissions, publishForm, validateForm };
package/dist/main.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as createFormancyMcpServer } from "./server-CdcuxHHN.mjs";
2
+ import { n as createFormancyMcpServer } from "./server-Cv8ZUKhl.mjs";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  //#region src/main.ts
5
5
  /**
@@ -0,0 +1,591 @@
1
+ import { createRequire } from "node:module";
2
+ import { authoringFacts, diffSchemas, schemaHash } from "@formancy/spec";
3
+ import { validateSchema } from "@formancy/spec/validate";
4
+ import { engineRefusal, expressionProblems, runScenarios } from "@formancy/core";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ //#region src/tools.ts
8
+ /**
9
+ * Everything the spec allows, so a model does not have to guess.
10
+ *
11
+ * The cheapest tool here and probably the most valuable: without it a model
12
+ * invents `type: "phone"` or `type: "email"` from its memory of other form
13
+ * builders, gets a validation error, and burns a turn finding out that `text`
14
+ * with `format: "email"` is the answer. Told up front, it does not.
15
+ */
16
+ function describeSpec() {
17
+ const facts = authoringFacts();
18
+ return {
19
+ ok: true,
20
+ summary: `formancy speaks spec versions ${facts.specVersions.join(" and ")}; new documents should use "${facts.currentSpecVersion}". A field type not in this list does not exist, whatever other form builders call it — an email field is type "text" with format "email".`,
21
+ data: facts
22
+ };
23
+ }
24
+ /**
25
+ * Check a document without publishing it — the server's publish checks, in the
26
+ * gate's order. Only the analysis of regular-expression `pattern`s stays on
27
+ * the server: it needs an analysis engine this package does not carry, and the
28
+ * server's refusal names the pattern.
29
+ *
30
+ * Schema validity, the engine's compile and expression sanity are different
31
+ * failures with different fixes, and a later check means nothing in a document
32
+ * an earlier one has rejected. So the structure is checked first, then whether
33
+ * the engine would open the document at all, then whether its expressions
34
+ * would ever do anything.
35
+ *
36
+ * The middle check was once missing, and this tool told a model that a form
37
+ * with a misspelled field name was valid: the validator does not read CEL and
38
+ * the expression check skips what the engine refuses, so nothing here asked
39
+ * the engine. The server did, and refused it.
40
+ */
41
+ function validateForm(document) {
42
+ const result = validateSchema(document);
43
+ if (!result.valid) return {
44
+ ok: false,
45
+ summary: `Not a valid formancy document: ${String(result.errors.length)} problem(s). Fix these before publishing.`,
46
+ data: {
47
+ valid: false,
48
+ errors: result.errors
49
+ }
50
+ };
51
+ const refusal = engineRefusal(document);
52
+ if (refusal !== void 0) return {
53
+ ok: false,
54
+ summary: "The document is structurally valid, but the engine refuses to open it — the same engine the server's publish gate and every renderer build, so this form could be neither published nor shown. " + refusal,
55
+ data: {
56
+ valid: true,
57
+ engineRefusal: refusal
58
+ }
59
+ };
60
+ const problems = expressionProblems(document);
61
+ if (problems.length > 0) return {
62
+ ok: false,
63
+ summary: `The document is structurally valid, but ${String(problems.length)} expression(s) will never do anything. These compile and then fail at evaluation, so nothing would report them at runtime: a computed field would stay empty and a visible rule would show the field it was meant to hide.`,
64
+ data: {
65
+ valid: true,
66
+ expressionProblems: problems
67
+ }
68
+ };
69
+ return {
70
+ ok: true,
71
+ summary: "Valid, and every expression type-checks.",
72
+ data: { valid: true }
73
+ };
74
+ }
75
+ /**
76
+ * What a change does to the submissions already collected.
77
+ *
78
+ * The question an agent cannot answer by reading the two documents, and the
79
+ * one whose wrong answer is permanent. `breaking` is not advice.
80
+ */
81
+ function diffForms(before, after) {
82
+ const first = validateSchema(before);
83
+ const second = validateSchema(after);
84
+ if (!first.valid || !second.valid) return {
85
+ ok: false,
86
+ summary: "Both documents have to be valid before they can be compared. Run validate_form on each.",
87
+ data: {
88
+ beforeValid: first.valid,
89
+ afterValid: second.valid
90
+ }
91
+ };
92
+ const changes = diffSchemas(before, after);
93
+ const worst = severityOf(changes);
94
+ return {
95
+ ok: true,
96
+ summary: changes.length === 0 ? "No change: the two documents are identical once canonicalised." : `${String(changes.length)} change(s), worst severity ${worst}. ` + ADVICE[worst],
97
+ data: {
98
+ severity: worst,
99
+ changes
100
+ }
101
+ };
102
+ }
103
+ const ADVICE = {
104
+ none: "",
105
+ compatible: "Existing drafts and submissions rebind silently.",
106
+ lossy: "Existing data rebinds, but some answers no longer have a field to live in. They are kept under data.__orphaned rather than deleted.",
107
+ breaking: "Existing drafts CANNOT rebind and will open read-only against the version that produced them. Say so before publishing, and consider a new form instead."
108
+ };
109
+ function severityOf(changes) {
110
+ if (changes.some((change) => change.severity === "breaking")) return "breaking";
111
+ if (changes.some((change) => change.severity === "lossy")) return "lossy";
112
+ if (changes.length > 0) return "compatible";
113
+ return "none";
114
+ }
115
+ /**
116
+ * Publish, but check first.
117
+ *
118
+ * The local validation is not an optimisation. A model that gets a 422 back
119
+ * has to guess what the server meant from an error shape it has never seen;
120
+ * a model that gets `expressionProblems` back is told which rule, in which
121
+ * words, and what to write instead. And an invalid document never travels.
122
+ */
123
+ async function publishForm(access, path, document, basedOn) {
124
+ const checked = validateForm(document);
125
+ if (!checked.ok) return {
126
+ ok: false,
127
+ summary: `Not published, because the document would not have worked. ${checked.summary}`,
128
+ data: checked.data
129
+ };
130
+ if (basedOn !== void 0) {
131
+ const current = await currentSchema(access, path);
132
+ if (!current.ok) return current.problem;
133
+ if (current.hash !== basedOn) return {
134
+ ok: false,
135
+ summary: `Not published: "${path}" has changed since this edit was based on it, and publishing the whole document would discard that change. Call get_form again, redo the edit against what is there now, and publish with the new basedOn.`,
136
+ data: {
137
+ basedOn,
138
+ current: current.hash
139
+ }
140
+ };
141
+ }
142
+ return request(access, "/forms", {
143
+ method: "POST",
144
+ body: JSON.stringify({
145
+ path,
146
+ schema: document
147
+ })
148
+ });
149
+ }
150
+ /**
151
+ * A form run against examples with their answers written down.
152
+ *
153
+ * The check nothing else here can make. `validate_form` says a document
154
+ * works; it cannot say the condition is backwards, because
155
+ * `kind == 'other'` and `kind != 'other'` are both valid CEL and the
156
+ * difference is between the document and what somebody meant
157
+ * ([0110](../../../docs/decisions/0110-a-form-is-checked-against-examples.md)).
158
+ *
159
+ * An agent is exactly who needs telling: it writes a rule from a sentence,
160
+ * and an example is the one thing that catches it writing the opposite rule.
161
+ * Local — no server, no credentials — so it can run before anything is
162
+ * published.
163
+ */
164
+ function checkScenarios(document, scenarios) {
165
+ if (scenarios.length === 0) return {
166
+ ok: false,
167
+ summary: "No scenarios to check, so this says nothing about the form. A scenario is a set of answers and what the form should make of them — whether it validates, which errors it gives, which fields are visible, and what the submission carries."
168
+ };
169
+ const checked = validateForm(document);
170
+ if (!checked.ok) return {
171
+ ok: false,
172
+ summary: `Nothing was checked, because the document would not have worked. ${checked.summary}`,
173
+ data: checked.data
174
+ };
175
+ const results = runScenarios(document, scenarios);
176
+ const broken = results.filter((result) => !result.passed);
177
+ return {
178
+ ok: broken.length === 0,
179
+ summary: broken.length === 0 ? `All ${String(results.length)} scenarios hold.` : `${String(broken.length)} of ${String(results.length)} scenarios no longer hold: ${broken.map((result) => `"${result.name}"`).join(", ")}. Each one below says what it expected and what happened — a rule that compiles can still be the opposite of the rule that was asked for.`,
180
+ data: { results }
181
+ };
182
+ }
183
+ /**
184
+ * An edit held up against what is published, without publishing it.
185
+ *
186
+ * The MCP half of a builder's review step. `validate_form` says a document
187
+ * works; it does not say that the rewrite dropped an option somebody has
188
+ * already chosen, and an agent reading only its own two documents cannot tell
189
+ * either. This fetches what is live, diffs against it, and hands back the
190
+ * change list and the hash — so the agent can put *that* in front of a person
191
+ * rather than "I updated your form", and then publish with the hash so the
192
+ * edit cannot land on a version it was never written against
193
+ * ([0109](../../../docs/decisions/0109-an-ai-edit-is-reviewed-before-it-lands.md)).
194
+ */
195
+ async function proposeFormEdit(access, path, document) {
196
+ const checked = validateForm(document);
197
+ if (!checked.ok) return {
198
+ ok: false,
199
+ summary: `Nothing to review, because the document would not have worked. ${checked.summary}`,
200
+ data: checked.data
201
+ };
202
+ const current = await currentSchema(access, path);
203
+ if (!current.ok) return current.problem;
204
+ const changes = diffSchemas(current.schema, document);
205
+ const worst = severityOf(changes);
206
+ return {
207
+ ok: true,
208
+ summary: changes.length === 0 ? `No change: "${path}" is already this document. Nothing to publish.` : `${String(changes.length)} change(s) to "${path}", worst severity ${worst}. ${ADVICE[worst]} Show these to the person before publishing, then publish with basedOn=${current.hash}.`,
209
+ data: {
210
+ severity: worst,
211
+ changes,
212
+ basedOn: current.hash
213
+ }
214
+ };
215
+ }
216
+ /** What the server currently has for a path, and its hash. */
217
+ async function currentSchema(access, path) {
218
+ const fetched = await getForm(access, path);
219
+ if (!fetched.ok) return {
220
+ ok: false,
221
+ problem: fetched
222
+ };
223
+ const schema = fetched.data?.schema;
224
+ if (!validateSchema(schema).valid) return {
225
+ ok: false,
226
+ problem: {
227
+ ok: false,
228
+ summary: `The form published at "${path}" is not a document this version of the tools can read, so an edit cannot be compared against it. Upgrade the tools to the server's spec version.`
229
+ }
230
+ };
231
+ return {
232
+ ok: true,
233
+ schema,
234
+ hash: schemaHash(schema)
235
+ };
236
+ }
237
+ async function listForms(access) {
238
+ return request(access, "/forms");
239
+ }
240
+ async function getForm(access, path) {
241
+ return request(access, `/f/${encodeURIComponent(path)}`);
242
+ }
243
+ async function listSubmissions(access, path) {
244
+ return request(access, `/f/${encodeURIComponent(path)}/submissions`);
245
+ }
246
+ /**
247
+ * One request, and one place that knows how a failure is reported.
248
+ *
249
+ * Every error comes back as `ok: false` with the server's own words rather
250
+ * than as a thrown exception, because a thrown exception reaches a model as a
251
+ * stack trace and tells it nothing it can act on.
252
+ */
253
+ async function request(access, route, init = {}) {
254
+ const send = access.fetch ?? globalThis.fetch;
255
+ const url = `${access.baseUrl.replace(/\/$/, "")}${route}`;
256
+ let response;
257
+ try {
258
+ response = await send(url, {
259
+ ...init,
260
+ headers: {
261
+ ...init.body === void 0 ? {} : { "content-type": "application/json" },
262
+ authorization: `Bearer ${access.apiKey}`,
263
+ ...init.headers
264
+ }
265
+ });
266
+ } catch (error) {
267
+ return {
268
+ ok: false,
269
+ summary: `Could not reach the formancy server at ${access.baseUrl}: ${error instanceof Error ? error.message : String(error)}. Check FORMANCY_URL, and that the server is running.`
270
+ };
271
+ }
272
+ const body = await response.json().catch(() => void 0);
273
+ if (!response.ok) {
274
+ const detail = messageFrom(body);
275
+ return {
276
+ ok: false,
277
+ summary: response.status === 401 || response.status === 403 ? `The server refused the request (${String(response.status)}). Check FORMANCY_API_KEY.` : `The server refused the request (${String(response.status)})${detail === void 0 ? "." : `: ${detail}`}`,
278
+ ...body === void 0 ? {} : { data: body }
279
+ };
280
+ }
281
+ return {
282
+ ok: true,
283
+ summary: "Done.",
284
+ data: body
285
+ };
286
+ }
287
+ function messageFrom(body) {
288
+ if (body === null || typeof body !== "object") return void 0;
289
+ const record = body;
290
+ if (typeof record.message === "string") return record.message;
291
+ if (typeof record.error === "string") return record.error;
292
+ }
293
+ //#endregion
294
+ //#region src/server.ts
295
+ /**
296
+ * Names and descriptions, apart from the wiring.
297
+ *
298
+ * Exported because the description is the prompt: it is the only thing telling
299
+ * a model when to reach for a tool, and a wrong one means a tool that is never
300
+ * called or called for the wrong reason. Kept where it can be read and tested
301
+ * rather than buried in a registration call.
302
+ */
303
+ const TOOL_DEFINITIONS = {
304
+ describe_spec: "List every field type, layout kind, rule kind and format the formancy spec defines. Call this BEFORE writing a form document. A type not in the list does not exist, whatever other form builders call it.",
305
+ validate_form: "Run the server's publish checks without publishing: against the spec, through the engine that renders the form (a misspelled field name, a cycle between computed fields, a condition that is not certain to produce a bool), and for expressions that compile but can never evaluate — the failure that is silent at runtime. Each comes back separately, with what to change.",
306
+ diff_forms: "Compare two form documents and report what the change would do to submissions already collected: compatible, lossy, or breaking. Call this before publishing over an existing form.",
307
+ check_scenarios: "Run a form against examples with their answers written down, and report which stopped holding. The ONE check that catches a condition which compiles and is the opposite of what was asked for — validate_form cannot, because both spellings are valid CEL. Needs no server. Write a scenario for every rule you add: a set of answers, whether the form is valid, which errors it gives, which fields are visible, and what the submission carries.",
308
+ propose_form_edit: "Hold an edit up against the form that is published, WITHOUT publishing it. Answers with what the edit would do to submissions already collected, and with a basedOn hash. Call this instead of publish_form whenever you are changing a form that already exists: show the changes to the person, get their agreement, then publish_form with the basedOn it gave you.",
309
+ publish_form: "Publish a form document to a formancy server. Validates locally first and refuses to send an invalid document, so the reason comes back as something to fix rather than as a 422. Pass basedOn when changing an existing form: a document is the WHOLE form, so publishing one based on an older version silently discards whatever was published in between, and the publish succeeds so nothing reports it.",
310
+ list_forms: "List the forms on the formancy server, with their current version.",
311
+ get_form: "Fetch one form document from the formancy server, with its version and schema hash.",
312
+ list_submissions: "List submissions for one form."
313
+ };
314
+ /**
315
+ * The version this server reports.
316
+ *
317
+ * From the manifest, not typed. It said `0.1.0` while the package was on 0.3.0 —
318
+ * a wrong statement in the one field a client uses to tell two installations
319
+ * apart, and exactly the hand-written number this repository keeps finding
320
+ * stale.
321
+ *
322
+ * `createRequire` rather than a JSON import: `../package.json` resolves to the
323
+ * package root from both `src/server.ts` and `dist/index.mjs`, so the same line
324
+ * is right in a test and in the published package.
325
+ */
326
+ const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version;
327
+ /**
328
+ * What a client is told a result looks like, before it reads one.
329
+ *
330
+ * One envelope for every tool rather than a schema each. The shape is the same
331
+ * everywhere — did it work, one sentence a person can read, and the part an
332
+ * agent acts on — so a client that has learned it once has learned it for all
333
+ * nine. A schema per tool would be nine places for `data` to drift from what
334
+ * the tool returns.
335
+ */
336
+ const RESULT_SHAPE = {
337
+ ok: z.boolean().describe("Whether the tool did what was asked."),
338
+ summary: z.string().describe("One or two sentences a person can read."),
339
+ data: z.unknown().optional().describe("The part an agent acts on, when there is one.")
340
+ };
341
+ /**
342
+ * Both readings of the answer: prose and structure.
343
+ *
344
+ * `content` is what a model reads; `structuredContent` is what a client parses
345
+ * without pulling JSON back out of a string. Both travel — prose alone throws
346
+ * away the part an agent acts on, and structure alone makes every refusal a
347
+ * parsing exercise.
348
+ */
349
+ function respond(result) {
350
+ return {
351
+ content: [{
352
+ type: "text",
353
+ text: result.data === void 0 ? result.summary : `${result.summary}\n\n${JSON.stringify(result.data, null, 2)}`
354
+ }],
355
+ structuredContent: {
356
+ ok: result.ok,
357
+ summary: result.summary,
358
+ ...result.data === void 0 ? {} : { data: result.data }
359
+ },
360
+ ...result.ok ? {} : { isError: true }
361
+ };
362
+ }
363
+ /**
364
+ * What a tool will do, for a client deciding whether to ask first.
365
+ *
366
+ * The only thing a host has to tell `validate_form` from `publish_form` before
367
+ * calling either. Without them a tool defaults to `readOnlyHint: false`,
368
+ * `destructiveHint: true`, `openWorldHint: true` — so this server’s local,
369
+ * read-only checks were all advertised as potentially destructive calls into an
370
+ * open world, which is the opposite of true in every case.
371
+ */
372
+ const LOCAL_CHECK = {
373
+ readOnlyHint: true,
374
+ openWorldHint: false
375
+ };
376
+ const SERVER_READ = {
377
+ readOnlyHint: true,
378
+ openWorldHint: true
379
+ };
380
+ /**
381
+ * Publishing. `destructiveHint: false` is not optimism: a published version is
382
+ * immutable and a publish inserts a new row, so nothing is overwritten
383
+ * ([0025](../../../docs/decisions/0025-immutability-in-the-database.md)).
384
+ *
385
+ * Not idempotent, though — publishing the same document twice makes a second
386
+ * version rather than the same one, and saying otherwise would invite a client
387
+ * to retry it freely.
388
+ */
389
+ const SERVER_WRITE = {
390
+ readOnlyHint: false,
391
+ destructiveHint: false,
392
+ idempotentHint: false,
393
+ openWorldHint: true
394
+ };
395
+ /** A tool that needs a server, when there is not one. */
396
+ function noServer(name) {
397
+ return {
398
+ ok: false,
399
+ summary: `${name} needs a formancy server, and none is configured. Set FORMANCY_URL and FORMANCY_API_KEY. The local tools — describe_spec, validate_form and diff_forms — work without one, so a form can be written and checked before a server exists.`
400
+ };
401
+ }
402
+ /**
403
+ * The arguments each tool takes.
404
+ *
405
+ * A document arrives as `unknown` on purpose: it is checked by
406
+ * `validateSchema` against the spec's own JSON Schema, which is the one
407
+ * authority on what a formancy document is. Describing its shape a second
408
+ * time here in Zod would be a second authority, and the day the two disagree
409
+ * the tool would reject a document the product accepts.
410
+ */
411
+ const DOCUMENT = z.unknown().describe("A formancy form document (the JSON schema object).");
412
+ const FORM_PATH = z.string().min(1).describe("The form's path, as it appears in its URL.");
413
+ const SCENARIOS = z.array(z.unknown()).describe("Examples with their answers: each is { name, changes, valid } and may also pin errors, visible, values and absent.");
414
+ const BASED_ON = z.string().optional().describe("The schema hash this edit was based on, from propose_form_edit or get_form. Given, the publish is refused if the form has changed since.");
415
+ function createFormancyMcpServer(options = {}) {
416
+ const server = new McpServer({
417
+ name: "formancy",
418
+ version: PACKAGE_VERSION
419
+ });
420
+ const { access } = options;
421
+ server.registerTool("describe_spec", {
422
+ description: TOOL_DEFINITIONS.describe_spec,
423
+ outputSchema: RESULT_SHAPE,
424
+ annotations: LOCAL_CHECK
425
+ }, () => respond(describeSpec()));
426
+ server.registerTool("validate_form", {
427
+ description: TOOL_DEFINITIONS.validate_form,
428
+ outputSchema: RESULT_SHAPE,
429
+ annotations: LOCAL_CHECK,
430
+ inputSchema: { document: DOCUMENT }
431
+ }, ({ document }) => respond(validateForm(document)));
432
+ server.registerTool("diff_forms", {
433
+ description: TOOL_DEFINITIONS.diff_forms,
434
+ outputSchema: RESULT_SHAPE,
435
+ annotations: LOCAL_CHECK,
436
+ inputSchema: {
437
+ before: DOCUMENT,
438
+ after: DOCUMENT
439
+ }
440
+ }, ({ before, after }) => respond(diffForms(before, after)));
441
+ server.registerTool("check_scenarios", {
442
+ description: TOOL_DEFINITIONS.check_scenarios,
443
+ outputSchema: RESULT_SHAPE,
444
+ annotations: LOCAL_CHECK,
445
+ inputSchema: {
446
+ document: DOCUMENT,
447
+ scenarios: SCENARIOS
448
+ }
449
+ }, ({ document, scenarios }) => respond(checkScenarios(document, scenarios)));
450
+ server.registerTool("propose_form_edit", {
451
+ description: TOOL_DEFINITIONS.propose_form_edit,
452
+ outputSchema: RESULT_SHAPE,
453
+ annotations: SERVER_READ,
454
+ inputSchema: {
455
+ path: FORM_PATH,
456
+ document: DOCUMENT
457
+ }
458
+ }, async ({ path, document }) => respond(access === void 0 ? noServer("propose_form_edit") : await proposeFormEdit(access, path, document)));
459
+ server.registerTool("publish_form", {
460
+ description: TOOL_DEFINITIONS.publish_form,
461
+ outputSchema: RESULT_SHAPE,
462
+ annotations: SERVER_WRITE,
463
+ inputSchema: {
464
+ path: FORM_PATH,
465
+ document: DOCUMENT,
466
+ basedOn: BASED_ON
467
+ }
468
+ }, async ({ path, document, basedOn }) => respond(access === void 0 ? noServer("publish_form") : await publishForm(access, path, document, basedOn)));
469
+ server.registerTool("list_forms", {
470
+ description: TOOL_DEFINITIONS.list_forms,
471
+ outputSchema: RESULT_SHAPE,
472
+ annotations: SERVER_READ
473
+ }, async () => respond(access === void 0 ? noServer("list_forms") : await listForms(access)));
474
+ server.registerTool("get_form", {
475
+ description: TOOL_DEFINITIONS.get_form,
476
+ outputSchema: RESULT_SHAPE,
477
+ annotations: SERVER_READ,
478
+ inputSchema: { path: FORM_PATH }
479
+ }, async ({ path }) => respond(access === void 0 ? noServer("get_form") : await getForm(access, path)));
480
+ server.registerTool("list_submissions", {
481
+ description: TOOL_DEFINITIONS.list_submissions,
482
+ outputSchema: RESULT_SHAPE,
483
+ annotations: SERVER_READ,
484
+ inputSchema: { path: FORM_PATH }
485
+ }, async ({ path }) => respond(access === void 0 ? noServer("list_submissions") : await listSubmissions(access, path)));
486
+ server.registerPrompt("build_a_form", {
487
+ title: "Build a form",
488
+ description: "Write a new formancy form from a description, in the order that gets it right the first time: the spec, then the document, then examples that check the rules.",
489
+ argsSchema: { description: z.string().min(1).describe("What the form is for, in a sentence or two.") }
490
+ }, ({ description }) => ({ messages: [{
491
+ role: "user",
492
+ content: {
493
+ type: "text",
494
+ text: [
495
+ `Write a formancy form for: ${description}`,
496
+ "",
497
+ "Work in this order, and do not skip the first step:",
498
+ "",
499
+ "1. Call `describe_spec`. It lists every field type, widget, rule kind and",
500
+ " format the document format has. A type that is not in that list does not",
501
+ " exist, whatever other form builders call it — an email field is `text`",
502
+ " with `format: \"email\"`.",
503
+ "2. Write the document.",
504
+ "3. Call `validate_form`. It runs the schema check, the engine compile, and",
505
+ " the check for expressions that compile and can never evaluate. Fix what",
506
+ " it names and call it again.",
507
+ "4. Write a scenario for every rule, and call `check_scenarios`. This is the",
508
+ " only check that catches a condition written backwards: `a == b` and",
509
+ " `a != b` are both valid CEL and both pass step 3. Write the scenarios",
510
+ " from the description above rather than from the rules you just wrote, or",
511
+ " they will agree with whatever the rules happen to say.",
512
+ "5. Show the person the form and what the scenarios checked, before",
513
+ " publishing anything."
514
+ ].join("\n")
515
+ }
516
+ }] }));
517
+ server.registerPrompt("change_a_form", {
518
+ title: "Change a published form",
519
+ description: "Edit a form that already exists, without discarding somebody else’s work and without publishing a change nobody has read.",
520
+ argsSchema: {
521
+ path: z.string().min(1).describe("The form's path, as it appears in its URL."),
522
+ change: z.string().min(1).describe("What should be different, in a sentence.")
523
+ }
524
+ }, ({ path, change }) => ({ messages: [{
525
+ role: "user",
526
+ content: {
527
+ type: "text",
528
+ text: [
529
+ `Change the form at "${path}": ${change}`,
530
+ "",
531
+ "A formancy document is the WHOLE form, so an edit based on an older version",
532
+ "silently discards whatever was published in between — and the publish",
533
+ "succeeds, so nothing reports it. Work this way:",
534
+ "",
535
+ "1. Call `get_form` for the current document.",
536
+ "2. Make the change to it.",
537
+ "3. Call `propose_form_edit`. It publishes nothing, and answers with what the",
538
+ " edit would cost submissions already collected, plus a `basedOn` hash.",
539
+ "4. Show the person that list. A change marked lossy means stored answers lose",
540
+ " the field they live in; say so plainly before going further.",
541
+ "5. Call `publish_form` with `basedOn` set to the hash from step 3. If it",
542
+ " refuses, the form moved — start again from step 1 rather than publishing",
543
+ " over it."
544
+ ].join("\n")
545
+ }
546
+ }] }));
547
+ server.registerPrompt("embed_a_form", {
548
+ title: "Embed a form in an application",
549
+ description: "Render a formancy document in a React or Angular application.",
550
+ argsSchema: { framework: z.enum(["react", "angular"]).describe("Which renderer the application uses.") }
551
+ }, ({ framework }) => ({ messages: [{
552
+ role: "user",
553
+ content: {
554
+ type: "text",
555
+ text: framework === "angular" ? [
556
+ "Render a formancy form in an Angular application.",
557
+ "",
558
+ "- Install `@formancy/angular` and `@formancy/core`.",
559
+ "- Build the engine once with `createFormEngine({ schema })` and provide",
560
+ " it with `provideFormancy(engine)`.",
561
+ "- Render `<formancy-form />`. It is zoneless and OnPush, and the engine",
562
+ " is the state — there is nothing to copy into a component.",
563
+ "- Styling is yours: the renderer ships no CSS. A theme from",
564
+ " `@formancy/themes` is one stylesheet import and a",
565
+ " `data-formancy-theme` attribute, or write your own against the",
566
+ " `data-formancy-part` hooks.",
567
+ "- The submitted value is `engine.value()`. Validate it on the server",
568
+ " too: the same engine runs there, so the two cannot disagree."
569
+ ].join("\n") : [
570
+ "Render a formancy form in a React application.",
571
+ "",
572
+ "- Install `@formancy/react` and `@formancy/core`.",
573
+ "- Build the engine once with `createFormEngine({ schema })` and put it",
574
+ " in `<FormancyProvider engine={engine}>`.",
575
+ "- Render `<FormancyForm />`. The engine is the state; there is nothing",
576
+ " to copy into component state and nothing to memoise.",
577
+ "- Styling is yours: the renderer ships no CSS. A theme from",
578
+ " `@formancy/themes` is one stylesheet import and a",
579
+ " `data-formancy-theme` attribute, or write your own against the",
580
+ " `data-formancy-part` hooks.",
581
+ "- The submitted value is `engine.value()`. Validate it on the server",
582
+ " too: the same engine runs there, so the two cannot disagree."
583
+ ].join("\n")
584
+ }
585
+ }] }));
586
+ return server;
587
+ }
588
+ //#endregion
589
+ export { getForm as a, publishForm as c, diffForms as i, validateForm as l, createFormancyMcpServer as n, listForms as o, describeSpec as r, listSubmissions as s, TOOL_DEFINITIONS as t };
590
+
591
+ //# sourceMappingURL=server-Cv8ZUKhl.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-Cv8ZUKhl.mjs","names":[],"sources":["../src/tools.ts","../src/server.ts"],"sourcesContent":["import { authoringFacts, diffSchemas, schemaHash } from '@formancy/spec'\nimport { validateSchema } from '@formancy/spec/validate'\nimport type { Change, FormSchema } from '@formancy/spec'\nimport { engineRefusal, expressionProblems, runScenarios } from '@formancy/core'\nimport type { Scenario } from '@formancy/core'\n\n/**\n * What formancy can be asked to do by a coding agent, as plain functions.\n *\n * Separated from the MCP server itself for the same reason `server-core` is separated\n * from `server`: a use-case that can only be exercised through a transport is one nobody\n * can test. Everything here is a function from JSON to JSON.\n *\n * **The tools check first and act second.** `publish_form` validates locally before it\n * opens a socket, so an invalid document never reaches the server and the model gets the\n * reason rather than a 422. That is possible because the format has a published JSON\n * Schema and the expressions are statically type-checked — a generated document is\n * checkable before anything is done with it.\n */\n\n/** A tool's answer: what happened, and what the agent should do about it. */\nexport interface ToolResult {\n /** False means the agent must fix something before trying again. */\n readonly ok: boolean\n /** Prose for the model. Written to be acted on, not logged. */\n readonly summary: string\n /** The machine-readable part, when there is one. */\n readonly data?: unknown\n}\n\n/** Where a formancy server is, and how to prove who you are to it. */\nexport interface ServerAccess {\n readonly baseUrl: string\n readonly apiKey: string\n /** Injected so tests do not need a socket. */\n readonly fetch?: typeof globalThis.fetch\n}\n\n// ---------------------------------------------------------------- local tools\n\n/**\n * Everything the spec allows, so a model does not have to guess.\n *\n * The cheapest tool here and probably the most valuable: without it a model\n * invents `type: \"phone\"` or `type: \"email\"` from its memory of other form\n * builders, gets a validation error, and burns a turn finding out that `text`\n * with `format: \"email\"` is the answer. Told up front, it does not.\n */\nexport function describeSpec(): ToolResult {\n const facts = authoringFacts()\n return {\n ok: true,\n summary:\n `formancy speaks spec versions ${facts.specVersions.join(' and ')}; new documents should ` +\n `use \"${facts.currentSpecVersion}\". A field type not in this list does not exist, whatever ` +\n `other form builders call it — an email field is type \"text\" with format \"email\".`,\n data: facts,\n }\n}\n\n/**\n * Check a document without publishing it — the server's publish checks, in the\n * gate's order. Only the analysis of regular-expression `pattern`s stays on\n * the server: it needs an analysis engine this package does not carry, and the\n * server's refusal names the pattern.\n *\n * Schema validity, the engine's compile and expression sanity are different\n * failures with different fixes, and a later check means nothing in a document\n * an earlier one has rejected. So the structure is checked first, then whether\n * the engine would open the document at all, then whether its expressions\n * would ever do anything.\n *\n * The middle check was once missing, and this tool told a model that a form\n * with a misspelled field name was valid: the validator does not read CEL and\n * the expression check skips what the engine refuses, so nothing here asked\n * the engine. The server did, and refused it.\n */\nexport function validateForm(document: unknown): ToolResult {\n const result = validateSchema(document)\n if (!result.valid) {\n return {\n ok: false,\n summary: `Not a valid formancy document: ${String(result.errors.length)} problem(s). Fix these before publishing.`,\n data: { valid: false, errors: result.errors },\n }\n }\n\n const refusal = engineRefusal(document as FormSchema)\n if (refusal !== undefined) {\n return {\n ok: false,\n summary:\n `The document is structurally valid, but the engine refuses to open it — the same engine the ` +\n `server's publish gate and every renderer build, so this form could be neither published nor shown. ` +\n refusal,\n data: { valid: true, engineRefusal: refusal },\n }\n }\n\n const problems = expressionProblems(document as FormSchema)\n if (problems.length > 0) {\n return {\n ok: false,\n summary:\n `The document is structurally valid, but ${String(problems.length)} expression(s) will never ` +\n `do anything. These compile and then fail at evaluation, so nothing would report them at runtime: ` +\n `a computed field would stay empty and a visible rule would show the field it was meant to hide.`,\n data: { valid: true, expressionProblems: problems },\n }\n }\n\n return {\n ok: true,\n summary: 'Valid, and every expression type-checks.',\n data: { valid: true },\n }\n}\n\n/**\n * What a change does to the submissions already collected.\n *\n * The question an agent cannot answer by reading the two documents, and the\n * one whose wrong answer is permanent. `breaking` is not advice.\n */\nexport function diffForms(before: unknown, after: unknown): ToolResult {\n const first = validateSchema(before)\n const second = validateSchema(after)\n if (!first.valid || !second.valid) {\n return {\n ok: false,\n summary: 'Both documents have to be valid before they can be compared. Run validate_form on each.',\n data: { beforeValid: first.valid, afterValid: second.valid },\n }\n }\n\n const changes = diffSchemas(before as FormSchema, after as FormSchema)\n const worst = severityOf(changes)\n\n return {\n ok: true,\n summary:\n changes.length === 0\n ? 'No change: the two documents are identical once canonicalised.'\n : `${String(changes.length)} change(s), worst severity ${worst}. ` + ADVICE[worst],\n data: { severity: worst, changes },\n }\n}\n\nconst ADVICE: Readonly<Record<'none' | 'compatible' | 'lossy' | 'breaking', string>> = {\n none: '',\n compatible: 'Existing drafts and submissions rebind silently.',\n lossy: 'Existing data rebinds, but some answers no longer have a field to live in. They are kept under data.__orphaned rather than deleted.',\n breaking:\n 'Existing drafts CANNOT rebind and will open read-only against the version that produced them. Say so before publishing, and consider a new form instead.',\n}\n\nfunction severityOf(changes: readonly Change[]): 'none' | 'compatible' | 'lossy' | 'breaking' {\n if (changes.some((change) => change.severity === 'breaking')) return 'breaking'\n if (changes.some((change) => change.severity === 'lossy')) return 'lossy'\n if (changes.length > 0) return 'compatible'\n return 'none'\n}\n\n// --------------------------------------------------------------- server tools\n\n/**\n * Publish, but check first.\n *\n * The local validation is not an optimisation. A model that gets a 422 back\n * has to guess what the server meant from an error shape it has never seen;\n * a model that gets `expressionProblems` back is told which rule, in which\n * words, and what to write instead. And an invalid document never travels.\n */\nexport async function publishForm(\n access: ServerAccess,\n path: string,\n document: unknown,\n /**\n * The schema hash the edit was based on, from `propose_form_edit` or\n * `get_form`. Given, the published form is checked against it first.\n *\n * Opt-in, and the reason is the first publish: a new form has nothing to be\n * based on, and requiring this would turn it into a fetch of something that\n * is not there. So the unsafe path still exists, which is why the tool's\n * description points at the safe one rather than leaving an agent to guess.\n */\n basedOn?: string,\n): Promise<ToolResult> {\n const checked = validateForm(document)\n if (!checked.ok) {\n return {\n ok: false,\n summary: `Not published, because the document would not have worked. ${checked.summary}`,\n data: checked.data,\n }\n }\n\n if (basedOn !== undefined) {\n const current = await currentSchema(access, path)\n if (!current.ok) return current.problem\n if (current.hash !== basedOn) {\n /*\n * The lost update, and the one mistake here nobody sees until the form\n * is wrong. A document is the WHOLE form, so publishing an edit based\n * on an older version over a newer one discards that version's change\n * entirely — and the publish succeeds, so nothing reports it.\n */\n return {\n ok: false,\n summary:\n `Not published: \"${path}\" has changed since this edit was based on it, and publishing ` +\n `the whole document would discard that change. Call get_form again, redo the edit ` +\n `against what is there now, and publish with the new basedOn.`,\n data: { basedOn, current: current.hash },\n }\n }\n }\n\n return request(access, '/forms', {\n method: 'POST',\n body: JSON.stringify({ path, schema: document }),\n })\n}\n\n/**\n * A form run against examples with their answers written down.\n *\n * The check nothing else here can make. `validate_form` says a document\n * works; it cannot say the condition is backwards, because\n * `kind == 'other'` and `kind != 'other'` are both valid CEL and the\n * difference is between the document and what somebody meant\n * ([0110](../../../docs/decisions/0110-a-form-is-checked-against-examples.md)).\n *\n * An agent is exactly who needs telling: it writes a rule from a sentence,\n * and an example is the one thing that catches it writing the opposite rule.\n * Local — no server, no credentials — so it can run before anything is\n * published.\n */\nexport function checkScenarios(document: unknown, scenarios: readonly Scenario[]): ToolResult {\n if (scenarios.length === 0) {\n /*\n * The vacuous pass. \"All 0 scenarios hold\" is true, and it is the single\n * most misleading sentence this tool could give an agent about to\n * publish.\n */\n return {\n ok: false,\n summary:\n 'No scenarios to check, so this says nothing about the form. A scenario is a set of ' +\n 'answers and what the form should make of them — whether it validates, which errors ' +\n 'it gives, which fields are visible, and what the submission carries.',\n }\n }\n\n const checked = validateForm(document)\n if (!checked.ok) {\n // One fact, not one failure per example: an agent given twenty identical\n // \"no such field\" lines reads the noise and not the cause.\n return {\n ok: false,\n summary: `Nothing was checked, because the document would not have worked. ${checked.summary}`,\n data: checked.data,\n }\n }\n\n const results = runScenarios(document as FormSchema, scenarios)\n const broken = results.filter((result) => !result.passed)\n\n return {\n ok: broken.length === 0,\n summary:\n broken.length === 0\n ? `All ${String(results.length)} scenarios hold.`\n : `${String(broken.length)} of ${String(results.length)} scenarios no longer hold: ` +\n `${broken.map((result) => `\"${result.name}\"`).join(', ')}. Each one below says what it ` +\n `expected and what happened — a rule that compiles can still be the opposite of the ` +\n `rule that was asked for.`,\n data: { results },\n }\n}\n\n/**\n * An edit held up against what is published, without publishing it.\n *\n * The MCP half of a builder's review step. `validate_form` says a document\n * works; it does not say that the rewrite dropped an option somebody has\n * already chosen, and an agent reading only its own two documents cannot tell\n * either. This fetches what is live, diffs against it, and hands back the\n * change list and the hash — so the agent can put *that* in front of a person\n * rather than \"I updated your form\", and then publish with the hash so the\n * edit cannot land on a version it was never written against\n * ([0109](../../../docs/decisions/0109-an-ai-edit-is-reviewed-before-it-lands.md)).\n */\nexport async function proposeFormEdit(\n access: ServerAccess,\n path: string,\n document: unknown,\n): Promise<ToolResult> {\n const checked = validateForm(document)\n if (!checked.ok) {\n // A change list for a document that cannot be published is a list of\n // things that will not happen.\n return {\n ok: false,\n summary: `Nothing to review, because the document would not have worked. ${checked.summary}`,\n data: checked.data,\n }\n }\n\n const current = await currentSchema(access, path)\n if (!current.ok) return current.problem\n\n const changes = diffSchemas(current.schema, document as FormSchema)\n const worst = severityOf(changes)\n\n return {\n ok: true,\n summary:\n changes.length === 0\n ? `No change: \"${path}\" is already this document. Nothing to publish.`\n : `${String(changes.length)} change(s) to \"${path}\", worst severity ${worst}. ${ADVICE[worst]} ` +\n `Show these to the person before publishing, then publish with basedOn=${current.hash}.`,\n data: { severity: worst, changes, basedOn: current.hash },\n }\n}\n\n/** What the server currently has for a path, and its hash. */\nasync function currentSchema(\n access: ServerAccess,\n path: string,\n): Promise<\n { ok: true; schema: FormSchema; hash: string } | { ok: false; problem: ToolResult }\n> {\n const fetched = await getForm(access, path)\n if (!fetched.ok) return { ok: false, problem: fetched }\n\n const schema = (fetched.data as { schema?: unknown } | undefined)?.schema\n const valid = validateSchema(schema)\n if (!valid.valid) {\n // The published form does not validate against this reader's spec: an\n // older client against a newer document. Said plainly rather than\n // compared anyway, which would report nonsense as a change list.\n return {\n ok: false,\n problem: {\n ok: false,\n summary:\n `The form published at \"${path}\" is not a document this version of the tools can read, ` +\n `so an edit cannot be compared against it. Upgrade the tools to the server's spec version.`,\n },\n }\n }\n\n return { ok: true, schema: schema as FormSchema, hash: schemaHash(schema as FormSchema) }\n}\n\nexport async function listForms(access: ServerAccess): Promise<ToolResult> {\n return request(access, '/forms')\n}\n\nexport async function getForm(access: ServerAccess, path: string): Promise<ToolResult> {\n return request(access, `/f/${encodeURIComponent(path)}`)\n}\n\nexport async function listSubmissions(access: ServerAccess, path: string): Promise<ToolResult> {\n return request(access, `/f/${encodeURIComponent(path)}/submissions`)\n}\n\n/**\n * One request, and one place that knows how a failure is reported.\n *\n * Every error comes back as `ok: false` with the server's own words rather\n * than as a thrown exception, because a thrown exception reaches a model as a\n * stack trace and tells it nothing it can act on.\n */\nasync function request(\n access: ServerAccess,\n route: string,\n init: RequestInit = {},\n): Promise<ToolResult> {\n const send = access.fetch ?? globalThis.fetch\n const url = `${access.baseUrl.replace(/\\/$/, '')}${route}`\n\n let response: Response\n try {\n response = await send(url, {\n ...init,\n headers: {\n ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),\n authorization: `Bearer ${access.apiKey}`,\n ...init.headers,\n },\n })\n } catch (error) {\n return {\n ok: false,\n summary:\n `Could not reach the formancy server at ${access.baseUrl}: ${error instanceof Error ? error.message : String(error)}. ` +\n `Check FORMANCY_URL, and that the server is running.`,\n }\n }\n\n const body: unknown = await response.json().catch(() => undefined)\n\n if (!response.ok) {\n const detail = messageFrom(body)\n return {\n ok: false,\n summary:\n response.status === 401 || response.status === 403\n ? `The server refused the request (${String(response.status)}). Check FORMANCY_API_KEY.`\n : `The server refused the request (${String(response.status)})${detail === undefined ? '.' : `: ${detail}`}`,\n ...(body === undefined ? {} : { data: body }),\n }\n }\n\n return { ok: true, summary: 'Done.', data: body }\n}\n\nfunction messageFrom(body: unknown): string | undefined {\n if (body === null || typeof body !== 'object') return undefined\n const record = body as { message?: unknown; error?: unknown }\n if (typeof record.message === 'string') return record.message\n if (typeof record.error === 'string') return record.error\n return undefined\n}\n","import { createRequire } from 'node:module'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'\nimport { z } from 'zod'\nimport {\n checkScenarios,\n describeSpec,\n diffForms,\n getForm,\n listForms,\n listSubmissions,\n proposeFormEdit,\n publishForm,\n validateForm,\n} from './tools.js'\nimport type { ServerAccess, ToolResult } from './tools.js'\n\n/**\n * The tools, wired to the protocol.\n *\n * Thin on purpose: every one of these is a call into `tools.ts` and a\n * conversion of the answer into MCP's content shape. The logic is tested\n * without a protocol, because a use-case that can only be exercised through a\n * transport is a use-case nobody tests properly.\n */\n\nexport interface McpServerOptions {\n /**\n * Where a formancy server is, if there is one.\n *\n * Absent is a supported state and not a degraded one: the four local tools\n * — describe_spec, validate_form, diff_forms and the checking half of\n * publishing — need no server at all, which means an agent can author and\n * verify a whole form before anybody has deployed anything.\n */\n readonly access?: ServerAccess\n}\n\n/**\n * Names and descriptions, apart from the wiring.\n *\n * Exported because the description is the prompt: it is the only thing telling\n * a model when to reach for a tool, and a wrong one means a tool that is never\n * called or called for the wrong reason. Kept where it can be read and tested\n * rather than buried in a registration call.\n */\nexport const TOOL_DEFINITIONS = {\n describe_spec:\n 'List every field type, layout kind, rule kind and format the formancy spec defines. ' +\n 'Call this BEFORE writing a form document. A type not in the list does not exist, whatever ' +\n 'other form builders call it.',\n validate_form:\n \"Run the server's publish checks without publishing: against the spec, through the engine \" +\n 'that renders the form (a misspelled field name, a cycle between computed fields, a ' +\n 'condition that is not certain to produce a bool), and for expressions that compile but ' +\n 'can never evaluate — the failure that is silent at runtime. Each comes back separately, ' +\n 'with what to change.',\n diff_forms:\n 'Compare two form documents and report what the change would do to submissions already ' +\n 'collected: compatible, lossy, or breaking. Call this before publishing over an existing form.',\n check_scenarios:\n 'Run a form against examples with their answers written down, and report which stopped ' +\n 'holding. The ONE check that catches a condition which compiles and is the opposite of ' +\n 'what was asked for — validate_form cannot, because both spellings are valid CEL. Needs no ' +\n 'server. Write a scenario for every rule you add: a set of answers, whether the form is ' +\n 'valid, which errors it gives, which fields are visible, and what the submission carries.',\n propose_form_edit:\n 'Hold an edit up against the form that is published, WITHOUT publishing it. Answers with ' +\n 'what the edit would do to submissions already collected, and with a basedOn hash. ' +\n 'Call this instead of publish_form whenever you are changing a form that already exists: ' +\n 'show the changes to the person, get their agreement, then publish_form with the basedOn ' +\n 'it gave you.',\n publish_form:\n 'Publish a form document to a formancy server. Validates locally first and refuses to send ' +\n 'an invalid document, so the reason comes back as something to fix rather than as a 422. ' +\n 'Pass basedOn when changing an existing form: a document is the WHOLE form, so publishing ' +\n 'one based on an older version silently discards whatever was published in between, and ' +\n 'the publish succeeds so nothing reports it.',\n list_forms: 'List the forms on the formancy server, with their current version.',\n get_form: 'Fetch one form document from the formancy server, with its version and schema hash.',\n list_submissions: 'List submissions for one form.',\n} as const\n\n/**\n * The version this server reports.\n *\n * From the manifest, not typed. It said `0.1.0` while the package was on 0.3.0 —\n * a wrong statement in the one field a client uses to tell two installations\n * apart, and exactly the hand-written number this repository keeps finding\n * stale.\n *\n * `createRequire` rather than a JSON import: `../package.json` resolves to the\n * package root from both `src/server.ts` and `dist/index.mjs`, so the same line\n * is right in a test and in the published package.\n */\nexport const PACKAGE_VERSION: string = (\n createRequire(import.meta.url)('../package.json') as { version: string }\n).version\n\n/**\n * What a client is told a result looks like, before it reads one.\n *\n * One envelope for every tool rather than a schema each. The shape is the same\n * everywhere — did it work, one sentence a person can read, and the part an\n * agent acts on — so a client that has learned it once has learned it for all\n * nine. A schema per tool would be nine places for `data` to drift from what\n * the tool returns.\n */\nconst RESULT_SHAPE = {\n ok: z.boolean().describe('Whether the tool did what was asked.'),\n summary: z.string().describe('One or two sentences a person can read.'),\n data: z.unknown().optional().describe('The part an agent acts on, when there is one.'),\n}\n\n/**\n * Both readings of the answer: prose and structure.\n *\n * `content` is what a model reads; `structuredContent` is what a client parses\n * without pulling JSON back out of a string. Both travel — prose alone throws\n * away the part an agent acts on, and structure alone makes every refusal a\n * parsing exercise.\n */\nfunction respond(result: ToolResult): {\n content: { type: 'text'; text: string }[]\n structuredContent: Record<string, unknown>\n isError?: boolean\n} {\n const text =\n result.data === undefined\n ? result.summary\n : `${result.summary}\\n\\n${JSON.stringify(result.data, null, 2)}`\n return {\n content: [{ type: 'text', text }],\n structuredContent: {\n ok: result.ok,\n summary: result.summary,\n ...(result.data === undefined ? {} : { data: result.data }),\n },\n // `isError` rather than a thrown exception: a refusal is an answer the model\n // should read and act on, not a transport failure.\n ...(result.ok ? {} : { isError: true }),\n }\n}\n\n/**\n * What a tool will do, for a client deciding whether to ask first.\n *\n * The only thing a host has to tell `validate_form` from `publish_form` before\n * calling either. Without them a tool defaults to `readOnlyHint: false`,\n * `destructiveHint: true`, `openWorldHint: true` — so this server’s local,\n * read-only checks were all advertised as potentially destructive calls into an\n * open world, which is the opposite of true in every case.\n */\nconst LOCAL_CHECK: ToolAnnotations = {\n readOnlyHint: true,\n // No network and no server: these are the ones that work before anything is\n // deployed, and saying so is what lets a host run them without asking.\n openWorldHint: false,\n}\n\nconst SERVER_READ: ToolAnnotations = { readOnlyHint: true, openWorldHint: true }\n\n/**\n * Publishing. `destructiveHint: false` is not optimism: a published version is\n * immutable and a publish inserts a new row, so nothing is overwritten\n * ([0025](../../../docs/decisions/0025-immutability-in-the-database.md)).\n *\n * Not idempotent, though — publishing the same document twice makes a second\n * version rather than the same one, and saying otherwise would invite a client\n * to retry it freely.\n */\nconst SERVER_WRITE: ToolAnnotations = {\n readOnlyHint: false,\n destructiveHint: false,\n idempotentHint: false,\n openWorldHint: true,\n}\n\n/** A tool that needs a server, when there is not one. */\nfunction noServer(name: string): ToolResult {\n return {\n ok: false,\n summary:\n `${name} needs a formancy server, and none is configured. Set FORMANCY_URL and ` +\n `FORMANCY_API_KEY. The local tools — describe_spec, validate_form and diff_forms — work ` +\n `without one, so a form can be written and checked before a server exists.`,\n }\n}\n\n/**\n * The arguments each tool takes.\n *\n * A document arrives as `unknown` on purpose: it is checked by\n * `validateSchema` against the spec's own JSON Schema, which is the one\n * authority on what a formancy document is. Describing its shape a second\n * time here in Zod would be a second authority, and the day the two disagree\n * the tool would reject a document the product accepts.\n */\nconst DOCUMENT = z.unknown().describe('A formancy form document (the JSON schema object).')\nconst FORM_PATH = z.string().min(1).describe(\"The form's path, as it appears in its URL.\")\n/*\n * The scenarios arrive loosely typed for the reason a document does: their\n * shape belongs to `@formancy/core`, and describing it a second time in Zod\n * would be a second authority that eventually disagrees with the first.\n */\nconst SCENARIOS = z\n .array(z.unknown())\n .describe(\n 'Examples with their answers: each is { name, changes, valid } and may also pin errors, ' +\n 'visible, values and absent.',\n )\nconst BASED_ON = z\n .string()\n .optional()\n .describe(\n 'The schema hash this edit was based on, from propose_form_edit or get_form. Given, the ' +\n 'publish is refused if the form has changed since.',\n )\n\nexport function createFormancyMcpServer(options: McpServerOptions = {}): McpServer {\n const server = new McpServer({ name: 'formancy', version: PACKAGE_VERSION })\n const { access } = options\n\n server.registerTool('describe_spec', { description: TOOL_DEFINITIONS.describe_spec, outputSchema: RESULT_SHAPE, annotations: LOCAL_CHECK }, () =>\n respond(describeSpec()),\n )\n\n server.registerTool(\n 'validate_form',\n { description: TOOL_DEFINITIONS.validate_form,\n outputSchema: RESULT_SHAPE,\n annotations: LOCAL_CHECK, inputSchema: { document: DOCUMENT } },\n ({ document }) => respond(validateForm(document)),\n )\n\n server.registerTool(\n 'diff_forms',\n {\n description: TOOL_DEFINITIONS.diff_forms,\n outputSchema: RESULT_SHAPE,\n annotations: LOCAL_CHECK,\n inputSchema: { before: DOCUMENT, after: DOCUMENT },\n },\n ({ before, after }) => respond(diffForms(before, after)),\n )\n\n server.registerTool(\n 'check_scenarios',\n {\n description: TOOL_DEFINITIONS.check_scenarios,\n outputSchema: RESULT_SHAPE,\n annotations: LOCAL_CHECK,\n inputSchema: { document: DOCUMENT, scenarios: SCENARIOS },\n },\n ({ document, scenarios }) => respond(checkScenarios(document, scenarios as never)),\n )\n\n server.registerTool(\n 'propose_form_edit',\n {\n description: TOOL_DEFINITIONS.propose_form_edit,\n outputSchema: RESULT_SHAPE,\n annotations: SERVER_READ,\n inputSchema: { path: FORM_PATH, document: DOCUMENT },\n },\n async ({ path, document }) =>\n respond(\n access === undefined\n ? noServer('propose_form_edit')\n : await proposeFormEdit(access, path, document),\n ),\n )\n\n server.registerTool(\n 'publish_form',\n {\n description: TOOL_DEFINITIONS.publish_form,\n outputSchema: RESULT_SHAPE,\n annotations: SERVER_WRITE,\n inputSchema: { path: FORM_PATH, document: DOCUMENT, basedOn: BASED_ON },\n },\n async ({ path, document, basedOn }) =>\n respond(\n access === undefined\n ? noServer('publish_form')\n : await publishForm(access, path, document, basedOn),\n ),\n )\n\n server.registerTool('list_forms', { description: TOOL_DEFINITIONS.list_forms, outputSchema: RESULT_SHAPE, annotations: SERVER_READ }, async () =>\n respond(access === undefined ? noServer('list_forms') : await listForms(access)),\n )\n\n server.registerTool(\n 'get_form',\n { description: TOOL_DEFINITIONS.get_form,\n outputSchema: RESULT_SHAPE,\n annotations: SERVER_READ, inputSchema: { path: FORM_PATH } },\n async ({ path }) =>\n respond(access === undefined ? noServer('get_form') : await getForm(access, path)),\n )\n\n server.registerTool(\n 'list_submissions',\n { description: TOOL_DEFINITIONS.list_submissions,\n outputSchema: RESULT_SHAPE,\n annotations: SERVER_READ, inputSchema: { path: FORM_PATH } },\n async ({ path }) =>\n respond(\n access === undefined ? noServer('list_submissions') : await listSubmissions(access, path),\n ),\n )\n\n /*\n * The three things somebody asks an agent to do with a form.\n *\n * A tool says what a model CAN call; a prompt says what to do with the set\n * of them, in order. That order is most of the value: a model that writes\n * the document first and calls `describe_spec` afterwards has already\n * invented `type: \"email\"`, and the correction costs a turn. Scenarios\n * written after the rules they check tend to agree with whatever the rules\n * happen to say.\n *\n * MCP's own answer to a skill pack, so it arrives with the server rather\n * than being documentation somebody has to find and paste\n * ([0112](../../../docs/decisions/0112-the-mcp-server-says-what-its-tools-do.md)).\n */\n server.registerPrompt(\n 'build_a_form',\n {\n title: 'Build a form',\n description:\n 'Write a new formancy form from a description, in the order that gets it right ' +\n 'the first time: the spec, then the document, then examples that check the rules.',\n argsSchema: {\n description: z.string().min(1).describe('What the form is for, in a sentence or two.'),\n },\n },\n ({ description }) => ({\n messages: [\n {\n role: 'user' as const,\n content: {\n type: 'text' as const,\n text: [\n `Write a formancy form for: ${description}`,\n '',\n 'Work in this order, and do not skip the first step:',\n '',\n '1. Call `describe_spec`. It lists every field type, widget, rule kind and',\n ' format the document format has. A type that is not in that list does not',\n ' exist, whatever other form builders call it — an email field is `text`',\n ' with `format: \"email\"`.',\n '2. Write the document.',\n '3. Call `validate_form`. It runs the schema check, the engine compile, and',\n ' the check for expressions that compile and can never evaluate. Fix what',\n ' it names and call it again.',\n '4. Write a scenario for every rule, and call `check_scenarios`. This is the',\n ' only check that catches a condition written backwards: `a == b` and',\n ' `a != b` are both valid CEL and both pass step 3. Write the scenarios',\n ' from the description above rather than from the rules you just wrote, or',\n ' they will agree with whatever the rules happen to say.',\n '5. Show the person the form and what the scenarios checked, before',\n ' publishing anything.',\n ].join('\\n'),\n },\n },\n ],\n }),\n )\n\n server.registerPrompt(\n 'change_a_form',\n {\n title: 'Change a published form',\n description:\n 'Edit a form that already exists, without discarding somebody else’s work and ' +\n 'without publishing a change nobody has read.',\n argsSchema: {\n path: z.string().min(1).describe(\"The form's path, as it appears in its URL.\"),\n change: z.string().min(1).describe('What should be different, in a sentence.'),\n },\n },\n ({ path, change }) => ({\n messages: [\n {\n role: 'user' as const,\n content: {\n type: 'text' as const,\n text: [\n `Change the form at \"${path}\": ${change}`,\n '',\n 'A formancy document is the WHOLE form, so an edit based on an older version',\n 'silently discards whatever was published in between — and the publish',\n 'succeeds, so nothing reports it. Work this way:',\n '',\n '1. Call `get_form` for the current document.',\n '2. Make the change to it.',\n '3. Call `propose_form_edit`. It publishes nothing, and answers with what the',\n ' edit would cost submissions already collected, plus a `basedOn` hash.',\n '4. Show the person that list. A change marked lossy means stored answers lose',\n ' the field they live in; say so plainly before going further.',\n '5. Call `publish_form` with `basedOn` set to the hash from step 3. If it',\n ' refuses, the form moved — start again from step 1 rather than publishing',\n ' over it.',\n ].join('\\n'),\n },\n },\n ],\n }),\n )\n\n server.registerPrompt(\n 'embed_a_form',\n {\n title: 'Embed a form in an application',\n description: 'Render a formancy document in a React or Angular application.',\n argsSchema: {\n framework: z.enum(['react', 'angular']).describe('Which renderer the application uses.'),\n },\n },\n ({ framework }) => ({\n messages: [\n {\n role: 'user' as const,\n content: {\n type: 'text' as const,\n // One framework's instructions, never both: a prompt that lists\n // the alternative makes the model choose again, having just been\n // told which one this application uses.\n text:\n framework === 'angular'\n ? [\n 'Render a formancy form in an Angular application.',\n '',\n '- Install `@formancy/angular` and `@formancy/core`.',\n '- Build the engine once with `createFormEngine({ schema })` and provide',\n ' it with `provideFormancy(engine)`.',\n '- Render `<formancy-form />`. It is zoneless and OnPush, and the engine',\n ' is the state — there is nothing to copy into a component.',\n '- Styling is yours: the renderer ships no CSS. A theme from',\n ' `@formancy/themes` is one stylesheet import and a',\n ' `data-formancy-theme` attribute, or write your own against the',\n ' `data-formancy-part` hooks.',\n '- The submitted value is `engine.value()`. Validate it on the server',\n ' too: the same engine runs there, so the two cannot disagree.',\n ].join('\\n')\n : [\n 'Render a formancy form in a React application.',\n '',\n '- Install `@formancy/react` and `@formancy/core`.',\n '- Build the engine once with `createFormEngine({ schema })` and put it',\n ' in `<FormancyProvider engine={engine}>`.',\n '- Render `<FormancyForm />`. The engine is the state; there is nothing',\n ' to copy into component state and nothing to memoise.',\n '- Styling is yours: the renderer ships no CSS. A theme from',\n ' `@formancy/themes` is one stylesheet import and a',\n ' `data-formancy-theme` attribute, or write your own against the',\n ' `data-formancy-part` hooks.',\n '- The submitted value is `engine.value()`. Validate it on the server',\n ' too: the same engine runs there, so the two cannot disagree.',\n ].join('\\n'),\n },\n },\n ],\n }),\n )\n\n return server\n}\n"],"mappings":"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,CACR;EACE,MAAM;EACN,SAAS;GACP,MAAM;GACN,MAAM;IACJ,8BAA8B;IAC9B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;GACF,CAAC,CAAC,KAAK,IAAI;EACb;CACF,CACF,EACF,EACF;CAEA,OAAO,eACL,iBACA;EACE,OAAO;EACP,aACE;EAEF,YAAY;GACV,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,4CAA4C;GAC7E,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,0CAA0C;EAC/E;CACF,IACC,EAAE,MAAM,cAAc,EACrB,UAAU,CACR;EACE,MAAM;EACN,SAAS;GACP,MAAM;GACN,MAAM;IACJ,uBAAuB,KAAK,KAAK;IACjC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;GACF,CAAC,CAAC,KAAK,IAAI;EACb;CACF,CACF,EACF,EACF;CAEA,OAAO,eACL,gBACA;EACE,OAAO;EACP,aAAa;EACb,YAAY,EACV,WAAW,EAAE,KAAK,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,SAAS,sCAAsC,EACzF;CACF,IACC,EAAE,iBAAiB,EAClB,UAAU,CACR;EACE,MAAM;EACN,SAAS;GACP,MAAM;GAIN,MACE,cAAc,YACV;IACE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;GACF,CAAC,CAAC,KAAK,IAAI,IACX;IACE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;GACF,CAAC,CAAC,KAAK,IAAI;EACnB;CACF,CACF,EACF,EACF;CAEA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formancy/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "formancy as tools for a coding agent: author a form, have it checked before it exists, and publish it.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,8 +20,8 @@
20
20
  "NOTICE"
21
21
  ],
22
22
  "dependencies": {
23
- "@formancy/core": "0.3.0",
24
- "@formancy/spec": "0.3.0",
23
+ "@formancy/core": "0.4.0",
24
+ "@formancy/spec": "0.4.0",
25
25
  "@modelcontextprotocol/sdk": "^1.30.1",
26
26
  "zod": "^4.6.5"
27
27
  },
@@ -1,280 +0,0 @@
1
- import { authoringFacts, diffSchemas } from "@formancy/spec";
2
- import { validateSchema } from "@formancy/spec/validate";
3
- import { engineRefusal, expressionProblems } from "@formancy/core";
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { z } from "zod";
6
- //#region src/tools.ts
7
- /**
8
- * Everything the spec allows, so a model does not have to guess.
9
- *
10
- * The cheapest tool here and probably the most valuable: without it a model
11
- * invents `type: "phone"` or `type: "email"` from its memory of other form
12
- * builders, gets a validation error, and burns a turn finding out that `text`
13
- * with `format: "email"` is the answer. Told up front, it does not.
14
- */
15
- function describeSpec() {
16
- const facts = authoringFacts();
17
- return {
18
- ok: true,
19
- summary: `formancy speaks spec versions ${facts.specVersions.join(" and ")}; new documents should use "${facts.currentSpecVersion}". A field type not in this list does not exist, whatever other form builders call it — an email field is type "text" with format "email".`,
20
- data: facts
21
- };
22
- }
23
- /**
24
- * Check a document without publishing it — the server's publish checks, in the
25
- * gate's order. Only the analysis of regular-expression `pattern`s stays on
26
- * the server: it needs an analysis engine this package does not carry, and the
27
- * server's refusal names the pattern.
28
- *
29
- * Schema validity, the engine's compile and expression sanity are different
30
- * failures with different fixes, and a later check means nothing in a document
31
- * an earlier one has rejected. So the structure is checked first, then whether
32
- * the engine would open the document at all, then whether its expressions
33
- * would ever do anything.
34
- *
35
- * The middle check was once missing, and this tool told a model that a form
36
- * with a misspelled field name was valid: the validator does not read CEL and
37
- * the expression check skips what the engine refuses, so nothing here asked
38
- * the engine. The server did, and refused it.
39
- */
40
- function validateForm(document) {
41
- const result = validateSchema(document);
42
- if (!result.valid) return {
43
- ok: false,
44
- summary: `Not a valid formancy document: ${String(result.errors.length)} problem(s). Fix these before publishing.`,
45
- data: {
46
- valid: false,
47
- errors: result.errors
48
- }
49
- };
50
- const refusal = engineRefusal(document);
51
- if (refusal !== void 0) return {
52
- ok: false,
53
- summary: "The document is structurally valid, but the engine refuses to open it — the same engine the server's publish gate and every renderer build, so this form could be neither published nor shown. " + refusal,
54
- data: {
55
- valid: true,
56
- engineRefusal: refusal
57
- }
58
- };
59
- const problems = expressionProblems(document);
60
- if (problems.length > 0) return {
61
- ok: false,
62
- summary: `The document is structurally valid, but ${String(problems.length)} expression(s) will never do anything. These compile and then fail at evaluation, so nothing would report them at runtime: a computed field would stay empty and a visible rule would show the field it was meant to hide.`,
63
- data: {
64
- valid: true,
65
- expressionProblems: problems
66
- }
67
- };
68
- return {
69
- ok: true,
70
- summary: "Valid, and every expression type-checks.",
71
- data: { valid: true }
72
- };
73
- }
74
- /**
75
- * What a change does to the submissions already collected.
76
- *
77
- * The question an agent cannot answer by reading the two documents, and the
78
- * one whose wrong answer is permanent. `breaking` is not advice.
79
- */
80
- function diffForms(before, after) {
81
- const first = validateSchema(before);
82
- const second = validateSchema(after);
83
- if (!first.valid || !second.valid) return {
84
- ok: false,
85
- summary: "Both documents have to be valid before they can be compared. Run validate_form on each.",
86
- data: {
87
- beforeValid: first.valid,
88
- afterValid: second.valid
89
- }
90
- };
91
- const changes = diffSchemas(before, after);
92
- const worst = severityOf(changes);
93
- return {
94
- ok: true,
95
- summary: changes.length === 0 ? "No change: the two documents are identical once canonicalised." : `${String(changes.length)} change(s), worst severity ${worst}. ` + ADVICE[worst],
96
- data: {
97
- severity: worst,
98
- changes
99
- }
100
- };
101
- }
102
- const ADVICE = {
103
- none: "",
104
- compatible: "Existing drafts and submissions rebind silently.",
105
- lossy: "Existing data rebinds, but some answers no longer have a field to live in. They are kept under data.__orphaned rather than deleted.",
106
- breaking: "Existing drafts CANNOT rebind and will open read-only against the version that produced them. Say so before publishing, and consider a new form instead."
107
- };
108
- function severityOf(changes) {
109
- if (changes.some((change) => change.severity === "breaking")) return "breaking";
110
- if (changes.some((change) => change.severity === "lossy")) return "lossy";
111
- if (changes.length > 0) return "compatible";
112
- return "none";
113
- }
114
- /**
115
- * Publish, but check first.
116
- *
117
- * The local validation is not an optimisation. A model that gets a 422 back
118
- * has to guess what the server meant from an error shape it has never seen;
119
- * a model that gets `expressionProblems` back is told which rule, in which
120
- * words, and what to write instead. And an invalid document never travels.
121
- */
122
- async function publishForm(access, path, document) {
123
- const checked = validateForm(document);
124
- if (!checked.ok) return {
125
- ok: false,
126
- summary: `Not published, because the document would not have worked. ${checked.summary}`,
127
- data: checked.data
128
- };
129
- return request(access, "/forms", {
130
- method: "POST",
131
- body: JSON.stringify({
132
- path,
133
- schema: document
134
- })
135
- });
136
- }
137
- async function listForms(access) {
138
- return request(access, "/forms");
139
- }
140
- async function getForm(access, path) {
141
- return request(access, `/f/${encodeURIComponent(path)}`);
142
- }
143
- async function listSubmissions(access, path) {
144
- return request(access, `/f/${encodeURIComponent(path)}/submissions`);
145
- }
146
- /**
147
- * One request, and one place that knows how a failure is reported.
148
- *
149
- * Every error comes back as `ok: false` with the server's own words rather
150
- * than as a thrown exception, because a thrown exception reaches a model as a
151
- * stack trace and tells it nothing it can act on.
152
- */
153
- async function request(access, route, init = {}) {
154
- const send = access.fetch ?? globalThis.fetch;
155
- const url = `${access.baseUrl.replace(/\/$/, "")}${route}`;
156
- let response;
157
- try {
158
- response = await send(url, {
159
- ...init,
160
- headers: {
161
- ...init.body === void 0 ? {} : { "content-type": "application/json" },
162
- authorization: `Bearer ${access.apiKey}`,
163
- ...init.headers
164
- }
165
- });
166
- } catch (error) {
167
- return {
168
- ok: false,
169
- summary: `Could not reach the formancy server at ${access.baseUrl}: ${error instanceof Error ? error.message : String(error)}. Check FORMANCY_URL, and that the server is running.`
170
- };
171
- }
172
- const body = await response.json().catch(() => void 0);
173
- if (!response.ok) {
174
- const detail = messageFrom(body);
175
- return {
176
- ok: false,
177
- summary: response.status === 401 || response.status === 403 ? `The server refused the request (${String(response.status)}). Check FORMANCY_API_KEY.` : `The server refused the request (${String(response.status)})${detail === void 0 ? "." : `: ${detail}`}`,
178
- ...body === void 0 ? {} : { data: body }
179
- };
180
- }
181
- return {
182
- ok: true,
183
- summary: "Done.",
184
- data: body
185
- };
186
- }
187
- function messageFrom(body) {
188
- if (body === null || typeof body !== "object") return void 0;
189
- const record = body;
190
- if (typeof record.message === "string") return record.message;
191
- if (typeof record.error === "string") return record.error;
192
- }
193
- //#endregion
194
- //#region src/server.ts
195
- /**
196
- * Names and descriptions, apart from the wiring.
197
- *
198
- * Exported because the description is the prompt: it is the only thing telling
199
- * a model when to reach for a tool, and a wrong one means a tool that is never
200
- * called or called for the wrong reason. Kept where it can be read and tested
201
- * rather than buried in a registration call.
202
- */
203
- const TOOL_DEFINITIONS = {
204
- describe_spec: "List every field type, layout kind, rule kind and format the formancy spec defines. Call this BEFORE writing a form document. A type not in the list does not exist, whatever other form builders call it.",
205
- validate_form: "Run the server's publish checks without publishing: against the spec, through the engine that renders the form (a misspelled field name, a cycle between computed fields, a condition that is not certain to produce a bool), and for expressions that compile but can never evaluate — the failure that is silent at runtime. Each comes back separately, with what to change.",
206
- diff_forms: "Compare two form documents and report what the change would do to submissions already collected: compatible, lossy, or breaking. Call this before publishing over an existing form.",
207
- publish_form: "Publish a form document to a formancy server. Validates locally first and refuses to send an invalid document, so the reason comes back as something to fix rather than as a 422.",
208
- list_forms: "List the forms on the formancy server, with their current version.",
209
- get_form: "Fetch one form document from the formancy server, with its version and schema hash.",
210
- list_submissions: "List submissions for one form."
211
- };
212
- /** MCP wants string content; a tool that answered with prose alone would throw
213
- * away the part an agent can act on, so both travel. */
214
- function respond(result) {
215
- return {
216
- content: [{
217
- type: "text",
218
- text: result.data === void 0 ? result.summary : `${result.summary}\n\n${JSON.stringify(result.data, null, 2)}`
219
- }],
220
- ...result.ok ? {} : { isError: true }
221
- };
222
- }
223
- /** A tool that needs a server, when there is not one. */
224
- function noServer(name) {
225
- return {
226
- ok: false,
227
- summary: `${name} needs a formancy server, and none is configured. Set FORMANCY_URL and FORMANCY_API_KEY. The local tools — describe_spec, validate_form and diff_forms — work without one, so a form can be written and checked before a server exists.`
228
- };
229
- }
230
- /**
231
- * The arguments each tool takes.
232
- *
233
- * A document arrives as `unknown` on purpose: it is checked by
234
- * `validateSchema` against the spec's own JSON Schema, which is the one
235
- * authority on what a formancy document is. Describing its shape a second
236
- * time here in Zod would be a second authority, and the day the two disagree
237
- * the tool would reject a document the product accepts.
238
- */
239
- const DOCUMENT = z.unknown().describe("A formancy form document (the JSON schema object).");
240
- const FORM_PATH = z.string().min(1).describe("The form's path, as it appears in its URL.");
241
- function createFormancyMcpServer(options = {}) {
242
- const server = new McpServer({
243
- name: "formancy",
244
- version: "0.1.0"
245
- });
246
- const { access } = options;
247
- server.registerTool("describe_spec", { description: TOOL_DEFINITIONS.describe_spec }, () => respond(describeSpec()));
248
- server.registerTool("validate_form", {
249
- description: TOOL_DEFINITIONS.validate_form,
250
- inputSchema: { document: DOCUMENT }
251
- }, ({ document }) => respond(validateForm(document)));
252
- server.registerTool("diff_forms", {
253
- description: TOOL_DEFINITIONS.diff_forms,
254
- inputSchema: {
255
- before: DOCUMENT,
256
- after: DOCUMENT
257
- }
258
- }, ({ before, after }) => respond(diffForms(before, after)));
259
- server.registerTool("publish_form", {
260
- description: TOOL_DEFINITIONS.publish_form,
261
- inputSchema: {
262
- path: FORM_PATH,
263
- document: DOCUMENT
264
- }
265
- }, async ({ path, document }) => respond(access === void 0 ? noServer("publish_form") : await publishForm(access, path, document)));
266
- server.registerTool("list_forms", { description: TOOL_DEFINITIONS.list_forms }, async () => respond(access === void 0 ? noServer("list_forms") : await listForms(access)));
267
- server.registerTool("get_form", {
268
- description: TOOL_DEFINITIONS.get_form,
269
- inputSchema: { path: FORM_PATH }
270
- }, async ({ path }) => respond(access === void 0 ? noServer("get_form") : await getForm(access, path)));
271
- server.registerTool("list_submissions", {
272
- description: TOOL_DEFINITIONS.list_submissions,
273
- inputSchema: { path: FORM_PATH }
274
- }, async ({ path }) => respond(access === void 0 ? noServer("list_submissions") : await listSubmissions(access, path)));
275
- return server;
276
- }
277
- //#endregion
278
- export { getForm as a, publishForm as c, diffForms as i, validateForm as l, createFormancyMcpServer as n, listForms as o, describeSpec as r, listSubmissions as s, TOOL_DEFINITIONS as t };
279
-
280
- //# sourceMappingURL=server-CdcuxHHN.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"server-CdcuxHHN.mjs","names":[],"sources":["../src/tools.ts","../src/server.ts"],"sourcesContent":["import { authoringFacts, diffSchemas } from '@formancy/spec'\nimport { validateSchema } from '@formancy/spec/validate'\nimport type { Change, FormSchema } from '@formancy/spec'\nimport { engineRefusal, expressionProblems } from '@formancy/core'\n\n/**\n * What formancy can be asked to do by a coding agent, as plain functions.\n *\n * Separated from the MCP server itself for the same reason `server-core` is separated\n * from `server`: a use-case that can only be exercised through a transport is one nobody\n * can test. Everything here is a function from JSON to JSON.\n *\n * **The tools check first and act second.** `publish_form` validates locally before it\n * opens a socket, so an invalid document never reaches the server and the model gets the\n * reason rather than a 422. That is possible because the format has a published JSON\n * Schema and the expressions are statically type-checked — a generated document is\n * checkable before anything is done with it.\n */\n\n/** A tool's answer: what happened, and what the agent should do about it. */\nexport interface ToolResult {\n /** False means the agent must fix something before trying again. */\n readonly ok: boolean\n /** Prose for the model. Written to be acted on, not logged. */\n readonly summary: string\n /** The machine-readable part, when there is one. */\n readonly data?: unknown\n}\n\n/** Where a formancy server is, and how to prove who you are to it. */\nexport interface ServerAccess {\n readonly baseUrl: string\n readonly apiKey: string\n /** Injected so tests do not need a socket. */\n readonly fetch?: typeof globalThis.fetch\n}\n\n// ---------------------------------------------------------------- local tools\n\n/**\n * Everything the spec allows, so a model does not have to guess.\n *\n * The cheapest tool here and probably the most valuable: without it a model\n * invents `type: \"phone\"` or `type: \"email\"` from its memory of other form\n * builders, gets a validation error, and burns a turn finding out that `text`\n * with `format: \"email\"` is the answer. Told up front, it does not.\n */\nexport function describeSpec(): ToolResult {\n const facts = authoringFacts()\n return {\n ok: true,\n summary:\n `formancy speaks spec versions ${facts.specVersions.join(' and ')}; new documents should ` +\n `use \"${facts.currentSpecVersion}\". A field type not in this list does not exist, whatever ` +\n `other form builders call it — an email field is type \"text\" with format \"email\".`,\n data: facts,\n }\n}\n\n/**\n * Check a document without publishing it — the server's publish checks, in the\n * gate's order. Only the analysis of regular-expression `pattern`s stays on\n * the server: it needs an analysis engine this package does not carry, and the\n * server's refusal names the pattern.\n *\n * Schema validity, the engine's compile and expression sanity are different\n * failures with different fixes, and a later check means nothing in a document\n * an earlier one has rejected. So the structure is checked first, then whether\n * the engine would open the document at all, then whether its expressions\n * would ever do anything.\n *\n * The middle check was once missing, and this tool told a model that a form\n * with a misspelled field name was valid: the validator does not read CEL and\n * the expression check skips what the engine refuses, so nothing here asked\n * the engine. The server did, and refused it.\n */\nexport function validateForm(document: unknown): ToolResult {\n const result = validateSchema(document)\n if (!result.valid) {\n return {\n ok: false,\n summary: `Not a valid formancy document: ${String(result.errors.length)} problem(s). Fix these before publishing.`,\n data: { valid: false, errors: result.errors },\n }\n }\n\n const refusal = engineRefusal(document as FormSchema)\n if (refusal !== undefined) {\n return {\n ok: false,\n summary:\n `The document is structurally valid, but the engine refuses to open it — the same engine the ` +\n `server's publish gate and every renderer build, so this form could be neither published nor shown. ` +\n refusal,\n data: { valid: true, engineRefusal: refusal },\n }\n }\n\n const problems = expressionProblems(document as FormSchema)\n if (problems.length > 0) {\n return {\n ok: false,\n summary:\n `The document is structurally valid, but ${String(problems.length)} expression(s) will never ` +\n `do anything. These compile and then fail at evaluation, so nothing would report them at runtime: ` +\n `a computed field would stay empty and a visible rule would show the field it was meant to hide.`,\n data: { valid: true, expressionProblems: problems },\n }\n }\n\n return {\n ok: true,\n summary: 'Valid, and every expression type-checks.',\n data: { valid: true },\n }\n}\n\n/**\n * What a change does to the submissions already collected.\n *\n * The question an agent cannot answer by reading the two documents, and the\n * one whose wrong answer is permanent. `breaking` is not advice.\n */\nexport function diffForms(before: unknown, after: unknown): ToolResult {\n const first = validateSchema(before)\n const second = validateSchema(after)\n if (!first.valid || !second.valid) {\n return {\n ok: false,\n summary: 'Both documents have to be valid before they can be compared. Run validate_form on each.',\n data: { beforeValid: first.valid, afterValid: second.valid },\n }\n }\n\n const changes = diffSchemas(before as FormSchema, after as FormSchema)\n const worst = severityOf(changes)\n\n return {\n ok: true,\n summary:\n changes.length === 0\n ? 'No change: the two documents are identical once canonicalised.'\n : `${String(changes.length)} change(s), worst severity ${worst}. ` + ADVICE[worst],\n data: { severity: worst, changes },\n }\n}\n\nconst ADVICE: Readonly<Record<'none' | 'compatible' | 'lossy' | 'breaking', string>> = {\n none: '',\n compatible: 'Existing drafts and submissions rebind silently.',\n lossy: 'Existing data rebinds, but some answers no longer have a field to live in. They are kept under data.__orphaned rather than deleted.',\n breaking:\n 'Existing drafts CANNOT rebind and will open read-only against the version that produced them. Say so before publishing, and consider a new form instead.',\n}\n\nfunction severityOf(changes: readonly Change[]): 'none' | 'compatible' | 'lossy' | 'breaking' {\n if (changes.some((change) => change.severity === 'breaking')) return 'breaking'\n if (changes.some((change) => change.severity === 'lossy')) return 'lossy'\n if (changes.length > 0) return 'compatible'\n return 'none'\n}\n\n// --------------------------------------------------------------- server tools\n\n/**\n * Publish, but check first.\n *\n * The local validation is not an optimisation. A model that gets a 422 back\n * has to guess what the server meant from an error shape it has never seen;\n * a model that gets `expressionProblems` back is told which rule, in which\n * words, and what to write instead. And an invalid document never travels.\n */\nexport async function publishForm(\n access: ServerAccess,\n path: string,\n document: unknown,\n): Promise<ToolResult> {\n const checked = validateForm(document)\n if (!checked.ok) {\n return {\n ok: false,\n summary: `Not published, because the document would not have worked. ${checked.summary}`,\n data: checked.data,\n }\n }\n\n return request(access, '/forms', {\n method: 'POST',\n body: JSON.stringify({ path, schema: document }),\n })\n}\n\nexport async function listForms(access: ServerAccess): Promise<ToolResult> {\n return request(access, '/forms')\n}\n\nexport async function getForm(access: ServerAccess, path: string): Promise<ToolResult> {\n return request(access, `/f/${encodeURIComponent(path)}`)\n}\n\nexport async function listSubmissions(access: ServerAccess, path: string): Promise<ToolResult> {\n return request(access, `/f/${encodeURIComponent(path)}/submissions`)\n}\n\n/**\n * One request, and one place that knows how a failure is reported.\n *\n * Every error comes back as `ok: false` with the server's own words rather\n * than as a thrown exception, because a thrown exception reaches a model as a\n * stack trace and tells it nothing it can act on.\n */\nasync function request(\n access: ServerAccess,\n route: string,\n init: RequestInit = {},\n): Promise<ToolResult> {\n const send = access.fetch ?? globalThis.fetch\n const url = `${access.baseUrl.replace(/\\/$/, '')}${route}`\n\n let response: Response\n try {\n response = await send(url, {\n ...init,\n headers: {\n ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),\n authorization: `Bearer ${access.apiKey}`,\n ...init.headers,\n },\n })\n } catch (error) {\n return {\n ok: false,\n summary:\n `Could not reach the formancy server at ${access.baseUrl}: ${error instanceof Error ? error.message : String(error)}. ` +\n `Check FORMANCY_URL, and that the server is running.`,\n }\n }\n\n const body: unknown = await response.json().catch(() => undefined)\n\n if (!response.ok) {\n const detail = messageFrom(body)\n return {\n ok: false,\n summary:\n response.status === 401 || response.status === 403\n ? `The server refused the request (${String(response.status)}). Check FORMANCY_API_KEY.`\n : `The server refused the request (${String(response.status)})${detail === undefined ? '.' : `: ${detail}`}`,\n ...(body === undefined ? {} : { data: body }),\n }\n }\n\n return { ok: true, summary: 'Done.', data: body }\n}\n\nfunction messageFrom(body: unknown): string | undefined {\n if (body === null || typeof body !== 'object') return undefined\n const record = body as { message?: unknown; error?: unknown }\n if (typeof record.message === 'string') return record.message\n if (typeof record.error === 'string') return record.error\n return undefined\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { z } from 'zod'\nimport {\n describeSpec,\n diffForms,\n getForm,\n listForms,\n listSubmissions,\n publishForm,\n validateForm,\n} from './tools.js'\nimport type { ServerAccess, ToolResult } from './tools.js'\n\n/**\n * The tools, wired to the protocol.\n *\n * Thin on purpose: every one of these is a call into `tools.ts` and a\n * conversion of the answer into MCP's content shape. The logic is tested\n * without a protocol, because a use-case that can only be exercised through a\n * transport is a use-case nobody tests properly.\n */\n\nexport interface McpServerOptions {\n /**\n * Where a formancy server is, if there is one.\n *\n * Absent is a supported state and not a degraded one: the four local tools\n * — describe_spec, validate_form, diff_forms and the checking half of\n * publishing — need no server at all, which means an agent can author and\n * verify a whole form before anybody has deployed anything.\n */\n readonly access?: ServerAccess\n}\n\n/**\n * Names and descriptions, apart from the wiring.\n *\n * Exported because the description is the prompt: it is the only thing telling\n * a model when to reach for a tool, and a wrong one means a tool that is never\n * called or called for the wrong reason. Kept where it can be read and tested\n * rather than buried in a registration call.\n */\nexport const TOOL_DEFINITIONS = {\n describe_spec:\n 'List every field type, layout kind, rule kind and format the formancy spec defines. ' +\n 'Call this BEFORE writing a form document. A type not in the list does not exist, whatever ' +\n 'other form builders call it.',\n validate_form:\n \"Run the server's publish checks without publishing: against the spec, through the engine \" +\n 'that renders the form (a misspelled field name, a cycle between computed fields, a ' +\n 'condition that is not certain to produce a bool), and for expressions that compile but ' +\n 'can never evaluate — the failure that is silent at runtime. Each comes back separately, ' +\n 'with what to change.',\n diff_forms:\n 'Compare two form documents and report what the change would do to submissions already ' +\n 'collected: compatible, lossy, or breaking. Call this before publishing over an existing form.',\n publish_form:\n 'Publish a form document to a formancy server. Validates locally first and refuses to send ' +\n 'an invalid document, so the reason comes back as something to fix rather than as a 422.',\n list_forms: 'List the forms on the formancy server, with their current version.',\n get_form: 'Fetch one form document from the formancy server, with its version and schema hash.',\n list_submissions: 'List submissions for one form.',\n} as const\n\n/** MCP wants string content; a tool that answered with prose alone would throw\n * away the part an agent can act on, so both travel. */\nfunction respond(result: ToolResult): {\n content: { type: 'text'; text: string }[]\n isError?: boolean\n} {\n const text =\n result.data === undefined\n ? result.summary\n : `${result.summary}\\n\\n${JSON.stringify(result.data, null, 2)}`\n // `isError` rather than a thrown exception: a refusal is an answer the model\n // should read and act on, not a transport failure.\n return { content: [{ type: 'text', text }], ...(result.ok ? {} : { isError: true }) }\n}\n\n/** A tool that needs a server, when there is not one. */\nfunction noServer(name: string): ToolResult {\n return {\n ok: false,\n summary:\n `${name} needs a formancy server, and none is configured. Set FORMANCY_URL and ` +\n `FORMANCY_API_KEY. The local tools — describe_spec, validate_form and diff_forms — work ` +\n `without one, so a form can be written and checked before a server exists.`,\n }\n}\n\n/**\n * The arguments each tool takes.\n *\n * A document arrives as `unknown` on purpose: it is checked by\n * `validateSchema` against the spec's own JSON Schema, which is the one\n * authority on what a formancy document is. Describing its shape a second\n * time here in Zod would be a second authority, and the day the two disagree\n * the tool would reject a document the product accepts.\n */\nconst DOCUMENT = z.unknown().describe('A formancy form document (the JSON schema object).')\nconst FORM_PATH = z.string().min(1).describe(\"The form's path, as it appears in its URL.\")\n\nexport function createFormancyMcpServer(options: McpServerOptions = {}): McpServer {\n const server = new McpServer({ name: 'formancy', version: '0.1.0' })\n const { access } = options\n\n server.registerTool('describe_spec', { description: TOOL_DEFINITIONS.describe_spec }, () =>\n respond(describeSpec()),\n )\n\n server.registerTool(\n 'validate_form',\n { description: TOOL_DEFINITIONS.validate_form, inputSchema: { document: DOCUMENT } },\n ({ document }) => respond(validateForm(document)),\n )\n\n server.registerTool(\n 'diff_forms',\n {\n description: TOOL_DEFINITIONS.diff_forms,\n inputSchema: { before: DOCUMENT, after: DOCUMENT },\n },\n ({ before, after }) => respond(diffForms(before, after)),\n )\n\n server.registerTool(\n 'publish_form',\n {\n description: TOOL_DEFINITIONS.publish_form,\n inputSchema: { path: FORM_PATH, document: DOCUMENT },\n },\n async ({ path, document }) =>\n respond(\n access === undefined\n ? noServer('publish_form')\n : await publishForm(access, path, document),\n ),\n )\n\n server.registerTool('list_forms', { description: TOOL_DEFINITIONS.list_forms }, async () =>\n respond(access === undefined ? noServer('list_forms') : await listForms(access)),\n )\n\n server.registerTool(\n 'get_form',\n { description: TOOL_DEFINITIONS.get_form, inputSchema: { path: FORM_PATH } },\n async ({ path }) =>\n respond(access === undefined ? noServer('get_form') : await getForm(access, path)),\n )\n\n server.registerTool(\n 'list_submissions',\n { description: TOOL_DEFINITIONS.list_submissions, inputSchema: { path: FORM_PATH } },\n async ({ path }) =>\n respond(\n access === undefined ? noServer('list_submissions') : await listSubmissions(access, path),\n ),\n )\n\n return server\n}\n"],"mappings":"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"}