sdpy-kit 0.1.0__py3-none-any.whl
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.
- kit/__init__.py +68 -0
- kit/ai/__init__.py +47 -0
- kit/ai/base_service.py +94 -0
- kit/ai/chat_service.py +110 -0
- kit/ai/embedding_service.py +133 -0
- kit/ai/exceptions.py +57 -0
- kit/ai/failover.py +244 -0
- kit/ai/image_service.py +99 -0
- kit/ai/model_builder.py +119 -0
- kit/ai/model_client.py +214 -0
- kit/ai/pool.py +411 -0
- kit/ai/profiles.py +373 -0
- kit/ai/rerank_service.py +93 -0
- kit/ai/runner.py +580 -0
- kit/ai/types.py +28 -0
- kit/common_kit.py +221 -0
- kit/config/__init__.py +44 -0
- kit/config/kit_config.py +147 -0
- kit/config/log_config.py +134 -0
- kit/db/__init__.py +70 -0
- kit/db/base_db_kit.py +406 -0
- kit/db/crud.py +361 -0
- kit/db/engine.py +230 -0
- kit/db/exceptions.py +34 -0
- kit/db/models/__init__.py +33 -0
- kit/db/models/base.py +56 -0
- kit/db/mysql_kit.py +89 -0
- kit/db/pg_kit.py +87 -0
- kit/db/session.py +85 -0
- kit/db/sqlite_kit.py +284 -0
- kit/db/transaction.py +109 -0
- kit/doc_kit.py +1502 -0
- kit/file_kit.py +429 -0
- kit/mcp_kit.py +547 -0
- kit/mineru_kit.py +918 -0
- kit/redis_kit.py +392 -0
- kit/zvec_kit.py +671 -0
- sdpy_kit-0.1.0.dist-info/METADATA +128 -0
- sdpy_kit-0.1.0.dist-info/RECORD +40 -0
- sdpy_kit-0.1.0.dist-info/WHEEL +4 -0
kit/mcp_kit.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
|
|
16
|
+
Project: sdpy
|
|
17
|
+
Author: Clark Chang
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# date: 2026-09-04
|
|
21
|
+
"""通用 MCP 客户端工具类
|
|
22
|
+
|
|
23
|
+
基于 MCP(Model Context Protocol)协议的通用客户端引擎,支持连接任意远程
|
|
24
|
+
MCP 服务器(Streamable HTTP / SSE 传输)并调用其工具。
|
|
25
|
+
|
|
26
|
+
架构(引擎 / 档案 / 密钥三层分离)::
|
|
27
|
+
|
|
28
|
+
业务代码
|
|
29
|
+
│ McpKit.call_tool(name, arguments) # 按档案调用
|
|
30
|
+
│ McpKit.call_tool(name, args, server="x") # 多服务器按名切换
|
|
31
|
+
▼
|
|
32
|
+
McpKit(本类,通用引擎,零业务逻辑)
|
|
33
|
+
├─ 档案: config/mcp_servers.json # 业务项目声明连哪些 MCP 服务器
|
|
34
|
+
└─ 密钥: 环境变量 / 项目根 .env # 档案中 token_env 指向变量名,值不入库
|
|
35
|
+
|
|
36
|
+
使用方式::
|
|
37
|
+
|
|
38
|
+
# 1) 按档案调用(默认服务器,业务项目配好档案 + .env 即用)
|
|
39
|
+
result = McpKit.call_tool(
|
|
40
|
+
"search_paper_by_title", {"title": "水声通信", "size": 10}
|
|
41
|
+
)
|
|
42
|
+
print(result.data["data"])
|
|
43
|
+
|
|
44
|
+
# 2) 多服务器按名切换(server=None 时使用档案 default_server)
|
|
45
|
+
result = McpKit.call_tool("query", {...}, server="lit-service-b")
|
|
46
|
+
|
|
47
|
+
# 3) 工具发现
|
|
48
|
+
tools = McpKit.list_tools("aminer") # [{name, description}]
|
|
49
|
+
|
|
50
|
+
# 4) 直连临时服务器(不经档案)
|
|
51
|
+
result = McpKit.call_tool_url(
|
|
52
|
+
"https://example.com/mcp", "query", {...}, token="..."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 5) 异步环境 / 批量复用连接(FastAPI 等已有事件循环进程内必须用异步 API)
|
|
56
|
+
result = await McpKit.call_tool_async("query", {...}, server="aminer")
|
|
57
|
+
async with McpKit.session("aminer") as call:
|
|
58
|
+
r1 = await call("search_a", {...})
|
|
59
|
+
r2 = await call("search_b", {...})
|
|
60
|
+
|
|
61
|
+
行为规范:
|
|
62
|
+
- 业务响应 ``success=false``(如付费拦截"余额不足")抛 McpKitError,
|
|
63
|
+
detail 携带原始响应(msg / code / log_id),便于上层诊断。
|
|
64
|
+
- 档案配置 fallback_url 时,主端点连接失败自动回退(仅传输层失败回退,
|
|
65
|
+
业务失败不回退)。
|
|
66
|
+
- 返回 content 非 JSON 文本时降级为原始文本放入 McpToolResult.data。
|
|
67
|
+
- 同步 API 内部使用 asyncio.run,禁止在已有事件循环的进程(如 FastAPI
|
|
68
|
+
请求处理)内调用;异步场景请使用 call_tool_async / session。
|
|
69
|
+
|
|
70
|
+
设计约定:
|
|
71
|
+
- 无内部可变状态,@classmethod 风格,线程安全(与 MinerUKit 同哲学)。
|
|
72
|
+
- 短连接为主(每次调用独立握手,实测约 1s);高频批量请用 session() 复用。
|
|
73
|
+
- 零业务逻辑:不感知任何具体 MCP 服务(如文献查询)的业务编排。
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
import asyncio
|
|
77
|
+
import json
|
|
78
|
+
import os
|
|
79
|
+
import time
|
|
80
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
81
|
+
from contextlib import asynccontextmanager
|
|
82
|
+
from dataclasses import dataclass
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
from typing import Any
|
|
85
|
+
|
|
86
|
+
from fastmcp import Client
|
|
87
|
+
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
88
|
+
|
|
89
|
+
from kit.config.kit_config import get_settings
|
|
90
|
+
from kit.config.log_config import get_default_logger
|
|
91
|
+
|
|
92
|
+
logger = get_default_logger(__name__)
|
|
93
|
+
|
|
94
|
+
# 传输类型常量
|
|
95
|
+
_TRANSPORT_STREAMABLE: str = "streamable-http"
|
|
96
|
+
_TRANSPORT_SSE: str = "sse"
|
|
97
|
+
_VALID_TRANSPORTS: tuple[str, ...] = (_TRANSPORT_STREAMABLE, _TRANSPORT_SSE)
|
|
98
|
+
|
|
99
|
+
# 文件缺失报错时附带的最小可用模板(token_env 为 token 所在环境变量名)
|
|
100
|
+
_MINIMAL_SERVERS_JSON: str = (
|
|
101
|
+
'{"default_server": "aminer", "servers": {"aminer": '
|
|
102
|
+
'{"transport": "streamable-http", "url": "https://mcp.aminer.cn/mcp", '
|
|
103
|
+
'"token_env": "AMINER_TOKEN", "timeout": 60}}}'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class McpKitError(Exception):
|
|
108
|
+
"""MCP 客户端异常(档案缺失 / 密钥未配置 / 连接失败 / 业务失败)
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
detail: 诊断详情。业务失败时为服务端原始响应 dict(如
|
|
112
|
+
``{"success": false, "msg": "余额不足,请充值"}``);
|
|
113
|
+
连接失败时为最后一次传输层错误字符串。
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, message: str, *, detail: Any = None) -> None:
|
|
117
|
+
super().__init__(message)
|
|
118
|
+
self.detail = detail
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class McpToolResult:
|
|
123
|
+
"""单次工具调用结果
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
server: 服务器名(档案模式)或 URL(直连模式)
|
|
127
|
+
tool: 工具名
|
|
128
|
+
elapsed_ms: 调用耗时(毫秒)
|
|
129
|
+
data: 业务数据。JSON 响应解析后的对象(dict / list);
|
|
130
|
+
非 JSON 文本时为原始字符串
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
server: str
|
|
134
|
+
tool: str
|
|
135
|
+
elapsed_ms: float
|
|
136
|
+
data: Any
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class _ServerProfile:
|
|
141
|
+
"""档案中单个服务器的配置(加载时已校验)"""
|
|
142
|
+
|
|
143
|
+
name: str
|
|
144
|
+
url: str
|
|
145
|
+
transport: str = _TRANSPORT_STREAMABLE
|
|
146
|
+
fallback_url: str | None = None
|
|
147
|
+
token_env: str | None = None
|
|
148
|
+
timeout: int | None = None
|
|
149
|
+
description: str = ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class McpKit:
|
|
153
|
+
"""通用 MCP 客户端工具类(引擎与业务解耦,档案驱动多服务器)
|
|
154
|
+
|
|
155
|
+
服务器档案默认读取 ``Settings.MCP_SERVERS_CONFIG_PATH`` 指向的
|
|
156
|
+
``config/mcp_servers.json``,字段说明见该文件模板与模块 docstring。
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# 档案加载与校验
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _load_config() -> tuple[dict[str, _ServerProfile], str | None]:
|
|
165
|
+
"""加载并校验服务器档案,返回 (档案字典, 默认服务器名或 None)
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
McpKitError: 档案不存在 / JSON 非法 / 字段校验失败
|
|
169
|
+
"""
|
|
170
|
+
path = Path(get_settings().MCP_SERVERS_CONFIG_PATH)
|
|
171
|
+
if not path.is_file():
|
|
172
|
+
raise McpKitError(
|
|
173
|
+
f"MCP 服务器档案不存在: {path}。"
|
|
174
|
+
f"请在项目根创建该文件,最小模板:\n{_MINIMAL_SERVERS_JSON}\n"
|
|
175
|
+
"(token_env 为 token 所在环境变量名,token 配置在 .env),"
|
|
176
|
+
"或调整 Settings.MCP_SERVERS_CONFIG_PATH"
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
raw: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
180
|
+
except json.JSONDecodeError as e:
|
|
181
|
+
raise McpKitError(f"MCP 服务器档案 JSON 解析失败: {path}: {e}") from e
|
|
182
|
+
if not isinstance(raw, dict):
|
|
183
|
+
raise McpKitError(f"MCP 服务器档案顶层必须是 JSON 对象: {path}")
|
|
184
|
+
|
|
185
|
+
servers: Any = raw.get("servers")
|
|
186
|
+
if not isinstance(servers, dict) or not servers:
|
|
187
|
+
raise McpKitError(f"MCP 服务器档案缺少 servers 配置或为空: {path}")
|
|
188
|
+
profiles = {
|
|
189
|
+
name: McpKit._build_profile(name, cfg, path)
|
|
190
|
+
for name, cfg in servers.items()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
default: Any = raw.get("default_server")
|
|
194
|
+
if default is not None:
|
|
195
|
+
if not isinstance(default, str) or default not in profiles:
|
|
196
|
+
raise McpKitError(
|
|
197
|
+
f"default_server {default!r} 未在 servers 中定义,"
|
|
198
|
+
f"可用: {sorted(profiles)}({path})"
|
|
199
|
+
)
|
|
200
|
+
return profiles, default
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def _build_profile(cls, name: str, cfg: Any, path: Path) -> _ServerProfile:
|
|
204
|
+
"""校验并构造单个服务器档案"""
|
|
205
|
+
if not isinstance(cfg, dict):
|
|
206
|
+
raise McpKitError(f"MCP 服务器 {name!r} 配置必须是 JSON 对象: {path}")
|
|
207
|
+
url = cfg.get("url")
|
|
208
|
+
if not isinstance(url, str) or not url.strip():
|
|
209
|
+
raise McpKitError(f"MCP 服务器 {name!r} 缺少必填字段 url: {path}")
|
|
210
|
+
transport = cfg.get("transport", _TRANSPORT_STREAMABLE)
|
|
211
|
+
if transport not in _VALID_TRANSPORTS:
|
|
212
|
+
raise McpKitError(
|
|
213
|
+
f"MCP 服务器 {name!r} transport 非法: {transport!r},"
|
|
214
|
+
f"可选 {_VALID_TRANSPORTS}"
|
|
215
|
+
)
|
|
216
|
+
timeout = cfg.get("timeout")
|
|
217
|
+
if timeout is not None and (not isinstance(timeout, int) or timeout <= 0):
|
|
218
|
+
raise McpKitError(f"MCP 服务器 {name!r} timeout 必须为正整数(秒): {path}")
|
|
219
|
+
token_env = cfg.get("token_env")
|
|
220
|
+
if token_env is not None and (
|
|
221
|
+
not isinstance(token_env, str) or not token_env.strip()
|
|
222
|
+
):
|
|
223
|
+
raise McpKitError(f"MCP 服务器 {name!r} token_env 必须为非空字符串: {path}")
|
|
224
|
+
return _ServerProfile(
|
|
225
|
+
name=name,
|
|
226
|
+
url=url.strip(),
|
|
227
|
+
transport=transport,
|
|
228
|
+
fallback_url=cfg.get("fallback_url"),
|
|
229
|
+
token_env=token_env,
|
|
230
|
+
timeout=timeout,
|
|
231
|
+
description=str(cfg.get("description", "")),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def _resolve_profile(cls, server: str | None) -> _ServerProfile:
|
|
236
|
+
"""按名解析服务器档案;server=None 时回退档案 default_server
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
McpKitError: server 未定义 / 未指定且无 default_server
|
|
240
|
+
"""
|
|
241
|
+
profiles, default = cls._load_config()
|
|
242
|
+
name = server if server is not None else default
|
|
243
|
+
if name is None:
|
|
244
|
+
raise McpKitError(
|
|
245
|
+
f"未指定 server 且档案未配置 default_server,可用: {sorted(profiles)}"
|
|
246
|
+
)
|
|
247
|
+
profile = profiles.get(name)
|
|
248
|
+
if profile is None:
|
|
249
|
+
raise McpKitError(
|
|
250
|
+
f"MCP 服务器 {name!r} 未在档案中定义,可用: {sorted(profiles)}"
|
|
251
|
+
)
|
|
252
|
+
return profile
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# 密钥与传输构造
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _parse_env_file(env_path: Path) -> dict[str, str]:
|
|
260
|
+
"""解析 .env 文件(仅支持 KEY=VALUE 行,忽略注释与空行)"""
|
|
261
|
+
result: dict[str, str] = {}
|
|
262
|
+
if not env_path.is_file():
|
|
263
|
+
return result
|
|
264
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
265
|
+
line = line.strip()
|
|
266
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
267
|
+
continue
|
|
268
|
+
key, _, value = line.partition("=")
|
|
269
|
+
result[key.strip()] = value.strip()
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def _resolve_token(cls, profile: _ServerProfile) -> str | None:
|
|
274
|
+
"""解析服务器 token:环境变量优先,回退项目根 .env 文件
|
|
275
|
+
|
|
276
|
+
与 pydantic Settings 的 env_file 同源(相对工作目录的 .env),
|
|
277
|
+
解决纯 .env 配置不进入 os.environ 的场景。
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
McpKitError: 档案声明了 token_env 但两处均未配置
|
|
281
|
+
"""
|
|
282
|
+
if not profile.token_env:
|
|
283
|
+
return None
|
|
284
|
+
token = os.environ.get(profile.token_env, "").strip()
|
|
285
|
+
if not token:
|
|
286
|
+
env_file = cls._parse_env_file(Path.cwd() / ".env")
|
|
287
|
+
token = env_file.get(profile.token_env, "").strip()
|
|
288
|
+
if not token:
|
|
289
|
+
raise McpKitError(
|
|
290
|
+
f"未配置环境变量 {profile.token_env}"
|
|
291
|
+
f"(请设置环境变量或在项目根 .env 中添加 {profile.token_env}=<token>)"
|
|
292
|
+
)
|
|
293
|
+
return token
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _build_transport(url: str, token: str | None, transport_type: str) -> Any:
|
|
297
|
+
"""构造传输层(Bearer 鉴权,token 为 None 时匿名)"""
|
|
298
|
+
headers = {"Authorization": f"Bearer {token}"} if token else None
|
|
299
|
+
if transport_type == _TRANSPORT_SSE:
|
|
300
|
+
return SSETransport(url, headers=headers)
|
|
301
|
+
return StreamableHttpTransport(url, headers=headers)
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _timeout_of(profile: _ServerProfile) -> int:
|
|
305
|
+
"""档案超时优先,缺省用全局 MCP_CALL_TIMEOUT"""
|
|
306
|
+
if profile.timeout is not None:
|
|
307
|
+
return profile.timeout
|
|
308
|
+
return get_settings().MCP_CALL_TIMEOUT
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# 调用内核
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _extract_text(result: Any) -> str:
|
|
316
|
+
"""提取 MCP 调用结果中的全部文本段(多段以换行拼接)"""
|
|
317
|
+
parts: list[str] = []
|
|
318
|
+
for block in result.content:
|
|
319
|
+
text = getattr(block, "text", None)
|
|
320
|
+
if text is not None:
|
|
321
|
+
parts.append(text)
|
|
322
|
+
return "\n".join(parts)
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
async def _invoke(
|
|
326
|
+
cls, client: Any, server_name: str, tool: str, arguments: dict[str, Any]
|
|
327
|
+
) -> McpToolResult:
|
|
328
|
+
"""在已建立的连接上调用工具:耗时统计 → 解析 → 业务判定
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
McpKitError: 业务响应 success=false(detail 携带原始响应)
|
|
332
|
+
"""
|
|
333
|
+
start = time.perf_counter()
|
|
334
|
+
result = await client.call_tool(tool, arguments)
|
|
335
|
+
elapsed = round((time.perf_counter() - start) * 1000, 1)
|
|
336
|
+
raw = cls._extract_text(result)
|
|
337
|
+
try:
|
|
338
|
+
data: Any = json.loads(raw)
|
|
339
|
+
except json.JSONDecodeError:
|
|
340
|
+
data = raw
|
|
341
|
+
if isinstance(data, dict) and data.get("success") is False:
|
|
342
|
+
msg = str(data.get("msg", ""))
|
|
343
|
+
logger.error("❌ MCP 业务失败(%s/%s): %s", server_name, tool, msg)
|
|
344
|
+
raise McpKitError(
|
|
345
|
+
f"MCP 业务失败({server_name}/{tool}): {msg}", detail=data
|
|
346
|
+
)
|
|
347
|
+
logger.info("✅ MCP 调用成功(%s/%s): %sms", server_name, tool, elapsed)
|
|
348
|
+
return McpToolResult(
|
|
349
|
+
server=server_name, tool=tool, elapsed_ms=elapsed, data=data
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
async def _call_with_fallback(
|
|
354
|
+
cls, profile: _ServerProfile, name: str, arguments: dict[str, Any]
|
|
355
|
+
) -> McpToolResult:
|
|
356
|
+
"""档案模式调用:主端点失败自动回退 fallback_url(业务失败不回退)"""
|
|
357
|
+
token = cls._resolve_token(profile)
|
|
358
|
+
timeout = cls._timeout_of(profile)
|
|
359
|
+
urls = [profile.url]
|
|
360
|
+
if profile.fallback_url:
|
|
361
|
+
urls.append(profile.fallback_url)
|
|
362
|
+
last_error: Exception | None = None
|
|
363
|
+
for url in urls:
|
|
364
|
+
client = Client(
|
|
365
|
+
cls._build_transport(url, token, profile.transport), timeout=timeout
|
|
366
|
+
)
|
|
367
|
+
try:
|
|
368
|
+
async with client:
|
|
369
|
+
return await cls._invoke(client, profile.name, name, arguments)
|
|
370
|
+
except McpKitError:
|
|
371
|
+
raise # 服务端已应答的业务失败,不做端点回退
|
|
372
|
+
except Exception as e: # noqa: BLE001 —— 传输层异常需尝试回退端点
|
|
373
|
+
last_error = e
|
|
374
|
+
logger.warning(
|
|
375
|
+
"⚠ MCP 端点连接失败(%s → %s): %s", profile.name, url, e
|
|
376
|
+
)
|
|
377
|
+
raise McpKitError(
|
|
378
|
+
f"MCP 连接失败({profile.name}/{name}),"
|
|
379
|
+
f"已尝试 {len(urls)} 个端点: {last_error}",
|
|
380
|
+
detail=str(last_error),
|
|
381
|
+
) from last_error
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------
|
|
384
|
+
# 公开 API:档案模式(同步)
|
|
385
|
+
# ------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
@classmethod
|
|
388
|
+
def list_tools(cls, server: str | None = None) -> list[dict[str, Any]]:
|
|
389
|
+
"""发现服务器的工具清单
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
server: 服务器名,None 时使用档案 default_server
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
list[dict]: [{name, description}]
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
McpKitError: 档案/密钥问题或连接失败
|
|
399
|
+
"""
|
|
400
|
+
profile = cls._resolve_profile(server)
|
|
401
|
+
return asyncio.run(cls._list_tools_async(profile))
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
async def _list_tools_async(cls, profile: _ServerProfile) -> list[dict[str, Any]]:
|
|
405
|
+
"""工具发现(主端点失败自动回退 fallback_url)"""
|
|
406
|
+
token = cls._resolve_token(profile)
|
|
407
|
+
timeout = cls._timeout_of(profile)
|
|
408
|
+
urls = [profile.url]
|
|
409
|
+
if profile.fallback_url:
|
|
410
|
+
urls.append(profile.fallback_url)
|
|
411
|
+
last_error: Exception | None = None
|
|
412
|
+
for url in urls:
|
|
413
|
+
client = Client(
|
|
414
|
+
cls._build_transport(url, token, profile.transport), timeout=timeout
|
|
415
|
+
)
|
|
416
|
+
try:
|
|
417
|
+
async with client:
|
|
418
|
+
tools = await client.list_tools()
|
|
419
|
+
logger.info(
|
|
420
|
+
"✅ MCP 工具发现(%s): %d 个工具", profile.name, len(tools)
|
|
421
|
+
)
|
|
422
|
+
return [
|
|
423
|
+
{"name": t.name, "description": t.description or ""}
|
|
424
|
+
for t in tools
|
|
425
|
+
]
|
|
426
|
+
except Exception as e: # noqa: BLE001 —— 传输层异常需尝试回退端点
|
|
427
|
+
last_error = e
|
|
428
|
+
logger.warning(
|
|
429
|
+
"⚠ MCP 端点连接失败(%s → %s): %s", profile.name, url, e
|
|
430
|
+
)
|
|
431
|
+
raise McpKitError(
|
|
432
|
+
f"MCP 连接失败({profile.name}/list_tools),"
|
|
433
|
+
f"已尝试 {len(urls)} 个端点: {last_error}",
|
|
434
|
+
detail=str(last_error),
|
|
435
|
+
) from last_error
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def call_tool(
|
|
439
|
+
cls,
|
|
440
|
+
name: str,
|
|
441
|
+
arguments: dict[str, Any],
|
|
442
|
+
*,
|
|
443
|
+
server: str | None = None,
|
|
444
|
+
) -> McpToolResult:
|
|
445
|
+
"""按档案调用工具(同步短连接,开箱即用主路径)
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
name: 工具名
|
|
449
|
+
arguments: 工具入参
|
|
450
|
+
server: 服务器名,None 时使用档案 default_server
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
McpToolResult: 调用结果(data 为解析后的业务数据)
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
McpKitError: 业务失败(detail 含原始响应)/ 档案 / 密钥 / 连接失败
|
|
457
|
+
"""
|
|
458
|
+
profile = cls._resolve_profile(server)
|
|
459
|
+
return asyncio.run(cls._call_with_fallback(profile, name, arguments))
|
|
460
|
+
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
# 公开 API:异步变体与连接复用
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
@classmethod
|
|
466
|
+
async def call_tool_async(
|
|
467
|
+
cls,
|
|
468
|
+
name: str,
|
|
469
|
+
arguments: dict[str, Any],
|
|
470
|
+
*,
|
|
471
|
+
server: str | None = None,
|
|
472
|
+
) -> McpToolResult:
|
|
473
|
+
"""call_tool 的异步版本(已有事件循环的进程内使用)"""
|
|
474
|
+
profile = cls._resolve_profile(server)
|
|
475
|
+
return await cls._call_with_fallback(profile, name, arguments)
|
|
476
|
+
|
|
477
|
+
@classmethod
|
|
478
|
+
@asynccontextmanager
|
|
479
|
+
async def session(
|
|
480
|
+
cls, server: str | None = None
|
|
481
|
+
) -> AsyncIterator[Callable[[str, dict[str, Any]], Awaitable[McpToolResult]]]:
|
|
482
|
+
"""建立长连接会话,批量调用复用连接(高频场景)
|
|
483
|
+
|
|
484
|
+
Usage::
|
|
485
|
+
|
|
486
|
+
async with McpKit.session("aminer") as call:
|
|
487
|
+
r1 = await call("search_paper_by_title", {...})
|
|
488
|
+
r2 = await call("get_papers_info", {...})
|
|
489
|
+
|
|
490
|
+
注意:会话固定使用主端点,不做 fallback 回退。
|
|
491
|
+
"""
|
|
492
|
+
profile = cls._resolve_profile(server)
|
|
493
|
+
token = cls._resolve_token(profile)
|
|
494
|
+
timeout = cls._timeout_of(profile)
|
|
495
|
+
async with Client(
|
|
496
|
+
cls._build_transport(profile.url, token, profile.transport), timeout=timeout
|
|
497
|
+
) as client:
|
|
498
|
+
|
|
499
|
+
async def call(tool: str, arguments: dict[str, Any]) -> McpToolResult:
|
|
500
|
+
return await cls._invoke(client, profile.name, tool, arguments)
|
|
501
|
+
|
|
502
|
+
yield call
|
|
503
|
+
|
|
504
|
+
# ------------------------------------------------------------------
|
|
505
|
+
# 公开 API:直连模式(不经档案)
|
|
506
|
+
# ------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def call_tool_url(
|
|
510
|
+
cls,
|
|
511
|
+
url: str,
|
|
512
|
+
name: str,
|
|
513
|
+
arguments: dict[str, Any],
|
|
514
|
+
*,
|
|
515
|
+
token: str | None = None,
|
|
516
|
+
transport: str = _TRANSPORT_STREAMABLE,
|
|
517
|
+
timeout: int | None = None,
|
|
518
|
+
) -> McpToolResult:
|
|
519
|
+
"""直连临时服务器调用工具(不经档案,用于探索/临时端点)
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
url: MCP 服务器端点
|
|
523
|
+
name: 工具名
|
|
524
|
+
arguments: 工具入参
|
|
525
|
+
token: Bearer token,None 时匿名
|
|
526
|
+
transport: 传输类型(streamable-http / sse)
|
|
527
|
+
timeout: 超时秒数,None 时用全局 MCP_CALL_TIMEOUT
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
McpKitError: transport 非法 / 业务失败 / 连接失败
|
|
531
|
+
"""
|
|
532
|
+
if transport not in _VALID_TRANSPORTS:
|
|
533
|
+
raise McpKitError(
|
|
534
|
+
f"transport 非法: {transport!r},可选 {_VALID_TRANSPORTS}"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
async def _run() -> McpToolResult:
|
|
538
|
+
if timeout is not None:
|
|
539
|
+
effective_timeout: int = timeout
|
|
540
|
+
else:
|
|
541
|
+
effective_timeout = get_settings().MCP_CALL_TIMEOUT
|
|
542
|
+
async with Client(
|
|
543
|
+
cls._build_transport(url, token, transport), timeout=effective_timeout
|
|
544
|
+
) as client:
|
|
545
|
+
return await cls._invoke(client, url, name, arguments)
|
|
546
|
+
|
|
547
|
+
return asyncio.run(_run())
|