Finance teams answer the same questions every week. Which customers are more than 60 days overdue? How much have we spent with this supplier this year? Where is marketing over budget? Each answer means opening the accounting system, running a report and tidying up an export.
An AI assistant can answer those in seconds, but only if three things hold: the figures come from your books rather than the model, the assistant can only read, and every answer can be traced. In this tutorial we build a Model Context Protocol (MCP) server in TypeScript that does all three, then run it against a real database and show you exactly what comes back. If MCP is new to you, start with what an MCP server is.
How the pieces fit

The assistant never touches the database. It calls tools on the MCP server, such as get_overdue_invoices. Each tool runs one fixed SQL query against a reporting database and returns exact rows; the model’s job is to pick the tool, fill in the parameters and explain the result.
The reporting database is a PostgreSQL copy of your accounting data, kept current by a sync from Xero or your ERP. We don’t point the server at the live accounting API, for two reasons: reporting queries are much faster on your own copy, and a read-only database user gives you a hard guarantee that nothing can change.
When this is worth building (and when it isn’t)
| Situation | Our advice |
|---|---|
| People ask the same finance questions every week, in different words | Good fit: a handful of read-only tools covers most of them |
| Answers need data from several systems (ledger, CRM, payroll) | Good fit, once that data is combined in a reporting database |
| You want a fixed weekly report emailed to the same people | Not needed: a scheduled report or an automation is simpler |
| You want AI to post entries or approve payments | Not as a first step. Start read-only; let it prepare drafts a person approves |
| Metric definitions differ between teams | Fix the definitions first, or the assistant will repeat the confusion |
Step 1: A read-only database user
Start with the guarantee. Create a PostgreSQL role that can only read the reporting schema, and make every session read-only by default, so even a bug can’t write.
create role mcp_finance login password '...';
grant usage on schema reporting to mcp_finance;
grant select on all tables in schema reporting to mcp_finance;
alter default privileges in schema reporting grant select on tables to mcp_finance;
-- belt and braces: every session starts read-only
alter role mcp_finance set default_transaction_read_only = on;
alter role mcp_finance set statement_timeout = '10s';Here is what happens when anything tries to write as that user. This is a real run against PostgreSQL 16:

