精选副业
‹ 站长专栏
站长原创 · 全文公开

从 0 到 1 搭建一个自己的 MCP 服务 · 全流程保姆级教程

这篇是我本人写的,所以全文放在这里,你不用领体验卡也能读完。文末有生财原帖链接,评论区的讨论在那边。

生财已经开启 MCP内测,如果你也想开发一个MCP服务,但是你又不知道怎么从 0 到 1 搭建一个自己的 MCP 服务,那么这篇帖子希望能给你带来启发

做MCP 也是受亦仁启发

更好的阅读体验见飞书文档
从 0 到 1 搭建一个自己的 MCP 服务 · 全流程保姆级教程
https://my.feishu.cn/docx/Q6pMdq6hzoexjix674ucHldUnLc
如果你有不懂的地方可以留言,也可以通过鱼丸联系我,欢迎来链接​​​从 0 到 1 搭建一个自己的 MCP 服务 · 全流程保姆级教程

关于这篇教程里的示例数据
文中的域名 blueocean-mcp.online 是真实上线的服务。
不过你直接用浏览器打开它,只会看到一行 "BlueOcean MCP",没有任何页面——这是正常的,MCP 服务本来就不是给人用浏览器看的,它是给 AI 客户端连的接口。 所以这一行字不重要,真正怎么用它,本文最后一章有完整的接入和使用教程。

第一部分 先搞明白在做什么
第 1 章 为什么写这篇教程,以及 MCP 到底能带来什么
这一章不写代码。先说清楚这篇教程是怎么来的,以及把数据做成 MCP 之后,你实际能得到什么。
起因:我用上了生财有术的 MCP,然后想自己做一个
生财有术上线了自己的 MCP 服务。我在本地把它跑通了,连上之后能看到一整排工具——45 个,分 9 大类:

分类

工具数

能干什么

帖子与内容

7

搜话题、搜内容、看帖子详情、翻评论、查文档

互动操作(写入)

5

点赞、取消赞、收藏、取消收藏、投币

用户与社交

3

搜用户、看资料、查关系链

航海(实战项目陪跑)

9

查活动、搜手册、看我的进度、搜我的产出

深海圈(付费社区)

9

搜内容、看课程、翻章节、读评论

项目库与项目工具

7

查项目、搜案例、找工具

线下聚会

2

搜聚会、看详情

我的账户

2

查积分、查权益

AI 情报

1

拉 AI 资讯流
用起来的感受和翻网页完全不是一回事。以前想找某个项目的案例,得打开网站、搜索、 翻好几页、一个个点进去看;现在直接问一句"帮我找几个跟短视频变现相关的案例, 按最近更新排",AI 自己去调工具、自己翻页、自己筛选,回来给我一段整理好的结论。
用爽了之后自然就想:我自己手里也有数据,能不能也做一个?
然后我就卡住了。找不到一篇讲清楚"从 0 到 1 怎么搭一个 MCP"的完整教程。 能查到的东西要么只讲某一个孤立的点,要么默认你已经会了运维和部署, 从"我有一份数据"到"别人能在 Claude 里用上它"这中间的一整段路,没人完整走一遍给你看。
所以我自己走了一遍,然后写了这篇。

这篇教程会带你走完的路
一份存在数据库里的数据 → 想清楚要暴露哪些能力 → 写授权 → 写 MCP 服务器 → 写测试 → 买服务器 → 买域名 → 部署上线 → 配 HTTPS → 接进 Claude Code 真正用起来。全程约半天,总花费约 ¥80。
做成 MCP 之后,你实际得到了什么
"能让 AI 调用你的数据"这句话太抽象了。说几个具体的场景。
一、数据从"人去查"变成"AI 帮你查完直接给结论"
我这个项目的数据是 3.8 万个 YouTube 频道的运营指标——订阅数、月涨粉、互动率、 所属赛道、破圈比等等,一共二十来个字段。
做成网站的时候,你得自己知道该筛什么:先选"新号",再按"月涨粉"排序, 再翻到某一页,再点进详情看它的赛道,然后回到列表用这个赛道再筛一遍……每一步都得你自己想清楚。
做成 MCP 之后,你只说一句:
帮我找订阅增速最快的黑马新号,看看它们都在做什么赛道

AI 自己完成这一串:调用频道检索工具(筛新号 + 黑马 + 按月涨粉排序)→ 拿到结果发现前几名分散在不同赛道 → 再调用赛道工具查这几个赛道的竞争情况 → 最后告诉你"这几个号涨得快,但赛道 A 已经很卷了,赛道 B 还有空间"。
关键差别不是"快",而是 AI 会基于第一次查询的结果决定第二次查什么。这件事网站做不到——网站只会等你点下一步。
二、不用做前端,对话就是界面
我的第一版是个网站。为了让人能用,我得写列表页、写详情页、写筛选表单、写分页器、 写排序、写响应式布局……前端的活儿一样不能少。
做 MCP 的时候,这些一个都不用写。你只需要写清楚"这个工具能干什么、接受哪些参数", 剩下的交互全部由对话完成。整个项目里我没有写一行 HTML、一行 CSS、一行 JavaScript。

这一条对个人开发者尤其重要
很多人有数据、有想法,卡在"我不会做前端"这一步。MCP 把这个门槛直接删掉了——你只要会写查询函数,就能交付一个能用的产品。
三、一次接入,多个客户端都能用
MCP 是一套标准协议,不是某家的私有接口。你把服务做出来之后,Claude Code、Claude Desktop、Cursor、VS Code(Copilot 代理模式)、Windsurf、Cline、Cherry Studio、Trae、Zed 等等一大批工具都能直接连, 你不需要为任何一个单独适配。
用户那边的接入成本也极低——一行命令,或者一段几行的 JSON 配置, 不用装任何东西。第 12 章会把主流工具的配置方法逐个给出来。
对比一下:如果你做的是私有 API,想让这些工具都能用,得给每一个单独写适配、 单独维护。MCP 把这件事变成了一次性工作。
四、能加授权、能收费,是个产品而不只是脚本
这一条是我认为最被低估的。
如果只是自己用,写个本地脚本就够了。但只要你想把它给别人用——不管是免费分发还是收费—— 你就需要解决:谁能用、用多久、怎么停掉某个人、怎么防止一个人把授权分享给一百个人。
这篇教程里有整整一章(第 4 章)专门讲卡密授权的实现。做完之后你的 MCP 就不是一个玩具脚本, 而是一个可以发卡、可以限时、可以随时吊销的服务。
这篇教程适合谁

说明

适合

会一点 Python(能看懂函数和字典就行),手里有一份数据或一个系统, 想让 AI 能直接调用它。不需要懂 MCP、不需要懂 Linux 运维、不需要有服务器经验

不适合

想深入了解 MCP 协议报文格式、想读 SDK 源码实现、 需要做容器化或集群部署的读者。这些本文都不涉及

你需要准备

一台能上网的电脑(Windows/Mac 都行)、一个能收信的邮箱、 一张能付美元的信用卡或支付宝、约 ¥80 预算
第 2 章 网站是给人看的,MCP 是给 AI 看的
同一份数据,做成网站和做成 MCP,差别不只是"换个壳"。这一章讲清楚这个差别,以及怎么把一个现成的系统翻译成一组工具。
先看这个项目的两个版本
我这个项目的数据,是用采集程序从 YouTube 抓回来的频道运营指标, 存在数据库里,一共三张表:

行数

内容

频道表

38,548

每个频道的订阅数、总播放、月涨粉、互动率、赛道、层级等 20 多个字段

赛道表

2,860

每个赛道的竞争度、变现潜力、新号突围率、蓝海指数

视频表

246,226

每条视频的标题、时长、播放、点赞、评论、标签
**第一版我做的是网站。**用户打开浏览器,看到一个表格:上面是筛选条件(赛道下拉框、层级下拉框、订阅数区间、年龄区间),中间是频道列表, 表头可以点击排序,下面是分页器。点某一行进去,是这个频道的详情页,十个指标卡片。
这是它真实的样子——打开默认显示全部 34,494 个频道,分成 345 页:
web1.png
第一版网站的列表页。上方是六个筛选条件,下方是可点击排序的表格

这个网站没问题,能用。但它有个隐含的前提:用户得自己懂这套指标体系。
举个具体的例子。假设你想找"值得对标的头部新号",在网站上你得这么操作:
第一次尝试——你可能先想到按订阅数筛,填个"订阅 ≥ 1000万":
web2.png

结果不对。这些号动辄跑了十几年,你对标不了。问题出在你用错了筛选维度—— 该筛的不是订阅数,是"层级"和"是否新号"这两个字段。
第二次尝试——改成"层级 = 头部" + "仅新号":
web3.png
这次对了:1,040 个头部新号,年龄集中在 17–23 个月。 但你得**先知道有

看出问题了吗?网站把所有判断都推给了用户。 你得知道"黑马"是什么意思、知道该按"月涨粉"还是"综合分"排序、 知道先筛赛道还是先筛订阅数。不懂这套体系的人打开这个页面,是懵的—— 他会像上面第一次尝试那样,筛出一堆没用的结果,然后关掉页面。
**第二版做成了 MCP。**同样一份数据、同样的需求,用户什么都不用懂,直接说人话:
帮我找值得对标的头部新号

模型读过工具描述,知道有"层级"和"新号"这两个维度、也知道它们的定义,所以它一次就筛对了——不会像上面那样先撞一次订阅数的墙。
更进一步,如果你问的是个更模糊的问题:
我想做一个新的 YouTube 频道,帮我看看现在哪个赛道竞争最小、还有机会

AI 会自己去调赛道工具、按蓝海指数排序、发现样本太少的赛道指标不可靠于是加上过滤条件、 再挑几个候选去查里面的头部频道长什么样,最后给你一段有判断的回答。上面那三张截图里的三次手动尝试,在这里被压缩成了一句话。
fig01.png
同一份数据的两种出口:网站要求用户自己会用,MCP 由 AI 代劳

一句话总结这个差别
网站是把数据摆出来,等人来取;MCP 是把能力交出去,让 AI 来编排。 前者的天花板是用户的水平,后者的天花板是模型的水平。
怎么把一个现成系统翻译成工具
假设你手里也有一个网站、一个数据库、或者一堆脚本,该怎么决定做成哪些工具? 我的做法是走这三步。
第一步:把现有功能列成清单
不要想着"MCP 应该有什么工具",而是先老老实实把现在这个系统能做什么写下来。 我当时列出来是这些:
按条件筛选频道列表(赛道、层级、新老号、订阅区间、年龄区间)
按任意字段排序、分页
查看单个频道的完整指标
查看有哪些赛道、每个赛道多少频道
查看有哪些层级、每层多少频道
然后我做了一件后来证明很有价值的事:去数据库里翻了一遍,看看有没有网站没用上的数据。 结果发现了两张表——赛道表(2,860 条,有竞争度、变现潜力、蓝海指数这些指标) 和视频表(24.6 万条),网站从头到尾一次都没用过。

建议你也做一遍这个检查
很多项目的数据库里都躺着"当时采了但界面上没做"的数据。做 MCP 的时候没有界面成本,加一个工具就是加一个函数,这些沉睡的数据可以直接变成能力。我后来把这两张表做成了付费档位专属的工具。
第二步:决定哪些合并、哪些拆开
这一步有个判断标准:看模型会不会用错。
比如"筛选频道"和"排序频道",在网站上是两个操作,但对模型来说它们总是一起用的—— 没人会先筛完再单独排序。所以合并成一个工具,排序作为参数。
反过来,"查频道列表"和"查单个频道详情"看着像一回事,但要拆开。 因为列表工具返回的是多条摘要,详情工具返回的是单条全字段。 如果合并,模型每次查列表都会拿到一大堆用不上的字段,白白占用上下文。
最后我定下来的一期工具是这五个:

工具名

做什么

为什么这么切

search_channels

筛选 + 排序 + 分页查频道

三个动作合成一个,模型总是一起用

get_channel

查单个频道全字段

和列表拆开,避免列表返回过多字段

list_niches

列出所有赛道及频道数

模型需要知道 niche 参数能填什么

list_tiers

列出所有层级及频道数

同上

get_license_status

查当前授权状态

让用户能在对话里自己查还剩几天
注意后面两个"列枚举值"的工具。这类工具很容易被忽略,但对模型非常重要—— 如果不告诉它 niche 这个参数能填哪些值,它就会瞎猜一个英文词填进去, 然后查出来 0 条结果。
第三步:写工具描述——这是写给模型看的
这是整个需求分析里最容易做错的一步。
很多人写工具描述的时候,下意识是在写 API 文档,给人看的。但 MCP 的工具描述是直接喂给模型的上下文,模型完全依赖它来决定"要不要调这个工具、参数怎么填"。
举个我这个项目里的真实例子。频道表里有个 score 字段,叫"综合分"。 如果我只写:

不好的描述

score: 频道综合评分

模型会怎么做?它会拿这个字段去给所有频道排序,然后告诉你"综合分最高的是这几个"。
**但这是错的。**因为这个分数在计算时,新号和老号用了完全不同的权重,而且是在各自的集合内做归一化的——新号的 60 分和老号的 60 分不是一回事,不能横向比较。
所以正确的描述必须把这个约束写进去:

好的描述

score: 综合分。新号与老号采用不同权重且分别归一化,
新老号之间不可直接比较分数。

加上这句之后,模型再排序时就会主动加上"只看新号"的过滤条件,或者在回答里 提醒你"这两个号年龄段不同,分数不可比"。

写工具描述的三条经验
**① 把陷阱写出来。**数据里任何"看起来能这么用但其实不能"的地方,都要明确说。
**② 把边界写出来。**比如我的库有入库门槛(订阅 ≥1000、有长视频、近半年有更新),所以我在描述里写了"查不到不等于不存在",避免模型下结论说"这个频道不存在"。
**③ 给使用建议。**比如"找蓝海对标建议 is_new=true 并按 subs_per_month 排序"——直接告诉模型典型用法,能显著提高它第一次就调对的概率。
顺带说说参数白名单
还有一个安全相关的设计要在这一步就想好。
我的 search_channels 有个 sort 参数,让模型指定按哪个字段排序。 这个参数最终会拼进 SQL 的 ORDER BY 里。
如果直接把模型传来的字符串拼进 SQL,那就是一个标准的 SQL 注入漏洞—— 哪怕模型本身没有恶意,用户也可以通过提示词诱导它传入危险内容。
正确做法是设白名单:

只允许这 9 个字段作为排序列,其余一律拒绝

SORTABLE_COLUMNS = (
"score", "subscribers", "total_views", "age_months",
"subs_per_month", "views_per_month", "engagement_rate",
"long_video_count", "video_count",
)

sort = (sort or "score").strip().lower()
if sort not in SORTABLE_COLUMNS:
raise ValueError(f"sort 必须是以下之一: {', '.join(SORTABLE_COLUMNS)}")

这样即使传进来 1; DROP TABLE channels,也会在这一行直接被挡掉, 连不上数据库。

**这一章你应该拿到的东西:**一份工具清单——每个工具叫什么、接受哪些参数、返回什么、描述怎么写。这份清单就是后面写代码的施工图。

第二部分 项目构成与核心实现
第 3 章 一个 MCP 服务到底由哪些文件组成
在写任何代码之前,先把整个项目摊开看一遍。知道每个文件负责什么,后面读代码才不会迷路。这一章是全教程的地图。
完整目录结构
先看全貌。整个项目就这些文件,不多:
你的项目目录/
├── mcp_license/ # 授权包:管卡密的一切
│ ├── __init__.py
│ ├── config.py # 配置项(全部可用环境变量覆盖)
│ ├── db.py # 数据库层(同时支持 SQLite 和 MySQL)
│ ├── schema.py # 四张表的建表语句
│ ├── keygen.py # 卡密生成、哈希、掩码
│ ├── service.py # ★ 核心:授权的全部逻辑
│ ├── errors.py # 错误类型
│ ├── admin_cli.py # 命令行管理工具
│ └── admin_web.py # 网页管理后台(可选)

