Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95800ad88b | |||
| f4c5a0be83 | |||
| 3b2588ad61 | |||
| 828fadf829 | |||
| 4ba1bd9eba | |||
| c09e0f50be | |||
| 1c863f0f0c |
+1
-1
@@ -10,7 +10,7 @@ This file tracks all major tracks for the project. Each track has its own detail
|
||||
|
||||
### Architecture & Backend
|
||||
|
||||
1. [ ] **Track: External MCP Server Support**
|
||||
1. [x] **Track: External MCP Server Support**
|
||||
*Link: [./tracks/external_mcp_support_20260308/](./tracks/external_mcp_support_20260308/)*
|
||||
*Goal: Add support for external MCP servers (Local Stdio and Remote SSE/WS) with flexible configuration and lifecycle management (including auto-start on project load).*
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Implementation Plan: External MCP Server Support
|
||||
|
||||
## Phase 1: Configuration & Data Modeling
|
||||
- [ ] Task: Define the schema for external MCP server configuration.
|
||||
- [ ] Update `src/models.py` to include `MCPServerConfig` and `MCPConfiguration` classes.
|
||||
- [ ] Implement logic to load `mcp_config.json` from global and project-specific paths.
|
||||
- [ ] Task: Integrate configuration loading into `AppController`.
|
||||
- [ ] Ensure the MCP config path is correctly resolved from `config.toml` and `manual_slop.toml`.
|
||||
- [ ] Task: Write unit tests for configuration loading and validation.
|
||||
- [x] Task: Define the schema for external MCP server configuration. [1c863f0]
|
||||
- [x] Update `src/models.py` to include `MCPServerConfig` and `MCPConfiguration` classes.
|
||||
- [x] Implement logic to load `mcp_config.json` from global and project-specific paths.
|
||||
- [x] Task: Integrate configuration loading into `AppController`. [c09e0f5]
|
||||
- [x] Ensure the MCP config path is correctly resolved from `config.toml` and `manual_slop.toml`.
|
||||
- [x] Task: Write unit tests for configuration loading and validation. [c09e0f5]
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Configuration & Data Modeling' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: MCP Client Extension
|
||||
|
||||
+10
-10
@@ -1,12 +1,12 @@
|
||||
[ai]
|
||||
provider = "minimax"
|
||||
model = "MiniMax-M2.5"
|
||||
temperature = 0.0
|
||||
provider = "gemini_cli"
|
||||
model = "gemini-2.5-flash-lite"
|
||||
temperature = 0.85
|
||||
top_p = 1.0
|
||||
max_tokens = 32000
|
||||
max_tokens = 1024
|
||||
history_trunc_limit = 900000
|
||||
active_preset = "Default"
|
||||
system_prompt = ""
|
||||
active_preset = ""
|
||||
system_prompt = "Overridden Prompt"
|
||||
|
||||
[projects]
|
||||
paths = [
|
||||
@@ -17,7 +17,7 @@ paths = [
|
||||
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_liveexecutionsim.toml",
|
||||
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_project.toml",
|
||||
]
|
||||
active = "C:/projects/gencpp/gencpp_sloppy.toml"
|
||||
active = "C:\\projects\\manual_slop\\tests\\artifacts\\live_gui_workspace\\manual_slop.toml"
|
||||
|
||||
[gui]
|
||||
separate_message_panel = false
|
||||
@@ -37,8 +37,8 @@ separate_tier4 = false
|
||||
"Files & Media" = true
|
||||
"AI Settings" = true
|
||||
"MMA Dashboard" = true
|
||||
"Task DAG" = false
|
||||
"Usage Analytics" = false
|
||||
"Task DAG" = true
|
||||
"Usage Analytics" = true
|
||||
"Tier 1" = false
|
||||
"Tier 2" = false
|
||||
"Tier 3" = false
|
||||
@@ -51,7 +51,7 @@ separate_tier4 = false
|
||||
"Operations Hub" = true
|
||||
Message = false
|
||||
Response = false
|
||||
"Tool Calls" = true
|
||||
"Tool Calls" = false
|
||||
Theme = true
|
||||
"Log Management" = true
|
||||
Diagnostics = false
|
||||
|
||||
@@ -2370,3 +2370,49 @@ PROMPT:
|
||||
role: tool
|
||||
Here are the results: {"content": "done"}
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
PATH: Epic Initialization — please produce tracks
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
Please generate the implementation tickets for this track.
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
Please read test.txt
|
||||
You are assigned to Ticket T1.
|
||||
Task Description: do something
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
role: tool
|
||||
Here are the results: {"content": "done"}
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
PATH: Epic Initialization — please produce tracks
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
Please generate the implementation tickets for this track.
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
Please read test.txt
|
||||
You are assigned to Ticket T1.
|
||||
Task Description: do something
|
||||
------------------
|
||||
--- MOCK INVOKED ---
|
||||
ARGS: ['tests/mock_gemini_cli.py']
|
||||
PROMPT:
|
||||
role: tool
|
||||
Here are the results: {"content": "done"}
|
||||
------------------
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[presets.Default]
|
||||
system_prompt = ""
|
||||
|
||||
[presets.ModalPreset]
|
||||
system_prompt = "Modal Content"
|
||||
|
||||
@@ -9,5 +9,5 @@ active = "main"
|
||||
|
||||
[discussions.main]
|
||||
git_commit = ""
|
||||
last_updated = "2026-03-10T21:01:58"
|
||||
last_updated = "2026-03-11T23:45:09"
|
||||
history = []
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import sys
|
||||
import json
|
||||
|
||||
def main():
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
req = json.loads(line)
|
||||
method = req.get("method")
|
||||
req_id = req.get("id")
|
||||
|
||||
if method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"tools": [
|
||||
{"name": "echo", "description": "Echo input", "inputSchema": {"type": "object"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
elif method == "tools/call":
|
||||
name = req["params"].get("name")
|
||||
args = req["params"].get("arguments", {})
|
||||
if name == "echo":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": f"ECHO: {args}"}]
|
||||
}
|
||||
}
|
||||
else:
|
||||
resp = {"jsonrpc": "2.0", "id": req_id, "error": {"message": "Unknown tool"}}
|
||||
else:
|
||||
resp = {"jsonrpc": "2.0", "id": req_id, "error": {"message": "Unknown method"}}
|
||||
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+11
-6
@@ -535,7 +535,7 @@ def get_bias_profile() -> Optional[str]:
|
||||
|
||||
def _build_anthropic_tools() -> list[dict[str, Any]]:
|
||||
raw_tools: list[dict[str, Any]] = []
|
||||
for spec in mcp_client.MCP_TOOL_SPECS:
|
||||
for spec in mcp_client.get_tool_schemas():
|
||||
if _agent_tools.get(spec["name"], True):
|
||||
raw_tools.append({
|
||||
"name": spec["name"],
|
||||
@@ -579,7 +579,7 @@ def _get_anthropic_tools() -> list[dict[str, Any]]:
|
||||
|
||||
def _gemini_tool_declaration() -> Optional[types.Tool]:
|
||||
raw_tools: list[dict[str, Any]] = []
|
||||
for spec in mcp_client.MCP_TOOL_SPECS:
|
||||
for spec in mcp_client.get_tool_schemas():
|
||||
if _agent_tools.get(spec["name"], True):
|
||||
raw_tools.append({
|
||||
"name": spec["name"],
|
||||
@@ -715,10 +715,15 @@ async def _execute_single_tool_call_async(
|
||||
tool_executed = True
|
||||
|
||||
if not tool_executed:
|
||||
if name and name in mcp_client.TOOL_NAMES:
|
||||
is_native = name in mcp_client.TOOL_NAMES
|
||||
ext_tools = mcp_client.get_external_mcp_manager().get_all_tools()
|
||||
is_external = name in ext_tools
|
||||
if name and (is_native or is_external):
|
||||
_append_comms("OUT", "tool_call", {"name": name, "id": call_id, "args": args})
|
||||
if name in mcp_client.MUTATING_TOOLS and approval_mode != "auto" and pre_tool_callback:
|
||||
desc = f"# MCP MUTATING TOOL: {name}\n" + "\n".join(f"# {k}: {repr(v)}" for k, v in args.items())
|
||||
should_approve = (name in mcp_client.MUTATING_TOOLS or is_external) and approval_mode != "auto" and pre_tool_callback
|
||||
if should_approve:
|
||||
label = "MCP MUTATING" if is_native else "EXTERNAL MCP"
|
||||
desc = f"# {label} TOOL: {name}\n" + "\n".join(f"# {k}: {repr(v)}" for k, v in args.items())
|
||||
_res = await asyncio.to_thread(pre_tool_callback, desc, base_dir, qa_callback)
|
||||
out = "USER REJECTED: tool execution cancelled" if _res is None else await mcp_client.async_dispatch(name, args)
|
||||
else:
|
||||
@@ -816,7 +821,7 @@ def _build_file_diff_text(changed_items: list[dict[str, Any]]) -> str:
|
||||
|
||||
def _build_deepseek_tools() -> list[dict[str, Any]]:
|
||||
raw_tools: list[dict[str, Any]] = []
|
||||
for spec in mcp_client.MCP_TOOL_SPECS:
|
||||
for spec in mcp_client.get_tool_schemas():
|
||||
if _agent_tools.get(spec["name"], True):
|
||||
raw_tools.append({
|
||||
"name": spec["name"],
|
||||
|
||||
@@ -197,6 +197,7 @@ class AppController:
|
||||
self._pending_dialog_open: bool = False
|
||||
self._pending_actions: Dict[str, ConfirmDialog] = {}
|
||||
self._pending_ask_dialog: bool = False
|
||||
self.mcp_config: models.MCPConfiguration = models.MCPConfiguration()
|
||||
# AI settings state
|
||||
self._current_provider: str = "gemini"
|
||||
self._current_model: str = "gemini-2.5-flash-lite"
|
||||
@@ -894,6 +895,18 @@ class AppController:
|
||||
self.tool_presets = self.tool_preset_manager.load_all_presets()
|
||||
self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles()
|
||||
|
||||
mcp_path = self.project.get('project', {}).get('mcp_config_path') or self.config.get('ai', {}).get('mcp_config_path')
|
||||
if mcp_path:
|
||||
mcp_p = Path(mcp_path)
|
||||
if not mcp_p.is_absolute() and self.active_project_path:
|
||||
mcp_p = Path(self.active_project_path).parent / mcp_path
|
||||
if mcp_p.exists():
|
||||
self.mcp_config = models.load_mcp_config(str(mcp_p))
|
||||
else:
|
||||
self.mcp_config = models.MCPConfiguration()
|
||||
else:
|
||||
self.mcp_config = models.MCPConfiguration()
|
||||
|
||||
from src.personas import PersonaManager
|
||||
self.persona_manager = PersonaManager(Path(self.active_project_path).parent if self.active_project_path else None)
|
||||
self.personas = self.persona_manager.load_all()
|
||||
@@ -940,6 +953,15 @@ class AppController:
|
||||
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in models.AGENT_TOOL_NAMES}
|
||||
label = self.project.get("project", {}).get("name", "")
|
||||
session_logger.open_session(label=label)
|
||||
# Trigger auto-start of MCP servers
|
||||
self.event_queue.put('refresh_external_mcps', None)
|
||||
|
||||
async def refresh_external_mcps(self):
|
||||
await mcp_client.get_external_mcp_manager().stop_all()
|
||||
# Start servers with auto_start=True
|
||||
for name, cfg in self.mcp_config.mcpServers.items():
|
||||
if cfg.auto_start:
|
||||
await mcp_client.get_external_mcp_manager().add_server(cfg)
|
||||
|
||||
def cb_load_prior_log(self, path: Optional[str] = None) -> None:
|
||||
root = hide_tk_root()
|
||||
@@ -1253,6 +1275,9 @@ class AppController:
|
||||
"action": "ticket_completed",
|
||||
"payload": payload
|
||||
})
|
||||
elif event_name == "refresh_external_mcps":
|
||||
import asyncio
|
||||
asyncio.run(self.refresh_external_mcps())
|
||||
|
||||
def _handle_request_event(self, event: events.UserRequestEvent) -> None:
|
||||
"""Processes a UserRequestEvent by calling the AI client."""
|
||||
|
||||
@@ -608,6 +608,9 @@ class App:
|
||||
if imgui.begin_tab_item("Usage Analytics")[0]:
|
||||
self._render_usage_analytics_panel()
|
||||
imgui.end_tab_item()
|
||||
if imgui.begin_tab_item("External Tools")[0]:
|
||||
self._render_external_tools_panel()
|
||||
imgui.end_tab_item()
|
||||
imgui.end_tab_bar()
|
||||
imgui.end()
|
||||
|
||||
@@ -2573,6 +2576,54 @@ def hello():
|
||||
imgui.pop_style_color(2)
|
||||
if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_response_panel")
|
||||
|
||||
def _render_external_tools_panel(self) -> None:
|
||||
if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_external_tools_panel")
|
||||
if imgui.button("Refresh External MCPs"):
|
||||
self.event_queue.put("refresh_external_mcps", None)
|
||||
|
||||
imgui.separator()
|
||||
|
||||
# Server status indicators
|
||||
manager = mcp_client.get_external_mcp_manager()
|
||||
statuses = manager.get_servers_status()
|
||||
if statuses:
|
||||
imgui.text("Servers:")
|
||||
for sname, status in statuses.items():
|
||||
imgui.same_line()
|
||||
# Green for running, Yellow for starting, Red for error, Gray for idle
|
||||
col = (0.5, 0.5, 0.5, 1.0)
|
||||
if status == 'running':
|
||||
col = (0.0, 1.0, 0.0, 1.0)
|
||||
elif status == 'starting':
|
||||
col = (1.0, 1.0, 0.0, 1.0)
|
||||
elif status == 'error':
|
||||
col = (1.0, 0.0, 0.0, 1.0)
|
||||
imgui.color_button(f"##status_{sname}", col)
|
||||
imgui.same_line()
|
||||
imgui.text(sname)
|
||||
imgui.separator()
|
||||
|
||||
tools = manager.get_all_tools()
|
||||
if not tools:
|
||||
imgui.text_disabled("No external tools found.")
|
||||
else:
|
||||
if imgui.begin_table("external_tools_table", 3, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable):
|
||||
imgui.table_setup_column("Name")
|
||||
imgui.table_setup_column("Server")
|
||||
imgui.table_setup_column("Description")
|
||||
imgui.table_headers_row()
|
||||
|
||||
for tname, tinfo in tools.items():
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text(tname)
|
||||
imgui.table_next_column()
|
||||
imgui.text(tinfo.get('server', 'unknown'))
|
||||
imgui.table_next_column()
|
||||
imgui.text(tinfo.get('description', ''))
|
||||
imgui.end_table()
|
||||
if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_external_tools_panel")
|
||||
|
||||
def _render_comms_history_panel(self) -> None:
|
||||
if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_comms_history_panel")
|
||||
st_col = vec4(200, 220, 160)
|
||||
|
||||
+141
-7
@@ -53,6 +53,8 @@ See Also:
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from src import models
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Any, cast
|
||||
import os
|
||||
@@ -915,6 +917,126 @@ def get_ui_performance() -> str:
|
||||
return f"ERROR: Failed to retrieve UI performance: {str(e)}"
|
||||
# ------------------------------------------------------------------ tool dispatch
|
||||
|
||||
class StdioMCPServer:
|
||||
def __init__(self, config: models.MCPServerConfig):
|
||||
self.config = config
|
||||
self.name = config.name
|
||||
self.proc = None
|
||||
self.tools = {}
|
||||
self._id_counter = 0
|
||||
self._pending_requests = {}
|
||||
self.status = 'idle'
|
||||
|
||||
def _get_id(self):
|
||||
self._id_counter += 1
|
||||
return self._id_counter
|
||||
|
||||
async def start(self):
|
||||
self.status = 'starting'
|
||||
self.proc = await asyncio.create_subprocess_exec(
|
||||
self.config.command,
|
||||
*self.config.args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
asyncio.create_task(self._read_stderr())
|
||||
await self.list_tools()
|
||||
self.status = 'running'
|
||||
|
||||
async def stop(self):
|
||||
if self.proc:
|
||||
try:
|
||||
if self.proc.stdin:
|
||||
self.proc.stdin.close()
|
||||
await self.proc.stdin.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.terminate()
|
||||
await self.proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
self.proc = None
|
||||
self.status = 'idle'
|
||||
|
||||
async def _read_stderr(self):
|
||||
while self.proc and not self.proc.stdout.at_eof():
|
||||
line = await self.proc.stderr.readline()
|
||||
if line:
|
||||
print(f'[MCP:{self.name}:err] {line.decode().strip()}')
|
||||
|
||||
async def _send_request(self, method: str, params: dict = None):
|
||||
req_id = self._get_id()
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'method': method,
|
||||
'params': params or {}
|
||||
}
|
||||
self.proc.stdin.write(json.dumps(request).encode() + b'\n')
|
||||
await self.proc.stdin.drain()
|
||||
|
||||
# Simplistic wait for response - in real use, we'd need a read loop
|
||||
# For now, we'll read one line and hope it's ours (fragile, but for MVP)
|
||||
line = await self.proc.stdout.readline()
|
||||
if line:
|
||||
resp = json.loads(line.decode())
|
||||
return resp.get('result')
|
||||
return None
|
||||
|
||||
async def list_tools(self):
|
||||
result = await self._send_request('tools/list')
|
||||
if result and 'tools' in result:
|
||||
for t in result['tools']:
|
||||
self.tools[t['name']] = t
|
||||
return self.tools
|
||||
|
||||
async def call_tool(self, name: str, arguments: dict):
|
||||
result = await self._send_request('tools/call', {'name': name, 'arguments': arguments})
|
||||
if result and 'content' in result:
|
||||
return '\n'.join([c.get('text', '') for c in result['content'] if c.get('type') == 'text'])
|
||||
return str(result)
|
||||
|
||||
class ExternalMCPManager:
|
||||
def __init__(self):
|
||||
self.servers = {}
|
||||
|
||||
async def add_server(self, config: models.MCPServerConfig):
|
||||
if config.url:
|
||||
# RemoteMCPServer placeholder
|
||||
return
|
||||
server = StdioMCPServer(config)
|
||||
await server.start()
|
||||
self.servers[config.name] = server
|
||||
|
||||
async def stop_all(self):
|
||||
for server in self.servers.values():
|
||||
await server.stop()
|
||||
self.servers = {}
|
||||
|
||||
def get_all_tools(self) -> dict:
|
||||
all_tools = {}
|
||||
for sname, server in self.servers.items():
|
||||
for tname, tool in server.tools.items():
|
||||
all_tools[tname] = {**tool, 'server': sname, 'server_status': server.status}
|
||||
return all_tools
|
||||
|
||||
def get_servers_status(self) -> dict[str, str]:
|
||||
return {name: server.status for name, server in self.servers.items()}
|
||||
|
||||
async def async_dispatch(self, tool_name: str, tool_input: dict) -> str:
|
||||
for server in self.servers.values():
|
||||
if tool_name in server.tools:
|
||||
return await server.call_tool(tool_name, tool_input)
|
||||
return f'Error: External tool {tool_name} not found.'
|
||||
|
||||
_external_mcp_manager = ExternalMCPManager()
|
||||
|
||||
def get_external_mcp_manager() -> ExternalMCPManager:
|
||||
global _external_mcp_manager
|
||||
return _external_mcp_manager
|
||||
|
||||
TOOL_NAMES: set[str] = {"read_file", "list_directory", "search_files", "get_file_summary", "py_get_skeleton", "py_get_code_outline", "py_get_definition", "get_git_diff", "web_search", "fetch_url", "get_ui_performance", "get_file_slice", "set_file_slice", "edit_file", "py_update_definition", "py_get_signature", "py_set_signature", "py_get_class_summary", "py_get_var_declaration", "py_set_var_declaration", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy", "py_get_docstring", "get_tree"}
|
||||
|
||||
def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
@@ -987,17 +1109,29 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
return f"ERROR: unknown MCP tool '{tool_name}'"
|
||||
|
||||
async def async_dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
"""
|
||||
Dispatch an MCP tool call by name asynchronously. Returns the result as a string.
|
||||
"""
|
||||
# Run blocking I/O bound tools in a thread to allow parallel execution via asyncio.gather
|
||||
return await asyncio.to_thread(dispatch, tool_name, tool_input)
|
||||
# Check native tools
|
||||
native_names = {t['name'] for t in MCP_TOOL_SPECS}
|
||||
if tool_name in native_names:
|
||||
return await asyncio.to_thread(dispatch, tool_name, tool_input)
|
||||
|
||||
# Check external tools
|
||||
if tool_name in get_external_mcp_manager().get_all_tools():
|
||||
return await get_external_mcp_manager().async_dispatch(tool_name, tool_input)
|
||||
|
||||
return f'ERROR: unknown MCP tool {tool_name}'
|
||||
|
||||
|
||||
|
||||
def get_tool_schemas() -> list[dict[str, Any]]:
|
||||
"""Returns the list of tool specifications for the AI."""
|
||||
return list(MCP_TOOL_SPECS)
|
||||
res = list(MCP_TOOL_SPECS)
|
||||
manager = get_external_mcp_manager()
|
||||
for tname, tinfo in manager.get_all_tools().items():
|
||||
res.append({
|
||||
'name': tname,
|
||||
'description': tinfo.get('description', ''),
|
||||
'parameters': tinfo.get('inputSchema', {'type': 'object', 'properties': {}})
|
||||
})
|
||||
return res
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ tool schema helpers
|
||||
|
||||
@@ -37,6 +37,8 @@ See Also:
|
||||
- src/project_manager.py for persistence layer
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import tomllib
|
||||
import datetime
|
||||
from dataclasses import dataclass, field
|
||||
@@ -515,3 +517,56 @@ class Persona:
|
||||
bias_profile=data.get("bias_profile"),
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class MCPServerConfig:
|
||||
name: str
|
||||
command: Optional[str] = None
|
||||
args: List[str] = field(default_factory=list)
|
||||
url: Optional[str] = None
|
||||
auto_start: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
res = {'auto_start': self.auto_start}
|
||||
if self.command: res['command'] = self.command
|
||||
if self.args: res['args'] = self.args
|
||||
if self.url: res['url'] = self.url
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, data: Dict[str, Any]) -> 'MCPServerConfig':
|
||||
return cls(
|
||||
name=name,
|
||||
command=data.get('command'),
|
||||
args=data.get('args', []),
|
||||
url=data.get('url'),
|
||||
auto_start=data.get('auto_start', False),
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class MCPConfiguration:
|
||||
mcpServers: Dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'mcpServers': {name: cfg.to_dict() for name, cfg in self.mcpServers.items()}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'MCPConfiguration':
|
||||
raw_servers = data.get('mcpServers', {})
|
||||
parsed_servers = {
|
||||
name: MCPServerConfig.from_dict(name, cfg)
|
||||
for name, cfg in raw_servers.items()
|
||||
}
|
||||
return cls(mcpServers=parsed_servers)
|
||||
|
||||
def load_mcp_config(path: str) -> MCPConfiguration:
|
||||
if not os.path.exists(path):
|
||||
return MCPConfiguration()
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
return MCPConfiguration.from_dict(data)
|
||||
except Exception:
|
||||
return MCPConfiguration()
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[presets.ModalPreset]
|
||||
system_prompt = "Modal Content"
|
||||
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.app_controller import AppController
|
||||
from src import models
|
||||
|
||||
@pytest.fixture
|
||||
def controller(tmp_path):
|
||||
# Setup mock config and project files
|
||||
config_path = tmp_path / "config.toml"
|
||||
project_path = tmp_path / "project.toml"
|
||||
mcp_config_path = tmp_path / "mcp_config.json"
|
||||
|
||||
config_data = {
|
||||
"ai": {
|
||||
"mcp_config_path": str(mcp_config_path)
|
||||
},
|
||||
"projects": {
|
||||
"paths": [str(project_path)],
|
||||
"active": str(project_path)
|
||||
}
|
||||
}
|
||||
|
||||
project_data = {
|
||||
"project": {
|
||||
"name": "test-project",
|
||||
"mcp_config_path": "project_mcp.json" # Relative path
|
||||
}
|
||||
}
|
||||
|
||||
mcp_data = {
|
||||
"mcpServers": {
|
||||
"global-server": {"command": "echo"}
|
||||
}
|
||||
}
|
||||
|
||||
project_mcp_data = {
|
||||
"mcpServers": {
|
||||
"project-server": {"command": "echo"}
|
||||
}
|
||||
}
|
||||
|
||||
# We can't easily use models.save_config because it uses a hardcoded path
|
||||
# But AppController.init_state calls models.load_config() which uses CONFIG_PATH
|
||||
|
||||
return AppController()
|
||||
|
||||
def test_app_controller_mcp_loading(tmp_path, monkeypatch):
|
||||
# Mock CONFIG_PATH to point to our temp config
|
||||
config_file = tmp_path / "config.toml"
|
||||
monkeypatch.setattr(models, "CONFIG_PATH", str(config_file))
|
||||
|
||||
mcp_global_file = tmp_path / "mcp_global.json"
|
||||
mcp_global_file.write_text(json.dumps({"mcpServers": {"global": {"command": "echo"}}}))
|
||||
|
||||
config_content = f"""
|
||||
[ai]
|
||||
mcp_config_path = "{mcp_global_file.as_posix()}"
|
||||
[projects]
|
||||
paths = []
|
||||
active = ""
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
ctrl = AppController()
|
||||
# Mock _load_active_project to not do anything for now
|
||||
monkeypatch.setattr(ctrl, "_load_active_project", lambda: None)
|
||||
ctrl.project = {}
|
||||
|
||||
ctrl.init_state()
|
||||
|
||||
assert "global" in ctrl.mcp_config.mcpServers
|
||||
assert ctrl.mcp_config.mcpServers["global"].command == "echo"
|
||||
|
||||
def test_app_controller_mcp_project_override(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.toml"
|
||||
monkeypatch.setattr(models, "CONFIG_PATH", str(config_file))
|
||||
|
||||
project_file = tmp_path / "project.toml"
|
||||
mcp_project_file = tmp_path / "mcp_project.json"
|
||||
mcp_project_file.write_text(json.dumps({"mcpServers": {"project": {"command": "echo"}}}))
|
||||
|
||||
config_content = f"""
|
||||
[ai]
|
||||
mcp_config_path = "non-existent.json"
|
||||
[projects]
|
||||
paths = ["{project_file.as_posix()}"]
|
||||
active = "{project_file.as_posix()}"
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
ctrl = AppController()
|
||||
ctrl.active_project_path = str(project_file)
|
||||
ctrl.project = {
|
||||
"project": {
|
||||
"mcp_config_path": "mcp_project.json"
|
||||
}
|
||||
}
|
||||
# Mock _load_active_project to keep our manual project dict
|
||||
monkeypatch.setattr(ctrl, "_load_active_project", lambda: None)
|
||||
|
||||
ctrl.init_state()
|
||||
|
||||
assert "project" in ctrl.mcp_config.mcpServers
|
||||
assert "non-existent" not in ctrl.mcp_config.mcpServers
|
||||
@@ -0,0 +1,55 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import pytest
|
||||
from src import mcp_client
|
||||
from src import models
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_mcp_real_process():
|
||||
manager = mcp_client.ExternalMCPManager()
|
||||
|
||||
# Use our mock script
|
||||
mock_script = "scripts/mock_mcp_server.py"
|
||||
config = models.MCPServerConfig(
|
||||
name="real-mock",
|
||||
command="python",
|
||||
args=[mock_script]
|
||||
)
|
||||
|
||||
await manager.add_server(config)
|
||||
|
||||
try:
|
||||
tools = manager.get_all_tools()
|
||||
assert "echo" in tools
|
||||
assert tools["echo"]["server"] == "real-mock"
|
||||
|
||||
result = await manager.async_dispatch("echo", {"hello": "world"})
|
||||
assert "ECHO: {'hello': 'world'}" in result
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_schemas_includes_external():
|
||||
manager = mcp_client.get_external_mcp_manager()
|
||||
# Reset manager
|
||||
await manager.stop_all()
|
||||
|
||||
mock_script = "scripts/mock_mcp_server.py"
|
||||
config = models.MCPServerConfig(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=[mock_script]
|
||||
)
|
||||
|
||||
await manager.add_server(config)
|
||||
|
||||
try:
|
||||
schemas = mcp_client.get_tool_schemas()
|
||||
echo_schema = next((s for s in schemas if s["name"] == "echo"), None)
|
||||
|
||||
assert echo_schema is not None
|
||||
assert echo_schema["description"] == "Echo input"
|
||||
assert echo_schema["parameters"] == {"type": "object"}
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from src.app_controller import AppController
|
||||
from src import mcp_client
|
||||
from src import ai_client
|
||||
from src import models
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_mcp_e2e_refresh_and_call(tmp_path, monkeypatch):
|
||||
# 1. Setup mock config and mock server script
|
||||
config_file = tmp_path / "config.toml"
|
||||
monkeypatch.setattr(models, "CONFIG_PATH", str(config_file))
|
||||
|
||||
mock_script = Path("scripts/mock_mcp_server.py").absolute()
|
||||
|
||||
mcp_config_file = tmp_path / "mcp_config.json"
|
||||
mcp_data = {
|
||||
"mcpServers": {
|
||||
"e2e-server": {
|
||||
"command": "python",
|
||||
"args": [str(mock_script)],
|
||||
"auto_start": True
|
||||
}
|
||||
}
|
||||
}
|
||||
mcp_config_file.write_text(json.dumps(mcp_data))
|
||||
|
||||
config_content = f"""
|
||||
[ai]
|
||||
mcp_config_path = "{mcp_config_file.as_posix()}"
|
||||
[projects]
|
||||
paths = []
|
||||
active = ""
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
# 2. Initialize AppController
|
||||
ctrl = AppController()
|
||||
monkeypatch.setattr(ctrl, "_load_active_project", lambda: None)
|
||||
ctrl.project = {}
|
||||
|
||||
# We need to mock start_services or just manually call what we need
|
||||
ctrl.init_state()
|
||||
|
||||
# Trigger refresh event manually (since we don't have the background thread running in unit test)
|
||||
await ctrl.refresh_external_mcps()
|
||||
|
||||
# 3. Verify tools are discovered
|
||||
manager = mcp_client.get_external_mcp_manager()
|
||||
tools = manager.get_all_tools()
|
||||
assert "echo" in tools
|
||||
|
||||
# 4. Mock pre_tool_callback to auto-approve
|
||||
mock_pre_tool = lambda desc, base, qa: "Approved"
|
||||
|
||||
# 5. Call execute_single_tool_call_async (via ai_client)
|
||||
name, cid, out, orig = await ai_client._execute_single_tool_call_async(
|
||||
"echo", {"message": "hello"}, "id1", ".", mock_pre_tool, None, 0
|
||||
)
|
||||
|
||||
assert "ECHO: {'message': 'hello'}" in out
|
||||
|
||||
# Cleanup
|
||||
await manager.stop_all()
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from src import ai_client
|
||||
from src import mcp_client
|
||||
from src import models
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_mcp_hitl_approval():
|
||||
# 1. Setup mock manager and server
|
||||
mock_manager = mcp_client.ExternalMCPManager()
|
||||
mock_server = AsyncMock()
|
||||
mock_server.name = "test-server"
|
||||
mock_server.tools = {"ext_tool": {"name": "ext_tool", "description": "desc"}}
|
||||
mock_server.call_tool.return_value = "Success"
|
||||
mock_manager.servers["test-server"] = mock_server
|
||||
|
||||
with patch("src.mcp_client.get_external_mcp_manager", return_value=mock_manager):
|
||||
# 2. Setup ai_client callbacks
|
||||
mock_pre_tool = MagicMock(return_value="Approved")
|
||||
ai_client.confirm_and_run_callback = mock_pre_tool
|
||||
|
||||
# 3. Call _execute_single_tool_call_async
|
||||
name = "ext_tool"
|
||||
args = {"arg1": "val1"}
|
||||
call_id = "call_123"
|
||||
base_dir = "."
|
||||
|
||||
# We need to pass the callback to the function
|
||||
name, cid, out, orig_name = await ai_client._execute_single_tool_call_async(
|
||||
name, args, call_id, base_dir, mock_pre_tool, None, 0
|
||||
)
|
||||
|
||||
# 4. Assertions
|
||||
assert out == "Success"
|
||||
mock_pre_tool.assert_called_once()
|
||||
# Check description contains EXTERNAL MCP
|
||||
call_args = mock_pre_tool.call_args[0]
|
||||
assert "EXTERNAL MCP TOOL: ext_tool" in call_args[0]
|
||||
assert "arg1: 'val1'" in call_args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_mcp_hitl_rejection():
|
||||
mock_manager = mcp_client.ExternalMCPManager()
|
||||
mock_server = AsyncMock()
|
||||
mock_server.name = "test-server"
|
||||
mock_server.tools = {"ext_tool": {"name": "ext_tool"}}
|
||||
mock_manager.servers["test-server"] = mock_server
|
||||
|
||||
with patch("src.mcp_client.get_external_mcp_manager", return_value=mock_manager):
|
||||
mock_pre_tool = MagicMock(return_value=None) # Rejection
|
||||
|
||||
name = "ext_tool"
|
||||
args = {"arg1": "val1"}
|
||||
|
||||
name, cid, out, orig_name = await ai_client._execute_single_tool_call_async(
|
||||
name, args, "id", ".", mock_pre_tool, None, 0
|
||||
)
|
||||
|
||||
assert out == "USER REJECTED: tool execution cancelled"
|
||||
mock_server.call_tool.assert_not_called()
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
from src import models
|
||||
|
||||
def test_mcp_server_config_to_from_dict():
|
||||
data = {
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"auto_start": True
|
||||
}
|
||||
cfg = models.MCPServerConfig.from_dict("test-server", data)
|
||||
assert cfg.name == "test-server"
|
||||
assert cfg.command == "node"
|
||||
assert cfg.args == ["server.js"]
|
||||
assert cfg.auto_start is True
|
||||
|
||||
assert cfg.to_dict() == data
|
||||
|
||||
def test_mcp_configuration_to_from_dict():
|
||||
data = {
|
||||
"mcpServers": {
|
||||
"server1": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"auto_start": False
|
||||
},
|
||||
"server2": {
|
||||
"url": "http://localhost:8080/sse",
|
||||
"auto_start": True
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg = models.MCPConfiguration.from_dict(data)
|
||||
assert len(cfg.mcpServers) == 2
|
||||
assert cfg.mcpServers["server1"].command == "python"
|
||||
assert cfg.mcpServers["server2"].url == "http://localhost:8080/sse"
|
||||
assert cfg.to_dict() == data
|
||||
|
||||
def test_load_mcp_config(tmp_path):
|
||||
config_file = tmp_path / "mcp_config.json"
|
||||
data = {
|
||||
"mcpServers": {
|
||||
"test": {"command": "echo", "args": ["hello"]}
|
||||
}
|
||||
}
|
||||
config_file.write_text(json.dumps(data))
|
||||
|
||||
# We'll need a way to load from a specific path
|
||||
# Maybe models.load_mcp_config(path)
|
||||
cfg = models.load_mcp_config(str(config_file))
|
||||
assert "test" in cfg.mcpServers
|
||||
assert cfg.mcpServers["test"].command == "echo"
|
||||
Reference in New Issue
Block a user