Skip to content
MagicMakersBook an audit
How We Work Our Process Case Studies Industries Blog About Us
Four cards show the steps to build an MCP server: design the tool, build the server with a few lines of code, connect an assistant, then test and harden with narrow permissions, approvals, logging and tests.

BlogSystem Design

How to Build an MCP Server: A Step-by-Step Guide in Python and TypeScript

A step-by-step guide to building an MCP server in Python and TypeScript, with tested code, plus what changes when you move from demo to production.

To build an MCP server, you define one or more tools, run them through an MCP SDK, and connect the server to an AI assistant. A first working server is about thirty lines of code. The hard part is not the code. It is deciding what the assistant should be allowed to do. This guide builds a small read-only server in Python and in TypeScript, shows how to connect it to an assistant, and covers what changes before you put one in front of real systems. We ran both versions against a real MCP client before publishing.

TL;DR#

  • A minimal MCP server needs an SDK, one tool with a clear name and typed inputs, and a transport. Over stdio it runs as a local subprocess of the assistant.
  • Design the tool before you write the code. The name, description and inputs decide whether an assistant uses it well.
  • On a stdio server, never write to standard output. It carries the protocol messages, and stray output breaks the connection.
  • Moving from demo to production is mostly about safety: read-only first, narrow permissions, real authentication, approvals for writes, and a log of every call.

What you will build#

A small "orders" server with one read-only tool, get_order, that looks up an order by its ID and returns its status. It uses a stand-in dictionary where your real system would go. In production you would replace that lookup with a call to your API or database.

We build it twice, in Python and in TypeScript, so you can use whichever your team runs. Both examples use the official SDKs and follow the structure of the official MCP quickstart. Official SDKs also exist for other languages, including Java, Kotlin, C#, Rust and Go.

Before you start: three decisions#

  1. Which one system? Start with a single system. A server that fronts one CRM is easier to secure and test than one that fronts everything.
  2. Which one read-only question? Pick something people ask repeatedly, such as "where is this order?". Add write actions later.
  3. Local or remote? The specification defines two standard transports. stdio runs the server as a subprocess on the same machine as the assistant, which suits a personal tool. Streamable HTTP serves requests over a single HTTP endpoint, which suits a server shared by a team. This guide uses stdio because it is the quickest way to see a server working.

Step 1: Design the tool first#

An assistant chooses tools by reading their names and descriptions, so these are part of your interface, not documentation afterwards.

DoAvoid
A clear verb and noun: get_orderVague names like orders or handle
One job per toolA single tool with a mode flag that does five things
Typed inputs with a short description of eachFree-form strings the model has to guess at
A description that says what it returns and when to use itNo description at all
Plain-text results and errors the model can act on: "No order found with ID 9999."Stack traces or raw database rows

Tools should be narrower than your API. A raw endpoint list is rarely a good tool list. Think about the questions people ask, then write a tool for each.

Step 2: Build a Python MCP server#

Create a project and install the SDK. The official quickstart uses uv:

uv init orders
cd orders
uv venv
source .venv/bin/activate   # on Windows: .venv\Scripts\activate
uv add "mcp[cli]"

Create orders.py:

from mcp.server import MCPServer

mcp = MCPServer("orders")

# Stand-in for your real system. In production this calls your API or database.
ORDERS = {
    "4821": {"status": "shipped", "carrier": "DHL", "eta": "Friday"},
    "4822": {"status": "processing", "carrier": None, "eta": None},
}


@mcp.tool()
async def get_order(order_id: str) -> str:
    """Look up an order by its ID and return its status.

    Args:
        order_id: The order number, for example 4821
    """
    order = ORDERS.get(order_id)
    if order is None:
        return f"No order found with ID {order_id}."
    return (
        f"Order {order_id}: {order['status']}, "
        f"carrier {order['carrier'] or 'not assigned'}, "
        f"ETA {order['eta'] or 'unknown'}."
    )


if __name__ == "__main__":
    mcp.run(transport="stdio")

The SDK reads the function's type hints and docstring and turns them into the tool definition the assistant sees. Run it with uv run orders.py. It waits silently for an assistant to connect, which is correct. We tested this server with mcp 2.3.0.

Step 3: Build a TypeScript MCP server#

This is a Node.js MCP server written in TypeScript. Set up the project:

mkdir orders && cd orders
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src

Create tsconfig.json. The types entry is needed on current TypeScript so that process compiles:

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

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

const server = new McpServer({ name: "orders", version: "1.0.0" });

// Stand-in for your real system. In production this calls your API or database.
const ORDERS: Record<string, { status: string; carrier: string | null; eta: string | null }> = {
  "4821": { status: "shipped", carrier: "DHL", eta: "Friday" },
  "4822": { status: "processing", carrier: null, eta: null },
};

server.registerTool(
  "get_order",
  {
    description: "Look up an order by its ID and return its status",
    inputSchema: z.object({
      order_id: z.string().describe("The order number, for example 4821"),
    }),
  },
  async ({ order_id }) => {
    const order = ORDERS[order_id];
    const text = order
      ? `Order ${order_id}: ${order.status}, carrier ${order.carrier ?? "not assigned"}, ETA ${order.eta ?? "unknown"}.`
      : `No order found with ID ${order_id}.`;
    return { content: [{ type: "text", text }] };
  },
);

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

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

Build it with npx tsc, then run node build/index.js. Build before you connect an assistant, or it will have nothing to launch. We tested this server with @modelcontextprotocol/server 2.3.1.

Step 4: Keep standard output clean#

