← Volver al blog
◆ Herramientas · AutomatizaciónSerie Ofensiva

Construyendo un MCP Server ofensivo

● Por el equipo TitanSec 31 de agosto de 2026 ~15 min de lectura Nivel: Avanzado

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ñoPor qué importa
Lista blanca de herramientasEl 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 libreEvita inyección de comandos vía el LLM
Logging de cada invocaciónTrazabilidad 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 el audit.log.jsonl y 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

#MCP#Claude#python#automatización#recon#redteam#tooling

Newsletter

Recibe cada nuevo writeup técnico. Sin spam, solo contenido.