A client came to me with a request that sounded simple:
"We have 20 years of data in our database. We want our managers to ask Claude questions and get reports. Marketing, HR, R&D, Production, Procurement, Sales — all of them."
No dashboards, no BI tool, no SQL training. Just: type a question, get an answer from the actual data.
The database is Azure SQL. It's been growing since the mid-2000s. It has hundreds of tables — real business data, but also legacy tables nobody remembers, staging tables, system tables, half-finished migrations, and a lot of columns that would confuse a human, let alone a language model.
Here's how I built it, what I got right, and what I'd tighten up next.
Claude (each manager's account)
│
│ HTTPS + secret key
▼
mcp.company-domain.com ──► Nginx (reverse proxy, SSL)
│
▼
Node.js MCP server on the company VPS
(table whitelist lives here)
+ knowledge base table (which tables matter per department)
│
│ read-only SQL user
▼
Azure SQL Database
Four decisions drove everything:
SELECT. A hallucinated DELETE is a syntax error, not a disaster.
MCP (Model Context Protocol) is the standard Claude uses to talk to external tools. You write a server that exposes "tools" — functions with a name, a description, and a schema — and Claude decides when to call them.
I built it in Node.js with the official SDK. Stripped down, the core looks like this:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import sql from "mssql";
import { z } from "zod";
const ALLOWED_TABLES = [
"SalesOrders",
"SalesOrderLines",
"Customers",
"Products",
"Suppliers",
"PurchaseOrders",
"ProductionBatches",
"Employees", // no salary columns exposed — see below
// ... one line per table each department needs
];
const server = new McpServer({ name: "company-data", version: "1.0.0" });
// Tool 1: let Claude discover what it's allowed to see
server.tool("list_tables", "List the tables available for querying", {}, async () => ({
content: [{ type: "text", text: ALLOWED_TABLES.join("\n") }],
}));
// Tool 2: schema for a whitelisted table
server.tool(
"describe_table",
"Get columns and types for a table",
{ table: z.string() },
async ({ table }) => {
if (!ALLOWED_TABLES.includes(table)) {
return { content: [{ type: "text", text: `Table '${table}' is not available.` }] };
}
const result = await pool.request()
.input("t", sql.NVarChar, table)
.query(`SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @t`);
return { content: [{ type: "text", text: JSON.stringify(result.recordset, null, 2) }] };
}
);
// Tool 3: run a read-only query
server.tool(
"run_query",
"Run a SELECT query against allowed tables. Max 500 rows.",
{ query: z.string() },
async ({ query }) => {
const q = query.trim();
if (!/^select\s/i.test(q)) {
return { content: [{ type: "text", text: "Only SELECT statements are allowed." }] };
}
for (const t of extractTableNames(q)) {
if (!ALLOWED_TABLES.includes(t)) {
return { content: [{ type: "text", text: `Table '${t}' is not available.` }] };
}
}
const result = await pool.request().query(`SELECT TOP 500 * FROM (${q}) AS sub`);
return { content: [{ type: "text", text: JSON.stringify(result.recordset) }] };
}
);
Three layers of protection on run_query:
Defence in depth. Any one layer could have a bug; all three at once is unlikely.
The instinct is to give Claude the whole schema and let it figure things out. Don't.
With hundreds of tables, Claude picks the wrong one constantly. There were three different "customer" tables from three different eras of the system. There were tables with _old, _bak, _v2 suffixes. There were tables with 80 columns where 70 were unused.
The whitelist solves two problems at once:
The whitelist lives in the server code. When a department needs a new table, I add one line and redeploy. That's deliberately manual — I want a human to look at every table before Claude can see it.
For the Employees table, I went one step further and exposed a SQL view with the sensitive columns removed, rather than the raw table. The view name goes on the whitelist; the table doesn't.
Whitelisting fixed what Claude can see. It didn't fix which table Claude should reach for when someone from Marketing asks a Marketing question.
So I added one more table to the database — a knowledge base that describes the other tables in plain English:
CREATE TABLE mcp_knowledge_base (
department NVARCHAR(50), -- Marketing, HR, RnD, Production, Procurement, Sales
table_name NVARCHAR(128),
description NVARCHAR(MAX), -- what this table holds, in business language
key_columns NVARCHAR(MAX), -- the columns that matter and what they mean
notes NVARCHAR(MAX) -- gotchas: "status 3 means cancelled", "amounts in AED", etc.
);
And a fourth tool on the MCP server:
server.tool(
"get_table_guide",
"Find which tables are relevant for a department or topic, with descriptions",
{ topic: z.string() },
async ({ topic }) => {
const result = await pool.request()
.input("t", sql.NVarChar, `%${topic}%`)
.query(`SELECT department, table_name, description, key_columns, notes
FROM mcp_knowledge_base
WHERE department LIKE @t OR description LIKE @t OR table_name LIKE @t`);
return { content: [{ type: "text", text: JSON.stringify(result.recordset, null, 2) }] };
}
);
Now when a manager asks about campaign performance, Claude calls get_table_guide("marketing"), gets back the three or four tables that actually matter with a description of each, and goes straight to them — instead of scanning fifty table names and guessing.
This table is the one thing the client's team updates themselves. When a column's meaning changes or a new report pattern emerges, they add a row. No code change, no redeploy. It's turned into a living data dictionary — something this company never had in 20 years — and it exists because an LLM needed it.
Two rules for writing the descriptions: write for a smart new employee, not a DBA, and put the gotchas in. "Amounts are in AED excluding VAT" saves Claude from a wrong answer far more often than a perfect schema does.
The MCP server runs on a local port on the VPS. Nginx sits in front on a company subdomain with an SSL certificate from Let's Encrypt.
server {
listen 443 ssl;
server_name mcp.company-domain.com;
ssl_certificate /etc/letsencrypt/live/mcp.company-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.company-domain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:7544;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
Two lines matter more than they look:
Upgrade / Connection headers.
Each manager adds the server in Claude as a custom connector: the subdomain URL plus the shared secret key. That's it — no software to install.
To start a session, the user types the name of the MCP server in their first message so Claude knows to use it. After that, they just ask questions in plain English:
"Compare procurement spend by supplier for the last three quarters and flag any supplier where cost per unit went up more than 10%."
Claude calls get_table_guide("procurement"), reads which tables matter and what the columns mean, calls describe_table where it needs more detail, writes the SQL, calls run_query, and turns the result into a readable answer — a table, a summary, sometimes a chart.
Anyone in the company whose Claude account doesn't have the connector configured gets nothing. Claude has no knowledge of the data on its own.
The client asked for a single shared key. The audience is a small group — CEO, CFO, general managers, department heads. People who already have cross-department visibility. Managing per-user keys for a group that size would have been more admin work than security benefit.
I agreed, with two conditions: the key stays with senior management only, and the DB user stays read-only so the worst case is "someone saw a report they shouldn't have," not "someone changed data."
Where this would break: if access expands to 50 people, or if departments genuinely need to be isolated from each other (HR from Sales, say), the shared key stops being acceptable. At that point the right move is per-department keys mapped to per-department whitelists, or OAuth through Claude's connector system so access follows the user's identity. The code change is small; the policy change is the hard part.
Being honest about the gaps:
TOP 500 is a global cap. Some tables should be lower.WHERE on the very large tables.
The client went from "ask IT for a report, wait three days" to "ask Claude, wait thirty seconds." Managers who never touched SQL are pulling supplier comparisons, production yield trends, and sales-by-region breakdowns themselves.
The whole thing is about 300 lines of Node, one Nginx config, a read-only SQL user, and one knowledge base table that the client now maintains themselves. The hard parts weren't technical — they were deciding what Claude should be allowed to see, and being disciplined about saying no to the rest.
If you've connected an LLM to a legacy database, I'd like to hear how you handled the "too many tables" problem — whitelist, views, semantic layer, something else?