MCP 服务链本地搭建:把 MCPClient 的 BASE_URL 改到 TaoToken 后,mcp.json 不用动
1. mcp.json 不用动——真正没着落的是 .env 里的对话通道自己动手搭一条 MCP 服务链前面几步其实都很顺mcp.json 里声明 weather-http 和 amap-amap-sseMCPClient 也能把工具列表读进来。真正让人卡住的是最后一步。很多教程只丢下一句「需要提前在 .env 文件中设置相关环境变量」可 API_KEY 去哪申请、BASE_URL 填什么、MODEL 该写哪个一个字都没提。这篇文章把这段补完mcp.json 保持原样打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建 API Key把对话通道指到 TaoToken。MCP 社区现在已经很成熟官方和第三方提供了大量现成的 MCP 服务器想调哪个就调哪个。但如果你和我一样喜欢钻研看完现成实现总会冒出那个问题能不能自己写一个客户端答案是可以而且代码量不大。真正写起来你就会发现MCP 侧的工具发现、连接、调用都有人帮你封装好了最难的反而是初始化大模型客户端那三行——API_KEY、BASE_URL、MODEL缺一个就报错。这三个变量为什么要单独准备因为 MCP 只是「工具调度层」负责把 weather-http 这样的本地服务和 amap-amap-sse 这样的远程服务接到你的程序里而真正理解你提问、决定是否调用工具、把结果组织成人话的是背后的大模型。那部分请求走的是 OpenAI 兼容协议需要一个独立的 API 通道。TaoToken 就是专门为这类场景准备的统一接入通道Key 从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建Base URL 填 https://taotoken.net/api 即可。2. 先看完整链路再决定动哪个文件这条链路从下往上分成四层。最底层是两个 MCP 服务weather-http 是你自己起的本地程序监听 127.0.0.1:8002amap-amap-sse 是高德地图的远程 MCP 服务走 SSE 协议。往上一层是 mcp.json它只负责告诉客户端「有哪些服务、用什么协议、地址是什么」。再往上是 MCPClient它读取配置、建立连接、把工具列表注入系统提示词。最上面才是大模型对话通道也就是 OpenAI 客户端初始化的部分。搞清楚分层之后该动哪个文件就一目了然了。mcp.json 是服务发现层里面的 url、type、name 都是描述 MCP 服务的和对话通道没有任何关系所以原样保留。MCP_Prompt.txt 是系统提示词里面放着$MCP_INFO$占位符和工具调用格式说明也不需要改。需要动的只有 .env——把 API_KEY 换成 TaoToken 的 Key把 BASE_URL 换成 https://taotoken.net/apiMODEL 按模型广场的实际 ID 填。mcp.json 一行不用改weather-http 和 amap-amap-sse 的连接逻辑也不会受影响。3. 第一步mcp.json 保持原样weather-http 与 amap-amap-sse 逐个确认3.1 配置文件与字段说明先看配置文件本身。这个文件描述了两个 MCP 服务类型分别是 streamable_http 和 sse{ mcpServers: { weather-http: { isActive: true, type: streamable_http, url: http://127.0.0.1:8002/mcp, name: weather-http }, amap-amap-sse: { isActive: true, type: sse, url: https://mcp.amap.com/sse?key{高德key}, name: amap-amap-sse } } }四个字段的职责用一个表说清楚字段含义本次是否需要改isActive是否激活该 MCP 服务不需要typeMCP 服务类型stdio / sse / streamable_http不需要url远程或本地服务的地址不需要name服务别名用于日志和工具注入标识不需要3.2 两个服务各自的启动前提这里有两个容易踩的坑和 TaoToken 无关但会影响验证。第一个amap 的 url 里带着{高德key}占位符这是高德开放平台申请的 Key和 TaoToken 的 API Key 是两回事别混在一起。第二个weather-http 的 type 是 streamable_http这个类型要求 8002 端口上真的有服务在监听。如果跑验证时报 ConnectionError先确认本地服务是否启动而不是急着去改 mcp.json。4. 第二步去 TaoToken 创建 Key把 .env 的 BASE_URL 指过去4.1 申请 Key 与确认模型 ID原文第 3 步只有一句话「需要提前在 .env 文件中设置相关环境变量」。这句话对第一次搭链路的开发者来说信息量几乎为零。这里给出可落地的操作打开 TaoToken 注册账号在控制台创建一个 API Key然后去模型广场确认你要用的模型 ID以广场当时列表为准不要凭记忆填日期后缀。接下来在项目根目录创建 .env 文件API_KEYYOUR_API_KEY BASE_URLhttps://taotoken.net/api MODEL4.2 .env 的参数对照变量来源注意事项API_KEYTaoToken 控制台创建用占位符 YOUR_API_KEY 时代码会报 401换成真实 KeyBASE_URL固定填 https://taotoken.net/api末尾不要加 /v1也不要加任何 UTM 参数MODEL模型广场模型 ID 以实际列表为准不能自己猜这里最容易出的错是把 Base URL 填成官网地址。官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 是给人注册、看模型、看用量用的填进 .env 的接口地址必须是 https://taotoken.net/api这两个地址分工不同混了就会连不上。TaoToken 的定位是统一 API 兼容通道所有消耗 Token 的请求都走这个接口而 MCP 服务本身仍然走 mcp.json 里原来的 url。5. 第三步MCPClient 读取 .env用 OpenAIClient 走 TaoToken5.1 核心代码MCPClient 的核心改动就在__init__里不再硬编码任何模型参数而是从 .env 加载。下面的版本支持 sse、stdio、streamable_http 三种类型mcp.json 原文不需要任何调整import asyncio import json import os import re from typing import Optional from dotenv import load_dotenv from mcp import ClientSession, StdioServerParameters from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamable_http_client from openai import OpenAI load_dotenv() class MCPClient: def __init__(self): self.session: Optional[ClientSession] None self.exit_stack asyncio.AsyncExitStack() self.api_key os.getenv(API_KEY) self.base_url os.getenv(BASE_URL) self.model os.getenv(MODEL) if not self.api_key or not self.base_url or not self.model: raise RuntimeError(请在 .env 中填写 API_KEY、BASE_URL、MODEL) self.client OpenAI(api_keyself.api_key, base_urlself.base_url) self.sessions {} self.messages [] with open(./MCP_Prompt.txt, r, encodingutf-8) as f: self.system_prompt f.read() async def connect_from_config(self, mcp_json_file: str): with open(mcp_json_file, r, encodingutf-8) as f: config json.load(f) for name, cfg in config.get(mcpServers, {}).items(): if not cfg.get(isActive, False): continue server_type cfg.get(type, stdio).lower() url cfg.get(url) try: if server_type sse: await self._connect_sse(name, url) elif server_type streamable_http: await self._connect_http(name, url) elif server_type stdio: await self._connect_stdio(name, cfg.get(command), cfg.get(args, []), cfg.get(env, {})) else: print(f{name} 的类型 {server_type} 不受支持已跳过) except Exception as exc: print(f{name} 连接失败: {exc}) async def _connect_sse(self, name: str, url: str): read, write await self.exit_stack.enter_async_context(sse_client(url)) await self._register_session(name, read, write) async def _connect_http(self, name: str, url: str): read, write, _ await self.exit_stack.enter_async_context(streamable_http_client(url)) await self._register_session(name, read, write) async def _connect_stdio(self, name: str, command: str, args: list, env: dict): params StdioServerParameters(commandcommand, argsargs, envenv) read, write await self.exit_stack.enter_async_context(stdio_client(params)) await self._register_session(name, read, write) async def _register_session(self, name: str, read, write): session await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.sessions[name] session tools (await session.list_tools()).tools lines [f## {name}, ### Available Tools] lines [f- {t.name}: {t.description} for t in tools] self.system_prompt self.system_prompt.replace($MCP_INFO$, \n.join(lines) \n$MCP_INFO$) print(fSuccessfully connected to {name} with tools: {[t.name for t in tools]}) async def ask(self, query: str) - str: self.messages [ {role: system, content: self.system_prompt}, {role: user, content: query}, ] answer self.client.chat.completions.create( modelself.model, max_tokens1024, messagesself.messages ).choices[0].message.content tag re.search(ruse_mcp_tool.*?/use_mcp_tool, answer, re.S) if not tag: return answer raw tag.group(0) server_name re.search(rserver_name(.*?)/server_name, raw).group(1) tool_name re.search(rtool_name(.*?)/tool_name, raw).group(1) tool_args json.loads(re.search(rarguments(.*?)/arguments, raw).group(1)) result await self.sessions[server_name].call_tool(tool_name, tool_args) self.messages.append({role: assistant, content: answer}) self.messages.append({role: user, content: f[工具 {tool_name} 返回: {result}]}) return self.client.chat.completions.create( modelself.model, max_tokens1024, messagesself.messages ).choices[0].message.content async def chat_loop(self): print(MCP Client Started!\n) while True: query input(Query: ).strip() if query.lower() quit: break if not query: continue print(await self.ask(query)) async def close(self): await self.exit_stack.aclose() async def main(): client MCPClient() try: await client.connect_from_config(./mcp.json) await client.chat_loop() finally: await client.close() if __name__ __main__: asyncio.run(main())5.2 服务端与客户端的职责参考这段代码里_connect_http和_connect_sse负责和服务端握手握手成功后调list_tools()拿工具清单。服务端把工具包成 MCP 协议客户端负责连接与对话TaoToken 不参与这两步它只出现在OpenAI(api_key..., base_url...)那一行。如果想验证「服务端和客户端到底通没通」可以直接注释掉chat_loop()之前的初始化逻辑只跑connect_from_config看两个服务是否都打印出工具列表。这一步通过之后再谈对话能省掉很多来回猜的时间。6. 验证先看 weather-http 工具注入再输入 Query6.1 预期输出在项目目录运行python mcp_client.py预期输出像这样Successfully connected to weather-http with tools: [...] Successfully connected to amap-amap-sse with tools: [...] MCP Client Started! Query:看到 weather-http 的工具列表被打印出来说明 mcp.json 的 streamable_http 配置在你当前的 MCPClient 版本里是能用的本地服务链没有因为 .env 的改动而受影响。这一步是整个改造的底线mcp.json 原样保留MCP 服务链照常工作。6.2 两种 Query 的验证顺序接下来输入 Query顺序有讲究。先问一个不依赖工具的问题比如「用一句话解释 MCP 协议」这一步只验证 TaoToken 的对话通道通不通。如果返回正常再问需要调用天气或地图服务的问题确认工具调用链路也通。这样万一报错你能立刻判断是 MCP 服务的问题还是大模型通道的问题而不是两头一起排查。确认整个链路走的是 TaoToken最直接的办法是回到 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 的用量页面看刚才那几条 Query 是否产生了记录。有记录说明 MCPClient 的请求确实发到了 TaoToken而不是某个本地代理或默认地址。这一步值得做因为在 .env 里填错 Base URL 时有些 OpenAI SDK 会静默回退到默认 api.openai.com这时候对话看起来正常但 Key 根本不是你想用的那个。7. 排障只聊这次改动可能引入的错如果验证时遇到问题按下面的顺序排查别急着改 mcp.json。7.1 401 UnauthorizedAPI_KEY 不对检查 .env 里是否还留着YOUR_API_KEY占位符或者 Key 复制时多了一个空格。Key 需要从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 控制台创建别拿 MCP 服务自己的 Key 来替代。7.2 Model Not FoundMODEL 填了不存在的 ID这种报错通常出现在client.chat.completions.create那一步。模型 ID 以 TaoToken 模型广场当时列表为准代码里任何地方都不要写死模型名统一从 .env 读。如果刚换过模型先回 .env 改 MODEL再重启脚本不要只改代码里临时传的那个参数。7.3 ConnectionErrorMCP 服务本身没就绪这个错和 TaoToken 无关。weather-http 是本地服务8002 端口必须真的有程序在跑amap-amap-sse 的 url 里如果带着未替换的{高德key}也会连不上。处理方式是回到 mcp.json 检查服务地址和 Key而不是去动 BASE_URL。8. 跑通之后去控制台对一下这次调用配置保存后先在 TaoToken 模型对话 里用同一把 Key 发一条消息确认模型 ID 和 Base URL 都没填错。若打算长期用这套链路写代码可以看看 Coding Plan 能不能覆盖日常消耗Key 的统一管理入口在 控制台 API Keys。如果之后想把同一个 Key 用到 Claude Code 里环境变量对照见 接入文档。