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 will have a working server that exposes a get_customer tool that agents can call during conversations.

  • Node.js 20 or later
  • npm or pnpm
  • An oHallo workspace with admin access (for registration in Step 6)
Terminal window
mkdir my-crm-server && cd my-crm-server
npm init -y
npm install @standfast/mcp-server @hono/node-server
npm install -D typescript @types/node

Create a tsconfig.json:

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

Create src/server.ts and define a get_customer tool. Every tool needs a name, description, input schema, and handler function.

MCP Hub automatically injects tenantId and workspaceId into the args at call time — you do not need to declare them in your input schema. Your handler receives them if you need them for data isolation, but they are not part of the tool’s declared interface.

import { createMcpServer, McpError } from '@standfast/mcp-server'
import type { McpToolDef } from '@standfast/mcp-server'
// Simulated customer database
const customers: Record<string, { id: string; name: string; email: string; plan: string }> = {
'cust_001': { id: 'cust_001', name: 'Acme Corp', email: 'billing@acme.com', plan: 'enterprise' },
'cust_002': { id: 'cust_002', name: 'Globex Inc', email: 'support@globex.com', plan: 'professional' },
}
const tools: McpToolDef[] = [
{
name: 'get_customer',
description: 'Look up a customer by their customer ID. Returns the customer name, email, and current plan.',
inputSchema: {
type: 'object',
properties: {
customerId: {
type: 'string',
description: 'The customer ID, e.g. cust_001',
},
},
required: ['customerId'],
},
handler: async (args) => {
const customerId = args.customerId as string
const customer = customers[customerId]
if (!customer) {
throw new McpError('not_found', `Customer ${customerId} not found`)
}
return {
id: customer.id,
name: customer.name,
email: customer.email,
plan: customer.plan,
}
},
},
]

Tool naming convention: Use snake_case verbs — get_customer, search_orders, create_quote. The name should clearly describe the action.

Descriptions matter. The planning agent reads tool descriptions to decide which tools to call. A vague description like “Get data” will cause the agent to skip your tool. Be specific: “Look up a customer by their customer ID. Returns the customer name, email, and current plan.”

createMcpServer returns a Hono app with the MCP endpoint already mounted. Add the server creation below the tools:

const app = await createMcpServer({
name: 'my-crm',
authType: 'api_key',
tools,
})

The authType: 'api_key' setting tells the server to validate incoming requests against the MCP_API_KEY environment variable. MCP Hub sends this key as a Bearer token in the Authorization header.

Use serve() from @hono/node-server to start the HTTP server. Add this at the bottom of src/server.ts:

import { serve } from '@hono/node-server'
serve({ fetch: app.fetch, port: 4100 }, (info) => {
console.log(`MCP server listening on port ${info.port}`)
})

The complete file at this point:

import { createMcpServer, McpError } from '@standfast/mcp-server'
import type { McpToolDef } from '@standfast/mcp-server'
import { serve } from '@hono/node-server'
const customers: Record<string, { id: string; name: string; email: string; plan: string }> = {
'cust_001': { id: 'cust_001', name: 'Acme Corp', email: 'billing@acme.com', plan: 'enterprise' },
'cust_002': { id: 'cust_002', name: 'Globex Inc', email: 'support@globex.com', plan: 'professional' },
}
const tools: McpToolDef[] = [
{
name: 'get_customer',
description: 'Look up a customer by their customer ID. Returns the customer name, email, and current plan.',
inputSchema: {
type: 'object',
properties: {
customerId: {
type: 'string',
description: 'The customer ID, e.g. cust_001',
},
},
required: ['customerId'],
},
handler: async (args) => {
const customerId = args.customerId as string
// tenantId and workspaceId are also available in args if needed:
// const tenantId = args.tenantId as string
const customer = customers[customerId]
if (!customer) {
throw new McpError('not_found', `Customer ${customerId} not found`)
}
return {
id: customer.id,
name: customer.name,
email: customer.email,
plan: customer.plan,
}
},
},
]
async function main() {
const app = await createMcpServer({
name: 'my-crm',
authType: 'api_key',
tools,
})
serve({ fetch: app.fetch, port: 4100 }, (info) => {
console.log(`MCP server listening on port ${info.port}`)
})
}
main()

Start the server:

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

With the server running, test it using curl. MCP uses JSON-RPC 2.0 over HTTP.

List available tools:

Terminal window
curl -s http://localhost:4100/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer my-secret-key" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}' | jq .

Expected response:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "get_customer",
"description": "Look up a customer by their customer ID. Returns the customer name, email, and current plan.",
"inputSchema": {
"type": "object",
"properties": {
"customerId": { "type": "string", "description": "The customer ID, e.g. cust_001" }
},
"required": ["customerId"]
}
}
]
}
}

Call a tool:

Terminal window
curl -s http://localhost:4100/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer my-secret-key" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_customer",
"arguments": {
"customerId": "cust_001"
}
}
}' | jq .

Expected response:

{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"id\":\"cust_001\",\"name\":\"Acme Corp\",\"email\":\"billing@acme.com\",\"plan\":\"enterprise\"}"
}
]
}
}

Test error handling:

Terminal window
curl -s http://localhost:4100/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer my-secret-key" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_customer",
"arguments": {
"customerId": "nonexistent"
}
}
}' | jq .

Once your server is deployed and accessible over HTTPS:

  1. Open the oHallo dashboard.
  2. Go to Settings then MCP Hub.
  3. Click Add MCP Server.
  4. Enter a name (e.g. “My CRM”), the server URL (e.g. https://mcp.yourcompany.com/mcp), and the API key.
  5. Click Connect. oHallo will call tools/list to discover your tools.
  6. You should see your get_customer tool listed.

Tools are not used automatically — you need to assign them to an agent:

  1. Go to Settings then Agents.
  2. Select the agent that should have access to your CRM tools (or create a new specialist agent).
  3. Under MCP Connections, select the MCP server you just added.
  4. Save. The agent can now call get_customer during conversations.

When a customer writes “Can you check my account details? My ID is cust_001”, the planning agent will recognise the intent and route to your specialist agent, which will call get_customer with the extracted customer ID.

Use McpError to throw structured errors that agents can understand and communicate to customers:

import { McpError } from '@standfast/mcp-server'
// In your tool handler:
handler: async (args) => {
const customerId = args.customerId as string
if (!customerId.startsWith('cust_')) {
throw new McpError('validation_error', 'Customer ID must start with "cust_"')
}
const customer = await db.findCustomer(customerId)
if (!customer) {
throw new McpError('not_found', `Customer ${customerId} not found`)
}
return { id: customer.id, name: customer.name }
}

Available error types:

Error typeWhen to use
not_foundThe requested resource does not exist
validation_errorInput parameters are invalid or missing
authentication_errorThe request could not be authenticated
permission_deniedThe caller does not have access to this resource
rate_limitToo many requests — try again later
server_errorAn unexpected error occurred in your system

When authType is set to 'api_key', the server expects a Bearer token in the Authorization header. The token is validated against the MCP_API_KEY environment variable.

Set the environment variable when starting your server:

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

In production, use a strong, randomly generated key. Store the same key in oHallo when registering the MCP server — oHallo will send it on every request.

If your server does not need authentication (e.g. it runs on an internal network), set authType: 'none'. This is not recommended for production deployments.