Architecture · MCP

The MCP Remediation Loop

isitsecure ships a local MCP server so the AI coding tool you already use can scan, explain, fix, verify and export without leaving the editor. The server supplies data and verbs. The host LLM supplies the conversation, the plan and the judgment.

7 toolshost LLM applies fixeslocal stdio · per userscan → explain → fix → verify → export

Three actors, one principle

The bar is four needs: scan, converse about the report, plan the fixes, run individual fixes. Only the first and the last are functions. The two in the middle are the host LLM reasoning and talking, and the MCP's job is to make that reasoning grounded instead of guessed.

You in your AI coding tool CursorClaude CodeClaude Desktop plain language Host LLM Claude inside the tool converses about the report decides priority plans the fixes adapts to your skill level edits your files tool calls self-describing data isitsecure MCP local stdio process spawned per user by the tool scan · explain · fix verify · export scan_url · scan_status nothing is hosted needs: pip install isitsecure[mcp] MCP: data + verbs tools, rich payloads, re-scan proof Host LLM: judgment conversation, plan, priority, tone converse()plan() not tools

1You work in Cursor, Claude Code or Claude Desktop and ask about your code in your own words. You never have to name isitsecure.

2The host LLM is the one that converses, prioritises and plans. Of the four needs (scan, converse, plan, run fixes), the two in the middle are its reasoning, not something you call.

3Your tool spawns isitsecure mcp as a local stdio process, one per user. It exposes seven tools and returns typed, self-describing payloads. Nothing runs on a server anywhere.

4The principle: the MCP supplies data + verbs; the host LLM supplies judgment. Building "conversation" or "planning" as tools fights the grain. Instead the scan and fix verbs are made excellent and their payloads rich enough that the LLM never has to guess.

The failure this prevents

Asked "what gets me to a C grade?", an agent once gave a perfect answer, but only because it could read the grader in plain_english.py. A real user's agent, scanning their own repo, has no access to isitsecure internals and would guess the thresholds. The fix was not a "grading tool". It was putting the grade model into the scan payload as path_to_next_grade.

The loop, one turn at a time

A sequence diagram of a real session. Solid arrows are calls, dashed arrows are what comes back. The panel on the right shows the payload at each step, which is what the host LLM reasons over.

You Host LLM isitsecure MCP “is this safe to ship?” scan(path, min_severity) ScanResult · grade F “what gets me to a C?” from path_to_next_grade no tool call explain(scan_id, finding_id) FindingDetail fix(scan_id, finding_id) FixProposal · applied: false host applies the diff writes no files you review, approve, undo verify(scan_id) VerifyResult · F → D export(scan_id, "html") content · host saves the file What the host LLM sees Your words. No tool named. Whether the LLM reaches for scan on its own is decided by metadata the server advertises (next step). Why the LLM picked scan, in order: 1 server instructions 2 tool description 3 tool name in the MCP initialize response: leads with "Security audit…" isitsecure / scan Caveat: the host decides, and some clients gate tools behind approval. scan(path, min_severity="medium") mode is fixed to code-only SAST ScanResult scan_id: "9f2c3a7b1e04" grade: "F" safe_to_launch: false counts: crit 1 · high 3 · med 4 · low 1 path_to_next_grade: D clear ≥1 "0 critical" C clear ≥4 "0 critical, 0 high" C+ clear ≥6 "…at most 2 medium" themes: [{title, severity, finding_count}] findings: [{id, severity, category, file, line, priority, what_it_is, attacker_could, fix}] Plain and technical register, both. Trimmed to min_severity and above. No tool call. The LLM reads the rung: C requires "0 critical, 0 high" clear_at_least: 4 "Fix the 1 critical and the 3 high findings and you are at a C." Same computation as the grader, handed over in the payload, so it never guesses the thresholds. FindingDetail id · severity · category file · line · code_snippet description technical_detail evidence what_it_is · attacker_could business_impact remediation stack_remediation walkthrough: [step, step, …] Leads with this finding's own text (its snippet, its evidence), not the category blurb the scan list carries. FixProposal finding_id · file · title category · severity diff: "--- a/app/api/… +++ b/…" fixed_file: "…whole file…" explanation: "…" applied: false next_step: "Review the diff and apply it with your editor…" Reads the finding's source file from the cached scan path. Needs an LLM key (ANTHROPIC_API_KEY). The MCP does not write files. Why: 1 the host has the edit UX: diff review, apply, undo, approval 2 single writer = coherence: the agent's context matches disk 3 less scary than "the tool rewrote 5 of your files" 4 applied with understanding: imports and style adapted to fit The diff is a reference implementation, not a mandate. VerifyResult previous_grade: "F" grade: "D" grade_improved: true resolved: ["SQL injection in …"] still_present_count: 3 unverifiable_count: 2 scan_id: "b7e15d20c9a3" LLM / DAST findings, held constant a fresh id so the loop continues Only rule-based findings count toward cleared and toward the grade move, so LLM non-determinism cannot inflate or deflate the result. ExportResult format: "html" content: "<!doctype html>…" suggested_filename: "isitsecure-report.html" next_step: "Save this with your editor (the MCP does not write)" Formats: html · sarif · json · markdown Rendered from the cache, no re-scan. SARIF feeds GitHub code scanning.

