Skip to content

Commit e7022c2

Browse files
authored
Merge pull request #48 from FuzzingLabs/dev
2 parents 07c32de + 2e96517 commit e7022c2

File tree

9 files changed

+324
-162
lines changed

9 files changed

+324
-162
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ __pycache__
1010

1111
# Podman/Docker container storage artifacts
1212
~/.fuzzforge/
13+
14+
# User-specific hub config (generated at runtime)
15+
hub-config.json

README.md

Lines changed: 106 additions & 132 deletions
Large diffs are not rendered by default.

assets/demopart1.gif

-360 KB
Binary file not shown.

assets/demopart2.gif

-2.15 MB
Binary file not shown.

fuzzforge-mcp/src/fuzzforge_mcp/application.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,46 @@ async def lifespan(_: FastMCP) -> AsyncGenerator[Settings]:
4747
4848
Typical workflow:
4949
1. Initialize a project with `init_project`
50-
2. Set project assets with `set_project_assets` (optional, only needed once for the source directory)
50+
2. Set project assets with `set_project_assets` — path to the directory containing
51+
target files (firmware images, binaries, source code, etc.)
5152
3. List available hub servers with `list_hub_servers`
5253
4. Discover tools from servers with `discover_hub_tools`
5354
5. Execute hub tools with `execute_hub_tool`
5455
55-
Hub workflow:
56-
1. List available hub servers with `list_hub_servers`
57-
2. Discover tools from servers with `discover_hub_tools`
58-
3. Execute hub tools with `execute_hub_tool`
56+
Agent context convention:
57+
When you call `discover_hub_tools`, some servers return an `agent_context` field
58+
with usage tips, known issues, rule templates, and workflow guidance. Always read
59+
this context before using the server's tools.
60+
61+
File access in containers:
62+
- Assets set via `set_project_assets` are mounted read-only at `/app/uploads/` and `/app/samples/`
63+
- A writable output directory is mounted at `/app/output/` — use it for extraction results, reports, etc.
64+
- Always use container paths (e.g. `/app/uploads/file`) when passing file arguments to hub tools
65+
66+
Stateful tools:
67+
- Some tools (e.g. radare2-mcp) require multi-step sessions. Use `start_hub_server` to launch
68+
a persistent container, then `execute_hub_tool` calls reuse that container. Stop with `stop_hub_server`.
69+
70+
Firmware analysis pipeline (when analyzing firmware images):
71+
1. **binwalk-mcp** (`binwalk_scan` + `binwalk_extract`) — identify and extract filesystem from firmware
72+
2. **yara-mcp** (`yara_scan_with_rules`) — scan extracted files with vulnerability rules to prioritize targets
73+
3. **radare2-mcp** (persistent session) — confirm dangerous code paths
74+
4. **searchsploit-mcp** (`search_exploitdb`) — query version strings from radare2 against ExploitDB
75+
Run steps 3 and 4 outputs feed into a final triage summary.
76+
77+
radare2-mcp agent context (upstream tool — no embedded context):
78+
- Start a persistent session with `start_hub_server("radare2-mcp")` before any calls.
79+
- IMPORTANT: the `open_file` tool requires the parameter name `file_path` (with underscore),
80+
not `filepath`. Example: `execute_hub_tool("hub:radare2-mcp:open_file", {"file_path": "/app/output/..."})`
81+
- Workflow: `open_file` → `analyze` → `list_imports` → `xrefs_to` → `run_command` with `pdf @ <addr>`.
82+
- Static binary fallback: firmware binaries are often statically linked. When `list_imports`
83+
returns an empty result, fall back to `list_symbols` and search for dangerous function names
84+
(system, strcpy, gets, popen, sprintf) in the output. Then use `xrefs_to` on their addresses.
85+
- For string extraction, use `run_command` with `iz` (data section strings).
86+
The `list_all_strings` tool may return garbled output for large binaries.
87+
- For decompilation, use `run_command` with `pdc @ <addr>` (pseudo-C) or `pdf @ <addr>`
88+
(annotated disassembly). The `decompile` tool may fail with "not available in current mode".
89+
- Stop the session with `stop_hub_server("radare2-mcp")` when done.
5990
""",
6091
lifespan=lifespan,
6192
)

fuzzforge-mcp/src/fuzzforge_mcp/resources/executions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ async def list_executions() -> list[dict[str, Any]]:
3030

3131
return [
3232
{
33-
"execution_id": exec_id,
34-
"has_results": storage.get_execution_results(project_path, exec_id) is not None,
33+
"execution_id": entry["execution_id"],
34+
"has_results": storage.get_execution_results(project_path, entry["execution_id"]) is not None,
3535
}
36-
for exec_id in execution_ids
36+
for entry in execution_ids
3737
]
3838

3939
except Exception as exception:

fuzzforge-mcp/src/fuzzforge_mcp/storage.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313

1414
import json
1515
import logging
16+
from datetime import UTC, datetime
1617
from pathlib import Path
1718
from tarfile import open as Archive # noqa: N812
1819
from typing import Any
20+
from uuid import uuid4
1921

2022
logger = logging.getLogger("fuzzforge-mcp")
2123

@@ -79,13 +81,15 @@ def init_project(self, project_path: Path) -> Path:
7981
storage_path = self._get_project_path(project_path)
8082
storage_path.mkdir(parents=True, exist_ok=True)
8183
(storage_path / "runs").mkdir(parents=True, exist_ok=True)
84+
(storage_path / "output").mkdir(parents=True, exist_ok=True)
8285

8386
# Create .gitignore to avoid committing large files
8487
gitignore_path = storage_path / ".gitignore"
8588
if not gitignore_path.exists():
8689
gitignore_path.write_text(
8790
"# FuzzForge storage - ignore large/temporary files\n"
8891
"runs/\n"
92+
"output/\n"
8993
"!config.json\n"
9094
)
9195

@@ -141,17 +145,85 @@ def set_project_assets(self, project_path: Path, assets_path: Path) -> Path:
141145
logger.info("Set project assets: %s -> %s", project_path.name, assets_path)
142146
return assets_path
143147

144-
def list_executions(self, project_path: Path) -> list[str]:
145-
"""List all execution IDs for a project.
148+
def get_project_output_path(self, project_path: Path) -> Path | None:
149+
"""Get the output directory path for a project.
150+
151+
Returns the path to the writable output directory that is mounted
152+
into hub tool containers at /app/output.
153+
154+
:param project_path: Path to the project directory.
155+
:returns: Path to output directory, or None if project not initialized.
156+
157+
"""
158+
output_path = self._get_project_path(project_path) / "output"
159+
if output_path.exists():
160+
return output_path
161+
return None
162+
163+
def record_execution(
164+
self,
165+
project_path: Path,
166+
server_name: str,
167+
tool_name: str,
168+
arguments: dict[str, Any],
169+
result: dict[str, Any],
170+
) -> str:
171+
"""Record an execution result to the project's runs directory.
146172
147173
:param project_path: Path to the project directory.
148-
:returns: List of execution IDs.
174+
:param server_name: Hub server name.
175+
:param tool_name: Tool name that was executed.
176+
:param arguments: Arguments passed to the tool.
177+
:param result: Execution result dictionary.
178+
:returns: Execution ID.
179+
180+
"""
181+
execution_id = f"{datetime.now(tz=UTC).strftime('%Y%m%dT%H%M%SZ')}_{uuid4().hex[:8]}"
182+
run_dir = self._get_project_path(project_path) / "runs" / execution_id
183+
run_dir.mkdir(parents=True, exist_ok=True)
184+
185+
metadata = {
186+
"execution_id": execution_id,
187+
"timestamp": datetime.now(tz=UTC).isoformat(),
188+
"server": server_name,
189+
"tool": tool_name,
190+
"arguments": arguments,
191+
"success": result.get("success", False),
192+
"result": result,
193+
}
194+
(run_dir / "metadata.json").write_text(json.dumps(metadata, indent=2, default=str))
195+
196+
logger.info("Recorded execution %s: %s:%s", execution_id, server_name, tool_name)
197+
return execution_id
198+
199+
def list_executions(self, project_path: Path) -> list[dict[str, Any]]:
200+
"""List all executions for a project with summary metadata.
201+
202+
:param project_path: Path to the project directory.
203+
:returns: List of execution summaries (id, timestamp, server, tool, success).
149204
150205
"""
151206
runs_dir = self._get_project_path(project_path) / "runs"
152207
if not runs_dir.exists():
153208
return []
154-
return [d.name for d in runs_dir.iterdir() if d.is_dir()]
209+
210+
executions: list[dict[str, Any]] = []
211+
for run_dir in sorted(runs_dir.iterdir(), reverse=True):
212+
if not run_dir.is_dir():
213+
continue
214+
meta_path = run_dir / "metadata.json"
215+
if meta_path.exists():
216+
meta = json.loads(meta_path.read_text())
217+
executions.append({
218+
"execution_id": meta.get("execution_id", run_dir.name),
219+
"timestamp": meta.get("timestamp"),
220+
"server": meta.get("server"),
221+
"tool": meta.get("tool"),
222+
"success": meta.get("success"),
223+
})
224+
else:
225+
executions.append({"execution_id": run_dir.name})
226+
return executions
155227

