Finance

Building an MCP server for finance: read-only AI access to your accounting data

Building an MCP server for finance: read-only AI access to your accounting data

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

Architecture: MCP client to finance MCP server to read-only reporting database and audit database, synced from Xero
The assistant calls tools; tools run fixed queries against a read-only copy of your accounting data and write an audit row.

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)

SituationOur advice
People ask the same finance questions every week, in different wordsGood 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 peopleNot needed: a scheduled report or an automation is simpler
You want AI to post entries or approve paymentsNot as a first step. Start read-only; let it prepare drafts a person approves
Metric definitions differ between teamsFix the definitions first, or the assistant will repeat the confusion
MCP pays off for open-ended questions over data you already trust.

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.

sql
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';
A dedicated, read-only role for the MCP server, with a query timeout.

Here is what happens when anything tries to write as that user. This is a real run against PostgreSQL 16:

Terminal: delete as mcp_finance fails with cannot execute DELETE in a read-only transaction
The database refuses the write, whatever the application code does.

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.

bash
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 tsx

Step 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.

typescript
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;
}
One tool, one fixed query. The model supplies parameters; it never writes SQL.

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.

typescript
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]
  );
}
Scope checks and an append-only audit log, shared by every tool.

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.

typescript
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'));
verifyBearerToken sets req.auth; the SDK exposes it to tools as extra.authInfo.

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.

bash
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:

MCP Inspector connected to the finance server, listing the Overdue invoices tool
The Inspector connected over Streamable HTTP, showing the server name, version and our one tool.

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.

Tool result: Lakeside Clinics 12,650 USD, 92 days overdue, then Acme Ltd 1,200 USD, 75 days
A real call. The model would turn this into “Lakeside Clinics owes $12,650, 92 days overdue…”

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

Tool result error: Not allowed: this tool needs the finance:ar:read permission
The scope check in action: a marketing token gets a clear refusal.

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

Terminal: audit.mcp_calls rows showing finance-team, get_overdue_invoices, minDaysOverdue 60, 4 rows
Who called which tool, with what input, how many rows came back, and when.

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

FactorWhy it matters
How many toolsEach tool is a query, a scope and tests. Five well-chosen tools cover most finance questions.
State of the dataA clean reporting database is quick to build on. Scattered spreadsheets need a pipeline first.
Identity setupUsing your existing SSO groups is quicker than inventing new roles.
Hosting and reviewWhere the server runs, and how much security review your company requires.
Model usageEvery question sends tool results to the model; small, precise results keep running costs down.

Mistakes we’d avoid

Sources

Spotted something out of date? Tell us

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.

Want AI to answer your finance team’s questions?

Tell us which systems hold your numbers and the questions you answer most. On a free 30-minute call an engineer will outline a safe first version.