mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-07-08 17:37:51 +02:00
feat: FuzzForge AI - complete rewrite for OSS release
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user