@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 CHANGED
@@ -3,9 +3,9 @@
3
3
  [![formfeed MCP server – quality and maintenance score on Glama](https://glama.ai/mcp/servers/formfeed-dev/formfeed/badges/score.svg)](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 six tools: `list_templates`, `get_template_schema`,
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 `get_render`.
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/<workspace id>` signs in with your Formfeed account (OAuth; the client
30
- opens the browser), `https://mcp.formfeed.dev/mcp` takes `Authorization: Bearer ff_…`. Self-host the
31
- same endpoint with `formfeed-mcp --http --port 8790`, plus `--auth-server <issuer>` for OAuth sign-in
32
- against your own Supabase Auth.
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>
@@ -21408,7 +21408,9 @@ var require_liquid_node = __commonJS({
21408
21408
  });
21409
21409
 
21410
21410
  // packages/mcp/src/lib/transports.ts
21411
- import { createServer } from "node:http";
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 === "") throw new Error(`${helper}: nothing to encode, the value is empty or missing from the data`);
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) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "QR code" });
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) return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${text4}` });
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) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "GiroCode" });
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(["name", "iban", "bic", "amount", "reference", "text", "purpose"]);
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 = ["#3E63DD", "#12A594", "#E5484D", "#F76B15", "#8E4EC6", "#FFC53D", "#0090FF", "#978365"];
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 = { data, backgroundColor: fill, borderColor: fill };
78370
- if (s.label !== void 0 && s.label !== null && s.label !== "") dataset["label"] = String(s.label);
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)) return { type: spec.type ?? "bar", data: spec.data ?? {}, options };
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
- return `<canvas class="ff-chart"${idAttr} width="${width}" height="${height}" style="width:${width}px;height:${height}px" data-ff-chart="${payload}"></canvas>`;
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({ ...asObject(spec), ...asObject(extra) });
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({ kind: "url", url: resolveAsset(ctx.assetBaseUrl, String(url ?? "")), width: o.width, height: o.height, alt: o.alt });
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 res = await this.fetchImpl(target.download_url, {
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 this.fetchImpl(url, {
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.12" };
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: [{ type: "text", text: message2 }],
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), { status, headers: { "content-type": "application/json", ...headers } });
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) return json({ error: "This endpoint takes API keys only; no authorization server is configured" }, 404);
82950
- const workspaceId2 = path.slice(`${RESOURCE_METADATA_PATH}${MCP_PATH}/`.length);
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
- // the OAuth resource is one workspace: add its id to the path
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)) return json({ error: "The workspace id is not a UUID" }, 404);
82965
- return json(resourceMetadata(origin, workspaceId2, authorizationServer2), 200);
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}/`)) return new Response(null, { status: 404 });
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({ error: `The path is /mcp for an API key or /mcp/<workspace id> for a signed-in member` }, 404);
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
- if (!credential) return json({ error: "Authorization: Bearer <formfeed api key> required; a signed-in member connects to /mcp/<workspace id>" }, 401, { "www-authenticate": 'Bearer realm="formfeed"' });
82974
- if (!API_KEY.test(credential) && JWT.test(credential))
82975
- return json({ error: "An access token needs its workspace: connect to /mcp/<workspace id>" }, 401, { "www-authenticate": 'Bearer realm="formfeed"' });
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) return json({ error: "This endpoint takes API keys only; connect to /mcp with a key" }, 404);
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
- const challenge = (description) => ({
82980
- "www-authenticate": `Bearer realm="formfeed", resource_metadata="${metadata}", error="invalid_token", error_description="${description}"`
82981
- });
82982
- if (!credential || !JWT.test(credential)) return json({ error: "Sign in: this endpoint takes an OAuth access token" }, 401, challenge("an access token is required"));
82983
- if (tokenExpired(credential)) return json({ error: "The access token expired" }, 401, challenge("the access token expired"));
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({ sessionIdGenerator: void 0, enableJsonResponse: true });
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) res.writeHead(500, { "content-type": "application/json" });
83016
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
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 opens /mcp/<workspace id> for OAuth access tokens;\n --public-origin <url> (or FORMFEED_PUBLIC_ORIGIN) is the origin clients see behind a proxy\n"
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({ port: Number(flag("--port") ?? 8790), host: flag("--host") ?? "127.0.0.1", baseUrl, region, authorizationServer, publicOrigin }).then(
83078
- (h) => process.stderr.write(`formfeed-mcp listening on ${h.url}
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.12",
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",