@formfeed/mcp 0.3.3 → 0.3.5

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
@@ -23,7 +23,9 @@ A test key renders free with a watermark; a live key consumes units. `FORMFEED_R
23
23
 
24
24
  ## Hosted (Streamable HTTP)
25
25
 
26
- `https://mcp.formfeed.dev/mcp` with `Authorization: Bearer ff_…`. Self-host the same endpoint with
27
- `formfeed-mcp --http --port 8790`.
26
+ `https://mcp.formfeed.dev/mcp/<workspace id>` signs in with your Formfeed account (OAuth; the client
27
+ opens the browser), `https://mcp.formfeed.dev/mcp` takes `Authorization: Bearer ff_…`. Self-host the
28
+ same endpoint with `formfeed-mcp --http --port 8790`, plus `--auth-server <issuer>` for OAuth sign-in
29
+ against your own Supabase Auth.
28
30
 
29
31
  Documentation: <https://docs.formfeed.dev/integrations/mcp>
@@ -18058,7 +18058,7 @@ var require_liquid_node = __commonJS({
18058
18058
  return false;
18059
18059
  return isFalsy(value, this.context) ? defaultValue : value;
18060
18060
  }
18061
- function json(value, space = 0) {
18061
+ function json2(value, space = 0) {
18062
18062
  const memoryLimit = this.context.memoryLimit;
18063
18063
  return JSON.stringify(value, (_key, val2) => {
18064
18064
  chargeJsonReplacerValue(memoryLimit, val2);
@@ -18094,9 +18094,9 @@ var require_liquid_node = __commonJS({
18094
18094
  var misc = {
18095
18095
  default: defaultFilter,
18096
18096
  raw,
18097
- jsonify: json,
18097
+ jsonify: json2,
18098
18098
  to_integer,
18099
- json,
18099
+ json: json2,
18100
18100
  inspect
18101
18101
  };
18102
18102
  var escapeMap = {
@@ -21410,7 +21410,6 @@ var require_liquid_node = __commonJS({
21410
21410
  // packages/mcp/src/lib/transports.ts
21411
21411
  import { createServer } from "node:http";
21412
21412
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21413
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21414
21413
 
21415
21414
  // packages/mcp/src/lib/server.ts
21416
21415
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -82407,6 +82406,7 @@ var Formfeed = class {
82407
82406
  fetchImpl;
82408
82407
  maxRetries;
82409
82408
  timeoutMs;
82409
+ extraHeaders;
82410
82410
  constructor(options) {
82411
82411
  if (!options.apiKey) throw new FormfeedError("invalid_request", "apiKey is required", 0);
82412
82412
  this.apiKey = options.apiKey;
@@ -82414,6 +82414,7 @@ var Formfeed = class {
82414
82414
  this.fetchImpl = options.fetch ?? globalThis.fetch;
82415
82415
  this.maxRetries = options.maxRetries ?? 3;
82416
82416
  this.timeoutMs = options.timeoutMs ?? 12e4;
82417
+ this.extraHeaders = Object.fromEntries(Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]));
82417
82418
  if (!this.fetchImpl) throw new FormfeedError("invalid_request", "fetch is not available; pass options.fetch", 0);
82418
82419
  }
82419
82420
  renders = {
@@ -82681,11 +82682,20 @@ var Formfeed = class {
82681
82682
  );
82682
82683
  }
82683
82684
  },
82684
- schema: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/schema`, void 0, options),
82685
+ /**
82686
+ * The JSON Schema of the template's data: stored, else inferred from the sample data. Of the
82687
+ * published (else the latest) version, or of `version`: `published`, `latest`, a number or a
82688
+ * channel name.
82689
+ */
82690
+ schema: (idOrSlug, options = {}) => {
82691
+ const { version, ...rest } = options;
82692
+ const query = version === void 0 ? "" : `?version=${encodeURIComponent(String(version))}`;
82693
+ return this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/schema${query}`, void 0, rest);
82694
+ },
82685
82695
  /**
82686
82696
  * Checks a version with data without rendering: syntax, header and footer, missing partials,
82687
82697
  * runtime errors and the data against the stored schema. Free. Without `data`, the version's
82688
- * sample data is checked.
82698
+ * sample data is checked. `version` takes a channel name too.
82689
82699
  */
82690
82700
  validate: (idOrSlug, input = {}, options = {}) => this.request("POST", `/templates/${encodeURIComponent(idOrSlug)}/validate`, input, options)
82691
82701
  };
@@ -82724,7 +82734,8 @@ var Formfeed = class {
82724
82734
  const headers = {
82725
82735
  authorization: `Bearer ${this.apiKey}`,
82726
82736
  accept: as === "bytes" ? "*/*" : "application/json",
82727
- "user-agent": "formfeed-sdk-ts/0.3"
82737
+ "user-agent": "formfeed-sdk-ts/0.3",
82738
+ ...this.extraHeaders
82728
82739
  };
82729
82740
  const isForm = typeof FormData !== "undefined" && body instanceof FormData;
82730
82741
  if (body !== void 0 && !isForm) headers["content-type"] = "application/json";
@@ -82760,14 +82771,14 @@ var Formfeed = class {
82760
82771
  if (res.status === 204) return { status: 204, body: void 0 };
82761
82772
  if (as === "bytes" && res.ok) return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) };
82762
82773
  const text2 = await res.text();
82763
- let json = null;
82774
+ let json2 = null;
82764
82775
  try {
82765
- json = text2 ? JSON.parse(text2) : null;
82776
+ json2 = text2 ? JSON.parse(text2) : null;
82766
82777
  } catch {
82767
82778
  if (res.ok) throw new FormfeedError("invalid_response", `the API answered HTTP ${res.status} with a body that is not JSON`, res.status);
82768
82779
  }
82769
82780
  if (!res.ok) {
82770
- const problem = json ?? {};
82781
+ const problem = json2 ?? {};
82771
82782
  throw new FormfeedError(
82772
82783
  problem.code ?? `http_${res.status}`,
82773
82784
  problem.detail ?? problem.title ?? `HTTP ${res.status}`,
@@ -82776,7 +82787,7 @@ var Formfeed = class {
82776
82787
  res.headers.get("x-request-id")
82777
82788
  );
82778
82789
  }
82779
- return { status: res.status, body: json };
82790
+ return { status: res.status, body: json2 };
82780
82791
  }
82781
82792
  }
82782
82793
  backoff(attempt, retryAfter) {
@@ -82792,7 +82803,7 @@ var encoder2 = new TextEncoder();
82792
82803
  // packages/mcp/src/lib/server.ts
82793
82804
  import { z as z2 } from "zod";
82794
82805
  var CONVERT_LIMIT_BYTES = 20 * 1024 * 1024;
82795
- var SERVER_INFO = { name: "formfeed", version: "0.3.3" };
82806
+ var SERVER_INFO = { name: "formfeed", version: "0.3.5" };
82796
82807
  var engineSchema = z2.enum(["jinja2", "liquid", "handlebars"]);
82797
82808
  var outputSchema = z2.enum(outputFormats);
82798
82809
  var kindSchema = z2.enum(["pdf", "image", "docx", "pptx"]);
@@ -82828,6 +82839,7 @@ function failure(e) {
82828
82839
  function createFormfeedServer(options) {
82829
82840
  const client = new Formfeed({
82830
82841
  apiKey: options.apiKey,
82842
+ headers: { "user-agent": `formfeed-mcp/${SERVER_INFO.version}`, ...options.workspaceId ? { "x-formfeed-workspace": options.workspaceId } : {} },
82831
82843
  ...options.region ? { region: options.region } : {},
82832
82844
  ...options.baseUrl ? { baseUrl: options.baseUrl } : {},
82833
82845
  ...options.fetch ? { fetch: options.fetch } : {}
@@ -83056,6 +83068,102 @@ function createFormfeedServer(options) {
83056
83068
  return server;
83057
83069
  }
83058
83070
 
83071
+ // packages/mcp/src/lib/web.ts
83072
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
83073
+ var WORKSPACE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
83074
+ var API_KEY = /^ff_(live|test)_/;
83075
+ var JWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
83076
+ var MCP_PATH = "/mcp";
83077
+ var RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
83078
+ function resourceMetadata(origin, workspaceId, authorizationServer2) {
83079
+ return {
83080
+ resource: `${origin}${MCP_PATH}/${workspaceId}`,
83081
+ authorization_servers: [authorizationServer2.replace(/\/$/, "")],
83082
+ bearer_methods_supported: ["header"],
83083
+ // Supabase Auth accepts the OIDC identity scopes only; what the token may do is the gateway's decision
83084
+ scopes_supported: ["openid", "email", "profile"],
83085
+ resource_name: "Formfeed",
83086
+ resource_documentation: "https://docs.formfeed.dev/integrations/mcp"
83087
+ };
83088
+ }
83089
+ function tokenExpired(token, now = Date.now()) {
83090
+ const part = token.split(".")[1] ?? "";
83091
+ try {
83092
+ const json2 = atob(part.replace(/-/g, "+").replace(/_/g, "/"));
83093
+ const exp = JSON.parse(json2).exp;
83094
+ return typeof exp === "number" && exp * 1e3 <= now;
83095
+ } catch {
83096
+ return false;
83097
+ }
83098
+ }
83099
+ function json(body, status, headers = {}) {
83100
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
83101
+ }
83102
+ function bearer(request) {
83103
+ const auth = request.headers.get("authorization") ?? "";
83104
+ return /^Bearer\s+/i.test(auth) ? auth.replace(/^Bearer\s+/i, "").trim() : "";
83105
+ }
83106
+ function mcpFetchHandler(options = {}) {
83107
+ const authorizationServer2 = options.authorizationServer?.replace(/\/$/, "");
83108
+ return async (request) => {
83109
+ const url = new URL(request.url);
83110
+ const origin = (options.publicOrigin ?? url.origin).replace(/\/$/, "");
83111
+ const path = url.pathname.replace(/\/+$/, "") || "/";
83112
+ if (path === "/healthz") return json({ ok: true }, 200);
83113
+ if (path === RESOURCE_METADATA_PATH || path.startsWith(`${RESOURCE_METADATA_PATH}${MCP_PATH}`)) {
83114
+ if (!authorizationServer2) return json({ error: "This endpoint takes API keys only; no authorization server is configured" }, 404);
83115
+ const workspaceId2 = path.slice(`${RESOURCE_METADATA_PATH}${MCP_PATH}/`.length);
83116
+ if (path === RESOURCE_METADATA_PATH || path === `${RESOURCE_METADATA_PATH}${MCP_PATH}`)
83117
+ return json(
83118
+ {
83119
+ resource: `${origin}${MCP_PATH}`,
83120
+ authorization_servers: [authorizationServer2],
83121
+ bearer_methods_supported: ["header"],
83122
+ resource_name: "Formfeed",
83123
+ resource_documentation: "https://docs.formfeed.dev/integrations/mcp",
83124
+ // the OAuth resource is one workspace: add its id to the path
83125
+ formfeed_workspace_path: `${origin}${MCP_PATH}/{workspace_id}`
83126
+ },
83127
+ 200
83128
+ );
83129
+ if (!WORKSPACE_ID.test(workspaceId2)) return json({ error: "The workspace id is not a UUID" }, 404);
83130
+ return json(resourceMetadata(origin, workspaceId2, authorizationServer2), 200);
83131
+ }
83132
+ if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return new Response(null, { status: 404 });
83133
+ const workspaceId = path === MCP_PATH ? null : path.slice(MCP_PATH.length + 1);
83134
+ if (workspaceId !== null && !WORKSPACE_ID.test(workspaceId))
83135
+ return json({ error: `The path is /mcp for an API key or /mcp/<workspace id> for a signed-in member` }, 404);
83136
+ const credential = bearer(request);
83137
+ if (workspaceId === null) {
83138
+ 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"' });
83139
+ if (!API_KEY.test(credential) && JWT.test(credential))
83140
+ return json({ error: "An access token needs its workspace: connect to /mcp/<workspace id>" }, 401, { "www-authenticate": 'Bearer realm="formfeed"' });
83141
+ } else {
83142
+ if (!authorizationServer2) return json({ error: "This endpoint takes API keys only; connect to /mcp with a key" }, 404);
83143
+ const metadata = `${origin}${RESOURCE_METADATA_PATH}${MCP_PATH}/${workspaceId}`;
83144
+ const challenge = (description) => ({
83145
+ "www-authenticate": `Bearer realm="formfeed", resource_metadata="${metadata}", error="invalid_token", error_description="${description}"`
83146
+ });
83147
+ if (!credential || !JWT.test(credential)) return json({ error: "Sign in: this endpoint takes an OAuth access token" }, 401, challenge("an access token is required"));
83148
+ if (tokenExpired(credential)) return json({ error: "The access token expired" }, 401, challenge("the access token expired"));
83149
+ }
83150
+ const mcp = createFormfeedServer({
83151
+ apiKey: credential,
83152
+ ...workspaceId ? { workspaceId } : {},
83153
+ ...options.baseUrl ? { baseUrl: options.baseUrl } : {},
83154
+ ...options.region ? { region: options.region } : {},
83155
+ ...options.fetch ? { fetch: options.fetch } : {}
83156
+ });
83157
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
83158
+ await mcp.connect(transport);
83159
+ try {
83160
+ return await transport.handleRequest(request);
83161
+ } finally {
83162
+ void mcp.close().catch(() => void 0);
83163
+ }
83164
+ };
83165
+ }
83166
+
83059
83167
  // packages/mcp/src/lib/transports.ts
83060
83168
  async function startStdio(options) {
83061
83169
  const server = createFormfeedServer({ localFiles: true, ...options });
@@ -83063,52 +83171,58 @@ async function startStdio(options) {
83063
83171
  }
83064
83172
  async function startHttp(options = {}) {
83065
83173
  const host = options.host ?? "127.0.0.1";
83066
- const path = options.path ?? "/mcp";
83174
+ const { port: _port, host: _host, ...handlerOptions } = options;
83175
+ void _port;
83176
+ void _host;
83177
+ const handler = mcpFetchHandler(handlerOptions);
83067
83178
  const server = createServer((req, res) => {
83068
- void handle(req, res).catch((e) => {
83179
+ void toRequest(req, host).then(handler).then((response) => send(response, res)).catch((e) => {
83069
83180
  if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" });
83070
83181
  res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
83071
83182
  });
83072
83183
  });
83073
- async function handle(req, res) {
83074
- const url = new URL(req.url ?? "/", `http://${host}`);
83075
- if (url.pathname === "/healthz") {
83076
- res.writeHead(200, { "content-type": "application/json" });
83077
- res.end(JSON.stringify({ ok: true }));
83078
- return;
83079
- }
83080
- if (url.pathname !== path) {
83081
- res.writeHead(404);
83082
- res.end();
83083
- return;
83084
- }
83085
- const auth = req.headers.authorization ?? "";
83086
- const apiKey = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
83087
- if (!apiKey) {
83088
- res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Bearer realm="formfeed"' });
83089
- res.end(JSON.stringify({ error: "Authorization: Bearer <formfeed api key> required" }));
83090
- return;
83091
- }
83092
- const mcp = createFormfeedServer({ apiKey, baseUrl: options.baseUrl, region: options.region, fetch: options.fetch });
83093
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
83094
- res.on("close", () => {
83095
- void transport.close();
83096
- void mcp.close();
83097
- });
83098
- await mcp.connect(transport);
83099
- await transport.handleRequest(req, res);
83100
- }
83101
83184
  await new Promise((resolve2, reject) => {
83102
83185
  server.once("error", reject);
83103
83186
  server.listen(options.port ?? 8790, host, () => resolve2());
83104
83187
  });
83105
83188
  const port = server.address().port;
83106
83189
  return {
83107
- url: `http://${host}:${port}${path}`,
83190
+ url: `http://${host}:${port}${MCP_PATH}`,
83108
83191
  port,
83109
83192
  close: () => new Promise((resolve2) => server.close(() => resolve2()))
83110
83193
  };
83111
83194
  }
83195
+ async function toRequest(req, host) {
83196
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
83197
+ const headers = new Headers();
83198
+ for (const [name, value] of Object.entries(req.headers)) {
83199
+ if (Array.isArray(value)) for (const v2 of value) headers.append(name, v2);
83200
+ else if (value !== void 0) headers.set(name, value);
83201
+ }
83202
+ const chunks = [];
83203
+ for await (const chunk of req) chunks.push(chunk);
83204
+ const method = req.method ?? "GET";
83205
+ const body = method === "GET" || method === "HEAD" ? void 0 : new Uint8Array(Buffer.concat(chunks));
83206
+ return new Request(url, { method, headers, ...body ? { body } : {} });
83207
+ }
83208
+ async function send(response, res) {
83209
+ const headers = {};
83210
+ response.headers.forEach((value, name) => {
83211
+ headers[name] = name === "set-cookie" ? [...headers[name] ?? [], value] : value;
83212
+ });
83213
+ res.writeHead(response.status, headers);
83214
+ if (!response.body) {
83215
+ res.end();
83216
+ return;
83217
+ }
83218
+ const reader = response.body.getReader();
83219
+ for (; ; ) {
83220
+ const { done, value } = await reader.read();
83221
+ if (done) break;
83222
+ res.write(value);
83223
+ }
83224
+ res.end();
83225
+ }
83112
83226
 
83113
83227
  // packages/mcp/src/main.ts
83114
83228
  var args = process.argv.slice(2);
@@ -83118,12 +83232,14 @@ var flag = (name) => {
83118
83232
  };
83119
83233
  var baseUrl = process.env["FORMFEED_BASE_URL"];
83120
83234
  var region = process.env["FORMFEED_REGION"];
83235
+ var authorizationServer = flag("--auth-server") ?? process.env["FORMFEED_AUTH_SERVER"];
83236
+ var publicOrigin = flag("--public-origin") ?? process.env["FORMFEED_PUBLIC_ORIGIN"];
83121
83237
  if (args.includes("--help") || args.includes("-h")) {
83122
83238
  process.stdout.write(
83123
- "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"
83239
+ "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"
83124
83240
  );
83125
83241
  } else if (args.includes("--http")) {
83126
- startHttp({ port: Number(flag("--port") ?? 8790), host: flag("--host") ?? "127.0.0.1", baseUrl, region }).then(
83242
+ startHttp({ port: Number(flag("--port") ?? 8790), host: flag("--host") ?? "127.0.0.1", baseUrl, region, authorizationServer, publicOrigin }).then(
83127
83243
  (h) => process.stderr.write(`formfeed-mcp listening on ${h.url}
83128
83244
  `)
83129
83245
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formfeed/mcp",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Model Context Protocol server for Formfeed: list templates, read data schemas, validate and render documents from AI agents.",
5
5
  "license": "MIT",
6
6
  "author": {