Skip to content

Commit b1cefa0

Browse files
Gh-Novelclaude
andcommitted
feat(agent): rewrite as a real MCP client running on local Ollama (Qwen 3)
The agent no longer imports tool functions or hardcodes schemas. It spawns the ThinkMCP server as a stdio subprocess, discovers all 13 tools over the protocol (tools/list), and invokes them with tools/call — the same integration path Claude Desktop uses. - agent/mcp_client.py: async MCP stdio client with Ollama-format schemas - agent/thinking_agent.py: Ollama (Qwen 3) loop with native thinking capture and <think>-tag fallback for instruct-only models - drop tool_executor.py / trace_parser.py (in-process dispatch and Anthropic-specific parsing are obsolete) - swap anthropic dependency for ollama; runs fully local Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eec6726 commit b1cefa0

5 files changed

Lines changed: 262 additions & 457 deletions

File tree

‎agent/mcp_client.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
MCP client wrapper — connects the agent to the ThinkMCP server over stdio.
3+
4+
The agent does NOT import tool functions directly: it spawns the MCP server
5+
as a subprocess, discovers tools via the protocol (tools/list), and invokes
6+
them with tools/call. This is the same path Claude Desktop / Cursor use.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
import sys
14+
from contextlib import AsyncExitStack
15+
from typing import Any
16+
17+
from mcp import ClientSession, StdioServerParameters
18+
from mcp.client.stdio import stdio_client
19+
20+
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21+
SERVER_PATH = os.path.join(_ROOT, "server", "mcp_server.py")
22+
23+
24+
class MCPToolClient:
25+
"""Async context manager holding a live MCP session to the ThinkMCP server.
26+
27+
Usage:
28+
async with MCPToolClient() as mcp:
29+
tools = mcp.ollama_tools # schemas in Ollama/OpenAI format
30+
out = await mcp.call_tool("web_search_tool", {"query": "..."})
31+
"""
32+
33+
def __init__(self, server_path: str = SERVER_PATH, env: dict[str, str] | None = None):
34+
self._server_path = server_path
35+
self._env = {**os.environ, **(env or {})}
36+
self._stack: AsyncExitStack | None = None
37+
self.session: ClientSession | None = None
38+
self.tools: list[Any] = []
39+
self.ollama_tools: list[dict] = []
40+
41+
async def __aenter__(self) -> "MCPToolClient":
42+
self._stack = AsyncExitStack()
43+
params = StdioServerParameters(
44+
command=sys.executable,
45+
args=[self._server_path],
46+
env=self._env,
47+
)
48+
read, write = await self._stack.enter_async_context(stdio_client(params))
49+
self.session = await self._stack.enter_async_context(ClientSession(read, write))
50+
await self.session.initialize()
51+
52+
listing = await self.session.list_tools()
53+
self.tools = listing.tools
54+
self.ollama_tools = [
55+
{
56+
"type": "function",
57+
"function": {
58+
"name": t.name,
59+
"description": t.description or "",
60+
"parameters": t.inputSchema
61+
or {"type": "object", "properties": {}, "required": []},
62+
},
63+
}
64+
for t in self.tools
65+
]
66+
return self
67+
68+
async def __aexit__(self, *exc_info) -> None:
69+
if self._stack is not None:
70+
await self._stack.aclose()
71+
self._stack = None
72+
self.session = None
73+
74+
async def call_tool(self, name: str, arguments: dict | None = None) -> str:
75+
"""Invoke a tool over MCP and flatten the result content to a string."""
76+
if self.session is None:
77+
raise RuntimeError("MCPToolClient is not connected — use 'async with'.")
78+
try:
79+
result = await self.session.call_tool(name, arguments=arguments or {})
80+
except Exception as exc:
81+
return json.dumps({"error": str(exc), "tool": name})
82+
83+
parts: list[str] = []
84+
for block in result.content:
85+
text = getattr(block, "text", None)
86+
if text is not None:
87+
parts.append(text)
88+
flat = "\n".join(parts) if parts else ""
89+
if getattr(result, "isError", False):
90+
return json.dumps({"error": flat or "tool returned an error", "tool": name})
91+
return flat

0 commit comments

Comments
 (0)