feat: Bearer 令牌鉴权与 MCP 工具接入(第 5、6 课) Cookie+会话改为自签 HS256 Bearer 令牌,登出按 token_version 全量失效 提示词模板、技能路由、智能体与 SSE 流式问答 同仓 MCP 服务器(JSON-RPC 2.0)与宿主侧工具调用、审计 接口测试脚本 apitest/ 与 HTML 报告;README 更新到当前状态 Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
feat: Bearer 令牌鉴权与 MCP 工具接入(第 5、6 课)
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
「2026 互联网软件开发」课程的个人项目:在已有的 Flask 教学材料应用上,逐步叠加 限定在本班范围内的知识库检索、Bearer 令牌鉴权、提示词模板与技能、多轮问答, 以及 MCP 工具接入。所有检索结果都能回溯到材料原文,向量检索用 Qdrant 承载。
课程增量与本仓库的对应关系:
apitest/
仓库同时存放变更的规格驱动文档,便于对照「需求 → 设计 → 任务 → 实现」的对应关系:
README.md ← 本文件 campusclaw-mygroup/ ← 实现代码 openspec/ ← 变更文档(proposal / specs / design / tasks) └── changes/ ├── add-traceable-vector-retrieval/ ← 可追溯的班级限定检索 ├── add-bearer-token-auth/ ← Cookie+Session 鉴权改为 Bearer 令牌 └── add-mcp-tool-integration/ ← MCP 工具接入与技能/智能体
上一课已经有了登录、角色权限与班级隔离,但材料只能整份下载,没法问「材料里讲了什么」。 本变更补上这条链:把材料切成切片、建索引、按班检索,并把每一条命中都带回它在原文中的位置。
三条硬要求:
class_id
200
两种召回各自独立过滤,再按名次融合(Reciprocal Rank Fusion,k = 60):
k = 60
keyword
MATCH
bm25()
vector
hybrid
几个刻意的取舍:
bm25
chunk_text
material_id
knowledge_entry_id
chunk_id
chunk_index
ngram
unicode61
trigram
chunking.search_blob()
向量库
向量 量库
OR
prompt
.txt
.md
POST /api/conversations/<id>/ask
meta
delta
done
同仓的 mcp-server/ 是一个独立的 MCP 服务器,提供 POST /mcp(JSON-RPC 2.0)和 GET /health,实现 initialize、tools/list、tools/call,内置 formula_hint、 unit_convert 两个工具。令牌只从环境变量 MCP_AUTH_TOKEN 读取,不进 Git、镜像和前端。
mcp-server/
POST /mcp
GET /health
initialize
tools/list
tools/call
formula_hint
unit_convert
MCP_AUTH_TOKEN
工具名@服务器名
【工具结果 n:工具@服务器】
model / messages / stream
tools
denied
error
/mcp-admin
原始实现用 Cookie + 服务端会话。本变更把它换成自签的 Bearer 令牌:令牌由标准库 hmac + hashlib + base64 + json 自行签名(JWT 形态 header.payload.signature, 算法 HS256),没有引入任何新依赖,签名密钥复用已有的 SECRET_KEY。
hmac
hashlib
base64
json
header.payload.signature
HS256
SECRET_KEY
POST /api/login
Authorization: Bearer <token>
/
/materials
/search
fetch
sub
ver
iat
exp
users.token_version
+1
401
WWW-Authenticate: Bearer
302
两个必须知道的取舍:
localStorage
textContent
innerHTML
campusclaw-mygroup/ ├── app.py Flask 应用与全部路由 ├── auth.py 请求守卫:解析 Bearer 头 → 校验令牌 → 回库读角色与班级 ├── tokens.py 令牌签发与校验(纯标准库 hmac/base64/json) ├── db.py SQLite 连接与路径配置 ├── schema.sql 建表语句 ├── init_db.py 幂等建库 + 预置账号与材料 ├── chunking.py 切分策略与二元组化(纯函数,无外部依赖) ├── services.py 外部服务客户端:Qdrant + 嵌入网关 + 对话网关 ├── retrieval.py 入库索引、两路召回、RRF 融合 ├── materials.py 上传落盘与入库 ├── prompts.py 提示词模板 ├── skills.py 技能文件解析、触发词与工具解析 ├── tutor.py 智能体、技能路由、消息组装、SSE 问答、工具调用 ├── mcp_host.py 本班 MCP 服务器登记、工具缓存、调用审计 ├── mcp_client.py JSON-RPC 客户端(Bearer 令牌只从环境变量读) ├── openapi.py OpenAPI 注解与 /openapi.json、/swagger ├── check_index.py 索引一致性断言(可当命令行工具跑) ├── mcp-server/ 独立 MCP 服务器(JSON-RPC 2.0) ├── apitest/ 按 URL 组织的接口测试脚本 + HTML 报告 ├── demo/ 截图用抓包网关与演示数据准备脚本 ├── templates/ Jinja2 页面壳(数据一律由脚本取) ├── static/ 前端脚本与样式(auth / chat / skills / mcp / docs) └── tests/ 单元测试(标准库 unittest)
未新增任何 pip 依赖:Qdrant 与两个网关一律用标准库 urllib.request 通信, requirements.txt 仍只有 Flask / argon2-cffi / python-dotenv。
urllib.request
requirements.txt
Flask
argon2-cffi
python-dotenv
cd campusclaw-mygroup cp .env.example .env # 然后填 SECRET_KEY(必填) docker compose up --build
cd campusclaw-mygroup python -m venv .venv .venv/Scripts/python.exe -m pip install -r requirements.txt # Windows .venv/Scripts/python.exe init_db.py .venv/Scripts/python.exe app.py # 默认 :5000
直跑时若想连本机 Qdrant,把 .env 里的 QDRANT_URL 改成 http://127.0.0.1:6333。
.env
QDRANT_URL
http://127.0.0.1:6333
http://qdrant:6333
EMBEDDING_BASE_URL
POST /v1/embeddings
EMBEDDING_API_KEY
EMBEDDING_MODEL
EMBEDDING_DIM
DIALOG_BASE_URL
POST /v1/chat/completions
DIALOG_API_KEY
DIALOG_MODEL
四项 EMBEDDING_* 全齐才启用向量检索;缺任意一项时应用照常启动,只在启动日志里 给出一条明确告警。密钥只从环境变量读,不入库、不写日志、不进响应体。
EMBEDDING_*
生成一个 SECRET_KEY:
python -c "import secrets; print(secrets.token_urlsafe(48))"
init_db.py 只在库为空时写入,内置两个班的师生账号(库里只存 argon2 哈希):
init_db.py
teacher3
teacher3-pass
student3
student3-pass
teacher4
teacher4-pass
student4
student4-pass
预置材料:3 班《向量检索讲解》、4 班《生物实验记录》——用来演示班级隔离。
下表除标注「免认证」者外,**一律需要 Authorization: Bearer <token>**。
GET
/health
/login
POST
/api/login
/api/logout
/chat
/skills-admin
/prompt-templates
/api/timetable
/api/materials
/materials/<id>
404
/api/search
query
mode
/api/ask
[1]
/api/materials/<id>/reindex
/api/prompt-templates
PUT
DELETE
/api/prompt-templates/<id>
/api/agents
/api/agents/<id>
/api/skills
/api/skills/<id>
/api/skills/<id>/enabled
/api/mcp/servers
/api/mcp/servers/<id>/connect
/api/mcp/servers/<id>/disconnect
/api/mcp/servers/<id>
/api/mcp/calls
403
/api/conversations
/api/conversations/<id>
/api/conversations/<id>/ask
meta → delta × n → done
/openapi.json
/swagger
检索请求示例:
TOKEN=$(curl -s http://127.0.0.1:8000/api/login \ -H "Content-Type: application/json" \ -d '{"username":"teacher3","password":"teacher3-pass"}' \ | python -c "import sys,json;print(json.load(sys.stdin)['token'])") curl -s http://127.0.0.1:8000/api/search \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "余弦相似度阈值", "mode": "keyword"}'
状态码语义:未认证一律 **401**(带 WWW-Authenticate: Bearer,不再 302 跳转); 非法模式或空查询 400;学生上传或重建索引 403;跨班材料 404;向量库不可用时 vector / hybrid 返回 503(且响应里不含任何编造的相似度数值), 而 keyword 仍正常返回 200。
400
503
单元测试(标准库 unittest,共 181 项):
unittest
cd campusclaw-mygroup .venv/Scripts/python.exe -X utf8 -m unittest discover
接口测试(应用已启动时运行,用例按 URL 组织在 apitest/cases/):
apitest/cases/
.venv/Scripts/python.exe apitest/run_all.py
每个脚本在独立子进程运行,汇总结果写入 apitest/report/report.html;报告目录已在 .gitignore 中,不进入版本库。
apitest/report/report.html
.gitignore
另有一个索引一致性检查工具:
.venv/Scripts/python.exe check_index.py
它校验「切片的 class_id 与所属条目一致」「FTS 表与切片表行数一致且 rowid 一一对应」等断言, 故意制造脱节数据时会以非零码退出。注意它读的是当前 DATA_DIR 下已建好表的库, 所以在全新克隆上要先跑一次 init_db.py(或指定一个已初始化的 DATA_DIR)。
rowid
DATA_DIR
整条检索链——包括看起来纯本地的 keyword 一路——都依赖一个可用的 EMBEDDING_API_KEY。
原因是索引就绪门槛:两路检索共同只认 index_status = 'ready' 的切片,而只有嵌入调用成功 才会把切片置为 ready。所以没有密钥时,上传产生的切片**全部为 failed**,此时用材料正文里 确有的词做关键字检索,也会返回「资料中未找到相关内容」。
index_status = 'ready'
ready
failed
这是课程规定的语义,不是实现缺陷。本项目刻意不做本地嵌入兜底——那样会让「向量检索」 在演示时看起来是对的,实际并没有调用任何嵌入模型。配上密钥后,重启应用会自动为缺少索引的 材料补建索引(retrieval.backfill())。
retrieval.backfill()
2026-10-27 在开发机上验证:
apitest/run_all.py
app
qdrant
mcp
meta → delta → done
tool_calls
formula_hint@demo-tools
ok
截图环境的说明:
demo/llm_capture.py
DIALOG_*
demo/README.md
已知限制:
版权所有:中国计算机学会技术支持:开源发展技术委员会 京ICP备13000930号-9 京公网安备 11010802047560号
campusclaw — 班级限定的可追溯知识库与 AI 助教平台
「2026 互联网软件开发」课程的个人项目:在已有的 Flask 教学材料应用上,逐步叠加 限定在本班范围内的知识库检索、Bearer 令牌鉴权、提示词模板与技能、多轮问答, 以及 MCP 工具接入。所有检索结果都能回溯到材料原文,向量检索用 Qdrant 承载。
课程增量与本仓库的对应关系:
apitest/仓库同时存放变更的规格驱动文档,便于对照「需求 → 设计 → 任务 → 实现」的对应关系:
这一课解决的问题
上一课已经有了登录、角色权限与班级隔离,但材料只能整份下载,没法问「材料里讲了什么」。 本变更补上这条链:把材料切成切片、建索引、按班检索,并把每一条命中都带回它在原文中的位置。
三条硬要求:
class_id一律忽略;跨班检索 返回200+ 空命中,而不是报错或泄露他班内容。检索是怎么工作的
两种召回各自独立过滤,再按名次融合(Reciprocal Rank Fusion,
k = 60):keywordMATCH+bm25()排序vectorclass_id过滤 → 丢弃余弦 < 0.35 → 按主键回表hybrid(默认)几个刻意的取舍:
bm25(越小越好),向量给的是余弦 (越大越好),两者量纲完全不同,直接相加没有意义。chunk_text是唯一事实来源。写进 Qdrant 的 payload 只有 5 个标识 键(class_id/material_id/knowledge_entry_id/chunk_id/chunk_index), 不含切片正文——向量库不是正文的存储。ngram分词器,unicode61对中文 命中为 0,trigram对两字词命中为 0。因此切分写入与查询匹配共用同一个chunking.search_blob(),把连续 CJK 拆成逐字二元组(向量库→向量 量库), 用显式OR拼成MATCH查询。提示词模板、技能与智能体
prompt字段,提问时作为 system 消息的开头原样传给对话模型。.txt/.md技能文件,解析出名称、适用条件、资料(学科限定)、工具、 启用状态和说明正文。提问时只在已绑定且已启用的技能里,按「适用条件」关键词与问题 做子串计分,取最高分命中;发给模型的只有「名称 + 说明正文」,触发词、工具等元数据 留在服务端用于路由和执行。POST /api/conversations/<id>/ask返回 SSE,依次为meta(技能、引用、 工具调用状态)→delta(正文增量)→done;历史只带最近 6 条,每条截断 500 字。MCP 工具接入
同仓的
mcp-server/是一个独立的 MCP 服务器,提供POST /mcp(JSON-RPC 2.0)和GET /health,实现initialize、tools/list、tools/call,内置formula_hint、unit_convert两个工具。令牌只从环境变量MCP_AUTH_TOKEN读取,不进 Git、镜像和前端。tools/list,工具清单整体替换缓存;「断开」清空缓存,此后调用一律拒绝。工具名@服务器名,保存时校验服务器属于本班、已连接且工具在发现清单内。tools/call,把结果文本作为 数据追加到用户消息末尾(【工具结果 n:工具@服务器】)。请求体仍然只有model / messages / stream,没有 OpenAItools字段,模型看不到工具清单或令牌。denied/error,只写审计,不阻断问答; 教师可在/mcp-admin查看工具、服务器、状态、用户和时间。令牌鉴权(Bearer)
原始实现用 Cookie + 服务端会话。本变更把它换成自签的 Bearer 令牌:令牌由标准库
hmac+hashlib+base64+json自行签名(JWT 形态header.payload.signature, 算法HS256),没有引入任何新依赖,签名密钥复用已有的SECRET_KEY。POST /api/login拿令牌,之后每个请求带Authorization: Bearer <token>。 浏览器导航带不了请求头,所以页面不再由服务端渲染数据:/、/materials、/search返回的是不含任何数据的空壳(200),真实数据由页面脚本带令牌fetch后再渲染。sub(账号 id)、ver(令牌版本号)、iat、exp。 角色与班级每次都按sub回库现读,所以改了班级或权限立刻生效,不存在「令牌里的旧角色」。users.token_version在登出时+1,校验时比对令牌里的ver与 库中当前值,不等即拒。于是同一账号的所有令牌一起作废(含在别处登录的那一枚); 代价是没有「只登出某一台设备」的能力。401并带WWW-Authenticate: Bearer,不再是302跳登录页。两个必须知道的取舍:
localStorage,换取「刷新页面不掉登录」的体验。代价是站点内任何 XSS 都能读到 令牌;本项目全部渲染走textContent、不用innerHTML,但这是接受该风险而非消除它。目录结构(实现部分)
未新增任何 pip 依赖:Qdrant 与两个网关一律用标准库
urllib.request通信,requirements.txt仍只有Flask/argon2-cffi/python-dotenv。快速开始
Docker Compose(推荐)
本机直跑(不用容器)
直跑时若想连本机 Qdrant,把
.env里的QDRANT_URL改成http://127.0.0.1:6333。环境变量
SECRET_KEYQDRANT_URLhttp://qdrant:6333EMBEDDING_BASE_URLPOST /v1/embeddings)EMBEDDING_API_KEYEMBEDDING_MODELEMBEDDING_DIMDIALOG_BASE_URLPOST /v1/chat/completions)DIALOG_API_KEYDIALOG_MODELMCP_AUTH_TOKEN四项
EMBEDDING_*全齐才启用向量检索;缺任意一项时应用照常启动,只在启动日志里 给出一条明确告警。密钥只从环境变量读,不入库、不写日志、不进响应体。生成一个
SECRET_KEY:预置账号
init_db.py只在库为空时写入,内置两个班的师生账号(库里只存 argon2 哈希):teacher3teacher3-passstudent3student3-passteacher4teacher4-passstudent4student4-pass预置材料:3 班《向量检索讲解》、4 班《生物实验记录》——用来演示班级隔离。
接口
下表除标注「免认证」者外,**一律需要
Authorization: Bearer <token>**。GET/healthGET/loginPOST/api/loginPOST/api/logoutGET/、/materials、/search、/chat、/skills-admin、/prompt-templates、/mcp-adminGET/api/timetableGET/api/materialsPOST/api/materialsGET/materials/<id>404POST/api/searchquery与可选modePOST/api/ask[1]标注的回答与出处列表POST/api/materials/<id>/reindexGET/POST/api/prompt-templatesPUT/DELETE/api/prompt-templates/<id>GET/POST/api/agentsPUT/api/agents/<id>GET/POST/api/skillsPUT/DELETE/api/skills/<id>POST/api/skills/<id>/enabledGET/POST/api/mcp/serversPOST/api/mcp/servers/<id>/connecttools/list,刷新工具缓存POST/api/mcp/servers/<id>/disconnectDELETE/api/mcp/servers/<id>GET/api/mcp/calls403GET/POST/api/conversationsGET/api/conversations/<id>404)POST/api/conversations/<id>/askmeta → delta × n → doneGET/openapi.json、/swagger检索请求示例:
状态码语义:未认证一律 **
401**(带WWW-Authenticate: Bearer,不再302跳转); 非法模式或空查询400;学生上传或重建索引403;跨班材料404;向量库不可用时vector/hybrid返回503(且响应里不含任何编造的相似度数值), 而keyword仍正常返回200。测试
单元测试(标准库
unittest,共 181 项):接口测试(应用已启动时运行,用例按 URL 组织在
apitest/cases/):每个脚本在独立子进程运行,汇总结果写入
apitest/report/report.html;报告目录已在.gitignore中,不进入版本库。另有一个索引一致性检查工具:
它校验「切片的
class_id与所属条目一致」「FTS 表与切片表行数一致且rowid一一对应」等断言, 故意制造脱节数据时会以非零码退出。注意它读的是当前DATA_DIR下已建好表的库, 所以在全新克隆上要先跑一次init_db.py(或指定一个已初始化的DATA_DIR)。⚠️ 一个必须知道的依赖
整条检索链——包括看起来纯本地的
keyword一路——都依赖一个可用的EMBEDDING_API_KEY。原因是索引就绪门槛:两路检索共同只认
index_status = 'ready'的切片,而只有嵌入调用成功 才会把切片置为ready。所以没有密钥时,上传产生的切片**全部为failed**,此时用材料正文里 确有的词做关键字检索,也会返回「资料中未找到相关内容」。这是课程规定的语义,不是实现缺陷。本项目刻意不做本地嵌入兜底——那样会让「向量检索」 在演示时看起来是对的,实际并没有调用任何嵌入模型。配上密钥后,重启应用会自动为缺少索引的 材料补建索引(
retrieval.backfill())。验证状态(如实说明)
2026-10-27 在开发机上验证:
unittest,覆盖切分、网关客户端、向量库客户端、检索融合、 令牌签发与校验、令牌版本号吊销、模板、技能路由、智能体、MCP、接口层与索引一致性)。apitest/run_all.py,覆盖登录、会话创建与 SSE 问答、检索、 技能增删改查、MCP 服务器登记/连接、工具调用与审计)。app/qdrant/mcp三个服务均为 healthy;重建索引后 1 片ready、0 片failed;混合检索能命中材料片段;问答 SSE 依次收到meta → delta → done,meta中带技能、引用与tool_calls;formula_hint@demo-tools调用状态为ok,审计记录可查。teacher3登录、材料「重建索引」、在「问答助手」选择 「抓包演示助手」提问、页面显示「技能:演示工具技能」与出处;技能管理页、工具接入页可正常打开。截图环境的说明:
DIALOG_BASE_URL临时指向本地抓包网关 (demo/llm_capture.py),业务代码未改;请求体由应用真实组装,网关返回固定的流式文本。DIALOG_*指回真实网关即可(demo/README.md有还原步骤)。已知限制:
localStorage;明文 HTTP 仅用于本地演示,生产部署必须置于 TLS 之后。