Skip to content

Commit f395d51

Browse files
Make MCP connections concurrency-safe and extend thinking config (#107)
* Fix: MCP Client compete state * Fix: cot passing * Upd: test and brand * Fix: tool refresh * Fix: deadlock
1 parent cd19512 commit f395d51

10 files changed

Lines changed: 75 additions & 32 deletions

File tree

docs/docs/.vitepress/config.mts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export default withMermaid({
6767
themeConfig: {
6868
// https://vitepress.dev/reference/default-theme-config
6969
siteTitle: "Amrita Core Docs",
70+
search: {
71+
provider: "local",
72+
},
7073
nav: [
7174
{ text: "Home", link: "/" },
7275
{ text: "Start", link: "/guide/introduction" },
@@ -420,6 +423,9 @@ export default withMermaid({
420423
themeConfig: {
421424
// https://vitepress.dev/reference/default-theme-config
422425
siteTitle: "Amrita Core 文档",
426+
search: {
427+
provider: "local",
428+
},
423429
nav: [
424430
{ text: "首页", link: "/zh/" },
425431
{ text: "开始", link: "/zh/guide/introduction" },

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "amrita_core"
3-
version = "0.10.2"
3+
version = "0.10.3"
44
description = "High performance, flexible, lightweight agent framework."
55
readme = "README.md"
66
requires-python = ">=3.10,<3.15"

src/amrita_core/adapters/openai.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,21 @@ async def call_api(
6060
completion: ChatCompletion | openai.AsyncStream[ChatCompletionChunk] | None = (
6161
None
6262
)
63+
64+
kwargs.setdefault("extra_body", {})
6365
if preset.thinking_config is not None:
64-
kwargs.update({"reasoning_effort": preset.thinking_config.thinking_effort})
66+
if preset.thinking_config.thinking_type is not None:
67+
kwargs["extra_body"].setdefault("thinking", {}).setdefault(
68+
"type", preset.thinking_config.thinking_type
69+
)
70+
if preset.thinking_config.enable_thinking:
71+
kwargs["extra_body"].setdefault("enable_thinking", True)
72+
73+
if preset.thinking_config.thinking_effort:
74+
kwargs.update(
75+
{"reasoning_effort": preset.thinking_config.thinking_effort}
76+
)
77+
6578
if stream := preset.config.stream:
6679
kwargs.update({"stream_options": {"include_usage": True}})
6780
completion = await client.chat.completions.create(
@@ -169,8 +182,19 @@ async def call_tools(
169182
api_key=key,
170183
timeout=config.llm.llm_timeout,
171184
)
185+
kwargs.setdefault("extra_body", {})
172186
if preset.thinking_config is not None:
173-
kwargs.update({"reasoning_effort": preset.thinking_config.thinking_effort})
187+
if preset.thinking_config.thinking_type is not None:
188+
kwargs["extra_body"].setdefault("thinking", {}).setdefault(
189+
"type", preset.thinking_config.thinking_type
190+
)
191+
if preset.thinking_config.enable_thinking:
192+
kwargs["extra_body"].setdefault("enable_thinking", True)
193+
194+
if preset.thinking_config.thinking_effort:
195+
kwargs.update(
196+
{"reasoning_effort": preset.thinking_config.thinking_effort}
197+
)
174198
completion: ChatCompletion = await client.chat.completions.create(
175199
model=model,
176200
messages=messages,

src/amrita_core/builtins/agent.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -886,11 +886,6 @@ def get_category(
886886
) -> Literal["workflow"]:
887887
return "workflow"
888888

889-
890-
# TODO: Resolve https://github.com/AmritaBot/AmritaCore/issues/20
891-
# class CompatibleReActAgentStrategy(BaseReActAgentStrategy):
892-
893-
894889
class HybridReActAgentStrategy(BaseReActAgentStrategy):
895890
"""**Hybrid ReAct Agent Strategy optimized for Mixture of Experts (MoE) architecture models.**
896891

src/amrita_core/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# TODO:分实例的配置
21
from __future__ import annotations
32

43
import random

src/amrita_core/consts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,8 @@
6464
## Mode choice
6565
6666
Calling the `agent_stop` tool indicates "information processing is complete, ready to organize the final response". Immediately after, switch to Daily Conversation Mode and provide the complete response directly, and not to call ANY other tool.
67+
68+
## X-Powered-By
69+
AmritaCore (Agent framework, see at https://core.amritabot.com)
70+
AmritaSense (Workflow runtime, see at https://sense.amritabot.com)
6771
"""

src/amrita_core/tools/mcp.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class MCPClient:
3939
server_script: MCP_SERVER_SCRIPT_TYPE
4040
_close_waitter: asyncio.Future | None = None
4141
_close_ttl: int
42+
_connect_lock: aiologic.Lock
4243

4344
def __init__(
4445
self,
@@ -59,6 +60,7 @@ def __init__(
5960
if connection_ttl < -1:
6061
raise ValueError("connection_ttl must be greater than or equals to -1")
6162
self._close_ttl = connection_ttl
63+
self._connect_lock = aiologic.Lock()
6264

6365
async def __aenter__(self) -> Self:
6466
self._close_waitter = None
@@ -105,25 +107,36 @@ async def simple_call(self, tool_name: str, data: dict[str, Any]) -> str:
105107
await self._close()
106108

107109
async def _connect(self, update_tools: bool = False):
108-
"""Connect to MCP Server
110+
"""Connect to MCP Server (idempotent, safe for concurrent calls)
109111
Args:
110112
update_tools (bool, optional): whether to update the tool list. Defaults to False.
113+
When True, tools are always refreshed from the server, even if already connected.
111114
"""
115+
# Cancel any pending close-waiter outside the lock to avoid deadlock:
116+
# _clean_waitter cancels the waiter task whose _close() also needs _connect_lock.
112117
await self._clean_waitter()
113-
if self.mcp_client is not None:
114-
raise RuntimeError("MCP Server is already connected!")
115-
116-
server_script: MCP_SERVER_SCRIPT_TYPE = self.server_script
117-
self.mcp_client = Client(server_script)
118-
await self.mcp_client.__aenter__()
119-
logger.info(f"Successfully connected to MCP Server@{server_script}")
120-
if update_tools or not self.tools:
121-
self.tools = [
122-
MCPToolSchema.model_validate(i.model_dump())
123-
for i in await self.mcp_client.list_tools()
124-
]
125-
logger.info(f"Available tools: {[tool.name for tool in self.tools]}")
126-
self._cast_tool_to_amrita()
118+
async with self._connect_lock:
119+
if self.mcp_client is not None:
120+
if update_tools:
121+
await self._refresh_tools()
122+
return
123+
124+
server_script: MCP_SERVER_SCRIPT_TYPE = self.server_script
125+
self.mcp_client = Client(server_script)
126+
await self.mcp_client.__aenter__()
127+
logger.info(f"Successfully connected to MCP Server@{server_script}")
128+
if update_tools or not self.tools:
129+
await self._refresh_tools()
130+
131+
async def _refresh_tools(self) -> None:
132+
"""Refresh the cached tool list from the connected MCP server."""
133+
assert self.mcp_client is not None, "Cannot refresh tools: not connected"
134+
self.tools = [
135+
MCPToolSchema.model_validate(i.model_dump())
136+
for i in await self.mcp_client.list_tools()
137+
]
138+
logger.info(f"Available tools: {[tool.name for tool in self.tools]}")
139+
self._cast_tool_to_amrita()
127140

128141
async def _clean_waitter(self):
129142
if self._close_waitter is not None:
@@ -158,9 +171,10 @@ async def close_no_wait(self):
158171

159172
async def _close(self) -> None:
160173
"""Close connection"""
161-
if self.mcp_client:
162-
await self.mcp_client.__aexit__(None, None, None)
163-
self.mcp_client = None
174+
async with self._connect_lock:
175+
if self.mcp_client:
176+
await self.mcp_client.__aexit__(None, None, None)
177+
self.mcp_client = None
164178

165179
def _format_tools_for_openai(self):
166180
"""Convert MCP tool format to OpenAI tool format"""

src/amrita_core/types/preset.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ class ModelConfig(BaseModel):
3232
)
3333

3434

35-
class ThinkingConfig(BaseModel):
35+
class ThinkingConfig(
36+
BaseModel
37+
): # TODO: use model-adapter specific config like the structure of `Content`.
3638
"""Thinking/reasoning configuration for a model preset."""
3739

3840
thinking_type: Literal["enabled", "disabled"] | None = Field(

tests/test_mcp.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,11 @@ async def test_simple_call_with_exception(self, mcp_client):
8888
assert "Test error" in error_data["error"]
8989

9090
@pytest.mark.asyncio
91-
async def test_connect_already_connected(self, mcp_client):
91+
async def test_connect_already_connected_suppress(self, mcp_client):
9292
"""Test _connect when already connected"""
9393
mcp_client.mcp_client = AsyncMock()
9494

95-
with pytest.raises(RuntimeError, match="MCP Server is already connected!"):
96-
await mcp_client._connect()
95+
await mcp_client._connect()
9796

9897

9998
class TestMultiClientManager:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)