1You ask "is this safe to ship?" in your editor. isitsecure is not mentioned anywhere in the question.

2The host LLM reaches for scan(path, min_severity) on its own. Discoverability comes from three things, in priority order: the server instructions in the initialize response, the tool description that leads with the trigger phrases, and the tool name. It maximises the odds; it cannot guarantee the call.

3scan returns a typed ScanResult: a scan_id, the grade, the launch verdict, severity counts, path_to_next_grade, root-cause themes and trimmed findings with a plain-English explanation and a priority each.

4You ask "what gets me to a C?". No tool runs. The LLM reads the rung for C from the payload (requires 0 critical, 0 high; clear at least 4) and answers from the grade model instead of guessing.

5explain(scan_id, finding_id) resolves the finding from the scan cache and returns a deep dive: its own description, technical detail, evidence and vulnerable snippet, then business impact, stack-tailored remediation and a step-by-step walkthrough.

6fix(scan_id, finding_id) reads the finding's source file and returns a proposed patch: a unified diff, the full fixed_file, an explanation, and applied: false.

7The host applies it with its own editor, inside your normal review flow. The MCP writes no files by default; the four reasons are in the panel.

8verify(scan_id) re-scans the same path and reports what cleared, what is still there, the grade before and after, and a fresh scan_id. Only rule-based findings are verifiable; LLM and DAST findings are reported as unverifiable and held constant.

9export(scan_id, format) renders the cached scan as html, sarif, json or markdown and returns the content. The host writes the file, same stance as fix.

Who applies the fix

Mutating the user's code is the riskiest capability here, so the decision is explicit. In the MCP context the host LLM holds the pen, and the quality gate is verify, not the act of applying.

Quality comes from verify, not from applying

Whoever writes the file, verify(scan_id) re-scans and confirms the finding is actually gone for rule-based findings, and reports grade movement attributable to that change. This decouples the quality gate from who does the writing, which is what makes "let the host apply" safe. Success is never assumed.

apply=true: proposed, not built

For headless callers, or a user who explicitly wants the tool to do it, fix(…, apply=true) would write through the same safety-net pipeline the CLI uses: back up the working tree, write one finding's fix, re-scan. Still one finding at a time, never "fix everything" over MCP. This fallback is still a proposal in docs/mcp.md.

Live-URL scans: a job handle, not a long call

A DAST scan of a running app takes minutes, far longer than a tool call should block. So scan_url starts the scan in the background and returns a job id at once; scan_status is polled until the result is ready.

Host LLMkeeps the conversationgoing while it polls isitsecure MCP background DAST job job_id: "3fa9c0d1e2b7" status: running status: done progress 0.0 progress 0.25 progress 0.7 progress 1.0 result: same shape as scan, with a scan_id for explain / export scan_url("http://localhost:3000") returns immediately: job_id, status "running" scan_status(job_id) running · progress 0.25 · result: null scan_status(job_id) running · progress 0.7 scan_status(job_id) done · progress 1.0 · result: {grade, findings, path_to_next_grade, …}

1scan_url(url) validates that the URL is http(s), starts a url-only DAST scan as a background task and returns a job_id immediately. The URL must be reachable from the machine running the server.

2The host polls scan_status(job_id). It gets running, a 0 to 1 progress, a message and result: null.

3Still running. Finished jobs are the only ones evicted when the job table is over its cap, so a running scan's handle never vanishes mid-flight.

4done. The full result has the same shape as scan, and its scan_id works with explain and export. Authenticated crawls and full SAST+DAST runs stay CLI-side for now.

Stable finding ids across turns

