@formfeed/mcp 0.3.12 → 0.3.14
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/README.md +7 -6
- package/bin/formfeed-mcp.mjs +231 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://glama.ai/mcp/servers/formfeed-dev/formfeed)
|
|
4
4
|
|
|
5
5
|
Model Context Protocol server for [Formfeed](https://formfeed.dev/?utm_source=npm&utm_content=mcp).
|
|
6
|
-
Gives Claude, Cursor and other agents
|
|
6
|
+
Gives Claude, Cursor and other agents seven tools: `list_templates`, `get_template_schema`,
|
|
7
7
|
`validate_template` (free: the data against the template's schema, ad-hoc HTML offline), `render`, `convert_to_pdf` (Word, Excel,
|
|
8
|
-
PowerPoint and OpenDocument to PDF) and `
|
|
8
|
+
PowerPoint and OpenDocument to PDF), `get_render` and `get_workspace` (which workspace the server works in, and the units left).
|
|
9
9
|
|
|
10
10
|
## stdio (Claude Desktop, Cursor, Claude Code)
|
|
11
11
|
|
|
@@ -26,9 +26,10 @@ A test key renders free with a watermark; a live key consumes units. `FORMFEED_R
|
|
|
26
26
|
|
|
27
27
|
## Hosted (Streamable HTTP)
|
|
28
28
|
|
|
29
|
-
`https://mcp.formfeed.dev/mcp
|
|
30
|
-
opens the browser
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
`https://mcp.formfeed.dev/mcp` takes `Authorization: Bearer ff_…`, or signs in with your Formfeed
|
|
30
|
+
account when no key is sent (OAuth; the client opens the browser, and you choose the workspace the
|
|
31
|
+
agent works in). `https://mcp.formfeed.dev/mcp/<workspace id>` pins another workspace for a signed-in
|
|
32
|
+
client. Self-host the same endpoint with `formfeed-mcp --http --port 8790`, plus `--auth-server <issuer>`
|
|
33
|
+
for OAuth sign-in against your own Supabase Auth.
|
|
33
34
|
|
|
34
35
|
Documentation: <https://docs.formfeed.dev/integrations/mcp>
|
package/bin/formfeed-mcp.mjs
CHANGED
|
@@ -21408,7 +21408,9 @@ var require_liquid_node = __commonJS({
|
|
|
21408
21408
|
});
|
|
21409
21409
|
|
|
21410
21410
|
// packages/mcp/src/lib/transports.ts
|
|
21411
|
-
import {
|
|
21411
|
+
import {
|
|
21412
|
+
createServer
|
|
21413
|
+
} from "node:http";
|
|
21412
21414
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21413
21415
|
|
|
21414
21416
|
// packages/mcp/src/lib/server.ts
|
|
@@ -70689,7 +70691,10 @@ var barcodeTypes = {
|
|
|
70689
70691
|
var ownTextLayout = /* @__PURE__ */ new Set(["ean13", "ean8", "upca"]);
|
|
70690
70692
|
function codeText(helper, value) {
|
|
70691
70693
|
const text4 = value === void 0 || value === null ? "" : String(value);
|
|
70692
|
-
if (text4 === "")
|
|
70694
|
+
if (text4 === "")
|
|
70695
|
+
throw new Error(
|
|
70696
|
+
`${helper}: nothing to encode, the value is empty or missing from the data`
|
|
70697
|
+
);
|
|
70693
70698
|
return text4;
|
|
70694
70699
|
}
|
|
70695
70700
|
var codeHelpers = [
|
|
@@ -70698,18 +70703,25 @@ var codeHelpers = [
|
|
|
70698
70703
|
aliases: ["qr"],
|
|
70699
70704
|
doc: {
|
|
70700
70705
|
signature: "qrcode(value, { size = 160, ecc = 'M', margin = 1, color, background })",
|
|
70701
|
-
description: "QR code as an SVG data URI for an <img> src.",
|
|
70706
|
+
description: "QR code as an SVG data URI for an <img> src. Give the image an `alt` that says where the code leads: a screen reader cannot scan it.",
|
|
70702
70707
|
examples: {
|
|
70703
|
-
jinja2: '<img src="{{ qrcode(order.url, { size: 120 }) }}">',
|
|
70704
|
-
liquid: '<img src="{{ order.url | qrcode: size: 120 }}">',
|
|
70705
|
-
handlebars: '<img src="{{qrcode order.url size=120}}">'
|
|
70708
|
+
jinja2: '<img src="{{ qrcode(order.url, { size: 120 }) }}" alt="QR code: {{ order.url }}">',
|
|
70709
|
+
liquid: '<img src="{{ order.url | qrcode: size: 120 }}" alt="QR code: {{ order.url }}">',
|
|
70710
|
+
handlebars: '<img src="{{qrcode order.url size=120}}" alt="QR code: {{order.url}}">'
|
|
70706
70711
|
},
|
|
70707
70712
|
category: "code"
|
|
70708
70713
|
},
|
|
70709
70714
|
fn: (ctx, value, options) => {
|
|
70710
70715
|
const o = options ?? {};
|
|
70711
70716
|
const svg = qrSvg(codeText("qrcode", value), o);
|
|
70712
|
-
if (ctx.drawing)
|
|
70717
|
+
if (ctx.drawing)
|
|
70718
|
+
return ctx.drawing({
|
|
70719
|
+
kind: "svg",
|
|
70720
|
+
svg,
|
|
70721
|
+
width: o.size,
|
|
70722
|
+
height: o.size,
|
|
70723
|
+
alt: "QR code"
|
|
70724
|
+
});
|
|
70713
70725
|
return svgDataUri(svg);
|
|
70714
70726
|
}
|
|
70715
70727
|
},
|
|
@@ -70717,11 +70729,11 @@ var codeHelpers = [
|
|
|
70717
70729
|
name: "barcode",
|
|
70718
70730
|
doc: {
|
|
70719
70731
|
signature: "barcode(value, { type = 'code128', height = 12, width, text = true, scale = 2 })",
|
|
70720
|
-
description: "Barcode (code128, ean13, ean8, upc, itf14, code39, datamatrix, pdf417) as an SVG data URI.",
|
|
70732
|
+
description: "Barcode (code128, ean13, ean8, upc, itf14, code39, datamatrix, pdf417) as an SVG data URI. Give the image an `alt` with the value it encodes.",
|
|
70721
70733
|
examples: {
|
|
70722
|
-
jinja2: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}">`,
|
|
70723
|
-
liquid: `<img src="{{ item.sku | barcode: type: 'code128' }}">`,
|
|
70724
|
-
handlebars: `<img src="{{barcode item.sku type='code128'}}">`
|
|
70734
|
+
jinja2: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}" alt="Barcode {{ item.sku }}">`,
|
|
70735
|
+
liquid: `<img src="{{ item.sku | barcode: type: 'code128' }}" alt="Barcode {{ item.sku }}">`,
|
|
70736
|
+
handlebars: `<img src="{{barcode item.sku type='code128'}}" alt="Barcode {{item.sku}}">`
|
|
70725
70737
|
},
|
|
70726
70738
|
category: "code"
|
|
70727
70739
|
},
|
|
@@ -70738,7 +70750,8 @@ var codeHelpers = [
|
|
|
70738
70750
|
includetext: o.text !== false,
|
|
70739
70751
|
...ownTextLayout.has(bcid) ? {} : { textxalign: "center" }
|
|
70740
70752
|
});
|
|
70741
|
-
if (ctx.drawing)
|
|
70753
|
+
if (ctx.drawing)
|
|
70754
|
+
return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${text4}` });
|
|
70742
70755
|
return svgDataUri(svg);
|
|
70743
70756
|
}
|
|
70744
70757
|
},
|
|
@@ -70747,11 +70760,11 @@ var codeHelpers = [
|
|
|
70747
70760
|
aliases: ["epc_qr", "girocode"],
|
|
70748
70761
|
doc: {
|
|
70749
70762
|
signature: "epcQr({ name, iban, bic?, amount?, reference?, text? }, { size })",
|
|
70750
|
-
description: "SEPA credit transfer QR code (EPC069-12 / GiroCode) that banking apps scan to prefill a payment.",
|
|
70763
|
+
description: "SEPA credit transfer QR code (EPC069-12 / GiroCode) that banking apps scan to prefill a payment. Give the image an `alt`, and print the bank details as text as well: the code is a picture.",
|
|
70751
70764
|
examples: {
|
|
70752
|
-
jinja2: '<img src="{{ epcQr({ name: company.name, iban: company.iban, amount: invoice.total, reference: invoice.number }) }}">',
|
|
70753
|
-
liquid: '<img src="{{ company.iban | epcQr: name: company.name, amount: invoice.total, reference: invoice.number }}">',
|
|
70754
|
-
handlebars: '<img src="{{epcQr name=company.name iban=company.iban amount=invoice.total reference=invoice.number}}">'
|
|
70765
|
+
jinja2: '<img src="{{ epcQr({ name: company.name, iban: company.iban, amount: invoice.total, reference: invoice.number }) }}" alt="QR code to pay invoice {{ invoice.number }}">',
|
|
70766
|
+
liquid: '<img src="{{ company.iban | epcQr: name: company.name, amount: invoice.total, reference: invoice.number }}" alt="QR code to pay invoice {{ invoice.number }}">',
|
|
70767
|
+
handlebars: '<img src="{{epcQr name=company.name iban=company.iban amount=invoice.total reference=invoice.number}}" alt="QR code to pay invoice {{invoice.number}}">'
|
|
70755
70768
|
},
|
|
70756
70769
|
category: "code"
|
|
70757
70770
|
},
|
|
@@ -70770,12 +70783,27 @@ var codeHelpers = [
|
|
|
70770
70783
|
(EPC_FIELDS.has(key) ? payment2 : qr)[key] = value;
|
|
70771
70784
|
const o = { ecc: "M", ...qr };
|
|
70772
70785
|
const svg = qrSvg(epcPayload(payment2), o);
|
|
70773
|
-
if (ctx.drawing)
|
|
70786
|
+
if (ctx.drawing)
|
|
70787
|
+
return ctx.drawing({
|
|
70788
|
+
kind: "svg",
|
|
70789
|
+
svg,
|
|
70790
|
+
width: o.size,
|
|
70791
|
+
height: o.size,
|
|
70792
|
+
alt: "GiroCode"
|
|
70793
|
+
});
|
|
70774
70794
|
return svgDataUri(svg);
|
|
70775
70795
|
}
|
|
70776
70796
|
}
|
|
70777
70797
|
];
|
|
70778
|
-
var EPC_FIELDS = /* @__PURE__ */ new Set([
|
|
70798
|
+
var EPC_FIELDS = /* @__PURE__ */ new Set([
|
|
70799
|
+
"name",
|
|
70800
|
+
"iban",
|
|
70801
|
+
"bic",
|
|
70802
|
+
"amount",
|
|
70803
|
+
"reference",
|
|
70804
|
+
"text",
|
|
70805
|
+
"purpose"
|
|
70806
|
+
]);
|
|
70779
70807
|
|
|
70780
70808
|
// node_modules/.pnpm/@date-fns+tz@1.5.0/node_modules/@date-fns/tz/constants/index.js
|
|
70781
70809
|
var constructFromSymbol = Symbol.for("constructDateFrom");
|
|
@@ -78352,7 +78380,16 @@ var collectionHelpers = [
|
|
|
78352
78380
|
];
|
|
78353
78381
|
|
|
78354
78382
|
// packages/engine/src/lib/helpers/document.ts
|
|
78355
|
-
var CHART_PALETTE = [
|
|
78383
|
+
var CHART_PALETTE = [
|
|
78384
|
+
"#3E63DD",
|
|
78385
|
+
"#12A594",
|
|
78386
|
+
"#E5484D",
|
|
78387
|
+
"#F76B15",
|
|
78388
|
+
"#8E4EC6",
|
|
78389
|
+
"#FFC53D",
|
|
78390
|
+
"#0090FF",
|
|
78391
|
+
"#978365"
|
|
78392
|
+
];
|
|
78356
78393
|
var SLICED = /* @__PURE__ */ new Set(["pie", "doughnut", "polarArea"]);
|
|
78357
78394
|
var colour = (value) => typeof value === "string" && value ? value : Array.isArray(value) && value.length ? value.map(String) : void 0;
|
|
78358
78395
|
function chartDataFromPlain(spec) {
|
|
@@ -78366,8 +78403,13 @@ function chartDataFromPlain(spec) {
|
|
|
78366
78403
|
});
|
|
78367
78404
|
const own = colour(s.color);
|
|
78368
78405
|
const fill = SLICED.has(type) ? own ?? labels.map((_2, i) => CHART_PALETTE[i % CHART_PALETTE.length]) : own ?? CHART_PALETTE[index % CHART_PALETTE.length];
|
|
78369
|
-
const dataset = {
|
|
78370
|
-
|
|
78406
|
+
const dataset = {
|
|
78407
|
+
data,
|
|
78408
|
+
backgroundColor: fill,
|
|
78409
|
+
borderColor: fill
|
|
78410
|
+
};
|
|
78411
|
+
if (s.label !== void 0 && s.label !== null && s.label !== "")
|
|
78412
|
+
dataset["label"] = String(s.label);
|
|
78371
78413
|
if (type === "line") dataset["fill"] = false;
|
|
78372
78414
|
return dataset;
|
|
78373
78415
|
});
|
|
@@ -78384,7 +78426,8 @@ function chartPayload(spec) {
|
|
|
78384
78426
|
const options = { ...spec.options ?? {} };
|
|
78385
78427
|
options["animation"] = false;
|
|
78386
78428
|
options["responsive"] = false;
|
|
78387
|
-
if (!isPlain(spec))
|
|
78429
|
+
if (!isPlain(spec))
|
|
78430
|
+
return { type: spec.type ?? "bar", data: spec.data ?? {}, options };
|
|
78388
78431
|
const data = chartDataFromPlain(spec);
|
|
78389
78432
|
const unnamed = data.datasets.length === 1 && data.datasets[0]?.["label"] === void 0;
|
|
78390
78433
|
const plugins = asObject(options["plugins"]);
|
|
@@ -78397,7 +78440,8 @@ function chartMarkup(spec, id) {
|
|
|
78397
78440
|
const height = toNumber(spec.height ?? 240) || 240;
|
|
78398
78441
|
const payload = escapeHtml(JSON.stringify(chartPayload(spec)));
|
|
78399
78442
|
const idAttr = id ? ` id="${escapeHtml(id)}"` : "";
|
|
78400
|
-
|
|
78443
|
+
const alt = typeof spec.alt === "string" && spec.alt.trim() ? ` role="img" aria-label="${escapeHtml(spec.alt.trim())}"` : "";
|
|
78444
|
+
return `<canvas class="ff-chart"${idAttr}${alt} width="${width}" height="${height}" style="width:${width}px;height:${height}px" data-ff-chart="${payload}"></canvas>`;
|
|
78401
78445
|
}
|
|
78402
78446
|
function imageMarkup(url, options = {}) {
|
|
78403
78447
|
const styles = [
|
|
@@ -78418,18 +78462,21 @@ var documentHelpers = [
|
|
|
78418
78462
|
name: "chart",
|
|
78419
78463
|
html: true,
|
|
78420
78464
|
doc: {
|
|
78421
|
-
signature: "chart({ type, labels, values, label, color } | { type, labels, series } | { type, data, options }, { width, height })",
|
|
78422
|
-
description: "Draws a Chart.js chart (bar, line, pie, doughnut, radar, \u2026) from plain data, `labels` with `values` or several `series` of `{ label, values, color }`, or from a full Chart.js `data` and `options`; animations are disabled so PDF and preview match.",
|
|
78465
|
+
signature: "chart({ type, labels, values, label, color } | { type, labels, series } | { type, data, options }, { width, height, alt })",
|
|
78466
|
+
description: "Draws a Chart.js chart (bar, line, pie, doughnut, radar, \u2026) from plain data, `labels` with `values` or several `series` of `{ label, values, color }`, or from a full Chart.js `data` and `options`; animations are disabled so PDF and preview match. `alt` says in words what the chart shows: a screen reader reads it in place of the picture, and a tagged PDF needs it.",
|
|
78423
78467
|
examples: {
|
|
78424
|
-
jinja2: "{{ chart({ type: 'line', labels: sales.labels, values: sales.quarters, width: 480 }) }}",
|
|
78425
|
-
liquid: "{{ '' | chart: type: 'line', labels: sales.labels, values: sales.quarters, width: 480 }}",
|
|
78426
|
-
handlebars: "{{chart type='line' labels=sales.labels values=sales.quarters width=480}}"
|
|
78468
|
+
jinja2: "{{ chart({ type: 'line', labels: sales.labels, values: sales.quarters, width: 480, alt: 'Sales by quarter' }) }}",
|
|
78469
|
+
liquid: "{{ '' | chart: type: 'line', labels: sales.labels, values: sales.quarters, width: 480, alt: 'Sales by quarter' }}",
|
|
78470
|
+
handlebars: "{{chart type='line' labels=sales.labels values=sales.quarters width=480 alt='Sales by quarter'}}"
|
|
78427
78471
|
},
|
|
78428
78472
|
category: "document"
|
|
78429
78473
|
},
|
|
78430
78474
|
fn: (ctx, spec, extra) => {
|
|
78431
78475
|
if (ctx.mode === "office") throw new Error(officeUnsupported("chart"));
|
|
78432
|
-
return chartMarkup({
|
|
78476
|
+
return chartMarkup({
|
|
78477
|
+
...asObject(spec),
|
|
78478
|
+
...asObject(extra)
|
|
78479
|
+
});
|
|
78433
78480
|
}
|
|
78434
78481
|
},
|
|
78435
78482
|
{
|
|
@@ -78437,18 +78484,24 @@ var documentHelpers = [
|
|
|
78437
78484
|
html: true,
|
|
78438
78485
|
doc: {
|
|
78439
78486
|
signature: "image(url, { width, height, fit, alt })",
|
|
78440
|
-
description: "Places an image with fixed size and crop mode (`cover`, `contain`, `fill`); the render-worker fetches it through its cache and blocks private hosts.",
|
|
78487
|
+
description: "Places an image with fixed size and crop mode (`cover`, `contain`, `fill`); the render-worker fetches it through its cache and blocks private hosts. `alt` says what it shows; without one the image counts as decoration and a screen reader passes over it.",
|
|
78441
78488
|
examples: {
|
|
78442
|
-
jinja2: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover' }) }}",
|
|
78443
|
-
liquid: "{{ product.photo | image: width: 120, height: 80, fit: 'cover' }}",
|
|
78444
|
-
handlebars: "{{image product.photo width=120 height=80 fit='cover'}}"
|
|
78489
|
+
jinja2: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover', alt: product.name }) }}",
|
|
78490
|
+
liquid: "{{ product.photo | image: width: 120, height: 80, fit: 'cover', alt: product.name }}",
|
|
78491
|
+
handlebars: "{{image product.photo width=120 height=80 fit='cover' alt=product.name}}"
|
|
78445
78492
|
},
|
|
78446
78493
|
category: "document"
|
|
78447
78494
|
},
|
|
78448
78495
|
fn: (ctx, url, options) => {
|
|
78449
78496
|
const o = asObject(options);
|
|
78450
78497
|
if (ctx.drawing)
|
|
78451
|
-
return ctx.drawing({
|
|
78498
|
+
return ctx.drawing({
|
|
78499
|
+
kind: "url",
|
|
78500
|
+
url: resolveAsset(ctx.assetBaseUrl, String(url ?? "")),
|
|
78501
|
+
width: o.width,
|
|
78502
|
+
height: o.height,
|
|
78503
|
+
alt: o.alt
|
|
78504
|
+
});
|
|
78452
78505
|
return imageMarkup(String(url ?? ""), o);
|
|
78453
78506
|
}
|
|
78454
78507
|
}
|
|
@@ -81925,7 +81978,8 @@ var Formfeed = class {
|
|
|
81925
81978
|
`render ${target.id} has no output (${target.status})`,
|
|
81926
81979
|
0
|
|
81927
81980
|
);
|
|
81928
|
-
const
|
|
81981
|
+
const fetchImpl = this.fetchImpl;
|
|
81982
|
+
const res = await fetchImpl(target.download_url, {
|
|
81929
81983
|
signal: options.signal
|
|
81930
81984
|
});
|
|
81931
81985
|
if (!res.ok)
|
|
@@ -82441,8 +82495,9 @@ var Formfeed = class {
|
|
|
82441
82495
|
() => controller.abort(options.signal?.reason)
|
|
82442
82496
|
);
|
|
82443
82497
|
let res;
|
|
82498
|
+
const fetchImpl = this.fetchImpl;
|
|
82444
82499
|
try {
|
|
82445
|
-
res = await
|
|
82500
|
+
res = await fetchImpl(url, {
|
|
82446
82501
|
method,
|
|
82447
82502
|
headers,
|
|
82448
82503
|
body: body === void 0 ? void 0 : isForm ? body : JSON.stringify(body),
|
|
@@ -82514,7 +82569,7 @@ var encoder2 = new TextEncoder();
|
|
|
82514
82569
|
// packages/mcp/src/lib/server.ts
|
|
82515
82570
|
import { z as z3 } from "zod";
|
|
82516
82571
|
var CONVERT_LIMIT_BYTES = 20 * 1024 * 1024;
|
|
82517
|
-
var SERVER_INFO = { name: "formfeed", version: "0.3.
|
|
82572
|
+
var SERVER_INFO = { name: "formfeed", version: "0.3.14" };
|
|
82518
82573
|
var READS = {
|
|
82519
82574
|
readOnlyHint: true,
|
|
82520
82575
|
destructiveHint: false,
|
|
@@ -82549,7 +82604,10 @@ var renderSummary = (r) => ({
|
|
|
82549
82604
|
units: r.units,
|
|
82550
82605
|
environment: r.environment,
|
|
82551
82606
|
template: r.template,
|
|
82552
|
-
error: r.error
|
|
82607
|
+
error: r.error,
|
|
82608
|
+
// the validator's verdict of a render whose template declares PDF/UA-1 (plan 21): whether the
|
|
82609
|
+
// file passed, and for each failed rule what to change and in which elements
|
|
82610
|
+
...r.accessibility ? { accessibility: r.accessibility } : {}
|
|
82553
82611
|
});
|
|
82554
82612
|
function text3(data) {
|
|
82555
82613
|
return {
|
|
@@ -82559,8 +82617,15 @@ function text3(data) {
|
|
|
82559
82617
|
}
|
|
82560
82618
|
function failure(e) {
|
|
82561
82619
|
const message2 = e instanceof FormfeedError ? `${e.code}: ${e.message}` : e instanceof Error ? e.message : String(e);
|
|
82620
|
+
const verdict = e instanceof FormfeedError && e.code === "pdfua_validation_failed" ? e.problem?.["accessibility"] : void 0;
|
|
82562
82621
|
return {
|
|
82563
|
-
content: [
|
|
82622
|
+
content: [
|
|
82623
|
+
{
|
|
82624
|
+
type: "text",
|
|
82625
|
+
text: verdict ? `${message2}
|
|
82626
|
+
${JSON.stringify({ accessibility: verdict }, null, 2)}` : message2
|
|
82627
|
+
}
|
|
82628
|
+
],
|
|
82564
82629
|
isError: true
|
|
82565
82630
|
};
|
|
82566
82631
|
}
|
|
@@ -82576,7 +82641,7 @@ function createFormfeedServer(options) {
|
|
|
82576
82641
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
82577
82642
|
});
|
|
82578
82643
|
const server = new McpServer(SERVER_INFO, {
|
|
82579
|
-
instructions: "Formfeed renders PDFs and images from HTML templates, and fills Word and PowerPoint templates (as DOCX, PPTX or PDF). Start with list_templates, read the data shape with get_template_schema, check data with validate_template, then call render and hand the download_url to the user. convert_to_pdf turns office documents into PDFs."
|
|
82644
|
+
instructions: "Formfeed renders PDFs and images from HTML templates, and fills Word and PowerPoint templates (as DOCX, PPTX or PDF). Start with list_templates, read the data shape with get_template_schema, check data with validate_template, then call render and hand the download_url to the user. convert_to_pdf turns office documents into PDFs. get_workspace says which workspace this server works in and what the period has left."
|
|
82580
82645
|
});
|
|
82581
82646
|
server.registerTool(
|
|
82582
82647
|
"list_templates",
|
|
@@ -82900,6 +82965,30 @@ function createFormfeedServer(options) {
|
|
|
82900
82965
|
}
|
|
82901
82966
|
}
|
|
82902
82967
|
);
|
|
82968
|
+
server.registerTool(
|
|
82969
|
+
"get_workspace",
|
|
82970
|
+
{
|
|
82971
|
+
title: "Which workspace this server works in",
|
|
82972
|
+
description: "Returns the workspace and organisation this server acts in, the plan, the environment (live or test) and the units of the current period: included, used and the balance of prepaid units. Call it when the user asks where a document will land or how many renders are left, or when list_templates returns nothing you expected. A signed-in agent works in the workspace chosen when it was connected, which the person changes in Formfeed under Account \u2192 Connected agents; an API key works in the workspace it was made for. Read-only and free.",
|
|
82973
|
+
inputSchema: {},
|
|
82974
|
+
annotations: READS
|
|
82975
|
+
},
|
|
82976
|
+
async () => {
|
|
82977
|
+
try {
|
|
82978
|
+
const { workspace, organization, plan, environment, period: period2, units } = await client.account.get();
|
|
82979
|
+
return text3({
|
|
82980
|
+
workspace,
|
|
82981
|
+
organization,
|
|
82982
|
+
plan,
|
|
82983
|
+
environment,
|
|
82984
|
+
period: period2,
|
|
82985
|
+
units
|
|
82986
|
+
});
|
|
82987
|
+
} catch (e) {
|
|
82988
|
+
return failure(e);
|
|
82989
|
+
}
|
|
82990
|
+
}
|
|
82991
|
+
);
|
|
82903
82992
|
return server;
|
|
82904
82993
|
}
|
|
82905
82994
|
|
|
@@ -82932,12 +83021,20 @@ function tokenExpired(token, now = Date.now()) {
|
|
|
82932
83021
|
}
|
|
82933
83022
|
}
|
|
82934
83023
|
function json(body, status, headers = {}) {
|
|
82935
|
-
return new Response(JSON.stringify(body), {
|
|
83024
|
+
return new Response(JSON.stringify(body), {
|
|
83025
|
+
status,
|
|
83026
|
+
headers: { "content-type": "application/json", ...headers }
|
|
83027
|
+
});
|
|
82936
83028
|
}
|
|
82937
83029
|
function bearer(request) {
|
|
82938
83030
|
const auth = request.headers.get("authorization") ?? "";
|
|
82939
83031
|
return /^Bearer\s+/i.test(auth) ? auth.replace(/^Bearer\s+/i, "").trim() : "";
|
|
82940
83032
|
}
|
|
83033
|
+
function challenge(metadata, description) {
|
|
83034
|
+
return {
|
|
83035
|
+
"www-authenticate": description ? `Bearer realm="formfeed", resource_metadata="${metadata}", error="invalid_token", error_description="${description}"` : `Bearer realm="formfeed", resource_metadata="${metadata}"`
|
|
83036
|
+
};
|
|
83037
|
+
}
|
|
82941
83038
|
function mcpFetchHandler(options = {}) {
|
|
82942
83039
|
const authorizationServer2 = options.authorizationServer?.replace(/\/$/, "");
|
|
82943
83040
|
return async (request) => {
|
|
@@ -82946,41 +83043,99 @@ function mcpFetchHandler(options = {}) {
|
|
|
82946
83043
|
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
82947
83044
|
if (path === "/healthz") return json({ ok: true }, 200);
|
|
82948
83045
|
if (path === RESOURCE_METADATA_PATH || path.startsWith(`${RESOURCE_METADATA_PATH}${MCP_PATH}`)) {
|
|
82949
|
-
if (!authorizationServer2)
|
|
82950
|
-
|
|
83046
|
+
if (!authorizationServer2)
|
|
83047
|
+
return json(
|
|
83048
|
+
{
|
|
83049
|
+
error: "This endpoint takes API keys only; no authorization server is configured"
|
|
83050
|
+
},
|
|
83051
|
+
404
|
|
83052
|
+
);
|
|
83053
|
+
const workspaceId2 = path.slice(
|
|
83054
|
+
`${RESOURCE_METADATA_PATH}${MCP_PATH}/`.length
|
|
83055
|
+
);
|
|
82951
83056
|
if (path === RESOURCE_METADATA_PATH || path === `${RESOURCE_METADATA_PATH}${MCP_PATH}`)
|
|
82952
83057
|
return json(
|
|
82953
83058
|
{
|
|
82954
83059
|
resource: `${origin}${MCP_PATH}`,
|
|
82955
83060
|
authorization_servers: [authorizationServer2],
|
|
82956
83061
|
bearer_methods_supported: ["header"],
|
|
83062
|
+
scopes_supported: ["openid", "email", "profile"],
|
|
82957
83063
|
resource_name: "Formfeed",
|
|
82958
83064
|
resource_documentation: "https://docs.formfeed.dev/integrations/mcp",
|
|
82959
|
-
//
|
|
83065
|
+
// a signed-in client works in the workspace chosen at consent; this path pins another one
|
|
82960
83066
|
formfeed_workspace_path: `${origin}${MCP_PATH}/{workspace_id}`
|
|
82961
83067
|
},
|
|
82962
83068
|
200
|
|
82963
83069
|
);
|
|
82964
|
-
if (!WORKSPACE_ID.test(workspaceId2))
|
|
82965
|
-
|
|
83070
|
+
if (!WORKSPACE_ID.test(workspaceId2))
|
|
83071
|
+
return json({ error: "The workspace id is not a UUID" }, 404);
|
|
83072
|
+
return json(
|
|
83073
|
+
resourceMetadata(origin, workspaceId2, authorizationServer2),
|
|
83074
|
+
200
|
|
83075
|
+
);
|
|
82966
83076
|
}
|
|
82967
|
-
if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`))
|
|
83077
|
+
if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`))
|
|
83078
|
+
return new Response(null, { status: 404 });
|
|
82968
83079
|
const workspaceId = path === MCP_PATH ? null : path.slice(MCP_PATH.length + 1);
|
|
82969
83080
|
if (workspaceId !== null && !WORKSPACE_ID.test(workspaceId))
|
|
82970
|
-
return json(
|
|
83081
|
+
return json(
|
|
83082
|
+
{
|
|
83083
|
+
error: `The path is /mcp for an API key or /mcp/<workspace id> for a signed-in member`
|
|
83084
|
+
},
|
|
83085
|
+
404
|
|
83086
|
+
);
|
|
82971
83087
|
const credential = bearer(request);
|
|
82972
83088
|
if (workspaceId === null) {
|
|
82973
|
-
|
|
82974
|
-
if (!
|
|
82975
|
-
return json(
|
|
83089
|
+
const metadata = `${origin}${RESOURCE_METADATA_PATH}${MCP_PATH}`;
|
|
83090
|
+
if (!credential)
|
|
83091
|
+
return authorizationServer2 ? json(
|
|
83092
|
+
{
|
|
83093
|
+
error: "Authorization: Bearer <formfeed api key> required, or sign in with your Formfeed account"
|
|
83094
|
+
},
|
|
83095
|
+
401,
|
|
83096
|
+
challenge(metadata)
|
|
83097
|
+
) : json(
|
|
83098
|
+
{ error: "Authorization: Bearer <formfeed api key> required" },
|
|
83099
|
+
401,
|
|
83100
|
+
{ "www-authenticate": 'Bearer realm="formfeed"' }
|
|
83101
|
+
);
|
|
83102
|
+
if (!API_KEY.test(credential) && JWT.test(credential)) {
|
|
83103
|
+
if (!authorizationServer2)
|
|
83104
|
+
return json(
|
|
83105
|
+
{
|
|
83106
|
+
error: "This endpoint takes API keys only; no authorization server is configured for a sign-in"
|
|
83107
|
+
},
|
|
83108
|
+
401,
|
|
83109
|
+
{ "www-authenticate": 'Bearer realm="formfeed"' }
|
|
83110
|
+
);
|
|
83111
|
+
if (tokenExpired(credential))
|
|
83112
|
+
return json(
|
|
83113
|
+
{ error: "The access token expired" },
|
|
83114
|
+
401,
|
|
83115
|
+
challenge(metadata, "the access token expired")
|
|
83116
|
+
);
|
|
83117
|
+
}
|
|
82976
83118
|
} else {
|
|
82977
|
-
if (!authorizationServer2)
|
|
83119
|
+
if (!authorizationServer2)
|
|
83120
|
+
return json(
|
|
83121
|
+
{
|
|
83122
|
+
error: "This endpoint takes API keys only; connect to /mcp with a key"
|
|
83123
|
+
},
|
|
83124
|
+
404
|
|
83125
|
+
);
|
|
82978
83126
|
const metadata = `${origin}${RESOURCE_METADATA_PATH}${MCP_PATH}/${workspaceId}`;
|
|
82979
|
-
|
|
82980
|
-
|
|
82981
|
-
|
|
82982
|
-
|
|
82983
|
-
|
|
83127
|
+
if (!credential || !JWT.test(credential))
|
|
83128
|
+
return json(
|
|
83129
|
+
{ error: "Sign in: this endpoint takes an OAuth access token" },
|
|
83130
|
+
401,
|
|
83131
|
+
challenge(metadata, "an access token is required")
|
|
83132
|
+
);
|
|
83133
|
+
if (tokenExpired(credential))
|
|
83134
|
+
return json(
|
|
83135
|
+
{ error: "The access token expired" },
|
|
83136
|
+
401,
|
|
83137
|
+
challenge(metadata, "the access token expired")
|
|
83138
|
+
);
|
|
82984
83139
|
}
|
|
82985
83140
|
const mcp = createFormfeedServer({
|
|
82986
83141
|
apiKey: credential,
|
|
@@ -82989,7 +83144,10 @@ function mcpFetchHandler(options = {}) {
|
|
|
82989
83144
|
...options.region ? { region: options.region } : {},
|
|
82990
83145
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
82991
83146
|
});
|
|
82992
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
83147
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
83148
|
+
sessionIdGenerator: void 0,
|
|
83149
|
+
enableJsonResponse: true
|
|
83150
|
+
});
|
|
82993
83151
|
await mcp.connect(transport);
|
|
82994
83152
|
try {
|
|
82995
83153
|
return await transport.handleRequest(request);
|
|
@@ -83012,8 +83170,11 @@ async function startHttp(options = {}) {
|
|
|
83012
83170
|
const handler = mcpFetchHandler(handlerOptions);
|
|
83013
83171
|
const server = createServer((req, res) => {
|
|
83014
83172
|
void toRequest(req, host).then(handler).then((response) => send(response, res)).catch((e) => {
|
|
83015
|
-
if (!res.headersSent)
|
|
83016
|
-
|
|
83173
|
+
if (!res.headersSent)
|
|
83174
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
83175
|
+
res.end(
|
|
83176
|
+
JSON.stringify({ error: e instanceof Error ? e.message : String(e) })
|
|
83177
|
+
);
|
|
83017
83178
|
});
|
|
83018
83179
|
});
|
|
83019
83180
|
await new Promise((resolve2, reject) => {
|
|
@@ -83071,13 +83232,18 @@ var authorizationServer = flag("--auth-server") ?? process.env["FORMFEED_AUTH_SE
|
|
|
83071
83232
|
var publicOrigin = flag("--public-origin") ?? process.env["FORMFEED_PUBLIC_ORIGIN"];
|
|
83072
83233
|
if (args.includes("--help") || args.includes("-h")) {
|
|
83073
83234
|
process.stdout.write(
|
|
83074
|
-
"formfeed-mcp: Model Context Protocol server for Formfeed\n\n formfeed-mcp stdio; needs FORMFEED_API_KEY\n formfeed-mcp --http Streamable HTTP on --port (8790) and --host (127.0.0.1); the key comes from Authorization: Bearer\n --auth-server <issuer> (or FORMFEED_AUTH_SERVER) also
|
|
83235
|
+
"formfeed-mcp: Model Context Protocol server for Formfeed\n\n formfeed-mcp stdio; needs FORMFEED_API_KEY\n formfeed-mcp --http Streamable HTTP on --port (8790) and --host (127.0.0.1); the key comes from Authorization: Bearer\n --auth-server <issuer> (or FORMFEED_AUTH_SERVER) also takes OAuth access tokens on /mcp and /mcp/<workspace id>;\n --public-origin <url> (or FORMFEED_PUBLIC_ORIGIN) is the origin clients see behind a proxy\n"
|
|
83075
83236
|
);
|
|
83076
83237
|
} else if (args.includes("--http")) {
|
|
83077
|
-
startHttp({
|
|
83078
|
-
(
|
|
83079
|
-
|
|
83080
|
-
|
|
83238
|
+
startHttp({
|
|
83239
|
+
port: Number(flag("--port") ?? 8790),
|
|
83240
|
+
host: flag("--host") ?? "127.0.0.1",
|
|
83241
|
+
baseUrl,
|
|
83242
|
+
region,
|
|
83243
|
+
authorizationServer,
|
|
83244
|
+
publicOrigin
|
|
83245
|
+
}).then((h) => process.stderr.write(`formfeed-mcp listening on ${h.url}
|
|
83246
|
+
`));
|
|
83081
83247
|
} else {
|
|
83082
83248
|
const apiKey = process.env["FORMFEED_API_KEY"];
|
|
83083
83249
|
if (!apiKey) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formfeed/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"mcpName": "io.github.formfeed-dev/formfeed",
|
|
5
5
|
"description": "Model Context Protocol server for Formfeed: list templates, read data schemas, validate and render documents from AI agents.",
|
|
6
6
|
"license": "MIT",
|