What we are building
In this guide we build a small but complete inventory MCP server: an agent will be able to look up stock levels and adjust them (tools) and read a live catalog (a resource). We will run it locally over stdio, expose it remotely over streamable HTTP, secure it, test it with the MCP Inspector, and look at deployment. It is deliberately minimal so the shape of an MCP server is obvious — the same pattern scales to anything: a payments system, an internal database, a logistics API.
You need Node.js 18 or newer and a working knowledge of TypeScript. If the protocol itself is new to you, skim What is MCP? first — this tutorial assumes you know what tools, resources and transports are.
Before you write a line of code, it is worth asking whether you need to. If your system is GitHub, Slack, Stripe, Search Console, an ad platform, or one of the common REST products, BusinessMCP already exposes it as an MCP tool — connect it in a click and skip the build. Write your own server only for the genuinely proprietary parts of your stack, then connect it to your workspace so it lives beside everything else.
Project setup
Create the project and install the official SDK plus Zod, which the SDK uses for input schemas. Express comes along for the HTTP transport later.
mkdir inventory-mcp && cd inventory-mcp
npm init -y
# SDK v1 (what the examples below use):
npm install @modelcontextprotocol/sdk zod express
# SDK v2, for the 2026-07-28 revision — the package split, so it is:
# npm install @modelcontextprotocol/server zod express
npm install -D typescript @types/node @types/express
npx tsc --initTwo configuration notes that save an hour of head-scratching: the SDK is published as ES modules, so set "type": "module" in package.json, and point tsc at an output directory (e.g. "outDir": "dist") with "module" and "moduleResolution" set to nodenext.
A clean structure keeps capabilities separate from wiring as the server grows:
inventory-mcp/
├── src/
│ ├── server.ts # capability registration (transport-agnostic)
│ ├── stdio.ts # local entry point
│ ├── http.ts # remote entry point
│ ├── tools.ts # callable functions
│ └── resources.ts # readable data
└── package.jsonThe server skeleton
The SDK gives you an McpServer object; you register capabilities on it and connect it to a transport. Build the server in a factory function so both the stdio and HTTP entry points can reuse it unchanged:
// src/server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { registerTools } from './tools'
import { registerResources } from './resources'
export function buildServer() {
const server = new McpServer({
name: 'inventory',
version: '1.0.0',
})
registerTools(server)
registerResources(server)
return server
}The local entry point wires it to stdio — the transport desktop clients use to run a server as a subprocess:
// src/stdio.ts
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { buildServer } from './server'
const server = buildServer()
await server.connect(new StdioServerTransport())One gotcha worth knowing now: a stdio server talks JSON-RPC over standard output, so never `console.log` in a stdio server — a stray line of text corrupts the protocol stream. Log to stderr (console.error) instead.
Adding tools
A tool is a name, a description the model reads, an input schema, and a handler. Register them with registerTool, giving each a Zod schema — the SDK converts it to JSON Schema for discovery and validates incoming arguments against it before your handler runs.
Descriptions matter more than the code: they are the model's only clue about when to use the tool, so write them like you are briefing a new teammate — what it does, and when to reach for it.
// src/tools.ts
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { db } from './db'
export function registerTools(server: McpServer) {
server.registerTool(
'get_stock',
{
title: 'Get stock level',
description:
'Look up the current stock level for a product by SKU. Use this whenever the user asks about availability or quantities.',
inputSchema: { sku: z.string().describe('The product SKU, e.g. "TS-BLK-M"') },
},
async ({ sku }) => {
const row = await db.stock(sku)
if (!row) {
return {
content: [{ type: 'text', text: `No product found for SKU ${sku}` }],
isError: true,
}
}
return {
content: [{ type: 'text', text: JSON.stringify({ sku, available: row.available }) }],
}
},
)
server.registerTool(
'adjust_stock',
{
title: 'Adjust stock',
description:
'Increase or decrease the stock count for a SKU. Use a negative delta to remove stock. Only call this when the user explicitly asks to change inventory.',
inputSchema: {
sku: z.string().describe('The product SKU'),
delta: z.number().int().describe('The change to apply, e.g. -2 or 10'),
},
},
async ({ sku, delta }) => {
const updated = await db.adjust(sku, delta)
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
},
)
}Three habits pay off immediately. Return structured, predictable content — the model reasons far better over clean JSON than over prose, and downstream automations can parse it. Signal failures with isError: true and a message the model can act on ("no product found" invites a retry with a different SKU; a stack trace invites confusion). And keep the tool set tight: a handful of sharp, well-described tools beats thirty vague ones, because every description competes for the model's attention.
Adding resources
Where a tool does something, a resource is something the agent can read. Expose data that the model benefits from having in context — a catalog, a config, a status document. Each resource gets a URI, metadata, and a read callback:
// src/resources.ts
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { db } from './db'
export function registerResources(server: McpServer) {
server.registerResource(
'catalog',
'inventory://catalog',
{
title: 'Product catalog',
description: 'The full product catalog with SKUs, names and prices',
mimeType: 'application/json',
},
async (uri) => {
const items = await db.catalog()
return {
contents: [
{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(items) },
],
}
},
)
}For families of resources — one per product, say — the SDK also supports URI templates (a ResourceTemplate with a pattern like inventory://products/{sku}), so the client can address inventory://products/TS-BLK-M without you registering every SKU by hand.
A useful rule of thumb for tools versus resources: if the agent should decide to fetch it mid-task, make it a tool; if the host application should be able to load it into context up front, make it a resource. When in doubt, a read-only tool is the more universally supported choice.
Going remote: streamable HTTP
stdio is ideal for local desktop clients, but a server your team — or an external agent — connects to over the network needs the streamable HTTP transport: a single endpoint that accepts POSTed JSON-RPC messages and can stream responses back. The simplest robust setup is stateless: build a fresh server and transport per request, so the deployment scales horizontally and works on serverless platforms.
// src/http.ts
import express from 'express'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { buildServer } from './server'
const app = express()
app.use(express.json())
app.post('/mcp', async (req, res) => {
const server = buildServer()
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
})
res.on('close', () => {
transport.close()
server.close()
})
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
})
app.listen(3000)Notice what did not change: tools.ts and resources.ts are untouched. Transports are wiring; capabilities are the product. (The SDK also supports stateful sessions — the transport can mint a session id on initialize and route subsequent requests to the same server instance — which you want when your server holds per-conversation state. Start stateless; add sessions when you need them.)
Authentication & scoping
Never ship a network-reachable MCP server without auth — it is an API into your systems. The MCP spec defines a full OAuth 2.1 authorization flow for HTTP servers (your server acts as an OAuth resource server; see the auth section of What is MCP?), which is what public, multi-tenant servers should implement. For an internal or single-tenant server, the pragmatic floor is a bearer token checked on every request:
app.post('/mcp', async (req, res) => {
const auth = req.headers.authorization ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
const caller = token ? await lookupApiKey(token) : null
if (!caller) {
res.status(401).json({ error: 'unauthorized' })
return
}
// ...build the server scoped to caller.allowedTools, then handle the request
})Beyond authentication, the rules that keep you safe are scope and audit. Each token should map to an allowlist of tools it may call — a reporting agent gets get_stock, not adjust_stock — and every invocation should be logged with the caller, the arguments, and the result. Rate-limit per caller so a runaway loop cannot hammer your backend.
Finally, validate on the server, not just in the declared schema — the model can and will send unexpected values, and a malicious client can send anything at all. Zod handles shape; your handler still owns semantics (does the SKU exist? is the delta sane?). Run the server against your backend with a least-privilege account, and treat every tool call as untrusted input.
Testing with the MCP Inspector
The fastest feedback loop is the official MCP Inspector — a local web UI that connects to your server as a real client:
npx tsc
npx @modelcontextprotocol/inspector node dist/stdio.jsThe Inspector opens in your browser, runs your server, and lets you see exactly what a model would see: the initialize handshake, the discovered tool list with schemas and descriptions, and the resources. You can call any tool with hand-typed arguments and inspect the raw JSON-RPC request and response. It also connects to a running streamable-HTTP server by URL, which is how you verify the remote deployment end to end.
Complement it with two cheaper layers: unit-test your handlers as ordinary functions against an in-memory data layer (they are just async functions), and for integration coverage the SDK ships a client class plus an in-memory transport, so a test can drive discovery and invocation without spawning processes. The bug the Inspector catches that unit tests miss is almost always a description problem — a tool that works perfectly but is described so vaguely the model never picks it.
Deployment options
You have two fundamentally different deployment shapes, and the transport decides which.
Local (stdio) — the server ships as a command users run on their own machine. For Claude Desktop, that is an entry in the config file:
{
"mcpServers": {
"inventory": {
"command": "node",
"args": ["/absolute/path/to/inventory-mcp/dist/stdio.js"]
}
}
}This suits personal and developer tools: zero network surface, local credentials, no hosting bill. The cost is distribution — every user installs and updates it themselves.
Remote (streamable HTTP) — the server runs behind HTTPS on any Node-capable host: a container, a VM, or a serverless platform (the stateless pattern above is serverless-friendly). One deployment serves every user and agent, secrets stay server-side, and you can update centrally. In exchange you own uptime, TLS, auth, rate limits, logging, and monitoring — real operational work that starts the day you share the URL.
Or skip hosting it yourself
If what you actually want is "my systems, callable by any AI agent, without becoming an ops team," you do not have to run any of this. Point BusinessMCP at a repo containing your server and it is deployed from GitHub and hosted for you — or connect a server you already run — and either way it joins your workspace's unified endpoint: one URL, one mcph_* key, authenticated, rate-limited, logged, and callable alongside your analytics, CRM and revenue tools.
When you are ready to expose the result to agents, see Expose your MCP endpoint, and read MCP best practices before anything touches production.
Frequently asked questions
What language should I write an MCP server in?
Any language with a JSON-RPC capable runtime works. The official SDKs cover TypeScript, Python and several other languages; TypeScript and Python are what most people reach for. The examples here use the TypeScript SDK v1 package, @modelcontextprotocol/sdk. Note that the v2 line — released alongside the 2026-07-28 spec revision — split that package into @modelcontextprotocol/server, /client and /core, so a v2 project imports McpServer from @modelcontextprotocol/server instead.
Should my MCP server use stdio or Streamable HTTP?
Use stdio for a local, single-user tool that runs as a subprocess of a desktop client like Claude Desktop. Use Streamable HTTP for anything hosted: multiple users, remote agents, or a URL other people connect to. The tool and resource code is identical — only the transport wiring changes.
Do I have to build a server to use BusinessMCP?
No. BusinessMCP already exposes your connected tools and unified data as a hosted MCP server at /api/mcp. Build your own only when you have a proprietary system that no connector covers — then connect it alongside everything else.
How do I secure a custom MCP server?
Require a bearer token on every request, validate all tool inputs on the server, scope each token to an allowlist of tools, rate-limit per caller, and log every invocation. See our MCP best practices guide for the full checklist.
Keep going
Turn your company into one AI-ready data platform on a single hosted MCP endpoint.