├── mcp_server/ # MCP 服务包:对外提供工具
│ ├── __init__.py
│ ├── config.py # 数据源、监听地址、公网域名
│ ├── data.py # 数据查询层
│ ├── auth.py # 从请求头取卡密,交给授权包校验
│ ├── app.py # ★ 核心:所有工具的定义
│ ├── __main__.py # 启动入口
│ └── sync.py # 数据同步脚本

├── tests/
│ └── test_license.py # 测试用例
├── requirements.txt # 依赖清单
└── .env.example # 环境变量模板

为什么分成两个包
授权和业务是两件不相干的事。授权包不关心你的数据是 YouTube 频道还是电商订单, 业务包不关心卡密怎么校验。分开的好处很实际:下次你做另一个 MCP, mcp_license/ 整个目录可以原样复制过去,只需要重写 mcp_server/。
授权包:mcp_license/
config.py —— 所有配置集中在这里
一个 dataclass,把所有可调参数集中起来:数据库连接、卡密哈希密钥、有效期档位、 频控阈值、IP 绑定规则、缓存时长。
关键设计是每一项都支持环境变量覆盖:
@classmethod
def from_env(cls) -> "LicenseConfig":
return cls(
db_dialect=os.environ.get("LICENSE_DB_DIALECT", "sqlite"),
pepper=os.environ.get("LICENSE_PEPPER", ""),
rate_per_minute=_env_int("LICENSE_RATE_PER_MINUTE", 30),

...

)

**没有它会怎样:**你会把数据库密码写死在代码里,然后代码传到服务器、上传到 Git 的时候把密码一起泄露出去。这是新手最常见的安全事故。 用环境变量之后,同一份代码在本地跑 SQLite、在服务器跑 MySQL,一行代码都不用改。
db.py —— 一套代码同时支持两种数据库
这个文件解决一个很现实的问题:本地开发想用 SQLite(零配置、跑测试快), 线上想用 MySQL(稳、能扛并发)。
做法是所有 SQL 统一用 ? 占位符书写,执行时按方言自动转换:
def _prep(self, sql: str) -> str:

SQLite 用 ?,MySQL 用 %s,写代码时统一用 ?

return sql if self.dialect == "sqlite" else sql.replace("?", "%s")

还封装了事务上下文管理器,以及一个小细节——for_update 属性:
@property
def for_update(self) -> str:

MySQL 需要显式行锁;SQLite 靠 BEGIN IMMEDIATE 的库级写锁,不需要

return " FOR UPDATE" if self.dialect == "mysql" else ""

**没有它会怎样:**你要么在本地也装 MySQL(麻烦,测试慢),要么写两套 SQL(维护噩梦)。
schema.py —— 四张表

表名

存什么

为什么需要

card_keys

卡密主表:哈希、掩码、档位、有效天数、状态、激活时间、到期时间、批次

核心表。注意不存卡密明文,只存哈希

license_sessions

每张卡绑定的 IP、绑定时间、当日换绑次数、调用计数

实现"一卡一 IP"

license_fingerprints

每张卡出现过的客户端指纹(IP 段 + UA)

识别一张卡被多设备使用

usage_logs

每次调用的时间、卡密、工具名、来源 IP、结果

审计 + 频控计数的载体
**没有它会怎样:**没有 usage_logs 你就不知道谁在用、用了多少、有没有异常;没有 license_sessions 就没法做 IP 绑定,一张卡会被无限转发。
keygen.py —— 卡密怎么来的
只有四个函数,但每个都有讲究,第 4 章会详细展开:
generate_key() —— 用密码学安全随机数生成 XXXX-XXXX-XXXX-XXXX
hash_key() —— 用 HMAC-SHA256 加密钥做哈希
mask_key() —— 生成 ****-****-****-5678 这样的掩码,给列表页显示用
is_valid_format() —— 格式校验,在查数据库之前先挡掉明显错的
service.py —— 整个项目最核心的文件
授权的全部逻辑都在这里,约 400 行。对外只暴露一个主要方法:
svc.validate_request(card_key, ip, user_agent, tool_name)

这一个调用内部做了七件事:格式校验 → 频控检查 → 查卡 → 惰性过期 → 状态检查 → 首次激活 → IP 绑定 → 指纹审计 → 档位权限。任何一步不通过就抛异常。
还有一组管理方法:generate() 发卡、disable_key() 停用、 list_keys() 列表、stats() 统计、refresh_expired() 批量刷新过期。
第 4 章会把这个文件逐段拆开讲。
errors.py —— 为什么错误信息要写中文
定义了几个异常类型,每个带一句面向最终用户的中文提示:
class KeyExpiredError(LicenseError):
code = "expired"

抛出时:

raise KeyExpiredError(f"授权已于 {expires_at} 过期,请获取新卡密并更新客户端配置")

为什么这么设计?因为这些错误最终会被模型读到,再转述给用户。 如果你抛一个 PermissionError: 403,模型只能干巴巴地说"出错了"; 如果你抛的是上面这句中文,模型会直接告诉用户"你的授权 9 月 5 号过期了,需要换新卡密"。

这是 MCP 开发和普通后端开发的一个思维差异
普通 API 的错误信息是给程序员看的(所以用错误码), MCP 的错误信息是给模型看的、最终给终端用户看的。把它当成客服话术来写,而不是当成日志来写。
admin_cli.py —— 你的日常管理工具
命令行工具,7 个子命令:
python -m mcp_license.admin_cli init-db # 建表
python -m mcp_license.admin_cli generate ... # 发卡
python -m mcp_license.admin_cli list # 看卡密列表(只显示掩码)
python -m mcp_license.admin_cli disable --key # 停用某张卡
python -m mcp_license.admin_cli delete --id # 删除
python -m mcp_license.admin_cli stats # 统计概览
python -m mcp_license.admin_cli expire-refresh # 批量刷新过期状态(定时任务用)

**没有它会怎样:**你只能手写 SQL 去数据库里改状态,发卡还得自己算哈希。
admin_web.py —— 可选的网页后台
用 Starlette 写的简单后台,四个页面:概览、卡密管理、发卡、调用日志。这个文件是可选的,不装也能用命令行完成所有操作。我加它是因为发卡时能一次看到明文列表,方便复制。
MCP 服务包:mcp_server/
config.py —— 数据源和监听地址
和授权包的 config 类似,但管的是另一批东西:数据从哪读(SQLite 还是 MySQL)、 服务监听哪个地址端口、以及一个后面会救你一命的字段 public_host (第 11 章会讲为什么它至关重要)。
data.py —— 把 SQL 封装成函数
所有数据库查询都在这里,MCP 工具本身不写 SQL。这一层做三件事:
参数校验:排序字段白名单、分页大小上限、翻页深度上限
SQL 拼装:WHERE 条件按传入参数动态组合,值一律用占位符绑定
结果整形:比如给频道详情自动加上 youtube_url 字段
这里有个细节值得说——排序的写法:
ORDER BY ({sort} IS NULL) ASC, {sort} {direction}, channel_id ASC

三段各有用途:第一段让空值排到最后(否则按降序排时 NULL 会跑到最前面); 第二段是真正的排序;第三段 channel_id ASC 是稳定性保证—— 如果排序字段有大量相同值,不加这个次级键的话,翻第 2 页时数据库可能返回和第 1 页重复的行。
auth.py —— 桥接层
很短,就两个函数,负责把 HTTP 世界和授权世界连起来:
def extract_credentials(headers, client_host):

从 Authorization: Bearer xxx 里取出卡密

auth = headers.get("authorization") or ""
key = auth[7:].strip() if auth.lower().startswith("bearer ") else auth.strip()

反代场景下取 X-Forwarded-For 的第一个 IP(这是用户的真实 IP)

xff = headers.get("x-forwarded-for") or ""
ip = xff.split(",")[0].strip() if xff else (client_host or "unknown")
ua = headers.get("user-agent") or ""
return key, ip, ua

X-Forwarded-For 这一行很重要
上线后你的服务前面会有一层反向代理(第 11 章会装)。这时候 client_host 拿到的是代理的 IP(127.0.0.1),不是用户的真实 IP。 如果不读 X-Forwarded-For,所有用户在你眼里都是同一个 IP,IP 绑定和限流就全废了。
app.py —— 所有工具的定义
这是 MCP 的门面。每个工具就是一个带装饰器的函数:
@mcp.tool()
def search_channels(ctx: Context, q: str | None = None, ...) -> dict:
"""筛选/排序/分页查询 YouTube 蓝海频道库。
...(这段 docstring 就是给模型看的说明书)
"""
st = guard(ctx, "search_channels") # ← 第一行:授权检查
result = repo.search(...) # ← 第二行:查数据
return _attach_notice(result, st) # ← 第三行:附加提示信息

注意每个工具函数的第一行都是 guard()。第 4 章开头会讲为什么 这个设计决定了"授权必须先写"。
main.py —— 启动入口
做三件事:读配置、创建服务、启动。第 11 章会往这里加一段关键代码。
sync.py —— 数据同步
把本地数据库的数据推送到服务器数据库。第 10 章部署时会用到。
根目录的三个文件
requirements.txt 与生产依赖
本地开发需要 pytest,线上不需要。所以线上单独用一份精简的依赖清单, 少装几个包、少占几十兆磁盘,也少几个潜在的安全更新负担。
.env.example —— 环境变量模板
这个文件只有变量名和说明,没有真实值。真实的密码写在服务器上的另一个文件里, 权限设成 600(只有 root 能读)。

这条规矩没有例外
任何真实的密码、密钥、Token,永远不要写进代码文件。我参考过一个开源项目,它把数据库密码和管理员 Token 直接写在代码里、 还在 SQL 文件的注释里又抄了一遍——任何人拿到源码就能生成无限张卡密。 你的代码可能会传到 Git、会发给别人、会被截图,而环境变量不会。
tests/ —— 测试
第 6 章专门讲。这里只说结论:授权逻辑必须有测试, 因为它的 bug 不会立刻暴露,而是一个月后集中爆发。
一次请求是怎么流过这些文件的
把上面所有文件串起来,看一次完整的调用:
fig02.png
一次工具调用流过的路径:每一步对应上面讲的一个文件

**这一章你应该有的印象:**授权包和业务包是分开的;每次调用都会先过授权再查数据;所有配置来自环境变量;真实密码不进代码。后面几章都是在填这张图里的某个方块。

第 4 章 卡密授权:把原理讲透
这是全教程最长的一章。按卡密的一生逐段讲实现:怎么生成、怎么存、怎么激活、怎么计时、怎么防滥用。看完你应该能直接照着写出来。
先回答一个问题:为什么授权要先写?
直觉上应该先写功能——把工具做出来,能查数据了,再补个鉴权。但这个顺序会让你返工。
原因很简单:授权决定了每个工具函数第一行长什么样。

没有授权时,你会这么写

@mcp.tool()
def search_channels(q=None, tier=None):
return repo.search(q=q, tier=tier)

有授权时,函数签名和函数体都要改

@mcp.tool()
def search_channels(ctx: Context, q=None, tier=None): # ← 多了 ctx 参数
st = guard(ctx, "search_channels") # ← 多了这一行
result = repo.search(q=q, tier=tier)
return _attach_notice(result, st) # ← 返回值也变了

如果你先写了 9 个工具再来加授权,这 9 个函数的签名、函数体、返回值全都要改一遍, 测试也要重写。而先把授权立起来,写第一个工具时就用对的模式,后面 8 个照着抄就行。

这个道理不只适用于 MCP
任何"横切关注点"(鉴权、日志、限流、事务)都应该在写第一个业务功能之前就定下模式。它们不是功能的补充,而是功能的前提。
第一步:生成卡密
格式:XXXX-XXXX-XXXX-XXXX
import secrets

ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 32 个字符

def generate_key() -> str:
groups = ["".join(secrets.choice(ALPHABET) for _ in range(4))
for _ in range(4)]
return "-".join(groups)

输出示例:ABCD-1234-EFGH-5678

三个细节:
① 必须用 secrets,不能用 random。random 是伪随机数,种子是可预测的——理论上有人观察到你发出的几张卡密之后, 可以推算出后续会生成什么。secrets 用的是操作系统的密码学安全随机源, 没有这个问题。这行代码的差别写起来只有一个单词,但性质完全不同。
**② 字符集去掉了 0、1、I、O。**因为这四个字符在很多字体里长得几乎一样(0 和 O、1 和 I)。卡密是要发给人、被人抄写和转发的,去掉它们能省掉大量 "我明明输对了为什么说无效"的客服问题。
**③ 密钥空间够大。**32 个字符选 16 位,是 3216 ≈ 1.2 × 1024,约等于 280。这个量级下,暴力枚举完全不现实——就算每秒试一亿次, 也要跑几亿年。
第二步:存储——只存哈希,不存明文
这是很多人会做错的地方。我参考过的那个方案就是把卡密明文直接存在数据库里的, 而且管理接口的列表功能还会把明文返回给前端。
**为什么这样不行:**数据库一旦泄露(拖库、备份文件丢失、运维误操作),所有卡密立刻全部作废——因为拿到的人可以直接用。
正确做法是只存哈希:
import hmac, hashlib

def hash_key(key: str, pepper: str) -> str:
if not pepper:
raise ValueError("pepper 不能为空")
return hmac.new(
pepper.encode("utf-8"),
key.strip().upper().encode("utf-8"),
hashlib.sha256
).hexdigest()

为什么用 HMAC 而不是直接 SHA256
如果直接 sha256(卡密),存在一个隐患:卡密的格式是公开的 (32 字符集、16 位),攻击者拿到数据库后可以离线批量生成候选卡密去撞哈希。 虽然 280 的空间很大,但如果你的卡密总量只有几千张, 攻击者可以针对性地做彩虹表。
HMAC 引入了一个只存在于服务器环境变量里的密钥(叫 pepper)。 没有这个 pepper,拿到整个数据库也算不出任何一张卡密的哈希对不对。

pepper 丢了 = 所有卡密作废
因为校验的时候是拿"用户输入的卡密 + pepper"重新算哈希去和数据库比对。 pepper 变了,所有已发出的卡密都会算出不同的哈希,全部认不出来。
生成之后必须离线备份(密码管理器、离线记事本),不要只留在服务器上。服务器可能会被销毁、重装、迁移。
生成 pepper 的方法:
python -c "import secrets; print(secrets.token_hex(32))"

输出一个 64 位十六进制字符串,存进环境变量 LICENSE_PEPPER

掩码:让管理页面能显示,又不泄露
def mask_key(key: str) -> str:
return "****-****-****-" + key.strip().upper()[-4:]

ABCD-1234-EFGH-5678 → ****-****-****-5678

数据库里同时存哈希和掩码。哈希用来校验,掩码用来在管理列表里显示—— 你能认出是哪一张(靠后四位),但看不到完整卡密。

明文只有一次机会
因为不存明文,所以生成卡密时展示的那一次,是唯一一次能看到完整卡密的机会。 我在发卡命令里加了 --out keys.txt 参数,直接写进文件,免得复制丢了。 这一点要在文档里跟使用者讲清楚。
第三步:激活——为什么设计成"首次调用即激活"
常见的做法是分两步:用户先在某个页面输入卡密"激活",然后才能使用。我没有这么设计,原因是 MCP 的使用场景不支持。
MCP 服务是被客户端在后台连接的,没有界面可以弹出激活框。 用户在 Claude Code 里配好卡密之后,下一步就直接开始提问了。 如果要求先激活,你得让他跑一个额外的命令,体验很割裂。
所以设计成:卡密配好后,第一次真正调用任何一个工具时自动激活并开始计时。
if card["status"] == "unused":
card = self._activate(kh, ip, now) # 首次调用,自动激活
activated_now = True

激活时把提示信息带回给用户:
notices.append(
f"卡密激活成功,有效期 {card['validity_days']} 天,"
f"至 {expires}(首次调用即开始计时)"
)

这段文字会附在工具返回结果里,模型读到后会转告用户。用户第一次提问时就知道 "我的卡开始计时了,到 X 月 X 日"。
并发问题:两个请求同时激活同一张卡
这是个真实存在的竞态。如果用户同时开了两个客户端,或者网络重试导致两个请求几乎同时到达, 可能出现:两个请求都读到"状态是 unused",于是都执行了激活, 后一个覆盖前一个的到期时间——用户白赚一次计时重置。
解法是事务 + 行锁 + 双重检查:
def _activate(self, kh, ip, now):
with self.db.transaction():

