#!/usr/bin/env node
import "dotenv/config";
import { mkdir, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { DocuSealApiError, DocuSealClient } from "./client.js";
const DOCUSEAL_URL = process.env.DOCUSEAL_URL ?? "http://localhost:3030";
const DOCUSEAL_API_KEY = process.env.DOCUSEAL_API_KEY;
if (!DOCUSEAL_API_KEY) {
console.error("DOCUSEAL_API_KEY is required");
process.exit(1);
}
const client = new DocuSealClient({
baseUrl: DOCUSEAL_URL,
apiKey: DOCUSEAL_API_KEY,
});
const server = new McpServer({
name: "docuseal-mcp-server",
version: "0.1.0",
});
function ok(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify({ success: true, data }, null, 2) }],
structuredContent: { success: true, data },
};
}
function fail(error: unknown) {
if (error instanceof DocuSealApiError) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
{
success: false,
error: error.message,
status: error.status,
details: error.body,
},
null,
2
),
},
],
structuredContent: {
success: false,
error: error.message,
status: error.status,
details: error.body,
},
isError: true,
};
}
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: message }, null, 2) }],
structuredContent: { success: false, error: message },
isError: true,
};
}
async function downloadDocuments(
docs: Array<{ name?: string; url: string }>,
outputDir: string
): Promise<Array<{ name?: string; url: string; file_path: string }>> {
await mkdir(outputDir, { recursive: true });
const saved: Array<{ name?: string; url: string; file_path: string }> = [];
for (const [index, doc] of docs.entries()) {
const response = await fetch(doc.url, {
headers: { "X-Auth-Token": DOCUSEAL_API_KEY! },
});
if (!response.ok) {
throw new Error(`Failed to download document from ${doc.url}: HTTP ${response.status}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
const safeName = doc.name ? `${doc.name.replace(/[^a-zA-Z0-9-_]/g, "_")}.pdf` : `document_${index + 1}.pdf`;
const filePath = join(outputDir, safeName || basename(new URL(doc.url).pathname));
await writeFile(filePath, bytes);
saved.push({ ...doc, file_path: filePath });
}
return saved;
}
function registerTools() {
server.registerTool(
"list_templates",
{
description:
"List templates with pagination. Parameters: limit (max 100), after, before, q (search), archived.",
inputSchema: {
limit: z.number().int().min(1).max(100).optional().describe("Number of templates to return (1-100)."),
after: z.number().int().optional().describe("Return items with ID greater than this value."),
before: z.number().int().optional().describe("Return items with ID less than this value."),
q: z.string().optional().describe("Search query for template name or related fields."),
archived: z.boolean().optional().describe("When true, returns archived templates."),
},
},
async (input) => {
try {
return ok(await client.listTemplates(input));
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"get_template",
{
description: "Get a template by template ID.",
inputSchema: {
template_id: z.number().int().describe("Unique DocuSeal template ID."),
},
},
async ({ template_id }) => {
try {
return ok(await client.getTemplate(template_id));
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"create_template_from_pdf",
{
description:
"Upload a PDF and create a template. Provide either file_path (preferred) or file_base64. Optional: name, filename.",
inputSchema: {
name: z.string().optional().describe("Optional template name."),
file_path: z.string().optional().describe("Absolute path to a local PDF file."),
file_base64: z.string().optional().describe("Base64-encoded PDF bytes."),
filename: z.string().optional().describe("Optional filename when using base64 upload."),
},
},
async ({ name, file_path, file_base64, filename }) => {
try {
if (!file_path && !file_base64) {
throw new Error("Either file_path or file_base64 is required");
}
return ok(
await client.createTemplateFromPdf({
name,
filePath: file_path,
fileBase64: file_base64,
filename,
})
);
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"create_submission",
{
description:
"Create a signature request from a template. Required: template_id and signers (submitters). Optional: message, send_email.",
inputSchema: {
template_id: z.number().int().describe("Template ID used for the signature request."),
signers: z
.array(
z.object({
name: z.string().optional().describe("Signer name."),
email: z.string().email().optional().describe("Signer email address."),
role: z.string().optional().describe("Signer role matching template role, e.g. First Party."),
phone: z.string().optional().describe("Signer phone in E.164 format, e.g. +1234567890."),
values: z.record(z.any()).optional().describe("Optional prefilled field values."),
})
)
.min(1)
.describe("List of signers/submitters."),
message: z
.union([
z.string().describe("Message body for signature email."),
z.object({
subject: z.string().optional(),
body: z.string().optional(),
}),
])
.optional(),
send_email: z.boolean().optional().describe("Set false to disable signature request emails."),
},
},
async ({ template_id, signers, message, send_email }) => {
try {
return ok(
await client.createSubmission({
template_id,
submitters: signers,
message,
send_email,
})
);
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"list_submissions",
{
description:
"List submissions with pagination and filters. Supports: status, template_id, q, archived, after, before, limit.",
inputSchema: {
template_id: z.number().int().optional(),
status: z.enum(["pending", "completed", "declined", "expired"]).optional(),
q: z.string().optional(),
slug: z.string().optional(),
template_folder: z.string().optional(),
archived: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
after: z.number().int().optional(),
before: z.number().int().optional(),
},
},
async (input) => {
try {
return ok(await client.listSubmissions(input));
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"get_submission",
{
description: "Get full submission details by submission ID.",
inputSchema: {
submission_id: z.number().int(),
},
},
async ({ submission_id }) => {
try {
return ok(await client.getSubmission(submission_id));
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"get_submission_documents",
{
description:
"Get submission documents. Optionally merge into one PDF and download documents locally by setting download=true.",
inputSchema: {
submission_id: z.number().int().describe("Submission ID."),
merge: z.boolean().optional().describe("When true, request merged documents from DocuSeal."),
download: z.boolean().optional().describe("When true, download returned documents to output_dir."),
output_dir: z
.string()
.optional()
.describe("Directory to save files when download=true. Defaults to ./downloads/submission_<id>."),
},
},
async ({ submission_id, merge, download, output_dir }) => {
try {
const result = await client.getSubmissionDocuments(submission_id, merge);
if (!download) {
return ok(result);
}
const defaultDir = join(process.cwd(), "downloads", `submission_${submission_id}`);
const savedDocuments = await downloadDocuments(result.documents ?? [], output_dir ?? defaultDir);
return ok({ ...result, saved_documents: savedDocuments });
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"list_submitters",
{
description: "List submitters. Optionally filter by submission_id. Supports pagination via limit/after/before.",
inputSchema: {
submission_id: z.number().int().optional().describe("Filter submitters by submission ID."),
limit: z.number().int().min(1).max(100).optional(),
after: z.number().int().optional(),
before: z.number().int().optional(),
},
},
async (input) => {
try {
return ok(await client.listSubmitters(input));
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"get_submitter",
{
description: "Get submitter details by submitter ID.",
inputSchema: {
submitter_id: z.number().int().describe("Unique submitter ID."),
},
},
async ({ submitter_id }) => {
try {
return ok(await client.getSubmitter(submitter_id));
} catch (error) {
return fail(error);
}
}
);
server.registerTool(
"update_submitter",
{
description:
"Update a submitter by ID. Accepts common fields like email, phone, name, completed, send_email, send_sms, and metadata.",
inputSchema: {
submitter_id: z.number().int().describe("Unique submitter ID."),
email: z.string().email().optional(),
phone: z.string().optional(),
name: z.string().optional(),
completed: z.boolean().optional().describe("Set true to mark submitter as completed/signed via API."),
send_email: z.boolean().optional().describe("Set true to (re)send signature request email where supported."),
send_sms: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
values: z.record(z.any()).optional().describe("Optional field values updates."),
},
},
async ({ submitter_id, ...payload }) => {
try {
return ok(await client.updateSubmitter(submitter_id, payload));
} catch (error) {
return fail(error);
}
}
);
}
async function main() {
registerTools();
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error("Failed to start docuseal-mcp-server:", error instanceof Error ? error.message : error);
process.exit(1);
});