refactor: remove module system, migrate to MCP hub tools architecture

This commit is contained in:
AFredefon
2026-03-08 17:53:29 +01:00
parent 075b678e9d
commit 1d495cedce
141 changed files with 1182 additions and 8992 deletions
@@ -1,7 +1,8 @@
"""FuzzForge MCP Server Application.
This is the main entry point for the FuzzForge MCP server, providing
AI agents with tools to execute security research modules.
AI agents with tools to discover and execute MCP hub tools for
security research.
"""
@@ -12,7 +13,7 @@ from fastmcp import FastMCP
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fuzzforge_mcp import resources, tools
from fuzzforge_runner import Settings
from fuzzforge_mcp.settings import Settings
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
@@ -38,19 +39,18 @@ mcp: FastMCP = FastMCP(
instructions="""
FuzzForge is a security research orchestration platform. Use these tools to:
1. **List modules**: Discover available security research modules
2. **Execute modules**: Run modules in isolated containers
3. **Execute workflows**: Chain multiple modules together
1. **List hub servers**: Discover registered MCP tool servers
2. **Discover tools**: Find available tools from hub servers
3. **Execute hub tools**: Run security tools in isolated containers
4. **Manage projects**: Initialize and configure projects
5. **Get results**: Retrieve execution results
6. **Hub tools**: Discover and execute tools from external MCP servers
Typical workflow:
1. Initialize a project with `init_project`
2. Set project assets with `set_project_assets` (optional, only needed once for the source directory)
3. List available modules with `list_modules`
4. Execute a module with `execute_module` — use `assets_path` param to pass different inputs per module
5. Read outputs from `results_path` returned by `execute_module` — check module's `output_artifacts` metadata for filenames
3. List available hub servers with `list_hub_servers`
4. Discover tools from servers with `discover_hub_tools`
5. Execute hub tools with `execute_hub_tool`
Hub workflow:
1. List available hub servers with `list_hub_servers`
@@ -6,9 +6,10 @@ from pathlib import Path
from typing import TYPE_CHECKING, cast
from fastmcp.server.dependencies import get_context
from fuzzforge_runner import Runner, Settings
from fuzzforge_mcp.exceptions import FuzzForgeMCPError
from fuzzforge_mcp.settings import Settings
from fuzzforge_mcp.storage import LocalStorage
if TYPE_CHECKING:
from fastmcp import Context
@@ -17,6 +18,9 @@ if TYPE_CHECKING:
# Track the current active project path (set by init_project)
_current_project_path: Path | None = None
# Singleton storage instance
_storage: LocalStorage | None = None
def set_current_project_path(project_path: Path) -> None:
"""Set the current project path.
@@ -60,11 +64,14 @@ def get_project_path() -> Path:
return Path.cwd()
def get_runner() -> Runner:
"""Get a configured Runner instance.
def get_storage() -> LocalStorage:
"""Get the storage backend instance.
:return: Runner instance configured from MCP settings.
:return: LocalStorage instance.
"""
settings: Settings = get_settings()
return Runner(settings)
global _storage
if _storage is None:
settings = get_settings()
_storage = LocalStorage(settings.storage.path)
return _storage
@@ -2,14 +2,12 @@
from fastmcp import FastMCP
from fuzzforge_mcp.resources import executions, modules, project, workflows
from fuzzforge_mcp.resources import executions, project
mcp: FastMCP = FastMCP()
mcp.mount(executions.mcp)
mcp.mount(modules.mcp)
mcp.mount(project.mcp)
mcp.mount(workflows.mcp)
__all__ = [
"mcp",
@@ -3,15 +3,12 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError
from fuzzforge_mcp.dependencies import get_project_path, get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
from fuzzforge_mcp.dependencies import get_project_path, get_storage
mcp: FastMCP = FastMCP()
@@ -26,16 +23,16 @@ async def list_executions() -> list[dict[str, Any]]:
:return: List of execution information dictionaries.
"""
runner: Runner = get_runner()
storage = get_storage()
project_path: Path = get_project_path()
try:
execution_ids = runner.list_executions(project_path)
execution_ids = storage.list_executions(project_path)
return [
{
"execution_id": exec_id,
"has_results": runner.get_execution_results(project_path, exec_id) is not None,
"has_results": storage.get_execution_results(project_path, exec_id) is not None,
}
for exec_id in execution_ids
]
@@ -53,11 +50,11 @@ async def get_execution(execution_id: str) -> dict[str, Any]:
:return: Execution information dictionary.
"""
runner: Runner = get_runner()
storage = get_storage()
project_path: Path = get_project_path()
try:
results_path = runner.get_execution_results(project_path, execution_id)
results_path = storage.get_execution_results(project_path, execution_id)
if results_path is None:
raise ResourceError(f"Execution not found: {execution_id}")
@@ -1,78 +0,0 @@
"""Module resources for FuzzForge MCP."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError
from fuzzforge_mcp.dependencies import get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
from fuzzforge_runner.runner import ModuleInfo
mcp: FastMCP = FastMCP()
@mcp.resource("fuzzforge://modules/")
async def list_modules() -> list[dict[str, Any]]:
"""List all available FuzzForge modules.
Returns information about modules that can be executed,
including their identifiers and availability status.
:return: List of module information dictionaries.
"""
runner: Runner = get_runner()
try:
modules: list[ModuleInfo] = runner.list_modules()
return [
{
"identifier": module.identifier,
"description": module.description,
"version": module.version,
"available": module.available,
}
for module in modules
]
except Exception as exception:
message: str = f"Failed to list modules: {exception}"
raise ResourceError(message) from exception
@mcp.resource("fuzzforge://modules/{module_identifier}")
async def get_module(module_identifier: str) -> dict[str, Any]:
"""Get information about a specific module.
:param module_identifier: The identifier of the module to retrieve.
:return: Module information dictionary.
"""
runner: Runner = get_runner()
try:
module: ModuleInfo | None = runner.get_module_info(module_identifier)
if module is None:
raise ResourceError(f"Module not found: {module_identifier}")
return {
"identifier": module.identifier,
"description": module.description,
"version": module.version,
"available": module.available,
}
except ResourceError:
raise
except Exception as exception:
message: str = f"Failed to get module: {exception}"
raise ResourceError(message) from exception
@@ -3,15 +3,12 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError
from fuzzforge_mcp.dependencies import get_project_path, get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
from fuzzforge_mcp.dependencies import get_project_path, get_settings, get_storage
mcp: FastMCP = FastMCP()
@@ -27,12 +24,12 @@ async def get_project() -> dict[str, Any]:
:return: Project information dictionary.
"""
runner: Runner = get_runner()
storage = get_storage()
project_path: Path = get_project_path()
try:
executions = runner.list_executions(project_path)
assets_path = runner.storage.get_project_assets_path(project_path)
executions = storage.list_executions(project_path)
assets_path = storage.get_project_assets_path(project_path)
return {
"path": str(project_path),
@@ -40,7 +37,7 @@ async def get_project() -> dict[str, Any]:
"has_assets": assets_path is not None,
"assets_path": str(assets_path) if assets_path else None,
"execution_count": len(executions),
"recent_executions": executions[:10], # Last 10 executions
"recent_executions": executions[:10],
}
except Exception as exception:
@@ -53,13 +50,11 @@ async def get_project_settings() -> dict[str, Any]:
"""Get current FuzzForge settings.
Returns the active configuration for the MCP server including
engine, storage, and project settings.
engine, storage, and hub settings.
:return: Settings dictionary.
"""
from fuzzforge_mcp.dependencies import get_settings
try:
settings = get_settings()
@@ -71,9 +66,10 @@ async def get_project_settings() -> dict[str, Any]:
"storage": {
"path": str(settings.storage.path),
},
"project": {
"path": str(settings.project.path),
"modules_path": str(settings.modules_path),
"hub": {
"enabled": settings.hub.enabled,
"config_path": str(settings.hub.config_path),
"timeout": settings.hub.timeout,
},
"debug": settings.debug,
}
@@ -1,53 +0,0 @@
"""Workflow resources for FuzzForge MCP.
Note: In FuzzForge AI, workflows are defined at runtime rather than
stored. This resource provides documentation about workflow capabilities.
"""
from __future__ import annotations
from typing import Any
from fastmcp import FastMCP
mcp: FastMCP = FastMCP()
@mcp.resource("fuzzforge://workflows/help")
async def get_workflow_help() -> dict[str, Any]:
"""Get help information about creating workflows.
Workflows in FuzzForge AI are defined at execution time rather
than stored. Use the execute_workflow tool with step definitions.
:return: Workflow documentation.
"""
return {
"description": "Workflows chain multiple modules together",
"usage": "Use the execute_workflow tool with step definitions",
"example": {
"workflow_name": "security-audit",
"steps": [
{
"module": "compile-contracts",
"configuration": {"solc_version": "0.8.0"},
},
{
"module": "slither",
"configuration": {},
},
{
"module": "echidna",
"configuration": {"test_limit": 10000},
},
],
},
"step_format": {
"module": "Module identifier (required)",
"configuration": "Module-specific configuration (optional)",
"name": "Step name for logging (optional)",
},
}
+113
View File
@@ -0,0 +1,113 @@
"""FuzzForge MCP Server settings.
Standalone settings for the MCP server. Replaces the previous dependency
on fuzzforge-runner Settings now that the module system has been removed
and FuzzForge operates exclusively through MCP hub tools.
All settings can be configured via environment variables with the prefix
``FUZZFORGE_``. Nested settings use double-underscore as delimiter.
Example:
``FUZZFORGE_ENGINE__TYPE=docker``
``FUZZFORGE_STORAGE__PATH=/data/fuzzforge``
``FUZZFORGE_HUB__CONFIG_PATH=/path/to/hub-config.json``
"""
from __future__ import annotations
from enum import StrEnum
from pathlib import Path
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class EngineType(StrEnum):
"""Supported container engine types."""
DOCKER = "docker"
PODMAN = "podman"
class EngineSettings(BaseModel):
"""Container engine configuration."""
#: Type of container engine to use.
type: EngineType = EngineType.DOCKER
#: Path to the container engine socket.
socket: str = Field(default="")
#: Custom graph root for Podman storage.
graphroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "storage")
#: Custom run root for Podman runtime state.
runroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "run")
class StorageSettings(BaseModel):
"""Storage configuration for local filesystem storage."""
#: Base path for local storage.
path: Path = Field(default=Path.home() / ".fuzzforge" / "storage")
class ProjectSettings(BaseModel):
"""Project configuration."""
#: Default path for FuzzForge projects.
default_path: Path = Field(default=Path.home() / ".fuzzforge" / "projects")
class HubSettings(BaseModel):
"""MCP Hub configuration for external tool servers.
Controls the hub that bridges FuzzForge with external MCP servers
(e.g., mcp-security-hub). AI agents discover and execute tools
from registered MCP servers.
Configure via environment variables:
``FUZZFORGE_HUB__ENABLED=true``
``FUZZFORGE_HUB__CONFIG_PATH=/path/to/hub-config.json``
``FUZZFORGE_HUB__TIMEOUT=300``
"""
#: Whether the MCP hub is enabled.
enabled: bool = Field(default=True)
#: Path to the hub configuration JSON file.
config_path: Path = Field(default=Path.home() / ".fuzzforge" / "hub-config.json")
#: Default timeout in seconds for hub tool execution.
timeout: int = Field(default=300)
class Settings(BaseSettings):
"""FuzzForge MCP Server settings.
Settings can be configured via environment variables with the prefix
``FUZZFORGE_``. Nested settings use double-underscore as delimiter.
"""
model_config = SettingsConfigDict(
case_sensitive=False,
env_nested_delimiter="__",
env_prefix="FUZZFORGE_",
)
#: Container engine settings.
engine: EngineSettings = Field(default_factory=EngineSettings)
#: Storage settings.
storage: StorageSettings = Field(default_factory=StorageSettings)
#: Project settings.
project: ProjectSettings = Field(default_factory=ProjectSettings)
#: MCP Hub settings.
hub: HubSettings = Field(default_factory=HubSettings)
#: Enable debug logging.
debug: bool = False
+203
View File
@@ -0,0 +1,203 @@
"""FuzzForge MCP Server - Local project storage.
Lightweight project storage for managing `.fuzzforge/` directories,
execution results, and project configuration. Extracted from the
former fuzzforge-runner storage module.
Storage is placed directly in the project directory as `.fuzzforge/`
for maximum visibility and ease of debugging.
"""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from tarfile import open as Archive # noqa: N812
logger = logging.getLogger("fuzzforge-mcp")
#: Name of the FuzzForge storage directory within projects.
FUZZFORGE_DIR_NAME: str = ".fuzzforge"
#: Standard results archive filename.
RESULTS_ARCHIVE_FILENAME: str = "results.tar.gz"
class StorageError(Exception):
"""Raised when a storage operation fails."""
class LocalStorage:
"""Local filesystem storage backend for FuzzForge.
Provides lightweight storage for project configuration and
execution results tracking.
Directory structure (inside project directory)::
{project_path}/.fuzzforge/
config.json # Project config (source path reference)
runs/ # Execution results
{execution_id}/
results.tar.gz
"""
_base_path: Path
def __init__(self, base_path: Path) -> None:
"""Initialize storage backend.
:param base_path: Root directory for global storage (fallback).
"""
self._base_path = base_path
self._base_path.mkdir(parents=True, exist_ok=True)
def _get_project_path(self, project_path: Path) -> Path:
"""Get the .fuzzforge storage path for a project.
:param project_path: Path to the project directory.
:returns: Storage path (.fuzzforge inside project).
"""
return project_path / FUZZFORGE_DIR_NAME
def init_project(self, project_path: Path) -> Path:
"""Initialize storage for a new project.
Creates a .fuzzforge/ directory inside the project for storing
configuration and execution results.
:param project_path: Path to the project directory.
:returns: Path to the project storage directory.
"""
storage_path = self._get_project_path(project_path)
storage_path.mkdir(parents=True, exist_ok=True)
(storage_path / "runs").mkdir(parents=True, exist_ok=True)
# Create .gitignore to avoid committing large files
gitignore_path = storage_path / ".gitignore"
if not gitignore_path.exists():
gitignore_path.write_text(
"# FuzzForge storage - ignore large/temporary files\n"
"runs/\n"
"!config.json\n"
)
logger.info("Initialized project storage: %s", storage_path)
return storage_path
def get_project_assets_path(self, project_path: Path) -> Path | None:
"""Get the configured source path for a project.
:param project_path: Path to the project directory.
:returns: Path to source directory, or None if not configured.
"""
storage_path = self._get_project_path(project_path)
config_path = storage_path / "config.json"
if config_path.exists():
config = json.loads(config_path.read_text())
source_path = config.get("source_path")
if source_path:
path = Path(source_path)
if path.exists():
return path
return None
def set_project_assets(self, project_path: Path, assets_path: Path) -> Path:
"""Set the source path for a project (reference only, no copying).
:param project_path: Path to the project directory.
:param assets_path: Path to source directory.
:returns: The assets path (unchanged).
:raises StorageError: If path doesn't exist.
"""
if not assets_path.exists():
msg = f"Assets path does not exist: {assets_path}"
raise StorageError(msg)
assets_path = assets_path.resolve()
storage_path = self._get_project_path(project_path)
storage_path.mkdir(parents=True, exist_ok=True)
config_path = storage_path / "config.json"
config: dict = {}
if config_path.exists():
config = json.loads(config_path.read_text())
config["source_path"] = str(assets_path)
config_path.write_text(json.dumps(config, indent=2))
logger.info("Set project assets: %s -> %s", project_path.name, assets_path)
return assets_path
def list_executions(self, project_path: Path) -> list[str]:
"""List all execution IDs for a project.
:param project_path: Path to the project directory.
:returns: List of execution IDs.
"""
runs_dir = self._get_project_path(project_path) / "runs"
if not runs_dir.exists():
return []
return [d.name for d in runs_dir.iterdir() if d.is_dir()]
def get_execution_results(
self,
project_path: Path,
execution_id: str,
) -> Path | None:
"""Retrieve execution results path.
:param project_path: Path to the project directory.
:param execution_id: Execution ID.
:returns: Path to results archive, or None if not found.
"""
storage_path = self._get_project_path(project_path)
# Try direct path
results_path = storage_path / "runs" / execution_id / RESULTS_ARCHIVE_FILENAME
if results_path.exists():
return results_path
# Search in all run directories
runs_dir = storage_path / "runs"
if runs_dir.exists():
for run_dir in runs_dir.iterdir():
if run_dir.is_dir() and execution_id in run_dir.name:
candidate = run_dir / RESULTS_ARCHIVE_FILENAME
if candidate.exists():
return candidate
return None
def extract_results(self, results_path: Path, destination: Path) -> Path:
"""Extract a results archive to a destination directory.
:param results_path: Path to the results archive.
:param destination: Directory to extract to.
:returns: Path to extracted directory.
:raises StorageError: If extraction fails.
"""
try:
destination.mkdir(parents=True, exist_ok=True)
with Archive(results_path, "r:gz") as tar:
tar.extractall(path=destination) # noqa: S202
logger.info("Extracted results: %s -> %s", results_path, destination)
return destination
except Exception as exc:
msg = f"Failed to extract results: {exc}"
raise StorageError(msg) from exc
@@ -2,13 +2,11 @@
from fastmcp import FastMCP
from fuzzforge_mcp.tools import hub, modules, projects, workflows
from fuzzforge_mcp.tools import hub, projects
mcp: FastMCP = FastMCP()
mcp.mount(modules.mcp)
mcp.mount(projects.mcp)
mcp.mount(workflows.mcp)
mcp.mount(hub.mcp)
__all__ = [
@@ -313,3 +313,249 @@ async def add_hub_server(
raise
msg = f"Failed to add hub server: {e}"
raise ToolError(msg) from e
@mcp.tool
async def start_hub_server(server_name: str) -> dict[str, Any]:
"""Start a persistent container session for a hub server.
Starts a Docker container that stays running between tool calls,
allowing stateful interactions. Tools are auto-discovered on start.
Use this for servers like radare2 or ghidra where you want to
keep an analysis session open across multiple tool calls.
After starting, use execute_hub_tool as normal - calls will be
routed to the persistent container automatically.
:param server_name: Name of the hub server to start (e.g., "radare2-mcp").
:return: Session status with container name and start time.
"""
try:
executor = _get_hub_executor()
result = await executor.start_persistent_server(server_name)
return {
"success": True,
"session": result,
"tools": result.get("tools", []),
"tool_count": result.get("tool_count", 0),
"message": (
f"Persistent session started for '{server_name}'. "
f"Discovered {result.get('tool_count', 0)} tools. "
"Use execute_hub_tool to call them — they will reuse this container. "
f"Stop with stop_hub_server('{server_name}') when done."
),
}
except ValueError as e:
msg = f"Server not found: {e}"
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to start persistent server: {e}"
raise ToolError(msg) from e
@mcp.tool
async def stop_hub_server(server_name: str) -> dict[str, Any]:
"""Stop a persistent container session for a hub server.
Terminates the running Docker container and cleans up resources.
After stopping, tool calls will fall back to ephemeral mode
(a new container per call).
:param server_name: Name of the hub server to stop.
:return: Result indicating if the session was stopped.
"""
try:
executor = _get_hub_executor()
stopped = await executor.stop_persistent_server(server_name)
if stopped:
return {
"success": True,
"message": f"Persistent session for '{server_name}' stopped and container removed.",
}
else:
return {
"success": False,
"message": f"No active persistent session found for '{server_name}'.",
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to stop persistent server: {e}"
raise ToolError(msg) from e
@mcp.tool
async def hub_server_status(server_name: str | None = None) -> dict[str, Any]:
"""Get status of persistent hub server sessions.
If server_name is provided, returns status for that specific server.
Otherwise returns status for all active persistent sessions.
:param server_name: Optional specific server to check.
:return: Session status information.
"""
try:
executor = _get_hub_executor()
if server_name:
status = executor.get_persistent_status(server_name)
if status:
return {"active": True, "session": status}
else:
return {
"active": False,
"message": f"No active persistent session for '{server_name}'.",
}
else:
sessions = executor.list_persistent_sessions()
return {
"active_sessions": sessions,
"count": len(sessions),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to get server status: {e}"
raise ToolError(msg) from e
# ------------------------------------------------------------------
# Continuous mode tools
# ------------------------------------------------------------------
@mcp.tool
async def start_continuous_hub_tool(
server_name: str,
start_tool: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Start a continuous/background tool on a hub server.
Automatically starts a persistent container if not already running,
then calls the server's start tool (e.g., cargo_fuzz_start) which
launches a background process and returns a session_id.
The tool runs indefinitely until stopped with stop_continuous_hub_tool.
Use get_continuous_hub_status to monitor progress.
Example workflow for continuous cargo fuzzing:
1. start_continuous_hub_tool("cargo-fuzzer-mcp", "cargo_fuzz_start", {"project_path": "/data/myproject"})
2. get_continuous_hub_status(session_id) -- poll every 10-30s
3. stop_continuous_hub_tool(session_id) -- when done
:param server_name: Hub server name (e.g., "cargo-fuzzer-mcp").
:param start_tool: Name of the start tool on the server.
:param arguments: Arguments for the start tool.
:return: Start result including session_id for monitoring.
"""
try:
executor = _get_hub_executor()
result = await executor.start_continuous_tool(
server_name=server_name,
start_tool=start_tool,
arguments=arguments or {},
)
# Return the server's response directly — it already contains
# session_id, status, targets, and a message.
return result
except ValueError as e:
msg = f"Server not found: {e}"
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to start continuous tool: {e}"
raise ToolError(msg) from e
@mcp.tool
async def get_continuous_hub_status(session_id: str) -> dict[str, Any]:
"""Get live status of a continuous hub tool session.
Returns current metrics, progress, and recent output from the
running tool. Call periodically (every 10-30 seconds) to monitor.
:param session_id: Session ID returned by start_continuous_hub_tool.
:return: Current status with metrics (executions, coverage, crashes, etc.).
"""
try:
executor = _get_hub_executor()
return await executor.get_continuous_tool_status(session_id)
except ValueError as e:
msg = str(e)
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to get continuous status: {e}"
raise ToolError(msg) from e
@mcp.tool
async def stop_continuous_hub_tool(session_id: str) -> dict[str, Any]:
"""Stop a running continuous hub tool session.
Gracefully stops the background process and returns final results
including total metrics and any artifacts (crash files, etc.).
:param session_id: Session ID of the session to stop.
:return: Final metrics and results summary.
"""
try:
executor = _get_hub_executor()
return await executor.stop_continuous_tool(session_id)
except ValueError as e:
msg = str(e)
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to stop continuous tool: {e}"
raise ToolError(msg) from e
@mcp.tool
async def list_continuous_hub_sessions() -> dict[str, Any]:
"""List all active and recent continuous hub tool sessions.
:return: List of sessions with their status and server info.
"""
try:
executor = _get_hub_executor()
sessions = executor.list_continuous_sessions()
return {
"sessions": sessions,
"count": len(sessions),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to list continuous sessions: {e}"
raise ToolError(msg) from e
@@ -1,392 +0,0 @@
"""Module tools for FuzzForge MCP."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fuzzforge_mcp.dependencies import get_project_path, get_runner, get_settings
if TYPE_CHECKING:
from fuzzforge_runner import Runner
from fuzzforge_runner.orchestrator import StepResult
mcp: FastMCP = FastMCP()
# Track running background executions
_background_executions: dict[str, dict[str, Any]] = {}
@mcp.tool
async def list_modules() -> dict[str, Any]:
"""List all available FuzzForge modules.
Returns information about modules that can be executed,
including their identifiers, availability status, and metadata
such as use cases, input requirements, and output artifacts.
:return: Dictionary with list of available modules and their details.
"""
try:
runner: Runner = get_runner()
settings = get_settings()
# Use the engine abstraction to list images
# Default filter matches locally-built fuzzforge-* modules
modules = runner.list_module_images(filter_prefix="fuzzforge-")
available_modules = [
{
"identifier": module.identifier,
"image": f"{module.identifier}:{module.version or 'latest'}",
"available": module.available,
"description": module.description,
# New metadata fields from pyproject.toml
"category": module.category,
"language": module.language,
"pipeline_stage": module.pipeline_stage,
"pipeline_order": module.pipeline_order,
"dependencies": module.dependencies,
"continuous_mode": module.continuous_mode,
"typical_duration": module.typical_duration,
# AI-discoverable metadata
"use_cases": module.use_cases,
"input_requirements": module.input_requirements,
"output_artifacts": module.output_artifacts,
}
for module in modules
]
# Sort by pipeline_order if available
available_modules.sort(key=lambda m: (m.get("pipeline_order") or 999, m["identifier"]))
return {
"modules": available_modules,
"count": len(available_modules),
"container_engine": settings.engine.type,
"registry_url": settings.registry.url,
"registry_tag": settings.registry.default_tag,
}
except Exception as exception:
message: str = f"Failed to list modules: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def execute_module(
module_identifier: str,
configuration: dict[str, Any] | None = None,
assets_path: str | None = None,
) -> dict[str, Any]:
"""Execute a FuzzForge module in an isolated container.
This tool runs a module in a sandboxed environment.
The module receives input assets and produces output results.
The response includes `results_path` pointing to the stored results archive.
Use this path directly to read outputs — no need to call `get_execution_results`.
:param module_identifier: The identifier of the module to execute.
:param configuration: Optional configuration dict to pass to the module.
:param assets_path: Optional path to input assets. Use this to pass specific
inputs to a module (e.g. crash files to crash-analyzer) without changing
the project's default assets. If not provided, uses project assets.
:return: Execution result including status and results path.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
result: StepResult = await runner.execute_module(
module_identifier=module_identifier,
project_path=project_path,
configuration=configuration,
assets_path=Path(assets_path) if assets_path else None,
)
return {
"success": result.success,
"execution_id": result.execution_id,
"module": result.module_identifier,
"results_path": str(result.results_path) if result.results_path else None,
"started_at": result.started_at.isoformat(),
"completed_at": result.completed_at.isoformat(),
"error": result.error,
}
except Exception as exception:
message: str = f"Module execution failed: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def start_continuous_module(
module_identifier: str,
configuration: dict[str, Any] | None = None,
assets_path: str | None = None,
) -> dict[str, Any]:
"""Start a module in continuous/background mode.
The module will run indefinitely until stopped with stop_continuous_module().
Use get_continuous_status() to check progress and metrics.
This is useful for long-running modules that should run until
the user decides to stop them.
:param module_identifier: The module to run.
:param configuration: Optional configuration. Set max_duration to 0 for infinite.
:param assets_path: Optional path to input assets.
:return: Execution info including session_id for monitoring.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
session_id = str(uuid.uuid4())[:8]
# Set infinite duration if not specified
if configuration is None:
configuration = {}
if "max_duration" not in configuration:
configuration["max_duration"] = 0 # 0 = infinite
try:
# Determine assets path
if assets_path:
actual_assets_path = Path(assets_path)
else:
storage = runner.storage
actual_assets_path = storage.get_project_assets_path(project_path)
# Use the new non-blocking executor method
executor = runner._executor
result = executor.start_module_continuous(
module_identifier=module_identifier,
assets_path=actual_assets_path,
configuration=configuration,
project_path=project_path,
execution_id=session_id,
)
# Store execution info for tracking
_background_executions[session_id] = {
"session_id": session_id,
"module": module_identifier,
"configuration": configuration,
"started_at": datetime.now(timezone.utc).isoformat(),
"status": "running",
"container_id": result["container_id"],
"input_dir": result["input_dir"],
"project_path": str(project_path),
# Incremental stream.jsonl tracking
"stream_lines_read": 0,
"total_crashes": 0,
}
return {
"success": True,
"session_id": session_id,
"module": module_identifier,
"container_id": result["container_id"],
"status": "running",
"message": f"Continuous module started. Use get_continuous_status('{session_id}') to monitor progress.",
}
except Exception as exception:
message: str = f"Failed to start continuous module: {exception}"
raise ToolError(message) from exception
def _get_continuous_status_impl(session_id: str) -> dict[str, Any]:
"""Internal helper to get continuous session status (non-tool version).
Uses incremental reads of ``stream.jsonl`` via ``tail -n +offset`` so that
only new lines appended since the last poll are fetched and parsed. Crash
counts and latest metrics are accumulated across polls.
"""
if session_id not in _background_executions:
raise ToolError(f"Unknown session: {session_id}. Use list_continuous_sessions() to see active sessions.")
execution = _background_executions[session_id]
container_id = execution.get("container_id")
# Carry forward accumulated state
metrics: dict[str, Any] = {
"total_executions": 0,
"total_crashes": execution.get("total_crashes", 0),
"exec_per_sec": 0,
"coverage": 0,
"current_target": "",
"new_events": [],
}
if container_id:
try:
runner: Runner = get_runner()
executor = runner._executor
# Check container status first
container_status = executor.get_module_status(container_id)
if container_status != "running":
execution["status"] = "stopped" if container_status == "exited" else container_status
# Incremental read: only fetch lines we haven't seen yet
lines_read: int = execution.get("stream_lines_read", 0)
stream_content = executor.read_module_output_incremental(
container_id,
start_line=lines_read + 1,
output_file="/data/output/stream.jsonl",
)
if stream_content:
new_lines = stream_content.strip().split("\n")
new_line_count = 0
for line in new_lines:
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
# Possible torn read on the very last line — skip it
# and do NOT advance the offset so it is re-read next
# poll when the write is complete.
continue
new_line_count += 1
metrics["new_events"].append(event)
# Extract latest metrics snapshot
if event.get("event") == "metrics":
metrics["total_executions"] = event.get("executions", 0)
metrics["current_target"] = event.get("target", "")
metrics["exec_per_sec"] = event.get("exec_per_sec", 0)
metrics["coverage"] = event.get("coverage", 0)
if event.get("event") == "crash_detected":
metrics["total_crashes"] += 1
# Advance offset by successfully parsed lines only
execution["stream_lines_read"] = lines_read + new_line_count
execution["total_crashes"] = metrics["total_crashes"]
except Exception as e:
metrics["error"] = str(e)
# Calculate elapsed time
started_at = execution.get("started_at", "")
elapsed_seconds = 0
if started_at:
try:
start_time = datetime.fromisoformat(started_at)
elapsed_seconds = int((datetime.now(timezone.utc) - start_time).total_seconds())
except Exception:
pass
return {
"session_id": session_id,
"module": execution.get("module"),
"status": execution.get("status"),
"container_id": container_id,
"started_at": started_at,
"elapsed_seconds": elapsed_seconds,
"elapsed_human": f"{elapsed_seconds // 60}m {elapsed_seconds % 60}s",
"metrics": metrics,
}
@mcp.tool
async def get_continuous_status(session_id: str) -> dict[str, Any]:
"""Get the current status and metrics of a running continuous session.
Call this periodically (e.g., every 30 seconds) to get live updates
on progress and metrics.
:param session_id: The session ID returned by start_continuous_module().
:return: Current status, metrics, and any events found.
"""
return _get_continuous_status_impl(session_id)
@mcp.tool
async def stop_continuous_module(session_id: str) -> dict[str, Any]:
"""Stop a running continuous session.
This will gracefully stop the module and collect any results.
:param session_id: The session ID of the session to stop.
:return: Final status and summary of the session.
"""
if session_id not in _background_executions:
raise ToolError(f"Unknown session: {session_id}")
execution = _background_executions[session_id]
container_id = execution.get("container_id")
input_dir = execution.get("input_dir")
try:
# Get final metrics before stopping (use helper, not the tool)
final_metrics = _get_continuous_status_impl(session_id)
# Stop the container and collect results
results_path = None
if container_id:
runner: Runner = get_runner()
executor = runner._executor
try:
results_path = executor.stop_module_continuous(container_id, input_dir)
except Exception:
# Container may have already stopped
pass
execution["status"] = "stopped"
execution["stopped_at"] = datetime.now(timezone.utc).isoformat()
return {
"success": True,
"session_id": session_id,
"message": "Continuous session stopped",
"results_path": str(results_path) if results_path else None,
"final_metrics": final_metrics.get("metrics", {}),
"elapsed": final_metrics.get("elapsed_human", ""),
}
except Exception as exception:
message: str = f"Failed to stop continuous module: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def list_continuous_sessions() -> dict[str, Any]:
"""List all active and recent continuous sessions.
:return: List of continuous sessions with their status.
"""
sessions = []
for session_id, execution in _background_executions.items():
sessions.append({
"session_id": session_id,
"module": execution.get("module"),
"status": execution.get("status"),
"started_at": execution.get("started_at"),
})
return {
"sessions": sessions,
"count": len(sessions),
}
@@ -3,15 +3,12 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fuzzforge_mcp.dependencies import get_project_path, get_runner, set_current_project_path
if TYPE_CHECKING:
from fuzzforge_runner import Runner
from fuzzforge_mcp.dependencies import get_project_path, get_storage, set_current_project_path
mcp: FastMCP = FastMCP()
@@ -22,25 +19,24 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]:
"""Initialize a new FuzzForge project.
Creates a `.fuzzforge/` directory inside the project for storing:
- assets/: Input files (source code, etc.)
- inputs/: Prepared module inputs (for debugging)
- runs/: Execution results from each module
- config.json: Project configuration
- runs/: Execution results
This should be called before executing modules or workflows.
This should be called before executing hub tools.
:param project_path: Path to the project directory. If not provided, uses current directory.
:return: Project initialization result.
"""
runner: Runner = get_runner()
storage = get_storage()
try:
path = Path(project_path) if project_path else get_project_path()
# Track this as the current active project
set_current_project_path(path)
storage_path = runner.init_project(path)
storage_path = storage.init_project(path)
return {
"success": True,
@@ -58,23 +54,18 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]:
async def set_project_assets(assets_path: str) -> dict[str, Any]:
"""Set the initial assets (source code) for a project.
This sets the DEFAULT source directory mounted into modules.
Usually this is the project root containing source code (e.g. Cargo.toml, src/).
This sets the DEFAULT source directory that will be mounted into
hub tool containers via volume mounts.
IMPORTANT: This OVERWRITES the previous assets path. Only call this once
during project setup. To pass different inputs to a specific module
(e.g. crash files to crash-analyzer), use the `assets_path` parameter
on `execute_module` instead.
:param assets_path: Path to the project source directory or archive.
:param assets_path: Path to the project source directory.
:return: Result including stored assets path.
"""
runner: Runner = get_runner()
storage = get_storage()
project_path: Path = get_project_path()
try:
stored_path = runner.set_project_assets(
stored_path = storage.set_project_assets(
project_path=project_path,
assets_path=Path(assets_path),
)
@@ -100,11 +91,11 @@ async def list_executions() -> dict[str, Any]:
:return: List of execution IDs.
"""
runner: Runner = get_runner()
storage = get_storage()
project_path: Path = get_project_path()
try:
executions = runner.list_executions(project_path)
executions = storage.list_executions(project_path)
return {
"success": True,
@@ -127,11 +118,11 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
:return: Result including path to results archive.
"""
runner: Runner = get_runner()
storage = get_storage()
project_path: Path = get_project_path()
try:
results_path = runner.get_execution_results(project_path, execution_id)
results_path = storage.get_execution_results(project_path, execution_id)
if results_path is None:
return {
@@ -140,7 +131,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
"error": "Execution results not found",
}
result = {
result: dict[str, Any] = {
"success": True,
"execution_id": execution_id,
"results_path": str(results_path),
@@ -148,7 +139,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
# Extract if requested
if extract_to:
extracted_path = runner.extract_results(results_path, Path(extract_to))
extracted_path = storage.extract_results(results_path, Path(extract_to))
result["extracted_path"] = str(extracted_path)
return result
@@ -1,92 +0,0 @@
"""Workflow tools for FuzzForge MCP."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fuzzforge_runner.orchestrator import WorkflowDefinition, WorkflowStep
from fuzzforge_mcp.dependencies import get_project_path, get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
from fuzzforge_runner.orchestrator import WorkflowResult
mcp: FastMCP = FastMCP()
@mcp.tool
async def execute_workflow(
workflow_name: str,
steps: list[dict[str, Any]],
initial_assets_path: str | None = None,
) -> dict[str, Any]:
"""Execute a workflow consisting of multiple module steps.
A workflow chains multiple modules together, passing the output of each
module as input to the next. This enables complex pipelines.
:param workflow_name: Name for this workflow execution.
:param steps: List of step definitions, each with "module" and optional "configuration".
:param initial_assets_path: Optional path to initial assets for the first step.
:return: Workflow execution result including status of each step.
Example steps format:
[
{"module": "module-a", "configuration": {"key": "value"}},
{"module": "module-b", "configuration": {}},
{"module": "module-c"}
]
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
# Convert step dicts to WorkflowStep objects
workflow_steps = [
WorkflowStep(
module_identifier=step["module"],
configuration=step.get("configuration"),
name=step.get("name", f"step-{i}"),
)
for i, step in enumerate(steps)
]
workflow = WorkflowDefinition(
name=workflow_name,
steps=workflow_steps,
)
result: WorkflowResult = await runner.execute_workflow(
workflow=workflow,
project_path=project_path,
initial_assets_path=Path(initial_assets_path) if initial_assets_path else None,
)
return {
"success": result.success,
"execution_id": result.execution_id,
"workflow_name": result.name,
"final_results_path": str(result.final_results_path) if result.final_results_path else None,
"steps": [
{
"step_index": step.step_index,
"module": step.module_identifier,
"success": step.success,
"execution_id": step.execution_id,
"results_path": str(step.results_path) if step.results_path else None,
"error": step.error,
}
for step in result.steps
],
}
except Exception as exception:
message: str = f"Workflow execution failed: {exception}"
raise ToolError(message) from exception