1. 多 MCP 服务器接入时,Base URL 和鉴权入口为什么会散成一团
MCP 客户端同时接入多个 MCP 服务器,是很多人从「跑通一个 demo」走向「日常真用」时绕不开的一步。MCP 全称 Model Context Protocol,它做的事情说白一点,就是让大模型能通过一套统一协议去调用外部工具——查天气、读写文件、查数据库、调内部接口。单个服务器时,你在.env里写一个BASE_URL、一个API_KEY、一个MODEL,跑起来就完事;可一旦服务器变成两个、三个,问题立刻冒出来。
我遇到过的典型混乱是这样的:天气服务器用硅基流动的地址,文件服务器想换成另一家,代码助手又想走第三个入口。结果.env里塞了BASE_URL_1、BASE_URL_2、WEATHER_KEY、FS_KEY,客户端代码里到处os.getenv,改一个地址要翻三个文件。更麻烦的是鉴权:每个服务器背后如果各自对接不同的大模型供应商,Key 的格式、额度、限流策略都不一样,排查一次 401 要挨个试。
这篇要解决的就是这个场景:MCP 客户端连接多服务器时,把 Base URL 统一改到 TaoToken,让所有服务器共用同一个鉴权入口。TaoToken 是一个兼容 OpenAI 接口规范的模型调用入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。它的价值在于:你不需要为每个 MCP 服务器单独维护一套供应商配置,客户端只认一个 Base URL、一个 Key,模型 ID 按需切换即可。
适合谁看?已经跟着基础篇跑通过单个 MCP 服务器、手里有 Python 和 uv 环境、想让 LLM 一次调用多个工具的人。如果你还没搭过服务器,建议先把天气或文件服务器跑起来再回来,因为这篇的重点是「多服务器 + 统一入口」,不是从零讲 MCP 是什么。
下面我会先给出多服务器的目录结构和配置片段,再把 Base URL 统一改到 TaoToken,最后用一次请求同时触发两个服务器的工具,验证连通性和鉴权是否都生效。整个过程可以照着敲。
2. TaoToken 前置准备:一个 Key 管住所有 MCP 服务器
在动手改客户端之前,先把 TaoToken 这边的入口准备好。这一步不复杂,但顺序别搞反——先有 Key,再去改配置,否则你改完代码发现没地方填 Key,还得回头。
2.1 拿到 API Key 和确认 Base URL
打开 TaoToken 的控制台,进入 API Keys 页面创建一个 Key。地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建后复制那串以sk-开头的字符串,先存到记事本里,后面要填进.env。
Base URL 这一项要特别注意:TaoToken 的 API 根地址是https://taotoken.net/api,但在 OpenAI 兼容客户端里,通常要写成带/v1的形式,也就是https://taotoken.net/api/v1。这一点和很多兼容入口一致,写错了会直接 404 或者local proxy failed。我建议你先在模型对话页面确认一下当前可用的模型 ID,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,把你要用的模型名记下来,比如某个支持 function calling 的模型。
注意:MCP 的工具调用依赖模型的 function calling 能力。选模型时优先挑明确支持工具调用的,否则客户端把
tools传过去,模型不认,会一直返回普通文本,看起来像「工具没生效」,其实是模型不支持。
2.2 为什么统一入口能解决多服务器鉴权分散
原来的做法是每个服务器一套配置。天气服务器读WEATHER_BASE_URL,文件服务器读FS_BASE_URL,客户端初始化时分别OpenAI(api_key=..., base_url=...)。服务器一多,MCPClient.__init__里就堆满分支。
统一到 TaoToken 后,客户端只创建一个OpenAI实例,base_url固定指向 TaoToken,api_key也只有一个。多服务器的差异被收敛到「连哪个服务器脚本」这一层,模型调用层完全共用。这样带来的直接好处有三个:改地址只改一处;Key 轮换只换一个;排查 401 时只需要确认一个 Key 是否有效。
如果你后面要长期跑编码类 Agent,可以考虑 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,它更适合高频、长时间的调用场景。这篇先用按量 Key 把流程跑通。
2.3 目录结构先摆清楚
我沿用基础篇的结构,服务器放在server/下,客户端放在client/mcp-client/下。多服务器就是多几个子目录:
project/ ├── server/ │ ├── weather/ │ │ ├── weather.py │ │ └── .venv/ │ └── filesystem/ │ ├── filesystem.py │ └── .venv/ └── client/ └── mcp-client/ ├── client_tools.py ├── .env └── .venv/两个服务器各自独立运行、独立虚拟环境,客户端通过 stdio 分别启动它们。这样即使某个服务器依赖冲突,也不会互相影响。文件服务器我沿用基础篇里的create_file、read_file、write_file三个工具,天气服务器保留查询工具,这里不重复贴服务器代码,重点放在客户端如何统一入口。
3. 可复制配置:把 Base URL 统一改到 TaoToken
这一节是全文的核心,给出可以直接复制的.env和客户端配置片段。路径和字段名我会写清楚,你照着改就行。
3.1 .env 文件:只留一套模型配置
在client/mcp-client/下新建或覆盖.env:
# TaoToken 统一入口 BASE_URL=https://taotoken.net/api/v1 API_KEY=sk-你的TaoToken密钥 MODEL=你的模型ID # 多服务器脚本路径(相对 client_tools.py 所在目录) WEATHER_SERVER=../../server/weather/weather.py FILESYSTEM_SERVER=../../server/filesystem/filesystem.py对比基础篇,这里最大的变化是:不再有WEATHER_BASE_URL、FS_BASE_URL这类分服务器字段。所有服务器共用BASE_URL、API_KEY、MODEL三项。服务器路径单独抽出来,方便增删服务器时只改这一处。
提示:
.env不要提交到公开仓库。如果你用 git,把.env加进.gitignore,只提交一份.env.example作为模板。
3.2 客户端初始化:一个 OpenAI 实例服务所有服务器
下面是client_tools.py的关键部分。我保留了多服务器连接、工具映射、循环调用工具的逻辑,但把模型客户端收敛成单例:
import asyncio import os import json from contextlib import AsyncExitStack from openai import OpenAI from dotenv import load_dotenv from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client load_dotenv() class MCPClient: def __init__(self): self.exit_stack = 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: raise ValueError("未找到 API_KEY,请在 .env 中配置") if not self.base_url: raise ValueError("未找到 BASE_URL,请在 .env 中配置") # 统一入口:所有 MCP 服务器共用这一个客户端 self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) self.sessions = {} self.tools_map = {} async def connect_to_server(self, server_id: str, server_script_path: str): if server_id in self.sessions: raise ValueError(f"服务器 {server_id} 已连接") is_python = server_script_path.endswith(".py") is_js = server_script_path.endswith(".js") if not (is_python or is_js): raise ValueError("服务器脚本必须是 Python 或 JavaScript 文件") command = "python" if is_python else "node" server_params = StdioServerParameters( command=command, args=[server_script_path], env=None, ) stdio_transport = await self.exit_stack.enter_async_context( stdio_client(server_params) ) stdio, write = stdio_transport session = await self.exit_stack.enter_async_context( ClientSession(stdio, write) ) await session.initialize() self.sessions[server_id] = {"session": session} print(f"已连接到 MCP 服务器: {server_id}") response = await session.list_tools() for tool in response.tools: self.tools_map[tool.name] = server_id关键点在于self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)这一行。它只创建一次,后面无论连多少个服务器,模型调用都走它。tools_map记录「工具名 → 服务器 ID」,这样模型返回某个工具调用时,客户端知道该去哪个 session 执行。
3.3 工具列表整合与循环调用
继续补上list_tools和process_query:
async def list_tools(self): if not self.sessions: print("没有已连接的服务器") return print("已连接的服务器工具列表:") for tool_name, server_id in self.tools_map.items(): print(f"工具: {tool_name}, 来源服务器: {server_id}") async def process_query(self, query: str) -> str: messages = [{"role": "user", "content": query}] available_tools = [] for tool_name, server_id in self.tools_map.items(): session = self.sessions[server_id]["session"] response = await session.list_tools() for tool in response.tools: if tool.name == tool_name: available_tools.append({ "type": "function", "function": { "name": tool.name, "description": tool.description, "parameters": tool.inputSchema, }, }) while True: response = self.client.chat.completions.create( model=self.model, messages=messages, tools=available_tools, ) choice = response.choices[0] if choice.finish_reason == "tool_calls": for tool_call in choice.message.tool_calls: tool_name = tool_call.function.name tool_args = json.loads(tool_call.function.arguments) server_id = self.tools_map.get(tool_name) if not server_id: raise ValueError(f"未找到工具 {tool_name} 对应的服务器") session = self.sessions[server_id]["session"] result = await session.call_tool(tool_name, tool_args) print(f"[调用工具 {tool_name} @ {server_id}] 参数: {tool_args}") messages.append({ "role": "tool", "content": result.content[0].text, "tool_call_id": tool_call.id, }) else: return choice.message.content注意parameters字段。有些教程写的是input_schema,但 OpenAI 兼容接口要求的是parameters。写错的话模型看不到工具参数定义,会报参数校验错误或者干脆不调用工具。这是我在多服务器场景里踩过的坑之一。
3.4 主函数:一次连接多个服务器
async def main(): client = MCPClient() try: await client.connect_to_server( "weather", os.getenv("WEATHER_SERVER") ) await client.connect_to_server( "filesystem", os.getenv("FILESYSTEM_SERVER") ) await client.list_tools() await client.chat_loop() finally: await client.clean() if __name__ == "__main__": asyncio.run(main())chat_loop和clean沿用基础篇即可,这里不重复。到这里,配置部分就完成了:一个.env、一个统一客户端、两个服务器连接。接下来验证。
4. 验证请求:一次提问同时触发两个服务器的工具
配置改完,最怕的是「看起来连上了,其实鉴权没生效」。所以验证要设计成一次请求同时用到两个服务器的工具,这样连通性和鉴权一起验。
4.1 启动客户端
在client/mcp-client/目录下:
uv venv .venv\Scripts\activate uv add openai python-dotenv mcp uv run client_tools.py启动后应该看到:
已连接到 MCP 服务器: weather 已连接到 MCP 服务器: filesystem 已连接的服务器工具列表: 工具: get_weather, 来源服务器: weather 工具: create_file, 来源服务器: filesystem 工具: read_file, 来源服务器: filesystem 工具: write_file, 来源服务器: filesystem MCP 客户端已启动!输入 'exit' 退出如果这里就报 401 或者local proxy failed,说明 Base URL 或 Key 有问题,先别往下走,去看第 5 节。
4.2 设计一个跨服务器的提问
在交互提示符下输入:
问: 帮我查询广东省东莞市未来七天的天气预报,并且在当前目录创建一个 weather.txt 文件,把查询到的天气内容写入文件中。这个提问会触发两次工具调用:先调get_weather(天气服务器),拿到结果后再调create_file或write_file(文件服务器)。如果两个服务器都正常、鉴权都生效,你会看到类似输出:
[调用工具 get_weather @ weather] 参数: {'city': '东莞', 'days': 7} [调用工具 create_file @ filesystem] 参数: {'file_name': 'weather.txt', 'content': '...'} AI回复: 已为你查询东莞未来七天天气,并写入 weather.txt。4.3 怎么判断鉴权真的生效了
光看工具被调用还不够,因为工具执行是本地 stdio,不经过 TaoToken。真正走 TaoToken 的是self.client.chat.completions.create这一步。判断方法有两个:
第一,看有没有报错。如果 Key 无效,这一步会抛AuthenticationError,客户端会打印发生错误: ...。没有报错,说明鉴权通过。
第二,看模型是否真的返回了tool_calls。如果模型不支持工具调用,finish_reason会是stop,直接返回文本,工具永远不会被触发。所以「工具被调用」本身就证明了两件事:鉴权生效、模型支持 function calling。
提示:如果你想让验证更彻底,可以在
.env里故意把API_KEY改错一位,重启客户端再问一次。这时应该看到 401 报错,改回来再跑一次恢复正常。这样你就确认了鉴权链路是真的在起作用,而不是碰巧。
4.4 用模型对话页面交叉验证
如果客户端这边一直调不通,可以先绕开 MCP,直接在模型对话页面发一条消息,确认 Key 和模型本身可用。地址是 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。如果那边正常、客户端不正常,问题就在客户端配置;如果那边也报错,问题在 Key 或模型 ID。这个交叉验证能帮你快速定位问题在哪一层。
5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth
多服务器 + 统一入口的组合,报错往往集中在几个固定位置。我把真实遇到过的几类列出来,对照着查。
5.1 401 Unauthorized
最常见。原因通常是三个:Key 复制时带了空格或换行;.env里API_KEY没被load_dotenv()读到;Key 已失效或被删除。
排查顺序:先print(os.getenv("API_KEY"))看读到的值对不对(注意别把完整 Key 打印到日志里);再确认.env和client_tools.py在同一目录,或者load_dotenv()指定了正确路径;最后去控制台确认 Key 状态。如果 Key 是从网页复制的,注意前后不要有引号以外的字符。
5.2 local proxy failed
这个报错通常和 Base URL 写法有关。https://taotoken.net/api和https://taotoken.net/api/v1是两个不同的路径,OpenAI 兼容客户端需要后者。如果你写成了前者,请求会打到错误的路由上,表现为连接失败或代理错误。
另一个可能是网络环境问题。这里不展开,只提醒:确认你的运行环境能正常访问https://taotoken.net/api/v1,可以用curl简单测一下:
curl https://taotoken.net/api/v1/models \ -H "Authorization: Bearer sk-你的密钥"返回模型列表就说明入口通。
5.3 reading choices 相关报错
response.choices[0]报IndexError或者reading 'choices'之类的错误,一般是返回体结构不符合预期。可能原因:Base URL 指向了一个不兼容 OpenAI 格式的地址;或者请求被中间层拦截返回了 HTML 错误页,客户端却按 JSON 解析。
排查方法:在create调用外面包一层 try,把原始响应打出来:
try: response = self.client.chat.completions.create(...) except Exception as e: print("原始错误:", repr(e)) raise看到具体错误信息,基本就能定位是地址问题还是 Key 问题。
5.4 OAuth 相关报错
如果你用的是某些需要 OAuth 授权的客户端(比如 Claude Code 这类),可能会遇到 OAuth 流程相关的报错。这类客户端通常有自己的配置文件,比如settings.json或auth.json。以 Claude Code 为例,配置里需要写全三件套:Base URL、API Key、Model ID。缺任何一项都可能触发 OAuth 回退或者鉴权失败。
如果你用的是 CC Switch、Cline MCP 或 Codex 的auth.json,同样要保证这三项齐全:
{ "baseUrl": "https://taotoken.net/api/v1", "apiKey": "sk-你的密钥", "model": "你的模型ID" }字段名各客户端略有差异,但核心就是这三项。Base URL 统一指向 TaoToken,Key 用同一个,Model ID 按客户端要求填。配置文档可以参考 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
5.5 工具被调用但结果不对
这类不是鉴权问题,而是工具映射问题。表现是模型调用了工具,但执行时报「未找到工具对应的服务器」。原因通常是tools_map里工具名重复——两个服务器有同名工具,后连接的覆盖了先连接的。
解决办法:在connect_to_server里检测工具名冲突,冲突时给工具名加服务器前缀,或者在tools_map里存成server_id::tool_name的形式。多服务器场景下,工具命名冲突是迟早要处理的,早点加上检测能省很多事。
6. 把统一入口用顺手的几个实操建议
跑通之后,有几个习惯能让这套配置更耐用。
第一,把服务器路径也放进.env,就像 3.1 节那样。增删服务器时只改环境变量,不动代码。第二,给tools_map加冲突检测,避免同名工具互相覆盖。第三,Key 轮换时只改.env一处,所有服务器自动生效,这正是统一入口最大的价值。
如果你后面要接入更多服务器,比如数据库查询、内部 API 调用,思路是一样的:服务器脚本各自独立,客户端只维护一份模型配置。需要看更多接入示例的话,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。长期跑编码类 Agent 的话,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
最后留一个我自己的习惯:每次改完.env,先跑一次「查天气 + 写文件」的跨服务器提问。这一个请求能同时验证鉴权、模型工具调用能力、两个服务器的连通性。跑通了,再去做别的改动。