Skip to content

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.

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.

  • 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)
Terminal window
mkdir my-catalog-server && cd my-catalog-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server@2 @modelcontextprotocol/node@2 zod@4
npm install -D typescript @types/node tsx

You 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"]
}

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 catalogue
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 },
}
// 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.”

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.

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:

Terminal window
MCP_API_KEY=my-secret-key npx tsx src/server.ts

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:

Terminal window
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:

Terminal window
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:

Terminal window
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
}

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:

  1. Open the oHallo dashboard.
  2. Go to MCP hub in the sidebar.
  3. Select Add MCP server, then Add custom on the Custom MCP server card.
  4. 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.
  5. Select Test & continue. oHallo calls tools/list with the key and lists the tools it discovered, get_product among them. When the endpoint does not answer, nothing is saved and the form shows the error.
  6. 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:

  1. Go to MCP hub in the sidebar and open the server you just added.
  2. In the Tools table, select Assign beside get_product.
  3. 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".

What your server receives from oHallo, whatever it is built with:

  • One HTTP POST per message, to the endpoint URL you registered. Each POST carries Content-Type: application/json, Accept: application/json, text/event-stream and 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 version 2025-11-25 (Transport and protocol versions lists the versions it accepts) and names itself ohallo in clientInfo, then sends notifications/initialized, and from then on sends the protocol version your server answered in the MCP-Protocol-Version header 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 a GET stream for messages from your server, and accepts a 405 answer to it, which the server in this guide gives.
  • tools/list when 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/call with tenantId and workspaceId in 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/cancelled for it and closes the request. Tool call timeout gives the limits and when a call is sent again.

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 typeWhen to use
not_foundThe requested record does not exist, or belongs to another customer
validation_errorInput parameters are invalid or missing
authentication_errorThe request could not be authenticated
rate_limitToo many requests; try again later
server_errorAn unexpected error occurred in your system

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.

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:

Terminal window
npx tsc
MCP_API_KEY=your-secret-key-here node dist/server.js

In 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 https address, with the client ID and client secret in a client_credentials grant, and sends the token as a Bearer token.

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.