跳到主要内容
版本:3.0.0

MCP 协议集成(Aegis.AI.Mcp)

当你希望同一份 Controller 方法既对外提供 REST API、又能让 LLM 通过 MCP 协议(Model Context Protocol)直接调用为 Tool 时,接入 Aegis.AI.Mcp。本文按 Aegis 3.x / .NET 8 口径说明组件的接入与使用方式,读完后你应该能完成组件引入、Tool 暴露(方法级或类级)、配置调整、ApiResponse 解包、XML 注释接入和连通性验证。

组件概览

字段说明
组件名称MCP 协议集成
真实类库Aegis.AI.Mcp
组件定位把 MVC Controller 上的 [McpTool] 方法/类同时暴露为 REST API 和 MCP Tool,复用 MVC 权威发现避免幽灵工具
引入方式Component.deps.json
组件声明Mcp
核心能力Controller 方法/类双协议透出、Streamable HTTP 传输、Stateless 默认模式、Tool 自动扫描与去重、ApiResponse 自动解包、XML 注释 → description、HTTP 谓词推断 annotation、命名策略可配置
是否可扩展
目标框架net8.0
注册入口src/AI/Aegis.AI.Mcp/ServiceCollectionExtensions.cs

这个组件的边界比较明确:它负责把已有的 MVC Controller 方法以 MCP Tool 形式对外暴露,并提供 /mcp HTTP 端点。它不负责重写 Controller、不接管路由、不替代 LLM 客户端代码。如果你需要把一段独立的业务逻辑(非 Controller 方法)暴露为 MCP Tool,更适合直接用官方 SDK 的 McpServerTool.Create(...) 手动注册,那属于另一路接入方案。

什么时候要用它