"Explain the SQLi one" and "fix #3" only work if a finding from an earlier turn can still be resolved. The server keeps the full report of each scan in an in-process cache keyed by scan_id; every later tool resolves its finding from there.

Conversation turns turn 1 scan(".")→ scan_id 9f2c…, findings with ids turn 4 "fix #3"→ fix("9f2c…", finding_id) turn 6 verify("9f2c…")→ re-scan cached as b7e1… turn 40 scan #21→ the oldest entry is evicted MCP server process _SCAN_CACHE: OrderedDict, max 20 scans 9f2c… → full report + repo_path b7e1… → re-scan report + repo_path … 18 more, newest last get_finding(scan_id, finding_id) resolves the finding and the path it was scanned from, so fix can read the file in-process only: gone on restart Why ids must be stable the scan list returns trimmed findings; the cache keeps the full report, so explain, fix and verify see everything the scan saw, turns later

1scan stores the full report under a fresh 12-character scan_id together with the path that was scanned. The findings it returns carry ids that stay valid for the session.

2Turns later you say "fix #3". get_finding(scan_id, finding_id) resolves the finding from the cached report, and the cached path lets fix read the actual source file. Every later tool builds on this one lookup.

3verify caches its re-scan as a new entry and returns the new scan_id, so the next fix targets the current state of the code.

4The cache is bounded to the 20 most recent scans (oldest evicted first) and lives only in the server process. On-disk persistence under ~/.isitsecure/scans/ is a possible later add; in-process is enough for one session's loop.

Why the CLI applies and the MCP does not

Same fix generator, opposite right answer. The divergence is intentional and recorded so it is not mistaken for an inconsistency.

FixGeneratorfinding + full file→ fixed file + diff isitsecure fix --repo . (CLI) no host LLMnobody else can write safety-net backupsnapshot the working tree write the fixone finding at a time re-scan to verifyfixed / needs review / couldn't fix fix(scan_id, finding_id) (MCP) host LLM presenta capable writer exists return the diffapplied: false host applies itits editor, its undo verify(scan_id)re-scan, grade before → after

1Both paths start from the same FixGenerator: the finding plus its full source file go to the LLM, and a fixed file and unified diff come back.

2The CLI has no host LLM, so it must apply the fix itself. It snapshots the working tree as a safety net, writes one finding's fix, then re-scans to confirm the finding is gone.

3The MCP always has a capable agent, so it defers the write. It returns the diff with applied: false, the host applies it in your review flow, and verify closes the loop with the same re-scan proof.

The tool surface

Seven tools, as registered in isitsecure/mcp_server.py. Every tool after scan takes a scan_id, which is what makes the conversation stateful.

ToolParametersReturnsWho writes
scanpath, min_severity="medium"scan_id, grade, launch verdict, counts, path_to_next_grade, themes, trimmed findingsnobody (code-only SAST, read only)
explainscan_id, finding_idthe finding's own description, technical detail, evidence, snippet; business impact; remediation; walkthroughnobody
fixscan_id, finding_idunified diff, fixed_file, explanation, applied: falsethe host LLM
verifyscan_idresolved / still present / unverifiable, grade before → after, fresh scan_idnobody (re-scan only)
exportscan_id, format="markdown"content as html, sarif, json or markdown, suggested filenamethe host LLM
scan_urlurl, min_severity="medium"job_id, status "running"nobody
scan_statusjob_idstatus running | done | error, progress 0–1, result once donenobody
Design doc versus code

docs/mcp.md now lists scan(path, min_severity), matching the registered tool: there is no mode argument and the scan is always code-only. The apply=true fallback on fix is still only a proposal in the doc and is not implemented.

Open questions from the design doc

Still being argued in docs/mcp.md
  • Scan-cache lifetime. In-process only (simplest, lost on restart) or on disk under ~/.isitsecure/scans/ (survives, but needs cleanup and expiry).
  • verify cost. A full re-scan with LLM review is slow. Could verification be scoped to changed files, or run rule-based only for speed?
  • Multi-file fixes. Some findings span files (adding RLS across tables). How does fix present and apply those safely under the one-finding-at-a-time rule?
  • Grade-path shape. The exact schema of path_to_next_grade that the LLM reads to answer "what gets me to a C?".
Out of scope for now

No hosted or multi-user MCP: this is a local stdio server spawned per user by their own tool. No authenticated or full DAST over MCP: scan_url is url-only; authenticated crawls and SAST+DAST runs stay on the CLI.