前几天看到 Sensor Tower 发了篇博客 Introducing The Sensor Tower MCP Server,通篇在讲「现在你可以用自然语言直接问 Sensor Tower 的广告和 App 数据了」。营销味很浓,读完一个工程师大概只剩一个念头:这东西到底怎么实现的?
带着这个问题,我干脆自己动手搭了一个。这篇就是过程记录:先拆一下 Sensor Tower 那套背后的工作流程,再用 FastMCP 从零搭一个最小、但能真正跑起来的 MCP Server。为了能照着敲就直接跑起来,后端特意挑了个免鉴权的公开天气 API,不用申请任何密钥。等骨架跑通了,再把真实项目里绕不开的几件事逐个补上:接进 Cursor、当普通 REST 接口用,以及最容易配错的 token 鉴权。文章里所有代码都在本地实际跑过(FastMCP 3.4.7、Python 3.12)。
不过在动手之前,得先说清楚 MCP 到底是什么,不然后面全是空中楼阁。
MCP 是个啥
MCP 的全称是 Model Context Protocol,是 Anthropic 牵头搞的一个开放标准,专门用来规范一件事:AI 应用该怎么跟外部的工具和数据打交道。它有专门的官网 modelcontextprotocol.io,规范、实现指南、各语言 SDK 的入口都在那儿。
为什么需要一个「标准」?设想一下,你有 M 个 AI 客户端(Claude、Cursor、各种 ChatBot),又有 N 个数据源(数据库、内部 API、第三方服务)。在没有统一约定的年代,想让它们两两互通,就得写 M×N 套对接代码,每加一个新客户端或新数据源,工作量都要翻一轮。MCP 做的事情,就是在中间立一个统一的协议:数据源那边只要照协议实现一次,所有支持 MCP 的客户端就都能用;客户端那边也只要对接协议一次,就能接上所有 MCP 服务。M×N 一下子变成了 M+N。
如果这么说还是抽象,可以借两个类比:它之于 AI,有点像 USB-C 之于各种设备——插头统一了,谁都能连;也有点像 LSP(Language Server Protocol)之于编辑器——编译器只要实现一次 LSP,所有编辑器就都有了补全和跳转。
协议里,一个 MCP Server 可以向客户端暴露三类东西:工具(tools)、资源(resources)和提示(prompts)。这篇我们只用到、也是目前最常用的一类——tools。搞清楚了这层背景,再回头看 Sensor Tower 那套就顺了。
先看懂 Sensor Tower 那套
官方博客的 “How It Works” 讲得比较绕,但剥掉营销话术,一句话就能概括:MCP Server 是夹在 AI 和后端 API 中间的一层适配器。你可以把它想象成餐厅前台——你(模型)用大白话点单,前台(MCP Server)把你的话翻译成后厨(后端 API)看得懂的工单,菜做好了再摆盘端回来。整条链路其实很短:
AI 客户端 ──(MCP 协议 / JSON-RPC)──> MCP Server ──(HTTP + token)──> Sensor Tower REST API
│
验证参数 / 规范化数据 / 格式化
具体到一次问答,发生的事情大致是这样。你在 AI 里问「上个季度美国下载量前 10 的游戏是哪些」,模型判断这事得调工具才能答,于是生成一个结构化的调用请求;MCP Server 收到后,先校验参数,再翻译成对 Sensor Tower REST API 的调用;API 返回一堆原始数据,Server 顺手做一遍规范化(比如把以分为单位的金额换算成美元、把小数换成百分比);最后把整理好的结果回灌给模型,由它组织成人话答复你。
把这条链路看透,你会发现真正属于 MCP Server 的活其实不多,就中间那两步:校验参数、调后端、带上鉴权、把数据整理干净。而理解意图、挑哪个工具、组织语言这些看着最”智能”的部分,全是模型和协议框架替你完成的。换句话说,写一个 MCP Server 的难度,远比”教 AI 用工具”这个说法听上去要低。
官方那套是闭源的企业产品,对外只暴露一个需要鉴权的远程端点 https://mcp.sensortower.com/mcp。但既然实现思路是公开、通用的,那我们完全可以照着这个套路,自己搭一个来看个究竟。
用 FastMCP 搭一个最小可用的 Server
真要从零开始,手写 MCP 协议是件苦差事——JSON-RPC 消息格式、初始化握手、会话管理,一大堆样板。所以别自己啃协议,用框架。Python 这边就是 FastMCP,它把协议那层全封好了,你只管用一个装饰器把普通函数「变成」工具就行。其他语言也是同样的思路,区别只在依赖。官方的规范、schema 和各语言 Tier 1 SDK(TypeScript、Python、Go、C#)都集中在 GitHub 组织 github.com/modelcontextprotocol 下。
先把环境搭好
有一个小前提得先交代:FastMCP 需要 Python 3.10 以上,而不少 macOS 自带的还停在 3.9,直接装会报一堆看不懂的错。为了省心,我习惯用 uv——它不光是个更快的包管理器,还能顺手把指定版本的 Python 拉下来,不用你自己去折腾多版本共存:
uv venv --python 3.12 .venv
uv pip install --python .venv fastmcp httpx starlette
如果你不想引入 uv,传统的 venv 加 pip 也完全可以,只要确保 python 版本够新:
python3 -m venv .venv && source .venv/bin/activate # 需 3.10+
pip install -r requirements.txt
整个项目结构很轻,就四个文件:
mcp-server-demo/
├── server.py # MCP server 主体
├── requirements.txt
├── .env.example
└── README.md
server.py 里都有什么
主角是 server.py。后端我用的是 Open-Meteo,一个免鉴权的天气加地理编码 API(地理编码就是”把地名翻译成经纬度”)。基于它,我们对外提供三个工具:search_location 按地名搜经纬度,get_current_weather 按经纬度查当前天气,而 get_weather_by_city 是个组合拳,按城市名一步到位——它内部会先地理编码拿到坐标,再去查天气。
文件不长,我们从里往外看,先看最底层的请求封装。所有工具最终都会从这个函数出去,它统一负责三件杂活:注入鉴权、失败重试、把各种异常收敛成一个 ToolError。之所以要重试,是因为上游 API 偶尔会抽风返回 429 限流或 5xx,直接失败体验太差,退避重试能把这类抖动兜住:
async def make_request(base_url: str, endpoint: str, params: dict[str, Any]) -> Any:
"""向后端 API 发一次带重试的 GET 请求,返回解析后的 JSON。"""
# 真实 API 在这里注入鉴权,例如放到 header 或 query。公开 API 可忽略。
if API_TOKEN:
params = {**params, "auth_token": API_TOKEN}
url = f"{base_url}{endpoint}"
backoff = 0.5
max_attempts = 4
for attempt in range(max_attempts):
try:
resp = await _client.get(url, params=params)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as e:
code = e.response.status_code
# 对可重试的错误做指数退避,其余直接抛给客户端。
if code in {429, 500, 502, 503, 504} and attempt < max_attempts - 1:
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 8.0)
continue
raise ToolError(f"上游 API 返回 {code}: {e.response.text[:200]}") from e
except (httpx.ReadTimeout, httpx.ConnectError) as e:
if attempt < max_attempts - 1:
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 8.0)
continue
raise ToolError(f"请求上游 API 失败: {e}") from e
往外一层是工具声明,也是整个文件最核心的地方。有个设计上的小取舍值得说一下:我没有把业务逻辑直接写进工具函数里,而是抽成了两个普通函数 _search_location 和 _current_weather,工具本身只做一层薄封装。好处很直接——像 get_weather_by_city 这种组合工具,就能直接复用底层函数,而不用绕一圈去调另一个工具;写单元测试时也可以只测纯逻辑,不必拉起整个 MCP。
@mcp.tool
async def search_location(
name: Annotated[
str,
Field(description="地点名称,如 'Tokyo'、'北京'、'San Francisco'", min_length=1),
],
count: Annotated[
int,
Field(description="返回候选数量(1-10)", ge=1, le=10),
] = 3,
) -> dict:
"""根据地名搜索地理坐标(经纬度),用于后续查询天气。"""
return await _search_location(name, count)
@mcp.tool
async def get_current_weather(
latitude: Annotated[float, Field(description="纬度", ge=-90, le=90)],
longitude: Annotated[float, Field(description="经度", ge=-180, le=180)],
) -> dict:
"""按经纬度获取当前天气(温度、体感、风速、天气代码)。"""
return await _current_weather(latitude, longitude)
@mcp.tool
async def get_weather_by_city(
city: Annotated[str, Field(description="城市名,如 'Tokyo'、'上海'", min_length=1)],
) -> dict:
"""一步到位:按城市名查当前天气(内部先地理编码再查天气)。"""
geo = await _search_location(city, count=1)
matches = geo.get("matches") or []
if not matches:
raise ToolError(f"找不到城市: {city}")
top = matches[0]
weather = await _current_weather(top["latitude"], top["longitude"])
return {"resolved_location": top, "weather": weather}
这段代码里有几个细节,恰恰是 MCP Server 好不好用的关键,值得停下来多看两眼。
第一,参数用的是 Annotated 搭配 Field(description=...)。这些描述文字看着像注释,作用却完全不同——FastMCP 会把它们连同 min_length、ge、le 这些约束,一起转成一份 JSON Schema 发给模型。模型正是靠这份 schema 判断”该调哪个工具、每个参数填什么”的。所以它不是写给人看的,是写给模型看的。
第二,函数的 docstring 会直接成为这个工具的描述。「这工具是干嘛的、什么场景下该用它」这句话,很大程度决定了模型会不会在对的时机想起它。这一点后面还会专门展开。
第三,返回值我做了裁剪。_search_location 只保留了 name、country、经纬度这几个真正用得上的字段,把原始响应里一堆用不到的东西丢掉了。这就是前面说的”规范化”——给模型的数据越干净,它答得越准,也越省 token。
看完封装,再看被封装的两个底层函数,逻辑其实平平无奇,就是拼好参数、发请求、把响应里关心的字段挑出来:
async def _search_location(name: str, count: int = 3) -> dict:
data = await make_request(
GEOCODE_BASE_URL,
"/v1/search",
{"name": name, "count": count, "language": "zh", "format": "json"},
)
results = data.get("results") or []
matches = [
{
"name": r.get("name"),
"country": r.get("country"),
"admin1": r.get("admin1"),
"latitude": r.get("latitude"),
"longitude": r.get("longitude"),
"timezone": r.get("timezone"),
}
for r in results
]
return {"query": name, "matches": matches}
async def _current_weather(latitude: float, longitude: float) -> dict:
data = await make_request(
WEATHER_BASE_URL,
"/v1/forecast",
{
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code",
},
)
current = data.get("current") or {}
return {
"latitude": data.get("latitude"),
"longitude": data.get("longitude"),
"timezone": data.get("timezone"),
"temperature": current.get("temperature_2m"),
"apparent_temperature": current.get("apparent_temperature"),
"humidity": current.get("relative_humidity_2m"),
"wind_speed": current.get("wind_speed_10m"),
"weather_code": current.get("weather_code"),
"units": data.get("current_units") or {},
}
最后是文件顶部的配置。两个 base URL 都从环境变量读、带默认值,方便将来换后端;两个 token 变量先埋在这里,等讲到鉴权那节再细说;HTTP 客户端用的是 httpx 的异步版本,因为 FastMCP 的工具本身是 async 的,配一个复用连接池、设好超时的 client 是标配:
GEOCODE_BASE_URL = os.getenv("GEOCODE_BASE_URL", "https://geocoding-api.open-meteo.com")
WEATHER_BASE_URL = os.getenv("WEATHER_BASE_URL", "https://api.open-meteo.com")
# ① 上游 API 的 token:我们的 server 拿它去调后端 API
API_TOKEN = os.getenv("DEMO_API_TOKEN")
# ② 保护本 server 的 API key:客户端调用时要带它。留空则不鉴权
MCP_API_KEY = os.getenv("MCP_API_KEY")
mcp = FastMCP("weather-demo")
_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)
)
让它跑起来:stdio 还是 http
工具写完了,还差一个入口把服务启起来。这里要引入 MCP 里一个绕不开的概念——传输方式(transport)。我们支持两种:
def main() -> None:
parser = argparse.ArgumentParser(description="Weather MCP Server (FastMCP demo)")
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default=os.getenv("TRANSPORT", "stdio"),
)
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
args = parser.parse_args()
if args.transport == "http":
mcp.run(transport="http", host=args.host, port=args.port)
else:
mcp.run(transport="stdio")
这两种方式的区别,说到底是”谁来启动、怎么通信”。stdio 模式下,进程是由 AI 客户端当子进程拉起来的,双方通过标准输入输出(也就是 stdin/stdout)你来我往,Cursor、Claude Desktop 这类本地插件走的就是这条路,好处是零配置、不占端口。http 模式则是你自己常驻一个进程、对外开一个 HTTP 服务(默认在 :8000/mcp),远程访问、多人共用,或者你想拿 curl 直接戳它的时候,才用得上。
python server.py # stdio(默认)
python server.py --transport http --port 8000 # http
顺带说个几乎人人都会撞一次的坑:在 IDE 里直接点运行 server.py,看到它停在 Starting MCP server 'weather-demo' with transport 'stdio' 一动不动,先别急着以为卡死了。stdio 模式本来就是这副样子——它在安静地等客户端从 stdin 喂数据,没有客户端连上来,它当然没反应。想手动测接口,记得加上 --transport http。
接进 Cursor
服务能跑了,下一步自然是把它接到真正的 AI 客户端里。Cursor 读的是 ~/.cursor/mcp.json,stdio 模式下进程由 Cursor 自动拉起,你自己不用手动运行:
{
"mcpServers": {
"weather-demo": {
"command": "/绝对路径/mcp-server-demo/.venv/bin/python",
"args": ["/绝对路径/mcp-server-demo/server.py"]
}
}
}
有个地方特别容易翻车:command 一定要指向 venv 里的那个 python,而不是系统的 python3。否则 Cursor 用系统解释器去跑,轻则缺依赖、重则版本太低直接起不来,而且报错还藏在日志里不好发现。
配好之后,到 Cursor 的 Settings → MCP 里看一眼,weather-demo 应该亮起绿灯、能列出那三个工具(要是没有,点一下刷新,或者干脆 Reload Window)。然后切到 Agent 模式,随口问一句「东京现在天气怎么样」,它就会自己调用 get_weather_by_city,把温度、体感、湿度、风速一并讲给你听。想指定用哪个 server,也可以直接发话:「用 weather-demo 查一下北京天气」。
第一次看到它真的调起来,多半会冒出一个疑问:我就问了句大白话,它是怎么知道该调哪个工具、还把”东京”填进参数里的?这正是下一节要拆的。
模型是怎么知道该调哪个工具的
答案说穿了很朴素:它不靠猜,靠的是每个工具随身带着的那份「说明书」。
还记得前面写的类型标注和 Field(description=...) 吗?它们会被 FastMCP 转成一份 JSON Schema,客户端通过 MCP 协议里的 tools/list 请求把它取走,长这样:
{
"name": "get_weather_by_city",
"description": "一步到位:按城市名查当前天气(内部先地理编码再查天气)。",
"inputSchema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名,如 'Tokyo'、'上海'", "minLength": 1 }
},
"required": ["city"]
}
}
这里值得留意的是,从头到尾我们都没写过 tools/list 这个方法——用 @mcp.tool 声明工具的那一刻,FastMCP 就已经替我们在 initialize 握手时声明了 tools 能力,并把 tools/list(列工具)和 tools/call(调工具)的应答逻辑自动实现好了。这也正是前面说的”框架把协议那层全兜住了”落到实处的样子:你只管写工具,协议怎么收发全不用管。
当你问「东京天气」时,客户端发给模型的其实不只是这一句话,还顺带附上了所有可用工具的名字、描述和参数 schema。模型经过 function calling 的专门训练,会拿你的意图去逐一比对这些工具的描述:你给的是一个城市名”东京”,那最贴合的显然是 get_weather_by_city,而不是那个要经纬度的 get_current_weather;接着它从你的话里把 city = "Tokyo" 抽出来,然后——注意,它此刻并不直接回话,而是输出一个结构化的调用请求:
{ "name": "get_weather_by_city", "arguments": { "city": "Tokyo" } }
剩下的就顺理成章了:客户端拿着这个请求,通过 tools/call 发给 server,server 调 Open-Meteo 拿回 JSON,结果再回灌给模型,最后被翻译成「东京现在 27.7°C,体感 31.8°C,湿度 68%……」这样的人话。
这里有个容易被一句话带过、其实值得较真的问题:这个”结构化请求”到底是谁规定它长这样的?答案是三方分层、各管一段,理清楚了对整个 MCP 的运作会豁然开朗。参数长什么样——arguments 里能有哪些字段、什么类型、哪些必填——是你定义的,就是上面那份 inputSchema。而 name 加 arguments 这层外壳、以及”一次工具调用在模型的输出里应该长什么样”,是各家大模型的 function calling 规范定的(OpenAI 管它叫 tool_calls,Anthropic 叫 tool_use);模型是被训练成、并且在解码时被约束成只能吐出符合这个壳的合法 JSON,而不是自由发挥地拼字符串。至于客户端和 server 之间怎么把这些消息传来传去,则由 MCP 协议用 JSON-RPC 的 tools/list、tools/call 来规定。
所以真正干”组装”这件事的,其实是客户端(Cursor):它先从 server 用 tools/list 把工具 schema 拉过来,翻译成模型厂商认识的 tool 格式,连同你的提问一起发给模型;模型据此生成 tool call;客户端再把它映射回 MCP 的 tools/call 发给 server。一句话收束:模型之所以能生成结构化请求,是因为它既看得到你写的 schema(知道该填什么),又被训练成只能按厂商格式输出(知道该长什么样),中间靠 MCP 协议和客户端这层胶水把两头黏起来。
理清了这条链路,也就自然得出一个挺实在的结论:工具好不好用,很大程度取决于描述写得好不好。要是把 description 敷衍成”处理数据”这种,模型多半会选错,或者干脆视而不见。这也是为什么正经的 MCP Server 会在工具描述、参数说明、乃至用法示例上下真功夫——本质上,写描述就是在”教”模型什么时候该用这个工具。不信你可以做个实验:把某个工具的 docstring 清空,刷新之后再问,大概率它就当没这个工具了。
也可以当普通 REST 接口用
到这儿,AI 侧的用法已经闭环了。但实际项目里常有另一种需求:我就想在自己的后端、脚本或者 Postman 里直接调这些能力,不绕 AI。这时候如果去硬啃 MCP 协议会很难受,因为它走的是 JSON-RPC 加会话握手,拿 curl 调起来非常别扭。更省事的办法,是在同一个服务上再顺手挂一层普通的 REST 端点——顺便说一句,Sensor Tower 那个开源实现也提供了类似的 /legacy/tools/invoke 旁路,思路是一样的。
FastMCP 用 @mcp.custom_route 就能加,注意它只在 http 模式下生效:
@mcp.custom_route("/api/weather_by_city", methods=["GET"])
async def api_weather_by_city(request: Request) -> JSONResponse:
if (denied := _check_auth(request)) is not None:
return denied
city = request.query_params.get("city")
if not city:
return JSONResponse({"error": "缺少 city 参数"}, status_code=400)
geo = await _search_location(city, count=1)
matches = geo.get("matches") or []
if not matches:
return JSONResponse({"error": f"找不到城市: {city}"}, status_code=404)
top = matches[0]
weather = await _current_weather(top["latitude"], top["longitude"])
return JSONResponse({"resolved_location": top, "weather": weather})
注意它内部复用的还是 _search_location、_current_weather 这两个底层函数——这就是前面把逻辑抽出来的回报:同一份实现,MCP 工具和 REST 端点都能用。三个工具各挂一个端点之后,就能一把梭了:
curl "http://127.0.0.1:8000/health"
curl "http://127.0.0.1:8000/api/weather_by_city?city=Tokyo"
curl "http://127.0.0.1:8000/api/search_location?name=Shanghai&count=2"
curl "http://127.0.0.1:8000/api/current_weather?latitude=31.23&longitude=121.47"
weather_by_city 实测返回:
{"resolved_location":{"name":"東京","country":"日本","latitude":35.6895,"longitude":139.69171,...},
"weather":{"temperature":27.7,"apparent_temperature":31.8,"humidity":68,"wind_speed":2.9,...}}
这样一来,程序化访问就有了两条路,各有各的适用场景。一条是走标准 MCP 协议(/mcp),也就是 AI Agent 用的那条,但别想着拿 curl 硬啃——它得先 initialize 拿到一个 session,再 tools/call,连 Accept 头都得同时带上 application/json, text/event-stream,少一个就给你甩个 406。所以走这条路,老老实实用 SDK:
import asyncio
from fastmcp import Client
async def main():
async with Client("http://127.0.0.1:8000/mcp") as c:
print([t.name for t in await c.list_tools()])
r = await c.call_tool("get_weather_by_city", {"city": "Tokyo"})
print(r.data)
asyncio.run(main())
另一条就是刚才那些 REST 旁路,任何语言的 HTTP 库都能调,没有任何门槛。到底选哪条?其实一句话就能定:自己的后端、脚本、前端想直接拿数据,走 REST 最省事;要对接 AI Agent、Cursor、Claude 这类,走标准 MCP 并配 SDK;而如果你压根只是想在本地 Cursor 里用,那连 http 都不用开,stdio 就够了。顺带提一嘴,官方那个 https://mcp.sensortower.com/mcp 只暴露标准 MCP 端点、需要鉴权,并没有开放这种 REST 旁路。
别把两个方向的 token 搞混
鉴权是最后一块,也是最容易踩坑的一块。坑就坑在,MCP 里的 token 其实有两个完全相反的方向,很多人一上来就把它们混作一谈,结果要么怎么调都 401,要么把服务裸奔挂在公网上。
先把两个方向掰开:一个是 MCP Server 拿着 token 去调上游 API(比如 Sensor Tower 的 API token,或者你自己后端的 key)——这是”我去访问别人”;另一个是客户端得带上 token 才能调你的 MCP Server(官方那个远程端点就要求带凭证)——这是”别人来访问我”。方向反了,配置自然就错。
第一个方向的代码其实早就埋好了,就是 make_request 里那个 API_TOKEN。token 是放 query 还是放 header,取决于上游 API 的脾气。放 query 的(像 Sensor Tower 的 auth_token=xxx)就是示例现在的写法;放 header 的(更常见的 Authorization: Bearer)也简单,给 client 设个默认头就行:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0),
headers={"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {},
)
不管哪种,token 都要走环境变量传进来,绝不能硬编码进代码提交上去。stdio 模式下,在 mcp.json 里用 env 字段注入即可:
{
"mcpServers": {
"weather-demo": {
"command": ".../.venv/bin/python",
"args": [".../server.py"],
"env": { "DEMO_API_TOKEN": "上游API密钥" }
}
}
}
第二个方向——保护你自己的 Server——在本地无所谓,但一旦用 http 暴露到公网就必须做,不然谁都能白嫖你的接口。给个最小实现:设了 MCP_API_KEY 就要求来访者带上 Authorization: Bearer,没设就放开,方便本地调试:
def _check_auth(request: Request) -> Optional[JSONResponse]:
"""校验客户端带的 Bearer token。返回 None 表示放行,否则返回 401 响应。"""
if not MCP_API_KEY:
return None # 未配置则不鉴权
auth = request.headers.get("authorization", "")
if auth == f"Bearer {MCP_API_KEY}":
return None
return JSONResponse({"error": "未授权:缺少或错误的 Bearer token"}, status_code=401)
在每个 REST 路由开头调一下 _check_auth(request) 就生效了(前面那个 api_weather_by_city 开头那行就是)。实测下来行为很清楚:不设 MCP_API_KEY 时随便调都是 200;设成 secret123 之后,不带或带错 token 一律 401,只有带对了才放行:
MCP_API_KEY=secret123 python server.py --transport http --port 8000
curl -H "Authorization: Bearer secret123" "http://127.0.0.1:8000/api/weather_by_city?city=Tokyo"
反过来,如果你是那个”去连别人的”角色,对方的远程 MCP 要求带 token,那就在 Cursor 的 mcp.json 里给那个 url 加上 headers:
{
"mcpServers": {
"sensortower": {
"url": "https://mcp.sensortower.com/mcp",
"headers": { "Authorization": "Bearer 你的token" }
}
}
}
关于鉴权,最后攒几点经验。stdio 模式基本不用操心”别人来访问我”这个方向,毕竟是本地子进程,能启动它就等于有权限,重点是用 env 把上游 token 安全地递进去。而 http 一旦上公网就绕不开客户端鉴权,示例里这个静态 Bearer 只是入门级别,真要上生产,建议直接用 FastMCP 内置的鉴权机制(BearerAuthProvider 或 OAuth),能做 JWT 校验、按 scope 授权这些。还有个细节别漏了:上面只给 REST 旁路加了校验,标准 /mcp 端点的鉴权最好交给 FastMCP 的 auth provider 统一兜,比自己在中间件里手搓要靠谱得多。
换成你自己的 API
天气只是个例子,把这个骨架改造成包你自己的接口,改动点其实很集中:先把 GEOCODE_BASE_URL、WEATHER_BASE_URL 换成你自己的地址,再到各个 @mcp.tool 函数里改 endpoint 和参数——这一步千万记得把 Field(description=...) 写清楚,模型全靠它选工具;需要鉴权就设上 DEMO_API_TOKEN,在 make_request 里按 query 或 header 注入;最后别忘了在函数末尾把返回值裁一裁,只留客户端真正用得上的字段。
再往大了说,这套路对任何 REST 或 GraphQL API 都成立,无非四步:
- 选框架:Python 用 FastMCP,TypeScript 用官方
@modelcontextprotocol/sdk,别自己去啃协议。 - 每个 API 端点写一个 tool,重点是把参数和用途描述写清楚,AI 全靠这个选工具。
- 在工具里做参数校验、调后端 API、带上鉴权、加重试。
- 结果规范化后返回,选 stdio(本地)或 http(远程)传输。
想清楚这四步,包什么 API 都是同一套动作。
写在最后
回头看,MCP Server 其实没有它名字听起来那么高深,说穿了就是给 AI 用的一层 API 适配器:把你已有的能力配上一份写给模型看的说明书,剩下选工具、填参数、组织语言这些活,都交给模型和协议框架去干。
如果只留三条经验,我会挑这几点。其一,工具好不好用,成败几乎都压在工具名和描述上,因为那是模型判断该不该、该怎么调它的唯一线索。其二,传输方式按场景选:本地开发交给 stdio、让 Cursor 自动拉起最省事,要往远程或公网走就换成 http,同时把客户端鉴权补齐。其三,两个方向的 token 分清楚——调上游 API 的密钥用 env 传,保护自家 Server 的凭证用 Bearer 校验。
一篇看似只是营销宣传的博客,拆到最后落成了一个能跑通、能接 Cursor、能当 REST 调、也能加鉴权的完整骨架。有了这套心智模型,再遇到「某某上线了 MCP Server」之类的消息,大概扫一眼就知道它葫芦里卖的什么药了。