Building a Secure Remote MCP Server for AI Agent Database Access
2026-10-21 - 7 min read
Per-laptop database logins for an AI agent don’t scale. How I replaced them with one hosted MCP server in production, with permissions outside the model.

When a team first lets an AI agent query production data, the setup usually lives on each person's laptop: a local MCP server and a database login per person. That's where this IoT platform's team started, with a version I built, and it worked until adding people became the hard part. I replaced it with one hosted MCP server in production. Staff sign in once, and the database and the gateway enforce what the agent can do. Design to production took two days.
Where it started
Earlier in the year the team asked for read access to production data from Claude. I shipped what was quickest: a Claude plugin that started four local MCP servers on each person's laptop, with a read-only database login per person, fetched from a secrets store under that person's SSO identity.
It worked, and people used it. In the month before the move, agents issued more than 800 production queries through that setup. But every new person cost a VPN exit node, an ODBC driver, CLI tools, an SSO profile, a per-user IAM policy and a database migration to create their login.
Then a new user's setup failed three ways in one afternoon:
- Claude Desktop doesn't read
~/.zshrc, so the environment the plugin expected wasn't there. - The plugin cache was four versions behind, because auto-update is off for third-party marketplaces.
- Claude, trying to help, invented a password variable that didn't exist and started editing the plugin cache.
The chat apps were worse: they refuse to install a plugin that ships a top-level bin/ folder, so anyone who lived in Claude chat couldn't use it at all.
What was failing: MCP server permissions on every laptop
The fragile part was where the security boundary sat. The login, the secret and the network path all lived on each laptop, so giving someone access meant provisioning a database account, and every new Claude surface meant another install that could drift.
Moving to a hosted server raised a second problem. The natural gate for "who can query production" was the portal's admin role. But that role also covered sales and support staff, who need the portal and have no reason to run raw production reads. Reusing it would have handed production reads to people with no use for them.
Before, the login, the secret and the network path all lived on each laptop. After, sign-in, the entitlement check and the database account sit outside the model.
Before: set up on every laptop
Per person: software, a VPN, an IAM policy and a login
After: one hosted server
Per person: one flag. Every call audited.
The chat apps couldn't install the laptop setup at all. The hosted server works from any Claude client.
One hosted MCP server in production
I wrote the design on a Thursday, had it running in dev that night and in production the next evening: 12 pull requests across 8 repositories.
The core is a small, framework-free MCP handler for AWS Lambda: stateless JSON-RPC, schema-checked arguments and one audit record per request. I kept the MCP SDK out of the runtime path to keep cold starts small. On top of it, the hosted server exposes about a dozen read-only tools across SQL Server and ClickHouse: bounded SQL, schema and sample lookups, a device lookup and a sensor-failure report.
Every permission sits outside the model:
| Layer | What enforces it |
|---|---|
| Identity | OAuth sign-in with company Google accounts, verified email required, one-hour tokens |
| Entitlement | A token authorizer checks the user is active and holds a new data-access flag, separate from the portal's admin role |
| Database | One read-only service account per database, sensitive columns denied, no per-person logins |
| Caps | 25 seconds, 2,000 rows and 60,000 characters per result, enforced by the database profile, the driver timeout and the tool |
| Audit | The person, the request ID and the full SQL for every call (never the results), through a separate insert-only login |
Granting access means flipping the flag. Every ClickHouse query also carries the person and the request in its query tag.
Here's an illustrative sketch of the query tool, not the client's code:
MAX_ROWS, MAX_CHARS, TIMEOUT_S = 2_000, 60_000, 25
def run_query(caller, sql: str) -> dict:
audit.write(person=caller.email, request=caller.request_id, sql=sql[:10_000])
tag = f"mcp:{caller.email}:{caller.request_id}"
out, chars = [], 0
with readonly_connection(timeout_s=TIMEOUT_S, query_tag=tag) as cur:
cur.execute(sql)
for row in cur: # one pass: stop reading at the cap
chars += len(repr(row))
if len(out) == MAX_ROWS or chars > MAX_CHARS:
return {"rows": out, "truncated": True}
out.append(list(row)) # arrays keep duplicate column names
return {"rows": out, "truncated": False}I also moved guidance out of the server's instructions field, which at least one major client drops, into a schema-notes tool that the model can call.
Three things only production shows you. A result cap that holds in dev can fail under load: the ClickHouse client kept draining a stream after the cap, dictionary rows silently dropped duplicate column names, and trimming 500 rows of 100 KB each ran into the Lambda timeout. The cap now stops reading at the limit in a single pass. An admin-side impersonation check can say a grant works while the real login gets a permissions error, so grants get tested as the real login. And CI's production deploys stalled on anonymous Docker Hub pull limits mid-launch, so I shipped both stacks from clean main checkouts with saved plans.
The client's backend lead reviewed the design and the pull requests, and owns the shared database library the server calls. I wrote the server, the sign-in service, the authorizer, the Terraform and the rollout.
Results
| Before | After | |
|---|---|---|
| Onboarding a person | Software, a VPN, an IAM policy and a database migration | Add a connector, sign in once, an admin flips a flag |
| Database logins | One per person | One read-only service account per database |
| Claude chat apps | Couldn't install the setup | Work like any other client |
The server has been in production for internal staff since October 2026. Checks there: requests without a token get a 401, sensitive columns are denied, and every call writes an audit record. Most people with access used it in its first week, and one of them assembled a unit's full history from about 30 queries in two minutes.
Five days after launch I removed the local servers and VPN tooling and retired the per-person logins and their secrets. Two privileged admin paths stay, deliberately.
The caps are deliberate. A few heavy analyses that parse JSON across millions of rows run into the 25-second limit, and the right fix for those is typed columns, not a longer timeout.
The same plumbing is now the base for a customer-facing MCP server, on a different trust model: curated, typed tools instead of raw SQL, the customer's group pinned on the server for every call, no extra reach for admins through it, and per-user hourly limits.
What I'd tell another team
A prompt can ask the model to behave. Only the database and the gateway can make it.
- Enforce access in the database and the gateway. Grants, column denies, a token authorizer and hard caps hold no matter what the model decides to try.
- Give agent access its own entitlement. Existing admin roles collect people for unrelated reasons.
- Test grants as the real login. An impersonation check and the real login can disagree.
- Don't rely on MCP server instructions. Put guidance in tool descriptions, tool results and a notes tool.
When this approach is wrong: raw SQL through one shared read-only account fits a small, trusted internal team where everyone should see the same data. If users have different rights inside the database, a shared account erases them, and you need per-caller credentials. If the callers are customers, give them curated tools with the tenant fixed on the server, which is how the customer-facing server is built.
Related
- AI systems services: MCP servers, agent backends and the data under them.
- Reduce MCP Server Memory Use Across Coding-Agent Sessions
- Your Agent Fleet's First Bottleneck Is Your Laptop's Shared State