Build1 publisher3 min readPublished
One global whitelist decides which of hundreds of Azure SQL tables Claude can read
A developer wired each manager's Claude account through a self-hosted Node MCP server and a read-only SQL login. The knowledge base table mapping tables to departments sits beside the whitelist and never reaches enforcement.
The Engineer · Build desk

What happened
- A client asked for managers in marketing, HR, R&D, production, procurement and sales to get reports by typing questions into Claude, with no dashboards, no BI tool and no SQL training.
- The Azure SQL database has been growing since the mid-2000s and holds hundreds of tables, among them legacy tables nobody remembers, staging tables, system tables and half-finished migrations.
- Requests travel from each manager's Claude account over HTTPS with a secret key to Nginx on the client's own VPS, on to a Node.js MCP server, and into Azure SQL through a read-only login.
- The server exposes list_tables, describe_table and run_query, and run_query screens the text with a regex, checks each referenced table against the whitelist, and relies on the read-only login as a third layer.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Anyone with the shared key can list every column of the whitelisted Employees table, so keeping salary out of reach rests on a view or on the grants given to the read-only login.
- decision Reporting has to be written as SQL aggregates, because a manager who asks for a list of orders receives 500 rows and no indication that more exist.
- cost The design commits the client to a VPS, TLS termination and a read-only login, plus somebody editing the whitelist every time a department needs another table.
The line that shapes this system is run_query's wrapper: `SELECT TOP 500 * FROM (${q}) AS sub` [10]. Claude's text goes inside a derived table and the cap is applied outside it. An aggregate written in SQL comes back intact. A request for rows comes back as the first 500, with nothing in the response to say the result was cut [4].
ALLOWED_TABLES is a const array at module scope [12]. list_tables prints it, describe_table checks membership before it touches INFORMATION_SCHEMA.COLUMNS [11], and run_query checks every name it pulls out of the query text [8]. One list answers for all three tools and for every caller [3]. The knowledge base table that records which tables matter per department sits on the same server [4], so it can steer what a manager is told to ask about, not what the server will run. The auth model matches: one shared key, small audience, which the developer lists as one of four decisions [5].
Employees is on the whitelist with the comment "no salary columns exposed" [12]. The tools work on table names. describe_table returns every COLUMN_NAME and DATA_TYPE that INFORMATION_SCHEMA holds for an allowed table [11], and run_query wraps whatever the model wrote and selects `*` [10]. A restriction at column level has to come from a view or from the grants on the read-only login [7].
A regex on the trimmed string decides what counts as a SELECT: `/^select\s/i` [9]. It is anchored at the start, so a query that opens with WITH is rejected before anything else happens [5]. It also runs before the connection pool is used. The read-only login is the last of the three layers to see a bad query [6]. The developer credited that login with making a hallucinated `DELETE` a syntax error and not a disaster [6].
The post also calls extractTableNames inside run_query without showing its implementation [17], and whitelist enforcement on a multi-table join rests on whatever that function does with the SQL string.
On accuracy the post offers its own experience. "With hundreds of tables, Claude picks the wrong one constantly," the developer wrote [13]. The evidence given is specific: three different customer tables from three eras of the system, tables suffixed `_old`, `_bak` and `_v2`, and tables with 80 columns where 70 were unused [14]. Ten used columns out of 80 is 12.5 percent of that table's width [1]. The whitelist excerpt names eight tables, followed by a comment saying one line per table each department needs [12][2]. No error rate before or after the whitelist appears in the post, so the improvement is an account of one deployment. For the design to carry, the same condition has to hold on your schema: the tables that matter are a small set, and somebody on the client side can name them.
What to watch
- Whether the developer publishes extractTableNames, since run_query's whitelist check on joins depends on it.
- Whether the single shared key is replaced with per-manager credentials, which would let the allowed table set vary by department.
- Any before-and-after figure on table-selection accuracy from the same database.