DEV Community

BuyWhere
BuyWhere

Posted on • Originally published at buywhere.ai

"Building an MCP Server from Scratch: A Complete 2026 Guide"

You've used MCP servers. Maybe you've connected Claude Desktop to filesystem, Postgres, or a web search tool. But building your own MCP server that AI agents can discover and use reliably? That's the next level.

This guide walks through building a production MCP server from scratch — not a toy example, but a server designed for real AI agents to discover, call, and trust.

Why Build (or Use) MCP Servers?

The Model Context Protocol solves a fundamental problem: every AI agent implements its own way to call tools. Claude Desktop has one interface. Cursor has another. VS Code Copilot, Cline, Roo Code — each invents its own tool-calling protocol.

MCP standardizes this. Build one server, and every MCP-compatible client can use it with zero integration work.

The ecosystem as of May 2026:

  • 4,800+ MCP servers registered across Glama, MCP.so, Smithery, PulseMCP
  • 40+ domains — databases, browsers, commerce, search, file systems
  • Dozens of MCP-compatible clients — Claude Desktop, Cursor, VS Code, Windsurf, Cline, OpenCode

If you build API tooling for AI agents, MCP is the distribution channel.

Choosing Your Transport

MCP supports two transports. The choice matters.

stdio (local)

The server runs as a child process. The client launches it, communicates over stdin/stdout.

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "@my-org/my-mcp-server"],
      "env": { "API_KEY": "sk-xxx" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When to use: Your server is an npm package or binary the developer installs locally. Fastest path to first tool call. Zero network configuration.

Trade-off: Every client spawns its own server process. State is per-session. No shared resources.

SSE (remote)

The server runs as an HTTP endpoint. Clients connect over Server-Sent Events.

mcp.buywhere.ai/sse
Enter fullscreen mode Exit fullscreen mode

When to use: Your server is a service. You control the infrastructure. Multiple clients share one server instance.

Trade-off: Network latency. Requires deployment. But enables shared state, rate limiting, and monitoring.

Transport Decision Framework

Concern stdio SSE (remote)
Developer setup npx -y one-liner URL endpoint
Latency Sub-ms (local IPC) 20-200ms network
State Per-client process Shared instances
Auth Env vars API keys in headers
Distribution npm / pip / cargo Your infrastructure

Real-world pattern: Start with stdio for developer adoption. Add SSE when you need production scale. @buywhere/mcp-server supports both — npm for local use, SSE endpoint for hosted access.

Core Architecture

An MCP server has three layers. Get these right, and you have a server that agents actually trust.

┌─────────────────────────┐
│     Tool Definitions     │  ← Agent sees this first
├─────────────────────────┤
│    Request Handlers      │  ← Business logic lives here
├─────────────────────────┤
│  Transport & Auth Layer  │  ← stdio or SSE, token validation
└─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Layer 1: Tool Definitions

This is what the AI agent sees before making any call. It's your contract.

Every tool needs:

  • name: snake_case, descriptive. Agents parse this.
  • description: The agent reads this to decide when to call your tool. This IS your prompt engineering.
  • inputSchema: JSON Schema. Strict typing means fewer agent mistakes.
const searchProductsTool = {
  name: "search_products",
  description: "Search product catalog by natural language query. " +
    "Returns matching products with prices, merchant info, and availability. " +
    "Use this when the user wants to find products or compare prices.",
  inputSchema: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "Natural language search query. Be specific — " +
          "include brand, model, category. Examples: 'iPhone 15 128GB', " +
          "'Sony headphones wireless noise cancelling'"
      },
      market: {
        type: "string",
        enum: ["sg", "sea", "us", "global"],
        description: "Market to search. Default: 'sg' for Singapore."
      },
      limit: {
        type: "integer",
        minimum: 1,
        maximum: 20,
        description: "Max results. Default 5. Higher values give more options " +
          "but cost more tokens."
      }
    },
    required: ["query"]
  }
};
Enter fullscreen mode Exit fullscreen mode

Why descriptions matter: The agent uses your tool description to decide when and how to call it. A vague description means the agent won't use your tool at all. A precise description means correct calls with correct arguments.

Layer 2: Request Handlers

This is where your business logic runs. Each tool gets a handler that receives the arguments and returns a result.

