{"slug": "i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-here-s", "title": "I Connected Claude to 20 Years of Azure SQL Data with a Custom MCP Server. Here's How.", "summary": "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.", "body_md": "A client came to me with a request that sounded simple:\n\n\"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.\"\n\nNo dashboards, no BI tool, no SQL training. Just: type a question, get an answer from the actual data.\n\nThe 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.\n\nHere's how I built it, what I got right, and what I'd tighten up next.\n\n```\nClaude (each manager's account)\n        │\n        │  HTTPS + secret key\n        ▼\nmcp.company-domain.com  ──►  Nginx (reverse proxy, SSL)\n                                    │\n                                    ▼\n                        Node.js MCP server on the company VPS\n                        (table whitelist lives here)\n                        + knowledge base table (which tables matter per department)\n                                    │\n                                    │  read-only SQL user\n                                    ▼\n                              Azure SQL Database\n```\n\nFour decisions drove everything:\n\n`SELECT`. A hallucinated `DELETE` is a syntax error, not a disaster.\nMCP (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.\n\nI built it in Node.js with the official SDK. Stripped down, the core looks like this:\n\n``` python\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport sql from \"mssql\";\nimport { z } from \"zod\";\n\nconst ALLOWED_TABLES = [\n  \"SalesOrders\",\n  \"SalesOrderLines\",\n  \"Customers\",\n  \"Products\",\n  \"Suppliers\",\n  \"PurchaseOrders\",\n  \"ProductionBatches\",\n  \"Employees\",          // no salary columns exposed — see below\n  // ... one line per table each department needs\n];\n\nconst server = new McpServer({ name: \"company-data\", version: \"1.0.0\" });\n\n// Tool 1: let Claude discover what it's allowed to see\nserver.tool(\"list_tables\", \"List the tables available for querying\", {}, async () => ({\n  content: [{ type: \"text\", text: ALLOWED_TABLES.join(\"\\n\") }],\n}));\n\n// Tool 2: schema for a whitelisted table\nserver.tool(\n  \"describe_table\",\n  \"Get columns and types for a table\",\n  { table: z.string() },\n  async ({ table }) => {\n    if (!ALLOWED_TABLES.includes(table)) {\n      return { content: [{ type: \"text\", text: `Table '${table}' is not available.` }] };\n    }\n    const result = await pool.request()\n      .input(\"t\", sql.NVarChar, table)\n      .query(`SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @t`);\n    return { content: [{ type: \"text\", text: JSON.stringify(result.recordset, null, 2) }] };\n  }\n);\n\n// Tool 3: run a read-only query\nserver.tool(\n  \"run_query\",\n  \"Run a SELECT query against allowed tables. Max 500 rows.\",\n  { query: z.string() },\n  async ({ query }) => {\n    const q = query.trim();\n    if (!/^select\\s/i.test(q)) {\n      return { content: [{ type: \"text\", text: \"Only SELECT statements are allowed.\" }] };\n    }\n    for (const t of extractTableNames(q)) {\n      if (!ALLOWED_TABLES.includes(t)) {\n        return { content: [{ type: \"text\", text: `Table '${t}' is not available.` }] };\n      }\n    }\n    const result = await pool.request().query(`SELECT TOP 500 * FROM (${q}) AS sub`);\n    return { content: [{ type: \"text\", text: JSON.stringify(result.recordset) }] };\n  }\n);\n```\n\nThree layers of protection on `run_query`:\n\nDefence in depth. Any one layer could have a bug; all three at once is unlikely.\n\nThe instinct is to give Claude the whole schema and let it figure things out. Don't.\n\nWith 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.\n\nThe whitelist solves two problems at once:\n\nThe 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.\n\nFor 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.\n\nWhitelisting fixed *what* Claude can see. It didn't fix *which* table Claude should reach for when someone from Marketing asks a Marketing question.\n\nSo I added one more table to the database — a knowledge base that describes the other tables in plain English:\n\n```\nCREATE TABLE mcp_knowledge_base (\n  department   NVARCHAR(50),    -- Marketing, HR, RnD, Production, Procurement, Sales\n  table_name   NVARCHAR(128),\n  description  NVARCHAR(MAX),   -- what this table holds, in business language\n  key_columns  NVARCHAR(MAX),   -- the columns that matter and what they mean\n  notes        NVARCHAR(MAX)    -- gotchas: \"status 3 means cancelled\", \"amounts in AED\", etc.\n);\n```\n\nAnd a fourth tool on the MCP server:\n\n```\nserver.tool(\n  \"get_table_guide\",\n  \"Find which tables are relevant for a department or topic, with descriptions\",\n  { topic: z.string() },\n  async ({ topic }) => {\n    const result = await pool.request()\n      .input(\"t\", sql.NVarChar, `%${topic}%`)\n      .query(`SELECT department, table_name, description, key_columns, notes\n              FROM mcp_knowledge_base\n              WHERE department LIKE @t OR description LIKE @t OR table_name LIKE @t`);\n    return { content: [{ type: \"text\", text: JSON.stringify(result.recordset, null, 2) }] };\n  }\n);\n```\n\nNow 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.\n\nThis 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.\n\nTwo 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.\n\nThe 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.\n\n```\nserver {\n    listen 443 ssl;\n    server_name mcp.company-domain.com;\n\n    ssl_certificate     /etc/letsencrypt/live/mcp.company-domain.com/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/mcp.company-domain.com/privkey.pem;\n\n    location / {\n        proxy_pass http://127.0.0.1:7544;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_set_header Host $host;\n        proxy_cache_bypass $http_upgrade;\n        proxy_read_timeout 300s;\n        proxy_send_timeout 300s;\n    }\n}\n```\n\nTwo lines matter more than they look:\n\n`Upgrade` / `Connection` headers.\nEach 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.\n\nTo 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:\n\n\"Compare procurement spend by supplier for the last three quarters and flag any supplier where cost per unit went up more than 10%.\"\n\nClaude 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.\n\nAnyone in the company whose Claude account *doesn't* have the connector configured gets nothing. Claude has no knowledge of the data on its own.\n\nThe 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.\n\nI 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.\"\n\nWhere 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.\n\nBeing honest about the gaps:\n\n`TOP 500` is a global cap. Some tables should be lower.`WHERE` on the very large tables.\nThe 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.\n\nThe 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.\n\nIf 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?", "url": "https://wpnews.pro/news/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-here-s", "canonical_source": "https://dev.to/mhk_sameera/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-heres-how-53ba", "published_at": "2026-09-15 10:08:03+00:00", "updated_at": "2026-09-15 10:39:13.504255+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-infrastructure", "large-language-models"], "entities": ["Claude", "Azure SQL", "Model Context Protocol", "Node.js", "Nginx", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-here-s", "markdown": "https://wpnews.pro/news/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-here-s.md", "text": "https://wpnews.pro/news/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-here-s.txt", "jsonld": "https://wpnews.pro/news/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-here-s.jsonld"}}