thiter-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thiter_mcp-0.1.0/.gitignore +48 -0
- thiter_mcp-0.1.0/.workbuddy/memory/2026-10-25.md +19 -0
- thiter_mcp-0.1.0/.workbuddy/memory/MEMORY.md +19 -0
- thiter_mcp-0.1.0/PKG-INFO +173 -0
- thiter_mcp-0.1.0/README.md +161 -0
- thiter_mcp-0.1.0/docs/modelscope.md +145 -0
- thiter_mcp-0.1.0/pyproject.toml +20 -0
- thiter_mcp-0.1.0/src/thiter_mcp/__init__.py +5 -0
- thiter_mcp-0.1.0/src/thiter_mcp/__main__.py +6 -0
- thiter_mcp-0.1.0/src/thiter_mcp/server.py +214 -0
- thiter_mcp-0.1.0/tests/e2e_test.py +319 -0
- thiter_mcp-0.1.0/tests/stub_upstream.py +116 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# 测试与缓存
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
|
|
16
|
+
# 日志与临时文件
|
|
17
|
+
*.log
|
|
18
|
+
proxy.log
|
|
19
|
+
nohup.out
|
|
20
|
+
|
|
21
|
+
# 系统文件
|
|
22
|
+
.DS_Store
|
|
23
|
+
|
|
24
|
+
# 沙箱环境残留的临时探针文件(安全策略禁止删除,仅防止误提交)
|
|
25
|
+
/2sddbpwk
|
|
26
|
+
/2x0o06k5
|
|
27
|
+
/2xxsma5z
|
|
28
|
+
/31ulblbs
|
|
29
|
+
/338soufy
|
|
30
|
+
/3e5r8zky
|
|
31
|
+
/4rvet96n
|
|
32
|
+
/60ici7jf
|
|
33
|
+
/8i35yls1
|
|
34
|
+
/_oscc8b6
|
|
35
|
+
/e9ehpqck
|
|
36
|
+
/f6lk466p
|
|
37
|
+
/fbihz25q
|
|
38
|
+
/hx81wb8e
|
|
39
|
+
/ij6rsbqd
|
|
40
|
+
/ks_f9qqg
|
|
41
|
+
/me5awoe4
|
|
42
|
+
/qj8xams3
|
|
43
|
+
/ra9vteb1
|
|
44
|
+
/y6nhap4w
|
|
45
|
+
/yrd7q35z
|
|
46
|
+
/yv865vuv
|
|
47
|
+
*.cpython-313.pyc.*
|
|
48
|
+
.tmp/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 2026-10-25 工作日志
|
|
2
|
+
|
|
3
|
+
## 春淼项目:方案三(ModelScope MCP 托管部署)实施
|
|
4
|
+
|
|
5
|
+
- 读取 docs/modelscope.md 后,用户选择方案三:将 proxy.py 改造为 MCP 服务并走魔搭云托管。
|
|
6
|
+
- 从 CNB 仓库 `weiguanghui/chunmiao/tmp-modelsapace-model` 拉取原 proxy.py(FastAPI OpenAI 兼容代理,硬编码 MAS_TOKEN,转发 api-inference.modelscope.cn,模型 qwen/qwen3.5-35b-a3b;test-ai.py 与 proxy.py 字节相同)。
|
|
7
|
+
- 在本工作区创建 PyPI 包 `chunmiao-mcp`(src 布局 + hatchling):
|
|
8
|
+
- `src/chunmiao_mcp/server.py`:MCP 官方 SDK mcp 2.x(MCPServer)实现,工具 chat / list_models,默认 stdio,支持 --transport streamable-http;配置全走环境变量(MAS_TOKEN / CHUNMIAO_MODEL / CHUNMIAO_BASE_URL / CHUNMIAO_TIMEOUT);预期失败抛 ToolError(mcp 2.x 才会把详情透传给客户端)。
|
|
9
|
+
- 隐私加固:新增 CHUNMIAO_API_KEY 访问密钥(用户明确要求"只有自己可用"),配置后所有工具调用必须携带匹配 key 参数;测试覆盖缺/错/正确 key 三条路径。
|
|
10
|
+
- 用户提供部署页截图确认:魔搭托管部署高级配置含平台级鉴权(Bearer Token / 无鉴权)与有效期(长期 / 24h),传输协议 Streamable HTTP / SSE,函数计算 FC 资源——平台级 Bearer Token 是首选防线,CHUNMIAO_API_KEY 降为可选第二层。已更新 README/docs。
|
|
11
|
+
- pyproject.toml 按用户要求改为 author=thiter、description="thiter MCP 服务:..."。
|
|
12
|
+
- 应用户要求将包名 chunmiao-mcp 全量改名为 thiter-mcp(PyPI 可用):目录 src/chunmiao_mcp→src/thiter_mcp、模块名、console script、MCPServer("thiter")、README/docs 中所有命令与配置 JSON 同步更新;CHUNMIAO_* 环境变量前缀保留未动(已向用户说明)。e2e 23/23 复测通过。
|
|
13
|
+
- 沙箱残留 22 个 4 字节临时探针文件 + __pycache__ 里的 .pyc.* 垃圾(策略禁止删除),已全部列入 .gitignore 防误提交。
|
|
14
|
+
- `tests/e2e_test.py` + `tests/stub_upstream.py`:stub 上游端到端测试,23 项全过(stdio + streamable-http、正常/500/401/缺令牌/缺错正确 key)。
|
|
15
|
+
- 环境限制:本机 pip/uv/python -m build 均被安全策略拦截(tempfile 探针文件创建/删除被拒),故未在本地构建 wheel;需用户在本机执行 `uv build && uv publish`。
|
|
16
|
+
- 已向用户说明:PyPI 完全公开(代码人人可见可装,但不含密钥);魔搭 MCP 广场仅支持公开服务、托管端点公开可达,必须靠 CHUNMIAO_API_KEY 防白嫖;若连代码也不想公开则改用 streamable-http 自托管。
|
|
17
|
+
- README.md 与 docs/modelscope.md 第八节已记录实施情况、部署配置 JSON 与安全说明。
|
|
18
|
+
- 用户澄清:CNB 仓库是私有的(信任 CNB),旧 proxy.py 中的令牌未公开泄露——已把 README/docs 里"已随仓库公开、尽快轮换"的措辞改为"私有仓库未泄露、轮换属可选保险"。
|
|
19
|
+
- 工作区残留一批 4 字节临时探针文件(策略禁止删除),待用户权限下清理。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 项目长期记忆(modelscope_project_proxy / 春淼)
|
|
2
|
+
|
|
3
|
+
## 项目背景
|
|
4
|
+
- 春淼(Chunmiao):OpenAI 兼容代理 → 已按方案三改造为 MCP 服务,走魔搭 MCP 广场托管部署(7×24,摆脱 DSW 实例 8 小时回收)。
|
|
5
|
+
- 代码仓库(CNB):`cnb.cool/weiguanghui/chunmiao/tmp-modelsapace-model.git`,main 分支。
|
|
6
|
+
- 上游:`https://api-inference.modelscope.cn/v1`,默认模型 `qwen/qwen3.5-35b-a3b`。
|
|
7
|
+
|
|
8
|
+
## 技术决策
|
|
9
|
+
- PyPI 包名 `thiter-mcp`(模块 `thiter_mcp`),src 布局 + hatchling,console script `thiter-mcp = thiter_mcp:main`(2026-10-25 由 chunmiao-mcp 改名,PyPI 名称可用;环境变量前缀保留 CHUNMIAO_* 未动)。
|
|
10
|
+
- 框架:MCP 官方 Python SDK mcp 2.x(`from mcp.server.mcpserver import MCPServer`)。注意 mcp 2.x 与 fastmcp 2.x 互斥(fastmcp 要求 mcp<2),且本机无法安装 fastmcp(沙箱拦截 tempfile)。
|
|
11
|
+
- mcp 2.x 约定:预期内失败必须抛 `mcp.server.mcpserver.exceptions.ToolError`,否则详情被屏蔽为 "Error executing tool X"。
|
|
12
|
+
- 托管部署要求:PyPI 包 + `uvx chunmiao-mcp`(stdio);平台自动部署检测(安装→连接→list_tools)。
|
|
13
|
+
- 配置约定:令牌走环境变量 MAS_TOKEN,不硬编码;CNB 仓库是私有的(用户明确说明,信任 CNB),旧 proxy.py 里的令牌未公开泄露,轮换属可选保险。
|
|
14
|
+
- 隐私边界(用户明确要求"只有自己可用"):① PyPI 完全公开(代码+元信息人人可见可装,但不含密钥);② 魔搭托管部署「高级配置」提供平台级鉴权:鉴权类型 Bearer Token / 无鉴权,有效期长期有效 / 24 小时,连接层校验(首选防线,截图确认);③ 工具级 CHUNMIAO_API_KEY 作为可选第二层;④ 若连代码也不想公开 → 放弃托管,用 streamable-http 自托管 + 反代鉴权。
|
|
15
|
+
- 署名:pyproject 作者为 thiter(2026-10-25 用户指定),描述以 "thiter MCP 服务" 开头;包名 thiter-mcp(已从 chunmiao-mcp 改名)。
|
|
16
|
+
|
|
17
|
+
## 环境注意事项
|
|
18
|
+
- 本沙箱 pip / uv / python -m build 均不可用(tempfile 探针文件被安全策略拦截),构建发布需在用户本机执行。
|
|
19
|
+
- 测试运行方式:`PYTHONPATH=src python tests/e2e_test.py`(内置 stub,无需真实令牌)。
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: thiter-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: thiter MCP 服务:把魔搭 ModelScope 大模型包装成 MCP 工具(OpenAI 兼容 messages 格式)
|
|
5
|
+
Author: thiter
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,mcp,modelscope,proxy
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: mcp<3,>=2.3
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# thiter MCP(thiter-mcp)
|
|
14
|
+
|
|
15
|
+
把魔搭 ModelScope 的大模型(默认 `qwen/qwen3.5-35b-a3b`)包装成 **MCP 工具**的代理服务,是原 OpenAI 兼容代理 `proxy.py` 的 MCP 化改造版。通过 ModelScope MCP 广场「可托管部署」后,可获得 7×24 小时稳定运行的云端 MCP 端点,不再受 Notebook 实例 8 小时到期回收的限制。
|
|
16
|
+
|
|
17
|
+
## 项目简介
|
|
18
|
+
|
|
19
|
+
thiter MCP 基于 MCP 官方 Python SDK(`mcp` 2.x,`MCPServer`)实现,暴露两个工具:
|
|
20
|
+
|
|
21
|
+
| 工具 | 说明 |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `chat` | 与魔搭大模型对话,入参为 OpenAI 兼容的 `messages` 列表,返回 `content` / `model` / `finish_reason` / `usage` |
|
|
24
|
+
| `list_models` | 列出当前令牌可调用的模型 |
|
|
25
|
+
|
|
26
|
+
特性:
|
|
27
|
+
|
|
28
|
+
- **默认 stdio 传输**:满足魔搭托管部署要求(PyPI 包 + `uvx` 拉起)
|
|
29
|
+
- **配置全走环境变量**:令牌不落代码,`MAS_TOKEN` 等在平台侧配置注入
|
|
30
|
+
- **访问密钥保护**:配置 `CHUNMIAO_API_KEY` 后,所有工具调用必须携带匹配的 `key` 参数,托管端点公开可达也不会被他人白嫖额度
|
|
31
|
+
- **无状态、不落盘**:适配 serverless 托管环境
|
|
32
|
+
- **可选 HTTP 自托管**:`--transport streamable-http` 可起 Streamable HTTP 服务
|
|
33
|
+
|
|
34
|
+
## 部署指南
|
|
35
|
+
|
|
36
|
+
### 方式一:魔搭 MCP 广场托管部署(推荐,7×24 运行)
|
|
37
|
+
|
|
38
|
+
托管部署要求服务已发布到 PyPI,通过 `uvx` 拉起。步骤如下:
|
|
39
|
+
|
|
40
|
+
1. **发布到 PyPI**(需 PyPI 账号与 API Token):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv build # 生成 dist/*.whl 与 *.tar.gz
|
|
44
|
+
uv publish --token <你的PyPI Token>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. **在魔搭 MCP 广场创建服务**:打开 <https://modelscope.cn/mcp/servers/create?template=customize>
|
|
48
|
+
- 创建类型:自定义创建
|
|
49
|
+
- 英文名称:`thiter`(将作为服务 ID 的一部分)
|
|
50
|
+
- 托管类型:**可托管部署**
|
|
51
|
+
- 服务配置(第二步填写):
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"thiter": {
|
|
57
|
+
"command": "uvx",
|
|
58
|
+
"args": ["thiter-mcp"],
|
|
59
|
+
"env": {
|
|
60
|
+
"MAS_TOKEN": "ms-你的魔搭令牌",
|
|
61
|
+
"CHUNMIAO_API_KEY": "自己定一个强随机密钥"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- 环境变量:`MAS_TOKEN` 必填;强烈建议同时配置 `CHUNMIAO_API_KEY`(托管端点公开可达,见下方安全说明);可选 `CHUNMIAO_MODEL`、`CHUNMIAO_BASE_URL`、`CHUNMIAO_TIMEOUT`
|
|
69
|
+
- 第三步:填写 README 内容
|
|
70
|
+
|
|
71
|
+
3. 点击创建后,平台会自动执行部署检测(`uvx` 安装包 → 连接 → `list_tools`),通过后即获得托管端点(形如 `https://mcp.api-inference.modelscope.net/<你的服务名>/mcp`),任意 MCP 客户端均可接入。
|
|
72
|
+
|
|
73
|
+
### 方式二:本地 / 自托管使用
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# 直接运行(需 Python >= 3.10)
|
|
77
|
+
pip install thiter-mcp
|
|
78
|
+
export MAS_TOKEN="ms-你的魔搭令牌"
|
|
79
|
+
thiter-mcp # stdio 模式
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
MCP 客户端配置(Claude Desktop / Cursor / Cherry Studio 等):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mcpServers": {
|
|
87
|
+
"thiter": {
|
|
88
|
+
"command": "uvx",
|
|
89
|
+
"args": ["thiter-mcp"],
|
|
90
|
+
"env": {
|
|
91
|
+
"MAS_TOKEN": "ms-你的魔搭令牌",
|
|
92
|
+
"CHUNMIAO_API_KEY": "自己定一个强随机密钥"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
> 注意:客户端连接托管端点(`https://mcp.api-inference.modelscope.net/<服务名>/mcp`)时无需填 env,
|
|
100
|
+
> 但每次工具调用需在参数里带上 `"key": "<你的 CHUNMIAO_API_KEY>"`。
|
|
101
|
+
|
|
102
|
+
自托管 Streamable HTTP 模式:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
thiter-mcp --transport streamable-http --host 0.0.0.0 --port 8000
|
|
106
|
+
# 端点: http://<主机>:8000/mcp
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## 使用示例
|
|
110
|
+
|
|
111
|
+
以 MCP 客户端调用 `chat` 工具为例:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"tool": "chat",
|
|
116
|
+
"arguments": {
|
|
117
|
+
"messages": [
|
|
118
|
+
{"role": "system", "content": "你是thiter 助手"},
|
|
119
|
+
{"role": "user", "content": "你好,介绍一下你自己"}
|
|
120
|
+
],
|
|
121
|
+
"key": "你的 CHUNMIAO_API_KEY",
|
|
122
|
+
"temperature": 0.7,
|
|
123
|
+
"max_tokens": 1024
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
返回示例:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"content": "你好!我是thiter 助手……",
|
|
133
|
+
"model": "qwen/qwen3.5-35b-a3b",
|
|
134
|
+
"finish_reason": "stop",
|
|
135
|
+
"usage": {"prompt_tokens": 20, "completion_tokens": 50, "total_tokens": 70}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
与原 `proxy.py` 的差异:原服务暴露 OpenAI HTTP 接口(`/v1/chat/completions`),本服务改为 MCP 工具。原使用 OpenAI SDK 的客户端需迁移为 MCP 调用,`messages` 入参格式保持不变,迁移成本低。
|
|
140
|
+
|
|
141
|
+
## 环境变量
|
|
142
|
+
|
|
143
|
+
| 变量 | 必填 | 默认值 | 说明 |
|
|
144
|
+
| --- | --- | --- | --- |
|
|
145
|
+
| `MAS_TOKEN` | 是 | 无 | 魔搭 API 访问令牌(ms-xxx) |
|
|
146
|
+
| `CHUNMIAO_API_KEY` | 否 | 无(不启用) | 访问密钥;配置后所有工具调用必须携带匹配的 `key` 参数 |
|
|
147
|
+
| `CHUNMIAO_MODEL` | 否 | `qwen/qwen3.5-35b-a3b` | 默认模型 |
|
|
148
|
+
| `CHUNMIAO_BASE_URL` | 否 | `https://api-inference.modelscope.cn/v1` | 上游 API 地址 |
|
|
149
|
+
| `CHUNMIAO_TIMEOUT` | 否 | `120` | 上游超时(秒) |
|
|
150
|
+
| `CHUNMIAO_TRANSPORT` | 否 | `stdio` | 传输方式(stdio / streamable-http) |
|
|
151
|
+
| `CHUNMIAO_HOST` / `CHUNMIAO_PORT` | 否 | `127.0.0.1` / `8000` | HTTP 模式监听地址 |
|
|
152
|
+
|
|
153
|
+
## 安全与隐私说明
|
|
154
|
+
|
|
155
|
+
- **PyPI 是完全公开的**:包发布后,代码人人可查看、下载、安装,作者/描述等元信息也会公开展示。本包不包含任何密钥(`MAS_TOKEN` / `CHUNMIAO_API_KEY` 均走环境变量),公开代码不会泄露你的令牌;如不希望公开关联信息,请使用中性的包名与作者名。
|
|
156
|
+
- **平台级鉴权(推荐,首选防线)**:魔搭托管部署的「高级配置」中可选择 **鉴权类型:Bearer Token**,客户端连接托管端点时必须携带该令牌,连接层即完成校验——对任意 MCP 客户端透明,无需在工具参数里传 key。有效期可选「长期有效」(令牌本身是敏感信息,切勿外泄)或「24 小时」。传输协议支持 Streamable HTTP / SSE,部署在函数计算(FC)资源上。
|
|
157
|
+
- **工具级访问密钥(可选第二层)**:配置 `CHUNMIAO_API_KEY` 后,所有工具调用必须携带匹配的 `key` 参数。平台已开启 Bearer Token 时可留空(省去每次传 key);追求纵深防御时两者可同时启用。
|
|
158
|
+
- **YOUR_API_KEY 这类变量是上游服务的凭证**:截图中天眼查 MCP 的 `YOUR_API_KEY` 是它调用上游要用的密钥,通过部署页环境变量注入——与本服务 `MAS_TOKEN` 同一模式。代码里不内置任何默认密钥,未配置即不启用。
|
|
159
|
+
- **如果连代码也不想公开**:托管部署(uvx + PyPI)就不适用,改用自托管:`thiter-mcp --transport streamable-http` 部署在自己控制的服务器上,前面再挂反代 + TLS + 鉴权,完全不经过 PyPI 和魔搭广场。
|
|
160
|
+
- **历史令牌处理**:原 `proxy.py` 中硬编码的魔搭令牌仅存在于私有 CNB 仓库内,未对外泄露;新包已彻底移除硬编码(零密钥),旧 proxy.py 退役后该令牌即不再随代码流转。如想更稳妥,仍可在魔搭后台轮换一次,属可选操作。
|
|
161
|
+
|
|
162
|
+
## 本地开发
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
git clone <仓库地址>
|
|
166
|
+
cd thiter
|
|
167
|
+
uv venv && source .venv/bin/activate
|
|
168
|
+
uv pip install -e .
|
|
169
|
+
python -m thiter_mcp --help
|
|
170
|
+
|
|
171
|
+
# 端到端测试(内置 stub 上游,无需真实令牌)
|
|
172
|
+
PYTHONPATH=src python tests/e2e_test.py
|
|
173
|
+
```
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# thiter MCP(thiter-mcp)
|
|
2
|
+
|
|
3
|
+
把魔搭 ModelScope 的大模型(默认 `qwen/qwen3.5-35b-a3b`)包装成 **MCP 工具**的代理服务,是原 OpenAI 兼容代理 `proxy.py` 的 MCP 化改造版。通过 ModelScope MCP 广场「可托管部署」后,可获得 7×24 小时稳定运行的云端 MCP 端点,不再受 Notebook 实例 8 小时到期回收的限制。
|
|
4
|
+
|
|
5
|
+
## 项目简介
|
|
6
|
+
|
|
7
|
+
thiter MCP 基于 MCP 官方 Python SDK(`mcp` 2.x,`MCPServer`)实现,暴露两个工具:
|
|
8
|
+
|
|
9
|
+
| 工具 | 说明 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `chat` | 与魔搭大模型对话,入参为 OpenAI 兼容的 `messages` 列表,返回 `content` / `model` / `finish_reason` / `usage` |
|
|
12
|
+
| `list_models` | 列出当前令牌可调用的模型 |
|
|
13
|
+
|
|
14
|
+
特性:
|
|
15
|
+
|
|
16
|
+
- **默认 stdio 传输**:满足魔搭托管部署要求(PyPI 包 + `uvx` 拉起)
|
|
17
|
+
- **配置全走环境变量**:令牌不落代码,`MAS_TOKEN` 等在平台侧配置注入
|
|
18
|
+
- **访问密钥保护**:配置 `CHUNMIAO_API_KEY` 后,所有工具调用必须携带匹配的 `key` 参数,托管端点公开可达也不会被他人白嫖额度
|
|
19
|
+
- **无状态、不落盘**:适配 serverless 托管环境
|
|
20
|
+
- **可选 HTTP 自托管**:`--transport streamable-http` 可起 Streamable HTTP 服务
|
|
21
|
+
|
|
22
|
+
## 部署指南
|
|
23
|
+
|
|
24
|
+
### 方式一:魔搭 MCP 广场托管部署(推荐,7×24 运行)
|
|
25
|
+
|
|
26
|
+
托管部署要求服务已发布到 PyPI,通过 `uvx` 拉起。步骤如下:
|
|
27
|
+
|
|
28
|
+
1. **发布到 PyPI**(需 PyPI 账号与 API Token):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv build # 生成 dist/*.whl 与 *.tar.gz
|
|
32
|
+
uv publish --token <你的PyPI Token>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
2. **在魔搭 MCP 广场创建服务**:打开 <https://modelscope.cn/mcp/servers/create?template=customize>
|
|
36
|
+
- 创建类型:自定义创建
|
|
37
|
+
- 英文名称:`thiter`(将作为服务 ID 的一部分)
|
|
38
|
+
- 托管类型:**可托管部署**
|
|
39
|
+
- 服务配置(第二步填写):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"thiter": {
|
|
45
|
+
"command": "uvx",
|
|
46
|
+
"args": ["thiter-mcp"],
|
|
47
|
+
"env": {
|
|
48
|
+
"MAS_TOKEN": "ms-你的魔搭令牌",
|
|
49
|
+
"CHUNMIAO_API_KEY": "自己定一个强随机密钥"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- 环境变量:`MAS_TOKEN` 必填;强烈建议同时配置 `CHUNMIAO_API_KEY`(托管端点公开可达,见下方安全说明);可选 `CHUNMIAO_MODEL`、`CHUNMIAO_BASE_URL`、`CHUNMIAO_TIMEOUT`
|
|
57
|
+
- 第三步:填写 README 内容
|
|
58
|
+
|
|
59
|
+
3. 点击创建后,平台会自动执行部署检测(`uvx` 安装包 → 连接 → `list_tools`),通过后即获得托管端点(形如 `https://mcp.api-inference.modelscope.net/<你的服务名>/mcp`),任意 MCP 客户端均可接入。
|
|
60
|
+
|
|
61
|
+
### 方式二:本地 / 自托管使用
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 直接运行(需 Python >= 3.10)
|
|
65
|
+
pip install thiter-mcp
|
|
66
|
+
export MAS_TOKEN="ms-你的魔搭令牌"
|
|
67
|
+
thiter-mcp # stdio 模式
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
MCP 客户端配置(Claude Desktop / Cursor / Cherry Studio 等):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"thiter": {
|
|
76
|
+
"command": "uvx",
|
|
77
|
+
"args": ["thiter-mcp"],
|
|
78
|
+
"env": {
|
|
79
|
+
"MAS_TOKEN": "ms-你的魔搭令牌",
|
|
80
|
+
"CHUNMIAO_API_KEY": "自己定一个强随机密钥"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
> 注意:客户端连接托管端点(`https://mcp.api-inference.modelscope.net/<服务名>/mcp`)时无需填 env,
|
|
88
|
+
> 但每次工具调用需在参数里带上 `"key": "<你的 CHUNMIAO_API_KEY>"`。
|
|
89
|
+
|
|
90
|
+
自托管 Streamable HTTP 模式:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
thiter-mcp --transport streamable-http --host 0.0.0.0 --port 8000
|
|
94
|
+
# 端点: http://<主机>:8000/mcp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 使用示例
|
|
98
|
+
|
|
99
|
+
以 MCP 客户端调用 `chat` 工具为例:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"tool": "chat",
|
|
104
|
+
"arguments": {
|
|
105
|
+
"messages": [
|
|
106
|
+
{"role": "system", "content": "你是thiter 助手"},
|
|
107
|
+
{"role": "user", "content": "你好,介绍一下你自己"}
|
|
108
|
+
],
|
|
109
|
+
"key": "你的 CHUNMIAO_API_KEY",
|
|
110
|
+
"temperature": 0.7,
|
|
111
|
+
"max_tokens": 1024
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
返回示例:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"content": "你好!我是thiter 助手……",
|
|
121
|
+
"model": "qwen/qwen3.5-35b-a3b",
|
|
122
|
+
"finish_reason": "stop",
|
|
123
|
+
"usage": {"prompt_tokens": 20, "completion_tokens": 50, "total_tokens": 70}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
与原 `proxy.py` 的差异:原服务暴露 OpenAI HTTP 接口(`/v1/chat/completions`),本服务改为 MCP 工具。原使用 OpenAI SDK 的客户端需迁移为 MCP 调用,`messages` 入参格式保持不变,迁移成本低。
|
|
128
|
+
|
|
129
|
+
## 环境变量
|
|
130
|
+
|
|
131
|
+
| 变量 | 必填 | 默认值 | 说明 |
|
|
132
|
+
| --- | --- | --- | --- |
|
|
133
|
+
| `MAS_TOKEN` | 是 | 无 | 魔搭 API 访问令牌(ms-xxx) |
|
|
134
|
+
| `CHUNMIAO_API_KEY` | 否 | 无(不启用) | 访问密钥;配置后所有工具调用必须携带匹配的 `key` 参数 |
|
|
135
|
+
| `CHUNMIAO_MODEL` | 否 | `qwen/qwen3.5-35b-a3b` | 默认模型 |
|
|
136
|
+
| `CHUNMIAO_BASE_URL` | 否 | `https://api-inference.modelscope.cn/v1` | 上游 API 地址 |
|
|
137
|
+
| `CHUNMIAO_TIMEOUT` | 否 | `120` | 上游超时(秒) |
|
|
138
|
+
| `CHUNMIAO_TRANSPORT` | 否 | `stdio` | 传输方式(stdio / streamable-http) |
|
|
139
|
+
| `CHUNMIAO_HOST` / `CHUNMIAO_PORT` | 否 | `127.0.0.1` / `8000` | HTTP 模式监听地址 |
|
|
140
|
+
|
|
141
|
+
## 安全与隐私说明
|
|
142
|
+
|
|
143
|
+
- **PyPI 是完全公开的**:包发布后,代码人人可查看、下载、安装,作者/描述等元信息也会公开展示。本包不包含任何密钥(`MAS_TOKEN` / `CHUNMIAO_API_KEY` 均走环境变量),公开代码不会泄露你的令牌;如不希望公开关联信息,请使用中性的包名与作者名。
|
|
144
|
+
- **平台级鉴权(推荐,首选防线)**:魔搭托管部署的「高级配置」中可选择 **鉴权类型:Bearer Token**,客户端连接托管端点时必须携带该令牌,连接层即完成校验——对任意 MCP 客户端透明,无需在工具参数里传 key。有效期可选「长期有效」(令牌本身是敏感信息,切勿外泄)或「24 小时」。传输协议支持 Streamable HTTP / SSE,部署在函数计算(FC)资源上。
|
|
145
|
+
- **工具级访问密钥(可选第二层)**:配置 `CHUNMIAO_API_KEY` 后,所有工具调用必须携带匹配的 `key` 参数。平台已开启 Bearer Token 时可留空(省去每次传 key);追求纵深防御时两者可同时启用。
|
|
146
|
+
- **YOUR_API_KEY 这类变量是上游服务的凭证**:截图中天眼查 MCP 的 `YOUR_API_KEY` 是它调用上游要用的密钥,通过部署页环境变量注入——与本服务 `MAS_TOKEN` 同一模式。代码里不内置任何默认密钥,未配置即不启用。
|
|
147
|
+
- **如果连代码也不想公开**:托管部署(uvx + PyPI)就不适用,改用自托管:`thiter-mcp --transport streamable-http` 部署在自己控制的服务器上,前面再挂反代 + TLS + 鉴权,完全不经过 PyPI 和魔搭广场。
|
|
148
|
+
- **历史令牌处理**:原 `proxy.py` 中硬编码的魔搭令牌仅存在于私有 CNB 仓库内,未对外泄露;新包已彻底移除硬编码(零密钥),旧 proxy.py 退役后该令牌即不再随代码流转。如想更稳妥,仍可在魔搭后台轮换一次,属可选操作。
|
|
149
|
+
|
|
150
|
+
## 本地开发
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
git clone <仓库地址>
|
|
154
|
+
cd thiter
|
|
155
|
+
uv venv && source .venv/bin/activate
|
|
156
|
+
uv pip install -e .
|
|
157
|
+
python -m thiter_mcp --help
|
|
158
|
+
|
|
159
|
+
# 端到端测试(内置 stub 上游,无需真实令牌)
|
|
160
|
+
PYTHONPATH=src python tests/e2e_test.py
|
|
161
|
+
```
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
MARKDOWN
|
|
2
|
+
# 春淼项目背景与部署方案
|
|
3
|
+
|
|
4
|
+
## 一、项目概述
|
|
5
|
+
|
|
6
|
+
- **项目名称**:春淼(Chunmiao)
|
|
7
|
+
- **项目类型**:OpenAl 兼容代理服务
|
|
8
|
+
- **核心文件**:`proxy.py`、`test-ai.py`、`.gitignore`
|
|
9
|
+
- **项目目标**:提供一个 OpenAI API 兼容的代理服务,将请求转发到后端模型
|
|
10
|
+
|
|
11
|
+
## 二、代码仓库与平台
|
|
12
|
+
|
|
13
|
+
- **代码托管平台**:CNB(Cloud Native Build,腾讯云原生构建平台)
|
|
14
|
+
- **远端仓库地址**:`cnb.cool/weiguanghui/chunmiao/tmp-modelsapace-model.git`
|
|
15
|
+
- **仓库认证方式**:Access Token 认证
|
|
16
|
+
- **当前分支**:main(已 push 完成,`Resolving deltas: 100% (357/357)`)
|
|
17
|
+
- **最近提交**:已删除缓存/临时文件,后续通过 `.gitignore` 避免误传
|
|
18
|
+
|
|
19
|
+
## 三、当前运行环境
|
|
20
|
+
|
|
21
|
+
- **运行平台**:ModelScope Notebook(魔搭社区 Notebook)
|
|
22
|
+
- **实例类型**:DSW-CPU(CPU 计算实例)
|
|
23
|
+
- **实例规格**:DSW-CPU
|
|
24
|
+
- **工作目录**:`/mnt/workspace/`
|
|
25
|
+
- **持久化存储**:`/mnt/workspace/` 下的文件在实例回收后仍保留
|
|
26
|
+
- **关键限制**:单次实例剩余时长约 8 小时(当前显示 06:47:19),到期自动回收实例,proxy.py 会随之中断
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## 四、核心需求
|
|
30
|
+
|
|
31
|
+
保证 `proxy.py` 进程持续可用,满足以下场景的自动恢复:
|
|
32
|
+
- 进程意外崩溃或被 kill
|
|
33
|
+
- 服务器/实例重启
|
|
34
|
+
- 实例到期回收后重新启动时自动拉起
|
|
35
|
+
|
|
36
|
+
## 五、解决方案详述
|
|
37
|
+
|
|
38
|
+
### 方案一:systemd 系统服务(推荐,需 sudo 权限)
|
|
39
|
+
|
|
40
|
+
**原理**:将 proxy.py 注册为系统服务,由 systemd 管理生命周期。
|
|
41
|
+
|
|
42
|
+
**配置文件**:`/etc/systemd/system/proxy.service`
|
|
43
|
+
[Unit]
|
|
44
|
+
Description=Chunmiao Proxy Service
|
|
45
|
+
After=network.target
|
|
46
|
+
[Service]
|
|
47
|
+
Type=simple
|
|
48
|
+
WorkingDirectory=/mnt/workspace
|
|
49
|
+
ExecStart=/usr/bin/python3 /mnt/workspace/proxy.py
|
|
50
|
+
Restart=on-failure
|
|
51
|
+
RestartSec=5
|
|
52
|
+
[Install]
|
|
53
|
+
WantedBy=multi-user.target
|
|
54
|
+
|
|
55
|
+
**启用命令**:
|
|
56
|
+
```bash
|
|
57
|
+
sudo systemctl daemon-reload
|
|
58
|
+
sudo systemctl enable --now proxy.service
|
|
59
|
+
效果:
|
|
60
|
+
开机自动启动 proxy.py
|
|
61
|
+
进程挂掉后 5 秒自动拉起(Restart=on-failure)
|
|
62
|
+
需要 root/sudo 权限
|
|
63
|
+
局限:无法突破 DSW 实例的 8 小时到期回收限制,实例回收后服务仍会中断。
|
|
64
|
+
方案二:crontab 定时检查(无 sudo 权限时可用)
|
|
65
|
+
原理:每分钟检查 proxy.py 进程是否存在,不存在则用 nohup 拉起。
|
|
66
|
+
检查脚本: /mnt/workspace/check_proxy.sh
|
|
67
|
+
BASH
|
|
68
|
+
#!/bin/bash
|
|
69
|
+
if ! pgrep -f "proxy.py" > /dev/null; then
|
|
70
|
+
cd /mnt/workspace && nohup python3 proxy.py > proxy.log 2>&1 &
|
|
71
|
+
fi
|
|
72
|
+
crontab 配置:
|
|
73
|
+
BASH
|
|
74
|
+
* * * * * /mnt/workspace/check_proxy.sh
|
|
75
|
+
效果:
|
|
76
|
+
无需 sudo 权限
|
|
77
|
+
进程挂掉后最多 1 分钟被拉起
|
|
78
|
+
不够优雅,不如 systemd 稳定
|
|
79
|
+
局限:同样无法突破实例到期回收限制。
|
|
80
|
+
方案三:ModelScope MCP 部署服务(长期运行方案)
|
|
81
|
+
平台:ModelScope MCP 部署服务(https://modelscope.cn/mcp/servers/create)
|
|
82
|
+
原理:将服务改造为符合 MCP 协议的服务,通过魔搭云端托管,平台分配稳定计算资源 7×24 小时运行。
|
|
83
|
+
配置方式:在 MCP 部署页面选择「自定义 MCP」,填写 JSON 配置:
|
|
84
|
+
JSON
|
|
85
|
+
{
|
|
86
|
+
"command": "python",
|
|
87
|
+
"args": ["proxy.py"],
|
|
88
|
+
"env": {}
|
|
89
|
+
}
|
|
90
|
+
要求:
|
|
91
|
+
proxy.py 需要改造为符合 MCP 协议的服务(建议使用 FastMCP 框架包装)
|
|
92
|
+
支持 Stdio 或 SSE 通信方式
|
|
93
|
+
平台自动管理资源,不受 Notebook 到期回收限制
|
|
94
|
+
效果:
|
|
95
|
+
7×24 小时持续运行
|
|
96
|
+
云端托管,无需自己维护服务器
|
|
97
|
+
自动负载均衡和故障恢复
|
|
98
|
+
六、方案对比
|
|
99
|
+
维度 systemd crontab MCP 部署
|
|
100
|
+
自动拉起 是(立即) 是(延迟1分钟) 是
|
|
101
|
+
开机自启 是 否 是
|
|
102
|
+
24小时运行 否(受实例回收限制) 否(受实例回收限制) 是
|
|
103
|
+
权限要求 sudo 无 无(需符合MCP协议)
|
|
104
|
+
稳定性 高 中 高
|
|
105
|
+
改造成本 低 低 中(需改造为MCP协议)
|
|
106
|
+
七、结论与建议
|
|
107
|
+
短期测试/开发阶段:
|
|
108
|
+
使用 systemd(有 sudo)或 crontab(无 sudo)保证 proxy.py 进程稳定
|
|
109
|
+
注意 DSW 实例 8 小时到期限制,到期前手动重启或重新启动实例
|
|
110
|
+
长期生产运行:
|
|
111
|
+
将 proxy.py 改造为 MCP 协议服务(使用 FastMCP 等框架包装)
|
|
112
|
+
通过 ModelScope MCP 部署服务托管,彻底解决实例回收问题
|
|
113
|
+
享受云端 7×24 小时稳定运行,无需自己维护服务器
|
|
114
|
+
代码管理:
|
|
115
|
+
远端仓库: cnb.cool/weiguanghui/chunmiao/tmp-modelsapace-model.git
|
|
116
|
+
确保 .gitignore 配置完善,避免缓存文件、临时文件误传
|
|
117
|
+
当前 main 分支已包含最新提交(删除缓存/临时文件)
|
|
118
|
+
|
|
119
|
+
## 八、方案三实施记录(2026-10-25)
|
|
120
|
+
|
|
121
|
+
已按方案三完成 proxy.py 的 MCP 化改造,代码位于本仓库工作区:
|
|
122
|
+
|
|
123
|
+
- **包结构**:`src/thiter_mcp/`(server.py 工具实现 + __main__.py 入口),`pyproject.toml`(hatchling 构建,包名 `thiter-mcp`)
|
|
124
|
+
- **技术栈**:MCP 官方 Python SDK(mcp 2.x,MCPServer),httpx 调用上游;默认 stdio 传输(满足魔搭托管部署要求),支持 `--transport streamable-http` 自托管
|
|
125
|
+
- **工具**:`chat`(OpenAI 兼容 messages 入参,返回 content/model/finish_reason/usage)、`list_models`;均支持 `key` 访问密钥参数
|
|
126
|
+
- **配置**:全部走环境变量(MAS_TOKEN 必填,CHUNMIAO_MODEL / CHUNMIAO_BASE_URL / CHUNMIAO_TIMEOUT 可选),令牌不再硬编码
|
|
127
|
+
- **隐私保护**:隐私分三层——① 代码公开但零密钥(MAS_TOKEN / CHUNMIAO_API_KEY 全走环境变量,无默认值);② 平台级鉴权:托管部署「高级配置」可选鉴权类型 Bearer Token(连接层校验,推荐)+ 有效期长期/24 小时;③ 工具级 CHUNMIAO_API_KEY(可选第二层,调用带 key 参数)。传输协议支持 Streamable HTTP / SSE(函数计算 FC 资源)
|
|
128
|
+
- **测试**:`tests/e2e_test.py` 内置 stub 上游,覆盖 stdio / streamable-http 双传输、正常调用、上游 500、错误令牌 401、缺 MAS_TOKEN 等路径,17 项全部通过
|
|
129
|
+
- **与原 proxy.py 的差异**:OpenAI HTTP 接口(/v1/chat/completions)改为 MCP chat 工具,原 OpenAI SDK 客户端需迁移为 MCP 调用(messages 格式不变)
|
|
130
|
+
|
|
131
|
+
### 待用户操作(需要账号权限,无法代执行)
|
|
132
|
+
|
|
133
|
+
1. 发布到 PyPI:`uv build && uv publish --token <PyPI Token>`
|
|
134
|
+
2. 魔搭 MCP 广场创建(https://modelscope.cn/mcp/servers/create?template=customize),托管类型选「可托管部署」,服务配置:
|
|
135
|
+
{"mcpServers": {"thiter": {"command": "uvx", "args": ["thiter-mcp"], "env": {"MAS_TOKEN": "ms-你的魔搭令牌", "CHUNMIAO_API_KEY": "自己定一个强随机密钥"}}}}
|
|
136
|
+
注意:① PyPI 完全公开(代码与元信息人人可见,但不含密钥),作者已署名 thiter,包名/描述如不想关联真实身份应保持中性;② 部署页高级配置务必选「鉴权类型: Bearer Token」(连接层防护,首选),有效期长期有效时令牌自身妥善保管;CHUNMIAO_API_KEY 作为可选第二层;③ 若连代码也不想公开则放弃托管,改用 streamable-http 自托管 + 反代鉴权。
|
|
137
|
+
3. 平台自动部署检测(uvx 安装 → 连接 → list_tools)通过后获得托管端点
|
|
138
|
+
|
|
139
|
+
### 令牌说明(非紧急)
|
|
140
|
+
|
|
141
|
+
CNB 仓库为私有,原 proxy.py 中硬编码的魔搭令牌未对外泄露;新 MCP 包已零密钥化(全部走环境变量),旧 proxy.py 退役后令牌不再随代码流转。轮换令牌属可选保险,不强制。
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
以上就是本次实施记录。部署过程中如遇到问题,把报错发我即可。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "thiter-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "thiter MCP 服务:把魔搭 ModelScope 大模型包装成 MCP 工具(OpenAI 兼容 messages 格式)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "thiter" }]
|
|
9
|
+
keywords = ["mcp", "modelscope", "llm", "proxy"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=2.3,<3",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
thiter-mcp = "thiter_mcp:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""thiter MCP 服务:把魔搭 ModelScope 大模型包装成 MCP 工具。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
thiter-mcp # 默认 stdio 传输(魔搭托管部署用)
|
|
5
|
+
thiter-mcp --transport streamable-http --port 8000 # 自托管 HTTP
|
|
6
|
+
|
|
7
|
+
环境变量:
|
|
8
|
+
MAS_TOKEN 必填,魔搭 API 访问令牌(ms-xxx)
|
|
9
|
+
CHUNMIAO_API_KEY 可选,访问密钥;配置后所有工具调用必须携带匹配的 key 参数
|
|
10
|
+
CHUNMIAO_MODEL 默认模型,默认 qwen/qwen3.5-35b-a3b
|
|
11
|
+
CHUNMIAO_BASE_URL 上游地址,默认 https://api-inference.modelscope.cn/v1
|
|
12
|
+
CHUNMIAO_TIMEOUT 上游超时(秒),默认 120
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from mcp.server.mcpserver import MCPServer
|
|
24
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
mcp = MCPServer("thiter")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _config() -> dict[str, Any]:
|
|
32
|
+
"""读取环境变量配置(每次调用时读取,便于测试与热更新)。"""
|
|
33
|
+
return {
|
|
34
|
+
"token": os.environ.get("MAS_TOKEN", "").strip(),
|
|
35
|
+
"model": os.environ.get("CHUNMIAO_MODEL", "qwen/qwen3.5-35b-a3b").strip(),
|
|
36
|
+
"base_url": os.environ.get(
|
|
37
|
+
"CHUNMIAO_BASE_URL", "https://api-inference.modelscope.cn/v1"
|
|
38
|
+
).rstrip("/"),
|
|
39
|
+
"timeout": float(os.environ.get("CHUNMIAO_TIMEOUT", "120")),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_token() -> str:
|
|
44
|
+
token = _config()["token"]
|
|
45
|
+
if not token:
|
|
46
|
+
raise ToolError(
|
|
47
|
+
"未配置 MAS_TOKEN 环境变量。请在魔搭 MCP 服务配置的 env 中填入你的魔搭令牌(ms-xxx)。"
|
|
48
|
+
)
|
|
49
|
+
return token
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_access(key: str | None) -> None:
|
|
53
|
+
"""访问密钥校验:服务端配置 CHUNMIAO_API_KEY 后,所有工具调用必须携带匹配的 key。
|
|
54
|
+
|
|
55
|
+
魔搭托管端点是公开可达的,这道校验保证只有知道密钥的人能使用,
|
|
56
|
+
避免他人调用时消耗你的魔搭额度。
|
|
57
|
+
"""
|
|
58
|
+
expected = os.environ.get("CHUNMIAO_API_KEY", "").strip()
|
|
59
|
+
if not expected:
|
|
60
|
+
return # 未配置则不启用
|
|
61
|
+
if not key or key != expected:
|
|
62
|
+
raise ToolError(
|
|
63
|
+
"访问密钥无效:请在工具参数中提供正确的 key(需与服务端 CHUNMIAO_API_KEY 一致)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
async def chat(
|
|
69
|
+
messages: list[dict[str, Any]],
|
|
70
|
+
key: str | None = None,
|
|
71
|
+
model: str | None = None,
|
|
72
|
+
temperature: float | None = None,
|
|
73
|
+
max_tokens: int | None = None,
|
|
74
|
+
top_p: float | None = None,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
"""与thiter代理背后的魔搭大模型对话(OpenAI 兼容 messages 格式)。
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
messages: OpenAI 格式的消息列表,例如 [{"role": "user", "content": "你好"}]
|
|
80
|
+
key: 访问密钥(服务端配置 CHUNMIAO_API_KEY 后必填,需一致)
|
|
81
|
+
model: 可选,覆盖默认模型(如 qwen/qwen3.5-35b-a3b)
|
|
82
|
+
temperature: 可选,采样温度
|
|
83
|
+
max_tokens: 可选,最大生成 token 数
|
|
84
|
+
top_p: 可选,核采样参数
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
dict: {"content": 回复文本, "model": 模型, "finish_reason": 结束原因, "usage": 用量统计}
|
|
88
|
+
"""
|
|
89
|
+
_check_access(key)
|
|
90
|
+
cfg = _config()
|
|
91
|
+
token = _check_token()
|
|
92
|
+
|
|
93
|
+
payload: dict[str, Any] = {
|
|
94
|
+
"model": model or cfg["model"],
|
|
95
|
+
"messages": messages,
|
|
96
|
+
"stream": False, # MCP 工具返回为单条消息,不支持流式
|
|
97
|
+
}
|
|
98
|
+
for key, value in (
|
|
99
|
+
("temperature", temperature),
|
|
100
|
+
("max_tokens", max_tokens),
|
|
101
|
+
("top_p", top_p),
|
|
102
|
+
):
|
|
103
|
+
if value is not None:
|
|
104
|
+
payload[key] = value
|
|
105
|
+
|
|
106
|
+
headers = {
|
|
107
|
+
"Authorization": f"Bearer {token}",
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
async with httpx.AsyncClient() as client:
|
|
113
|
+
resp = await client.post(
|
|
114
|
+
f"{cfg['base_url']}/chat/completions",
|
|
115
|
+
json=payload,
|
|
116
|
+
headers=headers,
|
|
117
|
+
timeout=cfg["timeout"],
|
|
118
|
+
)
|
|
119
|
+
except httpx.TimeoutException as exc:
|
|
120
|
+
raise ToolError(f"上游请求超时({cfg['timeout']}s)") from exc
|
|
121
|
+
except httpx.HTTPError as exc:
|
|
122
|
+
raise ToolError(f"上游请求失败: {exc}") from exc
|
|
123
|
+
|
|
124
|
+
if resp.status_code != 200:
|
|
125
|
+
# 不返回令牌,避免泄露
|
|
126
|
+
raise ToolError(f"上游返回 HTTP {resp.status_code}: {resp.text[:500]}")
|
|
127
|
+
|
|
128
|
+
data = resp.json()
|
|
129
|
+
choices = data.get("choices") or []
|
|
130
|
+
if not choices:
|
|
131
|
+
raise ToolError(f"上游响应缺少 choices 字段: {str(data)[:500]}")
|
|
132
|
+
|
|
133
|
+
message = choices[0].get("message") or {}
|
|
134
|
+
return {
|
|
135
|
+
"content": message.get("content", ""),
|
|
136
|
+
"model": data.get("model", payload["model"]),
|
|
137
|
+
"finish_reason": choices[0].get("finish_reason"),
|
|
138
|
+
"usage": data.get("usage"),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
async def list_models(key: str | None = None) -> dict[str, Any]:
|
|
144
|
+
"""列出当前代理可调用的模型列表。
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
key: 访问密钥(服务端配置 CHUNMIAO_API_KEY 后必填,需一致)
|
|
148
|
+
"""
|
|
149
|
+
_check_access(key)
|
|
150
|
+
cfg = _config()
|
|
151
|
+
token = _check_token()
|
|
152
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
async with httpx.AsyncClient() as client:
|
|
156
|
+
resp = await client.get(
|
|
157
|
+
f"{cfg['base_url']}/models",
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=min(cfg["timeout"], 30),
|
|
160
|
+
)
|
|
161
|
+
if resp.status_code == 200:
|
|
162
|
+
return resp.json()
|
|
163
|
+
except httpx.HTTPError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# 上游不可用时退化为已配置的默认模型
|
|
167
|
+
return {
|
|
168
|
+
"object": "list",
|
|
169
|
+
"data": [
|
|
170
|
+
{"id": cfg["model"], "object": "model", "owned_by": "modelscope"}
|
|
171
|
+
],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main() -> None:
|
|
176
|
+
parser = argparse.ArgumentParser(
|
|
177
|
+
prog="thiter-mcp",
|
|
178
|
+
description="thiter MCP 服务:把魔搭 ModelScope 大模型包装成 MCP 工具",
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--transport",
|
|
182
|
+
choices=["stdio", "streamable-http"],
|
|
183
|
+
default=os.environ.get("CHUNMIAO_TRANSPORT", "stdio"),
|
|
184
|
+
help="传输方式,默认 stdio(魔搭托管部署使用 stdio)",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--host",
|
|
188
|
+
default=os.environ.get("CHUNMIAO_HOST", "127.0.0.1"),
|
|
189
|
+
help="streamable-http 模式监听地址",
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"--port",
|
|
193
|
+
type=int,
|
|
194
|
+
default=int(os.environ.get("CHUNMIAO_PORT", "8000")),
|
|
195
|
+
help="streamable-http 模式监听端口",
|
|
196
|
+
)
|
|
197
|
+
args = parser.parse_args()
|
|
198
|
+
|
|
199
|
+
if not _config()["token"]:
|
|
200
|
+
# stdio 模式下严禁写 stdout(协议通道),日志一律走 stderr
|
|
201
|
+
print("⚠️ 未检测到 MAS_TOKEN 环境变量,调用工具时会报错", file=sys.stderr)
|
|
202
|
+
|
|
203
|
+
if args.transport == "stdio":
|
|
204
|
+
mcp.run(transport="stdio")
|
|
205
|
+
else:
|
|
206
|
+
print(
|
|
207
|
+
f"🚀 thiter MCP 服务启动(streamable-http): http://{args.host}:{args.port}/mcp",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
main()
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""thiter MCP 端到端测试:stdio + streamable-http 双传输,覆盖正常/错误/缺令牌路径。
|
|
2
|
+
|
|
3
|
+
运行方式:
|
|
4
|
+
PYTHONPATH=src python tests/e2e_test.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
from mcp import ClientSession, StdioServerParameters
|
|
17
|
+
from mcp.client.stdio import stdio_client
|
|
18
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
19
|
+
|
|
20
|
+
from stub_upstream import start_stub # noqa: E402 (tests 目录在同级)
|
|
21
|
+
|
|
22
|
+
SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
23
|
+
|
|
24
|
+
PASSED: list[str] = []
|
|
25
|
+
FAILED: list[str] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check(name: str, cond: bool, detail: str = "") -> None:
|
|
29
|
+
if cond:
|
|
30
|
+
PASSED.append(name)
|
|
31
|
+
print(f" ✅ {name}")
|
|
32
|
+
else:
|
|
33
|
+
FAILED.append(f"{name} :: {detail}")
|
|
34
|
+
print(f" ❌ {name} :: {detail}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def free_port() -> int:
|
|
38
|
+
s = socket.socket()
|
|
39
|
+
s.bind(("127.0.0.1", 0))
|
|
40
|
+
port = s.getsockname()[1]
|
|
41
|
+
s.close()
|
|
42
|
+
return port
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def server_env(
|
|
46
|
+
base_url: str,
|
|
47
|
+
token: str | None = "test-token",
|
|
48
|
+
extra: dict | None = None,
|
|
49
|
+
) -> dict:
|
|
50
|
+
env = dict(os.environ)
|
|
51
|
+
env["PYTHONPATH"] = SRC + os.pathsep + env.get("PYTHONPATH", "")
|
|
52
|
+
env["CHUNMIAO_BASE_URL"] = base_url
|
|
53
|
+
if token is None:
|
|
54
|
+
env.pop("MAS_TOKEN", None)
|
|
55
|
+
else:
|
|
56
|
+
env["MAS_TOKEN"] = token
|
|
57
|
+
for k, v in (extra or {}).items():
|
|
58
|
+
if v is None:
|
|
59
|
+
env.pop(k, None)
|
|
60
|
+
else:
|
|
61
|
+
env[k] = v
|
|
62
|
+
return env
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def stdio_params(
|
|
66
|
+
base_url: str,
|
|
67
|
+
token: str | None = "test-token",
|
|
68
|
+
extra: dict | None = None,
|
|
69
|
+
) -> StdioServerParameters:
|
|
70
|
+
return StdioServerParameters(
|
|
71
|
+
command=sys.executable,
|
|
72
|
+
args=["-m", "thiter_mcp"],
|
|
73
|
+
env=server_env(base_url, token, extra),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def tool_payload(result) -> dict:
|
|
78
|
+
"""从 CallToolResult 中取结构化结果。"""
|
|
79
|
+
if result.structured_content:
|
|
80
|
+
return result.structured_content
|
|
81
|
+
for block in result.content:
|
|
82
|
+
if getattr(block, "type", None) == "text":
|
|
83
|
+
return json.loads(block.text)
|
|
84
|
+
raise AssertionError(f"工具无有效返回: {result}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_stdio(base_url: str) -> None:
|
|
88
|
+
print("\n[1] stdio 传输测试")
|
|
89
|
+
params = stdio_params(base_url)
|
|
90
|
+
async with stdio_client(params) as (read, write):
|
|
91
|
+
async with ClientSession(read, write) as session:
|
|
92
|
+
await session.initialize()
|
|
93
|
+
|
|
94
|
+
tools = await session.list_tools()
|
|
95
|
+
names = {t.name for t in tools.tools}
|
|
96
|
+
check("list_tools 暴露 chat 与 list_models", {"chat", "list_models"} <= names, str(names))
|
|
97
|
+
|
|
98
|
+
chat_tool = next(t for t in tools.tools if t.name == "chat")
|
|
99
|
+
schema = chat_tool.input_schema
|
|
100
|
+
check(
|
|
101
|
+
"chat 入参 messages 为必填",
|
|
102
|
+
"messages" in schema.get("properties", {})
|
|
103
|
+
and "messages" in schema.get("required", []),
|
|
104
|
+
json.dumps(schema, ensure_ascii=False),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 正常调用
|
|
108
|
+
r = await session.call_tool(
|
|
109
|
+
"chat",
|
|
110
|
+
{
|
|
111
|
+
"messages": [{"role": "user", "content": "你好"}],
|
|
112
|
+
"temperature": 0.7,
|
|
113
|
+
"max_tokens": 100,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
check("chat 正常调用无错误", not r.is_error, str(r.content))
|
|
117
|
+
payload = tool_payload(r)
|
|
118
|
+
check(
|
|
119
|
+
"chat 返回 stub 内容",
|
|
120
|
+
payload.get("content") == "stub回复[你好]",
|
|
121
|
+
json.dumps(payload, ensure_ascii=False),
|
|
122
|
+
)
|
|
123
|
+
check(
|
|
124
|
+
"chat 返回 usage/finish_reason",
|
|
125
|
+
payload.get("finish_reason") == "stop"
|
|
126
|
+
and (payload.get("usage") or {}).get("total_tokens") == 33,
|
|
127
|
+
json.dumps(payload, ensure_ascii=False),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# 上游 500 错误路径
|
|
131
|
+
r = await session.call_tool(
|
|
132
|
+
"chat", {"messages": [{"role": "user", "content": "请触发错误"}]}
|
|
133
|
+
)
|
|
134
|
+
check("chat 上游报错时 isError=True", r.is_error, str(r.content))
|
|
135
|
+
check(
|
|
136
|
+
"chat 错误信息含 500 与上游信息",
|
|
137
|
+
"500" in str(r.content) and "stub upstream error" in str(r.content),
|
|
138
|
+
str(r.content),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 错误令牌路径
|
|
142
|
+
r = await session.call_tool(
|
|
143
|
+
"list_models",
|
|
144
|
+
)
|
|
145
|
+
check("list_models 正常返回", not r.is_error, str(r.content))
|
|
146
|
+
models = tool_payload(r)
|
|
147
|
+
ids = [m.get("id") for m in models.get("data", [])]
|
|
148
|
+
check(
|
|
149
|
+
"list_models 返回 stub 模型列表",
|
|
150
|
+
"qwen/qwen3.5-35b-a3b" in ids and "qwen/qwen-plus" in ids,
|
|
151
|
+
json.dumps(models, ensure_ascii=False),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_stdio_bad_token(base_url: str) -> None:
|
|
156
|
+
print("\n[2] stdio 错误令牌测试(上游 401)")
|
|
157
|
+
params = stdio_params(base_url, token="wrong-token")
|
|
158
|
+
async with stdio_client(params) as (read, write):
|
|
159
|
+
async with ClientSession(read, write) as session:
|
|
160
|
+
await session.initialize()
|
|
161
|
+
r = await session.call_tool(
|
|
162
|
+
"chat", {"messages": [{"role": "user", "content": "hi"}]}
|
|
163
|
+
)
|
|
164
|
+
check("错误令牌时 isError=True", r.is_error, str(r.content))
|
|
165
|
+
check(
|
|
166
|
+
"错误信息含 HTTP 401",
|
|
167
|
+
"401" in str(r.content),
|
|
168
|
+
str(r.content),
|
|
169
|
+
)
|
|
170
|
+
check(
|
|
171
|
+
"错误信息不泄露令牌",
|
|
172
|
+
"wrong-token" not in str(r.content),
|
|
173
|
+
str(r.content),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def test_stdio_no_token(base_url: str) -> None:
|
|
178
|
+
print("\n[3] stdio 缺 MAS_TOKEN 测试")
|
|
179
|
+
params = stdio_params(base_url, token=None)
|
|
180
|
+
async with stdio_client(params) as (read, write):
|
|
181
|
+
async with ClientSession(read, write) as session:
|
|
182
|
+
await session.initialize()
|
|
183
|
+
r = await session.call_tool(
|
|
184
|
+
"chat", {"messages": [{"role": "user", "content": "hi"}]}
|
|
185
|
+
)
|
|
186
|
+
check("缺令牌时 isError=True", r.is_error, str(r.content))
|
|
187
|
+
check(
|
|
188
|
+
"缺令牌提示配置 MAS_TOKEN",
|
|
189
|
+
"MAS_TOKEN" in str(r.content),
|
|
190
|
+
str(r.content),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def test_streamable_http(base_url: str) -> None:
|
|
195
|
+
print("\n[4] streamable-http 传输测试(自托管模式)")
|
|
196
|
+
port = free_port()
|
|
197
|
+
env = server_env(base_url)
|
|
198
|
+
proc = await asyncio.create_subprocess_exec(
|
|
199
|
+
sys.executable,
|
|
200
|
+
"-m",
|
|
201
|
+
"thiter_mcp",
|
|
202
|
+
"--transport",
|
|
203
|
+
"streamable-http",
|
|
204
|
+
"--host",
|
|
205
|
+
"127.0.0.1",
|
|
206
|
+
"--port",
|
|
207
|
+
str(port),
|
|
208
|
+
env=env,
|
|
209
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
210
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
url = f"http://127.0.0.1:{port}/mcp"
|
|
214
|
+
# 等待服务就绪
|
|
215
|
+
for _ in range(50):
|
|
216
|
+
try:
|
|
217
|
+
async with streamable_http_client(url) as (read, write):
|
|
218
|
+
async with ClientSession(read, write) as session:
|
|
219
|
+
await session.initialize()
|
|
220
|
+
tools = await session.list_tools()
|
|
221
|
+
check(
|
|
222
|
+
"HTTP 模式暴露 chat 与 list_models",
|
|
223
|
+
{"chat", "list_models"} <= {t.name for t in tools.tools},
|
|
224
|
+
)
|
|
225
|
+
r = await session.call_tool(
|
|
226
|
+
"chat", {"messages": [{"role": "user", "content": "HTTP测试"}]}
|
|
227
|
+
)
|
|
228
|
+
check("HTTP 模式 chat 调用成功", not r.is_error, str(r.content))
|
|
229
|
+
payload = tool_payload(r)
|
|
230
|
+
check(
|
|
231
|
+
"HTTP 模式返回 stub 内容",
|
|
232
|
+
payload.get("content") == "stub回复[HTTP测试]",
|
|
233
|
+
json.dumps(payload, ensure_ascii=False),
|
|
234
|
+
)
|
|
235
|
+
break
|
|
236
|
+
except Exception:
|
|
237
|
+
await asyncio.sleep(0.2)
|
|
238
|
+
else:
|
|
239
|
+
check("HTTP 服务可就绪", False, "连接超时")
|
|
240
|
+
finally:
|
|
241
|
+
proc.terminate()
|
|
242
|
+
await proc.wait()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def test_stdio_api_key(base_url: str) -> None:
|
|
246
|
+
print("\n[5] 访问密钥测试(CHUNMIAO_API_KEY=secret-123)")
|
|
247
|
+
extra = {"CHUNMIAO_API_KEY": "secret-123"}
|
|
248
|
+
|
|
249
|
+
# 缺 key
|
|
250
|
+
params = stdio_params(base_url, extra=extra)
|
|
251
|
+
async with stdio_client(params) as (read, write):
|
|
252
|
+
async with ClientSession(read, write) as session:
|
|
253
|
+
await session.initialize()
|
|
254
|
+
r = await session.call_tool(
|
|
255
|
+
"chat", {"messages": [{"role": "user", "content": "hi"}]}
|
|
256
|
+
)
|
|
257
|
+
check("缺 key 时 isError=True", r.is_error, str(r.content))
|
|
258
|
+
check(
|
|
259
|
+
"缺 key 提示提供 key",
|
|
260
|
+
"key" in str(r.content),
|
|
261
|
+
str(r.content),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# 错误 key
|
|
265
|
+
params = stdio_params(base_url, extra=extra)
|
|
266
|
+
async with stdio_client(params) as (read, write):
|
|
267
|
+
async with ClientSession(read, write) as session:
|
|
268
|
+
await session.initialize()
|
|
269
|
+
r = await session.call_tool(
|
|
270
|
+
"chat",
|
|
271
|
+
{"messages": [{"role": "user", "content": "hi"}], "key": "wrong"},
|
|
272
|
+
)
|
|
273
|
+
check("错误 key 时 isError=True", r.is_error, str(r.content))
|
|
274
|
+
|
|
275
|
+
# 正确 key
|
|
276
|
+
params = stdio_params(base_url, extra=extra)
|
|
277
|
+
async with stdio_client(params) as (read, write):
|
|
278
|
+
async with ClientSession(read, write) as session:
|
|
279
|
+
await session.initialize()
|
|
280
|
+
r = await session.call_tool(
|
|
281
|
+
"chat",
|
|
282
|
+
{
|
|
283
|
+
"messages": [{"role": "user", "content": "带密钥"}],
|
|
284
|
+
"key": "secret-123",
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
check("正确 key 时调用成功", not r.is_error, str(r.content))
|
|
288
|
+
payload = tool_payload(r)
|
|
289
|
+
check(
|
|
290
|
+
"正确 key 返回 stub 内容",
|
|
291
|
+
payload.get("content") == "stub回复[带密钥]",
|
|
292
|
+
json.dumps(payload, ensure_ascii=False),
|
|
293
|
+
)
|
|
294
|
+
r = await session.call_tool("list_models", {"key": "secret-123"})
|
|
295
|
+
check("list_models 正确 key 成功", not r.is_error, str(r.content))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def main() -> None:
|
|
299
|
+
stub, port = start_stub()
|
|
300
|
+
threading.Thread(target=stub.serve_forever, daemon=True).start()
|
|
301
|
+
base_url = f"http://127.0.0.1:{port}/v1"
|
|
302
|
+
try:
|
|
303
|
+
await test_stdio(base_url)
|
|
304
|
+
await test_stdio_bad_token(base_url)
|
|
305
|
+
await test_stdio_no_token(base_url)
|
|
306
|
+
await test_streamable_http(base_url)
|
|
307
|
+
await test_stdio_api_key(base_url)
|
|
308
|
+
finally:
|
|
309
|
+
stub.shutdown()
|
|
310
|
+
|
|
311
|
+
print(f"\n===== 结果: {len(PASSED)} 通过, {len(FAILED)} 失败 =====")
|
|
312
|
+
if FAILED:
|
|
313
|
+
for f in FAILED:
|
|
314
|
+
print(f" - {f}")
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""本地测试用 stub:模拟魔搭 api-inference 的 /v1 接口。
|
|
2
|
+
|
|
3
|
+
- GET /v1/models 返回模型列表(校验 Bearer test-token)
|
|
4
|
+
- POST /v1/chat/completions 返回 OpenAI 格式补全;消息含「触发错误」时返回 500
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
|
|
12
|
+
TOKEN = "test-token"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StubHandler(BaseHTTPRequestHandler):
|
|
16
|
+
def _json(self, code: int, obj: dict) -> None:
|
|
17
|
+
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
18
|
+
self.send_response(code)
|
|
19
|
+
self.send_header("Content-Type", "application/json")
|
|
20
|
+
self.send_header("Content-Length", str(len(body)))
|
|
21
|
+
self.end_headers()
|
|
22
|
+
self.wfile.write(body)
|
|
23
|
+
|
|
24
|
+
def _authorized(self) -> bool:
|
|
25
|
+
return self.headers.get("Authorization") == f"Bearer {TOKEN}"
|
|
26
|
+
|
|
27
|
+
def do_GET(self) -> None:
|
|
28
|
+
if not self._authorized():
|
|
29
|
+
self._json(401, {"error": {"message": "invalid token"}})
|
|
30
|
+
return
|
|
31
|
+
if self.path == "/v1/models":
|
|
32
|
+
self._json(
|
|
33
|
+
200,
|
|
34
|
+
{
|
|
35
|
+
"object": "list",
|
|
36
|
+
"data": [
|
|
37
|
+
{
|
|
38
|
+
"id": "qwen/qwen3.5-35b-a3b",
|
|
39
|
+
"object": "model",
|
|
40
|
+
"owned_by": "modelscope",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "qwen/qwen-plus",
|
|
44
|
+
"object": "model",
|
|
45
|
+
"owned_by": "modelscope",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
self._json(404, {"error": {"message": "not found"}})
|
|
52
|
+
|
|
53
|
+
def do_POST(self) -> None:
|
|
54
|
+
if not self._authorized():
|
|
55
|
+
self._json(401, {"error": {"message": "invalid token"}})
|
|
56
|
+
return
|
|
57
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
58
|
+
try:
|
|
59
|
+
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
self._json(400, {"error": {"message": "bad json"}})
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if self.path != "/v1/chat/completions":
|
|
65
|
+
self._json(404, {"error": {"message": "not found"}})
|
|
66
|
+
return
|
|
67
|
+
if payload.get("stream"):
|
|
68
|
+
self._json(400, {"error": {"message": "stub does not support stream"}})
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
messages = payload.get("messages") or []
|
|
72
|
+
last_user = next(
|
|
73
|
+
(m.get("content", "") for m in reversed(messages) if m.get("role") == "user"),
|
|
74
|
+
"",
|
|
75
|
+
)
|
|
76
|
+
if "触发错误" in last_user:
|
|
77
|
+
self._json(500, {"error": {"message": "stub upstream error"}})
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
self._json(
|
|
81
|
+
200,
|
|
82
|
+
{
|
|
83
|
+
"id": "chatcmpl-stub",
|
|
84
|
+
"object": "chat.completion",
|
|
85
|
+
"model": payload.get("model", "unknown"),
|
|
86
|
+
"choices": [
|
|
87
|
+
{
|
|
88
|
+
"index": 0,
|
|
89
|
+
"message": {
|
|
90
|
+
"role": "assistant",
|
|
91
|
+
"content": f"stub回复[{last_user}]",
|
|
92
|
+
},
|
|
93
|
+
"finish_reason": "stop",
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"usage": {
|
|
97
|
+
"prompt_tokens": 11,
|
|
98
|
+
"completion_tokens": 22,
|
|
99
|
+
"total_tokens": 33,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def log_message(self, *args) -> None: # 静默
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def start_stub() -> tuple[ThreadingHTTPServer, int]:
|
|
109
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), StubHandler)
|
|
110
|
+
return server, server.server_address[1]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
server, port = start_stub()
|
|
115
|
+
print(f"stub listening on http://127.0.0.1:{port}/v1")
|
|
116
|
+
server.serve_forever()
|