新闻详情

新闻详情

首页 / 资讯中心 / 详情

ASP.NET Core 集成 MCP:让 AI 直接调用你的接口

发布时间:2026/9/25 4:24:49来源:尧图网络
ASP.NET Core 集成 MCP:让 AI 直接调用你的接口
1. 为什么要把 .NET 接口暴露给 AI1.1 从一个真实痛点说起去年底我接手了一个内部工单系统的维护工作前端同事跑过来跟我说“能不能让 AI 直接帮我查工单状态我不想每次都在 Swagger 页面里翻接口、填参数、点 Try it out。”当时我的第一反应是——这不就是个 API 调用吗写个脚本不就行了但仔细一想问题没那么简单。传统的做法是AI 模型生成一段代码人去执行拿到结果再贴回给 AI。这个链路里人始终是中间人。而 MCPModel Context Protocol要解决的核心问题就是把这个“人”从链路里拿掉让 AI 直接调用你的接口拿到结果继续推理。我花了大概两周时间把一个中等规模的 ASP.NET Core 项目改造成了 MCP 服务端同时写了一个客户端做验证。踩了不少坑也积累了一些经验。这篇文章就是把这整个过程拆开揉碎讲清楚包括为什么要这么设计、每一步怎么做、哪些地方容易翻车。1.2 MCP 到底是什么用大白话解释MCP 全称 Model Context Protocol翻译过来叫“模型上下文协议”。你可以把它理解成 AI 世界里的“USB 接口标准”。以前每个 AI 工具要调用外部能力都得自己定一套协议A 工具用 HTTPB 工具用 WebSocketC 工具用 gRPC乱得很。MCP 就是把这些统一起来定义了一套标准的“工具描述”和“调用方式”。具体到 .NET 场景你的 ASP.NET Core 接口本来是通过 HTTP 暴露给前端或者别的服务的。现在你想让 AI 也能调有两种思路思路一把现有接口包装成 MCP 工具AI 通过 MCP 协议调用MCP 服务端再转发到你的业务接口。思路二直接在 ASP.NET Core 项目里集成 MCP 服务端能力让同一个进程既提供 REST API又提供 MCP 工具。我选的是思路二原因后面会详细说。先给结论思路二的好处是复用现有的依赖注入、认证、日志体系不用额外维护一个中间层。1.3 适合谁来读这篇内容如果你满足以下任意一条这篇内容应该对你有用手里有现成的 ASP.NET Core 项目想让 AI 助手直接调用里面的接口正在做 AI Agent 相关开发需要把内部系统能力暴露给模型对 MCP 协议感兴趣想找一个 .NET 技术栈的落地案例已经在用 Swagger 管理接口想进一步把接口“AI 化”不需要你提前了解 MCP 协议细节但需要你熟悉 ASP.NET Core 的基本开发流程知道什么是依赖注入、中间件、Controller。如果这些还不熟建议先补一下基础。2. 整体方案设计与技术选型2.1 两种集成方式的取舍前面提到了两种思路这里展开说一下为什么选思路二。思路一的做法是单独起一个 MCP 服务端进程里面定义工具每个工具的实现就是去 HTTP 调用你的业务接口。这种做法的好处是解耦彻底MCP 服务端挂了不影响业务接口。但坏处也很明显多了一层网络跳转延迟增加认证要透传Token 管理麻烦业务接口改了参数MCP 服务端也得跟着改维护成本翻倍。思路二是在同一个 ASP.NET Core 进程里既注册 Controller 提供 REST API又注册 MCP 服务端提供工具调用。两者共享同一个依赖注入容器业务逻辑直接调 Service 层不走 HTTP。这样做的好处是零网络开销工具调用直接走内存认证体系复用MCP 调用和 REST 调用走同一套授权逻辑业务逻辑只写一遍Service 层同时服务两种入口部署简单还是一个进程坏处是 MCP 服务端的稳定性会影响主进程。但实际测试下来MCP 服务端的资源消耗很低只要工具实现里不做阻塞操作基本不会拖垮主进程。2.2 核心依赖包的选择.NET 生态里做 MCP 服务端目前主流的选择是ModelContextProtocol这个 NuGet 包。我用的版本是 0.1.0-preview虽然还是预览版但核心功能已经稳定了。安装命令很简单dotnet add package ModelContextProtocol --prerelease dotnet add package ModelContextProtocol.AspNetCore --prerelease第一个包是核心协议实现第二个包提供了 ASP.NET Core 的集成扩展。如果你只需要做客户端装第一个就够了。这里有个坑要注意预览版的 API 变动比较频繁建议在csproj里锁定版本号不要用浮动版本。我就因为没锁版本某次dotnet restore之后编译直接报错排查了半天才发现是包升级导致 API 签名变了。PackageReference IncludeModelContextProtocol Version0.1.0-preview.12 / PackageReference IncludeModelContextProtocol.AspNetCore Version0.1.0-preview.12 /2.3 项目结构规划我建议在现有项目里新建一个Mcp文件夹专门放 MCP 相关的代码。结构大概是这样YourProject/ ├── Controllers/ # 现有 REST API ├── Services/ # 业务逻辑层 ├── Mcp/ │ ├── Tools/ # MCP 工具定义 │ ├── McpExtensions.cs # 注册扩展方法 │ └── McpOptions.cs # 配置类 ├── Program.cs └── appsettings.json这样做的好处是 MCP 相关代码集中管理不会和现有代码混在一起。后面如果要单独开关 MCP 功能也方便。2.4 认证方案的设计MCP 客户端调用服务端时需要携带认证信息。我的做法是复用现有的 JWT 认证体系。MCP 客户端在初始化时配置一个 HTTP Header里面带上 Bearer Token。服务端这边MCP 的端点也走同样的 JWT 中间件。这里有个细节MCP 协议本身支持在初始化握手时传递认证信息但不同客户端的实现不太一样。最稳妥的做法还是走标准的 HTTP Authorization Header兼容性最好。注意不要把 MCP 端点暴露在公网上而不加认证。我见过有人图省事MCP 服务端直接监听 0.0.0.0 且不加任何认证结果被扫描到之后AI 工具被恶意调用产生了大量无效请求。认证这一步绝对不能省。3. 服务端落地把接口变成 MCP 工具3.1 定义第一个 MCP 工具假设我们有一个查询订单状态的服务IOrderService里面有个方法GetOrderStatusAsync(string orderId)。现在要把它暴露成 MCP 工具。首先定义一个工具类using ModelContextProtocol.Server; using System.ComponentModel; namespace YourProject.Mcp.Tools; [McpServerToolType] public class OrderTools { private readonly IOrderService _orderService; public OrderTools(IOrderService orderService) { _orderService orderService; } [McpServerTool, Description(根据订单号查询订单当前状态返回状态描述和更新时间)] public async Taskstring GetOrderStatus( [Description(订单号格式为 ORD 开头的字符串)] string orderId) { var result await _orderService.GetOrderStatusAsync(orderId); return $订单 {orderId} 当前状态{result.Status}更新时间{result.UpdatedAt:yyyy-MM-dd HH:mm:ss}; } }几个关键点解释一下[McpServerToolType]标记这个类是一个工具容器MCP 框架会自动扫描里面的方法。[McpServerTool]标记具体的方法是一个可调用的工具。[Description]特性非常重要它决定了 AI 能不能正确理解这个工具的用途和参数含义。我实测下来Description 写得好不好直接决定了 AI 调用工具的准确率。比如上面这个例子如果只写“查询订单”AI 可能会在用户问“帮我看看订单到哪了”的时候犹豫要不要调用。写成“根据订单号查询订单当前状态返回状态描述和更新时间”AI 就能明确知道这个工具能解决什么问题。3.2 参数类型的处理MCP 工具的参数支持基本类型string、int、bool、double 等。复杂对象需要序列化成 JSON 字符串传递或者拆成多个简单参数。我建议尽量用简单参数因为 AI 生成参数值时简单类型的准确率明显更高。比如要传一个日期范围不要传一个DateRange对象而是传startDate和endDate两个字符串参数。[McpServerTool, Description(查询指定时间范围内的订单列表)] public async Taskstring GetOrdersByDateRange( [Description(开始日期格式 yyyy-MM-dd)] string startDate, [Description(结束日期格式 yyyy-MM-dd)] string endDate, [Description(每页数量默认 20)] int pageSize 20) { // 实现逻辑 }可选参数给默认值这样 AI 不传的时候也能正常工作。但要注意Description 里要说明默认值是多少否则 AI 不知道不传参数会是什么行为。3.3 在 Program.cs 里注册 MCP 服务端注册代码不复杂但顺序很重要var builder WebApplication.CreateBuilder(args); // 现有的服务注册 builder.Services.AddControllers(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { /* 配置 */ }); builder.Services.AddScopedIOrderService, OrderService(); // 注册 MCP 服务端 builder.Services.AddMcpServer() .WithToolsFromAssembly(); // 自动扫描当前程序集里的工具类 var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); // 映射 MCP 端点 app.MapMcp(/mcp); app.Run();WithToolsFromAssembly()会自动扫描当前程序集里所有标记了[McpServerToolType]的类并注册里面的工具方法。工具类的构造函数参数会从依赖注入容器里解析所以IOrderService能直接注入进来。MapMcp(/mcp)把 MCP 端点映射到/mcp路径。客户端连接的时候就用这个地址。3.4 和 Swagger 共存的处理现有项目里通常已经有 Swagger 了加了 MCP 之后Swagger 页面里可能会多出一些 MCP 相关的端点看起来比较乱。我的做法是在 Swagger 配置里过滤掉 MCP 相关的路径builder.Services.AddSwaggerGen(options { options.DocInclusionPredicate((docName, apiDesc) { // 过滤掉 MCP 端点 return !apiDesc.RelativePath?.StartsWith(mcp) ?? true; }); });这样 Swagger 页面保持干净只展示业务接口。MCP 端点不需要在 Swagger 里展示因为它不是给人看的是给 AI 用的。另外如果你之前给 API 加了统一前缀比如/api/v1MCP 端点建议不要加这个前缀单独走/mcp路径。这样职责清晰也方便后面做独立的限流和监控。3.5 工具粒度的设计原则这是我在实际项目中感受最深的一点工具不是越多越好也不是越细越好。一开始我把每个 Service 方法都暴露成了工具结果 AI 面对几十个工具选择困难经常调错。后来我做了合并把相关的操作整合成一个工具通过参数来区分具体行为。比如原来有CreateOrder、CancelOrder、UpdateOrder三个工具我合并成了一个ManageOrder工具加一个action参数来区分。这样 AI 只需要知道“有个工具能管理订单”具体做什么由 action 决定。但也不能合并得太粗。如果一个工具能做的事情太多Description 就很难写清楚AI 反而不知道怎么用。我的经验是一个工具对应一个明确的业务意图参数控制在 5 个以内。粒度优点缺点适用场景细粒度一方法一工具职责清晰工具数量多AI 选择困难工具总数少于 10 个中粒度按业务聚合数量适中意图明确需要设计 action 参数大多数场景推荐粗粒度一系统一工具数量极少Description 难写AI 容易懵不推荐4. 客户端落地让 AI 真正调起来4.1 客户端的基本结构MCP 客户端的作用是连接服务端获取工具列表然后根据 AI 的决策调用对应工具。在 .NET 里客户端可以是一个控制台程序也可以集成在 Web 应用里。我写了一个控制台客户端做验证核心代码如下using ModelContextProtocol.Client; var transport new HttpClientTransport(new HttpClientTransportOptions { Endpoint new Uri(https://localhost:8889/mcp), AdditionalHeaders new Dictionarystring, string { [Authorization] Bearer YOUR_TOKEN_HERE } }); await using var client await McpClient.CreateAsync(transport); // 获取工具列表 var tools await client.ListToolsAsync(); foreach (var tool in tools) { Console.WriteLine($工具{tool.Name} - {tool.Description}); } // 调用工具 var result await client.CallToolAsync(GetOrderStatus, new Dictionarystring, object? { [orderId] ORD20240101001 }); Console.WriteLine(result.Content.First().Text);HttpClientTransport是 HTTP 传输方式适合远程连接。如果服务端和客户端在同一台机器上也可以用StdioTransport通过标准输入输出通信延迟更低。4.2 把工具列表喂给 AI 模型客户端拿到工具列表后需要把这些工具的描述转换成 AI 模型能理解的格式通常是 JSON Schema。MCP 客户端库一般会提供转换方法。以 OpenAI 风格的 API 为例转换后的格式大概是这样{ type: function, function: { name: GetOrderStatus, description: 根据订单号查询订单当前状态返回状态描述和更新时间, parameters: { type: object, properties: { orderId: { type: string, description: 订单号格式为 ORD 开头的字符串 } }, required: [orderId] } } }把这段 JSON 作为tools参数传给模型模型在需要的时候就会返回一个tool_calls里面包含要调用的工具名和参数。客户端解析这个返回调用对应的 MCP 工具再把结果传回给模型形成闭环。4.3 处理工具调用的返回结果MCP 工具的返回结果是一个CallToolResult对象里面包含一个Content列表。每个 Content 可能是文本、图片或者其他类型。大多数情况下我们只关心文本内容。但这里有个坑如果工具执行过程中抛异常了MCP 框架会把异常信息包装成错误结果返回而不是直接抛出。所以客户端需要判断result.IsError属性var result await client.CallToolAsync(GetOrderStatus, args); if (result.IsError) { Console.WriteLine($工具调用失败{result.Content.First().Text}); } else { Console.WriteLine($工具调用成功{result.Content.First().Text}); }服务端这边工具方法里抛出的异常会被框架捕获异常消息会作为错误内容返回。所以工具方法里要写好异常处理给 AI 返回有意义的错误信息而不是一堆堆栈跟踪。4.4 多轮对话中的工具调用管理实际使用中AI 可能需要在一次对话里调用多个工具。比如用户问“帮我查一下订单 ORD001 的状态如果还没发货就取消掉”AI 需要先调GetOrderStatus根据结果再决定要不要调CancelOrder。客户端需要维护一个对话历史每次工具调用和结果都要追加到历史里再传给模型进行下一轮推理。这个过程可能循环多次直到模型不再请求调用工具而是直接给出最终回答。我建议设置一个最大循环次数比如 10 次防止模型陷入无限调用。同时要记录每次调用的工具名和参数方便排查问题。实操心得在开发调试阶段把每次工具调用的请求和响应都打到日志里。我一开始没打日志AI 调错了工具我都不知道它传了什么参数排查起来很痛苦。后来加了详细日志一眼就能看出是 Description 写得不够清楚还是参数格式有问题。5. 常见问题与排查技巧实录5.1 连接失败类问题问题一客户端连不上服务端报 SSL 错误本地开发时ASP.NET Core 默认用自签名证书客户端验证会失败。解决办法是在客户端配置里跳过证书验证仅限开发环境var handler new HttpClientHandler { ServerCertificateCustomValidationCallback (message, cert, chain, errors) true }; var httpClient new HttpClient(handler);生产环境一定要用受信任的证书不要跳过验证。问题二连接超时检查服务端是否真的在监听 MCP 端点。可以在浏览器里访问https://localhost:8889/mcp如果返回 404说明MapMcp没生效。常见原因是MapMcp写在了app.Run()之后或者路径写错了。5.2 工具调用类问题问题三AI 不调用工具直接编造答案这是最常见的问题根本原因通常是工具 Description 写得不够明确。AI 不确定这个工具能不能回答用户的问题就选择自己编。解决办法把 Description 写成“当用户问 XXX 时使用此工具”的格式。比如不要写“查询订单状态”而是写“当用户询问订单当前状态、物流进度时使用此工具查询”。问题四AI 调用工具时参数格式错误比如日期格式传成了2024/01/01而不是2024-01-01。解决办法是在参数的 Description 里明确写出格式要求并且服务端做参数校验格式不对时返回明确的错误提示让 AI 知道怎么修正。问题五工具返回结果太长AI 处理不了如果工具返回一个很长的列表AI 的上下文可能装不下。解决办法是在工具实现里做分页或者只返回摘要信息详细内容让 AI 再调一次工具获取。5.3 性能与稳定性问题问题六MCP 调用导致主进程响应变慢如果工具方法里有耗时操作比如查数据库、调外部接口会占用线程池资源。解决办法是把耗时操作改成异步并且设置超时时间。MCP 框架支持在工具方法上设置超时超时后会自动取消。问题七并发调用时出现资源竞争多个 AI 客户端同时调用同一个工具时如果工具方法里有共享状态可能出现竞争。解决办法是工具类注册为 Scoped 生命周期每次调用创建新实例避免共享状态。5.4 常见问题速查表问题现象可能原因排查方法解决方案客户端连不上端点路径错误浏览器访问 /mcp检查 MapMcp 路径SSL 证书错误自签名证书查看错误详情开发环境跳过验证AI 不调工具Description 不清晰查看工具列表改写 Description参数格式错误缺少格式说明查看调用日志补充参数 Description返回结果过长未分页查看返回内容实现分页或摘要主进程变慢同步阻塞调用查看线程池状态改异步 超时并发竞争共享状态查看异常日志改 Scoped 生命周期5.5 几个容易忽略的细节第一个细节MCP 工具的命名不要用下划线或特殊字符用驼峰或帕斯卡命名。有些 AI 模型对工具名的解析规则比较严格特殊字符可能导致调用失败。第二个细节工具返回的文本里不要包含 Markdown 格式。AI 模型可能会把 Markdown 符号当成内容的一部分影响后续推理。返回纯文本就好格式化的事情让 AI 自己去做。第三个细节如果工具需要调用外部 HTTP 接口记得设置合理的超时和重试策略。我遇到过外部接口偶发超时导致 MCP 工具调用卡住AI 那边一直等不到结果。后来加了 5 秒超时和一次重试问题就解决了。第四个细节在appsettings.json里给 MCP 相关配置单独开一个节点方便不同环境用不同配置。比如开发环境可以开启详细日志生产环境关闭。{ Mcp: { Enabled: true, EndpointPath: /mcp, EnableDetailedLogging: false, ToolTimeoutSeconds: 30 } }然后在Program.cs里根据配置决定是否注册 MCP 服务端。这样如果某个环境不需要 MCP直接改配置就行不用改代码。6. 从能用到好用几个进阶优化点6.1 给工具加上权限控制不是所有 AI 客户端都应该能调用所有工具。我的做法是在工具方法里检查当前用户的角色没有权限就返回错误信息。[McpServerTool, Description(取消指定订单)] public async Taskstring CancelOrder( [Description(订单号)] string orderId, IHttpContextAccessor httpContextAccessor) { var user httpContextAccessor.HttpContext?.User; if (!user.IsInRole(OrderManager)) { return 当前用户没有取消订单的权限; } // 执行取消逻辑 }IHttpContextAccessor可以从依赖注入里拿到这样工具方法就能访问当前请求的上下文信息。注意要把IHttpContextAccessor注册到容器里builder.Services.AddHttpContextAccessor();6.2 工具调用的审计日志为了排查问题和满足合规要求我建议记录每次工具调用的详细信息谁调的、什么时候调的、调了什么工具、传了什么参数、返回了什么结果、耗时多少。这些信息可以在 MCP 中间件里统一记录不用每个工具方法里都写一遍。MCP 框架提供了拦截器机制可以注册一个IMcpServerToolInterceptor来实现。6.3 工具版本管理当业务接口升级时MCP 工具也可能需要改。为了不影响已经在用的 AI 客户端我建议给工具加版本号。比如GetOrderStatusV2旧版本保留一段时间等所有客户端都升级后再下线。或者在 Description 里标注版本让 AI 知道有新版本可用。但这种方式不太可靠AI 不一定能正确理解版本差异。最稳妥的还是工具名里带版本号。6.4 和现有 Swagger 文档的联动既然已经有 Swagger 文档了能不能自动生成 MCP 工具理论上可以但实际做下来效果一般。因为 Swagger 里的接口描述通常比较技术化不适合直接给 AI 看。MCP 工具的 Description 需要针对 AI 的理解能力做优化人工编写效果更好。不过可以做一个辅助工具从 Swagger 文档里提取接口列表和参数信息生成 MCP 工具的代码骨架然后人工补充 Description。这样能省一部分工作量。7. 一些个人体会这套方案我在两个项目里落地过一个内部工单系统一个对外的小程序后端。工单系统那边AI 助手接入后客服人员查订单、改状态的效率大概提升了三成因为不用再切到后台管理系统里操作了直接在对话窗口里说一句话就行。小程序后端那边主要是给运营同事用他们不懂技术但通过 AI 助手能直接查数据、导出报表省了很多提需求的沟通成本。踩过的坑主要集中在前两天工具 Description 写不好、认证配置不对、SSL 证书报错这些问题排查起来比较费时间。但一旦跑通后面的维护成本很低。业务逻辑改的时候Service 层改完REST API 和 MCP 工具同时生效不用改两遍。如果你正准备做类似的事情我的建议是先从一两个简单的查询类工具开始跑通整个链路再逐步增加复杂的操作类工具。不要一上来就把所有接口都暴露出去那样调试起来会很乱。另外MCP 协议本身还在演进中预览版的 API 可能还会变。建议关注官方仓库的更新升级版本时先在小项目里验证没问题再推到主项目。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

