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.
Prerequisites
Section titled “Prerequisites”- Node.js 20 or later
- npm or pnpm
- An oHallo workspace with admin access (for registration in Step 6)
Step 1: Create the project
Section titled “Step 1: Create the project”mkdir my-crm-server && cd my-crm-servernpm init -ynpm install @standfast/mcp-server @hono/node-servernpm install -D typescript @types/nodeCreate a tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"]}Step 2: Define your tools
Section titled “Step 2: Define your tools”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 databaseconst 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.”
Step 3: Create the server
Section titled “Step 3: Create the server”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.
Step 4: Start the server
Section titled “Step 4: Start the server”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:
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 using curl. MCP uses JSON-RPC 2.0 over HTTP.
List available tools:
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:
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:
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 .Step 6: Register in oHallo
Section titled “Step 6: Register in oHallo”Once your server is deployed and accessible over HTTPS:
- Open the oHallo dashboard.
- Go to Settings then MCP Hub.
- Click Add MCP Server.
- Enter a name (e.g. “My CRM”), the server URL (e.g.
https://mcp.yourcompany.com/mcp), and the API key. - Click Connect. oHallo will call
tools/listto discover your tools. - You should see your
get_customertool listed.
Step 7: Assign tools to an agent
Section titled “Step 7: Assign tools to an agent”Tools are not used automatically — you need to assign them to an agent:
- Go to Settings then Agents.
- Select the agent that should have access to your CRM tools (or create a new specialist agent).
- Under MCP Connections, select the MCP server you just added.
- Save. The agent can now call
get_customerduring 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.
Error handling
Section titled “Error handling”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 type | When to use |
|---|---|
not_found | The requested resource does not exist |
validation_error | Input parameters are invalid or missing |
authentication_error | The request could not be authenticated |
permission_denied | The caller does not have access to this resource |
rate_limit | Too many requests — try again later |
server_error | An unexpected error occurred in your system |
Authentication
Section titled “Authentication”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:
MCP_API_KEY=your-secret-key-here node dist/server.jsIn 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.
Next steps
Section titled “Next steps”- Tool Schema — detailed guide to input schemas and return values
- Example: Shipping Tracker — a complete multi-tool example
- Testing and Debugging — how to verify your server works correctly