Initial prototype of MCP Browser
A generic, minimalistic MCP browser with optimized context usage: - Generic JSON-RPC interface with call() and discover() methods - Sparse mode for context optimization - 4 built-in MCP servers (screen, memory, patterns, onboarding) - Multi-server management - Identity-aware onboarding as third visible tool 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
9a7172096f
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# MCP Browser specific
|
||||||
|
~/.mcp-*/
|
||||||
|
.mcp-cache/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/_build/
|
||||||
|
site/
|
||||||
|
|
||||||
|
# Package managers
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
.coverage.*
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
# MCP Browser
|
||||||
|
|
||||||
|
A generic, minimalistic MCP (Model Context Protocol) browser that provides an abstract interface for AI systems to interact with MCP servers with optimized context usage.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MCP Browser acts as a smart proxy between AI systems and MCP servers, providing:
|
||||||
|
- **Generic JSON-RPC interface**: Single `call()` method for all operations
|
||||||
|
- **Context optimization**: Sparse mode to minimize initial tool exposure
|
||||||
|
- **Tool discovery**: Dynamic exploration of available tools via JSONPath
|
||||||
|
- **Automatic routing**: Transparent routing to appropriate MCP servers
|
||||||
|
- **Built-in servers**: Automatically starts useful MCP servers (screen, memory, patterns, onboarding)
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
1. **Minimalistic API**
|
||||||
|
- `call(jsonrpc_object)`: Execute any JSON-RPC call
|
||||||
|
- `discover(jsonpath)`: Explore available tools and their schemas
|
||||||
|
- `onboarding(identity)`: Get/set identity-specific instructions
|
||||||
|
|
||||||
|
2. **Context Optimization**
|
||||||
|
- Only exposes 3 essential tools initially in sparse mode
|
||||||
|
- Tools are loaded on-demand to minimize context usage
|
||||||
|
- Full tool descriptions cached but not exposed until needed
|
||||||
|
|
||||||
|
3. **Generic Design**
|
||||||
|
- Protocol-agnostic (works with any MCP server)
|
||||||
|
- No hardcoded tool knowledge
|
||||||
|
- Configuration-driven server management
|
||||||
|
|
||||||
|
4. **Built-in Servers**
|
||||||
|
- **Screen**: GNU screen session management for persistent processes
|
||||||
|
- **Memory**: Project memory, tasks, decisions, and knowledge management
|
||||||
|
- **Patterns**: Auto-response pattern management for automation
|
||||||
|
- **Onboarding**: Identity-aware onboarding for AI contexts
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp-browser/
|
||||||
|
├── mcp_browser/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── proxy.py # Main MCP proxy
|
||||||
|
│ ├── server.py # MCP server management
|
||||||
|
│ ├── multi_server.py # Multi-server manager
|
||||||
|
│ ├── registry.py # Tool registry and discovery
|
||||||
|
│ ├── filter.py # Message filtering and sparse mode
|
||||||
|
│ ├── buffer.py # JSON-RPC message buffering
|
||||||
|
│ └── config.py # Configuration management
|
||||||
|
├── mcp_servers/ # Built-in MCP servers
|
||||||
|
│ ├── base.py # Base server implementation
|
||||||
|
│ ├── screen/ # Screen session management
|
||||||
|
│ ├── memory/ # Memory and context management
|
||||||
|
│ ├── pattern_manager/ # Pattern automation
|
||||||
|
│ └── onboarding/ # Identity-aware onboarding
|
||||||
|
├── tests/
|
||||||
|
├── docs/
|
||||||
|
└── config/
|
||||||
|
└── default.yaml # Default configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
|
||||||
|
# Initialize browser (built-in servers start automatically)
|
||||||
|
async with MCPBrowser() as browser:
|
||||||
|
# Execute any JSON-RPC call
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discover tool details
|
||||||
|
tool_info = browser.discover("$.tools[?(@.name=='Bash')]")
|
||||||
|
|
||||||
|
# Use identity-aware onboarding
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"arguments": {
|
||||||
|
"identity": "MyProject",
|
||||||
|
"instructions": "Remember to focus on code quality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sparse Mode
|
||||||
|
|
||||||
|
In sparse mode (default), only 3 tools are initially visible:
|
||||||
|
1. **mcp_discover**: Explore available tools using JSONPath
|
||||||
|
2. **mcp_call**: Execute any tool by name
|
||||||
|
3. **onboarding**: Get/set identity-specific instructions
|
||||||
|
|
||||||
|
All other tools (potentially hundreds) are hidden but fully accessible through these meta-tools.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Generic**: No tool-specific knowledge built into the browser
|
||||||
|
2. **Minimal**: Smallest possible API surface
|
||||||
|
3. **Efficient**: Optimized for minimal context usage
|
||||||
|
4. **Transparent**: Acts as a pass-through proxy with intelligent enhancements
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Default MCP Browser Configuration
|
||||||
|
|
||||||
|
# MCP Server definitions
|
||||||
|
servers:
|
||||||
|
# Default in-memory server for testing
|
||||||
|
memory:
|
||||||
|
command: ["npx", "-y", "@modelcontextprotocol/server-memory"]
|
||||||
|
name: "memory"
|
||||||
|
description: "In-memory MCP server for testing"
|
||||||
|
|
||||||
|
# Filesystem server example
|
||||||
|
filesystem:
|
||||||
|
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem"]
|
||||||
|
args: ["--directory", "/tmp"]
|
||||||
|
name: "filesystem"
|
||||||
|
description: "Filesystem MCP server"
|
||||||
|
env:
|
||||||
|
MCP_VERBOSE: "true"
|
||||||
|
|
||||||
|
# Python-based MCP server example
|
||||||
|
python_example:
|
||||||
|
command: ["python", "-m", "mcp.server.example"]
|
||||||
|
name: "python_example"
|
||||||
|
description: "Example Python MCP server"
|
||||||
|
|
||||||
|
# Default server to use
|
||||||
|
default_server: "memory"
|
||||||
|
|
||||||
|
# Enable sparse mode for context optimization
|
||||||
|
sparse_mode: true
|
||||||
|
|
||||||
|
# Enable built-in servers (screen, memory, patterns, onboarding)
|
||||||
|
enable_builtin_servers: true
|
||||||
|
|
||||||
|
# Debug mode (shows MCP communication)
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# Buffer size for reading server output
|
||||||
|
buffer_size: 65536
|
||||||
|
|
||||||
|
# Request timeout in seconds
|
||||||
|
timeout: 30.0
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
# MCP Browser Design Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MCP Browser is a generic, minimalistic proxy for the Model Context Protocol (MCP) that provides an abstract interface optimized for AI systems. It acts as an intelligent intermediary between AI clients and MCP servers, implementing context optimization strategies inspired by claude-composer.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
### 1. **Minimalism**
|
||||||
|
- Only two public methods: `call()` and `discover()`
|
||||||
|
- No tool-specific knowledge built into the browser
|
||||||
|
- Generic JSON-RPC interface for all operations
|
||||||
|
|
||||||
|
### 2. **Context Optimization**
|
||||||
|
- Sparse mode reduces initial tool exposure from potentially hundreds to just 2
|
||||||
|
- Tools are discovered on-demand using JSONPath queries
|
||||||
|
- Full functionality maintained while minimizing token usage
|
||||||
|
|
||||||
|
### 3. **Transparency**
|
||||||
|
- Acts as a pass-through proxy with intelligent enhancements
|
||||||
|
- Preserves full MCP protocol compatibility
|
||||||
|
- No modification of actual tool functionality
|
||||||
|
|
||||||
|
### 4. **Genericity**
|
||||||
|
- Works with any MCP server without modification
|
||||||
|
- Configuration-driven server management
|
||||||
|
- Protocol-agnostic design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ AI Client │
|
||||||
|
│ (uses 2 methods)│
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ MCP Browser │
|
||||||
|
│ ┌───────────┐ │
|
||||||
|
│ │ Proxy │ │
|
||||||
|
│ ├───────────┤ │
|
||||||
|
│ │ Registry │ │
|
||||||
|
│ ├───────────┤ │
|
||||||
|
│ │ Filter │ │
|
||||||
|
│ └───────────┘ │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ MCP Server │
|
||||||
|
│ (any server) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Responsibilities
|
||||||
|
|
||||||
|
1. **Proxy (`proxy.py`)**
|
||||||
|
- Main entry point and API
|
||||||
|
- Manages MCP server lifecycle
|
||||||
|
- Routes messages between client and server
|
||||||
|
- Handles virtual tool calls
|
||||||
|
|
||||||
|
2. **Registry (`registry.py`)**
|
||||||
|
- Stores full tool descriptions
|
||||||
|
- Provides JSONPath-based discovery
|
||||||
|
- Generates sparse tool list
|
||||||
|
|
||||||
|
3. **Filter (`filter.py`)**
|
||||||
|
- Intercepts and modifies messages
|
||||||
|
- Implements sparse mode transformation
|
||||||
|
- Handles virtual tool responses
|
||||||
|
|
||||||
|
4. **Server (`server.py`)**
|
||||||
|
- Spawns and manages MCP server process
|
||||||
|
- Handles bidirectional communication
|
||||||
|
- Manages request/response correlation
|
||||||
|
|
||||||
|
5. **Buffer (`buffer.py`)**
|
||||||
|
- Ensures atomic JSON-RPC message delivery
|
||||||
|
- Handles partial message buffering
|
||||||
|
|
||||||
|
## Sparse Mode Operation
|
||||||
|
|
||||||
|
### Initial State
|
||||||
|
When a client requests `tools/list`, instead of returning all tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"tools": [
|
||||||
|
{"name": "tool1", "description": "..."},
|
||||||
|
{"name": "tool2", "description": "..."},
|
||||||
|
... // potentially hundreds more
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sparse Response
|
||||||
|
The browser returns only meta-tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "mcp_discover",
|
||||||
|
"description": "Discover available tools using JSONPath. 150 tools available.",
|
||||||
|
"inputSchema": {...}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mcp_call",
|
||||||
|
"description": "Execute any MCP tool by constructing a JSON-RPC call.",
|
||||||
|
"inputSchema": {...}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Discovery Flow
|
||||||
|
|
||||||
|
1. Client uses `mcp_discover` to explore tools:
|
||||||
|
```python
|
||||||
|
browser.discover("$.tools[?(@.name contains 'file')]")
|
||||||
|
# Returns all file-related tools
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Client uses `mcp_call` to execute any tool:
|
||||||
|
```python
|
||||||
|
await browser.call({
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "Read",
|
||||||
|
"arguments": {"path": "/tmp/file.txt"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Flow
|
||||||
|
|
||||||
|
### Standard Tool Call
|
||||||
|
```
|
||||||
|
Client -> Browser: tools/call(mcp_call, {method: "tools/call", params: {...}})
|
||||||
|
Browser -> Server: tools/call(ActualTool, {...})
|
||||||
|
Server -> Browser: Result
|
||||||
|
Browser -> Client: Result
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discovery Call
|
||||||
|
```
|
||||||
|
Client -> Browser: tools/call(mcp_discover, {jsonpath: "$.tools[*].name"})
|
||||||
|
Browser: Process locally using registry
|
||||||
|
Browser -> Client: ["tool1", "tool2", ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration System
|
||||||
|
|
||||||
|
### Hierarchical Loading
|
||||||
|
1. Command-line arguments (highest priority)
|
||||||
|
2. Project configuration (`.mcp-browser/config.yaml`)
|
||||||
|
3. User configuration (`~/.mcp-browser/config.yaml`)
|
||||||
|
4. Default configuration (lowest priority)
|
||||||
|
|
||||||
|
### Server Configuration
|
||||||
|
```yaml
|
||||||
|
servers:
|
||||||
|
my_server:
|
||||||
|
command: ["python", "-m", "my_mcp_server"]
|
||||||
|
args: ["--port", "8080"]
|
||||||
|
env:
|
||||||
|
API_KEY: "${API_KEY}"
|
||||||
|
description: "My custom MCP server"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Innovations
|
||||||
|
|
||||||
|
### 1. **Virtual Tool Pattern**
|
||||||
|
Instead of modifying the MCP protocol, we inject virtual tools that exist only in the browser layer. These tools (`mcp_discover`, `mcp_call`) provide meta-functionality for tool discovery and execution.
|
||||||
|
|
||||||
|
### 2. **JSONPath Discovery**
|
||||||
|
Using JSONPath for tool discovery provides a flexible, powerful query language that AI systems can easily use to explore available functionality.
|
||||||
|
|
||||||
|
### 3. **Transparent Routing**
|
||||||
|
The `mcp_call` tool acts as a universal router, allowing execution of any tool without needing to expose all tools initially.
|
||||||
|
|
||||||
|
### 4. **Context Budget**
|
||||||
|
By reducing initial tool exposure from O(n) to O(1), we dramatically reduce context usage while maintaining full functionality.
|
||||||
|
|
||||||
|
## Comparison with Claude Composer
|
||||||
|
|
||||||
|
| Feature | Claude Composer | MCP Browser |
|
||||||
|
|---------|----------------|-------------|
|
||||||
|
| Language | TypeScript | Python |
|
||||||
|
| Target | Claude Code CLI | Generic AI systems |
|
||||||
|
| API | CLI wrapper | Library API |
|
||||||
|
| Tools | Hardcoded meta-tools | Generic virtual tools |
|
||||||
|
| Discovery | get_tool_description | JSONPath queries |
|
||||||
|
| Router | use_tool | mcp_call |
|
||||||
|
| Config | YAML toolsets | YAML servers |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Caching Layer**: Cache tool responses and descriptions
|
||||||
|
2. **Multi-Server Support**: Route to multiple MCP servers
|
||||||
|
3. **Streaming Support**: Handle streaming responses
|
||||||
|
4. **Tool Namespacing**: Automatic namespace management
|
||||||
|
5. **Metrics**: Usage statistics and performance monitoring
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""
|
||||||
|
AI-optimized usage example for MCP Browser.
|
||||||
|
|
||||||
|
Shows how an AI system would use MCP Browser with minimal context usage.
|
||||||
|
Only two methods are needed: call() for execution and discover() for exploration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
|
||||||
|
|
||||||
|
class AIAssistant:
|
||||||
|
"""Example AI assistant using MCP Browser."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.browser = None
|
||||||
|
self.discovered_tools = {}
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Initialize connection to MCP server."""
|
||||||
|
self.browser = MCPBrowser()
|
||||||
|
await self.browser.initialize()
|
||||||
|
|
||||||
|
# In sparse mode, only 2 tools are visible initially:
|
||||||
|
# - mcp_discover: For exploring available tools
|
||||||
|
# - mcp_call: For executing any tool
|
||||||
|
|
||||||
|
async def execute_user_request(self, user_request: str):
|
||||||
|
"""Process a user request using MCP tools."""
|
||||||
|
print(f"User: {user_request}\n")
|
||||||
|
|
||||||
|
# Example: User wants to run a bash command
|
||||||
|
if "run command" in user_request.lower():
|
||||||
|
# First, discover if Bash tool exists
|
||||||
|
bash_tools = self.browser.discover("$.tools[?(@.name=='Bash')]")
|
||||||
|
|
||||||
|
if not bash_tools:
|
||||||
|
print("AI: Bash tool not available on this MCP server.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the tool schema (cached after first discovery)
|
||||||
|
bash_schema = bash_tools[0] if isinstance(bash_tools, list) else bash_tools
|
||||||
|
print(f"AI: Found Bash tool. Schema: {bash_schema['name']}")
|
||||||
|
|
||||||
|
# Execute the command using mcp_call
|
||||||
|
response = await self.browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "Bash",
|
||||||
|
"arguments": {
|
||||||
|
"command": "echo 'Hello from AI-optimized MCP Browser!'",
|
||||||
|
"description": "Test echo command"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
result = response["result"]
|
||||||
|
print(f"AI: Command executed successfully.")
|
||||||
|
if isinstance(result, dict) and "content" in result:
|
||||||
|
content = result["content"][0]["text"]
|
||||||
|
print(f"Output: {content}")
|
||||||
|
else:
|
||||||
|
print(f"AI: Error: {response.get('error', {}).get('message')}")
|
||||||
|
|
||||||
|
# Example: User wants to see available tools
|
||||||
|
elif "what tools" in user_request.lower():
|
||||||
|
# Use discover to get all tool names efficiently
|
||||||
|
tool_names = self.browser.discover("$.tools[*].name")
|
||||||
|
|
||||||
|
if tool_names:
|
||||||
|
print(f"AI: I have access to {len(tool_names)} tools:")
|
||||||
|
for name in tool_names[:10]: # Show first 10
|
||||||
|
print(f" - {name}")
|
||||||
|
if len(tool_names) > 10:
|
||||||
|
print(f" ... and {len(tool_names) - 10} more")
|
||||||
|
else:
|
||||||
|
print("AI: No tools found on this MCP server.")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the MCP connection."""
|
||||||
|
if self.browser:
|
||||||
|
await self.browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Demonstrate AI-optimized MCP usage."""
|
||||||
|
print("=== AI-Optimized MCP Browser Usage ===\n")
|
||||||
|
print("Key insight: Only 2 methods needed for full MCP access:")
|
||||||
|
print("- browser.call(jsonrpc_object) - Execute any operation")
|
||||||
|
print("- browser.discover(jsonpath) - Explore available tools")
|
||||||
|
print("\nThis minimizes context usage while maintaining full functionality.\n")
|
||||||
|
print("-" * 50 + "\n")
|
||||||
|
|
||||||
|
assistant = AIAssistant()
|
||||||
|
await assistant.initialize()
|
||||||
|
|
||||||
|
# Simulate user requests
|
||||||
|
await assistant.execute_user_request("What tools do you have?")
|
||||||
|
print("\n" + "-" * 50 + "\n")
|
||||||
|
|
||||||
|
await assistant.execute_user_request("Run command to show current directory")
|
||||||
|
print("\n" + "-" * 50 + "\n")
|
||||||
|
|
||||||
|
await assistant.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""
|
||||||
|
Basic usage example for MCP Browser.
|
||||||
|
|
||||||
|
Demonstrates the minimal API: call() and discover()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Create browser instance
|
||||||
|
async with MCPBrowser() as browser:
|
||||||
|
print("=== MCP Browser Basic Usage ===\n")
|
||||||
|
|
||||||
|
# 1. List available tools (sparse mode active)
|
||||||
|
print("1. Listing tools (sparse mode):")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
tools = response["result"]["tools"]
|
||||||
|
for tool in tools:
|
||||||
|
print(f" - {tool['name']}: {tool['description'][:60]}...")
|
||||||
|
|
||||||
|
# 2. Discover all actual tools using JSONPath
|
||||||
|
print("\n2. Discovering all tool names:")
|
||||||
|
tool_names = browser.discover("$.tools[*].name")
|
||||||
|
print(f" Found {len(tool_names) if tool_names else 0} tools")
|
||||||
|
if tool_names:
|
||||||
|
print(f" First 5: {tool_names[:5]}")
|
||||||
|
|
||||||
|
# 3. Get details for a specific tool
|
||||||
|
print("\n3. Getting details for a specific tool:")
|
||||||
|
bash_tool = browser.discover("$.tools[?(@.name=='Bash')]")
|
||||||
|
if bash_tool:
|
||||||
|
print(f" Bash tool schema:")
|
||||||
|
print(json.dumps(bash_tool[0] if isinstance(bash_tool, list) else bash_tool, indent=2)[:200] + "...")
|
||||||
|
|
||||||
|
# 4. Use mcp_call to execute a tool
|
||||||
|
print("\n4. Executing a tool via mcp_call:")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "echo", # Assuming echo tool exists
|
||||||
|
"arguments": {
|
||||||
|
"message": "Hello from MCP Browser!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print(" Success:", response["result"])
|
||||||
|
else:
|
||||||
|
print(" Error:", response.get("error", {}).get("message"))
|
||||||
|
|
||||||
|
# 5. Direct JSON-RPC call
|
||||||
|
print("\n5. Direct JSON-RPC call:")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "ping", # If server supports ping
|
||||||
|
"params": {}
|
||||||
|
})
|
||||||
|
print(" Response:", response)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Demonstration of MCP Browser with built-in servers.
|
||||||
|
|
||||||
|
Shows how the built-in servers (screen, memory, patterns, onboarding)
|
||||||
|
are automatically available and can be used through the unified interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path for development
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
|
||||||
|
|
||||||
|
async def demo_builtin_servers():
|
||||||
|
"""Demonstrate built-in server functionality."""
|
||||||
|
|
||||||
|
print("=== MCP Browser Built-in Servers Demo ===\n")
|
||||||
|
print("This demo shows the 4 built-in servers that start automatically:")
|
||||||
|
print("- Screen: GNU screen session management")
|
||||||
|
print("- Memory: Persistent project memory")
|
||||||
|
print("- Patterns: Auto-response patterns")
|
||||||
|
print("- Onboarding: Identity-aware onboarding")
|
||||||
|
print("\n" + "-" * 60 + "\n")
|
||||||
|
|
||||||
|
# Create browser with built-in servers only (no external server)
|
||||||
|
browser = MCPBrowser(server_name="builtin-only")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await browser.initialize()
|
||||||
|
print("✓ MCP Browser initialized with built-in servers\n")
|
||||||
|
|
||||||
|
# 1. Test Onboarding (directly available in sparse mode)
|
||||||
|
print("1. ONBOARDING DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# First, get onboarding for a new identity
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "onboard-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"arguments": {
|
||||||
|
"identity": "DemoAssistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Getting onboarding for 'DemoAssistant':")
|
||||||
|
if "result" in response:
|
||||||
|
text = response["result"]["content"][0]["text"]
|
||||||
|
print(text[:300] + "...\n")
|
||||||
|
|
||||||
|
# Set onboarding instructions
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "onboard-2",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"arguments": {
|
||||||
|
"identity": "DemoAssistant",
|
||||||
|
"instructions": "Welcome back! Remember:\n- You're helping with the MCP Browser project\n- Focus on simplicity and clarity\n- The user prefers concise responses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
print("✓ Set onboarding instructions for DemoAssistant\n")
|
||||||
|
|
||||||
|
# 2. Discover available tools
|
||||||
|
print("\n2. DISCOVERING BUILT-IN TOOLS")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Use mcp_discover to find all tools
|
||||||
|
all_tools = browser.discover("$.tools[*].name")
|
||||||
|
print(f"Total tools available: {len(all_tools) if all_tools else 0}")
|
||||||
|
|
||||||
|
# Group by server
|
||||||
|
tool_groups = {}
|
||||||
|
for tool in all_tools or []:
|
||||||
|
if "::" in tool:
|
||||||
|
server, tool_name = tool.split("::", 1)
|
||||||
|
if server not in tool_groups:
|
||||||
|
tool_groups[server] = []
|
||||||
|
tool_groups[server].append(tool_name)
|
||||||
|
|
||||||
|
for server, tools in tool_groups.items():
|
||||||
|
print(f"\n{server}:")
|
||||||
|
for tool in tools[:3]: # Show first 3
|
||||||
|
print(f" - {tool}")
|
||||||
|
if len(tools) > 3:
|
||||||
|
print(f" ... and {len(tools) - 3} more")
|
||||||
|
|
||||||
|
# 3. Use Memory server
|
||||||
|
print("\n\n3. MEMORY SERVER DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Add a task
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "mem-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "builtin:memory::task_add",
|
||||||
|
"arguments": {
|
||||||
|
"content": "Complete MCP Browser documentation",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Added task to memory")
|
||||||
|
|
||||||
|
# Get memory summary
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "mem-2",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "builtin:memory::memory_summary",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print("\nMemory Summary:")
|
||||||
|
print(response["result"]["content"][0]["text"][:200] + "...")
|
||||||
|
|
||||||
|
# 4. Use Screen server
|
||||||
|
print("\n\n4. SCREEN SERVER DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# List sessions
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "screen-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "builtin:screen::list_sessions",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print("Screen sessions:")
|
||||||
|
print(response["result"]["content"][0]["text"])
|
||||||
|
|
||||||
|
# 5. Pattern Manager
|
||||||
|
print("\n\n5. PATTERN MANAGER DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Add a pattern
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "pattern-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "builtin:patterns::add_pattern",
|
||||||
|
"arguments": {
|
||||||
|
"trigger": ["Continue?", "(y/n)"],
|
||||||
|
"response": "y",
|
||||||
|
"description": "Auto-confirm continue prompts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
print("✓ Added auto-response pattern for continue prompts")
|
||||||
|
|
||||||
|
# List patterns
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "pattern-2",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "builtin:patterns::list_patterns",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print("\nActive patterns:")
|
||||||
|
print(response["result"]["content"][0]["text"][:200] + "...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
print("\n\n✓ MCP Browser closed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\nNote: This demo uses built-in Python MCP servers.")
|
||||||
|
print("Make sure Python 3.8+ is available.\n")
|
||||||
|
|
||||||
|
# Check for screen if on Linux/Mac
|
||||||
|
import platform
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
subprocess.run(["screen", "--version"], capture_output=True, check=True)
|
||||||
|
except:
|
||||||
|
print("Warning: GNU screen not installed. Screen server features won't work.")
|
||||||
|
print("Install with: sudo apt-get install screen (Ubuntu) or brew install screen (Mac)\n")
|
||||||
|
|
||||||
|
asyncio.run(demo_builtin_servers())
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Complete demonstration of MCP Browser functionality.
|
||||||
|
|
||||||
|
This example shows:
|
||||||
|
1. How sparse mode reduces context
|
||||||
|
2. Tool discovery using JSONPath
|
||||||
|
3. Tool execution via mcp_call
|
||||||
|
4. Direct JSON-RPC calls
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path for development
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
|
||||||
|
|
||||||
|
async def demonstrate_mcp_browser():
|
||||||
|
"""Complete demonstration of MCP Browser features."""
|
||||||
|
|
||||||
|
print("=== MCP Browser Complete Demo ===\n")
|
||||||
|
print("This demo shows how MCP Browser provides full MCP access")
|
||||||
|
print("through just 2 methods: call() and discover()\n")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
# Initialize browser with debug mode for visibility
|
||||||
|
browser = MCPBrowser()
|
||||||
|
browser.config_loader.DEFAULT_CONFIG["debug"] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await browser.initialize()
|
||||||
|
print("\n✓ MCP Browser initialized\n")
|
||||||
|
|
||||||
|
# 1. Show sparse mode in action
|
||||||
|
print("\n1. SPARSE MODE DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "sparse-demo",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
tools = response["result"]["tools"]
|
||||||
|
print(f"Initial tools exposed: {len(tools)}")
|
||||||
|
for tool in tools:
|
||||||
|
print(f" - {tool['name']}: {tool['description'][:50]}...")
|
||||||
|
|
||||||
|
# Show actual tool count
|
||||||
|
actual_count = len(browser.registry.tools)
|
||||||
|
print(f"\nActual tools available: {actual_count}")
|
||||||
|
print(f"Context saved: {actual_count - len(tools)} tool descriptions hidden")
|
||||||
|
|
||||||
|
# 2. Demonstrate discovery
|
||||||
|
print("\n\n2. TOOL DISCOVERY DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Get all tool names
|
||||||
|
print("\n→ Discovering all tool names:")
|
||||||
|
tool_names = browser.discover("$.tools[*].name")
|
||||||
|
if tool_names:
|
||||||
|
print(f" Found {len(tool_names)} tools: {', '.join(tool_names[:5])}...")
|
||||||
|
|
||||||
|
# Find specific tools
|
||||||
|
print("\n→ Finding tools with 'memory' in name:")
|
||||||
|
memory_tools = browser.discover("$.tools[?(@.name =~ /.*memory.*/i)]")
|
||||||
|
if memory_tools:
|
||||||
|
for tool in memory_tools:
|
||||||
|
print(f" - {tool['name']}")
|
||||||
|
|
||||||
|
# Get tool schema
|
||||||
|
print("\n→ Getting schema for a specific tool:")
|
||||||
|
schema = browser.discover("$.tools[0].inputSchema")
|
||||||
|
if schema:
|
||||||
|
print(f" Schema type: {schema.get('type', 'unknown')}")
|
||||||
|
if 'properties' in schema:
|
||||||
|
print(f" Properties: {list(schema['properties'].keys())}")
|
||||||
|
|
||||||
|
# 3. Demonstrate tool execution
|
||||||
|
print("\n\n3. TOOL EXECUTION DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# First, discover available tools to find one we can call
|
||||||
|
first_tool = browser.discover("$.tools[0]")
|
||||||
|
if first_tool:
|
||||||
|
tool_name = first_tool["name"]
|
||||||
|
print(f"\n→ Executing '{tool_name}' tool via mcp_call:")
|
||||||
|
|
||||||
|
# Prepare arguments based on schema
|
||||||
|
args = {}
|
||||||
|
if "inputSchema" in first_tool:
|
||||||
|
schema = first_tool["inputSchema"]
|
||||||
|
if schema.get("type") == "object" and "properties" in schema:
|
||||||
|
# Create minimal valid arguments
|
||||||
|
for prop, prop_schema in schema["properties"].items():
|
||||||
|
if prop in schema.get("required", []):
|
||||||
|
if prop_schema.get("type") == "string":
|
||||||
|
args[prop] = "test"
|
||||||
|
elif prop_schema.get("type") == "number":
|
||||||
|
args[prop] = 0
|
||||||
|
elif prop_schema.get("type") == "boolean":
|
||||||
|
args[prop] = False
|
||||||
|
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "exec-demo",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "mcp_call",
|
||||||
|
"arguments": {
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print(" ✓ Tool executed successfully")
|
||||||
|
result_preview = str(response["result"])[:100]
|
||||||
|
print(f" Result preview: {result_preview}...")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Error: {response.get('error', {}).get('message', 'Unknown')}")
|
||||||
|
|
||||||
|
# 4. Direct JSON-RPC demonstration
|
||||||
|
print("\n\n4. DIRECT JSON-RPC DEMONSTRATION")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
print("\n→ Sending custom JSON-RPC request:")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "custom",
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "0.1.0",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "mcp-browser-demo",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print(" ✓ Custom request successful")
|
||||||
|
print(f" Server info: {response['result'].get('serverInfo', {})}")
|
||||||
|
|
||||||
|
# 5. Show AI-optimized usage pattern
|
||||||
|
print("\n\n5. AI-OPTIMIZED USAGE PATTERN")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
print("\nFor AI systems, the entire MCP protocol is accessible via:")
|
||||||
|
print("\n # Execute any operation")
|
||||||
|
print(" response = await browser.call(jsonrpc_object)")
|
||||||
|
print("\n # Discover tools and schemas")
|
||||||
|
print(" info = browser.discover(jsonpath_query)")
|
||||||
|
print("\nThis minimal API provides full functionality with minimal context!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Error: {e}")
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
print("\n✓ MCP Browser closed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\nNote: This demo requires an MCP server to be configured.")
|
||||||
|
print("Edit config/default.yaml or create ~/.mcp-browser/config.yaml")
|
||||||
|
print("to configure your MCP server.\n")
|
||||||
|
|
||||||
|
asyncio.run(demonstrate_mcp_browser())
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""
|
||||||
|
MCP Browser - A generic, minimalistic MCP protocol interface.
|
||||||
|
|
||||||
|
Provides an abstract interface for AI systems to interact with MCP servers
|
||||||
|
with optimized context usage through sparse mode and on-demand tool discovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .proxy import MCPBrowser
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__all__ = ["MCPBrowser"]
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""
|
||||||
|
JSON-RPC message buffering to ensure complete messages are processed.
|
||||||
|
|
||||||
|
Handles partial JSON messages and ensures atomic delivery of complete
|
||||||
|
JSON-RPC messages, critical for reliable MCP communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcBuffer:
|
||||||
|
"""Buffer for accumulating and extracting complete JSON-RPC messages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.buffer = ""
|
||||||
|
|
||||||
|
def append(self, data: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Append data to buffer and extract complete JSON-RPC messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw string data to append
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of complete JSON-RPC message dictionaries
|
||||||
|
"""
|
||||||
|
self.buffer += data
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# Extract complete JSON messages line by line
|
||||||
|
lines = self.buffer.split('\n')
|
||||||
|
|
||||||
|
# Keep the last incomplete line in the buffer
|
||||||
|
self.buffer = lines[-1]
|
||||||
|
|
||||||
|
# Process complete lines
|
||||||
|
for line in lines[:-1]:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
# Validate it's a proper JSON-RPC message
|
||||||
|
if isinstance(msg, dict) and ('jsonrpc' in msg or 'method' in msg or 'id' in msg):
|
||||||
|
messages.append(msg)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Log or handle malformed JSON
|
||||||
|
pass
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear the buffer."""
|
||||||
|
self.buffer = ""
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""
|
||||||
|
Configuration management for MCP Browser.
|
||||||
|
|
||||||
|
Handles loading and validation of MCP server configurations,
|
||||||
|
supporting hierarchical config loading and runtime overrides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MCPServerConfig:
|
||||||
|
"""Configuration for a single MCP server."""
|
||||||
|
command: List[str]
|
||||||
|
args: List[str] = field(default_factory=list)
|
||||||
|
env: Dict[str, str] = field(default_factory=dict)
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MCPBrowserConfig:
|
||||||
|
"""Main configuration for MCP Browser."""
|
||||||
|
servers: Dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||||
|
default_server: Optional[str] = None
|
||||||
|
sparse_mode: bool = True
|
||||||
|
debug: bool = False
|
||||||
|
buffer_size: int = 65536
|
||||||
|
timeout: float = 30.0
|
||||||
|
enable_builtin_servers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoader:
|
||||||
|
"""Loads and manages MCP Browser configuration."""
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"servers": {
|
||||||
|
"default": {
|
||||||
|
"command": ["npx", "-y", "@modelcontextprotocol/server-memory"],
|
||||||
|
"name": "memory",
|
||||||
|
"description": "Default in-memory MCP server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default_server": "default",
|
||||||
|
"sparse_mode": True,
|
||||||
|
"debug": False,
|
||||||
|
"enable_builtin_servers": True
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
self.config_path = config_path or self._find_config_file()
|
||||||
|
self._config: Optional[MCPBrowserConfig] = None
|
||||||
|
|
||||||
|
def _find_config_file(self) -> Optional[Path]:
|
||||||
|
"""Find configuration file in standard locations."""
|
||||||
|
locations = [
|
||||||
|
Path.cwd() / "mcp-browser.yaml",
|
||||||
|
Path.cwd() / ".mcp-browser" / "config.yaml",
|
||||||
|
Path.home() / ".mcp-browser" / "config.yaml",
|
||||||
|
Path(__file__).parent.parent / "config" / "default.yaml"
|
||||||
|
]
|
||||||
|
|
||||||
|
for loc in locations:
|
||||||
|
if loc.exists():
|
||||||
|
return loc
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load(self) -> MCPBrowserConfig:
|
||||||
|
"""Load configuration from file or use defaults."""
|
||||||
|
if self._config:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
config_data = self.DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
if self.config_path and self.config_path.exists():
|
||||||
|
with open(self.config_path) as f:
|
||||||
|
file_config = yaml.safe_load(f)
|
||||||
|
if file_config:
|
||||||
|
self._merge_configs(config_data, file_config)
|
||||||
|
|
||||||
|
# Convert to dataclass instances
|
||||||
|
servers = {}
|
||||||
|
for name, server_config in config_data.get("servers", {}).items():
|
||||||
|
servers[name] = MCPServerConfig(
|
||||||
|
command=server_config["command"],
|
||||||
|
args=server_config.get("args", []),
|
||||||
|
env=server_config.get("env", {}),
|
||||||
|
name=server_config.get("name", name),
|
||||||
|
description=server_config.get("description")
|
||||||
|
)
|
||||||
|
|
||||||
|
self._config = MCPBrowserConfig(
|
||||||
|
servers=servers,
|
||||||
|
default_server=config_data.get("default_server"),
|
||||||
|
sparse_mode=config_data.get("sparse_mode", True),
|
||||||
|
debug=config_data.get("debug", False),
|
||||||
|
buffer_size=config_data.get("buffer_size", 65536),
|
||||||
|
timeout=config_data.get("timeout", 30.0),
|
||||||
|
enable_builtin_servers=config_data.get("enable_builtin_servers", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]):
|
||||||
|
"""Merge override config into base config."""
|
||||||
|
for key, value in override.items():
|
||||||
|
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||||
|
self._merge_configs(base[key], value)
|
||||||
|
else:
|
||||||
|
base[key] = value
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""
|
||||||
|
Message filtering and transformation for sparse mode.
|
||||||
|
|
||||||
|
Intercepts and modifies JSON-RPC messages to implement
|
||||||
|
sparse mode and virtual tool injection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, Optional, List, Callable
|
||||||
|
from .registry import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFilter:
|
||||||
|
"""Filter and transform JSON-RPC messages for sparse mode."""
|
||||||
|
|
||||||
|
def __init__(self, registry: ToolRegistry, sparse_mode: bool = True):
|
||||||
|
self.registry = registry
|
||||||
|
self.sparse_mode = sparse_mode
|
||||||
|
self._handled_ids: set = set()
|
||||||
|
|
||||||
|
def filter_outgoing(self, message: dict) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Filter messages going from client to server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Outgoing JSON-RPC message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified message or None to block
|
||||||
|
"""
|
||||||
|
# For now, pass through all outgoing messages
|
||||||
|
return message
|
||||||
|
|
||||||
|
def filter_incoming(self, message: dict) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Filter messages coming from server to client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Incoming JSON-RPC message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified message or None to block
|
||||||
|
"""
|
||||||
|
# Check if this is a duplicate error for a handled request
|
||||||
|
if (message.get("id") in self._handled_ids and
|
||||||
|
message.get("error", {}).get("code") == -32603):
|
||||||
|
# Block duplicate error
|
||||||
|
self._handled_ids.discard(message.get("id"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Intercept tools/list responses for sparse mode
|
||||||
|
if (self.sparse_mode and
|
||||||
|
message.get("id") and
|
||||||
|
message.get("result", {}).get("tools")):
|
||||||
|
return self._filter_tools_response(message)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _filter_tools_response(self, message: dict) -> dict:
|
||||||
|
"""Apply sparse mode filtering to tools/list response."""
|
||||||
|
tools = message["result"]["tools"]
|
||||||
|
|
||||||
|
# Update registry with full tool list
|
||||||
|
self.registry.update_tools(tools)
|
||||||
|
|
||||||
|
# Replace with sparse tools
|
||||||
|
message = message.copy()
|
||||||
|
message["result"] = message["result"].copy()
|
||||||
|
message["result"]["tools"] = self.registry.get_sparse_tools()
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def mark_handled(self, request_id: Union[str, int]):
|
||||||
|
"""Mark a request ID as handled locally."""
|
||||||
|
self._handled_ids.add(request_id)
|
||||||
|
|
||||||
|
def is_virtual_tool(self, tool_name: str) -> bool:
|
||||||
|
"""Check if a tool is virtual (handled locally)."""
|
||||||
|
return tool_name in ["mcp_discover", "mcp_call", "onboarding"]
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualToolHandler:
|
||||||
|
"""Handles virtual tool calls that don't exist on the MCP server."""
|
||||||
|
|
||||||
|
def __init__(self, registry: ToolRegistry, server_callback: Callable):
|
||||||
|
self.registry = registry
|
||||||
|
self.server_callback = server_callback
|
||||||
|
|
||||||
|
async def handle_tool_call(self, message: dict) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Handle virtual tool calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Tool call request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response message or None if not handled
|
||||||
|
"""
|
||||||
|
if message.get("method") != "tools/call":
|
||||||
|
return None
|
||||||
|
|
||||||
|
tool_name = message.get("params", {}).get("name")
|
||||||
|
|
||||||
|
if tool_name == "mcp_discover":
|
||||||
|
return await self._handle_discover(message)
|
||||||
|
elif tool_name == "mcp_call":
|
||||||
|
return await self._handle_call(message)
|
||||||
|
elif tool_name == "onboarding":
|
||||||
|
# Onboarding is handled specially in the proxy
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_discover(self, message: dict) -> dict:
|
||||||
|
"""Handle mcp_discover tool call."""
|
||||||
|
params = message.get("params", {}).get("arguments", {})
|
||||||
|
jsonpath = params.get("jsonpath", "$.tools[*]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.registry.discover(jsonpath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message.get("id"),
|
||||||
|
"result": {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": json.dumps(result, indent=2) if result else "No matches found"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message.get("id"),
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": f"Discovery error: {str(e)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _handle_call(self, message: dict) -> dict:
|
||||||
|
"""Handle mcp_call tool - forward transformed request."""
|
||||||
|
params = message.get("params", {}).get("arguments", {})
|
||||||
|
|
||||||
|
# Extract method and params from the tool arguments
|
||||||
|
method = params.get("method")
|
||||||
|
call_params = params.get("params", {})
|
||||||
|
|
||||||
|
if not method:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message.get("id"),
|
||||||
|
"error": {
|
||||||
|
"code": -32602,
|
||||||
|
"message": "Missing 'method' parameter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create the actual JSON-RPC call
|
||||||
|
forwarded_request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message.get("id"), # Use same ID for response mapping
|
||||||
|
"method": method,
|
||||||
|
"params": call_params
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward to server and get response
|
||||||
|
try:
|
||||||
|
# The server_callback should handle sending and receiving
|
||||||
|
response = await self.server_callback(forwarded_request)
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message.get("id"),
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": str(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""
|
||||||
|
Multi-server management for MCP Browser.
|
||||||
|
|
||||||
|
Manages multiple MCP servers including built-in servers that are
|
||||||
|
automatically started with the browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .server import MCPServer
|
||||||
|
from .config import MCPServerConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MultiServerManager:
|
||||||
|
"""Manages multiple MCP servers."""
|
||||||
|
|
||||||
|
def __init__(self, debug: bool = False):
|
||||||
|
self.debug = debug
|
||||||
|
self.servers: Dict[str, MCPServer] = {}
|
||||||
|
self.builtin_servers = self._get_builtin_servers()
|
||||||
|
|
||||||
|
def _get_builtin_servers(self) -> Dict[str, MCPServerConfig]:
|
||||||
|
"""Get configuration for built-in MCP servers."""
|
||||||
|
base_path = Path(__file__).parent.parent / "mcp_servers"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"builtin:screen": MCPServerConfig(
|
||||||
|
command=["python3", str(base_path / "screen" / "screen_server.py")],
|
||||||
|
name="screen",
|
||||||
|
description="GNU screen session management"
|
||||||
|
),
|
||||||
|
"builtin:memory": MCPServerConfig(
|
||||||
|
command=["python3", str(base_path / "memory" / "memory_server.py")],
|
||||||
|
name="memory",
|
||||||
|
description="Persistent memory and context management"
|
||||||
|
),
|
||||||
|
"builtin:patterns": MCPServerConfig(
|
||||||
|
command=["python3", str(base_path / "pattern_manager" / "pattern_server.py")],
|
||||||
|
name="patterns",
|
||||||
|
description="Auto-response pattern management"
|
||||||
|
),
|
||||||
|
"builtin:onboarding": MCPServerConfig(
|
||||||
|
command=["python3", str(base_path / "onboarding" / "onboarding_server.py")],
|
||||||
|
name="onboarding",
|
||||||
|
description="Identity-aware onboarding management"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start_builtin_servers(self):
|
||||||
|
"""Start all built-in servers."""
|
||||||
|
for name, config in self.builtin_servers.items():
|
||||||
|
if self.debug:
|
||||||
|
print(f"Starting built-in server: {name}")
|
||||||
|
|
||||||
|
server = MCPServer(config, debug=self.debug)
|
||||||
|
await server.start()
|
||||||
|
self.servers[name] = server
|
||||||
|
|
||||||
|
# Initialize each server
|
||||||
|
try:
|
||||||
|
await server.send_request("initialize", {
|
||||||
|
"protocolVersion": "0.1.0",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "mcp-browser",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
print(f"Failed to initialize {name}: {e}")
|
||||||
|
|
||||||
|
async def add_server(self, name: str, config: MCPServerConfig):
|
||||||
|
"""Add and start a custom server."""
|
||||||
|
if name in self.servers:
|
||||||
|
raise ValueError(f"Server {name} already exists")
|
||||||
|
|
||||||
|
server = MCPServer(config, debug=self.debug)
|
||||||
|
await server.start()
|
||||||
|
self.servers[name] = server
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
await server.send_request("initialize", {
|
||||||
|
"protocolVersion": "0.1.0",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "mcp-browser",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get tools from all servers."""
|
||||||
|
all_tools = []
|
||||||
|
|
||||||
|
for server_name, server in self.servers.items():
|
||||||
|
try:
|
||||||
|
response = await server.send_request("tools/list", {})
|
||||||
|
tools = response.get("tools", [])
|
||||||
|
|
||||||
|
# Add server prefix to tool names to avoid conflicts
|
||||||
|
for tool in tools:
|
||||||
|
# Keep original name for display
|
||||||
|
tool["_original_name"] = tool["name"]
|
||||||
|
tool["_server"] = server_name
|
||||||
|
# Prefix tool name with server
|
||||||
|
tool["name"] = f"{server_name}::{tool['name']}"
|
||||||
|
tool["description"] = f"[{server_name}] {tool['description']}"
|
||||||
|
|
||||||
|
all_tools.extend(tools)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
print(f"Failed to get tools from {server_name}: {e}")
|
||||||
|
|
||||||
|
return all_tools
|
||||||
|
|
||||||
|
async def route_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Route a tool call to the appropriate server."""
|
||||||
|
# Check if tool has server prefix
|
||||||
|
if "::" in tool_name:
|
||||||
|
server_name, actual_tool = tool_name.split("::", 1)
|
||||||
|
|
||||||
|
if server_name in self.servers:
|
||||||
|
# Call the tool on the specific server
|
||||||
|
response = await self.servers[server_name].send_request("tools/call", {
|
||||||
|
"name": actual_tool,
|
||||||
|
"arguments": arguments
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
raise Exception(f"Server {server_name} not found")
|
||||||
|
else:
|
||||||
|
# Try to find tool in any server (backward compatibility)
|
||||||
|
for server_name, server in self.servers.items():
|
||||||
|
try:
|
||||||
|
response = await server.send_request("tools/call", {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise Exception(f"Tool {tool_name} not found in any server")
|
||||||
|
|
||||||
|
async def stop_all(self):
|
||||||
|
"""Stop all servers."""
|
||||||
|
for name, server in self.servers.items():
|
||||||
|
if self.debug:
|
||||||
|
print(f"Stopping server: {name}")
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
self.servers.clear()
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
"""
|
||||||
|
Main MCP Browser proxy implementation.
|
||||||
|
|
||||||
|
Provides a generic, minimalistic interface for interacting with MCP servers
|
||||||
|
with automatic routing, sparse mode, and context optimization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, Optional, Union
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import ConfigLoader, MCPBrowserConfig
|
||||||
|
from .server import MCPServer
|
||||||
|
from .multi_server import MultiServerManager
|
||||||
|
from .registry import ToolRegistry
|
||||||
|
from .filter import MessageFilter, VirtualToolHandler
|
||||||
|
from .buffer import JsonRpcBuffer
|
||||||
|
|
||||||
|
|
||||||
|
class MCPBrowser:
|
||||||
|
"""
|
||||||
|
Generic MCP protocol browser with minimal API.
|
||||||
|
|
||||||
|
Provides two main methods:
|
||||||
|
- call(): Execute any JSON-RPC call
|
||||||
|
- discover(): Explore available tools using JSONPath
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None, server_name: Optional[str] = None,
|
||||||
|
enable_builtin_servers: bool = True):
|
||||||
|
"""
|
||||||
|
Initialize MCP Browser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Optional path to configuration file
|
||||||
|
server_name: Optional MCP server name to use (overrides default)
|
||||||
|
enable_builtin_servers: Whether to start built-in servers (screen, memory, etc.)
|
||||||
|
"""
|
||||||
|
self.config_loader = ConfigLoader(config_path)
|
||||||
|
self.config: Optional[MCPBrowserConfig] = None
|
||||||
|
self.server: Optional[MCPServer] = None
|
||||||
|
self.multi_server: Optional[MultiServerManager] = None
|
||||||
|
self.registry = ToolRegistry()
|
||||||
|
self.filter: Optional[MessageFilter] = None
|
||||||
|
self.virtual_handler: Optional[VirtualToolHandler] = None
|
||||||
|
self._server_name = server_name
|
||||||
|
self._enable_builtin_servers = enable_builtin_servers
|
||||||
|
self._initialized = False
|
||||||
|
self._response_buffer: Dict[Union[str, int], asyncio.Future] = {}
|
||||||
|
self._next_id = 1
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self.initialize()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Initialize the browser and start MCP server."""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
self.config = self.config_loader.load()
|
||||||
|
|
||||||
|
# Determine which server to use
|
||||||
|
server_name = self._server_name or self.config.default_server
|
||||||
|
if not server_name or server_name not in self.config.servers:
|
||||||
|
raise ValueError(f"Server '{server_name}' not found in configuration")
|
||||||
|
|
||||||
|
server_config = self.config.servers[server_name]
|
||||||
|
|
||||||
|
# Create multi-server manager if using built-in servers
|
||||||
|
if self._enable_builtin_servers:
|
||||||
|
self.multi_server = MultiServerManager(debug=self.config.debug)
|
||||||
|
await self.multi_server.start_builtin_servers()
|
||||||
|
|
||||||
|
# Create main server if specified
|
||||||
|
if server_name != "builtin-only":
|
||||||
|
self.server = MCPServer(server_config, debug=self.config.debug)
|
||||||
|
# Set up message handling
|
||||||
|
self.server.add_message_handler(self._handle_server_message)
|
||||||
|
# Start server
|
||||||
|
await self.server.start()
|
||||||
|
|
||||||
|
# Create filter and handler
|
||||||
|
self.filter = MessageFilter(self.registry, sparse_mode=self.config.sparse_mode)
|
||||||
|
self.virtual_handler = VirtualToolHandler(self.registry, self._forward_to_server)
|
||||||
|
|
||||||
|
# Initialize connection
|
||||||
|
await self._initialize_connection()
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the browser and stop all MCP servers."""
|
||||||
|
if self.server:
|
||||||
|
await self.server.stop()
|
||||||
|
if self.multi_server:
|
||||||
|
await self.multi_server.stop_all()
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
async def call(self, jsonrpc_object: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute a JSON-RPC call.
|
||||||
|
|
||||||
|
This is the main generic interface for all MCP operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jsonrpc_object: Complete JSON-RPC request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON-RPC response object
|
||||||
|
|
||||||
|
Example:
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "Bash",
|
||||||
|
"arguments": {"command": "ls"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
# Ensure request has an ID
|
||||||
|
if "id" not in jsonrpc_object:
|
||||||
|
jsonrpc_object = jsonrpc_object.copy()
|
||||||
|
jsonrpc_object["id"] = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
|
||||||
|
request_id = jsonrpc_object["id"]
|
||||||
|
|
||||||
|
# Check if this is a virtual tool call
|
||||||
|
if jsonrpc_object.get("method") == "tools/call":
|
||||||
|
tool_name = jsonrpc_object.get("params", {}).get("name")
|
||||||
|
|
||||||
|
if self.filter.is_virtual_tool(tool_name):
|
||||||
|
# Handle virtual tool locally
|
||||||
|
response = await self.virtual_handler.handle_tool_call(jsonrpc_object)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
elif tool_name == "onboarding" and self.multi_server:
|
||||||
|
# Special handling for onboarding tool - route to built-in server
|
||||||
|
try:
|
||||||
|
args = jsonrpc_object.get("params", {}).get("arguments", {})
|
||||||
|
response = await self.multi_server.route_tool_call(
|
||||||
|
"builtin:onboarding::onboarding", args
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": response
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {"code": -32603, "message": str(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create future for response
|
||||||
|
future = asyncio.Future()
|
||||||
|
self._response_buffer[request_id] = future
|
||||||
|
|
||||||
|
# Send to server
|
||||||
|
self.server.send_raw(json.dumps(jsonrpc_object))
|
||||||
|
|
||||||
|
# Wait for response
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(future, timeout=self.config.timeout)
|
||||||
|
return response
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
del self._response_buffer[request_id]
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": "Request timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def discover(self, jsonpath: str) -> Any:
|
||||||
|
"""
|
||||||
|
Discover available tools and their properties using JSONPath.
|
||||||
|
|
||||||
|
This is a synchronous convenience method for tool discovery.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jsonpath: JSONPath expression to query tool registry
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Query results (list, dict, or primitive value)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Get all tool names
|
||||||
|
tools = browser.discover("$.tools[*].name")
|
||||||
|
|
||||||
|
# Get specific tool
|
||||||
|
bash_tool = browser.discover("$.tools[?(@.name=='Bash')]")
|
||||||
|
|
||||||
|
# Get all input schemas
|
||||||
|
schemas = browser.discover("$.tools[*].inputSchema")
|
||||||
|
"""
|
||||||
|
return self.registry.discover(jsonpath)
|
||||||
|
|
||||||
|
async def _initialize_connection(self):
|
||||||
|
"""Initialize MCP connection and populate tool registry."""
|
||||||
|
# Send initialize request
|
||||||
|
init_response = await self.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "init",
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "0.1.0",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "mcp-browser",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "error" in init_response:
|
||||||
|
raise RuntimeError(f"Failed to initialize: {init_response['error']}")
|
||||||
|
|
||||||
|
# Get tool list from main server
|
||||||
|
if self.server:
|
||||||
|
tools_response = await self.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "tools",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "error" in tools_response:
|
||||||
|
raise RuntimeError(f"Failed to list tools: {tools_response['error']}")
|
||||||
|
|
||||||
|
# Also get tools from multi-server if enabled
|
||||||
|
if self.multi_server:
|
||||||
|
builtin_tools = await self.multi_server.get_all_tools()
|
||||||
|
# Add to registry without going through filter
|
||||||
|
existing_tools = self.registry.raw_tool_list
|
||||||
|
self.registry.update_tools(existing_tools + builtin_tools)
|
||||||
|
|
||||||
|
def _handle_server_message(self, message: dict):
|
||||||
|
"""Handle incoming message from MCP server."""
|
||||||
|
# Apply incoming filter
|
||||||
|
filtered = self.filter.filter_incoming(message)
|
||||||
|
if not filtered:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if this is a response to a pending request
|
||||||
|
msg_id = filtered.get("id")
|
||||||
|
if msg_id in self._response_buffer:
|
||||||
|
future = self._response_buffer.pop(msg_id)
|
||||||
|
future.set_result(filtered)
|
||||||
|
|
||||||
|
async def _forward_to_server(self, request: dict) -> dict:
|
||||||
|
"""Forward a request to the MCP server and get response."""
|
||||||
|
# This is used by the virtual tool handler for mcp_call
|
||||||
|
return await self.call(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience function for simple usage
|
||||||
|
async def create_browser(config_path: Optional[Path] = None,
|
||||||
|
server_name: Optional[str] = None) -> MCPBrowser:
|
||||||
|
"""Create and initialize an MCP Browser instance."""
|
||||||
|
browser = MCPBrowser(config_path, server_name)
|
||||||
|
await browser.initialize()
|
||||||
|
return browser
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""
|
||||||
|
Tool registry for storing and discovering MCP tools.
|
||||||
|
|
||||||
|
Manages tool descriptions, provides JSONPath-based discovery,
|
||||||
|
and supports sparse mode for context optimization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
from jsonpath_ng import parse as jsonpath_parse
|
||||||
|
from jsonpath_ng.exceptions import JsonPathParserError
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
"""Registry for MCP tools with discovery capabilities."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tools: Dict[str, Any] = {}
|
||||||
|
self.raw_tool_list: List[Dict[str, Any]] = []
|
||||||
|
self._metadata: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def update_tools(self, tools: List[Dict[str, Any]]):
|
||||||
|
"""
|
||||||
|
Update the registry with a list of tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: List of tool definitions from MCP server
|
||||||
|
"""
|
||||||
|
self.raw_tool_list = tools
|
||||||
|
self.tools.clear()
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
if "name" in tool:
|
||||||
|
self.tools[tool["name"]] = tool
|
||||||
|
|
||||||
|
def get_tool(self, name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get a tool definition by name."""
|
||||||
|
return self.tools.get(name)
|
||||||
|
|
||||||
|
def get_all_tool_names(self) -> List[str]:
|
||||||
|
"""Get all registered tool names."""
|
||||||
|
return list(self.tools.keys())
|
||||||
|
|
||||||
|
def discover(self, jsonpath: str) -> Union[List[Any], Any, None]:
|
||||||
|
"""
|
||||||
|
Discover tools using JSONPath queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jsonpath: JSONPath expression to query tools
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Query results or None if no matches
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$.tools[*].name - Get all tool names
|
||||||
|
$.tools[?(@.name=='Bash')] - Get Bash tool details
|
||||||
|
$.tools[*].inputSchema - Get all input schemas
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
expr = jsonpath_parse(jsonpath)
|
||||||
|
except (JsonPathParserError, Exception):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create a searchable structure
|
||||||
|
search_data = {
|
||||||
|
"tools": self.raw_tool_list,
|
||||||
|
"tool_names": self.get_all_tool_names(),
|
||||||
|
"metadata": self._metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute JSONPath query
|
||||||
|
matches = expr.find(search_data)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
elif len(matches) == 1:
|
||||||
|
return matches[0].value
|
||||||
|
else:
|
||||||
|
return [match.value for match in matches]
|
||||||
|
|
||||||
|
def get_sparse_tools(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get minimal tool list for sparse mode.
|
||||||
|
|
||||||
|
Returns only essential meta-tools for discovery.
|
||||||
|
"""
|
||||||
|
tool_count = len(self.tools)
|
||||||
|
|
||||||
|
sparse_tools = [
|
||||||
|
{
|
||||||
|
"name": "mcp_discover",
|
||||||
|
"description": f"Discover available tools using JSONPath. {tool_count} tools available.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"jsonpath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSONPath expression (e.g., '$.tools[*].name')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["jsonpath"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mcp_call",
|
||||||
|
"description": "Execute any MCP tool by constructing a JSON-RPC call.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON-RPC method (e.g., 'tools/call')"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Method parameters"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["method", "params"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onboarding",
|
||||||
|
"description": "Get or set identity-specific onboarding instructions for AI contexts.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"identity": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Identity for onboarding (e.g., 'Claude', project name)"
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: Set new instructions. If omitted, retrieves existing."
|
||||||
|
},
|
||||||
|
"append": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Append to existing instructions instead of replacing",
|
||||||
|
"default": False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["identity"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return sparse_tools
|
||||||
|
|
||||||
|
def set_metadata(self, key: str, value: Any):
|
||||||
|
"""Set metadata that can be discovered via JSONPath."""
|
||||||
|
self._metadata[key] = value
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
"""Export registry as JSON for debugging."""
|
||||||
|
return json.dumps({
|
||||||
|
"tools": self.raw_tool_list,
|
||||||
|
"metadata": self._metadata
|
||||||
|
}, indent=2)
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""
|
||||||
|
MCP server process management.
|
||||||
|
|
||||||
|
Handles spawning, lifecycle management, and communication with MCP servers.
|
||||||
|
Supports both interactive and non-interactive modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Dict, Any, Callable, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .buffer import JsonRpcBuffer
|
||||||
|
from .config import MCPServerConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MCPServer:
|
||||||
|
"""Manages a single MCP server process."""
|
||||||
|
|
||||||
|
def __init__(self, config: MCPServerConfig, debug: bool = False):
|
||||||
|
self.config = config
|
||||||
|
self.debug = debug
|
||||||
|
self.process: Optional[subprocess.Popen] = None
|
||||||
|
self.buffer = JsonRpcBuffer()
|
||||||
|
self._running = False
|
||||||
|
self._message_handlers: List[Callable[[dict], None]] = []
|
||||||
|
self._next_id = 1
|
||||||
|
self._pending_requests: Dict[Union[str, int], asyncio.Future] = {}
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the MCP server process."""
|
||||||
|
if self.process:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prepare environment
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update({
|
||||||
|
"NODE_NO_READLINE": "1",
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
**self.config.env
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = self.config.command + self.config.args
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"Starting MCP server: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
# Start process
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE if self.debug else subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
bufsize=0 # Unbuffered
|
||||||
|
)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# Start reading outputs
|
||||||
|
asyncio.create_task(self._read_stdout())
|
||||||
|
if self.debug:
|
||||||
|
asyncio.create_task(self._read_stderr())
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the MCP server process."""
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self.process:
|
||||||
|
self.process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(self.process.wait),
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.process.kill()
|
||||||
|
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
async def send_request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Send a JSON-RPC request and wait for response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: JSON-RPC method name
|
||||||
|
params: Optional parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response result or raises exception on error
|
||||||
|
"""
|
||||||
|
if not self.process:
|
||||||
|
raise RuntimeError("MCP server not started")
|
||||||
|
|
||||||
|
request_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params or {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create future for response
|
||||||
|
future = asyncio.Future()
|
||||||
|
self._pending_requests[request_id] = future
|
||||||
|
|
||||||
|
# Send request
|
||||||
|
request_str = json.dumps(request) + "\n"
|
||||||
|
self.process.stdin.write(request_str)
|
||||||
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"Sent: {request_str.strip()}")
|
||||||
|
|
||||||
|
# Wait for response
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(future, timeout=30.0)
|
||||||
|
return response
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
del self._pending_requests[request_id]
|
||||||
|
raise TimeoutError(f"No response for request {request_id}")
|
||||||
|
|
||||||
|
def send_raw(self, message: str):
|
||||||
|
"""Send raw message to MCP server (for pass-through)."""
|
||||||
|
if not self.process:
|
||||||
|
raise RuntimeError("MCP server not started")
|
||||||
|
|
||||||
|
if not message.endswith('\n'):
|
||||||
|
message += '\n'
|
||||||
|
|
||||||
|
self.process.stdin.write(message)
|
||||||
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
def add_message_handler(self, handler: Callable[[dict], None]):
|
||||||
|
"""Add a handler for incoming messages."""
|
||||||
|
self._message_handlers.append(handler)
|
||||||
|
|
||||||
|
async def _read_stdout(self):
|
||||||
|
"""Read and process stdout from MCP server."""
|
||||||
|
while self._running and self.process:
|
||||||
|
try:
|
||||||
|
line = await asyncio.to_thread(self.process.stdout.readline)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
messages = self.buffer.append(line)
|
||||||
|
for msg in messages:
|
||||||
|
await self._handle_message(msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
print(f"Error reading stdout: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _read_stderr(self):
|
||||||
|
"""Read and log stderr from MCP server."""
|
||||||
|
while self._running and self.process:
|
||||||
|
try:
|
||||||
|
line = await asyncio.to_thread(self.process.stderr.readline)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"MCP stderr: {line.strip()}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _handle_message(self, message: dict):
|
||||||
|
"""Handle an incoming JSON-RPC message."""
|
||||||
|
if self.debug:
|
||||||
|
print(f"Received: {json.dumps(message)}")
|
||||||
|
|
||||||
|
# Check if it's a response to a pending request
|
||||||
|
msg_id = message.get("id")
|
||||||
|
if msg_id in self._pending_requests:
|
||||||
|
future = self._pending_requests.pop(msg_id)
|
||||||
|
|
||||||
|
if "error" in message:
|
||||||
|
future.set_exception(Exception(message["error"].get("message", "Unknown error")))
|
||||||
|
else:
|
||||||
|
future.set_result(message.get("result"))
|
||||||
|
|
||||||
|
# Call registered handlers
|
||||||
|
for handler in self._message_handlers:
|
||||||
|
try:
|
||||||
|
handler(message)
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
print(f"Handler error: {e}")
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""
|
||||||
|
Base MCP server implementation for Python.
|
||||||
|
|
||||||
|
Provides a foundation for building MCP servers with standard
|
||||||
|
JSON-RPC handling and tool management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, List, Optional, Callable
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMCPServer(ABC):
|
||||||
|
"""Base class for MCP servers."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, version: str = "1.0.0"):
|
||||||
|
self.name = name
|
||||||
|
self.version = version
|
||||||
|
self.tools: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle a tool call. Must be implemented by subclasses."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def register_tool(self, name: str, description: str, input_schema: Dict[str, Any],
|
||||||
|
handler: Optional[Callable] = None):
|
||||||
|
"""Register a tool with the server."""
|
||||||
|
self.tools[name] = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"inputSchema": input_schema,
|
||||||
|
"handler": handler
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle a JSON-RPC request."""
|
||||||
|
method = request.get("method")
|
||||||
|
params = request.get("params", {})
|
||||||
|
request_id = request.get("id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == "initialize":
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "tools/list":
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"tools": list(self.tools.values())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
if tool_name not in self.tools:
|
||||||
|
raise Exception(f"Tool '{tool_name}' not found")
|
||||||
|
|
||||||
|
# Use registered handler if available, otherwise use abstract method
|
||||||
|
tool_info = self.tools[tool_name]
|
||||||
|
if tool_info.get("handler"):
|
||||||
|
result = await tool_info["handler"](arguments)
|
||||||
|
else:
|
||||||
|
result = await self.handle_tool_call(tool_name, arguments)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": result
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise Exception(f"Method '{method}' not found")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": str(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Run the MCP server, reading from stdin and writing to stdout."""
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# Platform-specific non-blocking setup
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
flags = fcntl.fcntl(sys.stdin.fileno(), fcntl.F_GETFL)
|
||||||
|
fcntl.fcntl(sys.stdin.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||||
|
except ImportError:
|
||||||
|
# Windows doesn't have fcntl
|
||||||
|
pass
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# Try to read available data
|
||||||
|
chunk = sys.stdin.read(4096)
|
||||||
|
if chunk:
|
||||||
|
buffer += chunk
|
||||||
|
|
||||||
|
# Process complete lines
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
response = await self.handle_request(request)
|
||||||
|
print(json.dumps(response), flush=True)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": None,
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": "Parse error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(json.dumps(error_response), flush=True)
|
||||||
|
|
||||||
|
except BlockingIOError:
|
||||||
|
# No data available, sleep briefly
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
except EOFError:
|
||||||
|
# stdin closed
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
|
||||||
|
def content_text(self, text: str) -> Dict[str, Any]:
|
||||||
|
"""Helper to create text content response."""
|
||||||
|
return {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": text
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Memory MCP Server - Persistent memory and context management.
|
||||||
|
|
||||||
|
Provides tools for managing project memory, tasks, decisions, patterns,
|
||||||
|
and knowledge across sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from dataclasses import dataclass, asdict, field
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
from base import BaseMCPServer
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Task:
|
||||||
|
id: str
|
||||||
|
content: str
|
||||||
|
status: str = "pending" # pending, in_progress, completed
|
||||||
|
priority: str = "medium" # low, medium, high
|
||||||
|
assignee: Optional[str] = None
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decision:
|
||||||
|
id: str
|
||||||
|
choice: str
|
||||||
|
reasoning: str
|
||||||
|
alternatives: List[str]
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Pattern:
|
||||||
|
id: str
|
||||||
|
pattern: str
|
||||||
|
description: str
|
||||||
|
priority: str = "medium"
|
||||||
|
effectiveness: float = 0.5
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
resolved: bool = False
|
||||||
|
solution: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryServer(BaseMCPServer):
|
||||||
|
"""MCP server for memory and context management."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
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._register_tools()
|
||||||
|
self._load_memory()
|
||||||
|
|
||||||
|
def _register_tools(self):
|
||||||
|
"""Register all memory management tools."""
|
||||||
|
|
||||||
|
# Task management
|
||||||
|
self.register_tool(
|
||||||
|
name="task_add",
|
||||||
|
description="Add a new task to the current project",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {"type": "string", "description": "Task description"},
|
||||||
|
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||||
|
"assignee": {"type": "string", "description": "Optional assignee"}
|
||||||
|
},
|
||||||
|
"required": ["content"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="task_list",
|
||||||
|
description="List tasks with optional status filter",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="task_update",
|
||||||
|
description="Update task status",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "string"},
|
||||||
|
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
|
||||||
|
},
|
||||||
|
"required": ["task_id", "status"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decision tracking
|
||||||
|
self.register_tool(
|
||||||
|
name="decision_add",
|
||||||
|
description="Record a decision with reasoning",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"choice": {"type": "string", "description": "The decision made"},
|
||||||
|
"reasoning": {"type": "string", "description": "Why this choice"},
|
||||||
|
"alternatives": {"type": "array", "items": {"type": "string"}}
|
||||||
|
},
|
||||||
|
"required": ["choice", "reasoning", "alternatives"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern management
|
||||||
|
self.register_tool(
|
||||||
|
name="pattern_add",
|
||||||
|
description="Add a pattern or recurring issue",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||||
|
"effectiveness": {"type": "number", "minimum": 0, "maximum": 1}
|
||||||
|
},
|
||||||
|
"required": ["pattern", "description"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="pattern_resolve",
|
||||||
|
description="Mark a pattern as resolved with solution",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern_id": {"type": "string"},
|
||||||
|
"solution": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["pattern_id", "solution"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knowledge management
|
||||||
|
self.register_tool(
|
||||||
|
name="knowledge_add",
|
||||||
|
description="Store knowledge or information",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {"type": "string"},
|
||||||
|
"value": {"type": "string"},
|
||||||
|
"category": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["key", "value"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="knowledge_get",
|
||||||
|
description="Retrieve knowledge by key or category",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {"type": "string"},
|
||||||
|
"category": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Project management
|
||||||
|
self.register_tool(
|
||||||
|
name="project_switch",
|
||||||
|
description="Switch to a different project context",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["project"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary and stats
|
||||||
|
self.register_tool(
|
||||||
|
name="memory_summary",
|
||||||
|
description="Get a summary of current project memory",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_memory(self):
|
||||||
|
"""Load memory for current project."""
|
||||||
|
self.project_dir = self.memory_dir / self.current_project
|
||||||
|
self.project_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Load data files
|
||||||
|
self.tasks = self._load_json("tasks.json", {})
|
||||||
|
self.decisions = self._load_json("decisions.json", {})
|
||||||
|
self.patterns = self._load_json("patterns.json", {})
|
||||||
|
self.knowledge = self._load_json("knowledge.json", {})
|
||||||
|
|
||||||
|
def _load_json(self, filename: str, default: Any) -> Any:
|
||||||
|
"""Load JSON file or return default."""
|
||||||
|
filepath = self.project_dir / filename
|
||||||
|
if filepath.exists():
|
||||||
|
with open(filepath) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _save_json(self, filename: str, data: Any):
|
||||||
|
"""Save data to JSON file."""
|
||||||
|
filepath = self.project_dir / filename
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
async def handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle memory tool calls."""
|
||||||
|
|
||||||
|
if tool_name == "task_add":
|
||||||
|
return await self._task_add(arguments)
|
||||||
|
elif tool_name == "task_list":
|
||||||
|
return await self._task_list(arguments)
|
||||||
|
elif tool_name == "task_update":
|
||||||
|
return await self._task_update(arguments)
|
||||||
|
elif tool_name == "decision_add":
|
||||||
|
return await self._decision_add(arguments)
|
||||||
|
elif tool_name == "pattern_add":
|
||||||
|
return await self._pattern_add(arguments)
|
||||||
|
elif tool_name == "pattern_resolve":
|
||||||
|
return await self._pattern_resolve(arguments)
|
||||||
|
elif tool_name == "knowledge_add":
|
||||||
|
return await self._knowledge_add(arguments)
|
||||||
|
elif tool_name == "knowledge_get":
|
||||||
|
return await self._knowledge_get(arguments)
|
||||||
|
elif tool_name == "project_switch":
|
||||||
|
return await self._project_switch(arguments)
|
||||||
|
elif tool_name == "memory_summary":
|
||||||
|
return await self._memory_summary()
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
|
async def _task_add(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Add a new task."""
|
||||||
|
task = Task(
|
||||||
|
id=str(uuid4()),
|
||||||
|
content=args["content"],
|
||||||
|
priority=args.get("priority", "medium"),
|
||||||
|
assignee=args.get("assignee")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.tasks[task.id] = asdict(task)
|
||||||
|
self._save_json("tasks.json", self.tasks)
|
||||||
|
|
||||||
|
return self.content_text(f"Added task: {task.id[:8]} - {task.content}")
|
||||||
|
|
||||||
|
async def _task_list(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""List tasks."""
|
||||||
|
status_filter = args.get("status")
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for task_id, task in self.tasks.items():
|
||||||
|
if status_filter and task["status"] != status_filter:
|
||||||
|
continue
|
||||||
|
tasks.append(f"[{task['status']}] {task_id[:8]} - {task['content']} ({task['priority']})")
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return self.content_text("No tasks found")
|
||||||
|
|
||||||
|
return self.content_text("Tasks:\n" + "\n".join(tasks))
|
||||||
|
|
||||||
|
async def _task_update(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Update task status."""
|
||||||
|
task_id = args["task_id"]
|
||||||
|
new_status = args["status"]
|
||||||
|
|
||||||
|
# Find task by ID or partial ID
|
||||||
|
full_id = None
|
||||||
|
for tid in self.tasks:
|
||||||
|
if tid.startswith(task_id):
|
||||||
|
full_id = tid
|
||||||
|
break
|
||||||
|
|
||||||
|
if not full_id:
|
||||||
|
return self.content_text(f"Task {task_id} not found")
|
||||||
|
|
||||||
|
self.tasks[full_id]["status"] = new_status
|
||||||
|
if new_status == "completed":
|
||||||
|
self.tasks[full_id]["completed_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
self._save_json("tasks.json", self.tasks)
|
||||||
|
|
||||||
|
return self.content_text(f"Updated task {full_id[:8]} to {new_status}")
|
||||||
|
|
||||||
|
async def _decision_add(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Record a decision."""
|
||||||
|
decision = Decision(
|
||||||
|
id=str(uuid4()),
|
||||||
|
choice=args["choice"],
|
||||||
|
reasoning=args["reasoning"],
|
||||||
|
alternatives=args["alternatives"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.decisions[decision.id] = asdict(decision)
|
||||||
|
self._save_json("decisions.json", self.decisions)
|
||||||
|
|
||||||
|
return self.content_text(f"Recorded decision: {decision.choice}")
|
||||||
|
|
||||||
|
async def _pattern_add(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Add a pattern."""
|
||||||
|
pattern = Pattern(
|
||||||
|
id=str(uuid4()),
|
||||||
|
pattern=args["pattern"],
|
||||||
|
description=args["description"],
|
||||||
|
priority=args.get("priority", "medium"),
|
||||||
|
effectiveness=args.get("effectiveness", 0.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.patterns[pattern.id] = asdict(pattern)
|
||||||
|
self._save_json("patterns.json", self.patterns)
|
||||||
|
|
||||||
|
return self.content_text(f"Added pattern: {pattern.pattern}")
|
||||||
|
|
||||||
|
async def _pattern_resolve(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Resolve a pattern."""
|
||||||
|
pattern_id = args["pattern_id"]
|
||||||
|
solution = args["solution"]
|
||||||
|
|
||||||
|
# Find pattern by ID or partial ID
|
||||||
|
full_id = None
|
||||||
|
for pid in self.patterns:
|
||||||
|
if pid.startswith(pattern_id):
|
||||||
|
full_id = pid
|
||||||
|
break
|
||||||
|
|
||||||
|
if not full_id:
|
||||||
|
return self.content_text(f"Pattern {pattern_id} not found")
|
||||||
|
|
||||||
|
self.patterns[full_id]["resolved"] = True
|
||||||
|
self.patterns[full_id]["solution"] = solution
|
||||||
|
|
||||||
|
self._save_json("patterns.json", self.patterns)
|
||||||
|
|
||||||
|
return self.content_text(f"Resolved pattern with: {solution}")
|
||||||
|
|
||||||
|
async def _knowledge_add(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Store knowledge."""
|
||||||
|
key = args["key"]
|
||||||
|
value = args["value"]
|
||||||
|
category = args.get("category", "general")
|
||||||
|
|
||||||
|
if category not in self.knowledge:
|
||||||
|
self.knowledge[category] = {}
|
||||||
|
|
||||||
|
self.knowledge[category][key] = {
|
||||||
|
"value": value,
|
||||||
|
"created_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
self._save_json("knowledge.json", self.knowledge)
|
||||||
|
|
||||||
|
return self.content_text(f"Stored knowledge: {key} in {category}")
|
||||||
|
|
||||||
|
async def _knowledge_get(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Retrieve knowledge."""
|
||||||
|
key = args.get("key")
|
||||||
|
category = args.get("category")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if key:
|
||||||
|
# Search for specific key across categories
|
||||||
|
for cat, items in self.knowledge.items():
|
||||||
|
if key in items:
|
||||||
|
results.append(f"[{cat}] {key}: {items[key]['value']}")
|
||||||
|
elif category:
|
||||||
|
# Get all items in category
|
||||||
|
if category in self.knowledge:
|
||||||
|
for k, v in self.knowledge[category].items():
|
||||||
|
results.append(f"{k}: {v['value']}")
|
||||||
|
else:
|
||||||
|
# List all categories
|
||||||
|
for cat in self.knowledge:
|
||||||
|
results.append(f"Category: {cat} ({len(self.knowledge[cat])} items)")
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return self.content_text("No knowledge found")
|
||||||
|
|
||||||
|
return self.content_text("\n".join(results))
|
||||||
|
|
||||||
|
async def _project_switch(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Switch project context."""
|
||||||
|
self.current_project = args["project"]
|
||||||
|
self._load_memory()
|
||||||
|
|
||||||
|
return self.content_text(f"Switched to project: {self.current_project}")
|
||||||
|
|
||||||
|
async def _memory_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Get memory summary."""
|
||||||
|
# Count items by status
|
||||||
|
task_stats = {"pending": 0, "in_progress": 0, "completed": 0}
|
||||||
|
for task in self.tasks.values():
|
||||||
|
task_stats[task["status"]] += 1
|
||||||
|
|
||||||
|
pattern_stats = {"resolved": 0, "unresolved": 0}
|
||||||
|
for pattern in self.patterns.values():
|
||||||
|
if pattern["resolved"]:
|
||||||
|
pattern_stats["resolved"] += 1
|
||||||
|
else:
|
||||||
|
pattern_stats["unresolved"] += 1
|
||||||
|
|
||||||
|
summary = f"""Memory Summary for Project: {self.current_project}
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- Pending: {task_stats['pending']}
|
||||||
|
- In Progress: {task_stats['in_progress']}
|
||||||
|
- Completed: {task_stats['completed']}
|
||||||
|
|
||||||
|
Decisions: {len(self.decisions)}
|
||||||
|
|
||||||
|
Patterns:
|
||||||
|
- Resolved: {pattern_stats['resolved']}
|
||||||
|
- Unresolved: {pattern_stats['unresolved']}
|
||||||
|
|
||||||
|
Knowledge Categories: {len(self.knowledge)}
|
||||||
|
Total Knowledge Items: {sum(len(items) for items in self.knowledge.values())}
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.content_text(summary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = MemoryServer()
|
||||||
|
asyncio.run(server.run())
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Onboarding MCP Server - Identity-aware onboarding management.
|
||||||
|
|
||||||
|
Provides personalized onboarding experiences where AI instances can
|
||||||
|
leave instructions for future contexts based on identity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
from base import BaseMCPServer
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingServer(BaseMCPServer):
|
||||||
|
"""MCP server for identity-aware onboarding."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("onboarding-server", "1.0.0")
|
||||||
|
self.onboarding_dir = Path.home() / ".mcp-onboarding"
|
||||||
|
self.onboarding_dir.mkdir(exist_ok=True)
|
||||||
|
self._register_tools()
|
||||||
|
|
||||||
|
def _register_tools(self):
|
||||||
|
"""Register onboarding tools."""
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="onboarding",
|
||||||
|
description="Get or set onboarding instructions for a specific identity",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"identity": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The identity to get/set onboarding for (e.g., 'Claude', 'Assistant', project name)"
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: New onboarding instructions to set. If not provided, retrieves existing."
|
||||||
|
},
|
||||||
|
"append": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, append to existing instructions instead of replacing",
|
||||||
|
"default": False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["identity"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="onboarding_list",
|
||||||
|
description="List all available onboarding identities",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="onboarding_delete",
|
||||||
|
description="Delete onboarding for a specific identity",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"identity": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The identity to delete onboarding for"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["identity"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="onboarding_export",
|
||||||
|
description="Export all onboarding data",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["json", "markdown"],
|
||||||
|
"default": "markdown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle onboarding tool calls."""
|
||||||
|
|
||||||
|
if tool_name == "onboarding":
|
||||||
|
return await self._handle_onboarding(arguments)
|
||||||
|
elif tool_name == "onboarding_list":
|
||||||
|
return await self._list_identities()
|
||||||
|
elif tool_name == "onboarding_delete":
|
||||||
|
return await self._delete_onboarding(arguments)
|
||||||
|
elif tool_name == "onboarding_export":
|
||||||
|
return await self._export_onboarding(arguments)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
|
async def _handle_onboarding(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Get or set onboarding for an identity."""
|
||||||
|
identity = self._sanitize_identity(args["identity"])
|
||||||
|
instructions = args.get("instructions")
|
||||||
|
append = args.get("append", False)
|
||||||
|
|
||||||
|
onboarding_file = self.onboarding_dir / f"{identity}.json"
|
||||||
|
|
||||||
|
if instructions is None:
|
||||||
|
# Get mode - retrieve existing onboarding
|
||||||
|
if onboarding_file.exists():
|
||||||
|
with open(onboarding_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
content = self._format_onboarding(identity, data)
|
||||||
|
return self.content_text(content)
|
||||||
|
else:
|
||||||
|
# Try to load default onboarding
|
||||||
|
default_file = self.onboarding_dir / "default.md"
|
||||||
|
if default_file.exists():
|
||||||
|
with open(default_file) as f:
|
||||||
|
default_content = f.read()
|
||||||
|
|
||||||
|
return self.content_text(
|
||||||
|
f"# Onboarding for {identity}\n\n"
|
||||||
|
f"No specific onboarding found. Using default:\n\n"
|
||||||
|
f"{default_content}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return self.content_text(
|
||||||
|
f"# Onboarding for {identity}\n\n"
|
||||||
|
f"No onboarding instructions found.\n\n"
|
||||||
|
f"To add onboarding, use:\n"
|
||||||
|
f"onboarding(identity='{identity}', instructions='Your instructions here')"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Set mode - store new onboarding
|
||||||
|
if onboarding_file.exists() and append:
|
||||||
|
with open(onboarding_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Append to history
|
||||||
|
data["history"].append({
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"instructions": instructions
|
||||||
|
})
|
||||||
|
data["current"] = data["current"] + "\n\n" + instructions
|
||||||
|
data["updated_at"] = datetime.now().isoformat()
|
||||||
|
else:
|
||||||
|
# Create new or replace
|
||||||
|
data = {
|
||||||
|
"identity": identity,
|
||||||
|
"current": instructions,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"history": [{
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"instructions": instructions
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(onboarding_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
return self.content_text(
|
||||||
|
f"Onboarding {'appended' if append else 'set'} for {identity}.\n\n"
|
||||||
|
f"Instructions:\n{instructions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _list_identities(self) -> Dict[str, Any]:
|
||||||
|
"""List all available identities."""
|
||||||
|
identities = []
|
||||||
|
|
||||||
|
for file in self.onboarding_dir.glob("*.json"):
|
||||||
|
identity = file.stem
|
||||||
|
with open(file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
created = data.get("created_at", "Unknown")
|
||||||
|
updated = data.get("updated_at", created)
|
||||||
|
history_count = len(data.get("history", []))
|
||||||
|
|
||||||
|
identities.append(
|
||||||
|
f"- **{identity}**: Created {created[:10]}, "
|
||||||
|
f"Updated {updated[:10]}, {history_count} revision(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not identities:
|
||||||
|
return self.content_text("No onboarding identities found.")
|
||||||
|
|
||||||
|
return self.content_text(
|
||||||
|
"# Available Onboarding Identities\n\n" +
|
||||||
|
"\n".join(identities)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _delete_onboarding(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Delete onboarding for an identity."""
|
||||||
|
identity = self._sanitize_identity(args["identity"])
|
||||||
|
onboarding_file = self.onboarding_dir / f"{identity}.json"
|
||||||
|
|
||||||
|
if not onboarding_file.exists():
|
||||||
|
return self.content_text(f"No onboarding found for {identity}")
|
||||||
|
|
||||||
|
onboarding_file.unlink()
|
||||||
|
return self.content_text(f"Deleted onboarding for {identity}")
|
||||||
|
|
||||||
|
async def _export_onboarding(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Export all onboarding data."""
|
||||||
|
format_type = args.get("format", "markdown")
|
||||||
|
|
||||||
|
all_data = {}
|
||||||
|
for file in self.onboarding_dir.glob("*.json"):
|
||||||
|
identity = file.stem
|
||||||
|
with open(file) as f:
|
||||||
|
all_data[identity] = json.load(f)
|
||||||
|
|
||||||
|
if format_type == "json":
|
||||||
|
return self.content_text(json.dumps(all_data, indent=2))
|
||||||
|
else:
|
||||||
|
# Markdown format
|
||||||
|
lines = ["# All Onboarding Data\n"]
|
||||||
|
|
||||||
|
for identity, data in all_data.items():
|
||||||
|
lines.append(f"## {identity}\n")
|
||||||
|
lines.append(f"**Created**: {data.get('created_at', 'Unknown')}")
|
||||||
|
lines.append(f"**Updated**: {data.get('updated_at', 'Unknown')}")
|
||||||
|
lines.append(f"\n### Current Instructions\n")
|
||||||
|
lines.append(data.get('current', 'No instructions'))
|
||||||
|
|
||||||
|
if data.get('history') and len(data['history']) > 1:
|
||||||
|
lines.append(f"\n### History ({len(data['history'])} revisions)\n")
|
||||||
|
for i, entry in enumerate(data['history']):
|
||||||
|
lines.append(f"#### Revision {i+1} - {entry['timestamp'][:10]}")
|
||||||
|
lines.append(entry['instructions'])
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("\n---\n")
|
||||||
|
|
||||||
|
return self.content_text("\n".join(lines))
|
||||||
|
|
||||||
|
def _sanitize_identity(self, identity: str) -> str:
|
||||||
|
"""Sanitize identity string for filesystem use."""
|
||||||
|
# Replace problematic characters
|
||||||
|
return identity.replace("/", "_").replace("\\", "_").replace(":", "_")
|
||||||
|
|
||||||
|
def _format_onboarding(self, identity: str, data: Dict[str, Any]) -> str:
|
||||||
|
"""Format onboarding data for display."""
|
||||||
|
lines = [
|
||||||
|
f"# Onboarding for {identity}",
|
||||||
|
f"",
|
||||||
|
f"**Created**: {data.get('created_at', 'Unknown')}",
|
||||||
|
f"**Updated**: {data.get('updated_at', 'Unknown')}",
|
||||||
|
f"**Revisions**: {len(data.get('history', []))}",
|
||||||
|
f"",
|
||||||
|
f"## Instructions",
|
||||||
|
f"",
|
||||||
|
data.get('current', 'No instructions set.'),
|
||||||
|
f"",
|
||||||
|
f"---",
|
||||||
|
f"",
|
||||||
|
f"*To update these instructions, use:*",
|
||||||
|
f"`onboarding(identity='{identity}', instructions='New instructions', append=True/False)`"
|
||||||
|
]
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = OnboardingServer()
|
||||||
|
asyncio.run(server.run())
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pattern Manager MCP Server - Auto-response pattern management.
|
||||||
|
|
||||||
|
Manages custom patterns for automating repetitive interactions,
|
||||||
|
with support for placeholders and command execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
from base import BaseMCPServer
|
||||||
|
|
||||||
|
|
||||||
|
class PatternServer(BaseMCPServer):
|
||||||
|
"""MCP server for pattern management."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("pattern-server", "1.0.0")
|
||||||
|
self.patterns_file = Path.home() / ".mcp-patterns" / "patterns.json"
|
||||||
|
self.patterns_file.parent.mkdir(exist_ok=True)
|
||||||
|
self.patterns: Dict[str, Dict[str, Any]] = self._load_patterns()
|
||||||
|
self._register_tools()
|
||||||
|
|
||||||
|
def _register_tools(self):
|
||||||
|
"""Register pattern management tools."""
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="add_pattern",
|
||||||
|
description="Add a new auto-response pattern",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"trigger": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Array of strings that must appear in sequence"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"type": ["string", "array"],
|
||||||
|
"description": "Response text or array of responses"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable description of the pattern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["trigger", "response"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="list_patterns",
|
||||||
|
description="List all active patterns",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="remove_pattern",
|
||||||
|
description="Remove a pattern by ID",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ID of the pattern to remove"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern_id"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="test_pattern",
|
||||||
|
description="Test if text matches a pattern",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text to test against patterns"
|
||||||
|
},
|
||||||
|
"pattern_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional specific pattern to test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_tool(
|
||||||
|
name="execute_pattern",
|
||||||
|
description="Execute a pattern's response (for testing)",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ID of the pattern to execute"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional context variables for placeholders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_patterns(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Load patterns from file."""
|
||||||
|
if self.patterns_file.exists():
|
||||||
|
with open(self.patterns_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_patterns(self):
|
||||||
|
"""Save patterns to file."""
|
||||||
|
with open(self.patterns_file, 'w') as f:
|
||||||
|
json.dump(self.patterns, f, indent=2)
|
||||||
|
|
||||||
|
async def handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle pattern tool calls."""
|
||||||
|
|
||||||
|
if tool_name == "add_pattern":
|
||||||
|
return await self._add_pattern(arguments)
|
||||||
|
elif tool_name == "list_patterns":
|
||||||
|
return await self._list_patterns()
|
||||||
|
elif tool_name == "remove_pattern":
|
||||||
|
return await self._remove_pattern(arguments)
|
||||||
|
elif tool_name == "test_pattern":
|
||||||
|
return await self._test_pattern(arguments)
|
||||||
|
elif tool_name == "execute_pattern":
|
||||||
|
return await self._execute_pattern(arguments)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
|
async def _add_pattern(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Add a new pattern."""
|
||||||
|
pattern_id = str(uuid4())[:8]
|
||||||
|
|
||||||
|
pattern = {
|
||||||
|
"id": pattern_id,
|
||||||
|
"trigger": args["trigger"],
|
||||||
|
"response": args["response"],
|
||||||
|
"description": args.get("description", ""),
|
||||||
|
"created_at": asyncio.get_event_loop().time()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.patterns[pattern_id] = pattern
|
||||||
|
self._save_patterns()
|
||||||
|
|
||||||
|
return self.content_text(
|
||||||
|
f"Added pattern {pattern_id}:\n"
|
||||||
|
f"Trigger: {' -> '.join(args['trigger'])}\n"
|
||||||
|
f"Response: {args['response']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _list_patterns(self) -> Dict[str, Any]:
|
||||||
|
"""List all patterns."""
|
||||||
|
if not self.patterns:
|
||||||
|
return self.content_text("No patterns defined")
|
||||||
|
|
||||||
|
lines = ["Active patterns:"]
|
||||||
|
for pid, pattern in self.patterns.items():
|
||||||
|
trigger_str = " -> ".join(pattern["trigger"])
|
||||||
|
response_str = str(pattern["response"])
|
||||||
|
if len(response_str) > 50:
|
||||||
|
response_str = response_str[:47] + "..."
|
||||||
|
|
||||||
|
lines.append(f"\n[{pid}] {pattern.get('description', 'No description')}")
|
||||||
|
lines.append(f" Trigger: {trigger_str}")
|
||||||
|
lines.append(f" Response: {response_str}")
|
||||||
|
|
||||||
|
return self.content_text("\n".join(lines))
|
||||||
|
|
||||||
|
async def _remove_pattern(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Remove a pattern."""
|
||||||
|
pattern_id = args["pattern_id"]
|
||||||
|
|
||||||
|
if pattern_id not in self.patterns:
|
||||||
|
return self.content_text(f"Pattern {pattern_id} not found")
|
||||||
|
|
||||||
|
pattern = self.patterns.pop(pattern_id)
|
||||||
|
self._save_patterns()
|
||||||
|
|
||||||
|
return self.content_text(f"Removed pattern {pattern_id}")
|
||||||
|
|
||||||
|
async def _test_pattern(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test if text matches patterns."""
|
||||||
|
text = args["text"]
|
||||||
|
specific_id = args.get("pattern_id")
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
if specific_id:
|
||||||
|
# Test specific pattern
|
||||||
|
if specific_id in self.patterns:
|
||||||
|
pattern = self.patterns[specific_id]
|
||||||
|
if self._matches_pattern(text, pattern["trigger"]):
|
||||||
|
matches.append(f"Pattern {specific_id} matches!")
|
||||||
|
else:
|
||||||
|
matches.append(f"Pattern {specific_id} does not match")
|
||||||
|
else:
|
||||||
|
return self.content_text(f"Pattern {specific_id} not found")
|
||||||
|
else:
|
||||||
|
# Test all patterns
|
||||||
|
for pid, pattern in self.patterns.items():
|
||||||
|
if self._matches_pattern(text, pattern["trigger"]):
|
||||||
|
matches.append(f"Pattern {pid} matches: {pattern.get('description', '')}")
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return self.content_text("No patterns match the text")
|
||||||
|
|
||||||
|
return self.content_text("\n".join(matches))
|
||||||
|
|
||||||
|
async def _execute_pattern(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute a pattern's response."""
|
||||||
|
pattern_id = args["pattern_id"]
|
||||||
|
context = args.get("context", {})
|
||||||
|
|
||||||
|
if pattern_id not in self.patterns:
|
||||||
|
return self.content_text(f"Pattern {pattern_id} not found")
|
||||||
|
|
||||||
|
pattern = self.patterns[pattern_id]
|
||||||
|
response = pattern["response"]
|
||||||
|
|
||||||
|
# Process response
|
||||||
|
processed = await self._process_response(response, context)
|
||||||
|
|
||||||
|
return self.content_text(f"Pattern response:\n{processed}")
|
||||||
|
|
||||||
|
def _matches_pattern(self, text: str, trigger: List[str]) -> bool:
|
||||||
|
"""Check if text matches a trigger pattern."""
|
||||||
|
# Simple implementation: check if all trigger strings appear in order
|
||||||
|
position = 0
|
||||||
|
for trigger_part in trigger:
|
||||||
|
index = text.find(trigger_part, position)
|
||||||
|
if index == -1:
|
||||||
|
return False
|
||||||
|
position = index + len(trigger_part)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _process_response(self, response: Any, context: Dict[str, Any]) -> str:
|
||||||
|
"""Process a response, handling special commands and placeholders."""
|
||||||
|
if isinstance(response, list):
|
||||||
|
# Process each item in array
|
||||||
|
processed = []
|
||||||
|
for item in response:
|
||||||
|
processed.append(await self._process_single_response(item, context))
|
||||||
|
return "\n".join(processed)
|
||||||
|
else:
|
||||||
|
return await self._process_single_response(response, context)
|
||||||
|
|
||||||
|
async def _process_single_response(self, response: str, context: Dict[str, Any]) -> str:
|
||||||
|
"""Process a single response string."""
|
||||||
|
# Handle special commands
|
||||||
|
|
||||||
|
# __CALL_TOOL_<command>_<args>
|
||||||
|
if response.startswith("__CALL_TOOL_"):
|
||||||
|
parts = response[12:].split("_", 1)
|
||||||
|
if len(parts) >= 1:
|
||||||
|
command = parts[0]
|
||||||
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[command] + (args.split() if args else []),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else f"Error: {result.stderr}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing command: {e}"
|
||||||
|
|
||||||
|
# __DELAY_<ms>
|
||||||
|
if response.startswith("__DELAY_"):
|
||||||
|
try:
|
||||||
|
ms = int(response[8:])
|
||||||
|
await asyncio.sleep(ms / 1000)
|
||||||
|
return f"[Delayed {ms}ms]"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Replace placeholders with context values
|
||||||
|
for key, value in context.items():
|
||||||
|
response = response.replace(f"{{{key}}}", str(value))
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = PatternServer()
|
||||||
|
asyncio.run(server.run())
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Screen MCP Server - GNU screen session management.
|
||||||
|
|
||||||
|
Provides tools for creating and managing persistent screen sessions,
|
||||||
|
useful for long-running processes and maintaining shell state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
from base import BaseMCPServer
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenServer(BaseMCPServer):
|
||||||
|
"""MCP server for GNU screen management."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("screen-server", "1.0.0")
|
||||||
|
self._register_tools()
|
||||||
|
|
||||||
|
def _register_tools(self):
|
||||||
|
"""Register all screen management tools."""
|
||||||
|
|
||||||
|
# Create session tool
|
||||||
|
self.register_tool(
|
||||||
|
name="create_session",
|
||||||
|
description="Create a new screen session with optional initial command",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name for the screen session"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional command to run in the session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute command tool
|
||||||
|
self.register_tool(
|
||||||
|
name="execute",
|
||||||
|
description="Execute a command in an existing screen session",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the screen session"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Command to execute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["session", "command"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Peek at session output
|
||||||
|
self.register_tool(
|
||||||
|
name="peek",
|
||||||
|
description="Get recent output from a screen session (last 50 lines by default)",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the screen session"
|
||||||
|
},
|
||||||
|
"lines": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of lines to retrieve (default: 50)",
|
||||||
|
"default": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["session"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# List sessions
|
||||||
|
self.register_tool(
|
||||||
|
name="list_sessions",
|
||||||
|
description="List all active screen sessions",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kill session
|
||||||
|
self.register_tool(
|
||||||
|
name="kill_session",
|
||||||
|
description="Terminate a screen session",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the screen session to kill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["session"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle screen tool calls."""
|
||||||
|
|
||||||
|
if tool_name == "create_session":
|
||||||
|
return await self._create_session(arguments)
|
||||||
|
elif tool_name == "execute":
|
||||||
|
return await self._execute_command(arguments)
|
||||||
|
elif tool_name == "peek":
|
||||||
|
return await self._peek_session(arguments)
|
||||||
|
elif tool_name == "list_sessions":
|
||||||
|
return await self._list_sessions()
|
||||||
|
elif tool_name == "kill_session":
|
||||||
|
return await self._kill_session(arguments)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
|
async def _create_session(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Create a new screen session."""
|
||||||
|
name = args["name"]
|
||||||
|
command = args.get("command")
|
||||||
|
|
||||||
|
# Check if session already exists
|
||||||
|
check_result = await self._run_command(["screen", "-ls", name])
|
||||||
|
if name in check_result.stdout:
|
||||||
|
return self.content_text(f"Session '{name}' already exists")
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
cmd = ["screen", "-dmS", name]
|
||||||
|
if command:
|
||||||
|
cmd.extend(["bash", "-c", command])
|
||||||
|
|
||||||
|
result = await self._run_command(cmd)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return self.content_text(f"Created screen session '{name}'" +
|
||||||
|
(f" running '{command}'" if command else ""))
|
||||||
|
else:
|
||||||
|
return self.content_text(f"Failed to create session: {result.stderr}")
|
||||||
|
|
||||||
|
async def _execute_command(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute a command in a screen session."""
|
||||||
|
session = args["session"]
|
||||||
|
command = args["command"]
|
||||||
|
|
||||||
|
# Send command to screen session
|
||||||
|
# Note: We need to send the command followed by Enter
|
||||||
|
cmd = ["screen", "-S", session, "-X", "stuff", f"{command}\n"]
|
||||||
|
|
||||||
|
result = await self._run_command(cmd)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return self.content_text(f"Executed command in session '{session}'")
|
||||||
|
else:
|
||||||
|
return self.content_text(f"Failed to execute command: {result.stderr}")
|
||||||
|
|
||||||
|
async def _peek_session(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Get recent output from a screen session."""
|
||||||
|
session = args["session"]
|
||||||
|
lines = args.get("lines", 50)
|
||||||
|
|
||||||
|
# Create temporary file for hardcopy
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get hardcopy of screen
|
||||||
|
cmd = ["screen", "-S", session, "-X", "hardcopy", tmp_path]
|
||||||
|
result = await self._run_command(cmd)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return self.content_text(f"Failed to peek at session: {result.stderr}")
|
||||||
|
|
||||||
|
# Read the output
|
||||||
|
with open(tmp_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Get last N lines
|
||||||
|
output_lines = content.strip().split('\n')
|
||||||
|
if len(output_lines) > lines:
|
||||||
|
output_lines = output_lines[-lines:]
|
||||||
|
|
||||||
|
output = '\n'.join(output_lines)
|
||||||
|
|
||||||
|
return self.content_text(output if output else "(No output)")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
async def _list_sessions(self) -> Dict[str, Any]:
|
||||||
|
"""List all active screen sessions."""
|
||||||
|
result = await self._run_command(["screen", "-ls"])
|
||||||
|
|
||||||
|
if "No Sockets found" in result.stdout:
|
||||||
|
return self.content_text("No active screen sessions")
|
||||||
|
|
||||||
|
# Parse screen list output
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
sessions = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if '\t' in line and '(' in line:
|
||||||
|
# Extract session info
|
||||||
|
parts = line.strip().split('\t')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
session_info = parts[0]
|
||||||
|
status = parts[1].strip('()')
|
||||||
|
sessions.append(f"{session_info} - {status}")
|
||||||
|
|
||||||
|
if sessions:
|
||||||
|
output = "Active screen sessions:\n" + '\n'.join(sessions)
|
||||||
|
else:
|
||||||
|
output = "No active screen sessions"
|
||||||
|
|
||||||
|
return self.content_text(output)
|
||||||
|
|
||||||
|
async def _kill_session(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Kill a screen session."""
|
||||||
|
session = args["session"]
|
||||||
|
|
||||||
|
cmd = ["screen", "-S", session, "-X", "quit"]
|
||||||
|
result = await self._run_command(cmd)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return self.content_text(f"Killed screen session '{session}'")
|
||||||
|
else:
|
||||||
|
return self.content_text(f"Failed to kill session: {result.stderr}")
|
||||||
|
|
||||||
|
async def _run_command(self, cmd: List[str]) -> subprocess.CompletedProcess:
|
||||||
|
"""Run a command and return the result."""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Check if screen is installed
|
||||||
|
try:
|
||||||
|
subprocess.run(["screen", "--version"], capture_output=True, check=True)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
print("Error: GNU screen is not installed", file=sys.stderr)
|
||||||
|
print("Install it with: sudo apt-get install screen", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
server = ScreenServer()
|
||||||
|
asyncio.run(server.run())
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
pyyaml>=6.0
|
||||||
|
jsonpath-ng>=1.5.3
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Setup configuration for MCP Browser."""
|
||||||
|
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
with open("README.md", "r", encoding="utf-8") as fh:
|
||||||
|
long_description = fh.read()
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="mcp-browser",
|
||||||
|
version="0.1.0",
|
||||||
|
author="MCP Browser Contributors",
|
||||||
|
description="A generic, minimalistic MCP protocol interface for AI systems",
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
url="https://github.com/yourusername/mcp-browser",
|
||||||
|
packages=find_packages(),
|
||||||
|
classifiers=[
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
],
|
||||||
|
python_requires=">=3.8",
|
||||||
|
install_requires=[
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"jsonpath-ng>=1.5.3",
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
"dev": [
|
||||||
|
"pytest>=7.0",
|
||||||
|
"pytest-asyncio>=0.20",
|
||||||
|
"black>=22.0",
|
||||||
|
"mypy>=0.990",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Quick test of the onboarding functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
|
||||||
|
|
||||||
|
async def test_onboarding():
|
||||||
|
"""Test onboarding functionality."""
|
||||||
|
|
||||||
|
print("Testing MCP Browser Onboarding...\n")
|
||||||
|
|
||||||
|
# Create browser with only built-in servers
|
||||||
|
browser = MCPBrowser(server_name="builtin-only")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await browser.initialize()
|
||||||
|
print("✓ Browser initialized\n")
|
||||||
|
|
||||||
|
# Test 1: Get onboarding for new identity
|
||||||
|
print("1. Getting onboarding for 'TestBot':")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"arguments": {
|
||||||
|
"identity": "TestBot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
content = response["result"]["content"][0]["text"]
|
||||||
|
print(content[:200] + "...\n")
|
||||||
|
|
||||||
|
# Test 2: Set onboarding
|
||||||
|
print("2. Setting onboarding instructions:")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"arguments": {
|
||||||
|
"identity": "TestBot",
|
||||||
|
"instructions": "You are TestBot. Your primary goals:\n- Be helpful\n- Be concise\n- Remember context"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
print("✓ Instructions set\n")
|
||||||
|
|
||||||
|
# Test 3: Retrieve onboarding
|
||||||
|
print("3. Retrieving onboarding:")
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"arguments": {
|
||||||
|
"identity": "TestBot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if "result" in response:
|
||||||
|
content = response["result"]["content"][0]["text"]
|
||||||
|
print(content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
print("\n✓ Test complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_onboarding())
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
"""Basic tests for MCP Browser."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from unittest.mock import Mock, AsyncMock, patch
|
||||||
|
|
||||||
|
from mcp_browser import MCPBrowser
|
||||||
|
from mcp_browser.registry import ToolRegistry
|
||||||
|
from mcp_browser.filter import MessageFilter
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistry:
|
||||||
|
"""Test the tool registry functionality."""
|
||||||
|
|
||||||
|
def test_update_tools(self):
|
||||||
|
"""Test updating registry with tools."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
tools = [
|
||||||
|
{"name": "tool1", "description": "Tool 1"},
|
||||||
|
{"name": "tool2", "description": "Tool 2"}
|
||||||
|
]
|
||||||
|
|
||||||
|
registry.update_tools(tools)
|
||||||
|
|
||||||
|
assert len(registry.tools) == 2
|
||||||
|
assert registry.get_tool("tool1")["description"] == "Tool 1"
|
||||||
|
assert registry.get_all_tool_names() == ["tool1", "tool2"]
|
||||||
|
|
||||||
|
def test_discover_jsonpath(self):
|
||||||
|
"""Test JSONPath discovery."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
tools = [
|
||||||
|
{"name": "Bash", "description": "Run commands"},
|
||||||
|
{"name": "Read", "description": "Read files"}
|
||||||
|
]
|
||||||
|
registry.update_tools(tools)
|
||||||
|
|
||||||
|
# Test various JSONPath queries
|
||||||
|
assert registry.discover("$.tools[*].name") == ["Bash", "Read"]
|
||||||
|
assert registry.discover("$.tools[?(@.name=='Bash')]")[0]["name"] == "Bash"
|
||||||
|
assert registry.discover("$.tools[0].description") == "Run commands"
|
||||||
|
assert registry.discover("$.nonexistent") is None
|
||||||
|
|
||||||
|
def test_sparse_tools(self):
|
||||||
|
"""Test sparse tool generation."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.update_tools([{"name": "tool1"}, {"name": "tool2"}])
|
||||||
|
|
||||||
|
sparse = registry.get_sparse_tools()
|
||||||
|
assert len(sparse) == 2
|
||||||
|
assert sparse[0]["name"] == "mcp_discover"
|
||||||
|
assert sparse[1]["name"] == "mcp_call"
|
||||||
|
assert "2 tools available" in sparse[0]["description"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageFilter:
|
||||||
|
"""Test message filtering."""
|
||||||
|
|
||||||
|
def test_sparse_mode_filtering(self):
|
||||||
|
"""Test that tools/list responses are filtered in sparse mode."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
filter = MessageFilter(registry, sparse_mode=True)
|
||||||
|
|
||||||
|
# Mock tools/list response
|
||||||
|
message = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": {
|
||||||
|
"tools": [
|
||||||
|
{"name": "tool1", "description": "Tool 1"},
|
||||||
|
{"name": "tool2", "description": "Tool 2"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = filter.filter_incoming(message)
|
||||||
|
|
||||||
|
# Should replace with sparse tools
|
||||||
|
assert len(filtered["result"]["tools"]) == 2
|
||||||
|
assert filtered["result"]["tools"][0]["name"] == "mcp_discover"
|
||||||
|
assert filtered["result"]["tools"][1]["name"] == "mcp_call"
|
||||||
|
|
||||||
|
# Registry should have full tools
|
||||||
|
assert len(registry.tools) == 2
|
||||||
|
|
||||||
|
def test_duplicate_error_filtering(self):
|
||||||
|
"""Test filtering of duplicate errors for handled requests."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
filter = MessageFilter(registry)
|
||||||
|
|
||||||
|
# Mark a request as handled
|
||||||
|
filter.mark_handled(123)
|
||||||
|
|
||||||
|
# Duplicate error should be filtered
|
||||||
|
error_msg = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 123,
|
||||||
|
"error": {"code": -32603, "message": "Tool not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert filter.filter_incoming(error_msg) is None
|
||||||
|
|
||||||
|
# ID should be removed from handled set
|
||||||
|
assert 123 not in filter._handled_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestMCPBrowser:
|
||||||
|
"""Test the main MCP Browser functionality."""
|
||||||
|
|
||||||
|
async def test_initialization(self):
|
||||||
|
"""Test browser initialization."""
|
||||||
|
with patch('mcp_browser.proxy.MCPServer') as mock_server_class:
|
||||||
|
mock_server = AsyncMock()
|
||||||
|
mock_server_class.return_value = mock_server
|
||||||
|
|
||||||
|
browser = MCPBrowser()
|
||||||
|
|
||||||
|
# Mock successful initialization
|
||||||
|
mock_server.send_request.side_effect = [
|
||||||
|
# Initialize response
|
||||||
|
{"result": {"protocolVersion": "0.1.0"}},
|
||||||
|
# Tools list response
|
||||||
|
{"result": {"tools": [{"name": "test", "description": "Test tool"}]}}
|
||||||
|
]
|
||||||
|
|
||||||
|
await browser.initialize()
|
||||||
|
|
||||||
|
assert browser._initialized
|
||||||
|
assert mock_server.start.called
|
||||||
|
|
||||||
|
async def test_call_method(self):
|
||||||
|
"""Test the generic call method."""
|
||||||
|
with patch('mcp_browser.proxy.MCPServer') as mock_server_class:
|
||||||
|
mock_server = AsyncMock()
|
||||||
|
mock_server_class.return_value = mock_server
|
||||||
|
mock_server.send_request.side_effect = [
|
||||||
|
{"result": {"protocolVersion": "0.1.0"}},
|
||||||
|
{"result": {"tools": []}}
|
||||||
|
]
|
||||||
|
|
||||||
|
browser = MCPBrowser()
|
||||||
|
await browser.initialize()
|
||||||
|
|
||||||
|
# Set up response handling
|
||||||
|
browser._response_buffer[1] = asyncio.Future()
|
||||||
|
|
||||||
|
# Simulate server response
|
||||||
|
async def simulate_response():
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
browser._handle_server_message({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": {"success": True}
|
||||||
|
})
|
||||||
|
|
||||||
|
asyncio.create_task(simulate_response())
|
||||||
|
|
||||||
|
# Make call
|
||||||
|
response = await browser.call({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "test",
|
||||||
|
"params": {}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response["result"]["success"] is True
|
||||||
|
|
||||||
|
async def test_discover_method(self):
|
||||||
|
"""Test the discover method."""
|
||||||
|
browser = MCPBrowser()
|
||||||
|
browser.registry.update_tools([
|
||||||
|
{"name": "tool1", "description": "First tool"},
|
||||||
|
{"name": "tool2", "description": "Second tool"}
|
||||||
|
])
|
||||||
|
|
||||||
|
# Test discovery
|
||||||
|
tool_names = browser.discover("$.tools[*].name")
|
||||||
|
assert tool_names == ["tool1", "tool2"]
|
||||||
|
|
||||||
|
specific_tool = browser.discover("$.tools[?(@.name=='tool1')]")
|
||||||
|
assert specific_tool[0]["description"] == "First tool"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
Loading…
Reference in New Issue