45. MCP 服务接入
一句话定位:Admin.NET 通过 Nabu.Mcp.AspNetCore 把已有的动态 API 零改造变成 MCP 工具,让 Cursor / Claude Desktop / 自研 Agent 直接调用业务接口;配套一个 AdminNetMcpFurionRouteCompat 中间件专门解决 Furion 动态 API 路由重放问题。
一、注册
// Admin.NET.Web.Core/Startup.cs
// 注册 MCP 服务
services.AddNabuMcp(options =>
{
options.ServerName = "Admin.NET.MCP";
options.RequireAuthorization = true;
options.ToolVisibility = McpToolVisibility.Authorized;
options.ToolNameFactory = (context) =>
{
string controller = char.ToLowerInvariant(context.ControllerName[0]) + context.ControllerName[1..];
string action = char.ToLowerInvariant(context.ActionName[0]) + context.ActionName[1..];
return controller + "_" + action;
};
});
services.AddAdminNetMcpFurionRouteCompat();| 配置项 | 值 | 含义 |
|---|---|---|
ServerName | Admin.NET.MCP | MCP Server 名称,客户端配置里要用它 |
RequireAuthorization | true | 每个工具调用都要过 ASP.NET Core 授权管线 |
ToolVisibility | McpToolVisibility.Authorized | 只有当前用户有权限的工具才对客户端可见 |
ToolNameFactory | controller_action | 工具名生成规则(见下) |
二、工具名生成规则
options.ToolNameFactory = (context) =>
{
string controller = char.ToLowerInvariant(context.ControllerName[0]) + context.ControllerName[1..];
string action = char.ToLowerInvariant(context.ActionName[0]) + context.ActionName[1..];
return controller + "_" + action;
};规则很简单:控制器名首字母小写 + 方法名首字母小写,中间用下划线连接。注意它只改首字母,不做全小写/驼峰切分,所以:
| 控制器 | Action | 生成的工具名 | 对应 HTTP 路由 |
|---|---|---|---|
SysUser | Page | sysUser_page | /api/sysUser/page |
SysUserService | AddUser | sysUser_addUser | /api/sysUser/addUser(Furion 会裁掉 Service 后缀) |
SysConfig | List | sysConfig_list | /api/sysConfig/list |
BizOrder | Submit | bizOrder_submit | /api/bizOrder/submit |
实际路由前缀受
App.json的DynamicApiControllerSettings控制(AsLowerCamelCase = true),工具名里的controller/action 取自 ActionDescriptor 的 ControllerName/ActionName(去掉了Service后缀,因为在 Furion 动态 API 里 controller 名已被规范化)。建议上线前用/kapi与 MCP 客户端两边对照确认。
三、Furion 路由兼容中间件原理
这是 Admin.NET 里最有价值的一段自研代码,文件:Admin.NET.Web.Core/Mcp/AdminNetMcpFurionRouteCompatMiddleware.cs。
3.1 问题背景
Nabu 执行 tools/call 时不是发真实 HTTP 请求,而是在自己捕获的 ASP.NET Core 管线委托上重放一个模拟请求。而 Furion 动态 API 是运行时通过 ApplicationPart 注入的,IStartupFilter 的插入顺序会让 Nabu 抓到的只是一个 空尾巴管线(没有路由、没有端点),于是:
Nabu 捕获到的管线 :[ Nabu Middleware ] → (空)
真实 HTTP 请求管线 :[ Furion UseStartups ] → [ Routing ] → [ Endpoints(动态API) ] → Response
结果:工具调用 4043.2 两层修补
第一层:捕获完整管线(AdminNetMcpFullPipelineCaptureFilter)
internal sealed class AdminNetMcpFullPipelineCaptureFilter : IStartupFilter
{
private static readonly FieldInfo PipelineField = typeof(McpPipelineProvider)
.GetField("_pipeline", BindingFlags.Instance | BindingFlags.NonPublic);
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(nextPipeline =>
{
var provider = app.ApplicationServices.GetService<McpPipelineProvider>();
if (provider != null && PipelineField != null && nextPipeline != null)
PipelineField.SetValue(provider, nextPipeline); // 反射写回完整管线
return nextPipeline;
});
next(app);
};
}
}
// 注册到集合最前面
services.Insert(0, ServiceDescriptor.Transient<IStartupFilter, AdminNetMcpFullPipelineCaptureFilter>());第二层:重写请求路径(UseAdminNetMcpFurionRouteCompat)
public static IApplicationBuilder UseAdminNetMcpFurionRouteCompat(this IApplicationBuilder app)
=> app.Use(static (context, next) => { Rewrite(context); return next(); });
private static void Rewrite(HttpContext context)
{
// ① 只处理 Nabu 的模拟请求
if (!context.Items.TryGetValue(McpConstants.ToolInvocationItemKey, out var toolNameObj)
|| toolNameObj is not string toolName || string.IsNullOrWhiteSpace(toolName)) return;
var registry = context.RequestServices.GetService<IMcpToolRegistry>();
if (registry == null || !registry.TryGetTool(toolName, out var tool) || tool?.Action == null) return;
// ② 只处理 Furion 动态 API 控制器
if (!typeof(IDynamicApiController).IsAssignableFrom(tool.Action.ControllerTypeInfo)) return;
var currentPath = context.Request.Path.Value ?? "/";
var rewritten = BuildFurionPath(tool, currentPath);
if (string.IsNullOrWhiteSpace(rewritten)) return;
ApplyPath(context, rewritten); // 同时写 Request.Path 与 IHttpRequestFeature.Path
}两条关键分支(BuildFurionPath):
// 显式绝对路由(如 [Route("api/mcp/admin")] + [HttpGet("zodiac")])已对齐真实 Endpoint,
// 不能再按约定插入 controller,否则会变成 /api/adminMcp/mcp/admin/zodiac 导致 404。
if (IsAbsoluteAttributeRoute(attrTemplate))
return CompactSlashes(path.StartsWith('/') ? path : "/" + path);
return EnsureFurionPrefix(path, tool); // 补齐 /api 前缀 + controller 段辅助逻辑速记:
| 方法 | 作用 |
|---|---|
ReplaceMvcTokens | 把 [controller]/[action]/[area] 替换成实际值 |
ExtractRouteValues | 用 Nabu 模板与当前路径从后往前对齐,提取路由参数值 |
SubstituteRouteTokens | 把 {xxx} 占位符替换成提取到的值 |
EnsureFurionPrefix(prefix, controller) | 补 /api 与 controller 段(取 DefaultRoutePrefix,默认 api) |
CompactSlashes | 合并多余斜杠、保证以 / 开头 |
ApplyPath | 同时写 Request.Path 和 IHttpRequestFeature.Path |
流程图:
tools/call ToolName=sysUser_page
│
├─ items[ToolInvocationItemKey] 存在?否 → 忽略(普通 HTTP 请求)
├─ IMcpToolRegistry.TryGetTool → 否 → 记 warning 日志并跳过
├─ ControllerTypeInfo 是 IDynamicApiController?否 → 忽略(普通 MVC 控制器)
├─ BuildFurionPath()
│ ├─ 特性路由是绝对路径 → 原样返回
│ └─ 否则 EnsureFurionPrefix → /api/sysUser/page
└─ ApplyPath() → 继续走后续管线 → Furion 动态 API 正常命中 → 200中间件位置很重要:必须在
UseRouting()之前。日志标识:Admin.NET MCP Furion route compat {Tool}: {From} -> {To}。
四、鉴权 RequireAuthorization
三个开关的组合效果:
RequireAuthorization = true
└─► 每次工具调用都在模拟请求上跑一遍 ASP.NET Core 授权
└─► JwtHandler.HandleAsync(Token 有效/黑名单/租户/TokenVersion)
└─► JwtHandler.CheckAuthorizeAsync(权限码白黑名单比对,见第 14 篇)
├─ 通过 → 执行工具
└─ 403 → 工具调用失败
ToolVisibility = McpToolVisibility.Authorized
└─► tools/list 时按当前用户权限过滤,无权工具直接不出现在列表里结论:MCP 工具完全复用现有 RBAC。 给某个角色看不到 sysUser_page,那个角色的 token 连 MCP 工具列表里都不会出现它。
客户端必须携带合法 Token。常见做法:
// MCP Server 配置(客户端侧)
{
"mcpServers": {
"adminnet": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:5005/mcp"],
"env": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}
}实际请求头注入方式取决于客户端;
mcp-remote支持--header "Authorization: Bearer ${TOKEN}"形式。Bearer Token 从POST /api/sysAuth/login拿(带X-API-KEY/签名/OpenAPI 身份也可,见第 22、23 篇相关章节)。
五、客户端接入示例
Cursor / Claude Desktop
// ~/.cursor/mcp.json 或 claude_desktop_config.json
{
"mcpServers": {
"adminnet": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://127.0.0.1:5005/mcp",
"--header", "Authorization: Bearer ${ADMINNET_TOKEN}"
],
"env": { "ADMINNET_TOKEN": "eyJhbGciOiJIUzI1NiIs..." }
}
}
}Python SDK(自研 Agent)
import asyncio, os
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main():
token = os.environ["ADMINNET_TOKEN"]
headers = {"Authorization": f"Bearer {token}"}
async with streamablehttp_client("http://127.0.0.1:5005/mcp", headers=headers) as (r, w, _):
async with ClientSession(r, w) as session:
await session.initialize()
tools = await session.list_tools()
print("可用工具:", [t.name for t in tools.tools][:20])
res = await session.call_tool("sysUser_page", {
"Page": 1, "PageSize": 5, "Account": "admin"
})
print(res.content[0].text[:500])
asyncio.run(main())C# / Semantic Kernel 风格
var transport = new SseClientTransport(
new SseClientTransportOptions
{
Endpoint = new Uri("http://127.0.0.1:5005/mcp/sse"),
AdditionalHeaders = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token}"
}
});
var client = await McpClientFactory.CreateAsync(transport);
var tools = await client.ListToolsAsync();六、把自己的接口做成好用的 MCP 工具
MCP 工具依赖 Swagger/Action 元信息,因此:
[ApiDescriptionSettings(Order = 200, Description = "用户管理")]
public class SysUserService : IDynamicApiController, IScoped
{
/// <summary>
/// 获取用户分页列表 🔖
/// </summary>
/// <param name="input">查询条件(Page、PageSize、Account、RealName...)</param>
/// <returns>用户分页数据</returns>
[DisplayName("获取用户分页列表")]
public async Task<SqlSugarPagedList<UserOutput>> Page(PageUserInput input)
{
// ...
}
}要点:
[DisplayName]与 XML 注释要写:/// <summary>会成为工具的description,LLM 靠它决定调不调- 参数用单个 Input DTO:比多个简单类型参数更容易被模型正确填充
- 返回值别太大:工具返回值会进上下文窗口,分页类接口务必
PageSize保守(建议在 DTO 上加[Range(1, 50)]) - 危险操作要显式加安全策略:
@tag:destructive之类的约定由客户端实现;服务端侧最少要保证RequireAuthorization打开
七、步骤清单
- 确认
Startup.cs里已注册services.AddNabuMcp(...)与services.AddAdminNetMcpFurionRouteCompat() - 确认中间件在
UseRouting()之前 - 用
/api/sysAuth/login拿 AccessToken(或用 ApiKey/签名方式) - 客户端
mcp.json里配--header "Authorization: Bearer {token}" tools/list验证工具清单;缺工具就检查该账号是否有对应按钮权限tools/call sysUser_page {"Page":1,"PageSize":5}验证通路- 打日志:看到
Admin.NET MCP Furion route compat xxx: /xxx -> /api/xxx即为路径重写生效
⚠️ 注意事项 / 常见坑
ToolVisibility = Authorized只影响"看得见与否":列表里没有不等于不能调用,真实拦截永远在服务端CheckAuthorizeAsync。自己写客户端别被列表骗了,没有权限的工具手工 call 仍会 403。- 中间件顺序:
UseAdminNetMcpFurionRouteCompat()必须在UseRouting()之前,否则路径改了也没用。 - 绝对路径路由别重复插 controller:源码里已有专门判断
IsAbsoluteAttributeRoute,但你自己写[Route("api/xxx")]时仍建议先 curl 确认最终路径。 ChannelPrefix/Activity: Nabu 用McpConstants.ToolInvocationItemKey标识模拟请求;如果你二开了中间件,不要清理context.Items。- 工具名冲突:多个 controller 下同名 action 会生成不同工具名(前缀不同),但如果 controller 名被 Furion 规范化成一样就会冲突,届时调整
[ApiDescriptionSettings(Name="...")]。 - Token 过期问题:MCP 是长连接,JWT 到期后调用会 401。客户端要做到:检测 401 → 重新登录 → 更新 header → 重试。
- 别把破坏性接口无保护地暴露:删除、批量更新类接口即使有 RBAC,LLM 也可能误调。建议这类接口的菜单权限只给极少数角色。
- 匿名接口不等于对 MCP 可见:
[AllowAnonymous]的接口虽然能被调用,但若同时是ToolVisibility.Authorized,不在列表里。 - 返回值序列化:走的是和 WebAPI 相同的 JSON 选项(
AddLongConverters(overMaxLengthOf17: true)等),long主键会输出成字符串,客户端解析要兼容。 - 本地 vs 生产:
InjectSpecificationDocument在生产默认关闭,但 MCP 不依赖 Swagger UI,仍然可用;别把它当成 Swagger 一同下线。 - 性能:工具调用 = 一次完整的 ASP.NET Core 管线执行(含 Authorization、DbConnection),别把它当成廉价 RPC,批量操作应封装成单个工具而不是循环调用十个。
小结
services.AddNabuMcp(...)+services.AddAdminNetMcpFurionRouteCompat()两步开启,RequireAuthorization = true复用现有 JWT + RBAC- 工具名规则:
controller_action,仅首字母小写、中线连接下划线 AdminNetMcpFurionRouteCompat做两件事:IStartupFilter反射回写完整管线 + 中间件把模拟路径重写成 Furion 真实路由- 中间件必须在
UseRouting()之前;日志Admin.NET MCP Furion route compat Tool: From -> To是排障第一线索 - 客户端把 Bearer Token 放
Authorization头,tools/list只返回有权限的工具