diff --git a/.env.example b/.env.example index 119f58b..e9d38eb 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ # MAGG_RELOAD_POLL_INTERVAL # MAGG_RELOAD_USE_WATCHDOG # MAGG_READ_ONLY + +# Kit Management Mode +# MAGG_KIT_CHANGES_ONLY=false # When true, only expose kit and view tools (load/unload/list/info kits, list_servers, status) diff --git a/docs/index.md b/docs/index.md index 4106799..32a3a42 100644 --- a/docs/index.md +++ b/docs/index.md @@ -609,6 +609,23 @@ mbro:magg> call magg_unload_kit name="web-tools" For complete documentation, see **[Kit Management Guide](kits.md)**. +### Kit-Only Mode + +Magg supports a restricted mode where only kit management and view operations are allowed. This is useful for controlled environments where you want to manage servers exclusively through curated kits. + +**Enable kit-only mode:** +```bash +MAGG_KIT_CHANGES_ONLY=true magg serve +``` + +**In kit-only mode, only these tools are exposed:** +- `magg_load_kit` - Load a kit +- `magg_unload_kit` - Unload a kit +- `magg_list_kits` - List available kits +- `magg_kit_info` - Get kit information +- `magg_list_servers` - View current servers +- `magg_status` - View system status + ## Authentication Magg supports optional bearer token authentication using RSA keypairs and JWT tokens. When enabled, all clients must provide a valid JWT token to access the server. diff --git a/docs/kits.md b/docs/kits.md index 8f8ef93..026c16c 100644 --- a/docs/kits.md +++ b/docs/kits.md @@ -150,6 +150,24 @@ Example custom kit: 4. **Server Names**: Use consistent, meaningful server names 5. **Keywords**: Add relevant keywords for discoverability +## Kit-Only Mode + +For controlled environments where server management should be restricted to curated kits, enable kit-only mode: + +```bash +MAGG_KIT_CHANGES_ONLY=true magg serve +``` + +**In kit-only mode:** +- ✅ Kit operations allowed: `load_kit`, `unload_kit`, `list_kits`, `kit_info` +- ✅ View operations allowed: `list_servers`, `status` +- ❌ Direct server management blocked: `add_server`, `remove_server`, `enable_server`, etc. + +**Use cases:** +- Production environments with centrally managed server configurations +- Team settings where infrastructure is controlled through reviewed kits +- Compliance scenarios requiring restricted ad-hoc changes + ## Example Kits ### Web Tools Kit diff --git a/magg/server/server.py b/magg/server/server.py index a987788..aa59036 100644 --- a/magg/server/server.py +++ b/magg/server/server.py @@ -45,24 +45,39 @@ def _register_tools(self): """ self_prefix_ = self.self_prefix_ - tools = [ + # Define kit-related tools + kit_tools = [ + (self.load_kit, f"{self_prefix_}load_kit", None), + (self.unload_kit, f"{self_prefix_}unload_kit", None), + (self.list_kits, f"{self_prefix_}list_kits", None), + (self.kit_info, f"{self_prefix_}kit_info", None), + ] + + # Define read-only/view tools + view_tools = [ + (self.list_servers, f"{self_prefix_}list_servers", None), + (self.status, f"{self_prefix_}status", None), + ] + + # Define modification tools + modification_tools = [ (self.add_server, f"{self_prefix_}add_server", None), (self.remove_server, f"{self_prefix_}remove_server", None), - (self.list_servers, f"{self_prefix_}list_servers", None), (self.enable_server, f"{self_prefix_}enable_server", None), (self.disable_server, f"{self_prefix_}disable_server", None), (self.search_servers, f"{self_prefix_}search_servers", None), (self.smart_configure, f"{self_prefix_}smart_configure", None), (self.analyze_servers, f"{self_prefix_}analyze_servers", None), - (self.status, f"{self_prefix_}status", None), (self.check, f"{self_prefix_}check", None), (self.reload_config_tool, f"{self_prefix_}reload_config", None), - (self.load_kit, f"{self_prefix_}load_kit", None), - (self.unload_kit, f"{self_prefix_}unload_kit", None), - (self.list_kits, f"{self_prefix_}list_kits", None), - (self.kit_info, f"{self_prefix_}kit_info", None), ] + # If kit_changes_only is enabled, only register kit and view tools + if self.config.kit_changes_only: + tools = kit_tools + view_tools + else: + tools = modification_tools + view_tools + kit_tools + def call_tool_wrapper(func): @wraps(func) async def wrapper(*args, **kwds): diff --git a/magg/settings.py b/magg/settings.py index 3c84863..fa486dc 100644 --- a/magg/settings.py +++ b/magg/settings.py @@ -214,6 +214,7 @@ class MaggConfig(BaseSettings): auto_reload: bool = Field(default=True, description="Enable automatic config reloading on file changes (env: MAGG_AUTO_RELOAD)") reload_poll_interval: float = Field(default=1.0, description="Config file poll interval in seconds (env: MAGG_RELOAD_POLL_INTERVAL)") stderr_show: bool = Field(default=False, description="Show stderr output from subprocess MCP servers (env: MAGG_STDERR_SHOW)") + kit_changes_only: bool = Field(default=False, description="Expose only kit-related and read-only tools (load/unload/list/info kits, list_servers, list_tools, status), allowing viewing current state but only allowing changes via kits (env: MAGG_KIT_CHANGES_ONLY)") servers: dict[str, ServerConfig] = Field(default_factory=dict, description="Servers configuration (loaded from config_path)") kits: dict[str, KitInfo] = Field(default_factory=dict, description="Loaded kits with metadata") diff --git a/pyproject.toml b/pyproject.toml index 1801022..47eba38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "magg" -version = "0.10.1" +version = "0.11.0" requires-python = ">=3.12" description = "MCP Aggregator" authors = [{ name = "Phillip Sitbon", email = "phillip.sitbon@gmail.com"}] diff --git a/readme.md b/readme.md index 836253f..6e48c8a 100644 --- a/readme.md +++ b/readme.md @@ -321,6 +321,7 @@ Magg supports several environment variables for configuration: - `MAGG_CONFIG_PATH` - Path to config file (default: `.magg/config.json`) - `MAGG_LOG_LEVEL` - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) - `MAGG_STDERR_SHOW=1` - Show stderr output from subprocess MCP servers (default: suppressed) +- `MAGG_KIT_CHANGES_ONLY=true` - Expose only kit-related and view tools (load_kit, unload_kit, list_kits, kit_info, list_servers, status), allowing viewing current state but only allowing server changes via kits (default: false) - `MAGG_AUTO_RELOAD` - Enable/disable config auto-reload (default: true) - `MAGG_RELOAD_POLL_INTERVAL` - Config polling interval in seconds (default: 1.0) - `MAGG_READ_ONLY=true` - Run in read-only mode diff --git a/test/magg/test_kit_changes_only.py b/test/magg/test_kit_changes_only.py new file mode 100644 index 0000000..d5e6ee1 --- /dev/null +++ b/test/magg/test_kit_changes_only.py @@ -0,0 +1,166 @@ +"""Tests for MAGG_KIT_CHANGES_ONLY environment variable.""" + +import json +import os +import pytest +import pytest_asyncio +from pathlib import Path + +from magg.server.server import MaggServer +from fastmcp.client import Client + + +class TestKitChangesOnly: + """Test MAGG_KIT_CHANGES_ONLY environment variable functionality.""" + + @pytest_asyncio.fixture + async def config_path(self, tmp_path): + """Create a temporary config path.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({"servers": {}})) + return config_path + + @pytest.mark.asyncio + async def test_default_mode_all_tools_exposed(self, config_path): + """Test that all tools are exposed by default (kit_changes_only=false).""" + # Ensure environment variable is not set or is false + os.environ.pop('MAGG_KIT_CHANGES_ONLY', None) + + server = MaggServer(str(config_path), enable_config_reload=False) + + async with server: + async with Client(server.mcp) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + + # Verify both kit tools and management tools are present + assert 'magg_load_kit' in tool_names, "Kit tools should be present" + assert 'magg_unload_kit' in tool_names + assert 'magg_list_kits' in tool_names + assert 'magg_kit_info' in tool_names + + assert 'magg_add_server' in tool_names, "Management tools should be present" + assert 'magg_remove_server' in tool_names + assert 'magg_list_servers' in tool_names + assert 'magg_status' in tool_names + + # Should have more than just kit tools + assert len(tool_names) > 5, "Should have many tools in default mode" + + @pytest.mark.asyncio + async def test_kit_changes_only_mode_enabled(self, config_path): + """Test that only kit and view tools are exposed when MAGG_KIT_CHANGES_ONLY=true.""" + os.environ['MAGG_KIT_CHANGES_ONLY'] = 'true' + + try: + server = MaggServer(str(config_path), enable_config_reload=False) + + async with server: + async with Client(server.mcp) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + + # Verify kit tools are present + assert 'magg_load_kit' in tool_names, "load_kit should be present" + assert 'magg_unload_kit' in tool_names, "unload_kit should be present" + assert 'magg_list_kits' in tool_names, "list_kits should be present" + assert 'magg_kit_info' in tool_names, "kit_info should be present" + + # Verify view tools are present + assert 'magg_list_servers' in tool_names, "list_servers should be present" + assert 'magg_status' in tool_names, "status should be present" + + # Verify modification tools are NOT present + assert 'magg_add_server' not in tool_names, "add_server should NOT be present" + assert 'magg_remove_server' not in tool_names, "remove_server should NOT be present" + assert 'magg_enable_server' not in tool_names, "enable_server should NOT be present" + assert 'magg_disable_server' not in tool_names, "disable_server should NOT be present" + assert 'magg_search_servers' not in tool_names, "search_servers should NOT be present" + assert 'magg_smart_configure' not in tool_names, "smart_configure should NOT be present" + assert 'magg_analyze_servers' not in tool_names, "analyze_servers should NOT be present" + assert 'magg_check' not in tool_names, "check should NOT be present" + assert 'magg_reload_config' not in tool_names, "reload_config should NOT be present" + + # Proxy tool may still be present (it's registered by ProxyMCP) + # Only kit tools + view tools + proxy should be present + expected_tool_names = { + 'magg_load_kit', 'magg_unload_kit', 'magg_list_kits', 'magg_kit_info', + 'magg_list_servers', 'magg_status', + 'proxy' + } + assert tool_names == expected_tool_names, f"Only kit and view tools should be present, got: {tool_names}" + + finally: + os.environ.pop('MAGG_KIT_CHANGES_ONLY', None) + + @pytest.mark.asyncio + async def test_kit_changes_only_mode_disabled(self, config_path): + """Test that all tools are exposed when MAGG_KIT_CHANGES_ONLY=false.""" + os.environ['MAGG_KIT_CHANGES_ONLY'] = 'false' + + try: + server = MaggServer(str(config_path), enable_config_reload=False) + + async with server: + async with Client(server.mcp) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + + # Verify both kit tools and management tools are present + assert 'magg_load_kit' in tool_names + assert 'magg_add_server' in tool_names + + # Should have many tools + assert len(tool_names) > 5 + + finally: + os.environ.pop('MAGG_KIT_CHANGES_ONLY', None) + + @pytest.mark.asyncio + async def test_kit_and_view_tools_functional_in_kit_changes_only_mode(self, tmp_path): + """Test that kit and view tools are functional when MAGG_KIT_CHANGES_ONLY=true.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({"servers": {}})) + + # Create a kit directory with a test kit + kitd_path = tmp_path / "kit.d" + kitd_path.mkdir() + + kit_path = kitd_path / "test-kit.json" + kit_path.write_text(json.dumps({ + "name": "test-kit", + "description": "Test kit for kit_changes_only mode", + "servers": { + "dummy-server": { + "source": "https://example.com", + "command": "echo", + "enabled": False + } + } + })) + + os.environ['MAGG_KIT_CHANGES_ONLY'] = 'true' + os.environ['MAGG_PATH'] = str(tmp_path) + + try: + server = MaggServer(str(config_path), enable_config_reload=False) + + async with server: + async with Client(server.mcp) as client: + # Test kit tools + result = await client.call_tool('magg_list_kits', {}) + assert 'test-kit' in str(result.content), "Should be able to list kits" + + result = await client.call_tool('magg_kit_info', {'name': 'test-kit'}) + assert 'test-kit' in str(result.content), "Should be able to get kit info" + + # Test view tools + result = await client.call_tool('magg_list_servers', {}) + assert result.content is not None, "Should be able to list servers" + + result = await client.call_tool('magg_status', {}) + assert result.content is not None, "Should be able to get status" + + finally: + os.environ.pop('MAGG_KIT_CHANGES_ONLY', None) + os.environ.pop('MAGG_PATH', None) diff --git a/uv.lock b/uv.lock index 922d23e..95738c6 100644 --- a/uv.lock +++ b/uv.lock @@ -676,7 +676,7 @@ wheels = [ [[package]] name = "magg" -version = "0.10.1" +version = "0.11.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },