Claude Code

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.

In short

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:

  1. Tool definitions, what tools Claude can call (name, description, input schema)
  2. Tool handlers, the actual code that runs when Claude calls each tool
  3. 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:

  1. Start with read-only tools. No cancel_order, no create_customer in v1. Add writes only after months of clean behaviour.
  2. Per-tool audit log. Log every tool call (caller, args, timestamp, result hash) so you can reconstruct any agent’s actions later.
  3. Test the MCP server in isolation first. Use the mcp-inspector debugging tool before plugging into Claude.
  4. Document the tools as well as the code. The description field 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 MCPBuild 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?
No. Most business MCP servers are internal, they live on the operator's laptop or on a private VPS, exposing only to the configured Claude clients. The official Anthropic catalogue is for public servers.
What language should I write it in?
TypeScript or Python, both have first-class MCP SDKs. Pick whatever your team already writes. The wire protocol is identical.
How does Claude authenticate against my server?
MCP supports several auth patterns. For local servers, environment variables. For remote servers, OAuth or bearer tokens. The SDK handles the details.

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