FOR UPDATE 锁住这一行,其他事务必须排队等待

row = self.db.query_one(
"SELECT * FROM card_keys WHERE key_hash=?" + self.db.for_update, (kh,)
)

拿到锁之后再检查一次状态(双重检查)

if row["status"] == "unused":
expires = now + timedelta(days=int(row["validity_days"]))
self.db.execute(
"UPDATE card_keys SET status='used', activated_at=?, expires_at=? "
"WHERE key_hash=?", (_fmt(now), _fmt(expires), kh)
)

同时建立 IP 绑定记录

self.db.execute("INSERT INTO license_sessions(...) VALUES(...)", (...))
return self.db.query_one("SELECT * FROM card_keys WHERE key_hash=?", (kh,))

关键在那个 if:第一个请求拿到锁、改完状态、释放锁; 第二个请求这时才拿到锁,再查一次发现状态已经是 used 了,于是跳过激活。 两个请求都成功返回,但只激活了一次。
第四步:计时——服务端时间是唯一权威
有效期怎么算?三个候选方案:

方案

问题

从购买时间算

用户买了不用,时间白白流逝,体验差

客户端记录激活时间

致命:用户改本地时间就能无限续期

服务端记录激活时刻(采用)

无法伪造,客户端改时间没用
实现上就是激活时算好到期时间存进数据库,之后每次校验都拿服务器当前时间和它比:
expires = _parse(card["expires_at"])
remaining = max(0, math.ceil((expires - now).total_seconds() / 86400))

注意用的是 ceil 向上取整。如果用 .days 整除, 最后不到一天时会显示"剩余 0 天",让用户以为已经过期了。
第五步:每次调用都校验(以及为什么要加缓存)
我参考的那个桌面软件方案是只在程序启动时校验一次。这在 MCP 场景下完全不行:
MCP 服务是长驻进程,可能几个月不重启
如果只在启动时校验,那么卡密过期了、被你手动停用了,都不会生效, 用户能一直用下去
所以改成每个请求都校验。但这带来一个新问题:每次调用都查一遍数据库, 高频使用时会有明显开销。
折中方案是加一个短时缓存:
def _cache_get(self, kh, ip, now):
cached = self._cache.get(kh)
if not cached:
return None
cached_at, card, bound_ip = cached
if ((now - cached_at).total_seconds() < self.cfg.cache_ttl_seconds # 60 秒内
and ip == bound_ip # IP 没变
and _parse(card["expires_at"]) > now): # 没过期
return card
self._cache.pop(kh, None)
return None

60 秒的取舍:吊销一张卡最慢 60 秒生效,这个延迟对业务完全可以接受, 换来的是数据库查询量下降一到两个数量级。
另外注意缓存命中的三个条件缺一不可——IP 变了要重新走完整流程 (否则 IP 绑定会被缓存绕过),过期了也不能用缓存。
第六步:过期处理——惰性 + 定时,双保险
卡密到期后状态要从 used 改成 expired。什么时候改?
**方法一:惰性过期。**用户来调用时顺手检查:
if card["status"] == "used" and _parse(card["expires_at"]) <= now:
self.db.execute(
"UPDATE card_keys SET status='expired' WHERE key_hash=? AND status='used'", (kh,)
)
card["status"] = "expired"

**方法二:定时任务。**每小时批量刷一次:
def refresh_expired(self) -> int:
return self.db.execute(
"UPDATE card_keys SET status='expired' WHERE status='used' AND expires_at<=?",
(_fmt(self.now()),)
)

**为什么两个都要?**惰性过期保证"用户一定拿不到过期卡的服务";定时任务保证"管理后台看到的统计数据是准的"(否则一张过期三个月没人用的卡, 在后台里还显示"使用中")。

我参考的方案在这里有个真实缺陷
它把状态刷新的 SQL 写在了"管理员打开统计页面"的代码里。 也就是说——只有管理员去看后台,过期状态才会更新。没有定时任务。 这种设计在数据量大了之后会让统计完全失真。
防滥用之一:IP 绑定(这里有个必须补的设计)
业务上的要求是:一张卡同一时刻只能在一个 IP 上用,新 IP 使用则旧 IP 失效。 目的是防止一个人买了卡之后发给一百个人用。
直接实现很简单——记录绑定 IP,来了新 IP 就改绑:

天真的实现

if s["bound_ip"] != ip:
self.db.execute("UPDATE license_sessions SET bound_ip=? WHERE key_hash=?", (ip, kh))

但这样写,规则形同虚设。
想一下两个人共用一张卡会发生什么:A 调用,绑定到 A 的 IP;B 调用,改绑到 B; A 再调用,又改绑回 A……两个人可以无限互相顶替,谁都能一直用下去, 只是偶尔会失败一次。这个规则完全没起到限制作用。
所以必须补两道闸门:
bound_at = _parse(s["bound_at"])
today = now.strftime("%Y-%m-%d")
rebind_count = s["rebind_count"] if s["rebind_date"] == today else 0
elapsed = (now - bound_at).total_seconds()

闸门一:距上次绑定不足 10 分钟,拒绝换绑

if elapsed < self.cfg.rebind_min_interval_seconds:
wait_min = math.ceil((self.cfg.rebind_min_interval_seconds - elapsed) / 60)
raise IpConflictError(
f"该卡密刚在其他网络位置使用(一张卡密同一时间只能在一个 IP 使用),"
f"如需在当前设备使用请约 {wait_min} 分钟后重试"
)

闸门二:当天换绑次数超上限,拒绝

if rebind_count >= self.cfg.rebind_daily_limit:
raise IpConflictError("该卡密今日更换网络位置次数过多,已暂时限制换绑,请明日再试")

通过闸门,执行换绑

self.db.execute(
"UPDATE license_sessions SET bound_ip=?, bound_at=?, rebind_date=?, rebind_count=? "
"WHERE key_hash=?", (ip, _fmt(now), today, rebind_count + 1, kh)
)
self._cache.pop(kh, None) # 换绑了,缓存要清掉

加上这两道之后:两个人共用时会频繁撞墙(每次换绑要等 10 分钟,一天最多换 10 次), 而单个用户正常换网络(家里 Wi-Fi 换成手机热点、宽带重拨换 IP)完全不受影响—— 因为正常用户不会 10 分钟内在两个网络之间反复横跳。

这是个通用的设计教训
"需求照着字面实现就失效"是很常见的情况。写完一条规则之后, 一定要站在滥用者的角度想一遍"我怎么绕过它"——如果绕过成本很低, 这条规则就等于没写。
防滥用之二:频率限制
两个维度:每分钟 30 次、每天 2000 次。
def _check_rate(self, kh, tool_name, ip, now):
minute_ago = _fmt(now - timedelta(seconds=60))
n = self.db.query_one(
"SELECT COUNT(*) AS n FROM usage_logs "
"WHERE key_hash=? AND created_at>=? AND result!='rate_limited'",
(kh, minute_ago)
)["n"]
if n >= self.cfg.rate_per_minute:
self._log(kh, tool_name, ip, "rate_limited", now)
raise RateLimitedError(f"请求过于频繁,请稍后再试(每分钟上限 {self.cfg.rate_per_minute} 次)")

每天的检查同理

三个设计点:
① 用 usage_logs 表当计数器,不需要额外引入 Redis。调用量不大的场景下,一次 COUNT 查询完全够用,而且顺便就有了审计记录。
② 统计时排除 rate_limited 记录。否则被限流之后,限流记录本身也算进计数,会导致越限越死,永远解不开。
**③ 频控放在最前面,对不存在的卡密也计数。**看这个顺序:
kh = hash_key(key, self.cfg.pepper)
self._check_rate(kh, tool_name, ip, now) # ← 先限流

card = self.db.query_one("SELECT * FROM card_keys WHERE key_hash=?", (kh,))
if card is None:
raise InvalidKeyError("卡密无效或不存在") # ← 后查卡

为什么这个顺序重要?**防枚举。**如果先查卡、卡不存在就直接返回, 那么攻击者可以无限高速地试卡密。放在前面之后,同一个不存在的卡密试 30 次就会被限流。
防滥用之三:多设备指纹审计
IP 绑定只能防"同时在多处用",防不了"轮流用"。所以再加一层观察: 统计每张卡在 24 小时内出现过多少个不同的客户端指纹。

指纹 = IP 段(/24) + User-Agent 的哈希

ip_prefix = ip.rsplit(".", 1)[0] if "." in ip else ip
fp = hashlib.md5(f"{ip_prefix}|{user_agent}".encode("utf-8")).hexdigest()

为什么用 IP 段而不是完整 IP?因为家宽 IP 经常在同一个 /24 段内变动, 用完整 IP 会把同一个用户识别成很多个。
然后是分级处置:
if n > self.cfg.fingerprint_disable_limit_24h: # 超过 6 个:自动停用
self.db.execute("UPDATE card_keys SET status='disabled' WHERE key_hash=?", (kh,))
raise MultiDeviceError("检测到该卡密被大量不同设备使用,已自动停用,请联系管理员")

if n > self.cfg.fingerprint_soft_limit_24h: # 超过 3 个:提示但放行
return "检测到多设备使用迹象,请勿共享卡密,持续多设备使用将被自动停用"

**分级很重要。**直接封号会误伤正常用户(换了台电脑、公司和家里各用一次),先警告再封号给了缓冲。而且那句警告会通过工具返回值传给模型,再转达给用户—— 相当于自动客服。
档位权限:一个字段的事
最后是收费档位。我的做法很简单,card_keys 表加一个 plan 字段, 中间件里判断一次:
def _tool_denied(self, plan, tool_name):
return plan == "trial" and tool_name in self.cfg.trial_denied_tools

配置里定义哪些工具不对免费卡开放

trial_denied_tools = ("search_niches", "get_niche", "list_channel_videos")

拒绝时给的提示同样是面向用户的:
raise PlanDeniedError(
f"当前卡密档位({card['plan']})无权使用工具 {tool_name},"
"该数据仅对更高档位卡密开放,请联系管理员升级"
)

把整章串起来:validate_request 的完整流程
fig03.png
validate_request 的完整流程:八道关卡,任何一关不过都会带着中文提示返回

**这一章的核心结论:**只存哈希不存明文;激活用事务加锁;时间以服务端为准;每请求校验加短缓存;每写一条规则都要想想怎么被绕过。

第 5 章 MCP 服务器详解
从一个最小可运行的例子开始,一步步加到完整版。假设你完全没接触过 MCP SDK。
先跑起来:20 行的最小 MCP 服务
装 SDK:
pip install mcp

新建一个文件 hello_mcp.py:
from mcp.server import MCPServer

mcp = MCPServer(name="hello", version="1.0.0")

@mcp.tool()
def add(a: int, b: int) -> int:
"""把两个数字相加。"""
return a + b

if __name__ == "__main__":
mcp.run(transport="streamable-http", host="127.0.0.1", port=8900)

跑起来:
python hello_mcp.py

INFO: Uvicorn running on http://127.0.0.1:8900

接进 Claude Code 试一下:
claude mcp add --transport http hello http://127.0.0.1:8900/mcp

重启 Claude Code,然后问它"用 hello 工具算一下 123 加 456"。它会调用你的函数返回 579。
**就这样,你已经有一个能跑的 MCP 服务了。**剩下的都是在这个骨架上加东西。

本地调试的一个坑
如果你的电脑开着代理(比如 Clash),本地客户端连 127.0.0.1 时 请求会被代理拦走,报 502。解决办法是设置环境变量 NO_PROXY=127.0.0.1,localhost。这个问题只影响本地调试, 上线后用户连公网域名不受影响。
工具是怎么定义的
三件事决定了模型看到的工具长什么样:

你写的东西

模型看到的

函数名 add

工具名称

docstring """把两个数字相加。"""

工具说明书,模型靠它决定要不要调

类型标注 a: int, b: int

参数的 JSON Schema(自动生成)
类型标注会自动转成参数约束,你不用手写 JSON Schema:
def search_channels(
q: str | None = None, # → 可选的字符串
is_new: bool | None = None, # → 可选的布尔值
subs_min: int | None = None, # → 可选的整数
page: int = 1, # → 整数,默认 1
) -> dict:

这一章最重要的部分:工具描述怎么写
我把这件事单独拎出来讲,因为它对最终效果的影响超过所有代码细节。
模型决定"调不调这个工具、参数怎么填",唯一依据就是你写的 docstring。 写得不好,模型要么不调、要么调错、要么把数据解读错。
反面例子
@mcp.tool()
def search_channels(q=None, tier=None, sort="score", page=1) -> dict:
"""搜索频道。"""

模型看到这个会怎样?它不知道 tier 能填什么值(会瞎填 "high"、"top"),不知道 sort 有哪些选项(会填 "views"、"popularity"),不知道返回什么(不敢用结果做判断)。最后大概率是调一次失败,然后放弃。
正面例子(我项目里的实际写法)
@mcp.tool()
def search_channels(ctx: Context, q=None, niche=None, tier=None,
is_new=None, subs_min=None, subs_max=None,
age_min=None, age_max=None,
sort="score", direction="desc", page=1, page_size=20) -> dict:
"""筛选/排序/分页查询 YouTube 蓝海频道库(34,000+ 频道)。

参数:
- q: 频道名模糊搜索
- niche: 赛道精确匹配(可先用 list_niches 查看赛道列表)
- tier: 层级,取值 头部/腰部/黑马
- is_new: true=只看新号(年龄≤24个月), false=只看老号
- subs_min/subs_max: 订阅数区间
- age_min/age_max: 频道年龄区间(月, 数据范围约 0.2~250)
- sort: 排序列, 可选 score/subscribers/total_views/age_months/
subs_per_month/views_per_month/engagement_rate/long_video_count/video_count, 默认 score
- direction: desc(默认)/asc
- page: 页码(上限 50 页), page_size: 每页条数(默认 20, 上限 100)

返回 total/page/pages/rows;rows 含全部指标字段。
注意 score 新老号不可横向比较;找蓝海对标建议 is_new=true 并按 subs_per_month 排序。
"""

高亮的部分就是关键差别。逐条说为什么:

写法

解决什么问题

"取值 头部/腰部/黑马"

枚举值直接给出。不给的话模型 100% 会填英文,查出 0 条

"年龄≤24个月"

解释业务口径。模型才知道 is_new 到底意味着什么

"数据范围约 0.2~250"

告诉边界。模型不会填 age_max=1000 这种没意义的值

sort 的完整可选值

白名单外的值会被拒绝,提前告诉它省一次失败调用

"上限 50 页""上限 100"

防止模型想翻 200 页把库爬空

"score 新老号不可横向比较"

**最关键的一条。**不写这句,模型会拿新号和老号的分数直接排序,给出错误结论

"找蓝海对标建议…"

给典型用法。显著提高第一次就调对的概率

一个判断标准
写完 docstring 之后自己读一遍,问:"一个完全没见过这个系统的人, 光看这段话能不能正确用起来?" 如果不能,模型也不能。
server instructions:给整个服务写一段说明
除了每个工具的描述,MCP 还允许给整个服务写一段说明, 客户端连上时就会读到。适合放那些"跨工具的公共知识":
INSTRUCTIONS = """YouTube 蓝海频道数据库(34,000+ 频道)。
帮助用户发现"年龄短、涨粉快、赛道竞争低"的对标频道。

指标语义:
- is_new=1 表示频道年龄 ≤ 24 个月("新号")
- score 综合分:新号与老号采用不同权重且分别归一化,新老号之间不可直接比较分数
- tier 层级:头部(订阅数前 20%)/ 黑马(体量未到但增长动能强)/ 腰部(其余)
- breakout_ratio = 最高单视频播放 ÷ 频道均播(破圈信号)
- 入库门槛:订阅 ≥1000、长视频(≥180 秒)≥3 个、近 180 天有发布;
查不到不等于不存在

所有工具需要授权卡密:请求头 Authorization: Bearer <卡密>。首次调用自动激活并开始计时。"""

