En el primer artículo conectamos Claude a un Kali MCP Server ya hecho. Ahora damos el paso natural: construir el nuestro. Un servidor MCP a medida te deja exponer exactamente las herramientas que quieres, con el control y la trazabilidad que un engagement exige — ni más capacidad de la autorizada, ni menos registro del necesario.
Alcance. Las herramientas de reconocimiento de este artículo son de uso dual. Ejecútalas solo contra activos propios o con autorización escrita. El servidor que construimos impone límites por diseño precisamente para que el alcance técnico no supere al alcance legal.
1. Qué expone un MCP Server y qué no
El Model Context Protocol define cómo un cliente (Claude) descubre y llama tools que publica un servidor. Para seguridad ofensiva, el servidor es la frontera de control: decide qué binarios se ejecutan, contra qué objetivos y con qué argumentos. Diseñarlo bien es, sobre todo, diseñar sus límites.
| Decisión de diseño | Por qué importa |
|---|---|
| Lista blanca de herramientas | El modelo no puede invocar nada fuera del catálogo publicado |
| Validación de objetivo (CIDR/host) | Impide salirse del alcance autorizado |
| Argumentos tipados, no shell libre | Evita inyección de comandos vía el LLM |
| Logging de cada invocación | Trazabilidad equivalente a un pentest manual |
2. Esqueleto del servidor
Usamos el SDK oficial de Python (mcp) con FastMCP, que reduce un servidor a funciones decoradas. Instalación y dependencias:
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]" httpx
El esqueleto mínimo declara el servidor y lo arranca por stdio (el transporte que usan los clientes de escritorio):
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("recon-ofensivo")
if __name__ == "__main__":
mcp.run() # transporte stdio por defecto
3. Límites de seguridad primero
Antes de la primera herramienta, escribimos las barandas. Un único punto que valide alcance y construya argumentos de forma segura:
# guards.py
import ipaddress, shutil
from urllib.parse import urlparse
ALLOWED_NETS = [ipaddress.ip_network("10.10.10.0/24")]
TOOL_ALLOWLIST = {"nmap", "httpx"}
class ScopeError(Exception):
"""El objetivo o la herramienta están fuera del alcance autorizado."""
def ensure_tool(name: str) -> str:
if name not in TOOL_ALLOWLIST:
raise ScopeError(f"herramienta no permitida: {name}")
path = shutil.which(name)
if not path:
raise ScopeError(f"binario no encontrado: {name}")
return path
def ensure_in_scope(target: str) -> str:
host = urlparse(target).hostname or target
ip = ipaddress.ip_address(host) # lanza si no es IP válida
if not any(ip in net for net in ALLOWED_NETS):
raise ScopeError(f"objetivo fuera de alcance: {host}")
return host
Nunca construyas el comando concatenando strings. Pasa los argumentos como lista a subprocess y jamás uses shell=True. Así, aunque el modelo "sugiera" ; rm -rf / en un parámetro, se trata como un argumento literal, no como shell.
4. La primera tool: un escaneo acotado
Cada herramienta es una función decorada con @mcp.tool(). El docstring y los tipos se convierten en el esquema que Claude ve, así que descríbelos con cuidado:
# server.py (continuación)
import asyncio
from guards import ensure_tool, ensure_in_scope, ScopeError
from audit import log_invocation
@mcp.tool()
async def nmap_scan(target: str, top_ports: int = 100) -> str:
"""Escaneo de puertos con nmap sobre un objetivo DENTRO del alcance.
Args:
target: IP del host autorizado (debe estar en el CIDR permitido).
top_ports: número de puertos más comunes a escanear (1-1000).
"""
try:
binary = ensure_tool("nmap")
host = ensure_in_scope(target)
except ScopeError as e:
return f"RECHAZADO: {e}"
top = max(1, min(int(top_ports), 1000))
args = [binary, "-sV", "-T4", "--top-ports", str(top), host]
log_invocation("nmap_scan", args)
proc = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
out, _ = await proc.communicate()
return out.decode(errors="replace")[:20000]
Fíjate en el patrón: validar → acotar → registrar → ejecutar. Si algo falla el control, la función devuelve RECHAZADO en vez de ejecutar nada.
5. Trazabilidad
Un pentest asistido por IA debe ser tan auditable como uno manual. Registramos cada invocación con timestamp antes de ejecutarla:
# audit.py
import json, time, pathlib
LOG = pathlib.Path("audit.log.jsonl")
def log_invocation(tool: str, args: list[str]) -> None:
entry = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "tool": tool, "args": args}
with LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
Con esto tienes un audit.log.jsonl reconstruible: qué se corrió, contra qué y cuándo. Imprescindible para el anexo de evidencias del informe.
6. Conectar Claude
Declara el servidor en el cliente MCP apuntando al intérprete del entorno virtual:
// ~/.claude.json (fragmento mcpServers)
"recon-ofensivo": {
"command": "/ruta/al/proyecto/.venv/bin/python",
"args": ["/ruta/al/proyecto/server.py"]
}
En Claude Code, /mcp debería listar recon-ofensivo con nmap_scan. Pídele algo dentro de alcance (“escanea los 100 puertos top de 10.10.10.25”) y verás la validación, el log y la salida. Prueba también uno fuera de alcance: debe responder RECHAZADO sin ejecutar nada.
Testea los límites, no solo la función. Tu suite debe cubrir los casos de rechazo: objetivo fuera del CIDR, herramienta no permitida, argumentos fuera de rango. En tooling ofensivo, las barandas son la funcionalidad crítica.
7. Siguientes pasos
A partir de este esqueleto puedes crecer con criterio:
dns_recon— subdominios y registros, con la misma validación de dominio en alcance.web_enum—httpx+ fuzzing acotado de rutas, con límite de hilos y wordlist fija.vuln_lookup— consulta a la NVD por producto/versión (solo lectura, sin tocar el objetivo).report_generator— convierte elaudit.log.jsonly los hallazgos en un borrador Markdown.
8. Conclusión
Construir tu propio MCP Server ofensivo no va de darle más poder a la IA, sino de darle el poder exacto, con límites explícitos y registro completo. El patrón validar → acotar → registrar → ejecutar convierte un LLM en un operador disciplinado dentro de tu alcance autorizado. Este desarrollo continúa en el proyecto interno MCP Seguridad Ofensiva de TitanSec, del que iremos publicando avances.
Fuentes
- Model Context Protocol — Documentación oficial (especificación y SDKs)
- MCP Python SDK — GitHub (
FastMCP) - OWASP Top 10 for LLM Applications — Excessive Agency (LLM03) (contexto de límites y mínimo privilegio para agentes)