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 will have a working server with realistic mock data, proper error handling, and instructions for testing.
What we are building
Section titled “What we are building”Three tools:
| Tool | Purpose |
|---|---|
get_shipment | Look up a shipment by tracking number |
list_shipments | List shipments for an account, with optional status filter |
estimate_delivery | Estimate delivery time between two locations |
Project setup
Section titled “Project setup”mkdir shipping-tracker && cd shipping-trackernpm init -ynpm install @standfast/mcp-server @hono/node-servernpm install -D typescript @types/nodeCreate tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"]}The complete server
Section titled “The complete server”Create src/server.ts with the full implementation:
import { createMcpServer, McpError } from '@standfast/mcp-server'import type { McpToolDef } from '@standfast/mcp-server'import { serve } from '@hono/node-server'
// ---------------------------------------------------------------------------// Mock data -- replace with real database or API calls in production// ---------------------------------------------------------------------------
interface Shipment { trackingNumber: string accountId: string status: 'processing' | 'in_transit' | 'out_for_delivery' | 'delivered' | 'exception' origin: string destination: string carrier: 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', accountId: 'acc_acme', status: 'in_transit', origin: 'Rotterdam, NL', destination: 'Munich, DE', carrier: 'EuroExpress', 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', accountId: 'acc_acme', status: 'delivered', origin: 'Amsterdam, NL', destination: 'Berlin, DE', carrier: 'EuroExpress', 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 M. Weber' }, ], }, { trackingNumber: 'SHP-2026-00144', accountId: 'acc_globex', status: 'exception', origin: 'Brussels, BE', destination: 'Vienna, AT', carrier: 'AlpineLogistics', 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', accountId: 'acc_acme', status: 'processing', origin: 'Rotterdam, NL', destination: 'Paris, FR', carrier: 'EuroExpress', 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 pairsconst 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 }, },}
// ---------------------------------------------------------------------------// Tool definitions// ---------------------------------------------------------------------------
const tools: McpToolDef[] = [ { name: 'get_shipment', description: 'Look up a shipment by its tracking number. Returns the current status, origin, destination, carrier, shipping events timeline, and estimated or actual delivery date. Use this when a customer asks about a specific shipment or tracking number.', inputSchema: { type: 'object', properties: { trackingNumber: { type: 'string', description: 'The shipment tracking number, e.g. SHP-2026-00142', }, }, required: ['trackingNumber'], }, handler: async (args) => { const trackingNumber = args.trackingNumber as string
const shipment = shipments.find((s) => s.trackingNumber === trackingNumber) if (!shipment) { throw new McpError('not_found', `Shipment with tracking number ${trackingNumber} not found`) }
return { trackingNumber: shipment.trackingNumber, status: shipment.status, origin: shipment.origin, destination: shipment.destination, carrier: shipment.carrier, weight: { value: shipment.weight, unit: shipment.weightUnit }, shippedAt: shipment.shippedAt, estimatedDelivery: shipment.estimatedDelivery, deliveredAt: shipment.deliveredAt, events: shipment.events, } }, },
{ name: 'list_shipments', description: 'List all shipments for a given account. 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.', inputSchema: { type: 'object', properties: { accountId: { type: 'string', description: 'The customer account ID, e.g. acc_acme', }, status: { type: 'string', enum: ['processing', 'in_transit', 'out_for_delivery', 'delivered', 'exception'], description: 'Filter shipments by status. Omit to return all statuses.', }, }, required: ['accountId'], }, handler: async (args) => { const accountId = args.accountId as string const statusFilter = args.status as string | undefined
let results = shipments.filter((s) => s.accountId === accountId)
if (results.length === 0) { throw new McpError('not_found', `No shipments found for account ${accountId}`) }
if (statusFilter) { results = results.filter((s) => s.status === statusFilter) }
return { accountId, shipments: results.map((s) => ({ trackingNumber: s.trackingNumber, status: s.status, origin: s.origin, destination: s.destination, carrier: s.carrier, shippedAt: s.shippedAt, estimatedDelivery: s.estimatedDelivery, deliveredAt: s.deliveredAt, })), totalCount: results.length, } }, },
{ name: '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.', inputSchema: { type: 'object', properties: { originCity: { type: 'string', description: 'The origin city name, e.g. Rotterdam, Amsterdam, Brussels', }, destinationCity: { type: 'string', description: 'The destination city name, e.g. Munich, Berlin, Paris, Vienna', }, }, required: ['originCity', 'destinationCity'], }, handler: async (args) => { const origin = (args.originCity as string).toLowerCase() const destination = (args.destinationCity as string).toLowerCase()
const originRoutes = deliveryEstimates[origin] if (!originRoutes) { throw new McpError( 'validation_error', `Origin city "${args.originCity}" is not in our service area. Available origins: Rotterdam, Amsterdam, Brussels.` ) }
const estimate = originRoutes[destination] if (!estimate) { throw new McpError( 'validation_error', `Destination city "${args.destinationCity}" is not available from ${args.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 { origin: args.originCity, destination: args.destinationCity, standard: { estimatedHours: estimate.standardHours, estimatedTimeframe: formatHours(estimate.standardHours), }, express: { estimatedHours: estimate.expressHours, estimatedTimeframe: formatHours(estimate.expressHours), }, } }, },]
// ---------------------------------------------------------------------------// Start the server// ---------------------------------------------------------------------------
async function main() { const app = await createMcpServer({ name: 'shipping-tracker', version: '1.0.0', authType: 'api_key', tools, })
serve({ fetch: app.fetch, port: 4200 }, (info) => { console.log(`Shipping tracker MCP server listening on port ${info.port}`) })}
main()How agents use this server
Section titled “How agents use this server”Here is how a real 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:
- The Orchestrator classifies the intent as a shipping inquiry and routes to the specialist agent that has the shipping tracker tools.
- The Specialist Agent calls
get_shipmentwithtrackingNumber: "SHP-2026-00142". - The tool returns the shipment details, including the status
in_transit, the event timeline, and the estimated delivery date. - The Message Agent composes a response using the returned data:
Agent response: “Your shipment SHP-2026-00142 is currently in transit. It departed the sorting facility in Duisburg, Germany on March 24th and is on its way to Munich. The estimated delivery is March 27th by 6:00 PM. I apologise for the delay — it looks like the shipment is running slightly behind the original schedule.”
If the customer then asks “What other shipments do I have pending?”, the agent calls list_shipments with the customer’s account ID and returns a summary.
Testing the server
Section titled “Testing the server”Start the server:
MCP_API_KEY=test-key npx tsx src/server.tsList tools:
curl -s http://localhost:4200/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer test-key" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }' | jq '.result.tools[].name'Expected output:
"get_shipment""list_shipments""estimate_delivery"Look up a shipment:
curl -s http://localhost:4200/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer test-key" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_shipment", "arguments": { "trackingNumber": "SHP-2026-00142" } } }' | jq .List shipments with a status filter:
curl -s http://localhost:4200/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer test-key" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "list_shipments", "arguments": { "accountId": "acc_acme", "status": "in_transit" } } }' | jq .Estimate delivery time:
curl -s http://localhost:4200/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer test-key" \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "estimate_delivery", "arguments": { "originCity": "Rotterdam", "destinationCity": "Munich" } } }' | jq .Test error handling (invalid tracking number):
curl -s http://localhost:4200/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer test-key" \ -d '{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "get_shipment", "arguments": { "trackingNumber": "INVALID-123" } } }' | jq .Deploying to production
Section titled “Deploying to production”When you are ready to deploy:
- Replace the mock data with real database queries or API calls to your shipping provider.
- Set a strong
MCP_API_KEYenvironment variable. - Deploy to any hosting that supports Node.js and HTTP (Docker, AWS ECS, Railway, Fly.io, a VM).
- Register the server in oHallo under Settings then MCP Hub.
- Assign the tools to the appropriate specialist agent.
Next steps
Section titled “Next steps”- Tool Schema — advanced input schema patterns
- Testing and Debugging — debugging tool calls in production
- Building a Custom Agent — create a specialist agent and connect your tools