上一篇文章讲了 MCP 的协议定位:它让 AI 应用能用统一方式连接外部工具和上下文。但真正落地时,最常见的问题不是“协议怎么定义”,而是“我到底该把什么能力接给 Agent”。
数据库是一个很好的起点。它足够真实,能明显提升 Agent 的分析能力;同时也足够危险,稍不注意就会把生产数据、写权限、慢查询和敏感信息一起暴露出去。本文用一个只读数据库 MCP Server,把“能用”和“安全”放在同一个示例里讲清楚。
本文脉络:
一、为什么先做只读数据库工具
二、目标架构:Host、MCP Server、MySQL 怎么配合
三、准备一个只读数据库账号
四、初始化 TypeScript MCP Server 项目
五、实现两个工具:查看表结构与只读查询
六、在 AI Agent 中配置 stdio MCP Server
七、实际对话效果:Agent 怎么用这个工具
八、安全护栏:不要只靠“提示词禁止写库”
九、从本地 stdio 进阶到远程服务
十、常见问题
一、为什么先做只读数据库工具 AI Agent 想分析业务问题,最缺的往往不是推理能力,而是真实上下文。
比如你问:
最近订单支付成功率为什么下降了?
如果 Agent 只能靠聊天上下文,它最多给你一套排查思路:
看支付渠道是否异常
看失败码分布
看最近是否发布了支付链路改动
看不同端、不同地区、不同渠道的差异
这些建议没有错,但它并不知道你系统里的真实数据。真正有用的回答,通常需要它能查到:
数据
例子
表结构
orders、payments、payment_channels 有哪些字段
指标数据
最近 24 小时支付成功率、失败码 Top N
分组维度
按渠道、端类型、地区、版本号拆分
时间窗口
异常开始前后数据对比
这就是数据库 MCP Server 的价值:把数据库变成 Agent 可发现、可调用、可审计的工具。
但数据库又是高风险系统。最危险的做法是把数据库连接串直接丢给 Agent,然后在提示词里写一句“只能查询,不能修改”。这不够,因为模型不是权限系统,提示词也不是安全边界。
所以本文从一开始就限定目标:
只做只读 只接本地 stdio 只暴露少量工具 只允许有限 SQL 只返回有限行数
这个边界很克制,但足以跑通一个真实可用的闭环。
二、目标架构:Host、MCP Server、MySQL 怎么配合 这次要做的不是让模型直接连接 MySQL,而是让 AI 应用通过 MCP Server 间接访问数据库:
换成一次真实调用来看,链路会更清楚:
这里有三个关键点:
层次
责任
MCP Host
决定何时把工具暴露给模型,是否需要用户确认
MCP Server
暴露受控工具,校验参数,执行查询,返回结构化结果
MySQL
用数据库账号权限兜底,确保即使 Server 写错也不能写库
也就是说,安全不是某一层单独完成的,而是多层叠加:
越靠右,越像真正的安全边界。
三、准备一个只读数据库账号 先在 MySQL 里创建一个专用账号。不要复用应用的主账号,也不要给 INSERT、UPDATE、DELETE 权限。
假设业务库叫 shop:
CREATE USER 'mcp_reader' @'%' IDENTIFIED BY 'replace-with-strong-password' ;GRANT SELECT , SHOW VIEW ON shop.* TO 'mcp_reader' @'%' ;FLUSH PRIVILEGES;
如果是生产环境,建议再收紧:
项目
建议
网络来源
只允许 MCP Server 所在机器或内网网段访问
数据库范围
只授权必要库,不要 *.*
表范围
敏感表单独排除,必要时只建脱敏视图
字段范围
身份证、手机号、邮箱、地址等字段不要直接暴露
查询资源
配置 MySQL 侧超时、连接数、只读副本
权限这一步很重要。后面代码里还会做 SQL 校验,但那只是第二层防护。真正的底线是:即使 MCP Server 有 bug,这个账号也不能写库。
四、初始化 TypeScript MCP Server 项目 新建一个独立项目,不要放进博客仓库,也不要和业务应用混在一起:
mkdir readonly-db-mcp-servercd readonly-db-mcp-servernpm init -y npm install @modelcontextprotocol/sdk@^1 mysql2 zod npm install -D typescript tsx @types/node mkdir srctouch src/index.ts tsconfig.json .env.example
截至 2026-07-05,@modelcontextprotocol/sdk 的稳定版本仍是 v1 主版本。npm 上也能看到 @modelcontextprotocol/server v2 beta 包,但本文为了让示例更稳定,使用官方稳定 SDK 包。
package.json 可以改成这样:
{ "name" : "readonly-db-mcp-server" , "version" : "1.0.0" , "type" : "module" , "private" : true , "scripts" : { "dev" : "tsx src/index.ts" , "build" : "tsc" , "start" : "node dist/index.js" } , "dependencies" : { "@modelcontextprotocol/sdk" : "^1.29.0" , "mysql2" : "^3.11.0" , "zod" : "^3.25.0" } , "devDependencies" : { "@types/node" : "^22.0.0" , "tsx" : "^4.0.0" , "typescript" : "^5.6.0" } }
tsconfig.json:
{ "compilerOptions" : { "target" : "ES2022" , "module" : "NodeNext" , "moduleResolution" : "NodeNext" , "strict" : true , "esModuleInterop" : true , "skipLibCheck" : true , "outDir" : "dist" } , "include" : [ "src/**/*.ts" ] }
.env.example:
DATABASE_URL=mysql://mcp_reader:replace-with-strong-password@127.0.0.1:3306/shop QUERY_TIMEOUT_MS=5000 MAX_ROWS=100
注意:stdio MCP Server 的日志不要随便写到标准输出。标准输出是 MCP 协议消息通道,业务日志建议写到标准错误,避免污染 JSON-RPC 消息。
五、实现两个工具:查看表结构与只读查询 这次只暴露两个 tool:
Tool
作用
风险
describe_table
查看某张表的字段结构
较低
query_readonly_database
执行只读 SQL
较高,需要严格限制
两个工具的分工可以理解成“先认路,再行动”:
完整代码如下。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" ;import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;import mysql from "mysql2/promise" ;import * as z from "zod/v4" ;const databaseUrl = process.env .DATABASE_URL ;const maxRows = Number (process.env .MAX_ROWS ?? "100" );const queryTimeoutMs = Number (process.env .QUERY_TIMEOUT_MS ?? "5000" );if (!databaseUrl) { throw new Error ("Missing DATABASE_URL" ); } const pool = mysql.createPool ({ uri : databaseUrl, waitForConnections : true , connectionLimit : 4 , maxIdle : 2 , idleTimeout : 30_000 , enableKeepAlive : true }); const server = new McpServer ({ name : "readonly-db-mcp-server" , version : "1.0.0" }); function removeSqlComments (sql : string ): string { return sql .replace (/\/\*[\s\S]*?\*\//g , " " ) .replace (/--.*$/gm , " " ) .trim (); } function assertSafeIdentifier (identifier : string ): string { if (!/^[a-zA-Z0-9_]+$/ .test (identifier)) { throw new Error ("Only letters, numbers, and underscore are allowed" ); } return identifier; } function assertReadOnlySql (sql : string ): string { const normalized = removeSqlComments (sql); if (!/^(select|show|describe|desc|explain)\b/i .test (normalized)) { throw new Error ("Only SELECT, SHOW, DESCRIBE, DESC, and EXPLAIN are allowed" ); } if (normalized.includes (";" )) { throw new Error ("Multiple statements are not allowed" ); } const forbiddenPattern = /\b(insert|update|delete|replace|drop|alter|create|truncate|grant|revoke|call|set|load|outfile|infile|lock|unlock)\b/i ; if (forbiddenPattern.test (normalized)) { throw new Error ("Write or administrative SQL keywords are not allowed" ); } return normalized; } function withLimit (sql : string , limit : number ): string { if (/\blimit\s+\d+/i .test (sql)) { return sql; } return `${sql} LIMIT ${limit} ` ; } async function runReadOnlyQuery (sql : string , params : unknown [] = [] ) { const connection = await pool.getConnection (); try { await connection.query (`SET SESSION max_execution_time = ${queryTimeoutMs} ` ); const [rows] = await connection.query ({ sql, values : params, timeout : queryTimeoutMs }); return rows; } finally { connection.release (); } } server.registerTool ( "describe_table" , { title : "Describe table" , description : "Read column metadata for one table in the configured database." , inputSchema : { table : z.string ().min (1 ).max (64 ).describe ("Table name, without database prefix" ) } }, async ({ table }) => { const safeTable = assertSafeIdentifier (table); const rows = await runReadOnlyQuery (`DESCRIBE \`${safeTable} \`` ); console .error ( JSON .stringify ({ event : "mcp_db_describe_table" , table : safeTable, at : new Date ().toISOString () }) ); return { content : [ { type : "text" , text : JSON .stringify (rows, null , 2 ) } ] }; } ); server.registerTool ( "query_readonly_database" , { title : "Query read-only database" , description : "Execute a read-only SQL query against the configured database. Use this for analytics and troubleshooting only." , inputSchema : { sql : z.string ().min (1 ).max (4000 ).describe ("A single read-only SQL statement" ), params : z.array (z.union ([z.string (), z.number (), z.boolean (), z.null ()])).optional (), limit : z.number ().int ().min (1 ).max (maxRows).optional () } }, async ({ sql, params = [], limit = maxRows }) => { const checkedSql = assertReadOnlySql (sql); const limitedSql = withLimit (checkedSql, limit); const rows = await runReadOnlyQuery (limitedSql, params); console .error ( JSON .stringify ({ event : "mcp_db_query" , sql : limitedSql, rowCount : Array .isArray (rows) ? rows.length : 0 , at : new Date ().toISOString () }) ); return { content : [ { type : "text" , text : JSON .stringify ( { sql : limitedSql, rows }, null , 2 ) } ] }; } ); async function main ( ) { const transport = new StdioServerTransport (); await server.connect (transport); console .error ("readonly-db-mcp-server is running" ); } main ().catch ((error ) => { console .error ("Server error:" , error); process.exit (1 ); });
这个 Server 做了几件事:
机制
作用
registerTool
向 MCP Host 暴露工具名称、描述、输入参数和处理函数
zod schema
校验模型传入的参数格式
assertReadOnlySql
限制 SQL 类型,拒绝写操作和多语句
withLimit
防止模型一次查出过多数据
max_execution_time
限制 MySQL 查询执行时间
console.error
把审计日志写到 stderr,避免污染 stdio 协议
这里有一个细节:describe_table 不让模型拼 DESCRIBE ${table},而是先检查表名只包含字母、数字和下划线,再加反引号。这是为了避免把表名参数变成注入入口。
六、在 AI Agent 中配置 stdio MCP Server 先构建项目:
然后在支持 MCP 的 AI 应用中添加本地 stdio Server。不同 Host 的配置文件位置不一样,但形态大致类似:
{ "mcpServers" : { "readonly-db" : { "command" : "node" , "args" : [ "/absolute/path/to/readonly-db-mcp-server/dist/index.js" ] , "env" : { "DATABASE_URL" : "mysql://mcp_reader:replace-with-strong-password@127.0.0.1:3306/shop" , "QUERY_TIMEOUT_MS" : "5000" , "MAX_ROWS" : "100" } } } }
如果开发阶段想直接用 tsx 跑,也可以:
{ "mcpServers" : { "readonly-db" : { "command" : "npx" , "args" : [ "tsx" , "/absolute/path/to/readonly-db-mcp-server/src/index.ts" ] , "env" : { "DATABASE_URL" : "mysql://mcp_reader:replace-with-strong-password@127.0.0.1:3306/shop" } } } }
生产或长期使用时,建议用 npm run build 后的 dist/index.js,依赖版本也要锁住。
七、实际对话效果:Agent 怎么用这个工具 配置好以后,可以这样问 Agent:
帮我看一下最近 24 小时支付失败最多的错误码,按错误码统计数量。
一个比较理想的过程是:
1. Agent 先调用 describe_table,查看 payments 表结构 2. 发现有 status、error_code、created_at 等字段 3. 调用 query_readonly_database,执行聚合 SQL 4. 拿到结果后解释 Top 错误码及占比 5. 如果需要,再按渠道或端类型继续拆分
把这次对话展开成时序图,大概是这样:
可能生成的 SQL 类似:
SELECT error_code, COUNT (* ) AS failed_count FROM paymentsWHERE status = 'FAILED' AND created_at >= NOW() - INTERVAL 24 HOUR GROUP BY error_codeORDER BY failed_count DESC LIMIT 20
这时 Agent 的回答就不再是泛泛建议,而是基于真实查询结果:
最近 24 小时支付失败主要集中在三个错误码: 1. CHANNEL_TIMEOUT:占失败请求的 43.2%,主要来自 wxpay 2. INSUFFICIENT_BALANCE:占 18.5%,属于正常业务失败 3. SIGN_INVALID:占 12.7%,从 14:20 开始明显上升 建议优先排查 14:20 左右 wxpay 渠道签名配置或网关发布记录。
这就是 MCP 工具的体验变化:模型不是“猜一个排查方向”,而是“先查上下文,再做判断”。
八、安全护栏:不要只靠“提示词禁止写库” 数据库 MCP Server 最大的坑,是把“只读”写在工具描述里,然后以为安全了。
工具描述当然有用,它会影响模型选择工具和构造参数。但它不是权限系统,不能作为唯一防线。
建议至少做五层限制:
层次
做法
数据库账号
专用只读账号,只授 SELECT 和必要的 SHOW VIEW
网络边界
只允许内网或本机访问数据库,不暴露公网
SQL 校验
只允许单条 SELECT / SHOW / DESCRIBE / EXPLAIN
资源限制
限制 LIMIT、超时时间、连接数、结果大小
审计日志
记录 SQL、时间、行数、调用来源,方便追踪
可以把这五层看成一条“逐层拦截”的流水线:
如果要上生产,还应该继续加:
只连只读副本,不连主库
敏感表只提供脱敏视图
对大表强制要求时间范围
禁止 SELECT *
对返回结果做字段级脱敏
给慢查询、错误率、调用次数加监控告警
尤其要注意,SQL 关键字正则只能做基础拦截,不能代替数据库权限和 SQL parser。更稳的生产方案是:
模型生成 SQL ↓ SQL parser 解析 AST ↓ 校验只包含允许的语句类型、表、字段、函数 ↓ 改写并强制追加 LIMIT / 时间范围 ↓ 用只读账号在只读副本执行
这比“看起来像 SELECT 就放行”可靠得多。
九、从本地 stdio 进阶到远程服务 本文用的是 stdio,因为它最适合本地开发和个人工具:
传输方式
适合场景
stdio
本机开发工具、个人数据库、只给一个 Host 使用
Streamable HTTP
团队共享工具、远程服务、需要鉴权和审计的平台
如果要把这个工具提供给团队使用,下一步通常不是“把 stdio 暴露出去”,而是改成 Streamable HTTP,并补上:
OAuth 或内部 SSO
用户身份透传
租户和权限隔离
查询审计后台
限流和配额
多实例部署
统一网关和 TLS
这时 MCP Server 就从“本地小工具”变成了“企业内部 AI 工具平台的一部分”。
不过不要一开始就把事情做重。一个可靠的演进路径是:
对应到架构形态,可以分成两个阶段:
每一步都先确认工具真的有价值,再增加复杂度。
十、常见问题 1. 为什么不用 Function Calling 直接调数据库? Function Calling 解决的是“模型怎么调用某个函数”的问题,MCP 解决的是“AI 应用怎么标准化发现、连接和调用外部能力”的问题。
如果只有一个应用、一个函数,Function Calling 足够。如果希望多个 Agent、多个客户端复用同一个数据库工具,MCP 更合适。
2. 能不能让 Agent 自己写任意 SQL? 开发库可以放宽一些,生产库不建议。更稳的方式是限制可查询表、字段和时间范围,并用 SQL parser 做结构化校验。
如果业务问题比较固定,也可以不开放自由 SQL,而是暴露更窄的工具,例如:
get_payment_error_summary get_order_count_by_status get_user_growth_by_day
工具越窄,灵活性越低,但安全性和稳定性越高。
3. 为什么要提供 describe_table? 因为 Agent 不知道你的表结构。没有表结构时,它很容易编造字段名。
describe_table 给它一个低风险入口:先读 schema,再生成 SQL。后续也可以增加 list_tables、get_table_indexes、get_column_stats 等只读工具。
4. 这个示例能直接上生产吗? 不建议直接上生产。它适合作为本地和测试环境的起点。
生产环境至少要补齐:
SQL parser 级别校验
表和字段白名单
敏感字段脱敏
查询成本控制
审计日志落库
监控告警
用户身份和权限体系
参考资料