156228
def get_execution_results(
157229
self,

fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,41 @@
2020

2121
mcp: FastMCP = FastMCP()
2222

23+
# Name of the convention tool that hub servers can implement to provide
24+
# rich usage context for AI agents (known issues, workflow tips, rules, etc.).
25+
_AGENT_CONTEXT_TOOL = "get_agent_context"
26+
2327
# Global hub executor instance (lazy initialization)
2428
_hub_executor: HubExecutor | None = None
2529

2630

31+
async def _fetch_agent_context(
32+
executor: HubExecutor,
33+
server_name: str,
34+
tools: list[Any],
35+
) -> str | None:
36+
"""Call get_agent_context if the server provides it.
37+
38+
Returns the context string, or None if the server doesn't implement
39+
the convention or the call fails.
40+
"""
41+
if not any(t.name == _AGENT_CONTEXT_TOOL for t in tools):
42+
return None
43+
try:
44+
result = await executor.execute_tool(
45+
identifier=f"hub:{server_name}:{_AGENT_CONTEXT_TOOL}",
46+
arguments={},
47+
)
48+
if result.success and result.result:
49+
content = result.result.get("content", [])
50+
if content and isinstance(content, list):
51+
text: str = content[0].get("text", "")
52+
return text
53+
except Exception: # noqa: BLE001, S110 - best-effort context fetch
54+
pass
55+
return None
56+
57+
2758
def _get_hub_executor() -> HubExecutor:
2859
"""Get or create the hub executor instance.
2960
@@ -50,19 +81,25 @@ def _get_hub_executor() -> HubExecutor:
5081

5182

5283
@mcp.tool
53-
async def list_hub_servers() -> dict[str, Any]:
84+
async def list_hub_servers(category: str | None = None) -> dict[str, Any]:
5485
"""List all registered MCP hub servers.
5586
5687
Returns information about configured hub servers, including
5788
their connection type, status, and discovered tool count.
5889
90+
:param category: Optional category to filter by (e.g. "binary-analysis",
91+
"web-security", "reconnaissance"). Only servers in this category
92+
are returned.
5993
:return: Dictionary with list of hub servers.
6094
6195
"""
6296
try:
6397
executor = _get_hub_executor()
6498
servers = executor.list_servers()
6599

100+
if category:
101+
servers = [s for s in servers if s.get("category") == category]
102+
66103
return {
67104
"servers": servers,
68105
"count": len(servers),
@@ -93,7 +130,14 @@ async def discover_hub_tools(server_name: str | None = None) -> dict[str, Any]:
93130

94131
if server_name:
95132
tools = await executor.discover_server_tools(server_name)
96-
return {
133+
134+
# Convention: auto-fetch agent context if server provides it.
135+
agent_context = await _fetch_agent_context(executor, server_name, tools)
136+
137+
# Hide the convention tool from the agent's tool list.
138+
visible_tools = [t for t in tools if t.name != "get_agent_context"]
139+
140+
result: dict[str, Any] = {
97141
"server": server_name,
98142
"tools": [
99143
{
@@ -102,15 +146,24 @@ async def discover_hub_tools(server_name: str | None = None) -> dict[str, Any]:
102146
"description": t.description,
103147
"parameters": [p.model_dump() for p in t.parameters],
104148
}
105-
for t in tools
149+
for t in visible_tools
106150
],
107-
"count": len(tools),
151+
"count": len(visible_tools),
108152
}
153+
if agent_context:
154+
result["agent_context"] = agent_context
155+
return result
109156
else:
110157
results = await executor.discover_all_tools()
111158
all_tools = []
159+
contexts: dict[str, str] = {}
112160
for server, tools in results.items():
161+
ctx = await _fetch_agent_context(executor, server, tools)
162+
if ctx:
163+
contexts[server] = ctx
113164
for tool in tools:
165+
if tool.name == "get_agent_context":
166+
continue
114167
all_tools.append({
115168
"identifier": tool.identifier,
116169
"name": tool.name,
@@ -119,11 +172,14 @@ async def discover_hub_tools(server_name: str | None = None) -> dict[str, Any]:
119172
"parameters": [p.model_dump() for p in tool.parameters],
120173
})
121174

122-
return {
175+
result = {
123176
"servers_discovered": len(results),
124177
"tools": all_tools,
125178
"count": len(all_tools),
126179
}
180+
if contexts:
181+
result["agent_contexts"] = contexts
182+
return result
127183

128184
except Exception as e:
129185
if isinstance(e, ToolError):
@@ -183,6 +239,11 @@ async def execute_hub_tool(
183239
Always use /app/uploads/<filename> or /app/samples/<filename> when
184240
passing file paths to hub tools — do NOT use the host path.
185241
242+
Tool outputs are persisted to a writable shared volume:
243+
- /app/output/ (writable — extraction results, reports, etc.)
244+
Files written here survive container destruction and are available
245+
to subsequent tool calls. The host path is .fuzzforge/output/.
246+
186247
"""
187248
try:
188249
executor = _get_hub_executor()
@@ -191,6 +252,7 @@ async def execute_hub_tool(
191252
# Mounts the assets directory at the standard paths used by hub tools:
192253
# /app/uploads — binwalk, and other tools that use UPLOAD_DIR
193254
# /app/samples — yara, capa, and other tools that use SAMPLES_DIR
255+
# /app/output — writable volume for tool outputs (persists across calls)
194256
extra_volumes: list[str] = []
195257
try:
196258
storage = get_storage()
@@ -202,6 +264,9 @@ async def execute_hub_tool(
202264
f"{assets_str}:/app/uploads:ro",
203265
f"{assets_str}:/app/samples:ro",
204266
]
267+
output_path = storage.get_project_output_path(project_path)
268+
if output_path:
269+
extra_volumes.append(f"{output_path!s}:/app/output:rw")
205270
except Exception: # noqa: BLE001 - never block tool execution due to asset injection failure
206271
extra_volumes = []
207272

@@ -212,6 +277,20 @@ async def execute_hub_tool(
212277
extra_volumes=extra_volumes or None,
213278
)
214279

280+
# Record execution history for list_executions / get_execution_results.
281+
try:
282+
storage = get_storage()
283+
project_path = get_project_path()
284+
storage.record_execution(
285+
project_path=project_path,
286+
server_name=result.server_name,
287+
tool_name=result.tool_name,
288+
arguments=arguments or {},
289+
result=result.to_dict(),
290+
)
291+
except Exception: # noqa: BLE001, S110 - never fail the tool call due to recording issues
292+
pass
293+
215294
return result.to_dict()
216295

217296
except Exception as e:
@@ -372,6 +451,9 @@ async def start_hub_server(server_name: str) -> dict[str, Any]:
372451
f"{assets_str}:/app/uploads:ro",
373452
f"{assets_str}:/app/samples:ro",
374453
]
454+
output_path = storage.get_project_output_path(project_path)
455+
if output_path:
456+
extra_volumes.append(f"{output_path!s}:/app/output:rw")
375457
except Exception: # noqa: BLE001 - never block server start due to asset injection failure
376458
extra_volumes = []
377459

0 commit comments

Comments
 (0)