tina-python 0.6.4__tar.gz → 0.6.5__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.
Files changed (53) hide show
  1. {tina_python-0.6.4/tina_python.egg-info → tina_python-0.6.5}/PKG-INFO +8 -3
  2. {tina_python-0.6.4 → tina_python-0.6.5}/README.md +3 -1
  3. {tina_python-0.6.4 → tina_python-0.6.5}/pyproject.toml +6 -2
  4. {tina_python-0.6.4 → tina_python-0.6.5}/setup.py +1 -1
  5. {tina_python-0.6.4 → tina_python-0.6.5}/tina/__init__.py +1 -1
  6. {tina_python-0.6.4 → tina_python-0.6.5}/tina/mcp/client.py +307 -239
  7. {tina_python-0.6.4 → tina_python-0.6.5}/tina/mcp/mcp_tools_executor.py +11 -2
  8. {tina_python-0.6.4 → tina_python-0.6.5/tina_python.egg-info}/PKG-INFO +8 -3
  9. {tina_python-0.6.4 → tina_python-0.6.5}/tina_python.egg-info/requires.txt +5 -1
  10. {tina_python-0.6.4 → tina_python-0.6.5}/LICENSE +0 -0
  11. {tina_python-0.6.4 → tina_python-0.6.5}/MANIFEST.in +0 -0
  12. {tina_python-0.6.4 → tina_python-0.6.5}/setup.cfg +0 -0
  13. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/__init__.py +0 -0
  14. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/agent.py +0 -0
  15. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/__init__.py +0 -0
  16. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/agent_response.py +0 -0
  17. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/agent_runtime.py +0 -0
  18. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/context_manager.py +0 -0
  19. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/events.py +0 -0
  20. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/executor.py +0 -0
  21. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/keyword_actions.py +0 -0
  22. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/prompt.py +0 -0
  23. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/state.py +0 -0
  24. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/core/tools.py +0 -0
  25. {tina_python-0.6.4 → tina_python-0.6.5}/tina/agent/multimodal_agent.py +0 -0
  26. {tina_python-0.6.4 → tina_python-0.6.5}/tina/core/__init__.py +0 -0
  27. {tina_python-0.6.4 → tina_python-0.6.5}/tina/core/error.py +0 -0
  28. {tina_python-0.6.4 → tina_python-0.6.5}/tina/core/logger.py +0 -0
  29. {tina_python-0.6.4 → tina_python-0.6.5}/tina/llm/BaseNewAPI.py +0 -0
  30. {tina_python-0.6.4 → tina_python-0.6.5}/tina/llm/__init__.py +0 -0
  31. {tina_python-0.6.4 → tina_python-0.6.5}/tina/llm/base_api.py +0 -0
  32. {tina_python-0.6.4 → tina_python-0.6.5}/tina/llm/base_multimodal_api.py +0 -0
  33. {tina_python-0.6.4 → tina_python-0.6.5}/tina/llm/ollama_api.py +0 -0
  34. {tina_python-0.6.4 → tina_python-0.6.5}/tina/mcp/__init__.py +0 -0
  35. {tina_python-0.6.4 → tina_python-0.6.5}/tina/py.typed +0 -0
  36. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/__init__.py +0 -0
  37. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/agent_worker.py +0 -0
  38. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/deepseek.py +0 -0
  39. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/doc_parser.py +0 -0
  40. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/env_reader.py +0 -0
  41. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/multimodal_formatter.py +0 -0
  42. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/output_parser.py +0 -0
  43. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/run_agent_in_cli.py +0 -0
  44. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/system_tools.py +0 -0
  45. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/timer.py +0 -0
  46. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/tui/__init__.py +0 -0
  47. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/tui/context_manager.py +0 -0
  48. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/tui/token.py +0 -0
  49. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/tui/tui.py +0 -0
  50. {tina_python-0.6.4 → tina_python-0.6.5}/tina/utils/type_mapper.py +0 -0
  51. {tina_python-0.6.4 → tina_python-0.6.5}/tina_python.egg-info/SOURCES.txt +0 -0
  52. {tina_python-0.6.4 → tina_python-0.6.5}/tina_python.egg-info/dependency_links.txt +0 -0
  53. {tina_python-0.6.4 → tina_python-0.6.5}/tina_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina-python
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: tina is in your computer!
5
5
  Home-page: https://gitee.com/wang-churi/tina
