1. 从单窗口桌面程序到多会话 MCP Server 的改造起点
Qt6Widgets 写出来的桌面程序,界面稳、交互顺,但天生是「一个人用一台机器」的形态。当 AI Agent 需要调用你程序里的能力时,问题就来了:Agent 是并发的,可能同时有多个会话在请求同一份 GUI 资源,而 Qt 的界面对象只能在主线程碰。这就是把传统 Qt6Widgets 程序改造成多会话 MCP Server 时最核心的矛盾——网络请求在子线程跑,GUI 操作必须回主线程,还要保证多个会话互不串扰。
MCP Server 在这里扮演的角色,是把桌面程序已有的功能(比如地图标注、图像抓取、参数更新)包装成 AI Agent 能识别的工具接口。Agent 通过 HTTP 发 JSON-RPC 请求,你的程序解析后执行,再把结果返回。听起来简单,但一旦并发上来,线程池、路由、会话隔离、跨线程回主线程这几件事必须一起解决,否则要么界面卡死,要么数据错乱。
这篇面向的是已经写过 Qt Widgets、想把它升级成生产力工具的开发者。我会给出可复制的config.toml与settings.json骨架、TaoToken 统一 Key/API 通道的接入步骤,以及用 QtConcurrent 线程池 + QtHttpServer 路由 +QMetaObject::invokeMethod回主线程的并发骨架。实测下来,16 线程池配合会话级 widget 缓存,能稳定支撑多 Agent 并行调用。
2. TaoToken 前置:统一 Key 与 API 通道接入
在动手改并发骨架之前,先把模型调用通道理顺。多会话 MCP Server 往往需要调用大模型做意图解析或工具编排,如果每个会话各自维护一套 Key,配置会迅速失控。TaoToken 提供统一的 API 通道,一个 Key 覆盖多种模型,适合放在 MCP Server 的服务端统一管理。
你需要先拿到 API Key,入口在控制台的 API Keys 页面:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite拿到 Key 后,模型对话调试可以用模型对话页验证通道是否通:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite如果你的 MCP Server 要长期跑编码类 Agent 或自动化任务,建议直接看 Coding Plan,它更适合持续性的工具调用场景:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite接入文档在:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewriteAPI 基础地址统一用https://taotoken.net/api,注意这个地址不带 UTM 参数,直接写进配置即可。下面给出config.toml骨架,把 Key、base_url、超时和并发上限集中管理:
# config.toml - MCP Server 统一模型通道配置 [llm] provider = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-你的TaoToken密钥" default_model = "claude-sonnet" request_timeout_ms = 60000 max_retries = 2 [server] listen_addr = "127.0.0.1" listen_port = 8081 route_path = "/mapserver" thread_pool_size = 16 thread_expiry_sec = 31536000 [session] max_sessions = 32 idle_timeout_sec = 1800对应的settings.json骨架用于 Agent 侧注册,把 MCP Server 暴露出去:
{ "mcpServers": { "mapserver MCP Server": { "type": "streamable", "url": "http://127.0.0.1:8081/mapserver", "env": { "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_API_KEY": "sk-你的TaoToken密钥" } } } }注意:
api_key不要硬编码进提交到仓库的文件,用环境变量或本地未跟踪的配置文件覆盖。base_url保持https://taotoken.net/api即可,不要额外拼接路径。
3. 可复制配置:QtConcurrent 线程池与 QtHttpServer 路由骨架
配置就绪后,进入代码层。核心是三件事:线程池初始化、HTTP 路由绑定、请求丢进线程池异步处理。先看服务器启动函数,它把 TCP、HTTP、MCP 三层串起来:
void MainWindow::startServer() { // 线程池:并发处理请求的核心 threadPool = new QThreadPool(); threadPool->setMaxThreadCount(16); threadPool->setExpiryTimeout(3600 * 24 * 365); const QString addr = lineEditAddr->text(); const int port = lineEditPort->text().toInt(); const QString route = lineEditRoute->text(); tcpServer = new QTcpServer(); if (!tcpServer->listen(QHostAddress(addr), port)) { statusBar()->showMessage("Error: " + tcpServer->errorString()); return; } svr = new QHttpServer(threadPool); if (!svr->bind(tcpServer)) { statusBar()->showMessage("HTTP bind failed"); return; } mcpServer = new MCP_SERVER::McpServer(); if (!mcpServer->bind_to_http(svr, threadPool, route.toStdString().c_str())) { statusBar()->showMessage("MCP bind failed"); return; } MCP_SERVER::register_toolfunc_update_point(mcpServer); MCP_SERVER::register_toolfunc_grab_view(mcpServer); }线程池的setMaxThreadCount(16)决定了同时能处理多少个请求。setExpiryTimeout设长一点,避免线程频繁销毁重建带来的开销。路由绑定部分,OPTIONS 处理跨域预检,POST 才是真正的业务入口,请求体解析后交给QtConcurrent::run异步执行:
bool McpServer::bind_to_http(QHttpServer* server, QThreadPool* threadPool, const QString& routePath) { if (!server) return false; server->route(routePath, QHttpServerRequest::Method::Options, [this](QHttpServerResponder& response) { QHttpServerResponse rp(QHttpServerResponder::StatusCode::NoContent); rp.setHeaders(defautl_Header()); response.sendResponse(rp); }); server->route(routePath, QHttpServerRequest::Method::Post, [this, threadPool](const QHttpServerRequest& request) { QByteArray array = request.body(); return QtConcurrent::run(threadPool, [this, array]() { QJsonParseError parseError; QJsonObject jsonObject = QJsonDocument::fromJson(array, &parseError).object(); if (parseError.error != QJsonParseError::NoError) { return error_happened("", "JSON Error", -32700); } QString method = jsonObject["method"].toString(); if (method == "initialize") return handle_initialize(jsonObject); if (method.startsWith("ping")) return handle_ping(jsonObject); if (method.startsWith("tools/list")) return handle_toolist(jsonObject); if (method.contains("tools/call")) return handle_tools_call(jsonObject); return error_happened(jsonObject["id"].toString(), QString("Method '%1' not found").arg(method), -32601); }); }); return true; }这里的关键点是:HTTP 回调本身不阻塞,真正的解析和执行都在线程池里。QtConcurrent::run返回一个 future,QtHttpServer 会等它完成再响应,所以调用方拿到的是完整结果,而不是半截。
会话管理用 QMap 做缓存,按 sessionId 按需创建 widget,避免每次请求都新建界面对象:
qtwidget_planetosm * MainWindow::session(QString id) { if (id.length() < 1) return this->osmWidget; if (m_map_widgets.contains(id)) return m_map_widgets[id]; qtwidget_planetosm * m = new qtwidget_planetosm(this); int nc = tabWidget_session->count(); tabWidget_session->addTab(m, id); m_map_indexes[id] = nc; m_map_widgets[id] = m; return m; }4. 跨线程回主线程:QMetaObject::invokeMethod 的正确用法
GUI 操作必须在主线程执行,这是 Qt 的硬约束。子线程里直接调widget->osm_grab_view()会触发断言。解决办法是QMetaObject::invokeMethod配合Qt::BlockingQueuedConnection,把 lambda 投递到 widget 所属线程执行,并等待结果:
QHttpServerResponse toolfunc_grab_view(McpServer* mcpServer, const QJsonObject& objreq) { QJsonObject params = objreq["params"].toObject(); QString sessionId = params["sessionId"].toString(); qtwidget_planetosm* widget = MainWindow::instance()->session(sessionId); QImage image; QMetaObject::invokeMethod(widget, [widget, &image]() { image = widget->osm_grab_view(); }, Qt::BlockingQueuedConnection); QJsonArray arr_content; QJsonObject result{ {"type", "image"}, {"data", QString::fromLatin1(imageToBase64(image).toBase64())} }; arr_content.append(result); QJsonObject mcpResponse{ {"jsonrpc", "2.0"}, {"id", objreq["id"]}, {"result", QJsonObject{{"isError", false}, {"content", arr_content}}} }; return mcpServer->send_response(mcpResponse); }Qt::BlockingQueuedConnection会阻塞当前工作线程直到主线程执行完 lambda,这样image写回后后续代码才能安全读取。lambda 用[widget, &image]捕获,widget 指针按值传,image 按引用传以便回写。这里有个坑:如果 widget 在等待期间被销毁,引用会悬空,所以会话生命周期要管好,别在请求处理中途删 widget。
更新点标记的工具函数同理,把 GUI 调用包进 invokeMethod:
QHttpServerResponse toolfunc_update_point(McpServer* mcpServer, const QJsonObject& objreq) { QJsonObject params = objreq["params"].toObject(); QString sessionId = params["sessionId"].toString(); QString markName = params["markName"].toString(); double lat = params["lat"].toDouble(); double lon = params["lon"].toDouble(); qtwidget_planetosm* widget = MainWindow::instance()->session(sessionId); bool success = false; QMetaObject::invokeMethod(widget, [widget, markName, lat, lon, &success]() { QVariantMap args; args["name"] = markName; args["lat"] = lat; args["lon"] = lon; QVariantMap result = widget->osm_layer_call_function("Markers", args); success = result["success"].toBool(); }, Qt::BlockingQueuedConnection); QJsonArray arr_content; QJsonObject result{ {"type", "text"}, {"text", success ? "Point updated successfully" : "Failed to update point"} }; arr_content.append(result); QJsonObject mcpResponse{ {"jsonrpc", "2.0"}, {"id", objreq["id"]}, {"result", QJsonObject{{"isError", !success}, {"content", arr_content}}} }; return mcpServer->send_response(mcpResponse); }工具函数的描述对象决定了 Agent 能不能正确调用,description要写清楚用途,inputSchema里每个参数都要标类型和说明,尤其是session_id这种状态保持参数:
QJsonObject toolfunc_grab_view_desc() { return QJsonObject({ {"name", "grab_view"}, {"type", "function"}, {"endpoint", "/map"}, {"method", "POST"}, {"description", "Capture the current map view as an image and return base64 data."}, {"inputSchema", QJsonObject{ {"type", "object"}, {"properties", QJsonObject{ {"session_id", QJsonObject{ {"type", "string"}, {"description", "Session ID for state retention across calls."} }}, {"format", QJsonObject{ {"type", "string"}, {"description", "Image format: png (default), jpg, jpeg."} }} }} }} }); }5. 验证请求与成功结果:并发会话压测与回主线程确认
配置和代码就位后,先做单请求验证。用 curl 打一个tools/list,确认路由和线程池都通:
curl -X POST http://127.0.0.1:8081/mapserver \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}'返回里应该能看到注册的工具列表。接着验证跨线程回主线程是否真的生效:发一个grab_view请求,观察界面是否正常截图、返回的 base64 能否解码成图片。如果主线程被阻塞,界面会卡住,返回也会超时。
并发压测用ab或wrk打多个会话 ID,模拟多 Agent 同时调用:
for i in $(seq 1 20); do curl -X POST http://127.0.0.1:8081/mapserver \ -H "Content-Type: application/json" \ -d "{\"jsonrpc\":\"2.0\",\"id\":\"$i\",\"method\":\"tools/call\",\"params\":{\"name\":\"update_point\",\"sessionId\":\"session$i\",\"markName\":\"P$i\",\"lat\":33.9,\"lon\":116.8}}" & done wait实测下来,20 个并发请求在 16 线程池下能全部返回,每个 session 对应独立的 tab 页,标记互不干扰。验证回主线程是否成功,可以在 invokeMethod 的 lambda 里加一行日志打印线程 ID,确认它和主线程 ID 一致:
QMetaObject::invokeMethod(widget, [widget]() { qDebug() << "GUI thread id:" << QThread::currentThreadId(); widget->osm_grab_view(); }, Qt::BlockingQueuedConnection);如果打印出的线程 ID 和qApp->thread()一致,说明回主线程成功。多会话并行时,每个 session 的 widget 独立,m_map_widgets缓存命中后不再重复创建,响应时间会明显下降。
6. 本篇常见错排查
报错一:QObject::setParent: Cannot set parent, new parent is in a different thread这是 widget 在子线程被创建导致的。检查session()是否只在主线程调用。如果工具函数里直接new qtwidget_planetosm,就会踩这个坑。正确做法是通过MainWindow::instance()->session()获取,且该函数内部若涉及创建,需确保在主线程执行,或改用 invokeMethod 投递创建动作。
报错二:QMetaObject::invokeMethod: No such method或 lambda 不执行invokeMethod传 lambda 时,目标对象必须有效且属于某个线程。如果 widget 指针为空,调用会静默失败。加一层判空:
if (!widget) { return error_happened(objreq["id"].toString(), "Session not found", -32000); }报错三:并发下返回结果错乱,session A 的数据出现在 session B多半是共享了同一个 widget 或全局变量。检查m_map_widgets的 key 是否用了正确的 sessionId,以及工具函数里是否误用了默认 widget。每个请求必须从params["sessionId"]取 ID,不能图省事用固定值。
报错四:请求超时,线程池打满setMaxThreadCount太小或请求里有阻塞操作。Qt::BlockingQueuedConnection本身会阻塞工作线程,如果主线程繁忙,工作线程会排队。适当调大线程池,或把非 GUI 的耗时计算挪到 invokeMethod 之外。
报错五:QHttpServer绑定失败,端口被占用换端口或先tcpServer->close()。启动前检查listen返回值,别忽略错误直接往下走。
7. 继续接入与调试入口
把上面的骨架跑通后,下一步是让 Agent 真正用起来。模型对话调试可以用:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite长期跑编码或自动化 Agent,Coding Plan 更合适:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewriteKey 管理在控制台:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite接入细节查文档:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite如果你用 Claude Code 这类工具对接,Anthropic 兼容入口在这里:
https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite最后提醒一句:会话缓存别无限增长,max_sessions和idle_timeout_sec要配合清理逻辑,否则跑久了内存会涨。我一般会在会话空闲超时后主动删 tab 并从 QMap 移除,避免悬空指针。