async function handleSearchProducts(args: {
  query: string;
  market?: string;
  limit?: number;
}) {
  const results = await catalog.search({
    query: args.query,
    market: args.market || "sg",
    limit: args.limit || 5
  });

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

Error handling is critical. The agent will retry on errors. Give it actionable messages:

try {
  const results = await catalog.search(params);
  return { content: [{ type: "text", text: JSON.stringify(results) }] };
} catch (error) {
  if (error instanceof RateLimitError) {
    return {
      content: [{
        type: "text",
        text: "Rate limit reached. Free tier: 1,000 queries/month. " +
          "Upgrade at https://buywhere.ai/pricing for higher limits."
      }],
      isError: true
    };
  }
  if (error instanceof AuthError) {
    return {
      content: [{
        type: "text",
        text: "Invalid API key. Get a free key at https://buywhere.ai/api-keys"
      }],
      isError: true
    };
  }
  throw error; // Unknown error — let the client handle it
}
Enter fullscreen mode Exit fullscreen mode

Layer 3: Transport & Auth

Wire it together with the MCP SDK:

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

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

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [searchProductsTool, getProductTool, compareProductsTool]
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  switch (name) {
    case "search_products": return handleSearchProducts(args);
    case "get_product": return handleGetProduct(args);
    case "compare_products": return handleCompareProducts(args);
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

Packaging for npm

Your server should be installable with one command:

npx -y @my-org/my-mcp-server
Enter fullscreen mode Exit fullscreen mode

package.json essentials:

{
  "name": "@my-org/my-mcp-server",
  "version": "1.0.0",
  "bin": {
    "my-mcp-server": "./dist/index.js"
  },
  "keywords": [
    "mcp",
    "model-context-protocol",
    "ai",
    "agent",
    "mcp-server",
    "claude",
    "cursor",
    "llm"
  ],
  "files": ["dist/"],
  "engines": { "node": ">=18" }
}
Enter fullscreen mode Exit fullscreen mode

Keyword strategy: npm search and AI tool directories use package.json keywords for discovery. Include mcp, mcp-server, ai, agent, and domain-specific terms for your tool category.

Testing with Real AI Agents

Unit tests verify your handlers. Integration tests verify the protocol. But real AI agents behave differently.

Test your server against:

  1. Claude Desktop — The reference client. If it doesn't work here, fix it first.
  2. Codex — Tests tool discovery with natural language queries.
  3. Cursor — Different tool-calling patterns. Tests compatibility.

Common failure modes:

  • Tool not called: Description is too generic. Agent doesn't understand when to use it.
  • Wrong arguments: Input schema missing constraints. Add enum, minimum, maximum.
  • Result ignored: Response format not parseable. Use clean JSON, not prose.
  • Timeout: Handler taking >30s. Add pagination or streaming.

Real-World Lessons from @buywhere/mcp-server

We shipped @buywhere/mcp-server on npm, listed it on the official MCP registry, and watched 1,900+ developers install it in a week. Here's what we learned.

Lesson 1: npm discovery is free distribution

npm keywords + package name are how developers and MCP directories find your server. We saw 770 downloads in a single day without any promotion — just being listed with the right keywords.

Lesson 2: The MCP registry listing is worth 10x any blog post

Being listed on the official MCP registry meant our server was discoverable by every MCP-compatible client. One registry listing outperformed 30+ Dev.to articles combined.

Lesson 3: Free tier is non-negotiable

Developers want to try before they commit. Our free tier (1,000 queries/month, no credit card) converted 3x better than gated trials in early tests.

Lesson 4: Tool descriptions ARE your UX

The agent reads your descriptions to decide what to do. We rewrote our search_products description 4 times based on observing how Claude actually used it. Be specific about inputs, outputs, and when the tool should be called.

Complete Server Template

Here's a minimal but production-ready MCP server template you can clone:

#!/usr/bin/env node
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 TOOLS = [
  {
    name: "hello",
    description: "Greet the user. Call this when the user says hello or introduces themselves.",
    inputSchema: {
      type: "object",
      properties: {
        name: {
          type: "string",
          description: "The user's name. Optional."
        }
      }
    }
  }
];

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

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: TOOLS
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  if (name === "hello") {
    const greeting = args?.name
      ? `Hello, ${args.name}! Your MCP server is working.`
      : "Hello, World! Your MCP server is working.";
    return { content: [{ type: "text", text: greeting }] };
  }
  throw new Error(`Unknown tool: ${name}`);
});

const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

Next Steps

  1. Clone the template and replace hello with your first real tool
  2. Test with Claude Desktop — the quickest feedback loop
  3. Publish to npm with discovery-optimized keywords
  4. Submit to MCP registries — Glama, MCP.so, Smithery, PulseMCP
  5. Write good tool descriptions — this is your entire UX

The MCP ecosystem is growing fast, and the servers that get adopted are the ones that are discoverable, reliable, and well-described. Build something agents actually want to call.


BuyWhere is an MCP server that gives AI agents access to a live product catalog across 50M+ products in Singapore, SEA, and US markets. Get a free API key at buywhere.ai.

Top comments (0)