Skip to content
Go back

ai

Building RedTeam MCP: An AI-Powered Penetration Testing Assistant

8 min read

RedTeam MCP: The AI-Powered Pentesting Assistant

Table of Contents

Open Table of Contents

Introduction

In my previous post on MCP, I explored the Model Context Protocol and how it bridges the gap between AI and external systems. Now, let’s put that knowledge into practice by building RedTeam MCP - an MCP server that enables AI assistants like Claude to orchestrate penetration testing tools.

This project serves a dual purpose:

  1. Learning TypeScript from a Python background (see my TypeScript for Pythonistas guide)
  2. Building a practical security tool that demonstrates MCP’s power

⚠️ Disclaimer: This tool is for authorized security testing only. Always ensure you have explicit permission before scanning any target.


The Vision

Imagine doing HackTheBox machines with an AI assistant that understands your workflow:

User: "I'm starting a new HTB machine at 10.10.10.123. Help me enumerate it."

Claude: I'll begin reconnaissance.

[Uses port_scan] → Found: 22/SSH, 80/HTTP, 443/HTTPS
[Uses tech_detect] → Apache 2.4.41, PHP 7.4.3, WordPress 5.9.1
[Uses subdomain_enum] → Found: admin.target.htb, dev.target.htb
[Uses vuln_scan] → Critical: CVE-2024-XXXX on admin.target.htb

Recommendation: Research CVE-2024-XXXX for potential exploitation.

The AI handles the tedious enumeration while you focus on the interesting parts: understanding the target and crafting exploits.


Architecture

At its core, the pattern is simple: the LLM never touches the tools directly. It speaks MCP to a server, and the server is what executes anything.

MCP architecture: LLM to MCP Server to local tools

In practice, RedTeam MCP expands that middle box into a layered architecture:

graph TB
    Client["MCP Client<br/>(Claude Desktop / LangGraph Agent)"]
    Server["MCP Server (redteam-mcp)"]

    Tasks["Task Tools<br/>port_scan · vuln_scan · directory_fuzz<br/>subdomain_enum · vhost_fuzz · tech_detect"]
    Low["Low-Level Tools<br/>nmap · nuclei<br/>httpx · whatweb"]
    Prompts["Prompts (Playbooks)<br/>htb_initial_recon · web_enumeration<br/>subdomain_discovery · quick_scan"]

    Guard["Security Guardrails<br/>Target Whitelisting · Rate Limiting<br/>Audit Logging · Input Validation"]
    Tools["Security Tools<br/>nmap | nuclei | ffuf | httpx | whatweb"]

    Client -->|MCP Protocol| Server
    Server --> Tasks
    Server --> Low
    Server --> Prompts
    Tasks --> Guard
    Low --> Guard
    Prompts --> Guard
    Guard -->|CLI Wrappers| Tools

Design Decisions

Why MCP and Not Just Function Calling?

The obvious objection: every major provider already supports function calling. Why add a protocol on top?

Because function calling couples the tools to whoever is calling them. Define port_scan as an OpenAI tool schema and you have an OpenAI tool. Move to Claude, and you rewrite the schema. Move to a local model, rewrite again. The scanner logic never changed — only the wrapper around it did.

MCP inverts that. The server declares what it offers, and any client that speaks the protocol can consume it:

ConcernFunction callingMCP server
Tool schemaPer-provider formatDeclared once, provider-agnostic
ReuseBound to one appAny MCP client
Process boundaryUsually in-processSeparate process, own privileges
PlaybooksApp-level stringsFirst-class prompts primitive
Adding a toolRedeploy the appClient re-lists at runtime

That reuse is not theoretical here. The exact same server binary is what the MCP Inspector drives in the screenshots below, what Claude Desktop connects to, and what the LangGraph agent will connect to later — with no changes to the scanning code.

The process boundary matters more than usual for this project. The tools shell out to nmap and nuclei, so keeping them behind a separate process means the scanners run with their own privileges and their own failure domain, rather than inside whatever is orchestrating the conversation.

To be fair about the trade-off: if you are building one feature, in one app, on one provider, function calling is less machinery and you should just use it. MCP starts paying off when the tool is meant to outlive the app that first used it — which is exactly the case for a pentesting toolkit you will reuse across clients and workflows for years.

Task-Oriented vs Tool-Oriented

A key insight: the AI doesn’t need to know which tool to use, just what task to accomplish.

Tool-oriented vs task-oriented tool design

❌ Tool-Oriented (Bad):

# AI must know ffuf's Host header fuzzing syntax
ffuf -u http://target.htb -H "Host: FUZZ.target.htb" -w wordlist.txt

✅ Task-Oriented (Good):

subdomain_enum --domain target.htb

We built 6 task-oriented tools that abstract the underlying complexity:

