Building your own MCP server: a complete Australian business example
Step-by-step build of a custom MCP server in TypeScript that exposes your business's internal API to Claude. Real example: wholesale-order lookup for a fictional AU SMB.
An MCP server is a small program that exposes typed tools to Claude. You write the tool definitions + the handlers; the official SDK handles the wire protocol. The example below builds a wholesale-order-lookup server for a fictional Australian wholesale business in TypeScript, runs locally, takes 30-60 minutes to adapt for your own API.
You can use Claude Code with the off-the-shelf MCP servers (Xero, Shopify, Slack, etc) and never write a line of integration code. But sometimes the integration you need is your own internal system, a custom CRM, a wholesale-order database, a proprietary scheduling tool, and the off-the-shelf catalogue doesn’t have you covered.
Building your own MCP server is much easier than it sounds. Here’s a complete example.
The scenario
A fictional Australian wholesale business, call it “Aussie Wholesale Co”, runs an internal Node API at https://internal.aussie-wholesale.com.au with endpoints for orders, customers, and inventory. The team wants Claude Code to be able to query this API conversationally:
- “Show me all unfulfilled orders for retailer X in the last 30 days”
- “Which products has retailer Y reordered most often?”
- “Draft a follow-up email to the 5 retailers with the oldest unfulfilled orders”
We’ll build an MCP server that exposes the internal API to Claude.
The pieces
A minimal MCP server has three parts:
- Tool definitions, what tools Claude can call (name, description, input schema)
- Tool handlers, the actual code that runs when Claude calls each tool
- Server bootstrap, the SDK starts up an MCP server, registers your tools, listens
Setup
mkdir aussie-wholesale-mcp && cd aussie-wholesale-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}
The server
src/server.ts:
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
const API_BASE = process.env.AUSSIE_WHOLESALE_API_BASE || 'https://internal.aussie-wholesale.com.au'
const API_TOKEN = process.env.AUSSIE_WHOLESALE_API_TOKEN
if (!API_TOKEN) {
console.error('AUSSIE_WHOLESALE_API_TOKEN env var required')
process.exit(1)
}
async function apiGet(path: string) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Authorization': `Bearer ${API_TOKEN}`, 'Accept': 'application/json' },
})
if (!res.ok) throw new Error(`API ${path}: ${res.status} ${res.statusText}`)
return res.json()
}
// ============ Tool definitions ============
const tools = [
{
name: 'list_unfulfilled_orders',
description: 'List wholesale orders that have been placed but not yet shipped. Optionally filter by retailer or date range.',
inputSchema: {
type: 'object',
properties: {
retailer_id: { type: 'string', description: 'Optional retailer ID to filter by' },
since_days: { type: 'number', description: 'Optional: only orders placed in the last N days', default: 30 },
},
},
},
{
name: 'get_retailer_summary',
description: 'Return a summary of a retailer: total orders, total revenue (AUD), top 5 products by quantity, last order date.',
inputSchema: {
type: 'object',
properties: { retailer_id: { type: 'string', description: 'The retailer ID' } },
required: ['retailer_id'],
},
},
{
name: 'list_low_stock_products',
description: 'List products where current inventory is at or below the reorder threshold.',
inputSchema: { type: 'object', properties: {} },
},
]
// ============ Server ============
const server = new Server(
{ name: 'aussie-wholesale-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
)
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }))
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args = {} } = req.params
try {
if (name === 'list_unfulfilled_orders') {
const params = new URLSearchParams()
if (args.retailer_id) params.set('retailer_id', String(args.retailer_id))
params.set('since_days', String(args.since_days ?? 30))
params.set('status', 'unfulfilled')
const orders = await apiGet(`/orders?${params}`)
return { content: [{ type: 'text', text: JSON.stringify(orders, null, 2) }] }
}
if (name === 'get_retailer_summary') {
const summary = await apiGet(`/retailers/${args.retailer_id}/summary`)
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] }
}
if (name === 'list_low_stock_products') {
const products = await apiGet('/products?status=low_stock')
return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] }
}
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { content: [{ type: 'text', text: `Tool error: ${msg}` }], isError: true }
}
})
const transport = new StdioServerTransport()
await server.connect(transport)
console.error('aussie-wholesale-mcp running on stdio')
That’s the entire server. About 80 lines.
Configuring Claude Code to use it
In your project .mcp.json:
{
"mcpServers": {
"aussie-wholesale": {
"command": "npx",
"args": ["-y", "tsx", "/path/to/aussie-wholesale-mcp/src/server.ts"],
"env": {
"AUSSIE_WHOLESALE_API_BASE": "https://internal.aussie-wholesale.com.au",
"AUSSIE_WHOLESALE_API_TOKEN": "your-internal-api-token"
}
}
}
}
Restart Claude Code. Run /mcp to verify the server loaded + tools are visible.
Using it from Claude
You: Show me all unfulfilled orders for our top 3 retailers in the last 14 days.
Claude: I'll check the unfulfilled orders for your top retailers.
[calls list_unfulfilled_orders for each retailer with since_days=14]
[receives orders + summaries]
Here's what I found:
- Retailer A (Coles WA): 4 unfulfilled orders, oldest 9 days old, total $12,400 AUD
- Retailer B (Woolworths NSW): 2 unfulfilled orders, both within 3 days
- Retailer C (IGA QLD): 1 unfulfilled order, 11 days old, $3,200 AUD
The Retailer A orders look like they need attention, would you like me to draft a follow-up to their procurement contact?
The MCP server returned raw data. Claude interpreted it conversationally. That’s the whole loop.
Production considerations
When you move beyond a personal-laptop experiment to running this on a VPS or in CI:
- Auth: stdio transport works for local. For remote MCP servers, use HTTP + OAuth or bearer-token. The SDK handles both.
- Logging: write to stderr (stdout is the MCP protocol channel). Pipe stderr to your usual logging stack.
- Rate limiting: protect your internal API. The MCP server should respect upstream API rate limits.
- Schema validation: use Zod (or similar) on tool inputs. The MCP SDK doesn’t validate beyond the declared schema; you should.
- Secrets: never log API tokens. Use env vars, never commit credentials.
What we’d do differently
If we were doing this for a real Australian business:
- Start with read-only tools. No
cancel_order, nocreate_customerin v1. Add writes only after months of clean behaviour. - Per-tool audit log. Log every tool call (caller, args, timestamp, result hash) so you can reconstruct any agent’s actions later.
- Test the MCP server in isolation first. Use the
mcp-inspectordebugging tool before plugging into Claude. - Document the tools as well as the code. The
descriptionfield is what Claude reads. Tight, accurate descriptions = better tool selection.
When to do this vs use an off-the-shelf MCP
| Use off-the-shelf MCP | Build your own MCP | |
|---|---|---|
| Common SaaS (Xero, Shopify, GitHub, Notion) | ✓ | , |
| Your own internal system / API | , | ✓ |
| Specialised vertical SaaS without an MCP | , | ✓ |
| Database access (Postgres etc) | ✓ (built-in MCP exists) | , |
| Combining multiple services with business logic | , | ✓ (build a custom aggregator) |
For most Australian SMBs, the answer is “use off-the-shelf MCPs.” For agencies, builder-style consultancies, and businesses with bespoke internal systems, building your own is genuinely worth the afternoon.
If you’d like help designing the MCP architecture for a specific business system, book a free audit, we’ve shipped a few custom MCP servers for AU clients and can scope yours.
Common questions
Do I need to publish my MCP server publicly?
What language should I write it in?
How does Claude authenticate against my server?
Want this built for your business?
Book a free 30-minute AI audit. We'll map your business and show you exactly which systems we'd build first. No pitch deck, no scoping fee.
Book my free AI audit