mcp = MCPServer(name="blueocean-channels", instructions=INSTRUCTIONS, version="1.0.0")

最后那句"查不到不等于不存在"很有用。因为我的库有入库门槛, 很多小频道根本没收录。不写这句的话,用户问"帮我查一下 XX 频道", 模型查不到就会说"这个频道不存在",这是错的结论。 写了之后它会说"这个频道不在库里,可能是没达到收录门槛"。
传输方式:stdio 还是 HTTP

stdio

Streamable HTTP

怎么跑

客户端在本地启动一个子进程

你部署一个服务,客户端通过网址连

用户要装什么

要装 Python 和你的代码

什么都不用装,填个网址

数据在哪

在用户电脑上

在你的服务器上

能不能加授权

很难(代码在用户手上,可以改)

可以,服务端说了算

适合

本地工具、个人自用

对外提供的服务
要做一个能给别人用、能收费的服务,必须用 Streamable HTTP。
理由不只是方便:如果用 stdio,你得把代码和数据打包发给用户,那你的数据就直接交出去了,而且授权逻辑跑在用户机器上,改两行就绕过了。
把授权接进来
现在把第 4 章的授权服务接到工具上。三步。
第一步:在创建服务时准备好 guard 函数
def create_server(server_cfg=None, license_svc=None, repo=None) -> MCPServer:
server_cfg = server_cfg or ServerConfig.from_env()
license_svc = license_svc or LicenseService(LicenseConfig.from_env())
repo = repo or ChannelRepository(server_cfg)

mcp = MCPServer(name="blueocean-channels", instructions=INSTRUCTIONS)

def guard(ctx: Context, tool_name: str):

ctx.request_context.request 就是底层的 HTTP 请求对象

req = ctx.request_context.request
if req is None:
raise AuthError(MISSING_KEY_MSG) # stdio 模式没有 HTTP 上下文
client_host = req.client.host if req.client else ""
return authorize(license_svc, req.headers, client_host, tool_name)

关键是 ctx.request_context.request——SDK 把原始的 HTTP 请求对象 挂在了上下文里,你能从中拿到请求头和客户端 IP。这是实现鉴权的入口。
第二步:每个工具第一行调 guard
@mcp.tool()
def list_tiers(ctx: Context) -> dict:
"""列出层级(tier)枚举及各层级频道数。"""
st = guard(ctx, "list_tiers")
return _attach_notice({"tiers": repo.tiers()}, st)

注意工具函数多了一个 ctx: Context 参数。这个参数不会出现在模型看到的 参数列表里——SDK 识别到这个类型标注后会自动注入,模型完全无感。
第三步:把提示信息附到返回值上
def _attach_notice(result: dict, st) -> dict:
if st.notice:
result["license_notice"] = st.notice
return result

这样"卡密激活成功,有效期 30 天""授权还剩 2 天""检测到多设备使用" 这些消息就会跟着数据一起返回给模型,模型再转告用户。相当于免费的通知渠道。
错误怎么返回给模型
MCP 的约定是:工具函数抛异常 = 这次调用失败,异常消息返回给模型。 所以我们不需要自己构造错误响应,直接 raise 就行:
class AuthError(Exception):
"""抛出后由 SDK 转为工具错误,message 会被模型读到"""

def authorize(svc, headers, client_host, tool_name):
key, ip, ua = extract_credentials(headers, client_host)
if not key:
raise AuthError(MISSING_KEY_MSG)
try:
return svc.validate_request(key, ip, user_agent=ua, tool_name=tool_name)
except LicenseError as e:
raise AuthError(e.message) from e # 把中文提示透出去

实际效果是这样的——用户配了个错卡密,提问后模型会回复:
调用 list_tiers 时出错:卡密无效或不存在
看起来你配置的卡密有问题,请检查一下 Claude Code 里的 Authorization 头是否填对了。

后面那句是模型自己加的。你只要把错误信息写成人话,模型就会自动做客服。
完整的工具长什么样
把上面所有东西合起来,一个真实的工具是这样:
@mcp.tool()
def get_channel(ctx: Context, channel_id: str) -> dict:
"""按 YouTube 频道 ID(如 UCfHTkoiaDR8asfYZRXN8h5w)查询单个频道的完整指标详情。

返回全部指标字段 + youtube_url 外链。核心 KPI:subscribers/total_views/
age_months/subs_per_month/views_per_month/engagement_rate/breakout_ratio/
avg_views/long_video_count/video_count/score/tier。
"""
st = guard(ctx, "get_channel") # 1. 鉴权
row = repo.get(channel_id) # 2. 查数据
if row is None:
raise ValueError(f"频道 {channel_id} 不在库中(入库有门槛,查不到不等于不存在)")
return _attach_notice(row, st) # 3. 附加提示

九个工具全是这个模式。写完第一个,剩下八个是体力活。

**这一章的要点:**docstring 就是给模型的说明书,把枚举值、边界、陷阱、典型用法都写进去;对外服务必须用 Streamable HTTP; ctx.request_context.request 是拿请求头的入口;错误信息要写成人话。

第 6 章 测试:怎么验证授权真的没漏洞
授权逻辑的 bug 有个特点——不会立刻暴露。你今天写完测一下"能激活",一切正常;一个月后卡密该过期了却没过期,或者你停用了某张卡对方还能用,这时候才发现。 这一章讲怎么提前把这些问题测出来。
为什么授权必须有测试
普通业务代码写错了,你点一下界面就发现了。授权代码不一样,它的关键行为都跟时间 和并发有关:
"30 天后过期"——你总不能等 30 天
"并发激活只能成功一次"——手动点两下不可能真的同时
"IP 变了要顶掉旧的"——你得有两个不同网络的设备
"限流每分钟 30 次"——手动调 30 次?
这些用手是测不了的,只能靠自动化测试。
核心技巧:假时钟
这是本章最有用的一个技巧,对任何带有效期的业务都适用。
做法是:不要在代码里直接调 datetime.now(),而是把"取当前时间" 做成一个可以替换的东西。

service.py 里

class LicenseService:
def __init__(self, cfg, db=None, now_fn=None):
self.now = now_fn or datetime.now # ← 关键:可注入

def validate_request(self, ...):
now = self.now() # ← 全部通过 self.now() 取时间

然后在测试里塞一个假的:
class FakeClock:
def __init__(self, start=datetime(2026, 8, 4, 10, 0, 0)):
self.t = start

def now(self):
return self.t

def advance(self, **kw):
self.t += timedelta(**kw) # 想跳多久跳多久

有了它,测"30 天后过期"变成三行:
def test_expired_rejected(svc_clock):
svc, clock = svc_clock
key = svc.generate()[0]
svc.validate_request(key, IP_A) # 激活

clock.advance(days=30, seconds=1) # 时间快进 30 天零 1 秒

with pytest.raises(KeyExpiredError): # 应该拒绝
svc.validate_request(key, IP_A)

顺便验证状态被改成了 expired

assert svc.db.query_one("SELECT status FROM card_keys")["status"] == "expired"

整个测试0.01 秒跑完,而且完全确定——不依赖真实时间、不受机器时区影响、 在任何机器上跑结果都一样。

这个模式叫依赖注入
核心思想是:把不确定的外部依赖(时间、随机数、网络、文件系统)从代码里"拔出来" 做成参数,平时用真的,测试时换成假的。
除了时间,随机数也常这么处理。不过卡密生成用的是密码学随机数, 测试时反而应该用真的(要验证生成的卡密确实各不相同)。
第二类:状态机测试
卡密有四个状态,状态之间的每条转换都要有用例:
fig04.png
卡密状态机:测试要覆盖每条转换,以及每条

比如"停用后立刻拒绝"这条:
def test_disable_takes_effect(svc_clock):
svc, clock = svc_clock
key = svc.generate()[0]
svc.validate_request(key, IP_A) # 正常使用中

assert svc.disable_key(key) # 管理员停用

with pytest.raises(KeyDisabledError): # 应该立刻拒绝
svc.validate_request(key, IP_A)

这个用例还顺带验证了一件容易忘的事:停用时必须清缓存。 如果 disable_key() 忘了清缓存,这个测试会失败—— 因为 60 秒内的请求还会命中缓存直接放行。
第三类:边界与对抗测试
这类测试是站在"想占便宜的用户"角度写的。
限流刚好卡在阈值
def test_rate_limit_per_minute(tmp_path):
svc, clock = make_service(tmp_path, rate_per_minute=5) # 测试时调小阈值
key = svc.generate()[0]

for _ in range(5):
svc.validate_request(key, IP_A) # 前 5 次都要成功

with pytest.raises(RateLimitedError): # 第 6 次必须被拦
svc.validate_request(key, IP_A)

clock.advance(seconds=61) # 过了窗口
assert svc.validate_request(key, IP_A).valid # 又能用了

注意测试时把阈值调小(5 而不是 30)。没必要为了测逻辑真的调 30 次, 阈值本身是配置项,调小不影响验证逻辑正确性。
枚举防护:不存在的卡密也要计数
def test_rate_limit_counts_unknown_keys(tmp_path):
svc, _ = make_service(tmp_path, rate_per_minute=3)

用一个根本不存在的卡密狂试

for _ in range(3):
with pytest.raises(InvalidKeyError):
svc.validate_request("AAAA-BBBB-CCCC-DDDD", IP_A)

第 4 次应该是限流错误,而不是「卡密无效」

with pytest.raises(RateLimitedError):
svc.validate_request("AAAA-BBBB-CCCC-DDDD", IP_A)

这个用例在验证第 4 章讲的那个顺序:频控必须在查卡之前。 如果哪天有人重构代码把顺序调过来了,这个测试会立刻失败。
IP 顶替与换绑闸门
def test_ip_binding(tmp_path):
svc, clock = make_service(tmp_path, cache_ttl_seconds=0)
key = svc.generate()[0]
svc.validate_request(key, IP_A) # 绑定到 A

clock.advance(minutes=5) # 才过 5 分钟
with pytest.raises(IpConflictError): # 换绑闸门:拒绝
svc.validate_request(key, IP_B)

clock.advance(minutes=6) # 累计 11 分钟,超过 10 分钟
assert svc.validate_request(key, IP_B).valid # 允许换绑到 B

with pytest.raises(IpConflictError): # A 回来立刻用 → 被顶掉了
svc.validate_request(key, IP_A)

这个用例完整验证了第 4 章那个"两道闸门"的设计。 注意开头 cache_ttl_seconds=0——测 IP 逻辑时要关掉缓存, 否则缓存会让第二次请求直接命中,测不到绑定逻辑。
验证明文没有落库
这条容易被忘,但很重要:
def test_no_plaintext_in_db(svc_clock):
svc, _ = svc_clock
keys = svc.generate(count=20)
rows = svc.db.query("SELECT * FROM card_keys")
dumped = str(rows)
for k in keys:
assert k not in dumped # 明文绝不能出现
assert ("****-****-****-" + k[-4:]) in dumped # 但掩码要有

把整张表转成字符串,然后断言没有任何一张卡密的明文出现在里面。 这是一道防线——万一以后有人改代码不小心把明文存进去了,这个测试会拦住。
怎么组织测试文件
用 pytest 的 fixture 把重复的初始化收敛起来:
def make_service(tmp_path, **cfg_overrides):
cfg = LicenseConfig(
db_dialect="sqlite",
sqlite_path=str(tmp_path / "license_test.db"), # 每个测试独立的临时库
pepper="test-pepper-secret",
**cfg_overrides, # 允许单个测试覆盖配置
)
db = Database(cfg)
init_db(db)
clock = FakeClock()
return LicenseService(cfg, db=db, now_fn=clock.now), clock

@pytest.fixture
def svc_clock(tmp_path):
return make_service(tmp_path)

两个设计点:
**① 用 tmp_path 做数据库路径。**这是 pytest 内置的 fixture,每个测试都会拿到一个独立的临时目录,测完自动清理。测试之间完全隔离,不会互相污染。
**② 允许覆盖配置。**大部分测试用默认配置,个别测试需要调小阈值(比如限流测试),通过 **cfg_overrides 传进去就行。
跑起来
pytest tests -q
....................................... [100%]
39 passed in 1.28s

我这个项目最终 39 个用例,1.3 秒跑完。每次改完授权代码就跑一遍, 几秒钟就能确认没有破坏任何已有行为。

测试的真正价值在改代码的时候
写测试的时候会觉得"我明明知道这样是对的,为什么还要写"。 真正的价值在于——三个月后你要加一个新功能,改动了 validate_request, 跑一下测试,1 秒钟就知道有没有踩到别的逻辑。
我在这个项目里就遇到过一次:改缓存判断条件时把 <= 写成了 <, 测试立刻标红,几秒钟定位。没有测试的话,这个 bug 会在某个用户身上以"偶尔多验一次"的形式出现, 极难复现。

**这一章的要点:**把时间做成可注入的依赖,用假时钟测有效期;状态机的每条转换都要有用例,包括"不该发生的"; 对抗测试要站在滥用者角度写;每个测试用独立的临时数据库。

第三部分 上线
第 7 章 服务器选型:为什么用 Vultr
代码写完了,接下来要让它跑在一台别人能访问到的机器上。这一章讲选哪家、选什么配置、要花多少钱。
先说清楚前提,这决定了后面所有取舍
我做这个项目的目标很明确:跑通 MCP 全流程,写出这篇教程, 先用最小的金额把全部流程走完一遍。
这个前提直接决定了选型原则:不是"什么配置最好",而是"什么最省钱、最灵活、随时能销毁"。

如果你的目标不一样,选型也该不一样
要长期运营、有付费用户,那该考虑稳定性、备份、监控、甚至多机房容灾, 预算也完全不是一个量级。先想清楚你要什么,再选。 这篇教程的选型是按"低成本跑通"来的。
结论:用 Vultr
官网 vultr.com。四条理由,都很实在。
一、按小时计费,用完销毁就停止扣费(最关键)
这一条是决定性的。只用一个月的话,决定成本的是计费方式,不是单价。

计费方式

标价

用一个月实际花费

年付套餐

$50/年(看起来很便宜)

$50 —— 钱一次付清,退不回来

按小时计费

$10/月

$10 —— 用完销毁只付到销毁那一刻
年付套餐月均摊下来是便宜,但那是用满一年的前提。用一个月就扔, 等于花 $50 买了 $4 的服务。
二、支持支付宝
这条对国内用户很实际。很多海外服务商只收 Visa/MasterCard 或 PayPal, 没有信用卡就卡在第一步。Vultr 在充值页面可以选 Alipay,扫码就付。
三、有东京、新加坡机房
离得近,延迟低。虽然对 MCP 这种小数据量调用来说延迟不是瓶颈, 但能选近的没必要选远的。
四、没有重大安全事故记录
同价位的一些厂商有过数据泄露或机房火灾的记录。虽然我们只是跑个测试服务, 但既然价格差不多,没必要选有前科的。
配置怎么定:对着实际需求推一遍
不要凭感觉选"看起来够用的",一项项算:

项目

MCP 服务实际需要多少

结论

内存

Python 服务本身很轻(约 100MB)。真正吃内存的是数据库, 而且峰值出现在一次性导入数据的时候

2GB(1GB 也能跑,加个 swap 就行)

CPU

对话式调用,一次查询几十毫秒,并发极低

1 核足够

硬盘

我的全量数据(3.8 万频道 + 2,860 赛道 + 24.6 万视频)导进 MySQL 后约 200MB

25GB 起步绰绰有余

流量

每次调用返回几 KB 的 JSON。就算一天一万次也才几十 MB

最低档 1TB 根本用不完

系统

本教程后面所有命令都按它写

Ubuntu 24.04 LTS,别选别的

机房

用户本身能访问 Claude,说明有国际网络,延迟不敏感

东京或新加坡

内存这一项我要多说一句
我第一次导入 24.6 万行视频数据时,MySQL 默认配置吃掉了 800MB 内存。 2GB 的机器跑得动,但很紧张。后来我调了两个 MySQL 参数, 内存占用从 831MB 降到 465MB(第 10 章会给具体配置)。
所以:选 2GB 更省心;选 1GB 也能跑,但要按第 10 章的方法调优 + 加 swap。
总预算