适合场景

  • 已有 MVC Controller 提供业务接口,希望 LLM 也能调用相同业务,避免写两份代码
  • Agent / RAG 系统需要调用 Aegis 业务方的能力(订单查询、库存更新、外部 API 透传等)
  • 团队希望保持「业务方写一遍接口,REST 客户端和 LLM 都能用」的代码组织
  • 已有大量 Controller 方法希望批量暴露,不想逐个加 [McpTool](用类级标记 + [McpToolIgnore]
  • 业务侧统一返回 ApiResponse,希望 LLM 拿到干净数据而不是包装层(开 UnwrapApiResponse
  • 需要 Stateless、纯查询或幂等写入的 Tool 暴露

不适合场景

  • 业务逻辑不在 Controller 里(在 BackgroundService、消息消费者、CLI 里)—— 适合用官方 SDK 的 McpServerTool.Create 手动注册
  • 需要长会话、sampling、elicitation 等 server→client 回调能力 —— 把 Stateless 改为 false,或评估是否走其他协议
  • 一次性脚本 / 工具 —— MCP 协议本身就是给 Agent 长期订阅用的,单次调用场景用普通 HTTP 更直接

最小可运行路径

这一节给你一条最短路径,用来确认组件已经真正生效。

第一步:安装与配置

确保你已经:

  • 安装 Aegis.AI.Mcp 包(会传递依赖 Aegis.Transfer,用于 ApiResponse 解包能力)
  • Component.deps.jsonServicesMiddlewares 中都加入 Mcp
  • (可选)在 appsettings.json 中加入 Mcp 配置节点

Component.deps.json

{
"Components": {
"Services": [
"Mcp"
],
"Middlewares": [
"Mcp"
]
}
}

Services 阶段负责扫描 Controller、注册 MCP Server;Middlewares 阶段负责调用 MapMcp(...) 挂载 HTTP 端点。两者缺一不可。

中间件顺序建议把 Mcp 放在 CorsAuthentication 之后,便于 MCP Client 跨域和鉴权链路正常工作:

{
"Components": {
"Middlewares": [
"Cors",
"Authentication",
"Mcp"
]
}
}

第二步:在 Controller 上标记 [McpTool]

[McpTool] 既可标在方法上(精确控制),也可标在类上(批量暴露)。

姿势 A:方法级标记

using Aegis.AI.Mcp;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Demo.Controllers;

[ApiController]
[Route("api/demo")]
public class DemoController : ControllerBase
{
[AllowAnonymous]
[HttpGet("greet")]
[McpTool(Name = "demo_greet", Description = "传入姓名生成问候语")]
public GreetResponse Greet(string name)
{
return new GreetResponse
{
Name = name,
Greeting = $"Hello, {name}! (from MCP + REST)"
};
}
}

姿势 B:类级标记(批量暴露)

using Aegis.AI.Mcp;
using Aegis.Core.Infrastructure.Controller;
using Aegis.Transfer.Responses;
using Microsoft.AspNetCore.Mvc;

namespace Demo.Controllers;

[McpTool(Description = "多级缓存策略示例")] // 整个类批量暴露
public class CacheController : ApiControllerBase
{
/// <summary>Redis 基础读取</summary>
[HttpGet("EasyGet")]
public Task<ApiResponse> EasyGet() { /* ... */ }

/// <summary>Redis 容错降级读取</summary>
[HttpGet("RedisDegradation")]
public Task<ApiResponse> RedisDegradation() { /* ... */ }

[McpToolIgnore] // 单方法排除
[HttpGet("internal-debug")]
public Task<ApiResponse> Debug() { /* ... */ }
}

[McpToolIgnore] // 整个类排除(覆盖类级 [McpTool])
public class LegacyController : ControllerBase { /* ... */ }

类级标记下,扫描器自动按 HTTP 谓词、返回类型、参数类型做六道过滤(详见「类级扫描的六道过滤」),不会误把 helper、文件流、void 方法暴露给 LLM。Tool description 会从 XML <summary> 自动取,无需逐个声明。

方法同时被两种协议消费:

  • REST:浏览器或 curl 访问 GET /api/demo/greet?name=xxx
  • MCP:LLM Agent 通过 MCP Client 连接 http://host/mcp,看到名为 demo_greet 的 Tool;类级批量标记时所有合格方法都会出现

第三步:启动应用并验证

启动后留意控制台的组件装配输出,正常情况应该看到类似:

MCP Server 注册成功(Stateless=True, 端点=/mcp, Server=Aegis.Mcp, 命名=SnakeCase, 注解推断=True)
共注册 N 个 MCP Tool(其中 M 个声明返回 ApiResponse,可走解包过滤)
已启用 ApiResponse 自动解包,覆盖 M 个 Tool ← 仅当 UnwrapApiResponse=true 时出现
MCP 端点注册成功: /mcp

随后用任意 MCP Client(如 Claude Desktop、Cursor、官方 inspector)连接 http://localhost:port/mcp,应能看到刚标记的 Tool。

配置项说明

组件读取 Mcp 配置节点,对应 McpOptions

节点类型默认值说明
Mcp:RoutePatternstring/mcpMCP HTTP 端点的路由模式,可改为 /api/mcp 等以匹配自身路由规范
Mcp:Statelessbooltrue是否启用 Stateless 模式;关闭时使用 stateful session,支持 sampling / elicitation 等 server→client 回调
Mcp:ServerNamestringAegis.McpMCP Server 名称,出现在 initialize 响应的 serverInfo
Mcp:ServerVersionstring入口程序集版本号MCP Server 版本;为 null 时从入口程序集 AssemblyName.Version 自动填充
Mcp:UnwrapApiResponseboolfalse是否自动解包 ApiResponse 包装并标记业务错误。默认关以保持向后兼容,业务方按需开启
Mcp:InferAnnotationsbooltrue是否按 HTTP 谓词推断 MCP annotation(readOnlyHint / destructiveHint / idempotentHint)
Mcp:NamingStrategyenumSnakeCaseTool 命名策略:SnakeCase / KebabCase / DotNotation
Mcp:XmlCommentsPathstring{CurrentDirectory}/{CurrentProject}.xmlXML 文档注释路径,与 Swagger 约定一致;文件不存在不报错
Mcp:ExtensionXmlCommentsstring[][]额外的 XML 注释路径列表(如 DTO 项目),支持通配符

完整配置示例:

{
"Mcp": {
"RoutePattern": "/mcp",
"Stateless": true,
"ServerName": "Aegis.Mcp",
"UnwrapApiResponse": true,
"InferAnnotations": true,
"NamingStrategy": "SnakeCase",
"XmlCommentsPath": "{CurrentDirectory}/{CurrentProject}.xml",
"ExtensionXmlComments": []
}
}

不写任何配置也能跑起来,全部走默认值(UnwrapApiResponse=falseInferAnnotations=trueNamingStrategy=SnakeCase)。

进阶能力

能力一:ApiResponse 自动解包

信息

开启开关:Mcp:UnwrapApiResponse=true

业务侧大量使用 ApiResponse 包装(如 SuccessResult(...) / FailedResult(...)),但 LLM 看到包装层会混淆 —— 它会以为 code / message 是业务字段。开启解包后:

场景LLM 看到什么
业务成功(code = 200/201result 字段提升到顶层,LLM 直接拿到业务数据
业务失败(其他 code)MCP 协议层 IsError=true + 业务消息(如 业务失败(code=500):xxx),LLM 直接向用户报错
ApiResponse(无 result 字段)message 文本作为响应内容
POCO / 基础类型原样透传,不受 Filter 影响

只对声明返回 ApiResponse / EntityResponse<T> / EntitiesResponse<T> 的方法生效(按声明类型精确匹配,不会误伤含 code/message 字段的第三方 DTO)。

示例:

[HttpGet("wrapped")]
[McpTool(Name = "demo_wrapped", Description = "演示 ApiResponse 透传风格")]
public ApiResponse GreetWrapped(string name)
{
return SuccessResult(new GreetResponse { Name = name, Greeting = $"Hello, {name}!" });
}
  • 关闭 UnwrapApiResponse:LLM 拿到 { code: 200, messageType: 0, message: "OK", result: { name, greeting } }
  • 开启 UnwrapApiResponse:LLM 拿到 { name, greeting },失败时收到 IsError=true

Filter 实现保证 JSON 解析异常时返回 null(原样透传),不会让成功的 Tool 调用变失败。

能力二:[FromServices] 真正可用

组件在扫描期从 IServiceCollection 的 descriptor 列表构造一个最小 IServiceProviderIsService 实现(ScanTimeServiceProbe),让 SDK 正确识别服务类型并自动剔除 [FromServices] 参数。

[McpTool(Name = "demo_echo_service", Description = "演示 [FromServices] 参数")]
public string EchoWithService([FromServices] ILogger<DemoController> logger, string message)
{
logger.LogInformation("Echo: {Message}", message);
return $"echo: {message}";
}

LLM 调用时只看到 message 参数,logger 由 DI 在运行时从 RequestContext.Services 解析。开放泛型(如 ILogger<T>)也按 GetGenericTypeDefinition 匹配。

[FromHeader][FromRoute] 参数在 MCP 协议里无意义(前者不是 LLM 该填的,后者是路径占位符),组件会在扫描阶段直接拒绝整个方法(不暴露为 Tool)并在 ComponentRegisterResult 中报告警告。这种处理方式比"暴露后调用失败"更稳,避免 LLM 看到工具却在调用时报错。

能力三:Tool 命名策略可配置

默认 snake_case,与 REST URL 风格一致。可通过 NamingStrategy 切换:

策略分隔符示例(OrderController.Create
SnakeCase(默认)_order_create
KebabCase-order-create
DotNotation.order.Create
{
"Mcp": { "NamingStrategy": "KebabCase" }
}

[McpTool(Name = "...")] 显式声明时优先采用,不走派生。方法级 Name 优先级高于类级 Name

能力四:类级 [McpTool] + [McpToolIgnore]

类级 [McpTool] 是「批量暴露该类下所有 Action 方法」的开关本身,不需要额外的全局开关。

[McpTool(Description = "订单服务")]                   // 整个类批量暴露
public class OrderController : ControllerBase { /* ... */ }

[McpToolIgnore] // 整个类排除
public class LegacyController : ControllerBase { /* ... */ }

[McpToolIgnore] 在两个层级生效:

  • 标在类上:整个类不会被扫描(即使类上同时有 [McpTool]
  • 标在方法上:在类级批量扫描时跳过该方法

类级扫描的六道过滤

类级 [McpTool] 批量扫描时,扫描器按以下顺序过滤方法,避免误把不适合 LLM 调用的方法暴露出去:

#过滤规则排除的方法示例
1[McpToolIgnore] 显式排除业务方主动排除的个别方法
2[NonAction] 标记helper 方法显式声明非 Action
3无 HTTP 谓词没有 [HttpGet]/[HttpPost] 等的 helper
4返回 IActionResult / FileResult / minimal API IResult文件流、二进制响应(MCP 是 JSON-RPC,无法承载)
5返回 void / 裸 Task / 裸 ValueTask空响应(LLM 无从处理);Task<T> 不受影响
6参数含 IFormFile / IFormFileCollection / IFormFile[]二进制文件上传(MCP 客户端通常不支持)

方法级显式 [McpTool] 只走前两道([McpToolIgnore] / [NonAction]),因为方法级标记代表业务方知情同意,允许暴露特殊签名(如返回 IActionResult 的设计选择)。

能力五:XML 注释自动加载为 Tool description

提示

零额外配置:开启 csproj 的 GenerateDocumentationFile 即可,路径与 Swagger 完全一致。不仅 <summary> 走 Tool description,<param> 也走参数 description、<returns> 作 Tool description 弱兜底。

复用 Swagger 的 {CurrentDirectory}/{CurrentProject}.xml 路径约定:

<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

description 按下面的优先级合并(取第一个非空值):

优先级来源示例
1方法级 [McpTool(Description = "...")][McpTool(Description = "按 ID 查询订单")]
2方法级 [System.ComponentModel.Description("...")][Description("按 ID 查询订单")]
3XML 注释 <summary>/// <summary>按 ID 查询订单</summary>
4类级 [McpTool(Description = "...")][McpTool(Description = "订单相关工具")]
5XML 注释 <returns>(前缀 Returns: ...,弱兜底)/// <returns>订单详情 DTO</returns>Returns: 订单详情 DTO
6自动兜底Order - GetById

业务方只写 XML <summary> 即可让 Tool description 自动生成,无需在 [McpTool] 上重复声明。类级批量标记时这是首选方式(参考 demo CacheController)。

参数级 description

除了 Tool 自身的 description,每个参数也走多源合并写入 inputSchema 的 description 字段:

优先级来源示例
1参数级 [System.ComponentModel.Description("...")][Description("订单号,大于 0")] long id
2XML 注释 <param name="...">/// <param name="id">订单号,大于 0</param>
3留空让 LLM 靠参数名推断语义

注入路径走 SDK 原生的 McpServerToolCreateOptions.SchemaCreateOptions.ParameterDescriptionProvider,业务方只要在 csproj 启用 GenerateDocumentationFile、在方法上写 <param> 注释,参数描述就会自动出现在 Tool schema 里。建议优先写 <param> 而不是 [Description] 特性 —— 前者同时服务于 Swagger / XML 文档,一份注释多处复用。

提示

<summary> / <param> / <returns> 三种节点共用同一份 XML 文档(同一个 GenerateDocumentationFile 产物),不需要额外配置路径或开关注释源。

如需把跨项目的 DTO 注释也接入,配置扩展路径(支持通配符):

{
"Mcp": {
"ExtensionXmlComments": [ "{CurrentDirectory}/*.Dto.xml" ]
}
}

文件不存在或解析失败时优雅降级为警告,不影响 Tool 注册。

自定义扩展

命名 Tool

[McpTool] 支持两个属性:

属性是否必填说明
NameTool 全局唯一名称。LLM 通过此名称调用。留空时按 Mcp:NamingStrategy 派生 {controller}{sep}{method}(剥离 Controller 后缀)
Description给 LLM 看的工具说明,建议写清楚「做什么」和「什么时候该调用」。类级标记时作为该类下所有方法的兜底描述

显式命名示例:

[McpTool(Name = "order_get_by_id", Description = "按订单 ID 查询订单详情。传入订单号返回订单实体。")]
public OrderDto GetOrder(long id) { ... }

选择返回风格

风格写法UnwrapApiResponse=false 时 LLM 看到什么UnwrapApiResponse=true 时 LLM 看到什么
POCO返回业务 DTO{ name, greeting }{ name, greeting }(不受影响)
ApiResponse 包装返回 SuccessResult(...){ code, messageType, message, result }result 字段提升到顶层
基础类型返回 int / string直接的基础类型值直接的基础类型值(不受影响)

三种风格的代码示例见 demo/Aegis.Webapi.BaseDemo/Controllers/McpDemo/McpDemoController.cs

Controller 注入依赖

Controller 方法支持 [FromServices],Controller 实例的构造函数支持注入 DI 服务。ScanTimeServiceProbe 让 SDK 在生成 schema 时正确识别服务参数并自动剔除。Controller 实例的激活策略与 MVC DefaultControllerActivator 一致:

  • 业务方调用过 AddControllersAsServices():Controller 直接从 DI 容器解析
  • 否则:走 ActivatorUtilities.CreateInstance,按构造函数参数从 DI 解析

高级用法

与鉴权集成

MCP 端点跑在 ASP.NET Core 管线上,可以正常使用 Authentication / Authorization 中间件。如果 Tool 需要登录态:

[HttpGet("me")]
[McpTool(Name = "user_get_me", Description = "获取当前登录用户信息")]
[Authorize]
public UserDto GetMe()
{
// 从 HttpContext 取用户
...
}

注意 MCP Client 是否在请求头里带上鉴权凭据,这取决于 Client 的实现。

切换到 Stateful Session

如果业务需要 sampling(让 Server 调用 Client 的 LLM)、elicitation(向 Client 用户问询)等回调能力,把 Stateless 关掉:

{
"Mcp": {
"Stateless": false
}
}

Stateful 模式下 Server 会维护会话状态,适合多轮交互的 Agent 场景。

修改路由前缀

如果业务方的 API 全部带 /api/v1 前缀,希望 MCP 端点也归入:

{
"Mcp": {
"RoutePattern": "/api/v1/mcp"
}
}

HTTP 谓词推断 annotation

InferAnnotations=true(默认)时,扫描器按 REST 语义推断 MCP annotation,帮 LLM 在多步规划时优先选只读/幂等工具:

HTTP 谓词ReadOnlyDestructiveIdempotent说明
HttpGettruefalsetrue只读、幂等,最强保证
HttpPutfalsefalsetrue可写、幂等,可安全 retry
HttpDeletefalsetruefalse破坏性、非幂等,需谨慎 retry
HttpPost / HttpPatch / 无谓词falsefalsefalse默认值
备注

SDK 默认对未声明的方法设 Destructive=true,组件改写为 false(更贴近 REST 语义),让 LLM 不会因为保守而拒绝调用 POST 接口。

接入后怎么确认生效

接入完成后,按顺序核对:

  • Component.deps.jsonServicesMiddlewares 都包含 Mcp
  • 启动日志中出现 MCP Server 注册成功MCP 端点注册成功: /mcp
  • 启动日志中 共注册 N 个 MCP Tool,N 等于预期方法数(方法级标记 + 类级批量扫描的合格方法)
  • 如果开启 UnwrapApiResponse,启动日志中出现 已启用 ApiResponse 自动解包,覆盖 M 个 Tool,M 等于声明返回 ApiResponse 的方法数
  • 如果开启 XML 注释,启动日志中出现 加载了 K 个 XML 文档文件
  • 用 MCP Client 列出 Tool 时,Tool 的 description 和每个参数的 description 都符合预期(<summary> / <param> 注释已注入)
  • curl http://localhost:port/mcp 能拿到 MCP 协议响应(不是 404)
  • 用 MCP Client 连接后,能列出预期的 Tool 列表,每个 Tool 的 description 与预期一致
  • 调用 Tool 能拿到与对应 REST 接口一致的返回;开启 UnwrapApiResponseApiResponse 已解包
  • [FromServices] 参数的方法在 schema 里没有该参数

如果 N 不对,多半是扫描阶段没找到对应方法或被过滤规则排除,检查:

  • 方法是否 public 实例方法(不支持 static
  • 是否在 Controller 类的公开方法上
  • 是否被 IsSpecialName(如属性 getter)排除
  • 类级扫描时:是否缺少 HTTP 谓词、是否返回 IActionResult / void、是否含 IFormFile 参数
  • 是否出现重名被跳过(看 ComponentRegisterResult 的 Fail 消息)

常见问题

为什么我加了 [McpTool],但 Tool 没出现?

优先检查下面几项:

  • 方法是否 public 实例方法(不支持 static
  • 所在类是否是 MVC 认可的 Controller(被 AddControllers() 扫描到)
  • 类级扫描时是否被六道过滤规则排除(缺 HTTP 谓词、返回 IActionResult、参数含 IFormFile 等)
  • 是否出现重名 Tool,后来者被静默跳过(扫描器会调 AddFail 但框架可能吞掉消息)
  • 是否依赖未注册(如 ApplicationPartManager 还没初始化,扫描器会直接返回空列表)

重名 Tool 会怎样?

扫描器按发现顺序注册,遇到重名时跳过后来者并调用 ComponentRegisterResult.AddFail 记录原因。由于 Aegis 框架可能不展示 Fail 消息,建议所有 [McpTool] 都显式指定 Name,或切换 NamingStrategy 避免碰撞(例如 UserController.DeleteUserGroupController.DeleteSnakeCase 下都会推导出 user_delete,但 KebabCase 下变成 user-delete vs user-group-delete)。

开启 UnwrapApiResponse 后,POCO 方法会被影响吗?

不会。解包按方法的声明返回类型精确匹配 —— 只有声明返回 ApiResponse / EntityResponse<T> / EntitiesResponse<T> 的方法才会被 Filter 处理,POCO 和基础类型原样透传。Filter 内部用 HashSet<string> 做 Tool 名精确查找,零误伤风险。

为什么 ApiResponse 没被自动解包?

检查清单:

  • Mcp:UnwrapApiResponse 是否为 true(默认 false
  • 方法是否声明返回 ApiResponse 或派生类型(POCO 不会被解包)
  • 启动日志是否出现 已启用 ApiResponse 自动解包(如果 UnwrapApiResponse=true 但没出现,说明没有声明返回 ApiResponse 的方法)
备注

默认关闭是为了保持行为克制,业务方按需显式开启。

为什么 [FromServices] 参数注入失败?

确认依赖是否在 DI 容器里注册过。ScanTimeServiceProbe 通过查 IServiceCollection 的 descriptor 列表回答「这个类型注册过吗」,开放泛型(如 ILogger<T>)按 GetGenericTypeDefinition 匹配。如果同一个 Controller 通过 HTTP 调用时能注入成功,那通过 MCP 调用时也能注入成功 —— 真正的服务解析仍由 SDK 走 RequestContext.Services(scoped 容器)完成。

XML 注释为什么没生效?

检查清单:

  • csproj 是否启用 <GenerateDocumentationFile>true</GenerateDocumentationFile>
  • 编译产物里是否真的有 {项目名}.xml 文件(在 bin/Debug/net8.0/ 下)
  • Mcp:XmlCommentsPath 路径是否正确(默认 {CurrentDirectory}/{CurrentProject}.xml 会替换占位符)
  • 启动日志是否出现 加载了 N 个 XML 文档文件(如果没出现,说明文件路径不对)
  • 启动日志的 加载了 N 个 XML 文档文件:A 条 summary / B 条 param / C 条 returns 反映了三类节点的解析量
  • 方法的 <summary> 是否非空;参数的 <param name="..."> 名字是否与方法参数一致

<summary> / <param> 注释里多换行/缩进会被自动压缩为单行空格分隔,不会污染 description。

类级 [McpTool] 暴露的方法比预期少?

类级批量扫描时会自动应用六道过滤(见「类级扫描的六道过滤」),检查被漏掉的方法:

  • 是否有 [HttpGet]/[HttpPost] 等谓词
  • 是否返回 IActionResult / FileResult / minimal API IResult(被排除)
  • 是否返回 void 或裸 Task(被排除,Task<T> 不受影响)
  • 是否含 IFormFile 参数(被排除)

方法级显式 [McpTool] 只走前两道过滤([McpToolIgnore] / [NonAction]),允许暴露特殊签名。

配套阅读