
给 AI 编码助手接内部 API 的安全做法:MCP 包装、查询校验与网关隔离
给 AI 编码助手接内部 API 的安全做法:MCP 包装、查询校验与网关隔离
把内部笔记 API 交给编码助手调用时,最容易踩的坑是共享长期令牌、让模型自己拼查询语句、以及助手绕过登录直接访问后端进程。本文用一个可在本机跑通的最小示例,演示如何用 MCP 服务器只暴露三个具名工具、在服务端校验并重建搜索参数、再在前面加一层网关,让每一次调用都能对应到具体的人或服务账号。
很多团队都有一两个内部小服务:值班表、发布冻结日历、运行手册索引。它们通常只服务两类调用方——团队自己在用的网页界面,以及自己写的后台任务。这两类调用方发出的请求形状是可预期的。当编码助手通过 MCP 去调用同一个接口时,它就成了第三类调用方:它根据眼前的文本决定下一个请求发什么。如果它只能在你定义的几个函数里挑一个,这没问题;如果它能发任意路径、任意请求体,问题就大了。这篇教程用一个可以在本机跑通的最小示例,把内部笔记 API 包成 MCP 服务器,只暴露三个具名工具,在服务端拒绝模型不该写出的搜索,并在前面加一层网关,让助手不直接接触后端进程。示例刻意做得很小,你可以把笔记列表换成工单系统、代码托管服务或任何你已经在跑的 HTTP API。
准备工作
这不是 Python 入门教程,也不是大模型入门教程。开始之前,你应当已经熟悉以下内容:
- Python 3.10 或更新版本,包括函数、字典和虚拟环境
- HTTP 基础:URL、JSON 请求体、请求头是什么
- API 令牌的概念:服务器在执行请求前会校验的一个密钥
- 编码助手是什么,以及它可以调用你暴露给它的函数(工具)
你不需要事先搭过 MCP 服务器,也不需要付费模型、云账号或 Docker,全部内容都在本机运行。MCP 是许多助手用来发现并调用这些工具的协议,知道这一点就够了。
先建目录和虚拟环境:
mkdir notes-mcp-demo && cd notes-mcp-demo
python3 -m venv .venv
source .venv/bin/activate在 Windows 上用 .venv\Scripts\activate 激活。然后安装本教程用到的库。注意 MCP 的 Python 包在 2.x 版本里调整了 FastMCP 的导入位置,所以这里固定在 1.x:
pip install "mcp>=1.0.0,<2.0.0" httpx uvicorn pytest版本号与依赖可用性请以各项目官网当前信息为准。
操作步骤
第一步:写一个很小的内部 API
这里的「内部 API」就是三条存在 Python 列表里的笔记,加上三个你自己的网页应用可能已经具备的函数。list_notes 返回全部笔记,或只返回某个 owner 的笔记;get_note 按 id 返回单条,例如 n2;search_notes 找出标题或正文包含某个词的笔记,可以限定 owner,并在 limit 行之后截断。
新建 notes_api.py:
from typing import Optional
NOTES = [
{"id": "n1", "owner": "oncall", "title": "on-call rotation", "body": "Pager is in #ops"},
{"id": "n2", "owner": "oncall", "title": "deploy window", "body": "Fridays are frozen"},
{"id": "n3", "owner": "platform", "title": "ssh keys", "body": "Rotate bastion keys in May"},
]
def list_notes(owner: Optional[str] = None) -> list[dict]:
if owner is None:
return NOTES
return [n for n in NOTES if n["owner"] == owner]
def get_note(note_id: str) -> dict | None:
return next((n for n in NOTES if n["id"] == note_id), None)
def search_notes(query: str, owner: Optional[str] = None, limit: int = 20) -> list[dict]:
q = query.lower()
rows = [
n for n in NOTES
if q in n["title"].lower() or q in n["body"].lower()
]
if owner:
rows = [n for n in rows if n["owner"] == owner]
return rows[:limit]search_notes 完全信任传进来的参数。你传 deploy,它就找这个子串;你传 title:* OR body:*,它同样找这个子串。它不会把 * 解释成「全部」,但真实的搜索 API 往往会。这正是下一个文件存在的理由。
这个文件是你已经在运营的那个 API 的替身,保持它无聊就好。不要为了省事在它前面放一个接受 HTTP 方法、路径和请求体的 MCP 工具——那是个通用代理。模型随后就能调用该进程会响应的任何路由,包括你为管理员加的、忘了在文档里提的路由。一份简短的具名函数清单是可以审查的清单;一个透传 HTTP 的工具等于把整个 API 交给模型去挑请求。
第二步:不让模型自己写查询
如果模型能把任意字符串塞进搜索,你等于给了它一门查询语言。在真实的笔记服务上,title:* OR body:* 可能意味着「返回所有笔记」,owner=oncall; DROP TABLE notes 属于同一类问题:模型在写查询,而不是在请求一个关键词。
你要的是相反的做法:模型可以请求「owner 为 oncall 的 deploy 这个词」,由你的服务器判断这个请求是否允许,然后自己构造搜索。
新建 safe_search.py:
import os
import re
class SafeSearchError(ValueError):
pass
MAX_RESULTS = min(int(os.getenv("NOTES_MAX_RESULTS", "20")), 100)
OWNER_ALLOWLIST = {
name.strip()
for name in os.getenv("NOTES_OWNER_ALLOWLIST", "oncall,platform").split(",")
if name.strip()
}
QUERY_OK = re.compile(r"^[a-zA-Z0-9 \-]{1,64}$")
def build_search(query: str, owner: str | None, limit: int | None) -> dict:
if not query or not QUERY_OK.match(query):
raise SafeSearchError("query must be 1-64 letters, numbers, spaces, or hyphens")
if owner is not None and owner not in OWNER_ALLOWLIST:
raise SafeSearchError("owner is not on the allowlist")
cap = MAX_RESULTS if limit is None else min(int(limit), MAX_RESULTS)
if cap < 1:
raise SafeSearchError("limit must be at least 1")
return {"query": query.strip(), "owner": owner, "limit": cap}这个文件做了几件事:
MAX_RESULTS默认 20,即使有人把环境变量设得更高也不会超过 100。OWNER_ALLOWLIST默认是oncall和platform。QUERY_OK只允许字母、数字、空格和连字符,长度 1 到 64。build_search校验模型被允许发送的三个参数。
具体来说:query 必须匹配 QUERY_OK。deploy 能通过,因为它只有字母;title:* OR body:* 不能通过,因为 * 和 : 不属于字母、数字、空格或连字符,函数会抛出 SafeSearchError,根本不会去搜索。owner 如果传了,必须是 oncall 或 platform,其他名字一律拒绝;传 None 表示不按 owner 过滤。limit 如果传了,会被压到 MAX_RESULTS 以内:请求 9999 行会变成 20 行;小于 1 的 limit 会被拒绝。
所以 {"query": "deploy", "owner": "oncall"} 是「关键词加白名单内的 owner」,而 title:* OR body:* 是模型试图写一个匹配一切的查询。在这个玩具列表里,两个字符串作为普通子串搜索都无害。之所以要加这道检查,是为了当你以后把 search_notes 指向一个把 * 当通配符的真实 API 时,同一个函数仍然会拒绝它们。
新建 test_safe_search.py,这样不用起服务器就能看到这三种情况通过或失败:
import pytest
from safe_search import SafeSearchError, build_search
def test_happy_path():
out = build_search("deploy window", "oncall", 10)
assert out["limit"] == 10
assert out["owner"] == "oncall"
def test_rejects_query_language():
with pytest.raises(SafeSearchError):
build_search("title:* OR body:*", None, 10)
def test_caps_limit():
out = build_search("keys", "platform", 9999)
assert out["limit"] == 20test_happy_path 是被允许的调用:正常短语、白名单内的 owner、低于上限的 limit。test_rejects_query_language 期望 SafeSearchError,因为查询里出现了查询语言字符。test_caps_limit 验证 9999 会被压到 20。运行 pytest 即可看到结果。
第三步:把 API 包进 MCP 服务器
接下来把这三个函数暴露成 MCP 工具。关键点是工具清单要短、要具名,每个工具的参数都要显式声明,而不是接受一个「方法 + 路径 + 请求体」的组合。搜索工具内部先调用 build_search,校验通过后再去调用 search_notes。这样无论调用方是哪个助手、提示词写得多客气,规则都生效在服务端。
服务器监听一个只有网关应该使用的地址和端口,例如本机的 8091。不要把 8091 暴露给助手直接访问——如果助手能直接打开这个端口,它就能绕过网关这道前门。
第四步:在前面加一层网关
网关放在 /notes/mcp,助手只跟网关说话,网关再转发给 MCP 服务器。这样登录、限流和日志只有一个入口。示例在本机运行,所以网关保持开放,不需要接登录提供方;但在真实部署里,网关是绑定调用方身份的地方。
第五步:端到端测试 tools/list 与 tools/call
写一个脚本,依次做三件事:列出工具,确认只有 list、get、search 三个;发起一次合法搜索,例如查询 deploy、owner 为 oncall,确认返回了正确的笔记;再发起一次非法搜索,例如 title:* OR body:*,确认被拒绝而不是返回全部笔记。这个脚本是整套配置的验收标准:它同时证明了工具面足够窄、校验确实在服务端执行、以及助手走的是网关而不是后端进程。
一个完整示例
把上面的部分串起来,一次完整的调用流程是这样的:
- 助手向网关的
/notes/mcp发起tools/list,拿到三个工具:list、get、search。 - 用户问「下一次发布冻结是什么时候」,助手决定调用
search,参数为{"query": "deploy", "owner": "oncall"}。 - 网关把请求转给 8091 上的 MCP 服务器。
- MCP 服务器调用
build_search:deploy只含字母,通过;oncall在白名单内,通过;limit 未传,取默认 20。 - 服务器用构造好的参数调用
search_notes,命中n2,标题是deploy window,正文是Fridays are frozen。 - 结果沿原路返回给助手,助手据此回答用户。
如果第二步的参数换成 {"query": "title:* OR body:*"},第四步就会抛出 SafeSearchError,搜索根本不会执行,助手拿到的是一个错误,而不是全部笔记。
注意事项
这套最小示例刻意保持开放,因此有几条限制必须清楚:
- 示例没有登录。 本机运行不需要登录提供方,但真实部署必须把每次调用绑定到一个具名主体:提出问题的那个人,或者为无人值守任务创建的服务账号。
- 不要共享长期令牌。 把同一个长期令牌配到每台笔记本上,会导致访问日志只记录「机器人」,无法区分是谁在查;也无法在不影响其他人的前提下撤销某一个人的访问。
- 聊天窗口里的 OAuth 不等于 API 的凭据。 打开助手时看到的 OAuth 界面证明的是谁打开了聊天,它本身不会改变搜索笔记的那个 HTTP 请求。那个请求仍然需要自己的凭据,笔记 API 仍然需要知道这个凭据属于哪个账号。
- 提示词不是控制手段。 「只读,不要改任何东西」只是文本。真正拒绝坏请求的必须是服务端。
- 不要暴露后端端口。 如果助手能直接打开 8091,它就能跳过网关,登录、限流和日志也就形同虚设。
- 不要提供通用 HTTP 工具。 一个接受方法、路径和请求体的工具,等于把整个 API 交给模型挑选请求,包括你没打算公开的管理路由。
- 注意工具结果里的敏感内容。 如果工具返回了密钥或个人数据,这些文本可能被带进下一轮提示词。
- 保留调用记录。 需要能查到哪个工具跑了、为谁跑的。缺少这层记录,事后无法审计,也无法解释某次访问是否被授权。
把笔记列表换成工单系统、代码托管服务或任何你已经在跑的 HTTP API 时,同样适用这三条规则:助手只调用网关,不直接调用后端;每次调用都以具名主体运行;模型只表达意图,查询由你的代码构造。示例中的默认值、上限和允许名单都可以按需调整,具体参数与依赖版本请以相关项目官网当前信息为准。