pentest-mcp
Exports structured engagement data, observations, entities, and relationships into an Obsidian vault as a readable projection of the canonical engagement store.
Captures terminal activity from tmux sessions, panes, and windows — including nested and reverse shells — and turns raw terminal bytes into searchable, timestamped segments with full provenance.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@pentest-mcpWhich pane and command did the svc_backup password come from?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
pentest-recorder · pentest-mcp
Your terminal is the best record of an engagement you will ever have. This turns it into one.
Two hours into an assessment you have a domain credential, four hosts, a share you can read, and no idea which pane you saw any of it in. So you scroll. Or you re-run the enumeration. Or you paste it all into a notes file by hand, and then do it again tomorrow.
pentest-recorder watches your existing tmux session, keeps the exact bytes
your terminal produced, and turns them into structured engagement state.
pentest-mcp hands that state to any MCP-compatible agent — so it can answer
where did I get svc_backup's password with the segment, the pane, the
timestamp, and the original bytes.
You keep your VM, your tmux, your VPNs and pivots, your aliases, your wordlists. Nothing gets wrapped. Nothing gets replaced.
tmux panes
│
▼
raw bytes ──────────────────────────────► raw/pane-000003.log
│ authoritative · never parsed
│ terminal emulation
▼
segments ◄── byte-addressable, immutable
│
│ LLM (local by default)
▼
observations ◄── append-only, every fact cites its source
│
│ deterministic rebuild
▼
entities · relationships · auth log
│
├──────────────► Obsidian vault (a projection, not the store)
│
└──────────────► pentest-mcp ────► Claude Code · Codex · any MCP agentThis is not an autonomous pentesting agent. The data layer holds truth and memory; the agent does the reasoning. The recorder never connects to anything you scanned — enforced by a test, not by good intentions.
Install
git clone git@github.com:lucianoengel/pentest-mcp.git
cd pentest-mcp
./install.shPython 3.11+, tmux, and SQLite with FTS5 — the last of which the installer
checks, because some distro Pythons omit it. No Docker, no database server,
no browser UI. Three third-party packages total: pyte, httpx, mcp.
Updating
cd pentest-mcp
git pull
./install.shRe-running the installer is the update. It rebuilds from the checkout, keeps
your existing config.toml, and leaves engagement data alone. If an
engagement was written by an older version, its store is migrated the next
time a command opens it for writing — pentest-recorder status reports the
schema version, and says so if they disagree.
Stop the recorder first if one is running (pentest-recorder stop), since a
running daemon keeps using the code it started with.
Related MCP server: mcp-ssh-interactive
Use
pentest-recorder init inlanefreight --client "ACME" --scope "172.16.119.0/24"
tmux new -s inlanefreight
pentest-recorder startThen work normally. Every pane in that session is captured, including panes and windows you open later.
pentest-recorder status # what is — and is NOT — being captured
pentest-recorder search 'Summer2026!' # find the exact string, across everything
pentest-recorder pause # stop capturing, right now
pentest-recorder sync # extract, rebuild, exportConnect an agent
claude mcp add pentest -- ~/.local/bin/pentest-mcp --engagement inlanefreightNow ask it things.
what credentials do I have that I haven't validated yet?
// list_entities(type="credential", filter={"validated": false})
{
"items": [{
"id": "credential:INLANEFREIGHT/svc_backup:password:HolyMoly123!",
"type": "credential",
"data": {
"username": "svc_backup",
"domain": "INLANEFREIGHT",
"secret": "HolyMoly123!", // exact, never normalized
"secret_type": "password",
"status": "unvalidated"
},
"fact_type": "CONFIRMED",
"observation_count": 1
}],
"total": 1
}where did that password come from?
// get_provenance(entity_id="credential:INLANEFREIGHT/svc_backup:...")
{
"observations": [{
"kind": "credential",
"source": "extraction",
"actor": "ollama/qwen2.5-coder:7b",
"verified": true, // appeared verbatim in the source
"segment_ids": [1]
}],
"segments": [{
"id": 1,
"terminal": "inlanefreight:2.1",
"ts_start": "2026-08-26T14:32:11+00:00",
"command": "cat /mnt/backup/scripts/backup.ini",
"cwd": "/home/kali/eng",
"raw_path": ".../raw/pane-000003.log",
"byte_start": 0,
"byte_end": 125 // the original bytes, still on disk
}]
}catch me up
// get_engagement_summary()
hosts: 2 · services: 1 · identities: 1
credentials: 2 (1 unvalidated)
auth: 1 successful, 1 failed
findings: 1 candidate · open tasks: 0
hosts_with_no_service_recorded: 1 ← DC01 is under-enumerated
entities_with_unverified_fields: 0
segments: 1 (1 pending extraction)That summary is the agent's index. It's why there is no list_unvalidated_credentials()
tool — the counts tell the agent which questions are worth asking, so twelve
read tools cover what would otherwise need thirty.
What it captures — and what it deliberately doesn't
It sees inside nested sessions. A caught reverse shell, ssh, evil-winrm,
msfconsole, sqlplus. That is where a lot of the good evidence lives, and it
is exactly what shell-history tooling cannot reach:
$ nc -lvnp 4444 ← the only local command that ever runs
connect to [10.10.14.7] from (UNKNOWN) [172.16.119.30] 51422
C:\inetpub\wwwroot> type web.config
<add name="prod" connectionString="...;Password=P@ssw0rd#2026;" />
↑ captured, extracted, attributed to nothingThe command that produced it is recorded only when it is genuinely known.
Inside that reverse shell, command is nc -lvnp 4444 — truthfully — and
exit_code stays empty rather than guessed.
It stops when you tell it to:
how | |
the agent window | excluded by default — an agent's own output must never re-enter as evidence |
one pane |
|
one window |
|
everything, immediately |
|
a pane you already pipe | detected, left alone, reported |
status lists every pane in scope that is not being captured, with the
reason. A silently unmonitored pane is the worst failure this tool can have,
so it is never a footnote:
Engagement: inlanefreight (client: ACME)
Recorder: running (pid 48213) since 2026-08-26T13:58:02+00:00
tmux server: 347338
Capturing: 3 pane(s)
%1 inlanefreight:1:recon.0 (0 B buffered)
%4 inlanefreight:2:ad.0 (2145 B buffered)
%7 inlanefreight:3:shell.0 (0 B buffered)
NOT capturing 2 pane(s) in scope:
%9 inlanefreight:5:agent.0 -- window excluded by configuration
%11 inlanefreight:4:web.0 -- already piped by another tool; left untouched
Segments: 184 total, 3 pending, 181 extracted, 0 failed
Extraction: ollama / qwen2.5-coder:7b
Local provider: no engagement data leaves this machine.Where your data lives
~/.local/share/pentest-recorder/engagements/inlanefreight/
├── engagement.db one ordinary SQLite file — the canonical store
├── raw/ exact terminal bytes, rotated and gzipped
└── evidence/Directories 0700, files 0600. The raw logs are the most sensitive thing
on the box — plaintext domain credentials and client data sit in them.
Treat that directory as loot.
Everything is inspectable with ordinary tools. A backup is cp -r; an audit
is a SQL query:
sqlite3 engagement.db \
"SELECT json_extract(data,'\$.username'), json_extract(data,'\$.status')
FROM entities WHERE type='credential';"Separate engagements are separate on disk
One directory and one database file per engagement — not a shared table with a filter. Two clients using the same RFC1918 space stay two distinct sets of entities, and searching one for the other's password returns nothing.
pentest-recorder init acme --client "ACME" --local-only
pentest-recorder init globex --client "Globex" --local-onlyEach binds to the tmux session of the same name. If two recorders ever target
one pane, pipe-pane -o refuses to displace the first and the second reports
the pane as unmonitored.
At the end of an engagement:
pentest-recorder purge -e acme --include-vaultWhat reaches the model
Three things sit between capture and a model, and each disables independently
in [extraction].
Facts the text determines are derived without a model. An address, a URL,
a UNC path, password=X, and credentials in recognised formats — NTLMv2,
Kerberos tickets, pwdump rows, JWTs, PEM keys — are read straight out of the
text. Where the format carries the account, as most do, the owner comes from
the value itself:
svc_qualys::INLANEFREIGHT:1122334455667788:AB12…:0101…
└────┬────┘ └─────┬─────┘
username domain ← both are part of the valueMeasured against a corpus of nine formats: the rule pass recovers 9/9
byte-identically with 9/9 correct attribution; qwen2.5-coder:7b asked to
transcribe the same values manages 6/9 and 5/9. What it will not do is
recognise a value that means something only because of where a tool prints
it — microsoft-ds is a service because nmap has a SERVICE column, and that
stays the model's job.
Long tokens are replaced before the model sees them. What remains is a placeholder, its length and character class, and all the surrounding context:
[SMB] NTLMv2-SSP Hash : svc_qualys::INLANEFREIGHT:1122334455667788:<Ta1b2c3d4:1>
└──────────── kept, so the model can attribute it ────┘The recorder substitutes the real bytes back. Transcription error stops being something detected and becomes something that cannot happen. Keeping the identifiers visible matters: hiding the whole credential drops classification to 44%, keeping them raises it to 78% — better than showing the model the raw value.
Repetition is collapsed and empty segments are skipped. Identical lines
become one instance and a count, with every distinct value preserved. A
segment carrying no candidate and no value the engagement does not already
know is never sent — the decision is on unknown values, not line shapes,
because mid-engagement every shape is familiar and connected to \\SQL01\payroll would otherwise be dropped along with a new host and share.
Skipped segments record why and stay reprocessable.
Numbers behind all of this are in bench/BASELINE.md.
When data leaves the machine
Extraction needs a language model, and there is no redaction option — to extract a secret you have to send the secret.
So the default provider is local (Ollama on 127.0.0.1), and nothing leaves
your machine unless you change that. If you configure a remote provider,
start tells you exactly what would be transmitted and refuses until you
acknowledge it:
Extraction is configured to use openai (gpt-4o-mini) at
https://api.openai.com/v1
This sends captured terminal output to that service. In a penetration
test that includes, in full and unredacted:
- plaintext passwords, password hashes, tokens and API keys
- usernames, domains, internal hostnames and IP addresses
- file contents, share names and command output
- vulnerability evidence and client-identifying data
There is no redaction option: extracting a secret requires sending it.For client work where that is contractually forbidden:
pentest-recorder init acme --local-onlyThat engagement refuses a remote provider permanently, regardless of what the config later says — and reports the refusal, while capture carries on.
API keys are read from the environment, named by api_key_env in the config.
The key is never written into the config file, engagement data, logs, or
exported Markdown.
Obsidian
Point obsidian.vault_path anywhere and the engagement is projected into
ordinary Markdown — dashboard, hosts, credentials, findings, timeline, and a
note per host, cross-linked with wikilinks. No plugin, and Obsidian itself
is optional — these are text files; cat, grep and git work fine.
# Credentials
| Identity | Secret | Type | Validated on | Source |
|---|---|---|---|---|
| INLANEFREIGHT\fiona | `Summer2026!` | Password | SMB FILE01 | Segment 1 |
| INLANEFREIGHT\svc_backup| `HolyMoly123!` | Password | Not yet | Segment 1 |The vault is a projection, not the store: delete it, re-export, lose
nothing. Generated files carry generated_by: pentest-recorder front matter,
and the exporter never overwrites a file lacking that marker — it writes
alongside and tells you. Your own notes live in Notes/, which is never
touched.
include_secrets is include by default, because accurate credential
tracking is the entire point. Set redact or partial if the vault syncs
somewhere you'd rather it didn't; that affects the projection only.
Configuration
~/.config/pentest-recorder/config.toml — generate it with
pentest-recorder config --write. Every setting has a documented default, and
an invalid value is reported by name with nothing applied.
[data]
root = "~/.local/share/pentest-recorder"
[obsidian]
vault_path = "~/Obsidian/Pentests"
include_secrets = "include" # include | redact | partial
[tmux]
session = "@engagement" # or a glob such as "client-*"
exclude_windows = ["agent"]
[capture]
idle_flush_seconds = 3.0
max_segment_bytes = 65536
rotate_bytes = 134217728
[extraction]
rules = true # derive facts the text determines
redact = true # replace long tokens before prompting
collapse = true # collapse repeated lines
route = true # skip segments carrying nothing new
escalation_model = "" # optional stronger model for hard cases
[llm]
provider = "ollama" # ollama | openai | openai-compatible
model = "qwen2.5-coder:7b"
base_url = "http://127.0.0.1:11434"
api_key_env = "OPENAI_API_KEY" # names the variable, never holds the key
[mcp]
default_engagement = ""Optional shell integration
Adds the exact command, working directory and exit code to segments from your outer shell:
source /path/to/pentest-mcp/shell/pentest-recorder.shCapture works fully without it, and activity inside a nested session stays correctly unattributed rather than guessed.
How it holds together
Four storage classes, each with exactly one rule:
class | tables | rule |
immutable |
| append only; never rewritten |
append-only |
| superseded, never edited |
derived |
| dropped and rebuilt from observations |
stateful |
| yours; derivation never touches them |
Five consequences worth knowing:
A model failure costs nothing. tmux writes pane output to a plain file; the recorder tails it from a checkpointed byte offset. Nothing in the capture path depends on this process running. Recorder dies → bytes keep landing on disk and are picked up on restart. Provider down → segments stay pending.
Every extracted literal is checked against its source. A value that
doesn't appear verbatim is kept but flagged, and surfaced as unverified
through MCP. HolyMoly123! quietly becoming HolyMoly123 is the exact
failure this tool exists to prevent — and plain substring matching cannot
detect it, since the truncation is a substring of the truth.
Merging is emergent. Entities are connected components over identity keys.
Learn on Tuesday that FILE01 is 172.16.119.10, and Monday's two separate
records become one on the next rebuild — no rewriting, no tombstones. Entity
ids are content-addressed, so they survive every rebuild.
A wrong merge is fixable and reversible. Tell the agent; it records a separation that derivation honours — including for links asserted transitively.
reprocess --model <better> is safe. New observations are added, never
substituted, and your findings and tasks are untouched. Capture cheaply and
locally now; improve extraction later.
Measured, not assumed
Three design questions were settled by measurement during the build. Reproduce
with the scripts in bench/.
Search index — trigram, not a tuned word tokenizer. Over 20 representative queries against real tool output:
index | matched |
| 5 / 20 |
| 20 / 20 |
Widening tokenchars enough to hold 172.16.119.10 together also glues
INLANEFREIGHT\fiona:Summer2026! into a single token, so searching for the
password alone finds nothing.
Capture volume — normalization kills noise, not bulk.
workload | raw | normalized | reduction |
progress line rewriting itself | 247,637 | 347 | 714× |
full-screen application | 7,731 | 209 | 37× |
large scrolling dump ( | 178,022 | 173,977 | 1× |
A 4,000-line find is all real content and passes through whole. That is why
local-first extraction is load-bearing rather than merely prudent.
Model — qwen2.5-coder:7b. Scored on whether secrets, hashes and
addresses come back byte-identically:
model | literal recall | kind recall | auth-result errors |
| 83% | 90% | 0 |
| 39% | 60% | 2 |
The 3B model returned nothing at all for both authentication fixtures and got success-vs-failure wrong twice — the one error this system must not make quietly.
MCP tools
Twelve read, three write. Tool-count bloat degrades an agent's ability to pick
the right one, so the counts in get_engagement_summary carry the hints that
would otherwise need a tool each.
read | |
| counts that reveal what to ask next — start here |
| hosts, services, identities, credentials, shares, artifacts |
| find an exact string; read the source it came from |
| trace any fact to its segment, pane, timestamp and byte range |
| what happened, newest first |
| what worked where, and what didn't |
| |
|
write | |
| notes, hypotheses, facts seen outside the terminal, corrections |
| confirm, reject, dismiss |
Writes are always attributed to the operator or the agent — never to extraction, and the caller cannot claim otherwise. No write path can alter a segment.
Development
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest tests/ -q
.venv/bin/ruff check src tests benchCI runs the suite on Python 3.11 through 3.14, installs tmux so the real-tmux acceptance tests actually run rather than skipping themselves, lints, and verifies the wheel builds with its licence metadata intact.
296 tests, including a full acceptance rehearsal against real tmux with a nested reverse shell, a real MCP client over stdio, real concurrent access between the recorder and the server, and an AST check that only the model provider module can reach a network.
tests/test_capture.py capture, segmentation, rotation, pause, restart
tests/test_normalize.py terminal emulation, both implementations
tests/test_extract.py the extraction contract and the verbatim guard
tests/test_derive.py union-find correlation, merges, corrections
tests/test_mcp.py tool surface, bounds, attribution
tests/test_acceptance.py end-to-end rehearsal in real tmux
tests/test_passivity.py proves the recorder never touches a targetDesign decisions, specs and the reasoning behind them live in
openspec/changes/add-pentest-recorder-mcp/.
Scope
Deliberately not built: autonomous pentesting, automatic exploitation, a replacement terminal or VM, a web dashboard, a graph database, multi-user collaboration, network MCP transport, or a parser per tool.
The recorder captures and organizes. The MCP server exposes. The agent reasons. Those stay separate.
Licence
MIT — © 2026 Luciano Engel.
Use it, fork it, ship it. It comes with no warranty, which matters more than usual here: this tool stores plaintext credentials and client data on disk. Read Where your data lives and When data leaves the machine before pointing it at a real engagement.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseNot gradedqualityCmaintenanceAn MCP server for EMBA firmware analysis that exposes structured security findings and tools to LLMs. It enables users to programmatically query, reason over, and correlate firmware analysis results such as kernel details, SBOMs, and attack paths.6
- AlicenseNot gradedqualityDmaintenanceMCP server that enables AI agents to run fully interactive SSH sessions (via tmux) and execute commands like a human operator, with persistent sessions and multiple concurrent connections.6MIT
- AlicenseNot gradedqualityDmaintenanceMCP server that provides programmatic access to the SOLVE-IT digital forensics knowledge base, enabling LLMs to query, navigate, and search forensic techniques, weaknesses, mitigations, objectives, and citations.1MIT
- AlicenseAqualityAmaintenanceRecords your terminal sessions per command (PTY + OSC 133) into local SQLite, so AI agents can search, retrieve, and diff what commands actually printed. Secret redaction is applied by default to everything served over MCP.46MIT
Related MCP Connectors
Search your AI chat history (ChatGPT, Claude, Codex) from any MCP client. Remote, private, read-only
Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.
Agentic search over your Dewey document collections from any MCP-compatible client.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/lucianoengel/pentest-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server