diff --git a/app/api.py b/app/api.py
index e4123eb..21bcb2b 100644
--- a/app/api.py
+++ b/app/api.py
@@ -10,6 +10,7 @@ from flask_cors import CORS
from flask_socketio import SocketIO
from workflows import graph_history_manager, run_agent_streaming
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
+import base64
api = Flask(__name__)
CORS(
@@ -260,6 +261,11 @@ def chat_stream():
# Запускаем стриминг ответа от LLM (используем обновленный агент)
accumulated_content = ""
+ # Вспомогательная функция для безопасной передачи JSON в HTML-атрибутах
+ def safe_b64(text):
+ if not text: return "e30=" # пустой json '{}'
+ return base64.b64encode(str(text).encode('utf-8')).decode('utf-8')
+
try:
for chunk_data in run_agent_streaming(graph_id, user_node_id,
assistant_node_id,
@@ -271,6 +277,27 @@ def chat_stream():
if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]:
if chunk_data.get("type") == "chunk":
accumulated_content += chunk_data.get("content", "")
+
+ elif chunk_data.get("type") == "tool_start":
+ # Встраиваем стартовый маркер в память базы данных
+ start_marker = f'\n\n
\n\n'
+ accumulated_content += start_marker
+
+ elif chunk_data.get("type") == "tool_end":
+ # Находим стартовый маркер и меняем его на финальный с данными
+ search_marker = f''
+
+ req_b64 = safe_b64(chunk_data.get("request"))
+ res_b64 = safe_b64(chunk_data.get("response"))
+ status = "error" if chunk_data.get("is_error") else "success"
+
+ end_marker = f''
+
+ if search_marker in accumulated_content:
+ accumulated_content = accumulated_content.replace(search_marker, end_marker)
+ else:
+ accumulated_content += f'\n\n{end_marker}\n\n'
+
yield f"data: {json.dumps(chunk_data)}\n\n"
# Обработка ошибки во время стриминга (крашим узел на фронте)
diff --git a/app/mcp_tools.py b/app/mcp_tools.py
new file mode 100644
index 0000000..178e989
--- /dev/null
+++ b/app/mcp_tools.py
@@ -0,0 +1,125 @@
+from pydantic import BaseModel, Field, create_model
+from langchain_core.tools import StructuredTool
+
+# ----------------- MCP MANAGER -----------------
+try:
+ from mcp.client.stdio import stdio_client, StdioServerParameters
+ from mcp.client.session import ClientSession
+ MCP_AVAILABLE = True
+except ImportError:
+ MCP_AVAILABLE = False
+
+_MCP_CACHED_TOOLS = {}
+
+def json_schema_to_pydantic(schema: dict, model_name: str) -> type[BaseModel]:
+ """Преобразует JSON Schema от MCP сервера в Pydantic модель для LangChain"""
+ fields = {}
+ properties = schema.get("properties", {})
+ required = schema.get("required", [])
+
+ for key, val in properties.items():
+ t = val.get("type", "string")
+ py_type = str
+ if t == "integer": py_type = int
+ elif t == "number": py_type = float
+ elif t == "boolean": py_type = bool
+ elif t == "array": py_type = list
+ elif t == "object": py_type = dict
+
+ desc = val.get("description", "")
+ if key in required:
+ fields[key] = (py_type, Field(..., description=desc))
+ else:
+ fields[key] = (py_type, Field(default=None, description=desc))
+
+ if not fields:
+ fields["kwargs"] = (dict, Field(default_factory=dict, description="Аргументы"))
+
+ return create_model(model_name, **fields)
+
+def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None):
+ """Обертка, которая поднимает контейнер/процесс ровно на 1 вызов тула и убивает его"""
+ args_schema = json_schema_to_pydantic(json_schema, f"MCP_{tool_name.replace('-','_')}_Schema")
+ command = server_config.get("command")
+
+ def mcp_tool_runner(**kwargs):
+ import asyncio
+ import json
+ input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
+
+ async def _run():
+ server_params = StdioServerParameters(command=command, args=args, env=full_env)
+ async with stdio_client(server_params) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.call_tool(tool_name, arguments=kwargs)
+ text_outputs = []
+ for c in result.content:
+ if hasattr(c, 'text'):
+ text_outputs.append(c.text)
+ else:
+ text_outputs.append(str(c))
+ return "\n".join(text_outputs)
+
+ raw_output = asyncio.run(_run())
+
+ if debug_callback:
+ # debug_callback(f"MCP: {tool_name}\n" + input_json, raw_output)
+ debug_callback(kwargs, raw_output)
+
+ return f"Инструмент '{tool_name}' вернул следующий результат:\n\n{raw_output}\n"
+
+ return StructuredTool.from_function(
+ func=mcp_tool_runner,
+ name=tool_name.replace('-','_'), # Langchain не любит тире в именах
+ description=tool_desc or f"MCP Tool {tool_name}",
+ args_schema=args_schema
+ )
+
+def fetch_mcp_tools(server_config, debug_callback=None):
+ """Один раз читает список тулов от сервера и кеширует их схемы"""
+ if not MCP_AVAILABLE:
+ print("⚠️ MCP серверы настроены, но библиотека 'mcp' не установлена. Выполните: pip install mcp")
+ return []
+
+ import asyncio
+ import shlex
+ import os
+
+ config_hash = str(server_config) # primitive hash
+ if config_hash in _MCP_CACHED_TOOLS:
+ return _MCP_CACHED_TOOLS[config_hash]
+
+ async def _fetch():
+ command = server_config.get("command", "")
+ args_str = server_config.get("args", "")
+ args = shlex.split(args_str) if args_str else []
+
+ env_dict = {}
+ env_str = server_config.get("envString", "")
+ if env_str:
+ for pair in env_str.split(','):
+ if '=' in pair:
+ k, v = pair.split('=', 1)
+ env_dict[k.strip()] = v.strip()
+
+ full_env = {**os.environ.copy(), **env_dict}
+ server_params = StdioServerParameters(command=command, args=args, env=full_env)
+
+ async with stdio_client(server_params) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ tools_resp = await session.list_tools()
+ return tools_resp.tools, full_env, args
+
+ try:
+ raw_tools, full_env, args = asyncio.run(_fetch())
+ wrapped_tools = []
+ for t in raw_tools:
+ wrapped_tools.append(create_mcp_tool(server_config, t.name, t.description, t.inputSchema, full_env, args, debug_callback))
+ _MCP_CACHED_TOOLS[config_hash] = wrapped_tools
+ print(f"🔌 Успешно загружено {len(wrapped_tools)} инструментов от MCP-сервера '{server_config.get('name')}'")
+ return wrapped_tools
+ except Exception as e:
+ print(f"❌ Ошибка инициализации MCP сервера {server_config.get('name')}: {e}")
+ return []
diff --git a/app/react_agent.py b/app/react_agent.py
index ec91bcf..8ee501c 100644
--- a/app/react_agent.py
+++ b/app/react_agent.py
@@ -16,9 +16,10 @@ import json
import os
import sys
from typing import Any, get_type_hints
-from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field, create_model
+from mcp_tools import fetch_mcp_tools
+
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict, debug_callback=None):
"""Инициализация ядра на базе LangGraph."""
m_cfg = MODELS.get(model_name)
@@ -166,6 +167,8 @@ finally:
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
"""Собирает инструменты двух видов на основе настроек Obsidian."""
tools = []
+
+ # Инициализация внутренних инструментов (скриптов)
custom_tools = obsidian_settings.get("customTools", [])
for ct in custom_tools:
@@ -247,6 +250,12 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
)
tools.append(help_tool)
+ # Инициализация внутренних MCP серверов
+ mcp_servers = obsidian_settings.get("mcpServers", [])
+ for mcp_server in mcp_servers:
+ mcp_tools = fetch_mcp_tools(mcp_server, debug_callback)
+ tools.extend(mcp_tools)
+
return tools
def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
diff --git a/app/test_mcp.py b/app/test_mcp.py
new file mode 100644
index 0000000..d803e9d
--- /dev/null
+++ b/app/test_mcp.py
@@ -0,0 +1,26 @@
+# test_mcp.py
+import asyncio
+import sys
+print(f"Python: {sys.version}")
+
+from mcp.client.stdio import stdio_client, StdioServerParameters
+from mcp.client.session import ClientSession
+
+async def test():
+ params = StdioServerParameters(
+ command="podman",
+ args=["run", "-i", "--rm", "ddg-mcp-server"],
+ env=None
+ )
+ print("opening stdio_client...")
+ async with stdio_client(params) as (read, write):
+ print("creating session...")
+ async with ClientSession(read, write) as session:
+ print("initializing...")
+ await session.initialize()
+ print("OK!")
+ tools = await session.list_tools()
+ for t in tools.tools:
+ print(f" - {t.name}")
+
+asyncio.run(test())
\ No newline at end of file
diff --git a/app/workflows.py b/app/workflows.py
index d161315..2a6d504 100644
--- a/app/workflows.py
+++ b/app/workflows.py
@@ -105,6 +105,7 @@ def run_agent_streaming(graph_id: str,
# 4. Выполняем цикл с потоковой передачей событий
step_counter = 0
+ tool_mapping = {} # Хранит связку: ID вызова -> Номер шага
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
@@ -115,6 +116,10 @@ def run_agent_streaming(graph_id: str,
for tc in chunk.tool_call_chunks:
if "name" in tc and tc["name"]:
step_counter += 1
+ tc_id = tc.get("id", f"unknown_{step_counter}")
+ # Сохраняем шаг для этого конкретного вызова
+ tool_mapping[tc_id] = step_counter
+
yield {
"type": "tool_start",
"name": tc["name"],
@@ -132,25 +137,33 @@ def run_agent_streaming(graph_id: str,
# Тул отработал и принес ответ
elif hasattr(chunk, "type") and chunk.type == "tool":
+ req = ""
+ res = str(chunk.content)
+
+ # Достаем JSON отправленных параметров из последнего debug_callback
+ if current_run_debug:
+ dbg = current_run_debug.pop(0) # Берем первый из очереди
+ req = dbg.get("req", "")
+
+ # Ловушка для определения ошибок
+ is_error = False
+ if getattr(chunk, "status", "") == "error" or "ОШИБКА ИСПОЛНЕНИЯ" in res or "SCRIPT RUNTIME ERROR" in res:
+ is_error = True
+
+ # Достаем ИМЕННО ТОТ ШАГ, на котором начинался вызов этого инструмента
+ tc_id = getattr(chunk, "tool_call_id", "")
+ actual_step = tool_mapping.get(tc_id, step_counter) # Fallback, если id нет
+
yield {
"type": "tool_end",
- "name": chunk.name,
- "step": step_counter,
- "node_id": assistant_node_id
+ "name": getattr(chunk, "name", "tool"),
+ "step": actual_step,
+ "node_id": assistant_node_id,
+ "request": req,
+ "response": res,
+ "is_error": is_error
}
- # В САМОМ КОНЦЕ, после того как агент закончил работу (вышел из цикла)
- if current_run_debug and obsidian_settings.get('showDebugInfo'):
- debug_section = "\n\n---\n" + "\n\n---\n".join([
- f"**JSON Request:**\n```json\n{d['req']}\n```\n**Result:**\n```text\n{d['res']}\n```"
- for d in current_run_debug
- ])
- yield {
- "type": "chunk",
- "content": debug_section,
- "node_id": assistant_node_id
- }
-
except Exception as e:
print(f"Ошибка при стриминге: {e}")
import traceback