@formfeed/mcp 0.3.13 → 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 +135 -30
- 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
|
|
@@ -81976,7 +81978,8 @@ var Formfeed = class {
|
|
|
81976
81978
|
`render ${target.id} has no output (${target.status})`,
|
|
81977
81979
|
0
|
|
81978
81980
|
);
|
|
81979
|
-
const
|
|
81981
|
+
const fetchImpl = this.fetchImpl;
|
|
81982
|
+
const res = await fetchImpl(target.download_url, {
|
|
81980
81983
|
signal: options.signal
|
|
81981
81984
|
});
|
|
81982
81985
|
if (!res.ok)
|
|
@@ -82492,8 +82495,9 @@ var Formfeed = class {
|
|
|
82492
82495
|
() => controller.abort(options.signal?.reason)
|
|
82493
82496
|
);
|
|
82494
82497
|
let res;
|
|
82498
|
+
const fetchImpl = this.fetchImpl;
|
|
82495
82499
|
try {
|
|
82496
|
-
res = await
|
|
82500
|
+
res = await fetchImpl(url, {
|
|
82497
82501
|
method,
|
|
82498
82502
|
headers,
|
|
82499
82503
|
body: body === void 0 ? void 0 : isForm ? body : JSON.stringify(body),
|
|
@@ -82565,7 +82569,7 @@ var encoder2 = new TextEncoder();
|
|
|
82565
82569
|
// packages/mcp/src/lib/server.ts
|
|
82566
82570
|
import { z as z3 } from "zod";
|
|
82567
82571
|
var CONVERT_LIMIT_BYTES = 20 * 1024 * 1024;
|
|
82568
|
-
var SERVER_INFO = { name: "formfeed", version: "0.3.
|
|
82572
|
+
var SERVER_INFO = { name: "formfeed", version: "0.3.14" };
|
|
82569
82573
|
var READS = {
|
|
82570
82574
|
readOnlyHint: true,
|
|
82571
82575
|
destructiveHint: false,
|
|
@@ -82637,7 +82641,7 @@ function createFormfeedServer(options) {
|
|
|
82637
82641
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
82638
82642
|
});
|
|
82639
82643
|
const server = new McpServer(SERVER_INFO, {
|
|
82640
|
-
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."
|
|
82641
82645
|
});
|
|
82642
82646
|
server.registerTool(
|
|
82643
82647
|
"list_templates",
|
|
@@ -82961,6 +82965,30 @@ function createFormfeedServer(options) {
|
|
|
82961
82965
|
}
|
|
82962
82966
|
}
|
|
82963
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
|
+
);
|
|
82964
82992
|
return server;
|
|
82965
82993
|
}
|
|
82966
82994
|
|
|
@@ -82993,12 +83021,20 @@ function tokenExpired(token, now = Date.now()) {
|
|
|
82993
83021
|
}
|
|
82994
83022
|
}
|
|
82995
83023
|
function json(body, status, headers = {}) {
|
|
82996
|
-
return new Response(JSON.stringify(body), {
|
|
83024
|
+
return new Response(JSON.stringify(body), {
|
|
83025
|
+
status,
|
|
83026
|
+
headers: { "content-type": "application/json", ...headers }
|
|
83027
|
+
});
|
|
82997
83028
|
}
|
|
82998
83029
|
function bearer(request) {
|
|
82999
83030
|
const auth = request.headers.get("authorization") ?? "";
|
|
83000
83031
|
return /^Bearer\s+/i.test(auth) ? auth.replace(/^Bearer\s+/i, "").trim() : "";
|
|
83001
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
|
+
}
|
|
83002
83038
|
function mcpFetchHandler(options = {}) {
|
|
83003
83039
|
const authorizationServer2 = options.authorizationServer?.replace(/\/$/, "");
|
|
83004
83040
|
return async (request) => {
|
|
@@ -83007,41 +83043,99 @@ function mcpFetchHandler(options = {}) {
|
|
|
83007
83043
|
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
83008
83044
|
if (path === "/healthz") return json({ ok: true }, 200);
|
|
83009
83045
|
if (path === RESOURCE_METADATA_PATH || path.startsWith(`${RESOURCE_METADATA_PATH}${MCP_PATH}`)) {
|
|
83010
|
-
if (!authorizationServer2)
|
|
83011
|
-
|
|
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
|
+
);
|
|
83012
83056
|
if (path === RESOURCE_METADATA_PATH || path === `${RESOURCE_METADATA_PATH}${MCP_PATH}`)
|
|
83013
83057
|
return json(
|
|
83014
83058
|
{
|
|
83015
83059
|
resource: `${origin}${MCP_PATH}`,
|
|
83016
83060
|
authorization_servers: [authorizationServer2],
|
|
83017
83061
|
bearer_methods_supported: ["header"],
|
|
83062
|
+
scopes_supported: ["openid", "email", "profile"],
|
|
83018
83063
|
resource_name: "Formfeed",
|
|
83019
83064
|
resource_documentation: "https://docs.formfeed.dev/integrations/mcp",
|
|
83020
|
-
//
|
|
83065
|
+
// a signed-in client works in the workspace chosen at consent; this path pins another one
|
|
83021
83066
|
formfeed_workspace_path: `${origin}${MCP_PATH}/{workspace_id}`
|
|
83022
83067
|
},
|
|
83023
83068
|
200
|
|
83024
83069
|
);
|
|
83025
|
-
if (!WORKSPACE_ID.test(workspaceId2))
|
|
83026
|
-
|
|
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
|
+
);
|
|
83027
83076
|
}
|
|
83028
|
-
if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`))
|
|
83077
|
+
if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`))
|
|
83078
|
+
return new Response(null, { status: 404 });
|
|
83029
83079
|
const workspaceId = path === MCP_PATH ? null : path.slice(MCP_PATH.length + 1);
|
|
83030
83080
|
if (workspaceId !== null && !WORKSPACE_ID.test(workspaceId))
|
|
83031
|
-
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
|
+
);
|
|
83032
83087
|
const credential = bearer(request);
|
|
83033
83088
|
if (workspaceId === null) {
|
|
83034
|
-
|
|
83035
|
-
if (!
|
|
83036
|
-
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
|
+
}
|
|
83037
83118
|
} else {
|
|
83038
|
-
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
|
+
);
|
|
83039
83126
|
const metadata = `${origin}${RESOURCE_METADATA_PATH}${MCP_PATH}/${workspaceId}`;
|
|
83040
|
-
|
|
83041
|
-
|
|
83042
|
-
|
|
83043
|
-
|
|
83044
|
-
|
|
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
|
+
);
|
|
83045
83139
|
}
|
|
83046
83140
|
const mcp = createFormfeedServer({
|
|
83047
83141
|
apiKey: credential,
|
|
@@ -83050,7 +83144,10 @@ function mcpFetchHandler(options = {}) {
|
|
|
83050
83144
|
...options.region ? { region: options.region } : {},
|
|
83051
83145
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
83052
83146
|
});
|
|
83053
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
83147
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
83148
|
+
sessionIdGenerator: void 0,
|
|
83149
|
+
enableJsonResponse: true
|
|
83150
|
+
});
|
|
83054
83151
|
await mcp.connect(transport);
|
|
83055
83152
|
try {
|
|
83056
83153
|
return await transport.handleRequest(request);
|
|
@@ -83073,8 +83170,11 @@ async function startHttp(options = {}) {
|
|
|
83073
83170
|
const handler = mcpFetchHandler(handlerOptions);
|
|
83074
83171
|
const server = createServer((req, res) => {
|
|
83075
83172
|
void toRequest(req, host).then(handler).then((response) => send(response, res)).catch((e) => {
|
|
83076
|
-
if (!res.headersSent)
|
|
83077
|
-
|
|
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
|
+
);
|
|
83078
83178
|
});
|
|
83079
83179
|
});
|
|
83080
83180
|
await new Promise((resolve2, reject) => {
|
|
@@ -83132,13 +83232,18 @@ var authorizationServer = flag("--auth-server") ?? process.env["FORMFEED_AUTH_SE
|
|
|
83132
83232
|
var publicOrigin = flag("--public-origin") ?? process.env["FORMFEED_PUBLIC_ORIGIN"];
|
|
83133
83233
|
if (args.includes("--help") || args.includes("-h")) {
|
|
83134
83234
|
process.stdout.write(
|
|
83135
|
-
"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"
|
|
83136
83236
|
);
|
|
83137
83237
|
} else if (args.includes("--http")) {
|
|
83138
|
-
startHttp({
|
|
83139
|
-
(
|
|
83140
|
-
|
|
83141
|
-
|
|
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
|
+
`));
|
|
83142
83247
|
} else {
|
|
83143
83248
|
const apiKey = process.env["FORMFEED_API_KEY"];
|
|
83144
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",
|