ToolUnderlying Implementation
subdomain_enumffuf with Host header fuzzing
directory_fuzzffuf with path fuzzing
vhost_fuzzffuf targeting IP with Host variations
port_scannmap with version detection
tech_detecthttpx + whatweb combination
vuln_scannuclei with severity filters

For power users, we also expose the low-level tools (nmap, nuclei, httpx, whatweb) with full options.

Taming Tool Output

Task-oriented tools solve the input side. The output side is where most of the real engineering went.

Scanners are written for a human reading a terminal. An aggressive nmap run against a single host emits banner grabs, script output, timing data, TTLs and fingerprint blobs; nuclei and ffuf will happily emit a JSON object per request. Piping that straight into the model has three distinct problems: it burns context the agent needs for reasoning, it buries the two or three facts that actually drive the next decision, and it drags formatting noise into the conversation that the model then wastes effort interpreting.

The rule we settled on: the tool owns the parsing, and the model never sees a raw scanner format.

1. Ask for machine-readable output. Every one of these tools has a structured mode. Never regex a human-facing format that was never meant to be stable:

const OUTPUT_FORMATS = {
  nmap: ["-oX", "-"], // XML on stdout
  nuclei: ["-jsonl"], // one JSON object per finding
  ffuf: ["-of", "json"], // structured results
};

2. Project down to what drives the next decision. For a port scan, that is the port, protocol, service and version. Not the TTL, not the reason, not the fingerprint blob:

interface DiscoveredPort {
  port: number;
  proto: "tcp" | "udp";
  service: string;
  product?: string;
  version?: string;
}

3. Render compactly. JSON is a poor wire format for a context window — repeated keys and punctuation are pure overhead. A terse table carries the same information:

10.10.10.123
22/tcp   ssh    OpenSSH 8.2p1
80/tcp   http   Apache 2.4.41
443/tcp  https  Apache 2.4.41

4. Truncate loudly. Long result lists still need a cap, but a silent cap is worse than no cap: the model treats what it sees as the complete picture and reasons from a false premise. Say what was dropped and how to ask for the rest:

Showing 20 of 143 discovered paths, ranked by status code.
Call directory_fuzz with a narrower filter to see the remainder.

5. Keep the raw output retrievable. Trimming is for the context window, not for the evidence. Each run writes its full output to disk and the tool returns the path, so findings stay auditable and you can go back to the original when the summary isn’t enough.

There’s a consequence here worth flagging. Everything crossing this boundary is attacker-influenced: service banners, page titles and HTTP headers are all content the target controls. Normalizing output is therefore also the point where you decide what a scanned host is allowed to say to your agent — a problem that deserves its own post.


Safety Guardrails

With great power comes great responsibility. We implemented multiple layers of protection:

Three guardrail layers: target whitelisting, rate limiting, audit logging

1. Target Whitelisting

const ALLOWED_TARGETS = [
  "*.htb", // HackTheBox domains
  "*.thm", // TryHackMe domains
  "10.10.10.*", // HTB IP range
  "10.10.11.*", // HTB IP range
  "*.lab.internal", // Local labs
];

Any target not matching these patterns is rejected before execution.

2. Rate Limiting

Token bucket algorithm prevents abuse:

const LIMITS = {
  port_scan: { maxTokens: 5, refillRate: 0.1 }, // 5 scans, refill 1/10sec
  vuln_scan: { maxTokens: 3, refillRate: 0.05 }, // 3 scans, refill 1/20sec
};

3. Audit Logging

Every invocation is logged with full context:

{
  "id": "audit-1706000000000-abc123",
  "timestamp": "2025-01-26T12:00:00Z",
  "action": "scan_started",
  "tool": "port_scan",
  "target": "10.10.10.123",
  "result": "success",
  "duration": 45200
}

Implementation Highlights

TypeScript for Security Tools

Half the point of this project was learning TypeScript from a Python background. Three things transferred differently than I expected.

Zod is not quite Pydantic. Both validate at runtime, but the direction of inference is reversed. In Pydantic the class is the source of truth and the runtime object; in Zod the schema is the source of truth, and the static type is derived from it:

const NmapInput = z.object({
  target: z.string().describe("Target IP or hostname"),
  scanType: z.enum(["quick", "default", "version", "aggressive"]),
  timing: z.enum(["T0", "T1", "T2", "T3", "T4", "T5"]).default("T4"),
});

type NmapInput = z.infer<typeof NmapInput>; // derived, never hand-written

That single declaration does three jobs: it validates input at runtime, it gives runNmapScan a compile-time parameter type, and the MCP SDK converts it into the JSON Schema the model sees. Which means those .describe() strings aren’t comments — they’re the documentation the LLM reads when deciding whether to call the tool. Getting them wrong is a functional bug, not a style nit.

