feat(mcp): 新增 MCP 服务基础设施,重构规则文件序号,新增 v1.3.0 工单文档
- 规则重组:全局/ 下 8 个规则合并为 6 个(01+02→01,05+06→04),序号顺延 - 新增项目规则 05-多入口功能同步规范(UI/语音入口覆盖检查) - 新增 MCP 服务基础设施:Mcp/ 目录(DI 注册、端点扩展、动态工具描述符)、单元测试 - v1.3.0 工单文档:03 系列(会议任务拆分)、04(富文本描述与附件管理) - MCP 接口与前端集成指南:docs/manual/08、09
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Server;
|
||||
using Hua.Todo.Application.Interfaces;
|
||||
using Hua.Todo.Application.Mcp;
|
||||
using Hua.Todo.Application.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Hua.Todo.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DynamicMcpToolExtensions 单元测试。
|
||||
/// 重点验证命名推导、描述生成、行为注解等纯逻辑,以及动态工具注册。
|
||||
/// </summary>
|
||||
public class DynamicMcpToolExtensionsTests
|
||||
{
|
||||
#region 命名推导
|
||||
|
||||
/// <summary>
|
||||
/// DeriveServiceName:ITaskService → task
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DeriveServiceName_ITaskService_ReturnsTask()
|
||||
{
|
||||
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
|
||||
Assert.Equal("task", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeriveServiceName:复合接口名转 snake_case。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DeriveServiceName_MultiWordInterface_ReturnsSnakeCase()
|
||||
{
|
||||
// 假设存在 ICloudSyncService → cloud_sync
|
||||
// 用动态类型模拟
|
||||
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITestMultiWordService));
|
||||
Assert.Equal("test_multi_word", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ToSnakeCase:PascalCase → snake_case
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToSnakeCase_PascalCase_ConvertsToSnakeCase()
|
||||
{
|
||||
Assert.Equal("get_all", DynamicMcpToolExtensions.ToSnakeCase("GetAll"));
|
||||
Assert.Equal("create_task", DynamicMcpToolExtensions.ToSnakeCase("CreateTask"));
|
||||
Assert.Equal("toggle_complete", DynamicMcpToolExtensions.ToSnakeCase("ToggleComplete"));
|
||||
Assert.Equal("id", DynamicMcpToolExtensions.ToSnakeCase("Id"));
|
||||
Assert.Equal("", DynamicMcpToolExtensions.ToSnakeCase(""));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 工具名组合
|
||||
|
||||
/// <summary>
|
||||
/// 完整工具名:prefix + 方法名 = task_get_all_tasks
|
||||
/// 验证 StripAsyncSuffix 和 ToSnakeCase 的组合效果。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToolNaming_PrefixAndMethod_FormatsCorrectly()
|
||||
{
|
||||
var prefix = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
|
||||
Assert.Equal("task", prefix);
|
||||
|
||||
// GetActiveTasksAsync → get_active_tasks
|
||||
var methodSuffix = DynamicMcpToolExtensions.ToSnakeCase(
|
||||
StripAsyncSuffix("GetActiveTasksAsync"));
|
||||
Assert.Equal("get_active_tasks", methodSuffix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StripAsyncSuffix:去掉 Async 后缀。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StripAsyncSuffix_RemovesAsync()
|
||||
{
|
||||
Assert.Equal("GetAllTasks", StripAsyncSuffix("GetAllTasksAsync"));
|
||||
Assert.Equal("Create", StripAsyncSuffix("CreateAsync"));
|
||||
Assert.Equal("Toggle", StripAsyncSuffix("ToggleAsync"));
|
||||
Assert.Equal("Delete", StripAsyncSuffix("DeleteAsync"));
|
||||
Assert.Equal("GetById", StripAsyncSuffix("GetById")); // 无 Async 后缀不修改
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 描述生成
|
||||
|
||||
/// <summary>
|
||||
/// GetMethodDescription:方法名 → 中文描述
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetMethodDescription_GeneratesChineseDescription()
|
||||
{
|
||||
var getMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.GetAllTasksAsync))!;
|
||||
var createMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.CreateTaskAsync))!;
|
||||
var updateMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.UpdateTaskAsync))!;
|
||||
var deleteMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.DeleteTaskAsync))!;
|
||||
var toggleMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.ToggleCompleteAsync))!;
|
||||
|
||||
Assert.StartsWith("获取", GetMethodDescription(getMethod));
|
||||
Assert.StartsWith("创建", GetMethodDescription(createMethod));
|
||||
Assert.StartsWith("更新", GetMethodDescription(updateMethod));
|
||||
Assert.StartsWith("删除", GetMethodDescription(deleteMethod));
|
||||
Assert.StartsWith("切换", GetMethodDescription(toggleMethod));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GetMethodDescription:带 DescriptionAttribute 时优先使用。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetMethodDescription_UsesDescriptionAttribute()
|
||||
{
|
||||
var method = typeof(ITestAnnotatedService).GetMethod(nameof(ITestAnnotatedService.DoSomething))!;
|
||||
var desc = GetMethodDescription(method);
|
||||
Assert.Equal("执行自定义操作", desc);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 行为注解
|
||||
|
||||
/// <summary>
|
||||
/// IsReadOnlyMethod:Get/List/Query/Search 前缀返回 true。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsReadOnlyMethod_GetPrefix_ReturnsTrue()
|
||||
{
|
||||
Assert.True(IsReadOnlyMethod("GetAllTasksAsync"));
|
||||
Assert.True(IsReadOnlyMethod("ListSubTodosAsync"));
|
||||
Assert.True(IsReadOnlyMethod("QueryByDateAsync"));
|
||||
Assert.True(IsReadOnlyMethod("SearchByKeywordAsync"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsReadOnlyMethod:非只读前缀返回 false。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsReadOnlyMethod_NonReadPrefix_ReturnsFalse()
|
||||
{
|
||||
Assert.False(IsReadOnlyMethod("CreateTaskAsync"));
|
||||
Assert.False(IsReadOnlyMethod("UpdateTaskAsync"));
|
||||
Assert.False(IsReadOnlyMethod("DeleteTaskAsync"));
|
||||
Assert.False(IsReadOnlyMethod("ToggleCompleteAsync"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsDestructiveMethod:Delete/Remove 前缀返回 true。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsDestructiveMethod_DeletePrefix_ReturnsTrue()
|
||||
{
|
||||
Assert.True(IsDestructiveMethod("DeleteTaskAsync"));
|
||||
Assert.True(IsDestructiveMethod("RemoveItemAsync"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsDestructiveMethod:非破坏性操作返回 false。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsDestructiveMethod_NonDestructive_ReturnsFalse()
|
||||
{
|
||||
Assert.False(IsDestructiveMethod("GetAllTasksAsync"));
|
||||
Assert.False(IsDestructiveMethod("CreateTaskAsync"));
|
||||
Assert.False(IsDestructiveMethod("UpdateTaskAsync"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 动态工具注册(集成测试)
|
||||
|
||||
/// <summary>
|
||||
/// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现 ITaskService 并生成工具。
|
||||
/// 工具数量 = ITaskService 的公共方法数(9 个)。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithDynamicApiTools_ScansAssemblyAndRegistersTools()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<ITaskService, MockTaskService>();
|
||||
|
||||
// Act - 仅注册工具到 DI,不绑定传输层
|
||||
services.AddMcpServer()
|
||||
.WithDynamicApiTools();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert:验证 McpServerTool 实例已注册到 DI
|
||||
var tools = provider.GetServices<McpServerTool>().ToList();
|
||||
Assert.NotEmpty(tools);
|
||||
|
||||
// ITaskService 有 9 个公共方法
|
||||
Assert.Equal(9, tools.Count);
|
||||
|
||||
// 工具名应与推导规则一致
|
||||
var toolNames = tools.Select(t => t.ProtocolTool.Name).OrderBy(n => n).ToList();
|
||||
Assert.Contains("task_get_all_tasks", toolNames);
|
||||
Assert.Contains("task_get_task_by_id", toolNames);
|
||||
Assert.Contains("task_get_active_tasks", toolNames);
|
||||
Assert.Contains("task_get_completed_tasks", toolNames);
|
||||
Assert.Contains("task_create_task", toolNames);
|
||||
Assert.Contains("task_update_task", toolNames);
|
||||
Assert.Contains("task_toggle_complete", toolNames);
|
||||
Assert.Contains("task_delete_task", toolNames);
|
||||
Assert.Contains("task_get_sub_tasks", toolNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成的工具携带正确的行为注解(ReadOnly/Destructive)。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithDynamicApiTools_ToolsHaveCorrectBehaviorAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<ITaskService, MockTaskService>();
|
||||
|
||||
// Act
|
||||
services.AddMcpServer()
|
||||
.WithDynamicApiTools();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var tools = provider.GetServices<McpServerTool>().ToList();
|
||||
|
||||
// Assert
|
||||
var getTool = tools.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
|
||||
Assert.True(getTool.ProtocolTool.Annotations?.ReadOnlyHint == true);
|
||||
|
||||
var deleteTool = tools.First(t => t.ProtocolTool.Name == "task_delete_task");
|
||||
Assert.True(deleteTool.ProtocolTool.Annotations?.DestructiveHint == true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 辅助方法(暴露 private 方法用于测试)
|
||||
|
||||
private static string StripAsyncSuffix(string name)
|
||||
=> name.EndsWith("Async") ? name[..^5] : name;
|
||||
|
||||
private static bool IsReadOnlyMethod(string name)
|
||||
=> name.StartsWith("Get") || name.StartsWith("List")
|
||||
|| name.StartsWith("Query") || name.StartsWith("Search");
|
||||
|
||||
private static bool IsDestructiveMethod(string name)
|
||||
=> name.StartsWith("Delete") || name.StartsWith("Remove");
|
||||
|
||||
private static string GetMethodDescription(MethodInfo method)
|
||||
{
|
||||
var descAttr = method.GetCustomAttribute<DescriptionAttribute>();
|
||||
if (descAttr != null) return descAttr.Description;
|
||||
|
||||
var name = method.Name;
|
||||
if (name.StartsWith("Get")) return $"获取{StripAsyncSuffix(name[3..])}";
|
||||
if (name.StartsWith("Create")) return $"创建{StripAsyncSuffix(name[6..])}";
|
||||
if (name.StartsWith("Update")) return $"更新{StripAsyncSuffix(name[6..])}";
|
||||
if (name.StartsWith("Delete")) return $"删除{StripAsyncSuffix(name[6..])}";
|
||||
if (name.StartsWith("Toggle")) return $"切换{StripAsyncSuffix(name[6..])}";
|
||||
return StripAsyncSuffix(name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region 测试用类型
|
||||
|
||||
/// <summary>
|
||||
/// Mock 多词接口,用于测试 snake_case 转换。
|
||||
/// </summary>
|
||||
public interface ITestMultiWordService : IDynamicApiService
|
||||
{
|
||||
Task<int> GetValueAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock 带 DescriptionAttribute 的接口。
|
||||
/// </summary>
|
||||
public interface ITestAnnotatedService : IDynamicApiService
|
||||
{
|
||||
[Description("执行自定义操作")]
|
||||
Task<int> DoSomething();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock 动态 API 服务接口(含简单参数方法)。
|
||||
/// </summary>
|
||||
public interface IMockTestService : IDynamicApiService
|
||||
{
|
||||
[Description("获取数据")]
|
||||
Task<string> GetDataAsync();
|
||||
|
||||
[Description("更新数据")]
|
||||
Task UpdateDataAsync(int id, string value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock 动态 API 服务实现。
|
||||
/// </summary>
|
||||
public class MockTestServiceImpl : IMockTestService
|
||||
{
|
||||
public Task<string> GetDataAsync() => Task.FromResult("mock data");
|
||||
public Task UpdateDataAsync(int id, string value) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock ITaskService 实现(桩实现,仅用于验证工具注册,不执行实际逻辑)。
|
||||
/// </summary>
|
||||
public class MockTaskService : ITaskService
|
||||
{
|
||||
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(new List<TaskDto>());
|
||||
public Task<TaskDto?> GetTaskByIdAsync(int id) => Task.FromResult<TaskDto?>(null);
|
||||
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(new List<TaskDto>());
|
||||
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(new List<TaskDto>());
|
||||
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) => Task.FromResult(new TaskDto());
|
||||
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto) => Task.FromResult(new TaskDto());
|
||||
public Task<TaskDto> ToggleCompleteAsync(int id) => Task.FromResult(new TaskDto());
|
||||
public Task DeleteTaskAsync(int id) => Task.CompletedTask;
|
||||
public Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId) => Task.FromResult(new List<TaskDto>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user