Skip to content

Example: Shipping Tracker

This guide builds a complete MCP server for shipping and delivery tracking. The server exposes three tools that let oHallo’s agents answer customer questions like “Where is my order?”, “What shipments are pending for my account?”, and “How long will delivery take?”

By the end, you have a working server with mock data, structured errors, output schemas that the oHallo dashboard reads, and the setup that lets each tool return a customer’s own records.

Three tools:

ToolPurpose
get_shipmentLook up a shipment by tracking number
list_shipmentsList the shipments of the customer, with optional status filter
estimate_deliveryEstimate delivery time between two locations
Terminal window
mkdir shipping-tracker && cd shipping-tracker
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

Create 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 with the full implementation. It is built on version 2 of the official MCP TypeScript SDK, @modelcontextprotocol/server, as Build an MCP Server describes step by step:

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'
// ---------------------------------------------------------------------------
// Mock data: replace with real database or API calls in production
// ---------------------------------------------------------------------------
const SHIPMENT_STATUSES = ['processing', 'in_transit', 'out_for_delivery', 'delivered', 'exception'] as const
interface Shipment {
trackingNumber: string
customerId: string
status: (typeof SHIPMENT_STATUSES)[number]
origin: string
destination: string
weight: number
weightUnit: string
shippedAt: string | null
estimatedDelivery: string | null
deliveredAt: string | null
events: Array<{ timestamp: string; location: string; description: string }>
}
const shipments: Shipment[] = [
{
trackingNumber: 'SHP-2026-00142',
customerId: 'C-1001',
status: 'in_transit',
origin: 'Rotterdam, NL',
destination: 'Munich, DE',
weight: 12.5,
weightUnit: 'kg',
shippedAt: '2026-03-22T08:00:00Z',
estimatedDelivery: '2026-03-27T18:00:00Z',
deliveredAt: null,
events: [
{ timestamp: '2026-03-22T08:00:00Z', location: 'Rotterdam, NL', description: 'Shipment picked up from warehouse' },
{ timestamp: '2026-03-23T14:30:00Z', location: 'Duisburg, DE', description: 'Arrived at sorting facility' },
{ timestamp: '2026-03-24T06:15:00Z', location: 'Duisburg, DE', description: 'Departed sorting facility' },
],
},
{
trackingNumber: 'SHP-2026-00143',
customerId: 'C-1001',
status: 'delivered',
origin: 'Amsterdam, NL',
destination: 'Berlin, DE',
weight: 3.2,
weightUnit: 'kg',
shippedAt: '2026-03-18T10:00:00Z',
estimatedDelivery: '2026-03-21T18:00:00Z',
deliveredAt: '2026-03-21T14:22:00Z',
events: [
{ timestamp: '2026-03-18T10:00:00Z', location: 'Amsterdam, NL', description: 'Shipment picked up' },
{ timestamp: '2026-03-19T16:00:00Z', location: 'Hannover, DE', description: 'In transit' },
{ timestamp: '2026-03-21T09:00:00Z', location: 'Berlin, DE', description: 'Out for delivery' },
{ timestamp: '2026-03-21T14:22:00Z', location: 'Berlin, DE', description: 'Delivered, signed by A. Doe' },
],
},
{
trackingNumber: 'SHP-2026-00144',
customerId: 'C-1001',
status: 'exception',
origin: 'Brussels, BE',
destination: 'Vienna, AT',
weight: 45.0,
weightUnit: 'kg',
shippedAt: '2026-03-20T07:00:00Z',
estimatedDelivery: '2026-03-25T18:00:00Z',
deliveredAt: null,
events: [
{ timestamp: '2026-03-20T07:00:00Z', location: 'Brussels, BE', description: 'Shipment picked up' },
{ timestamp: '2026-03-22T11:00:00Z', location: 'Stuttgart, DE', description: 'Held at customs, documentation required' },
],
},
{
trackingNumber: 'SHP-2026-00145',
customerId: 'C-1001',
status: 'processing',
origin: 'Rotterdam, NL',
destination: 'Paris, FR',
weight: 8.0,
weightUnit: 'kg',
shippedAt: null,
estimatedDelivery: null,
deliveredAt: null,
events: [
{ timestamp: '2026-03-25T16:00:00Z', location: 'Rotterdam, NL', description: 'Order received, awaiting pickup' },
],
},
]
// Delivery time estimates in hours between city pairs
const deliveryEstimates: Record<string, Record<string, { standardHours: number; expressHours: number }>> = {
rotterdam: {
munich: { standardHours: 96, expressHours: 48 },
berlin: { standardHours: 72, expressHours: 36 },
paris: { standardHours: 48, expressHours: 24 },
vienna: { standardHours: 120, expressHours: 60 },
},
amsterdam: {
munich: { standardHours: 96, expressHours: 48 },
berlin: { standardHours: 72, expressHours: 36 },
paris: { standardHours: 48, expressHours: 24 },
vienna: { standardHours: 120, expressHours: 60 },
},
brussels: {
munich: { standardHours: 72, expressHours: 36 },
berlin: { standardHours: 96, expressHours: 48 },
paris: { standardHours: 24, expressHours: 12 },
vienna: { standardHours: 120, expressHours: 60 },
},
}
// ---------------------------------------------------------------------------
// Answers, errors and output schemas
// ---------------------------------------------------------------------------
// 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) }] }
}
const shipmentSummary = z.object({
trackingNumber: z.string(),
customerId: z.string(),
status: z.enum(SHIPMENT_STATUSES),
origin: z.string(),
destination: z.string(),
shippedAt: z.string().nullable(),
estimatedDelivery: z.string().nullable(),
deliveredAt: z.string().nullable(),
})
const shipmentDetail = shipmentSummary.extend({
weight: z.object({ value: z.number(), unit: z.string() }),
events: z.array(z.object({ timestamp: z.string(), location: z.string(), description: z.string() })),
})
const deliveryOption = z.object({ estimatedHours: z.number(), estimatedTimeframe: z.string() })
function summary(s: Shipment): z.infer<typeof shipmentSummary> {
return {
trackingNumber: s.trackingNumber,
customerId: s.customerId,
status: s.status,
origin: s.origin,
destination: s.destination,
shippedAt: s.shippedAt,
estimatedDelivery: s.estimatedDelivery,
deliveredAt: s.deliveredAt,
}
}
// ---------------------------------------------------------------------------
// Tool definitions
// ---------------------------------------------------------------------------
function buildServer(): McpServer {
const server = new McpServer({ name: 'shipping-tracker', version: '1.0.0' })
server.registerTool(
'get_shipment',
{
description:
'Look up a shipment by its tracking number. Returns the current status, origin, destination, shipping events timeline, and estimated or actual delivery date. Use this when a customer asks about a specific shipment or tracking number. Return only the data provided by this tool.',
inputSchema: z.object({
trackingNumber: z.string().describe('The shipment tracking number, e.g. SHP-2026-00142'),
}),
outputSchema: shipmentDetail,
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ trackingNumber }) => {
const shipment = shipments.find((s) => s.trackingNumber === trackingNumber)
if (!shipment) {
return toolError({ error_type: 'not_found', reason: `Shipment with tracking number ${trackingNumber} not found` })
}
return answer({
...summary(shipment),
weight: { value: shipment.weight, unit: shipment.weightUnit },
events: shipment.events,
})
},
)
server.registerTool(
'list_shipments',
{
description:
"List the customer's shipments. Optionally filter by shipment status. Returns a summary of each shipment including tracking number, status, origin, destination, and dates. Use this when a customer asks about their recent shipments or pending deliveries. Return only the data provided by this tool.",
inputSchema: z.object({
customerId: z.string().describe('The customer whose shipments to list'),
status: z.enum(SHIPMENT_STATUSES).optional().describe('Filter shipments by status. Omit to return all statuses.'),
}),
outputSchema: z.object({
customerId: z.string(),
shipments: z.array(shipmentSummary),
totalCount: z.number().int(),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ customerId, status }) => {
// A customer with no shipments answers an empty list.
let results = shipments.filter((s) => s.customerId === customerId)
if (status) {
results = results.filter((s) => s.status === status)
}
return answer({
customerId,
shipments: results.map(summary),
totalCount: results.length,
})
},
)
server.registerTool(
'estimate_delivery',
{
description:
'Estimate the delivery time between two cities for both standard and express shipping. Returns estimated hours and a human-readable timeframe. Use this when a customer asks how long a delivery will take or wants to compare shipping options. Return only the data provided by this tool.',
inputSchema: z.object({
originCity: z.string().describe('The origin city name, e.g. Rotterdam, Amsterdam, Brussels'),
destinationCity: z.string().describe('The destination city name, e.g. Munich, Berlin, Paris, Vienna'),
}),
outputSchema: z.object({
origin: z.string(),
destination: z.string(),
standard: deliveryOption,
express: deliveryOption,
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ originCity, destinationCity }) => {
const originRoutes = deliveryEstimates[originCity.toLowerCase()]
if (!originRoutes) {
return toolError({
error_type: 'validation_error',
reason: `Origin city "${originCity}" is not in our service area. Available origins: Rotterdam, Amsterdam, Brussels.`,
})
}
const estimate = originRoutes[destinationCity.toLowerCase()]
if (!estimate) {
return toolError({
error_type: 'validation_error',
reason: `Destination city "${destinationCity}" is not available from ${originCity}. Available destinations: Munich, Berlin, Paris, Vienna.`,
})
}
const formatHours = (hours: number): string => {
if (hours < 24) return `${hours} hours`
const days = Math.round(hours / 24)
return `${days} business day${days > 1 ? 's' : ''}`
}
return answer({
origin: originCity,
destination: destinationCity,
standard: {
estimatedHours: estimate.standardHours,
estimatedTimeframe: formatHours(estimate.standardHours),
},
express: {
estimatedHours: estimate.expressHours,
estimatedTimeframe: formatHours(estimate.expressHours),
},
})
},
)
return server
}
// ---------------------------------------------------------------------------
// Start the server
// ---------------------------------------------------------------------------
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)
})
httpServer.listen(4200, () => {
console.log('Shipping tracker MCP server listening on port 4200')
})

