实战教程:MCP 协议赋能数据库成为 AI 服务端点
详解通过 MCP 和认证机制将关系数据库封装为 AI 可调用接口。技术密度高(130+ 互动)。
详解通过 MCP 和认证机制将关系数据库封装为 AI 可调用接口。技术密度高(130+ 互动)。
MCP(Model Context Protocol,模型上下文协议)无疑是当下 AI 领域最令人兴奋的趋势之一。不仅人人都在讨论它,大家还争相开发自己的版本,以期在 AI 时代占据一席之地。没这种感觉?猜猜 MCP Server Directory 中列出了多少个 MCP 服务器?
目前的数量是 4,684 个。别忘了,MCP 还只是一个诞生六个月的婴儿。不过,你是否注意到了左侧唯一的筛选条件:
猜猜应用这个筛选条件后还剩多少个?只有 59 个——大约占总数的 1%。如果你不熟悉这个筛选条件,我会做一个简短说明;如果你已经了解,可以跳过。
MCP 最初主要被设计为一种用于本地执行的协议,AI 模型可以通过它与同一台机器上运行的本地工具和数据源进行交互,例如官方文档中列出的 MCP FileSystem 和 Fetch。这意味着,无论底层传输方式是 stdio 还是 HTTP SSE,你都必须在本地计算机上安装并运行 MCP 服务器。这种设计选择在早期是合理的,因为它简化了安全问题并降低了延迟。该协议简单易用,非常适合开发者在个人计算机上尝试集成 AI 工具。然而,随着 MCP 逐渐普及以及 AI 生态系统不断演进,对安全远程执行的需求也变得愈发明显:
通过远程 MCP 服务器,组织可以经由互联网向 AI 模型开放其数据和服务,因此最重要的事情就是 Auth(身份认证和授权)。最初的解决方式是要求用户在产品中生成 API key,然后将其配置到 MCP 设置中。下面是 Neon MCP 服务器的一个示例:
{ "mcpServers": { "neon": { "command": "npx", "args": [ "-y", "@neondatabase/mcp-server-neon", "start", "<YOUR_NEON_API_KEY>" ] } }}
开发者都很熟悉这个流程。但由于害怕被淘汰,越来越多的 SaaS 产品开始提供 MCP 服务器(如果你不明白,可以搜索“SaaS is dead”😁),对于非开发者而言,这显然不是一种良好的用户体验。此外,用户还需要不时手动更新 NPM 包,才能保持使用最新版本。
Kent 在他的推文中提出了同样的担忧:
到目前为止,我看到的所有示例都要求用户生成一个 token,然后将该 token 放入 MCP 配置中。这就是我们目前能做到的最好方案吗?我原本希望能找到一个支持通过托管 MCP 服务器完成 OAuth 流程的 MCP 客户端示例。
MCP 虽然年轻,但发展速度非常快。OAuth 支持于 2025 年 3 月引入,当时它诞生大约才三个月。以下是官方规范中的授权流程步骤:
该规范同时提供了客户端和服务器端的 SDK 支持,使更多 MCP 服务器能够采用并利用这一机制。甚至一些原本依赖 API key 的现有服务器,也开始拥抱这一全新的现代化解决方案。例如,上面提到的 Neon MCP 服务器也增加了对此机制的支持。
{ "mcpServers": { "Neon": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.neon.tech/sse"] } }}
添加上述 MCP 配置后,MCP 客户端会自动打开浏览器,引导用户完成标准 OAuth 流程,不再需要 API key:
有了这种现代化的 MCP 服务器,你就不用再担心产品经理或 CEO 跑到你的工位前,请你帮忙连接公司正在使用的某个 SaaS 产品的 MCP 服务器了。相信我,这种事真的会发生。😂
凭借 OAuth 支持带来的无缝体验,许多公司都迫切希望提供 MCP 服务器,以增强现有产品或服务,尤其是在 SaaS 领域。如何平衡灵活性与简单性一直是 SaaS 面临的核心挑战——按钮太多会让用户不知所措,按钮太少又会限制高级用户。例如,我一直很喜欢 Trello 简洁高效的 UX/UI 设计:
然而,在处理某些日常工作时,它每次都会让我感到头疼。
上周完成了多少张卡片?
谁的未完成卡片最多?
哪个列表中的未完成卡片最多?
Trello 并没有提供直接展示答案的方式;你必须手动计算。这正是 MCP 与 LLM 的组合能够提供出色解决方案的地方:让用户通过自然语言完成工作。我认为,这才是 Microsoft CEO Satya Nadella 在那场被解读为提出“SaaS is dead”的采访中真正想表达的观点。
这似乎是显而易见的选择。然而,由于这些 API 很可能早在 LLM 出现之前就已经设计完成,因此它们可能并不适合供 LLM 使用。一方面,参数和返回数据的语义可能不够直观,LLM 难以理解;另一方面,API 可能缺乏足够的灵活性,无法提供支持用户目标所需的功能。
对于考虑采用这种方式的人来说,最大的顾虑可能是时间。别担心,我们从来都不是真的从零开始,不是吗?MCP 正在快速演进,整个生态系统也是如此。接下来,我将向你展示一套现代化技术栈,它可以显著减少需要编写的代码,加快开发速度。
MCP TypeScript SDK 它完整实现了 MCP 规范,包括授权机制。它提供了强大的抽象,让你不必再费力处理底层协议实现,可以专注于业务逻辑。
它完整实现了 MCP 规范,包括授权机制。它提供了强大的抽象,让你不必再费力处理底层协议实现,可以专注于业务逻辑。
ZenStack ZenStack 是一个构建在 Prisma ORM 之上的、以 schema 为先的 TypeScript 工具包。其核心部分是一种名为 ZModel 的 DSL,它统一了数据建模和访问控制。下面是一个简单博客文章模型的示例:model User { id Int @id @default(autoincrement()) email String @unique name String? password String @password @omit posts Post[] @@allow('read', true)}model Post { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt title String content String? published Boolean @default(false) viewCount Int @default(0) author User? @relation(fields: [authorId], references: [id]) authorId Int? @default(auth().id) @@allow('all', auth() == author) // logged-in users can view published posts @@allow('read', auth() != null && published)} 基于 ZModel schema,ZenStack 会自动为数据库创建结构良好、类型安全且经过授权的 CRUD API。MCP 的核心价值在于其工具,而这些工具本质上由 schema 定义和可执行操作组成——对于大多数 SaaS 应用而言,具体就是对数据库执行的 CRUD 操作。ZenStack 可以直接根据其 schema 生成这两部分;得益于访问控制策略,LLM 能够安全地调用它们。因此,你的主要任务是定义 schema,其余一切都由 ZenStack 管理。
ZenStack 是一个构建在 Prisma ORM 之上的、以 schema 为先的 TypeScript 工具包。其核心部分是一种名为 ZModel 的 DSL,它统一了数据建模和访问控制。下面是一个简单博客文章模型的示例:
model User { id Int @id @default(autoincrement()) email String @unique name String? password String @password @omit posts Post[] @@allow('read', true)}model Post { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt title String content String? published Boolean @default(false) viewCount Int @default(0) author User? @relation(fields: [authorId], references: [id]) authorId Int? @default(auth().id) @@allow('all', auth() == author) // logged-in users can view published posts @@allow('read', auth() != null && published)}
基于 ZModel schema,ZenStack 会自动为数据库创建结构良好、类型安全且经过授权的 CRUD API。MCP 的核心价值在于其工具,而这些工具本质上由 schema 定义和可执行操作组成——对于大多数 SaaS 应用而言,具体就是对数据库执行的 CRUD 操作。ZenStack 可以直接根据其 schema 生成这两部分;得益于访问控制策略,LLM 能够安全地调用它们。因此,你的主要任务是定义 schema,其余一切都由 ZenStack 管理。
接下来,我将带你从零开始,了解如何把数据库转换为一个采用 OAuth 身份认证和授权的 MCP 服务器。我会使用上面提到的博客文章模型作为示例;你可以将它应用到自己的应用中,只需相应地修改 ZModel schema 即可。
使用下面的脚本初始化一个带有 Prisma 和 Express 的项目:
npx try-prisma@latest -t orm/express -n blog-app-mcp
安装 MCP SDK、bcrypt(用于密码哈希):
npm install @modelcontextprotocol/sdk, bcrypt, @types/bcrypt
npx zenstack@latest init
为了支持 OAuth,服务器需要存储以下信息:
让我们将这些信息添加到 ZModel schema 文件中,这样我们就可以使用生成的类型安全 API 对其进行操作:
model OAuthClient { id Int @id @default(autoincrement()) client_id String @unique client_secret String? ...}model AuthorizationCode { id Int @id @default(autoincrement()) code String @unique ...}model AccessToken { id Int @id @default(autoincrement()) token String @unique ...}model RefreshToken { id Int @id @default(autoincrement()) token String @unique ...}
根据 MCP 规范,这是服务器必须实现的三个端点:
MCP SDK 提供了一个实用函数 mcpAuthRouter,用于安装这些标准授权服务器端点。我们需要做的是实现一个 OAuthServerProvider 并将其传递给 mcpAuthRouter。
让我们创建一个 AuthMiddleware 类来处理所有的认证逻辑,以及一个实现 OAuthServerProvider 的 PasswordAuthProvider 来执行实际的逻辑。
export class AuthMiddleware { private authProvider: PasswordAuthProvider; private authRouter: express.Router = express.Router(); ... private setupRouter() { // Add OAuth router using the mcpAuthRouter function this.authRouter.use( '/', mcpAuthRouter({ provider: this.authProvider, issuerUrl: new URL(config.baseUrl), baseUrl: new URL(config.baseUrl), scopesSupported: ['read', 'write'], }) ); ... }}
这里是将被实际调用的三个函数,对应于上面提到的三个必要端点:
export interface OAuthServerProvider { // /register get clientsStore(): OAuthRegisteredClientsStore; // /authorize authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>; // /token exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string): Promise<OAuthTokens>;}
clientStore 通过 registerClient 和 getClient 函数处理注册和读取,它简单地从数据库中写入和读取 OAuthClient 模型。
实际的授权流程从 authorize 开始。它的任务是用所有必要的参数重定向到实际的授权服务器。由于在我们的情况下授权服务器就是它本身,让我们重定向到 /auth/login 路径:
const loginUrl = new URL('/auth/login', config.baseUrl); ... res.redirect(loginUrl.toString());
因此,我们需要创建一个 login.html 来提供此服务。它是一个典型的登录表单,如下所示:
点击登录后,它将授权流程中传递的所有 URL 参数与电子邮件和密码组合在一起,发送到服务器端点 /auth/login。
服务器端点 /auth/login 最终会调用 PasswordAuthProvider 的 handlelogin 函数。它验证用户身份。如果正确,则创建认证代码,返回给客户端,并将其存储在数据库中的 AuthorizationCode 实例中:
// Generate authorization code const authCode = crypto.randomBytes(32).toString('hex'); // Store authorization code in database await this.prisma.authorizationCode.create({ data: { code: authCode, clientId, userId: user.id, codeChallenge, redirectUri, expiresAt: new Date(Date.now() + 600000), // 10 minutes scopes, }, });
从服务器获取返回的认证代码后,login.html 将重定向到 MCP 客户端,带上认证代码。最后,MCP 客户端将使用认证代码调用 /token 端点来交换访问令牌,这会调用 exchangeAuthorizationCode 函数。
在 exchangeAuthorizationCode 中,服务器首先验证它刚刚授予的认证代码是否有效,以及客户端的身份标识,例如 PKCE。如果一切正确,它将生成访问令牌和刷新令牌,将它们存储在数据库中,然后返回给 MCP 客户端。
// Store tokens in databaseawait this.prisma.accessToken.create({ data: { token: accessToken, clientId: client.client_id, userId: authData.userId, scopes: authData.scopes as any, expiresAt, },});await this.prisma.refreshToken.create({ data: { token: refreshToken, clientId: client.client_id, userId: authData.userId, scopes: authData.scopes as any, expiresAt: new Date(Date.now() + 86400 * 30 * 1000), // 30 days },});// Clean up authorization codeawait this.prisma.authorizationCode.delete({ where: { code: authorizationCode },});return { access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, refresh_token: refreshToken, scope: (authData.scopes as string[]).join(' '),};
在 MCP 客户端获得访问令牌后,每次它通过主端点与服务器通信时,都会在 Authorization 请求头中放置访问令牌。服务器应该使用它来验证客户端并获取客户端的身份。让我们选择常规的 /mcp 端点作为主端点,并使用 getFlexibleAuthMiddleware 来完成这项工作。
MCP 客户端发送到服务器的第一个请求是初始化请求。服务器应该响应服务器信息和生成的会话 ID,客户端将始终包含这个 ID 来维持会话。作为生产就绪的 MCP 服务器,它肯定应该支持多个同时连接。因此,我们使用一个 map 来按会话 ID 存储每个客户端。
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
为了管理每个用户的会话,我们将为其创建一个专用的 MCPServer。由于它是按用户按 MCP 服务器的,我们可以在其中存储 userId,这将在执行工具时用于授权。
if (sessionId && transports[sessionId]) { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // Handle new MCP connection initialization transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sessionId: string) => { console.log(`New MCP session initialized: ${sessionId}, User ID: ${userId}`); transports[sessionId] = transport!; }, }); const mcpServer = createMCPServer(userId); await mcpServer.connect(transport); console.log(`MCP server connected for User ID: ${userId}`); // Handle connection close transport.onclose = () => { if (transport?.sessionId) { console.log(`MCP session closed: ${transport.sessionId}`); delete transports[transport.sessionId]; } };}else { // invalid request ...}// Handle the requestawait transport.handleRequest(req, res, req.body);
我们将完全依赖 ZenStack 来完成这项工作。记住工具的两个部分:schema 和执行。
ZenStack 有一个本地 Zod 插件来为其 CRUD API 生成 Zod schema。我们可以通过将以下内容添加到 schema.zmodel 来启用它:
plugin zod { provider = '@core/zod'}
然后,运行 zenstack generate 后,它为每个模型的 @zenstackhq/runtime/zod/input 中支持的所有操作生成 Zod schema。例如,这是 Post 的一个:
import { z } from 'zod';import type { Prisma } from '.zenstack/models';declare type PostInputSchemaType = { findUnique: z.ZodType<Prisma.PostFindUniqueArgs>; findFirst: z.ZodType<Prisma.PostFindFirstArgs>; findMany: z.ZodType<Prisma.PostFindManyArgs>; create: z.ZodType<Prisma.PostCreateArgs>; createMany: z.ZodType<Prisma.PostCreateManyArgs>; delete: z.ZodType<Prisma.PostDeleteArgs>; deleteMany: z.ZodType<Prisma.PostDeleteManyArgs>; update: z.ZodType<Prisma.PostUpdateArgs>; updateMany: z.ZodType<Prisma.PostUpdateManyArgs>; upsert: z.ZodType<Prisma.PostUpsertArgs>; aggregate: z.ZodType<Prisma.PostAggregateArgs>; groupBy: z.ZodType<Prisma.PostGroupByArgs>; count: z.ZodType<Prisma.PostCountArgs>;};
所以每个模型的每个操作都将成为我们 MCP 服务器的工具。
每个函数的执行非常直接明了;只需用 LLM 生成的参数动态调用该函数。因此,整个工具创建逻辑只需不足 30 行代码的一个 lambda 表达式:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import crudInputSchema from '@zenstackhq/runtime/zod/input';...export function createMCPServer(userId: number) { const server = new McpServer( ... ) Object.entries(crudInputSchema) .filter(([name]) => modelNames.includes(getModelName(name))) .forEach(([name, functions]) => { const modelName = getModelName(name); Object.entries(functions as Record<string, any>) .filter(([functionName]) => functionNames.includes(functionName)) .forEach(([functionName, schema]) => { const toolName = `${modelName}_${functionName}`; server.tool( toolName, `Prisma client API '${functionName}' function input argument for model '${modelName}'. ${currentUserPrompt}`, { args: schema, }, async ({ args }) => { console.log(`Calling tool: ${toolName} with args:`, JSON.stringify(args, null, 2)); const prisma = getPrisma(userId); const data = await (prisma as any)[modelName][functionName](args); console.log(`Tool ${toolName} returned:`, JSON.stringify(data, null, 2)); return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], }; } ); }); }); return server;}
需要说明的是,用来执行该操作的 Prisma 客户端是 ZenStack 增强版本,包含当前用户身份,即 MCPServer 初始化期间存储的 userId。这将完全消除未授权的数据访问,无论 LLM 为该函数生成什么参数,即使发生幻觉也是如此:
import { enhance } from '@zenstackhq/runtime';// Gets a Prisma client bound to the current user identityexport function getPrisma(userId: number | null) { const user = userId ? { id: userId } : undefined; return enhance(prisma, { user });}
还有一个优势是,由于这些工具实际上是标准的 Prisma Client API,它对 LLM 的效果很好:
首先,其声明式且结构良好的特性使其易于理解和推理,允许 LLM 以最小的歧义生成准确且可预测的查询
其次,该 API 的灵活性允许单个调用访问多个模型(数据库表),使 LLM 能够高效地导航复杂的数据关系,并在不同实体之间执行复杂查询,无需单独的 API 调用或复杂的编排逻辑。
第三,Prisma 多年来被广泛采用,提供了丰富的文档、示例和社区使用案例生态系统,LLM 可以从中学习——大大增加了生成正确且上下文感知代码的机会。
示例项目包含种子数据供你试玩。只需运行以下命令即可准备就绪:
npx prisma db pushnpx prisma db seed
它创建了三个拥有文章的用户。所有用户的密码都是 password123。
尽情享受使用你选择的任何 MCP 客户端进行试玩:
我非常期待听到你在应用中取得的成果!如果你在编写 ZModel 模式时需要任何建议,请随时联系我或加入我们的 Discord。我随时愿意帮助!