· 9 min read_other

What it took to build a safe DynamoDB MCP server

The makes it trivial to hand an your database. That's the problem. Most DynamoDB MCP servers are a thin process holding your raw AWS credentials with a write path and no review step — we've written about why that pattern is risky and how to choose safely from the user's side.

This post is the other side: what it actually took to build the safe version. DynoTable is a desktop DynamoDB client whose MCP server exposes its gated AI toolkit to external agents — Claude Code, Cursor, Codex — without the agent ever touching AWS credentials. Getting there meant shipping an authorization server inside an Electron app, designing an identity type that makes credential mix-ups unrepresentable, and deciding, more than once, that the convenient default was the wrong one.

If you're building an MCP server around anything sensitive, these are the decisions you'll meet too.

Loopback HTTP is not automatically safe

The server binds 127.0.0.1, which sounds safe and isn't sufficient. Any web page you visit can attempt requests toward localhost, and DNS rebinding lets a hostile page dress those up with a plausible Host. So the first layer is an origin guard: reject any request carrying a real web-page Origin, reject non-loopback Host headers — but allow requests with no Origin at all, because legitimate CLI clients like Claude Code don't send one.

That last clause is the honest part: an origin guard only blocks the browser attack class. It cannot authenticate anyone. The bearer token is the real gate, which raises the question of where tokens come from.

We shipped an OAuth 2.1 authorization server inside the app

MCP's remote-server auth story is OAuth 2.1, and for a local desktop server there is no upstream identity provider to lean on. So the app is the authorization server: dynamic client registration, (S256), authorization codes, refresh tokens, revocation, and the RFC 8414 / RFC 9728 metadata documents that let clients discover it all.

Rules we set early and kept:

  • Don't hand-roll the protocol. The MCP SDK ships the full AS surface as a router; we implement only the storage/policy provider beneath it. One consequence we accepted: our MCP framework speaks one HTTP stack internally and the SDK router speaks another, so the AS runs as a second loopback listener, with the resource server pointing clients at it via protected-resource metadata. Two listeners is a scoped, explicit cost; re-deriving PKCE and token grammar by hand is an unbounded one.
  • Redirects are loopback-only (per RFC 8252 for native apps), and an invalid redirect never gets an error redirect — it gets a direct 400, so an attacker's URI is never a destination.
  • Authorization codes are single-use — a replayed code is an invalid grant, full stop.
  • Refresh can narrow scope but never widen it, and it re-issues the same identity payload verbatim — a refreshed token can't quietly acquire a different region.
  • Revocation is re-checked on every request, not cached at connect — clicking "Revoke" in Settings kills the client's next call.

Development builds use a simpler auth path, built so it cannot exist in a release binary — an unauthenticated full-scope token in shipped software would be a local backdoor behind an origin guard that deliberately admits no-Origin requests.

One connection, one AWS identity — carried whole, never guessed

The core safety property: an external agent connects to a profile, not to "DynoTable". Each connection is bound to one AWS profile and one region, backed by its own non-shared DynamoDB client, so two agents on different profiles can't bleed into each other.

The binding started life as loose fields — awsProfile, region, profileId, an optional local port — re-declared across every record on the path. It's now one discriminated-union identity type with two arms: online (profile + region + id) and offline (local port + id, for DynamoDB Local profiles). Offline-ness is the discriminant itself, so an online identity without credentials or an offline one carrying a region is a type error, not a runtime surprise. That one type travels intact through consent → token → grant → every request.

MCP server (tools)User (consent modal)In-app OAuth ASClaude Code / CursorMCP server (tools)User (consent modal)In-app OAuth ASClaude Code / Cursorregister + authorize (PKCE)which profile? which scope?profile P, read-onlycode → token (identity {P, region}pinned)tool call + bearer tokenverify, re-check revocation,clamp scope to license, resolve P's credsresult (agent never sees AWS keys)

