一、先说背景:MCP 到底在解决什么问题
如果你还没接触过 MCP,简单说------它是 Anthropic 提出的一套开放协议,目的是让大模型能以标准化的方式调用外部工具和数据源。你可以把它理解为"AI 时代的 USB-C 接口":大模型是主机,MCP Server 是外设,协议统一了两者之间的通信方式。
一个 MCP Server 对外暴露的核心概念就三个:
- Tools:可被模型调用的操作(比如"创建工单"、"查询统计数据")
- Resources:可被模型读取的数据(比如"项目列表"、"用户配置")
- Prompts:预定义的提示词模板(引导模型更好地使用上述能力)
看起来不难,但实践的时候就有很多问题了。。。
二、裸写 MCP Server,痛点在哪
最开始我的做法很直接------拿到 MCP SDK,直接在 handler 里把业务逻辑写了:
go
func handleCreateOrder(params map[string]interface{}) (*mcp.Result, error) {
title := params["title"].(string)
workspaceID := params["workspace_id"].(string)
// 校验工作空间是否存在
var ws Workspace
err := db.QueryRowContext(ctx, "SELECT id, status FROM workspaces WHERE id = $1", workspaceID).Scan(&ws.ID, &ws.Status)
if err != nil {
return nil, err
}
// 校验标题规范
if !isValidTitle(title) {
return nil, fmt.Errorf("title invalid")
}
// 创建工单
_, err = db.ExecContext(ctx,
"INSERT INTO orders (title, workspace_id, status, created_at) VALUES ($1, $2, $3, $4)",
title, workspaceID, "open", time.Now(),
)
if err != nil {
return nil, err
}
// 发消息通知下游
mq.Produce("order.created", title)
return &mcp.Result{Content: "Order created"}, nil
}
一个 Tool 的 handler 写成这样,看着还行。但当 Tool 数量膨胀到 20 多个,问题就暴露了:
1. Tool 之间的业务逻辑大量重复。 比如"创建工单"和"批量导入工单"都需要校验空间状态、检查配额、发通知,这些逻辑散落在不同的 handler 里,改一处漏一处。
2. 协议层和业务层耦合严重。 MCP 的参数解析、结果封装和业务规则搅在一起。想把同一套业务逻辑同时暴露给 HTTP API 和 MCP?基本等于重写。
3. 测试极其困难。 想单独测试"工单创建"的业务规则,必须 mock 掉 MCP SDK、数据库、消息队列......一个单测写下来比业务代码还长。
4. 新人接手成本高。 每个 handler 都是一个"大泥球",不深入读完全部代码,根本不知道这个 Tool 到底做了几件事。
以上都是"事务脚本"(Transaction Script)模式的典型症状。
三、DDD 的哪些概念刚好解决这些问题
聊 DDD 之前先声明一下立场:我不觉得所有项目都该上 DDD。但 MCP Server 的特点和 DDD 中几个核心概念比较契合。
3.1 限界上下文(Bounded Context)≈ MCP 的 Tool 分组
一个有一定规模的 MCP Server,对外暴露的 Tool 不可能只属于一个业务域。比如一个工单管理系统,至少涉及:
- 工单域:创建/查询/流转工单
- 评审域:提交评审、审批处理
- 统计域:查看进度、导出报表
- 组织域:管理团队、权限控制
如果不划分边界,所有 Tool 的 handler 挤在一个包里,互相引用对方的数据结构和数据库查询,最后就是一锅粥。
限界上下文的价值就在于:明确每个域自己管自己的模型和规则,域与域之间通过定义好的接口交互。映射到代码结构:
csharp
mcp-server/
├── domain/
│ ├── order/ # 工单域
│ │ ├── entity.go # Order, OrderStatus 等实体
│ │ ├── repository.go # 持久化接口(只定义,不实现)
│ │ ├── service.go # 域服务:分配算法、状态流转规则
│ │ └── event.go # 域事件:OrderCreated, OrderAssigned
│ ├── review/ # 评审域
│ │ ├── entity.go
│ │ ├── repository.go
│ │ └── service.go
│ └── statistic/ # 统计域
│ ├── entity.go
│ ├── repository.go
│ └── service.go
├── application/ # 应用层:编排域服务,对应每个 Tool 的用例
│ ├── order_usecase.go
│ ├── review_usecase.go
│ └── statistic_usecase.go
├── infrastructure/ # 基础设施:数据库、消息队列、外部 API 的具体实现
│ ├── postgres/
│ ├── mq/
│ └── mcp/ # MCP 协议适配层
│ ├── server.go
│ └── tools.go
└── main.go
3.2 实体和值对象:让业务规则有处安放
MCP Server 开发中一个特别容易犯的错误是:把业务规则写在协议层。
比如"工单状态只能从 open 到 in_progress,不能从 closed 跳回 open"------这个规则应该在哪?如果写在 Tool handler 里,那 HTTP API 也要抄一份;如果写在数据库触发器里,那调试起来要命。
DDD 的做法是把这类规则封装在实体内部:
go
type OrderStatus string
const (
OrderOpen OrderStatus = "open"
OrderInProgress OrderStatus = "in_progress"
OrderResolved OrderStatus = "resolved"
OrderClosed OrderStatus = "closed"
)
type Order struct {
ID string
Title string
WorkspaceID string
Status OrderStatus
AssigneeID string
CreatedAt time.Time
}
var validTransitions = map[OrderStatus][]OrderStatus{
OrderOpen: {OrderInProgress, OrderClosed},
OrderInProgress: {OrderResolved, OrderOpen},
OrderResolved: {OrderClosed, OrderOpen},
OrderClosed: {},
}
func (o *Order) TransitTo(target OrderStatus) error {
allowed := validTransitions[o.Status]
for _, s := range allowed {
if s == target {
o.Status = target
return nil
}
}
return fmt.Errorf("cannot transit from %s to %s", o.Status, target)
}
这样一来,不管是 MCP Tool 还是 HTTP Handler 还是消息消费者,只要操作 Order 实体,状态流转规则就自动生效。规则跟着模型走,而不是跟着入口走。
3.3 应用服务:一个 Tool 对应一个 Use Case
DDD 中应用层的职责很明确:编排领域对象完成一个完整的业务用例。不多不少,刚好对应 MCP 的一个 Tool。
go
type OrderUseCase struct {
orderRepo order.Repository
workspaceRepo workspace.Repository
eventBus event.Bus
}
func (uc *OrderUseCase) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (*order.Order, error) {
ws, err := uc.workspaceRepo.Get(ctx, cmd.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("workspace not found: %w", err)
}
if !ws.IsActive() {
return nil, fmt.Errorf("workspace %s is not active", ws.ID)
}
o := order.New(cmd.Title, ws.ID)
if err := uc.orderRepo.Save(ctx, o); err != nil {
return nil, err
}
uc.eventBus.Publish(ctx, order.CreatedEvent{OrderID: o.ID})
return o, nil
}
而 MCP 的 Tool handler 退化成一个薄薄的适配层:
go
func (s *MCPServer) registerCreateOrderTool() {
s.server.AddTool(mcp.Tool{
Name: "create_order",
Description: "在指定工作空间下创建一个工单",
InputSchema: createOrderSchema,
}, func(ctx context.Context, params map[string]interface{}) (*mcp.Result, error) {
cmd := CreateOrderCommand{
Title: params["title"].(string),
WorkspaceID: params["workspace_id"].(string),
}
o, err := s.orderUseCase.CreateOrder(ctx, cmd)
if err != nil {
return mcp.ErrorResult(err.Error()), nil
}
return mcp.TextResult(fmt.Sprintf("工单 %s 已创建,ID: %s", o.Title, o.ID)), nil
})
}
这个结构的好处是:Tool handler 只负责参数解析和结果格式化,业务逻辑完全在应用层,应用层又依赖领域层的规则。 三层各司其职,改任何一层都不会牵连其他层。
3.4 Repository 模式:基础设施可替换
MCP Server 有一个很现实的需求:同一套业务逻辑,可能需要跑在不同的基础设施上。开发环境用 SQLite,测试环境用 PostgreSQL,生产环境可能还要加一层缓存。
Repository 接口定义在领域层:
go
// domain/order/repository.go
type Repository interface {
Get(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
ListByWorkspace(ctx context.Context, wsID string, opts ListOptions) ([]*Order, error)
}
具体实现在基础设施层:
go
// infrastructure/postgres/order_repo.go
type OrderRepo struct {
db *sql.DB
}
func (r *OrderRepo) Get(ctx context.Context, id string) (*order.Order, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, title, workspace_id, status, assignee_id, created_at FROM orders WHERE id = $1", id)
var doc orderRow
if err := row.Scan(&doc.ID, &doc.Title, &doc.WorkspaceID, &doc.Status, &doc.AssigneeID, &doc.CreatedAt); err != nil {
return nil, err
}
return doc.toEntity(), nil
}
对 MCP Server 来说,这意味着你可以非常容易地写集成测试------用一个内存实现的 Repository 替换掉真实数据库,测试跑得飞快,又不用起任何外部依赖。
四、MCP 协议的特性反过来也在"呼唤" DDD
前面说的是 DDD 如何解决 MCP Server 开发的痛点,反过来看,MCP 协议本身的设计也在"暗示"你应该用 DDD 的方式来组织代码。
4.1 Tool 的 Description 就是一份"统一语言"
DDD 强调团队要建立统一语言(Ubiquitous Language)。而 MCP 的 Tool 定义天然要求你用清晰、无歧义的自然语言描述每个操作------因为大模型要靠这些描述来理解该在什么场景下调用这个 Tool。
go
mcp.Tool{
Name: "assign_order",
Description: "将一个待分配的工单指派给指定处理人。工单必须处于 open 状态,且处理人必须属于该工作空间的团队成员。",
}
当你认真地写这段描述时,你其实已经在做领域建模了------定义实体、明确前置条件、约束业务规则。如果你的代码结构也按同样的语言来组织,Tool 描述和代码实现之间就能保持一致,维护成本大幅降低。
4.2 Resource 和 Domain Entity 天然映射
MCP 的 Resource 用 URI 标识,比如 order://workspace-123/orders/order-456。这和 DDD 中的聚合根(Aggregate Root)思路非常像:每个 Resource 对应一个有唯一标识的领域实体,通过聚合根来访问。
go
s.server.AddResource(mcp.Resource{
URI: "order://workspaces/{workspace_id}/orders/{order_id}",
Name: "工单详情",
Description: "获取指定工作空间下某个工单的完整信息",
MimeType: "application/json",
}, func(ctx context.Context, uri string) (*mcp.ResourceContent, error) {
wsID, orderID := parseOrderURI(uri)
o, err := s.orderUseCase.GetOrder(ctx, wsID, orderID)
if err != nil {
return nil, err
}
return mcp.JSONContent(o), nil
})
4.3 多 Server 协作 ≈ 上下文映射
真实场景中,一个 AI Agent 可能同时连接多个 MCP Server。比如一个自动化运维流程,可能涉及:
- 监控系统 MCP Server(查询告警、拉取指标)
- 工单系统 MCP Server(创建工单、流转处理)
- 发布系统 MCP Server(触发回滚、灰度发布)
每个 Server 有自己的领域模型,Server 之间需要协调。这和 DDD 中的上下文映射(Context Map)类似,你需要定义清楚各个 Server 之间的关系------是共享内核、是防腐层、还是发布/订阅。
五、实践中的取舍
说了这么多好处,也聊聊实际的。
也不是所有 MCP Server 都需要 DDD。 如果你的 Server 就暴露三五个 Tool,业务逻辑很薄,那直接写 handler 反而更高效。引入 DDD 的分层会增加代码量和认知成本,收益不一定覆盖成本。
DDD 的分层不必一步到位。 我的建议是先按"协议层 - 应用层 - 基础设施层"三层来拆,领域层的建模可以随着业务复杂度的增长逐步引入。很多时候你一开始不知道边界在哪,做着做着才能看清楚。
警惕过度设计。 不要为了"符合 DDD"而硬拆。如果一个概念在你的业务里就是个简单的 CRUD,没有复杂的状态流转和业务规则,那就老老实实用贫血模型。把精力留给真正复杂的核心域。
六、思考和总结
回到标题的问题:DDD 架构为什么适合 MCP Server 开发?
总结下来三点:
- MCP 的 Tool/Resource/Prompt 三件套,天然对应 DDD 的应用服务、领域实体和统一语言。 两者的思维模型高度一致。
- MCP Server 通常作为已有业务系统的"AI 门面"存在,需要复用而非重写业务逻辑。 DDD 的分层架构让业务规则独立于协议层,可以被多个入口(HTTP、gRPC、MCP)共享。
- MCP 生态鼓励多 Server 协作,这要求每个 Server 有清晰的领域边界和明确的对外契约。 这正是 DDD 限界上下文和上下文映射要解决的问题。
当然,架构永远是手段不是目的。选择 DDD 不是因为它看着"高级",而是因为在特定场景下,它确实能帮你写出更容易维护、更容易测试、更容易演进的代码。