├── .python-version ├── magg ├── server │ ├── __init__.py │ └── defaults.py ├── discovery │ ├── __init__.py │ └── catalog.py ├── util │ ├── __init__.py │ ├── paths.py │ ├── stdio_patch.py │ ├── system.py │ ├── transports.py │ ├── uri.py │ └── terminal.py ├── contrib │ ├── magg.mbro │ ├── readme.md │ └── kit.d │ │ └── example.json ├── __main__.py ├── mbro │ ├── __main__.py │ ├── __init__.py │ └── validator.py ├── proxy │ ├── __init__.py │ ├── types.py │ └── server.py ├── logs │ ├── formatter.py │ ├── filter.py │ ├── adapter.py │ ├── queue.py │ ├── handler.py │ ├── config.py │ ├── __init__.py │ ├── listener.py │ └── defaults.py ├── __init__.py ├── process.py └── client.py ├── .gitignore ├── .env.example ├── .github └── workflows │ ├── test.yml │ ├── readme.md │ ├── publish.yml │ └── manual-publish.yml ├── compose.yaml ├── examples ├── sampling.py ├── config_reload.py ├── embedding.py ├── messaging.py └── authentication.py ├── test └── magg │ ├── test_client_mounting.py │ ├── test_prefix_handling.py │ ├── test_mounting_debug.py │ ├── test_mounting_real.py │ ├── test_status_check.py │ ├── test_mbro_validator.py │ ├── test_basic.py │ ├── test_mbro_cli_overhaul.py │ ├── test_e2e_simple.py │ ├── test_mounting.py │ ├── test_client_api.py │ ├── test_tool_delegation.py │ ├── test_kit_info.py │ ├── test_config_migration.py │ ├── test_error_handling.py │ ├── test_in_memory.py │ ├── test_integration.py │ └── test_e2e_mounting.py ├── scripts ├── validate_manual_release.py └── fix_whitespace.py ├── pyproject.toml ├── contributing.md ├── docs ├── kits.md └── authentication.md └── dockerfile /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /magg/server/__init__.py: -------------------------------------------------------------------------------- 1 | """Magg Server Management 2 | """ 3 | -------------------------------------------------------------------------------- /magg/discovery/__init__.py: -------------------------------------------------------------------------------- 1 | """Tool discovery and search capabilities.""" 2 | -------------------------------------------------------------------------------- /magg/util/__init__.py: -------------------------------------------------------------------------------- 1 | """Common utilities and helper functions. 2 | """ 3 | -------------------------------------------------------------------------------- /magg/contrib/magg.mbro: -------------------------------------------------------------------------------- 1 | # A simple MBro script to load up a Magg stdio server. 2 | connect magg magg serve 3 | -------------------------------------------------------------------------------- /magg/__main__.py: -------------------------------------------------------------------------------- 1 | from magg import process 2 | 3 | process.setup() 4 | 5 | from magg.cli import main 6 | 7 | main() 8 | -------------------------------------------------------------------------------- /magg/mbro/__main__.py: -------------------------------------------------------------------------------- 1 | from magg import process 2 | 3 | process.setup() 4 | 5 | from magg.mbro.cli import main 6 | 7 | main() 8 | -------------------------------------------------------------------------------- /magg/mbro/__init__.py: -------------------------------------------------------------------------------- 1 | """MBRO - MCP Browser - Interactive MCP client tool.""" 2 | 3 | from .cli import main 4 | 5 | __all__ = ["main"] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.gitkeep 4 | !.github/ 5 | !.python-version 6 | !.env.example 7 | 8 | *.py[co] 9 | __pycache__/ 10 | 11 | play/ 12 | local/ 13 | CLAUDE.local* 14 | -------------------------------------------------------------------------------- /magg/contrib/readme.md: -------------------------------------------------------------------------------- 1 | # Magg Contrib 2 | 3 | This is a place to develop new features and provide shared data like kits, scripts, and other resources for the Magg ecosystem. 4 | 5 | It's a namespace package, so you can develop your own contrib packages without needing to work with the main Magg repository. 6 | -------------------------------------------------------------------------------- /magg/proxy/__init__.py: -------------------------------------------------------------------------------- 1 | """MCP proxy tool, making it easier to work with proxied MCP capabilities. 2 | """ 3 | from .mixin import ProxyMCP 4 | from .client import ProxyClient 5 | from .server import ProxyFastMCP 6 | 7 | __all__ = ( 8 | "ProxyMCP", 9 | "ProxyClient", 10 | "ProxyFastMCP", 11 | ) 12 | -------------------------------------------------------------------------------- /magg/util/paths.py: -------------------------------------------------------------------------------- 1 | """Helpers to locate package paths. 2 | 3 | Used when searching for kits and mbro scripts. 4 | """ 5 | from pathlib import Path 6 | 7 | from magg import contrib 8 | 9 | __all__ = "get_contrib_paths", "contrib" 10 | 11 | 12 | def get_contrib_paths() -> list[Path]: 13 | """Get all paths for contrib namespace packages.""" 14 | return [Path(path) for path in contrib.__path__ if Path(path).is_dir()] 15 | -------------------------------------------------------------------------------- /magg/logs/formatter.py: -------------------------------------------------------------------------------- 1 | """Logging formatter module. 2 | 3 | Defines a default formatter for log messages. 4 | """ 5 | import logging 6 | 7 | __all__ = "DefaultFormatter", 8 | 9 | 10 | class DefaultFormatter(logging.Formatter): 11 | """Default log formatter. 12 | 13 | Uses `{}`-style formatting. 14 | """ 15 | def __init__(self, fmt=None, datefmt=None, style="{", **kwds): 16 | super().__init__(fmt=fmt, datefmt=datefmt, style=style, **kwds) 17 | -------------------------------------------------------------------------------- /magg/logs/filter.py: -------------------------------------------------------------------------------- 1 | """Useful logging filters. 2 | """ 3 | import logging 4 | 5 | __all__ = "IgnoreHealthCheckerFilter", 6 | 7 | 8 | class IgnoreHealthCheckerFilter(logging.Filter): 9 | user_agent = 'ELB-HealthChecker/2.0' 10 | 11 | def __init__(self, user_agent=None): 12 | super().__init__() 13 | if user_agent: 14 | self.user_agent = user_agent 15 | 16 | def filter(self, record): 17 | return record.getMessage().find(self.user_agent) == -1 18 | -------------------------------------------------------------------------------- /magg/logs/adapter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __all__ = "LoggerAdapter", 4 | 5 | 6 | class LoggerAdapter(logging.LoggerAdapter): 7 | """Default logger adapter. 8 | 9 | Just a thin wrapper around the standard library's LoggerAdapter. 10 | 11 | Its main purpose is to set Python 3.13's new merge_extra to True by default. 12 | """ 13 | 14 | def __init__(self, logger, extra=None, merge_extra=True): 15 | super().__init__(logger, extra, merge_extra) 16 | 17 | def process(self, msg, kwds): 18 | return super().process(msg, kwds) 19 | -------------------------------------------------------------------------------- /magg/__init__.py: -------------------------------------------------------------------------------- 1 | """Magg - MCP Aggregator 2 | 3 | A self-aware MCP server that manages and aggregates other MCP tools and servers. 4 | """ 5 | from importlib import metadata 6 | 7 | try: 8 | __version__ = metadata.version("magg") 9 | except metadata.PackageNotFoundError: 10 | __version__ = "unknown" 11 | 12 | del metadata 13 | 14 | # Export main components 15 | from .client import MaggClient 16 | from .messaging import MaggMessageHandler, MessageRouter, ServerMessageCoordinator 17 | 18 | __all__ = [ 19 | "MaggClient", 20 | "MaggMessageHandler", 21 | "MessageRouter", 22 | "ServerMessageCoordinator", 23 | ] 24 | -------------------------------------------------------------------------------- /magg/contrib/kit.d/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "description": "Example kit with common MCP servers", 4 | "author": "Magg Contributors", 5 | "version": "1.0.0", 6 | "keywords": ["example", "demo"], 7 | "servers": { 8 | "filesystem": { 9 | "source": "npx @modelcontextprotocol/server-filesystem", 10 | "command": "npx", 11 | "args": ["@modelcontextprotocol/server-filesystem", "/tmp"], 12 | "notes": "File system access server with /tmp as root" 13 | }, 14 | "memory": { 15 | "source": "npx @modelcontextprotocol/server-memory", 16 | "command": "npx", 17 | "args": ["@modelcontextprotocol/server-memory"], 18 | "notes": "In-memory key-value store" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Docker Registry Configuration 2 | # REGISTRY=ghcr.io/ 3 | 4 | # Image source prefix for docker compose (defaults to $USER) 5 | # SOURCE=local 6 | 7 | # Config volume path for compose (defaults to named volume 'magg-config' except for dev) 8 | # MAGG_CONFIG_VOLUME=./.magg 9 | 10 | # Authentication 11 | # MAGG_PRIVATE_KEY= # generate with `magg auth private-key --oneline` 12 | # MAGG_JWT= # For clients - generate with `magg auth jwt -q` 13 | 14 | # Logging 15 | # MAGG_LOG_LEVEL=WARNING 16 | 17 | # Other options 18 | # MAGG_CONFIG_PATH=./magg/config.json 19 | # MAGG_SELF_PREFIX=magg # The prefix used in the MCP server for magg's own tools 20 | 21 | # Config reloading 22 | 23 | # MAGG_AUTO_RELOAD 24 | # MAGG_RELOAD_POLL_INTERVAL 25 | # MAGG_RELOAD_USE_WATCHDOG 26 | # MAGG_READ_ONLY 27 | -------------------------------------------------------------------------------- /magg/logs/queue.py: -------------------------------------------------------------------------------- 1 | """Async-capable logging queue. 2 | 3 | TODO: Make use of asyncio.Queue dynamically? 4 | """ 5 | import asyncio 6 | import queue 7 | 8 | __all__ = "LogQueue", 9 | 10 | 11 | class LogQueue(queue.Queue): 12 | """Queue for logging messages. 13 | """ 14 | def __init__(self): 15 | super().__init__(maxsize=0) 16 | 17 | def put(self, item, block=True, timeout=None) -> asyncio.Handle | None: 18 | try: 19 | # Use get_running_loop() to avoid deprecation warning 20 | loop = asyncio.get_running_loop() 21 | return loop.call_soon_threadsafe(super().put, item, block, timeout) 22 | except RuntimeError: 23 | # No running event loop, use synchronous put 24 | pass 25 | 26 | return super().put(item, block, timeout) 27 | -------------------------------------------------------------------------------- /magg/logs/handler.py: -------------------------------------------------------------------------------- 1 | """Logging queue handler. 2 | """ 3 | import logging.handlers 4 | from queue import Queue 5 | 6 | __all__ = "QueueHandler", "StreamHandler", 7 | 8 | 9 | class QueueHandler(logging.handlers.QueueHandler): 10 | """Queue handler. 11 | 12 | Just a thin wrapper around the standard library's QueueHandler that 13 | starts the listener if it isn't already running. 14 | """ 15 | listener: logging.handlers.QueueListener | None 16 | 17 | def __init__(self, queue): 18 | super().__init__(queue) 19 | 20 | def emit(self, record): 21 | if not bool(self.listener) and isinstance(self.queue, Queue): 22 | self.listener.start() 23 | super().emit(record) 24 | 25 | 26 | class StreamHandler(logging.StreamHandler): 27 | """Stream handler. 28 | 29 | Just a thin wrapper around the standard library's StreamHandler. 30 | 31 | Used to have a consistent import path for all handlers. 32 | """ 33 | 34 | def __init__(self, stream=None): 35 | super().__init__(stream) 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | paths: 7 | - 'magg/**' 8 | - 'tests/**' 9 | - 'pyproject.toml' 10 | - 'uv.lock' 11 | pull_request: 12 | branches: ['*'] 13 | paths: 14 | - 'magg/**' 15 | - 'tests/**' 16 | - 'pyproject.toml' 17 | - 'uv.lock' 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | python-version: ['3.12', '3.13'] 25 | fail-fast: false # Continue testing other versions if one fails 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Install uv 32 | uses: astral-sh/setup-uv@v6 33 | with: 34 | enable-cache: true 35 | 36 | - name: Set up Python ${{ matrix.python-version }} 37 | run: uv python install ${{ matrix.python-version }} 38 | 39 | - name: Install dependencies 40 | run: | 41 | uv sync --all-groups --locked 42 | 43 | - name: Run tests 44 | run: | 45 | uv run pytest -v -------------------------------------------------------------------------------- /magg/proxy/types.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Self 2 | 3 | from mcp.types import Annotations 4 | from pydantic import Field 5 | 6 | __all__ = "LiteralProxyType", "LiteralProxyAction", "ProxyResponseInfo" 7 | 8 | LiteralProxyType = Literal["tool", "resource", "prompt"] 9 | LiteralProxyAction = Literal["list", "info", "call"] 10 | 11 | 12 | class ProxyResponseInfo(Annotations): 13 | """Metadata for proxy tool responses, pulled from annotations. 14 | 15 | Note that this info cannot always be retrieved, e.g., for empty results. 16 | 17 | It is mostly useful for introspection and debugging, and identifying 18 | query-typed results (list, info) that can be further processed by the client. 19 | """ 20 | proxy_type: LiteralProxyType | None = Field( 21 | None, 22 | description="Type of the proxied capability (tool, resource, prompt).", 23 | ) 24 | proxy_action: LiteralProxyAction | None = Field( 25 | None, 26 | description="Action performed by the proxy (list, info, call).", 27 | ) 28 | proxy_path: str | None = Field( 29 | None, 30 | description="Name or URI of the specific tool/resource/prompt (with FastMCP prefixing).", 31 | ) 32 | 33 | @classmethod 34 | def from_annotations(cls, annotations: Annotations) -> Self: 35 | """Create ProxyResponseInfo from Annotations.""" 36 | return cls(**annotations.model_dump(mode="json", exclude_unset=True, exclude_defaults=True)) 37 | -------------------------------------------------------------------------------- /magg/util/stdio_patch.py: -------------------------------------------------------------------------------- 1 | """Utilities for patching stdio transports to control stderr behavior.""" 2 | import os 3 | from contextlib import asynccontextmanager 4 | from pathlib import Path 5 | 6 | __all__ = "patch_stdio_transport_stderr", 7 | 8 | 9 | def patch_stdio_transport_stderr(transport): 10 | """Patch a stdio transport to suppress stderr output. 11 | 12 | This monkey-patches the transport's connect method to redirect stderr to /dev/null. 13 | Only works for StdioTransport and its subclasses. 14 | """ 15 | from fastmcp.client.transports import StdioTransport 16 | 17 | if not isinstance(transport, StdioTransport): 18 | return transport 19 | 20 | original_connect = transport.connect 21 | 22 | async def patched_connect(**session_kwargs): 23 | from mcp.client import stdio 24 | original_stdio_client = stdio.stdio_client 25 | 26 | @asynccontextmanager 27 | async def silent_stdio_client(server_params, errlog=None): 28 | with Path(os.devnull).open('w') as devnull: 29 | async with original_stdio_client(server_params, errlog=devnull) as result: 30 | yield result 31 | 32 | stdio.stdio_client = silent_stdio_client 33 | 34 | try: 35 | return await original_connect(**session_kwargs) 36 | 37 | finally: 38 | stdio.stdio_client = original_stdio_client 39 | 40 | transport.connect = patched_connect 41 | return transport 42 | -------------------------------------------------------------------------------- /magg/logs/config.py: -------------------------------------------------------------------------------- 1 | """Logging configuration. 2 | """ 3 | import logging 4 | from logging import Logger, config as logging_config 5 | from typing import Literal 6 | 7 | from fastmcp.utilities import logging as fastmcp_logging 8 | 9 | from .defaults import LOGGING_CONFIG 10 | 11 | __all__ = "configure_logging", "LOGGING_CONFIG", 12 | 13 | 14 | def configure_logging(config=None, *, incremental=False) -> None: 15 | """Configure logging. 16 | 17 | Args: 18 | config (dict): Logging configuration dictionary. 19 | If None, the default configuration will be used. 20 | 21 | incremental (bool): Whether to apply the configuration incrementally. 22 | 23 | Returns: 24 | None 25 | """ 26 | if config is None: 27 | config = LOGGING_CONFIG.copy() 28 | 29 | if incremental: 30 | config["incremental"] = True 31 | 32 | logging_config.dictConfig(config) 33 | 34 | 35 | def configure_logging_fastmcp( 36 | level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", 37 | logger: Logger | None = None, 38 | enable_rich_tracebacks: bool = True, 39 | ) -> None: 40 | """Patched configuration for FastMCP logging.""" 41 | # from rich.logging import RichHandler 42 | # from rich.console import Console 43 | # rich_handler: RichHandler | None = logging.getHandlerByName("rich") 44 | # if rich_handler: 45 | # rich_handler.console = Console(stderr=True) 46 | 47 | 48 | fastmcp_logging.configure_logging = configure_logging_fastmcp 49 | -------------------------------------------------------------------------------- /magg/process.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | __all__ = "initialize_process", "is_initialized", "setup", 5 | 6 | _initialized = False 7 | 8 | 9 | def initialize_process(**environment) -> bool: 10 | """Process-level initialization. 11 | """ 12 | global _initialized 13 | 14 | if _initialized: 15 | return False 16 | 17 | _initialized = True 18 | 19 | for key, value in environment.items(): 20 | os.environ.setdefault(key, value) 21 | 22 | if not os.environ.get("NO_TERM", False): 23 | from .util.system import initterm 24 | initterm() 25 | 26 | return True 27 | 28 | 29 | def is_initialized() -> bool: 30 | """Check if the process has been initialized.""" 31 | return _initialized 32 | 33 | 34 | def setup(source: str | None = __name__, **environment) -> None: 35 | """Application initialization 36 | 37 | Sets up the package environment, logging, and configuration in proper order. 38 | """ 39 | first = initialize_process(**environment) 40 | 41 | if first: 42 | from .logs import initialize_logging 43 | initialize_logging() 44 | 45 | logger = logging.getLogger(__name__) 46 | logger.debug("Process initialized by %r", source) 47 | 48 | from .settings import ConfigManager 49 | config_manager = ConfigManager() 50 | 51 | if not config_manager.config_path.exists(): 52 | logger.debug("Config file %s does not exist. Using default settings.", config_manager.config_path) 53 | 54 | config_manager.load_config() 55 | -------------------------------------------------------------------------------- /magg/logs/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from . import config, adapter 3 | 4 | 5 | def initialize_logging(*, configure_logging: bool = True, start_listeners: bool = True) -> None: 6 | """Initialize logging queues and listeners. 7 | 8 | Typically called once on application startup, after logging has been configured. 9 | """ 10 | if configure_logging: 11 | config.configure_logging() 12 | 13 | if start_listeners: 14 | from .listener import QueueListener 15 | QueueListener.start_all() 16 | 17 | 18 | def adapt_logger(logger, extra) -> adapter.LoggerAdapter: 19 | """ 20 | Adapt a logger object by attaching additional contextual information 21 | provided via the `extras` parameter. This function ensures a consistent 22 | logging format enriched with dynamic data, improving the clarity and 23 | traceability of log messages. 24 | 25 | :param logger: A logging.Logger instance to adapt or None. If None, 26 | a default logger is utilized. 27 | :param extra: A dictionary containing additional contextual 28 | information to be included in the logs. 29 | :return: A LoggerAdapter instance that wraps the provided logger 30 | and enriches its output with the given extras. 31 | :rtype: adapter.LoggerAdapter 32 | """ 33 | if logger is None: 34 | logger = get_logger() 35 | 36 | return adapter.LoggerAdapter(logger, extra) 37 | 38 | 39 | def get_logger(name: str | None = None) -> logging.Logger: 40 | """Get a logger by name. 41 | 42 | Args: 43 | name (str | None): The name of the logger. If None, the root logger is returned. 44 | 45 | Returns: 46 | logging.Logger: The logger instance. 47 | """ 48 | return logging.getLogger(name) 49 | -------------------------------------------------------------------------------- /magg/logs/listener.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import logging.handlers 3 | import weakref 4 | 5 | from .queue import LogQueue 6 | 7 | __all__ = "QueueListener", 8 | 9 | 10 | class QueueListener(logging.handlers.QueueListener): 11 | """Queue listener. 12 | 13 | Support self-starting and stopping, and sets respect_handler_level to True by default. 14 | """ 15 | __listeners = [] 16 | 17 | def __init__(self, queue: LogQueue, *handlers: logging.Handler, respect_handler_level=True, start=False): 18 | super().__init__(queue, *handlers, respect_handler_level=respect_handler_level) 19 | 20 | type(self).__listeners.append(weakref.proxy(self, type(self).__listeners.remove)) 21 | 22 | if start: 23 | self.start() 24 | 25 | def __bool__(self): 26 | return self._thread is not None 27 | 28 | def __del__(self): 29 | self.stop() 30 | 31 | def start(self): 32 | if not self: 33 | super().start() 34 | atexit.register(self.stop) 35 | 36 | def stop(self): 37 | if self: 38 | super().stop() 39 | atexit.unregister(self.stop) 40 | 41 | @classmethod 42 | def start_all(cls): 43 | """Start all listeners. 44 | 45 | NOTE: When using the logs.handler.QueueHandler handler, 46 | this method is called automatically upon emitting a log record. 47 | """ 48 | for listener in cls.__listeners: 49 | listener.start() 50 | 51 | @classmethod 52 | def stop_all(cls): 53 | """Stop all listeners. 54 | 55 | NOTE: Each listener stops itself on deletion or program exit, so calling this method is optional. 56 | """ 57 | for listener in cls.__listeners: 58 | listener.stop() 59 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | name: magg 2 | 3 | services: 4 | base: 5 | profiles: ["_"] # Hidden from normal operations 6 | build: 7 | dockerfile: dockerfile 8 | args: 9 | MAGG_READ_ONLY: ${MAGG_READ_ONLY:-false} 10 | environment: 11 | MAGG_PRIVATE_KEY: ${MAGG_PRIVATE_KEY:-} 12 | MAGG_LOG_LEVEL: ${MAGG_LOG_LEVEL:-} 13 | MAGG_CONFIG_PATH: ${MAGG_CONFIG_PATH:-} 14 | MAGG_SELF_PREFIX: ${MAGG_SELF_PREFIX:-} 15 | MAGG_AUTO_RELOAD: ${MAGG_AUTO_RELOAD:-false} 16 | MAGG_RELOAD_POLL_INTERVAL: ${MAGG_RELOAD_POLL_INTERVAL:-} 17 | MAGG_RELOAD_USE_WATCHDOG: ${MAGG_RELOAD_USE_WATCHDOG:-} 18 | MAGG_STDERR_SHOW: ${MAGG_STDERR_SHOW:-} 19 | MAGG_PREFIX_SEP: ${MAGG_PREFIX_SEP:-} 20 | volumes: 21 | - ${MAGG_CONFIG_VOLUME:-magg-config}:/home/magg/.magg 22 | networks: 23 | - magg-network 24 | restart: unless-stopped 25 | 26 | magg: 27 | extends: 28 | service: base 29 | build: 30 | target: pro 31 | image: ${REGISTRY:-}${REGISTRY:+/}magg:${SOURCE:-${USER}}-pro 32 | container_name: magg-server 33 | ports: 34 | - "8000:8000" 35 | 36 | magg-beta: 37 | extends: 38 | service: base 39 | build: 40 | target: pre 41 | image: ${REGISTRY:-}${REGISTRY:+/}magg:${SOURCE:-${USER}}-pre 42 | container_name: magg-server-pre 43 | ports: 44 | - "8001:8000" 45 | 46 | magg-dev: 47 | extends: 48 | service: base 49 | build: 50 | target: dev 51 | environment: 52 | MAGG_AUTO_RELOAD: ${MAGG_AUTO_RELOAD:-true} 53 | image: ${REGISTRY:-}${REGISTRY:+/}magg:${SOURCE:-${USER}}-dev 54 | container_name: magg-server-dev 55 | ports: 56 | - "8008:8000" 57 | volumes: 58 | - ./.magg:/home/magg/.magg:rw 59 | 60 | volumes: 61 | magg-config: 62 | 63 | networks: 64 | magg-network: 65 | driver: bridge 66 | -------------------------------------------------------------------------------- /magg/client.py: -------------------------------------------------------------------------------- 1 | """Magg FastMCP client wrapper with authentication and proxy support.""" 2 | from typing import Any 3 | 4 | from fastmcp.client import BearerAuth 5 | from fastmcp.client.messages import MessageHandler, MessageHandlerT 6 | from httpx import Auth 7 | 8 | from .proxy.client import ProxyClient 9 | from .settings import ClientSettings 10 | from .messaging import MaggMessageHandler 11 | 12 | 13 | class MaggClient(ProxyClient): 14 | """Magg-specific client with authentication and proxy support. 15 | 16 | This client is designed for external code that needs to talk to Magg servers. 17 | It automatically handles JWT authentication from environment variables and 18 | provides proxy-aware methods for accessing aggregated MCP capabilities. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | transport: Any, 24 | *args, 25 | settings: ClientSettings | None = None, 26 | auth: Auth | str | None = None, 27 | transparent: bool = True, 28 | message_handler: MessageHandlerT | None = None, 29 | **kwds, 30 | ): 31 | """Initialize Magg client with JWT authentication and proxy support. 32 | 33 | Args: 34 | transport: Same as FastMCP Client transport argument 35 | *args: Additional positional arguments for ProxyClient 36 | settings: Client settings (defaults to loading from env) 37 | auth: Override auth (if not provided, uses JWT from settings) 38 | transparent: Enable transparent proxy mode (default: True for Magg) 39 | message_handler: Optional message handler for notifications/requests 40 | **kwds: Additional keyword arguments for ProxyClient/FastMCP Client 41 | """ 42 | self.settings = settings or ClientSettings() 43 | 44 | if auth is None and self.settings.jwt: 45 | auth = BearerAuth(self.settings.jwt) 46 | 47 | super().__init__( 48 | transport, 49 | *args, 50 | auth=auth, 51 | transparent=transparent, 52 | message_handler=message_handler, 53 | **kwds 54 | ) 55 | -------------------------------------------------------------------------------- /magg/util/system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | try: 7 | from rich import console, pretty, traceback 8 | 9 | _rc: console.Console | None = None 10 | 11 | except (ImportError, ModuleNotFoundError): 12 | pass 13 | 14 | __all__ = "initterm", "is_subdirectory", "get_project_root", "get_subprocess_environment", 15 | 16 | 17 | def initterm(**kwds) -> Optional["console.Console"]: 18 | try: 19 | if not os.isatty(0): 20 | return None 21 | 22 | except (AttributeError, OSError): 23 | return None 24 | 25 | try: 26 | global _rc 27 | 28 | if _rc is None: 29 | kwds.setdefault("color_system", "truecolor") 30 | kwds.setdefault("file", sys.stderr) 31 | _rc = console.Console(**kwds) 32 | pretty.install(console=_rc) 33 | traceback.install(console=_rc, show_locals=True) 34 | 35 | return _rc 36 | 37 | except NameError: 38 | return None 39 | 40 | 41 | def is_subdirectory(child: Path, parent: Path) -> bool: 42 | """Check if child is a subdirectory of parent. 43 | 44 | Args: 45 | child: Potential subdirectory path 46 | parent: Parent directory path 47 | 48 | Returns: 49 | True if child is same as or subdirectory of parent 50 | """ 51 | child_abs = child.resolve() 52 | parent_abs = parent.resolve() 53 | 54 | return child_abs.is_relative_to(parent_abs) 55 | 56 | 57 | def get_project_root() -> Path: 58 | """Get the current project root (where .magg directory is).""" 59 | return Path.cwd() 60 | 61 | 62 | def get_subprocess_environment(*, inherit: bool = False, provided: dict | None = None) -> dict: 63 | """Get the environment for subprocesses. 64 | 65 | Args: 66 | inherit: If True, inherit the current environment. 67 | provided: Additional environment variables to include. 68 | 69 | Returns: 70 | A dictionary of environment variables. 71 | """ 72 | env = os.environ.copy() if inherit else {} 73 | 74 | if provided: 75 | env.update(provided) 76 | 77 | return env 78 | -------------------------------------------------------------------------------- /magg/logs/defaults.py: -------------------------------------------------------------------------------- 1 | """Default logging config. 2 | 3 | Uses standard Python logging configuration schema. 4 | 5 | Intended for apps that don't use Django. 6 | 7 | Just sets every logger to use this package's components. 8 | """ 9 | import os 10 | 11 | 12 | LOGGING_CONFIG = { 13 | "version": 1, 14 | "disable_existing_loggers": False, 15 | "formatters": { 16 | "default": { 17 | "()": "magg.logs.formatter.DefaultFormatter", 18 | "format": "[{asctime}.{msecs:03.0f}] {levelname} {name} {message}", 19 | "datefmt": "%Y-%m-%d %H:%M:%S", 20 | "style": "{", 21 | "defaults": { 22 | # "foo": "bar", 23 | }, 24 | }, 25 | }, 26 | "handlers": { 27 | "stream": { 28 | "class": "magg.logs.handler.StreamHandler", 29 | "formatter": "default", 30 | "stream": "ext://sys.stderr", 31 | }, 32 | "default": { 33 | "class": "magg.logs.handler.QueueHandler", 34 | "queue": "magg.logs.queue.LogQueue", 35 | "listener": "magg.logs.listener.QueueListener", 36 | "handlers": ["stream"], 37 | }, 38 | # "rich": { 39 | # "class": "rich.logging.RichHandler", 40 | # # "formatter": "default", 41 | # "rich_tracebacks": True, 42 | # } 43 | }, 44 | "loggers": { 45 | "root": { 46 | "handlers": ["default"], 47 | "level": "WARNING", 48 | "propagate": False, 49 | }, 50 | "magg": { 51 | "handlers": ["default"], 52 | "level": (os.getenv("MAGG_LOG_LEVEL") or "INFO").upper(), 53 | "propagate": False, 54 | }, 55 | "FastMCP": { 56 | "handlers": ["default"], 57 | "level": (os.getenv("FASTMCP_LOG_LEVEL") or "CRITICAL").upper(), 58 | "propagate": False, 59 | }, 60 | "mcp": { 61 | "handlers": ["default"], 62 | "level": "ERROR", 63 | "propagate": False, 64 | }, 65 | "uvicorn": { 66 | "handlers": ["default"], 67 | "level": "WARNING", 68 | "propagate": False, 69 | }, 70 | "uvicorn.access": { 71 | "handlers": ["default"], 72 | "level": "WARNING", 73 | "propagate": False, 74 | }, 75 | "uvicorn.error": { 76 | "handlers": ["default"], 77 | "level": "ERROR", 78 | "propagate": False, 79 | }, 80 | }, 81 | } 82 | -------------------------------------------------------------------------------- /examples/sampling.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = [ 4 | # "anthropic>=0.54.0", 5 | # "fastmcp<3", 6 | # "magg>=0.3.4", 7 | # ] 8 | # /// 9 | """This example demonstrates how to use the FastMCP client with a custom sampling handler. 10 | 11 | It connects to a local FastMCP server and uses the Anthropic API to handle sampling requests. 12 | """ 13 | 14 | import asyncio 15 | import json 16 | 17 | from anthropic import AsyncAnthropic 18 | from fastmcp.client import Client 19 | from fastmcp.client.sampling import ( 20 | SamplingMessage, 21 | SamplingParams, 22 | RequestContext, 23 | ) 24 | from mcp.types import CreateMessageRequestParams 25 | from rich.traceback import install 26 | 27 | from magg.util.transform import is_mcp_result_json_typed, extract_mcp_result_json, get_mcp_result_contents 28 | 29 | mcp_url = "http://localhost:8000/mcp" 30 | 31 | 32 | install(show_locals=True) 33 | 34 | 35 | 36 | async def claude_sampling_handler( 37 | messages: list[SamplingMessage], 38 | params: CreateMessageRequestParams, 39 | context: RequestContext, 40 | ): 41 | client = AsyncAnthropic() 42 | 43 | claude_messages = [ 44 | {"role": msg.role, "content": msg.content.text} 45 | for msg in messages if hasattr(msg.content, 'text') 46 | ] 47 | 48 | response = await client.messages.create( 49 | model="claude-3-5-sonnet-20241022", 50 | messages=claude_messages, 51 | max_tokens=params.maxTokens or 4096, 52 | ) 53 | 54 | return response.content[0].text 55 | 56 | 57 | async def main(): 58 | # Create a client with the custom sampling handler 59 | client = Client(mcp_url, sampling_handler=claude_sampling_handler) 60 | 61 | async with client: 62 | # Call a tool that uses sampling 63 | result = await client.call_tool( 64 | name="magg_smart_configure", 65 | arguments={ 66 | "source": "https://github.com/wrtnlabs/calculator-mcp" 67 | }, 68 | ) 69 | 70 | for content in result: 71 | if is_mcp_result_json_typed(content): 72 | json_content = extract_mcp_result_json(content) 73 | print(json.dumps(json.loads(json_content), indent=2)) 74 | else: 75 | data = get_mcp_result_contents(content) 76 | 77 | if isinstance(data, str): 78 | print("Text Result:", data) 79 | else: 80 | print("Data Result:", data) 81 | 82 | 83 | if __name__ == "__main__": 84 | asyncio.run(main()) 85 | -------------------------------------------------------------------------------- /test/magg/test_client_mounting.py: -------------------------------------------------------------------------------- 1 | """Test client mounting approaches with FastMCP.""" 2 | 3 | import pytest 4 | import tempfile 5 | from pathlib import Path 6 | 7 | from fastmcp import FastMCP, Client 8 | from magg.util.transports import NoValidatePythonStdioTransport 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_fastmcp_mounting_approaches(): 13 | """Test different approaches to mounting external servers.""" 14 | 15 | # Create a test server file 16 | with tempfile.TemporaryDirectory() as tmpdir: 17 | server_code = ''' 18 | from fastmcp import FastMCP 19 | 20 | mcp = FastMCP("calculator") 21 | 22 | @mcp.tool() 23 | def add(a: int, b: int) -> int: 24 | """Add two numbers.""" 25 | return a + b 26 | 27 | @mcp.tool() 28 | def multiply(a: int, b: int) -> int: 29 | """Multiply two numbers.""" 30 | return a * b 31 | 32 | if __name__ == "__main__": 33 | mcp.run() 34 | ''' 35 | server_path = Path(tmpdir) / "calc_server.py" 36 | server_path.write_text(server_code) 37 | 38 | # Create transport 39 | transport = NoValidatePythonStdioTransport( 40 | script_path=str(server_path), 41 | cwd=str(tmpdir) 42 | ) 43 | 44 | # Create client 45 | client = Client(transport) 46 | 47 | # Test 1: Verify client can connect and list tools 48 | async with client: 49 | tools = await client.list_tools() 50 | assert len(tools) == 2 51 | tool_names = [tool.name for tool in tools] 52 | assert "add" in tool_names 53 | assert "multiply" in tool_names 54 | 55 | # Test calling a tool 56 | result = await client.call_tool("add", {"a": 5, "b": 3}) 57 | assert hasattr(result, 'content') 58 | assert len(result.content) > 0 59 | assert "8" in result.content[0].text 60 | 61 | # Test 2: Create proxy from client (new way) 62 | proxy = FastMCP.as_proxy(client) 63 | assert proxy is not None 64 | 65 | # Test 3: Mount proxy to another server 66 | magg = FastMCP("magg-test") 67 | 68 | # Define a local tool 69 | @magg.tool() 70 | def local_tool() -> str: 71 | """A local tool in Magg.""" 72 | return "Local response" 73 | 74 | # Mount the proxy 75 | magg.mount(server=proxy, prefix="calc") 76 | 77 | # Verify tools are available via the mount 78 | # Note: We can't directly test the mounted tools without running the server 79 | # but we've verified the mounting process works 80 | assert True # Basic smoke test passed 81 | -------------------------------------------------------------------------------- /magg/util/transports.py: -------------------------------------------------------------------------------- 1 | """Custom transport classes that don't validate script paths. 2 | 3 | These transports pass through script arguments without validation, 4 | letting the underlying command fail if the script doesn't exist. 5 | """ 6 | from fastmcp.client import PythonStdioTransport, StdioTransport, NodeStdioTransport 7 | 8 | __all__ = "NoValidatePythonStdioTransport", "NoValidateNodeStdioTransport" 9 | 10 | 11 | class NoValidatePythonStdioTransport(PythonStdioTransport): 12 | """Python transport that doesn't validate script paths.""" 13 | 14 | def __init__( 15 | self, 16 | script_path: str, 17 | args: list[str] | None = None, 18 | python_cmd: str = "python", 19 | env: dict[str, str] | None = None, 20 | cwd: str | None = None, 21 | keep_alive: bool = True 22 | ): 23 | """Initialize without script validation. 24 | 25 | Args: 26 | script_path: Script path or module argument (e.g., "-m", "script.py") 27 | args: Additional arguments 28 | python_cmd: Python command to use 29 | env: Environment variables 30 | cwd: Working directory 31 | keep_alive: Whether to keep process alive 32 | """ 33 | full_args = [script_path] if script_path else [] 34 | if args: 35 | full_args.extend(args) 36 | 37 | StdioTransport.__init__( 38 | self, 39 | command=python_cmd, 40 | args=full_args, 41 | env=env, 42 | cwd=cwd, 43 | keep_alive=keep_alive 44 | ) 45 | 46 | 47 | class NoValidateNodeStdioTransport(NodeStdioTransport): 48 | """Node.js transport that doesn't validate script paths.""" 49 | 50 | def __init__( 51 | self, 52 | script_path: str, 53 | args: list[str] | None = None, 54 | node_cmd: str = "node", 55 | env: dict[str, str] | None = None, 56 | cwd: str | None = None, 57 | keep_alive: bool = True 58 | ): 59 | """Initialize without script validation. 60 | 61 | Args: 62 | script_path: Script path or other node argument 63 | args: Additional arguments 64 | node_cmd: Node command to use 65 | env: Environment variables 66 | cwd: Working directory 67 | keep_alive: Whether to keep process alive 68 | """ 69 | full_args = [script_path] if script_path else [] 70 | if args: 71 | full_args.extend(args) 72 | 73 | StdioTransport.__init__( 74 | self, 75 | command=node_cmd, 76 | args=full_args, 77 | env=env, 78 | cwd=cwd, 79 | keep_alive=keep_alive 80 | ) 81 | -------------------------------------------------------------------------------- /scripts/validate_manual_release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Validate version for the "Manual Publish" workflow. 3 | 4 | This script checks if the current version of the package is suitable for manual release. 5 | - It should only allow pre-releases, post-releases, or dev releases. 6 | - It will output a JSON object with the validation result. 7 | - If the version is a regular release, it will exit with an error. 8 | - If the version is suitable, it will exit with success. 9 | - The script is intended to be run in a GitHub Actions workflow. 10 | - It uses the `importlib.metadata` module to get the package version. 11 | """ 12 | import json 13 | import sys 14 | from importlib.metadata import version as get_version 15 | 16 | from packaging.version import Version 17 | 18 | 19 | def validate_version(): 20 | """Validate that the current version is suitable for manual release.""" 21 | try: 22 | version_str = get_version("magg") 23 | version = Version(version_str) 24 | except Exception as e: 25 | result = { 26 | "valid": False, 27 | "version": "unknown", 28 | "error": str(e), 29 | "message": f"Failed to get version: {e}" 30 | } 31 | print(json.dumps(result)) 32 | sys.exit(1) 33 | 34 | # Check if it's a pre-release, post-release, or dev release 35 | is_prerelease = version.is_prerelease 36 | is_postrelease = version.is_postrelease 37 | is_devrelease = version.is_devrelease 38 | 39 | # Manual release should only handle pre/post/dev releases 40 | if not (is_prerelease or is_postrelease or is_devrelease): 41 | result = { 42 | "valid": False, 43 | "version": version_str, 44 | "error": "regular_release", 45 | "message": f"Version {version_str} is a regular release. Manual publish only supports pre-releases, post-releases, or dev releases.", 46 | "is_prerelease": False, 47 | "is_postrelease": False, 48 | "is_devrelease": False 49 | } 50 | print(json.dumps(result)) 51 | sys.exit(1) 52 | 53 | # Build result 54 | types = [] 55 | if is_prerelease: 56 | types.append("pre-release") 57 | if is_postrelease: 58 | types.append("post-release") 59 | if is_devrelease: 60 | types.append("dev release") 61 | 62 | result = { 63 | "valid": True, 64 | "version": version_str, 65 | "message": f"Valid {' + '.join(types)}: {version_str}", 66 | "is_prerelease": is_prerelease, 67 | "is_postrelease": is_postrelease, 68 | "is_devrelease": is_devrelease, 69 | "types": types 70 | } 71 | 72 | print(json.dumps(result)) 73 | sys.exit(0) 74 | 75 | 76 | if __name__ == "__main__": 77 | validate_version() 78 | -------------------------------------------------------------------------------- /scripts/fix_whitespace.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Fix trailing whitespace and ensure newline at end of files. 3 | 4 | Recursively finds Python files in the current directory, excluding 5 | hidden/dotted folders and __pycache__ directories. 6 | """ 7 | import sys 8 | from pathlib import Path 9 | 10 | 11 | def fix_file(filepath): 12 | """Fix trailing whitespace and ensure newline at end of file. 13 | """ 14 | try: 15 | with open(filepath, 'r', encoding='utf-8') as f: 16 | lines = f.readlines() 17 | except (UnicodeDecodeError, PermissionError) as e: 18 | print(f"Skipping {filepath}: {e}") 19 | return False 20 | 21 | if not lines: 22 | return False 23 | 24 | changed = False 25 | 26 | # Fix trailing whitespace on each line 27 | for i, line in enumerate(lines): 28 | cleaned = line.rstrip() 29 | if line != cleaned + '\n' and line != cleaned: 30 | lines[i] = cleaned + '\n' if cleaned or i < len(lines) - 1 else cleaned 31 | changed = True 32 | 33 | # Ensure file ends with newline 34 | if lines and not lines[-1].endswith('\n'): 35 | lines[-1] += '\n' 36 | changed = True 37 | 38 | if changed: 39 | with open(filepath, 'w', encoding='utf-8') as f: 40 | f.writelines(lines) 41 | return True 42 | 43 | return False 44 | 45 | 46 | def find_python_files(root_dir='.'): 47 | """Find all Python files recursively, excluding hidden and cache directories. 48 | """ 49 | root_path = Path(root_dir).resolve() 50 | 51 | for path in root_path.rglob('*.py'): 52 | # Skip if any parent directory starts with '.' or is __pycache__ 53 | skip = False 54 | for parent in path.relative_to(root_path).parents: 55 | if parent.name.startswith('.') or parent.name == '__pycache__': 56 | skip = True 57 | break 58 | 59 | # Also check the file itself 60 | if path.name.startswith('.') or path.parent.name == '__pycache__': 61 | skip = True 62 | 63 | if not skip: 64 | yield path 65 | 66 | 67 | def main(): 68 | """Process all Python files in current directory tree.""" 69 | # Determine root directory 70 | if len(sys.argv) > 1: 71 | root_dir = sys.argv[1] 72 | else: 73 | root_dir = '.' 74 | 75 | fixed_files = [] 76 | total_files = 0 77 | 78 | print(f"Scanning for Python files in {Path(root_dir).resolve()}...") 79 | 80 | for filepath in find_python_files(root_dir): 81 | total_files += 1 82 | if fix_file(filepath): 83 | fixed_files.append(filepath) 84 | 85 | print(f"\nScanned {total_files} Python files.") 86 | 87 | if fixed_files: 88 | print(f"Fixed {len(fixed_files)} files:") 89 | for f in sorted(fixed_files): 90 | print(f" {f}") 91 | else: 92 | print("No files needed fixing") 93 | 94 | 95 | if __name__ == "__main__": 96 | main() 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "magg" 3 | version = "0.10.1" 4 | requires-python = ">=3.12" 5 | description = "MCP Aggregator" 6 | authors = [{ name = "Phillip Sitbon", email = "phillip.sitbon@gmail.com"}] 7 | readme = "readme.md" 8 | license = {text = "AGPL-3.0-or-later"} 9 | license-files = ["license.md"] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Environment :: Web Environment", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", 15 | "Natural Language :: English", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | "Programming Language :: Python :: Implementation :: CPython", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Topic :: Internet :: Proxy Servers", 24 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 25 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 26 | ] 27 | keywords = [ 28 | "model", 29 | "context", 30 | "protocol", 31 | "ai", 32 | "agent", 33 | "mcp", 34 | "aggregator", 35 | "proxy", 36 | "fastmcp", 37 | "aiohttp", 38 | "pydantic", 39 | "pydantic-settings", 40 | "rich", 41 | ] 42 | 43 | packages = [ 44 | {include = "magg"}, 45 | {include = "test", format = "sdist"}, 46 | ] 47 | 48 | include = [ 49 | "readme.md", 50 | "license.md", 51 | "compose.yaml", 52 | "dockerfile", 53 | ".dockerignore", 54 | ".env.example", 55 | ] 56 | 57 | dependencies = [ 58 | "fastmcp>=2.8.0", 59 | "aiohttp>=3.12.13", 60 | "pydantic>=2.11.7", 61 | "pydantic-settings>=2.10.0", 62 | "rich>=14.0.0", 63 | "prompt-toolkit>=3.0.51", 64 | "cryptography>=45.0.4", 65 | "pyjwt>=2.10.1", 66 | "watchdog>=6.0.0", 67 | "art>=6.5", 68 | ] 69 | 70 | [project.urls] 71 | Homepage = "https://github.com/sitbon/magg" 72 | Repository = "https://github.com/sitbon/magg.git" 73 | Documentation = "https://github.com/sitbon/magg#readme" 74 | Issues = "https://github.com/sitbon/magg/issues" 75 | "Release Notes" = "https://github.com/sitbon/magg/releases" 76 | 77 | [project.scripts] 78 | magg = "magg.cli:main" 79 | mbro = "magg.mbro.cli:main" 80 | 81 | [project.optional-dependencies] 82 | test = [ 83 | "pytest>=8.4.0", 84 | "pytest-asyncio>=1.0.0", 85 | ] 86 | 87 | [dependency-groups] 88 | dev = [ 89 | "pytest>=8.4.0", 90 | "pytest-asyncio>=1.0.0", 91 | "keyring>=25.6.0", 92 | "anthropic>=0.54.0", 93 | "pyjwt>=2.10.1", 94 | "packaging>=25.0", 95 | ] 96 | 97 | [tool.pytest.ini_options] 98 | markers = [ 99 | "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", 100 | ] 101 | 102 | [build-system] 103 | requires = ["hatchling"] 104 | build-backend = "hatchling.build" 105 | -------------------------------------------------------------------------------- /magg/util/uri.py: -------------------------------------------------------------------------------- 1 | """URI utilities for Magg - handles URI parsing and directory extraction.""" 2 | 3 | from pathlib import Path 4 | from urllib.parse import urlparse, unquote 5 | import os 6 | 7 | from .system import get_project_root, is_subdirectory 8 | 9 | __all__ = "extract_directory_from_uri", "validate_working_directory" 10 | 11 | 12 | def extract_directory_from_uri(uri: str) -> Path | None: 13 | """Extract a directory path from a URI. 14 | 15 | Handles: 16 | - file:// URIs -> direct path 17 | - GitHub URIs -> None (remote) 18 | - HTTP/HTTPS URIs -> None (remote) 19 | - Plain paths -> treat as file path 20 | 21 | Returns: 22 | Path object if local directory can be determined, None otherwise 23 | """ 24 | # Parse the URI 25 | parsed = urlparse(uri) 26 | 27 | if parsed.scheme == 'file': 28 | # file:// URI - extract the path 29 | path = unquote(parsed.path) 30 | # Remove leading slash on Windows 31 | if os.name == 'nt' and path.startswith('/') and len(path) > 2 and path[2] == ':': 32 | path = path[1:] 33 | return Path(path) 34 | elif parsed.scheme in ('http', 'https', 'git', 'ssh'): 35 | # Remote URI - no local directory 36 | return None 37 | elif not parsed.scheme: 38 | # No scheme - treat as local path 39 | return Path(uri) 40 | else: 41 | # Unknown scheme 42 | return None 43 | 44 | 45 | def validate_working_directory(cwd: Path | str | None, source_uri: str | None) -> tuple[Path | None, str | None]: 46 | """Validate and normalize working directory for a server. 47 | 48 | Args: 49 | cwd: Proposed working directory (or None) 50 | source_uri: URI of the source (or None) 51 | 52 | Returns: 53 | Tuple of (normalized_path or None, error_message or None) 54 | """ 55 | if cwd is None: 56 | # No validation needed if no cwd provided 57 | return None, None 58 | 59 | project_root = get_project_root() 60 | 61 | # Normalize the provided path 62 | cwd = Path(cwd) 63 | 64 | # Make absolute if relative 65 | if not cwd.is_absolute(): 66 | cwd = project_root / cwd 67 | 68 | # Resolve to canonical path 69 | try: 70 | cwd = cwd.resolve() 71 | except (OSError, RuntimeError): 72 | return None, f"Invalid working directory: {cwd}" 73 | 74 | # Check that it exists 75 | if not cwd.exists(): 76 | return None, f"Working directory does not exist: {cwd}" 77 | 78 | if not cwd.is_dir(): 79 | return None, f"Working directory is not a directory: {cwd}" 80 | 81 | # Check that it's not the exact project root 82 | if cwd == project_root: 83 | return None, "Working directory cannot be the project root" 84 | 85 | # If source has a local directory, validate relationship 86 | if source_uri: 87 | source_dir = extract_directory_from_uri(source_uri) 88 | if source_dir is not None: 89 | source_dir_abs = source_dir.resolve() 90 | if not is_subdirectory(cwd, source_dir_abs): 91 | return None, f"Working directory must be within source directory: {source_dir_abs}" 92 | 93 | return cwd, None 94 | -------------------------------------------------------------------------------- /magg/discovery/catalog.py: -------------------------------------------------------------------------------- 1 | """Simplified tool catalog for search functionality only.""" 2 | 3 | import json 4 | import logging 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from .search import ToolSearchEngine, ToolSearchResult, ToolCatalog 9 | 10 | 11 | class CatalogManager: 12 | """Manages tool search catalog - search functionality only.""" 13 | 14 | def __init__(self, catalog_path: Path | None = None): 15 | self.catalog_path = catalog_path or Path.cwd() / ".magg" / "search_cache.json" 16 | self.catalog_path.parent.mkdir(parents=True, exist_ok=True) 17 | 18 | self.search_catalog = ToolCatalog() 19 | self.logger = logging.getLogger(__name__) 20 | 21 | self.load_search_cache() 22 | 23 | def load_search_cache(self) -> None: 24 | """Load search cache from disk.""" 25 | if not self.catalog_path.exists(): 26 | return 27 | 28 | try: 29 | with open(self.catalog_path, 'r') as f: 30 | data = json.load(f) 31 | 32 | if "search_catalog" in data: 33 | self.search_catalog.import_catalog(data["search_catalog"]) 34 | 35 | except Exception as e: 36 | self.logger.error("Error loading search cache: %s", e) 37 | 38 | def save_search_cache(self) -> None: 39 | """Save search cache to disk.""" 40 | try: 41 | data = { 42 | "search_catalog": self.search_catalog.export_catalog() 43 | } 44 | 45 | with open(self.catalog_path, 'w') as f: 46 | json.dump(data, f, indent=2) 47 | 48 | except Exception as e: 49 | self.logger.error("Error saving search cache: %s", e) 50 | 51 | @classmethod 52 | async def search_only(cls, query: str, limit_per_source: int = 5) -> dict[str, list[ToolSearchResult]]: 53 | """Search for tools without auto-adding to cache.""" 54 | async with ToolSearchEngine() as search_engine: 55 | results = await search_engine.search_all(query, limit_per_source) 56 | return results 57 | 58 | async def search_and_cache(self, query: str, limit_per_source: int = 5) -> dict[str, list[ToolSearchResult]]: 59 | """Search for tools and update the cache.""" 60 | async with ToolSearchEngine() as search_engine: 61 | results = await search_engine.search_all(query, limit_per_source) 62 | 63 | for source_results in results.values(): 64 | self.search_catalog.add_results(source_results) 65 | 66 | self.save_search_cache() 67 | 68 | return results 69 | 70 | def search_local_cache(self, query: str) -> list[ToolSearchResult]: 71 | """Search the local cache.""" 72 | return self.search_catalog.search_catalog(query) 73 | 74 | def get_search_stats(self) -> dict[str, Any]: 75 | """Get statistics about the search cache.""" 76 | total_cached = len(self.search_catalog.catalog) 77 | 78 | source_counts = {} 79 | for result in self.search_catalog.catalog.values(): 80 | source_counts[result.source] = source_counts.get(result.source, 0) + 1 81 | 82 | return { 83 | "total_cached": total_cached, 84 | "source_breakdown": source_counts, 85 | "cache_path": str(self.catalog_path) 86 | } 87 | -------------------------------------------------------------------------------- /test/magg/test_prefix_handling.py: -------------------------------------------------------------------------------- 1 | """Test prefix and separator handling.""" 2 | 3 | import pytest 4 | import os 5 | from magg.settings import MaggConfig, ServerConfig 6 | from magg.server.server import MaggServer 7 | from fastmcp import Client 8 | 9 | 10 | class TestPrefixHandling: 11 | """Test custom prefix and separator handling.""" 12 | 13 | def test_prefix_separator_on_magg_config(self): 14 | """Test that prefix_sep is a configurable field on MaggConfig.""" 15 | config = MaggConfig() 16 | assert hasattr(config, 'prefix_sep') 17 | assert config.prefix_sep == "_" # Default value 18 | 19 | # Test it can be configured 20 | config2 = MaggConfig(prefix_sep="-") 21 | assert config2.prefix_sep == "-" 22 | 23 | def test_server_config_uses_separator(self): 24 | """Test that ServerConfig validation uses the separator.""" 25 | # Valid prefix 26 | server = ServerConfig(name="test", source="test", prefix="myprefix") 27 | assert server.prefix == "myprefix" 28 | 29 | # Invalid prefix with underscore 30 | with pytest.raises(ValueError, match="cannot contain underscores"): 31 | ServerConfig(name="test", source="test", prefix="my_prefix") 32 | 33 | @pytest.mark.asyncio 34 | async def test_custom_self_prefix(self, tmp_path, monkeypatch): 35 | """Test that custom self_prefix is used correctly.""" 36 | # Set custom prefix via environment 37 | monkeypatch.setenv("MAGG_SELF_PREFIX", "myapp") 38 | 39 | config_path = tmp_path / "config.json" 40 | server = MaggServer(config_path, enable_config_reload=False) 41 | 42 | # Check configuration 43 | assert server.self_prefix == "myapp" 44 | assert server.self_prefix_ == "myapp_" 45 | 46 | await server.setup() 47 | 48 | # Verify tools have the correct prefix 49 | async with Client(server.mcp) as client: 50 | tools = await client.list_tools() 51 | tool_names = [tool.name for tool in tools] 52 | 53 | # All Magg tools should have myapp_ prefix 54 | myapp_tools = [t for t in tool_names if t.startswith("myapp_")] 55 | assert len(myapp_tools) > 0 56 | assert "myapp_list_servers" in tool_names 57 | assert "myapp_add_server" in tool_names 58 | assert "myapp_status" in tool_names 59 | 60 | # Should not have any magg_ tools 61 | magg_tools = [t for t in tool_names if t.startswith("magg_")] 62 | assert len(magg_tools) == 0 63 | 64 | def test_prefix_separator_consistency(self): 65 | """Test that prefix separator is used consistently.""" 66 | # Create a server with default Magg config 67 | config = MaggConfig() 68 | assert config.self_prefix == "magg" 69 | assert config.prefix_sep == "_" 70 | 71 | # Server config validation should use the separator 72 | server1 = ServerConfig(name="test1", source="test", prefix="myprefix") 73 | assert server1.prefix == "myprefix" 74 | 75 | # Empty prefix is allowed 76 | server2 = ServerConfig(name="test2", source="test", prefix="") 77 | assert server2.prefix == "" 78 | 79 | # None prefix is allowed 80 | server3 = ServerConfig(name="test3", source="test", prefix=None) 81 | assert server3.prefix is None 82 | -------------------------------------------------------------------------------- /test/magg/test_mounting_debug.py: -------------------------------------------------------------------------------- 1 | """Debug test for FastMCP mounting.""" 2 | 3 | import asyncio 4 | import pytest 5 | import tempfile 6 | from pathlib import Path 7 | 8 | from fastmcp import FastMCP, Client 9 | from magg.util.transports import NoValidatePythonStdioTransport 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_mounting_debug(): 14 | """Debug mounting behavior.""" 15 | 16 | with tempfile.TemporaryDirectory() as tmpdir: 17 | # Create a simple server 18 | server_code = ''' 19 | from fastmcp import FastMCP 20 | 21 | mcp = FastMCP("test") 22 | 23 | @mcp.tool 24 | def hello(name: str = "World") -> str: 25 | """Say hello.""" 26 | return f"Hello, {name}!" 27 | 28 | if __name__ == "__main__": 29 | mcp.run() 30 | ''' 31 | server_file = Path(tmpdir) / "server.py" 32 | server_file.write_text(server_code) 33 | 34 | # Create main server 35 | main = FastMCP("main") 36 | 37 | # Add a local tool to main 38 | @main.tool 39 | def main_tool() -> str: 40 | """A tool on the main server.""" 41 | return "This is the main server" 42 | 43 | # Create client and proxy 44 | transport = NoValidatePythonStdioTransport( 45 | script_path=str(server_file), 46 | cwd=tmpdir 47 | ) 48 | client = Client(transport) 49 | proxy = FastMCP.as_proxy(client) 50 | 51 | print(f"Created proxy: {type(proxy)}") 52 | print(f"Proxy has _has_lifespan: {hasattr(proxy, '_has_lifespan')}") 53 | 54 | # Mount the proxy 55 | main.mount(server=proxy, prefix="test") 56 | print("Mounted proxy with prefix 'test'") 57 | 58 | # Check what tools are available 59 | print("\nChecking tools on main server:") 60 | 61 | # Method 1: Check _tool_manager 62 | if hasattr(main, '_tool_manager'): 63 | tool_manager = main._tool_manager 64 | if hasattr(tool_manager, '_tools'): 65 | tools = tool_manager._tools 66 | print(f" _tool_manager._tools: {list(tools.keys())}") 67 | 68 | # Also check for mounted tools 69 | if hasattr(tool_manager, 'tools'): 70 | print(f" _tool_manager.tools: {list(tool_manager.tools.keys())}") 71 | 72 | # Method 2: Test if proxy is working 73 | print("\nTesting proxy directly:") 74 | if hasattr(proxy, '_client'): 75 | print(" Proxy has _client") 76 | # The proxy should handle the client connection 77 | 78 | # Method 3: Check server state after mounting 79 | print("\nChecking mounted servers:") 80 | if hasattr(main, '_mounted_servers'): 81 | print(f" _mounted_servers: {list(main._mounted_servers.keys())}") 82 | 83 | # Method 4: Try to access tools through tool manager 84 | print("\nLooking for prefixed tools:") 85 | if hasattr(main, '_tool_manager') and hasattr(main._tool_manager, '_tools'): 86 | all_tools = main._tool_manager._tools 87 | prefixed_tools = [name for name in all_tools if name.startswith('test')] 88 | print(f" Prefixed tools: {prefixed_tools}") 89 | 90 | # Method 5: Test with the actual server running 91 | print("\nTesting with server run:") 92 | # We can't easily test the full flow here since it requires running the server 93 | # But we can check if the mounting was registered 94 | 95 | print("\nMounting appears to have succeeded, but tools may not be available until server is run.") 96 | 97 | 98 | if __name__ == "__main__": 99 | asyncio.run(test_mounting_debug()) 100 | -------------------------------------------------------------------------------- /test/magg/test_mounting_real.py: -------------------------------------------------------------------------------- 1 | """Test real server mounting with FastMCP.""" 2 | 3 | import pytest 4 | import asyncio 5 | import tempfile 6 | from pathlib import Path 7 | 8 | from fastmcp import FastMCP, Client 9 | 10 | 11 | class TestRealMounting: 12 | """Test mounting real servers using FastMCP.""" 13 | 14 | @pytest.mark.asyncio 15 | async def test_mount_python_server(self): 16 | """Test mounting a real Python MCP server.""" 17 | # Create a temporary directory for our test server 18 | with tempfile.TemporaryDirectory() as tmpdir: 19 | # Create a simple MCP server 20 | server_code = ''' 21 | from fastmcp import FastMCP 22 | 23 | mcp = FastMCP("test-server") 24 | 25 | @mcp.tool 26 | def test_tool(message: str) -> str: 27 | """A test tool.""" 28 | return f"Test server says: {message}" 29 | 30 | if __name__ == "__main__": 31 | mcp.run() 32 | ''' 33 | server_file = Path(tmpdir) / "server.py" 34 | server_file.write_text(server_code) 35 | 36 | # Create the main Magg server 37 | main_server = FastMCP("test-magg") 38 | 39 | # Try different approaches to mount the server 40 | 41 | # Import the custom transport 42 | from magg.util.transports import NoValidatePythonStdioTransport 43 | 44 | # Approach 1: Using Client with custom transport 45 | try: 46 | transport = NoValidatePythonStdioTransport( 47 | script_path=str(server_file), 48 | cwd=tmpdir 49 | ) 50 | client = Client(transport) 51 | # Try to mount the client directly 52 | main_server.mount(server=client, prefix="test1") 53 | print("✓ Direct client mount succeeded") 54 | except Exception as e: 55 | print(f"✗ Direct client mount failed: {e}") 56 | 57 | # Approach 2: Try with proxy flag (deprecated approach - remove this test) 58 | # This approach is deprecated and causes warnings 59 | # Keeping as commented documentation of what NOT to do 60 | # try: 61 | # transport = NoValidatePythonStdioTransport( 62 | # script_path=str(server_file), 63 | # cwd=tmpdir 64 | # ) 65 | # client = Client(transport) 66 | # main_server.mount(server=client, prefix="test2", as_proxy=True) 67 | # print("✓ Client mount with as_proxy=True succeeded") 68 | # except Exception as e: 69 | # print(f"✗ Client mount with as_proxy=True failed: {e}") 70 | 71 | # Approach 3: Try as_proxy (new way) 72 | try: 73 | transport = NoValidatePythonStdioTransport( 74 | script_path=str(server_file), 75 | cwd=tmpdir 76 | ) 77 | client = Client(transport) 78 | proxy = FastMCP.as_proxy(client) 79 | main_server.mount(server=proxy, prefix="test3") 80 | print("✓ as_proxy mount succeeded") 81 | except Exception as e: 82 | print(f"✗ as_proxy mount failed: {e}") 83 | 84 | # Get tool names through client 85 | print("\nAvailable tools on main server:") 86 | # List tools through the FastMCP client 87 | async with Client(main_server) as client: 88 | tools = await client.list_tools() 89 | for tool in tools: 90 | print(f" - {tool.name}") 91 | 92 | 93 | if __name__ == "__main__": 94 | # Run the test directly 95 | test = TestRealMounting() 96 | asyncio.run(test.test_mount_python_server()) 97 | -------------------------------------------------------------------------------- /test/magg/test_status_check.py: -------------------------------------------------------------------------------- 1 | """Test the new status and check tools.""" 2 | 3 | import pytest 4 | from fastmcp import Client 5 | 6 | from magg.server.server import MaggServer 7 | 8 | 9 | class TestStatusAndCheckTools: 10 | """Test the status and check tools.""" 11 | 12 | @pytest.mark.asyncio 13 | async def test_status_tool(self, tmp_path): 14 | """Test the magg_status tool returns proper statistics.""" 15 | config_path = tmp_path / "config.json" 16 | server = MaggServer(config_path) 17 | await server.setup() 18 | 19 | async with Client(server.mcp) as client: 20 | result = await client.call_tool("magg_status", {}) 21 | assert hasattr(result, 'content') 22 | assert len(result.content) > 0 23 | 24 | # Parse the JSON response 25 | import json 26 | response = json.loads(result.content[0].text) 27 | 28 | # Extract the output from MaggResponse 29 | assert "output" in response 30 | data = response["output"] 31 | 32 | # Check the structure 33 | assert "servers" in data 34 | assert "tools" in data 35 | assert "prefixes" in data 36 | 37 | # Check server stats 38 | assert "total" in data["servers"] 39 | assert "enabled" in data["servers"] 40 | assert "mounted" in data["servers"] 41 | assert "disabled" in data["servers"] 42 | 43 | # Check tool stats 44 | assert "total" in data["tools"] 45 | 46 | assert data["servers"]["total"] == 0 47 | assert data["tools"]["total"] >= 11 # Magg tools + proxy 48 | 49 | @pytest.mark.asyncio 50 | async def test_check_tool_report_mode(self, tmp_path): 51 | """Test the magg_check tool in report mode.""" 52 | config_path = tmp_path / "config.json" 53 | server = MaggServer(config_path) 54 | await server.setup() 55 | 56 | async with Client(server.mcp) as client: 57 | # Test with no servers - should work 58 | result = await client.call_tool("magg_check", {"action": "report"}) 59 | assert hasattr(result, 'content') 60 | assert len(result.content) > 0 61 | 62 | # Parse the JSON response 63 | import json 64 | response = json.loads(result.content[0].text) 65 | 66 | # Extract the output from MaggResponse 67 | assert "output" in response 68 | data = response["output"] 69 | 70 | # Check the structure 71 | assert "servers_checked" in data 72 | assert "healthy" in data 73 | assert "unresponsive" in data 74 | assert "results" in data 75 | 76 | # With no servers 77 | assert data["servers_checked"] == 0 78 | assert data["healthy"] == 0 79 | assert data["unresponsive"] == 0 80 | 81 | @pytest.mark.asyncio 82 | async def test_check_tool_with_timeout(self, tmp_path): 83 | """Test the magg_check tool with custom timeout.""" 84 | config_path = tmp_path / "config.json" 85 | server = MaggServer(config_path) 86 | await server.setup() 87 | 88 | async with Client(server.mcp) as client: 89 | # Test with custom timeout 90 | result = await client.call_tool("magg_check", { 91 | "action": "report", 92 | "timeout": 2.0 93 | }) 94 | assert hasattr(result, 'content') 95 | assert len(result.content) > 0 96 | 97 | # Should still work with no servers 98 | import json 99 | response = json.loads(result.content[0].text) 100 | data = response["output"] 101 | assert data["servers_checked"] == 0 102 | -------------------------------------------------------------------------------- /examples/config_reload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Demo script to demonstrate config reloading functionality.""" 3 | import asyncio 4 | import json 5 | import os 6 | import signal 7 | import sys 8 | from pathlib import Path 9 | import logging 10 | 11 | # Add magg to path 12 | sys.path.insert(0, str(Path(__file__).parent.parent)) 13 | 14 | from magg.server.server.server import MaggServer 15 | from magg.server.runner import MaggRunner 16 | from magg import process 17 | 18 | process.setup(MAGG_LOG_LEVEL="INFO") 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | async def demo_config_reload(): 23 | """Demonstrate config reloading with file watching.""" 24 | config_path = Path(".magg") / "config.json" 25 | 26 | logger.setLevel(logging.INFO) 27 | logger.info( 28 | """Starting Magg server with config reloading enabled 29 | Config path: %s 30 | You can: 31 | 1. Modify the config file to see automatic reload 32 | 2. Send SIGHUP signal to trigger reload: kill -HUP %d 33 | 3. Use the magg_reload_config tool via MCP client 34 | 35 | Press Ctrl+C to stop""", 36 | config_path, os.getpid(), 37 | ) 38 | 39 | runner = MaggRunner(config_path) 40 | 41 | try: 42 | await runner.run_http("localhost", 8000) 43 | except KeyboardInterrupt: 44 | logger.info("Shutting down...") 45 | 46 | 47 | async def demo_manual_reload(): 48 | """Demonstrate manual config reload.""" 49 | config_path = Path.home() / ".magg" / "config.json" 50 | 51 | logger.info("Demonstrating manual config reload") 52 | 53 | server = MaggServer(str(config_path), enable_config_reload=False) 54 | async with server: 55 | # Show current servers 56 | logger.info("Current servers:") 57 | for name, srv in server.config.servers.items(): 58 | logger.info(" - %s (%s)", name, "enabled" if srv.enabled else "disabled") 59 | 60 | # Simulate config change 61 | logger.info("\nSimulating config file change...") 62 | if config_path.exists(): 63 | # Load current config 64 | with open(config_path) as f: 65 | config_data = json.load(f) 66 | 67 | # Add a demo server 68 | config_data["servers"]["demo-server"] = { 69 | "source": "https://example.com/demo", 70 | "command": "echo", 71 | "args": ["Demo server"], 72 | "enabled": True 73 | } 74 | 75 | # Save modified config 76 | with open(config_path, "w") as f: 77 | json.dump(config_data, f, indent=2) 78 | 79 | logger.info("Added 'demo-server' to config") 80 | 81 | # Trigger manual reload 82 | logger.info("\nTriggering manual reload...") 83 | success = await server.reload_config() 84 | 85 | if success: 86 | logger.info("Reload successful!") 87 | logger.info("\nServers after reload:") 88 | for name, srv in server.config.servers.items(): 89 | logger.info(" - %s (%s)", name, "enabled" if srv.enabled else "disabled") 90 | else: 91 | logger.error("Reload failed!") 92 | 93 | # Clean up demo server 94 | if "demo-server" in config_data["servers"]: 95 | del config_data["servers"]["demo-server"] 96 | with open(config_path, "w") as f: 97 | json.dump(config_data, f, indent=2) 98 | logger.info("\nCleaned up demo-server from config") 99 | 100 | 101 | def main(): 102 | """Main entry point.""" 103 | import argparse 104 | 105 | parser = argparse.ArgumentParser(description="Demo config reloading") 106 | parser.add_argument( 107 | "--mode", 108 | choices=["auto", "manual"], 109 | default="auto", 110 | help="Demo mode: auto (file watching + SIGHUP) or manual" 111 | ) 112 | 113 | args = parser.parse_args() 114 | 115 | if args.mode == "auto": 116 | asyncio.run(demo_config_reload()) 117 | else: 118 | asyncio.run(demo_manual_reload()) 119 | 120 | 121 | if __name__ == "__main__": 122 | main() 123 | -------------------------------------------------------------------------------- /examples/embedding.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Demo of embedding Magg server in applications. 3 | 4 | This example shows how to run Magg server programmatically and 5 | connect to it using the in-memory client. 6 | """ 7 | import asyncio 8 | import logging 9 | import os 10 | import sys 11 | 12 | from magg.server.runner import MaggRunner 13 | 14 | # Suppress noisy logs for cleaner demo output 15 | logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) 16 | logging.getLogger("mcp.server.streamable_http").setLevel(logging.CRITICAL) 17 | logging.getLogger("mcp.client.streamable_http").setLevel(logging.CRITICAL) 18 | 19 | 20 | async def in_memory_client_example(): 21 | """Example using in-memory client to connect to Magg.""" 22 | print("=== In-Memory Client Example ===") 23 | 24 | # Create runner (uses config from ./.magg/config.json if available) 25 | async with MaggRunner() as runner: 26 | # The `async with` simply ensures that the MaggServer is properly set up even when not running as a server. 27 | 28 | # Access the in-memory client 29 | client = runner.client 30 | print(f"Client created: {client}") 31 | 32 | # Use the client to interact with Magg 33 | async with client: 34 | tools = await client.list_tools() 35 | print(f"\nFound {len(tools)} tools:") 36 | 37 | # Show first 5 tools 38 | for tool in tools[:5]: 39 | print(f" - {tool.name}: {tool.description}") 40 | 41 | if len(tools) > 5: 42 | print(f" ... and {len(tools) - 5} more") 43 | 44 | # Call a tool 45 | result = await client.call_tool("magg_list_servers", {}) 46 | print(f"\nServers: {result}") 47 | 48 | print("\n") 49 | 50 | 51 | async def run_http_server(): 52 | """Example of running Magg as an HTTP server.""" 53 | print("=== HTTP Server Example ===") 54 | print("Starting Magg HTTP server on http://localhost:8080") 55 | print("Press Ctrl+C to stop\n") 56 | 57 | runner = MaggRunner() 58 | 59 | # This will run until interrupted 60 | try: 61 | await runner.run_http(host="localhost", port=8080) 62 | except KeyboardInterrupt: 63 | print("\nServer stopped.") 64 | 65 | 66 | async def concurrent_server_and_client(): 67 | """Example of running server and using client concurrently.""" 68 | print("=== Concurrent Server & Client Example ===") 69 | 70 | runner = MaggRunner() 71 | 72 | stderr_prev = sys.stderr 73 | 74 | try: 75 | print("Redirecting stderr to /dev/null temporarily to suppress annoying asyncio.CancelledError messages") 76 | sys.stderr = open(os.devnull, 'w') 77 | 78 | async with runner: 79 | # Start HTTP server in background 80 | server_task = await runner.start_http(port=8081) 81 | 82 | # Give server time to start 83 | await asyncio.sleep(1) 84 | 85 | # Use the in-memory client 86 | async with runner.client as session: 87 | print("Connected to Magg server") 88 | 89 | # List and call tools 90 | tools = await session.list_tools() 91 | print(f"Available tools: {len(tools)}") 92 | 93 | # Try to call a tool 94 | try: 95 | result = await session.call_tool("magg_list_servers", {}) 96 | print("Successfully called magg_list_servers") 97 | except Exception as e: 98 | print(f"Tool call error: {e}") 99 | 100 | # Cancel server task 101 | server_task.cancel() 102 | try: 103 | await server_task 104 | except asyncio.CancelledError: 105 | pass 106 | 107 | finally: 108 | # sys.stderr.close() 109 | sys.stderr = stderr_prev 110 | 111 | print("Done\n") 112 | 113 | 114 | async def main(): 115 | """Run examples.""" 116 | # In-memory client example 117 | await in_memory_client_example() 118 | 119 | # Concurrent example 120 | await concurrent_server_and_client() 121 | 122 | # Uncomment to run HTTP server (blocks until Ctrl+C) 123 | # await run_http_server() 124 | 125 | 126 | if __name__ == "__main__": 127 | print("Magg Server Embedding Examples\n") 128 | asyncio.run(main()) 129 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, discussion, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the readme.md with details of changes to the interface; this includes new environment 13 | variables, exposed ports, useful file locations, and container parameters. 14 | 3. Increase the version numbers in any example files and pyproject.toml to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to temporarily or 61 | permanently ban any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project owner at phillip.sitbon@gmail.com. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /magg/proxy/server.py: -------------------------------------------------------------------------------- 1 | """Magg server ProxyMCP mixin. 2 | """ 3 | import asyncio 4 | import logging 5 | from functools import cached_property 6 | 7 | from fastmcp import FastMCP, Client 8 | from fastmcp.client import FastMCPTransport 9 | from fastmcp.client.messages import MessageHandler 10 | from fastmcp.tools import FunctionTool 11 | import mcp.types 12 | 13 | from .mixin import ProxyMCP 14 | from ..messaging import MessageRouter, ServerMessageCoordinator 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | __all__ = "ProxyFastMCP", "BackendMessageHandler", 19 | 20 | 21 | class BackendMessageHandler(MessageHandler): 22 | """Message handler that forwards notifications from backend servers.""" 23 | 24 | def __init__(self, server_id: str, coordinator: ServerMessageCoordinator): 25 | super().__init__() 26 | self.server_id = server_id 27 | self.coordinator = coordinator 28 | 29 | async def on_tool_list_changed( 30 | self, 31 | notification: mcp.types.ToolListChangedNotification 32 | ) -> None: 33 | """Forward tool list changed notification.""" 34 | await self.coordinator.handle_tool_list_changed(notification, self.server_id) 35 | 36 | async def on_resource_list_changed( 37 | self, 38 | notification: mcp.types.ResourceListChangedNotification 39 | ) -> None: 40 | """Forward resource list changed notification.""" 41 | await self.coordinator.handle_resource_list_changed(notification, self.server_id) 42 | 43 | async def on_prompt_list_changed( 44 | self, 45 | notification: mcp.types.PromptListChangedNotification 46 | ) -> None: 47 | """Forward prompt list changed notification.""" 48 | await self.coordinator.handle_prompt_list_changed(notification, self.server_id) 49 | 50 | async def on_progress( 51 | self, 52 | notification: mcp.types.ProgressNotification 53 | ) -> None: 54 | """Forward progress notification.""" 55 | await self.coordinator.handle_progress(notification, self.server_id) 56 | 57 | async def on_logging_message( 58 | self, 59 | notification: mcp.types.LoggingMessageNotification 60 | ) -> None: 61 | """Forward logging message notification.""" 62 | await self.coordinator.handle_logging_message(notification, self.server_id) 63 | 64 | 65 | class ProxyFastMCP(ProxyMCP, FastMCP): 66 | """FastMCP server with ProxyMCP capabilities and message forwarding.""" 67 | 68 | def __init__(self, *args, **kwargs): 69 | super().__init__(*args, **kwargs) 70 | # Initialize message routing 71 | self._message_router = MessageRouter() 72 | self._message_coordinator = ServerMessageCoordinator(self._message_router) 73 | self._backend_handlers: dict[str, BackendMessageHandler] = {} 74 | 75 | @cached_property 76 | def _proxy_backend_client(self) -> Client: 77 | """Create a client connected to our own FastMCP server. [cached]""" 78 | # Create a client that connects to ourselves using FastMCPTransport 79 | # This allows us to introspect our own capabilities 80 | transport = FastMCPTransport(self) 81 | return Client(transport) 82 | 83 | def _register_proxy_tool(self): 84 | tool = FunctionTool.from_function( 85 | self._proxy_tool, 86 | name=self.PROXY_TOOL_NAME, 87 | serializer=self._tool_serializer, 88 | ) 89 | 90 | self.add_tool(tool) 91 | 92 | async def register_client_message_handler( 93 | self, 94 | handler: MessageHandler, 95 | client_id: str | None = None 96 | ) -> None: 97 | """Register a message handler for client notifications. 98 | 99 | Args: 100 | handler: Message handler to register 101 | client_id: Optional client ID for targeted messaging 102 | """ 103 | await self._message_router.register_handler(handler, client_id) 104 | 105 | async def unregister_client_message_handler( 106 | self, 107 | handler: MessageHandler, 108 | client_id: str | None = None 109 | ) -> None: 110 | """Unregister a client message handler.""" 111 | await self._message_router.unregister_handler(handler, client_id) 112 | 113 | @property 114 | def message_coordinator(self) -> ServerMessageCoordinator: 115 | """Access to the message coordinator for debugging/monitoring.""" 116 | return self._message_coordinator 117 | -------------------------------------------------------------------------------- /test/magg/test_mbro_validator.py: -------------------------------------------------------------------------------- 1 | """Tests for mbro input validator.""" 2 | 3 | import pytest 4 | from unittest.mock import MagicMock 5 | from prompt_toolkit.document import Document 6 | from prompt_toolkit.validation import ValidationError 7 | 8 | from magg.mbro.validator import InputValidator 9 | 10 | 11 | class TestInputValidator: 12 | """Test the InputValidator class.""" 13 | 14 | @pytest.fixture 15 | def validator(self): 16 | """Create a validator instance with a mock CLI.""" 17 | mock_cli = MagicMock() 18 | return InputValidator(mock_cli) 19 | 20 | def test_empty_input_valid(self, validator): 21 | """Test that empty input is valid.""" 22 | doc = Document("") 23 | validator.validate(doc) # Should not raise 24 | 25 | def test_complete_command_valid(self, validator): 26 | """Test that complete commands are valid.""" 27 | # Simple commands 28 | for cmd in ["help", "quit", "tools", "resources"]: 29 | doc = Document(cmd) 30 | validator.validate(doc) # Should not raise 31 | 32 | # Commands with arguments 33 | doc = Document("call my_tool") 34 | validator.validate(doc) # Should not raise 35 | 36 | def test_backslash_continuation(self, validator): 37 | """Test that backslash at end of line needs continuation.""" 38 | assert validator._needs_continuation("some command \\") 39 | assert not validator._needs_continuation("some command") 40 | 41 | def test_unclosed_quotes(self, validator): 42 | """Test detection of unclosed quotes.""" 43 | assert validator._has_unclosed_quotes('hello "world') 44 | assert validator._has_unclosed_quotes("hello 'world") 45 | assert not validator._has_unclosed_quotes('hello "world"') 46 | assert not validator._has_unclosed_quotes("hello 'world'") 47 | 48 | # Test escaped quotes 49 | assert not validator._has_unclosed_quotes('hello "world\\"quote"') 50 | assert validator._has_unclosed_quotes('hello "world\\"') 51 | 52 | def test_unclosed_brackets(self, validator): 53 | """Test detection of unclosed brackets.""" 54 | assert validator._has_unclosed_brackets("call tool {") 55 | assert validator._has_unclosed_brackets("call tool { 'key': [") 56 | assert not validator._has_unclosed_brackets("call tool {}") 57 | assert not validator._has_unclosed_brackets("call tool { 'key': [] }") 58 | 59 | # Test brackets in strings 60 | assert not validator._has_unclosed_brackets('call tool "{"') 61 | assert validator._has_unclosed_brackets('call tool "{" {') 62 | 63 | def test_syntax_errors(self, validator): 64 | """Test detection of syntax errors in key=value pairs.""" 65 | # Valid key=value pairs 66 | assert not validator._has_syntax_errors("call tool key=value") 67 | assert not validator._has_syntax_errors("call tool key1=value1 key2=value2") 68 | 69 | # Invalid key=value pairs 70 | assert validator._has_syntax_errors("call tool =value") 71 | assert validator._has_syntax_errors("call tool key=") 72 | 73 | # JSON args shouldn't be checked for key=value syntax 74 | assert not validator._has_syntax_errors('call tool {"key": "value"}') 75 | 76 | def test_valid_pair(self, validator): 77 | """Test key=value pair validation.""" 78 | assert validator._is_valid_pair("key=value") 79 | assert validator._is_valid_pair("key=123") 80 | assert not validator._is_valid_pair("=value") 81 | assert not validator._is_valid_pair("key=") 82 | assert not validator._is_valid_pair("invalid") 83 | 84 | def test_complete_mbro_command(self, validator): 85 | """Test detection of complete mbro commands.""" 86 | # Standalone commands 87 | assert validator._is_complete_mbro_command("help") 88 | assert validator._is_complete_mbro_command("quit") 89 | assert validator._is_complete_mbro_command("tools") 90 | 91 | # Commands that need arguments 92 | assert not validator._is_complete_mbro_command("call") 93 | assert validator._is_complete_mbro_command("call my_tool") 94 | assert not validator._is_complete_mbro_command("connect") 95 | assert validator._is_complete_mbro_command("connect name stdio") 96 | 97 | # Unknown commands 98 | assert not validator._is_complete_mbro_command("unknown") 99 | assert not validator._is_complete_mbro_command("") 100 | -------------------------------------------------------------------------------- /test/magg/test_basic.py: -------------------------------------------------------------------------------- 1 | """Basic functionality tests for Magg using pytest.""" 2 | 3 | import pytest 4 | from fastmcp import Client 5 | from magg.server.server import MaggServer 6 | import tempfile 7 | from pathlib import Path 8 | 9 | 10 | class TestMaggBasicFunctionality: 11 | """Test basic Magg functionality.""" 12 | 13 | @pytest.mark.asyncio 14 | async def test_basic_setup_and_tools(self, tmp_path): 15 | """Test Magg setup and tool availability.""" 16 | config_path = tmp_path / "config.json" 17 | server = MaggServer(config_path, enable_config_reload=False) 18 | 19 | async with server: 20 | expected_tools = ["magg_list_servers", "magg_add_server", "magg_status", "magg_check"] 21 | 22 | async with Client(server.mcp) as client: 23 | tools = await client.list_tools() 24 | tool_names = [tool.name for tool in tools] 25 | 26 | for tool in expected_tools: 27 | assert tool in tool_names 28 | 29 | # list_tools was removed from the server 30 | # @pytest.mark.asyncio 31 | # async def test_magg_list_tools(self): 32 | # """Test Magg list tools functionality.""" 33 | # server = MaggServer() 34 | # await server.setup() 35 | # 36 | # async with Client(server.mcp) as client: 37 | # result = await client.call_tool("magg_list_tools", {}) 38 | # assert len(result) > 0 39 | # assert hasattr(result[0], 'text') 40 | # assert isinstance(result[0].text, str) 41 | 42 | @pytest.mark.asyncio 43 | async def test_list_servers(self, tmp_path): 44 | """Test listing servers.""" 45 | config_path = tmp_path / "config.json" 46 | server = MaggServer(config_path, enable_config_reload=False) 47 | 48 | async with server: 49 | async with Client(server.mcp) as client: 50 | result = await client.call_tool("magg_list_servers", {}) 51 | assert hasattr(result, 'content') 52 | assert len(result.content) > 0 53 | assert hasattr(result.content[0], 'text') 54 | assert isinstance(result.content[0].text, str) 55 | 56 | 57 | class TestMaggServerManagement: 58 | """Test server management functionality.""" 59 | 60 | @pytest.mark.asyncio 61 | async def test_add_server(self, tmp_path): 62 | """Test adding a server.""" 63 | config_path = tmp_path / "config.json" 64 | server = MaggServer(config_path, enable_config_reload=False) 65 | 66 | import time 67 | unique_id = str(int(time.time())) 68 | 69 | async with server: 70 | async with Client(server.mcp) as client: 71 | server_name = f"testserver{unique_id}" 72 | result = await client.call_tool("magg_add_server", { 73 | "name": server_name, 74 | "source": f"https://github.com/example/test-{unique_id}", 75 | "prefix": "test", 76 | "command": "echo test", 77 | "enable": False # Don't try to mount it 78 | }) 79 | 80 | assert hasattr(result, 'content') 81 | assert len(result.content) > 0 82 | assert hasattr(result.content[0], 'text') 83 | 84 | list_result = await client.call_tool("magg_list_servers", {}) 85 | assert hasattr(list_result, 'content') 86 | assert len(list_result.content) > 0 87 | assert hasattr(list_result.content[0], 'text') 88 | assert server_name in list_result.content[0].text 89 | 90 | 91 | class TestMaggServerSearch: 92 | """Test server search functionality.""" 93 | 94 | @pytest.mark.integration 95 | @pytest.mark.asyncio 96 | async def test_search_servers(self, tmp_path): 97 | """Test server search (requires internet).""" 98 | config_path = tmp_path / "config.json" 99 | server = MaggServer(config_path, enable_config_reload=False) 100 | 101 | async with server: 102 | async with Client(server.mcp) as client: 103 | try: 104 | result = await client.call_tool("magg_search_servers", { 105 | "query": "filesystem", 106 | "limit": 3 107 | }) 108 | assert hasattr(result, 'content') 109 | assert len(result.content) > 0 110 | assert hasattr(result.content[0], 'text') 111 | except Exception as e: 112 | pytest.skip(f"Search test failed (requires internet): {e}") 113 | -------------------------------------------------------------------------------- /.github/workflows/readme.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows for Magg 2 | 3 | This directory contains GitHub Actions workflows for automated testing, publishing, and Docker image management for the Magg package. 4 | 5 | ## Workflows 6 | 7 | ### 1. Test (`test.yml`) 8 | - **Trigger**: On every push and pull request to any branch 9 | - **Purpose**: Run pytest across multiple Python versions 10 | - **Actions**: 11 | - Read Python version from `.python-version` 12 | - Test on Python 3.12, 3.13, and the project's default version 13 | - Install all dependencies via uv 14 | - Run pytest with all tests 15 | 16 | ### 2. Publish to PyPI (`publish.yml`) 17 | - **Trigger**: On push to main branch 18 | - **Purpose**: Automatically publish new versions to PyPI when version changes 19 | - **Actions**: 20 | 1. Check if version in pyproject.toml has changed since last publish 21 | 2. If changed, build the package 22 | 3. Create GPG-signed tag (vX.Y.Z) 23 | 4. Create simplified tag for major.minor (vX.Y) 24 | 5. Push tags to repository 25 | 6. Create GitHub release with changelog 26 | 7. Publish to PyPI 27 | 8. Update latest-publish tag for future comparisons 28 | 29 | ### 3. Docker Build and Publish (`docker-publish.yml`) 30 | - **Trigger**: 31 | - Push to main or beta branch 32 | - Version tags matching `v*.*.*` 33 | - Pull requests to main or beta (build only) 34 | - **Purpose**: Build and publish multi-stage Docker images 35 | - **Actions**: 36 | 1. Build and test dev image with multiple Python versions 37 | 2. Run pytest inside dev container 38 | 3. If tests pass, build pro and pre images 39 | 4. Push all images to GitHub Container Registry (ghcr.io) 40 | - **Images Created**: 41 | - `pro` (production): WARNING log level 42 | - `pre` (staging): INFO log level 43 | - `dev` (development): DEBUG log level, includes dev dependencies 44 | 45 | ### 4. Manual Publish Dry Run (`manual-publish.yml`) 46 | - **Trigger**: Manual workflow dispatch 47 | - **Purpose**: Test the publishing process without actually publishing 48 | - **Actions**: 49 | - Simulates the entire publish workflow 50 | - Shows what would be committed and tagged 51 | - Does not actually publish or push changes 52 | 53 | ## Required Secrets 54 | 55 | The following secrets must be configured in the GitHub repository settings: 56 | 57 | - `PAT_TOKEN`: Personal Access Token with read/write permissions on Content 58 | - `GPG_PRIVATE_KEY`: The GPG private key for signing commits and tags 59 | - `GPG_PASSPHRASE`: The passphrase for the GPG key 60 | - `PYPI_TOKEN`: The API token for publishing to PyPI 61 | 62 | ## Required Environment Variables 63 | 64 | The following environment variables should be set in the `publish` environment: 65 | 66 | - `GPG_PUBLIC_KEY`: The GPG public key (informational) 67 | - `PYPI_TOKEN_NAME`: The name of the PyPI token (informational) 68 | - `SIGNED_COMMIT_USER`: The name for git commits 69 | - `SIGNED_COMMIT_EMAIL`: The email for git commits 70 | 71 | ## Version Numbering 72 | 73 | The version number follows the pattern `X.Y.Z` where: 74 | - X = Major version (breaking changes) 75 | - Y = Minor version (new features) 76 | - Z = Patch version (bug fixes) 77 | 78 | The workflow only publishes when the version in pyproject.toml is manually changed. 79 | 80 | ## Docker Image Tags 81 | 82 | Docker images are tagged based on the trigger: 83 | - **From beta branch**: `beta`, `beta-pre`, `beta-dev` 84 | - **From version tags**: `1.2.3`, `1.2`, `latest` (main only) 85 | - **With Python versions**: `beta-dev-py3.12`, `beta-dev-py3.13`, etc. 86 | 87 | ## Environment Configuration 88 | 89 | The publish environment requires: 90 | - `SIGNED_COMMIT_USER`: Git username for commits 91 | - `SIGNED_COMMIT_EMAIL`: Git user email for commits 92 | 93 | ## Usage 94 | 95 | **Publishing to PyPI**: 96 | 1. Update version in pyproject.toml 97 | 2. Commit and push to main branch 98 | 3. Workflow will automatically detect version change and publish 99 | 100 | **Docker Images**: 101 | - Push to beta branch for testing images 102 | - Version tags created by publish workflow trigger production images 103 | 104 | **Testing Publishing**: Use the manual workflow dispatch to dry-run 105 | 106 | ## Branch Strategy 107 | 108 | - **main**: Production branch - triggers PyPI releases and Docker builds on tags 109 | - **beta**: Testing branch - triggers Docker builds on every push 110 | - **feature branches**: Run tests only 111 | 112 | ## Troubleshooting 113 | 114 | - If git operations fail, ensure the PAT_TOKEN has sufficient permissions 115 | - If GPG signing fails, ensure the GPG key is properly imported and the secrets are correct 116 | - If PyPI publishing fails, check that the PYPI_TOKEN has sufficient permissions 117 | - If Docker builds fail, check that dev image tests are passing 118 | - Container tests use the dev image to gate all other image builds -------------------------------------------------------------------------------- /docs/kits.md: -------------------------------------------------------------------------------- 1 | # Magg Kits 2 | 3 | Kits are a way to bundle related MCP servers together for easy installation and management. Think of them as "packages" or "toolkits" that group servers with similar functionality. 4 | 5 | ## Overview 6 | 7 | A kit is a JSON file that contains: 8 | - Metadata about the kit (name, description, author, version, etc.) 9 | - One or more server configurations 10 | - Links to documentation and resources 11 | 12 | When you load a kit into Magg, all its servers are added to your configuration. When you unload a kit, servers that were only loaded from that kit are removed (servers shared by multiple kits are preserved). 13 | 14 | ## Kit Discovery 15 | 16 | Magg looks for kits in these locations: 17 | 1. `$MAGG_KITD_PATH` (defaults to `~/.magg/kit.d`) 18 | 2. `.magg/kit.d` in the same directory as your `config.json` 19 | 20 | Kit files must have a `.json` extension and follow the kit schema. 21 | 22 | ## Kit File Format 23 | 24 | ```json 25 | { 26 | "name": "calculator", 27 | "description": "Basic calculator functionality for MCP", 28 | "author": "Magg Team", 29 | "version": "1.0.0", 30 | "keywords": ["math", "calculator", "arithmetic"], 31 | "links": { 32 | "homepage": "https://github.com/example/calculator-kit", 33 | "docs": "https://github.com/example/calculator-kit/wiki" 34 | }, 35 | "servers": { 36 | "calc": { 37 | "source": "https://github.com/example/mcp-calc-server", 38 | "command": "python", 39 | "args": ["-m", "mcp_calc_server"], 40 | "notes": "Basic calculator server", 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | ## Kit Management Tools 47 | 48 | Magg provides these tools for managing kits: 49 | 50 | ### List Available Kits 51 | ```bash 52 | # Using mbro 53 | mbro call magg_list_kits 54 | 55 | # Shows all kits with their status (loaded/available) 56 | ``` 57 | 58 | ### Load a Kit 59 | ```bash 60 | # Load a kit and all its servers 61 | mbro call magg_load_kit name="calculator" 62 | 63 | # This will: 64 | # 1. Load the kit from calculator.json 65 | # 2. Add all servers from the kit 66 | # 3. Mount any enabled servers 67 | # 4. Update config.json 68 | ``` 69 | 70 | ### Unload a Kit 71 | ```bash 72 | # Unload a kit 73 | mbro call magg_unload_kit name="calculator" 74 | 75 | # This will: 76 | # 1. Remove servers that only belong to this kit 77 | # 2. Update servers that belong to multiple kits 78 | # 3. Unmount removed servers 79 | # 4. Update config.json 80 | ``` 81 | 82 | ### Get Kit Information 83 | ```bash 84 | # Get detailed info about a kit 85 | mbro call magg_kit_info name="web-tools" 86 | 87 | # Shows: 88 | # - Kit metadata 89 | # - All servers in the kit 90 | # - Whether the kit is loaded 91 | ``` 92 | 93 | ## Server Tracking 94 | 95 | Each server in `config.json` now has a `kits` field that tracks which kits it was loaded from: 96 | 97 | ```json 98 | { 99 | "servers": { 100 | "calc": { 101 | "source": "...", 102 | "command": "python", 103 | "kits": ["calculator", "math-tools"] 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | This allows Magg to: 110 | - Know which servers came from which kits 111 | - Only remove servers when their last kit is unloaded 112 | - Handle servers that appear in multiple kits 113 | 114 | ## Creating Your Own Kits 115 | 116 | To create a kit: 117 | 118 | 1. Create a JSON file in `~/.magg/kit.d/` (e.g., `my-tools.json`) 119 | 2. Add kit metadata and server configurations 120 | 3. Use `magg_load_kit` to load it 121 | 122 | Example custom kit: 123 | ```json 124 | { 125 | "name": "my-tools", 126 | "description": "My personal MCP server collection", 127 | "author": "Your Name", 128 | "version": "1.0.0", 129 | "servers": { 130 | "tool1": { 131 | "source": "https://github.com/you/tool1", 132 | "command": "node", 133 | "args": ["index.js"], 134 | "enabled": true 135 | }, 136 | "tool2": { 137 | "source": "https://github.com/you/tool2", 138 | "uri": "http://localhost:8080/mcp", 139 | "enabled": false 140 | } 141 | } 142 | } 143 | ``` 144 | 145 | ## Best Practices 146 | 147 | 1. **Kit Naming**: Use descriptive names that indicate the kit's purpose 148 | 2. **Versioning**: Include version numbers for tracking updates 149 | 3. **Documentation**: Provide links to docs and setup instructions 150 | 4. **Server Names**: Use consistent, meaningful server names 151 | 5. **Keywords**: Add relevant keywords for discoverability 152 | 153 | ## Example Kits 154 | 155 | ### Web Tools Kit 156 | Groups web automation and scraping servers: 157 | - Playwright server for browser automation 158 | - Puppeteer server as an alternative 159 | - Web scraping utilities 160 | 161 | ### Development Kit 162 | Groups development-related servers: 163 | - Git operations server 164 | - GitHub API server 165 | - Code analysis tools 166 | 167 | ### Data Kit 168 | Groups data processing servers: 169 | - SQLite database server 170 | - CSV processing server 171 | - Data transformation tools 172 | -------------------------------------------------------------------------------- /test/magg/test_mbro_cli_overhaul.py: -------------------------------------------------------------------------------- 1 | """Tests for the mbro CLI overhaul features.""" 2 | 3 | import pytest 4 | import tempfile 5 | from pathlib import Path 6 | from unittest.mock import patch, AsyncMock, MagicMock 7 | 8 | from magg.mbro.cli import MCPBrowserCLI, handle_commands 9 | from magg.mbro.parser import CommandParser 10 | 11 | 12 | class TestCommandParser: 13 | """Test the new command parser functionality.""" 14 | 15 | def test_parse_command_with_comments(self): 16 | """Test parsing commands with comments.""" 17 | # Comment at end of line 18 | parts = CommandParser.parse_command_line("connect test python server.py # this is a comment") 19 | assert parts == ["connect", "test", "python", "server.py"] 20 | 21 | # Comment as whole line 22 | parts = CommandParser.parse_command_line("# just a comment") 23 | assert parts == [] 24 | 25 | # Comment inside quotes should be preserved 26 | parts = CommandParser.parse_command_line('echo "hello # world"') 27 | assert parts == ["echo", "hello # world"] 28 | 29 | def test_split_commands_by_semicolon(self): 30 | """Test splitting multiple commands by semicolon.""" 31 | commands = CommandParser.split_commands("connect test python server.py; tools") 32 | assert commands == ["connect test python server.py", "tools"] 33 | 34 | # With newlines 35 | commands = CommandParser.split_commands("connect test python server.py\ntools") 36 | assert commands == ["connect test python server.py", "tools"] 37 | 38 | # With line continuation 39 | commands = CommandParser.split_commands("connect test \\\npython server.py") 40 | assert commands == ["connect test python server.py"] 41 | 42 | def test_parse_connect_args(self): 43 | """Test parsing connect arguments: name and connection string.""" 44 | # Basic usage: name followed by connection string 45 | name, conn = CommandParser.parse_connect_args(["myserver", "python", "server.py"]) 46 | assert name == "myserver" 47 | assert conn == "python server.py" 48 | 49 | # Single word connection 50 | name, conn = CommandParser.parse_connect_args(["calc", "npx", "@playwright/mcp@latest"]) 51 | assert name == "calc" 52 | assert conn == "npx @playwright/mcp@latest" 53 | 54 | # URL connection 55 | name, conn = CommandParser.parse_connect_args(["webserver", "http://localhost:8000/mcp"]) 56 | assert name == "webserver" 57 | assert conn == "http://localhost:8000/mcp" 58 | 59 | # Should fail with insufficient args 60 | try: 61 | CommandParser.parse_connect_args(["python"]) 62 | assert False, "Should have raised ValueError" 63 | except ValueError as e: 64 | assert "Usage: connect " in str(e) 65 | 66 | # Should fail with no args 67 | try: 68 | CommandParser.parse_connect_args([]) 69 | assert False, "Should have raised ValueError" 70 | except ValueError as e: 71 | assert "Usage: connect " in str(e) 72 | 73 | 74 | class TestMBroCLIOverhaul: 75 | """Test the mbro CLI overhaul features.""" 76 | 77 | @pytest.mark.asyncio 78 | async def test_handle_commands_from_args(self): 79 | """Test handling commands from command line args.""" 80 | cli = MagicMock() 81 | cli.handle_command = AsyncMock() 82 | 83 | args = MagicMock() 84 | args.commands = ["connect test python server.py", ";", "tools"] 85 | 86 | executed = await handle_commands(cli, args) 87 | 88 | assert executed is True 89 | assert cli.handle_command.call_count == 2 90 | cli.handle_command.assert_any_call("connect test python server.py") 91 | cli.handle_command.assert_any_call("tools") 92 | 93 | @pytest.mark.asyncio 94 | async def test_handle_commands_from_stdin(self): 95 | """Test handling commands from stdin.""" 96 | cli = MagicMock() 97 | cli.handle_command = AsyncMock() 98 | 99 | args = MagicMock() 100 | args.commands = ["-"] 101 | 102 | # Mock stdin 103 | test_input = "connect test python server.py\ntools\n" 104 | with patch('sys.stdin.read', return_value=test_input): 105 | executed = await handle_commands(cli, args) 106 | 107 | assert executed is True 108 | assert cli.handle_command.call_count == 2 109 | cli.handle_command.assert_any_call("connect test python server.py") 110 | cli.handle_command.assert_any_call("tools") 111 | 112 | def test_cli_command_parsing_with_comments(self): 113 | """Test that CLI properly handles comments.""" 114 | cli = MCPBrowserCLI(json_only=True) 115 | 116 | # Parse a command with a comment 117 | parts = CommandParser.parse_command_line("connect test python server.py # comment") 118 | assert parts == ["connect", "test", "python", "server.py"] 119 | -------------------------------------------------------------------------------- /test/magg/test_e2e_simple.py: -------------------------------------------------------------------------------- 1 | """Simple E2E test for Magg server without mounting.""" 2 | 3 | import asyncio 4 | import tempfile 5 | import json 6 | from pathlib import Path 7 | import subprocess 8 | import time 9 | import sys 10 | 11 | import pytest 12 | from fastmcp import Client 13 | from magg.settings import ConfigManager, ServerConfig, MaggConfig 14 | 15 | 16 | @pytest.mark.asyncio 17 | @pytest.mark.integration 18 | async def test_e2e_simple(): 19 | """Test Magg server basic functionality without mounting other servers.""" 20 | 21 | with tempfile.TemporaryDirectory() as tmpdir: 22 | tmpdir = Path(tmpdir) 23 | 24 | # 1. Create Magg config directory 25 | magg_dir = tmpdir / "magg_test" 26 | magg_dir.mkdir() 27 | config_dir = magg_dir / ".magg" 28 | config_dir.mkdir() 29 | 30 | # 2. Create empty config 31 | config = MaggConfig() 32 | config_path = config_dir / "config.json" 33 | with open(config_path, 'w') as f: 34 | json.dump({'servers': {}}, f, indent=2) 35 | 36 | # Create empty auth.json to prevent using default keys 37 | auth_path = config_dir / "auth.json" 38 | with open(auth_path, 'w') as f: 39 | json.dump({ 40 | 'bearer': { 41 | 'issuer': 'https://magg.local', 42 | 'audience': 'test', 43 | 'key_path': str(tmpdir / 'nonexistent') 44 | } 45 | }, f) 46 | 47 | print(f"Config saved to: {config_path}") 48 | 49 | # 3. Start Magg server as subprocess 50 | magg_script = magg_dir / "run_magg.py" 51 | magg_script.write_text(f''' 52 | import sys 53 | import os 54 | sys.path.insert(0, "{Path.cwd()}") 55 | os.chdir("{magg_dir}") 56 | 57 | from magg.server.server import MaggServer 58 | import asyncio 59 | 60 | async def main(): 61 | server = MaggServer("{config_path}") 62 | await server.setup() 63 | print("Magg server started", flush=True) 64 | await server.mcp.run_http_async(host="localhost", port=54322) 65 | 66 | asyncio.run(main()) 67 | ''') 68 | 69 | # Start Magg 70 | print("Starting Magg server...") 71 | magg_proc = subprocess.Popen( 72 | [sys.executable, str(magg_script)], 73 | stdout=subprocess.PIPE, 74 | stderr=subprocess.PIPE, 75 | text=True 76 | ) 77 | 78 | # Wait for startup 79 | started = False 80 | for i in range(10): # Try for up to 10 seconds 81 | if magg_proc.poll() is not None: 82 | # Process ended 83 | stdout, stderr = magg_proc.communicate() 84 | print(f"Magg process ended with code {magg_proc.returncode}") 85 | print(f"STDOUT:\n{stdout}") 86 | print(f"STDERR:\n{stderr}") 87 | pytest.fail(f"Magg server failed to start: {stderr}") 88 | 89 | # Check if server is listening on the port 90 | import socket 91 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 92 | result = sock.connect_ex(('localhost', 54322)) 93 | sock.close() 94 | 95 | if result == 0: 96 | print("Server is listening on port 54322") 97 | started = True 98 | break 99 | 100 | time.sleep(1) 101 | 102 | if not started: 103 | pytest.fail("Magg server didn't start listening on port 54322") 104 | 105 | try: 106 | # 4. Connect to Magg as client 107 | print("\nConnecting to Magg...") 108 | client = Client("http://localhost:54322/mcp/") 109 | 110 | # Use the client in async context 111 | async with client: 112 | tools = await client.list_tools() 113 | tool_names = [tool.name for tool in tools] 114 | print(f"\nAvailable tools: {tool_names}") 115 | 116 | # Verify Magg's own tools are available 117 | assert "magg_list_servers" in tool_names 118 | assert "magg_add_server" in tool_names 119 | 120 | # Test listing servers (should be empty) 121 | result = await client.call_tool("magg_list_servers", {}) 122 | print(f"\nResult: {result}") 123 | 124 | # Parse the JSON response 125 | if hasattr(result, 'content') and result.content: 126 | response_text = result.content[0].text 127 | else: 128 | response_text = "{}" 129 | servers_data = json.loads(response_text) 130 | print(f"Parsed servers data: {servers_data}") 131 | 132 | assert servers_data["output"] == [] 133 | 134 | print("\n✅ All tests passed!") 135 | 136 | finally: 137 | # Cleanup 138 | magg_proc.terminate() 139 | magg_proc.wait() 140 | 141 | 142 | if __name__ == "__main__": 143 | asyncio.run(test_e2e_simple()) 144 | -------------------------------------------------------------------------------- /test/magg/test_mounting.py: -------------------------------------------------------------------------------- 1 | """Test FastMCP mounting functionality and client types.""" 2 | 3 | import pytest 4 | import inspect 5 | from unittest.mock import patch, MagicMock 6 | 7 | from fastmcp import FastMCP, Client 8 | 9 | 10 | class TestFastMCPMounting: 11 | """Test FastMCP mounting capabilities and client compatibility.""" 12 | 13 | @pytest.fixture 14 | def test_mcp(self): 15 | """Create a test FastMCP server.""" 16 | return FastMCP("test") 17 | 18 | def test_http_client_creation(self): 19 | """Test HTTP client creation and attributes.""" 20 | http_client = Client("http://localhost:8080") 21 | 22 | # Check client was created 23 | assert http_client is not None 24 | 25 | # Check for lifespan-related attributes 26 | lifespan_attrs = [attr for attr in dir(http_client) if 'lifespan' in attr.lower()] 27 | has_lifespan = hasattr(http_client, '_has_lifespan') 28 | 29 | # Store results for analysis 30 | assert isinstance(lifespan_attrs, list) 31 | assert isinstance(has_lifespan, bool) 32 | 33 | def test_command_client_creation(self): 34 | """Test command client creation with MCP config.""" 35 | mcp_config = { 36 | "mcpServers": { 37 | "test": { 38 | "command": "echo hello" 39 | } 40 | } 41 | } 42 | 43 | command_client = Client(mcp_config) 44 | 45 | # Check client was created 46 | assert command_client is not None 47 | 48 | # Check for lifespan-related attributes 49 | lifespan_attrs = [attr for attr in dir(command_client) if 'lifespan' in attr.lower()] 50 | has_lifespan = hasattr(command_client, '_has_lifespan') 51 | 52 | # Store results for analysis 53 | assert isinstance(lifespan_attrs, list) 54 | assert isinstance(has_lifespan, bool) 55 | 56 | def test_client_attribute_comparison(self): 57 | """Test comparing attributes between different client types.""" 58 | http_client = Client("http://localhost:8080") 59 | mcp_config = {"mcpServers": {"test": {"command": "echo hello"}}} 60 | command_client = Client(mcp_config) 61 | 62 | http_attrs = set(dir(http_client)) 63 | cmd_attrs = set(dir(command_client)) 64 | 65 | # Both should have basic client attributes 66 | assert len(http_attrs) > 0 67 | assert len(cmd_attrs) > 0 68 | 69 | # Find differences 70 | http_unique = http_attrs - cmd_attrs 71 | cmd_unique = cmd_attrs - http_attrs 72 | common = http_attrs & cmd_attrs 73 | common_lifespan = [attr for attr in common if 'lifespan' in attr.lower()] 74 | 75 | # Store results for analysis 76 | assert isinstance(http_unique, set) 77 | assert isinstance(cmd_unique, set) 78 | assert isinstance(common_lifespan, list) 79 | 80 | def test_mount_method_signature(self, test_mcp): 81 | """Test FastMCP mount method signature.""" 82 | mount_sig = inspect.signature(test_mcp.mount) 83 | 84 | # Should have mount method 85 | assert hasattr(test_mcp, 'mount') 86 | assert callable(test_mcp.mount) 87 | 88 | # Check signature parameters 89 | params = list(mount_sig.parameters.keys()) 90 | assert len(params) > 0 # Should have at least one parameter 91 | 92 | # Typically expect name and client parameters 93 | assert isinstance(params, list) 94 | 95 | 96 | 97 | class TestClientLifespanCompatibility: 98 | """Test client lifespan compatibility issues.""" 99 | 100 | def test_lifespan_attribute_presence(self): 101 | """Test presence of lifespan attributes on different clients.""" 102 | # HTTP client 103 | http_client = Client("http://localhost:8080") 104 | http_has_lifespan = hasattr(http_client, '_has_lifespan') 105 | 106 | # Command client 107 | mcp_config = {"mcpServers": {"test": {"command": "echo hello"}}} 108 | command_client = Client(mcp_config) 109 | cmd_has_lifespan = hasattr(command_client, '_has_lifespan') 110 | 111 | # Document the current behavior 112 | assert isinstance(http_has_lifespan, bool) 113 | assert isinstance(cmd_has_lifespan, bool) 114 | 115 | def test_client_type_consistency(self): 116 | """Test that both client types have consistent interfaces.""" 117 | http_client = Client("http://localhost:8080") 118 | mcp_config = {"mcpServers": {"test": {"command": "echo hello"}}} 119 | command_client = Client(mcp_config) 120 | 121 | # Both should be Client instances 122 | assert isinstance(http_client, Client) 123 | assert isinstance(command_client, Client) 124 | 125 | # Both should have similar base interface 126 | http_methods = [m for m in dir(http_client) if not m.startswith('_')] 127 | cmd_methods = [m for m in dir(command_client) if not m.startswith('_')] 128 | 129 | # Should have some common public methods 130 | common_methods = set(http_methods) & set(cmd_methods) 131 | assert len(common_methods) > 0 132 | -------------------------------------------------------------------------------- /test/magg/test_client_api.py: -------------------------------------------------------------------------------- 1 | """Test FastMCP Client API usage patterns.""" 2 | 3 | import pytest 4 | import inspect 5 | from fastmcp import Client 6 | 7 | 8 | class TestFastMCPClientAPI: 9 | """Test FastMCP Client constructor and API patterns.""" 10 | 11 | def test_http_client_creation(self): 12 | """Test HTTP client creation.""" 13 | # Test HTTP client (should work) 14 | http_client = Client("http://localhost:8080") 15 | 16 | assert http_client is not None 17 | assert isinstance(http_client, Client) 18 | 19 | def test_command_client_creation_with_keyword(self): 20 | """Test command client creation with keyword argument.""" 21 | try: 22 | # Test command-based client with command= keyword 23 | command_client = Client(command=["echo", "hello"]) 24 | 25 | assert command_client is not None 26 | assert isinstance(command_client, Client) 27 | 28 | except TypeError as e: 29 | # If this syntax isn't supported, document it 30 | pytest.skip(f"Command keyword syntax not supported: {e}") 31 | 32 | def test_command_client_creation_positional(self): 33 | """Test command client creation with positional argument.""" 34 | try: 35 | # Test alternative syntax with positional argument 36 | command_client = Client(["echo", "hello"]) 37 | 38 | assert command_client is not None 39 | assert isinstance(command_client, Client) 40 | 41 | except (TypeError, ValueError) as e: 42 | # If this syntax isn't supported, document it 43 | pytest.skip(f"Positional command syntax not supported: {e}") 44 | 45 | def test_client_constructor_signature(self): 46 | """Test Client constructor signature.""" 47 | sig = inspect.signature(Client.__init__) 48 | 49 | # Should have constructor 50 | assert sig is not None 51 | 52 | # Get parameter information 53 | params = list(sig.parameters.keys()) 54 | assert len(params) > 0 # Should have at least 'self' 55 | 56 | # Document signature for analysis 57 | param_info = {name: param for name, param in sig.parameters.items()} 58 | assert 'self' in param_info 59 | 60 | def test_client_types_supported(self): 61 | """Test what types of clients are supported.""" 62 | # HTTP client should always work 63 | http_client = Client("http://localhost:8080") 64 | assert isinstance(http_client, Client) 65 | 66 | # Test different URL schemes 67 | https_client = Client("https://example.com") 68 | assert isinstance(https_client, Client) 69 | 70 | # Test if WebSocket URLs are supported 71 | try: 72 | ws_client = Client("ws://localhost:8080") 73 | assert isinstance(ws_client, Client) 74 | except (ValueError, TypeError): 75 | # WebSocket might not be supported 76 | pytest.skip("WebSocket URLs not supported") 77 | 78 | 79 | class TestClientParameterValidation: 80 | """Test Client parameter validation.""" 81 | 82 | def test_invalid_url_handling(self): 83 | """Test how Client handles invalid URLs.""" 84 | with pytest.raises((ValueError, TypeError)): 85 | Client("not-a-valid-url") 86 | 87 | def test_empty_command_handling(self): 88 | """Test how Client handles empty commands.""" 89 | try: 90 | # Test empty command list 91 | with pytest.raises((ValueError, TypeError)): 92 | Client([]) 93 | except TypeError: 94 | # Constructor might not accept list at all 95 | pytest.skip("List constructor not supported") 96 | 97 | def test_invalid_command_handling(self): 98 | """Test how Client handles invalid commands.""" 99 | try: 100 | # Test invalid command types 101 | with pytest.raises((ValueError, TypeError)): 102 | Client(command=123) # Invalid type 103 | except TypeError: 104 | # Constructor might not accept command keyword 105 | pytest.skip("Command keyword not supported") 106 | 107 | 108 | class TestClientCompatibility: 109 | """Test Client compatibility with different scenarios.""" 110 | 111 | def test_client_attribute_consistency(self): 112 | """Test that different client types have consistent attributes.""" 113 | http_client = Client("http://localhost:8080") 114 | 115 | # Check basic attributes 116 | assert hasattr(http_client, '__class__') 117 | 118 | # Check if client has expected MCP-related attributes 119 | client_attrs = [attr for attr in dir(http_client) if not attr.startswith('_')] 120 | assert len(client_attrs) > 0 121 | 122 | def test_client_method_availability(self): 123 | """Test availability of expected client methods.""" 124 | http_client = Client("http://localhost:8080") 125 | 126 | # Check for common async methods 127 | async_methods = [method for method in dir(http_client) if 'async' in method.lower()] 128 | 129 | # Check for connection-related methods 130 | conn_methods = [method for method in dir(http_client) if any(keyword in method.lower() 131 | for keyword in ['connect', 'close', 'call', 'list'])] 132 | 133 | # Document available methods 134 | assert isinstance(async_methods, list) 135 | assert isinstance(conn_methods, list) 136 | -------------------------------------------------------------------------------- /magg/util/terminal.py: -------------------------------------------------------------------------------- 1 | """Terminal utilities for better CLI output.""" 2 | import os 3 | import sys 4 | 5 | import art 6 | 7 | from .system import initterm 8 | 9 | 10 | class Colors: 11 | """ANSI color codes for terminal output.""" 12 | HEADER = '\033[95m' 13 | OKBLUE = '\033[94m' 14 | OKCYAN = '\033[96m' 15 | OKGREEN = '\033[92m' 16 | WARNING = '\033[93m' 17 | FAIL = '\033[91m' 18 | ENDC = '\033[0m' 19 | BOLD = '\033[1m' 20 | UNDERLINE = '\033[4m' 21 | 22 | @classmethod 23 | def disable(cls): 24 | """Disable colors (for non-tty output).""" 25 | cls.HEADER = '' 26 | cls.OKBLUE = '' 27 | cls.OKCYAN = '' 28 | cls.OKGREEN = '' 29 | cls.WARNING = '' 30 | cls.FAIL = '' 31 | cls.ENDC = '' 32 | cls.BOLD = '' 33 | cls.UNDERLINE = '' 34 | 35 | 36 | # Disable colors if not a TTY 37 | if not sys.stderr.isatty(): 38 | Colors.disable() 39 | 40 | 41 | def print_text(text: str = "", *args, **kwds): 42 | kwds.setdefault('file', sys.stderr) 43 | print(text, *args, **kwds) 44 | 45 | 46 | def print_header(text: str, *args, **kwds): 47 | print_text(f"{Colors.BOLD}{Colors.HEADER}{text}{Colors.ENDC}", *args, **kwds) 48 | 49 | 50 | def print_success(text: str, *args, **kwds): 51 | print_text(f"{Colors.OKGREEN}✓ {text}{Colors.ENDC}", *args, **kwds) 52 | 53 | 54 | def print_error(text: str, *args, **kwds): 55 | print_text(f"{Colors.FAIL}✗ {text}{Colors.ENDC}", *args, **kwds) 56 | 57 | 58 | def print_warning(text: str, *args, **kwds): 59 | print_text(f"{Colors.WARNING}⚠ {text}{Colors.ENDC}", *args, **kwds) 60 | 61 | 62 | def print_info(text: str, *args, **kwds): 63 | print_text(f"{Colors.OKCYAN}ⓘ {text}{Colors.ENDC}", *args, **kwds) 64 | 65 | def print_server_list(servers: dict): 66 | """Print a formatted list of servers.""" 67 | if not servers: 68 | print_info("No servers configured") 69 | return 70 | 71 | print_header("Configured Servers") 72 | 73 | for name, server in servers.items(): 74 | status_color = Colors.OKGREEN if server.enabled else Colors.WARNING 75 | status_text = "enabled" if server.enabled else "disabled" 76 | 77 | print_text(f"\n {Colors.BOLD}{name}{Colors.ENDC} ({server.prefix}) - {status_color}{status_text}{Colors.ENDC}") 78 | print_text(f" Source: {server.source}") 79 | 80 | if server.command: 81 | full_command = server.command 82 | if server.args: 83 | full_command += ' ' + ' '.join(server.args) 84 | print_text(f" Command: {full_command}") 85 | 86 | if server.uri: 87 | print_text(f" URI: {server.uri}") 88 | 89 | if server.cwd: 90 | print_text(f" Working Dir: {server.cwd}") 91 | 92 | if server.env: 93 | print_text(f" Environment: {', '.join(f'{k}={v}' for k, v in server.env.items())}") 94 | 95 | if server.notes: 96 | print_text(f" Notes: {Colors.OKCYAN}{server.notes}{Colors.ENDC}") 97 | 98 | 99 | def format_command(command: str, args: list[str] | None = None) -> str: 100 | """Format a command with arguments.""" 101 | if args: 102 | return f"{command} {' '.join(args)}" 103 | return command 104 | 105 | 106 | def print_status_summary(config_path: str, total: int, enabled: int, disabled: int): 107 | """Print a status summary.""" 108 | print_header("Magg Status") 109 | print_text(f""" Config: {config_path} 110 | Total servers: {Colors.BOLD}{total}{Colors.ENDC} 111 | {Colors.OKGREEN}● Enabled: {enabled}{Colors.ENDC} 112 | {Colors.WARNING}○ Disabled: {disabled}{Colors.ENDC}""") 113 | 114 | 115 | def confirm_action(prompt: str) -> bool: 116 | """Ask for confirmation before an action.""" 117 | try: 118 | response = input(f"{Colors.WARNING}{prompt} [y/N]: {Colors.ENDC}").strip().lower() 119 | return response in ('y', 'yes') 120 | except (EOFError, KeyboardInterrupt): 121 | print() # New line after Ctrl+C 122 | return False 123 | 124 | 125 | def print_startup_banner(): 126 | """Print a beautiful startup banner. 127 | """ 128 | # import pyfiglet 129 | # Use banner font which has solid # characters 130 | # ascii_art = pyfiglet.figlet_format("MAGG", font="big") 131 | # ascii_art = pyfiglet.figlet_format("MAGG", font="isometric3") 132 | # ascii_art = pyfiglet.figlet_format("MAGG", font="whimsy") 133 | 134 | # ascii_art = art.text2art("MAGG", font="cricket") 135 | # ascii_art = art.text2art("MAGG", font="diamond") 136 | # ascii_art = art.text2art("MAGG", font="tarty1") 137 | ascii_art = art.text2art("MAGG", font="isometric3") 138 | 139 | if os.environ.get("NO_RICH", "").lower() in ("1", "true", "yes"): 140 | print(ascii_art, file=sys.stderr) 141 | else: 142 | try: 143 | console = initterm() 144 | if console: 145 | # Apply gradient colors to each line 146 | lines = ascii_art.split('\n') 147 | colors = ['#4796E4', '#5B8FE6', '#7087E8', '#847ACE', '#9B72B8', '#B26BA2', '#C3677F'] 148 | 149 | for i, line in enumerate(lines): 150 | if line.strip(): 151 | color_idx = min(i, len(colors) - 1) 152 | console.print(line, style=f"bold {colors[color_idx]}") 153 | 154 | console.print() 155 | else: 156 | print(ascii_art, file=sys.stderr) 157 | except ImportError: 158 | print(ascii_art, file=sys.stderr) 159 | -------------------------------------------------------------------------------- /test/magg/test_tool_delegation.py: -------------------------------------------------------------------------------- 1 | """Test tool delegation functionality for Magg.""" 2 | 3 | import pytest 4 | from fastmcp import FastMCP, Client 5 | from magg.settings import ServerConfig 6 | 7 | 8 | class TestToolDelegation: 9 | """Test tool delegation patterns.""" 10 | 11 | def test_fastmcp_tool_creation(self): 12 | """Test that FastMCP tools can be created correctly.""" 13 | mcp = FastMCP("test-delegation") 14 | 15 | @mcp.tool() 16 | def test_tool(message: str) -> str: 17 | """Test tool that returns a message.""" 18 | return f"Received: {message}" 19 | 20 | # Test that tool was registered (FastMCP internal structure may vary) 21 | # This is a basic smoke test 22 | assert mcp is not None 23 | 24 | @pytest.mark.asyncio 25 | async def test_delegation_pattern(self): 26 | """Test delegation pattern with FastMCP client.""" 27 | # Create a simple server for testing 28 | server = FastMCP("test-server") 29 | 30 | @server.tool() 31 | def delegate_test(query: str) -> str: 32 | """A tool that could be delegated to.""" 33 | return f"Delegated result: {query}" 34 | 35 | # Test calling through client 36 | async with Client(server) as client: 37 | tools = await client.list_tools() 38 | assert len(tools) > 0 39 | assert tools[0].name == "delegate_test" 40 | 41 | result = await client.call_tool("delegate_test", {"query": "test"}) 42 | assert hasattr(result, 'content') 43 | assert len(result.content) > 0 44 | assert "Delegated result: test" in result.content[0].text 45 | 46 | def test_tool_prefix_handling(self): 47 | """Test that tool prefixes are handled correctly.""" 48 | # Create server with specific prefix 49 | server = ServerConfig( 50 | name="prefixedserver", 51 | source="https://example.com", 52 | prefix="custom", 53 | command="echo" 54 | ) 55 | 56 | # Test prefix validation 57 | assert server.prefix == "custom" 58 | 59 | # Test that default prefix is now None 60 | server2 = ServerConfig( 61 | name="test-server", 62 | source="https://example.com", 63 | command="echo" 64 | ) 65 | assert server2.prefix is None # Default is None now 66 | 67 | def test_tool_name_collision_handling(self): 68 | """Test handling of tool name collisions.""" 69 | # In FastMCP, tools are prefixed by mount point 70 | # This prevents collisions automatically 71 | server1 = ServerConfig( 72 | name="server1", 73 | source="https://example.com", 74 | prefix="srv1", 75 | command="echo" 76 | ) 77 | 78 | server2 = ServerConfig( 79 | name="server2", 80 | source="https://example.com", 81 | prefix="srv2", 82 | command="echo" 83 | ) 84 | 85 | # Different prefixes prevent collision 86 | assert server1.prefix != server2.prefix 87 | 88 | 89 | class TestToolDiscovery: 90 | """Test tool discovery functionality.""" 91 | 92 | @pytest.mark.asyncio 93 | async def test_server_tool_listing(self): 94 | """Test listing tools from a server via Client.""" 95 | from magg.server.server import MaggServer 96 | 97 | server = MaggServer() 98 | await server.setup() 99 | 100 | # List tools through the FastMCP client 101 | async with Client(server.mcp) as client: 102 | tools = await client.list_tools() 103 | tool_names = [tool.name for tool in tools] 104 | 105 | # Should at least have Magg's own tools 106 | assert "magg_list_servers" in tool_names 107 | assert "magg_add_server" in tool_names 108 | assert len(tool_names) > 0 109 | 110 | @pytest.mark.asyncio 111 | async def test_mounted_server_tools(self): 112 | """Test that mounted server tools appear in listings.""" 113 | from magg.server.server import MaggServer 114 | 115 | server = MaggServer() 116 | await server.setup() 117 | 118 | # Create a test MCP server 119 | test_server = FastMCP("test-server") 120 | 121 | @test_server.tool() 122 | def test_tool1(message: str) -> str: 123 | """Test tool 1.""" 124 | return f"Tool 1: {message}" 125 | 126 | @test_server.tool() 127 | def test_tool2(value: int) -> int: 128 | """Test tool 2.""" 129 | return value * 2 130 | 131 | # Mount the test server 132 | server.mcp.mount(server=test_server, prefix="test") 133 | 134 | # Access tools through the client 135 | async with Client(server.mcp) as client: 136 | tools = await client.list_tools() 137 | tool_names = [tool.name for tool in tools] 138 | 139 | # Should have Magg's own tools 140 | assert "magg_list_servers" in tool_names 141 | assert "magg_add_server" in tool_names 142 | 143 | # Should have test server tools with prefix 144 | assert "test_test_tool1" in tool_names 145 | assert "test_test_tool2" in tool_names 146 | 147 | # Verify we have tools from both servers 148 | server_prefix = server.self_prefix_ 149 | magg_tools = [t for t in tool_names if t.startswith(server_prefix)] 150 | test_tools = [t for t in tool_names if t.startswith("test_")] 151 | 152 | assert len(magg_tools) >= 2 # At least magg_list_servers and magg_add_server 153 | assert len(test_tools) >= 2 # At least tool1 and tool2 154 | # Verify our specific tools are present 155 | assert "test_test_tool1" in test_tools 156 | assert "test_test_tool2" in test_tools 157 | -------------------------------------------------------------------------------- /magg/mbro/validator.py: -------------------------------------------------------------------------------- 1 | """Input validator for mbro multiline support.""" 2 | 3 | import codeop 4 | from prompt_toolkit.validation import Validator, ValidationError 5 | 6 | 7 | class InputValidator(Validator): 8 | """Validator that detects incomplete input for multiline support.""" 9 | 10 | def __init__(self, cli_instance): 11 | self.cli = cli_instance 12 | 13 | def validate(self, document): 14 | text = document.text.strip() 15 | if not text: 16 | return 17 | 18 | if self._needs_continuation(text): 19 | return 20 | 21 | if self._has_syntax_errors(text): 22 | raise ValidationError( 23 | message="Incomplete input - press Enter to continue or fix syntax", 24 | cursor_position=len(text) 25 | ) 26 | 27 | def _needs_continuation(self, text: str) -> bool: 28 | """Check if input needs continuation like Python REPL.""" 29 | if not text.strip(): 30 | return False 31 | 32 | if text.endswith('\\'): 33 | return True 34 | 35 | if self._has_unclosed_quotes(text): 36 | return True 37 | 38 | if self._has_unclosed_brackets(text): 39 | return True 40 | 41 | try: 42 | words = text.strip().split(maxsplit=1) 43 | if words and words[0] in { 44 | 'help', 'quit', 'exit', 'connect', 'connections', 'conns', 'switch', 45 | 'disconnect', 'tools', 'resources', 'prompts', 'call', 'resource', 46 | 'prompt', 'status', 'search', 'info' 47 | }: 48 | return False 49 | 50 | result = codeop.compile_command(text, '', 'exec') 51 | return result is None 52 | except SyntaxError: 53 | return False 54 | 55 | return False 56 | 57 | @staticmethod 58 | def _is_complete_mbro_command(text: str) -> bool: 59 | """Check if text is a complete mbro command.""" 60 | text = text.strip() 61 | if not text: 62 | return False 63 | 64 | mbro_commands = { 65 | 'help', 'quit', 'exit', 'connect', 'connections', 'conns', 'switch', 66 | 'disconnect', 'tools', 'resources', 'prompts', 'call', 'resource', 67 | 'prompt', 'status', 'search', 'info' 68 | } 69 | 70 | standalone_commands = { 71 | 'help', 'quit', 'exit', 'connections', 'conns', 'disconnect', 72 | 'tools', 'resources', 'prompts', 'status' 73 | } 74 | 75 | words = text.split() 76 | if not words or words[0] not in mbro_commands: 77 | return False 78 | 79 | command = words[0] 80 | 81 | if command in standalone_commands: 82 | return True 83 | 84 | if command == 'call': 85 | return len(words) >= 2 86 | elif command in ['connect', 'switch', 'resource', 'prompt', 'search', 'info']: 87 | return len(words) >= 2 88 | 89 | return True 90 | 91 | @staticmethod 92 | def _has_unclosed_quotes(text: str) -> bool: 93 | """Check for unclosed string literals.""" 94 | in_single = False 95 | in_double = False 96 | escaped = False 97 | 98 | for char in text: 99 | if escaped: 100 | escaped = False 101 | continue 102 | 103 | if char == '\\' and (in_single or in_double): 104 | escaped = True 105 | elif char == '"' and not in_single: 106 | in_double = not in_double 107 | elif char == "'" and not in_double: 108 | in_single = not in_single 109 | 110 | return in_single or in_double 111 | 112 | @staticmethod 113 | def _has_unclosed_brackets(text: str) -> bool: 114 | """Check for unclosed brackets or braces.""" 115 | stack = [] 116 | pairs = {'(': ')', '[': ']', '{': '}'} 117 | in_string = False 118 | string_char = None 119 | escaped = False 120 | 121 | for char in text: 122 | if escaped: 123 | escaped = False 124 | continue 125 | 126 | if char == '\\' and in_string: 127 | escaped = True 128 | continue 129 | 130 | if not in_string: 131 | if char in ['"', "'"]: 132 | in_string = True 133 | string_char = char 134 | elif char in pairs: 135 | stack.append(char) 136 | elif char in pairs.values(): 137 | if not stack: 138 | return False 139 | if pairs[stack[-1]] == char: 140 | stack.pop() 141 | else: 142 | return False 143 | else: 144 | if char == string_char: 145 | in_string = False 146 | string_char = None 147 | 148 | return len(stack) > 0 or in_string 149 | 150 | def _has_syntax_errors(self, text: str) -> bool: 151 | """Check for obvious syntax errors.""" 152 | if 'call ' in text and '=' in text: 153 | parts = text.split() 154 | if len(parts) >= 3: 155 | params = ' '.join(parts[2:]) 156 | if not params.startswith('{') and '=' in params: 157 | pairs = params.split() 158 | for pair in pairs: 159 | if '=' in pair and not self._is_valid_pair(pair): 160 | return True 161 | 162 | return False 163 | 164 | @staticmethod 165 | def _is_valid_pair(pair: str) -> bool: 166 | """Check if key=value pair is valid.""" 167 | if '=' not in pair: 168 | return False 169 | key, value = pair.split('=', 1) 170 | return bool(key.strip()) and bool(value.strip()) 171 | -------------------------------------------------------------------------------- /examples/messaging.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Example demonstrating Magg's messaging and notifications feature. 3 | 4 | This example shows how to use MaggClient with message handlers to receive 5 | notifications from backend MCP servers. 6 | """ 7 | import asyncio 8 | import mcp.types 9 | from magg import MaggClient, MaggMessageHandler 10 | 11 | 12 | class CustomMessageHandler(MaggMessageHandler): 13 | """Custom message handler that logs all notifications.""" 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self.notification_count = 0 18 | 19 | async def on_message(self, message): 20 | """Called for all messages.""" 21 | self.notification_count += 1 22 | print(f"📥 Received message #{self.notification_count}: {type(message).__name__}") 23 | 24 | async def on_tool_list_changed(self, notification: mcp.types.ToolListChangedNotification): 25 | """Called when tool list changes.""" 26 | print("🔧 Tool list changed! Available tools may have been updated.") 27 | 28 | async def on_resource_list_changed(self, notification: mcp.types.ResourceListChangedNotification): 29 | """Called when resource list changes.""" 30 | print("📁 Resource list changed! Available resources may have been updated.") 31 | 32 | async def on_progress(self, notification: mcp.types.ProgressNotification): 33 | """Called for progress updates.""" 34 | if notification.params: 35 | progress = notification.params.progress 36 | total = notification.params.total 37 | if total: 38 | percentage = (progress / total) * 100 39 | print(f"⏳ Progress: {progress}/{total} ({percentage:.1f}%)") 40 | else: 41 | print(f"⏳ Progress: {progress}") 42 | 43 | async def on_logging_message(self, notification: mcp.types.LoggingMessageNotification): 44 | """Called for log messages from servers.""" 45 | if notification.params: 46 | level = notification.params.level 47 | data = notification.params.data 48 | print(f"📝 Log [{level.upper()}]: {data}") 49 | 50 | 51 | async def callback_example(): 52 | """Example using callback-based message handler.""" 53 | print("🚀 Starting callback-based message handler example...") 54 | 55 | def on_tool_change(notification): 56 | print("🔧 [Callback] Tools changed!") 57 | 58 | def on_progress(notification): 59 | if notification.params and notification.params.progress is not None: 60 | print(f"⏳ [Callback] Progress: {notification.params.progress}") 61 | 62 | # Create handler with callbacks 63 | handler = MaggMessageHandler( 64 | on_tool_list_changed=on_tool_change, 65 | on_progress=on_progress 66 | ) 67 | 68 | # Create client with message handler 69 | client = MaggClient( 70 | "http://localhost:8000/mcp/", # MCP endpoint with trailing slash 71 | message_handler=handler 72 | ) 73 | 74 | try: 75 | async with client: 76 | print("✅ Connected to Magg server with message handling") 77 | 78 | # List tools to see what's available 79 | tools = await client.list_tools() 80 | print(f"📋 Found {len(tools)} tools available") 81 | 82 | # Keep connection open to receive notifications 83 | print("👂 Listening for notifications... (press Ctrl+C to stop)") 84 | await asyncio.sleep(30) # Listen for 30 seconds 85 | 86 | except KeyboardInterrupt: 87 | print("\n🛑 Stopped listening for notifications") 88 | except Exception as e: 89 | print(f"❌ Error: {e}") 90 | 91 | 92 | async def class_example(): 93 | """Example using class-based message handler.""" 94 | print("🚀 Starting class-based message handler example...") 95 | 96 | # Create custom handler 97 | handler = CustomMessageHandler() 98 | 99 | # Create client with message handler 100 | client = MaggClient( 101 | "http://localhost:8000/mcp/", # MCP endpoint with trailing slash 102 | message_handler=handler 103 | ) 104 | 105 | try: 106 | async with client: 107 | print("✅ Connected to Magg server with custom message handler") 108 | 109 | # List available capabilities 110 | tools = await client.list_tools() 111 | resources = await client.list_resources() 112 | prompts = await client.list_prompts() 113 | 114 | print(f"📋 Available capabilities:") 115 | print(f" 🔧 Tools: {len(tools)}") 116 | print(f" 📁 Resources: {len(resources)}") 117 | print(f" 💬 Prompts: {len(prompts)}") 118 | 119 | # Keep connection open to receive notifications 120 | print("👂 Listening for notifications... (press Ctrl+C to stop)") 121 | await asyncio.sleep(30) # Listen for 30 seconds 122 | 123 | print(f"📊 Total notifications received: {handler.notification_count}") 124 | 125 | except KeyboardInterrupt: 126 | print("\n🛑 Stopped listening for notifications") 127 | except Exception as e: 128 | print(f"❌ Error: {e}") 129 | 130 | 131 | async def main(): 132 | """Run both examples.""" 133 | print("🧲 Magg Messaging Example") 134 | print("=" * 50) 135 | print() 136 | print("This example demonstrates Magg's real-time messaging capabilities.") 137 | print("Make sure you have a Magg server running at http://localhost:8000") 138 | print() 139 | 140 | # Run callback example 141 | await callback_example() 142 | print() 143 | 144 | # Wait a bit between examples 145 | await asyncio.sleep(2) 146 | 147 | # Run class example 148 | await class_example() 149 | 150 | print() 151 | print("✨ Examples completed!") 152 | print() 153 | print("💡 Tips:") 154 | print("- Try adding/removing servers while the client is connected") 155 | print("- Run operations that trigger progress notifications") 156 | print("- Check server logs to see notifications being sent") 157 | 158 | 159 | if __name__ == "__main__": 160 | asyncio.run(main()) 161 | -------------------------------------------------------------------------------- /magg/server/defaults.py: -------------------------------------------------------------------------------- 1 | MAGG_INSTRUCTIONS = """ 2 | Magg (MCP Aggregator) manages and aggregates other MCP servers. 3 | 4 | Key capabilities: 5 | - Add and manage MCP servers with intelligent configuration 6 | - Aggregate tools from multiple servers with prefixes to avoid conflicts 7 | - Search for new MCP servers online 8 | - Export/import configurations 9 | - Smart configuration assistance using LLM sampling 10 | - Expose server metadata as resources for LLM consumption 11 | 12 | Use {self_prefix}_add_server to register new MCP servers, then they will be automatically mounted. 13 | Tools from mounted servers are available with their configured prefixes. 14 | """ 15 | 16 | 17 | MAGG_ADD_SERVER_DOC = """ 18 | Tool: magg_add_server 19 | 20 | Description: 21 | Add a new MCP server. 22 | 23 | Parameters: 24 | name (string) (required) 25 | Unique server name 26 | source (string) (required) 27 | URL of the server package/repository 28 | prefix (string | null) (optional) 29 | Tool prefix (defaults to conformed server name) 30 | command (string | null) (optional) 31 | Full command to run (e.g., 'python server.py', 'npx @playwright/mcp@latest') 32 | NOTE: This should include the full command, not just the executable name. 33 | Arguments will be split automatically. 34 | uri (string | null) (optional) 35 | URI for HTTP servers 36 | env (object | string | null) (optional) 37 | Environment variables (dict or JSON string) 38 | cwd (string | null) (optional) 39 | Working directory (for commands) 40 | notes (string | null) (optional) 41 | Setup notes 42 | enable (boolean | null) (optional) 43 | Whether to enable the server immediately (default: True) 44 | transport (object | string | null) (optional) 45 | Transport-specific configuration (dict or JSON string) 46 | Common options for all command-based servers: 47 | - `keep_alive` (boolean): Keep the process alive between requests (default: true) 48 | 49 | Python servers (command="python ..."): 50 | - `python_cmd` (string): Python executable path (default: sys.executable) 51 | 52 | Node.js servers (command="node ..."): 53 | - `node_cmd` (string): Node executable path (default: "node") 54 | 55 | NPX servers (command="npx ..."): 56 | - `use_package_lock` (boolean): Use package-lock.json if present (default: true) 57 | 58 | UVX servers (command="uvx ..."): 59 | - `python_version` (string): Python version to use (e.g., "3.11") 60 | - `with_packages` (array): Additional packages to install 61 | - `from_package` (string): Install tool from specific package 62 | 63 | HTTP/SSE servers (uri-based): 64 | - `headers` (object): HTTP headers to include 65 | - `auth` (string): Authentication method ("oauth" or bearer token) 66 | - `sse_read_timeout` (number): Timeout for SSE reads in seconds 67 | 68 | Examples: 69 | - Python: `{"keep_alive": false, "python_cmd": "/usr/bin/python3"}` 70 | - UVX: `{"python_version": "3.11", "with_packages": ["requests", "pandas"]}` 71 | - HTTP: `{"headers": {"Authorization": "Bearer token123"}, "sse_read_timeout": 30}` 72 | 73 | Example configurations arguments: 74 | [ 75 | "calc": { 76 | "name": "Calculator MCP", 77 | "source": "https://github.com/wrtnlabs/calculator-mcp", 78 | "prefix": "calc", 79 | "command": "npx -y @wrtnlabs/calculator-mcp@latest" 80 | }, 81 | "playwright": { 82 | "name": "Playwright MCP", 83 | "source": "https://github.com/microsoft/playwright-mcp", 84 | "prefix": "playwright", 85 | "notes": "Browser automation MCP server using Playwright.", 86 | "command": "npx @playwright/mcp@latest" 87 | }, 88 | "test": { 89 | "name": "test", 90 | "source": "play", 91 | "command": "python play/test_server.py" 92 | }, 93 | "hello": { 94 | "name": "hello", 95 | "source": "https://www.npmjs.com/package/mcp-hello-world", 96 | "command": "npx mcp-hello-world@latest" 97 | } 98 | ] 99 | """ 100 | 101 | 102 | PROXY_TOOL_DOC = """ 103 | Tool: proxy 104 | 105 | Description: 106 | Main proxy tool for dynamic access to mounted MCP servers. 107 | 108 | This tool provides a unified interface for: 109 | - Listing available tools, resources, or prompts across servers 110 | - Getting detailed info about specific capabilities 111 | - Calling tools, reading resources, or getting prompts 112 | 113 | Annotations are used to provide rich type information for results, 114 | which can generally be expected to ultimately include JSON-encoded 115 | EmbeddedResource results that can be interpreted by the client. 116 | 117 | Parameters: 118 | action (string) (required) 119 | Action to perform: list, info, or call. 120 | type (string) (required) 121 | Type of MCP capability to interact with: tool, resource, or prompt. 122 | args (object | string | null) (optional) 123 | Arguments for a 'call' action (call tool, read resource, or get prompt). 124 | Can be provided as a dict or JSON string (automatically parsed). 125 | path (string | null) (optional) 126 | Name or URI of the specific tool/resource/prompt (with FastMCP prefixing). 127 | Not allowed for 'list' and 'info' actions. 128 | limit (integer | null) (optional) 129 | Maximum number of items to return (for 'list' action only). Default: 100, Max: 1000 130 | offset (integer | null) (optional) 131 | Number of items to skip (for 'list' action only). Default: 0 132 | filter_server (string | null) (optional) 133 | Filter results by server name prefix (for 'list' action only) 134 | 135 | Example usage (MBRO commands): 136 | - List all tools: 137 | - `call proxy {"action": "list", "type": "tool"}` 138 | 139 | - List tools with pagination: 140 | - `call proxy {"action": "list", "type": "tool", "limit": 50, "offset": 0}` 141 | 142 | - List tools from a specific server: 143 | - `call proxy {"action": "list", "type": "tool", "filter_server": "serena_"}` 144 | 145 | - Get info about a specific tool: 146 | - `call proxy {"action": "info", "type": "tool", "path": "calc:add"}` 147 | 148 | - Call a tool with arguments: 149 | - `call proxy {"action": "call", "type": "tool", "path": "calc:add", "args": {"a": 5, "b": 10}}` 150 | """ 151 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS base 2 | 3 | ARG USER=magg 4 | ARG HOME=/home/${USER} 5 | ARG PACKAGE=${USER} 6 | ARG UID=1000 7 | 8 | ENV PATH="${HOME}/.local/bin:${PATH}" 9 | 10 | RUN apk add --no-cache tini bash curl nano nodejs npm && \ 11 | addgroup -g ${UID} -S ${USER} && \ 12 | adduser -u ${UID} -S -G ${USER} -h ${HOME} -s /bin/bash ${USER} && \ 13 | chmod 755 ${HOME} 14 | 15 | USER ${USER} 16 | WORKDIR ${HOME} 17 | 18 | FROM base AS venv 19 | 20 | ARG PYTHON_VERSION 21 | 22 | ENV PYTHONDONTWRITEBYTECODE=1 \ 23 | PYTHONUNBUFFERED=1 \ 24 | UV_COMPILE_BYTECODE=0 \ 25 | UV_LINK_MODE=copy \ 26 | PATH="${HOME}/.venv/bin:${PATH}" \ 27 | VIRTUAL_ENV="${HOME}/.venv" \ 28 | PS1="(${PACKAGE}) \h:\w\$ " 29 | 30 | # Activating the venv through bash the "normal" way: 31 | # ENV BASH_ENV="${HOME}/.bashrc" # enables .bashrc to be sourced in non-interactive shells e.g. `bash -c` 32 | # RUN echo "source ~/.venv/bin/activate" >> ${HOME}/.bashrc 33 | 34 | RUN curl -LsSf https://astral.sh/uv/install.sh | sh 35 | 36 | ADD --chown=${USER}:${USER} .python-version ./ 37 | 38 | RUN if [ -n "${PYTHON_VERSION}" ]; then \ 39 | echo "${PYTHON_VERSION}" > .python-version; \ 40 | fi 41 | 42 | FROM venv AS proj 43 | 44 | LABEL org.opencontainers.image.source=https://github.com/sitbon/magg \ 45 | org.opencontainers.image.description="Magg - The Model Context Protocol (MCP) Aggregator (Project)" \ 46 | org.opencontainers.image.licenses=AGPLv3 \ 47 | org.opencontainers.image.authors="Phillip Sitbon " 48 | 49 | ARG MAGG_CONFIG_PATH="${HOME}/.magg/config.json" 50 | ARG MAGG_READ_ONLY=false 51 | 52 | ENV MAGG_CONFIG_PATH="${MAGG_CONFIG_PATH}" \ 53 | MAGG_READ_ONLY="${MAGG_READ_ONLY}" 54 | 55 | RUN --mount=type=cache,uid=${UID},gid=${UID},target=${HOME}/.cache/uv \ 56 | --mount=type=bind,source=uv.lock,target=uv.lock \ 57 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 58 | uv sync --locked --no-install-project --no-dev 59 | 60 | # Fix for Python 3.12 extension suffix mismatch on Alpine 61 | # Python 3.12 expects linux-gnu but we have linux-musl wheels 62 | RUN if [ "${PYTHON_VERSION}" = "3.12" ]; then \ 63 | find .venv/lib -name "*.cpython-*-x86_64-linux-musl.so" -exec sh -c \ 64 | 'ln -sf "$(basename "$1")" "$(dirname "$1")/$(echo "$(basename "$1")" | sed "s/-musl\.so$/-gnu.so/")"' _ {} \; ; \ 65 | fi 66 | 67 | ADD --chown=${USER}:${USER} pyproject.toml uv.lock readme.md license.md ./ 68 | ADD --chown=${USER}:${USER} ${PACKAGE}/ ./${PACKAGE}/ 69 | 70 | RUN --mount=type=cache,uid=${UID},gid=${UID},target=${HOME}/.cache/uv \ 71 | uv sync --locked --no-dev 72 | 73 | RUN mkdir -p .magg && \ 74 | chmod 755 .magg 75 | 76 | EXPOSE 8000 77 | 78 | ENTRYPOINT ["/sbin/tini", "--"] 79 | CMD ["magg", "serve", "--http", "--host", "0.0.0.0", "--port", "8000"] 80 | 81 | 82 | FROM proj AS pre 83 | 84 | LABEL org.opencontainers.image.source=https://github.com/sitbon/magg \ 85 | org.opencontainers.image.description="Magg - The Model Context Protocol (MCP) Aggregator (Staging)" \ 86 | org.opencontainers.image.licenses=AGPLv3 \ 87 | org.opencontainers.image.authors="Phillip Sitbon " 88 | 89 | ENV MAGG_LOG_LEVEL=INFO 90 | 91 | USER root 92 | 93 | RUN chown -R root:${USER} ${HOME}/.venv ${HOME}/${PACKAGE} && \ 94 | chmod -R a-w,a+rX ${HOME}/.venv ${HOME}/${PACKAGE} && \ 95 | chown -R ${USER}:${USER} ${HOME}/.magg && \ 96 | chmod -R u+rwX ${HOME}/.magg && \ 97 | if [ "${MAGG_READ_ONLY}" = "true" ] || [ "${MAGG_READ_ONLY}" = "1" ] || [ "${MAGG_READ_ONLY}" = "yes" ]; then \ 98 | chmod -R a-w ${HOME}/.magg; \ 99 | fi 100 | # Note: The above check does not work with volume mounts (e.g. compose), so the real enforcement 101 | # is done in the application code. 102 | 103 | USER ${USER} 104 | 105 | FROM pre AS pro 106 | 107 | LABEL org.opencontainers.image.source=https://github.com/sitbon/magg \ 108 | org.opencontainers.image.description="Magg - The Model Context Protocol (MCP) Aggregator" \ 109 | org.opencontainers.image.licenses=AGPLv3 \ 110 | org.opencontainers.image.authors="Phillip Sitbon " 111 | 112 | ENV MAGG_LOG_LEVEL=WARNING 113 | 114 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 115 | CMD ["magg", "status"] 116 | 117 | FROM proj AS dev 118 | 119 | LABEL org.opencontainers.image.source=https://github.com/sitbon/magg \ 120 | org.opencontainers.image.description="Magg - The Model Context Protocol (MCP) Aggregator (Development)" \ 121 | org.opencontainers.image.licenses=AGPLv3 \ 122 | org.opencontainers.image.authors="Phillip Sitbon " 123 | 124 | ENV MAGG_LOG_LEVEL=DEBUG 125 | 126 | ADD --chown=${USER}:${USER} test/ ./test/ 127 | 128 | RUN --mount=type=cache,uid=1000,gid=1000,target=${HOME}/.cache/uv \ 129 | --mount=type=bind,source=uv.lock,target=uv.lock \ 130 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 131 | uv sync --locked --dev 132 | 133 | FROM dev AS pkg 134 | 135 | LABEL org.opencontainers.image.source=https://github.com/sitbon/magg \ 136 | org.opencontainers.image.description="Magg - The Model Context Protocol (MCP) Aggregator (Packaging)" \ 137 | org.opencontainers.image.licenses=AGPLv3 \ 138 | org.opencontainers.image.authors="Phillip Sitbon " 139 | 140 | RUN --mount=type=cache,uid=${UID},gid=${UID},target=${HOME}/.cache/uv \ 141 | --mount=type=bind,source=uv.lock,target=uv.lock \ 142 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 143 | uv build 144 | 145 | FROM venv AS user 146 | 147 | LABEL org.opencontainers.image.source=https://github.com/sitbon/magg \ 148 | org.opencontainers.image.description="Magg - The Model Context Protocol (MCP) Aggregator (User Environment)" \ 149 | org.opencontainers.image.licenses=AGPLv3 \ 150 | org.opencontainers.image.authors="Phillip Sitbon " 151 | 152 | ENV PS1="(user) \h:\w\$ " 153 | 154 | COPY --from=pkg ${HOME}/dist/ ${HOME}/dist/ 155 | 156 | RUN uv init --no-workspace --no-package --no-readme --no-description --name user && \ 157 | uv sync && \ 158 | uv add "magg[dev] @ $(ls -t1 dist/*.whl | head -n 1)" 159 | 160 | CMD ["bash"] 161 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Tests"] 6 | types: 7 | - completed 8 | branches: [main] 9 | 10 | jobs: 11 | check-version: 12 | name: Check Version Change 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | runs-on: ubuntu-latest 15 | environment: publish 16 | outputs: 17 | should_publish: ${{ steps.version_check.outputs.changed }} 18 | version: ${{ steps.version_check.outputs.version }} 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | token: ${{ secrets.PAT_TOKEN }} 26 | 27 | - name: Install uv 28 | uses: astral-sh/setup-uv@v6 29 | with: 30 | enable-cache: true 31 | 32 | - name: Check if version changed 33 | id: version_check 34 | run: | 35 | # Get current version from pyproject.toml 36 | CURRENT_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") 37 | echo "version=${CURRENT_VERSION}" >> $GITHUB_OUTPUT 38 | 39 | # Get last published version from latest-publish tag 40 | if git show-ref --tags --quiet --verify refs/tags/latest-publish; then 41 | # Checkout the last published commit 42 | git checkout latest-publish 43 | LAST_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") 44 | git checkout - 45 | else 46 | LAST_VERSION="" 47 | fi 48 | 49 | echo "Current version: ${CURRENT_VERSION}" 50 | echo "Last published version: ${LAST_VERSION:-none}" 51 | 52 | if [ "${CURRENT_VERSION}" != "${LAST_VERSION}" ]; then 53 | echo "Version changed, will publish" 54 | echo "changed=true" >> $GITHUB_OUTPUT 55 | else 56 | echo "Version unchanged, skipping publish" 57 | echo "changed=false" >> $GITHUB_OUTPUT 58 | fi 59 | 60 | publish: 61 | name: Publish PyPI Package and GitHub Release 62 | needs: check-version 63 | if: ${{ needs.check-version.outputs.should_publish == 'true' }} 64 | runs-on: ubuntu-latest 65 | environment: publish 66 | permissions: 67 | contents: write 68 | id-token: write 69 | 70 | steps: 71 | - name: Checkout code 72 | uses: actions/checkout@v4 73 | with: 74 | fetch-depth: 0 # Need full history for commit count 75 | token: ${{ secrets.PAT_TOKEN }} 76 | 77 | - name: Install uv 78 | uses: astral-sh/setup-uv@v6 79 | with: 80 | enable-cache: true 81 | 82 | - name: Set up Python 83 | run: uv python install 84 | 85 | - name: Install dependencies 86 | run: | 87 | uv sync --all-groups --locked 88 | 89 | - name: Import GPG key 90 | uses: crazy-max/ghaction-import-gpg@v6 91 | with: 92 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 93 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 94 | git_user_signingkey: true 95 | git_commit_gpgsign: true 96 | git_tag_gpgsign: true 97 | 98 | - name: Configure Git 99 | run: | 100 | git config user.name "${{ vars.SIGNED_COMMIT_USER }}" 101 | git config user.email "${{ vars.SIGNED_COMMIT_EMAIL }}" 102 | git config commit.gpgsign true 103 | git config tag.gpgsign true 104 | 105 | - name: Build and tag release 106 | run: | 107 | # Get current version (3-part) 108 | V=${{ needs.check-version.outputs.version }} 109 | echo "VERSION=${V}" >> $GITHUB_ENV 110 | 111 | # Build the package 112 | UV_FROZEN=true uv build 113 | 114 | # Create a 3-part version tag 115 | TAG="v${V}" 116 | echo "TAG=${TAG}" >> $GITHUB_ENV 117 | TAG_MESSAGE="[Automatic] Release Version ${V} from $(git rev-parse --short HEAD)" 118 | git tag -s -m "${TAG_MESSAGE}" "${TAG}" 119 | 120 | # Create or update 2-digit version tag (simple, unsigned) 121 | MAJOR_MINOR=$(echo ${V} | cut -d. -f1-2) 122 | TAG_2DIGIT="v${MAJOR_MINOR}" 123 | # Delete existing 2-digit tag if it exists 124 | git tag -d "${TAG_2DIGIT}" 2>/dev/null || true 125 | git push origin :refs/tags/"${TAG_2DIGIT}" 2>/dev/null || true 126 | # Create new simple unsigned tag 127 | git tag --no-sign "${TAG_2DIGIT}" 128 | 129 | # Update latest-publish tag after getting changelog 130 | LATEST_TAG="latest-publish" 131 | 132 | # Get changelog since last release with rich formatting 133 | echo "CHANGELOG<> $GITHUB_ENV 134 | git log ${LATEST_TAG}..${{ github.sha }} --pretty=format:'### [%s](https://github.com/${{ github.repository }}/commit/%H)%nDate: %ad%n%n%b%n' | sed '/^Signed-off-by:/d' | sed 's/^$/>/g' >> $GITHUB_ENV 135 | echo "EOFEOF" >> $GITHUB_ENV 136 | 137 | # Delete remote latest-publish tag FIRST (before creating new one) 138 | git push origin :refs/tags/${LATEST_TAG} || true 139 | 140 | # Now create the new latest-publish tag locally 141 | git tag -d ${LATEST_TAG} || true 142 | git tag --no-sign ${LATEST_TAG} 143 | 144 | # Push all tags to remote 145 | git push origin --tags 146 | 147 | - name: Create GitHub Release 148 | uses: softprops/action-gh-release@v2 149 | with: 150 | tag_name: ${{ env.TAG }} 151 | name: 🧲 Magg Release v${{ env.VERSION }} 152 | body: | 153 | ## Changes 154 | ${{ env.CHANGELOG }} 155 | 156 | ## Installation 157 | ```bash 158 | uv add magg==${{ env.VERSION }} 159 | ``` 160 | files: | 161 | dist/*.tar.gz 162 | dist/*.whl 163 | 164 | - name: Publish to PyPI 165 | if: success() 166 | env: 167 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 168 | run: | 169 | uv publish --token $PYPI_TOKEN -------------------------------------------------------------------------------- /test/magg/test_kit_info.py: -------------------------------------------------------------------------------- 1 | """Tests for KitInfo model and kit metadata functionality.""" 2 | 3 | import json 4 | import pytest 5 | import tempfile 6 | from pathlib import Path 7 | 8 | from magg.settings import KitInfo, MaggConfig, ConfigManager 9 | 10 | 11 | class TestKitInfo: 12 | """Test KitInfo model.""" 13 | 14 | def test_kit_info_creation(self): 15 | """Test creating KitInfo with all fields.""" 16 | kit_info = KitInfo( 17 | name="test-kit", 18 | description="Test kit description", 19 | path="/path/to/kit.json", 20 | source="file" 21 | ) 22 | 23 | assert kit_info.name == "test-kit" 24 | assert kit_info.description == "Test kit description" 25 | assert kit_info.path == "/path/to/kit.json" 26 | assert kit_info.source == "file" 27 | 28 | def test_kit_info_minimal(self): 29 | """Test creating KitInfo with only required fields.""" 30 | kit_info = KitInfo(name="minimal-kit") 31 | 32 | assert kit_info.name == "minimal-kit" 33 | assert kit_info.description is None 34 | assert kit_info.path is None 35 | assert kit_info.source is None 36 | 37 | def test_kit_info_inline_source(self): 38 | """Test creating KitInfo for inline kit (no file).""" 39 | kit_info = KitInfo( 40 | name="inline-kit", 41 | description="Kit created programmatically", 42 | source="inline" 43 | ) 44 | 45 | assert kit_info.name == "inline-kit" 46 | assert kit_info.description == "Kit created programmatically" 47 | assert kit_info.path is None 48 | assert kit_info.source == "inline" 49 | 50 | 51 | class TestKitInfoPersistence: 52 | """Test saving and loading kit metadata.""" 53 | 54 | def test_save_load_kit_info(self, tmp_path): 55 | """Test saving and loading config with KitInfo.""" 56 | config_path = tmp_path / "config.json" 57 | manager = ConfigManager(str(config_path)) 58 | 59 | # Create config with kit metadata 60 | config = MaggConfig() 61 | config.kits["file-kit"] = KitInfo( 62 | name="file-kit", 63 | description="Kit from file", 64 | path="/path/to/file-kit.json", 65 | source="file" 66 | ) 67 | config.kits["inline-kit"] = KitInfo( 68 | name="inline-kit", 69 | description="Programmatic kit", 70 | source="inline" 71 | ) 72 | 73 | # Save config 74 | assert manager.save_config(config) is True 75 | 76 | # Verify JSON structure 77 | with open(config_path) as f: 78 | data = json.load(f) 79 | 80 | assert "kits" in data 81 | assert "file-kit" in data["kits"] 82 | assert data["kits"]["file-kit"]["name"] == "file-kit" 83 | assert data["kits"]["file-kit"]["description"] == "Kit from file" 84 | assert data["kits"]["file-kit"]["path"] == "/path/to/file-kit.json" 85 | assert data["kits"]["file-kit"]["source"] == "file" 86 | 87 | assert "inline-kit" in data["kits"] 88 | assert data["kits"]["inline-kit"]["name"] == "inline-kit" 89 | assert data["kits"]["inline-kit"]["description"] == "Programmatic kit" 90 | assert "path" not in data["kits"]["inline-kit"] # None values excluded 91 | assert data["kits"]["inline-kit"]["source"] == "inline" 92 | 93 | # Load config back 94 | loaded = manager.load_config() 95 | 96 | assert len(loaded.kits) == 2 97 | assert loaded.kits["file-kit"].name == "file-kit" 98 | assert loaded.kits["file-kit"].description == "Kit from file" 99 | assert loaded.kits["file-kit"].path == "/path/to/file-kit.json" 100 | assert loaded.kits["file-kit"].source == "file" 101 | 102 | assert loaded.kits["inline-kit"].name == "inline-kit" 103 | assert loaded.kits["inline-kit"].description == "Programmatic kit" 104 | assert loaded.kits["inline-kit"].path is None 105 | assert loaded.kits["inline-kit"].source == "inline" 106 | 107 | def test_backward_compatibility(self, tmp_path): 108 | """Test loading old format (list of kit names) converts to new format.""" 109 | config_path = tmp_path / "config.json" 110 | 111 | # Create old-style config 112 | old_config = { 113 | "servers": {}, 114 | "kits": ["kit1", "kit2", "kit3"] 115 | } 116 | 117 | with open(config_path, "w") as f: 118 | json.dump(old_config, f) 119 | 120 | # Load config 121 | manager = ConfigManager(str(config_path)) 122 | config = manager.load_config() 123 | 124 | # Should convert to new format 125 | assert isinstance(config.kits, dict) 126 | assert len(config.kits) == 3 127 | assert "kit1" in config.kits 128 | assert "kit2" in config.kits 129 | assert "kit3" in config.kits 130 | 131 | # Check converted kit info 132 | assert config.kits["kit1"].name == "kit1" 133 | assert config.kits["kit1"].source == "legacy" 134 | assert config.kits["kit1"].description is None 135 | assert config.kits["kit1"].path is None 136 | 137 | def test_kit_info_no_env_pollution(self): 138 | """Test that KitInfo doesn't pick up environment variables.""" 139 | import os 140 | 141 | # Set some environment variables that might conflict 142 | old_path = os.environ.get("PATH") 143 | old_name = os.environ.get("NAME") 144 | 145 | try: 146 | os.environ["PATH"] = "/usr/bin:/bin" 147 | os.environ["NAME"] = "environment-name" 148 | 149 | # Create KitInfo - should not pick up env vars 150 | kit_info = KitInfo( 151 | name="test-kit", 152 | path="/path/to/kit.json" 153 | ) 154 | 155 | assert kit_info.name == "test-kit" 156 | assert kit_info.path == "/path/to/kit.json" 157 | assert kit_info.path != "/usr/bin:/bin" 158 | 159 | finally: 160 | # Restore environment 161 | if old_path is not None: 162 | os.environ["PATH"] = old_path 163 | else: 164 | os.environ.pop("PATH", None) 165 | 166 | if old_name is not None: 167 | os.environ["NAME"] = old_name 168 | else: 169 | os.environ.pop("NAME", None) 170 | -------------------------------------------------------------------------------- /test/magg/test_config_migration.py: -------------------------------------------------------------------------------- 1 | """Test configuration functionality.""" 2 | 3 | import pytest 4 | import tempfile 5 | import json 6 | from pathlib import Path 7 | 8 | from magg.settings import ConfigManager, ServerConfig, MaggConfig 9 | 10 | 11 | class TestConfigStructure: 12 | """Test configuration structure and functionality.""" 13 | 14 | @pytest.fixture 15 | def temp_config_file(self): 16 | """Create a temporary config file.""" 17 | with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: 18 | config_path = Path(f.name) 19 | yield config_path 20 | if config_path.exists(): 21 | config_path.unlink() 22 | 23 | def test_new_config_structure(self, temp_config_file): 24 | """Test creating and using new config structure.""" 25 | config_manager = ConfigManager(str(temp_config_file)) 26 | config = config_manager.load_config() 27 | 28 | # Test adding servers 29 | server1 = ServerConfig( 30 | name="weatherserver", 31 | source="https://github.com/example/weather-mcp", 32 | command="npx", 33 | args=["weather-mcp"], 34 | prefix="weather" 35 | ) 36 | 37 | server2 = ServerConfig( 38 | name="filesystemserver", 39 | source="https://github.com/example/filesystem-mcp", 40 | uri="http://localhost:8080" 41 | ) 42 | 43 | config.add_server(server1) 44 | config.add_server(server2) 45 | 46 | # Save and reload 47 | assert config_manager.save_config(config) is True 48 | 49 | loaded_config = config_manager.load_config() 50 | assert len(loaded_config.servers) == 2 51 | 52 | # Verify servers loaded correctly 53 | weather = loaded_config.servers["weatherserver"] 54 | assert weather.source == "https://github.com/example/weather-mcp" 55 | assert weather.command == "npx" 56 | assert weather.prefix == "weather" 57 | 58 | filesystem = loaded_config.servers["filesystemserver"] 59 | assert filesystem.uri == "http://localhost:8080" 60 | assert filesystem.prefix is None # Default is None now 61 | 62 | def test_config_serialization_format(self, temp_config_file): 63 | """Test the actual JSON format of saved config.""" 64 | config_manager = ConfigManager(str(temp_config_file)) 65 | config = MaggConfig() 66 | 67 | # Add a server with all fields 68 | server = ServerConfig( 69 | name="testserver", 70 | source="https://github.com/test/test-mcp", 71 | prefix="test", 72 | command="python", 73 | args=["-m", "test_mcp"], 74 | env={"TEST_VAR": "value"}, 75 | cwd="/tmp/test", 76 | notes="Test server for unit tests", 77 | enabled=False 78 | ) 79 | 80 | config.add_server(server) 81 | config_manager.save_config(config) 82 | 83 | # Read raw JSON 84 | with open(temp_config_file, 'r') as f: 85 | raw_data = json.load(f) 86 | 87 | # Check structure 88 | assert "servers" in raw_data 89 | assert "testserver" in raw_data["servers"] 90 | 91 | server_data = raw_data["servers"]["testserver"] 92 | assert "name" not in server_data # Name left out and used as a key 93 | assert server_data["source"] == "https://github.com/test/test-mcp" 94 | assert server_data["prefix"] == "test" 95 | assert server_data["command"] == "python" 96 | assert server_data["args"] == ["-m", "test_mcp"] 97 | assert server_data["env"] == {"TEST_VAR": "value"} 98 | assert server_data["cwd"] == "/tmp/test" 99 | assert server_data["notes"] == "Test server for unit tests" 100 | assert server_data["enabled"] is False 101 | 102 | def test_minimal_server_config(self, temp_config_file): 103 | """Test minimal server configuration.""" 104 | config_manager = ConfigManager(str(temp_config_file)) 105 | config = MaggConfig() 106 | 107 | # Minimal server - just name and source 108 | server = ServerConfig( 109 | name="minimal", 110 | source="https://example.com" 111 | ) 112 | 113 | config.add_server(server) 114 | config_manager.save_config(config) 115 | 116 | # Reload and check defaults 117 | loaded_config = config_manager.load_config() 118 | minimal = loaded_config.servers["minimal"] 119 | 120 | assert minimal.name == "minimal" 121 | assert minimal.source == "https://example.com" 122 | assert minimal.prefix is None # Default is None now 123 | assert minimal.enabled is True # Default enabled 124 | assert minimal.command is None 125 | assert minimal.args is None 126 | assert minimal.uri is None 127 | assert minimal.env is None 128 | assert minimal.cwd is None 129 | assert minimal.notes is None 130 | 131 | def test_environment_variable_override(self): 132 | """Test that environment variables can override settings.""" 133 | import os 134 | 135 | # Set environment variable 136 | os.environ["MAGG_LOG_LEVEL"] = "DEBUG" 137 | 138 | try: 139 | config = MaggConfig() 140 | assert config.log_level == "DEBUG" 141 | finally: 142 | # Clean up 143 | del os.environ["MAGG_LOG_LEVEL"] 144 | 145 | def test_invalid_server_in_config(self, temp_config_file): 146 | """Test handling of servers with names that need prefix generation.""" 147 | # Write config with server that needs prefix adjustment 148 | invalid_config = { 149 | "servers": { 150 | "valid": { 151 | "source": "https://example.com" 152 | }, 153 | "123invalid": { 154 | "source": "https://example.com" 155 | } 156 | } 157 | } 158 | 159 | with open(temp_config_file, 'w') as f: 160 | json.dump(invalid_config, f) 161 | 162 | config_manager = ConfigManager(str(temp_config_file)) 163 | config = config_manager.load_config() 164 | 165 | # Both servers should load now with auto-generated prefixes 166 | assert len(config.servers) == 2 167 | assert "valid" in config.servers 168 | assert "123invalid" in config.servers 169 | 170 | # Check that the problematic server now has None prefix 171 | assert config.servers["123invalid"].name == "123invalid" 172 | assert config.servers["123invalid"].prefix is None # Default is None now 173 | -------------------------------------------------------------------------------- /test/magg/test_error_handling.py: -------------------------------------------------------------------------------- 1 | """Test error handling for invalid servers and edge cases.""" 2 | 3 | import pytest 4 | import asyncio 5 | import tempfile 6 | import json 7 | from pathlib import Path 8 | from unittest.mock import patch, MagicMock, AsyncMock 9 | 10 | from magg.settings import ConfigManager, ServerConfig, MaggConfig 11 | from magg.server.server import MaggServer 12 | 13 | 14 | class TestErrorHandling: 15 | """Test error handling for various failure scenarios.""" 16 | 17 | @pytest.mark.asyncio 18 | async def test_invalid_server_connection(self): 19 | """Test handling of invalid server connections.""" 20 | # Create a server config with invalid command 21 | invalid_server = ServerConfig( 22 | name="invalidserver", 23 | source="https://example.com/invalid", 24 | prefix="invalid", 25 | command="nonexistent-command", 26 | args=["--invalid-args"] 27 | ) 28 | 29 | # Test that the invalid server is created but will fail on mount 30 | assert invalid_server.command == "nonexistent-command" 31 | assert invalid_server.args == ["--invalid-args"] 32 | 33 | @pytest.mark.asyncio 34 | async def test_malformed_config_handling(self): 35 | """Test handling of malformed configuration files.""" 36 | with tempfile.TemporaryDirectory() as tmpdir: 37 | config_path = Path(tmpdir) / "malformed.json" 38 | 39 | # Write malformed JSON 40 | with open(config_path, 'w') as f: 41 | f.write('{"servers": {invalid json}') 42 | 43 | # ConfigManager should handle this gracefully 44 | manager = ConfigManager(str(config_path)) 45 | config = manager.load_config() 46 | 47 | # Should return empty config on parse error 48 | assert config.servers == {} 49 | 50 | @pytest.mark.asyncio 51 | async def test_missing_command_handling(self): 52 | """Test server without command or URI.""" 53 | # This should be valid - server can be created without command/URI 54 | server = ServerConfig( 55 | name="nocommand", 56 | source="https://example.com/test" 57 | ) 58 | 59 | assert server.command is None 60 | assert server.uri is None 61 | 62 | @pytest.mark.asyncio 63 | async def test_duplicate_server_names(self): 64 | """Test handling of duplicate server names.""" 65 | with tempfile.TemporaryDirectory() as tmpdir: 66 | config_path = Path(tmpdir) / "config.json" 67 | server = MaggServer(str(config_path)) 68 | 69 | # Add first server 70 | result1 = await server.add_server( 71 | name="duplicate", 72 | source="https://example.com/1", 73 | command="echo test1" 74 | ) 75 | assert result1.is_success 76 | 77 | # Try to add duplicate 78 | result2 = await server.add_server( 79 | name="duplicate", 80 | source="https://example.com/2", 81 | command="echo test2" 82 | ) 83 | assert result2.is_error 84 | assert "already exists" in result2.errors[0] 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_invalid_url_format_handling(self): 89 | """Test handling of invalid URL formats.""" 90 | # URLs are just strings, no validation enforced 91 | server = ServerConfig( 92 | name="test", 93 | source="not-a-valid-url" 94 | ) 95 | assert server.source == "not-a-valid-url" 96 | 97 | @pytest.mark.asyncio 98 | async def test_environment_variable_handling(self): 99 | """Test handling of environment variables in server configs.""" 100 | server = ServerConfig( 101 | name="envtest", 102 | source="https://example.com", 103 | env={"TEST_VAR": "value", "ANOTHER_VAR": "another"} 104 | ) 105 | 106 | assert server.env == {"TEST_VAR": "value", "ANOTHER_VAR": "another"} 107 | 108 | 109 | class TestConfigValidation: 110 | """Test configuration validation and error cases.""" 111 | 112 | def test_empty_config_creation(self): 113 | """Test creating empty configuration.""" 114 | config = MaggConfig() 115 | assert config.servers == {} 116 | assert len(config.get_enabled_servers()) == 0 117 | 118 | def test_server_without_url(self): 119 | """Test that server requires URL.""" 120 | with pytest.raises(Exception): # Pydantic will raise validation error 121 | ServerConfig(name="test") # Missing required 'url' field 122 | 123 | def test_server_without_required_fields(self): 124 | """Test server with minimal required fields.""" 125 | # Only name and url are required 126 | server = ServerConfig( 127 | name="minimal", 128 | source="https://example.com" 129 | ) 130 | 131 | assert server.name == "minimal" 132 | assert server.source == "https://example.com" 133 | assert server.command is None 134 | assert server.args is None 135 | 136 | 137 | class TestMountingErrors: 138 | """Test error handling during server mounting.""" 139 | 140 | @pytest.mark.asyncio 141 | async def test_mount_nonexistent_command(self): 142 | """Test mounting server with non-existent command.""" 143 | server = MaggServer() 144 | 145 | with patch.object(server.server_manager, 'mount_server', new_callable=AsyncMock) as mock_mount: 146 | mock_mount.return_value = False # Simulate mount failure 147 | 148 | result = await server.add_server( 149 | name="badcommand", 150 | source="https://example.com", 151 | command="this-command-does-not-exist --help" 152 | ) 153 | 154 | assert result.is_error 155 | assert "Failed to mount" in result.errors[0] 156 | 157 | @pytest.mark.asyncio 158 | async def test_mount_with_invalid_working_dir(self): 159 | """Test mounting server with invalid working directory.""" 160 | server = MaggServer() 161 | 162 | with patch('magg.server.server.validate_working_directory') as mock_validate: 163 | mock_validate.return_value = (None, "Invalid working directory") 164 | 165 | result = await server.add_server( 166 | name="badworkdir", 167 | source="https://example.com", 168 | command="python test.py", 169 | cwd="/nonexistent/directory" 170 | ) 171 | 172 | assert result.is_error 173 | assert "Invalid working directory" in result.errors[0] 174 | -------------------------------------------------------------------------------- /test/magg/test_in_memory.py: -------------------------------------------------------------------------------- 1 | """Test in-memory Magg server functionality via FastMCPTransport.""" 2 | import json 3 | import sys 4 | import pytest 5 | from fastmcp import Client 6 | from fastmcp.client import FastMCPTransport 7 | 8 | from magg.server.server import MaggServer 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_in_memory_basic_tools(tmp_path): 13 | """Test basic Magg tools work via in-memory transport.""" 14 | # Create config in temp directory 15 | config_path = tmp_path / ".magg" / "config.json" 16 | config_path.parent.mkdir() 17 | 18 | # Create server 19 | server = MaggServer(str(config_path)) 20 | await server.setup() 21 | 22 | # Create in-memory client 23 | client = Client(FastMCPTransport(server.mcp)) 24 | 25 | async with client: 26 | # Test listing tools 27 | tools = await client.list_tools() 28 | tool_names = {tool.name for tool in tools} 29 | 30 | # Should have Magg management tools 31 | assert "magg_add_server" in tool_names 32 | assert "magg_list_servers" in tool_names 33 | assert "magg_remove_server" in tool_names 34 | assert "proxy" in tool_names 35 | 36 | # Test listing servers (should be empty) 37 | result = await client.call_tool("magg_list_servers", {}) 38 | assert hasattr(result, 'content') 39 | assert len(result.content) == 1 40 | assert result.content[0].type == "text" 41 | assert "[]" in result.content[0].text # Empty list 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_in_memory_server_management(tmp_path): 46 | """Test adding and managing servers via in-memory transport.""" 47 | config_path = tmp_path / ".magg" / "config.json" 48 | config_path.parent.mkdir() 49 | 50 | server = MaggServer(str(config_path)) 51 | await server.setup() 52 | 53 | client = Client(FastMCPTransport(server.mcp)) 54 | 55 | async with client: 56 | # Add a test server 57 | result = await client.call_tool("magg_add_server", { 58 | "name": "test-server", 59 | "source": "https://example.com/test", 60 | "command": "echo test", # Full command string 61 | "enable": False # Don't try to actually mount 62 | }) 63 | 64 | assert hasattr(result, 'content') 65 | assert len(result.content) == 1 66 | assert "server_added" in result.content[0].text 67 | 68 | # List servers 69 | result = await client.call_tool("magg_list_servers", {}) 70 | assert hasattr(result, 'content') 71 | assert len(result.content) == 1 72 | response = json.loads(result.content[0].text) 73 | assert response["errors"] is None 74 | servers = response["output"] 75 | assert len(servers) == 1 76 | assert servers[0]["name"] == "test-server" 77 | assert servers[0]["enabled"] is False 78 | 79 | # Remove server 80 | result = await client.call_tool("magg_remove_server", { 81 | "name": "test-server" 82 | }) 83 | assert "server_removed" in result.content[0].text 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_in_memory_proxy_tool(tmp_path): 88 | """Test proxy tool works via in-memory transport.""" 89 | config_path = tmp_path / ".magg" / "config.json" 90 | config_path.parent.mkdir() 91 | 92 | server = MaggServer(str(config_path)) 93 | await server.setup() 94 | 95 | client = Client(FastMCPTransport(server.mcp)) 96 | 97 | async with client: 98 | # Use proxy to list tools 99 | result = await client.call_tool("proxy", { 100 | "action": "list", 101 | "type": "tool" 102 | }) 103 | 104 | # Should return embedded resource with tool list 105 | assert hasattr(result, 'content') 106 | assert len(result.content) == 1 107 | assert result.content[0].type == "resource" 108 | assert result.content[0].resource.mimeType == "application/json" 109 | 110 | # Check annotations 111 | assert hasattr(result.content[0], "annotations") 112 | assert result.content[0].annotations.proxyAction == "list" 113 | assert result.content[0].annotations.proxyType == "tool" 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_in_memory_tool_call_requires_setup(tmp_path): 118 | """Test that external server tools require setup() to be called.""" 119 | config_path = tmp_path / ".magg" / "config.json" 120 | config_path.parent.mkdir() 121 | 122 | # Create a simple test MCP server script 123 | test_server = tmp_path / "test_server.py" 124 | test_server.write_text(""" 125 | import sys 126 | from fastmcp import FastMCP 127 | 128 | mcp = FastMCP("test-server") 129 | 130 | @mcp.tool() 131 | async def test_add(a: int, b: int) -> str: 132 | return f"Result: {a + b}" 133 | 134 | if __name__ == "__main__": 135 | mcp.run() 136 | """) 137 | 138 | # Create config with the test server 139 | config_data = { 140 | "servers": { 141 | "test": { 142 | "name": "test", 143 | "source": str(tmp_path), 144 | "prefix": "test", 145 | "command": sys.executable, 146 | "args": [str(test_server)], 147 | "enabled": True 148 | } 149 | } 150 | } 151 | config_path.write_text(json.dumps(config_data)) 152 | 153 | # Create server WITHOUT calling setup() 154 | server = MaggServer(str(config_path)) 155 | client = Client(FastMCPTransport(server.mcp)) 156 | 157 | async with client: 158 | # List tools - should NOT have test server tools 159 | tools = await client.list_tools() 160 | tool_names = {tool.name for tool in tools} 161 | assert "test_test_add" not in tool_names 162 | 163 | # Try to call the tool - should fail 164 | with pytest.raises(Exception) as exc_info: 165 | await client.call_tool("test_test_add", {"a": 5, "b": 3}) 166 | assert "Unknown tool" in str(exc_info.value) or "not found" in str(exc_info.value) 167 | 168 | # Now call setup() 169 | await server.setup() 170 | 171 | # Create new client after setup 172 | client_after = Client(FastMCPTransport(server.mcp)) 173 | 174 | async with client_after: 175 | # List tools - should NOW have test server tools 176 | tools = await client_after.list_tools() 177 | tool_names = {tool.name for tool in tools} 178 | assert "test_test_add" in tool_names 179 | 180 | # Call the tool - should work 181 | result = await client_after.call_tool("test_test_add", {"a": 5, "b": 3}) 182 | assert hasattr(result, 'content') 183 | assert len(result.content) == 1 184 | assert result.content[0].text == "Result: 8" 185 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish.yml: -------------------------------------------------------------------------------- 1 | name: Manual Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | dry_run: 7 | description: 'Dry run (no actual publish)' 8 | required: true 9 | default: true 10 | type: boolean 11 | create_github_release: 12 | description: 'Create GitHub release' 13 | required: false 14 | default: 'auto' 15 | type: choice 16 | options: 17 | - 'auto' 18 | - 'yes' 19 | - 'no' 20 | 21 | jobs: 22 | manual-publish: 23 | name: Manual Publish 24 | runs-on: ubuntu-latest 25 | environment: publish 26 | permissions: 27 | contents: write 28 | id-token: write 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Install uv 36 | uses: astral-sh/setup-uv@v6 37 | with: 38 | enable-cache: true 39 | 40 | - name: Set up Python 41 | run: uv python install 42 | 43 | - name: Install dependencies 44 | run: | 45 | uv sync --all-groups --locked 46 | 47 | - name: Import GPG key 48 | uses: crazy-max/ghaction-import-gpg@v6 49 | with: 50 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 51 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 52 | git_user_signingkey: true 53 | git_commit_gpgsign: true 54 | git_tag_gpgsign: true 55 | 56 | - name: Configure Git 57 | run: | 58 | git config user.name "${{ vars.SIGNED_COMMIT_USER }}" 59 | git config user.email "${{ vars.SIGNED_COMMIT_EMAIL }}" 60 | git config commit.gpgsign true 61 | git config tag.gpgsign true 62 | 63 | - name: Validate version 64 | id: validate 65 | run: | 66 | # Validate and get version info 67 | VALIDATION_JSON=$(python scripts/validate_manual_release.py) 68 | echo "Validation result:" 69 | echo "${VALIDATION_JSON}" | jq . 70 | 71 | # Extract values 72 | VERSION=$(echo "${VALIDATION_JSON}" | jq -r .version) 73 | IS_POSTRELEASE=$(echo "${VALIDATION_JSON}" | jq -r .is_postrelease) 74 | MESSAGE=$(echo "${VALIDATION_JSON}" | jq -r .message) 75 | 76 | echo "${MESSAGE}" 77 | 78 | # Set outputs 79 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 80 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 81 | echo "is_postrelease=${IS_POSTRELEASE}" >> $GITHUB_OUTPUT 82 | 83 | - name: Check if tag exists 84 | run: | 85 | TAG="v${VERSION}" 86 | if git show-ref --tags --quiet --verify "refs/tags/${TAG}"; then 87 | echo "❌ Tag ${TAG} already exists!" 88 | exit 1 89 | fi 90 | echo "✅ Tag ${TAG} does not exist, can proceed" 91 | 92 | - name: Build package 93 | if: ${{ !inputs.dry_run }} 94 | run: UV_FROZEN=true uv build 95 | 96 | - name: Create and push tag 97 | if: ${{ !inputs.dry_run }} 98 | run: | 99 | TAG="v${VERSION}" 100 | TAG_MESSAGE="[Manual] Release Version ${VERSION}" 101 | git tag -s -m "${TAG_MESSAGE}" "${TAG}" 102 | 103 | # Get changelog for GitHub release 104 | echo "CHANGELOG<> $GITHUB_ENV 105 | git log latest-publish..HEAD --pretty=format:'### [%s](https://github.com/${{ github.repository }}/commit/%H)%n*%ad*%n%n%b%n' | sed '/^Signed-off-by:/d' | sed 's/^$/>/g' >> $GITHUB_ENV 106 | echo "EOFEOF" >> $GITHUB_ENV 107 | 108 | git push origin "${TAG}" 109 | 110 | - name: Update latest-publish tag for post-releases 111 | if: ${{ !inputs.dry_run && steps.validate.outputs.is_postrelease == 'true' }} 112 | run: | 113 | # Check if this is a post-release of the latest published version 114 | if git show-ref --tags --quiet --verify refs/tags/latest-publish; then 115 | # Get the version from latest-publish tag 116 | git checkout latest-publish 117 | LATEST_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") 118 | git checkout - 119 | 120 | # Extract base versions (first 3 parts only) 121 | LATEST_BASE=$(echo "${LATEST_VERSION}" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+') 122 | CURRENT_BASE=$(echo "${VERSION}" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+') 123 | 124 | if [ "${LATEST_BASE}" = "${CURRENT_BASE}" ]; then 125 | echo "Updating latest-publish tag for post-release ${VERSION}" 126 | git push origin :refs/tags/latest-publish || true 127 | git tag -d latest-publish || true 128 | git tag --no-sign latest-publish 129 | git push origin refs/tags/latest-publish 130 | else 131 | echo "Not updating latest-publish: post-release ${VERSION} (base ${CURRENT_BASE}) is not based on latest ${LATEST_VERSION} (base ${LATEST_BASE})" 132 | fi 133 | else 134 | echo "No latest-publish tag found, skipping update" 135 | fi 136 | 137 | - name: Create GitHub Release 138 | if: ${{ !inputs.dry_run && (inputs.create_github_release == 'yes' || (inputs.create_github_release == 'auto' && steps.validate.outputs.is_postrelease == 'true')) }} 139 | uses: softprops/action-gh-release@v2 140 | with: 141 | tag_name: v${{ env.VERSION }} 142 | name: 🧲 Magg Release v${{ env.VERSION }} 143 | body: | 144 | ***Note: This is a manual release. PyPI availability depends on the type of release (pre, post, or dev).*** 145 | 146 | ## Changes 147 | ${{ env.CHANGELOG }} 148 | 149 | ## Installation 150 | ```bash 151 | uv add magg==${{ env.VERSION }} 152 | ``` 153 | files: | 154 | dist/*.tar.gz 155 | dist/*.whl 156 | 157 | - name: Publish to PyPI 158 | if: ${{ !inputs.dry_run }} 159 | env: 160 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 161 | run: uv publish --token $PYPI_TOKEN 162 | 163 | - name: Dry run summary 164 | if: ${{ inputs.dry_run }} 165 | run: | 166 | echo "=== DRY RUN SUMMARY ===" 167 | echo "Version: ${VERSION}" 168 | echo "Tag: v${VERSION}" 169 | echo "Would create GitHub release: ${{ (inputs.create_github_release == 'yes' || (inputs.create_github_release == 'auto' && steps.validate.outputs.is_postrelease == 'true')) && 'YES' || 'NO' }}" 170 | echo "" 171 | echo "No actual changes were made." -------------------------------------------------------------------------------- /examples/authentication.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Example of connecting to MCP servers with authentication. 3 | 4 | This script demonstrates: 5 | 1. Bearer token authentication with configurable environment variables 6 | 2. Direct token passing 7 | 3. Using the MaggClient for automatic authentication 8 | """ 9 | import argparse 10 | import asyncio 11 | import logging 12 | import os 13 | import sys 14 | from typing import Any 15 | 16 | from fastmcp import Client 17 | from fastmcp.client import BearerAuth 18 | from magg.client import MaggClient 19 | 20 | 21 | async def bearer_auth(args: argparse.Namespace) -> None: 22 | """Test bearer token authentication with configurable options.""" 23 | jwt = args.token 24 | if not jwt: 25 | jwt = os.environ.get(args.env_var) 26 | if not jwt: 27 | print(f"Error: No JWT token provided. Set {args.env_var} or use --token", file=sys.stderr) 28 | sys.exit(1) 29 | 30 | print(f"Connecting to {args.url} with bearer token authentication...") 31 | 32 | if args.magg: 33 | # Use MaggClient 34 | print(f"Using MaggClient{' with provided token' if args.token else f' (loading from {args.env_var})'}") 35 | 36 | # If using custom env var, set MAGG_JWT for MaggClient 37 | if args.env_var != "MAGG_JWT" and not args.token: 38 | os.environ["MAGG_JWT"] = jwt 39 | 40 | if args.token: 41 | auth = BearerAuth(args.token) 42 | client = MaggClient(args.url, auth=auth) 43 | else: 44 | # Let MaggClient handle auth from MAGG_JWT env var 45 | client = MaggClient(args.url) 46 | 47 | async with client: 48 | print(f"Transparent proxy mode: {client._transparent}") 49 | 50 | # Test the connection 51 | await check_client_capabilities(client) 52 | 53 | # Test proxy-specific functionality if available 54 | try: 55 | print("\nTesting proxy functionality...") 56 | tools = await client.proxy("tool", "list") 57 | print(f"Proxy list returned {len(tools)} items") 58 | except Exception as e: 59 | print(f"Proxy functionality not available: {e}") 60 | else: 61 | # Use regular FastMCP Client 62 | print(f"Using FastMCP Client") 63 | auth = BearerAuth(jwt) 64 | 65 | async with Client(args.url, auth=auth) as client: 66 | # Test the connection 67 | await check_client_capabilities(client) 68 | 69 | 70 | async def check_client_capabilities(client: Any) -> None: 71 | """Test basic MCP client capabilities.""" 72 | # List available tools 73 | tools = await client.list_tools() 74 | print(f"\nFound {len(tools)} tools:") 75 | for tool in tools[:5]: # Show first 5 tools 76 | print(f" - {tool.name}: {tool.description}") 77 | if len(tools) > 5: 78 | print(f" ... and {len(tools) - 5} more") 79 | 80 | # List resources 81 | try: 82 | resources = await client.list_resources() 83 | print(f"\nFound {len(resources)} resources") 84 | except Exception as e: 85 | print(f"\nResource listing not available: {e}") 86 | 87 | # List prompts 88 | try: 89 | prompts = await client.list_prompts() 90 | print(f"\nFound {len(prompts)} prompts") 91 | except Exception as e: 92 | print(f"\nPrompt listing not available: {e}") 93 | 94 | # Call a simple tool if available 95 | if tools: 96 | # Look for a simple info/status tool 97 | info_tools = [t for t in tools if "status" in t.name.lower() or "info" in t.name.lower()] 98 | if info_tools: 99 | tool = info_tools[0] 100 | print(f"\nCalling tool: {tool.name}") 101 | try: 102 | result = await client.call_tool(tool.name) 103 | print(f"Result: {result[:200]}..." if len(str(result)) > 200 else f"Result: {result}") 104 | except Exception as e: 105 | print(f"Tool call failed: {e}") 106 | 107 | 108 | def create_parser() -> argparse.ArgumentParser: 109 | """Create command line parser.""" 110 | parser = argparse.ArgumentParser( 111 | description="Test MCP server authentication", 112 | formatter_class=argparse.RawDescriptionHelpFormatter, 113 | epilog=""" 114 | Examples: 115 | # Test with bearer token from environment 116 | %(prog)s bearer 117 | 118 | # Test with custom URL 119 | %(prog)s bearer http://localhost:9000 120 | 121 | # Test with custom environment variable 122 | %(prog)s bearer --env-var MY_TOKEN 123 | 124 | # Test with direct token 125 | %(prog)s bearer --token "eyJ..." 126 | 127 | # Test using MaggClient (auto-loads from MAGG_JWT) 128 | %(prog)s bearer --magg 129 | 130 | # Test MaggClient with direct token 131 | %(prog)s bearer --magg --token "eyJ..." 132 | """ 133 | ) 134 | 135 | parser.add_argument( 136 | "--debug", 137 | action="store_true", 138 | help="Enable debug output" 139 | ) 140 | 141 | subparsers = parser.add_subparsers(dest="auth_type", help="Authentication type") 142 | 143 | # Bearer authentication subcommand 144 | bearer_parser = subparsers.add_parser( 145 | "bearer", 146 | help="Test bearer token authentication" 147 | ) 148 | bearer_parser.add_argument( 149 | "url", 150 | nargs="?", 151 | default="http://localhost:8000/mcp", 152 | help="MCP server URL (default: http://localhost:8000/mcp)" 153 | ) 154 | bearer_parser.add_argument( 155 | "--env-var", 156 | default="MAGG_JWT", 157 | help="Environment variable name for JWT (default: MAGG_JWT)" 158 | ) 159 | bearer_parser.add_argument( 160 | "--token", 161 | help="JWT token (overrides environment variable)" 162 | ) 163 | bearer_parser.add_argument( 164 | "--magg", 165 | action="store_true", 166 | help="Use MaggClient instead of regular FastMCP Client" 167 | ) 168 | 169 | return parser 170 | 171 | 172 | async def main(): 173 | """Main entry point.""" 174 | parser = create_parser() 175 | args = parser.parse_args() 176 | 177 | if args.debug: 178 | logging.basicConfig(level=logging.DEBUG) 179 | 180 | # Check if subcommand was provided 181 | if not args.auth_type: 182 | parser.print_help() 183 | sys.exit(1) 184 | 185 | try: 186 | if args.auth_type == "bearer": 187 | await bearer_auth(args) 188 | except KeyboardInterrupt: 189 | print("\nInterrupted by user") 190 | sys.exit(130) 191 | except Exception as e: 192 | print(f"\nError: {e}", file=sys.stderr) 193 | if args.debug: 194 | import traceback 195 | traceback.print_exc() 196 | sys.exit(1) 197 | 198 | 199 | if __name__ == "__main__": 200 | asyncio.run(main()) 201 | -------------------------------------------------------------------------------- /test/magg/test_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for Magg server functionality.""" 2 | 3 | import pytest 4 | import tempfile 5 | import os 6 | from pathlib import Path 7 | from unittest.mock import AsyncMock, MagicMock, patch 8 | 9 | from magg.server.server import MaggServer 10 | from magg.settings import MaggConfig, ConfigManager, ServerConfig 11 | 12 | 13 | class TestIntegration: 14 | """Test full integration of server creation and management.""" 15 | 16 | @pytest.mark.asyncio 17 | async def test_add_python_server(self): 18 | """Test adding a local Python server.""" 19 | with tempfile.TemporaryDirectory() as tmpdir: 20 | # Create test Python script 21 | server_script = Path(tmpdir) / "test_server.py" 22 | server_script.write_text(''' 23 | from fastmcp import FastMCP 24 | 25 | mcp = FastMCP("test-python-server") 26 | 27 | @mcp.tool() 28 | def test_tool(message: str) -> str: 29 | return f"Test response: {message}" 30 | 31 | if __name__ == "__main__": 32 | mcp.run() 33 | ''') 34 | 35 | # Create Magg server 36 | config_path = Path(tmpdir) / "config.json" 37 | server = MaggServer(str(config_path)) 38 | 39 | # Add the server 40 | result = await server.add_server( 41 | name="pythontest", 42 | source="file://" + str(tmpdir), 43 | command=f"python {server_script}", 44 | cwd=str(tmpdir) 45 | ) 46 | 47 | assert result.is_success 48 | assert result.output["server"]["name"] == "pythontest" 49 | assert result.output["server"]["command"] == f"python {server_script}" 50 | 51 | # Verify it was saved 52 | config = server.config 53 | assert "pythontest" in config.servers 54 | 55 | @pytest.mark.asyncio 56 | async def test_add_server_with_python_module(self): 57 | """Test adding a Python server using -m module syntax.""" 58 | with tempfile.TemporaryDirectory() as tmpdir: 59 | # Create Magg server 60 | config_path = Path(tmpdir) / "config.json" 61 | server = MaggServer(str(config_path)) 62 | 63 | # Add server with -m syntax 64 | result = await server.add_server( 65 | name="moduletest", 66 | source="https://github.com/example/module-server", 67 | command="python -m example.server --port 8080", 68 | cwd=str(tmpdir) 69 | ) 70 | 71 | assert result.is_success 72 | assert result.output["server"]["command"] == "python -m example.server --port 8080" 73 | 74 | @pytest.mark.asyncio 75 | async def test_transport_selection(self): 76 | """Test that correct transport is selected based on command.""" 77 | with tempfile.TemporaryDirectory() as tmpdir: 78 | config_path = Path(tmpdir) / "config.json" 79 | server = MaggServer(str(config_path)) 80 | 81 | # Test Python transport 82 | result = await server.add_server( 83 | name="pythontransport", 84 | source="https://example.com", 85 | command="python script.py" 86 | ) 87 | assert result.is_success 88 | 89 | # Test Node transport 90 | result = await server.add_server( 91 | name="nodetransport", 92 | source="https://example.com", 93 | command="node server.js" 94 | ) 95 | assert result.is_success 96 | 97 | # Test NPX transport 98 | result = await server.add_server( 99 | name="npxtransport", 100 | source="https://example.com", 101 | command="npx @example/server" 102 | ) 103 | assert result.is_success 104 | 105 | # Test UVX transport 106 | result = await server.add_server( 107 | name="uvxtransport", 108 | source="https://example.com", 109 | command="uvx example-server" 110 | ) 111 | assert result.is_success 112 | 113 | # Test HTTP transport 114 | result = await server.add_server( 115 | name="httptransport", 116 | source="https://example.com", 117 | uri="http://localhost:8080" 118 | ) 119 | assert result.is_success 120 | 121 | # Verify all were saved 122 | config = server.config 123 | assert len(config.servers) == 5 124 | 125 | 126 | class TestServerLifecycle: 127 | """Test server lifecycle management.""" 128 | 129 | @pytest.mark.asyncio 130 | async def test_enable_disable_server(self): 131 | """Test enabling and disabling servers.""" 132 | with tempfile.TemporaryDirectory() as tmpdir: 133 | config_path = Path(tmpdir) / "config.json" 134 | server = MaggServer(str(config_path)) 135 | 136 | # Add a disabled server 137 | result = await server.add_server( 138 | name="lifecycle", 139 | source="https://example.com", 140 | command="echo test", 141 | enable=False 142 | ) 143 | assert result.is_success 144 | assert result.output["server"]["enabled"] is False 145 | 146 | # Enable it 147 | result = await server.enable_server("lifecycle") 148 | assert result.is_success 149 | 150 | # Check it's enabled in config 151 | config = server.config 152 | assert config.servers["lifecycle"].enabled is True 153 | 154 | # Disable it again 155 | result = await server.disable_server("lifecycle") 156 | assert result.is_success 157 | 158 | # Check it's disabled 159 | config = server.config 160 | assert config.servers["lifecycle"].enabled is False 161 | 162 | @pytest.mark.asyncio 163 | async def test_remove_server(self): 164 | """Test removing servers.""" 165 | with tempfile.TemporaryDirectory() as tmpdir: 166 | config_path = Path(tmpdir) / "config.json" 167 | server = MaggServer(str(config_path)) 168 | 169 | # Add a server 170 | await server.add_server( 171 | name="toremove", 172 | source="https://example.com", 173 | command="echo test" 174 | ) 175 | 176 | # Remove it 177 | result = await server.remove_server("toremove") 178 | assert result.is_success 179 | 180 | # Verify it's gone 181 | config = server.config 182 | assert "toremove" not in config.servers 183 | 184 | # Try to remove non-existent 185 | result = await server.remove_server("nonexistent") 186 | assert result.is_error 187 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | # Magg Authentication Guide 2 | 3 | This guide covers how to set up and use bearer token authentication in Magg. 4 | 5 | ## Overview 6 | 7 | Magg uses RSA keypair-based bearer token authentication with JWT tokens. When enabled, all clients must provide a valid JWT token to access the server. Authentication is optional - if no keys exist, the server runs without authentication. 8 | 9 | ## Quick Start 10 | 11 | ### 1. Initialize Authentication 12 | 13 | Generate RSA keypair (one-time setup): 14 | ```bash 15 | magg auth init 16 | ``` 17 | 18 | This creates: 19 | - Private key: `~/.ssh/magg/magg.key` 20 | - Public key: `~/.ssh/magg/magg.key.pub` 21 | 22 | ### 2. Generate JWT Token 23 | 24 | ```bash 25 | # Display token on screen 26 | magg auth token 27 | 28 | # Export to environment variable 29 | export MAGG_JWT=$(magg auth token -q) 30 | ``` 31 | 32 | ### 3. Connect with Authentication 33 | 34 | Using MaggClient (recommended): 35 | ```python 36 | from magg.client import MaggClient 37 | 38 | # Automatically uses MAGG_JWT environment variable 39 | async with MaggClient("http://localhost:8000/mcp") as client: 40 | tools = await client.list_tools() 41 | ``` 42 | 43 | ## Detailed Setup 44 | 45 | ### Custom Configuration 46 | 47 | Initialize with custom parameters: 48 | ```bash 49 | # Custom audience and issuer 50 | magg auth init --audience myapp --issuer https://mycompany.com 51 | 52 | # Custom key location 53 | magg auth init --key-path /opt/magg/keys 54 | ``` 55 | 56 | ### Token Generation Options 57 | 58 | Generate tokens with specific parameters: 59 | ```bash 60 | # Custom subject and expiration 61 | magg auth token --subject "my-service" --hours 168 62 | 63 | # Include scopes (informational only, not enforced) 64 | magg auth token --scopes "read" "write" "admin" 65 | 66 | # Export format for shell scripts 67 | magg auth token --export 68 | # Output: export MAGG_JWT="eyJ..." 69 | ``` 70 | 71 | ### Key Management 72 | 73 | Display keys for backup or verification: 74 | ```bash 75 | # Show public key (safe to share) 76 | magg auth public-key 77 | 78 | # Show private key (keep secret!) 79 | magg auth private-key 80 | 81 | # Export private key in single-line format for env vars 82 | magg auth private-key --oneline 83 | ``` 84 | 85 | ## Environment Variables 86 | 87 | ### Server Configuration 88 | 89 | - `MAGG_PRIVATE_KEY`: Private key content (takes precedence over file) 90 | ```bash 91 | export MAGG_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE..." 92 | ``` 93 | 94 | ### Client Configuration 95 | 96 | - `MAGG_JWT`: JWT token for client authentication 97 | ```bash 98 | export MAGG_JWT="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." 99 | ``` 100 | 101 | ## Configuration Files 102 | 103 | ### Auth Configuration (`.magg/auth.json`) 104 | 105 | ```json 106 | { 107 | "bearer": { 108 | "issuer": "https://magg.local", 109 | "audience": "myapp", 110 | "key_path": "/custom/path/to/keys" 111 | } 112 | } 113 | ``` 114 | 115 | ### Key File Locations 116 | 117 | Default locations: 118 | - Private key: `{key_path}/{audience}.key` 119 | - Public key: `{key_path}/{audience}.key.pub` 120 | 121 | Example with custom audience "prod": 122 | - `/home/user/.ssh/magg/prod.key` 123 | - `/home/user/.ssh/magg/prod.key.pub` 124 | 125 | ## Client Examples 126 | 127 | ### Python with MaggClient 128 | 129 | ```python 130 | import os 131 | from magg.client import MaggClient 132 | 133 | # Method 1: Auto-load from environment 134 | os.environ['MAGG_JWT'] = 'your-jwt-token' 135 | async with MaggClient("http://localhost:8000/mcp") as client: 136 | tools = await client.list_tools() 137 | 138 | # Method 2: Explicit token 139 | from fastmcp.client import BearerAuth 140 | auth = BearerAuth('your-jwt-token') 141 | async with MaggClient("http://localhost:8000/mcp", auth=auth) as client: 142 | tools = await client.list_tools() 143 | 144 | # Method 3: Transparent proxy mode (no prefixes) 145 | async with MaggClient("http://localhost:8000/mcp", transparent=True) as client: 146 | # Call tools without prefixes 147 | result = await client.call_tool("add", {"a": 5, "b": 3}) 148 | # Instead of: client.call_tool("calc_add", {"a": 5, "b": 3}) 149 | ``` 150 | 151 | ### Using with curl 152 | 153 | ```bash 154 | # Get JWT token 155 | JWT=$(magg auth token -q) 156 | 157 | # Make authenticated request 158 | curl -H "Authorization: Bearer $JWT" http://localhost:8000/mcp/ 159 | ``` 160 | 161 | ## Disabling Authentication 162 | 163 | To run Magg without authentication: 164 | 165 | ### Option 1: Don't Generate Keys 166 | Simply don't run `magg auth init`. No keys = no auth. 167 | 168 | ### Option 2: Remove Existing Keys 169 | ```bash 170 | rm ~/.ssh/magg/magg.key* 171 | ``` 172 | 173 | ### Option 3: Configure Non-Existent Path 174 | Edit `.magg/auth.json`: 175 | ```json 176 | { 177 | "bearer": { 178 | "key_path": "/path/that/does/not/exist" 179 | } 180 | } 181 | ``` 182 | 183 | ## Security Best Practices 184 | 185 | 1. **Protect Private Keys** 186 | - Files are created with 0600 permissions (owner read/write only) 187 | - Never commit private keys to version control 188 | - Use `.gitignore` to exclude `.magg/` directory 189 | 190 | 2. **Token Management** 191 | - Use short expiration times for development (default: 24 hours) 192 | - Longer expiration for production services 193 | - Rotate tokens regularly 194 | 195 | 3. **Production Deployment** 196 | - Use environment variables for keys and tokens 197 | - Consider using a secrets management service 198 | - Enable HTTPS for transport security 199 | 200 | 4. **Multiple Environments** 201 | - Use different audiences for dev/staging/prod 202 | - Separate key pairs per environment 203 | - Example: `magg auth init --audience prod` 204 | 205 | ## Troubleshooting 206 | 207 | ### Check Authentication Status 208 | ```bash 209 | magg auth status 210 | ``` 211 | 212 | Output shows: 213 | - Current configuration 214 | - Key file locations 215 | - Whether keys exist 216 | 217 | ### Common Issues 218 | 219 | 1. **"Authentication is not enabled"** 220 | - No private key found 221 | - Run `magg auth init` or check `MAGG_PRIVATE_KEY` 222 | 223 | 2. **"Invalid token"** 224 | - Token expired (check with jwt.io) 225 | - Wrong audience or issuer 226 | - Using token from different key pair 227 | 228 | 3. **"Permission denied" when reading key** 229 | - Check file permissions: `ls -la ~/.ssh/magg/` 230 | - Should be 0600 for private key 231 | 232 | ## Advanced Usage 233 | 234 | ### Custom Token Claims 235 | 236 | While Magg doesn't enforce scopes, you can include them for client-side logic: 237 | ```bash 238 | magg auth token --scopes "projects:read" "servers:write" 239 | ``` 240 | 241 | ### Token Introspection 242 | 243 | Decode a token to see its claims: 244 | ```bash 245 | # Using Python 246 | python -c "import jwt; print(jwt.decode('$MAGG_JWT', options={'verify_signature': False}))" 247 | ``` 248 | 249 | ### Integration with CI/CD 250 | 251 | Generate long-lived tokens for automated systems: 252 | ```bash 253 | # 30-day token for CI 254 | magg auth token --subject "github-actions" --hours 720 --quiet 255 | ``` -------------------------------------------------------------------------------- /test/magg/test_e2e_mounting.py: -------------------------------------------------------------------------------- 1 | """End-to-end test for Magg server mounting.""" 2 | 3 | import asyncio 4 | import tempfile 5 | import json 6 | from pathlib import Path 7 | import subprocess 8 | import time 9 | import sys 10 | 11 | import pytest 12 | from fastmcp import Client 13 | from magg.settings import ConfigManager, ServerConfig, MaggConfig 14 | 15 | 16 | @pytest.mark.asyncio 17 | @pytest.mark.integration 18 | async def test_e2e_mounting(): 19 | """Test Magg with real server mounting end-to-end.""" 20 | 21 | with tempfile.TemporaryDirectory() as tmpdir: 22 | tmpdir = Path(tmpdir) 23 | 24 | # 1. Create a simple test MCP server 25 | calc_dir = tmpdir / "calculator_server" 26 | calc_dir.mkdir() 27 | 28 | calc_server = calc_dir / "server.py" 29 | calc_server.write_text(''' 30 | from fastmcp import FastMCP 31 | 32 | mcp = FastMCP("calculator") 33 | 34 | @mcp.tool 35 | def add(a: int, b: int) -> int: 36 | """Add two numbers.""" 37 | return a + b 38 | 39 | @mcp.tool 40 | def multiply(a: int, b: int) -> int: 41 | """Multiply two numbers.""" 42 | return a * b 43 | 44 | if __name__ == "__main__": 45 | mcp.run() 46 | ''') 47 | 48 | # 2. Create Magg config 49 | magg_dir = tmpdir / "magg_test" 50 | magg_dir.mkdir() 51 | config_dir = magg_dir / ".magg" 52 | config_dir.mkdir() 53 | 54 | config = MaggConfig() 55 | 56 | # Add calculator server (no sources anymore) 57 | server = ServerConfig( 58 | name="calc", 59 | source=f"file://{calc_dir}", 60 | prefix="calc", # Explicit prefix 61 | command="python", 62 | args=["server.py"], 63 | cwd=str(calc_dir) 64 | ) 65 | config.add_server(server) 66 | 67 | # Save config 68 | config_path = config_dir / "config.json" 69 | with open(config_path, 'w') as f: 70 | json.dump({ 71 | 'servers': {s.name: s.model_dump(mode="json") for s in config.servers.values()} 72 | }, f, indent=2) 73 | 74 | # Create empty auth.json to prevent using default keys 75 | auth_path = config_dir / "auth.json" 76 | with open(auth_path, 'w') as f: 77 | json.dump({ 78 | 'bearer': { 79 | 'issuer': 'https://magg.local', 80 | 'audience': 'test', 81 | 'key_path': str(tmpdir / 'nonexistent') 82 | } 83 | }, f) 84 | 85 | print(f"Config saved to: {config_path}") 86 | 87 | # 3. Start Magg server as subprocess 88 | magg_script = magg_dir / "run_magg.py" 89 | magg_script.write_text(f''' 90 | import sys 91 | import os 92 | sys.path.insert(0, "{Path.cwd()}") 93 | os.chdir("{magg_dir}") 94 | 95 | from magg.server.server import MaggServer 96 | import asyncio 97 | 98 | async def main(): 99 | server = MaggServer("{config_path}") 100 | await server.setup() 101 | print("Magg server started", flush=True) 102 | await server.mcp.run_http_async(host="localhost", port=54321) 103 | 104 | asyncio.run(main()) 105 | ''') 106 | 107 | # Start Magg 108 | print("Starting Magg server...") 109 | magg_proc = subprocess.Popen( 110 | [sys.executable, str(magg_script)], 111 | stdout=subprocess.PIPE, 112 | stderr=subprocess.PIPE, 113 | text=True 114 | ) 115 | 116 | # Wait for startup and check if process started 117 | started = False 118 | for i in range(10): # Try for up to 10 seconds 119 | if magg_proc.poll() is not None: 120 | # Process ended 121 | stdout, stderr = magg_proc.communicate() 122 | print(f"Magg process ended with code {magg_proc.returncode}") 123 | print(f"STDOUT:\n{stdout}") 124 | print(f"STDERR:\n{stderr}") 125 | pytest.fail(f"Magg server failed to start: {stderr}") 126 | 127 | # Check if server is listening on the port 128 | import socket 129 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 130 | result = sock.connect_ex(('localhost', 54321)) 131 | sock.close() 132 | 133 | if result == 0: 134 | print("Server is listening on port 54321") 135 | started = True 136 | break 137 | 138 | time.sleep(1) 139 | 140 | if not started: 141 | # Get any output so far 142 | stdout, stderr = magg_proc.communicate(timeout=1) 143 | print(f"Server didn't start in time. STDOUT:\n{stdout}") 144 | print(f"STDERR:\n{stderr}") 145 | pytest.fail("Magg server didn't start listening on port 54321") 146 | 147 | try: 148 | # 4. Connect to Magg as client 149 | print("\nConnecting to Magg...") 150 | client = Client("http://localhost:54321/mcp/") 151 | 152 | # Use the client in async context 153 | async with client: 154 | tools = await client.list_tools() 155 | tool_names = [tool.name for tool in tools] 156 | print(f"\nAvailable tools: {tool_names}") 157 | 158 | # Verify calculator tools are mounted with prefix 159 | assert "calc_add" in tool_names 160 | assert "calc_multiply" in tool_names 161 | 162 | # Test calling a mounted tool 163 | result = await client.call_tool("calc_add", {"a": 5, "b": 3}) 164 | print(f"\ncalc_add(5, 3) = {result}") 165 | # Parse the result - calculator returns CallToolResult 166 | if hasattr(result, 'content') and result.content: 167 | result_text = result.content[0].text 168 | assert result_text == "8" 169 | else: 170 | assert False, f"Unexpected result format: {result}" 171 | 172 | result = await client.call_tool("calc_multiply", {"a": 4, "b": 7}) 173 | print(f"calc_multiply(4, 7) = {result}") 174 | if hasattr(result, 'content') and result.content: 175 | result_text = result.content[0].text 176 | assert result_text == "28" 177 | else: 178 | assert False, f"Unexpected result format: {result}" 179 | 180 | # Test Magg's own tools 181 | assert "magg_list_servers" in tool_names 182 | servers_result = await client.call_tool("magg_list_servers", {}) 183 | print(f"\nServers: {servers_result}") 184 | if isinstance(servers_result, list) and servers_result: 185 | servers_text = servers_result[0].text 186 | servers_data = json.loads(servers_text) 187 | print(f"Servers data: {servers_data}") 188 | assert len(servers_data["output"]) == 1 189 | assert servers_data["output"][0]["name"] == "calc" 190 | 191 | print("\n✅ All tests passed!") 192 | 193 | finally: 194 | # Cleanup 195 | magg_proc.terminate() 196 | magg_proc.wait() 197 | 198 | 199 | if __name__ == "__main__": 200 | asyncio.run(test_e2e_mounting()) 201 | --------------------------------------------------------------------------------