AI Cloud Architect
HomeBlog › Building Your First Custom MCP Server: An End-to-End Developer Tutorial

Building Your First Custom MCP Server: An End-to-End Developer Tutorial

Why the Model Context Protocol Changes Everything

Before Anthropic introduced the Model Context Protocol (MCP), connecting large language models to internal databases, ticketing systems, or local developer environments required writing bespoke, brittle glue code for every individual integration. If you wanted Claude to read from Jira, query Postgres, and inspect local Git logs, you had to maintain three separate API wrappers, handle three different authentication flows, and manually inject schema definitions into every request payload.

MCP replaces this chaotic fragmentation with a universal, open standard. By separating the client application (like Claude Code or your custom desktop app) from the data server, MCP allows you to build modular tools once and expose them securely across your entire enterprise architecture.

On Anthropic's Claude Certified Developer – Foundations (CCDV-F) exam, Domain 5 (Tools and MCPs) accounts for nearly 11% of your total score, while weaving heavily into Domain 1 (Applications and Integration, 33.1%). You are evaluated not just on theoretical definitions, but on code-level execution: instantiating servers over standard input/output (stdio), structuring JSON-RPC schemas, and returning structured error contracts.

To help you build functional muscle memory for test day, let's walk through building an end-to-end, production-ready custom MCP server in TypeScript.

Understanding the Transport Layer: stdio vs. SSE

Before writing code, you must select the appropriate communication transport. The CCDV-F blueprint routinely tests your ability to choose between local process execution and remote network routing:

Transport Type Primary Use Case Network Boundary Key CCDV-F Exam Tip
stdio (Standard I/O) Local CLI tools, IDE copilots, filesystem inspectors Runs locally on the host machine; zero network overhead Used by .mcp.json and CLAUDE.md for developer workstations
SSE (Server-Sent Events) Distributed cloud services, remote enterprise databases Operates over HTTP/HTTPS across remote server boundaries Required when connecting web applications to centralized internal APIs
Direct Client-Side Simple single-purpose scripts without shared tooling Embedded directly in the host app SDK execution loop Best for quick, isolated tasks where reusable server architecture is overkill

For this tutorial, we will build an In-Memory Enterprise Ticket Tracker using the stdio transport, allowing Claude Code to query customer support tickets and update statuses directly from your local development terminal.

Building Your MCP Server From Scratch

 

1.Initialize the Project and Install SDK Dependencies:Node.js v18+ required.

Create a clean project directory, initialize a Node TypeScript module, and install the official Anthropic MCP SDK alongside zod for robust schema validation.

Bash
mkdir mcp-ticket-server && cd mcp-ticket-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node
npx tsc --init

In your tsconfig.json, ensure "moduleResolution": "node" and "target": "ES2022" are enabled so asynchronous streams and ES modules compile correctly.

2.Instantiate the Server and Define In-Memory State:Set up the baseline server infrastructure.

Create an index.ts file. We will import the high-level McpServer class and set up a mock database of enterprise support tickets to simulate production data querying.

TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Simulate an enterprise database table
interface Ticket {
  id: string;
  customer: string;
  issue: string;
  status: "OPEN" | "IN_PROGRESS" | "RESOLVED";
  priority: "LOW" | "HIGH" | "CRITICAL";
}

const ticketDatabase: Map = new Map([
  ["TKT-101", { id: "TKT-101", customer: "Acme Corp", issue: "SSO login failure after migration", status: "OPEN", priority: "CRITICAL" }],
  ["TKT-102", { id: "TKT-102", customer: "Globex Inc", issue: "Exporting CSV causes 504 Timeout", status: "IN_PROGRESS", priority: "HIGH" }],
]);

// Initialize the MCP Server instance
const server = new McpServer({
  name: "enterprise-ticket-tracker",
  version: "1.0.0",
});
3.Register Tools Using the Action + Scope + Boundary Formula:Optimize descriptions for LLM tool selection.

A tool's JSON schema dictates how parameter syntax is formatted, but the natural language description dictates when Claude chooses to call it. On the CCDV-F exam, overlapping or vague descriptions are the primary cause of misrouted tool loops.

We will register two tools using explicit boundaries: one for reading tickets and one for updating status flags.

