diff --git a/.tags b/.tags index 6285486..99b6ca1 100644 --- a/.tags +++ b/.tags @@ -221,6 +221,8 @@ _save_patterns build/lib/mcp_servers/pattern_manager/pattern_server.py /^ def _save_patterns mcp_servers/pattern_manager/pattern_server.py /^ def _save_patterns(self):$/;" m class:PatternServer _signal_handler build/lib/mcp_browser/daemon.py /^ def _signal_handler(self, signum, frame):$/;" m class:MCPBrowserDaemon _signal_handler mcp_browser/daemon.py /^ def _signal_handler(self, signum, frame):$/;" m class:MCPBrowserDaemon +_start_config_watcher build/lib/mcp_browser/proxy.py /^ async def _start_config_watcher(self):$/;" m class:MCPBrowser +_start_config_watcher mcp_browser/proxy.py /^ async def _start_config_watcher(self):$/;" m class:MCPBrowser _task_add build/lib/mcp_servers/memory/memory_server.py /^ async def _task_add(self, args: Dict[str, Any]) -> Dict[str, Any]:$/;" m class:MemoryServer _task_add mcp_servers/memory/memory_server.py /^ async def _task_add(self, args: Dict[str, Any]) -> Dict[str, Any]:$/;" m class:MemoryServer _task_list build/lib/mcp_servers/memory/memory_server.py /^ async def _task_list(self, args: Dict[str, Any]) -> Dict[str, Any]:$/;" m class:MemoryServer @@ -229,6 +231,8 @@ _task_update build/lib/mcp_servers/memory/memory_server.py /^ async def _task _task_update mcp_servers/memory/memory_server.py /^ async def _task_update(self, args: Dict[str, Any]) -> Dict[str, Any]:$/;" m class:MemoryServer _test_pattern build/lib/mcp_servers/pattern_manager/pattern_server.py /^ async def _test_pattern(self, args: Dict[str, Any]) -> Dict[str, Any]:$/;" m class:PatternServer _test_pattern mcp_servers/pattern_manager/pattern_server.py /^ async def _test_pattern(self, args: Dict[str, Any]) -> Dict[str, Any]:$/;" m class:PatternServer +_update_server_configs build/lib/mcp_browser/proxy.py /^ def _update_server_configs(self):$/;" m class:MCPBrowser +_update_server_configs mcp_browser/proxy.py /^ def _update_server_configs(self):$/;" m class:MCPBrowser add_message_handler build/lib/mcp_browser/server.py /^ def add_message_handler(self, handler: Callable[[dict], None]):$/;" m class:MCPServer add_message_handler mcp_browser/server.py /^ def add_message_handler(self, handler: Callable[[dict], None]):$/;" m class:MCPServer add_server build/lib/mcp_browser/multi_server.py /^ async def add_server(self, name: str, config: MCPServerConfig):$/;" m class:MultiServerManager @@ -264,8 +268,6 @@ close mcp_browser/proxy.py /^ async def close(self):$/;" m class:MCPBrowser cmdclass setup.py /^ cmdclass={$/;" v completion build/lib/mcp_browser/__main__.py /^ completion = subparsers.add_parser("completion", help="Get completion")$/;" v completion mcp_browser/__main__.py /^ completion = subparsers.add_parser("completion", help="Get completion")$/;" v -config build/lib/mcp_browser/__main__.py /^ config = loader.load()$/;" v -config mcp_browser/__main__.py /^ config = loader.load()$/;" v config_path build/lib/mcp_browser/__main__.py /^ config_path=config_path,$/;" v config_path build/lib/mcp_browser/__main__.py /^ config_path = Path(args.config) if args.config else None$/;" v config_path mcp_browser/__main__.py /^ config_path=config_path,$/;" v @@ -394,18 +396,13 @@ jsonrpc mcp_browser/__main__.py /^ jsonrpc = subparsers.add_parser("jsonrpc", keywords setup.py /^ keywords="mcp model-context-protocol ai llm tools json-rpc",$/;" v kill_daemon_with_children build/lib/mcp_browser/daemon.py /^def kill_daemon_with_children(socket_path: Path) -> bool:$/;" f kill_daemon_with_children mcp_browser/daemon.py /^def kill_daemon_with_children(socket_path: Path) -> bool:$/;" f -list_prompts build/lib/mcp_browser/__main__.py /^ list_prompts = subparsers.add_parser("prompts-list", help="List available prompts")$/;" v -list_prompts mcp_browser/__main__.py /^ list_prompts = subparsers.add_parser("prompts-list", help="List available prompts")$/;" v -list_resources build/lib/mcp_browser/__main__.py /^ list_resources = subparsers.add_parser("resources-list", help="List available resources")$/;" v -list_resources mcp_browser/__main__.py /^ list_resources = subparsers.add_parser("resources-list", help="List available resources")$/;" v -list_tools build/lib/mcp_browser/__main__.py /^ list_tools = subparsers.add_parser("tools-list", help="List available tools")$/;" v -list_tools mcp_browser/__main__.py /^ list_tools = subparsers.add_parser("tools-list", help="List available tools")$/;" v load build/lib/mcp_browser/config.py /^ def load(self) -> MCPBrowserConfig:$/;" m class:ConfigLoader load mcp_browser/config.py /^ def load(self) -> MCPBrowserConfig:$/;" m class:ConfigLoader load_config build/lib/mcp_browser/default_configs.py /^ def load_config(self) -> dict:$/;" m class:ConfigManager load_config mcp_browser/default_configs.py /^ def load_config(self) -> dict:$/;" m class:ConfigManager loader build/lib/mcp_browser/__main__.py /^ loader = ConfigLoader()$/;" v loader mcp_browser/__main__.py /^ loader = ConfigLoader()$/;" v +log_test test_mcp_protocol.py /^def log_test(msg):$/;" f long_description setup.py /^ long_description = fh.read()$/;" v long_description setup.py /^ long_description=long_description,$/;" v long_description_content_type setup.py /^ long_description_content_type="text\/markdown",$/;" v @@ -417,6 +414,7 @@ main examples/basic_usage.py /^async def main():$/;" f main mcp_browser/__main__.py /^def main():$/;" f main mcp_browser/client_main.py /^def main():$/;" f main mcp_browser/daemon_main.py /^def main():$/;" f +main test_mcp_protocol.py /^async def main():$/;" f main tests/test_browser_functionality.py /^def main():$/;" f main tests/test_integration.py /^async def main():$/;" f mark_handled build/lib/mcp_browser/filter.py /^ def mark_handled(self, request_id: Union[str, int]):$/;" m class:MessageFilter @@ -512,19 +510,27 @@ test_brave_search_integration tests/test_brave_search.py /^async def test_brave_ test_browser_without_servers tests/test_basic.py /^ async def test_browser_without_servers(self):$/;" m class:TestMCPBrowser test_browser_without_servers tests/test_browser_functionality.py /^async def test_browser_without_servers():$/;" f test_claude_connection tests/test_claude_connection.py /^async def test_claude_connection():$/;" f +test_daemon_initialization test_mcp_protocol.py /^async def test_daemon_initialization():$/;" f +test_direct_initialization test_mcp_protocol.py /^async def test_direct_initialization():$/;" f test_discover_jsonpath tests/test_basic.py /^ def test_discover_jsonpath(self):$/;" m class:TestToolRegistry +test_discovery test_discovery.py /^async def test_discovery():$/;" f +test_double_handshake_issue test_mcp_protocol.py /^async def test_double_handshake_issue():$/;" f test_duplicate_error_filtering tests/test_basic.py /^ def test_duplicate_error_filtering(self):$/;" m class:TestMessageFilter test_error_handling tests/test_integration.py /^async def test_error_handling():$/;" f test_message_filter tests/test_simple.py /^def test_message_filter():$/;" f test_onboarding tests/test_onboarding.py /^async def test_onboarding():$/;" f +test_screen_utf8 test_screen_utf8.py /^async def test_screen_utf8():$/;" f test_server_connection build/lib/mcp_browser/__main__.py /^async def test_server_connection(browser: MCPBrowser, server_name: Optional[str] = None):$/;" f test_server_connection mcp_browser/__main__.py /^async def test_server_connection(browser: MCPBrowser, server_name: Optional[str] = None):$/;" f +test_server_mode_initialization test_mcp_protocol.py /^async def test_server_mode_initialization():$/;" f +test_server_mode_timeout test_claude_desktop_flow.py /^async def test_server_mode_timeout():$/;" f test_sparse_mode tests/test_simple.py /^def test_sparse_mode():$/;" f test_sparse_mode_filtering tests/test_basic.py /^ def test_sparse_mode_filtering(self):$/;" m class:TestMessageFilter test_sparse_tools tests/test_basic.py /^ def test_sparse_tools(self):$/;" m class:TestToolRegistry test_tool_registry tests/test_simple.py /^def test_tool_registry():$/;" f test_update_tools tests/test_basic.py /^ def test_update_tools(self):$/;" m class:TestToolRegistry test_virtual_tool_handling tests/test_basic.py /^ async def test_virtual_tool_handling(self):$/;" m class:TestMCPBrowser +test_with_logging test_claude_desktop_flow.py /^async def test_with_logging():$/;" f to_json build/lib/mcp_browser/registry.py /^ def to_json(self) -> str:$/;" m class:ToolRegistry to_json mcp_browser/registry.py /^ def to_json(self) -> str:$/;" m class:ToolRegistry trace build/lib/mcp_browser/logging_config.py /^def trace(self, message, *args, **kwargs):$/;" f @@ -539,3 +545,5 @@ user_options setup.py /^ user_options = []$/;" v class:TestCommand version build/lib/mcp_browser/__main__.py /^ version=f"%(prog)s {__version__}",$/;" v version mcp_browser/__main__.py /^ version=f"%(prog)s {__version__}",$/;" v version setup.py /^ version="0.1.0",$/;" v +watch_config build/lib/mcp_browser/proxy.py /^ async def watch_config():$/;" f function:MCPBrowser._start_config_watcher +watch_config mcp_browser/proxy.py /^ async def watch_config():$/;" f function:MCPBrowser._start_config_watcher diff --git a/HANDOFF_INSTRUCTIONS.md b/HANDOFF_INSTRUCTIONS.md new file mode 100644 index 0000000..dc353e6 --- /dev/null +++ b/HANDOFF_INSTRUCTIONS.md @@ -0,0 +1,267 @@ +# Claude Memory (cmem) Handoff System Guide + +## Overview + +The `cmem` (Claude Memory) handoff system enables seamless AI assistant transitions by providing persistent memory and context across sessions. This is critical for long-running projects where different AI instances need to understand previous work and continue development effectively. + +## Core Concepts + +### 1. Session-Based Memory +- **Sessions**: Each AI work session is tracked with timestamps and outcomes +- **Auto-rotation**: Sessions automatically rotate after 4 hours to maintain context freshness +- **Session Names**: Descriptive names like "Morning Development", "Late Night Development" + +### 2. Structured Knowledge Types +- **Tasks**: Open, in-progress, and completed work items with priorities and assignees +- **Decisions**: Important choices with reasoning and alternatives considered +- **Patterns**: Recurring insights or learnings with effectiveness tracking +- **Knowledge**: Categorized information storage for facts and discoveries + +### 3. Handoff Context +The handoff system provides incoming AIs with: +- Current session status and duration +- Active/pending tasks requiring attention +- Recent decisions that affect current work +- High-priority patterns that influence approach +- Project intelligence metrics + +## Using the Handoff System + +### For Incoming AI Assistants + +**Step 1: Get Handoff Summary** +```bash +cmem handoff +``` +This provides a markdown summary optimized for AI consumption with: +- Current session context +- Active tasks requiring attention +- Recent decisions influencing work +- Key patterns to apply +- Project statistics + +**Step 2: Get Detailed Context** +```bash +cmem context +``` +This provides JSON data with complete structured information: +- Full task details with IDs and metadata +- Complete decision history with alternatives +- Pattern effectiveness and frequency data +- Session tracking information + +**Step 3: Continue or Start New Session** +Based on handoff information: +- If session < 4 hours old: Continue current session +- If session > 4 hours old: Auto-rotation will start new session +- Use `cmem session start "New Session Name"` for manual session creation + +### For Outgoing AI Assistants + +**Before Ending Work:** +1. **Complete Tasks**: Update any finished work + ```bash + cmem task complete + ``` + +2. **Record Decisions**: Document important choices made + ```bash + cmem decision "Decision" "Reasoning" "Alternative1,Alternative2" + ``` + +3. **Add Patterns**: Capture learnings for future AIs + ```bash + cmem pattern add "Pattern Name" "Description" --priority high + ``` + +4. **Update Knowledge**: Store important discoveries + ```bash + cmem knowledge add "key" "value" --category "category" + ``` + +5. **End Session** (optional): + ```bash + cmem session end "Outcome description" + ``` + +## Integration with MCP Browser + +The MCP Browser memory server automatically syncs with cmem when available: + +### Automatic Sync +- **Task Operations**: Adding, updating, completing tasks sync to cmem +- **Pattern Creation**: New patterns are automatically added to cmem +- **Decision Recording**: Decisions made through MCP are stored in cmem +- **Identity-Based Storage**: Each identity gets separate memory space + +### Identity System +```bash +# Use onboarding tool with identity-specific instructions +onboarding identity="ProjectName" instructions="Focus on code quality" + +# Memory server uses identity for separate storage +# cmem integration syncs under that identity context +``` + +### Bidirectional Flow +1. **MCP โ†’ cmem**: Tool operations automatically sync to persistent storage +2. **cmem โ†’ MCP**: Memory server can read cmem data for context +3. **Cross-Session**: Patterns and decisions persist across AI instances + +## Handoff Data Structure + +### Session Information +```json +{ + "session": { + "id": "2025-06-28-morning-development", + "name": "Morning Development", + "startTime": "2025-06-28T09:31:17.858Z", + "status": "active" + } +} +``` + +### Task Structure +```json +{ + "id": "43801be2", + "description": "Task description", + "priority": "high|medium|low", + "status": "open|in_progress|completed", + "assignee": "assignee_name", + "createdAt": "2025-06-26T02:59:51.654Z" +} +``` + +### Decision Structure +```json +{ + "id": "793cbd6e", + "decision": "Decision made", + "reasoning": "Why this was chosen", + "alternatives": ["Alt 1", "Alt 2"], + "timestamp": "2025-06-26T14:48:36.187Z" +} +``` + +### Pattern Structure +```json +{ + "id": "03c8e07c", + "pattern": "Pattern Name", + "description": "Detailed description", + "priority": "high|medium|low", + "effectiveness": 0.8, + "frequency": 5 +} +``` + +## Best Practices for AI Handoffs + +### 1. **Read Before Acting** +Always check handoff information before starting work: +```bash +# Quick check +cmem handoff + +# Detailed context for complex work +cmem context +``` + +### 2. **Maintain Context Continuity** +- Continue existing sessions when < 4 hours old +- Reference previous decisions in new work +- Apply high-priority patterns to current tasks +- Use established assignee names for consistency + +### 3. **Document Decisions** +Record ANY significant choice: +- Technology selections +- Architecture decisions +- Approach changes +- Problem-solving strategies + +### 4. **Pattern Recognition** +Capture insights that will help future AIs: +- Recurring problems and solutions +- Effective approaches +- Things to avoid +- Meta-patterns about the development process + +### 5. **Task Management** +- Break large work into trackable tasks +- Update status as work progresses +- Complete tasks when finished +- Create new tasks for discovered work + +## Example Handoff Workflow + +### Incoming AI Workflow +```bash +# 1. Get handoff summary +cmem handoff + +# 2. Check specific task details +cmem task list + +# 3. Review recent patterns +cmem pattern list --priority high + +# 4. Start work based on active tasks +# ... do work ... + +# 5. Update progress +cmem task update in_progress +``` + +### Outgoing AI Workflow +```bash +# 1. Complete finished tasks +cmem task complete + +# 2. Document decisions made +cmem decision "Use Docker for Firecrawl" "Simpler deployment" "Native install,VM" + +# 3. Add learning patterns +cmem pattern add "Test all new features" "Always add tests before committing" --priority high + +# 4. Create tasks for remaining work +cmem task add "Fix failing tests" --priority high --assignee next-ai + +# 5. End session with outcome +cmem session end "Completed MCP browser enhancements with tests" +``` + +## Integration with Development Workflow + +### Pre-Commit Checklist +- [ ] All tasks updated with current status +- [ ] New decisions documented with reasoning +- [ ] Patterns captured from development process +- [ ] Knowledge updated with discoveries +- [ ] Next tasks created for continuation + +### Session Management +- **Short sessions (< 1 hour)**: Continue existing session +- **Medium sessions (1-4 hours)**: Continue or start new based on context +- **Long sessions (> 4 hours)**: Auto-rotation creates new session + +### Cross-Project Context +- Use identity parameter for project-specific contexts +- Different projects maintain separate memory spaces +- Patterns can be shared across projects when relevant + +## Error Handling + +### When cmem is Unavailable +- MCP memory server gracefully degrades to local storage +- Sync attempts fail silently without breaking functionality +- Manual sync possible when cmem becomes available + +### Memory Conflicts +- Sessions auto-rotate to prevent conflicts +- Task IDs are unique across sessions +- Patterns merge based on similarity detection + +This handoff system ensures smooth AI transitions and maintains project continuity across multiple development sessions. \ No newline at end of file diff --git a/README.md b/README.md index 1a4fcef..d5ddab0 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,10 @@ pip install git+https://github.com/Xilope0/mcp-browser.git ## Quick Start ```bash -# Run interactive mode +# Run enhanced interactive mode (NEW!) +./mcp-browser-interactive + +# Run basic interactive mode mcp-browser # Run as MCP server (for chaining) @@ -139,6 +142,39 @@ In sparse mode (default), only 3 tools are initially visible: All other tools (potentially hundreds) are hidden but fully accessible through these meta-tools. +## Enhanced Interactive Mode + +The new `./mcp-browser-interactive` provides a much better testing and exploration experience: + +**Features:** +- **Tab completion** for commands and tool names +- **Command history** with readline support +- **Smart argument parsing** with key=value syntax +- **Built-in help** and tool discovery +- **Test mode** to try tools with sample data +- **Direct tool calls** without verbose JSON-RPC syntax + +**Interactive Commands:** +```bash +help # Show available commands +list [pattern] # List tools (with optional filter) +discover # Explore using JSONPath +call key=value # Call tool with arguments +test # Test tool with sample data + key=value # Direct tool call (shortcut) +onboard # Manage onboarding instructions +status # Show connection status +``` + +**Example Session:** +```bash +mcp> list bash # Find bash-related tools +mcp> discover $.tools[*].name # List all tool names +mcp> test Bash # Test Bash tool +mcp> Bash command="ls -la" # Direct tool call +mcp> onboard Claude "Focus on code quality" # Set onboarding +``` + ## Design Principles 1. **Generic**: No tool-specific knowledge built into the browser diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 6243a9d..fe0c072 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -3,6 +3,10 @@ ``` ./ setup.py + test_claude_desktop_flow.py + test_discovery.py + test_mcp_protocol.py + test_screen_utf8.py mcp_browser.egg-info/ systemd/ build/ diff --git a/mcp-browser-interactive b/mcp-browser-interactive new file mode 100755 index 0000000..aa9c52a --- /dev/null +++ b/mcp-browser-interactive @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +MCP Browser Interactive Mode Launcher +""" + +import sys +import asyncio +from pathlib import Path + +# Add the package to path +sys.path.insert(0, str(Path(__file__).parent)) + +from mcp_browser.interactive_client import main + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/mcp_browser/interactive_client.py b/mcp_browser/interactive_client.py new file mode 100644 index 0000000..22cc98b --- /dev/null +++ b/mcp_browser/interactive_client.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +""" +Enhanced Interactive MCP Browser Client + +Provides a user-friendly interactive interface for exploring and using MCP tools +with better discovery, autocompletion, and testing capabilities. +""" + +import asyncio +import json +import readline +import sys +from pathlib import Path +from typing import Dict, Any, List, Optional +import traceback + +from .proxy import MCPBrowser +from .daemon import MCPBrowserClient, get_socket_path, is_daemon_running +from .logging_config import get_logger + + +class InteractiveMCPClient: + """Enhanced interactive MCP browser client.""" + + def __init__(self, server_name: Optional[str] = None, use_daemon: bool = True): + self.server_name = server_name + self.use_daemon = use_daemon + self.browser: Optional[MCPBrowser] = None + self.client: Optional[MCPBrowserClient] = None + self.logger = get_logger(__name__) + self.tool_cache: Dict[str, Any] = {} + self.command_history: List[str] = [] + + # Setup readline + self._setup_readline() + + def _setup_readline(self): + """Setup readline for better command line experience.""" + readline.set_completer(self._completer) + readline.parse_and_bind('tab: complete') + readline.set_completer_delims(' \t\n`!@#$%^&*()=+[{]}\\|;:\'",<>?') + + # Load history + history_file = Path.home() / ".mcp_browser_history" + try: + readline.read_history_file(str(history_file)) + except FileNotFoundError: + pass + + # Save history on exit + import atexit + atexit.register(readline.write_history_file, str(history_file)) + + def _completer(self, text: str, state: int) -> Optional[str]: + """Tab completion for commands and tool names.""" + if state == 0: + # Get current line + line = readline.get_line_buffer() + + # Complete commands + commands = ['discover', 'call', 'list', 'help', 'quit', 'onboard', 'status', 'test'] + + # Add tool names if we have them cached + if self.tool_cache: + commands.extend(self.tool_cache.keys()) + + # Filter matches + self.matches = [cmd for cmd in commands if cmd.startswith(text)] + + try: + return self.matches[state] + except IndexError: + return None + + async def initialize(self): + """Initialize the MCP browser connection.""" + print("๐Ÿ” MCP Browser Interactive Mode") + print("Type 'help' for commands, 'quit' to exit") + print() + + # Try to connect + if self.use_daemon: + socket_path = get_socket_path(self.server_name) + if is_daemon_running(socket_path): + try: + self.client = MCPBrowserClient(socket_path) + await self.client.__aenter__() + print(f"โœ… Connected to daemon at {socket_path}") + except Exception as e: + self.logger.warning(f"Failed to connect to daemon: {e}") + self.client = None + + if not self.client: + # Fallback to standalone + print("๐Ÿš€ Starting standalone MCP browser...") + self.browser = MCPBrowser(server_name=self.server_name) + await self.browser.initialize() + print("โœ… MCP browser initialized") + + # Load initial tool list + await self._refresh_tools() + + async def _refresh_tools(self): + """Refresh the tool cache.""" + try: + response = await self._call_mcp({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }) + + if "result" in response and "tools" in response["result"]: + self.tool_cache.clear() + for tool in response["result"]["tools"]: + self.tool_cache[tool["name"]] = tool + except Exception as e: + self.logger.warning(f"Failed to refresh tools: {e}") + + async def _call_mcp(self, request: Dict[str, Any]) -> Dict[str, Any]: + """Call MCP method through client or browser.""" + if self.client: + return await self.client.call(request) + elif self.browser: + return await self.browser.call(request) + else: + raise RuntimeError("No MCP connection available") + + async def run(self): + """Main interactive loop.""" + try: + await self.initialize() + + while True: + try: + # Get user input + line = input("mcp> ").strip() + if not line: + continue + + self.command_history.append(line) + + # Parse and execute command + await self._execute_command(line) + + except KeyboardInterrupt: + print("\nUse 'quit' to exit") + continue + except EOFError: + break + + except Exception as e: + print(f"โŒ Error: {e}") + traceback.print_exc() + finally: + await self.cleanup() + + async def _execute_command(self, line: str): + """Execute a user command.""" + parts = line.split() + if not parts: + return + + command = parts[0].lower() + args = parts[1:] + + if command == 'help': + self._show_help() + elif command == 'quit' or command == 'exit': + print("๐Ÿ‘‹ Goodbye!") + sys.exit(0) + elif command == 'list': + await self._list_tools(args) + elif command == 'discover': + await self._discover_tools(args) + elif command == 'call': + await self._call_tool(args) + elif command == 'onboard': + await self._manage_onboarding(args) + elif command == 'status': + await self._show_status() + elif command == 'test': + await self._test_tool(args) + elif command == 'refresh': + await self._refresh_tools() + print("๐Ÿ”„ Tool cache refreshed") + else: + # Try to call it as a tool directly + await self._call_tool_direct(command, args) + + def _show_help(self): + """Show help information.""" + help_text = """ +๐Ÿ” MCP Browser Interactive Commands + +Basic Commands: + help Show this help + quit, exit Exit the browser + refresh Refresh tool cache + status Show connection status + +Tool Discovery: + list [pattern] List available tools (optional filter) + discover Discover tools using JSONPath + +Tool Execution: + call [args...] Call a tool with arguments + test Test a tool with sample data + [args...] Direct tool call (shortcut) + +Onboarding: + onboard Get onboarding for identity + onboard Set onboarding + +Examples: + list # List all tools + list bash # List tools containing 'bash' + discover $.tools[*].name # Get all tool names + discover $.tools[?(@.name=='Bash')] # Get Bash tool details + call mcp_discover jsonpath="$.tools[*].name" + test Bash # Test Bash tool + onboard Claude # Get Claude's onboarding + onboard Claude "Focus on code quality" # Set onboarding +""" + print(help_text) + + async def _list_tools(self, args: List[str]): + """List available tools with optional filtering.""" + pattern = args[0] if args else None + + tools = list(self.tool_cache.values()) + if pattern: + tools = [t for t in tools if pattern.lower() in t["name"].lower() or + pattern.lower() in t.get("description", "").lower()] + + if not tools: + print("โŒ No tools found") + return + + print(f"๐Ÿ“‹ Available Tools ({len(tools)} found):") + print() + + for tool in tools: + name = tool["name"] + desc = tool.get("description", "No description") + # Truncate long descriptions + if len(desc) > 80: + desc = desc[:77] + "..." + + # Add emoji based on tool type + emoji = "๐Ÿ”" if "discover" in name else "๐Ÿš€" if "call" in name else "๐Ÿ“‹" if "onboard" in name else "๐Ÿ› ๏ธ" + print(f" {emoji} {name}") + print(f" {desc}") + print() + + async def _discover_tools(self, args: List[str]): + """Discover tools using JSONPath.""" + if not args: + print("โŒ Usage: discover ") + print("Examples:") + print(" discover $.tools[*].name") + print(" discover $.tools[?(@.name=='Bash')]") + return + + jsonpath = " ".join(args) + + try: + response = await self._call_mcp({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "mcp_discover", + "arguments": {"jsonpath": jsonpath} + } + }) + + if "result" in response: + content = response["result"].get("content", []) + if content and content[0].get("type") == "text": + result = content[0]["text"] + print("๐Ÿ” Discovery Result:") + print(result) + else: + print("โŒ No content in response") + elif "error" in response: + print(f"โŒ Error: {response['error']['message']}") + + except Exception as e: + print(f"โŒ Discovery failed: {e}") + + async def _call_tool(self, args: List[str]): + """Call a tool with arguments.""" + if not args: + print("โŒ Usage: call [key=value...]") + print("Example: call mcp_discover jsonpath=\"$.tools[*].name\"") + return + + tool_name = args[0] + + # Parse key=value arguments + arguments = {} + for arg in args[1:]: + if "=" in arg: + key, value = arg.split("=", 1) + # Remove quotes if present + value = value.strip('"\'') + arguments[key] = value + else: + # Positional argument - try to guess the parameter name + if tool_name in self.tool_cache: + tool = self.tool_cache[tool_name] + schema = tool.get("inputSchema", {}) + props = schema.get("properties", {}) + required = schema.get("required", []) + + # Use first required parameter + if required and len(arguments) == 0: + arguments[required[0]] = arg + else: + arguments[f"arg_{len(arguments)}"] = arg + + await self._execute_tool_call(tool_name, arguments) + + async def _call_tool_direct(self, tool_name: str, args: List[str]): + """Direct tool call (shortcut syntax).""" + if tool_name not in self.tool_cache: + print(f"โŒ Unknown tool: {tool_name}") + print("Use 'list' to see available tools") + return + + # Parse arguments like _call_tool + arguments = {} + for arg in args: + if "=" in arg: + key, value = arg.split("=", 1) + value = value.strip('"\'') + arguments[key] = value + else: + # Use tool schema to guess parameter + tool = self.tool_cache[tool_name] + schema = tool.get("inputSchema", {}) + required = schema.get("required", []) + if required and len(arguments) == 0: + arguments[required[0]] = arg + + await self._execute_tool_call(tool_name, arguments) + + async def _execute_tool_call(self, tool_name: str, arguments: Dict[str, Any]): + """Execute a tool call and display results.""" + print(f"๐Ÿš€ Calling {tool_name} with {arguments}") + + try: + response = await self._call_mcp({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + } + }) + + if "result" in response: + self._display_result(response["result"]) + elif "error" in response: + print(f"โŒ Error: {response['error']['message']}") + + except Exception as e: + print(f"โŒ Tool call failed: {e}") + + def _display_result(self, result: Any): + """Display tool call result in a nice format.""" + if isinstance(result, dict) and "content" in result: + # MCP content format + content = result["content"] + for item in content: + if item.get("type") == "text": + print("๐Ÿ“„ Result:") + print(item["text"]) + elif item.get("type") == "image": + print(f"๐Ÿ–ผ๏ธ Image: {item.get('url', 'No URL')}") + else: + print(f"๐Ÿ“ฆ Content: {json.dumps(item, indent=2)}") + else: + # Raw result + print("๐Ÿ“ฆ Result:") + if isinstance(result, (dict, list)): + print(json.dumps(result, indent=2)) + else: + print(str(result)) + + async def _test_tool(self, args: List[str]): + """Test a tool with sample data.""" + if not args: + print("โŒ Usage: test ") + return + + tool_name = args[0] + if tool_name not in self.tool_cache: + print(f"โŒ Unknown tool: {tool_name}") + return + + tool = self.tool_cache[tool_name] + schema = tool.get("inputSchema", {}) + + print(f"๐Ÿงช Testing {tool_name}") + print(f"๐Ÿ“‹ Description: {tool.get('description', 'No description')}") + print(f"๐Ÿ“Š Schema: {json.dumps(schema, indent=2)}") + + # Generate sample arguments + sample_args = self._generate_sample_args(schema) + print(f"๐ŸŽฒ Sample arguments: {sample_args}") + + # Ask user if they want to proceed + try: + confirm = input("Proceed with test? [y/N]: ").strip().lower() + if confirm in ['y', 'yes']: + await self._execute_tool_call(tool_name, sample_args) + except KeyboardInterrupt: + print("\nโŒ Test cancelled") + + def _generate_sample_args(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Generate sample arguments based on schema.""" + args = {} + props = schema.get("properties", {}) + + for name, prop in props.items(): + prop_type = prop.get("type", "string") + + if prop_type == "string": + if "example" in prop: + args[name] = prop["example"] + elif name.lower() in ["jsonpath", "path"]: + args[name] = "$.tools[*].name" + elif name.lower() in ["query", "search"]: + args[name] = "test query" + else: + args[name] = f"sample_{name}" + elif prop_type == "boolean": + args[name] = False + elif prop_type == "number": + args[name] = 1 + elif prop_type == "array": + args[name] = ["sample"] + elif prop_type == "object": + args[name] = {} + + return args + + async def _manage_onboarding(self, args: List[str]): + """Manage onboarding instructions.""" + if not args: + print("โŒ Usage: onboard [instructions]") + return + + identity = args[0] + instructions = " ".join(args[1:]) if len(args) > 1 else None + + arguments = {"identity": identity} + if instructions: + arguments["instructions"] = instructions + + await self._execute_tool_call("onboarding", arguments) + + async def _show_status(self): + """Show connection and tool status.""" + print("๐Ÿ“Š MCP Browser Status") + print() + + if self.client: + print("๐Ÿ”— Connection: Daemon") + elif self.browser: + print("๐Ÿ”— Connection: Standalone") + else: + print("โŒ Connection: None") + + print(f"๐Ÿ› ๏ธ Tools cached: {len(self.tool_cache)}") + print(f"๐Ÿ“ Command history: {len(self.command_history)}") + + if self.server_name: + print(f"๐ŸŽฏ Server: {self.server_name}") + + # Show tool breakdown + if self.tool_cache: + meta_tools = [name for name in self.tool_cache if name.startswith("mcp_") or name == "onboarding"] + regular_tools = [name for name in self.tool_cache if name not in meta_tools] + + print() + print(f"๐Ÿ” Meta tools: {len(meta_tools)} ({', '.join(meta_tools)})") + print(f"๐Ÿ› ๏ธ Regular tools: {len(regular_tools)}") + + async def cleanup(self): + """Cleanup resources.""" + try: + if self.client: + await self.client.__aexit__(None, None, None) + if self.browser: + await self.browser.close() + except Exception as e: + self.logger.warning(f"Cleanup error: {e}") + + +async def main(): + """Main entry point for interactive mode.""" + import argparse + + parser = argparse.ArgumentParser(description="Interactive MCP Browser") + parser.add_argument("--server", help="MCP server name") + parser.add_argument("--no-daemon", action="store_true", help="Don't use daemon") + + args = parser.parse_args() + + client = InteractiveMCPClient( + server_name=args.server, + use_daemon=not args.no_daemon + ) + + await client.run() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/mcp_browser/registry.py b/mcp_browser/registry.py index b9503d1..a11b070 100644 --- a/mcp_browser/registry.py +++ b/mcp_browser/registry.py @@ -91,13 +91,13 @@ class ToolRegistry: sparse_tools = [ { "name": "mcp_discover", - "description": f"Discover available tools and servers using JSONPath. {tool_count} tools from {server_count} servers available.", + "description": f"๐Ÿ” PROXY META-TOOL: Discover {tool_count} hidden tools from {server_count} MCP servers without loading them into context. This prevents context explosion while enabling full tool access via JSONPath queries. Use this to explore what's available before calling specific tools.", "inputSchema": { "type": "object", "properties": { "jsonpath": { "type": "string", - "description": "JSONPath expression (e.g., '$.tools[*].name')" + "description": "JSONPath expression to query tool catalog. Examples: '$.tools[*].name' (list all), '$.tools[?(@.name=='Bash')]' (find specific), '$.servers[*]' (list servers)" } }, "required": ["jsonpath"] @@ -105,17 +105,17 @@ class ToolRegistry: }, { "name": "mcp_call", - "description": "Execute any MCP tool by constructing a JSON-RPC call.", + "description": f"๐Ÿš€ PROXY META-TOOL: Execute any of the {tool_count} available MCP tools by constructing JSON-RPC calls. This is the universal interface to all hidden tools - you can call ANY tool discovered via mcp_discover without it being loaded into your context.", "inputSchema": { "type": "object", "properties": { "method": { "type": "string", - "description": "JSON-RPC method (e.g., 'tools/call')" + "description": "JSON-RPC method to call. For tool execution use 'tools/call'. Other methods: 'tools/list', 'prompts/list', 'resources/list'" }, "params": { "type": "object", - "description": "Method parameters" + "description": "Method parameters. For 'tools/call': {'name': 'tool_name', 'arguments': {...}}. The arguments object contains the actual tool parameters." } }, "required": ["method", "params"] @@ -123,21 +123,21 @@ class ToolRegistry: }, { "name": "onboarding", - "description": "Get or set identity-specific onboarding instructions for AI contexts.", + "description": "๐Ÿ“‹ BUILT-IN TOOL: Manage persistent, identity-aware onboarding instructions. This tool lets AI instances leave instructions for future contexts based on identity (project name, user, etc). Perfect for maintaining context across sessions without consuming tokens.", "inputSchema": { "type": "object", "properties": { "identity": { "type": "string", - "description": "Identity for onboarding (e.g., 'Claude', project name)" + "description": "Identity key for onboarding instructions (e.g., 'Claude', 'MyProject', 'WebDev'). Each identity can have separate instructions." }, "instructions": { "type": "string", - "description": "Optional: Set new instructions. If omitted, retrieves existing." + "description": "Optional: New instructions to store. If omitted, retrieves existing instructions for this identity. Use this to leave notes for future AI sessions." }, "append": { "type": "boolean", - "description": "Append to existing instructions instead of replacing", + "description": "If true, append to existing instructions instead of replacing them entirely", "default": False } }, diff --git a/mcp_servers/memory/memory_server.py b/mcp_servers/memory/memory_server.py index 854b747..8e07e7e 100755 --- a/mcp_servers/memory/memory_server.py +++ b/mcp_servers/memory/memory_server.py @@ -54,13 +54,14 @@ class Pattern: class MemoryServer(BaseMCPServer): - """MCP server for memory and context management.""" + """MCP server for memory and context management with cmem integration.""" - def __init__(self): + def __init__(self, identity: str = "default"): super().__init__("memory-server", "1.0.0") self.memory_dir = Path.home() / ".mcp-memory" self.memory_dir.mkdir(exist_ok=True) - self.current_project = "default" + self.current_project = identity + self.cmem_integration = self._setup_cmem_integration() self._register_tools() self._load_memory() @@ -211,6 +212,53 @@ class MemoryServer(BaseMCPServer): self.patterns = self._load_json("patterns.json", {}) self.knowledge = self._load_json("knowledge.json", {}) + def _setup_cmem_integration(self) -> bool: + """Setup integration with cmem by creating identity-specific directories.""" + try: + # Check if cmem is available + import subprocess + result = subprocess.run(['cmem', 'stats'], capture_output=True, text=True, timeout=5) + if result.returncode != 0: + return False + + # Create identity-specific directory + identity_dir = self.memory_dir / self.current_project + identity_dir.mkdir(exist_ok=True) + + # Check if we should symlink to cmem storage + claude_dir = Path.home() / ".claude" + if claude_dir.exists(): + # Try to find cmem session data + cmem_session_dirs = list(claude_dir.glob("sessions/*/")) + if cmem_session_dirs: + # Use the most recent session + latest_session = max(cmem_session_dirs, key=lambda p: p.stat().st_mtime) + + # Create symlinks for task/pattern/decision integration + self._create_cmem_bridges(identity_dir, latest_session) + return True + + return False + except Exception as e: + # Fail silently - cmem integration is optional + return False + + def _create_cmem_bridges(self, identity_dir: Path, session_dir: Path): + """Create bridge files to sync with cmem.""" + # Create bridge files that can sync with cmem format + bridge_dir = identity_dir / "cmem_bridge" + bridge_dir.mkdir(exist_ok=True) + + # Store reference to cmem session for potential sync + bridge_info = { + "session_dir": str(session_dir), + "last_sync": datetime.now().isoformat(), + "integration_active": True + } + + with open(bridge_dir / "info.json", 'w') as f: + json.dump(bridge_info, f, indent=2) + def _load_json(self, filename: str, default: Any) -> Any: """Load JSON file or return default.""" filepath = self.project_dir / filename @@ -263,6 +311,9 @@ class MemoryServer(BaseMCPServer): self.tasks[task.id] = asdict(task) self._save_json("tasks.json", self.tasks) + # Try to sync with cmem if integration is active + await self._sync_task_to_cmem(task, "add") + return self.content_text(f"Added task: {task.id[:8]} - {task.content}") async def _task_list(self, args: Dict[str, Any]) -> Dict[str, Any]: @@ -298,6 +349,9 @@ class MemoryServer(BaseMCPServer): self.tasks[full_id]["status"] = new_status if new_status == "completed": self.tasks[full_id]["completed_at"] = datetime.now().isoformat() + # Sync completion to cmem + task_obj = Task(**self.tasks[full_id]) + await self._sync_task_to_cmem(task_obj, "complete") self._save_json("tasks.json", self.tasks) @@ -315,6 +369,9 @@ class MemoryServer(BaseMCPServer): self.decisions[decision.id] = asdict(decision) self._save_json("decisions.json", self.decisions) + # Try to sync with cmem + await self._sync_decision_to_cmem(decision) + return self.content_text(f"Recorded decision: {decision.choice}") async def _pattern_add(self, args: Dict[str, Any]) -> Dict[str, Any]: @@ -330,6 +387,9 @@ class MemoryServer(BaseMCPServer): self.patterns[pattern.id] = asdict(pattern) self._save_json("patterns.json", self.patterns) + # Try to sync with cmem + await self._sync_pattern_to_cmem(pattern, "add") + return self.content_text(f"Added pattern: {pattern.pattern}") async def _pattern_resolve(self, args: Dict[str, Any]) -> Dict[str, Any]: @@ -438,6 +498,89 @@ Total Knowledge Items: {sum(len(items) for items in self.knowledge.values())} """ return self.content_text(summary) + + async def _sync_task_to_cmem(self, task: Task, action: str): + """Sync task with cmem if integration is active.""" + if not self.cmem_integration: + return + + try: + import subprocess + import asyncio + + if action == "add": + # Map our priority to cmem priority + priority_map = {"low": "low", "medium": "medium", "high": "high"} + cmem_priority = priority_map.get(task.priority, "medium") + + # Add task to cmem + cmd = ['cmem', 'task', 'add', task.content, '--priority', cmem_priority] + if task.assignee: + cmd.extend(['--assignee', task.assignee]) + + result = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + await result.communicate() + + elif action == "complete": + # Try to find and complete corresponding cmem task + # This is best-effort since we don't have direct ID mapping + cmd = ['cmem', 'task', 'complete', task.content[:50]] # Use content prefix + result = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + await result.communicate() + + except Exception: + # Fail silently - cmem sync is optional + pass + + async def _sync_pattern_to_cmem(self, pattern: Pattern, action: str): + """Sync pattern with cmem if integration is active.""" + if not self.cmem_integration: + return + + try: + import subprocess + import asyncio + + if action == "add": + # Add pattern to cmem + cmd = ['cmem', 'pattern', 'add', pattern.pattern, pattern.description] + if pattern.priority != "medium": + cmd.extend(['--priority', pattern.priority]) + + result = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + await result.communicate() + + except Exception: + # Fail silently - cmem sync is optional + pass + + async def _sync_decision_to_cmem(self, decision: Decision): + """Sync decision with cmem if integration is active.""" + if not self.cmem_integration: + return + + try: + import subprocess + import asyncio + + # Add decision to cmem + alternatives_str = ', '.join(decision.alternatives) + cmd = ['cmem', 'decision', decision.choice, decision.reasoning, alternatives_str] + + result = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + await result.communicate() + + except Exception: + # Fail silently - cmem sync is optional + pass if __name__ == "__main__": diff --git a/mcp_servers/onboarding/default.md b/mcp_servers/onboarding/default.md index 12dba93..f94611d 100644 --- a/mcp_servers/onboarding/default.md +++ b/mcp_servers/onboarding/default.md @@ -1,27 +1,48 @@ # MCP Browser - Universal Model Context Protocol Proxy -Welcome to MCP Browser! This tool acts as a proxy between AI systems and MCP servers, providing: +Welcome to MCP Browser! This tool solves the **context explosion problem** by acting as a smart proxy between AI systems and potentially hundreds of MCP tools. -## Core Capabilities +## The Context Problem MCP Browser Solves -### 1. **Proxy Mode** -MCP Browser acts as a transparent proxy to external MCP servers configured in `~/.claude/mcp-browser/config.yaml`. You can: -- Connect to any MCP server (filesystem, brave-search, github, etc.) -- Add new servers at runtime without restarting -- Access all tools from configured servers through the proxy +Traditional MCP setups expose ALL tools to the AI context immediately, which can easily consume thousands of tokens. MCP Browser implements a **minimal-to-maximal interface pattern**: -### 2. **Built-in Tools** -Always available, regardless of external servers: -- **Screen Management** - Create/manage GNU screen sessions -- **Memory & Tasks** - Persistent memory and task tracking -- **Pattern Manager** - Auto-response patterns -- **Onboarding** - Context-specific instructions (this tool) +- **What AI sees**: Only 3 simple meta-tools (minimal context usage) +- **What AI can access**: All tools from all configured MCP servers (maximal functionality) +- **How it works**: JSONRPC proxy that filters and routes tool calls transparently -### 3. **Sparse Mode Optimization** -To minimize context usage, only 3 meta-tools are shown initially: -- `mcp_discover` - Discover all available tools using JSONPath +## Core Architecture: Minimal Interface โ†’ Maximal Backend + +### 1. **Sparse Mode Frontend** (What AI Sees) +Only 3 meta-tools are exposed, preventing context explosion: +- `mcp_discover` - Explore available tools without loading them into context - `mcp_call` - Execute any tool by constructing JSON-RPC calls -- `onboarding` - Get/set identity-specific instructions +- `onboarding` - Identity-aware persistent instructions + +### 2. **Transparent JSONRPC Proxy** (How It Works) +- **Intercepts** `tools/list` responses and replaces full catalogs with sparse tools +- **Routes** tool calls to appropriate internal or external MCP servers +- **Transforms** meta-tool calls into actual JSONRPC requests +- **Buffers** responses and handles async message routing + +### 3. **Multi-Server Backend** (What's Available) +- **Built-in Servers**: Screen, Memory, Patterns, Onboarding (always available) +- **External Servers**: Any MCP server configured in `~/.claude/mcp-browser/config.yaml` +- **Runtime Discovery**: New servers added without restart via config monitoring + +## Key Insight: Tool Discovery Without Context Pollution + +Instead of loading hundreds of tool descriptions into context, AI can discover them on-demand: + +```python +# Explore what's available (uses 0 additional context) +mcp_discover(jsonpath="$.tools[*].name") + +# Get specific tool details only when needed +mcp_discover(jsonpath="$.tools[?(@.name=='brave_web_search')]") + +# Execute discovered tools +mcp_call(method="tools/call", params={"name": "brave_web_search", "arguments": {...}}) +``` ## Discovery Examples diff --git a/tests/test_basic.py b/tests/test_basic.py index f8da21b..cfcf24d 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -54,7 +54,7 @@ class TestToolRegistry: assert sparse[0]["name"] == "mcp_discover" assert sparse[1]["name"] == "mcp_call" assert sparse[2]["name"] == "onboarding" - assert "2 tools available" in sparse[0]["description"] + assert "2 hidden tools" in sparse[0]["description"] class TestMessageFilter: diff --git a/tests/test_cmem_integration.py b/tests/test_cmem_integration.py new file mode 100644 index 0000000..cf6a667 --- /dev/null +++ b/tests/test_cmem_integration.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Test suite for cmem integration in memory server. +""" + +import pytest +import asyncio +import json +import tempfile +from unittest.mock import Mock, AsyncMock, patch +from pathlib import Path +import sys + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from mcp_servers.memory.memory_server import MemoryServer + + +class TestCmemIntegration: + """Test cmem integration functionality.""" + + def setup_method(self): + """Setup test environment with temporary directory.""" + self.temp_dir = tempfile.mkdtemp() + + def test_memory_server_initialization_default(self): + """Test memory server initializes with default identity.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + assert server.current_project == "default" + + def test_memory_server_initialization_custom_identity(self): + """Test memory server initializes with custom identity.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer(identity="test_project") + assert server.current_project == "test_project" + + def test_setup_cmem_integration_no_cmem(self): + """Test cmem integration setup when cmem is not available.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 1 # cmem not available + + server = MemoryServer() + assert server.cmem_integration is False + + def test_setup_cmem_integration_available(self): + """Test cmem integration setup when cmem is available.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 0 # cmem available + + # Create mock .claude directory + claude_dir = Path(self.temp_dir) / ".claude" + claude_dir.mkdir() + sessions_dir = claude_dir / "sessions" / "test_session" + sessions_dir.mkdir(parents=True) + + server = MemoryServer() + + # Should have attempted cmem integration + assert hasattr(server, 'cmem_integration') + + def test_create_cmem_bridges(self): + """Test creation of cmem bridge files.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + + identity_dir = Path(self.temp_dir) / ".mcp-memory" / "test" + identity_dir.mkdir(parents=True) + + session_dir = Path(self.temp_dir) / "session" + session_dir.mkdir() + + server._create_cmem_bridges(identity_dir, session_dir) + + bridge_dir = identity_dir / "cmem_bridge" + assert bridge_dir.exists() + + info_file = bridge_dir / "info.json" + assert info_file.exists() + + with open(info_file) as f: + bridge_info = json.load(f) + + assert bridge_info["session_dir"] == str(session_dir) + assert bridge_info["integration_active"] is True + assert "last_sync" in bridge_info + + @pytest.mark.asyncio + async def test_sync_task_to_cmem_add(self): + """Test syncing task addition to cmem.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + from mcp_servers.memory.memory_server import Task + task = Task( + id="test-id", + content="Test task", + priority="high", + assignee="test_user" + ) + + with patch('asyncio.create_subprocess_exec') as mock_subprocess: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + await server._sync_task_to_cmem(task, "add") + + # Verify subprocess was called with correct arguments + mock_subprocess.assert_called_once() + args = mock_subprocess.call_args[0] + assert args[0] == "cmem" + assert args[1] == "task" + assert args[2] == "add" + assert args[3] == "Test task" + assert "--priority" in args + assert "high" in args + assert "--assignee" in args + assert "test_user" in args + + @pytest.mark.asyncio + async def test_sync_task_to_cmem_complete(self): + """Test syncing task completion to cmem.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + from mcp_servers.memory.memory_server import Task + task = Task( + id="test-id", + content="Test task completion", + status="completed" + ) + + with patch('asyncio.create_subprocess_exec') as mock_subprocess: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + await server._sync_task_to_cmem(task, "complete") + + # Verify subprocess was called for completion + mock_subprocess.assert_called_once() + args = mock_subprocess.call_args[0] + assert args[0] == "cmem" + assert args[1] == "task" + assert args[2] == "complete" + assert "Test task completion"[:50] in args[3] # Truncated content + + @pytest.mark.asyncio + async def test_sync_pattern_to_cmem(self): + """Test syncing pattern to cmem.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + from mcp_servers.memory.memory_server import Pattern + pattern = Pattern( + id="test-pattern-id", + pattern="Test pattern", + description="Pattern description", + priority="high" + ) + + with patch('asyncio.create_subprocess_exec') as mock_subprocess: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + await server._sync_pattern_to_cmem(pattern, "add") + + # Verify subprocess was called with correct arguments + mock_subprocess.assert_called_once() + args = mock_subprocess.call_args[0] + assert args[0] == "cmem" + assert args[1] == "pattern" + assert args[2] == "add" + assert args[3] == "Test pattern" + assert args[4] == "Pattern description" + assert "--priority" in args + assert "high" in args + + @pytest.mark.asyncio + async def test_sync_decision_to_cmem(self): + """Test syncing decision to cmem.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + from mcp_servers.memory.memory_server import Decision + decision = Decision( + id="test-decision-id", + choice="Test choice", + reasoning="Test reasoning", + alternatives=["Alt 1", "Alt 2"] + ) + + with patch('asyncio.create_subprocess_exec') as mock_subprocess: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + await server._sync_decision_to_cmem(decision) + + # Verify subprocess was called with correct arguments + mock_subprocess.assert_called_once() + args = mock_subprocess.call_args[0] + assert args[0] == "cmem" + assert args[1] == "decision" + assert args[2] == "Test choice" + assert args[3] == "Test reasoning" + assert args[4] == "Alt 1, Alt 2" + + @pytest.mark.asyncio + async def test_sync_with_integration_disabled(self): + """Test that sync methods do nothing when integration is disabled.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = False + + from mcp_servers.memory.memory_server import Task + task = Task(id="test", content="test") + + with patch('asyncio.create_subprocess_exec') as mock_subprocess: + await server._sync_task_to_cmem(task, "add") + + # Should not have called subprocess + mock_subprocess.assert_not_called() + + @pytest.mark.asyncio + async def test_sync_error_handling(self): + """Test that sync errors are handled gracefully.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + from mcp_servers.memory.memory_server import Task + task = Task(id="test", content="test") + + with patch('asyncio.create_subprocess_exec') as mock_subprocess: + mock_subprocess.side_effect = Exception("Subprocess error") + + # Should not raise exception + await server._sync_task_to_cmem(task, "add") + + @pytest.mark.asyncio + async def test_task_add_with_sync(self): + """Test task addition triggers cmem sync.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + with patch.object(server, '_sync_task_to_cmem') as mock_sync: + mock_sync.return_value = None # Async function + + result = await server._task_add({ + "content": "Test task", + "priority": "high" + }) + + # Verify sync was called + mock_sync.assert_called_once() + args = mock_sync.call_args[0] + assert args[1] == "add" # action + assert args[0].content == "Test task" + + # Verify task was added + assert "Added task:" in result["content"][0]["text"] + + @pytest.mark.asyncio + async def test_task_update_completion_with_sync(self): + """Test task completion triggers cmem sync.""" + with patch('pathlib.Path.home', return_value=Path(self.temp_dir)): + server = MemoryServer() + server.cmem_integration = True + + # Add a task first + task_id = "test-task-id" + server.tasks[task_id] = { + "id": task_id, + "content": "Test task", + "status": "pending", + "priority": "medium", + "assignee": None, + "created_at": "2025-01-01T00:00:00", + "completed_at": None + } + + with patch.object(server, '_sync_task_to_cmem') as mock_sync: + mock_sync.return_value = None # Async function + + result = await server._task_update({ + "task_id": task_id, + "status": "completed" + }) + + # Verify sync was called + mock_sync.assert_called_once() + args = mock_sync.call_args[0] + assert args[1] == "complete" # action + + # Verify task was updated + assert server.tasks[task_id]["status"] == "completed" + assert server.tasks[task_id]["completed_at"] is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_interactive_client.py b/tests/test_interactive_client.py new file mode 100644 index 0000000..30a2bd9 --- /dev/null +++ b/tests/test_interactive_client.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +""" +Test suite for the enhanced interactive MCP client. +""" + +import pytest +import asyncio +import json +from unittest.mock import Mock, AsyncMock, patch +from pathlib import Path +import sys + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from mcp_browser.interactive_client import InteractiveMCPClient +from mcp_browser.proxy import MCPBrowser + + +class TestInteractiveMCPClient: + """Test the interactive MCP client functionality.""" + + def setup_method(self): + """Setup test environment.""" + self.client = InteractiveMCPClient(use_daemon=False) + + def test_initialization(self): + """Test client initialization.""" + assert self.client.server_name is None + assert self.client.use_daemon is False + assert self.client.tool_cache == {} + assert self.client.command_history == [] + + def test_completer_commands(self): + """Test tab completion for commands.""" + # Mock readline state + with patch('readline.get_line_buffer', return_value='help'): + matches = [] + state = 0 + while True: + match = self.client._completer('hel', state) + if match is None: + break + matches.append(match) + state += 1 + + assert 'help' in matches + + def test_completer_tools(self): + """Test tab completion includes tool names when cached.""" + # Setup tool cache + self.client.tool_cache = { + 'Bash': {'name': 'Bash', 'description': 'Execute bash commands'}, + 'mcp_discover': {'name': 'mcp_discover', 'description': 'Discover tools'} + } + + with patch('readline.get_line_buffer', return_value='Bash'): + matches = [] + state = 0 + while True: + match = self.client._completer('Ba', state) + if match is None: + break + matches.append(match) + state += 1 + + assert 'Bash' in matches + + @pytest.mark.asyncio + async def test_refresh_tools(self): + """Test tool cache refresh functionality.""" + # Mock MCP browser + mock_browser = AsyncMock() + mock_browser.call.return_value = { + "result": { + "tools": [ + {"name": "test_tool", "description": "Test tool"}, + {"name": "another_tool", "description": "Another test tool"} + ] + } + } + self.client.browser = mock_browser + + await self.client._refresh_tools() + + assert len(self.client.tool_cache) == 2 + assert "test_tool" in self.client.tool_cache + assert "another_tool" in self.client.tool_cache + + @pytest.mark.asyncio + async def test_call_mcp_browser(self): + """Test MCP call through browser.""" + mock_browser = AsyncMock() + expected_response = {"result": {"test": "data"}} + mock_browser.call.return_value = expected_response + + self.client.browser = mock_browser + + request = {"jsonrpc": "2.0", "id": 1, "method": "test"} + response = await self.client._call_mcp(request) + + assert response == expected_response + mock_browser.call.assert_called_once_with(request) + + @pytest.mark.asyncio + async def test_call_mcp_client(self): + """Test MCP call through daemon client.""" + mock_client = AsyncMock() + expected_response = {"result": {"test": "data"}} + mock_client.call.return_value = expected_response + + self.client.client = mock_client + self.client.browser = None + + request = {"jsonrpc": "2.0", "id": 1, "method": "test"} + response = await self.client._call_mcp(request) + + assert response == expected_response + mock_client.call.assert_called_once_with(request) + + def test_generate_sample_args_string(self): + """Test sample argument generation for string properties.""" + schema = { + "properties": { + "query": {"type": "string"}, + "jsonpath": {"type": "string"}, + "command": {"type": "string"} + } + } + + args = self.client._generate_sample_args(schema) + + assert args["jsonpath"] == "$.tools[*].name" + assert args["query"] == "test query" + assert args["command"] == "sample_command" + + def test_generate_sample_args_types(self): + """Test sample argument generation for different types.""" + schema = { + "properties": { + "text": {"type": "string"}, + "enabled": {"type": "boolean"}, + "count": {"type": "number"}, + "items": {"type": "array"}, + "config": {"type": "object"} + } + } + + args = self.client._generate_sample_args(schema) + + assert isinstance(args["text"], str) + assert isinstance(args["enabled"], bool) + assert isinstance(args["count"], (int, float)) + assert isinstance(args["items"], list) + assert isinstance(args["config"], dict) + + def test_generate_sample_args_examples(self): + """Test sample argument generation uses examples when available.""" + schema = { + "properties": { + "query": { + "type": "string", + "example": "example query" + } + } + } + + args = self.client._generate_sample_args(schema) + assert args["query"] == "example query" + + @pytest.mark.asyncio + async def test_execute_tool_call(self): + """Test tool execution with proper result display.""" + mock_browser = AsyncMock() + mock_browser.call.return_value = { + "result": { + "content": [ + {"type": "text", "text": "Test result"} + ] + } + } + self.client.browser = mock_browser + + # Capture output + with patch('builtins.print') as mock_print: + await self.client._execute_tool_call("test_tool", {"arg": "value"}) + + # Verify MCP call was made + mock_browser.call.assert_called_once() + call_args = mock_browser.call.call_args[0][0] + assert call_args["method"] == "tools/call" + assert call_args["params"]["name"] == "test_tool" + assert call_args["params"]["arguments"] == {"arg": "value"} + + # Verify output was printed + mock_print.assert_called() + + @pytest.mark.asyncio + async def test_execute_tool_call_error(self): + """Test tool execution error handling.""" + mock_browser = AsyncMock() + mock_browser.call.return_value = { + "error": { + "code": -32603, + "message": "Tool execution failed" + } + } + self.client.browser = mock_browser + + with patch('builtins.print') as mock_print: + await self.client._execute_tool_call("test_tool", {}) + + # Check that error was printed + print_calls = [str(call) for call in mock_print.call_args_list] + assert any("Error:" in call for call in print_calls) + + def test_display_result_text_content(self): + """Test display of text content results.""" + result = { + "content": [ + {"type": "text", "text": "Hello, World!"} + ] + } + + with patch('builtins.print') as mock_print: + self.client._display_result(result) + + # Verify text was printed + calls = [call[0][0] for call in mock_print.call_args_list] + assert "Hello, World!" in calls + + def test_display_result_image_content(self): + """Test display of image content results.""" + result = { + "content": [ + {"type": "image", "url": "http://example.com/image.png"} + ] + } + + with patch('builtins.print') as mock_print: + self.client._display_result(result) + + # Verify image info was printed + calls = [call[0][0] for call in mock_print.call_args_list] + assert any("Image:" in call for call in calls) + + def test_display_result_raw_data(self): + """Test display of raw result data.""" + result = {"key": "value", "number": 42} + + with patch('builtins.print') as mock_print: + self.client._display_result(result) + + # Verify JSON was printed + calls = [call[0][0] for call in mock_print.call_args_list] + assert any("Result:" in call for call in calls) + + @pytest.mark.asyncio + async def test_execute_command_help(self): + """Test help command execution.""" + with patch('builtins.print') as mock_print: + await self.client._execute_command("help") + + # Verify help was printed + mock_print.assert_called() + print_calls = [str(call) for call in mock_print.call_args_list] + assert any("MCP Browser Interactive Commands" in call for call in print_calls) + + @pytest.mark.asyncio + async def test_execute_command_list(self): + """Test list command execution.""" + # Setup tool cache + self.client.tool_cache = { + 'test_tool': {'name': 'test_tool', 'description': 'A test tool'}, + 'bash_tool': {'name': 'bash_tool', 'description': 'Bash execution tool'} + } + + with patch('builtins.print') as mock_print: + await self.client._execute_command("list bash") + + # Verify filtered tools were printed + print_calls = [str(call) for call in mock_print.call_args_list] + assert any("bash_tool" in call for call in print_calls) + + @pytest.mark.asyncio + async def test_execute_command_refresh(self): + """Test refresh command execution.""" + mock_browser = AsyncMock() + mock_browser.call.return_value = { + "result": {"tools": [{"name": "new_tool", "description": "New tool"}]} + } + self.client.browser = mock_browser + + with patch('builtins.print') as mock_print: + await self.client._execute_command("refresh") + + # Verify tool cache was updated + assert "new_tool" in self.client.tool_cache + + # Verify refresh message was printed + print_calls = [str(call) for call in mock_print.call_args_list] + assert any("Tool cache refreshed" in call for call in print_calls) + + @pytest.mark.asyncio + async def test_execute_command_unknown_tool(self): + """Test handling of unknown direct tool calls.""" + with patch('builtins.print') as mock_print: + await self.client._execute_command("unknown_tool arg1") + + # Verify error message was printed + print_calls = [str(call) for call in mock_print.call_args_list] + assert any("Unknown tool:" in call for call in print_calls) + + +class TestInteractiveMCPClientIntegration: + """Integration tests for interactive client.""" + + @pytest.mark.asyncio + async def test_full_workflow_mock(self): + """Test a complete workflow with mocked dependencies.""" + client = InteractiveMCPClient(use_daemon=False) + + # Mock browser + mock_browser = AsyncMock() + + # Mock tools/list response + tools_response = { + "result": { + "tools": [ + { + "name": "mcp_discover", + "description": "Discover tools", + "inputSchema": { + "type": "object", + "properties": { + "jsonpath": {"type": "string"} + }, + "required": ["jsonpath"] + } + } + ] + } + } + + # Mock discover response + discover_response = { + "result": { + "content": [ + {"type": "text", "text": '["mcp_discover", "mcp_call", "onboarding"]'} + ] + } + } + + # Configure mock to return different responses based on call + def mock_call(request): + if request.get("method") == "tools/list": + return tools_response + elif (request.get("method") == "tools/call" and + request.get("params", {}).get("name") == "mcp_discover"): + return discover_response + else: + return {"error": {"code": -32601, "message": "Method not found"}} + + mock_browser.call.side_effect = mock_call + client.browser = mock_browser + + # Test tool refresh + await client._refresh_tools() + assert "mcp_discover" in client.tool_cache + + # Test discovery command + with patch('builtins.print'): + await client._execute_command("discover $.tools[*].name") + + # Verify calls were made + assert mock_browser.call.call_count >= 2 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_simple.py b/tests/test_simple.py index 416e1fe..9eb0b6a 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -49,7 +49,7 @@ def test_sparse_mode(): assert sparse[2]["name"] == "onboarding" # Check tool count in description - assert "5 tools available" in sparse[0]["description"] + assert "5 hidden tools" in sparse[0]["description"] print("โœ“ Sparse mode tests passed")