Files
forms/src/lib/mcp.ts
T

551 lines
22 KiB
TypeScript

// Minimal JSON-RPC 2.0 / MCP server. Tool-only — no resources/prompts.
import { createHash } from "crypto";
import { nanoid } from "nanoid";
import { prisma } from "./db";
import { canEditForm, canViewResults, parseFields, parseSettings } from "./forms";
import { visibleFields } from "./logic";
import { recordAudit } from "./audit";
import { snapshotForm } from "./versions";
import { publicFormUrl } from "./urls";
import type { Field, FormSettings } from "./types";
const PROTOCOL_VERSION = "2024-11-05";
type Ctx = { userId: string; role: string };
type JsonRpcReq = { jsonrpc: "2.0"; id?: number | string | null; method: string; params?: unknown };
type JsonRpcRes =
| { jsonrpc: "2.0"; id: number | string | null; result: unknown }
| { jsonrpc: "2.0"; id: number | string | null; error: { code: number; message: string; data?: unknown } };
export async function authenticate(authHeader: string | null): Promise<Ctx | null> {
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
const secret = authHeader.slice(7).trim();
const tokenHash = createHash("sha256").update(secret).digest("hex");
const t = await prisma.accessToken.findUnique({
where: { tokenHash },
include: { user: { select: { id: true, role: true } } },
});
if (!t) return null;
await prisma.accessToken.update({ where: { id: t.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return { userId: t.user.id, role: t.user.role };
}
export async function handleRpc(req: JsonRpcReq, ctx: Ctx): Promise<JsonRpcRes | null> {
const id = req.id ?? null;
try {
switch (req.method) {
case "initialize":
return ok(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {}, resources: { subscribe: false } },
serverInfo: { name: "formbuilder", version: "0.2.0" },
});
case "notifications/initialized":
return null;
case "ping":
return ok(id, {});
case "tools/list":
return ok(id, { tools: TOOL_LIST });
case "tools/call": {
const { name, arguments: args } = (req.params ?? {}) as { name: string; arguments?: Record<string, unknown> };
const tool = TOOLS[name];
if (!tool) return err(id, -32601, `Unknown tool: ${name}`);
const text = await tool(ctx, args ?? {});
return ok(id, { content: [{ type: "text", text }] });
}
case "resources/list":
return ok(id, await listResources(ctx));
case "resources/read":
return ok(id, await readResource((req.params as { uri: string }).uri, ctx));
default:
return err(id, -32601, `Unknown method: ${req.method}`);
}
} catch (e) {
return err(id, -32603, e instanceof Error ? e.message : "Internal error");
}
}
function ok(id: JsonRpcRes["id"], result: unknown): JsonRpcRes {
return { jsonrpc: "2.0", id, result };
}
function err(id: JsonRpcRes["id"], code: number, message: string): JsonRpcRes {
return { jsonrpc: "2.0", id, error: { code, message } };
}
// ---------- Tool definitions ----------
const ALL_FIELD_TYPES = ["short_text","long_text","number","email","date","select","multi_select","checkbox","rating","file","signature","page_break","calculated"] as const;
const FIELD_INPUT_SCHEMA = {
type: "object",
properties: {
type: { type: "string", enum: ALL_FIELD_TYPES },
label: { type: "string" },
help: { type: "string" },
required: { type: "boolean" },
placeholder: { type: "string" },
options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" } }, required: ["value","label"] } },
min: { type: "number" }, max: { type: "number" },
accept: { type: "string" }, maxSizeMB: { type: "number" },
expression: { type: "string" },
},
required: ["type","label"],
};
const TOOL_LIST = [
{
name: "list_forms",
description: "List forms visible to the authenticated user. Paginated.",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "Page size, max 100 (default 50)." },
cursor: { type: "string", description: "Opaque cursor returned by a previous call." },
tag: { type: "string", description: "Filter to forms with this tag slug." },
},
additionalProperties: false,
},
},
{
name: "get_form",
description: "Get a form's schema and settings by id or slug.",
inputSchema: {
type: "object",
properties: { id: { type: "string" }, slug: { type: "string" } },
additionalProperties: false,
},
},
{
name: "create_form",
description: "Create a new form. Returns the new form id and slug.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
fields: { type: "array", items: FIELD_INPUT_SCHEMA },
publish: { type: "boolean", description: "Publish immediately (default false)." },
},
required: ["title"],
additionalProperties: false,
},
},
{
name: "update_form",
description: "Update form metadata. Pass only the fields you want to change.",
inputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
published: { type: "boolean" },
settings: { type: "object", additionalProperties: true },
},
required: ["id"],
additionalProperties: false,
},
},
{
name: "delete_form",
description: "Delete a form and all of its responses. Owner or admin only.",
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
},
{
name: "add_field",
description: "Append a field to a form (or insert at index).",
inputSchema: {
type: "object",
properties: { formId: { type: "string" }, field: FIELD_INPUT_SCHEMA, index: { type: "number" } },
required: ["formId", "field"],
additionalProperties: false,
},
},
{
name: "remove_field",
description: "Remove a field by id.",
inputSchema: {
type: "object",
properties: { formId: { type: "string" }, fieldId: { type: "string" } },
required: ["formId", "fieldId"],
additionalProperties: false,
},
},
{
name: "update_field",
description: "Update properties of an existing field.",
inputSchema: {
type: "object",
properties: {
formId: { type: "string" }, fieldId: { type: "string" },
patch: { type: "object", additionalProperties: true },
},
required: ["formId", "fieldId", "patch"],
additionalProperties: false,
},
},
{
name: "reorder_fields",
description: "Set the order of fields. Pass an array of field ids.",
inputSchema: {
type: "object",
properties: { formId: { type: "string" }, order: { type: "array", items: { type: "string" } } },
required: ["formId", "order"],
additionalProperties: false,
},
},
{
name: "list_members",
description: "List workspace members. Admin only.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "set_member_role",
description: "Promote or demote a member. Admin only.",
inputSchema: {
type: "object",
properties: { userId: { type: "string" }, role: { type: "string", enum: ["admin", "member"] } },
required: ["userId", "role"],
additionalProperties: false,
},
},
{
name: "create_access_token",
description: "Mint an MCP token for the current user. Returns the secret once.",
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
},
{
name: "list_responses",
description: "List responses for a form. Caller must be owner, admin, or listed viewer. Paginated.",
inputSchema: {
type: "object",
properties: {
formId: { type: "string" },
limit: { type: "number", description: "Page size, max 200 (default 50)." },
cursor: { type: "string", description: "Opaque cursor from a previous call." },
},
required: ["formId"],
additionalProperties: false,
},
},
{
name: "get_response",
description: "Get a single response by id.",
inputSchema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "submit_response",
description: "Submit a response to a published form. Field keys may be field ids or labels.",
inputSchema: {
type: "object",
properties: {
formId: { type: "string" }, slug: { type: "string" },
data: { type: "object", additionalProperties: true },
},
required: ["data"],
additionalProperties: false,
},
},
] as const;
const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => Promise<string>> = {
async list_forms(ctx, args) {
const isAdmin = ctx.role === "admin";
const limit = Math.min(Math.max(Number(args.limit ?? 50), 1), 100);
const cursor = typeof args.cursor === "string" && args.cursor ? args.cursor : undefined;
const tagSlug = typeof args.tag === "string" && args.tag ? args.tag : undefined;
const baseWhere = isAdmin
? {}
: { OR: [{ ownerId: ctx.userId }, { viewers: { some: { userId: ctx.userId } } }, { published: true }] };
const where = tagSlug
? { AND: [baseWhere, { tags: { some: { tag: { slug: tagSlug } } } }] }
: baseWhere;
const forms = await prisma.form.findMany({
where,
orderBy: { updatedAt: "desc" },
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
include: { _count: { select: { responses: true } }, tags: { include: { tag: { select: { slug: true, name: true } } } } },
});
const hasMore = forms.length > limit;
const page = hasMore ? forms.slice(0, limit) : forms;
const nextCursor = hasMore ? page[page.length - 1].id : null;
return JSON.stringify({
forms: page.map((f) => ({
id: f.id, slug: f.slug, title: f.title, published: f.published,
responses: f._count.responses, updatedAt: f.updatedAt.toISOString(),
tags: f.tags.map((ft) => ({ slug: ft.tag.slug, name: ft.tag.name })),
})),
nextCursor,
}, null, 2);
},
async get_form(_ctx, args) {
const form = await findForm(args);
if (!form) throw new Error("Form not found");
return JSON.stringify({
id: form.id, slug: form.slug, title: form.title, description: form.description,
published: form.published, fields: parseFields(form.fields),
settings: parseSettings(form.settings),
}, null, 2);
},
async create_form(ctx, args) {
const title = String(args.title ?? "Untitled form");
const description = args.description ? String(args.description) : null;
const rawFields = Array.isArray(args.fields) ? (args.fields as Partial<Field>[]) : [];
// Always generate the id ourselves — never trust an id supplied via MCP.
const fields: Field[] = rawFields.map((f) => ({ ...(f as Field), id: nanoid(8) }));
const settings: FormSettings = { visibility: "public" };
const form = await prisma.form.create({
data: {
slug: nanoid(10), title, description, ownerId: ctx.userId,
fields: JSON.stringify(fields), settings: JSON.stringify(settings),
published: args.publish === true,
},
});
await snapshotForm({
formId: form.id,
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
authorId: ctx.userId, force: true,
});
await recordAudit({
actorId: ctx.userId, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title, via: "mcp" },
});
return JSON.stringify({ id: form.id, slug: form.slug, url: publicFormUrl(form.slug) }, null, 2);
},
async list_responses(ctx, args) {
const formId = String(args.formId);
if (!(await canViewResults(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const limit = Math.min(Math.max(Number(args.limit ?? 50), 1), 200);
const cursor = typeof args.cursor === "string" && args.cursor ? args.cursor : undefined;
const rows = await prisma.response.findMany({
where: { formId },
orderBy: { createdAt: "desc" },
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
include: { submitter: { select: { email: true, name: true } } },
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1].id : null;
return JSON.stringify({
responses: page.map((r) => ({
id: r.id, at: r.createdAt.toISOString(),
by: r.submitter ? r.submitter.email : null,
data: safeJson(r.data),
})),
nextCursor,
}, null, 2);
},
async get_response(ctx, args) {
const r = await prisma.response.findUnique({ where: { id: String(args.id) } });
if (!r) throw new Error("Not found");
if (!(await canViewResults(r.formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
return JSON.stringify({ id: r.id, formId: r.formId, at: r.createdAt.toISOString(), data: safeJson(r.data) }, null, 2);
},
async update_form(ctx, args) {
const formId = String(args.id);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const data: Record<string, unknown> = {};
if (args.title !== undefined) data.title = String(args.title);
if (args.description !== undefined) data.description = args.description === null ? null : String(args.description);
if (args.published !== undefined) data.published = !!args.published;
if (args.settings !== undefined) {
const cur = await prisma.form.findUnique({ where: { id: formId }, select: { settings: true } });
const merged = { ...parseSettings(cur?.settings ?? "{}"), ...(args.settings as FormSettings) };
data.settings = JSON.stringify(merged);
}
const out = await prisma.form.update({ where: { id: formId }, data, select: { id: true, slug: true, published: true, title: true, description: true, fields: true, settings: true } });
await snapshotForm({
formId, current: { title: out.title, description: out.description, fields: out.fields, settings: out.settings },
authorId: ctx.userId,
});
await recordAudit({
actorId: ctx.userId, action: "form.updated", entityType: "form", entityId: formId, metadata: { via: "mcp" },
});
return JSON.stringify({ id: out.id, slug: out.slug, published: out.published }, null, 2);
},
async delete_form(ctx, args) {
const formId = String(args.id);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
await prisma.form.delete({ where: { id: formId } });
await recordAudit({
actorId: ctx.userId, action: "form.deleted", entityType: "form", entityId: formId, metadata: { via: "mcp" },
});
return JSON.stringify({ ok: true }, null, 2);
},
async add_field(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const fields = parseFields(form.fields);
const incoming = args.field as Partial<Field>;
const newField: Field = { ...(incoming as Field), id: nanoid(8) };
const idx = typeof args.index === "number" ? Math.max(0, Math.min(fields.length, args.index)) : fields.length;
fields.splice(idx, 0, newField);
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
return JSON.stringify({ id: newField.id }, null, 2);
},
async remove_field(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const fields = parseFields(form.fields).filter((f) => f.id !== args.fieldId);
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
return JSON.stringify({ ok: true }, null, 2);
},
async update_field(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const fields = parseFields(form.fields).map((f) => f.id === args.fieldId ? { ...f, ...(args.patch as Partial<Field>) } : f);
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
return JSON.stringify({ ok: true }, null, 2);
},
async reorder_fields(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const order = (args.order as string[]) ?? [];
const fields = parseFields(form.fields);
const map = new Map(fields.map((f) => [f.id, f]));
const ordered: Field[] = [];
for (const id of order) { const f = map.get(id); if (f) { ordered.push(f); map.delete(id); } }
for (const f of map.values()) ordered.push(f); // append any unspecified
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(ordered) } });
return JSON.stringify({ ok: true }, null, 2);
},
async list_members(ctx) {
if (ctx.role !== "admin") throw new Error("Forbidden — admin only");
const members = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return JSON.stringify(members.map((m) => ({ ...m, createdAt: m.createdAt.toISOString() })), null, 2);
},
async set_member_role(ctx, args) {
if (ctx.role !== "admin") throw new Error("Forbidden — admin only");
const role = args.role === "admin" ? "admin" : "member";
await prisma.user.update({ where: { id: String(args.userId) }, data: { role } });
return JSON.stringify({ ok: true }, null, 2);
},
async create_access_token(ctx, args) {
const { createHash, randomBytes } = await import("node:crypto");
const secret = `fb_${randomBytes(24).toString("hex")}`;
const tokenHash = createHash("sha256").update(secret).digest("hex");
const prefix = secret.slice(0, 10);
const t = await prisma.accessToken.create({
data: { userId: ctx.userId, name: String(args.name ?? "MCP token"), tokenHash, prefix },
});
await recordAudit({
actorId: ctx.userId, action: "token.created", entityType: "token", entityId: t.id,
metadata: { name: t.name, via: "mcp" },
});
return JSON.stringify({ secret, note: "Copy now — it won't be shown again." }, null, 2);
},
async submit_response(ctx, args) {
const form = await findForm(args);
if (!form || !form.published) throw new Error("Form not available");
const fields = parseFields(form.fields);
// Allow callers to use either field ids OR labels as keys.
const incoming = (args.data ?? {}) as Record<string, unknown>;
const byId: Record<string, unknown> = {};
for (const f of fields) {
if (f.id in incoming) byId[f.id] = incoming[f.id];
else if (f.label in incoming) byId[f.id] = incoming[f.label];
}
const shown = visibleFields(fields, byId);
for (const f of shown) {
const v = byId[f.id];
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
if (f.required && empty) throw new Error(`Missing: ${f.label}`);
}
const r = await prisma.response.create({
data: { formId: form.id, submitterId: ctx.userId, data: JSON.stringify(byId) },
});
return JSON.stringify({ id: r.id }, null, 2);
},
};
async function findForm(args: Record<string, unknown>) {
if (args.id) return prisma.form.findUnique({ where: { id: String(args.id) } });
if (args.slug) return prisma.form.findUnique({ where: { slug: String(args.slug) } });
return null;
}
function safeJson(s: string): unknown {
try { return JSON.parse(s); } catch { return s; }
}
// ---------- Resources ----------
//
// formbuilder://forms/{id} → full form schema
// formbuilder://forms/{id}/responses → recent responses (capped at 200)
async function listResources(ctx: Ctx) {
const isAdmin = ctx.role === "admin";
const forms = await prisma.form.findMany({
where: isAdmin ? {} : { OR: [{ ownerId: ctx.userId }, { viewers: { some: { userId: ctx.userId } } }, { published: true }] },
orderBy: { updatedAt: "desc" },
select: { id: true, slug: true, title: true },
});
const resources = forms.flatMap((f) => [
{ uri: `formbuilder://forms/${f.id}`, name: f.title, mimeType: "application/json", description: `Form schema (${f.slug})` },
{ uri: `formbuilder://forms/${f.id}/responses`, name: `${f.title} — responses`, mimeType: "application/json" },
]);
return { resources };
}
async function readResource(uri: string, ctx: Ctx) {
const m = uri.match(/^formbuilder:\/\/forms\/([^/]+)(?:\/(responses))?$/);
if (!m) throw new Error(`Unknown resource: ${uri}`);
const formId = m[1];
const kind = m[2];
if (kind === "responses") {
if (!(await canViewResults(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const responses = await prisma.response.findMany({
where: { formId }, orderBy: { createdAt: "desc" }, take: 200,
include: { submitter: { select: { email: true } } },
});
const body = responses.map((r) => ({
id: r.id, at: r.createdAt.toISOString(), by: r.submitter?.email ?? null, data: safeJson(r.data),
}));
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
}
const form = await prisma.form.findUnique({ where: { id: formId } });
if (!form) throw new Error("Not found");
const body = {
id: form.id, slug: form.slug, title: form.title, description: form.description,
published: form.published, fields: parseFields(form.fields), settings: parseSettings(form.settings),
};
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
}