Step 2: Project setup
We use the official TypeScript SDK (the 1.x line, published as @modelcontextprotocol/sdk, which most projects run today; the newer 2.x line splits it into smaller packages with a very similar API), zod for input validation and node-postgres for the database.
mkdir finance-mcp && cd finance-mcp
npm init -y
npm install @modelcontextprotocol/sdk@1 zod pg express
npm install -D typescript @types/node @types/pg @types/express tsxStep 3: The first tool
Each tool has a name, a description the model reads when deciding what to call, an input schema and a handler. Keep descriptions specific; they’re effectively the instructions the model follows. The SQL assumes invoice data synced from Xero, where approved but unpaid invoices have the status AUTHORISED.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { Pool, types } from 'pg';
import { requireScope, audit } from './guards.js';
// Return SQL dates as plain 'YYYY-MM-DD' strings instead of shifting them into the server's time zone.
types.setTypeParser(1082, value => value);
const db = new Pool({ connectionString: process.env.REPORTING_DATABASE_URL });
// A fresh server per request keeps stateless HTTP requests fully isolated.
export function createServer() {
const server = new McpServer({ name: 'finance', version: '1.0.0' });
server.registerTool(
'get_overdue_invoices',
{
title: 'Overdue invoices',
description:
'Unpaid customer invoices more than N days past their due date, oldest first. ' +
'Amounts are in the invoice currency. Use this for accounts receivable questions.',
inputSchema: {
minDaysOverdue: z.number().int().min(1).max(365).default(30),
customer: z.string().max(100).optional()
},
// Tell clients this tool only reads: no confirmation prompts, safe to retry.
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async ({ minDaysOverdue, customer }, extra) => {
requireScope(extra, 'finance:ar:read');
const { rows } = await db.query(
`select i.invoice_number, c.name as customer, i.currency, i.amount_due,
i.due_date, (current_date - i.due_date) as days_overdue
from reporting.invoices i
join reporting.customers c on c.id = i.customer_id
where i.status = 'AUTHORISED'
and i.amount_due > 0
and i.due_date < current_date - $1::int
and ($2::text is null or c.name ilike '%' || $2 || '%')
order by days_overdue desc
limit 200`,
[minDaysOverdue, customer ?? null]
);
await audit(extra, 'get_overdue_invoices', { minDaysOverdue, customer }, rows.length);
return {
content: [{ type: 'text', text: JSON.stringify({ asOf: new Date().toISOString(), rows }, null, 2) }]
};
}
);
// ...more tools here
return server;
}Three details matter. The model fills in minDaysOverdue and customer, but zod validates them and the SQL uses placeholders, so nothing can be injected. The result includes asOf, so the assistant can say when the figures were taken. And the annotations tell clients the tool only reads, which changes how they treat it (more on that below).
Step 4: Check who is asking
For a server your whole finance team uses, run it over HTTP behind your identity provider rather than locally. The SDK passes the verified token’s details to every handler as extra.authInfo, so each tool checks the caller’s scopes before running. A department head gets a budget tool; only the finance group gets receivables.
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { Pool } from 'pg';
type Extra = RequestHandlerExtra<any, any>;
// Separate pool with insert-only rights on the audit schema.
const auditDb = new Pool({ connectionString: process.env.AUDIT_DATABASE_URL });
export function requireScope(extra: Extra, scope: string) {
const scopes = extra.authInfo?.scopes ?? [];
if (!scopes.includes(scope)) {
throw new Error(`Not allowed: this tool needs the ${scope} permission.`);
}
}
export async function audit(extra: Extra, tool: string, input: unknown, rowCount: number) {
await auditDb.query(
`insert into audit.mcp_calls (client_id, tool, input, row_count, called_at)
values ($1, $2, $3, $4, now())`,
[extra.authInfo?.clientId ?? 'unknown', tool, JSON.stringify(input), rowCount]
);
}The audit table lives in a separate database or schema that the read-only role can’t touch, written by a second connection pool with insert-only rights. When someone asks how a number in the board pack was produced, you can show the exact tool, parameters and time.
Step 5: Serve it over HTTP
The SDK’s Streamable HTTP transport runs inside an ordinary Express app. The stateless setup below follows the SDK’s own example: a fresh server and transport for each request, which is the simplest option to run behind a load balancer. Put your bearer-token check in front of it, using the identity provider your company already has.
import express from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createServer } from './server.js';
import { verifyBearerToken } from './auth.js'; // e.g. the SDK's requireBearerAuth middleware
const app = express();
app.use(express.json());
app.post('/mcp', verifyBearerToken, async (req, res) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.log('finance MCP server on :3000'));Step 6: Test it with the MCP Inspector
The MCP Inspector lets you list tools and call them by hand, before any AI is involved. It’s the fastest way to check the SQL, the permissions and what the model will actually see.
npx @modelcontextprotocol/inspector
# connect to http://localhost:3000/mcp with a test token,
# then call get_overdue_invoices with { "minDaysOverdue": 60 }Connected with a finance token, the Inspector lists our tool with its title and description, exactly as an AI client will see it:

Running it for invoices more than 60 days overdue returns exact rows from the database, oldest first, with correct dates. Note the badges: Read-only and Idempotent ticked, Destructive crossed out. Those come from the annotations.

Now the same call with a token that only has marketing permissions. The tool refuses before touching the database:

And the audit table afterwards, with each successful call recorded:

What to add next
Once receivables work, the same pattern covers spend by supplier for a date range, budget versus actual by department, cash position across bank accounts and one customer’s payment history. Each is one tool, one query, one scope. Keep anything that changes money out of the server; at most, let a tool prepare a draft (a reminder email, a bill from a supplier invoice) that a person approves in the accounting system.
The other half of the work is the data underneath. If “revenue” means three things in three spreadsheets, the assistant will faithfully repeat the confusion. We built department reporting on BigQuery and Google Cloud for one client, and agreeing metric definitions mattered as much as any code. See our ETL and data pipelines work if your data needs that groundwork first.
What shapes the cost
| Factor | Why it matters |
|---|---|
| How many tools | Each tool is a query, a scope and tests. Five well-chosen tools cover most finance questions. |
| State of the data | A clean reporting database is quick to build on. Scattered spreadsheets need a pipeline first. |
| Identity setup | Using your existing SSO groups is quicker than inventing new roles. |
| Hosting and review | Where the server runs, and how much security review your company requires. |
| Model usage | Every question sends tool results to the model; small, precise results keep running costs down. |
Mistakes we’d avoid
Sources
Questions we often hear
Can the model run any SQL it likes?
No. Each tool runs one fixed, parameterized query, inputs are validated, and the database user is read-only with a timeout.
Does this work with ChatGPT as well as Claude?
Yes. MCP is an open standard, and the same server works with any client that supports it.
Have you built MCP servers for clients?
Yes, for clients we can’t name publicly. On a free 30-minute call we can walk you through similar setups.



