rename: FuzzForge → SecPipe

Rename the entire project from FuzzForge to SecPipe:
- Python packages: fuzzforge_cli → secpipe_cli, fuzzforge_common → secpipe_common,
  fuzzforge_mcp → secpipe_mcp, fuzzforge_tests → secpipe_tests
- Directories: fuzzforge-cli → secpipe-cli, fuzzforge-common → secpipe-common,
  fuzzforge-mcp → secpipe-mcp, fuzzforge-tests → secpipe-tests
- Environment variables: FUZZFORGE_* → SECPIPE_*
- MCP server name: SecPipe MCP Server
- CI workflows, Makefile, Dockerfile, hub-config, NOTICE updated
- Fix mcp-server.yml to use uvicorn secpipe_mcp.application:app
This commit is contained in:
AFredefon committed 2026-04-09 04:10:46 +02:00
1 parent bbf864e88b
commit be009a4094
120 files changed
+866 -800

No files matched your search

+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", "secpipe_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)
+217
View File
@@ -0,0 +1,217 @@
# SecPipe MCP
Model Context Protocol (MCP) server that enables AI agents to orchestrate SecPipe security research modules.
## Overview
SecPipe 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 SecPipe CLI to automatically configure MCP for your AI agent:
```bash
# For GitHub Copilot
uv run secpipe mcp install copilot
# For Claude Code (VS Code extension)
uv run secpipe mcp install claude-code
# For Claude Desktop (standalone app)
uv run secpipe mcp install claude-desktop
# Verify installation
uv run secpipe 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": {
"secpipe": {
"command": "/path/to/secpipe_ai/.venv/bin/python",
"args": ["-m", "secpipe_mcp"],
"cwd": "/path/to/secpipe_ai",
"env": {
"SECPIPE_MODULES_PATH": "/path/to/secpipe_ai/secpipe-modules",
"SECPIPE_ENGINE__TYPE": "docker"
}
}
}
}
```
#### GitHub Copilot (`~/.config/Code/User/mcp.json`)
```json
{
"servers": {
"secpipe": {
"type": "stdio",
"command": "/path/to/secpipe_ai/.venv/bin/python",
"args": ["-m", "secpipe_mcp"],
"cwd": "/path/to/secpipe_ai",
"env": {
"SECPIPE_MODULES_PATH": "/path/to/secpipe_ai/secpipe-modules",
"SECPIPE_ENGINE__TYPE": "docker"
}
}
}
}
```
#### Claude Desktop (`~/.config/Claude/claude_desktop_config.json`)
```json
{
"mcpServers": {
"secpipe": {
"type": "stdio",
"command": "/path/to/secpipe_ai/.venv/bin/python",
"args": ["-m", "secpipe_mcp"],
"cwd": "/path/to/secpipe_ai",
"env": {
"SECPIPE_MODULES_PATH": "/path/to/secpipe_ai/secpipe-modules",
"SECPIPE_ENGINE__TYPE": "docker"
}
}
}
}
```
## Environment Variables
| Variable | Required | Default | Description |
| -------- | -------- | ------- | ----------- |
| `SECPIPE_MODULES_PATH` | Yes | - | Path to the modules directory |
| `SECPIPE_ENGINE__TYPE` | No | `docker` | Container engine (`docker` or `podman`) |
| `SECPIPE_ENGINE__GRAPHROOT` | No | - | Container storage path (Podman under Snap only) |
| `SECPIPE_ENGINE__RUNROOT` | No | - | Container runtime state path (Podman under Snap only) |
## Available Tools
The MCP server exposes the following tools to AI agents:
### Project Management
- **`init_project`** - Initialize a new SecPipe 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 SecPipe 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 secpipe_mcp
# Run HTTP server for testing (not for production)
uv run uvicorn secpipe_mcp.application:app --reload
```
## Architecture
```text
┌─────────────────────────────────────────┐
│ AI Agent (Claude/Copilot) │
│ via MCP Protocol │
└─────────────────────────────────────────┘
│
│ stdio/JSON-RPC
▼
┌─────────────────────────────────────────┐
│ SecPipe MCP Server │
│ Tools: init_project, list_modules, │
│ execute_module, execute_workflow│
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ SecPipe 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
- [SecPipe Main README](../README.md) - Overall project documentation
- [Module SDK](../secpipe-modules/secpipe-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
+35
View File
@@ -0,0 +1,35 @@
[project]
name = "secpipe-mcp"
version = "0.0.1"
description = "SecPipe MCP Server - AI agent gateway for SecPipe AI."
authors = []
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastmcp==2.14.1",
"secpipe-common==0.0.1",
"pydantic==2.12.4",
"pydantic-settings==2.12.0",
"pyyaml>=6.0",
"structlog==25.5.0",
]
[project.scripts]
secpipe-mcp = "secpipe_mcp.__main__:main"
[project.optional-dependencies]
lints = [
"bandit==1.8.6",
"mypy==1.18.2",
"ruff==0.14.4",
]
tests = [
"secpipe-tests==0.0.1",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-httpx==0.36.0",
]
[tool.uv.sources]
secpipe-common = { workspace = true }
secpipe-tests = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+31
View File
@@ -0,0 +1,31 @@
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
]
"src/**" = [
"ASYNC109", # async with timeout param: intentional pattern
"EM102", # f-string in exception: existing pattern
"PERF401", # list comprehension: readability over perf
"PLR0913", # too many arguments: API compatibility
"PLW0602", # global variable: intentional for shared state
"PLW0603", # global statement: intentional for shared state
"RET504", # unnecessary assignment: readability
"RET505", # unnecessary elif after return: readability
"TC001", # TYPE_CHECKING: causes circular imports
"TC003", # TYPE_CHECKING: causes circular imports
"TRY300", # try-else: existing pattern
"TRY301", # abstract raise: existing pattern
"TRY003", # message in exception: existing pattern
]
+1
View File
@@ -0,0 +1 @@
"""TODO."""
+17
View File
@@ -0,0 +1,17 @@
"""SecPipe MCP Server entry point."""
from secpipe_mcp.application import mcp
def main() -> None:
"""Run the SecPipe 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,91 @@
"""SecPipe MCP Server Application.
This is the main entry point for the SecPipe MCP server, providing
AI agents with tools to discover and execute MCP hub tools for
security research.
"""
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
from fastmcp import FastMCP
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from secpipe_mcp import resources, tools
from secpipe_mcp.settings 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="SecPipe MCP Server",
instructions="""
SecPipe is a security research orchestration platform. Use these tools to:
1. **List hub servers**: Discover registered MCP tool servers
2. **Discover tools**: Find available tools from hub servers
3. **Execute hub tools**: Run security tools in isolated containers
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` — path to the directory containing
target files (firmware images, binaries, source code, etc.)
3. List available hub servers with `list_hub_servers`
4. Discover tools from servers with `discover_hub_tools`
5. Execute hub tools with `execute_hub_tool`
Skill packs:
Use `list_skills` to see available analysis pipelines (e.g. firmware-analysis).
Load one with `load_skill("firmware-analysis")` to get domain-specific guidance
and a scoped list of relevant hub servers. Skill packs describe the methodology —
follow the pipeline steps while adapting to what you find at each stage.
Agent context convention:
When you call `discover_hub_tools`, some servers return an `agent_context` field
with usage tips, known issues, rule templates, and workflow guidance. Always read
this context before using the server's tools.
Artifact tracking:
After each `execute_hub_tool` call, new output files are automatically tracked.
Use `list_artifacts` to find files produced by previous tools instead of parsing
paths from tool output text. Filter by source server or file type.
File access in containers:
- Assets set via `set_project_assets` are mounted read-only at `/app/uploads/` and `/app/samples/`
- A writable output directory is mounted at `/app/output/` — use it for extraction results, reports, etc.
- Always use container paths (e.g. `/app/uploads/file`) when passing file arguments to hub tools
Stateful tools:
- Some tools require multi-step sessions. Use `start_hub_server` to launch
a persistent container, then `execute_hub_tool` calls reuse that container. Stop with `stop_hub_server`.
""",
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,99 @@
"""Dependency injection helpers for SecPipe MCP."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from fastmcp.server.dependencies import get_context
from secpipe_mcp.exceptions import SecPipeMCPError
from secpipe_mcp.settings import Settings
from secpipe_mcp.storage import LocalStorage
if TYPE_CHECKING:
from fastmcp import Context
# Track the current active project path (set by init_project)
_current_project_path: Path | None = None
# Singleton storage instance
_storage: LocalStorage | None = None
# Currently loaded skill pack (set by load_skill)
_active_skill: dict[str, Any] | None = None
def set_current_project_path(project_path: Path) -> None:
"""Set the current project path.
Called by init_project to track which project is active.
:param project_path: Path to the project directory.
"""
global _current_project_path
_current_project_path = project_path
def get_settings() -> Settings:
"""Get MCP server settings from context.
:return: Settings instance.
:raises SecPipeMCPError: If settings not available.
"""
context: Context = get_context()
if context.request_context is None:
message: str = "Request context not available"
raise SecPipeMCPError(message)
return cast("Settings", context.request_context.lifespan_context)
def get_project_path() -> Path:
"""Get the current project path.
Returns the project path set by init_project, or falls back to
the current working directory if no project has been initialized.
:return: Path to the current project.
"""
global _current_project_path
if _current_project_path is not None:
return _current_project_path
# Fall back to current working directory (where the AI agent is working)
return Path.cwd()
def get_storage() -> LocalStorage:
"""Get the storage backend instance.
:return: LocalStorage instance.
"""
global _storage
if _storage is None:
settings = get_settings()
_storage = LocalStorage(settings.storage.path)
return _storage
def set_active_skill(skill: dict[str, Any] | None) -> None:
"""Set (or clear) the currently loaded skill pack.
:param skill: Parsed skill dict, or None to unload.
"""
global _active_skill
_active_skill = skill
def get_active_skill() -> dict[str, Any] | None:
"""Get the currently loaded skill pack.
:return: Active skill dict, or None if no skill is loaded.
"""
return _active_skill
@@ -0,0 +1,5 @@
"""TODO."""
class SecPipeMCPError(Exception):
"""TODO."""
@@ -0,0 +1,14 @@
"""SecPipe MCP Resources."""
from fastmcp import FastMCP
from secpipe_mcp.resources import executions, project
mcp: FastMCP = FastMCP()
mcp.mount(executions.mcp)
mcp.mount(project.mcp)
__all__ = [
"mcp",
]
@@ -0,0 +1,71 @@
"""Execution resources for SecPipe MCP."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError
from secpipe_mcp.dependencies import get_project_path, get_storage
mcp: FastMCP = FastMCP()
@mcp.resource("secpipe://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.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
execution_ids = storage.list_executions(project_path)
return [
{
"execution_id": entry["execution_id"],
"has_results": storage.get_execution_results(project_path, entry["execution_id"]) is not None,
}
for entry in execution_ids
]
except Exception as exception:
message: str = f"Failed to list executions: {exception}"
raise ResourceError(message) from exception
@mcp.resource("secpipe://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.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
results_path = storage.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,79 @@
"""Project resources for SecPipe MCP."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError
from secpipe_mcp.dependencies import get_project_path, get_settings, get_storage
mcp: FastMCP = FastMCP()
@mcp.resource("secpipe://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.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
executions = storage.list_executions(project_path)
assets_path = 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],
}
except Exception as exception:
message: str = f"Failed to get project info: {exception}"
raise ResourceError(message) from exception
@mcp.resource("secpipe://project/settings")
async def get_project_settings() -> dict[str, Any]:
"""Get current SecPipe settings.
Returns the active configuration for the MCP server including
engine, storage, and hub settings.
:return: Settings dictionary.
"""
try:
settings = get_settings()
return {
"engine": {
"type": settings.engine.type,
"socket": settings.engine.socket,
},
"storage": {
"path": str(settings.storage.path),
},
"hub": {
"enabled": settings.hub.enabled,
"config_path": str(settings.hub.config_path),
"timeout": settings.hub.timeout,
},
"debug": settings.debug,
}
except Exception as exception:
message: str = f"Failed to get settings: {exception}"
raise ResourceError(message) from exception
+113
View File
@@ -0,0 +1,113 @@
"""SecPipe MCP Server settings.
Standalone settings for the MCP server. Replaces the previous dependency
on secpipe-runner Settings now that the module system has been removed
and SecPipe operates exclusively through MCP hub tools.
All settings can be configured via environment variables with the prefix
``SECPIPE_``. Nested settings use double-underscore as delimiter.
Example:
``SECPIPE_ENGINE__TYPE=docker``
``SECPIPE_STORAGE__PATH=/data/secpipe``
``SECPIPE_HUB__CONFIG_PATH=/path/to/hub-config.json``
"""
from __future__ import annotations
from enum import StrEnum
from pathlib import Path
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class EngineType(StrEnum):
"""Supported container engine types."""
DOCKER = "docker"
PODMAN = "podman"
class EngineSettings(BaseModel):
"""Container engine configuration."""
#: Type of container engine to use.
type: EngineType = EngineType.DOCKER
#: Path to the container engine socket.
socket: str = Field(default="")
#: Custom graph root for Podman storage.
graphroot: Path = Field(default=Path.home() / ".secpipe" / "containers" / "storage")
#: Custom run root for Podman runtime state.
runroot: Path = Field(default=Path.home() / ".secpipe" / "containers" / "run")
class StorageSettings(BaseModel):
"""Storage configuration for local filesystem storage."""
#: Base path for local storage.
path: Path = Field(default=Path.home() / ".secpipe" / "storage")
class ProjectSettings(BaseModel):
"""Project configuration."""
#: Default path for SecPipe projects.
default_path: Path = Field(default=Path.home() / ".secpipe" / "projects")
class HubSettings(BaseModel):
"""MCP Hub configuration for external tool servers.
Controls the hub that bridges SecPipe with external MCP servers
(e.g., mcp-security-hub). AI agents discover and execute tools
from registered MCP servers.
Configure via environment variables:
``SECPIPE_HUB__ENABLED=true``
``SECPIPE_HUB__CONFIG_PATH=/path/to/hub-config.json``
``SECPIPE_HUB__TIMEOUT=300``
"""
#: Whether the MCP hub is enabled.
enabled: bool = Field(default=True)
#: Path to the hub configuration JSON file.
config_path: Path = Field(default=Path.home() / ".secpipe" / "hub-config.json")
#: Default timeout in seconds for hub tool execution.
timeout: int = Field(default=300)
class Settings(BaseSettings):
"""SecPipe MCP Server settings.
Settings can be configured via environment variables with the prefix
``SECPIPE_``. Nested settings use double-underscore as delimiter.
"""
model_config = SettingsConfigDict(
case_sensitive=False,
env_nested_delimiter="__",
env_prefix="SECPIPE_",
)
#: Container engine settings.
engine: EngineSettings = Field(default_factory=EngineSettings)
#: Storage settings.
storage: StorageSettings = Field(default_factory=StorageSettings)
#: Project settings.
project: ProjectSettings = Field(default_factory=ProjectSettings)
#: MCP Hub settings.
hub: HubSettings = Field(default_factory=HubSettings)
#: Enable debug logging.
debug: bool = False
@@ -0,0 +1,44 @@
name: firmware-analysis
description: |
## Firmware Binary Vulnerability Analysis
Goal: Find exploitable vulnerabilities in firmware images.
### Pipeline
1. **Extract the filesystem** from the firmware image.
Look for SquashFS, JFFS2, CPIO, or other embedded filesystems.
2. **Scan extracted files for vulnerability patterns.**
Use vulnerability-focused rules to identify binaries with dangerous
function calls (system, strcpy, popen, sprintf, gets).
Prioritize targets by match count — the binary with the most hits
is the highest-priority target.
3. **Deep-analyze the highest-priority binary.**
Open a persistent analysis session. Look for:
- Dangerous function calls with unsanitized input
- Hardcoded credentials or backdoor strings
- Network service listeners with weak input validation
Focus on confirming whether flagged patterns are actually reachable.
4. **Search for known CVEs** matching library version strings found
during analysis. Cross-reference with public exploit databases.
5. **Compile findings** with severity ratings:
- CRITICAL: confirmed remote code execution paths
- HIGH: command injection or buffer overflow with reachable input
- MEDIUM: hardcoded credentials, weak crypto, format string issues
- LOW: informational findings (library versions, service fingerprints)
### Key files to prioritize in extracted firmware
- `usr/sbin/httpd`, `usr/bin/httpd` — web servers (high-priority)
- `etc/shadow`, `etc/passwd` — credential files
- `www/cgi-bin/*` — CGI scripts (command injection vectors)
- Custom binaries in `usr/sbin/`, `usr/bin/` — vendor attack surface
servers:
- binwalk-mcp
- yara-mcp
- radare2-mcp
- searchsploit-mcp
@@ -0,0 +1,90 @@
name: go-fuzzing
description: |
## Go Fuzzing Vulnerability Discovery
Goal: Find memory safety bugs, panics, and logic errors in a Go project
using native Go fuzzing (go test -fuzz).
### Pipeline
1. **Analyze the Go project** to understand its attack surface.
Use `go_analyze` to scan the codebase and identify:
- Fuzzable entry points: functions accepting `[]byte`, `string`,
`io.Reader`, or other parser-like signatures (`Parse*`, `Decode*`,
`Unmarshal*`, `Read*`, `Open*`)
- Existing `Fuzz*` test functions already in `*_test.go` files
- Unsafe/cgo usage that increases the severity of any bugs found
- Known CVEs via govulncheck (enable with `run_vulncheck: true`)
If there are **no existing Fuzz targets**, stop here and report
that the project needs fuzz harnesses written first, listing the
recommended entry points from the analysis.
2. **Test harness quality** before committing to a long fuzzing campaign.
Use `go_harness_test` to evaluate each Fuzz* function:
- Compilation check — does `go test -c` succeed?
- Seed execution — do the seed corpus entries pass without panics?
- Short fuzzing trial — does the harness sustain fuzzing for 15-30s?
- Quality score (0-100): ≥80 = production-ready, ≥50 = needs work, <50 = broken
**Decision point:**
- If all harnesses are **broken** (score < 50): stop and report issues.
The user needs to fix them before fuzzing is useful.
- If some are **production-ready** or **needs-improvement** (score ≥ 50):
proceed with those targets to step 3.
- Skip broken harnesses — do not waste fuzzing time on them.
3. **Run fuzzing** on the viable targets.
Use `go_fuzz_run` for a bounded campaign:
- Set `duration` based on project size: 60-120s for quick scan,
300-600s for thorough analysis.
- Pass only the targets that scored ≥ 50 in step 2 via the `targets`
parameter — do not fuzz broken harnesses.
- The fuzzer collects crash inputs to `/app/output/crashes/{FuzzName}/`.
**Alternative — continuous mode** for deeper exploration:
- Use `go_fuzz_start` to begin background fuzzing.
- Periodically check `go_fuzz_status` to monitor progress.
- Use `go_fuzz_stop` when satisfied or when crashes are found.
If **no crashes** are found after a reasonable duration, report that
the fuzzing campaign completed cleanly with the execution metrics.
4. **Analyze crashes** found during fuzzing.
Use `go_crash_analyze` to process the crash inputs:
- Reproduction: re-run each crash input to confirm it's real
- Classification: categorize by type (nil-dereference, index-out-of-range,
slice-bounds, divide-by-zero, stack-overflow, data-race, panic, etc.)
- Severity assignment: critical / high / medium / low
- Deduplication: group crashes by signature (target + type + top 3 frames)
Skip this step if no crashes were found in step 3.
5. **Compile the vulnerability report** with findings organized by severity:
- **CRITICAL**: nil-dereference, segfault, data-race, stack-overflow
- **HIGH**: index/slice out of bounds, allocation overflow
- **MEDIUM**: integer overflow, divide by zero, explicit panics
- **LOW**: timeout, unclassified crashes
For each unique crash, include:
- The fuzz target that triggered it
- The crash type and root cause function + file + line
- Whether it was reproducible
- The crash input file path for manual investigation
### What the user's project needs
- A `go.mod` file (any Go module)
- At least one `*_test.go` file with `func FuzzXxx(f *testing.F)` functions
- Seed corpus entries added via `f.Add(...)` in the Fuzz functions
### Interpretation guide
- **govulncheck CVEs** (step 1) are known dependency vulnerabilities — report separately
- **Fuzzer crashes** (steps 3-4) are new bugs found by fuzzing the project's own code
- High execution counts with zero crashes = good sign (code is robust to that input space)
- Low quality scores in step 2 usually mean the harness needs better seed corpus or input handling
servers:
- go-analyzer-mcp
- go-harness-tester-mcp
- go-fuzzer-mcp
- go-crash-analyzer-mcp
+653
View File
@@ -0,0 +1,653 @@
"""SecPipe MCP Server - Local project storage.
Lightweight project storage for managing `.secpipe/` directories,
execution results, and project configuration. Extracted from the
former secpipe-runner storage module.
Storage is placed directly in the project directory as `.secpipe/`
for maximum visibility and ease of debugging.
"""
from __future__ import annotations
import json
import logging
import mimetypes
from datetime import UTC, datetime
from pathlib import Path
from tarfile import open as Archive # noqa: N812
from typing import Any
from uuid import uuid4
import yaml
logger = logging.getLogger("secpipe-mcp")
#: Name of the SecPipe storage directory within projects.
SECPIPE_DIR_NAME: str = ".secpipe"
#: Standard results archive filename.
RESULTS_ARCHIVE_FILENAME: str = "results.tar.gz"
class StorageError(Exception):
"""Raised when a storage operation fails."""
class LocalStorage:
"""Local filesystem storage backend for SecPipe.
Provides lightweight storage for project configuration and
execution results tracking.
Directory structure (inside project directory)::
{project_path}/.secpipe/
config.json # Project config (source path reference)
runs/ # Execution results
{execution_id}/
results.tar.gz
"""
_base_path: Path
def __init__(self, base_path: Path) -> None:
"""Initialize storage backend.
:param base_path: Root directory for global storage (fallback).
"""
self._base_path = base_path
self._base_path.mkdir(parents=True, exist_ok=True)
def _get_project_path(self, project_path: Path) -> Path:
"""Get the .secpipe storage path for a project.
:param project_path: Path to the project directory.
:returns: Storage path (.secpipe inside project).
"""
return project_path / SECPIPE_DIR_NAME
def init_project(self, project_path: Path) -> Path:
"""Initialize storage for a new project.
Creates a .secpipe/ directory inside the project for storing
configuration and execution results.
:param project_path: Path to the project directory.
:returns: Path to the project storage directory.
"""
storage_path = self._get_project_path(project_path)
storage_path.mkdir(parents=True, exist_ok=True)
(storage_path / "runs").mkdir(parents=True, exist_ok=True)
(storage_path / "output").mkdir(parents=True, exist_ok=True)
# Create .gitignore to avoid committing large files
gitignore_path = storage_path / ".gitignore"
if not gitignore_path.exists():
gitignore_path.write_text(
"# SecPipe storage - ignore large/temporary files\n"
"runs/\n"
"output/\n"
"artifacts.json\n"
"!config.json\n"
)
logger.info("Initialized project storage: %s", storage_path)
return storage_path
def get_project_assets_path(self, project_path: Path) -> Path | None:
"""Get the configured source path for a project.
:param project_path: Path to the project directory.
:returns: Path to source directory, or None if not configured.
"""
storage_path = self._get_project_path(project_path)
config_path = storage_path / "config.json"
if config_path.exists():
config = json.loads(config_path.read_text())
source_path = config.get("source_path")
if source_path:
path = Path(source_path)
if path.exists():
return path
return None
def set_project_assets(self, project_path: Path, assets_path: Path) -> Path:
"""Set the source path for a project (reference only, no copying).
:param project_path: Path to the project directory.
:param assets_path: Path to source directory.
:returns: The assets path (unchanged).
:raises StorageError: If path doesn't exist.
"""
if not assets_path.exists():
msg = f"Assets path does not exist: {assets_path}"
raise StorageError(msg)
assets_path = assets_path.resolve()
storage_path = self._get_project_path(project_path)
storage_path.mkdir(parents=True, exist_ok=True)
config_path = storage_path / "config.json"
config: dict[str, Any] = {}
if config_path.exists():
config = json.loads(config_path.read_text())
config["source_path"] = str(assets_path)
config_path.write_text(json.dumps(config, indent=2))
logger.info("Set project assets: %s -> %s", project_path.name, assets_path)
return assets_path
def get_project_output_path(self, project_path: Path) -> Path | None:
"""Get the output directory path for a project.
Returns the path to the writable output directory that is mounted
into hub tool containers at /app/output.
:param project_path: Path to the project directory.
:returns: Path to output directory, or None if project not initialized.
"""
output_path = self._get_project_path(project_path) / "output"
if output_path.exists():
return output_path
return None
def record_execution(
self,
project_path: Path,
server_name: str,
tool_name: str,
arguments: dict[str, Any],
result: dict[str, Any],
) -> str:
"""Record an execution result to the project's runs directory.
:param project_path: Path to the project directory.
:param server_name: Hub server name.
:param tool_name: Tool name that was executed.
:param arguments: Arguments passed to the tool.
:param result: Execution result dictionary.
:returns: Execution ID.
"""
execution_id = f"{datetime.now(tz=UTC).strftime('%Y%m%dT%H%M%SZ')}_{uuid4().hex[:8]}"
run_dir = self._get_project_path(project_path) / "runs" / execution_id
run_dir.mkdir(parents=True, exist_ok=True)
metadata = {
"execution_id": execution_id,
"timestamp": datetime.now(tz=UTC).isoformat(),
"server": server_name,
"tool": tool_name,
"arguments": arguments,
"success": result.get("success", False),
"result": result,
}
(run_dir / "metadata.json").write_text(json.dumps(metadata, indent=2, default=str))
logger.info("Recorded execution %s: %s:%s", execution_id, server_name, tool_name)
return execution_id
def list_executions(self, project_path: Path) -> list[dict[str, Any]]:
"""List all executions for a project with summary metadata.
:param project_path: Path to the project directory.
:returns: List of execution summaries (id, timestamp, server, tool, success).
"""
runs_dir = self._get_project_path(project_path) / "runs"
if not runs_dir.exists():
return []
executions: list[dict[str, Any]] = []
for run_dir in sorted(runs_dir.iterdir(), reverse=True):
if not run_dir.is_dir():
continue
meta_path = run_dir / "metadata.json"
if meta_path.exists():
meta = json.loads(meta_path.read_text())
executions.append({
"execution_id": meta.get("execution_id", run_dir.name),
"timestamp": meta.get("timestamp"),
"server": meta.get("server"),
"tool": meta.get("tool"),
"success": meta.get("success"),
})
else:
executions.append({"execution_id": run_dir.name})
return executions
def get_execution_results(
self,
project_path: Path,
execution_id: str,
) -> Path | None:
"""Retrieve execution results path.
:param project_path: Path to the project directory.
:param execution_id: Execution ID.
:returns: Path to results archive, or None if not found.
"""
storage_path = self._get_project_path(project_path)
# Try direct path
results_path = storage_path / "runs" / execution_id / RESULTS_ARCHIVE_FILENAME
if results_path.exists():
return results_path
# Search in all run directories
runs_dir = storage_path / "runs"
if runs_dir.exists():
for run_dir in runs_dir.iterdir():
if run_dir.is_dir() and execution_id in run_dir.name:
candidate = run_dir / RESULTS_ARCHIVE_FILENAME
if candidate.exists():
return candidate
return None
def extract_results(self, results_path: Path, destination: Path) -> Path:
"""Extract a results archive to a destination directory.
:param results_path: Path to the results archive.
:param destination: Directory to extract to.
:returns: Path to extracted directory.
:raises StorageError: If extraction fails.
"""
try:
destination.mkdir(parents=True, exist_ok=True)
with Archive(results_path, "r:gz") as tar:
tar.extractall(path=destination) # noqa: S202
logger.info("Extracted results: %s -> %s", results_path, destination)
return destination
except Exception as exc:
msg = f"Failed to extract results: {exc}"
raise StorageError(msg) from exc
# ------------------------------------------------------------------
# Artifact tracking
# ------------------------------------------------------------------
def _artifacts_path(self, project_path: Path) -> Path:
"""Get the path to the artifacts registry file.
:param project_path: Path to the project directory.
:returns: Path to artifacts.json.
"""
return self._get_project_path(project_path) / "artifacts.json"
def _load_artifacts(self, project_path: Path) -> list[dict[str, Any]]:
"""Load the artifact registry from disk.
:param project_path: Path to the project directory.
:returns: List of artifact dicts.
"""
path = self._artifacts_path(project_path)
if path.exists():
try:
return json.loads(path.read_text()) # type: ignore[no-any-return]
except (json.JSONDecodeError, OSError):
return []
return []
def _save_artifacts(self, project_path: Path, artifacts: list[dict[str, Any]]) -> None:
"""Persist the artifact registry to disk.
:param project_path: Path to the project directory.
:param artifacts: Full artifact list to write.
"""
path = self._artifacts_path(project_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(artifacts, indent=2, default=str))
def _classify_file(self, file_path: Path) -> str:
"""Classify a file into a human-friendly type string.
:param file_path: Path to the file.
:returns: Type string (e.g. "elf-binary", "text", "directory").
"""
mime, _ = mimetypes.guess_type(str(file_path))
suffix = file_path.suffix.lower()
# Try reading ELF magic for binaries with no extension
if mime is None and suffix == "":
try:
header = file_path.read_bytes()[:4]
if header == b"\x7fELF":
return "elf-binary"
except OSError:
pass
if mime:
if "json" in mime:
return "json"
if "text" in mime or "xml" in mime or "yaml" in mime:
return "text"
if "image" in mime:
return "image"
if "octet-stream" in mime:
return "binary"
type_map: dict[str, str] = {
".json": "json",
".sarif": "sarif",
".md": "markdown",
".txt": "text",
".log": "text",
".csv": "csv",
".yaml": "yaml",
".yml": "yaml",
".xml": "xml",
".html": "html",
".elf": "elf-binary",
".so": "elf-binary",
".bin": "binary",
".gz": "archive",
".tar": "archive",
".zip": "archive",
}
return type_map.get(suffix, "binary")
def scan_artifacts(
self,
project_path: Path,
server_name: str,
tool_name: str,
) -> list[dict[str, Any]]:
"""Scan the output directory for new or modified files and register them.
Compares the current state of .secpipe/output/ against the existing
artifact registry and registers any new or modified files.
:param project_path: Path to the project directory.
:param server_name: Hub server that produced the artifacts.
:param tool_name: Tool that produced the artifacts.
:returns: List of newly registered artifact dicts.
"""
output_path = self.get_project_output_path(project_path)
if output_path is None or not output_path.exists():
return []
existing = self._load_artifacts(project_path)
known: dict[str, dict[str, Any]] = {a["path"]: a for a in existing}
now = datetime.now(tz=UTC).isoformat()
new_artifacts: list[dict[str, Any]] = []
for file_path in output_path.rglob("*"):
if not file_path.is_file():
continue
# Use the container-style path (/app/output/...) so it's
# directly usable in subsequent tool calls.
relative = file_path.relative_to(output_path)
container_path = f"/app/output/{relative}"
stat = file_path.stat()
size = stat.st_size
mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
prev = known.get(container_path)
if prev and prev.get("mtime") == mtime and prev.get("size") == size:
continue # Unchanged — skip
artifact: dict[str, Any] = {
"path": container_path,
"host_path": str(file_path),
"type": self._classify_file(file_path),
"size": size,
"mtime": mtime,
"source_server": server_name,
"source_tool": tool_name,
"registered_at": now,
}
if prev:
# Update existing entry in-place
idx = next(i for i, a in enumerate(existing) if a["path"] == container_path)
existing[idx] = artifact
else:
existing.append(artifact)
new_artifacts.append(artifact)
if new_artifacts:
self._save_artifacts(project_path, existing)
logger.info(
"Registered %d new artifact(s) from %s:%s",
len(new_artifacts),
server_name,
tool_name,
)
return new_artifacts
def list_artifacts(
self,
project_path: Path,
*,
source: str | None = None,
artifact_type: str | None = None,
) -> list[dict[str, Any]]:
"""List registered artifacts, with optional filters.
:param project_path: Path to the project directory.
:param source: Filter by source server name.
:param artifact_type: Filter by artifact type (e.g. "elf-binary", "json").
:returns: List of matching artifact dicts.
"""
artifacts = self._load_artifacts(project_path)
if source:
artifacts = [a for a in artifacts if a.get("source_server") == source]
if artifact_type:
artifacts = [a for a in artifacts if a.get("type") == artifact_type]
return artifacts
def get_artifact(self, project_path: Path, path: str) -> dict[str, Any] | None:
"""Get a single artifact by its container path.
:param project_path: Path to the project directory.
:param path: Container path of the artifact (e.g. /app/output/...).
:returns: Artifact dict, or None if not found.
"""
artifacts = self._load_artifacts(project_path)
for artifact in artifacts:
if artifact["path"] == path:
return artifact
return None
# ------------------------------------------------------------------
# Reports
# ------------------------------------------------------------------
def list_execution_metadata(self, project_path: Path) -> list[dict[str, Any]]:
"""Load full execution metadata for all runs, sorted oldest-first.
:param project_path: Path to the project directory.
:returns: List of full metadata dicts (includes arguments, result).
"""
runs_dir = self._get_project_path(project_path) / "runs"
if not runs_dir.exists():
return []
metadata: list[dict[str, Any]] = []
for run_dir in sorted(runs_dir.iterdir()):
if not run_dir.is_dir():
continue
meta_path = run_dir / "metadata.json"
if meta_path.exists():
try:
metadata.append(json.loads(meta_path.read_text()))
except (json.JSONDecodeError, OSError):
continue
return metadata
def save_report(
self,
project_path: Path,
content: str,
fmt: str = "markdown",
) -> Path:
"""Save a generated report to .secpipe/reports/.
:param project_path: Path to the project directory.
:param content: Report content string.
:param fmt: Format name used to choose file extension.
:returns: Path to the saved report file.
"""
reports_dir = self._get_project_path(project_path) / "reports"
reports_dir.mkdir(parents=True, exist_ok=True)
ext_map = {"markdown": "md", "json": "json", "sarif": "sarif"}
ext = ext_map.get(fmt, "md")
filename = f"{datetime.now(tz=UTC).strftime('%Y%m%dT%H%M%SZ')}_report.{ext}"
report_path = reports_dir / filename
report_path.write_text(content)
logger.info("Saved report: %s", report_path)
return report_path
def list_reports(self, project_path: Path) -> list[dict[str, Any]]:
"""List generated reports for a project, newest first.
:param project_path: Path to the project directory.
:returns: List of report dicts with filename, host_path, size, created_at.
"""
reports_dir = self._get_project_path(project_path) / "reports"
if not reports_dir.exists():
return []
reports: list[dict[str, Any]] = []
for report_path in sorted(reports_dir.iterdir(), reverse=True):
if report_path.is_file():
stat = report_path.stat()
reports.append({
"filename": report_path.name,
"host_path": str(report_path),
"size": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
})
return reports
# ------------------------------------------------------------------
# Skill packs
# ------------------------------------------------------------------
#: Directory containing built-in skill packs shipped with SecPipe.
_BUILTIN_SKILLS_DIR: Path = Path(__file__).parent / "skills"
def _skill_dirs(self, project_path: Path) -> list[Path]:
"""Return skill directories in priority order (project-local first).
:param project_path: Path to the project directory.
:returns: List of directories that may contain skill YAML files.
"""
dirs: list[Path] = []
project_skills = self._get_project_path(project_path) / "skills"
if project_skills.is_dir():
dirs.append(project_skills)
if self._BUILTIN_SKILLS_DIR.is_dir():
dirs.append(self._BUILTIN_SKILLS_DIR)
return dirs
def list_skills(self, project_path: Path) -> list[dict[str, Any]]:
"""List available skill packs from project and built-in directories.
:param project_path: Path to the project directory.
:returns: List of skill summaries (name, description first line, source).
"""
seen: set[str] = set()
skills: list[dict[str, Any]] = []
for skill_dir in self._skill_dirs(project_path):
for yaml_path in sorted(skill_dir.glob("*.yaml")):
skill = self._parse_skill_file(yaml_path)
if skill is None:
continue
name = skill["name"]
if name in seen:
continue # project-local overrides built-in
seen.add(name)
desc = skill.get("description", "")
first_line = desc.strip().split("\n", 1)[0] if desc else ""
is_project = ".secpipe" in str(yaml_path.parent)
source = "project" if is_project else "builtin"
skills.append({
"name": name,
"summary": first_line,
"source": source,
"servers": skill.get("servers", []),
})
return skills
def load_skill(self, project_path: Path, name: str) -> dict[str, Any] | None:
"""Load a skill pack by name.
Searches project-local skills first, then built-in skills.
:param project_path: Path to the project directory.
:param name: Skill name (filename without .yaml extension).
:returns: Parsed skill dict with name, description, servers — or None.
"""
for skill_dir in self._skill_dirs(project_path):
yaml_path = skill_dir / f"{name}.yaml"
if yaml_path.is_file():
return self._parse_skill_file(yaml_path)
return None
@staticmethod
def _parse_skill_file(yaml_path: Path) -> dict[str, Any] | None:
"""Parse and validate a skill YAML file.
:param yaml_path: Path to the YAML file.
:returns: Parsed skill dict, or None if invalid.
"""
try:
data = yaml.safe_load(yaml_path.read_text())
except (yaml.YAMLError, OSError):
logger.warning("Failed to parse skill file: %s", yaml_path)
return None
if not isinstance(data, dict):
return None
name = data.get("name")
if not name or not isinstance(name, str):
logger.warning("Skill file missing 'name': %s", yaml_path)
return None
return {
"name": name,
"description": data.get("description", ""),
"servers": data.get("servers", []),
}
@@ -0,0 +1,16 @@
"""SecPipe MCP Tools."""
from fastmcp import FastMCP
from secpipe_mcp.tools import hub, projects, reports
mcp: FastMCP = FastMCP()
mcp.mount(projects.mcp)
mcp.mount(hub.mcp)
mcp.mount(reports.mcp)
__all__ = [
"mcp",
]
+709
View File
@@ -0,0 +1,709 @@
"""MCP Hub tools for SecPipe MCP server.
This module provides tools for interacting with external MCP servers
through the SecPipe hub. AI agents can:
- List available hub servers and their tools
- Discover tools from hub servers
- Execute hub tools
"""
from __future__ import annotations
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from secpipe_common.hub import HubExecutor, HubServerConfig, HubServerType
from secpipe_mcp.dependencies import get_project_path, get_settings, get_storage
mcp: FastMCP = FastMCP()
# Name of the convention tool that hub servers can implement to provide
# rich usage context for AI agents (known issues, workflow tips, rules, etc.).
_AGENT_CONTEXT_TOOL = "get_agent_context"
# Global hub executor instance (lazy initialization)
_hub_executor: HubExecutor | None = None
async def _fetch_agent_context(
executor: HubExecutor,
server_name: str,
tools: list[Any],
) -> str | None:
"""Call get_agent_context if the server provides it.
Returns the context string, or None if the server doesn't implement
the convention or the call fails.
"""
if not any(t.name == _AGENT_CONTEXT_TOOL for t in tools):
return None
try:
result = await executor.execute_tool(
identifier=f"hub:{server_name}:{_AGENT_CONTEXT_TOOL}",
arguments={},
)
if result.success and result.result:
content = result.result.get("content", [])
if content and isinstance(content, list):
text: str = content[0].get("text", "")
return text
except Exception: # noqa: BLE001, S110 - best-effort context fetch
pass
return 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 SECPIPE_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(category: str | None = None) -> dict[str, Any]:
"""List all registered MCP hub servers.
Returns information about configured hub servers, including
their connection type, status, and discovered tool count.
:param category: Optional category to filter by (e.g. "binary-analysis",
"web-security", "reconnaissance"). Only servers in this category
are returned.
:return: Dictionary with list of hub servers.
"""
try:
executor = _get_hub_executor()
servers = executor.list_servers()
if category:
servers = [s for s in servers if s.get("category") == category]
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)
# Convention: auto-fetch agent context if server provides it.
agent_context = await _fetch_agent_context(executor, server_name, tools)
# Hide the convention tool from the agent's tool list.
visible_tools = [t for t in tools if t.name != "get_agent_context"]
result: dict[str, Any] = {
"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 visible_tools
],
"count": len(visible_tools),
}
if agent_context:
result["agent_context"] = agent_context
return result
else:
results = await executor.discover_all_tools()
all_tools = []
contexts: dict[str, str] = {}
for server, tools in results.items():
ctx = await _fetch_agent_context(executor, server, tools)
if ctx:
contexts[server] = ctx
for tool in tools:
if tool.name == "get_agent_context":
continue
all_tools.append({
"identifier": tool.identifier,
"name": tool.name,
"server": server,
"description": tool.description,
"parameters": [p.model_dump() for p in tool.parameters],
})
result = {
"servers_discovered": len(results),
"tools": all_tools,
"count": len(all_tools),
}
if contexts:
result["agent_contexts"] = contexts
return result
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:binwalk-mcp:binwalk_scan"
- "hub:yara-mcp:yara_scan_with_rules"
- "hub:nmap:nmap_scan"
FILE ACCESS — if set_project_assets was called, the assets directory is
mounted read-only inside the container at two standard paths:
- /app/uploads/ (used by binwalk, and tools with UPLOAD_DIR)
- /app/samples/ (used by yara, capa, and tools with SAMPLES_DIR)
Always use /app/uploads/<filename> or /app/samples/<filename> when
passing file paths to hub tools — do NOT use the host path.
Tool outputs are persisted to a writable shared volume:
- /app/output/ (writable — extraction results, reports, etc.)
Files written here survive container destruction and are available
to subsequent tool calls. The host path is .secpipe/output/.
"""
try:
executor = _get_hub_executor()
# Inject project assets as Docker volume mounts if configured.
# Mounts the assets directory at the standard paths used by hub tools:
# /app/uploads — binwalk, and other tools that use UPLOAD_DIR
# /app/samples — yara, capa, and other tools that use SAMPLES_DIR
# /app/output — writable volume for tool outputs (persists across calls)
extra_volumes: list[str] = []
try:
storage = get_storage()
project_path = get_project_path()
assets_path = storage.get_project_assets_path(project_path)
if assets_path:
assets_str = str(assets_path)
extra_volumes = [
f"{assets_str}:/app/uploads:ro",
f"{assets_str}:/app/samples:ro",
]
output_path = storage.get_project_output_path(project_path)
if output_path:
extra_volumes.append(f"{output_path!s}:/app/output:rw")
except Exception: # noqa: BLE001 - never block tool execution due to asset injection failure
extra_volumes = []
result = await executor.execute_tool(
identifier=identifier,
arguments=arguments or {},
timeout=timeout,
extra_volumes=extra_volumes or None,
)
# Record execution history for list_executions / get_execution_results.
try:
storage = get_storage()
project_path = get_project_path()
storage.record_execution(
project_path=project_path,
server_name=result.server_name,
tool_name=result.tool_name,
arguments=arguments or {},
result=result.to_dict(),
)
except Exception: # noqa: BLE001, S110 - never fail the tool call due to recording issues
pass
# Scan for new artifacts produced by the tool in /app/output.
response = result.to_dict()
try:
storage = get_storage()
project_path = get_project_path()
new_artifacts = storage.scan_artifacts(
project_path=project_path,
server_name=result.server_name,
tool_name=result.tool_name,
)
if new_artifacts:
response["artifacts"] = [
{"path": a["path"], "type": a["type"], "size": a["size"]}
for a in new_artifacts
]
except Exception: # noqa: BLE001, S110 - never fail the tool call due to artifact scanning
pass
# Append workflow suggestions based on hints configured for this tool.
try:
hint = executor.registry.get_workflow_hint(result.tool_name)
if hint:
response["suggested_next_steps"] = hint
except Exception: # noqa: BLE001, S110 - never fail the tool call due to hint lookup
pass
return response
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
@mcp.tool
async def start_hub_server(server_name: str) -> dict[str, Any]:
"""Start a persistent container session for a hub server.
Starts a Docker container that stays running between tool calls,
allowing stateful interactions. Tools are auto-discovered on start.
Use this for servers like radare2 or ghidra where you want to
keep an analysis session open across multiple tool calls.
After starting, use execute_hub_tool as normal - calls will be
routed to the persistent container automatically.
:param server_name: Name of the hub server to start (e.g., "radare2-mcp").
:return: Session status with container name and start time.
"""
try:
executor = _get_hub_executor()
# Inject project assets as Docker volume mounts (same logic as execute_hub_tool).
extra_volumes: list[str] = []
try:
storage = get_storage()
project_path = get_project_path()
assets_path = storage.get_project_assets_path(project_path)
if assets_path:
assets_str = str(assets_path)
extra_volumes = [
f"{assets_str}:/app/uploads:ro",
f"{assets_str}:/app/samples:ro",
]
output_path = storage.get_project_output_path(project_path)
if output_path:
extra_volumes.append(f"{output_path!s}:/app/output:rw")
except Exception: # noqa: BLE001 - never block server start due to asset injection failure
extra_volumes = []
result = await executor.start_persistent_server(server_name, extra_volumes=extra_volumes or None)
return {
"success": True,
"session": result,
"tools": result.get("tools", []),
"tool_count": result.get("tool_count", 0),
"message": (
f"Persistent session started for '{server_name}'. "
f"Discovered {result.get('tool_count', 0)} tools. "
"Use execute_hub_tool to call them — they will reuse this container. "
f"Stop with stop_hub_server('{server_name}') when done."
),
}
except ValueError as e:
msg = f"Server not found: {e}"
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to start persistent server: {e}"
raise ToolError(msg) from e
@mcp.tool
async def stop_hub_server(server_name: str) -> dict[str, Any]:
"""Stop a persistent container session for a hub server.
Terminates the running Docker container and cleans up resources.
After stopping, tool calls will fall back to ephemeral mode
(a new container per call).
:param server_name: Name of the hub server to stop.
:return: Result indicating if the session was stopped.
"""
try:
executor = _get_hub_executor()
stopped = await executor.stop_persistent_server(server_name)
if stopped:
return {
"success": True,
"message": f"Persistent session for '{server_name}' stopped and container removed.",
}
else:
return {
"success": False,
"message": f"No active persistent session found for '{server_name}'.",
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to stop persistent server: {e}"
raise ToolError(msg) from e
@mcp.tool
async def hub_server_status(server_name: str | None = None) -> dict[str, Any]:
"""Get status of persistent hub server sessions.
If server_name is provided, returns status for that specific server.
Otherwise returns status for all active persistent sessions.
:param server_name: Optional specific server to check.
:return: Session status information.
"""
try:
executor = _get_hub_executor()
if server_name:
status = executor.get_persistent_status(server_name)
if status:
return {"active": True, "session": status}
else:
return {
"active": False,
"message": f"No active persistent session for '{server_name}'.",
}
else:
sessions = executor.list_persistent_sessions()
return {
"active_sessions": sessions,
"count": len(sessions),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to get server status: {e}"
raise ToolError(msg) from e
# ------------------------------------------------------------------
# Continuous mode tools
# ------------------------------------------------------------------
@mcp.tool
async def start_continuous_hub_tool(
server_name: str,
start_tool: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Start a continuous/background tool on a hub server.
Automatically starts a persistent container if not already running,
then calls the server's start tool (e.g., cargo_fuzz_start) which
launches a background process and returns a session_id.
The tool runs indefinitely until stopped with stop_continuous_hub_tool.
Use get_continuous_hub_status to monitor progress.
Example workflow for continuous cargo fuzzing:
1. start_continuous_hub_tool("cargo-fuzzer-mcp", "cargo_fuzz_start", {"project_path": "/data/myproject"})
2. get_continuous_hub_status(session_id) -- poll every 10-30s
3. stop_continuous_hub_tool(session_id) -- when done
:param server_name: Hub server name (e.g., "cargo-fuzzer-mcp").
:param start_tool: Name of the start tool on the server.
:param arguments: Arguments for the start tool.
:return: Start result including session_id for monitoring.
"""
try:
executor = _get_hub_executor()
result = await executor.start_continuous_tool(
server_name=server_name,
start_tool=start_tool,
arguments=arguments or {},
)
# Return the server's response directly — it already contains
# session_id, status, targets, and a message.
return result
except ValueError as e:
msg = f"Server not found: {e}"
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to start continuous tool: {e}"
raise ToolError(msg) from e
@mcp.tool
async def get_continuous_hub_status(session_id: str) -> dict[str, Any]:
"""Get live status of a continuous hub tool session.
Returns current metrics, progress, and recent output from the
running tool. Call periodically (every 10-30 seconds) to monitor.
:param session_id: Session ID returned by start_continuous_hub_tool.
:return: Current status with metrics (executions, coverage, crashes, etc.).
"""
try:
executor = _get_hub_executor()
return await executor.get_continuous_tool_status(session_id)
except ValueError as e:
msg = str(e)
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to get continuous status: {e}"
raise ToolError(msg) from e
@mcp.tool
async def stop_continuous_hub_tool(session_id: str) -> dict[str, Any]:
"""Stop a running continuous hub tool session.
Gracefully stops the background process and returns final results
including total metrics and any artifacts (crash files, etc.).
:param session_id: Session ID of the session to stop.
:return: Final metrics and results summary.
"""
try:
executor = _get_hub_executor()
return await executor.stop_continuous_tool(session_id)
except ValueError as e:
msg = str(e)
raise ToolError(msg) from e
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to stop continuous tool: {e}"
raise ToolError(msg) from e
@mcp.tool
async def list_continuous_hub_sessions() -> dict[str, Any]:
"""List all active and recent continuous hub tool sessions.
:return: List of sessions with their status and server info.
"""
try:
executor = _get_hub_executor()
sessions = executor.list_continuous_sessions()
return {
"sessions": sessions,
"count": len(sessions),
}
except Exception as e:
if isinstance(e, ToolError):
raise
msg = f"Failed to list continuous sessions: {e}"
raise ToolError(msg) from e
@@ -0,0 +1,317 @@
"""Project management tools for SecPipe MCP."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from secpipe_mcp.dependencies import (
get_active_skill,
get_project_path,
get_storage,
set_active_skill,
set_current_project_path,
)
mcp: FastMCP = FastMCP()
@mcp.tool
async def init_project(project_path: str | None = None) -> dict[str, Any]:
"""Initialize a new SecPipe project workspace.
Creates a `.secpipe/` directory for storing configuration and execution results.
Call this once before using hub tools. The project path is a working directory
for SecPipe state — it does not need to contain the files you want to analyze.
Use `set_project_assets` separately to specify the target files.
:param project_path: Working directory for SecPipe state. Defaults to current directory.
:return: Project initialization result.
"""
storage = get_storage()
try:
path = Path(project_path) if project_path else get_project_path()
# Track this as the current active project
set_current_project_path(path)
storage_path = storage.init_project(path)
return {
"success": True,
"project_path": str(path),
"storage_path": str(storage_path),
"message": f"Project initialized. Storage at {path}/.secpipe/",
}
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 directory containing target files to analyze.
Points SecPipe to the directory with your analysis targets
(firmware images, binaries, source code, etc.). This directory
is mounted read-only into hub tool containers.
:param assets_path: Path to the directory containing files to analyze.
:return: Result including stored assets path.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
stored_path = storage.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 execution summaries including server, tool, timestamp, and success status.
:return: List of execution summaries.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
executions = storage.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.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
results_path = storage.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: dict[str, Any] = {
"success": True,
"execution_id": execution_id,
"results_path": str(results_path),
}
# Extract if requested
if extract_to:
extracted_path = storage.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
@mcp.tool
async def list_artifacts(
source: str | None = None,
artifact_type: str | None = None,
) -> dict[str, Any]:
"""List all artifacts produced by hub tools in the current project.
Artifacts are files created by tool executions in /app/output/.
They are automatically tracked after each execute_hub_tool call.
:param source: Filter by source server name (e.g. "binwalk-mcp").
:param artifact_type: Filter by type (e.g. "elf-binary", "json", "text", "archive").
:return: List of artifacts with path, type, size, and source info.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
artifacts = storage.list_artifacts(
project_path,
source=source,
artifact_type=artifact_type,
)
return {
"success": True,
"artifacts": artifacts,
"count": len(artifacts),
}
except Exception as exception:
message: str = f"Failed to list artifacts: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def get_artifact(path: str) -> dict[str, Any]:
"""Get metadata for a specific artifact by its container path.
:param path: Container path of the artifact (e.g. /app/output/extract_abc123/squashfs-root/usr/sbin/httpd).
:return: Artifact metadata including path, type, size, source tool, and timestamps.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
artifact = storage.get_artifact(project_path, path)
if artifact is None:
return {
"success": False,
"path": path,
"error": "Artifact not found",
}
return {
"success": True,
"artifact": artifact,
}
except Exception as exception:
message: str = f"Failed to get artifact: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def list_skills() -> dict[str, Any]:
"""List available skill packs.
Skill packs provide domain-specific pipeline guidance for AI agents.
They describe analysis methodologies and list the hub servers needed.
Project-local skills (.secpipe/skills/) override built-in skills.
:return: List of available skills with name, summary, and server list.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
skills = storage.list_skills(project_path)
active = get_active_skill()
return {
"success": True,
"skills": skills,
"count": len(skills),
"active_skill": active["name"] if active else None,
}
except Exception as exception:
message: str = f"Failed to list skills: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def load_skill(name: str) -> dict[str, Any]:
"""Load a skill pack to guide the current analysis session.
A skill pack provides a pipeline description and specifies which
hub servers are relevant. Once loaded, the pipeline description
is available as context and only the listed servers need discovery.
:param name: Skill name (e.g. "firmware-analysis").
:return: Loaded skill with full description and server list.
"""
storage = get_storage()
project_path: Path = get_project_path()
try:
skill = storage.load_skill(project_path, name)
if skill is None:
return {
"success": False,
"name": name,
"error": f"Skill '{name}' not found. Use list_skills to see available skills.",
}
set_active_skill(skill)
return {
"success": True,
"name": skill["name"],
"description": skill["description"],
"servers": skill["servers"],
"message": f"Skill '{name}' loaded. Follow the pipeline description above. "
f"Discover tools from: {', '.join(skill['servers'])}" if skill["servers"] else f"Skill '{name}' loaded.",
}
except Exception as exception:
message: str = f"Failed to load skill: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def unload_skill() -> dict[str, Any]:
"""Unload the currently active skill pack.
Clears the active pipeline guidance. The agent returns to generic mode.
:return: Confirmation of unload.
"""
active = get_active_skill()
if active is None:
return {
"success": True,
"message": "No skill was loaded.",
}
name = active["name"]
set_active_skill(None)
return {
"success": True,
"message": f"Skill '{name}' unloaded.",
}
@@ -0,0 +1,346 @@
"""Report generation tools for SecPipe MCP."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from secpipe_mcp.dependencies import get_project_path, get_storage
mcp: FastMCP = FastMCP()
# Maximum characters of tool output to embed per execution in markdown reports.
_OUTPUT_TRUNCATE_CHARS: int = 2000
# ------------------------------------------------------------------
# Formatting helpers
# ------------------------------------------------------------------
def _format_size(size: int) -> str:
"""Format a byte count as a human-friendly string."""
for unit in ("B", "KB", "MB", "GB"):
if size < 1024: # noqa: PLR2004
return f"{size} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size //= 1024
return f"{size:.1f} TB"
def _truncate(text: str, max_chars: int = _OUTPUT_TRUNCATE_CHARS) -> str:
"""Truncate text and append an indicator when truncated."""
if len(text) <= max_chars:
return text
omitted = len(text) - max_chars
return text[:max_chars] + f"\n... [{omitted} chars omitted]"
def _extract_output_text(result: dict[str, Any]) -> str:
"""Extract a human-readable output string from an execution result dict.
Handles both flat dicts (``{"output": "..."}`` or ``{"content": [...]}``),
and the nested format stored by ``record_execution`` where the MCP tool
response is stored one level deeper under the ``"result"`` key.
"""
# Flat output field (most hub tools set this)
output = result.get("output", "")
if output and isinstance(output, str):
return output
# MCP content list format — check both at this level and one level down
for candidate in (result, result.get("result") or {}):
content = candidate.get("content", [])
if isinstance(content, list):
texts = [item.get("text", "") for item in content if isinstance(item, dict)]
combined = "\n".join(t for t in texts if t)
if combined:
return combined
parts: list[str] = []
if result.get("stdout"):
parts.append(f"stdout:\n{result['stdout']}")
if result.get("stderr"):
parts.append(f"stderr:\n{result['stderr']}")
return "\n".join(parts)
# ------------------------------------------------------------------
# Report builders
# ------------------------------------------------------------------
def _report_header(
title: str,
project_path: Path,
assets_path: Path | None,
now: str,
) -> list[str]:
"""Build the header block of the Markdown report."""
lines = [
f"# {title}",
"",
f"**Generated:** {now} ",
f"**Project:** `{project_path}` ",
]
if assets_path:
lines.append(f"**Assets:** `{assets_path}` ")
lines += ["", "---", ""]
return lines
def _report_summary(
executions: list[dict[str, Any]],
artifacts: list[dict[str, Any]],
) -> list[str]:
"""Build the summary table block of the Markdown report."""
success_count = sum(1 for e in executions if e.get("success"))
fail_count = len(executions) - success_count
tool_ids = list(dict.fromkeys(
f"{e.get('server', '?')}:{e.get('tool', '?')}" for e in executions
))
timestamps = [e["timestamp"] for e in executions if e.get("timestamp")]
lines = [
"## Summary",
"",
"| Metric | Value |",
"|--------|-------|",
f"| Total executions | {len(executions)} |",
f"| Successful | {success_count} |",
f"| Failed | {fail_count} |",
f"| Artifacts produced | {len(artifacts)} |",
f"| Unique tools | {len(set(tool_ids))} |",
]
if len(timestamps) >= 2: # noqa: PLR2004
lines.append(f"| Time range | {timestamps[0]} → {timestamps[-1]} |")
elif timestamps:
lines.append(f"| Time | {timestamps[0]} |")
lines.append("")
if tool_ids:
lines += [", ".join(f"`{t}`" for t in tool_ids), ""]
lines[-2] = f"**Tools used:** {lines[-2]}"
lines += ["---", ""]
return lines
def _report_timeline(
executions: list[dict[str, Any]],
artifacts: list[dict[str, Any]],
) -> list[str]:
"""Build the execution timeline block of the Markdown report."""
if not executions:
return []
lines: list[str] = ["## Execution Timeline", ""]
for idx, meta in enumerate(executions, 1):
server = meta.get("server", "unknown")
tool = meta.get("tool", "unknown")
ts = meta.get("timestamp", "")
status = "✓ Success" if meta.get("success") else "✗ Failed"
lines.append(f"### [{idx}] {server} :: {tool} — {ts}")
lines += ["", f"- **Status:** {status}"]
arguments = meta.get("arguments") or {}
if arguments:
lines.append("- **Arguments:**")
for k, v in arguments.items():
lines.append(f" - `{k}`: `{v}`")
result = meta.get("result") or {}
output_text = _extract_output_text(result).strip()
if output_text:
truncated = _truncate(output_text)
lines += ["- **Output:**", " ```"]
lines.extend(f" {line}" for line in truncated.splitlines())
lines.append(" ```")
exec_artifacts = [
a for a in artifacts
if a.get("source_server") == server and a.get("source_tool") == tool
]
if exec_artifacts:
lines.append(f"- **Artifacts produced:** {len(exec_artifacts)} file(s)")
lines.append("")
return lines
def _report_artifacts(artifacts: list[dict[str, Any]]) -> list[str]:
"""Build the artifacts section of the Markdown report."""
if not artifacts:
return []
lines: list[str] = ["---", "", "## Artifacts", "", f"**{len(artifacts)} file(s) total**", ""]
by_type: dict[str, list[dict[str, Any]]] = {}
for a in artifacts:
by_type.setdefault(a.get("type", "unknown"), []).append(a)
for art_type, arts in sorted(by_type.items()):
lines += [
f"### {art_type} ({len(arts)})",
"",
"| Path | Size | Source |",
"|------|------|--------|",
]
for a in arts:
path = a.get("path", "")
size = _format_size(a.get("size", 0))
source = f"`{a.get('source_server', '?')}:{a.get('source_tool', '?')}`"
lines.append(f"| `{path}` | {size} | {source} |")
lines.append("")
return lines
def _build_markdown_report(
title: str,
project_path: Path,
assets_path: Path | None,
executions: list[dict[str, Any]],
artifacts: list[dict[str, Any]],
) -> str:
"""Build a Markdown-formatted analysis report."""
now = datetime.now(tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
lines: list[str] = (
_report_header(title, project_path, assets_path, now)
+ _report_summary(executions, artifacts)
+ _report_timeline(executions, artifacts)
+ _report_artifacts(artifacts)
+ ["---", "", "*Generated by SecPipe*", ""]
)
return "\n".join(lines)
def _build_json_report(
title: str,
project_path: Path,
assets_path: Path | None,
executions: list[dict[str, Any]],
artifacts: list[dict[str, Any]],
) -> str:
"""Build a JSON-formatted analysis report."""
success_count = sum(1 for e in executions if e.get("success"))
report = {
"title": title,
"generated_at": datetime.now(tz=UTC).isoformat(),
"project_path": str(project_path),
"assets_path": str(assets_path) if assets_path else None,
"summary": {
"total_executions": len(executions),
"successful": success_count,
"failed": len(executions) - success_count,
"artifact_count": len(artifacts),
},
"executions": executions,
"artifacts": artifacts,
}
return json.dumps(report, indent=2, default=str)
def _write_to_path(content: str, path: Path) -> None:
"""Write report content to an explicit output path (sync helper)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
# ------------------------------------------------------------------
# MCP tools
# ------------------------------------------------------------------
@mcp.tool
async def generate_report(
title: str | None = None,
report_format: str = "markdown",
output_path: str | None = None,
) -> dict[str, Any]:
"""Generate a comprehensive analysis report for the current project.
Aggregates all execution history, tool outputs, and tracked artifacts
into a structured report. The report is saved to `.secpipe/reports/`
and its content is returned so the agent can read it immediately.
:param title: Optional report title. Defaults to the project folder name.
:param report_format: Output format — ``"markdown"`` (default) or ``"json"``.
:param output_path: Optional absolute path to save the report. When omitted,
the report is saved automatically to `.secpipe/reports/`.
:return: Report content, save path, and counts of included items.
"""
storage = get_storage()
project_path = get_project_path()
try:
fmt = report_format.lower().strip()
if fmt not in ("markdown", "json"):
return {
"success": False,
"error": f"Unsupported format '{fmt}'. Use 'markdown' or 'json'.",
}
executions = storage.list_execution_metadata(project_path)
artifacts = storage.list_artifacts(project_path)
assets_path = storage.get_project_assets_path(project_path)
resolved_title = title or f"SecPipe Analysis Report — {project_path.name}"
if fmt == "json":
content = _build_json_report(
resolved_title, project_path, assets_path, executions, artifacts
)
else:
content = _build_markdown_report(
resolved_title, project_path, assets_path, executions, artifacts
)
if output_path:
save_path = Path(output_path)
_write_to_path(content, save_path)
else:
save_path = storage.save_report(project_path, content, fmt)
return {
"success": True,
"report_path": str(save_path),
"format": fmt,
"executions_included": len(executions),
"artifacts_included": len(artifacts),
"content": content,
}
except Exception as exception:
message: str = f"Failed to generate report: {exception}"
raise ToolError(message) from exception
@mcp.tool
async def list_reports() -> dict[str, Any]:
"""List all generated reports for the current project.
Reports are stored in `.secpipe/reports/` and are ordered newest-first.
:return: List of report files with filename, path, size, and creation time.
"""
storage = get_storage()
project_path = get_project_path()
try:
reports = storage.list_reports(project_path)
return {
"success": True,
"reports": reports,
"count": len(reports),
}
except Exception as exception:
message: str = f"Failed to list reports: {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 secpipe_mcp.application import mcp
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable
from fastmcp.client import FastMCPTransport
from secpipe_tests.fixtures import SecPipeProjectIdentifier
pytest_plugins = ["secpipe_tests.fixtures"]
@pytest.fixture(autouse=True)
def environment(
monkeypatch: pytest.MonkeyPatch,
random_project_identifier: Callable[[], SecPipeProjectIdentifier],
) -> None:
"""TODO."""
monkeypatch.setenv("SECPIPE_PROJECT_IDENTIFIER", str(random_project_identifier()))
monkeypatch.setenv("SECPIPE_API_HOST", "127.0.0.1")
monkeypatch.setenv("SECPIPE_API_PORT", "8000")
@pytest.fixture
async def mcp_client() -> AsyncGenerator[Client[FastMCPTransport]]:
"""TODO."""
async with Client(transport=mcp) as client:
yield client
+31
View File
@@ -0,0 +1,31 @@
"""MCP tool tests for SecPipe AI.
Tests the MCP tools that are available in SecPipe AI.
"""
import pytest
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastmcp import Client
from fastmcp.client import FastMCPTransport
async def test_init_project_tool_exists(
mcp_client: "Client[FastMCPTransport]",
) -> None:
"""Test that the init_project tool is available."""
tools = await mcp_client.list_tools()
tool_names = [tool.name for tool in tools]
assert "init_project" in tool_names
async def test_mcp_has_expected_tool_count(
mcp_client: "Client[FastMCPTransport]",
) -> None:
"""Test that MCP has the expected number of tools."""
tools = await mcp_client.list_tools()
# Should have project tools + hub tools
assert len(tools) >= 2