Each tool declares an output schema, so the oHallo dashboard lists the fields each one returns, and the Customer records settings offer them when you choose the field that says whose data it is.

Here is how a conversation flows when a customer asks about their shipment:

Customer: “Hi, can you check on my shipment SHP-2026-00142? It was supposed to arrive yesterday.”

What happens behind the scenes:

  1. The Orchestrator reads the request as a shipping question and hands it to the specialist agent that holds the shipping tracker tools.
  2. The Specialist Agent calls get_shipment with trackingNumber: "SHP-2026-00142". The tool returns a customer’s records, so when the customer has not verified, oHallo first asks them to verify, and the tool answers once they have.
  3. The tool returns the shipment details, including the status in_transit, the event timeline, and the estimated delivery date. The answer names C-1001 as the customer, the verified customer’s number, so oHallo passes it on.
  4. The Message Agent composes a response from the returned data. A reply might read:

Assistant: “Your shipment SHP-2026-00142 is in transit. It left the sorting facility in Duisburg on March 24th and is on its way to Munich, with delivery expected on March 27th by 6:00 PM.”

If the customer then asks “What other shipments do I have pending?”, the agent calls list_shipments, and oHallo fills customerId with the number of the customer who verified, so the summary holds only their shipments.

Both shipment tools return a customer’s records, so they are declared A customer (see Deploying to production): an answer reaches the assistant only when it names that customer. get_shipment returns any shipment by tracking number, and oHallo withholds the answer when the shipment belongs to someone else. Its not_found error names no customer, so oHallo withholds that answer too, and the assistant learns only that the result cannot be shown.

