Building a Secure Remote MCP Server for AI Agent Database Access

2026-10-21 - 7 min read
Daniel Young
Daniel Young
Founder, DRYCodeWorks

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.

Laptops, each with its own key, tangle into one cable that leads to a single locked door in front of a database.

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.

Where the security boundary sits

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

Claude on one laptopthe desktop app or Code only
On each laptop
Four local MCP processesscripts installed by a plugin
A secret fetched under your SSO identityper-user IAM policy
A database login per personcreated by a migration
A VPN exit nodeto reach the databases
SQL Server and ClickHouse

Per person: software, a VPN, an IAM policy and a login

After: one hosted server

Any Claude clientchat, desktop, mobile or Code
Outside the model
Sign in once with company GoogleOAuth, short-lived tokens
Token authorizeractive user and a data-access flag
Stateless MCP functionabout a dozen read-only tools
Read-only service accountsensitive columns denied, hard result caps
SQL Server and ClickHouse

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:

LayerWhat enforces it
IdentityOAuth sign-in with company Google accounts, verified email required, one-hour tokens
EntitlementA token authorizer checks the user is active and holds a new data-access flag, separate from the portal's admin role
DatabaseOne read-only service account per database, sensitive columns denied, no per-person logins
Caps25 seconds, 2,000 rows and 60,000 characters per result, enforced by the database profile, the driver timeout and the tool
AuditThe 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:

bounded_query.py (illustrative)python
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

BeforeAfter
Onboarding a personSoftware, a VPN, an IAM policy and a database migrationAdd a connector, sign in once, an admin flips a flag
Database loginsOne per personOne read-only service account per database
Claude chat appsCouldn't install the setupWork 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.