mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-10-12 05:57:20 +02:00
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:
1 parent
bbf864e88b
commit
be009a4094
120 files changed
+866
-800
No files matched your search
@@ -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"]
|
||||
@@ -0,0 +1,51 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
PODMAN?=/usr/bin/podman
|
||||
|
||||
ARTIFACTS?=./dist
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit clean cloc format image mypy pytest ruff version wheel
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES) $(TESTS)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
image:
|
||||
$(PODMAN) build \
|
||||
--build-arg PACKAGE=$(PACKAGE) \
|
||||
--file ./Dockerfile \
|
||||
--no-cache \
|
||||
--tag $(PACKAGE):$(VERSION) \
|
||||
--volume $(ARTIFACTS):/wheels
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES) $(TESTS)
|
||||
|
||||
pytest:
|
||||
uv run pytest -v $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
|
||||
wheel:
|
||||
uv build --out-dir $(ARTIFACTS)
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,6 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -0,0 +1,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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -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
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user