feat: FuzzForge AI - complete rewrite for OSS release

This commit is contained in:
AFredefon
2026-01-30 09:57:48 +01:00
commit b46f050aef
226 changed files with 12943 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM docker.io/debian:trixie
ARG PACKAGE
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /bin/
WORKDIR /app
RUN /bin/uv venv && /bin/uv pip install --find-links /wheels $PACKAGE
CMD [ "/bin/uv", "run", "uvicorn", "fuzzforge_mcp.application:app"]
+51
View File
@@ -0,0 +1,51 @@
PACKAGE=$(word 1, $(shell uv version))
VERSION=$(word 2, $(shell uv version))
PODMAN?=/usr/bin/podman
ARTIFACTS?=./dist
SOURCES=./src
TESTS=./tests
.PHONY: bandit clean cloc format image mypy pytest ruff version wheel
bandit:
uv run bandit --recursive $(SOURCES)
clean:
@find . -type d \( \
-name '*.egg-info' \
-o -name '.mypy_cache' \
-o -name '.pytest_cache' \
-o -name '.ruff_cache' \
-o -name '__pycache__' \
\) -printf 'removing directory %p\n' -exec rm -rf {} +
cloc:
cloc $(SOURCES) $(TESTS)
format:
uv run ruff format $(SOURCES) $(TESTS)
image:
$(PODMAN) build \
--build-arg PACKAGE=$(PACKAGE) \
--file ./Dockerfile \
--no-cache \
--tag $(PACKAGE):$(VERSION) \
--volume $(ARTIFACTS):/wheels
mypy:
uv run mypy $(SOURCES) $(TESTS)
pytest:
uv run pytest -v $(TESTS)
ruff:
uv run ruff check --fix $(SOURCES) $(TESTS)
version:
@echo '$(PACKAGE)@$(VERSION)'
wheel:
uv build --out-dir $(ARTIFACTS)
+223
View File
@@ -0,0 +1,223 @@
# FuzzForge MCP
Model Context Protocol (MCP) server that enables AI agents to orchestrate FuzzForge security research modules.
## Overview
FuzzForge MCP provides a standardized interface for AI agents (Claude Code, GitHub Copilot, Claude Desktop) to:
- List and discover available security modules
- Execute modules in isolated containers
- Chain modules together in workflows
- Manage project assets and results
The server communicates with AI agents using the [Model Context Protocol](https://modelcontextprotocol.io/) over stdio.
## Installation
### Automatic Installation (Recommended)
Use the FuzzForge CLI to automatically configure MCP for your AI agent:
```bash
# For GitHub Copilot
uv run fuzzforge mcp install copilot
# For Claude Code (VS Code extension)
uv run fuzzforge mcp install claude-code
# For Claude Desktop (standalone app)
uv run fuzzforge mcp install claude-desktop
# Verify installation
uv run fuzzforge mcp status
```
After installation, restart your AI agent to activate the connection.
### Manual Installation
For custom setups, you can manually configure the MCP server.
#### Claude Code (`.mcp.json` in project root)
```json
{
"mcpServers": {
"fuzzforge": {
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
"args": ["-m", "fuzzforge_mcp"],
"cwd": "/path/to/fuzzforge-oss",
"env": {
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
"FUZZFORGE_ENGINE__TYPE": "podman",
"FUZZFORGE_ENGINE__GRAPHROOT": "~/.fuzzforge/containers/storage",
"FUZZFORGE_ENGINE__RUNROOT": "~/.fuzzforge/containers/run"
}
}
}
}
```
#### GitHub Copilot (`~/.config/Code/User/mcp.json`)
```json
{
"servers": {
"fuzzforge": {
"type": "stdio",
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
"args": ["-m", "fuzzforge_mcp"],
"cwd": "/path/to/fuzzforge-oss",
"env": {
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
"FUZZFORGE_ENGINE__TYPE": "podman",
"FUZZFORGE_ENGINE__GRAPHROOT": "~/.fuzzforge/containers/storage",
"FUZZFORGE_ENGINE__RUNROOT": "~/.fuzzforge/containers/run"
}
}
}
}
```
#### Claude Desktop (`~/.config/Claude/claude_desktop_config.json`)
```json
{
"mcpServers": {
"fuzzforge": {
"type": "stdio",
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
"args": ["-m", "fuzzforge_mcp"],
"cwd": "/path/to/fuzzforge-oss",
"env": {
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
"FUZZFORGE_ENGINE__TYPE": "podman",
"FUZZFORGE_ENGINE__GRAPHROOT": "~/.fuzzforge/containers/storage",
"FUZZFORGE_ENGINE__RUNROOT": "~/.fuzzforge/containers/run"
}
}
}
}
```
## Environment Variables
| Variable | Required | Default | Description |
| -------- | -------- | ------- | ----------- |
| `FUZZFORGE_MODULES_PATH` | Yes | - | Path to the modules directory |
| `FUZZFORGE_ENGINE__TYPE` | No | `podman` | Container engine (`podman` or `docker`) |
| `FUZZFORGE_ENGINE__GRAPHROOT` | No | `~/.fuzzforge/containers/storage` | Container image storage path |
| `FUZZFORGE_ENGINE__RUNROOT` | No | `~/.fuzzforge/containers/run` | Container runtime state path |
## Available Tools
The MCP server exposes the following tools to AI agents:
### Project Management
- **`init_project`** - Initialize a new FuzzForge project
- **`set_project_assets`** - Set initial assets (source code, contracts, etc.) for the project
### Module Management
- **`list_modules`** - List all available security research modules
- **`execute_module`** - Execute a single module in an isolated container
### Workflow Management
- **`execute_workflow`** - Execute a workflow consisting of multiple chained modules
### Resources
The server also provides resources for accessing:
- Project information and configuration
- Module metadata and schemas
- Execution results and artifacts
- Workflow definitions and status
## Usage Examples
### From AI Agent (e.g., Claude Code)
Once configured, AI agents can interact with FuzzForge naturally:
```text
User: List the available security modules
AI Agent: [Calls list_modules tool]
```
```text
User: Run echidna fuzzer on my Solidity contracts
AI Agent: [Calls init_project, set_project_assets, then execute_module]
```
```text
User: Create a workflow that compiles contracts, runs slither, then echidna
AI Agent: [Calls execute_workflow with appropriate steps]
```
### Direct Testing (Development)
For testing during development, you can run the MCP server directly:
```bash
# Run MCP server in stdio mode (for AI agents)
uv run python -m fuzzforge_mcp
# Run HTTP server for testing (not for production)
uv run uvicorn fuzzforge_mcp.application:app --reload
```
## Architecture
```text
┌─────────────────────────────────────────┐
│ AI Agent (Claude/Copilot) │
│ via MCP Protocol │
└─────────────────────────────────────────┘
│ stdio/JSON-RPC
┌─────────────────────────────────────────┐
│ FuzzForge MCP Server │
│ Tools: init_project, list_modules, │
│ execute_module, execute_workflow│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ FuzzForge Runner │
│ Podman/Docker Orchestration │
└─────────────────────────────────────────┘
┌─────────┼─────────┐
▼ ▼ ▼
[Module 1] [Module 2] [Module 3]
Container Container Container
```
## Development
### Building the Package
```bash
# Install development dependencies
uv sync
# Run type checking
uv run mypy src/
# Run tests
uv run pytest
```
## See Also
- [FuzzForge Main README](../README.md) - Overall project documentation
- [Module SDK](../fuzzforge-modules/fuzzforge-modules-sdk/README.md) - Creating custom modules
- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
+6
View File
@@ -0,0 +1,6 @@
[mypy]
plugins = pydantic.mypy
strict = True
warn_unused_ignores = True
warn_redundant_casts = True
warn_return_any = True
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "fuzzforge-mcp"
version = "0.0.1"
description = "FuzzForge MCP Server - AI agent gateway for FuzzForge OSS."
authors = []
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastmcp==2.14.1",
"fuzzforge-runner==0.0.1",
"fuzzforge-types==0.0.1",
"pydantic==2.12.4",
"pydantic-settings==2.12.0",
"structlog==25.5.0",
]
[project.scripts]
fuzzforge-mcp = "fuzzforge_mcp.__main__:main"
[project.optional-dependencies]
lints = [
"bandit==1.8.6",
"mypy==1.18.2",
"ruff==0.14.4",
]
tests = [
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-httpx==0.36.0",
]
[tool.uv.sources]
fuzzforge-runner = { workspace = true }
fuzzforge-types = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+16
View File
@@ -0,0 +1,16 @@
line-length = 120
[lint]
select = [ "ALL" ]
ignore = [
"COM812", # conflicts with the formatter
"D203", # conflicts with 'D211'
"D213", # conflicts with 'D212'
]
[lint.per-file-ignores]
"tests/*" = [
"PLR0913", # allowing functions with many arguments in tests (required for fixtures)
"PLR2004", # allowing comparisons using unamed numerical constants in tests
"S101", # allowing 'assert' statements in tests
]
@@ -0,0 +1 @@
"""TODO."""
@@ -0,0 +1,17 @@
"""FuzzForge MCP Server entry point."""
from fuzzforge_mcp.application import mcp
def main() -> None:
"""Run the FuzzForge MCP server in stdio mode.
This is the primary entry point for AI agent integration.
The server communicates via stdin/stdout using the MCP protocol.
"""
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
@@ -0,0 +1,64 @@
"""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.
"""
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
from fastmcp import FastMCP
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fuzzforge_mcp import resources, tools
from fuzzforge_runner import Settings
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
@asynccontextmanager
async def lifespan(_: FastMCP) -> AsyncGenerator[Settings]:
"""Initialize MCP server lifespan context.
Loads settings from environment variables and makes them
available to all tools and resources.
:param mcp: FastMCP server instance (unused).
:return: Settings instance for dependency injection.
"""
settings: Settings = Settings()
yield settings
mcp: FastMCP = FastMCP(
name="FuzzForge MCP Server",
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
4. **Manage projects**: Initialize and configure projects
5. **Get results**: Retrieve execution results
Typical workflow:
1. Initialize a project with `init_project`
2. Set project assets with `set_project_assets` (optional)
3. List available modules with `list_modules`
4. Execute a module with `execute_module`
5. Get results with `get_execution_results`
""",
lifespan=lifespan,
)
mcp.add_middleware(middleware=ErrorHandlingMiddleware())
mcp.mount(resources.mcp)
mcp.mount(tools.mcp)
# HTTP app for testing (primary mode is stdio)
app = mcp.http_app()
@@ -0,0 +1,48 @@
"""Dependency injection helpers for FuzzForge MCP."""
from __future__ import annotations
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
if TYPE_CHECKING:
from fastmcp import Context
def get_settings() -> Settings:
"""Get MCP server settings from context.
:return: Settings instance.
:raises FuzzForgeMCPError: If settings not available.
"""
context: Context = get_context()
if context.request_context is None:
message: str = "Request context not available"
raise FuzzForgeMCPError(message)
return cast("Settings", context.request_context.lifespan_context)
def get_project_path() -> Path:
"""Get the current project path.
:return: Path to the current project.
"""
settings: Settings = get_settings()
return Path(settings.project.default_path)
def get_runner() -> Runner:
"""Get a configured Runner instance.
:return: Runner instance configured from MCP settings.
"""
settings: Settings = get_settings()
return Runner(settings)
@@ -0,0 +1,5 @@
"""TODO."""
class FuzzForgeMCPError(Exception):
"""TODO."""
@@ -0,0 +1,16 @@
"""FuzzForge MCP Resources."""
from fastmcp import FastMCP
from fuzzforge_mcp.resources import executions, modules, project, workflows
mcp: FastMCP = FastMCP()
mcp.mount(executions.mcp)
mcp.mount(modules.mcp)
mcp.mount(project.mcp)
mcp.mount(workflows.mcp)
__all__ = [
"mcp",
]
@@ -0,0 +1,75 @@
"""Execution resources 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 ResourceError
from fuzzforge_mcp.dependencies import get_project_path, get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
mcp: FastMCP = FastMCP()
@mcp.resource("fuzzforge://executions/")
async def list_executions() -> list[dict[str, Any]]:
"""List all executions for the current project.
Returns a list of execution IDs and basic metadata.
:return: List of execution information dictionaries.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
execution_ids = runner.list_executions(project_path)
return [
{
"execution_id": exec_id,
"has_results": runner.get_execution_results(project_path, exec_id) is not None,
}
for exec_id in execution_ids
]
except Exception as exception:
message: str = f"Failed to list executions: {exception}"
raise ResourceError(message) from exception
@mcp.resource("fuzzforge://executions/{execution_id}")
async def get_execution(execution_id: str) -> dict[str, Any]:
"""Get information about a specific execution.
:param execution_id: The execution ID to retrieve.
:return: Execution information dictionary.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
results_path = runner.get_execution_results(project_path, execution_id)
if results_path is None:
raise ResourceError(f"Execution not found: {execution_id}")
return {
"execution_id": execution_id,
"results_path": str(results_path),
"results_exist": results_path.exists(),
}
except ResourceError:
raise
except Exception as exception:
message: str = f"Failed to get execution: {exception}"
raise ResourceError(message) from exception
@@ -0,0 +1,78 @@
"""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
@@ -0,0 +1,84 @@
"""Project resources 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 ResourceError
from fuzzforge_mcp.dependencies import get_project_path, get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
mcp: FastMCP = FastMCP()
@mcp.resource("fuzzforge://project")
async def get_project() -> dict[str, Any]:
"""Get information about the current project.
Returns the current project configuration including paths
and available executions.
:return: Project information dictionary.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
executions = runner.list_executions(project_path)
assets_path = runner.storage.get_project_assets_path(project_path)
return {
"path": str(project_path),
"name": project_path.name,
"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
}
except Exception as exception:
message: str = f"Failed to get project info: {exception}"
raise ResourceError(message) from exception
@mcp.resource("fuzzforge://project/settings")
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.
:return: Settings dictionary.
"""
from fuzzforge_mcp.dependencies import get_settings
try:
settings = get_settings()
return {
"engine": {
"type": settings.engine.type,
"socket": settings.engine.socket,
},
"storage": {
"path": str(settings.storage.path),
},
"project": {
"path": str(settings.project.path),
"modules_path": str(settings.modules_path),
},
"debug": settings.debug,
}
except Exception as exception:
message: str = f"Failed to get settings: {exception}"
raise ResourceError(message) from exception
@@ -0,0 +1,53 @@
"""Workflow resources for FuzzForge MCP.
Note: In FuzzForge OSS, 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 OSS 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,16 @@
"""FuzzForge MCP Tools."""
from fastmcp import FastMCP
from fuzzforge_mcp.tools import modules, projects, workflows
mcp: FastMCP = FastMCP()
mcp.mount(modules.mcp)
mcp.mount(projects.mcp)
mcp.mount(workflows.mcp)
__all__ = [
"mcp",
]
@@ -0,0 +1,347 @@
"""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 and availability status.
: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
modules = runner.list_module_images(filter_prefix="localhost/")
available_modules = [
{
"identifier": module.identifier,
"image": f"localhost/{module.identifier}:{module.version or 'latest'}",
"available": module.available,
}
for module in modules
]
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.
: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. 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,
)
# 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"],
}
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)."""
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")
# Initialize metrics
metrics: dict[str, Any] = {
"total_executions": 0,
"total_crashes": 0,
"exec_per_sec": 0,
"coverage": 0,
"current_target": "",
"latest_events": [],
}
# Read stream.jsonl from inside the running container
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
# Read stream.jsonl from container
stream_content = executor.read_module_output(container_id, "/data/output/stream.jsonl")
if stream_content:
lines = stream_content.strip().split("\n")
# Get last 20 events
recent_lines = lines[-20:] if len(lines) > 20 else lines
crash_count = 0
for line in recent_lines:
try:
event = json.loads(line)
metrics["latest_events"].append(event)
# Extract metrics from events
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":
crash_count += 1
except json.JSONDecodeError:
continue
metrics["total_crashes"] = crash_count
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),
}
@@ -0,0 +1,145 @@
"""Project management 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_mcp.dependencies import get_project_path, get_runner
if TYPE_CHECKING:
from fuzzforge_runner import Runner
mcp: FastMCP = FastMCP()
@mcp.tool
async def init_project(project_path: str | None = None) -> dict[str, Any]:
"""Initialize a new FuzzForge project.
Creates the necessary storage directories for a project. This should
be called before executing modules or workflows.
:param project_path: Path to the project directory. If not provided, uses current directory.
:return: Project initialization result.
"""
runner: Runner = get_runner()
try:
path = Path(project_path) if project_path else get_project_path()
storage_path = runner.init_project(path)
return {
"success": True,
"project_path": str(path),
"storage_path": str(storage_path),
"message": f"Project initialized at {path}",
}
except Exception as exception:
message: str = f"Failed to initialize project: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def set_project_assets(assets_path: str) -> dict[str, Any]:
"""Set the initial assets for a project.
Assets are input files that will be provided to modules during execution.
This could be source code, contracts, binaries, etc.
:param assets_path: Path to assets file (archive) or directory.
:return: Result including stored assets path.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
stored_path = runner.set_project_assets(
project_path=project_path,
assets_path=Path(assets_path),
)
return {
"success": True,
"project_path": str(project_path),
"assets_path": str(stored_path),
"message": f"Assets stored from {assets_path}",
}
except Exception as exception:
message: str = f"Failed to set project assets: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def list_executions() -> dict[str, Any]:
"""List all executions for the current project.
Returns a list of execution IDs that can be used to retrieve results.
:return: List of execution IDs.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
executions = runner.list_executions(project_path)
return {
"success": True,
"project_path": str(project_path),
"executions": executions,
"count": len(executions),
}
except Exception as exception:
message: str = f"Failed to list executions: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def get_execution_results(execution_id: str, extract_to: str | None = None) -> dict[str, Any]:
"""Get results for a specific execution.
:param execution_id: The execution ID to retrieve results for.
:param extract_to: Optional directory to extract results to.
:return: Result including path to results archive.
"""
runner: Runner = get_runner()
project_path: Path = get_project_path()
try:
results_path = runner.get_execution_results(project_path, execution_id)
if results_path is None:
return {
"success": False,
"execution_id": execution_id,
"error": "Execution results not found",
}
result = {
"success": True,
"execution_id": execution_id,
"results_path": str(results_path),
}
# Extract if requested
if extract_to:
extracted_path = runner.extract_results(results_path, Path(extract_to))
result["extracted_path"] = str(extracted_path)
return result
except Exception as exception:
message: str = f"Failed to get execution results: {exception}"
raise ToolError(message) from exception
@@ -0,0 +1,92 @@
"""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
+1
View File
@@ -0,0 +1 @@
"""TODO."""
+34
View File
@@ -0,0 +1,34 @@
"""TODO."""
from typing import TYPE_CHECKING
import pytest
from fastmcp import Client
from fuzzforge_mcp.application import mcp
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable
from fastmcp.client import FastMCPTransport
from fuzzforge_types import FuzzForgeProjectIdentifier
pytest_plugins = ["fuzzforge_tests.fixtures"]
@pytest.fixture(autouse=True)
def environment(
monkeypatch: pytest.MonkeyPatch,
random_project_identifier: Callable[[], FuzzForgeProjectIdentifier],
) -> None:
"""TODO."""
monkeypatch.setenv("FUZZFORGE_PROJECT_IDENTIFIER", str(random_project_identifier()))
monkeypatch.setenv("FUZZFORGE_API_HOST", "127.0.0.1")
monkeypatch.setenv("FUZZFORGE_API_PORT", "8000")
@pytest.fixture
async def mcp_client() -> AsyncGenerator[Client[FastMCPTransport]]:
"""TODO."""
async with Client(transport=mcp) as client:
yield client
+189
View File
@@ -0,0 +1,189 @@
"""TODO."""
from datetime import UTC, datetime
from http import HTTPMethod
from json import loads
from typing import TYPE_CHECKING, cast
from fuzzforge_sdk.api.responses.executions import (
GetFuzzForgeModuleExecutionsResponse,
GetFuzzForgeWorkflowExecutionsResponse,
ModuleExecutionSummary,
WorkflowExecutionSummary,
)
from fuzzforge_sdk.api.responses.modules import (
GetFuzzForgeModuleDefinitionResponse,
GetFuzzForgeModuleDefinitionsResponse,
ModuleDefinitionSummary,
)
from fuzzforge_types import FuzzForgeExecutionStatus, FuzzForgeModule
if TYPE_CHECKING:
from collections.abc import Callable
from fastmcp import Client
from fastmcp.client import FastMCPTransport
from fuzzforge_types import (
FuzzForgeExecutionIdentifier,
FuzzForgeModuleIdentifier,
FuzzForgeProjectIdentifier,
FuzzForgeWorkflowIdentifier,
)
from mcp.types import TextResourceContents
from pytest_httpx import HTTPXMock
async def test_get_fuzzforge_module_when_server_returns_valid_data(
httpx_mock: HTTPXMock,
mcp_client: Client[FastMCPTransport],
random_module_description: Callable[[], str],
random_module_identifier: Callable[[], FuzzForgeModuleIdentifier],
random_module_name: Callable[[], str],
) -> None:
"""TODO."""
module = GetFuzzForgeModuleDefinitionResponse(
module_description=random_module_description(),
module_identifier=random_module_identifier(),
module_name=random_module_name(),
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
)
httpx_mock.add_response(
json=module.model_dump(mode="json"),
method=HTTPMethod.GET,
url=f"http://127.0.0.1:8000/modules/{module.module_identifier}",
)
response = FuzzForgeModule.model_validate_json(
json_data=cast(
"TextResourceContents",
(await mcp_client.read_resource(f"fuzzforge://modules/{module.module_identifier}"))[0],
).text,
)
assert response.module_description == module.module_description
assert response.module_identifier == module.module_identifier
assert response.module_name == module.module_name
async def test_get_fuzzforge_modules_when_server_returns_valid_data(
httpx_mock: HTTPXMock,
mcp_client: Client[FastMCPTransport],
random_module_description: Callable[[], str],
random_module_identifier: Callable[[], FuzzForgeModuleIdentifier],
random_module_name: Callable[[], str],
) -> None:
"""TODO."""
modules = [
ModuleDefinitionSummary(
module_description=random_module_description(),
module_identifier=random_module_identifier(),
module_name=random_module_name(),
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
),
ModuleDefinitionSummary(
module_description=random_module_description(),
module_identifier=random_module_identifier(),
module_name=random_module_name(),
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
),
]
httpx_mock.add_response(
json=GetFuzzForgeModuleDefinitionsResponse(
modules=modules,
total=2,
limit=100,
offset=0,
).model_dump(mode="json"),
method=HTTPMethod.GET,
url="http://127.0.0.1:8000/modules",
)
response = [
ModuleDefinitionSummary.model_validate(entry)
for entry in loads(
cast("TextResourceContents", (await mcp_client.read_resource("fuzzforge://modules/"))[0]).text
)
]
assert len(response) == len(modules)
for expected, module in zip(modules, response, strict=True):
assert module.module_description == expected.module_description
assert module.module_identifier == expected.module_identifier
assert module.module_name == expected.module_name
async def test_get_executions_when_server_returns_valid_data(
httpx_mock: HTTPXMock,
mcp_client: Client[FastMCPTransport],
random_module_identifier: Callable[[], FuzzForgeModuleIdentifier],
random_module_execution_identifier: Callable[[], FuzzForgeExecutionIdentifier],
random_workflow_identifier: Callable[[], FuzzForgeWorkflowIdentifier],
random_workflow_execution_identifier: Callable[[], FuzzForgeExecutionIdentifier],
) -> None:
"""TODO."""
project_identifier: FuzzForgeProjectIdentifier = mcp_client.transport.server._lifespan_result.PROJECT_IDENTIFIER # type: ignore[union-attr] # noqa: SLF001
modules = [
ModuleExecutionSummary(
execution_identifier=random_module_execution_identifier(),
module_identifier=random_module_identifier(),
execution_status=FuzzForgeExecutionStatus.PENDING,
error=None,
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
),
ModuleExecutionSummary(
execution_identifier=random_module_execution_identifier(),
module_identifier=random_module_identifier(),
execution_status=FuzzForgeExecutionStatus.PENDING,
error=None,
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
),
]
workflows = [
WorkflowExecutionSummary(
execution_identifier=random_workflow_execution_identifier(),
workflow_identifier=random_workflow_identifier(),
workflow_status=FuzzForgeExecutionStatus.PENDING,
error=None,
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
),
WorkflowExecutionSummary(
execution_identifier=random_workflow_execution_identifier(),
workflow_identifier=random_workflow_identifier(),
workflow_status=FuzzForgeExecutionStatus.PENDING,
error=None,
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
),
]
httpx_mock.add_response(
json=GetFuzzForgeModuleExecutionsResponse(
executions=modules,
project_identifier=project_identifier,
total=2,
).model_dump(mode="json"),
method=HTTPMethod.GET,
url=f"http://127.0.0.1:8000/projects/{project_identifier}/modules",
)
httpx_mock.add_response(
json=GetFuzzForgeWorkflowExecutionsResponse(
workflows=workflows,
project_identifier=project_identifier,
total=2,
).model_dump(mode="json"),
method=HTTPMethod.GET,
url=f"http://127.0.0.1:8000/projects/{project_identifier}/workflows",
)
response = loads(cast("TextResourceContents", (await mcp_client.read_resource("fuzzforge://executions/"))[0]).text)
assert len(response) == len(modules) + len(workflows)
for expected_module, module in zip(
modules, [ModuleExecutionSummary.model_validate(entry) for entry in response[: len(modules)]], strict=True
):
assert module.execution_identifier == expected_module.execution_identifier
assert module.module_identifier == expected_module.module_identifier
for expected_workflow, workflow in zip(
workflows, [WorkflowExecutionSummary.model_validate(entry) for entry in response[len(workflows) :]], strict=True
):
assert workflow.execution_identifier == expected_workflow.execution_identifier
assert workflow.workflow_identifier == expected_workflow.workflow_identifier