jFinal 里用 SolonMCP 搭的 MCP 服务端,/mcp/sse一调就通,getWeather也能正常回“晴,14度”,可偏偏ChatModel.of(apiUrl)换到统一模型通道时就抛 401。排障第一步先记一件事:去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建 Key,TaoToken 的接口 Base URL 是https://taotoken.net/api,绝对不能把官网页面地址填进ChatModel.of()。
这篇不做泛泛的 MCP 入门,只解决原文第 3、4 节里那个最容易被忽略的坑:已经有一份能跑通的McpClientProvider,也有一份能跑的ChatModel.of(apiUrl).provider("ollama").model(...),当你把模型提供者从本机 Ollama 换成线上统一通道时,apiUrl到底该写什么、Key 该放到哪一层、为什么会出现 401/404、以及defaultToolsAdd(toolProvider)里的 MCP 工具集要不要跟着改。读完之后你应该能做到:MCP 工具集继续由本机McpClientProvider提供,模型请求单独走 TaoToken 通道,两条链路各管各的,互不干扰。
1. getWeather 能返回,ChatModel.of 却报 401:先把两个 apiUrl 分开
1.1 复现现场:同一个变量名,两条完全不同的链路
原文第 3 节的客户端代码是这样的:构造一个McpClientProvider,apiUrl指向http://localhost:8080/mcp/sse,然后callToolAsText("getWeather", map)拿到文本结果,再顺手readResourceAsText("config://app-version")读个资源。这一段没毛病,因为它走的是 MCP 协议里的 SSE 通道,服务端是你在 2.x 节里刚起的 Undertow。
到了第 4 节,事情变得微妙。代码里出现了第二个apiUrl,写法是private static final String apiUrl = "http://127.0.0.1:11434/api/chat";,配provider = "ollama"、model = "qwen2.5:1.5b",然后ChatModel.of(apiUrl).provider(provider).model(model).defaultToolsAdd(toolProvider).build()。两个apiUrl名字一样,含义完全不同:前者是 MCP 服务端点,后者是 LLM 的推理接口。
很多人就是在“换个模型提供者”这一步把两者合并了。想换成线上通道时,直接把ChatModel.of(...)里的常量替换成一个看起来像“平台地址”的字符串,Key 又忘了传,于是 401 就来了。把两条链路在代码里改成两个明确命名的常量,是止血的第一步。
1.2 401 和 404 分别指向哪里
401 的意思很直白:服务端认得这个地址,但没收到合法的凭证,或者收到的凭证不属于这个通道。落到本篇场景,通常是三种情况:Key 压根没传、Key 传到了McpClientProvider那一层而不是ChatModel那一层、或者apiUrl写成了一个不接受这种鉴权的页面地址。
404 则通常跟鉴权无关,是路径拼错了。最常见的两种:把 Base URL 写成https://taotoken.net/api/v1,导致实际请求路径变成/api/v1/chat/completions这类重复前缀;或者把给人点的落地页地址填进了接口字段,路径自然对不上。记住一个分工:https://taotoken.net/?utm_source=taotoken_aicg_blog_end是浏览器里打开用来注册、建 Key、看模型列表的;https://taotoken.net/api才是塞进代码里的接口地址。
2. SolonMCP 那一侧不用动:@McpServerEndpoint 还是老样子
2.1 HelloApp 里必须开的异步开关
先确认服务端这头是干净的。原文 2.1 节的入口类继承JFinalConfig,用UndertowServer.create(HelloApp.class)起服务,端口 8080,关键在于onDeploy里给 jfinal 过滤器开了异步支持。MCP 内部是响应式实现,同步过滤器会把 SSE 的长连接堵住,不开这个开关,/mcp/sse很快就会断。
public class HelloApp extends JFinalConfig { public static void main(String[] args) { UndertowServer.create(HelloApp.class) .setDevMode(false) .setPort(8080) .onDeploy((cl, di) -> { // MCP 是响应式模型,jfinal 过滤器必须开异步 di.getFilters().get("jfinal").setAsyncSupported(true); }) .start(); } public void configPlugin(Plugins me) { me.add(mcpServerConfig); } public void configHandler(Handlers me) { me.add(mcpServerConfig); } private McpServerConfig mcpServerConfig = new McpServerConfig(); }2.2 McpServerConfig:只拦 /mcp/ 前缀的那段路由
McpServerConfig同时实现IPlugin和Handler,start()里用Solon.start(...)拉起 Solon 容器并加载mcpserver.yml,stop()里做Solon.stopBlock(false, ...)的优雅停机。handle()方法只处理target以/mcp/开头的请求,其余原样交给next.handle(...)。这段逻辑跟 401 一点关系都没有,所以排障时不要来这里乱改。
public class McpServerConfig extends Handler implements IPlugin { public boolean start() { Solon.start(McpServerConfig.class, new String[]{"--cfg=mcpserver.yml"}); return true; } public boolean stop() { if (Solon.app() != null) { Solon.stopBlock(false, Solon.cfg().stopDelay()); } return true; } @Override public void handle(String target, HttpServletRequest req, HttpServletResponse resp, boolean[] isHandled) { if (target.startsWith("/mcp/")) { Context ctx = new SolonServletContext(req, resp); try { Solon.app().tryHandle(ctx); if (isHandled != null && isHandled.length > 0) { isHandled[0] = true; } } finally { ContextUtil.currentRemove(); } } else if (next != null) { next.handle(target, req, resp, isHandled); } } }2.3 McpServer:getWeather 就挂在这个端点下
工具类是整条链路里最轻的一环,一个注解加几个方法。@McpServerEndpoint(sseEndpoint = "/mcp/sse")声明了客户端要连的路径,@ToolMapping声明工具,@Param(description = ...)给参数加描述。想让模型正确挑到getWeather,描述字段写清楚比方法名写得好更重要。编译参数建议打开-parameters,否则参数名拿不到,调用时可能对不上。
@McpServerEndpoint(sseEndpoint = "/mcp/sse") public class McpServer { @ToolMapping(description = "查询天气预报") public String getWeather(@Param(description = "城市位置") String location) { return "晴,14度"; } @ResourceMapping(uri = "config://app-version", description = "获取应用版本号") public String getAppVersion() { return "v3.2.0"; } @PromptMapping(description = "生成关于某个主题的提问") public Collection<ChatMessage> askQuestion(@Param(description = "主题") String topic) { return Arrays.asList(ChatMessage.ofUser("请解释一下'" + topic + "'的概念?")); } }服务端四个文件确认完,就可以把注意力全部转到客户端那句ChatModel.of(apiUrl)上。记住:/mcp/sse通,只能证明工具集可用,完全不能证明模型通道的地址和 Key 是对的。
3. ChatModel 的 apiUrl 换成 https://taotoken.net/api,Key 单独放
3.1 先去把 Key 和模型 ID 拿到手
打开 TaoToken 注册登录,进控制台创建一把 API Key,本文统一用YOUR_API_KEY占位,别把真 Key 提交进仓库。同一页面能看模型广场,MODEL_ID就从那里的当时列表里挑一个可用的对话模型,不要凭记忆写gpt-5或者自己加日期后缀,那类字符串在通道里通常直接 404。
如果你只是先验证链路能不能通,先记两个值就够了:接口地址https://taotoken.net/api,以及一把 Key。模型 ID 等你要跑代码时再回模型广场抄一次,比提前写死在常量里靠谱。
3.2 三种 apiUrl 写法对照
写进ChatModel.of(...)的值 | 结果 |
|---|---|
https://taotoken.net/?utm_source=taotoken_aicg_blog_end | 401 或 404,这是给人点的页面 |
https://taotoken.net/api/v1 | 路径前缀重复,容易 404 |
https://taotoken.net/api | 正确,末尾不带/v1 |
表格第三行就是本篇唯一要你改的地方。原文那个http://127.0.0.1:11434/api/chat是 Ollama 自己的完整端点,换到兼容通道时不要照着它的形状去拼/api/chat或/api/v1,直接把 Base URL 交给 SDK,它会自己补后面的路径。
3.3 改写后的 McpClientTest
下面这份是原文第 4 节代码的通道替换版。两处注意:MCP_SSE_URL保持本机不变,MODEL_BASE_URL换成统一通道;Key 从环境变量读,不写进源码。provider与apiKey的具体字段名以你当前使用的 solon-ai 版本为准,如果版本里叫别的名字,在对应 provider 配置项里找等效字段。
public class McpClientTest { // MCP 工具集:仍然连本机 SolonMCP,不要换成通道地址 private static final String MCP_SSE_URL = "http://localhost:8080/mcp/sse"; // 模型通道:统一入口的 Base URL,末尾不要加 /v1 private static final String MODEL_BASE_URL = "https://taotoken.net/api"; // 模型 ID 以模型广场当时列表为准 private static final String MODEL_ID = "YOUR_MODEL_ID"; public static void main(String[] args) throws Exception { McpClientProvider toolProvider = McpClientProvider.builder() .apiUrl(MCP_SSE_URL) .build(); ChatModel chatModel = ChatModel.of(MODEL_BASE_URL) .provider("openai") .apiKey(System.getenv("TAOTOKEN_API_KEY")) .model(MODEL_ID) .defaultToolsAdd(toolProvider) .build(); ChatResponse resp = chatModel.prompt("杭州今天的天气怎么样?").call(); System.out.println(resp.getMessage()); } }3.4 Key 不进代码:环境变量加本地配置
export TAOTOKEN_API_KEY=YOUR_API_KEYKey 从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建后复制,落到本地环境变量或者 IDE 的运行配置里,~/.zshrc、~/.bashrc都行,别写进McpServerConfig,也别挂到McpClientProvider上。MCP 客户端连的是你本机的 8080,本来就不需要外部 Key,把它传进去只会让人误以为“两个地址用同一套凭证”,下次排障更乱。
4. 401/404 对照表:本篇三个高频错法
4.1 401:Key 根本没参与这次请求
症状是服务端回鉴权失败,日志里能看到请求确实到了通道,但没带Authorization。原因通常是apiKey(...)这一行漏了,或者写在了McpClientProvider的 builder 上。另一种情况是环境变量没生效——IDE 里改了运行配置但没重启进程,System.getenv返回null,SDK 就不会带凭证。
4.2 404:路径前缀重复或多写一层
把官网落地页地址填进ChatModel.of(...),或者写成https://taotoken.net/api/v1,走的路径就会和 SDK 自己拼的路径叠起来。判断方法很简单:让 SDK 打印一次实际请求的完整 URL,和https://taotoken.net/api开头的拼接结果对一下,多出来的那段就是问题所在。
4.3 /mcp/sse 自己 404:别往 Key 上想
如果连McpClientProvider那步也开始 404,方向就完全不同了。检查McpServerConfig.handle()里的target.startsWith("/mcp/")有没有写错,@McpServerEndpoint(sseEndpoint = "/mcp/sse")有没有被扫到,以及 Undertow 端口是不是被别的进程占了。这三项跟模型通道、跟 Key 都没有关系,不要因为两个地址都叫apiUrl就混在一起查。
5. 排完错怎么验证:先页面发一条,再回 Java 跑 prompt
5.1 用同一把 Key 在模型对话里验一遍
代码改完先别急着跑main。打开 TaoToken 模型对话,用刚才那把 Key 和同一个模型 ID 发一条测试消息。这一步能把问题范围瞬间缩小:页面通、Java 不通,说明是代码里的地址或 Key 传参有问题;页面也不通,说明 Key 或模型 ID 本身就不对。
5.2 回到 Java 侧看工具调用有没有被带上
main跑起来后重点看两件事:getWeather有没有被模型选中并调用,以及最终回答里有没有把“晴,14度”这个工具结果串进去。如果模型回了话但没调工具,检查工具描述和方法参数;如果压根没回话,回到第 4 节的 401/404 对照表。
5.3 在控制台对账,确认这次调用记上了
调用成功之后,建议回一次 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的用量页面,看这次请求有没有落到记录里。有记录说明链路是真的通了,不是某种缓存假象;没记录但代码有输出,就要回去确认MODEL_BASE_URL是不是又被改回了带/v1的写法。排障做完整闭环,下次换模型才不用重新猜。
6. 工具集留本机,模型请求走通道:这套组合的长期姿势
6.1 MCP 工具只能生成和解释,不能替你去连生产库
getWeather这类示例工具无所谓,但如果你的 MCP 工具后面接了数据库查询,记住边界:模型通过工具拿到的应该是“生成出来的 SQL 文本”,真正执行要在你本地的 SQL 客户端或运维终端里做,再把报错或结果贴回对话。任何写成“让模型直接连上库执行诊断”的用法,都不该出现在生产环境。TaoToken 在这条链路里只承担模型请求这一小段。
6.2 想换模型,只动一个字符串
这是把MODEL_BASE_URL单独抽成常量的好处。以后从对话模型换到另一个更擅长的模型,只需要把MODEL_ID改成模型广场上当前可用的那个 ID,MCP_SSE_URL和toolProvider那几行完全不用碰。MCP 工具集是稳定的,模型是易变的,把易变的部分收敛到一个常量上,改动成本最低。
6.3 jFinal + SolonMCP 这套接下来怎么做
先把本机/mcp/sse跑通,再把ChatModel.of(...)指向https://taotoken.net/api,中间不混用任何页面地址。准备长期拿它写代码的话,可以去 Coding Plan 看看套餐是否够用;需要新建或轮换 Key 就在 控制台 API Keys 操作;如果你还想把这套通道搬到终端里,Claude Code 的变量写法可以照 接入文档 对一遍,ANTHROPIC_BASE_URL同样是https://taotoken.net/api,不要带/v1。