Shelling out is where TypeScript nearly bit me. Python’s subprocess.run takes an argument list by default and you must opt into a shell with shell=True. Node’s child_process offers both, and the more discoverable one is the dangerous one:

// ❌ exec() interpolates through a shell
exec(`nmap -sV ${target}`); // target = "10.10.10.1; rm -rf ~"

// ✅ execFile() takes an argv array — no shell, no interpolation
await execFile("nmap", ["-sV", target], { timeout: 300_000 });

For a tool whose inputs are hostnames arriving from a conversation, that difference is the difference between a scanner and a remote shell. The target whitelist from the previous section should reject a payload like that long before it reaches here — which is precisely why both layers exist rather than just one.

Types vanish at runtime, and that took adjusting to. A Pydantic model is a real object you can carry around and introspect; a TypeScript interface is erased at compile time. The DiscoveredPort from earlier doesn’t exist when the code runs, so anything crossing a boundary — parsed nmap XML, a tool argument, a JSON file on disk — still needs validating by something that survives to runtime. Coming from Python, I kept reaching for a type where what I actually needed was a schema.

Wiring a validated schema into the server is then mechanical:

server.registerTool(
  "port_scan",
  {
    description: "Scan for open ports and services",
    inputSchema: { target, scanType, timing },
  },
  async input => {
    const result = await runNmapScan(input);
    return { content: [{ type: "text", text: formatResult(result) }] };
  }
);

Workflow Prompts

MCP supports prompts - pre-defined workflows the AI can follow:

const PROMPTS = [
  {
    name: "htb_initial_recon",
    description: "Complete HTB machine reconnaissance",
    template: `
    1. Port scan with version detection
    2. Technology detection on web services
    3. Subdomain enumeration if domain found
    4. Vulnerability scan on discovered services
  `,
  },
];

Project Structure

redteam-mcp/
├── src/
│   ├── index.ts              # MCP server (10 tools + 4 prompts)
│   ├── tools/
│   │   ├── fuzzing.ts        # subdomain, directory, vhost fuzzing
│   │   ├── nmap.ts           # Port scanning
│   │   ├── nuclei.ts         # Vulnerability scanning
│   │   └── httpx.ts          # HTTP probing
│   ├── prompts/
│   │   └── playbooks.ts      # Enumeration workflows
│   └── utils/
│       ├── validation.ts     # Target whitelisting
│       ├── rateLimiter.ts    # Token bucket rate limiting
│       └── logger.ts         # Audit logging
├── exercises/                # TypeScript learning exercises
├── docs/
│   └── SECURITY.md          # Security guardrails documentation
└── examples/
    ├── htb-machine-workflow.md  # Demo walkthrough
    └── quick-examples.md        # Quick reference

Testing with MCP Inspector

Before connecting to an AI agent, test your server with MCP Inspector:

cd redteam-mcp
npm run build
npx @modelcontextprotocol/inspector node ./build/index.js

The Inspector boots a proxy and hands you a pre-authenticated URL:

MCP Inspector starting up and printing its local URL

This opens a web UI where you can:

MCP Inspector listing the four RedTeam MCP playbooks

Here you can see the four playbooks registered by the server — htb_initial_recon, web_enumeration, subdomain_discovery and quick_scan — all reachable by the agent as MCP prompts.


What’s Next

This post covered the MCP Server — the tool provider. The next phase of the project is the Agent built with LangGraph that:

  1. Connects to the MCP server
  2. Decides which tools to use based on context
  3. Analyzes outputs and plans next steps
  4. Maintains state across the reconnaissance workflow

From tool provider to LangGraph agent

The agent wraps this server in a StateGraph that can loop back on itself when a step doesn’t yield enough information:

graph TB
    subgraph Agent["LangGraph Agent"]
        direction LR
        Recon[Recon] --> Enum[Enum] --> Analyze[Analyze] --> Report[Report]
        Analyze -->|Need More Info| Recon
    end

    Agent --> MCPClient["MCP Client"]
    MCPClient --> ServerRef["RedTeam MCP Server (this post)"]

Resources


Conclusion

RedTeam MCP demonstrates how MCP can bridge AI assistants and specialized security tools. The key takeaways:

  1. Task-oriented design makes tools AI-friendly
  2. Output normalization matters as much as input design — the tool should own the parsing so the model never sees a raw scanner format
  3. Safety guardrails are non-negotiable for security tools, and they belong at more than one layer
  4. TypeScript + Zod gives you runtime validation, static types and the schema the model reads from a single declaration
  5. MCP’s architecture cleanly separates concerns, and buys reuse across every client that speaks the protocol

With proper safeguards, AI-assisted penetration testing isn’t just possible - it’s practical. The tedious enumeration phase becomes conversational, letting you focus on the creative aspects of security research.

The next phase of the project will complete the picture with a LangGraph agent that orchestrates this MCP server autonomously.