6
6
  Author: 王出日
@@ -20,11 +20,14 @@ Requires-Dist: python-dotenv
20
20
  Provides-Extra: tui
21
21
  Requires-Dist: textual>=0.80; extra == "tui"
22
22
  Provides-Extra: mcp
23
- Requires-Dist: mcp-python; extra == "mcp"
23
+ Requires-Dist: mcp; extra == "mcp"
24
24
  Provides-Extra: test
25
25
  Requires-Dist: pytest>=9.0; extra == "test"
26
26
  Requires-Dist: pytest-asyncio>=1.4; extra == "test"
27
27
  Requires-Dist: pytest-cov>=7.0; extra == "test"
28
+ Provides-Extra: all
29
+ Requires-Dist: textual>=0.80; extra == "all"
30
+ Requires-Dist: mcp; extra == "all"
28
31
  Dynamic: author
29
32
  Dynamic: home-page
30
33
  Dynamic: license-file
@@ -692,6 +695,8 @@ tina 支持通过 MCP(Model Context Protocol)连接外部工具生态。
692
695
  pip install tina-python[mcp]
693
696
  ```
694
697
 
698
+ > 想一次装齐所有可选功能(TUI + MCP):`pip install tina-python[all]`
699
+
695
700
  ### 4.2 使用 MCP 客户端
696
701
 
697
702
  ```python
@@ -1077,7 +1082,7 @@ MAX_INPUT=8000
1077
1082
  | 用途 | 依赖 |
1078
1083
  |------|------|
1079
1084
  | 核心功能 | `httpx`, `python-dotenv` |
1080
- | MCP 支持 | `mcp` (包名: `mcp-python`) |
1085
+ | MCP 支持 | `mcp` |
1081
1086
  | TUI 界面(可选) | `textual` |
1082
1087
  | 测试 | `pytest`, `pytest-asyncio`, `pytest-cov` |
1083
1088
 
@@ -660,6 +660,8 @@ tina 支持通过 MCP(Model Context Protocol)连接外部工具生态。
660
660
  pip install tina-python[mcp]
661
661
  ```
662
662
 
663
+ > 想一次装齐所有可选功能(TUI + MCP):`pip install tina-python[all]`
664
+
663
665
  ### 4.2 使用 MCP 客户端
664
666
 
665
667
  ```python
@@ -1045,7 +1047,7 @@ MAX_INPUT=8000
1045
1047
  | 用途 | 依赖 |
1046
1048
  |------|------|
1047
1049
  | 核心功能 | `httpx`, `python-dotenv` |
1048
- | MCP 支持 | `mcp` (包名: `mcp-python`) |
1050
+ | MCP 支持 | `mcp` |
1049
1051
  | TUI 界面(可选) | `textual` |
1050
1052
  | 测试 | `pytest`, `pytest-asyncio`, `pytest-cov` |
1051
1053
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tina-python"
7
- version = "0.6.4"
7
+ version = "0.6.5"
8
8
  description = "tina is in your computer!"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.10"
@@ -32,13 +32,17 @@ tui = [
32
32
  "textual>=0.80",
33
33
  ]
34
34
  mcp = [
35
- "mcp-python",
35
+ "mcp",
36
36
  ]
37
37
  test = [
38
38
  "pytest>=9.0",
39
39
  "pytest-asyncio>=1.4",
40
40
  "pytest-cov>=7.0",
41
41
  ]
42
+ all = [
43
+ "textual>=0.80",
44
+ "mcp",
45
+ ]
42
46
 
43
47
  [tool.pytest.ini_options]
44
48
  testpaths = ["test"]
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="tina-python",
5
- version="0.6.4",
5
+ version="0.6.5",
6
6
  packages=find_packages(),
7
7
  package_data={"tina": ["py.typed"]},
8
8
  install_requires=["httpx", "python-dotenv"],