项目

金额

说明

服务器

约 $10 ≈ ¥72/月

1 核 2GB。提前销毁按实际小时数结算,会更少

域名

¥10–90/年

.xyz / .online 首年常常几块钱,.com 约 ¥70–90

Vultr 首充门槛

通常 $10 起

充进去是余额,按小时从里面扣

合计

约 ¥80–160

够完整跑通一个月
我这个项目实际买的域名是 .online 后缀,首年 $0.98 + ICANN 费 $0.20 = $1.18, 折合人民币不到 9 块钱。

最容易多花钱的地方:忘记销毁
Vultr 的服务器关机状态照样按小时计费——因为 CPU、内存、IP 都还给你留着。 不用了必须执行 Destroy(销毁),不是 Stop。
这一点我在第 8 章末尾会再强调一次,并列出销毁后还要检查的残留计费项。

第 8 章 买服务器:完整流程
从注册账号到拿到一台能 SSH 登录的机器。全程约 10 分钟。
第一步:注册并充值
打开 vultr.com,右上角 Sign Up
填邮箱和密码注册
去邮箱点验证链接。建议用 Gmail 或 Outlook—— 国内邮箱(163、QQ)经常把海外服务商的邮件拦进垃圾箱甚至直接丢弃, 后续的账单、告警邮件都要靠它
登录后进 Billing 页面充值,支付方式选 Alipay,金额 $10
第二步:创建服务器,六处关键选项
后台点右上角蓝色 + → Deploy New Server。页面从上到下六个区块:
fig05.png
Vultr 创建服务器页面:六处需要你决定的地方

五条一定要注意的事

① 最便宜那档是 IPv6 Only,千万别选
Vultr 有一档价格特别低($2.50/月上下,硬盘只有 10GB), 但它没有独立的 IPv4 地址。
后果:用户访问不了(绝大多数网络还是 IPv4 为主)、Let's Encrypt 也签不出证书,整个部署走不下去。认准配置里带独立 IPv4 的档位。

② Additional Features 里有一个必踩的坑
有个选项叫 "No Public IPv4 Address",免费。听起来像是"不额外买 IPv4", 实际是"不给我 IPv4"。勾了之后果和上一条一样——整个部署废掉。这一项保持不勾。

③ Auto Backups 默认可能是勾选的
页面上它标着 "Recommended",所以默认就勾上了,额外收 20% 的费用。 一个月的测试机用不上,取消掉。
判断方法:看右下角 Summary 的金额。如果显示 $12.40 而不是 $10.00, 说明那 $2.40 就是它。

④ 先选机房,再选套餐
不同机房支持的套餐不完全一样。如果先选套餐再选机房, 容易撞到"该套餐在此机房不可用",页面卡住动不了。 按图上 1→2→3→4 的顺序走就不会有问题。

⑤ Limited User Login 不要勾
这个选项会创建一个叫 linuxuser 的普通账号代替 root。 虽然从安全角度这是好习惯,但本教程后面的命令都是按 root 写的, 勾了之后每条命令都要加 sudo。新手建议保持不勾。
第三步:拿到登录信息
点 Deploy Now,等 1–3 分钟,状态从 Installing 变成 Running。
点进服务器详情页,在 Server Information 区块能看到三样东西:
IP Address : 203.0.113.45 # ← 你的服务器 IP
Username : root
Password : •••••••••••• # ← 点旁边的眼睛图标显示明文,再点复制图标

把这三样记下来,第 10 章部署时要用。密码很长且随机,建议直接用复制按钮,别手抄。

**这一章的完成标志:**服务器状态 Running,你手上有 IP、用户名 root、以及 root 密码。
最后:用完怎么销毁(现在就记住)
虽然是一个月后的事,但现在就说清楚,免得忘了一直扣费。
**关机(Stop)不停止计费,必须销毁(Destroy)。**路径:Products → 点进服务器 → Settings(或右上角 ⋯ 菜单)→ Destroy → 输入确认。
销毁之后还要检查三个可能残留的计费项:

项目

在哪

怎么处理

Snapshots(快照)

Products → Snapshots

按容量收费,不留就删

Reserved IP(保留 IP)

Products → Network

未绑定服务器的保留 IP 仍然计费,删掉

Block Storage

Products → Block Storage

本教程没用到,如有就删

第 9 章 在 Namecheap 买域名并解析到服务器
这一章会写得很细,逐屏逐字段。如果你从没在国外网站买过域名,照着做就行,不需要任何背景知识。
先解决一个高频误解:要不要备案

备案跟服务器所在地绑定,不跟域名注册地绑定
服务器在境外(日本、新加坡、美国等)→ 完全不需要备案,国外注册商的域名可以直接用,当天就能上线。
服务器在中国大陆 → 必须备案,而且国外注册商的域名基本无法完成备案手续。
我们第 8 章买的是东京机房的服务器,所以不需要备案,这一节可以放心跳过备案的事。
为什么用 Namecheap
界面简单、价格透明、免费送隐私保护(这项很多注册商要另外收费)。

但有一件事要先确认:支付方式
Namecheap 只支持 Visa / MasterCard / American Express / Discover / PayPal,不支持支付宝和微信。
如果你没有这几种支付方式,先别注册账号,直接换成阿里云或腾讯云买域名—— 它们支持支付宝微信,缺点是需要实名认证(上传身份证,几小时到 1 个工作日通过)。 买完同样是加一条 A 记录,后面的步骤完全一样。
第一步:注册账号
打开 namecheap.com,右上角 Sign Up。表单有五项:

字段

填什么

注意

First name

你的名的拼音

比如"甄帅"填 Shuai

Last name

你的姓的拼音

比如"甄帅"填 Zhen。中文习惯是姓在前,英文表单是名在前,别填反

Email address

能收信的邮箱

建议 Gmail/Outlook,后面 ICANN 验证邮件要靠它

Username

登录用的账号名

最容易卡住的一项,见下方

Password

密码

要求含大小写和数字

Username 被占用时的报错很容易看错
如果你填的用户名已经有人用了,页面顶部会出现一行红字:
User name you requested is already in use. Please try using a different username.
这行字出现在表单最上方,而 First name / Last name 就在它正下方,很容易被误以为是姓名字段的问题。
实际上它说的是 Username 被占用了。解决办法:在后面加数字或下划线, 比如 myname2026、myname_mcp,一般一次就能过。
第二步:搜域名、选后缀
登录后在首页搜索框输入你想要的名字,比如 my-mcp。 搜索结果会列出各种后缀的价格和是否可用。

后缀

首年价格

建议

.com

约 $6–10

最稳妥,但续费贵(约 $15/年)

.online / .xyz / .top

常常 $1 以内

测试用途首选。我买的就是 .online,首年 $0.98

域名不用起得好记
这跟做网站不一样。你的用户只会在配置文件里粘贴一次这个域名, 之后再也不会手动输入。所以好不好记完全不重要,便宜就行。
选好后点 Add to Cart,然后点 Checkout。
第三步:填 WHOIS 联系信息(这一步最容易卡住)
ICANN 规定每个域名都必须登记注册人的联系信息。这个表单有十来个字段, 而且校验很严,是整个流程中最容易反复失败的地方。
逐字段来。假设你的地址是"广东省深圳市南山区南山街道幸福路 66 号 802 室":

字段

填什么

为什么

First / Last name

Shuai / Zhen

和注册账号时一样,用拼音

Company Name

留空

个人注册不用填

"registering on behalf of a company"

不要勾

勾了 Company Name 会变必填

Address Line 1

Room 802, No.66 Xingfu Road

只接受拉丁字母,中文会报 INVALID。门牌号在前,由小到大

Address Line 2

Nanshan Subdistrict, Nanshan District

街道和区放这里

City

Shenzhen

填城市,不要填区。"南山区"不是城市,深圳才是

State/Province

Guangdong

省份拼音。不要带任何中文字符

Zip/Postal Code

518000

填你所在地的真实邮编

Country

China

务必和电话区号、邮编一致。默认可能是 United States,记得改

Phone

区号选 +86,号码填手机号

区号别用默认的 +1

Email Address

你的邮箱

见下方那条最坑的提醒

输入邮箱前,把输入法切成英文半角
这是最隐蔽的一个坑。中文输入法下打出来的 @ 和 . 是全角字符,跟英文的 @ . 长得几乎一模一样, 肉眼根本看不出区别,但校验必然失败,页面只会红着脸说 "A valid email is required"。
解决办法:点进邮箱框,Ctrl+A 全选删除,按 Shift 确认输入法切到"英"(任务栏能看到), 然后一个字符一个字符手敲,不要复制粘贴。

关于地址真实性
WHOIS 信息按 ICANN 规则必须真实准确,填假地址可能导致域名被暂停。 不用担心隐私——下一步的 Domain Privacy 会把这些信息从公开数据库里隐藏掉。
第四步:Domain Privacy 保持开启
结账页会有一项 Domain Privacy(有的地方叫 WhoisGuard), Namecheap 免费赠送一年,默认是勾上的。
一定要保持开启。不开的话,你刚才填的姓名、家庭住址、手机号、邮箱会全部出现在公开的 WHOIS 数据库里,任何人输入你的域名就能查到。 开启后显示的是 Namecheap 的代理信息。
第五步:付款
确认账单:域名费 + ICANN fee $0.20(这个是强制的,每个域名都有,不是乱收费)。 我的账单是 $0.98 + $0.20 = $1.18。
填信用卡信息付款。付完会跳到订单完成页,显示订单号。
第六步:验证邮箱(有时限,必须做)

15 天内不做,域名会被强制暂停解析
这是 ICANN 的硬性规定,不是 Namecheap 的规矩。域名被暂停后你的服务会直接不可用。
去你填的邮箱找验证邮件:
主题一般含 "Verify your email address" 或 "Immediate action required"
发件人是 support@namecheap.com 或 verification@namecheap.com
记得翻垃圾邮件夹
点邮件里的链接,几秒完成

注意有两封不同的验证邮件
注册账号时有一封(验证你的 Namecheap 账号),买完域名后还有一封(ICANN 要求验证域名注册人邮箱)。这是两件事,两封都要点。
判断方法:去 Domain List 页面看域名的 Status—— 如果是黄色的 ⚠ ALERT 且右侧按钮显示 VERIFY CONTACTS,说明还没验证完; 验证完成后会变成绿色的 ✓ ACTIVE,按钮变回 MANAGE。
第七步:进入域名管理页
左侧菜单 Domain List → 找到你的域名 → 右侧点 MANAGE (或者直接点蓝色的域名名称)。
进去后,在域名标题正下方有一排四个带图标的标签:
fig06.png
这排标签样式像图标条,很容易看漏。Advanced DNS 是最右边那个

这排标签很容易找不到
它不在左侧菜单,也不在顶部导航栏,而是在页面中间、域名大标题的正下方。 样式是灰底方块加小图标,看起来像装饰条而不是选项卡。
当前选中的 Domain 是青色高亮那块,Advanced DNS 在它右边隔两格。
第八步:删掉默认的停放页记录(关键,不做后面全白干)
新注册的域名,Namecheap 会自动给它加上指向广告停放页的记录。这些记录会强占你的域名解析,不删掉的话,你加多少条 A 记录都不会生效。
坑在于这些记录分散在两个不同的地方,都要清。
地方一:Domain 标签页的 Redirect Domain
就在你刚进来的这一页,往下滚,找到 REDIRECT DOMAIN 区块。 如果里面有一条类似这样的记录:
Source URL: my-mcp.online → Destination URL: http://www.my-mcp.online/

点右侧的 Remove 删掉它。如果页面出现 Save All Changes 按钮,点一下保存。
删干净后这个区块应该显示 You haven't defined any Redirect Domain yet.
地方二:Advanced DNS 的 HOST RECORDS
切到 Advanced DNS 标签,找到 HOST RECORDS 区块。 把这类记录全部删掉(点每行最右边的垃圾桶图标):

Type

Host

Value

处理

CNAME Record

@

parkingpage.namecheap.com.

URL Redirect Record

www

任何 http:// 开头的地址


原则很简单:Host 是 @ 或 www 的记录,除了你马上要加的那条 A 记录, 其余全部删掉。
顺便确认 Nameservers
回到 Domain 标签页,往上找到 NAMESERVERS 区块, 确认选的是 Namecheap BasicDNS(默认值)。只有用 Namecheap 自己的 DNS,Advanced DNS 里加的记录才会生效。
第九步:添加 A 记录
在 Advanced DNS 的 HOST RECORDS 区块,点 ADD NEW RECORD,填四个格子:
fig07.png
添加 A 记录:填完点最右边的绿色对勾保存

两个最常见的填错
Host 只能填 @,不要填完整域名。@ 是"根域名本身"的意思。
Value 只填纯 IP,不要带 http://,也不要带结尾的斜杠。
点行末的绿色对勾保存。如果页面上方出现 SAVE ALL CHANGES,再点一次—— 有时候只点行末对勾还不够,不点这个按钮改动会丢。
第十步:验证解析生效
等 5–15 分钟(新域名首次解析可能久一点),在你电脑上打开命令行执行:
nslookup my-mcp.online 8.8.8.8

看到这样就成功了:
服务器: dns.google
Address: 8.8.8.8

名称: my-mcp.online
Address: 203.0.113.45 ← 你的服务器 IP

查出来的结果

说明

怎么办

正是你的服务器 IP

成功

继续第 10 章

只有"名称"没有 Address

A 记录还没生效

再等 10 分钟

是别的 IP(如 192.64.x.x)

停放页记录没删干净

回第八步,两个地方都检查

找不到域名

NS 还没生效

新注册域名可能要等更久,最多等几小时

这一章的完成标志:nslookup 能查到你的域名指向你的服务器 IP,并且域名列表页的状态是绿色 ACTIVE。

第 10 章 部署:从空机器到服务跑起来
这一章假设你从没登录过 Linux 服务器。每条命令都会说清楚在做什么、执行后应该看到什么。全程约 1 小时,大部分时间在等安装。
第一步:连上服务器
SSH 是什么
SSH 是远程登录服务器的方式。你在自己电脑上敲命令,命令实际在服务器上执行, 结果回显到你屏幕上。Windows 10/11 和 macOS 都自带 SSH,不用装任何软件。
Windows 打开 PowerShell(开始菜单搜 PowerShell),macOS 打开终端,然后:
ssh root@203.0.113.45

第一次连会问一句:
The authenticity of host '203.0.113.45' can't be established.
ED25519 key fingerprint is SHA256:xxxxx.
Are you sure you want to continue connecting (yes/no)?

输入 yes 回车。这是在问"你确认要信任这台机器吗",第一次连都会问。
然后输入密码。注意:输密码时屏幕上什么都不显示——不是卡住了, Linux 就是这么设计的。粘贴或者盲敲完直接回车。
登录成功后你会看到类似这样的欢迎信息,提示符变成 root@my-mcp:~#:
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-generic x86_64)
root@my-mcp:~#

从现在开始,所有命令都在这个窗口里敲
后面所有以 # 开头的说明和命令,都是在服务器上执行的,不是在你自己电脑上。
第二步:先验收机器,再部署
新买的机器先花 5 分钟测一下,确认没被超售、网络正常。 这一步的意义在于:如果机器有问题,现在换还来得及(还在退款期), 部署完才发现就麻烦了。
测硬盘读写
dd if=/dev/zero of=/tmp/iotest bs=1M count=1024 oflag=direct

1073741824 bytes (1.1 GB) copied, 3.35 s, 320 MB/s

rm -f /tmp/iotest

**怎么判断:**写入速度 100 MB/s 以上算正常,SSD 通常在 200–500 MB/s。如果只有几十 MB/s,说明这台机器的磁盘被严重超售,建议换一台。
测 CPU
time python3 -c "s=0
for i in range(5000000): s+=i*i"

real 0m1.72s

