Back to Blog

Building Your First MCP Server: Step-by-Step Tutorial

Ultrion TeamAugust 6, 202615 min read

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:

  1. See your tools listed
  2. Call each tool with test inputs
  3. 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

  1. 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
  }
}
  1. Deploy your server (SkillExchange handles hosting when you publish)

  2. Set your pricing β€” €0.05/call with 50 free calls for new users is a good starting point

  3. 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.

Newsletter

Enjoying this article?

Get weekly insights on building and selling AI skills, MCP tools, and creator economics. Join 2,000+ AI builders and creators.

No spam. Unsubscribe anytime.

Get the Free MCP Server Handbook

50+ pages of practical guides, code examples, and production-ready templates.

  • Complete MCP protocol reference
  • 15+ production-ready templates
  • Security best practices guide

No spam. Unsubscribe anytime. We respect your privacy.

Related Articles

Ready to try AI skills?

Browse the marketplace and discover skills for your AI agents.

Browse Skills