构建一个安全的 DynamoDB MCP 服务器需要付出什么
让把数据库交给 变得轻而易举。这正是问题所在。大多数 DynamoDB MCP 服务器都是一个持有你原始 AWS 凭证的轻量进程,带有写入路径却没有审查步骤 —— 我们已经从用户的角度撰文讲过为什么这种模式有风险、以及如何安全地做出选择。
这篇文章讲的是另一面:构建安全版本实际需要付出什么。DynoTable 是一个桌面 DynamoDB 客户端,它的 MCP 服务器将其受门控的 AI 工具集暴露给外部智能体 —— Claude Code、Cursor、Codex —— 而智能体从不接触 AWS 凭证。要做到这一点,意味着要在一个 Electron 应用_内部_交付一个 授权服务器,设计一种让凭证混用变得无法表示的身份类型,并且不止一次地判定:那个方便的默认选项恰恰是错误的。
如果你正在围绕任何敏感事物构建一个 MCP 服务器,这些决定你也会遇到。
回环 HTTP 并不天然安全
服务器绑定 127.0.0.1,这听起来安全,却并不足够。你访问的任何网页都可以尝试向 localhost 发起请求,而 DNS 重绑定能让一个恶意页面给这些请求套上一个看似合理的 Host。因此第一层是来源守卫(origin guard):拒绝任何携带真实网页 Origin 的请求,拒绝非回环的 Host 头 —— 但允许完全没有 Origin 的请求,因为像 Claude Code 这样的合法 CLI 客户端并不会发送它。
最后这一条才是老实话:来源守卫只能挡住浏览器这一类攻击,它无法对任何人进行身份验证。真正的关卡是 bearer token,而这就引出了一个问题:token 从何而来。
我们在应用内交付了一个 OAuth 2.1 授权服务器
MCP 的远程服务器认证方案是 OAuth 2.1,而对于本地桌面服务器来说,并没有可以依赖的上游身份提供方。因此应用本身_就是_授权服务器:动态客户端注册、(S256)、授权码、刷新 token、撤销,以及让客户端得以发现这一切的 RFC 8414 / RFC 9728 元数据文档。
我们早早定下并一直坚持的规则:
- 不要手工重造协议。 MCP SDK 以路由器的形式提供了完整的授权服务器(AS)表面;我们只实现其下的存储/策略提供方。我们接受了一个后果:我们的 MCP 框架内部使用一套 HTTP 栈,而 SDK 路由器使用另一套,因此 AS 作为第二个回环监听器运行,资源服务器通过受保护资源元数据将客户端指向它。两个监听器是一项有界、明确的代价;而手工重新推导 PKCE 和 token 语法则是无界的代价。
- 重定向仅限回环(依据针对原生应用的 RFC 8252),且无效的重定向绝不会得到一个错误重定向 —— 它得到的是一个直接的 400,因此攻击者的 URI 永远不会成为跳转目的地。
- 授权码只能使用一次 —— 被重放的授权码即为无效授权,没有例外。
- 刷新可以收窄权限范围,但绝不能扩大它,并且它会逐字重新签发相同的身份载荷 —— 刷新后的 token 无法悄悄获得一个不同的区域。
- 撤销在每次请求时都会重新检查,而不是在连接时缓存 —— 在设置中点击“撤销”会终止该客户端的下一次调用。
开发版构建则使用一条更简单的认证路径,它的构建方式决定了它不可能存在于发布版二进制文件中 —— 在已交付的软件里放一个未经认证的全权限 token,就等于在一个故意放行无 Origin 请求的来源守卫背后开了一个本地后门。
一个连接,一个 AWS 身份 —— 整体传递,绝不猜测
核心的安全属性:外部智能体连接到的是_某个配置文件_,而不是“DynoTable”。每个连接都绑定到一个 AWS 配置文件和一个区域,由其各自独享的 DynamoDB 客户端支撑,因此位于不同配置文件上的两个智能体无法相互渗透。
这个绑定最初是一堆松散的字段 —— awsProfile、region、profileId、一个可选的本地端口 —— 在整条路径上的每条记录里被反复声明。如今它是一个可辨识联合(discriminated union)的身份类型,有两个分支:online(配置文件 + 区域 + id)和 offline(本地端口 + id,用于 DynamoDB Local 配置文件)。是否离线本身就是辨识判据,因此一个没有凭证的在线身份、或一个携带区域的离线身份,都是_类型错误_,而不是运行时的意外。这一个类型会完整无损地贯穿同意 → token → 授权 → 每一次请求。
有两个我们视为承重的后果:
- 区域在同意时被冻结。 用户批准的是“区域 Y 中的配置文件 X”,而这个元组被钉死在 token 上 —— 在刷新后依然存续,绝不会在热路径上从可变状态重新解析。修改配置文件的区域需要重新同意。另一种做法 —— 按请求解析区域,或回退到某个默认值 —— 正是智能体悄无声息地最终去查询
us-east-1的原因。 - 格式错误的 token 一律返回 401,绝不做强制转换。 如果一个 token 的身份声明无法解析成一个有效的分支,该请求即为未授权。没有
'default'配置文件,没有默认区域,没有空字符串。旧代码中每一处本会猜测的地方,如今都是硬性失败。
同意是一个产品界面,而不是一个对话框
最初的同意提示是平台原生的消息框。它在打开期间会冻结 Electron 主进程,无法与应用的设计系统保持一致,甚至无法为文档截图。如今它是一个应用内的模态框:显示正在连接的客户端名称、一个仅覆盖用户所选择暴露的那些配置文件的配置文件选择器,以及以最窄优先方式提供的三种权限范围 —— 只读、读取 + 暂存、完全。过期或重复的同意请求会以拒绝告终,而不是让 OAuth 流程一直挂起。