**怎么判断:**500 万次循环在 3 秒以内算正常。超过 5 秒说明 CPU 被超售严重。
测能不能访问关键服务
for u in "https://acme-v02.api.letsencrypt.org/directory" \
"http://archive.ubuntu.com" \
"https://pypi.org/simple/" ; do
echo -n "$u : "
curl -s -o /dev/null -w '%{http_code}' -m 15 "$u"
done

https://acme-v02.api.letsencrypt.org/directory : 200

http://archive.ubuntu.com : 200

https://pypi.org/simple/ : 200

这三个分别是:证书签发服务、系统软件源、Python 包源。都返回 200 才能继续, 不然后面装东西会卡住。
查 IP 有没有被拉黑
这一项很容易被忽略,但很重要——如果服务器 IP 在某些黑名单上, 可能影响证书签发和用户访问。
python3 - <<'EOF'
import socket
ip = "203.0.113.45" # 换成你的 IP
rev = ".".join(reversed(ip.split(".")))
for zone, name in {
"bl.spamcop.net": "SpamCop",
"b.barracudacentral.org": "Barracuda",
"dnsbl.sorbs.net": "SORBS",
}.items():
try:
r = socket.gethostbyname(f"{rev}.{zone}")
print(f" ✗ {name} 命中 -> {r}")
except socket.gaierror:
print(f" ✓ {name} 未命中")
EOF

一个判断技巧:怎么识别误报
有些黑名单(比如 Spamhaus)会拒绝来自公共 DNS 的查询, 这时它返回的不是"命中",而是一个错误码,但代码看起来像命中了。
**验证方法:拿几个已知结果的 IP 做对照。**比如同时查一下8.8.8.8(必定干净)和 127.0.0.2(黑名单官方的测试点,必定命中)。如果这三个查询返回的值完全一样,那就是误报——说明查询本身被拒了,根本没查到真实结果。
这个"用已知结果做对照"的思路,在排查任何检测类问题时都好用。
第三步:系统基础设置

时区改成北京时间(默认是 UTC,日志时间会差 8 小时)

timedatectl set-timezone Asia/Shanghai
date

Thu Aug 6 20:16:17 PM CST 2026

主机名改成好认的(可选)

hostnamectl set-hostname my-mcp

防火墙放行 80 和 443(22 通常默认已开)

ufw allow 80/tcp
ufw allow 443/tcp
ufw status

22/tcp ALLOW Anywhere

80/tcp ALLOW Anywhere

443/tcp ALLOW Anywhere

防火墙这一步别漏
Ubuntu 默认可能只放行了 22 端口。不开 80 和 443,后面签证书会失败、 用户也访问不了。而且这个问题很难排查——服务本身跑得好好的,就是外面连不进来。
第四步:安装运行环境
export DEBIAN_FRONTEND=noninteractive # 让安装过程不弹交互提示

apt-get update -qq # 更新软件包索引

apt-get install -y -qq --no-install-recommends \
python3-venv python3-dev build-essential \
mysql-server pkg-config \
ca-certificates curl gnupg

装完确认版本:
python3 --version

Python 3.12.3

mysql --version

mysql Ver 8.0.46 for Linux on x86_64

systemctl is-active mysql

active

为什么要用虚拟环境
Python 的包如果直接装到系统里,不同项目的依赖会打架 (A 项目要 requests 2.0,B 项目要 requests 3.0)。虚拟环境相当于给这个项目单独隔出一个干净的 Python。
mkdir -p /opt/my-mcp && cd /opt/my-mcp
python3 -m venv .venv
.venv/bin/python -m pip install --quiet --upgrade pip

之后所有 Python 命令都用 .venv/bin/python 开头,而不是直接 python3。
第五步:建数据库和账号
先说两个设计原则:

原则

为什么

授权库和业务库分开

卡密数据和业务数据是两回事。分开之后,业务库出问题不会牵连授权, 备份策略也可以不一样(授权库每天备份,业务库丢了重新导就行)

用最小权限账号,不用 root

服务用的数据库账号只给它需要的那两个库的权限。 万一代码有 SQL 注入漏洞,攻击者也动不了其他数据
mysql <<'SQL'
CREATE DATABASE IF NOT EXISTS mcp_license DEFAULT CHARSET utf8mb4;
CREATE DATABASE IF NOT EXISTS mcp_data DEFAULT CHARSET utf8mb4;

CREATE USER IF NOT EXISTS 'mcp_app'@'localhost' IDENTIFIED BY '换成一个长随机密码';
GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,INDEX,ALTER,DROP,REFERENCES
ON mcp_license.* TO 'mcp_app'@'localhost';
GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,INDEX,ALTER,DROP,REFERENCES
ON mcp_data.* TO 'mcp_app'@'localhost';
FLUSH PRIVILEGES;
SQL

密码用这个命令生成一个:
python3 -c "import secrets,string; a=string.ascii_letters+string.digits; print(''.join(secrets.choice(a) for _ in range(28)))"

MySQL 内存调优(1GB 机器必做,2GB 也建议做)
cat > /etc/mysql/mysql.conf.d/tuning.cnf <<'EOF'
[mysqld]
innodb_buffer_pool_size = 256M
max_connections = 50
performance_schema = OFF
EOF

systemctl restart mysql
free -h

我实测的效果:调优前占用 831MB,调优后 465MB,省下将近一半。 三个参数的作用:
innodb_buffer_pool_size —— MySQL 的数据缓存。默认会吃很多, 我们数据量小,256M 够用
max_connections —— 最大连接数。默认 151,我们用不到那么多
performance_schema —— 性能监控功能,占内存且我们用不上,关掉
第六步:生成并保管密钥
创建环境变量文件,把所有配置和凭据放进去:
PEPPER=$(python3 -c "import secrets; print(secrets.token_hex(32))")
ADMTOK=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")

cat > /etc/my-mcp.env <<EOF
LICENSE_PEPPER=${PEPPER}
LICENSE_ADMIN_TOKEN=${ADMTOK}

LICENSE_DB_DIALECT=mysql
LICENSE_MYSQL_HOST=127.0.0.1
LICENSE_MYSQL_USER=mcp_app
LICENSE_MYSQL_PASSWORD=你刚才生成的数据库密码
LICENSE_MYSQL_DATABASE=mcp_license

BLUEOCEAN_DATA_BACKEND=mysql
BLUEOCEAN_MYSQL_HOST=127.0.0.1
BLUEOCEAN_MYSQL_USER=mcp_app
BLUEOCEAN_MYSQL_PASSWORD=你刚才生成的数据库密码
BLUEOCEAN_MYSQL_DATABASE=mcp_data

MCP_HOST=127.0.0.1
MCP_PORT=8900
EOF

chmod 600 /etc/my-mcp.env # 只有 root 能读

把 PEPPER 显示出来,立刻离线备份

grep LICENSE_PEPPER /etc/my-mcp.env

现在就把 LICENSE_PEPPER 抄下来存到密码管理器
这个值丢了,所有已发出的卡密全部作废,无法恢复。
服务器可能被销毁、重装、误删。不要只留在服务器上。
第七步:上传代码
在你自己的电脑上(不是服务器),开一个新的命令行窗口:

Windows PowerShell / macOS 终端都可以用 scp

scp -r mcp_license mcp_server requirements.txt root@203.0.113.45:/opt/my-mcp/

不需要上传的东西:tests/(线上不跑测试)、.venv/ (服务器上重新建)、任何 .env 真实配置文件(已经在服务器上了)。
回到服务器窗口,装依赖:
cd /opt/my-mcp
.venv/bin/pip install --quiet pymysql mcp uvicorn starlette

验证能导入

.venv/bin/python -c "import mcp, pymysql; from mcp.server import MCPServer; print('OK')"

OK

第八步:初始化数据库表
cd /opt/my-mcp
set -a; . /etc/my-mcp.env; set +a # 加载环境变量到当前 shell

.venv/bin/python -m mcp_license.admin_cli init-db

数据库表结构初始化完成(mysql)

确认四张表都建好了

mysql -u"$LICENSE_MYSQL_USER" -p"$LICENSE_MYSQL_PASSWORD" -D mcp_license -e "SHOW TABLES;"

card_keys

license_fingerprints

license_sessions

usage_logs

解释一下 set -a; . 文件; set +a
这三个命令的作用是把文件里的变量加载成环境变量。 set -a 打开"自动导出"模式,. 读取文件, set +a 关掉。
这样之后,当前窗口里执行的命令就能读到 LICENSE_PEPPER 这些值了。新开窗口需要重新执行一次。
第九步:把数据传上去
我的数据在本地 SQLite 里,70MB。直接传比较慢,先压缩:
在你自己的电脑上:
python -c "
import gzip, shutil
with open('mydata.db','rb') as f, gzip.open('mydata.db.gz','wb',compresslevel=6) as g:
shutil.copyfileobj(f, g, 1024*1024)
"

70.6 MB → 32.5 MB

scp mydata.db.gz root@203.0.113.45:/opt/my-mcp/data/

我实测上传速度约 200 KB/s,32.5MB 传了大概 3 分钟。压缩这一步省了一半时间。
回到服务器解压并导入:
cd /opt/my-mcp
gunzip -kf data/mydata.db.gz

set -a; . /etc/my-mcp.env; set +a
time .venv/bin/python -m mcp_server.sync --source data/mydata.db

我的实际导入结果:
channels: 38548 行, 8.0s
niches: 2860 行, 0.3s
videos: 246226 行, 27.2s

real 0m35.808s

24.6 万行视频数据 27 秒导完。关键是同步脚本里用了分批提交——每 2000 行提交一次事务,而不是一行一条 INSERT:
while True:
rows = cur.fetchmany(2000) # 一次取 2000 行
if not rows:
break
with target.transaction(): # 一个事务提交这 2000 行
target.executemany(upsert, [tuple(r) for r in rows])

如果一行一个事务,同样的数据量要跑几十分钟。
导完验证一下:
mysql -u"$LICENSE_MYSQL_USER" -p"$LICENSE_MYSQL_PASSWORD" -D mcp_data -e "
SELECT 'channels' t, COUNT(*) n FROM channels
UNION ALL SELECT 'niches', COUNT(*) FROM niches
UNION ALL SELECT 'videos', COUNT(*) FROM videos;"

然后把压缩包删掉释放磁盘:
rm -f data/mydata.db.gz
df -h /

第十步:做成系统服务(开机自启、挂了自动重启)
现在服务能跑,但你一关 SSH 窗口它就停了。要让它常驻后台, 用 Linux 的 systemd。
systemd 是什么
Linux 的服务管理器。你告诉它"这个程序怎么启动",它负责: 开机自动拉起、崩溃了自动重启、日志统一收集、可以随时查状态。
cat > /etc/systemd/system/my-mcp.service <<'EOF'
[Unit]
Description=My MCP Server
After=network.target mysql.service
Requires=mysql.service

[Service]
Type=simple
WorkingDirectory=/opt/my-mcp
EnvironmentFile=/etc/my-mcp.env
ExecStart=/opt/my-mcp/.venv/bin/python -m mcp_server
Restart=always
RestartSec=5
StandardOutput=append:/var/log/my-mcp.log
StandardError=append:/var/log/my-mcp.err.log

[Install]
WantedBy=multi-user.target
EOF

逐行解释关键项:

配置

作用

After / Requires=mysql.service

保证 MySQL 先启动。 不写的话开机时可能服务先起来、数据库还没好,直接崩

EnvironmentFile

从哪读环境变量。这就是凭据不进代码的关键

Restart=always

不管什么原因退出都自动重启

RestartSec=5

重启前等 5 秒,避免疯狂重启把 CPU 打满

StandardOutput=append:

日志写到文件,方便排查
启用并启动:
systemctl daemon-reload # 让 systemd 重新读配置
systemctl enable --now my-mcp # enable=开机自启,--now=现在也启动

systemctl is-active my-mcp

active

确认端口在监听

ss -tlnp | grep 8900

LISTEN 0 2048 127.0.0.1:8900 ... users:(("python",pid=5239,...))

注意监听地址是 127.0.0.1 而不是 0.0.0.0
这意味着只有服务器本机能访问这个端口,外网访问不到。 这是故意的——第 11 章会在前面加一层 Caddy 负责对外和 HTTPS。让业务服务只监听本机,是标准做法,能减少直接暴露的攻击面。
常用的运维命令:
systemctl status my-mcp # 看状态
systemctl restart my-mcp # 重启
tail -f /var/log/my-mcp.log # 实时看日志
journalctl -u my-mcp -n 50 # 看最近 50 条系统日志

第十一步:定时任务
两个定时任务:每小时刷新过期卡密、每天备份授权库。

过期刷新

cat > /etc/systemd/system/mcp-expire.service <<'EOF'
[Unit]
Description=Refresh expired license keys
[Service]
Type=oneshot
WorkingDirectory=/opt/my-mcp
EnvironmentFile=/etc/my-mcp.env
ExecStart=/opt/my-mcp/.venv/bin/python -m mcp_license.admin_cli expire-refresh
EOF

cat > /etc/systemd/system/mcp-expire.timer <<'EOF'
[Unit]
Description=Run expire-refresh hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now mcp-expire.timer

systemctl list-timers 'mcp*'

NEXT LEFT UNIT ACTIVATES

Thu 2026-08-06 21:00:00 CST 37min mcp-expire.timer mcp-expire.service

备份任务同理,把 ExecStart 换成一个 mysqldump 脚本、 OnCalendar 换成 daily 即可。备份脚本建议加一行 find 备份目录 -mtime +30 -delete 自动清理 30 天前的旧备份。

这一章的完成标志:systemctl is-active my-mcp 返回 active,ss -tlnp | grep 8900 能看到监听,数据库里三张表数据齐全, systemctl list-timers 能看到两个定时器。

第 11 章 HTTPS 配置,以及那个让全线不通的 421
前半章配 HTTPS,10 分钟搞定。后半章讲一个必踩的坑——证书配好之后,通过域名访问会全线返回 421,而本机直连完全正常。
为什么需要反向代理
现在的状况是:MCP 服务跑在 127.0.0.1:8900,只有服务器本机能访问。 我们需要让外网用户通过 https://你的域名/mcp 访问到它。
中间这一层就是反向代理,它负责:
监听公网的 80 和 443 端口
处理 HTTPS 加密解密(申请证书、自动续期)
把请求转发给内部的 8900 端口
把响应返回给用户
我用 Caddy 而不是 Nginx,原因只有一个:Caddy 自动申请和续期 Let's Encrypt 证书,配置只要一行域名。Nginx 要额外装 certbot、写续期任务, 对新手来说多好几个坑。
安装 Caddy
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
apt-get update -qq
apt-get install -y -qq caddy

caddy version

v2.11.4

写配置文件
cat > /etc/caddy/Caddyfile <<'EOF'
my-mcp.online {
encode zstd gzip

MCP 服务

handle /mcp* {
reverse_proxy 127.0.0.1:8900 {
flush_interval -1
transport http {
read_timeout 300s
}
}
}

管理后台不对公网开放

handle /admin* {
respond "forbidden" 403
}

handle /healthz {
respond "ok" 200
}

handle {
respond "My MCP Server" 200
}
}
EOF

把 my-mcp.online 换成你自己的域名。几个关键配置:

配置

作用

第一行直接写域名

Caddy 看到域名就自动去申请 HTTPS 证书,不需要任何额外配置

flush_interval -1

**关闭响应缓冲。**MCP 的 Streamable HTTP 会用到流式响应,缓冲会导致数据卡住不返回

read_timeout 300s

长连接超时时间,给流式响应留足余量

handle /admin* → 403

管理后台不对公网开放, 需要用时通过 SSH 隧道访问

/healthz

健康检查端点,方便快速确认服务活着
校验配置并重载:
caddy validate --config /etc/caddy/Caddyfile

Valid configuration

systemctl reload caddy

证书自动签发
重载之后 Caddy 会自动去 Let's Encrypt 申请证书。整个过程 10 秒左右。
curl -s https://my-mcp.online/healthz

ok

看证书信息:
echo | openssl s_client -connect my-mcp.online:443 -servername my-mcp.online 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates

subject=CN = my-mcp.online

issuer=C = US, O = Let's Encrypt, CN = YE1

notBefore=Aug 6 14:08:31 2026 GMT