TypeScript
// Tool 1: Fetch Ticket Details
server.tool(
  "get_ticket_details",
  "Queries the internal support database to retrieve issue details, priority level, and customer name for a specific ticket ID. Do NOT use this tool to search by customer name; you must provide an exact TKT-XXX identifier.",
  {
    ticketId: z.string().describe("The exact ticket identifier, formatted as TKT-XXX (e.g., TKT-101)."),
  },
  async ({ ticketId }) => {
    const ticket = ticketDatabase.get(ticketId.toUpperCase());

    if (!ticket) {
      // CCDV-F Best Practice: Return structured error metadata instead of crashing
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            isError: true,
            errorCategory: "NOT_FOUND",
            userMessage: `Ticket ${ticketId} does not exist in the system.`,
            remediationInstruction: "Verify the ticket ID format. Do not guess ticket numbers without user confirmation."
          })
        }],
        isError: true,
      };
    }

    return {
      content: [{ type: "text", text: JSON.stringify(ticket, null, 2) }],
    };
  }
);

// Tool 2: Update Ticket Status
server.tool(
  "update_ticket_status",
  "Updates the operational lifecycle status of an existing customer support ticket. Use this tool only when an engineer explicitly commands a status change.",
  {
    ticketId: z.string().describe("The exact ticket identifier (e.g., TKT-101)."),
    newStatus: z.enum(["OPEN", "IN_PROGRESS", "RESOLVED"]).describe("The new lifecycle state to apply."),
  },
  async ({ ticketId, newStatus }) => {
    const ticket = ticketDatabase.get(ticketId.toUpperCase());

    if (!ticket) {
      return {
        content: [{ type: "text", text: `Error: Cannot update. Ticket ${ticketId} was not found.` }],
        isError: true,
      };
    }

    ticket.status = newStatus;
    ticketDatabase.set(ticketId, ticket);

    return {
      content: [{ type: "text", text: `Success: Ticket ${ticketId} status updated to ${newStatus}.` }],
    };
  }
);



Finally, initialize the `StdioServerTransport` and connect it to your server instance. This tells Node.js to listen for JSON-RPC messages arriving over `stdin` and route responses back through `stdout`.

typescript

async function runServer() {

const transport = new StdioServerTransport();

await server.connect(transport);

console.error("Enterprise Ticket Tracker MCP Server running on stdio");

}

runServer().catch((error) => {
  console.error("Fatal error running server:", error);
  process.exit(1);
});

*Why use `console.error` for logging?* When using the `stdio` transport, standard output (`stdout`) is reserved strictly for JSON-RPC tool payloads. If you use `console.log()` to print debug messages, you will corrupt the JSON stream and crash the client connection! Always pipe debug telemetry to `stderr` (`console.error`).



To test your server in a live coding environment without exposing secrets globally, create an `.mcp.json` file in your project root. This follows the enterprise project hierarchy tested on both the CCDV-F and CCAR-F exams.

json

{

"mcpServers": {

"ticket-tracker": {

"command": "npx",

"args": ["ts-node", "./index.ts"],

"env": {

"NODE_ENV": "development"

}

}

}

}


When you launch Claude Code in this terminal directory, it will dynamically detect `.mcp.json`, prompt you to trust the local workspace, and automatically expose `get_ticket_details` and `update_ticket_status` to Claude's tool array.


 

Defensive Engineering for Exam Day

When analyzing scenario code snippets on the proctored CCDV-F exam, you will routinely encounter implementations that look syntactically correct but fail in production. Keep these three defensive engineering rules in mind:

  1. Never Throw Unhandled Exceptions in Tool Handlers: If your database query fails or an API times out, throwing a raw Node.js exception crashes the entire stdio process. Always wrap execution logic in try/catch blocks and return a structured JSON payload with the isError: true flag. This allows Claude to read your remediationInstruction and self-correct on its next turn.

  2. Enforce Nullable Schemas for Incomplete Data: When extracting data from unstructured user requests, forcing Claude to populate optional fields causes hallucinated arguments. If an ID or filter is optional, explicitly design your Zod schema using .optional() or nullable types (z.string().nullable()).

  3. Practice Trace Debugging Without an IDE: In a proctored testing center, you cannot attach a debugger to inspect why an MCP server dropped a connection or misparsed a JSON-RPC payload. Practicing SDK execution flows and diagnosing malformed trace payloads on specialized study hubs like ccaftraining.com builds the unaided mental recall required to conquer exam scenarios under strict time limits.

By building custom MCP servers that combine strict syntactic schemas with semantic, boundary-driven tool descriptions, you transform Claude from a passive chatbot into a deterministic, enterprise-grade engineering collaborator.

Putting Claude Code to work for the CCA-F?

Test where you stand with our free, timed mock exam before you book the real thing.

Start the practice simulator
← Back to all articles