上篇读的是本地文件。如果数据在接口里,怎么交给 AI?
思路很直接:
AI 调用 MCP 工具,工具请求 HTTP 接口,再把结果返回。
这次自己启动一个模拟接口,不需要账号、密钥,也不用连接公司系统。
一、准备三个文件
新建文件夹 mcp-http,按下面的结构创建文件:
text
mcp-http/
├── run.py
├── check.py
└── fixtures/
└── mock_api.py
在这个文件夹打开终端,安装依赖。
Mac:
bash
python3 -m venv .venv
.venv/bin/python -m pip install mcp==2.3.0 pydantic==2.14.1
Windows PowerShell:
powershell
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install mcp==2.3.0 pydantic==2.14.1
Python 使用 3.11 或更高版本。
二、先准备一个模拟接口
打开 fixtures/mock_api.py,复制下面的代码:
python
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import sys
import time
from urllib.parse import urlsplit, parse_qs
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
mode = os.environ.get("DEMO_MOCK_MODE", "normal")
if mode == "slow":
time.sleep(1.2)
status = (
503 if mode == "error"
else 302 if mode == "redirect"
else 200
)
query = parse_qs(urlsplit(self.path).query)
rows = (
[] if query.get("status") == ["closed"]
else [{"id": "HTTP-001", "title": "虚构登录问题"}]
)
raw = json.dumps(
{"issues": rows}, ensure_ascii=False
).encode("utf-8")
if mode == "oversize":
raw = b"x" * 5000
if mode == "invalid":
raw = b"not-json"
if mode == "shape":
raw = b'{"issues": [{}]}'
self.send_response(status)
if mode == "redirect":
self.send_header("Location", "http://example.invalid/")
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
try:
self.wfile.write(raw)
except (BrokenPipeError, ConnectionResetError):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(
("127.0.0.1", int(os.environ.get("DEMO_API_PORT", "8765"))),
Handler,
)
if len(sys.argv) > 1:
Path(sys.argv[1]).write_text(str(server.server_port))
try:
server.serve_forever()
finally:
server.server_close()
它会在本机启动一个接口:
text
http://127.0.0.1:8765/issues
查询未关闭问题时,返回一条虚构记录;查询已关闭问题时,返回空列表。
代码里的其他模式用来模拟超时、接口报错等情况,先保持默认即可。
三、把接口包装成 MCP 工具
打开 run.py,复制完整代码:
python
from typing import Any, Literal
import json
import os
import socket
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import (
build_opener,
ProxyHandler,
HTTPRedirectHandler,
)
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("http-wrapper-demo")
READ_ONLY = ToolAnnotations(
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=False,
)
PORT = int(os.environ.get("DEMO_API_PORT", "8765"))
if not 1 <= PORT <= 65535:
raise ValueError("invalid_demo_port")
MAX_BYTES = 4096
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
OPENER = build_opener(ProxyHandler({}), NoRedirect())
@mcp.tool(annotations=READ_ONLY)
def list_api_issues(
project: Literal["demo-project"],
status: Literal["open", "closed"] = "open",
) -> dict[str, Any]:
"""查询本地演示接口,不接受调用方传入网址。"""
url = f"http://127.0.0.1:{PORT}/issues?" + urlencode({
"project": project,
"status": status,
})
try:
with OPENER.open(url, timeout=0.5) as response:
raw = response.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ToolError("upstream_response_too_large")
data = json.loads(raw.decode("utf-8"))
except HTTPError as error:
raise ToolError(f"upstream_http_{error.code}") from None
except (TimeoutError, socket.timeout):
raise ToolError("upstream_timeout") from None
except URLError as error:
code = (
"upstream_timeout"
if isinstance(error.reason, TimeoutError)
else "upstream_unreachable"
)
raise ToolError(code) from None
except (ValueError, UnicodeError):
raise ToolError("upstream_invalid_json") from None
if not isinstance(data, dict) or not isinstance(data.get("issues"), list):
raise ToolError("upstream_invalid_shape")
rows = data["issues"]
if any(
not isinstance(row, dict)
or set(row) != {"id", "title"}
or not all(isinstance(v, str) for v in row.values())
for row in rows
):
raise ToolError("upstream_invalid_shape")
return {
"source": "local-mock-http",
"count": len(rows),
"issues": rows,
}
if __name__ == "__main__":
mcp.run(transport="stdio")
重点看两个地方:
地址固定在代码里。 AI 只能选择项目和状态,不能随便传一个网址。
失败会明确报错。 接口超时、返回错误、数据格式不对,都不会被包装成"没有问题"。
四、启动接口,再检查工具
先在第一个终端启动模拟接口。
Mac:
bash
.venv/bin/python fixtures/mock_api.py
Windows:
powershell
.\.venv\Scripts\python.exe fixtures/mock_api.py
这个终端保持打开。没有输出是正常现象,接口正在等待请求。
再打开第二个终端,进入同一个项目文件夹。
打开 check.py,复制:
python
import asyncio
import json
import sys
from pathlib import Path
from mcp import Client, StdioServerParameters
async def main():
config = {
"command": sys.executable,
"args": [
str(Path(__file__).with_name("run.py").resolve())
],
"env": {
"PYTHONUTF8": "1",
"PYTHONIOENCODING": "utf-8",
},
}
async with Client(StdioServerParameters(**config)) as client:
result = await client.call_tool("list_api_issues", {
"project": "demo-project",
"status": "open",
})
if result.is_error:
for item in result.content:
print(getattr(item, "text", ""))
raise SystemExit(1)
data = result.structured_content
assert data["source"] == "local-mock-http"
assert data["count"] == 1
assert data["issues"][0]["id"] == "HTTP-001"
print(json.dumps(data, ensure_ascii=False, indent=2))
print("检查通过。客户端配置:")
print(json.dumps(config, ensure_ascii=False, indent=2))
if __name__ == "__main__":
asyncio.run(main())
Mac 执行:
bash
.venv/bin/python check.py
Windows 执行:
powershell
.\.venv\Scripts\python.exe check.py
正常结果应该包含:
json
{
"source": "local-mock-http",
"count": 1,
"issues": [
{
"id": "HTTP-001",
"title": "虚构登录问题"
}
]
}
这就说明:工具确实请求了接口,并拿到了返回数据。
五、让 AI 调用它
按照第 04 篇的方法,在 Cherry Studio 添加本地 stdio 服务。
名称可以填 local-issue-api,其他字段使用 check.py 输出的配置。
给当前 Agent 启用服务,然后发送:
text
请调用 list_api_issues。
project 填 demo-project,status 填 open。
根据工具结果,告诉我有几条问题,分别是什么。
如果工具失败,请说明失败,不要回答"没有问题"。
模拟接口所在的第一个终端需要一直保持运行。
注意:这里的 HTTP 是工具访问数据的方式,客户端和 MCP 服务之间仍然使用 stdio。
六、故意让它失败一次
在第一个终端按 Ctrl+C,停止模拟接口。
再在第二个终端运行 check.py。
这时应该看到:
text
upstream_unreachable
这表示接口连接失败,不代表问题数量为零。
再启动模拟接口,重新检查,结果应恢复正常。
以后接真实业务接口时,也要保留这个区别:
| 返回情况 | 应该怎么理解 |
|---|---|
| 请求成功,列表为空 | 查到了,确实没有匹配记录 |
| 请求超时或连接失败 | 没查成功,暂时不知道结果 |
| 返回格式不对 | 数据异常,需要检查接口 |
如果启动时提示端口被占用,先检查是不是已经开了一个模拟接口,避免重复启动。