notAfter=Nov 4 14:08:30 2026 GMT

顺便验证 HTTP 会自动跳转到 HTTPS:
curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}' http://my-mcp.online/healthz

308 -> https://my-mcp.online/healthz

证书续期不用管
Let's Encrypt 的证书有效期 90 天,Caddy 会在到期前自动续期, 你什么都不用做。这是我推荐 Caddy 的主要原因。
然后就撞墙了:HTTP 421 Invalid Host header
健康检查通了,证书也签好了。但当我用 MCP 客户端连 https://my-mcp.online/mcp 时,初始化直接失败。
用原始 HTTP 请求探一下,看到了真正的错误:
curl -X POST https://my-mcp.online/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'

HTTP 421
Invalid Host header

而在服务器本机直连 8900 端口,完全正常:
curl -X POST http://127.0.0.1:8900/mcp -H 'Content-Type: application/json' ...
HTTP 200

排障过程:三步定位
第一步:先排除网络和代理
我一开始怀疑是本地代理干扰。分别用直连和走代理试了一次:
直连: HTTP 421 | Invalid Host header
经代理: HTTP 421 | Invalid Host header

两边一样,说明不是网络问题,是服务端真的返回了 421。这一步很重要——如果不先排除,你会在网络上浪费很多时间。
第二步:看响应体到底说了什么
421 这个状态码本身不常见(Misdirected Request), 但响应体里的 Invalid Host header 是个明确线索—— 说明有代码在检查 HTTP 请求的 Host 头,并且不认我们的域名。

这是排障的一个通用习惯
**不要只看状态码,一定要看响应体。**很多框架会在响应体里写明确的原因,比状态码信息量大得多。用 curl 时别加 -o /dev/null, 或者至少单独跑一次把内容打出来。
第三步:去 SDK 源码里搜这句话
grep -rn "Invalid Host header" .venv/lib/python3.12/site-packages/mcp/

mcp/server/transport_security.py:109: return Response("Invalid Host header", status_code=421)

找到了。打开这个文件看逻辑:
class TransportSecuritySettings(BaseModel):
"""防 DNS 重绑定攻击的设置"""
enable_dns_rebinding_protection: bool = True
allowed_hosts: list[str] = Field(default_factory=list)
allowed_origins: list[str] = Field(default_factory=list)

再往上追,找到关键的这段:

mcp/server/lowlevel/server.py

if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)

根因找到了。
根因:SDK 的自动安全策略
MCP SDK 有一个防护机制,针对的是 DNS 重绑定攻击—— 简单说就是:恶意网页可以把某个域名解析到 127.0.0.1, 从而用浏览器去访问你本机上跑的服务,偷取数据。
SDK 的防御方式是检查 HTTP 请求的 Host 头,只接受白名单里的值。 而且它很贴心地自动判断:如果你的服务绑定在 127.0.0.1 (说明是本地服务,可能被这种攻击针对),就自动开启这个防护, 白名单默认只有 localhost 那几个。
问题就出在这里:
fig08.png
421 的成因:两个各自正确的设计撞在了一起

两种修法,选哪个

方案

做法

评价

A. 关掉防护

enable_dns_rebinding_protection=False

**不推荐。**一行就能改好,但等于把 SDK 特意加的一层安全防护整个拆掉

B. 让 Caddy 改写 Host

转发时把 Host 改成 127.0.0.1:8900

能work,但服务端从此拿不到真实域名,日志和调试都会受影响

C. 把域名加进白名单(采用)

显式配置 allowed_hosts

推荐。防护保留,只是告诉它"这个域名是我自己的"
实现方案 C
先在配置里加一个字段:

mcp_server/config.py

@dataclass
class ServerConfig:
host: str = "127.0.0.1"
port: int = 8900

公网域名。服务绑定 127.0.0.1 时 SDK 会自动开启 DNS 重绑定防护,

白名单默认只含 localhost,反代过来的真实 Host 会被拒(HTTP 421)。

配置此项即把域名加入白名单,防护仍然保留。

public_host: str = ""

@classmethod
def from_env(cls):
return cls(
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8900")),
public_host=os.environ.get("MCP_PUBLIC_HOST", ""),
)

然后在启动时构造白名单:

mcp_server/__main__.py

from mcp.server.transport_security import TransportSecuritySettings

def build_security(cfg: ServerConfig) -> TransportSecuritySettings:
"""构造 DNS 重绑定防护白名单。保留防护,而不是关闭它。"""
hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
origins = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"]
if cfg.public_host:
hosts += [cfg.public_host, f"{cfg.public_host}:*"]
origins += [f"https://{cfg.public_host}", f"http://{cfg.public_host}"]
return TransportSecuritySettings(
enable_dns_rebinding_protection=True, # 防护保持开启
allowed_hosts=hosts,
allowed_origins=origins,
)

def main():
cfg = ServerConfig.from_env()
server = create_server(cfg)
server.run(
transport="streamable-http",
host=cfg.host,
port=cfg.port,
transport_security=build_security(cfg),
)

最后在服务器的环境变量文件里加一行,重启服务:
echo 'MCP_PUBLIC_HOST=my-mcp.online' >> /etc/my-mcp.env
systemctl restart my-mcp

再试一次:
curl -X POST https://my-mcp.online/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Authorization: Bearer ABCD-1234-EFGH-5678' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{...},"instructions":"..."}}

通了。

这个坑值得记住的地方不是答案,是过程
① 先排除环境因素(网络、代理)——两条路径结果一样,就能确定是服务端问题。
② 看响应体而不只是状态码——Invalid Host header 这几个字是唯一的线索。
③ 拿错误信息去源码里搜——这是定位第三方库行为最快的办法,比搜索引擎准得多,因为你搜的是你正在用的这个版本的实际代码。
④ 修的时候优先选"配置正确"而不是"关掉检查"——安全机制存在总是有原因的,绕过之前先搞清楚它防的是什么。

这一章的完成标志:https://你的域名/healthz 返回 ok,证书由 Let's Encrypt 签发,/mcp 端点能正常完成 MCP 初始化握手。

第四部分 接入与使用
第 12 章 接进 Claude Code:调用逻辑详解
服务已经跑在公网上了。这一章讲怎么接进客户端、一次对话内部到底发生了什么、以及上线之后的日常。
第一步:发一张卡密
在服务器上:
cd /opt/my-mcp
set -a; . /etc/my-mcp.env; set +a

.venv/bin/python -m mcp_license.admin_cli generate \
--count 5 --days 30 --plan trial --batch launch01

已生成 5 张卡密(明文仅此一次展示,请妥善保存):
ABCD-1234-EFGH-5678
BCDE-2345-FGHI-6789
CDEF-3456-GHIJ-7890
DEFG-4567-HIJK-8901
EFGH-5678-IJKL-9012

明文只显示这一次
因为数据库只存哈希,关掉窗口就找不回来了。 可以加 --out keys.txt 参数直接写进文件。
第二步:接进你用的 AI 工具
MCP 是开放协议,不是某一家的私有接口。你的服务做出来之后, 市面上主流的 AI 工具基本都能连——这是做 MCP 相比做私有 API 的一大好处:写一次,处处能用。
下面按工具分别给配置方法。不用全看,找你在用的那个即可。
先记住这套通用配置
除了 Codex(它用 TOML,后面单独讲),其余客户端基本都是同一套 JSON 结构, 只是文件放在不同位置:
{
"mcpServers": {
"blueocean": {
"type": "http",
"url": "https://my-mcp.online/mcp",
"headers": {
"Authorization": "Bearer ABCD-1234-EFGH-5678"
}
}
}
}

记住这个结构,各家的差别只在于三点:配置文件叫什么、放在哪、 以及 type 字段写 http 还是 streamable-http。

目前最主流的两个是 Claude Code 和 Codex
这两个用的人最多,而且配置方式恰好完全不同—— Claude Code 一行命令搞定,Codex 要手写 TOML 配置文件。 下面把这两个放在最前面详细讲。
① Claude Code(命令行)
claude mcp add --transport http blueocean https://my-mcp.online/mcp \
--header "Authorization: Bearer ABCD-1234-EFGH-5678"

验证:
claude mcp list
blueocean: https://my-mcp.online/mcp (HTTP) - √ Connected

默认作用域是 local(只在当前项目目录生效)。想全局可用加 --scope user:
claude mcp add --scope user --transport http blueocean https://my-mcp.online/mcp \
--header "Authorization: Bearer ABCD-1234-EFGH-5678"

claude mcp get blueocean # 看详情
claude mcp remove blueocean # 删掉

② Codex CLI
Codex 是另一个主流选择,但它的配置和上面那套通用 JSON 不一样——它用 TOML。 这是所有客户端里最特殊的一个,单独说清楚。
配置文件位置:

作用域

文件位置

全局(所有会话)

~/.codex/config.toml

只在当前项目

项目目录下的 .codex/config.toml
打开(没有就新建)这个文件,加上:

~/.codex/config.toml

[mcp_servers.blueocean]
url = "https://my-mcp.online/mcp"
http_headers = { "Authorization" = "Bearer ABCD-1234-EFGH-5678" }

三个要点:
段名格式是 [mcp_servers.服务名],服务名自己起, 对应别家 JSON 里 mcpServers 下面那个 key
远程服务用 url 字段(本地 stdio 服务才用 command)
自定义请求头用 http_headers,注意 TOML 的内联表写法: 等号两边的键要带引号,整体用花括号包起来
更安全的写法:把卡密放进环境变量
上面那种写法卡密是明文写在配置文件里的。Codex 支持把它挪到环境变量:

~/.codex/config.toml

[mcp_servers.blueocean]
url = "https://my-mcp.online/mcp"
bearer_token_env_var = "BLUEOCEAN_KEY"

然后在你的 shell 里设置这个环境变量:

macOS / Linux(写进 ~/.bashrc 或 ~/.zshrc 让它长期生效)

export BLUEOCEAN_KEY="ABCD-1234-EFGH-5678"

Windows PowerShell(写进 $PROFILE 长期生效)

$env:BLUEOCEAN_KEY = "ABCD-1234-EFGH-5678"

Codex 会自动读这个变量,拼成 Authorization: Bearer <值> 发出去。好处是配置文件可以放心分享或提交,里面没有密钥。
验证是否接上
启动 Codex,在对话界面里输入:
/mcp

会列出当前已连接的 MCP 服务和它们提供的工具。能看到 blueocean 和它的 9 个工具就说明成功了。

Codex 的两个容易踩的点
① codex mcp add 命令是给本地 stdio 服务用的,形式是 codex mcp add 名字 -- 启动命令。 我们这种远程 HTTP 服务直接改 TOML 文件更直接。
② TOML 的语法比 JSON 严格一点。http_headers 那行如果写成 { Authorization = "..." } (键没加引号)在某些版本会解析失败,建议键和值都用双引号包起来。
③ Claude Desktop
编辑配置文件,把上面那段通用 JSON 填进去:

系统

配置文件位置

Windows

%APPDATA%\Claude\claude_desktop_config.json

macOS

~/Library/Application Support/Claude/claude_desktop_config.json
也可以从界面进:设置 → Developer → Edit Config。改完完全退出并重开应用。
④ Cursor
两种作用域:

范围

文件位置

只在当前项目

项目根目录下的 .cursor/mcp.json

全局

~/.cursor/mcp.json
内容就是上面那段通用 JSON。也可以走界面:Settings → Tools & Integrations(或 MCP)→ Add new MCP server。
⑤ VS Code(GitHub Copilot 代理模式)
VS Code 通过 Copilot 的 Agent 模式支持 MCP。配置放在工作区的 .vscode/mcp.json,或者用命令面板搜 MCP: Add Server 引导添加。
注意 VS Code 的字段名和别家略有不同,顶层键是 servers:
{
"servers": {
"blueocean": {
"type": "http",
"url": "https://my-mcp.online/mcp",
"headers": { "Authorization": "Bearer ABCD-1234-EFGH-5678" }
}
}
}

加好后在 Copilot Chat 里切到 Agent 模式,点工具图标就能看到你的工具。
⑥ Windsurf
配置文件:~/.codeium/windsurf/mcp_config.json, 或者从 Settings → Cascade → MCP Servers 进去编辑。结构同通用 JSON。
⑦ Cline(VS Code / JetBrains 扩展)
点 Cline 面板右上角的 MCP Servers 图标 → Configure MCP Servers, 会打开一个 JSON 文件,把通用配置填进去即可。
⑧ Cherry Studio(国内常用的桌面客户端)
进 设置 → MCP 服务器 → 添加服务器,在表单里填:

字段

填什么

名称

随便起,比如 blueocean

类型

可流式传输的 HTTP(Streamable HTTP)

URL

https://my-mcp.online/mcp

请求头

Authorization=Bearer ABCD-1234-EFGH-5678
Cherry Studio 支持 stdio / SSE / Streamable HTTP 多种传输,选 Streamable HTTP 那一项。
⑨ 其他工具
Trae、Zed、Continue、5ire、DeepChat、ChatWise 等也都支持 MCP, 配置方式大同小异——找到它的 MCP 配置入口,填 URL 和请求头。

如果某个客户端只支持 stdio,连不了远程服务怎么办
有些客户端(尤其是较老的版本)只支持 stdio 方式,也就是只能启动本地进程,填不了网址。这种情况可以用官方的桥接工具 mcp-remote把远程服务"伪装"成本地进程: { "mcpServers": { "blueocean": { "command": "npx", "args": [ "-y", "mcp-remote", "https://my-mcp.online/mcp", "--header", "Authorization: Bearer ABCD-1234-EFGH-5678" ] } } } 需要本机装了 Node.js。能直接填 URL 的话优先直连,桥接只是兜底方案。

三条通用经验
**① 加完必须重启客户端。**新加的 MCP 服务要重启会话才会加载工具。claude mcp list 显示 Connected 只代表握手成功,工具要新开窗口才能用。
**② 各家的字段名可能有差异。**比如 VS Code 顶层用 servers,多数其他工具用 mcpServers;type 有的写 http、 有的写 streamable-http。填错了一般会直接报连接失败,换一个试试即可。
**③ 客户端更新很快。**MCP 生态还在快速演进,配置路径和字段偶尔会变。如果按上面的方法连不上,直接查该工具官方文档里的 MCP 章节,那是最新的。

这就是做 MCP 的价值之一
你只写了一个服务,上面这些工具全都能用,而且用户接入成本都是"填个网址加个请求头"。 如果你做的是私有 API,每接一个客户端都得单独适配一次。
调用逻辑详解:一次对话内部发生了什么
这是本章的核心。很多人用了 MCP 但不清楚背后的机制, 导致遇到问题时无从下手。完整走一遍。
fig09.png
一次对话的完整流程。关键是第 ⑧ 步——模型会基于上一次结果决定下一次查什么

逐步说明

步骤

发生了什么

值得注意的

⓪ 启动

客户端连上服务,拉取工具清单和 instructions

这一步不走授权中间件(它不是工具调用), 所以只是"连上"不会激活卡密、不消耗额度

① 提问

你说一句人话

不需要提到工具名,模型自己判断

② 决策

模型读工具描述,决定调哪个、参数填什么

这一步的质量完全取决于你的 docstring 写得好不好(第 5 章)

③④ 调用

客户端把请求发到你的服务器,自动带上配置里的 Authorization 头

卡密对模型是不可见的,是客户端加的

⑤ 授权

八道关卡依次检查(第 4 章)

失败会返回中文提示,模型会转述给你

⑥ 查询

执行 SQL,返回 JSON

数据从不离开你的服务器,只有查询结果出去

⑧⑨ 多轮

模型基于第一次的结果,决定要不要再查一次、查什么

这是 MCP 比普通 API 强的地方,下面单独说

⑩ 综合

把多轮结果整合成回答

不是罗列数据,而是带判断的结论
为什么第 ⑧ 步是关键
如果只是"一问一查一答",那和写个 REST API 没本质区别。MCP 真正的价值在于模型可以连续、有策略地调用。
我实际测试时遇到过一个很典型的例子:我问"帮我找蓝海赛道", 模型第一次调用返回的结果里,排第一的赛道只有 1 个频道样本。模型自己意识到"样本量太小,这个指标不可靠",于是加上 min_channel_count=10 重新查了一次, 才给出结论。
这个判断我没教过它——它是从我写的工具描述里 "min_channel_count 过滤样本过少的赛道(建议 ≥5,样本极少的赛道指标可能失真)" 这句话推出来的。

