You have read about the Model Context Protocol, you understand what an MCP server does, and now you want to build one. This tutorial walks you through building a working MCP server from scratch - one that exposes a database query tool, a readable resource, and a reusable prompt template. By the end you will have a foundation you can extend for any real-world integration.

This guide uses TypeScript and the official Anthropic SDK. The same concepts apply in Python if that is your preferred language - the SDK interfaces are nearly identical.

Prerequisites

Before building your MCP server, make sure you have the following in place:

  • Node.js 18 or later - the SDK requires modern Node features. Check with node --version.
  • TypeScript basics - you need to be comfortable with interfaces, async/await, and generics. You do not need to be an expert.
  • Understanding of what MCP is - if you have not read the conceptual overview of the Model Context Protocol and how tools, resources, and prompts fit together, start there before writing any code. The protocol design decisions will make more sense with that context.
  • Claude Desktop or Cursor - you will need an MCP-compatible host to test your server against. Claude Desktop is the easiest starting point.
  • A package manager - npm, pnpm, or yarn. This tutorial uses npm.

Setting Up the Project

Create a new directory and initialize a Node.js project with TypeScript support.

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install --save-dev typescript @types/node tsx

The @modelcontextprotocol/sdk package is the official Anthropic SDK for building MCP servers in JavaScript and TypeScript. The tsx package lets you run TypeScript files directly without a separate compile step, which speeds up development.

Add a tsconfig.json at the project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Update your package.json to add build and start scripts:

{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Create the src/ directory and an entry point file:

mkdir src
touch src/index.ts
What you will build in this tutorial
1
Server init - Create an MCP server instance with stdio transport
2
Tools - Expose a query_database tool the AI can call
3
Resources - Add readable data at db://schema
4
Prompts - Create a reusable "analyze data" prompt template
5
Test - Connect to Claude Desktop and verify everything works

Building a Basic MCP Server

The core of any MCP server is the Server instance and a transport. Start with a minimal working server, then layer in capabilities.

Open src/index.ts and add the following:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  {
    name: "my-mcp-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
      resources: {},
      prompts: {},
    },
  }
);

The first argument is your server's identity - the name and version that MCP clients will see when they connect. The second argument declares what capabilities your MCP server supports. Declare only what you implement.

Exposing a Database Query Tool

Tools are the most common capability in a real MCP server. This example exposes aquery_users tool that simulates a database lookup. In production you would replace the mock data with an actual database client like pg for Postgres ormysql2 for MySQL.

// Mock database - replace with your actual DB client
const mockUsers = [
  { id: 1, name: "Alice Chen", email: "[email protected]", plan: "pro" },
  { id: 2, name: "Bob Torres", email: "[email protected]", plan: "free" },
  { id: 3, name: "Carol Smith", email: "[email protected]", plan: "enterprise" },
];

// List all tools the MCP server exposes
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "query_users",
        description:
          "Query the users database. Optionally filter by plan type (free, pro, enterprise).",
        inputSchema: {
          type: "object",
          properties: {
            plan: {
              type: "string",
              description: "Filter users by plan type. Omit to return all users.",
              enum: ["free", "pro", "enterprise"],
            },
          },
          required: [],
        },
      },
    ],
  };
});

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "query_users") {
    const plan = args?.plan as string | undefined;
    const results = plan
      ? mockUsers.filter((u) => u.plan === plan)
      : mockUsers;

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

  throw new Error(`Unknown tool: ${name}`);
});

Two things to pay attention to here. First, the description field on each tool is what the AI model reads to decide when to call it. Write these descriptions as if you are explaining the tool to a capable but uninformed colleague. Second, the inputSchemafollows JSON Schema format. The model uses this to know what arguments to pass.

Adding Resources

Resources are readable data sources your MCP server makes available - similar to files or documents the model can read for context. They are identified by URIs and are better suited to structured reference data than to real-time queries.

import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: "db://users/schema",
        name: "Users Table Schema",
        description: "The database schema for the users table, including column names and types.",
        mimeType: "application/json",
      },
    ],
  };
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;

  if (uri === "db://users/schema") {
    const schema = {
      table: "users",
      columns: [
        { name: "id", type: "integer", primaryKey: true },
        { name: "name", type: "varchar(255)", nullable: false },
        { name: "email", type: "varchar(255)", nullable: false, unique: true },
        { name: "plan", type: "enum(free,pro,enterprise)", nullable: false },
        { name: "created_at", type: "timestamp", nullable: false },
      ],
    };

    return {
      contents: [
        {
          uri,
          mimeType: "application/json",
          text: JSON.stringify(schema, null, 2),
        },
      ],
    };
  }

  throw new Error(`Unknown resource: ${uri}`);
});

Resources shine for schema definitions, configuration data, documentation snippets, and lookup tables. For data that changes frequently or requires query parameters, use tools instead.

Adding Prompts

Prompts let your MCP server package up reusable workflows as named templates. When a user invokes a prompt through their AI host, your server generates the message content - which can include pre-fetched data from your database or APIs.

import {
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListPromptsRequestSchema, async () => {
  return {
    prompts: [
      {
        name: "user_summary",
        description: "Generate a summary of users by plan type, including counts and trends.",
        arguments: [
          {
            name: "focus_plan",
            description: "Optionally focus the summary on a specific plan tier.",
            required: false,
          },
        ],
      },
    ],
  };
});