@@ -19,4 +19,4 @@ __all__ = [
19
19
  "AgentState",
20
20
  "AgentEvents",
21
21
  ]
22
- __version__ = "0.6.4"
22
+ __version__ = "0.6.5"
@@ -1,239 +1,307 @@
1
- import asyncio
2
- import json
3
- import uuid
4
- import threading
5
- from typing import Dict, List, Any, Optional, Union, Tuple
6
- from contextlib import AsyncExitStack
7
- from datetime import datetime
8
- from ..core import logger # 假设你有统一的logger
9
-
10
- try:
11
- from mcp import ClientSession, StdioServerParameters
12
- from mcp.client.stdio import stdio_client
13
- from mcp.client.sse import sse_client
14
-
15
- MCP_AVAILABLE = True
16
- except ImportError:
17
- MCP_AVAILABLE = False
18
-
19
-
20
- def _tool_input_schema(tool: Any) -> Dict[str, Any]:
21
- """获取 MCP 工具的输入 schema,兼容 SDK 的字段改名
22
-
23
- MCP Python SDK 早期版本用驼峰 `inputSchema`,新版本(pydantic 模型)改为
24
- 下划线 `input_schema`。这里两种都取,取不到返回空 dict。
25
- """
26
- schema = getattr(tool, "input_schema", None)
27
- if schema is None:
28
- schema = getattr(tool, "inputSchema", None)
29
- return schema or {}
30
-
31
-
32
- class MCPClient:
33
- def __init__(self):
34
- if not MCP_AVAILABLE:
35
- raise ImportError("请先安装MCP依赖: pip install mcp-python")
36
-
37
- self.servers = (
38
- {}
39
- ) # {server_id: {"session": s, "stack": stack, "tools": [], "config": {}}}
40
- self.request_history = []
41
- self.loop = asyncio.new_event_loop()
42
- self._loop_is_running = False
43
- self._lock = threading.Lock() # 保护 servers 字典的线程安全
44
-
45
- self.start_loop_thread()
46
-
47
- def start_loop_thread(self):
48
- """启动专用后台线程运行事件循环"""
49
-
50
- def run_event_loop():
51
- asyncio.set_event_loop(self.loop)
52
- self._loop_is_running = True
53
- try:
54
- self.loop.run_forever()
55
- finally:
56
- # 最后的清理逻辑
57
- self._loop_is_running = False
58
- self.loop.close()
59
-
60
- thread = threading.Thread(target=run_event_loop, daemon=True)
61
- thread.start()
62
-
63
- # --- 核心内部异步实现 (解决资源残留) ---
64
-
65
- async def _add_server_async(self, server_id: str, config: Dict[str, Any]) -> bool:
66
- if server_id in self.servers:
67
- return True
68
-
69
- # 为每个 Server 创建独立的 Stack
70
- stack = AsyncExitStack()
71
- try:
72
- server_type = config.get("type", "").lower()
73
- if server_type == "stdio":
74
- params = StdioServerParameters(
75
- command=config["command"],
76
- args=config.get("args", []),
77
- env=config.get("env"),
78
- )
79
- # 进入 stdio 上下文 (管理子进程)
80
- transport = await stack.enter_async_context(stdio_client(params))
81
- # 进入 session 上下文 (管理协议)
82
- session = await stack.enter_async_context(
83
- ClientSession(transport[0], transport[1])
84
- )
85
- elif server_type == "sse":
86
- transport = await stack.enter_async_context(sse_client(config["url"]))
87
- session = await stack.enter_async_context(
88
- ClientSession(transport[0], transport[1])
89
- )
90
- else:
91
- raise ValueError(f"Unsupported server type: {server_type}")
92
-
93
- await session.initialize()
94
- res = await session.list_tools()
95
-
96
- with self._lock:
97
- self.servers[server_id] = {
98
- "session": session,
99
- "stack": stack, # 这里的 stack 包含了这个 server 的所有资源
100
- "tools": res.tools,
101
- "config": config,
102
- "added_at": datetime.now(),
103
- }
104
- return True
105
- except Exception as e:
106
- await stack.aclose() # 出错时立即清理已打开的资源
107
- logger.error(f"MCP Add Server Error [{server_id}]: {e}")
108
- return False
109
-
110
- async def _close_server_async(self, server_id: str) -> bool:
111
- """物理杀掉子进程并移除服务器"""
112
- with self._lock:
113
- server_info = self.servers.pop(server_id, None)
114
-
115
- if server_info:
116
- # 这一步是关键:调用 stack.aclose() 会按相反顺序关闭 session transport
117
- # 对于 stdio 来说,这会发送信号给子进程并等待其退出
118
- await server_info["stack"].aclose()
119
- return True
120
- return False
121
-
122
- # --- 同步接口桥接 ---
123
-
124
- def add_server(self, server_id: str, config: Dict[str, Any], timeout=30) -> bool:
125
- future = asyncio.run_coroutine_threadsafe(
126
- self._add_server_async(server_id, config), self.loop
127
- )
128
- try:
129
- return future.result(timeout=timeout)
130
- except Exception as e:
131
- logger.error(f"Sync add_server timeout/error: {e}")
132
- return False
133
-
134
- def call_tool(
135
- self,
136
- tool_name: str,
137
- tool_args: Dict[str, Any],
138
- server_id: Optional[str] = None,
139
- timeout=60,
140
- ):
141
- future = asyncio.run_coroutine_threadsafe(
142
- self._call_tool_async(tool_name, tool_args, server_id), self.loop
143
- )
144
- return future.result(timeout=timeout)
145
-
146
- # --- 异步接口 (a开头,保持Tina风格) ---
147
-
148
- async def acall_tool(
149
- self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
150
- ):
151
- return await self._call_tool_async(tool_name, tool_args, server_id)
152
-
153
- async def _call_tool_async(
154
- self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
155
- ):
156
- target_sid = server_id
157
- # 自动寻址逻辑
158
- if not target_sid:
159
- with self._lock:
160
- for sid, info in self.servers.items():
161
- if any(t.name == tool_name for t in info["tools"]):
162
- target_sid = sid
163
- break
164
-
165
- if not target_sid or target_sid not in self.servers:
166
- return {"success": False, "error": f"Tool {tool_name} not found"}
167
-
168
- session = self.servers[target_sid]["session"]
169
- try:
170
- # 记录历史
171
- res = await session.call_tool(tool_name, tool_args)
172
- # 这里统一处理结果,避免 TextContent 对象导致后续 JSON 序列化失败
173
-
174
- history_item = {
175
- "id": str(uuid.uuid4()),
176
- "timestamp": datetime.now().isoformat(),
177
- "tool": tool_name,
178
- "server": target_sid,
179
- "success": True,
180
- }
181
- self.request_history.append(history_item)
182
-
183
- return {"success": True, "content": res.content, "server_id": target_sid}
184
- except Exception as e:
185
- return {"success": False, "error": str(e)}
186
-
187
- def to_tina_tools(self):
188
- """
189
- 将当前已连接的所有 MCP 工具转换为 Tina 格式
190
- 确保指定 name="mcp" 以避免 ToolsNotNamed 异常
191
- """
192
- from ..agent.core.tools import Tools
193
-
194
- # 必须指定 name,这样 self.tools = _tools + self.tools 才能正常工作
195
- tina_tools = Tools(name="mcp")
196
-
197
- with self._lock:
198
- for sid, info in self.servers.items():
199
- for tool in info["tools"]:
200
- # 注意:Tina Tools 内部可能还会根据包名加一层前缀
201
- # 这里的 register_no_function 保持你习惯的格式
202
- schema = _tool_input_schema(tool)
203
- properties = schema.get("properties", {}) or {}
204
- tina_tools.register_no_function(
205
- name=f"{sid}_{tool.name}", # 外部包名是mcp,内部就是 mcp_sid_name
206
- description=f"[MCP:{sid}] {tool.description}",
207
- parameters={
208
- k: {
209
- "type": v.get("type", "str"),
210
- "description": v.get("description", ""),
211
- }
212
- for k, v in properties.items()
213
- },
214
- required_parameters=schema.get("required", []) or [],
215
- )
216
- return tina_tools
217
-
218
- def close(self):
219
- """优雅关闭所有资源"""
220
- if self._loop_is_running:
221
- # 获取所有服务器 ID
222
- with self._lock:
223
- ids = list(self.servers.keys())
224
-
225
- # 提交清理任务
226
- tasks = [self._close_server_async(sid) for sid in ids]
227
- future = asyncio.run_coroutine_threadsafe(asyncio.gather(*tasks), self.loop)
228
- future.result(timeout=10)
229
-
230
- # 停止循环
231
- self.loop.call_soon_threadsafe(self.loop.stop)
232
-
233
- def __del__(self):
234
- # 析构时尽量尝试静默关闭
235
- try:
236
- if hasattr(self, "loop") and self.loop.is_running():
237
- self.loop.call_soon_threadsafe(self.loop.stop)
238
- except:
239
- pass
1
+ import asyncio
2
+ import concurrent.futures
3
+ import json
4
+ import uuid
5
+ import threading
6
+ from typing import Dict, List, Any, Optional, Union, Tuple
7
+ from contextlib import AsyncExitStack
8
+ from datetime import datetime
9
+ from ..core import logger # 假设你有统一的logger
10
+
11
+ try:
12
+ from mcp import ClientSession, StdioServerParameters
13
+ from mcp.client.stdio import stdio_client
14
+ from mcp.client.sse import sse_client
15
+
16
+ MCP_AVAILABLE = True
17
+ except ImportError:
18
+ MCP_AVAILABLE = False
19
+
20
+
21
+ def _tool_input_schema(tool: Any) -> Dict[str, Any]:
22
+ """获取 MCP 工具的输入 schema,兼容 SDK 的字段改名
23
+
24
+ MCP Python SDK 早期版本用驼峰 `inputSchema`,新版本(pydantic 模型)改为
25
+ 下划线 `input_schema`。这里两种都取,取不到返回空 dict。
26
+ """
27
+ schema = getattr(tool, "input_schema", None)
28
+ if schema is None:
29
+ schema = getattr(tool, "inputSchema", None)
30
+ return schema or {}
31
+
32
+
33
+ class MCPClient:
34
+ def __init__(self):
35
+ if not MCP_AVAILABLE:
36
+ raise ImportError("请先安装MCP依赖: pip install mcp")
37
+
38
+ self.servers = (
39
+ {}
40
+ ) # {server_id: {"session": s, "tools": [], "config": {}, "stop": Event}}
41
+ self.request_history = []
42
+ self._server_futures = {} # {server_id: concurrent.futures.Future}
43
+ self.loop = asyncio.new_event_loop()
44
+ self._loop_is_running = False
45
+ self._lock = threading.Lock() # 保护 servers 字典的线程安全
46
+
47
+ self.start_loop_thread()
48
+
49
+ def start_loop_thread(self):
50
+ """启动专用后台线程运行事件循环"""
51
+
52
+ def run_event_loop():
53
+ asyncio.set_event_loop(self.loop)
54
+ self._loop_is_running = True
55
+ try:
56
+ self.loop.run_forever()
57
+ finally:
58
+ # 最后的清理逻辑
59
+ self._loop_is_running = False
60
+ self.loop.close()
61
+
62
+ thread = threading.Thread(target=run_event_loop, daemon=True)
63
+ thread.start()
64
+
65
+ # --- 核心内部异步实现 ---
66
+
67
+ async def _serve_server(
68
+ self,
69
+ server_id: str,
70
+ config: Dict[str, Any],
71
+ ready: concurrent.futures.Future,
72
+ ) -> None:
73
+ """在同一个 task 内完成 MCP 会话的进入与退出。
74
+
75
+ mcp 2.x 基于 anyio,context 的进入/退出必须在同一个 task,
76
+ 否则退出时会报 "Attempted to exit cancel scope in a different task"
77
+ 因此这里用一个常驻 task 持有会话,直到 stop 事件触发再统一关闭。
78
+ """
79
+ stack = AsyncExitStack()
80
+ stop = asyncio.Event()
81
+ try:
82
+ server_type = config.get("type", "").lower()
83
+ if server_type == "stdio":
84
+ params = StdioServerParameters(
85
+ command=config["command"],
86
+ args=config.get("args", []),
87
+ env=config.get("env"),
88
+ )
89
+ transport = await stack.enter_async_context(stdio_client(params))
90
+ session = await stack.enter_async_context(
91
+ ClientSession(transport[0], transport[1])
92
+ )
93
+ elif server_type == "sse":
94
+ transport = await stack.enter_async_context(sse_client(config["url"]))
95
+ session = await stack.enter_async_context(
96
+ ClientSession(transport[0], transport[1])
97
+ )
98
+ else:
99
+ raise ValueError(f"Unsupported server type: {server_type}")
100
+
101
+ await session.initialize()
102
+ res = await session.list_tools()
103
+
104
+ with self._lock:
105
+ self.servers[server_id] = {
106
+ "session": session,
107
+ "tools": res.tools,
108
+ "config": config,
109
+ "added_at": datetime.now(),
110
+ "stop": stop,
111
+ }
112
+
113
+ ready.set_result(True)
114
+ await stop.wait()
115
+ except Exception as e:
116
+ logger.error(f"MCP Server Error [{server_id}]: {e}")
117
+ if not ready.done():
118
+ ready.set_result(False)
119
+ finally:
120
+ with self._lock:
121
+ self.servers.pop(server_id, None)
122
+ self._server_futures.pop(server_id, None)
123
+ try:
124
+ await stack.aclose()
125
+ except Exception as e:
126
+ logger.error(f"MCP Cleanup Error [{server_id}]: {e}")
127
+
128
+ # --- 同步接口桥接 ---
129
+
130
+ def add_server(
131
+ self,
132
+ server_id: str,
133
+ config: Dict[str, Any],
134
+ max_retries: int = 1,
135
+ timeout: int = 90,
136
+ ) -> bool:
137
+ """添加并连接一个 MCP 服务器,失败时按 max_retries 重试"""
138
+ with self._lock:
139
+ if server_id in self.servers:
140
+ return True
141
+
142
+ for attempt in range(1, max(1, max_retries) + 1):
143
+ ready: concurrent.futures.Future = concurrent.futures.Future()
144
+ future = asyncio.run_coroutine_threadsafe(
145
+ self._serve_server(server_id, config, ready), self.loop
146
+ )
147
+ try:
148
+ ok = ready.result(timeout=timeout)
149
+ except Exception as e:
150
+ logger.error(f"Sync add_server timeout/error [{server_id}]: {e}")
151
+ ok = False
152
+
153
+ if ok:
154
+ with self._lock:
155
+ self._server_futures[server_id] = future
156
+ return True
157
+
158
+ if attempt < max_retries:
159
+ logger.warning(
160
+ f"MCP 添加服务器 '{server_id}' {attempt} 次失败,重试中..."
161
+ )
162
+ return False
163
+
164
+ def remove_server(self, server_id: str, timeout: int = 10) -> bool:
165
+ """移除服务器并等待其资源(子进程/连接)释放"""
166
+ with self._lock:
167
+ info = self.servers.get(server_id)
168
+ if info is None:
169
+ return False
170
+ stop = info["stop"]
171
+ future = self._server_futures.get(server_id)
172
+
173
+ self.loop.call_soon_threadsafe(stop.set)
174
+ if future is not None:
175
+ try:
176
+ future.result(timeout=timeout)
177
+ except Exception as e:
178
+ logger.error(f"Sync remove_server timeout/error [{server_id}]: {e}")
179
+ return True
180
+
181
+ @staticmethod
182
+ def _format_server_info(server_id: str, info: Dict[str, Any]) -> Dict[str, Any]:
183
+ return {
184
+ "server_id": server_id,
185
+ "config": info["config"],
186
+ "tools": [t.name for t in info["tools"]],
187
+ "added_at": info["added_at"].isoformat(),
188
+ }
189
+
190
+ def get_server_info(
191
+ self, server_id: Optional[str] = None
192
+ ) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
193
+ """获取已连接 MCP 服务器的信息;不传 server_id 时返回全部"""
194
+ with self._lock:
195
+ if server_id is not None:
196
+ info = self.servers.get(server_id)
197
+ return self._format_server_info(server_id, info) if info else None
198
+ return [
199
+ self._format_server_info(sid, info)
200
+ for sid, info in self.servers.items()
201
+ ]
202
+
203
+ def call_tool(
204
+ self,
205
+ tool_name: str,
206
+ tool_args: Dict[str, Any],
207
+ server_id: Optional[str] = None,
208
+ timeout=60,
209
+ ):
210
+ future = asyncio.run_coroutine_threadsafe(
211
+ self._call_tool_async(tool_name, tool_args, server_id), self.loop
212
+ )
213
+ return future.result(timeout=timeout)
214
+
215
+ # --- 异步接口 (a开头,保持Tina风格) ---
216
+
217
+ async def acall_tool(
218
+ self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
219
+ ):
220
+ return await self._call_tool_async(tool_name, tool_args, server_id)
221
+
222
+ async def _call_tool_async(
223
+ self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
224
+ ):
225
+ target_sid = server_id
226
+ # 自动寻址逻辑
227
+ if not target_sid:
228
+ with self._lock:
229
+ for sid, info in self.servers.items():
230
+ if any(t.name == tool_name for t in info["tools"]):
231
+ target_sid = sid
232
+ break
233
+
234
+ if not target_sid or target_sid not in self.servers:
235
+ return {"success": False, "error": f"Tool {tool_name} not found"}
236
+
237
+ session = self.servers[target_sid]["session"]
238
+ try:
239
+ # 记录历史
240
+ res = await session.call_tool(tool_name, tool_args)
241
+ # 这里统一处理结果,避免 TextContent 对象导致后续 JSON 序列化失败
242
+
243
+ history_item = {
244
+ "id": str(uuid.uuid4()),
245
+ "timestamp": datetime.now().isoformat(),
246
+ "tool": tool_name,
247
+ "server": target_sid,
248
+ "success": True,
249
+ }
250
+ self.request_history.append(history_item)
251
+
252
+ return {"success": True, "content": res.content, "server_id": target_sid}
253
+ except Exception as e:
254
+ return {"success": False, "error": str(e)}
255
+
256
+ def to_tina_tools(self):
257
+ """
258
+ 将当前已连接的所有 MCP 工具转换为 Tina 格式
259
+ 确保指定 name="mcp" 以避免 ToolsNotNamed 异常
260
+ """
261
+ from ..agent.core.tools import Tools
262
+
263
+ # 必须指定 name,这样 self.tools = _tools + self.tools 才能正常工作
264
+ tina_tools = Tools(name="mcp")
265
+
266
+ with self._lock:
267
+ for sid, info in self.servers.items():
268
+ for tool in info["tools"]:
269
+ # 注意:Tina Tools 内部可能还会根据包名加一层前缀
270
+ # 这里的 register_no_function 保持你习惯的格式
271
+ schema = _tool_input_schema(tool)
272
+ properties = schema.get("properties", {}) or {}
273
+ tina_tools.register_no_function(
274
+ name=f"{sid}_{tool.name}", # 外部包名是mcp,内部就是 mcp_sid_name
275
+ description=f"[MCP:{sid}] {tool.description}",
276
+ parameters={
277
+ k: {
278
+ "type": v.get("type", "str"),
279
+ "description": v.get("description", ""),
280
+ }
281
+ for k, v in properties.items()
282
+ },
283
+ required_parameters=schema.get("required", []) or [],
284
+ )
285
+ return tina_tools
286
+
287
+ def close(self):
288
+ """优雅关闭所有资源"""
289
+ if not self._loop_is_running:
290
+ return
291
+
292
+ with self._lock:
293
+ ids = list(self.servers.keys())
294
+
295
+ # 通知各 server 的常驻 task 退出,并在原 task 内完成资源清理
296
+ for sid in ids:
297
+ self.remove_server(sid)
298
+
299
+ self.loop.call_soon_threadsafe(self.loop.stop)
300
+
301
+ def __del__(self):
302
+ # 析构时尽量尝试静默关闭
303
+ try:
304
+ if hasattr(self, "loop") and self.loop.is_running():
305
+ self.loop.call_soon_threadsafe(self.loop.stop)
306
+ except:
307
+ pass
@@ -3,6 +3,15 @@ from ..core import logger
3
3
  from typing import Dict, Any
