mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-05-28 06:42:25 +02:00
refactor: remove module system, migrate to MCP hub tools architecture
This commit is contained in:
@@ -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)",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user