MOS管从入门到精通:NMOS/PMOS开关电路设计与选型避坑指南 2026/9/25 4:53:58

MOS管从入门到精通:NMOS/PMOS开关电路设计与选型避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
ESP32 -O2崩溃根因解析:编译器优化与嵌入式代码可靠性 2026/9/25 4:53:58

ESP32 -O2崩溃根因解析:编译器优化与嵌入式代码可靠性

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
特斯拉HW4.0硬件深度拆解:11摄像头+4D雷达如何重塑自动驾驶感知 2026/9/25 4:53:52

特斯拉HW4.0硬件深度拆解:11摄像头+4D雷达如何重塑自动驾驶感知

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
C++中^不是次方:幂运算的正确姿势与避坑指南 2026/9/25 4:53:52

C++中^不是次方:幂运算的正确姿势与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
C# WinForm流程图控件源码解析:GDI+绘制、拖动与序列化全实现 2026/9/25 4:53:52

C# WinForm流程图控件源码解析:GDI+绘制、拖动与序列化全实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Wormhole勒索病毒深度分析:蠕虫式横向传播与应急响应实战 2026/9/25 4:53:52

Wormhole勒索病毒深度分析:蠕虫式横向传播与应急响应实战

1. 一次真实的应急响应:从一台中招机器说起凌晨两点被电话叫醒,对方是合作公司的运维负责人,语气很急——财务共享盘里所有文件后缀全变了,桌面上多了一个文本文件,里面写着要联系某个邮箱、支付一笔加密货币。我让他先…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