这个选择器有它自己的一小段信任边界:客户端可以在授权 URL 中_提示_一个配置文件,但授权是根据用户实际选择的内容签发的,并会对照他们所看到的快照进行校验 —— 伪造的提示无法绑定一个从未出现在屏幕上的配置文件。
我们同样明确地说明同意不会赋予你什么:授权仅限于权限范围,且每个连接只批准一次。应用内助手的逐次调用权限提示并不适用于 MCP 流量 —— 一个泄露的全权限 token 可以(向暂存区)写入,而无需人来逐一查看每次调用。缓解措施包括:较短的 token 有效期、按客户端撤销、一条始终开启并标记每一个源自 MCP 的操作的审计记录,以及将只读作为默认选项。把它写下来作为残余风险,比假装同意模态框已经消除了它更有用。
面向无头工作的护栏
外部智能体没有渲染进程、没有确认弹窗、没有用户在旁观看。每一个交互式环节都需要一个无头的等价物:
- 实时的许可证收紧,逐次调用生效。 授权存储的是用户批准的权限范围;而_有效_范围会在每次工具调用时根据应用当前的许可证状态推导得出 —— 只读许可证会把一个完全授权收紧,登出状态会在进入任何 token 分支之前以 401 拒绝(因此开发 token 无法绕过它),而会话中途的重新激活则无需重新同意即可重新放宽。这里有一个刻意保留的不对称:应用内导出在只读许可证下依然可用(你的数据就是你的数据),但通过 MCP 的无头批量导出会被收紧关闭 —— 交互式的数据可携权与由智能体驱动的数据外流通道是不同的信任面。
- 用扫描预算取代确认弹窗。 在应用里,全表读取会先征询。而在无头模式下,一个基于项数/字节数/时长的预算会对每一次 Scan 加以约束,针对表的 SQL 会通过一个基于磁盘的引擎流式处理,而不是受制于内存上限。
- 断开连接会如实中止。 如果客户端在 Scan 中途掉线,服务器会在下一个分页边界处中止 —— 绝不会在语句中途 —— 并释放临时状态。
- 可通过网络完成。 当 AWS 凭证需要一个 MFA 代码、且客户端支持 MCP elicitation 时,服务器会_通过智能体自己的 UI_ 征询这个 6 位代码 —— Claude Code 会提示用户 —— 然后重试。SSO 被刻意排除在这条路径之外:它需要一个浏览器设备流程,假装并非如此只会带来一条更糟糕的错误消息。需要相同凭证的并发调用会合并为一次提示。
通过 MCP 暴露数据库到底安全吗?
我们的老实回答:只有当服务器围绕这样一个假设来设计时才安全 —— 智能体往好了说是糊涂的,往坏了说是有敌意的。让智能体的_意图_变得不可信,这正是该架构从不依赖智能体循规蹈矩的原因:凭证留在应用内,写入永远只会落入一个可审查的暂存区,无论模型要求什么,权限范围都会约束一次调用能做的事,并且每一个操作都会被审计。连接侧的指南讲解了这对每个客户端在实践中意味着什么。
对任何触及你数据库的 MCP 服务器,你应当要求什么
- 凭证存放在哪里 —— 在智能体的进程里,还是在服务器背后?
- 是否存在带有真实权限范围的按连接同意,还是一个配置文件就授予了一切?
- 写入能否在没有人工审查步骤的情况下执行?
- 身份(配置文件、区域)是在同意时被钉死,还是在调用时从默认值解析出来?
- 遇到格式错误或已撤销的 token 会发生什么 —— 硬性失败,还是回退?
- 是否有一条能区分源自智能体的操作的审计记录?
- 什么在约束一次失控的 Scan,客户端在操作中途断开连接时又会发生什么?
如果一个服务器无法回答这些问题,那么便利的集成就是有风险的那个。要估量一次无界的 Scan 究竟会让你付出多少代价,定价计算器会让人清醒 —— 而如果你更愿意亲眼看看安全版本的运行,而不是读到它,下载 DynoTable,打开 MCP 服务器,用不到一分钟就能以只读权限连接一个客户端。