4
4
 
5
5
 
6
+ def _content_to_text(result: Dict[str, Any]) -> str:
7
+ """将 MCP 返回的 TextContent 列表拼接为纯文本"""
8
+ parts = []
9
+ for item in result.get("content") or []:
10
+ text = getattr(item, "text", None)
11
+ parts.append(text if text is not None else str(item))
12
+ return "\n".join(parts)
13
+
14
+
6
15
  class MCPToolExecutor:
7
16
  """
8
17
  MCP工具执行器,用于执行MCP工具调用
@@ -36,7 +45,7 @@ class MCPToolExecutor:
36
45
  logger.debug(
37
46
  f"MCPToolExecutor - 工具 '{_tool_name}' 执行结果: {result}:参数 {_tool_args}"
38
47
  )
39
- return result["content"]
48
+ return _content_to_text(result)
40
49
  else:
41
50
  logger.error(
42
51
  f"MCPToolExecutor - 工具'{_tool_name}' 执行错误: {result.get('error', '未知错误')}"
@@ -57,7 +66,7 @@ class MCPToolExecutor:
57
66
  logger.debug(
58
67
  f"MCPToolExecutor - 工具 '{_tool_name}' 执行结果: {result}:参数 {_tool_args}"
59
68
  )
60
- return result["content"]
69
+ return _content_to_text(result)
61
70
  else:
62
71
  logger.error(
63
72
  f"MCPToolExecutor - 工具'{_tool_name}' 执行错误: {result.get('error', '未知错误')}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina-python
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: tina is in your computer!
5
5
  Home-page: https://gitee.com/wang-churi/tina
6
6
  Author: 王出日
@@ -20,11 +20,14 @@ Requires-Dist: python-dotenv
20
20
  Provides-Extra: tui
21
21
  Requires-Dist: textual>=0.80; extra == "tui"
22
22
  Provides-Extra: mcp
23
- Requires-Dist: mcp-python; extra == "mcp"
23
+ Requires-Dist: mcp; extra == "mcp"
24
24
  Provides-Extra: test
25
25
  Requires-Dist: pytest>=9.0; extra == "test"
26
26
  Requires-Dist: pytest-asyncio>=1.4; extra == "test"
27
27
  Requires-Dist: pytest-cov>=7.0; extra == "test"
28
+ Provides-Extra: all
29
+ Requires-Dist: textual>=0.80; extra == "all"
30
+ Requires-Dist: mcp; extra == "all"
28
31
  Dynamic: author
29
32
  Dynamic: home-page
30
33
  Dynamic: license-file
@@ -692,6 +695,8 @@ tina 支持通过 MCP(Model Context Protocol)连接外部工具生态。
692
695
  pip install tina-python[mcp]
693
696
  ```
694
697
 
698
+ > 想一次装齐所有可选功能(TUI + MCP):`pip install tina-python[all]`
699
+
695
700
  ### 4.2 使用 MCP 客户端
696
701
 
697
702
  ```python
@@ -1077,7 +1082,7 @@ MAX_INPUT=8000
1077
1082
  | 用途 | 依赖 |
1078
1083
  |------|------|
1079
1084
  | 核心功能 | `httpx`, `python-dotenv` |
1080
- | MCP 支持 | `mcp` (包名: `mcp-python`) |
1085
+ | MCP 支持 | `mcp` |
1081
1086
  | TUI 界面(可选) | `textual` |
1082
1087
  | 测试 | `pytest`, `pytest-asyncio`, `pytest-cov` |
1083
1088
 
@@ -1,8 +1,12 @@
1
1
  httpx
2
2
  python-dotenv
3
3
 
4
+ [all]
5
+ textual>=0.80
6
+ mcp
7
+
4
8
  [mcp]
5
- mcp-python
9
+ mcp
6
10
 
7
11
  [test]
8
12
  pytest>=9.0
File without changes
File without changes
File without changes
File without changes