Two consequences we consider load-bearing:

  • Region is frozen at consent. The user approves "profile X in region Y", and that tuple is pinned on the token — surviving refresh, never re-resolved from mutable state on the hot path. Editing the profile's region requires re-consent. The alternative — resolving region per-request or falling back to a default — is how an agent quietly ends up querying us-east-1.
  • Malformed tokens are a 401, never a coercion. If a token's identity claims don't parse into a valid arm, the request is unauthorized. No 'default' profile, no default region, no empty string. Every place the old code would have guessed is now a hard failure.

The first consent prompt was the platform-native message box. It froze the Electron main process while open, couldn't match the app's design system, and couldn't even be screenshotted for docs. It's now an in-app modal: the connecting client's name, a profile picker over exactly the profiles the user chose to expose, and three scopes offered narrowest-first — read-only, read + stage, full. Expired or duplicate consent requests settle as denied rather than hanging the OAuth flow.

The in-app MCP consent modal: the connecting client, the profile picker, and the three narrowest-first scopes — read-only, read + stage, and full access.
The in-app MCP consent modal: the connecting client, the profile picker, and the three narrowest-first scopes — read-only, read + stage, and full access.

The picker has a small trust boundary of its own: a client may hint a profile in the authorize URL, but the grant is minted from what the user actually selected, validated against the snapshot they were shown — a forged hint can't bind a profile that was never on screen.

We're equally explicit about what consent does not give you: authorization is scope-only, approved once per connection. The in-app assistant's per-call permission prompts do not apply to MCP traffic — a leaked full-scope token can write (into staging) without a human seeing each call. Short token lifetimes, per-client revocation, an always-on audit trail marking every MCP-originated action, and read-only as the default offer are the mitigations. Writing that down as a residual risk was more useful than pretending the consent modal closed it.

Guardrails for headless work

An external agent has no renderer, no confirmation popups, no user watching. Everything interactive needed a headless equivalent:

  • Live license clamping, per call. The grant stores the scope the user approved; the effective scope is derived on every tool call from the app's current license state — a read-only license clamps a full grant down, a signed-out state rejects with 401 before any token branch (so the dev token can't bypass it), and a mid-session reactivation re-widens without re-consent. One deliberate asymmetry: in-app export stays available under a read-only license (your data is your data), but headless bulk export over MCP is clamped off — an interactive portability right and an agent-driven egress channel are different trust surfaces.
  • Scan budgets instead of confirmation popups. In the app, a whole-table read asks first. Headless, an items/bytes/duration budget bounds every scan, and SQL over tables streams through a disk-backed engine rather than an in-memory cap.
  • Disconnect aborts honestly. If the client drops mid-scan, the server aborts at the next page boundary — never mid-statement — and disposes the temporary state.
  • completes over the wire. When AWS credentials need an MFA code and the client supports MCP elicitation, the server elicits the 6-digit code through the agent's own UI — Claude Code prompts the human — and retries. SSO is deliberately excluded from this path: it needs a browser device flow, and pretending otherwise would just be a worse error message. Concurrent calls needing the same credentials coalesce into one prompt.

Is exposing a database over MCP safe at all?

Our honest answer: only if the server is designed around the assumption that the agent is confused at best and adversarial at worst. makes an agent's intent untrustworthy, which is why the architecture never relies on the agent behaving: credentials stay in the app, writes only ever land in a reviewable staging area, scopes clamp what a call can do regardless of what the model asks for, and every action is audited. The connect-side guide covers what that means in practice for each client.

What to demand from any MCP server that touches your database

  • Where do the credentials live — in the agent's process, or behind the server?
  • Is there per-connection consent with real scopes, or does one config file grant everything?
  • Can a write execute without a human review step?
  • Is the identity (profile, region) pinned at consent, or resolved from defaults at call time?
  • What happens on a malformed or revoked token — hard failure, or a fallback?
  • Is there an audit trail that distinguishes agent-originated actions?
  • What bounds a runaway scan, and what happens when the client disconnects mid-operation?

If a server can't answer those, the convenient integration is the risky one. For sizing what an unbounded scan would actually cost you, the pricing calculator is sobering — and if you'd rather see the safe version running than read about it, download DynoTable, flip on the MCP server, and connect a client at read-only scope in under a minute.

Work with DynamoDB without the Console

DynoTable is a fast desktop client for DynamoDB — browse tables, run SQL-style queries, and edit items locally.