mcp-vos-auth 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.
- mcp_vos_auth-0.1.0/.gitignore +8 -0
- mcp_vos_auth-0.1.0/LICENSE +21 -0
- mcp_vos_auth-0.1.0/PKG-INFO +191 -0
- mcp_vos_auth-0.1.0/README.md +166 -0
- mcp_vos_auth-0.1.0/pyproject.toml +44 -0
- mcp_vos_auth-0.1.0/src/mcp_vos_auth/__init__.py +18 -0
- mcp_vos_auth-0.1.0/src/mcp_vos_auth/middleware.py +169 -0
- mcp_vos_auth-0.1.0/tests/test_middleware.py +90 -0
- mcp_vos_auth-0.1.0/uv.lock +1804 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinehoo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-vos-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Token validation middleware for FastMCP tools
|
|
5
|
+
Project-URL: Repository, https://github.com/vinehoo/mcp-vos-auth
|
|
6
|
+
Author: vber
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: authentication,fastmcp,mcp,middleware
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: fastmcp<4,>=2.11
|
|
19
|
+
Requires-Dist: httpx<1,>=0.27
|
|
20
|
+
Requires-Dist: pip>=26.1.2
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# mcp-vos-auth
|
|
27
|
+
|
|
28
|
+
用于 FastMCP 的工具调用 token 校验 middleware。它在工具执行前读取工具名、参数和
|
|
29
|
+
`token` 参数,通过 Vinehoo `check_token` 接口验证 token;验证失败时工具返回“无权限”。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
### 通过 PyPI 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mcp-vos-auth
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 不通过 pip 使用
|
|
40
|
+
|
|
41
|
+
无论采用下面哪种方式,目标项目都必须提供本包的运行依赖:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install fastmcp httpx
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
#### 方式一:设置 `PYTHONPATH`(本地多项目开发推荐)
|
|
48
|
+
|
|
49
|
+
将本项目的 `src` 目录加入 Python 模块搜索路径:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export PYTHONPATH="/path/to/mcp-vos-auth/src:$PYTHONPATH"
|
|
53
|
+
python your_server.py
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
例如本仓库位于 `/Users/vber/Documents/codes/mcp-vos-auth` 时:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export PYTHONPATH="/Users/vber/Documents/codes/mcp-vos-auth/src:$PYTHONPATH"
|
|
60
|
+
python your_server.py
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
目标项目不需要修改导入语句:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from mcp_vos_auth import register_token_auth
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`PYTHONPATH` 只对当前终端会话有效。需要长期使用时,可将 `export` 命令加入 shell 配置,
|
|
70
|
+
或写入项目的启动脚本。
|
|
71
|
+
|
|
72
|
+
#### 方式二:在程序入口添加源码路径(仅建议临时调试)
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import sys
|
|
76
|
+
|
|
77
|
+
sys.path.insert(0, "/path/to/mcp-vos-auth/src")
|
|
78
|
+
|
|
79
|
+
from mcp_vos_auth import register_token_auth
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
这种方式会把本机绝对路径写入业务代码,不适合多人协作或生产部署。
|
|
83
|
+
|
|
84
|
+
#### 方式三:将包源码复制到目标项目
|
|
85
|
+
|
|
86
|
+
把本仓库的 `src/mcp_vos_auth` 目录完整复制到目标项目根目录:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
your-project/
|
|
90
|
+
├── your_server.py
|
|
91
|
+
└── mcp_vos_auth/
|
|
92
|
+
├── __init__.py
|
|
93
|
+
└── middleware.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
然后正常导入:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from mcp_vos_auth import register_token_auth
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
该方式部署简单,但复制后的源码不会自动同步本仓库后续的修复和升级,需要自行维护版本。
|
|
103
|
+
|
|
104
|
+
以上三种方式都不需要安装 `mcp-vos-auth` 本身。对于本地联调,优先使用 `PYTHONPATH`;
|
|
105
|
+
对于需要固定源码且不使用包管理的部署,可复制 `mcp_vos_auth` 目录。
|
|
106
|
+
|
|
107
|
+
## 使用
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from fastmcp import FastMCP
|
|
111
|
+
from mcp_vos_auth import register_token_auth
|
|
112
|
+
|
|
113
|
+
mcp = FastMCP("protected-server")
|
|
114
|
+
|
|
115
|
+
# 自动创建 middleware 并注册到当前 FastMCP 实例。
|
|
116
|
+
register_token_auth(mcp, platform="app")
|
|
117
|
+
|
|
118
|
+
@mcp.tool
|
|
119
|
+
def get_order(order_id: int, token: str) -> dict:
|
|
120
|
+
return {"order_id": order_id}
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
mcp.run()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
不传 `platform` 时校验中台用户:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
register_token_auth(mcp)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
默认保护所有工具。可排除公开工具,或只保护指定工具:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
register_token_auth(mcp, excluded_tools={"health", "version"})
|
|
136
|
+
# 或
|
|
137
|
+
register_token_auth(mcp, protected_tools={"get_order", "delete_order"})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
其他配置:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
register_token_auth(
|
|
144
|
+
mcp,
|
|
145
|
+
token_parameter="access_token",
|
|
146
|
+
timeout=3.0,
|
|
147
|
+
unauthorized_message="无权限",
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
如果工具使用 Pydantic 模型等嵌套参数,可以用点路径指定 token 的位置:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
@mcp.tool
|
|
155
|
+
async def submit_feedback(params: SubmitFeedbackInput) -> str:
|
|
156
|
+
...
|
|
157
|
+
|
|
158
|
+
register_token_auth(mcp, token_parameter="params.token")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`on_validation` 回调可获取工具名、完整工具参数和标准化校验结果。请勿在日志中记录完整
|
|
162
|
+
token:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
async def audit(tool_name, arguments, result):
|
|
166
|
+
print(tool_name, result.valid, result.data)
|
|
167
|
+
|
|
168
|
+
register_token_auth(mcp, on_validation=audit)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
安全策略为 fail-closed:缺少 token、HTTP 401、`error_code != 0`、响应格式异常、网络错误
|
|
172
|
+
或超时都会阻止工具执行。token 参数仍会传给工具函数;如工具不需要使用它,可以保留该
|
|
173
|
+
参数但不要记录或返回它。
|
|
174
|
+
|
|
175
|
+
## 构建和发布
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
python -m pip install build twine
|
|
179
|
+
python -m build
|
|
180
|
+
twine check dist/*
|
|
181
|
+
twine upload dist/*
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
发布前请在 `pyproject.toml` 中确认包名、作者、Repository URL 和版本号。
|
|
185
|
+
|
|
186
|
+
## 测试
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
python -m pip install -e '.[test]'
|
|
190
|
+
pytest
|
|
191
|
+
```
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# mcp-vos-auth
|
|
2
|
+
|
|
3
|
+
用于 FastMCP 的工具调用 token 校验 middleware。它在工具执行前读取工具名、参数和
|
|
4
|
+
`token` 参数,通过 Vinehoo `check_token` 接口验证 token;验证失败时工具返回“无权限”。
|
|
5
|
+
|
|
6
|
+
## 安装
|
|
7
|
+
|
|
8
|
+
### 通过 PyPI 安装
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install mcp-vos-auth
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### 不通过 pip 使用
|
|
15
|
+
|
|
16
|
+
无论采用下面哪种方式,目标项目都必须提供本包的运行依赖:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install fastmcp httpx
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
#### 方式一:设置 `PYTHONPATH`(本地多项目开发推荐)
|
|
23
|
+
|
|
24
|
+
将本项目的 `src` 目录加入 Python 模块搜索路径:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export PYTHONPATH="/path/to/mcp-vos-auth/src:$PYTHONPATH"
|
|
28
|
+
python your_server.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
例如本仓库位于 `/Users/vber/Documents/codes/mcp-vos-auth` 时:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export PYTHONPATH="/Users/vber/Documents/codes/mcp-vos-auth/src:$PYTHONPATH"
|
|
35
|
+
python your_server.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
目标项目不需要修改导入语句:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from mcp_vos_auth import register_token_auth
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`PYTHONPATH` 只对当前终端会话有效。需要长期使用时,可将 `export` 命令加入 shell 配置,
|
|
45
|
+
或写入项目的启动脚本。
|
|
46
|
+
|
|
47
|
+
#### 方式二:在程序入口添加源码路径(仅建议临时调试)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
sys.path.insert(0, "/path/to/mcp-vos-auth/src")
|
|
53
|
+
|
|
54
|
+
from mcp_vos_auth import register_token_auth
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
这种方式会把本机绝对路径写入业务代码,不适合多人协作或生产部署。
|
|
58
|
+
|
|
59
|
+
#### 方式三:将包源码复制到目标项目
|
|
60
|
+
|
|
61
|
+
把本仓库的 `src/mcp_vos_auth` 目录完整复制到目标项目根目录:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
your-project/
|
|
65
|
+
├── your_server.py
|
|
66
|
+
└── mcp_vos_auth/
|
|
67
|
+
├── __init__.py
|
|
68
|
+
└── middleware.py
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
然后正常导入:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from mcp_vos_auth import register_token_auth
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
该方式部署简单,但复制后的源码不会自动同步本仓库后续的修复和升级,需要自行维护版本。
|
|
78
|
+
|
|
79
|
+
以上三种方式都不需要安装 `mcp-vos-auth` 本身。对于本地联调,优先使用 `PYTHONPATH`;
|
|
80
|
+
对于需要固定源码且不使用包管理的部署,可复制 `mcp_vos_auth` 目录。
|
|
81
|
+
|
|
82
|
+
## 使用
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from fastmcp import FastMCP
|
|
86
|
+
from mcp_vos_auth import register_token_auth
|
|
87
|
+
|
|
88
|
+
mcp = FastMCP("protected-server")
|
|
89
|
+
|
|
90
|
+
# 自动创建 middleware 并注册到当前 FastMCP 实例。
|
|
91
|
+
register_token_auth(mcp, platform="app")
|
|
92
|
+
|
|
93
|
+
@mcp.tool
|
|
94
|
+
def get_order(order_id: int, token: str) -> dict:
|
|
95
|
+
return {"order_id": order_id}
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
mcp.run()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
不传 `platform` 时校验中台用户:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
register_token_auth(mcp)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
默认保护所有工具。可排除公开工具,或只保护指定工具:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
register_token_auth(mcp, excluded_tools={"health", "version"})
|
|
111
|
+
# 或
|
|
112
|
+
register_token_auth(mcp, protected_tools={"get_order", "delete_order"})
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
其他配置:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
register_token_auth(
|
|
119
|
+
mcp,
|
|
120
|
+
token_parameter="access_token",
|
|
121
|
+
timeout=3.0,
|
|
122
|
+
unauthorized_message="无权限",
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
如果工具使用 Pydantic 模型等嵌套参数,可以用点路径指定 token 的位置:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
@mcp.tool
|
|
130
|
+
async def submit_feedback(params: SubmitFeedbackInput) -> str:
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
register_token_auth(mcp, token_parameter="params.token")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`on_validation` 回调可获取工具名、完整工具参数和标准化校验结果。请勿在日志中记录完整
|
|
137
|
+
token:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
async def audit(tool_name, arguments, result):
|
|
141
|
+
print(tool_name, result.valid, result.data)
|
|
142
|
+
|
|
143
|
+
register_token_auth(mcp, on_validation=audit)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
安全策略为 fail-closed:缺少 token、HTTP 401、`error_code != 0`、响应格式异常、网络错误
|
|
147
|
+
或超时都会阻止工具执行。token 参数仍会传给工具函数;如工具不需要使用它,可以保留该
|
|
148
|
+
参数但不要记录或返回它。
|
|
149
|
+
|
|
150
|
+
## 构建和发布
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
python -m pip install build twine
|
|
154
|
+
python -m build
|
|
155
|
+
twine check dist/*
|
|
156
|
+
twine upload dist/*
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
发布前请在 `pyproject.toml` 中确认包名、作者、Repository URL 和版本号。
|
|
160
|
+
|
|
161
|
+
## 测试
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python -m pip install -e '.[test]'
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-vos-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Token validation middleware for FastMCP tools"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "vber" }]
|
|
13
|
+
keywords = ["fastmcp", "mcp", "middleware", "authentication"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"fastmcp>=2.11,<4",
|
|
25
|
+
"httpx>=0.27,<1",
|
|
26
|
+
"pip>=26.1.2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = [
|
|
31
|
+
"pytest>=8",
|
|
32
|
+
"pytest-asyncio>=0.24",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/vinehoo/mcp-vos-auth"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/mcp_vos_auth"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""FastMCP token authentication middleware."""
|
|
2
|
+
|
|
3
|
+
from .middleware import (
|
|
4
|
+
DEFAULT_CHECK_TOKEN_URL,
|
|
5
|
+
TokenAuthMiddleware,
|
|
6
|
+
TokenValidationResult,
|
|
7
|
+
register_token_auth,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DEFAULT_CHECK_TOKEN_URL",
|
|
12
|
+
"TokenAuthMiddleware",
|
|
13
|
+
"TokenValidationResult",
|
|
14
|
+
"register_token_auth",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Vinehoo token validation middleware for FastMCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable, Collection
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from fastmcp.exceptions import ToolError
|
|
12
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
13
|
+
|
|
14
|
+
DEFAULT_CHECK_TOKEN_URL = (
|
|
15
|
+
"https://callback.vinehoo.com/go-wechat/wechat/v3/other/check_token"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class TokenValidationResult:
|
|
23
|
+
"""Normalized result returned by the token validation endpoint."""
|
|
24
|
+
|
|
25
|
+
valid: bool
|
|
26
|
+
data: dict[str, Any] | None = None
|
|
27
|
+
error_code: int | None = None
|
|
28
|
+
error_message: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ValidationCallback = Callable[
|
|
32
|
+
[str, dict[str, Any], TokenValidationResult], Awaitable[None] | None
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TokenAuthMiddleware(Middleware):
|
|
37
|
+
"""Validate a token argument before executing a FastMCP tool.
|
|
38
|
+
|
|
39
|
+
Validation is fail-closed: missing tokens, invalid responses, timeouts, and
|
|
40
|
+
validator service failures all deny the tool call.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
check_url: str = DEFAULT_CHECK_TOKEN_URL,
|
|
47
|
+
platform: str | None = None,
|
|
48
|
+
token_parameter: str = "token",
|
|
49
|
+
timeout: float = 5.0,
|
|
50
|
+
unauthorized_message: str = "无权限",
|
|
51
|
+
protected_tools: Collection[str] | None = None,
|
|
52
|
+
excluded_tools: Collection[str] = (),
|
|
53
|
+
client: httpx.AsyncClient | None = None,
|
|
54
|
+
on_validation: ValidationCallback | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if timeout <= 0:
|
|
57
|
+
raise ValueError("timeout must be greater than zero")
|
|
58
|
+
if protected_tools is not None and excluded_tools:
|
|
59
|
+
raise ValueError("protected_tools and excluded_tools cannot be used together")
|
|
60
|
+
|
|
61
|
+
self.check_url = check_url
|
|
62
|
+
self.platform = platform
|
|
63
|
+
self.token_parameter = token_parameter
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self.unauthorized_message = unauthorized_message
|
|
66
|
+
self.protected_tools = frozenset(protected_tools) if protected_tools else None
|
|
67
|
+
self.excluded_tools = frozenset(excluded_tools)
|
|
68
|
+
self._client = client
|
|
69
|
+
self.on_validation = on_validation
|
|
70
|
+
|
|
71
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next: Any) -> Any:
|
|
72
|
+
tool_name = context.message.name
|
|
73
|
+
arguments = dict(context.message.arguments or {})
|
|
74
|
+
|
|
75
|
+
if not self._is_protected(tool_name):
|
|
76
|
+
return await call_next(context)
|
|
77
|
+
|
|
78
|
+
token = self._get_argument(arguments, self.token_parameter)
|
|
79
|
+
if not isinstance(token, str) or not token.strip():
|
|
80
|
+
await self._notify(tool_name, arguments, TokenValidationResult(
|
|
81
|
+
valid=False, error_message="missing token"
|
|
82
|
+
))
|
|
83
|
+
raise ToolError(self.unauthorized_message)
|
|
84
|
+
|
|
85
|
+
result = await self.validate_token(token.strip())
|
|
86
|
+
await self._notify(tool_name, arguments, result)
|
|
87
|
+
if not result.valid:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"Denied FastMCP tool call: tool=%s error_code=%s error=%s",
|
|
90
|
+
tool_name,
|
|
91
|
+
result.error_code,
|
|
92
|
+
result.error_message,
|
|
93
|
+
)
|
|
94
|
+
raise ToolError(self.unauthorized_message)
|
|
95
|
+
|
|
96
|
+
return await call_next(context)
|
|
97
|
+
|
|
98
|
+
def _is_protected(self, tool_name: str) -> bool:
|
|
99
|
+
if self.protected_tools is not None:
|
|
100
|
+
return tool_name in self.protected_tools
|
|
101
|
+
return tool_name not in self.excluded_tools
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _get_argument(arguments: dict[str, Any], path: str) -> Any:
|
|
105
|
+
"""Read an argument using a dotted path such as ``params.token``."""
|
|
106
|
+
|
|
107
|
+
value: Any = arguments
|
|
108
|
+
for part in path.split("."):
|
|
109
|
+
if not part or not isinstance(value, dict):
|
|
110
|
+
return None
|
|
111
|
+
value = value.get(part)
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
async def validate_token(self, token: str) -> TokenValidationResult:
|
|
115
|
+
params = {"platform": self.platform} if self.platform is not None else None
|
|
116
|
+
headers = {"VINEHOO-Authorization": f"Bearer {token}"}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if self._client is not None:
|
|
120
|
+
response = await self._client.get(
|
|
121
|
+
self.check_url, headers=headers, params=params, timeout=self.timeout
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
async with httpx.AsyncClient() as client:
|
|
125
|
+
response = await client.get(
|
|
126
|
+
self.check_url, headers=headers, params=params, timeout=self.timeout
|
|
127
|
+
)
|
|
128
|
+
except httpx.HTTPError as exc:
|
|
129
|
+
logger.exception("Token validation request failed")
|
|
130
|
+
return TokenValidationResult(valid=False, error_message=str(exc))
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
payload = response.json()
|
|
134
|
+
except ValueError:
|
|
135
|
+
return TokenValidationResult(
|
|
136
|
+
valid=False,
|
|
137
|
+
error_message=f"validator returned non-JSON response ({response.status_code})",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not isinstance(payload, dict):
|
|
141
|
+
return TokenValidationResult(valid=False, error_message="invalid validator response")
|
|
142
|
+
|
|
143
|
+
error_code = payload.get("error_code")
|
|
144
|
+
error_message = payload.get("error_msg")
|
|
145
|
+
data = payload.get("data")
|
|
146
|
+
valid = response.status_code == 200 and error_code == 0 and isinstance(data, dict)
|
|
147
|
+
return TokenValidationResult(
|
|
148
|
+
valid=valid,
|
|
149
|
+
data=data if isinstance(data, dict) else None,
|
|
150
|
+
error_code=error_code if isinstance(error_code, int) else None,
|
|
151
|
+
error_message=error_message if isinstance(error_message, str) else None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def _notify(
|
|
155
|
+
self, tool_name: str, arguments: dict[str, Any], result: TokenValidationResult
|
|
156
|
+
) -> None:
|
|
157
|
+
if self.on_validation is None:
|
|
158
|
+
return
|
|
159
|
+
callback_result = self.on_validation(tool_name, arguments, result)
|
|
160
|
+
if callback_result is not None:
|
|
161
|
+
await callback_result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def register_token_auth(server: Any, **kwargs: Any) -> TokenAuthMiddleware:
|
|
165
|
+
"""Create and register token authentication middleware on a FastMCP server."""
|
|
166
|
+
|
|
167
|
+
middleware = TokenAuthMiddleware(**kwargs)
|
|
168
|
+
server.add_middleware(middleware)
|
|
169
|
+
return middleware
|