Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
17 changes: 17 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions docs/kits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 22 additions & 7 deletions magg/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions magg/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}]
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 166 additions & 0 deletions test/magg/test_kit_changes_only.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.