I Connected Claude to 20 Years of Azure SQL Data with a Custom MCP Server. Here's How. A developer built a custom Model Context Protocol (MCP) server that connects Claude to a client's 20-year-old Azure SQL database, letting managers in departments like sales, HR, and marketing query business data in natural language without SQL training or BI tools. The Node.js server exposes whitelisted tables and read-only SELECT queries through three tools, using a table allowlist, query validation, and a read-only database user as layered safeguards against hallucinated or destructive queries. 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: python 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?