Building Servers with SDKs
By the Syncfusion DevTutorials team · Last updated
Building a complete MCP server: McpServer setup, registering tools, resources and prompts, stdio and HTTP transports, the same server in Python, and testing with handler tests, the Inspector and CI.
Learning to cook from a recipe card is one thing. Cooking a full meal for guests is another: you plan, prepare each dish, taste as you go, and check everything before it leaves the kitchen.
In this lesson, you put the whole chapter together. You build a complete MCP server with the official SDK: set it up, register tools, resources, and prompts, run it over stdio or HTTP, and test it properly.
The main idea is straightforward: create an McpServer, register each tool, resource, and prompt with a schema and a handler, choose a transport, and test at three levels: handlers directly, with the MCP Inspector, and with a real host.
A Real-World Example
Ravi builds the first version of Brightgrid's returns server in an afternoon:
- He starts from a minimal template: one tool, one resource, one prompt.
- He replaces them with
lookup_order,book_return,kb://returns-policy, and areply_to_customerprompt. - He writes small tests that call each handler directly.
- He runs the server over stdio and checks it with the Inspector CLI.
- He adds it to VS Code and tries it in chat.
- For the company's shared deployment, he runs the same code over HTTP.
In simple words: build small, test each layer, then connect.
What You Will Learn
In this lesson, you will learn:
- How to set up a server with the TypeScript SDK.
- How to register tools, resources, and prompts.
- How to run it over stdio and over HTTP.
- The same server in Python.
- How to test handlers, use the Inspector, and smoke-test in CI.
The MCP Lab's Server code tab runs real SDK code. Press Start from a minimal server to load a small starting server, then change it and press Restart or Update live. Everything you build there uses the same calls as the files below.
Server Setup
The Pieces
The SDK handles the protocol. Your handlers, services, and backend clients do the work, each with one job.
An MCP server combines protocol communication with your application's functionality. The SDK handles messages to and from the host, while your code defines what the server can do. To expose that functionality, you create a server object, register its tools, resources, and prompts, and connect a transport so a host can reach it.
A TypeScript server has three parts:
- Create an
McpServerwith a name and version. - Register tools, resources, and prompts.
- Connect a transport: stdio, or HTTP through
createMcpHandler.
The SDK takes care of the JSON-RPC protocol details, while your code handles the actual business logic and data access. In other words, the server object is the coordinator; your handlers do the work.
Under the hood, registerTool stores the definition and the handler, and the SDK answers tools/list and tools/call for you:
A stand-in for registerTool
// What registerTool builds: a tiny stand-in for the SDK, to see the moving parts.
function createServer(name) {
const tools = new Map();
return {
registerTool(toolName, def, handler) {
tools.set(toolName, { def, handler });
},
listTools() {
return [...tools].map(([n, t]) => ({
name: n,
description: t.def.description,
inputSchema: t.def.inputSchema,
}));
},
async callTool(n, args) {
const t = tools.get(n);
if (!t) {
return { error: { code: -32602, message: `Tool ${n} not found` } };
}
return { result: await t.handler(args) };
},
};
}
const server = createServer("demo");
server.registerTool(
"add_numbers",
{
description: "Add two numbers.",
inputSchema: {
type: "object",
properties: { a: { type: "number" }, b: { type: "number" } },
},
},
async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }),
);
console.log(JSON.stringify(server.listTools()));
server
.callTool("add_numbers", { a: 2, b: 3 })
.then((r) => console.log(JSON.stringify(r)));
Expected output:
[{"name":"add_numbers","description":"Add two numbers.","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}}}}]
{"result":{"content":[{"type":"text","text":"5"}]}}
Handler Registration
Here is a complete small server in src/index.ts. It runs over stdio by default, or over HTTP with --http:
import { createServer } from "node:http";
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { toNodeHandler } from "@modelcontextprotocol/node";
import * as z from "zod";
const orders: Record<string, { item: string; status: string }> = {
"A-1001": { item: "Desk lamp", status: "shipped" },
};
function buildServer() {
const server = new McpServer({ name: "brightgrid-returns", version: "1.0.0" });
server.registerTool(
"lookup_order",
{
title: "Look up an order",
description: "Get the item and status of one order. Use it when the customer gives an order id.",
inputSchema: z.object({ order_id: z.string().describe("The order id, like A-1001") }),
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
},
async ({ order_id }) => {
const order = orders[order_id];
if (!order) {
return { content: [{ type: "text", text: `No order ${order_id}` }], isError: true };
}
return { content: [{ type: "text", text: `${order_id}: ${order.item}, ${order.status}` }] };
},
);
server.registerResource(
"returns-policy",
"kb://returns-policy",
{ title: "Returns policy", mimeType: "text/plain" },
async (uri) => ({ contents: [{ uri: uri.href, text: "Returns are free within 30 days." }] }),
);
server.registerPrompt(
"reply_to_customer",
{ description: "A short, kind reply.", argsSchema: z.object({ question: z.string() }) },
({ question }) => ({
messages: [{ role: "user", content: { type: "text", text: `Reply kindly and briefly: ${question}` } }],
}),
);
return server;
}
if (process.argv.includes("--http")) {
const port = Number(process.env.PORT ?? 3001);
createServer(toNodeHandler(createMcpHandler(buildServer))).listen(port, "127.0.0.1", () => {
console.error(`MCP server on http://127.0.0.1:${port}/mcp`);
});
} else {
await buildServer().connect(new StdioServerTransport());
console.error("MCP server running on stdio"); // stderr, never stdout
}
This example shows the key pattern for a real server: one factory builds the McpServer, and each transport chooses how the host reaches it. The HTTP version is usually a fresh server per request, while stdio keeps a single long-lived process.
Notice:
buildServer()is a factory. For HTTP,createMcpHandlercan build a server for each request, which fits common stateless HTTP patterns.- Logs use
console.error, so they go to stderr. - The HTTP server listens on
127.0.0.1only. Put a proper web server with HTTPS and sign-in in front of it before anyone else uses it. You will see how in Chapter 7.
Schemas with zod
The SDK turns zod schemas into JSON Schema for tools/list.
This matters because the host and the model need to know what arguments a tool accepts before they call it. Without a schema, the model is guessing.
From zod to JSON Schema
// How zod schemas in the SDK turn into JSON Schema in tools/list.
const mapping = [
["z.string()", '{ "type": "string" }'],
[
"z.number().int().min(1)",
'{ "type": "integer", "minimum": 1, "maximum": 9007199254740991 }',
],
['z.enum(["low", "high"])', '{ "type": "string", "enum": ["low", "high"] }'],
["z.string().regex(/^A-\\d{4}$/)", '{ "type": "string", "pattern": "^A-\\\\d{4}$" }'],
[
'z.string().describe("Like A-1001")',
'{ "type": "string", "description": "Like A-1001" }',
],
["z.boolean().optional()", '{ "type": "boolean" } and not in "required"'],
];
for (const [zod, json] of mapping) {
console.log(zod.padEnd(34), "->", json);
}
Expected output:
z.string() -> { "type": "string" }
z.number().int().min(1) -> { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }
z.enum(["low", "high"]) -> { "type": "string", "enum": ["low", "high"] }
z.string().regex(/^A-\d{4}$/) -> { "type": "string", "pattern": "^A-\\d{4}$" }
z.string().describe("Like A-1001") -> { "type": "string", "description": "Like A-1001" }
z.boolean().optional() -> { "type": "boolean" } and not in "required"
Everything so far has used the TypeScript SDK, but MCP is language-independent. The same concepts—tools, resources, prompts, handlers, and transports—apply in Python; only the syntax changes.
The Same Server in Python
MCPServer and Decorators
The Python SDK (mcp v2) uses decorators. Type hints become the input schema, and the docstring becomes the description. This is the same idea as the TypeScript example: you declare the tool, then the framework exposes it over MCP.
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("brightgrid-returns", version="1.0.0")
ORDERS = {"A-1001": {"item": "Desk lamp", "status": "shipped"}}
@mcp.tool(annotations={"readOnlyHint": True})
def lookup_order(order_id: str) -> str:
"""Get the item and status of one order."""
order = ORDERS.get(order_id)
if order is None:
raise ToolError(f"No order {order_id}")
return f"{order_id}: {order['item']}, {order['status']}"
@mcp.resource("kb://returns-policy", mime_type="text/plain")
def returns_policy() -> str:
"""How returns work."""
return "Returns are free within 30 days."
@mcp.prompt()
def reply_to_customer(question: str) -> str:
"""A short, kind reply."""
return f"Reply kindly and briefly: {question}"
if __name__ == "__main__":
mcp.run() # stdio; mcp.run(transport="streamable-http") for HTTP
Two things to know about the Python SDK:
- Raise
ToolErrorfor failures the model should read. Other exceptions are reported as a general "Error executing tool" message, so internal details don't leak. - A return type like
-> strcan also produce an output schema, and the result may includestructuredContentsuch as{"result": "..."}. The exact shape depends on the SDK version and how the function is wrapped, so keep the example scoped to the basic pattern.
The two SDKs look different, but the wire is the same. A host can't tell whether a server was written in TypeScript or Python. Pick the language your team maintains best.
Testing
Level 1: Handlers Directly
Call each handler as a normal function, with good and bad arguments. These tests are fast, need no transport, and catch most logic bugs before you ever run a full server.
Test a handler directly
// Test a handler directly, without any transport. Fast and reliable.
async function lookupOrder({ order_id }) {
const orders = { "A-1001": "shipped" };
if (!orders[order_id]) {
return { content: [{ type: "text", text: `No order ${order_id}` }], isError: true };
}
return { content: [{ type: "text", text: `${order_id}: ${orders[order_id]}` }] };
}
const cases = [
{ args: { order_id: "A-1001" }, expectError: false, expectText: "A-1001: shipped" },
{ args: { order_id: "A-9999" }, expectError: true, expectText: "No order A-9999" },
];
(async () => {
for (const c of cases) {
const r = await lookupOrder(c.args);
const pass = !!r.isError === c.expectError && r.content[0].text === c.expectText;
console.log(pass ? "pass" : "FAIL", JSON.stringify(c.args));
}
})();
Expected output:
pass {"order_id":"A-1001"}
pass {"order_id":"A-9999"}
Level 2: The MCP Inspector
Run the real server and talk to it over the real transport:
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name lookup_order --tool-arg order_id=A-1001
The web UI (npx @modelcontextprotocol/inspector) does the same with buttons and shows every message. This is where you see whether the host and the server agree on the tool schema and argument names.
Level 3: A Real Host
Add the server to VS Code or another host, as in the last lesson, and try real questions. Watch which tools the model picks and with which arguments. Weak descriptions show up here.
Smoke Tests in CI
The Inspector CLI prints JSON, so a CI job can start the server, call a tool, and check the answer on every change:
Check Inspector output in CI
// Read Inspector CLI output (it prints JSON) and check it in a CI script.
const inspectorOutput = `{
"content": [{ "type": "text", "text": "A-1001: Desk lamp, shipped, arrives 2026-10-14" }]
}`;
const result = JSON.parse(inspectorOutput);
const checks = [
["no isError", !result.isError],
["has text content", result.content?.[0]?.type === "text"],
["mentions the order", result.content[0].text.includes("A-1001")],
];
for (const [name, ok] of checks) {
console.log(ok ? "pass" : "FAIL", name);
}
console.log(checks.every(([, ok]) => ok) ? "smoke test passed" : "smoke test failed");
Expected output:
pass no isError
pass has text content
pass mentions the order
smoke test passed
Choosing the Transport
The same code can serve both transports. A command-line flag or environment variable chooses one at startup:
Pick the transport at start-up
// Choosing the transport at start-up: one server, two ways to run it.
function startPlan(argv, env) {
if (argv.includes("--http")) {
const port = Number(env.PORT ?? 3001);
return `HTTP: createMcpHandler(buildServer) + toNodeHandler, listening on 127.0.0.1:${port}/mcp`;
}
return "stdio: server.connect(new StdioServerTransport()), logs to stderr";
}
console.log(startPlan(["node", "build/index.js"], {}));
console.log(startPlan(["node", "build/index.js", "--http"], { PORT: "4000" }));
Expected output:
stdio: server.connect(new StdioServerTransport()), logs to stderr
HTTP: createMcpHandler(buildServer) + toNodeHandler, listening on 127.0.0.1:4000/mcp
Test the unhappy paths too: unknown ids, wrong types, missing arguments, a backend that is down, and a user who declines a confirmation. Most real problems live there, not in the happy path.
Common mistakes
- Forgetting to await
connect. The server exits before it reads any message. - Binding HTTP to
0.0.0.0in development. Anyone on the network can call it. Use127.0.0.1. - Leaking internals in errors. Stack traces and SQL in tool errors help attackers. Return clear, short messages.
- No tests for bad input. The model will send bad input. Test it before users do.
- Changing tool names casually. Hosts, prompts, and approvals may depend on them. Treat names as a public contract.
Exercises
Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.
What to Remember
- Create an
McpServer, register tools, resources, and prompts, then connect a transport. - For HTTP, wrap a factory with
createMcpHandlerand serve it withtoNodeHandler. For stdio,connect(new StdioServerTransport()). - Log to stderr. Bind local HTTP servers to 127.0.0.1.
- In Python,
MCPServerwith@mcp.tool(),@mcp.resource(), and@mcp.prompt(). RaiseToolErrorfor messages the model should see. - Test at three levels: handlers, the Inspector, and a real host. Add Inspector smoke tests to CI.
Next: Chapter 7, Multi-Server Architectures, connecting many servers safely.