这就是第 5 章强调"工具描述要写透"的回报
你在描述里写的每一条约束和建议,都会变成模型的判断依据。写得好的描述,能让模型表现得像一个懂业务的分析师;写得差的描述,它只会当个查询代理。
配好之后,到底怎么用?
这是很多人卡住的地方:配置成功了,然后呢? 界面上没多出什么按钮,也没有"调用工具"的入口,好像什么都没变。

先说结论:你什么都不用做,直接用大白话提问就行
不需要输入命令、不需要 @ 谁、不需要点任何按钮。模型会自己判断"这个问题需要查数据",然后自动调用工具。 你要做的只是把问题问清楚。
第一步:确认工具真的加载进来了
配置完必须重启客户端(退出重开,不是新开一个对话)。重启后按下面的方法确认:

客户端

怎么确认

Claude Code

命令行执行 claude mcp list,看到 √ Connected; 或在对话里输入 /mcp

Codex CLI

对话界面输入 /mcp,会列出已连接的服务和工具

Claude Desktop

输入框右下角有个工具图标,点开能看到工具清单

Cursor

Settings → MCP 页面,服务名前面是绿点表示已连接

Cherry Studio

设置 → MCP 服务器,对应条目显示已启用且能展开工具列表
能看到 search_channels、get_niche 这些名字,就说明成了。

看不到工具的三个常见原因
**① 没重启。**这是最常见的,占八成。完全退出客户端再打开,不是新建对话。
② 卡密没配对。注意 Bearer 后面有一个空格,完整格式是 Bearer ABCD-1234-EFGH-5678。
**③ 配置文件位置或格式不对。**回上一节对照你用的那款工具再检查一遍,特别注意 VS Code 用 servers、Codex 用 TOML 这两个特例。
第二步:直接问,像跟人说话一样
不用学任何语法。下面这些问法都可以直接复制去试:

你想做什么

直接这么问

先摸个底

这个频道库里有多少数据?最近一次更新是什么时候?

找对标账号

帮我找订阅增速最快的黑马新号,列前 10 个

找选题方向

现在哪个赛道竞争最小、还有机会?样本太少的别给我

看某个赛道

book summary 这个赛道现在什么情况?头部都是谁?

查具体频道

UCX6591PGH9Po1ToIwyBDTMQ 这个频道的数据给我看看

做横向对比

帮我对比一下做美妆和做美食,哪个赛道新号更容易起来

问得越像"给同事下任务",效果越好
不好的问法:search_channels(它不是命令行,不用报工具名)
不好的问法:查频道(太短,模型不知道你要什么)
好的问法:我想做个新号,帮我找几个一年以内、涨粉快、赛道又不太卷的账号参考一下
把背景和目的说出来,模型才知道该筛什么、该按什么排序、结果要怎么解读。这跟给同事交代任务是一个道理。
第三步:看它有没有真的调用工具
提问后,客户端界面上会有明显的提示,不同工具的呈现方式不太一样:

客户端

调用工具时你会看到

Claude Code

一行灰色的 blueocean - search_channels, 展开能看到传了什么参数、返回了什么

Claude Desktop

回答上方出现一个可展开的工具调用卡片

Cursor / Cline

对话流里插入一段工具调用记录,可能需要你点同意才继续

Codex CLI

终端里打印工具名和参数
如果没看到任何工具调用,说明模型认为这个问题不需要查数据(或者它没意识到有这个工具)。这时候明确点它一下就行:
用 blueocean 这个 MCP 查一下,现在哪个赛道竞争最小

第四步:接着追问,这才是 MCP 的精髓
很多人问完一句就停了,这样只用到了它十分之一的能力。
MCP 真正的价值在于模型能基于上一次的结果继续查。所以拿到第一批结果后接着聊:
第一轮:哪个赛道竞争最小?
→ 模型返回 5 个赛道

第二轮:第三个那个赛道,里面头部频道都是什么水平?
→ 模型自动调用赛道详情工具,拿到 Top 频道

第三轮:这里面涨得最快的那个,它最近发的视频都是什么选题?
→ 模型再调视频明细工具

第四轮:综合看下来,我一个新号进这个赛道,机会大吗?
→ 模型基于前三轮的全部数据给判断

这四轮下来,模型串起了三个不同的工具、七八次查询,而你只是像聊天一样问了四句话。这是网站和 API 都做不到的。

几个能让结果更好的小技巧
**① 让它先摸底。**第一句先问"这个库里有什么数据",模型了解了数据边界之后,后面的回答会准得多。
② 明确要求它给判断,而不是列数据。比如加一句"不要只给我表格,告诉我你的结论和理由"。
③ 数据反常时追问一句"为什么"。模型经常能从字段里推出原因(比如从国家和语言字段看出某个赛道的玩家集中在哪个市场)。
**④ 让它自己纠错。**如果结果看着不对,直接说"这个结果不太对,样本是不是太少了?"模型通常会自己加过滤条件重查。
常见问题

现象

原因和解决

提示"缺少授权卡密"

请求头没配对。检查 Bearer 和卡密之间有一个空格, 卡密本身没有多余空格或换行

提示"卡密无效或不存在"

卡密抄错了。注意字符集里没有 0、1、I、O, 看着像 0 的其实是字母 O 的可能性为零,反过来也是

提示"该卡密刚在其他网络位置使用"

这张卡被别人也在用(或者你换了网络)。 去表格里换一张标着「否」的

提示"当前卡密档位无权使用"

你在调 pro 档位的工具。体验卡能用的是 6 个基础工具, 赛道分析、赛道详情、视频明细这三个用不了

提示"请求过于频繁"

触发了限流(每分钟 30 次)。等一分钟再问。 正常对话远达不到这个量,通常是脚本在跑

模型说"我没有这个工具"

没重启客户端,或者配置没生效。回第一步检查

返回的数据很少或为空

筛选条件太严。让模型放宽条件重试, 比如"条件放宽一点再查一次"
真的用它干一次活
"返回 200"不算验收成功。真正的验收是让它做一次完整的分析。
我在接入后问了这样一个问题,下面是真实的执行过程和结果。
第一轮:先看整体
模型先调了 get_stats_overview 摸底:
频道 38548 · 赛道 2860 · 视频 246226 · 数据采集于 2026-08-06
新号 5694 / 老号 32854
国家 Top:US 11641、IN 7586、GB 1832

第二轮:查赛道
然后调 search_niches,并且自己加上了 min_channel_count=10:

赛道

蓝海指数

竞争度

变现潜力

新号突围率

nclex review

62.0

19.0

52.6

54.5%

digital art

60.3

62.3

15.8

100%

book summary

58.7

6.8

15.8

59.6%

youtube automation

56.0

6.3

42.1

38.5%
第三轮:下钻验证
模型注意到 book summary 的竞争度只有 6.8(全库最低一档), 于是调 get_niche 去看这个赛道里的头部频道到底是什么水平:
book summary 赛道 57 个频道,头部三强:
Book Insider 488,000 订阅 月涨粉 11,675 增长动能 2.53
Talk Series 261,000 订阅 月涨粉 6,014 增长动能 0.66
Audio Books Arc 131,000 订阅 月涨粉 3,376 增长动能 0.32

最后给出的结论
模型综合三轮数据,给的不是数据罗列,而是这样的判断:

模型的实际输出(节选)
"这个赛道的竞争度 6.8 意味着流量没被垄断,但代价是天花板也低—— 最大的 Book Insider 才 48.8 万订阅,而且跑了 41.8 个月。低竞争和低天花板是同一枚硬币的两面。
另外 12 个头部频道里 7 个是印度、1 个巴基斯坦,语言标注是 hi / en-IN。 这说明现有打法是面向南亚市场的,变现潜力只有 15.8 分基本就是这个原因(印度流量 CPM 低)。 反过来说,面向欧美的英语原声这块可能还是真空。"
这段回答里有几个东西是单纯查数据得不到的:
把"竞争度低"和"天花板低"联系起来,指出这是同一件事的两面
从国家和语言字段推断出"现有玩家都在做南亚市场"
把这个推断和"变现潜力分数低"对应上,给出因果解释
顺着这个逻辑指出一个数据里没有、但可以推出来的机会
这才是做 MCP 的意义——不是让 AI 帮你按按钮,是让它基于你的数据做推理。
上线之后的日常
发卡与管理
cd /opt/my-mcp && set -a && . /etc/my-mcp.env && set +a

批量发卡(档位可选 1/30/90/180/365 天)

.venv/bin/python -m mcp_license.admin_cli generate --count 10 --days 30 --plan trial --batch b2

看卡密列表(只显示掩码)

.venv/bin/python -m mcp_license.admin_cli list

看用量统计

.venv/bin/python -m mcp_license.admin_cli stats

停用某张卡(60 秒内生效)

.venv/bin/python -m mcp_license.admin_cli disable --key ABCD-1234-EFGH-5678

查看谁在用
mysql -u"$LICENSE_MYSQL_USER" -p"$LICENSE_MYSQL_PASSWORD" -D mcp_license -e "
SELECT u.created_at 时间, c.key_mask 卡密, u.tool_name 工具, u.result 结果, u.ip 来源IP
FROM usage_logs u LEFT JOIN card_keys c ON c.key_hash=u.key_hash
ORDER BY u.id DESC LIMIT 20;"

这张表能看出很多东西:哪些工具最常被调用、有没有人在被限流、 同一张卡是不是在多个 IP 之间跳。
更新数据
重新跑一次第 10 章第九步的同步流程即可。 同步脚本是 upsert(有则更新、无则插入),可以反复执行,不会产生重复数据。
什么时候该销毁服务器

再强调一次:关机不停止计费
Vultr 的服务器只要还存在就一直按小时扣费。不用了要执行 Destroy, 并检查快照、保留 IP 这些残留计费项(第 8 章末尾有清单)。
域名如果不打算续用,记得关掉 Auto-Renew,否则明年会自动扣费。
诚实地说说这套方案的局限
教程写到这里该收尾了,但我想把不适用的场景也讲清楚,免得你踩坑。

局限

说明

什么时候会成为问题

单机部署,没有冗余

服务器挂了服务就没了,没有主备切换

有付费用户、要求可用性的时候。届时需要加监控、备份实例

卡密不能续期叠加

用新卡会覆盖旧卡,剩余天数不累加

做长期订阅业务时。需要额外实现合并逻辑

IP 绑定对流动网络不友好

经常切换网络(咖啡厅、手机热点、公司家里来回跑)的用户会撞到换绑冷却

用户群体移动性强的时候。可以放宽阈值或改用其他绑定方式

数据出口无法完全防爬

虽然有翻页深度和配额限制,但有心人仍可长期慢速拉取

数据本身就是核心资产的时候。需要更细的行为分析

没有用户体系

卡密是 bearer token,谁拿到谁能用,不知道背后是谁

需要用户画像、需要按人计费的时候
这些限制对"跑通流程 + 小范围分发"来说完全够用。真要做成商业产品,上面每一条都要重新设计。但那是另一个阶段的问题了——先把东西做出来、跑起来、有人用, 比一开始就设计一个完美架构重要得多。

**到这里你应该拥有:**一个公网可访问的 MCP 服务,带 HTTPS、带卡密授权、能发卡能吊销、接进 Claude Code 能真正干活。总花费约 ¥80,总耗时约半天。
送你一张卡,直接来体验
教程里从头到尾用的这个服务是真实在跑的,不是为了写文章搭的演示环境。 接入地址是 https://blueocean-mcp.online/mcp。
提醒一下:这个地址是给 AI 客户端连的,不是给浏览器打开的。你要是直接粘到浏览器里,只会看到一行 "BlueOcean MCP" —— 那不代表服务有问题,MCP 本来就没有网页界面。要真正用起来,得按前面几节的方法把它配进你的 AI 工具。
里面有 3.8 万个 YouTube 频道的运营指标、2,860 个赛道的竞争度和变现潜力、24.6 万条视频的明细。对做出海内容、找选题方向的人来说,应该能派上用场。
我按第 12 章讲的方法,批量生成了 200 张 30 天体验卡,需要的人直接拿去用:

服务器上执行的就是这一条命令

python -m mcp_license.admin_cli generate \
--count 200 --days 30 --plan trial --batch public200 \
--out cards_batch200.txt

已生成 200 张卡密(明文仅此一次展示,请妥善保存)
已写入 cards_batch200.txt

拿到卡密后,按上一节任意一种客户端的配置方式接入即可,比如 Claude Code:
claude mcp add --transport http blueocean https://blueocean-mcp.online/mcp \
--header "Authorization: Bearer 你拿到的卡密"

接好之后可以直接试这几个问题:
帮我找订阅增速最快的黑马新号
现在哪个赛道竞争最小、还有机会
这个库里一共有多少数据,最近一次采集是什么时候

200 张卡密放在一张可以在线编辑的表格里,而不是直接写在这篇文章里。 原因很简单——写在文章里没人知道哪张已经被拿走了, 大家都从同一份列表里挑,很容易撞到同一张,然后互相把对方顶下线(第 4 章讲的 IP 绑定机制)。
表格有个 「是否使用」 列,默认都是 否。你挑一张标着「否」的拿走,然后顺手把它改成「是」,后面的人就知道这张已经有人用了。
打开卡密领取表 https://my.feishu.cn/base/XeMdbAw9ba7JJJsgoPsc68RVnM2

领卡的三步
① 打开上面的表格,找一张「是否使用」为否的。
② 复制「卡密」列的内容,按上一节的方法配进你的 AI 工具。
③ 回表格把这一行的「是否使用」改成「是」——这一步很重要,能让后面的人不用和你撞卡。 「领取人」和「备注」两列是选填的,想留个名或写点反馈都可以。

配好之后不知道怎么用?
这是最常见的困惑——界面上没多出任何按钮,好像什么都没变。 其实你什么都不用做,直接用大白话提问就行,模型会自己判断要不要查数据。
比如直接问:帮我找订阅增速最快的黑马新号。
详细的用法(怎么确认工具加载成功、该怎么问、怎么看它有没有调用工具、 连续追问的正确姿势、七种常见报错怎么解决)都在本章前面的 「配好之后,到底怎么用?」那一节, 第一次用建议先看一眼。

关于这 200 张卡的几点说明
① 30 天从你第一次真正调用工具时开始算——不是从领到那天算,所以领了先放着不会浪费。
② 档位是 trial,能用 6 个工具:频道检索、频道详情、赛道列表、层级列表、库概览、授权状态查询。赛道蓝海分析和视频明细那三个属于 pro 档位,trial 卡调不了。
③ 一张卡同一时刻只绑一个 IP(第 4 章讲的机制),因此一个人只需领取一张就好。
最后
这篇教程写的是我自己走过一遍的路。当初想做的时候找不到这样一份东西, 只能一步步试出来——所以写下来给后面的人省点事。
如果你照着做完了,不妨也把你的 MCP 做点什么分享出来。这个生态现在还很早,能用的东西不多,做出来的每一个都有价值。

社群里还有 966 篇别人的实操复盘

体验卡免费,不花一分钱3 天看遍社群近 180 天全部内容正式加入 72 小时内无理由退款
生财有术体验卡
领取体验卡的二维码

长按保存这张二维码,打开微信扫一扫,从相册选这张图即可添加;电脑上直接用微信扫码。

我写的这些免费读。体验卡免费领,3 天内可以查阅社群近 180 天的全部内容。
站长专栏的其他文章
书情
使用Hermes,7个Agent帮你24小时视频生产全流程
2026-08-02 · 约 12338 字
读全文
书情
防Claude Code封号,更好的适应美区时区小工具分享|ChinaTime · 中国时区悬浮时钟
2026-07-14 · 约 1528 字
读全文
书情
Claude Code 日消耗4亿token,我在做什么?
2026-07-13 · 约 9317 字
读全文