A zero-cost SQL MCP server for Claude and ChatGPT
A zero-cost SQL MCP server for Claude and ChatGPT
We wanted our customer-service team to ask an AI about ticket orders without anyone writing an API. Microsoft's Data API builder turned out to do the whole job with one JSON file, a two-line Dockerfile and an App Service plan we were already paying for. Here is what worked, what broke, and the eight things I wish I had known on day one.
- Server
- Data API builder 2.0.12Microsoft's SQL MCP Server
- Hosting
- Existing Linux B2 plansecond web app, container
- Extra cost
- 0 USD / monthmemory went from 55% to 63%
- Code written
- 0 linesone JSON config, one Dockerfile
- Exposed
- 4 read-only entities3 tools: describe, read, aggregate
- Clients
- Claude Code, ChatGPT BusinessClaude.ai pending admin
The problem we actually had
Ticket Shine resells event tickets. Our order data lives in Azure SQL behind a Microsoft Access front end that has grown for years. The support team's questions are simple: Which orders for Saturday's game are still delayed? What did we note on order 48213? How many purchases did we make last month? Answering them means someone opens Access, finds the right form, and reads the screen back.
Large language models are good at exactly this kind of question, if they can see the data. The Model Context Protocol (MCP) is how you let them see it: a small HTTP server exposes "tools" the model can call, and the model decides when to call them. The catch is that most MCP tutorials start with "write a server in Python". I did not want to own another codebase, and I did not want a chatbot with write access to production.
Why Data API builder
Data API builder (DAB) is Microsoft's open-source engine that turns a database into REST and GraphQL endpoints from a config file. Since version 2.0 it also speaks MCP, and Microsoft ships it as a container image on their registry. Three properties sold me:
- Read-only is a config switch, not a code review. Every write tool (create, update, delete, execute) is turned off in JSON, and each entity's permissions list only the
readaction. The model cannot reach a write path that does not exist. - It validates against the real schema.
dab validateconnects to the database and checks every entity, view and key field before you deploy anything. - It runs anywhere a container runs. Our App Service plan already hosts a Function App. A B2 plan is dedicated compute, so a second app on it costs nothing extra.
What the setup looked like
The config is about 200 lines of JSON. The parts that matter fit on a napkin:
// runtime.mcp
"dml-tools": {
"describe-entities": true,
"read-records": true,
"aggregate-records": true,
"create-record": false, "update-record": false,
"delete-record": false, "execute-entity": false
},
"description": "Read-only access to Ticket Shine customer-service data.
Use describe_entities first, then read_records or aggregate_records.
Never guess: if no rows are returned, say so."
// one entity
"DelayedOrders": {
"description": "Unfulfilled orders whose tickets are delayed by the primary market",
"source": { "object": "rpt.DelayedOrders", "type": "view", "key-fields": ["OrderNum"] },
"permissions": [{ "role": "anonymous", "actions": [{ "action": "read" }] }]
}
The Dockerfile is the base image plus a copy of that file. Then four Azure CLI commands: create the web app on the existing plan, set two app settings, turn on Always On, and allow the app's outbound IPs through the SQL firewall.
FROM mcr.microsoft.com/azure-databases/data-api-builder:2.0.12
COPY dab-config.json /App/dab-config.json
az webapp create -n ts-sql-mcp -g $RG --plan $PLAN \
--deployment-container-image-name docker.io/<you>/ts-sql-mcp:1
az webapp config appsettings set -n ts-sql-mcp -g $RG --settings \
WEBSITES_PORT=5000 MSSQL_CONNECTION_STRING="Server=tcp:...;Encrypt=True;"
az webapp config set -n ts-sql-mcp -g $RG --always-on true
Local test first: set the connection string as an environment variable, dab validate, dab start, then point the MCP Inspector at localhost:5000/mcp. If Inspector lists exactly three tools, you are done with the hard part.
Eight things that cost me an evening
None of these are bugs. All of them are the kind of thing documentation mentions once, in a sentence you skim.
1. "Failed to parse the config file" means "wrong folder"
My first dab validate failed with a parse error. The JSON was fine. I had run the command from my home directory, where there was no config file at all, and DAB reports a missing file as a parse failure.
Run every dab command from the folder that holds the config, and set the connection string in the same shell window. Environment variables do not follow you to a new terminal.
2. 504 on Azure means the port, not the database
The container started, the logs looked healthy, and every request timed out. App Service probes port 80 by default. DAB listens on 5000. One app setting, WEBSITES_PORT=5000, and the gateway timeout turned into a health report.
When a custom container returns 504 or "container didn't respond", check the port setting before you read a single log line.
3. In production mode, an empty health roles list means nobody
DAB has a rich /health endpoint that reports database connectivity per entity. The generated config sets "roles": []. In development mode that means everyone; in production mode it means no one, and you get a 403 with the message "Comprehensive Health Check Report is not allowed". I set roles to ["anonymous"], since the report only exposes entity names and the database type, and the web app is IP-restricted anyway.
4. You may not get the least-privilege login you planned
The clean design is a dedicated SQL login with SELECT on the reporting schema and nothing else. I did not have rights to create logins on that server. The pragmatic answer: use the existing application login and let DAB be the enforcement point. The write tools are disabled, the permissions are read-only, and the model never sees a connection string. It is not defence in depth, and I have written it down as the first thing to fix, but it is honest about where the boundary is.
Decide where read-only is enforced and say so in the runbook. "Both the SQL login and DAB" is best. "DAB only" is acceptable for a pilot if you know it and plan the upgrade.
5. Two vendors, two very different IP allowlists
With no authentication on the endpoint, the only thing keeping the internet out is App Service access restrictions. Anthropic publishes one outbound range for MCP calls, 160.79.104.0/21. One rule, done.
OpenAI publishes a JSON file of 138 CIDR blocks that changes over time. An App Service rule holds at most eight addresses, so that is eighteen rules. I wrote a twelve-line PowerShell script that downloads the file, deletes the old chatgpt-* rules and recreates them. It runs monthly.
$json = Invoke-RestMethod "https://openai.com/chatgpt-actions.json"
$cidrs = $json.prefixes.ipv4Prefix
for ($i = 0; $i -lt $cidrs.Count; $i += 8) {
$chunk = ($cidrs[$i..($i+7)] | Where-Object { $_ }) -join ","
az webapp config access-restriction add -n $APP -g $RG `
--rule-name ("chatgpt-{0:D2}" -f ($i/8)) --action Allow `
--ip-address $chunk --priority (200 + $i/8) --output none
}
Add the allowlist before you create the connector in the vendor UI. ChatGPT's "Error creating connector. Something went wrong" was a 403 from my own firewall, and the UI gives you no hint.
6. ChatGPT labels your read-only tool "DESTRUCTIVE"
After connecting, ChatGPT's developer view showed aggregate_records tagged PUBLIC WRITE, OPEN WORLD and DESTRUCTIVE. The tool computes a count. The reason is that DAB 2.0.12 does not send MCP tool annotations such as readOnlyHint, and ChatGPT assumes the worst when a tool says nothing about itself. The practical effect is a confirmation prompt on the first call in each chat. I put a sentence in the user guide so nobody panics, and I am watching DAB releases for annotation support.
7. Where the connector lives depends on who you are
Three clients, three answers. In Claude Code, adding an HTTP MCP server from the IDE writes it to the project scope, so it vanishes when you open another folder; claude mcp add --scope user makes it global. On a Claude Team plan, custom connectors are admin-only by default, and a member sees no "Add" button at all, so I drafted a two-paragraph request for our admin. In ChatGPT Business, a workspace admin creates the app under Plugins with Developer mode on, and members enable Developer mode in their own settings to see it.
8. Descriptions are the product
The model never sees your schema diagram. It sees the entity descriptions and field descriptions you wrote, and the server-level instruction string. "Never guess: if no rows are returned, say so" changed the answers more than any other line in the file. DAB also warns when an entity has no explicit fields list; take the warning seriously, because a described field is the difference between the model asking for Status and inventing OrderStatus.
Quick reference: symptom to cause
| Symptom | Cause | Fix |
|---|---|---|
| Failed to parse the config file | Wrong working directory | cd to the config folder |
| 504 Gateway Timeout | App Service probing port 80 | WEBSITES_PORT=5000 |
| 403 on /health | Empty roles in production mode | "roles": ["anonymous"] |
| Login failed in container log | Outbound IPs not in SQL firewall | Add the app's outbound IPs |
| Error creating connector (ChatGPT) | Vendor IPs blocked by access restrictions | Allowlist OpenAI's published ranges |
| Server disappears in Claude Code | Added at project scope | Re-add with --scope user |
What it costs and what it is worth
Hosting: nothing new. The plan's memory went from about 55% to 63% with DAB idle, and CPU did not move. Docker Hub free tier holds the image. The real cost was one evening of my time and one Azure CLI login into the wrong tenant that I did not notice for twenty minutes.
The value showed up in the first conversation. A support agent asked "which events have the most delayed orders right now?" and got a grouped count from a live view in a few seconds, with the exact rows on request. Nobody opened Access.
What is next
- Managed identity for the SQL connection, so the app settings hold no password.
- Entra ID authentication on the MCP endpoint, so the IP allowlist becomes a second layer instead of the only one.
- A dedicated read-only login once I have the rights, so the database enforces what the config already promises.
- Field lists for every entity, with descriptions written for the model, not for a DBA.
If you have a SQL database and an App Service plan with headroom, you are about two hours from the same result. Start with dab validate, run it from the right folder, and set the port.
Comments
Post a Comment