很多 MCP 实践文章会告诉你:把业务接口封装成 MCP Tool 就可以了。
但在真实项目里,决定 MCP 是否好用的,往往不是"能不能封装",而是下面这些细节:
- Tool 的
name / description / input_schema是怎么生成的 - 参数没有描述会不会影响模型调用
- 为什么不建议 Tool 直接走 Controller
- Python、TypeScript、Java 在实现方式上有什么差异
这篇文章重点讨论这些容易被忽略、但会直接影响 MCP 落地质量的问题。
一、核心原则:MCP Tool 是给模型设计的 API
业务接口不应该直接暴露给模型,而是应该通过 MCP Server 做一层"可控适配"。
这一层负责:
text
参数校验
鉴权
限流
数据格式转换
敏感字段脱敏
异常转换
日志审计
最终暴露给模型的不是原始接口,而是一个清晰的 Tool:
json
{
"name": "query_order",
"description": "根据订单 ID 查询订单详情",
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单唯一标识"
}
},
"required": ["order_id"]
}
}
也就是说:
MCP Tool 不是传统 API 的简单转发,而是面向模型重新设计的业务能力。
二、Tool 三要素到底从哪里来?
一个 MCP Tool 通常包含三个关键元素:
text
name 工具名
description 工具用途说明
input_schema 输入参数结构
不同语言的 MCP SDK 获取这三要素的方式不同。
1. Python:从函数自动推断
Python 里通常通过 @mcp.tool() 注册工具:
python
from typing import Annotated
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("order-mcp-server")
@mcp.tool()
async def query_order(
order_id: Annotated[str, "订单唯一标识"]
) -> dict:
"""根据订单 ID 查询订单详情"""
return {
"order_id": order_id,
"status": "PAID",
"amount": 199.0
}
生成逻辑大致是:
text
函数名 → name
docstring → description
类型注解 → input_schema
Annotated → 参数描述
如果不写 Annotated 或参数说明,schema 里可能只有类型,没有语义描述。
2. TypeScript:显式声明,最可控
TypeScript 通常通过 server.tool() 显式注册:
ts
server.tool(
"query_order",
"根据订单 ID 查询订单详情",
{
orderId: z
.string()
.describe("订单唯一标识,来自订单系统"),
},
async ({ orderId }) => {
return {
content: [
{
type: "text",
text: JSON.stringify({
orderId,
status: "PAID",
amount: 199.0,
}),
},
],
};
}
);
这里三要素都是显式给出的:
text
"query_order" → name
"根据订单 ID 查询订单详情" → description
z.string().describe(...) → input_schema
TypeScript 的好处是 schema 非常可控,尤其适合生产环境。
3. Java:注解 + 反射
在 Spring AI 中,可以通过 @Tool 暴露方法:
java
@Tool(description = "根据订单 ID 查询订单详情")
public OrderDTO queryOrder(
@Parameter(description = "订单唯一标识,来自订单系统")
String orderId
) {
return orderService.queryOrder(orderId);
}
生成逻辑一般是:
text
方法名 → name
@Tool → description
参数类型 → input_schema
@Parameter → 参数描述
Java 的优势是适合已有 Spring Boot / 微服务体系,可以直接复用 Service 层业务逻辑。
三、参数没有 description,会不会出问题?
会,而且问题通常很明显。
比如下面这个 schema:
json
{
"type": "object",
"properties": {
"order_id": {
"type": "string"
}
},
"required": ["order_id"]
}
从程序角度看,它是合法的;但从模型角度看,它的信息不够。
模型只知道:
text
order_id 是 string
但并不一定知道:
text
这是订单系统里的订单 ID
不是用户 ID
不是支付流水 ID
不是物流单号
当参数更多时,问题会更严重。
典型问题 1:参数语义不清
json
{
"id": {
"type": "string"
}
}
模型很难判断这个 id 是什么。
更好的写法:
json
{
"order_id": {
"type": "string",
"description": "订单唯一标识,来自订单系统"
}
}
典型问题 2:时间参数容易填错
不推荐:
json
{
"start": {
"type": "string"
},
"end": {
"type": "string"
}
}
推荐:
json
{
"start_time": {
"type": "string",
"description": "查询开始时间,ISO8601 格式,例如 2024-01-01T00:00:00Z"
},
"end_time": {
"type": "string",
"description": "查询结束时间,ISO8601 格式,例如 2024-01-31T23:59:59Z"
}
}
典型问题 3:枚举值不明确
不推荐:
json
{
"status": {
"type": "string"
}
}
推荐:
json
{
"status": {
"type": "string",
"enum": ["PAID", "UNPAID", "CANCELLED"],
"description": "订单状态:PAID=已支付,UNPAID=未支付,CANCELLED=已取消"
}
}
四、为什么不建议 MCP Tool 直接走 Controller?
很多人在封装时会这样写:
text
MCP Tool → HTTP API → Controller → Service
看起来是在复用现有接口,但这通常不是最佳实践。
1. Controller 是 HTTP 协议层
Controller 通常处理的是:
text
RequestBody
Header
Cookie
Session
HTTP 状态码
前端统一响应格式
例如:
json
{
"code": 0,
"msg": "ok",
"data": {
"orderId": "123",
"status": "PAID"
}
}
但模型真正需要的是:
json
{
"orderId": "123",
"status": "PAID"
}
所以 Tool 直接走 Controller,会引入多余的协议包装。
2. 可能出现"自己调用自己"的反模式
如果 MCP Server 和业务系统在同一个项目里,却这样调用:
text
Tool → HTTP 调用本机接口 → Controller → Service
就会多一跳网络调用,增加延迟和复杂度。
3. 安全上下文容易错位
Controller 往往依赖 HTTP 上下文:
text
Header
Token
Session
Cookie
Request IP
而 MCP Tool 调用时,上下文来源不同,需要单独设计用户身份、租户、权限和审计逻辑。
推荐结构
更推荐这样:
text
Controller → Service → Repository / RPC / HTTP
MCP Tool → Service → Repository / RPC / HTTP
也就是:
text
Controller 和 MCP Tool 是两个入口
Service 是统一业务逻辑
这样既能复用业务逻辑,又不会把模型调用和 HTTP 协议层强耦合。
五、Python / TypeScript / Java 的差异
| 语言 | 注册方式 | schema 控制 | 适合场景 |
|---|---|---|---|
| Python | 装饰器自动推断 | 中等 | 原型、轻量工具 |
| TypeScript | 显式声明 | 强 | 网关、中台、生产服务 |
| Java | 注解 + 反射 | 中到强 | Spring Boot、企业系统 |
简单理解:
text
Python:写起来最快
TypeScript:schema 最可控
Java:最适合复用企业业务系统
六、Tool 设计最佳实践
1. Tool 名称要表达业务动作
推荐:
text
query_order
cancel_order
create_invoice
search_customer
不推荐:
text
call_api
do_request
execute
Tool 名称应该让模型一眼知道"这个工具能做什么"。
2. 参数名要自解释
推荐:
text
order_id
customer_id
start_time
end_time
不推荐:
text
id
type
start
end
参数越模糊,模型越容易填错。
3. 每个参数都尽量写 description
特别是以下类型:
text
ID 类参数
时间类参数
枚举类参数
分页参数
金额参数
布尔参数
比如:
json
{
"include_cancelled": {
"type": "boolean",
"description": "是否包含已取消订单,true=包含,false=不包含"
}
}
4. 输出字段要裁剪
不要把业务接口原样返回给模型。
不推荐:
json
{
"userPhone": "13800000000",
"internalRemark": "...",
"riskScore": 87,
"debugInfo": "..."
}
推荐:
json
{
"orderId": "123",
"status": "PAID",
"amount": 199.0
}
原则是:
text
模型只需要完成任务所需的信息
不需要内部字段、敏感字段和调试字段
5. 参数太多时要拆 Tool
如果一个 Tool 有十几个参数,模型调用成功率通常会下降。
不推荐:
text
search_order(
order_id,
user_id,
status,
start_time,
end_time,
min_amount,
max_amount,
page,
size,
sort,
channel,
tenant_id
)
可以拆成:
text
query_order_by_id
search_orders_by_user
search_orders_by_status
七、一个实用判断标准
判断一个 Tool schema 是否合格,可以用一句话:
如果一个新同事只看这个 schema 都不知道怎么填,那模型大概率也会填错。
所以,Tool schema 不是单纯给程序看的,而是给模型"理解和决策"用的。
八、总结
业务接口封装成 MCP Tool,并不是把接口换个协议暴露出去。
更准确地说,它是在做三件事:
text
1. 把业务接口抽象成模型可理解的能力
2. 把输入输出设计成模型容易使用的结构
3. 把调用过程纳入安全、权限、审计和治理
最后可以用这句话总结:
MCP Tool 不是传统接口封装,而是给模型设计 API。
这意味着,写 MCP Tool 时,你不是只在写代码,而是在告诉模型:
text
什么时候用这个工具
需要传什么参数
返回结果应该如何理解
只有把这些细节设计好,MCP 才能从"能调用"变成"稳定、准确、可落地"。