用MCP构建AI原生CRM:Salestrics架构拆解与工程实现
在 AI-native revenue teams 的工作流里CRM 不再只是销售录入数据的后台系统而应该成为 AI 代理读取客户信息、推进商机、记录互动动作的数据底座。过去几年销售团队开始把越来越多的 CRM 操作交给自动化工具或智能助手但传统 CRM 的 API 大多围绕人工页面操作设计字段多、权限复杂、文档分散AI 客户端接入成本很高。MCPModel Context Protocol提供了一套标准化的工具与数据接入方式于是出现了一类新项目把 MCP server 与 CRM 能力构建在同一个服务里。Salestrics 正是这种思路的开源示例——它展示了如何做一个开放 MCP server同时承担轻度 CRM 功能面向 AI-native revenue teams。这篇文章用工程视角做三件事先拆解这类项目为什么成立、在协议层如何组织然后用官方 SDK 搭建一个最小可运行的 MCP server CRM 服务包含客户查询、客户创建和商机阶段更新三个工具最后讨论从 demo 走向生产时需要补齐的存储、权限、审计和安全边界。读完以后你可以基于同样的结构自建一个面向 AI 客户端的业务数据服务也能理解 Salestrics 这类项目在架构上到底做了什么。1. 先理清这类项目在解决什么问题1.1 传统 CRM 在 AI 接入上的核心痛点传统 CRM 系统的问题是它是为人在浏览器里操作而设计的不是为 agent 直接调用而设计的。销售代表登录 CRM 后在一个表单里填客户名称、阶段、金额、下次跟进时间这个过程对人类很自然。但 AI 代理接入时它面对的是另一套世界REST API 端点、分页参数、可变字段命名、对象关系、权限模型。它的目标是“帮我看看这个月所有 stage 是 negotiation 的商机”“把某条商机的阶段从 proposal 改成 won”“给某个客户创建一条跟进任务”。这些动作如果都要通过一层层 API 文档翻译AI 应用开发成本会迅速上升。更深层的问题是字段语义。CRM 里的stage字段在中文环境可能叫“阶段”在数据库里是stage还是sales_stage取值是proposal还是报价阶段不同团队习惯完全不同。AI 客户端需要一套显式的接口描述告诉它每个参数是什么意思、取值范围是什么、缺省值是什么。传统 REST API 的 OpenAPI 文档会有一定帮助但通常不够模型友好而且很多 CRM 的 API 权限模型是为了支持大量不同角色设计的agent 接入时往往不知道当前凭据能做什么、不能做什么。Salestrics 这类项目尝试改变这种结构不把 AI 当作 CRM 外部的一个消费者而是让 MCP server 直接暴露“销售数据模型”和“销售动作”给模型客户端。协议是最新的数据描述是紧凑的工具边界是清晰的。1.2 MCP 为什么适合作为 CRM 的 AI 接入层MCP 是 Anthropic 在 2024 年提出并开放的协议它的目标不是替代某种 CRM API而是建立模型与外部工具、数据源之间的标准连接方式。一个 MCP server 可以理解为一个“AI 可用的工具服务器”它向模型客户端暴露三种核心能力Tools工具模型可以主动调用的操作比如查询客户列表、创建客户、修改商机阶段。Resources资源暴露给客户端的只读数据比如一份客户名单、一段团队销售规范、一个 CSV 报表。Prompts提示模板化的提示信息比如“帮我生成一封跟进邮件”的固定开头和字段要求。对 CRM 场景来说Tools 是核心。它等价于把传统 API 的动作包装成模型可以直接理解的原语。模型不需要知道 HTTP method、URL 路径、header 怎么拼它只需要知道“有一个工具叫update_deal_stage输入是customerId和stage结果会返回客户最新状态”。这一层标准化带来的好处是明显的Claude Desktop、Cursor、自研 Agent 应用只要实现了 MCP client就能连接同一套 MCP server。不需要为每个前端客户端分别写一套 API 适配层。1.3 为什么把 MCP server 和 CRM 合并成同一个服务单独做一个 MCP 网关去对接已有 CRM 也是一种可行方案很多大厂会在内部做这一步。但 Salestrics 选择把 MCP server 和 CRM 业务模型放在一起这背后有几个实际取舍。第一是降低部署复杂度。假设你有 CRM 系统、单独跑一个 MCP 适配服务那你要部署两套服务还要处理多跳认证、数据同步延迟、字段映射维护。合并成一个服务之后MCP 工具直接操作业务对象模型上下文和数据表之间只有一层领域逻辑。第二是数据语义能保持原生。MCP tool 的参数就是 CRM 实体的字段不需要通过 JSON Patch 或自建翻译层。AI 客户端看到的 schema 和开发者在数据库里理解的实体基本一致排错链条更短。第三是适合小团队和开源场景。一个 revenue team 可能只有几十个人不需要大型 CRM 的完整复杂功能但需要一套能被 AI 工具有效调用的轻量数据服务。合并设计让项目可以保持小而精部署在团队内部快速被 agent 使用。不过这个设计也要付出代价MCP server 一旦承担 CRM 业务逻辑就要自己处理数据一致性、权限隔离、审计和并发控制。协议网关可以很薄业务服务却必须是完整的。Salestrics 类项目是否适合生产环境关键不看 MCP 部分写得多好而是看 CRM 部分在工程上是否经得起真实销售数据考验。2. 架构上拆解一个 MCP CRM 服务2.1 从模型客户端到数据表的完整链路一个 MCP CRM 服务从请求到数据表通常经过这样一条链路模型客户端 - MCP Client - 传输层stdio 或 HTTP- MCP Server - 工具注册表 - 领域服务 - CRM 数据存储每一层的作用模型客户端发起对话的 AI 应用它决定在什么时机调用哪个工具。MCP Client负责与 MCP Server 建立会话、发送 JSON-RPC 请求、接收结果。传输层MCP 消息的载体。本地进程用 stdio远程服务用 HTTP。MCP Server核心运行时负责协议握手、工具注册、请求分发。工具注册表server.tool()注册的所有工具集合。领域服务业务规则所在位置比如变更商机阶段时要校验阶段是否合法、是否写审计日志。CRM 数据存储最终保存数据的地方可能是内存 Map、SQLite、PostgreSQL。在这条链路里最容易想歪的地方是把所有业务逻辑都堆在工具回调函数里。工具回调应该薄只做参数转义、调用领域服务、返回结果。数据库访问、状态变更、权限判断要下沉到领域服务和存储层。否则项目一旦从 demo 变成真实系统工具函数会迅速膨胀成无法维护的泥球。2.2 传输方式应该选 stdio 还是 HTTPMCP server 写好后首先要回答的问题就是通过什么方式让客户端连接。目前主流选择有两种stdio 和 HTTPStreamable HTTP早期也常见 HTTP SSE。传输方式适用场景优点注意事项stdio本地桌面客户端、个人调试无需网络端口、不暴露服务、配置简单stdout 被协议占用日志必须写 stderrHTTP远程服务、团队共享、部署在服务器可多客户端连接、可统一认证、可接入已有网关需要认证、限流、CORS、TLS 等生产配置在 Salestrics 这类项目里本地验证用 stdio 最方便。一个 Claude Desktop 客户端可以直接通过command: node, args: [/path/to/server.js]启动本地 server。如果是多人销售团队共享的 CRM 服务必须走 HTTP因为数据不在某个人的笔记本上而且团队需要统一权限和审计。2.3 CRM 数据模型与 MCP 工具应该怎么映射把 CRM 数据模型映射成 MCP 工具本质上是把“对象 动作”转化为“工具 参数”。一个最小的 AI-native CRM 至少需要这几张实体表和对应工具实体关键字段MCP 工具示例读写方向Account 客户id, name, industry, ownerlist_customers,create_customer读 / 写Contact 联系人id, account_id, name, email, phoneget_contact,create_contact读 / 写Lead 线索id, name, source, statuscreate_lead,update_lead_status读 / 写Opportunity 商机id, account_id, amount, stage, close_dateget_deal,update_deal_stage读 / 写Activity 活动id, type, subject, due_date, assigneecreate_task,complete_task读 / 写映射主要有两条规则。查询用列表或详情工具命名尽量用list_xxx、get_xxx参数要包含分页、筛选条件。写操作直接把参数映射到实体字段每个字段必须给出 describe 和 enum否则模型不知道可能值。还有一个容易被忽略的点MCP Resources 在 CRM 里非常有用。除了工具之外你可以把团队销售规范、客户分组规则、常用话术模板作为 Resources 暴露给模型。这样模型在调用工具前可以先读取资源理解当前销售流程而不是只能靠工具参数里那几行描述。3. 搭建最小可运行的 MCP server CRM 服务3.1 环境准备与工程初始化演示环境按以下版本准备工作实际项目请以当前官方 SDK 的最新版本为准Node.js 20 或更高版本建议 22 LTSnpm 10 或更高版本也可以使用 pnpmTypeScriptMCP TypeScript SDKmodelcontextprotocol/sdkZod用于工具参数 schema 声明创建工程并安装依赖mkdir salestrics-demo cd salestrics-demo npm init -y npm install modelcontextprotocol/sdk zod npm install -D typescript tsx types/nodemodelcontextprotocol/sdk是官方 MCP 开发包提供McpServer、StdioServerTransport等核心能力。zod负责定义工具参数的 schemaSDK 会自动把 zod schema 转换成 MCP 协议需要的 JSON Schema 格式。tsx用于本地直接运行 TypeScript免去每次编译。3.2 项目结构与 TypeScript 配置工程目录按清晰分层来组织后续扩展存储层时会比较顺手salestrics-demo ├── src │ ├── db.ts │ └── index.ts ├── package.json └── tsconfig.jsonpackage.json需要设置type: module这样 TypeScript 可以按 ESM 方式编译。scripts里提供开发、构建和启动命令{ name: salestrics-demo, version: 0.1.0, private: true, type: module, scripts: { dev: tsx src/index.ts, build: tsc, start: node dist/index.js } }tsconfig.json使用 NodeNext 模块解析。注意 MCP SDK 的 ESM 导入路径带.js后缀使用 NodeNext 才能正确识别{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: dist, rootDir: src, strict: true, esModuleInterop: true, skipLibCheck: true } }3.3 数据层先用内存 Map 模拟 CRM 表在 demo 阶段数据层使用一个Map模拟 CRM 数据库。这样做的好处是零配置、无外部服务适合先把 MCP 协议链路跑通。生产环境必须替换为 SQLite 或 PostgreSQL这一点在最后一章会展开。src/db.ts定义客户实体和生产环境的扩展点export type Customer { id: string; name: string; email?: string; stage: string; value: number; owner: string; createdAt: string; }; export type CreateCustomerInput { name: string; email?: string; stage: string; value: number; owner: string; }; const customers new Mapstring, Customer(); export function listCustomers(limit: number): Customer[] { return [...customers.values()].slice(0, limit); } export function getCustomer(id: string): Customer | undefined { return customers.get(id); } export function createCustomer(input: CreateCustomerInput): Customer { const customer: Customer { id: crypto.randomUUID(), ...input, createdAt: new Date().toISOString(), }; customers.set(customer.id, customer); return customer; } export function updateCustomerStage(id: string, stage: string): Customer | undefined { const customer customers.get(id); if (!customer) { return undefined; } customer.stage stage; return customer; }这个文件可以看作一个内存仓储。每个函数都对应一种数据操作后续替换成 SQL 或 ORM 时工具层不需要改动只要保证这些函数的语义一致。3.4 MCP server注册工具并启动src/index.ts是核心入口它做几件事创建McpServer实例注册 CRM 工具连接 stdio transport。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { z } from zod; import { listCustomers, createCustomer, updateCustomerStage, } from ./db.js; const server new McpServer({ name: salestrics-demo, version: 0.1.0, }); server.tool( list_customers, 列出当前团队的客户列表按创建时间倒序返回, { limit: z.number().int().min(1).max(100).optional().describe(返回条数默认 20), }, async ({ limit }) { const rows listCustomers(limit ?? 20); return { content: [{ type: text, text: JSON.stringify(rows, null, 2) }], }; } ); server.tool( create_customer, 创建一条新客户记录, { name: z.string().min(1).max(200).describe(客户名称), email: z.string().email().optional().describe(客户邮箱), stage: z.enum([lead, opportunity, proposal, won, lost]).default(lead).describe(客户当前阶段), value: z.number().nonnegative().default(0).describe(预计金额以用户币种为单位), }, async (params) { const customer createCustomer({ name: params.name, email: params.email, stage: params.stage, value: params.value, owner: current-user, }); return { content: [{ type: text, text: JSON.stringify(customer, null, 2) }], }; } ); server.tool( update_deal_stage, 更新一个客户的销售阶段, { customerId: z.string().min(1).describe(客户 ID), stage: z.enum([lead, opportunity, proposal, won, lost]).describe(新的销售阶段), }, async ({ customerId, stage }) { const customer updateCustomerStage(customerId, stage); if (!customer) { return { content: [{ type: text, text: 客户 ${customerId} 不存在 }], isError: true, }; } return { content: [{ type: text, text: JSON.stringify(customer, null, 2) }], }; } ); const transport new StdioServerTransport(); await server.connect(transport); console.error([salestrics-demo] MCP server is ready over stdio);这段代码是 MCP server 的最小骨架理解它有四个关键点。第一server.tool()的第二个参数是工具描述。这个描述不是给人看的注释而是给模型看的说明会进入工具的 schema。要写得具体说明工具返回值是什么、接收什么参数否则模型很难在正确时机调用它。第二第三个参数是 zod schema 对象。SDK 会把 zod 对象转换为 MCP 工具参数所需的 JSON Schema。z.enum限制模型的输入必须在预设值内这个约束比在函数内部做 if 判断更强因为模型在发起调用前就能看到候选值。第三返回值统一包装成一个数组第一项是type: text的文本内容。这个结构是 MCP 协议要求的结果格式模型最终读取的就是这里面的文本。第四最后使用console.error打印服务启动日志而不是console.log。原因是 stdio transport 模式下stdout 被用来传输 JSON-RPC 协议消息任何额外输出都会污染协议流导致客户端解析失败。这一步错的人非常多后面排查章节会再展开。3.5 启动与基础验证启动服务npm run dev如果一切正常终端只会打印一行 stderr 日志[salestrics-demo] MCP server is ready over stdio注意这个命令不会退出它会一直等待客户端通过 stdin 发送 JSON-RPC 请求。这不是卡死而是 stdio server 的正常行为。因为 stdout 被协议占用所以不会看到普通 Web 服务那种请求日志。要验证工具是否正常需要启动一个 MCP client 来连接它下一章会介绍如何用 MCP Inspector 完成这一步。4. 关键实现解释工具注册、参数校验与输出格式4.1 server.tool 是如何把 zod schema 变成 JSON Schema 的server.tool()是 MCP TypeScript SDK 注册工具的入口。它的核心价值是你写一份 zod schemaSDK 在注册阶段自动把它转成 JSON Schema并注册到协议的能力列表中。当模型客户端请求工具列表时它拿到的是标准 JSON Schema而不是 zod 对象。常见的 zod 类型和 JSON Schema 对应关系如下zod 写法生成的 JSON Schema 片段含义z.string().min(1).max(200){type:string,minLength:1,maxLength:200}非空字符串z.enum([lead,won,lost]){type:string,enum:[lead,won,lost]}只能取这三个值z.number().nonnegative(){type:number,minimum:0}非负数z.string().email(){type:string,format:email}邮箱格式.default(lead)default:lead模型不传时使用默认值.optional()required:[]中不包含该字段可选字段把参数 schema 写清楚是让 AI 客户端能正确使用工具的关键。很多工具调用失败不是因为代码有 bug而是 schema 里没有描述阶段字段的取值范围模型传了一个stage: completed而代码只接受won和lost。使用.describe()给每个字段补充自然语言说明也很有帮助。比如value字段如果只写z.number()模型可能以为是客户数量。写成z.number().describe(预计金额以用户币种为单位)模型才能判断该传多大数字。4.2 返回值为什么要包成 content 数组MCP 的 ToolResult 结构不是一个简单的字符串而是一个对象包含{ content: [ { type: text, text: {\id\:\...\,\name\:\Acme\,\stage\:\won\} } ], isError: false }content是数组意味着一个工具可以返回多种内容。最常用的是text未来可以扩展到 image、resource link 等类型。isError用于标记调用是否失败。在 CRM 场景里返回 JSON 字符串是最直接的方式。由于 model 本身擅长解析 JSON你不需要返回结构化表格只要保证字符串是合法的 JSON并且字段命名稳定模型就能从中提取信息。4.3 错误处理要让模型能理解而不是返回内部异常工具回调中一旦抛异常MCP 客户端会收到协议错误模型通常只能看到“Internal error”这种没有业务含义的信息。这在真实使用时基本不可用。推荐做法是业务异常不要 throw而是返回带isError: true的结果。例如update_deal_stage里如果客户不存在返回一段可读文本同时标记 isError。这样模型能根据返回内容决定下一步比如告诉用户“客户不存在”或者建议先调用create_customer。if (!customer) { return { content: [{ type: text, text: 客户 ${customerId} 不存在 }], isError: true, }; }不要直接把数据库连接失败、堆栈信息返回给模型。给模型一个安全、有意义的错误摘要完整堆栈写到 server 日志里供人排查。5. 在 MCP Inspector 与真实客户端里完成端到端验证5.1 先用 MCP Inspector 做协议级验证MCP Inspector 是官方提供的可视化调试工具。它启动一个本地调试前端可以连接任意 MCP server查看工具列表、资源列表手动调用工具。这比自己写 MCP client 省事很多是验证 MCP server 的优先选择。先构建项目npm run build然后启动 Inspector 连接dist/index.jsnpx modelcontextprotocol/inspector node dist/index.jsInspector 会在本地启动一个 Web UI自动打开浏览器。连接成功后在左侧页面可以看到Tools 列表里有list_customers、create_customer、update_deal_stage每个工具的参数 schema 会显示出来可以直接填写参数发起一次 tool call手动调用一次create_customer传入name、stagewon返回结果里能看到生成后的客户对象。这个流程能确认协议握手、工具注册、参数校验、结果返回全部正常。5.2 在 Claude Desktop 中接入本地 stdio 服务Claude Desktop 支持通过配置文件接入 MCP server。配置文件位置因系统不同通常在macOS:~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:%APPDATA%\Claude\claude_desktop_config.json把服务注册进去{ mcpServers: { salestrics-demo: { command: node, args: [/absolute/path/to/salestrics-demo/dist/index.js] } } }注意args必须使用绝对路径。配置完成后重启 Claude Desktop在对话中问“调用 create_customer 创建一个名为 Acme 的客户”模型会识别到对应的 MCP 工具并尝试调用。启动的 server 进程由 Claude Desktop 管理生命周期绑定在桌面应用上。5.3 在 Cursor 中使用 MCP 工具Cursor 的 MCP 配置入口在 Settings - MCP或者在开发者工具中找到 MCP 面板。添加一条新的命令型 MCP servernode /absolute/path/to/salestrics-demo/dist/index.js添加成功后Cursor 会启动该进程并建立连接。你可以直接在对话里让 AI 使用这些工具操作数据。5.4 预期验证流程推荐按以下顺序验证能最快发现协议层问题在 Inspector 中调用create_customer创建一条客户记录。在 Inspector 中调用list_customers确认刚才创建的客户出现在结果中。调用update_deal_stage传入上一步返回的客户 ID把 stage 改为won。再次调用list_customers确认 stage 已更新。预期结果中create_customer返回类似这样的 JSON{ id: b7f4c1a0-3d6a-4f10-9d1a-6e2e0a3c5d4f, name: Acme, email: opsacme.example, stage: won, value: 120000, owner: current-user, createdAt: 2026-01-01T00:00:00.000Z }如果前三步都成功说明 MCP 协议链路没问题。如果某一步失败排查思路从第 6 章开始。6. 常见问题排查与根因分析6.1 客户端连接不上服务问题现象常见原因检查方式处理建议客户端提示无法连接 MCP serverargs路径错误或没有先构建在终端手工运行配置的命令确认dist/index.js存在路径使用绝对路径server 启动后立即退出Node 版本过低或 SDK 依赖不兼容查看 stderr 日志升级到 Node 20重新安装依赖配置文件改动不生效修改后未重启客户端检查配置文件格式是否合法 JSON重启客户端确认配置被加载协议流被日志串扰在代码里使用了console.log打印业务信息使用 Inspector 连接时观察解析错误日志一律使用console.error或独立日志文件最常见的根因还是console.log污染 stdout。MCP 在 stdio 传输模式下stdout 是完全的协议通道任何非 JSON-RPC 文本都会被客户端当作非法数据。排查时先确认代码里没有历史遗留的console.log再检查启动配置。6.2 工具列表为空或调用报错问题现象常见原因检查方式处理建议客户端连接成功但看不到工具server.tool()注册逻辑存在语法或类型错误查看 stderr 是否有异常堆栈简化注册代码确保工具注册在 connect 之前完成调用工具返回参数校验错误schema 字段与模型猜测的值不一致用 Inspector 查看工具 schema 详情给参数增加describe必要时用z.enum限定取值工具调用超时工具回调里有同步阻塞操作或未返回检查 server 日志和耗时保持回调轻薄长任务异步化SDK 版本差异导致 API 不匹配安装的版本与示例代码版本不一致检查node_modules/modelcontextprotocol/sdk版本参考当前 SDK 文档调整server.tool或server.registerTool的用法6.3 MCP 返回内容展示异常或解析失败问题现象常见原因检查方式处理建议模型返回“无法读取数据”返回文本不是合法 JSON在 Inspector 中查看原始响应使用JSON.stringify稳定输出中文显示乱码写入数据时编码不一致检查存储层的字符编码统一使用 UTF-8写入时显式设置编码模型误解了返回内容返回字段缺少语义说明让工具返回值包含简短字段名和常量值在工具描述里写明字段含义返回值保持精简6.4 三个最容易踩的坑第一个坑是使用console.log打印调试信息。现象是本地直接运行 server 正常但一接入 Claude Desktop 或 Inspector 就连接失败。原因是 stdout 被协议占用。解决办法是调试日志都用console.error或者接入专门的日志库输出到文件。第二个坑是只验证 server 能启动不验证工具调用。很多 MCP 示例项目能启动但工具注册、参数 schema 转换、返回格式可能一直有问题。单独启动 server 无法发现这些问题必须通过 Inspector 或真实客户端手动调用一次工具才能确认。这也是为什么本文在第 5 章强调端到端验证流程。第三个坑是内存存储造成“数据丢”的错觉。进程重启后数据消失不是代码 bug而是数据层没有持久化。在 demo 阶段可以接受但如果准备接入真实销售数据要尽早把数据层从内存 Map 换成 SQLite 或 PostgreSQL否则审计、并发和恢复都没法做。7. 从演示走向生产Salestrics 类项目的落地建议7.1 把内存存储替换为 SQLite / PostgreSQL内存 Map 只适合本地验证。生产环境至少需要持久化数据不能因为进程重启而丢失。事务商机阶段变更和活动日志写入要能被回滚。