Skip to content
This repository was archived by the owner on Apr 22, 2026. It is now read-only.

Commit 7924e44

Browse files
committed
feat(hub): volume mounts, get_agent_context convention, category filter
1 parent a824809 commit 7924e44

1 file changed

Lines changed: 87 additions & 5 deletions

File tree

  • fuzzforge-mcp/src/fuzzforge_mcp/tools

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)