server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "user_summary") {
    const planCounts = mockUsers.reduce(
      (acc, u) => {
        acc[u.plan] = (acc[u.plan] || 0) + 1;
        return acc;
      },
      {} as Record<string, number>
    );

    const focusPlan = args?.focus_plan;
    const contextNote = focusPlan
      ? `Focus your analysis on the ${focusPlan} tier.`
      : "Cover all plan tiers.";

    return {
      description: "User summary prompt with current data",
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Here is the current user distribution by plan:

${JSON.stringify(planCounts, null, 2)}

Please provide a concise summary of this user base. ${contextNote}
Include any observations about distribution balance and what it might indicate.`,
          },
        },
      ],
    };
  }

  throw new Error(`Unknown prompt: ${name}`);
});

Starting the Server

Wire everything together at the bottom of src/index.ts:

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

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

Note that logging goes to stderr, not stdout. When using stdio transport, stdout is reserved for the MCP protocol messages. Any output to stdout that is not valid JSON-RPC will break the connection.

Testing Your MCP Server

There are two ways to test a local MCP server: the MCP Inspector and connecting directly to Claude Desktop.

Using the MCP Inspector

The MCP Inspector is a browser-based debugging tool that lets you connect to a local MCP server and interactively call tools, read resources, and trigger prompts. It is the fastest way to verify your server is working before connecting it to a full AI host.

npx @modelcontextprotocol/inspector tsx src/index.ts

This launches a local web UI, typically at http://localhost:5173. You will see your server's capabilities listed, and you can call query_users directly from the interface to confirm the response format looks correct.

Connecting to Claude Desktop

Once the inspector confirms your server is working, connect it to Claude Desktop. Edit the Claude Desktop config at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["--loader", "tsx/esm", "/absolute/path/to/src/index.ts"]
    }
  }
}

Use absolute paths. Claude Desktop launches the MCP server as a subprocess and needs to find the file regardless of its own working directory. Restart Claude Desktop after saving the config, and your tools will appear in the conversation interface.

Test by asking Claude: "How many pro users are there?" - it should call query_userswith plan: "pro" and return the filtered result.

Production Considerations

A working demo MCP server and a production-ready one are separated by error handling, authentication, logging, and deployment strategy. Here is what to address before shipping.

Error Handling

Always validate inputs before acting on them, and return structured errors rather than letting exceptions propagate uncaught:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    if (name === "query_users") {
      const validPlans = ["free", "pro", "enterprise"];
      if (args?.plan && !validPlans.includes(args.plan as string)) {
        return {
          content: [
            {
              type: "text",
              text: `Invalid plan type: ${args.plan}. Valid options: ${validPlans.join(", ")}`,
            },
          ],
          isError: true,
        };
      }

      // ... rest of tool logic
    }

    throw new Error(`Unknown tool: ${name}`);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return {
      content: [{ type: "text", text: `Error: ${message}` }],
      isError: true,
    };
  }
});

Return errors as content with isError: true rather than throwing. This gives the model a chance to recover gracefully rather than crashing the tool call.

Authentication

The MCP protocol itself does not enforce authentication - that is your responsibility. For stdio transport running locally, authentication is less critical because the server only accepts connections from the local host process. For HTTP/SSE transport, you must implement your own auth layer.

Common patterns include:

  • Environment variables for API keys and database credentials - read them at startup, never hardcode
  • OAuth tokens passed via HTTP headers when using SSE transport
  • Restricting database users to read-only permissions for query-only tools
  • Tool-level permission checks before executing write operations
// Load credentials from environment at startup
const DB_URL = process.env.DATABASE_URL;
if (!DB_URL) {
  console.error("DATABASE_URL environment variable is required");
  process.exit(1);
}

Structured Logging

Log every tool call with enough context to debug issues and audit AI actions in production. Since stdout is reserved for the protocol, all logs go to stderr:

function logToolCall(name: string, args: unknown, durationMs: number, success: boolean) {
  const entry = {
    timestamp: new Date().toISOString(),
    tool: name,
    args,
    durationMs,
    success,
  };
  console.error(JSON.stringify(entry));
}

// In your handler:
const start = Date.now();
try {
  const result = await runQuery(args);
  logToolCall(name, args, Date.now() - start, true);
  return result;
} catch (error) {
  logToolCall(name, args, Date.now() - start, false);
  throw error;
}

Deployment

For local developer tooling, stdio transport is the right choice - your MCP server runs as a subprocess and shuts down with the host. For a shared team server or a remotely accessible MCP server, use HTTP with SSE transport and deploy it as a long-running service.

A few deployment options depending on your scale:

  • Local stdio - simplest, one server process per developer machine, no networking required
  • Docker container with HTTP/SSE - good for shared team deployments, deployable to any container platform
  • Serverless (AWS Lambda, Cloudflare Workers) - works for stateless MCP servers that do not hold persistent connections
  • Dedicated VPS or managed Node.js service - best for servers with persistent database connections and state

Build your TypeScript before deploying:

npm run build
node dist/index.js

Next Steps

You now have a working MCP server with tools, resources, and prompts. The natural progression from here is to replace the mock data with a real database connection, add input validation using a library like Zod, and write integration tests that verify your tool handlers return the expected shapes.

From there, the most valuable thing you can build is a read-only wrapper around your primary database. Once your team has that, you will find AI tools like Claude and Cursor become significantly more useful for exploratory work - developers can ask natural language questions about production data without writing one-off scripts.

At RoboticForce, we build and deploy production MCP servers for clients across Canada. If you are evaluating MCP adoption for your team or working through a production integration that needs careful security and performance design, get in touch - we work with teams remotely across Canada.