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/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())