feat(hub): add hub integration and rename project to FuzzForge AI

This commit is contained in:
AFredefon
2026-02-25 23:12:42 +01:00
parent 04c8383739
commit f3899279d5
33 changed files with 1904 additions and 73 deletions
+9 -9
View File
@@ -45,11 +45,11 @@ For custom setups, you can manually configure the MCP server.
{
"mcpServers": {
"fuzzforge": {
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
"command": "/path/to/fuzzforge_ai/.venv/bin/python",
"args": ["-m", "fuzzforge_mcp"],
"cwd": "/path/to/fuzzforge-oss",
"cwd": "/path/to/fuzzforge_ai",
"env": {
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge_ai/fuzzforge-modules",
"FUZZFORGE_ENGINE__TYPE": "docker"
}
}
@@ -64,11 +64,11 @@ For custom setups, you can manually configure the MCP server.
"servers": {
"fuzzforge": {
"type": "stdio",
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
"command": "/path/to/fuzzforge_ai/.venv/bin/python",
"args": ["-m", "fuzzforge_mcp"],
"cwd": "/path/to/fuzzforge-oss",
"cwd": "/path/to/fuzzforge_ai",
"env": {
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge_ai/fuzzforge-modules",
"FUZZFORGE_ENGINE__TYPE": "docker"
}
}
@@ -83,11 +83,11 @@ For custom setups, you can manually configure the MCP server.
"mcpServers": {
"fuzzforge": {
"type": "stdio",
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
"command": "/path/to/fuzzforge_ai/.venv/bin/python",
"args": ["-m", "fuzzforge_mcp"],
"cwd": "/path/to/fuzzforge-oss",
"cwd": "/path/to/fuzzforge_ai",
"env": {
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge_ai/fuzzforge-modules",
"FUZZFORGE_ENGINE__TYPE": "docker"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "fuzzforge-mcp"
version = "0.0.1"
description = "FuzzForge MCP Server - AI agent gateway for FuzzForge OSS."
description = "FuzzForge MCP Server - AI agent gateway for FuzzForge AI."
authors = []
readme = "README.md"
requires-python = ">=3.14"
@@ -43,6 +43,7 @@ FuzzForge is a security research orchestration platform. Use these tools to:
3. **Execute workflows**: Chain multiple modules together
4. **Manage projects**: Initialize and configure projects
5. **Get results**: Retrieve execution results
6. **Hub tools**: Discover and execute tools from external MCP servers
Typical workflow:
1. Initialize a project with `init_project`
@@ -50,6 +51,11 @@ Typical workflow:
3. List available modules with `list_modules`
4. Execute a module with `execute_module` — use `assets_path` param to pass different inputs per module
5. Read outputs from `results_path` returned by `execute_module` — check module's `output_artifacts` metadata for filenames
Hub workflow:
1. List available hub servers with `list_hub_servers`
2. Discover tools from servers with `discover_hub_tools`
3. Execute hub tools with `execute_hub_tool`
""",
lifespan=lifespan,
)
@@ -1,6 +1,6 @@
"""Workflow resources for FuzzForge MCP.
Note: In FuzzForge OSS, workflows are defined at runtime rather than
Note: In FuzzForge AI, workflows are defined at runtime rather than
stored. This resource provides documentation about workflow capabilities.
"""
@@ -19,7 +19,7 @@ mcp: FastMCP = FastMCP()
async def get_workflow_help() -> dict[str, Any]:
"""Get help information about creating workflows.
Workflows in FuzzForge OSS are defined at execution time rather
Workflows in FuzzForge AI are defined at execution time rather
than stored. Use the execute_workflow tool with step definitions.
:return: Workflow documentation.
@@ -2,13 +2,14 @@
from fastmcp import FastMCP
from fuzzforge_mcp.tools import modules, projects, workflows
from fuzzforge_mcp.tools import hub, modules, projects, workflows
mcp: FastMCP = FastMCP()
mcp.mount(modules.mcp)
mcp.mount(projects.mcp)
mcp.mount(workflows.mcp)
mcp.mount(hub.mcp)
__all__ = [
"mcp",
@@ -0,0 +1,315 @@
"""MCP Hub tools for FuzzForge MCP server.
This module provides tools for interacting with external MCP servers
through the FuzzForge hub. AI agents can:
- List available hub servers and their tools
- Discover tools from hub servers
- Execute hub tools
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fuzzforge_common.hub import HubExecutor, HubServerConfig, HubServerType
from fuzzforge_mcp.dependencies import get_settings
mcp: FastMCP = FastMCP()
# Global hub executor instance (lazy initialization)
_hub_executor: HubExecutor | None = None
def _get_hub_executor() -> HubExecutor:
"""Get or create the hub executor instance.
:returns: Hub executor instance.
:raises ToolError: If hub is disabled.
"""
global _hub_executor
settings = get_settings()
if not settings.hub.enabled:
msg = "MCP Hub is disabled. Enable it via FUZZFORGE_HUB__ENABLED=true"
raise ToolError(msg)
if _hub_executor is None:
config_path = settings.hub.config_path
_hub_executor = HubExecutor(
config_path=config_path,
timeout=settings.hub.timeout,
)
return _hub_executor
@mcp.tool
async def list_hub_servers() -> dict[str, Any]:
"""List all registered MCP hub servers.
Returns information about configured hub servers, including
their connection type, status, and discovered tool count.
:return: Dictionary with list of hub servers.
"""
try:
executor = _get_hub_executor()
servers = executor.list_servers()
return {
"servers": servers,
"count": len(servers),
"enabled_count": len([s for s in servers if s["enabled"]]),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to list hub servers: {e}"
raise ToolError(msg) from e
@mcp.tool
async def discover_hub_tools(server_name: str | None = None) -> dict[str, Any]:
"""Discover tools from hub servers.
Connects to hub servers and retrieves their available tools.
If server_name is provided, only discovers from that server.
Otherwise discovers from all enabled servers.
:param server_name: Optional specific server to discover from.
:return: Dictionary with discovered tools.
"""
try:
executor = _get_hub_executor()
if server_name:
tools = await executor.discover_server_tools(server_name)
return {
"server": server_name,
"tools": [
{
"identifier": t.identifier,
"name": t.name,
"description": t.description,
"parameters": [p.model_dump() for p in t.parameters],
}
for t in tools
],
"count": len(tools),
}
else:
results = await executor.discover_all_tools()
all_tools = []
for server, tools in results.items():
for tool in tools:
all_tools.append({
"identifier": tool.identifier,
"name": tool.name,
"server": server,
"description": tool.description,
"parameters": [p.model_dump() for p in tool.parameters],
})
return {
"servers_discovered": len(results),
"tools": all_tools,
"count": len(all_tools),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to discover hub tools: {e}"
raise ToolError(msg) from e
@mcp.tool
async def list_hub_tools() -> dict[str, Any]:
"""List all discovered hub tools.
Returns tools that have been previously discovered from hub servers.
Run discover_hub_tools first if no tools are listed.
:return: Dictionary with list of discovered tools.
"""
try:
executor = _get_hub_executor()
tools = executor.list_tools()
return {
"tools": tools,
"count": len(tools),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to list hub tools: {e}"
raise ToolError(msg) from e
@mcp.tool
async def execute_hub_tool(
identifier: str,
arguments: dict[str, Any] | None = None,
timeout: int | None = None,
) -> dict[str, Any]:
"""Execute a tool from a hub server.
:param identifier: Tool identifier (format: hub:server:tool or server:tool).
:param arguments: Tool arguments matching the tool's input schema.
:param timeout: Optional execution timeout in seconds.
:return: Tool execution result.
Example identifiers:
- "hub:nmap:nmap_scan"
- "nmap:nmap_scan"
- "hub:nuclei:nuclei_scan"
"""
try:
executor = _get_hub_executor()
result = await executor.execute_tool(
identifier=identifier,
arguments=arguments or {},
timeout=timeout,
)
return result.to_dict()
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Hub tool execution failed: {e}"
raise ToolError(msg) from e
@mcp.tool
async def get_hub_tool_schema(identifier: str) -> dict[str, Any]:
"""Get the input schema for a hub tool.
Returns the JSON Schema that describes the tool's expected arguments.
:param identifier: Tool identifier (format: hub:server:tool or server:tool).
:return: JSON Schema for the tool's input.
"""
try:
executor = _get_hub_executor()
schema = executor.get_tool_schema(identifier)
if schema is None:
msg = f"Tool '{identifier}' not found. Run discover_hub_tools first."
raise ToolError(msg)
return {
"identifier": identifier,
"schema": schema,
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to get tool schema: {e}"
raise ToolError(msg) from e
@mcp.tool
async def add_hub_server(
name: str,
server_type: str,
image: str | None = None,
command: list[str] | None = None,
url: str | None = None,
category: str | None = None,
description: str | None = None,
capabilities: list[str] | None = None,
environment: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Add a new MCP server to the hub.
Register a new external MCP server that can be used for tool discovery
and execution. Servers can be Docker images, local commands, or SSE endpoints.
:param name: Unique name for the server (e.g., "nmap", "nuclei").
:param server_type: Connection type ("docker", "command", or "sse").
:param image: Docker image name (for docker type).
:param command: Command and args (for command type).
:param url: SSE endpoint URL (for sse type).
:param category: Category for grouping (e.g., "reconnaissance").
:param description: Human-readable description.
:param capabilities: Docker capabilities to add (e.g., ["NET_RAW"]).
:param environment: Environment variables to pass.
:return: Information about the added server.
Examples:
- Docker: add_hub_server("nmap", "docker", image="nmap-mcp:latest", capabilities=["NET_RAW"])
- Command: add_hub_server("custom", "command", command=["python", "server.py"])
"""
try:
executor = _get_hub_executor()
# Parse server type
try:
stype = HubServerType(server_type)
except ValueError:
msg = f"Invalid server type: {server_type}. Use 'docker', 'command', or 'sse'."
raise ToolError(msg) from None
# Validate required fields based on type
if stype == HubServerType.DOCKER and not image:
msg = "Docker image required for docker type"
raise ToolError(msg)
if stype == HubServerType.COMMAND and not command:
msg = "Command required for command type"
raise ToolError(msg)
if stype == HubServerType.SSE and not url:
msg = "URL required for sse type"
raise ToolError(msg)
config = HubServerConfig(
name=name,
type=stype,
image=image,
command=command,
url=url,
category=category,
description=description,
capabilities=capabilities or [],
environment=environment or {},
)
server = executor.add_server(config)
return {
"success": True,
"server": {
"name": server.name,
"identifier": server.identifier,
"type": server.config.type.value,
"enabled": server.config.enabled,
},
"message": f"Server '{name}' added. Use discover_hub_tools('{name}') to discover its tools.",
}
except ValueError as e:
msg = f"Failed to add server: {e}"
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to add hub server: {e}"
raise ToolError(msg) from e
@@ -187,6 +187,9 @@ async def start_continuous_module(
"container_id": result["container_id"],
"input_dir": result["input_dir"],
"project_path": str(project_path),
# Incremental stream.jsonl tracking
"stream_lines_read": 0,
"total_crashes": 0,
}
return {
@@ -204,24 +207,29 @@ async def start_continuous_module(
def _get_continuous_status_impl(session_id: str) -> dict[str, Any]:
"""Internal helper to get continuous session status (non-tool version)."""
"""Internal helper to get continuous session status (non-tool version).
Uses incremental reads of ``stream.jsonl`` via ``tail -n +offset`` so that
only new lines appended since the last poll are fetched and parsed. Crash
counts and latest metrics are accumulated across polls.
"""
if session_id not in _background_executions:
raise ToolError(f"Unknown session: {session_id}. Use list_continuous_sessions() to see active sessions.")
execution = _background_executions[session_id]
container_id = execution.get("container_id")
# Initialize metrics
# Carry forward accumulated state
metrics: dict[str, Any] = {
"total_executions": 0,
"total_crashes": 0,
"total_crashes": execution.get("total_crashes", 0),
"exec_per_sec": 0,
"coverage": 0,
"current_target": "",
"latest_events": [],
"new_events": [],
}
# Read stream.jsonl from inside the running container
if container_id:
try:
runner: Runner = get_runner()
@@ -232,34 +240,45 @@ def _get_continuous_status_impl(session_id: str) -> dict[str, Any]:
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")
# Incremental read: only fetch lines we haven't seen yet
lines_read: int = execution.get("stream_lines_read", 0)
stream_content = executor.read_module_output_incremental(
container_id,
start_line=lines_read + 1,
output_file="/data/output/stream.jsonl",
)
if stream_content:
lines = stream_content.strip().split("\n")
# Get last 20 events
recent_lines = lines[-20:] if len(lines) > 20 else lines
crash_count = 0
new_lines = stream_content.strip().split("\n")
new_line_count = 0
for line in recent_lines:
for line in new_lines:
if not line.strip():
continue
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:
# Possible torn read on the very last line — skip it
# and do NOT advance the offset so it is re-read next
# poll when the write is complete.
continue
metrics["total_crashes"] = crash_count
new_line_count += 1
metrics["new_events"].append(event)
# Extract latest metrics snapshot
if event.get("event") == "metrics":
metrics["total_executions"] = event.get("executions", 0)
metrics["current_target"] = event.get("target", "")
metrics["exec_per_sec"] = event.get("exec_per_sec", 0)
metrics["coverage"] = event.get("coverage", 0)
if event.get("event") == "crash_detected":
metrics["total_crashes"] += 1
# Advance offset by successfully parsed lines only
execution["stream_lines_read"] = lines_read + new_line_count
execution["total_crashes"] = metrics["total_crashes"]
except Exception as e:
metrics["error"] = str(e)
+2 -2
View File
@@ -1,6 +1,6 @@
"""MCP tool tests for FuzzForge OSS.
"""MCP tool tests for FuzzForge AI.
Tests the MCP tools that are available in the OSS version.
Tests the MCP tools that are available in FuzzForge AI.
"""
import pytest