Start the server:

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

The SDK sends each answer as a server-sent event, so the commands keep the event’s data: line with sed before formatting it with jq, as in Build an MCP Server.

List tools:

Terminal window
curl -s http://localhost:4200/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer test-key" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}' | sed -n 's/^data: //p' | jq '.result.tools[].name'

Expected output:

"get_shipment"
"list_shipments"
"estimate_delivery"

Look up a shipment:

Terminal window
curl -s http://localhost:4200/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer test-key" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_shipment",
"arguments": {
"trackingNumber": "SHP-2026-00142"
}
}
}' | sed -n 's/^data: //p' | jq .result.structuredContent

List shipments with a status filter:

Terminal window
curl -s http://localhost:4200/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer test-key" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "list_shipments",
"arguments": {
"customerId": "C-1001",
"status": "in_transit"
}
}
}' | sed -n 's/^data: //p' | jq .result.structuredContent

Estimate delivery time:

Terminal window
curl -s http://localhost:4200/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer test-key" \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "estimate_delivery",
"arguments": {
"originCity": "Rotterdam",
"destinationCity": "Munich"
}
}
}' | sed -n 's/^data: //p' | jq .result.structuredContent

Test error handling (unknown tracking number):

Terminal window
curl -s http://localhost:4200/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer test-key" \
-d '{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "get_shipment",
"arguments": {
"trackingNumber": "SHP-0000-00000"
}
}
}' | sed -n 's/^data: //p' | jq .result

Expected output:

{
"content": [
{
"type": "text",
"text": "{\"error_type\":\"not_found\",\"reason\":\"Shipment with tracking number SHP-0000-00000 not found\"}"
}
],
"isError": true
}

When you are ready to deploy:

  1. Replace the mock data with real database queries or API calls to your shipping provider.
  2. Set a strong MCP_API_KEY environment variable.
  3. Deploy to any hosting that runs Node.js and serves HTTPS on a public host name at the standard port (a container platform, a managed application host, or a VM).
  4. Register the server in oHallo: MCP hub in the sidebar, then Add MCP server and Add custom (Build an MCP Server walks through it).
  5. Set each tool’s risk in the Risk column of the connection’s Tools table. All three declare readOnlyHint: true, so each starts as read (PII):
    • estimate_delivery returns no personal data: set it to read.
    • get_shipment and list_shipments return a customer’s shipments: keep read (PII).
  6. Assign the tools to the appropriate specialist agent: on the connection under MCP hub, select Assign beside each tool in the Tools table.
  7. Add a tool that finds a customer, as the customer lookup contract describes, and set it up on the connection’s page under Settings → Customer records, under Finding customers and Verifying with MitID, so it returns your customer number beside the CPR number or the MitID UUID and oHallo can tie a customer who verifies with National eID to your customer number.
  8. Set up what each tool returns, one tool at a time, under Customer data on the same page (Declare the scope of each tool is the API). oHallo calls a tool once it is declared:
    • estimate_delivery: General information, for anyone.
    • get_shipment: A customer, with customerId as the field that says whose data it is and trackingNumber ticked under Inputs that say what is asked for.
    • list_shipments: A customer, with shipments[0].customerId picked as the field that says whose data it is, which oHallo checks on every item of the list, and customerId as the input that receives the customer number. Over the API the same field is "ownerField": "shipments[].customerId".