1. 从零手写 MCP Server 到底难在哪:Cursor 工具调用链路拆解
MCP Server 这个词最近出现频率很高,但真正动手写一个最小可跑版本的人并不多。原因不是代码复杂,而是链路里藏着几个容易卡住的点:stdio 通信怎么建立、工具函数怎么被模型发现、Cursor 怎么知道去启动你的进程、模型请求又该走哪个 Base URL。这几个问题任何一个没打通,表现都是「工具列表空的」或者「模型不调用工具」。
我这次的目标很明确:在 Cursor 里从零手写一个最小 MCP Server,用 Python + uv 搭工程,把 stdio 通信和工具注册原理讲清楚,最后把模型请求的 Base URL 指向 TaoToken,用一次真实的工具调用验证整条链路是否跑通。适合已经用过 Cursor、想搞明白 MCP 底层机制、又不想被一堆抽象概念绕晕的开发者。
先说清楚 MCP 是什么。MCP(Model Context Protocol)本质是一套约定:客户端把用户的 Prompt 和当前可用的工具列表一起发给 LLM,LLM 判断是否需要调用工具,如果需要就返回工具名和参数,客户端再通过统一接口去执行这个工具,把结果塞回对话。整个过程里,Server 负责「提供工具」,Client 负责「编排调用」,LLM 负责「决策」。
那为什么要在 Cursor 里手写而不是直接用现成的?因为现成的 Server 你看不到工具注册的细节,也看不到 stdio 上到底传了什么。手写一遍,你会清楚@mcp.tool()装饰器做了什么、mcp.run(transport='stdio')启动后进程在等什么、Cursor 的mcp.json里那几个字段分别对应什么。这些搞懂了,后面接任何模型、任何工具都是同一套逻辑。
还有一个现实问题:模型请求走哪里。Cursor 默认走它自己的通道,但如果你想统一管理 Key、想换模型、想看请求到底发了什么,就需要把 Base URL 改到一个可控的入口。TaoToken 在这里的角色就是统一入口——一个 Key 打通模型对话和工具调用,Base URL 填https://taotoken.net/api,模型 ID 按需选。这样你在 Cursor 里调工具时,模型侧的请求和工具侧的本地进程是两条独立但协同的链路,排障时能分开定位。
下面按「装环境 → 写 Server → 配 Cursor → 验证 → 排错」的顺序走,每一步都给可复制的命令和配置。技术部分会占大头,拿 Key 的部分放在前面快速带过。
2. TaoToken 前置准备:统一 Key 与 Base URL 配置
在动手写 Server 之前,先把模型侧的入口准备好。这一步很快,但顺序不能反——因为后面 Cursor 里验证工具调用时,模型请求要能正常返回,否则你分不清是 Server 没起来还是模型没通。
TaoToken 的定位是一个统一的 API 入口,把模型对话、编码计划、API Key 管理放在同一个控制台里。对这次实验来说,你只需要三样东西:Base URL、API Key、Model ID。这三样凑齐,Cursor 里任何需要填模型配置的地方都能用。
先拿 Key。打开控制台页面https://taotoken.net/console,登录后在 API Keys 区域创建一个新 Key。建议命名带上用途,比如cursor-mcp-test,方便后面区分。创建后立刻复制保存,页面刷新后通常不再完整显示。
Base URL 固定填https://taotoken.net/api,注意不要带结尾斜杠,也不要加 UTM 参数——API 调用路径和官网推广链接是两回事,混了会 404。Model ID 按你实际要用的填,比如claude-3-5-sonnet-20241022这类,具体以控制台模型列表为准。
如果你后面打算长期在 Cursor 里做编码和 Agent 任务,可以顺带看一下 Coding Plan 页面https://taotoken.net/coding-plan,它把常用编码模型的额度打包,比单次调用更省心。这次实验用按量 Key 就够。
把这三样记在一个临时文本里:
| 配置项 | 值 |
|---|---|
| Base URL | https://taotoken.net/api |
| API Key | 控制台创建后复制 |
| Model ID | 控制台模型列表里选 |
注意:Base URL 和官网地址不要混用。官网是
https://taotoken.net/,API 是https://taotoken.net/api,前者用于浏览文档和控制台,后者用于代码里的请求地址。
模型对话的调试入口在https://taotoken.net/models,接入文档在https://taotoken.net/doc。这两个页面在你排错时会用到:前者可以快速验证 Key 是否有效,后者能查到不同模型对应的参数格式。
这一步做完,模型侧就通了。接下来进入正题:写 Server。
3. 可复制配置:pyproject.toml、weather.py 与 Cursor mcp.json
这一节是全文的核心,所有代码和配置都可以直接复制。我按「建工程 → 写 Server → 配 Cursor」三步走,每步都给完整文件内容。
3.1 用 uv 初始化工程
uv 是目前比较顺手的 Python 包管理器,装依赖快,虚拟环境管理也干净。Windows 下用 PowerShell 装:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"macOS 或 Linux 用:
curl -LsSf https://astral.sh/uv/install.sh | sh装完后新开一个终端,验证:
uv --version然后建工程目录并初始化:
uv init weather cd weather uv venv激活虚拟环境。Windows:
.venv\Scripts\activatemacOS / Linux:
source .venv/bin/activate装依赖:
uv add "mcp[cli]" httpx这一步会生成pyproject.toml和uv.lock。pyproject.toml里会自动写入依赖,你不需要手动改,但可以打开确认一下mcp和httpx都在。如果后面 Cursor 启动 Server 报「找不到模块」,多半是这个文件里的依赖没装全,回到这一步重跑uv add即可。
3.2 写 Server 入口 weather.py
在工程根目录新建weather.py,内容如下。这段代码定义了两个工具:查某州天气预警、查某坐标的天气预报。工具本身调用的是公开天气 API,重点不在业务逻辑,而在于展示@mcp.tool()怎么注册、mcp.run(transport='stdio')怎么启动。
from typing import Any import httpx from mcp.server.fastmcp import FastMCP # 初始化 FastMCP 服务器,命名为 "weather" mcp = FastMCP("weather") # 常量 NWS_API_BASE = "https://api.weather.gov" USER_AGENT = "weather-app/1.0" async def make_nws_request(url: str) -> dict[str, Any] | None: """向 NWS API 发请求,带错误处理。""" headers = { "User-Agent": USER_AGENT, "Accept": "application/geo+json", } async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers, timeout=30.0) response.raise_for_status() return response.json() except Exception: return None def format_alert(feature: dict) -> str: """把单条预警格式化成可读字符串。""" props = feature["properties"] return f""" Event: {props.get('event', 'Unknown')} Area: {props.get('areaDesc', 'Unknown')} Severity: {props.get('severity', 'Unknown')} Description: {props.get('description', 'No description available')} Instructions: {props.get('instruction', 'No specific instructions provided')} """ @mcp.tool() async def get_alerts(state: str) -> str: """Get weather alerts for a US state. Args: state: Two-letter US state code (e.g. CA, NY) """ url = f"{NWS_API_BASE}/alerts/active/area/{state}" data = await make_nws_request(url) if not data or "features" not in data: return "Unable to fetch alerts or no alerts found." if not data["features"]: return "No active alerts for this state." alerts = [format_alert(feature) for feature in data["features"]] return "\n---\n".join(alerts) @mcp.tool() async def get_forecast(latitude: float, longitude: float) -> str: """Get weather forecast for a location. Args: latitude: Latitude of the location longitude: Longitude of the location """ points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}" points_data = await make_nws_request(points_url) if not points_data: return "Unable to fetch forecast data for this location." forecast_url = points_data["properties"]["forecast"] forecast_data = await make_nws_request(forecast_url) if not forecast_data: return "Unable to fetch detailed forecast." periods = forecast_data["properties"]["periods"] forecasts = [] for period in periods[:5]: forecast = f""" {period['name']}: Temperature: {period['temperature']}°{period['temperatureUnit']} Wind: {period['windSpeed']} {period['windDirection']} Forecast: {period['detailedForecast']} """ forecasts.append(forecast) return "\n---\n".join(forecasts) if __name__ == "__main__": mcp.run(transport="stdio")几个关键点解释一下。FastMCP("weather")里的名字是 Server 标识,Cursor 里显示的就是它。@mcp.tool()装饰器把函数注册进工具列表,函数的 docstring 会作为工具描述发给模型——所以 docstring 要写清楚参数含义,模型靠它判断该不该调、怎么传参。mcp.run(transport='stdio')表示这个 Server 通过标准输入输出和客户端通信,不监听端口,进程由 Cursor 拉起。
3.3 配 Cursor 的 mcp.json
打开 Cursor,右上角设置 → MCP Servers → Add new global MCP Server。填入:
{ "mcpServers": { "weather": { "command": "uv", "args": [ "--directory", "/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather", "run", "weather.py" ] } } }把/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather换成你工程目录的绝对路径。Windows 下形如C:\\Users\\you\\projects\\weather,注意 JSON 里反斜杠要转义。macOS / Linux 形如/Users/you/projects/weather。
保存后回到 MCP Servers 列表,应该能看到一个叫weather的条目,状态是绿色,展开能看到get_alerts和get_forecast两个工具。如果显示红色或没有工具,先看下一节的排错。
3.4 把模型请求指向 TaoToken
Cursor 里模型配置的位置在 Settings → Models。把 Base URL 改成https://taotoken.net/api,API Key 填你在控制台创建的那个,Model ID 填你要用的模型。保存后 Cursor 的模型请求就走 TaoToken 了。
这里有个容易混的点:MCP Server 的启动和模型请求是两条链路。Server 由 Cursor 本地拉起,走 stdio;模型请求走网络到 TaoToken。两条链路都通,工具调用才能完整跑完。排错时先确认 Server 是绿的,再确认模型能正常对话,最后才测工具调用。
4. 验证请求:一次工具调用跑通整条链路
配置完成后,新建一个 Cursor 对话,模型选你刚配好的那个。先做一次普通对话确认模型通:
你好,简单介绍一下你自己。如果这句能正常返回,说明 Base URL 和 Key 没问题。接下来测工具调用。在对话里输入:
帮我查一下 CA 州当前的天气预警。预期行为是:Cursor 把这句话和工具列表一起发给模型,模型判断需要调用get_alerts,参数state="CA",Cursor 收到工具调用指令后通过 stdio 把请求发给本地 weather 进程,进程调用天气 API 拿到数据,返回给 Cursor,Cursor 再把结果交给模型组织成自然语言回复。
如果一切正常,你会看到回复里包含 CA 州的预警信息,同时在 Cursor 的工具调用记录里能看到get_alerts被调用了一次。再测一个带坐标的:
帮我查一下纬度 37.7749、经度 -122.4194 的天气预报。这次应该触发get_forecast,返回未来几天的预报。两个工具都调通,说明 Server 注册、stdio 通信、模型决策、结果回传整条链路是通的。
实测下来,模型选择会影响工具调用成功率。有些模型对工具调用的支持更稳,有些会忽略工具直接编答案。如果发现模型不调用工具而是自己瞎编,先换一个工具调用能力强的模型再试。Cursor 里切换模型很快,不用改代码。
验证通过后,你可以打开 TaoToken 的模型对话页面https://taotoken.net/models,对照看请求是否正常计费、返回是否完整。这一步不是必须,但能帮你确认请求确实走了 TaoToken 而不是别的地方。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来。MCP 链路涉及本地进程、stdio、网络请求、模型 API 四层,报错信息往往指向其中一层,但表述很模糊。下面几个是我和身边人踩过的坑。
401 Unauthorized。这个基本是模型侧的问题,不是 Server 的问题。检查三处:API Key 是否复制完整(前后有没有空格)、Base URL 是否写成https://taotoken.net/api(不要带斜杠、不要带 UTM)、Key 是否已过期或被删。如果 Key 没问题,去https://taotoken.net/api-keys重新生成一个再试。注意 401 不会影响 MCP Server 的绿色状态,因为 Server 本身不校验模型 Key。
local proxy failed / connection refused。这个通常出现在模型请求走本地代理的场景。如果你之前配过本地代理,Cursor 可能还在用旧配置。检查 Settings → Models 里的 Base URL 是否被改回了本地地址。另外确认没有其他工具占用同名端口。这个报错和 MCP Server 无关,Server 走的是 stdio,不经过网络代理。
reading 'choices' 或 undefined is not an object。这是模型返回格式不符合预期时的典型报错,多半是 Model ID 填错了,或者该模型不支持当前请求格式。回到https://taotoken.net/doc查一下你用的模型 ID 拼写,确认它支持工具调用。有些模型只支持纯对话,传了 tools 参数会返回异常结构,Cursor 解析时就报这个错。
OAuth 相关报错。如果你在 Cursor 里登录过某些账号,可能会残留 OAuth token,和 API Key 冲突。表现是请求被重定向到登录页或返回 403。解决办法是在 Cursor 设置里退出相关账号登录,只用 API Key 认证。MCP Server 本身不涉及 OAuth,它只认 stdio。
Server 显示红色 / 工具列表为空。先看mcp.json里的路径是不是绝对路径,相对路径 Cursor 解析不了。再看uv是否在系统 PATH 里,Cursor 启动子进程时用的是系统环境,终端里能跑不代表 Cursor 能跑。最后确认pyproject.toml里依赖装全了,在工程目录下手动跑一次:
uv run weather.py如果这行能启动且不报错(会卡住等待 stdio 输入,这是正常的),说明 Server 本身没问题,问题在 Cursor 配置。按 Ctrl+C 退出即可。
工具被调用但返回空。检查天气 API 是否可达,以及state参数是不是两位州代码。get_forecast对经纬度格式敏感,传字符串会失败。这些是工具内部逻辑问题,和 MCP 链路无关,看 Server 进程的 stderr 输出能定位。
排错时记住一个原则:先分层,再定位。Server 绿不绿看本地进程,模型通不通看普通对话,工具调不调用看模型能力,工具返回对不对看业务代码。四层分开测,比盯着一个报错猜要快得多。
6. 把这条链路用起来:从天气工具到自己的业务工具
天气工具只是载体,真正有价值的是这套模式可以复制到任何本地工具上。你把手写的weather.py换成查数据库、读本地文件、调内部接口,注册方式完全一样,Cursor 那边的mcp.json也只需要改路径和文件名。
具体做法是:新建一个my_tool.py,用FastMCP("my-tool")初始化,把你要暴露的能力写成带@mcp.tool()的异步函数,docstring 写清楚参数,最后mcp.run(transport='stdio')。然后在mcp.json里加一个条目,command还是uv,args指向新文件。重启 Cursor,新工具就出现在列表里。
模型侧继续用 TaoToken 统一入口,Base URL 保持https://taotoken.net/api,换模型只改 Model ID。这样你的本地工具和模型请求是解耦的:工具在本地跑,数据不出机器;模型请求走统一入口,Key 和额度集中管理。想验证某个模型对工具调用的支持程度,直接在https://taotoken.net/models里切换测试,不用改任何 Server 代码。
如果你打算把这套东西用在长期编码或 Agent 任务上,Coding Plan 页面https://taotoken.net/coding-plan里有针对编码场景的额度方案,比按量调用更适合高频使用。接入文档https://taotoken.net/doc里有不同语言的调用示例,Python 之外的场景也能参考。
最后留一个实用技巧:调试工具调用时,在 Server 的工具函数里加一行print(..., file=sys.stderr),把入参打到 stderr。Cursor 会把 Server 的 stderr 收集到日志里,你能看到模型实际传了什么参数。这比在模型侧猜要直接得多。stdio 通信下,stdout 被协议占用,调试输出必须走 stderr,这一点新手很容易踩。