最近在把一个跑在 Flutter 里的 mcp_server 服务端整体迁到鸿蒙设备上,折腾了一周多,总算把它从一个"能编译过"的状态,推进到了"能稳定当工业级 AI 插件服务端用"的状态。这里先说结论:mcp_server 这个 Dart 生态三方库,完全可以在鸿蒙系统上落地,Model Context Protocol 那一整套工具注册、上下文协商、资源暴露的机制不需要重写,真正费劲的是传输层、线程模型和生命周期管理这三件事。
这篇文章我会按自己实际动手的顺序来写:先说明为什么要在鸿蒙上引入 MCP 服务端,再把鸿蒙化适配的总体技术路线讲清楚,接着给一份从源码到 HAP 包的实操流程,然后重点展开通信引擎的设计和工业级加固方案,最后把踩过的几个大坑完整复盘一遍。如果你正准备把 Flutter 系的服务端能力搬上鸿蒙,或者想在你的鸿蒙 AI 应用里接入 MCP 协议,这篇应该能帮你少走不少弯路。
1. 为什么 AI 智能体在鸿蒙上需要一个 MCP 服务端
1.1 从"智能体需要工具"这个真实需求说起
现在做 AI 应用,尤其是做 Agent 类应用的人,应该都有体会:模型本身再强,如果没有工具调用能力,它也就是个聊天窗口。要让模型去查天气、操作日历、读写文件、调用系统能力,你就得给它一套"工具"接口。但工具接口怎么做,各家有各家的搞法:有的直接暴露 REST API,有的走 WebSocket 协议,有的干脆把逻辑写在提示词里让模型"假装调用"。这种情况在 PC 上还好,到了鸿蒙这种设备端场景,你会发现更麻烦——应用要跑在折叠屏、平板、元服务卡片甚至带屏设备上,同一个智能体可能同时要和几个 UI 界面、几个后台服务通信,如果工具接口都是各写各的,维护成本会迅速失控。
Model Context Protocol(简称 MCP)就是为了解决这个问题出现的。它把"模型怎么发现工具、怎么调用工具、怎么读写上下文资源"标准化了。你可以把它理解成 AI 世界的 USB 接口:模型是主机,工具是外设,MCP 就是那个统一的插口规范。只要工具方实现了 MCP 服务端,任何支持 MCP 的客户端和模型都能直接插上用,不用再单独写适配层。
1.2 mcp_server 这个 Flutter 三方库到底解决的是什么问题
mcp_server 是 Dart/Flutter 社区实现的一版 MCP 服务端 SDK。它不是一个 UI 组件库,也不是什么状态管理框架,而是一套完整的 JSON-RPC 2.0 服务端实现,封装了协议握手、工具发现、工具调用分发、资源读取、提示词模板这些 MCP 核心功能。
选它而不是自己从零写协议,原因很简单:MCP 规范里的细节比想象中多。协议版本协商、初始化握手、capabilities 声明、错误码定义、批量请求处理、流式响应……这些全部手写一遍非常容易出协议兼容性问题。而且社区版已经处理好了很多边界情况,比如客户端断开后的请求超时、并发调用时的响应乱序等。在我实际用下来,这个库的 API 设计得比较干净,核心入口就几个对象:服务端实例、传输层对象、工具注册表、资源提供器。后面我会具体展示。
1.3 鸿蒙化不等于重新造轮子:先盘点能复用的部分
很多人在刚拿到鸿蒙化需求时,第一反应是"又要用 ArkTS 重写一遍"。但如果你做的是 Flutter 项目,情况完全不同:HarmonyOS NEXT 上是可以跑 Flutter 引擎的,社区维护的鸿蒙版 Flutter SDK 已经能够支撑 Dart 代码直接编译到鸿蒙应用里。这意味着 mcp_server 这种纯 Dart 实现的库,理论上不需要用 ArkTS 重写,只需要处理它依赖的 dart:io 能力在鸿蒙引擎上的兼容性问题。
我个人的判断是:能复用的部分包括 MCP 协议层(握手、消息编解码、工具分发逻辑)、工具注册与调度的核心代码、以及大部分纯 Dart 的工具实现。需要动手改的主要是传输层(Socket/HTTP 服务在鸿蒙上的行为差异)、线程模型(Dart isolate 在鸿蒙进程里的调度方式)、以及生命周期管理(鸿蒙对应用后台和常驻服务的限制)。搞清楚这条主线,适配工作就不会跑偏。
2. 鸿蒙化适配的总体技术路线:先摸清 Flutter 在鸿蒙上的边界
2.1 鸿蒙版 Flutter 引擎的现状与限制
先说基础环境。要把 Flutter 工程编译成鸿蒙 HAP 包,目前主流做法是用社区维护的 flutter_flutter 鸿蒙分支(也就是 OpenHarmony SIG 那套工具链)。这套 SDK 基于 Flutter 3.22 之后的版本衍生,支持 ArkTS 工程自动生成、鸿蒙原生组件嵌入、以及大部分 dart:io API。但"大部分支持"意味着不是全支持,差异集中在几个点上:
- 文件系统路径规则不同(鸿蒙的沙箱路径和 Linux/Android 不同)。
- 网络权限模型不同(必须在 module.json5 里声明 ohos.permission.INTERNET,否则 Socket 全部静默失败)。
- 部分 dart:io 底层实现依赖鸿蒙 napi 桥接,性能和稳定性跟标准 Flutter 有差距,尤其高频读写时问题明显。
所以适配的第一步不是改代码,而是先确认你用的 Flutter 和鸿蒙 SDK 版本组合是否稳定。我这边用的是 5.0.x 的鸿蒙 SDK 搭配 flutter_flutter 的 harmony 分支,整体比较顺。如果你还在用老的 API 9 那套,建议先升级。
2.2 mcp_server 的依赖画像:它碰了哪些 dart:io 能力
拿到 mcp_server 源码后,我第一件事是把它所有依赖和内部 import 列出来,做了一张表,标出每个模块对 dart:io 的依赖程度。这里特别要关注的是传输层实现。
| 依赖模块 | 用途 | 对 dart:io 的依赖 | 鸿蒙适配风险 |
|---|---|---|---|
| mcp_server 核心 | 协议消息处理、工具路由 | 低,主要是事件循环和 Stream | 低,纯 Dart 逻辑 |
| json_rpc_2 | JSON-RPC 消息封装 | 低,只做编解码 | 低,直接可用 |
| shelf / shelf_io | HTTP 服务承载 | 高,依赖 HttpServer | 中,需要验证鸿蒙引擎的 HttpServer 实现 |
| dart:io ServerSocket | TCP Socket 传输 | 高,直接使用 | 高,出现地址绑定时序问题 |
| dart:convert / collection | 序列化、集合工具 | 无 | 低 |
| uuid | 请求 ID 生成 | 无,纯 Dart | 低 |
这个表格非常有用。你会发现真正有高风险的其实只有两块:HTTP 服务承载和底层 Socket。也就是说,如果能在传输层做一次替换或者规避,整个库的鸿蒙化风险就下降大半。
2.3 适配策略:能"直编"的绝不桥接,不能直编的才走 Channel
基于上面的依赖分析,我确定了一个适配原则:协议层和业务层走 Dart 直编,传输层和系统能力走桥接或替换。具体来说:
- MCP 核心、工具注册、上下文管理:全量保留 Dart 代码,只在必要处加编译条件。
- HTTP/SSE 传输:优先验证 dart:io HttpServer 在鸿蒙上的行为,有问题就切换到自定义 transport 实现。
- TCP 自定义传输:实在绕不过 socket 时序问题时,可以通过 Platform Channel 调 ArkTS 侧的网络能力,把数据流桥接回 Dart 层。
这样做的好处是,后续 mcp_server 库上游更新时,你只需要在适配层做同步,不需要把整个库 fork 死。下面我会详细说代码级实操。
3. 核心适配实操:从源码到 HAP 的完整链路
3.1 拉源码与依赖替换:用 path 依赖锁定本地适配版
我的做法是先把 mcp_server 源码拉下来,作为一个本地模块放进工程里,用 path 依赖替换 pub 仓库里的正式版本。这样做的原因是:适配过程中要动的代码比想象中多,如果直接用 pub 依赖,每次改完还得靠 git patch 维护,很容易在版本升级时丢失改动。把它放成本地 package 后,适配改动就成了普通代码改动,回滚、对比、提交都方便。
dependencies: flutter: sdk: flutter mcp_server: path: third_party/mcp_server json_rpc_2: ^3.0.7 uuid: ^4.4.0pubspec 调整完之后,记得把鸿蒙工程需要的声明补上。鸿蒙版的 Flutter 模板和标准 Flutter 有差异,它会要求提供 ohos 目录和对应的模块配置。
3.2 编译期修正:三个最典型的类型冲突
第一次把工程切换到鸿蒙 SDK 编译时,报错比预想的多,但大多数都是同一个性质的问题。这里列三个最典型的:
第一,鸿蒙 SDK 的 dart:io File 行为差异。mcp_server 内部的某个示例工具在做资源文件读取时用了File('${Directory.current.path}/config.json')。在 Android 上这么写没毛病,但在鸿蒙沙箱里Directory.current返回的路径可能指向不可读区域。我的处理方式是把资源路径改成从外部传入,通过构造参数注入,避免服务端自己猜路径。
第二,HttpServer 的 bind 行为不一致。标准 Dart 里HttpServer.bind(InternetAddress.anyIPv4, 8080)会监听所有网卡,但鸿蒙的 napi 桥接实现里,InternetAddress.anyIPv4和localhost的处理时序跟原生 Dart 不一样,表现为偶尔端口能通、偶尔通不了。后面我会在踩坑章节详细展开。
第三,与生成代码的命名冲突。mcp_server 内部定义了一个Resource类,鸿蒙 Flutter 模板生成的外层 Model 里也可能出现同名类。如果启用全局引入,编译器会报冲突。解决方式是把 mcp_server 的引入改成带前缀方式:
import 'package:mcp_server/mcp_server.dart' as mcp;这是很朴素但很有效的做法。你永远不会想跟框架生成的代码抢类名。
3.3 权限与构建配置:INTERNET 权限决定一切
鸿蒙应用的所有敏感权限都需要在ohos/module.json5里声明。对于任何网络型服务端,ohos.permission.INTERNET是必须的,否则运行时所有 Socket 和 HTTP 请求都会静默失败——不报错,就是连不上,非常坑。
{ "module": { "name": "entry", "type": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }如果你还需要读取本地证书做 TLS,还要申请ohos.permission.READ_IMAGEVIDEO之类的,但对 MCP 服务端本身来说,INTERNET 这一个就够了。另外,如果你的智能体工具里涉及麦克风、位置、相机这些敏感能力,记得要动态申请而不是只写在配置文件里,后者只是第一步。
构建产物方面,鸿蒙化之后的打包和标准 Flutter 不一样:先通过flutter build hap生成 HAP 包,再通过 DevEco Studio 签名后安装到设备。这里提醒一句,如果只是为了调试,可以先用签名工具生成 debug 证书,但工业级分发必须用正式签名,否则后台常驻和部分系统能力接口会被系统拦掉。
4. 传输层选型与"透明上下文通信引擎"的设计
4.1 stdio / SSE / 自定义 Socket 在鸿蒙上的真实表现
MCP 标准里定义了多种传输方式,落到鸿蒙设备上,每种的实际表现差异很大。我把它做成了一张对比表,应该能帮你省下不少调研时间。
| 传输方式 | 鸿蒙适配难度 | 稳定性 | 适用场景 | 备注 |
|---|---|---|---|---|
| stdio | 低 | 高 | 本地单进程内通信 | 鸿蒙上推荐,适合进程内嵌 |
| HTTP + SSE | 中高 | 中 | 跨进程/跨设备通信 | 长连接需要额外心跳,避免断流 |
| 自定义 TCP Socket | 高 | 中 | 需要完全掌控协议 | socket bind 时序需要额外处理 |
| WebSocket 桥接 | 中 | 高 | 连接 ArkTS 侧原生能力 | 通过 Platform Channel 转发,最稳 |
从我实跑下来的结果看,如果 MCP 服务端和 AI 客户端在同一个鸿蒙进程里,stdio 是最可靠的,几乎没有适配成本。但如果你的架构是"UI 一个进程、服务端一个常驻进程",那就必须上网络型传输。我最终采用的是"本地 stdio + 可选 SSE"双通道方案:默认走进程内,跨进程时开一个轻量的 SSE transport。
4.2 Platform Channel 作为本地桥:让 AI 客户端与服务端握手
在鸿蒙上你很难绕开一个情况:有一部分系统能力只有 ArkTS 侧能调,比如元服务卡片的更新、部分系统服务接口、以及某些硬件能力。这时 MCP 服务端如果完全封闭在 Dart 侧,这些工具就永远注册不上。我的做法是在工具层做一个"桥接工具":Dart 侧注册一个名为arkts_bridge.*的工具组,当模型调用这个工具时,实际通过 MethodChannel 发消息给 ArkTS 侧执行,执行结果再封装成 MCP 的 tool response 返回给模型。
class ArkTSBridgeTool extends mcp.Tool { final MethodChannel channel; ArkTSBridgeTool(this.channel) : super( name: 'arkts_bridge_invoke', description: '调用鸿蒙原生能力', inputSchema: { 'type': 'object', 'properties': { 'method': {'type': 'string'}, 'params': {'type': 'object'}, }, 'required': ['method'], }, ); @override Future<mcp.ToolResponse> call(mcp.CallToolRequest request) async { final method = request.params['method'] as String; final params = request.params['params'] ?? {}; final result = await channel.invokeMethod(method, params); return mcp.ToolResponse.text(jsonEncode(result)); } }这个设计的价值在于:模型侧看到的是一套统一的 MCP 工具接口,完全感知不到底层是纯 Dart 实现还是 ArkTS 实现。这就是标题里说的"透明"——对 AI 客户端来说,鸿蒙系统的能力边界被隐藏在了标准协议之后,调用方式完全一致。
4.3 上下文的归一化:session 管理 + 工具注册表
所谓"上下文通信引擎",落到工程层面其实就是两件事:一个连接一个 session,维护会话状态;一个全局工具注册表,让模型知道当前设备上有哪些能力可用。
Session 管理我直接复用了 mcp_server 自带的 session 机制,但加了鸿蒙特有的身份标记:每个连接进来时,除了协议握手,还要带一个deviceToken用来标识是哪个页面或卡片发起的请求。这样服务端就能区分"这是主界面发来的调用"和"这是元服务卡片发来的调用",在权限控制上可以做区分。
工具注册表我自己封装了一层,支持按设备能力动态注册和注销。鸿蒙设备形态多,折叠屏和手表上能提供的工具完全不同。我在服务端启动时扫描一次当前设备支持的系统能力(包括传感器、网络状态、存储信息等),然后只注册对应的工具。打个比方,手表上没有摄像头服务,那camera_capture这个工具就不应该出现在注册表里。这也是 MCP 设备端落地很关键的一个细节:不能假设所有设备都有同样的能力全集。
Session 模型上我画了一张内部数据流图,但没有用 Mermaid,直接文字描述:外部连接进入传输层 -> 握手确认协议版本和能力 -> session 建立 -> 客户端发工具调用请求 -> 服务端查注册表找对应处理器 -> 执行并返回结果 -> session 记录上下文。核心就是注册表查得够快、session 回收得够勤。
5. 工业级服务端的工程加固:isolate、连接治理与生命周期
5.1 并发模型:别让 MCP server 卡住 UI 线程
mcp_server 默认的示例基本是单 isolate 跑一个 server 实例,这在纯服务端场景没问题,但鸿蒙上的 Flutter 应用里还有 UI 线程和 Platform Channel 在跑,如果 MCP server 直接跑在 root isolate 里,一旦某个工具执行耗时操作(比如网络同步),UI 立刻掉帧甚至卡死。
我的做法是专门开一个后台 isolate 跑 MCP server,通过SendPort和ReceivePort与主 isolate 通信。这里有个注意事项:Dart 的 isolate 之间不能共享内存,所以你在工具中调用的跨 isolate 数据必须可序列化。在鸿蒙上尤其要注意,所有通过 MethodChannel 回传的 ArkTS 数据最终都会走 JSON 序列化,大对象和大 List 会成为性能瓶颈。
Future<void> runMcpServerInBackground(SendPort sendPort) async { final receivePort = ReceivePort(); sendPort.send(receivePort.sendPort); final server = mcp.McpServer( transport: StdioTransport(), ); server.registerTool(MyTool()); await server.start(); receivePort.listen((message) { // 接收主 isolate 的动态指令,比如注册新工具,或关闭服务 }); }启动方式则是这样:
final isolate = await Isolate.spawn(runMcpServerInBackground, sendPort.sendPort);这样隔离后,即使某个工具实现有问题导致 isolate 崩溃,也不会拖垮整个应用,配合 supervisor 逻辑还能自动重启。
5.2 连接治理与错误恢复:不要让一个坏连接拖垮整个服务
跑了两天之后我发现,最影响 MCP 服务端稳定性的不是协议逻辑,而是连接管理。当某个 AI 客户端非正常断开(比如直接杀进程)时,服务端的 socket 连接并不会立刻感知,session 会一直挂着,资源一直占着。积累久了,连接的句柄、缓冲区、注册的定时器都会泄漏。
解决办法是在传输层包一层超时管理:
class TimeoutTransport extends mcp.Transport { final mcp.Transport inner; final Duration timeout; TimeoutTransport(this.inner, {this.timeout = const Duration(seconds: 30)}); @override Stream<mcp.Message> get messages => inner.messages.timeout( timeout, onTimeout: (sink) => sink.addError(TimeoutException('mcp connection idle')), ); }另外我还在工具调用层加了全局超时。默认每个工具最多执行 15 秒,超过就返回 MCP 错误码中的internal_error。AI 模型拿到这个错误后通常会把"工具超时"记入上下文,下一次让它换个工具或重试,总比一直傻等强。
5.3 资源与进程生命周期管理:常驻服务要为鸿蒙规则让路
这是鸿蒙化适配里跟通用 Flutter 最不一样的地方。鸿蒙系统对应用后台运行有严格限制,如果你开发的是一个聚合在应用内的插件服务端,而不是一个独立的系统服务,那进程随时可能被系统回收。所以"常驻"这件事,不能硬碰硬去跑后台任务,而应该从产品架构上规避。
我的做法是走"按需启动 + 快速恢复"路线:当智能体有工具调用需求时,服务进程启动并建立 MCP 会话;当会话空闲超过 1 分钟时,优雅关闭 socket 并释放资源。同时在 UI 侧保留一个轻量唤醒机制,下次有请求时 100ms 内重新建连。实测这种模式在鸿蒙上最稳——既不被系统判为后台违规,又不会让用户感到明显延迟。
如果确实需要处理较长时间的任务,例如文件下载或模型推理,我建议通过鸿蒙的后台任务接口申请临时资源,而不是自己死撑 socket。短期任务合规,长期任务走系统资源,这才是工业级的做法。
6. 踩坑实录:鸿蒙化 mcp_server 最难缠的三个问题
6.1 ServerSocket 绑定失败:内核日志都看不懂的网络问题
第一个大坑出现在自定义 TCP transport 上。当时在鸿蒙设备上跑 mcp_server,反复出现SocketException: Connection refused,而且不是每次必现,是间歇性的。程序里没有任何报错代码指向明确的失败原因。
我把排查链路走了一遍:首先在 Dart 层打印 bind 前后的日志,发现HttpServer.bind返回成功,但随后客户端连接全部被拒绝。接下来我在 ArkTS 侧写了一个同样的 socket server 做对比,发现 ArkTS 原生 socket 服务完全正常,说明系统层面没问题。然后我在 Dart 侧改用ServerSocket.bind(InternetAddress.loopbackIPv4, port)绑定,服务立刻稳定。
结论是鸿蒙 Flutter 引擎在 napi 桥接InternetAddress.anyIPv4(即 0.0.0.0)时存在地址选择的时序问题,多网卡环境下偶尔会绑定到失效地址。解决办法很多时候反而是绕开:要么绑定loopbackIPv4只服务本地客户端,要么指定具体的局域网 IP。如果一定要监听所有网卡,就在 bind 前先延迟 300ms,等网络栈完成初始化,实测有效但这更像玄学,不推荐作为长期方案。
6.2 SSE 长连接频繁断流:定位到心跳机制缺失才真正解掉
为了支持跨进程连接,我上了 HTTP+SSE transport,很快就遇到一个新问题:SSE 长连接会在 20 到 40 秒之间无规律断流。一开始怀疑是鸿蒙网络策略在断长连接,试过在 manifest 里调一堆网络配置,全部无效。
后来我把日志精确到每 5 秒打一条的两端数据流,才发现根本原因:服务端发送 SSE 事件后,中间没有任何数据交互,而模型侧的 HTTP 客户端会在空闲时自动断开连接(标准 HTTP 空闲超时)。MCP 的 SSE 规范本身没有强制要求心跳,但实际部署必须加上。
解决方式是给传输层加了一个 10 秒一次的heartbeat注释事件(event: heartbeat),这样链接永远处于活跃状态,断流问题直接消失。这个小细节如果你不是实际跑过,光读规范真的很难发现。
6.3 配置解析差异:Dart 与 ArkTS 的 JSON 边界问题
最后一个坑是本地化工具接入鸿蒙系统能力时遇到的。我们在 ArkTS 侧拿到一些配置参数后用 JSON 传给 Dart,结构大概是{"level": 0, "tag": "network"}。Dart 侧解析时我用config['level'] as int,结果运行时直接抛type 'int' is not a subtype of type 'double',但日志里明明打印的是 0。
问题出现在鸿蒙的 JSON 序列化对数字类型的处理上。ArkTS 侧如果某个字段来自原生层的高精度浮点类型,序列化后可能带有小数尾巴,Dart 解出来会变成 double。最佳实践是解析时不要用强断言,统一做类型归一化:
int _toInt(dynamic value) { if (value is int) return value; if (value is double) return value.round(); return int.parse(value.toString()); }这件事给我一个很深的教训:跨语言桥接层的类型边界,永远比你想的更脆弱,防御式解析是工业级的必修课。
7. 帮你省时间的落地建议
如果你打算在鸿蒙设备上跑 MCP 服务端,我的经验可以浓缩成四句话:协议层安心复用 mcp_server,改造成本主要在传输层;能走 stdio 就别上 socket,能合到 Dart 侧就别去硬调 ArkTS;网络权限尽早配好,比任何代码都管用;给所有连接和工具调用都加上超时和重试,别期望任何一方永远在线。
我自己的项目里,最终稳定运行的架构是:Flutter UI + 后台 isolate 内的 mcp_server 实例,本地通信走 stdio,元服务场景走 Platform Channel 桥接到 ArkTS,外接 AI 客户端时再开一个 SSE 通道。这样既保证了单设备上的响应速度,又保留了跨端扩展的能力。这套结构跑了一周多,再也没有出现过之前那些莫名其妙的断连和卡死问题。如果你手里也有类似的鸿蒙 AI 服务端需求,照着上面的思路走一遍,应该就能避开我踩过的坑。