On a stdio server, standard output carries the protocol messages. Anything else written there corrupts them and breaks the connection. In Python, do not use print(). In TypeScript, do not use console.log(). Send diagnostics to standard error instead, which is why the example logs with console.error.

Step 5: Connect it to an assistant#

An assistant needs to know how to launch your server. In Claude Desktop that is the mcpServers section of its configuration file. For the Python server:

{
  "mcpServers": {
    "orders": {
      "command": "uv",
      "args": ["--directory", "/ABSOLUTE/PATH/TO/orders", "run", "orders.py"]
    }
  }
}

For the TypeScript server:

{
  "mcpServers": {
    "orders": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/orders/build/index.js"]
    }
  }
}

Use absolute paths. You may also need the full path to uv or node in the command field. Restart the assistant after editing the file. Other clients, including editors such as VS Code and Cursor, have their own configuration but follow the same pattern: a name, a command and its arguments.

Step 6: Test it#

Start with the obvious check. Ask the assistant, "Where is order 4821?" and confirm it calls get_order and answers from the result. Then test the unhappy paths: an order that does not exist, an empty ID, an unusually long one.

Do not stop at manual testing. A server is code, so test it like code. This short script starts your Python server and calls the tool through the SDK's own client, which is what an assistant does:

import asyncio
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main():
    params = StdioServerParameters(command=sys.executable, args=["orders.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool("get_order", {"order_id": "4821"})
            print(result.content[0].text)


asyncio.run(main())

Expected output: Order 4821: shipped, carrier DHL, ETA Friday. Build tests like this around the cases your real users will hit, and rerun them whenever you change a tool's name or description, because those changes alter how assistants behave.

Step 7: From demo to production#

A stdio server on your laptop is a demo. A server that touches real systems needs more:

  • Replace the stand-in with the real call. Add timeouts, handle errors, and return clear text when the upstream system fails.
  • Add real authentication for remote servers. The specification's authorization model applies to servers served over HTTP. A server must not accept tokens that were not issued for it, and must not pass them on to other services.
  • Scope permissions narrowly. Start with read access. Avoid wildcard scopes such as * or all, and add privileges only when a tool needs them.
  • Require approval for writes. Anything that moves money, deletes data or sends a message should wait for a person.
  • Log every call. Record the tool, the arguments, the caller and the result, so "what did it do, and why?" always has an answer.
  • Validate inputs on the server. Do not trust that the model will send sensible values.
  • Treat returned text as untrusted. A tool that reads tickets, emails or documents is reading text somebody else wrote, and it can contain instructions aimed at the model. Nothing a tool returns should widen what the assistant may do.
  • Limit what comes back. Cap result sizes so one call cannot flood the assistant's context.
  • Bind state to the user. If your server hands out identifiers, such as a cart or workflow ID, check they belong to the caller. Holding an identifier is not proof of identity.

The thinking behind this is in Your agent doesn't need a bigger prompt. It needs a boundary. and An agent you cannot audit is not in production, it is on trial.

Common mistakes when building an MCP server#

  • Mirroring your API one-to-one. Fifty endpoints become fifty tools, and the assistant chooses badly between them.
  • Vague descriptions. If a human could not tell when to use the tool, neither can the model.
  • Write tools first. Start read-only and earn the right to add writes.
  • Printing to standard output on a stdio server.
  • Returning too much. Enormous payloads waste context and bury the answer.
  • Treating the demo as the product. There is no authentication, logging or testing in the example above, and a real server needs all three.

Build it yourself, or bring in help#

A first read-only server is a few hours of work, and building one is a good way to learn how MCP behaves. The harder parts are the ones around the code: authentication, permissions, approvals, logging, testing against real cases, and deciding which tools should exist at all.

That is the work we do. On our Meridian Console build, an AI assistant sits over a read-only MCP server spanning four support and finance platforms, with writes refused at the transport. On GeoVerdant, an MCP server lets assistants run the same land-analysis workflow a person does. You can see how we work on our MCP development services page, and the basics are covered in what is an MCP server.

If you want a second pair of eyes on a server you are building, or want us to build it, we're happy to walk through it with you. Book a free audit.

Key takeaways#

  • A working MCP server is a few dozen lines: an SDK, a clearly named tool with typed inputs, and a transport.
  • Design the tool before the code. Names and descriptions are part of the interface.
  • Keep standard output clean on stdio servers, and test the server like any other code.
  • Production means read-only first, narrow permissions, real authentication, approvals for writes and a log of every call.

FAQ#

How do I build an MCP server?#

Install an MCP SDK, define a tool with a clear name, typed inputs and a description, and run the server over a transport such as stdio. Then connect it to an AI assistant by telling the assistant how to launch it. The steps above build a working read-only server in Python and in TypeScript.

How long does it take to build an MCP server?#

A minimal read-only server takes a few hours. A production server takes longer because of authentication, permissions, approvals, logging and testing. For our own projects, the audit takes about a week and a first server is typically live in three to four weeks.

Should I use Python or TypeScript for an MCP server?#

Use whichever your team already runs. Both have official SDKs, and so do several other languages. The choice matters less than the tool design and the safeguards around it.

What is the difference between stdio and Streamable HTTP?#

With stdio, the assistant launches your server as a subprocess on the same machine, which suits a personal or local tool. With Streamable HTTP, the server is hosted and receives each message as an HTTP request at one endpoint, which suits a server shared by a team and needs proper authentication.

How do I secure an MCP server?#

Start read-only, give each tool the narrowest permission it needs, keep credentials on the server, validate every input, require human approval for irreversible actions and log every call. For remote servers, use the specification's authorization model and never accept or forward tokens that were not issued for your server.

Sources#