Building Your First MCP Server: Step-by-Step Tutorial
This tutorial takes you from zero to a deployed, monetized MCP server in under two hours. No prior MCP experience needed. We'll build a real, useful MCP server and publish it on SkillExchange.
What We're Building
A PDF generation MCP server that takes structured data and produces formatted PDF documents. Real-world useful, technically instructive, and genuinely monetizable.
Agents will be able to call your server to:
- Generate invoices from JSON data
- Create formatted reports from markdown
- Produce certificates from templates
Prerequisites
- Node.js 20+ installed
- Basic TypeScript knowledge
- A SkillExchange account (free)
- 60β90 minutes
Step 1: Project Setup
mkdir mcp-pdf-server && cd mcp-pdf-server
npm init -y
npm install @modelcontextprotocol/sdk pdfkit
npm install -D typescript @types/node tsx
npx tsc --init
Configure tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Step 2: Define Your Tools
An MCP server exposes tools β each tool is a capability an agent can invoke. Let's define three tools:
// src/tools.ts
export const toolDefinitions = [
{
name: "generate_invoice",
description: "Generate a professional PDF invoice from structured data. Returns a base64-encoded PDF.",
inputSchema: {
type: "object",
properties: {
invoiceNumber: { type: "string", description: "Unique invoice number" },
date: { type: "string", description: "Invoice date (ISO 8601)" },
from: {
type: "object",
properties: {
name: { type: "string" },
address: { type: "string" },
email: { type: "string" }
}
},
to: {
type: "object",
properties: {
name: { type: "string" },
address: { type: "string" },
email: { type: "string" }
}
},
items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
quantity: { type: "number" },
unitPrice: { type: "number" }
}
}
},
currency: { type: "string", description: "ISO currency code, e.g. EUR, USD" }
},
required: ["invoiceNumber", "date", "from", "to", "items"]
}
},
{
name: "generate_report",
description: "Generate a formatted PDF report from markdown content.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
subtitle: { type: "string" },
content: { type: "string", description: "Markdown content for the report body" },
author: { type: "string" }
},
required: ["title", "content"]
}
},
{
name: "generate_certificate",
description: "Generate a PDF certificate of completion or achievement.",
inputSchema: {
type: "object",
properties: {
recipientName: { type: "string" },
courseName: { type: "string" },
date: { type: "string" },
issuer: { type: "string" }
},
required: ["recipientName", "courseName", "issuer"]
}
}
];
Step 3: Implement the Server
// src/server.ts
import { Server } from "@modelcontextprotocol/sdk/server";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
import PDFDocument from "pdfkit";
import { toolDefinitions } from "./tools.js";
import {
generateInvoice,
generateReport,
generateCertificate
} from "./generators.js";
const server = new Server(
{ name: "pdf-generator", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Handle tool discovery
server.setRequestHandler("tools/list", async () => ({
tools: toolDefinitions
}));
// Handle tool invocation
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "generate_invoice": {
const pdfBuffer = await generateInvoice(args);
return {
content: [
{
type: "text",
text: `Invoice generated successfully. PDF size: ${pdfBuffer.length} bytes.`
},
{
type: "resource",
resource: {
uri: `data:application/pdf;base64,${pdfBuffer.toString("base64")}`,
mimeType: "application/pdf",
name: `invoice-${args.invoiceNumber}.pdf`
}
}
]
};
}
case "generate_report": {
const pdfBuffer = await generateReport(args);
return {
content: [
{
type: "text",
text: `Report "${args.title}" generated successfully.`
},
{
type: "resource",
resource: {
uri: `data:application/pdf;base64,${pdfBuffer.toString("base64")}`,
mimeType: "application/pdf",
name: `report-${Date.now()}.pdf`
}
}
]
};
}
case "generate_certificate": {
const pdfBuffer = await generateCertificate(args);
return {
content: [
{
type: "text",
text: `Certificate for ${args.recipientName} generated successfully.`
},
{
type: "resource",
resource: {
uri: `data:application/pdf;base64,${pdfBuffer.toString("base64")}`,
mimeType: "application/pdf",
name: `certificate-${args.recipientName}.pdf`
}
}
]
};
}
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true
};
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true
};
}
});
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("PDF Generator MCP Server running");
Step 4: Implement the Generators
// src/generators.ts
import PDFDocument from "pdfkit";
export async function generateInvoice(data: any): Promise<Buffer> {
return new Promise((resolve) => {
const doc = new PDFDocument({ size: "A4", margin: 50 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
// Header
doc.fontSize(24).font("Helvetica-Bold").text("INVOICE", { align: "right" });
doc.moveDown();
// Invoice details
doc.fontSize(10).font("Helvetica")
.text(`Invoice #: ${data.invoiceNumber}`)
.text(`Date: ${data.date}`)
.moveDown();
// From / To
doc.font("Helvetica-Bold").text("From:", 50, doc.y);
doc.font("Helvetica")
.text(data.from.name)
.text(data.from.address)
.text(data.from.email)
.moveDown();
doc.font("Helvetica-Bold").text("Bill To:");
doc.font("Helvetica")
.text(data.to.name)
.text(data.to.address)
.text(data.to.email)
.moveDown();
// Items table
const currency = data.currency || "EUR";
const symbol = currency === "EUR" ? "β¬" : currency === "USD" ? "$" : currency;
doc.font("Helvetica-Bold")
.text("Description", 50, doc.y, { width: 250 })
.text("Qty", 300, doc.y, { width: 50, align: "right" })
.text("Unit Price", 380, doc.y, { width: 80, align: "right" })
.text("Total", 470, doc.y, { width: 80, align: "right" })
.moveDown();
let total = 0;
doc.font("Helvetica");
for (const item of data.items) {
const lineTotal = item.quantity * item.unitPrice;
total += lineTotal;
doc.text(item.description, 50, doc.y, { width: 250 })
.text(String(item.quantity), 300, doc.y, { width: 50, align: "right" })
.text(`${symbol}${item.unitPrice.toFixed(2)}`, 380, doc.y, { width: 80, align: "right" })
.text(`${symbol}${lineTotal.toFixed(2)}`, 470, doc.y, { width: 80, align: "right" })
.moveDown(0.5);
}
doc.moveDown()
.font("Helvetica-Bold").fontSize(12)
.text(`Total: ${symbol}${total.toFixed(2)}`, { align: "right" });
doc.end();
});
}
export async function generateReport(data: any): Promise<Buffer> {
return new Promise((resolve) => {
const doc = new PDFDocument({ size: "A4", margin: 72 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
// Title page
doc.fontSize(28).font("Helvetica-Bold").text(data.title, { align: "center" });
if (data.subtitle) {
doc.moveDown().fontSize(14).font("Helvetica").text(data.subtitle, { align: "center" });
}
if (data.author) {
doc.moveDown(2).fontSize(10).text(`By ${data.author}`, { align: "center" });
}
doc.moveDown(4);
// Content (basic markdown parsing)
doc.fontSize(11);
const lines = data.content.split("\n");
for (const line of lines) {
if (line.startsWith("## ")) {
doc.moveDown().font("Helvetica-Bold").fontSize(16).text(line.slice(3));
doc.fontSize(11).font("Helvetica");
} else if (line.startsWith("# ")) {
doc.moveDown().font("Helvetica-Bold").fontSize(20).text(line.slice(2));
doc.fontSize(11).font("Helvetica");
} else if (line.startsWith("- ")) {
doc.text(` β’ ${line.slice(2)}`);
} else if (line.trim() === "") {
doc.moveDown(0.5);
} else {
doc.text(line);
}
}
doc.end();
});
}
export async function generateCertificate(data: any): Promise<Buffer> {
return new Promise((resolve) => {
const doc = new PDFDocument({ size: "A4", layout: "landscape", margin: 50 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
// Border
doc.rect(20, 20, doc.page.width - 40, doc.page.height - 40).strokeColor("#4F46E5").lineWidth(3).stroke();
// Title
doc.fontSize(36).font("Helvetica-Bold").fillColor("#4F46E5")
.text("Certificate of Completion", { align: "center" }, 120);
// Recipient
doc.moveDown(2).fontSize(16).font("Helvetica").fillColor("#000")
.text("This is to certify that", { align: "center" });
doc.moveDown().fontSize(28).font("Helvetica-Bold").fillColor("#4F46E5")
.text(data.recipientName, { align: "center" });
doc.moveDown().fontSize(16).font("Helvetica").fillColor("#000")
.text(`has successfully completed ${data.courseName}`, { align: "center" });
// Footer
doc.moveDown(4).fontSize(12)
.text(`Issued by: ${data.issuer}`, { align: "center" });
if (data.date) {
doc.text(`Date: ${data.date}`, { align: "center" });
}
doc.end();
});
}
Step 5: Test Locally
Test with the MCP Inspector before publishing:
npx @modelcontextprotocol/inspector npx tsx src/server.ts
This opens a web UI where you can:
- See your tools listed
- Call each tool with test inputs
- Verify the PDF output is correct
Test the invoice generator:
{
"invoiceNumber": "INV-001",
"date": "2026-08-06",
"from": {
"name": "Acme Corp",
"address": "123 Main St, Berlin",
"email": "billing@acme.com"
},
"to": {
"name": "Client GmbH",
"address": "456 Oak Ave, Munich",
"email": "ap@client.com"
},
"items": [
{ "description": "Consulting services", "quantity": 10, "unitPrice": 150 },
{ "description": "Software license", "quantity": 1, "unitPrice": 500 }
],
"currency": "EUR"
}
Step 6: Add Error Handling and Input Validation
Production MCP servers need robust validation:
// src/validation.ts
export function validateInvoiceInput(args: any): string[] {
const errors: string[] = [];
if (!args.invoiceNumber || typeof args.invoiceNumber !== "string") {
errors.push("invoiceNumber is required and must be a string");
}
if (!args.items || !Array.isArray(args.items) || args.items.length === 0) {
errors.push("items must be a non-empty array");
}
for (const [i, item] of (args.items || []).entries()) {
if (item.quantity <= 0) errors.push(`items[${i}].quantity must be positive`);
if (item.unitPrice < 0) errors.push(`items[${i}].unitPrice must be non-negative`);
}
return errors;
}
Step 7: Publish to SkillExchange
- Create a skill manifest:
{
"name": "pdf-generator",
"displayName": "PDF Generator",
"description": "Generate professional PDF invoices, reports, and certificates from structured data via MCP",
"version": "1.0.0",
"protocol": "mcp",
"transport": "stdio",
"categories": ["document", "business", "utility"],
"pricing": {
"model": "per-invocation",
"price": 0.05,
"freeTier": 50
}
}
Deploy your server (SkillExchange handles hosting when you publish)
Set your pricing β β¬0.05/call with 50 free calls for new users is a good starting point
Write a compelling description that helps agents understand when to use your skill
Step 8: Monitor and Iterate
Once published, track these metrics on SkillExchange:
- Discovery rate β How often agents find your skill
- Trial rate β How many try the free tier
- Conversion rate β Free β paid conversion
- Error rate β Failed invocations (aim for <0.1%)
- Latency β Average response time (aim for <2s)
Update your skill weekly in the beginning. Fix errors fast, add features based on usage patterns, and adjust pricing based on conversion data.
Next Steps
- Add more document types (contracts, proposals, letters)
- Support custom templates and branding
- Add multi-language support
- Create a skill chain that combines document generation with email delivery
Your MCP server is now live, monetized, and available to any AI agent in the world. Welcome to the creator economy of the AI age.