Build an MCP Server
This guide walks you through building an MCP server from scratch, testing it locally, and connecting it to oHallo. By the end, you have a working server with a get_product tool that an agent calls during conversations.
oHallo calls MCP servers over the protocol’s Streamable HTTP transport. This guide builds one in TypeScript with version 2 of the official MCP TypeScript SDK: @modelcontextprotocol/server, with @modelcontextprotocol/node to serve it from Node.js. A server built with another MCP SDK, in any language, connects to oHallo when it answers what How oHallo calls your server lists.
Transport and protocol versions
Section titled “Transport and protocol versions”oHallo connects to your server over the Streamable HTTP transport. When it opens a connection it proposes MCP protocol version 2025-11-25, and it accepts a server that answers 2025-06-18, 2025-03-26, 2024-11-05 or 2024-10-07 instead.
With version 2 of the TypeScript SDK, keep the default handling of these versions in createMcpHandler, as the server in this guide does. The handler serves them unless it is created with legacy: 'reject', and with that option it answers each of these versions with an unsupported protocol version error.
Prerequisites
Section titled “Prerequisites”- Node.js 20 or later
- npm
- curl and jq, for the local tests in Step 5
- An oHallo account where you can add MCP servers (for Step 6)
Step 1: Create the project
Section titled “Step 1: Create the project”mkdir my-catalog-server && cd my-catalog-servernpm init -ynpm pkg set type=modulenpm install @modelcontextprotocol/server@2 @modelcontextprotocol/node@2 zod@4npm install -D typescript @types/node tsxYou write each tool’s input and output schema with Zod 4, which the SDK reads and publishes as JSON Schema.
Create a tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "strict": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src"]}Step 2: Define your tools
Section titled “Step 2: Define your tools”Create src/server.ts and define a get_product tool. The SDK’s McpServer registers each tool with a name, a configuration and a handler. The configuration holds the description, the input schema, the output schema and the tool’s annotations, and the server publishes all of them in its tools/list response, which is what oHallo reads.
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'import type { CallToolResult } from '@modelcontextprotocol/server'import * as z from 'zod'
// Simulated product catalogueconst products: Record<string, { sku: string; name: string; price: { amount: number; currency: string }; inStock: boolean }> = { 'EX-100': { sku: 'EX-100', name: 'Example Desk Lamp', price: { amount: 49.5, currency: 'EUR' }, inStock: true },}
// A tool's answer: the data as structuredContent, and the same data as JSON in the result's text item.function answer(data: Record<string, unknown>): CallToolResult { return { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data }}
// A tool's error: a result marked isError whose text item is the JSON of the error.function toolError(error: { error_type: string; reason: string; [field: string]: string }): CallToolResult { return { isError: true, content: [{ type: 'text', text: JSON.stringify(error) }] }}
function buildServer(): McpServer { const server = new McpServer({ name: 'my-catalog', version: '1.0.0' })
server.registerTool( 'get_product', { description: 'Look up a product by its SKU. Returns the product name, its price and whether it is in stock. Return only the data provided by this tool.', inputSchema: z.object({ sku: z.string().describe('The product SKU, e.g. EX-100'), }), outputSchema: z.object({ sku: z.string(), name: z.string(), price: z.object({ amount: z.number(), currency: z.string() }), inStock: z.boolean(), }), annotations: { readOnlyHint: true }, }, async ({ sku }) => { const product = products[sku] if (!product) { return toolError({ error_type: 'not_found', reason: `Product ${sku} not found` }) } return answer({ sku: product.sku, name: product.name, price: product.price, inStock: product.inStock }) }, )
return server}A handler returns a CallToolResult. answer returns the data twice: as structuredContent, which the output schema describes, and as the same JSON in the result’s text item, as the MCP specification asks of a tool that returns structured content. toolError marks the result isError and puts the JSON of { error_type, reason } in its text item (see Error handling).
oHallo adds tenantId and workspaceId to the arguments of every call it makes. The SDK passes a handler only the arguments its input schema declares, so get_product, which has no use for them, leaves them out. Tool Schema shows a tool that reads them.
Tool naming convention: Use snake_case verbs such as get_product, search_orders, create_quote. The name should clearly describe the action.
Descriptions matter. The Orchestrator reads each agent’s tools, by name and description, when it decides which agent takes a request, and the agent reads the description and the input schema to decide when to call the tool and with what arguments. A vague description like “Get data” leaves the tool unused. Be specific: “Look up a product by its SKU. Returns the product name, its price and whether it is in stock.”
Step 3: Serve the tools over HTTP
Section titled “Step 3: Serve the tools over HTTP”createMcpHandler takes a function that builds a fresh McpServer and returns the handler that serves it: the SDK builds a new server instance for every HTTP request, so the endpoint holds no state between requests. toNodeHandler adapts that handler to the request and response of Node’s http module. Add this below buildServer, with import { timingSafeEqual } from 'node:crypto', import { createServer } from 'node:http' and import { toNodeHandler } from '@modelcontextprotocol/node' at the top of the file:
const mcpHandler = toNodeHandler(createMcpHandler(buildServer))
// True when the request carries the key in MCP_API_KEY as its Bearer token.function hasValidKey(authorization: string | undefined): boolean { const expected = process.env.MCP_API_KEY if (!expected || !authorization?.startsWith('Bearer ')) return false const given = Buffer.from(authorization.slice('Bearer '.length)) const wanted = Buffer.from(expected) return given.length === wanted.length && timingSafeEqual(given, wanted)}
const httpServer = createServer((req, res) => { if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') { res.writeHead(404).end() return } if (!hasValidKey(req.headers.authorization)) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null })) return } void mcpHandler(req, res)})hasValidKey compares the Bearer token in the Authorization header with the MCP_API_KEY environment variable, in constant time. oHallo sends the key you register with the connection as that token, and any other request is answered 401 before the SDK reads it.
Step 4: Start the server
Section titled “Step 4: Start the server”Start listening. Add this at the bottom of src/server.ts:
httpServer.listen(4100, () => { console.log('MCP server listening on port 4100')})The complete file at this point:
import { timingSafeEqual } from 'node:crypto'import { createServer } from 'node:http'import { toNodeHandler } from '@modelcontextprotocol/node'import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'import type { CallToolResult } from '@modelcontextprotocol/server'import * as z from 'zod'
const products: Record<string, { sku: string; name: string; price: { amount: number; currency: string }; inStock: boolean }> = { 'EX-100': { sku: 'EX-100', name: 'Example Desk Lamp', price: { amount: 49.5, currency: 'EUR' }, inStock: true },}
function answer(data: Record<string, unknown>): CallToolResult { return { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data }}
function toolError(error: { error_type: string; reason: string; [field: string]: string }): CallToolResult { return { isError: true, content: [{ type: 'text', text: JSON.stringify(error) }] }}
function buildServer(): McpServer { const server = new McpServer({ name: 'my-catalog', version: '1.0.0' })
server.registerTool( 'get_product', { description: 'Look up a product by its SKU. Returns the product name, its price and whether it is in stock. Return only the data provided by this tool.', inputSchema: z.object({ sku: z.string().describe('The product SKU, e.g. EX-100'), }), outputSchema: z.object({ sku: z.string(), name: z.string(), price: z.object({ amount: z.number(), currency: z.string() }), inStock: z.boolean(), }), annotations: { readOnlyHint: true }, }, async ({ sku }) => { const product = products[sku] if (!product) { return toolError({ error_type: 'not_found', reason: `Product ${sku} not found` }) } return answer({ sku: product.sku, name: product.name, price: product.price, inStock: product.inStock }) }, )
return server}
const mcpHandler = toNodeHandler(createMcpHandler(buildServer))
function hasValidKey(authorization: string | undefined): boolean { const expected = process.env.MCP_API_KEY if (!expected || !authorization?.startsWith('Bearer ')) return false const given = Buffer.from(authorization.slice('Bearer '.length)) const wanted = Buffer.from(expected) return given.length === wanted.length && timingSafeEqual(given, wanted)}
const httpServer = createServer((req, res) => { if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') { res.writeHead(404).end() return } if (!hasValidKey(req.headers.authorization)) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null })) return } void mcpHandler(req, res)})
httpServer.listen(4100, () => { console.log('MCP server listening on port 4100')})Start the server:
MCP_API_KEY=my-secret-key npx tsx src/server.tsStep 5: Test locally with curl
Section titled “Step 5: Test locally with curl”With the server running, test it with curl. MCP uses JSON-RPC 2.0 over HTTP, and the Streamable HTTP transport answers only a request that accepts both application/json and text/event-stream. The SDK sends each answer as a server-sent event, with the JSON-RPC response on the event’s data: line, so the commands below keep that line with sed and format it with jq.
List available tools:
curl -s http://localhost:4100/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer my-secret-key" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }' | sed -n 's/^data: //p' | jq .Expected response:
{ "result": { "tools": [ { "name": "get_product", "description": "Look up a product by its SKU. Returns the product name, its price and whether it is in stock. Return only the data provided by this tool.", "inputSchema": { "type": "object", "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "sku": { "type": "string", "description": "The product SKU, e.g. EX-100" } }, "required": [ "sku" ] }, "annotations": { "readOnlyHint": true }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "sku": { "type": "string" }, "name": { "type": "string" }, "price": { "type": "object", "properties": { "amount": { "type": "number" }, "currency": { "type": "string" } }, "required": [ "amount", "currency" ], "additionalProperties": false }, "inStock": { "type": "boolean" } }, "required": [ "sku", "name", "price", "inStock" ], "additionalProperties": false } } ] }, "jsonrpc": "2.0", "id": 1}Call a tool:
curl -s http://localhost:4100/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer my-secret-key" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_product", "arguments": { "sku": "EX-100" } } }' | sed -n 's/^data: //p' | jq .Expected response:
{ "result": { "content": [ { "type": "text", "text": "{\"sku\":\"EX-100\",\"name\":\"Example Desk Lamp\",\"price\":{\"amount\":49.5,\"currency\":\"EUR\"},\"inStock\":true}" } ], "structuredContent": { "sku": "EX-100", "name": "Example Desk Lamp", "price": { "amount": 49.5, "currency": "EUR" }, "inStock": true } }, "jsonrpc": "2.0", "id": 2}Test error handling:
curl -s http://localhost:4100/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer my-secret-key" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "get_product", "arguments": { "sku": "EX-999" } } }' | sed -n 's/^data: //p' | jq .Expected response:
{ "result": { "content": [ { "type": "text", "text": "{\"error_type\":\"not_found\",\"reason\":\"Product EX-999 not found\"}" } ], "isError": true }, "jsonrpc": "2.0", "id": 3}Step 6: Register in oHallo
Section titled “Step 6: Register in oHallo”Deploy the server where oHallo can reach it: a public host name served over HTTPS on the standard port, such as https://mcp.example.com/mcp. oHallo calls public addresses over https only, on port 443 or 80, so an http endpoint, an endpoint with another port, such as https://mcp.example.com:4100/mcp, or one on a private address is refused when you save the connection. Then:
- Open the oHallo dashboard.
- Go to MCP hub in the sidebar.
- Select Add MCP server, then Add custom on the Custom MCP server card.
- Enter a name (e.g. “My Catalog”) and the endpoint URL, choose the Scope (all brands and workspaces, one brand or one workspace), choose API key as the auth type with Single API key as the credential shape, and enter the key.
- Select Test & continue. oHallo calls
tools/listwith the key and lists the tools it discovered,get_productamong them. When the endpoint does not answer, nothing is saved and the form shows the error. - Choose an agent for each tool and select Confirm, or skip the step and assign the tools later, as Step 7 describes.
The same step over the API is POST /api/mcp-connections, which stores the key and runs the same tools/list discovery. See MCP Connections to set an account up without the dashboard.
Step 7: Assign, classify and declare each tool
Section titled “Step 7: Assign, classify and declare each tool”An agent calls a tool once three things are set: the tool is assigned to the agent, it has a risk classification, and it has a declaration of whose records it returns.
Assign the tool to an agent:
- Go to MCP hub in the sidebar and open the server you just added.
- In the Tools table, select Assign beside
get_product. - Choose the agent under Assign to agent and select Assign.
To give the tools to a new specialist agent, create it first under Agents in the sidebar with New agent. Over the API this is PUT /api/mcp-connections/{connectionId}/assignments.
Classify its risk. oHallo classifies a tool it discovers from the tool’s annotations until you change it: get_product declares readOnlyHint: true, so it starts as read (PII) (read_pii in the API). It returns no personal data, so set it to read in the Risk column of the Tools table. A change that lowers the assurance a tool requires is confirmed on a panel before it applies. Guardrails describes the classifications.
Set up what it returns. Follow the Customer records link on the connection’s page, or open Settings → Customer records and choose the connection. Under Customer data, select Set up beside get_product, choose General information, for anyone under What this tool returns, and select Save. That choice is offered for a tool classified read. A tool that returns a customer’s records is declared A customer or A customer’s company instead, as Tool Schema describes.
When a customer writes “Is the EX-100 desk lamp in stock?”, the Orchestrator hands the request to the agent that holds get_product, and the agent calls it with sku: "EX-100".
How oHallo calls your server
Section titled “How oHallo calls your server”What your server receives from oHallo, whatever it is built with:
- One HTTP
POSTper message, to the endpoint URL you registered. EachPOSTcarriesContent-Type: application/json,Accept: application/json, text/event-streamand the connection’s credential. Your server can answer with a single JSON body or with a server-sent event stream. - A connection opens with
initialize. oHallo asks for protocol version2025-11-25(Transport and protocol versions lists the versions it accepts) and names itselfohalloinclientInfo, then sendsnotifications/initialized, and from then on sends the protocol version your server answered in theMCP-Protocol-Versionheader of every request. It reuses a connection, with the session ID your server returns if it returns one, for up to 30 seconds, and then opens a new one. It also opens aGETstream for messages from your server, and accepts a405answer to it, which the server in this guide gives. tools/listwhen you save the connection, and every five minutes after. The tool list oHallo holds for the connection follows what your server publishes, so a tool you add, change or remove reaches oHallo at the next check.tools/callwithtenantIdandworkspaceIdin the arguments, and on every call an agent makes, an idempotency key in_meta(see Repeated calls and the idempotency key).- Up to 10 seconds for each answer. A call still open at its limit is cancelled: oHallo sends
notifications/cancelledfor it and closes the request. Tool call timeout gives the limits and when a call is sent again.
Error handling
Section titled “Error handling”A tool answers an error with a result marked isError whose text item is the JSON of an object holding an error_type and a reason, as toolError in Step 2 builds it. The agent that called the tool reads that text, so write a reason that says what happened in terms a person can act on.
On a tool declared A customer or A customer’s company, oHallo shows the assistant only answers that name the verified customer, or their company, as the owner of the record, errors included. Tool Schema shows an error that names its owner. A record of another customer is answered exactly as one that does not exist, with not_found.
async ({ sku }) => { if (!/^[A-Z]{2}-\d{3}$/.test(sku)) { return toolError({ error_type: 'validation_error', reason: 'A SKU has the form EX-100: two letters, a hyphen and three digits' }) }
const product = await db.findProduct(sku) if (!product) { return toolError({ error_type: 'not_found', reason: `Product ${sku} not found` }) }
return answer({ sku: product.sku, name: product.name, price: product.price, inStock: product.inStock })}Return errors as results. An error a handler throws reaches oHallo as a result marked isError whose text item holds only the error’s message, with no error_type and no owner, and the SDK answers arguments that do not match the input schema the same way, with a text item that begins Input validation error.
These error types cover the common cases:
| Error type | When to use |
|---|---|
not_found | The requested record does not exist, or belongs to another customer |
validation_error | Input parameters are invalid or missing |
authentication_error | The request could not be authenticated |
rate_limit | Too many requests; try again later |
server_error | An unexpected error occurred in your system |
Repeated calls and the idempotency key
Section titled “Repeated calls and the idempotency key”oHallo sends an idempotency key in the MCP request’s _meta, under the key eu.ohallo/idempotency-key. The key is a UUID. Every call an agent makes carries one, whatever the tool’s classification, and every call to a tool classified write, write_pii or destructive carries one:
{ "method": "tools/call", "params": { "name": "create_order", "arguments": { "sku": "EX-100", "quantity": 2, "tenantId": "5b6c7d8e-9f01-4234-8567-89abcdef0123", "workspaceId": "0a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d" }, "_meta": { "eu.ohallo/idempotency-key": "3f6b1c8e-5a2d-5f0e-9c41-7d2b8e6a1f03" } }}What oHallo does with the key. On a state-changing tool, a call is never sent to your server while an earlier call with the same key is still open, and once your server has answered, oHallo answers a repeat of that key from the recorded answer for 24 hours, without calling you again. When your answer does not reach oHallo, because the connection dropped, the call timed out or your server answered with an HTTP error, the call can reach your server again, with the same key.
So treat the key as your deduplication key, and make the effect happen exactly once on your side.
The SDK passes the request’s _meta to the handler as ctx.mcpReq._meta, and the usual shape is a unique column:
server.registerTool( 'create_order', { description: 'Create an order for a product. Return only the data provided by this tool.', inputSchema: z.object({ sku: z.string().describe('The product SKU, e.g. EX-100'), quantity: z.number().int().describe('The number of units to order'), }), }, async (args, ctx) => { const key = ctx.mcpReq._meta?.['eu.ohallo/idempotency-key'] if (typeof key !== 'string') return answer(await createOrderWithoutDedup(args))
// Store the key with the record, uniquely. const existing = await db.getOrderByIdempotencyKey(key) if (existing) return answer({ order_id: existing.id, status: existing.status })
try { const order = await db.createOrder({ ...args, idempotencyKey: key }) return answer({ order_id: order.id, status: order.status }) } catch (err) { // Lost a race against a concurrent retry: return the winner. if (isUniqueViolation(err)) { const winner = await db.getOrderByIdempotencyKey(key) if (winner) return answer({ order_id: winner.id, status: winner.status }) } throw err } },)Two rules follow from the key:
- Return the original result for a repeated key. A repeat asks “did this land?”, and the answer is the record you already made.
- A key identifies one intended call, not one set of arguments. When an agent deliberately calls your tool twice with the same arguments in one run, the two calls carry two different keys, and your server decides whether the second one is a mistake. When oHallo sends the same call again, it carries the same key.
An agent’s calls to tools classified read or read_pii carry a key as well, and oHallo sends reads again freely, since repeating a read changes nothing. The lookup oHallo makes to verify a customer is sent without a key. Keep the deduplication on the tools that change state.
Authentication
Section titled “Authentication”oHallo sends the credential you register with the connection on every request. With the API key auth type and the Single API key shape, that is a Bearer token in the Authorization header. The server in this guide accepts a request only when that token equals the MCP_API_KEY environment variable (Step 3), and answers any other request with 401.
Set the environment variable when you start your server. To run the compiled server, build it with npx tsc first:
npx tscMCP_API_KEY=your-secret-key-here node dist/server.jsIn production, use a strong, randomly generated key, and enter the same key in oHallo when you register the server.
The custom MCP server form offers two other credentials a server can check:
- Named headers (auth type API key, credential shape Named headers): one or more headers your server expects, each sent with the value you enter.
- OAuth2 client credentials: oHallo requests an access token from the token endpoint you enter, a public
httpsaddress, with the client ID and client secret in aclient_credentialsgrant, and sends the token as aBearertoken.
Your own token check
Section titled “Your own token check”To validate the token against your own records, and give your handlers what it grants, resolve it before the SDK reads the request and pass the result on as the request’s auth. toNodeHandler hands it to the SDK, and every tool handler reads it as ctx.http?.authInfo:
import type { AuthInfo } from '@modelcontextprotocol/server'
const httpServer = createServer(async (req, res) => { const authorization = req.headers.authorization const session = authorization?.startsWith('Bearer ') ? await lookUpToken(authorization.slice('Bearer '.length)) : null if (!session) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null })) return } const auth: AuthInfo = { token: session.token, clientId: session.clientId, scopes: session.scopes } await mcpHandler(Object.assign(req, { auth }), res)})A handler reads it from its second argument:
async (args, ctx) => { // The token, clientId and scopes that lookUpToken returned for this request const scopes = ctx.http?.authInfo?.scopes ?? [] return answer({ scopes })}This auth context is distinct from the tenantId and workspaceId that oHallo adds to the arguments: authInfo comes from your own token validation, and the arguments come from oHallo at call time.
Next steps
Section titled “Next steps”- Tool Schema: input schemas, output schemas, annotations and return values
- Example: Shipping Tracker: a complete server with tools that return a customer’s records
- Testing and Debugging: how to verify your server works correctly
- MCP Connections: register and govern the connection from the API