Guide · Security Scanning
MCP Tools for Semgrep — SAST patterns, finding confidence, and autofix integration
Semgrep finds security issues differently from CVE scanners: instead of matching package versions against a vulnerability database, it matches code patterns against rules that encode security anti-patterns — SQL queries built via string concatenation, hardcoded credentials, missing authentication middleware, insecure deserialization. When you build an MCP tool that runs Semgrep — scanning source code for OWASP Top 10 violations, checking changed files in a pull request, or auditing a specific module for secrets — three distinctions determine whether you surface signal or noise: severity is INFO/WARNING/ERROR, not CRITICAL/HIGH/MEDIUM/LOW (these map differently and must never be conflated with CVSS scores), the metadata.confidence field (HIGH confidence rules have near-zero false-positive rate; LOW confidence rules find speculative patterns that require manual triage before action), and check_id is the right deduplication and grouping key (the same rule fires per line, so a loop with 10 iterations of an unsafe pattern produces 10 findings — group by check_id to give developers actionable counts rather than a flat list of thousands).
TL;DR
Semgrep exits with code 1 when findings are found and code 2 when an error occurs — check status !== 2 for the "scan ran successfully" condition, not status === 0. Group results by check_id before presenting them, not by individual line number — a single SQL injection pattern repeated in a loop produces dozens of identical findings from one conceptual issue. Filter out metadata.confidence === 'LOW' findings entirely in automated contexts — their false-positive rate makes automated action on them counterproductive. Do not map ERROR/WARNING/INFO onto CRITICAL/HIGH/LOW — these severity scales measure different things.
Running Semgrep via subprocess: key flags and exit codes
Semgrep is a Python-based CLI tool installed via pip or the official installer. Like Trivy, it runs as a subprocess in an MCP tool context. The key difference from most CLI tools is the exit code convention: Semgrep exits with code 0 when the scan completes with no findings, code 1 when the scan completes and findings were found, and code 2 when an actual error occurred (invalid config, parse failure, network timeout downloading rules). Your subprocess wrapper must treat exit codes 0 and 1 as successful scan completions and only treat code 2 as an error condition.
The --json flag writes the full results to stdout. Unlike Trivy, Semgrep's JSON output is reliably sized for pipe consumption even on large codebases — it compresses repeated rule metadata and the output size scales with finding count rather than file count. The --quiet flag suppresses the progress banner and version notice that Semgrep writes to stderr by default. Without it, stderr contains text that can confuse log parsers.
The --config auto option is Semgrep's rule auto-selection mode: it detects languages present in the target directory and downloads the corresponding community registry rules. This is the right default for MCP tools where the codebase language is not known in advance. For targeted scans, pass specific rulesets via --config p/owasp-top-ten or a local rule file. Multiple --config flags are additive.
import { spawnSync } from 'node:child_process';
function runSemgrep(targetPath, config = 'auto', options = {}) {
const args = [
'scan',
'--json',
'--quiet',
];
// config can be 'auto', 'p/owasp-top-ten', 'p/javascript.lang.security',
// a local path './rules/custom.yaml', or an array for multiple configs
const configs = Array.isArray(config) ? config : [config];
for (const c of configs) {
args.push('--config', c);
}
if (options.include) {
// e.g. '*.js,*.ts' — filter by file extension
args.push('--include', options.include);
}
if (options.excludeRule) {
// suppress a specific rule by ID
args.push('--exclude-rule', options.excludeRule);
}
if (options.noGitIgnore) {
// scan files that .gitignore would exclude
args.push('--no-git-ignore');
}
if (options.maxFilesSizeKb) {
args.push('--max-target-bytes', String(options.maxFilesSizeKb * 1024));
}
args.push(targetPath);
const result = spawnSync('semgrep', args, {
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024, // 50MB — semgrep JSON can be large
timeout: 300_000, // 5 minutes for large codebases
});
// Exit code 2 = actual error; 0 = clean; 1 = findings found — all three
// indicate the scan ran; only 2 indicates a scan failure
if (result.status === 2 || result.error) {
const msg = result.stderr || result.error?.message || 'unknown semgrep error';
throw new Error(`Semgrep scan failed (exit 2): ${msg}`);
}
let parsed;
try {
parsed = JSON.parse(result.stdout);
} catch (e) {
throw new Error(`Failed to parse Semgrep JSON output: ${e.message}\nstdout: ${result.stdout?.slice(0, 500)}`);
}
return {
results: parsed.results ?? [],
errors: parsed.errors ?? [],
paths: {
scanned: parsed.paths?.scanned ?? [],
ignored: parsed.paths?._comment ? [] : (parsed.paths?.ignored ?? []),
},
version: parsed.version,
foundFindings: result.status === 1,
};
}
Parsing results: check_id, path, location, severity, confidence
Each entry in the Semgrep results array represents one location in source code where a rule matched. The check_id field identifies the rule — for example, javascript.lang.security.audit.sqli.node-mssql-sqli.node-mssql-sqli or python.django.security.django-no-csrf-token.django-no-csrf-token. The path field is the file path relative to the scan root. The start and end objects each contain line, col, and offset fields — all three are available for precise location reporting.
Most of the actionable metadata lives in the extra object. extra.message is the human-readable explanation of what the rule found and why it matters — this is the text you should show to developers, not the rule ID. extra.severity is ERROR, WARNING, or INFO. extra.metadata contains the structured data attached to the rule: confidence (HIGH/MEDIUM/LOW), cwe (array of CWE IDs), owasp (array of OWASP categories), category (security/correctness/performance/maintainability), and technology. extra.lines contains the actual source code that matched the pattern — the literal snippet from the file, which is essential for developer review.
Group findings by check_id before presenting them. A single vulnerable pattern — for example, a SQL query built via string concatenation — may appear in dozens of places across a codebase. Presenting each as a separate top-level finding creates noise and hides the actual scope of the problem. Grouping by check_id gives you a count of occurrences per rule, which is the signal developers need to prioritize remediation effort.
function parseFindings(semgrepOutput) {
const { results } = semgrepOutput;
// Group by check_id — each unique rule that fired
const byRule = new Map();
for (const finding of results) {
const ruleId = finding.check_id;
if (!byRule.has(ruleId)) {
byRule.set(ruleId, {
checkId: ruleId,
severity: finding.extra?.severity ?? 'INFO',
confidence: finding.extra?.metadata?.confidence ?? 'LOW',
category: finding.extra?.metadata?.category ?? 'security',
cwe: finding.extra?.metadata?.cwe ?? [],
owasp: finding.extra?.metadata?.owasp ?? [],
message: finding.extra?.message ?? '',
hasAutofix: Boolean(finding.extra?.fix || finding.extra?.fix_regex),
occurrences: [],
});
}
byRule.get(ruleId).occurrences.push({
path: finding.path,
startLine: finding.start?.line,
endLine: finding.end?.line,
startCol: finding.start?.col,
lines: finding.extra?.lines ?? '', // the matching source snippet
fix: finding.extra?.fix ?? null, // autofix replacement text
fixRegex: finding.extra?.fix_regex ?? null, // regex-based autofix
});
}
// Convert to sorted array: ERROR first, HIGH confidence first within severity
const SEVERITY_RANK = { ERROR: 3, WARNING: 2, INFO: 1 };
const CONFIDENCE_RANK = { HIGH: 3, MEDIUM: 2, LOW: 1 };
return Array.from(byRule.values()).sort((a, b) => {
const severityDiff = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
if (severityDiff !== 0) return severityDiff;
const confDiff = (CONFIDENCE_RANK[b.confidence] ?? 0) - (CONFIDENCE_RANK[a.confidence] ?? 0);
if (confDiff !== 0) return confDiff;
// More occurrences = higher priority within same severity+confidence
return b.occurrences.length - a.occurrences.length;
});
}
Severity model: ERROR/WARNING/INFO vs CVSS
Semgrep's three-level severity scale measures something fundamentally different from CVSS scores. CVSS measures the potential impact of a known vulnerability — how easily it can be exploited, what access it grants, whether it affects confidentiality or integrity. Semgrep's severity measures the rule author's confidence that the matched pattern represents a problem requiring action: ERROR means the pattern is almost certainly a security issue (string concatenation into a SQL query, eval() on user-controlled input, hardcoded AWS credentials), WARNING means the pattern is often problematic but depends on context (missing Content-Security-Policy header, use of MD5 for any purpose), and INFO means the match is worth awareness but not necessarily a defect (use of console.log with a variable that might contain PII, use of a deprecated but not dangerous API).
Never translate Semgrep severity to CVSS language in an MCP tool output. Do not say "this ERROR is a CRITICAL finding" or "this WARNING is HIGH severity" — the scales are incompatible and the translation misleads developers about what the finding means. Use Semgrep's own terminology when presenting results, and educate users that ERROR means "fix this now" rather than "CVSS 9.0".
The metadata.confidence field is a more useful signal than severity alone for automated tooling decisions. HIGH confidence rules have near-zero false-positive rates — they match patterns that are essentially always wrong. MEDIUM confidence rules may have false positives in 5–20% of cases. LOW confidence rules use speculative patterns where false positives can exceed 50%. Automated actions (blocking CI, creating tickets automatically, sending alerts) should only be triggered by HIGH or MEDIUM confidence findings at ERROR severity.
function severityToActionPriority(severity, confidence) {
// Returns: 'block' | 'warn' | 'inform' | 'suppress'
// 'block' = fail CI, require immediate action
// 'warn' = surface prominently, do not block
// 'inform' = include in full report, not highlighted
// 'suppress' = do not show in automated reports at all
if (severity === 'ERROR' && confidence === 'HIGH') return 'block';
if (severity === 'ERROR' && confidence === 'MEDIUM') return 'block';
if (severity === 'ERROR' && confidence === 'LOW') return 'warn';
if (severity === 'WARNING' && confidence === 'HIGH') return 'warn';
if (severity === 'WARNING' && confidence === 'MEDIUM') return 'warn';
if (severity === 'WARNING' && confidence === 'LOW') return 'inform';
if (severity === 'INFO' && confidence === 'HIGH') return 'inform';
// INFO + MEDIUM or LOW confidence = high false-positive rate
// Suppress from automated contexts; include only in full audit reports
return 'suppress';
}
// Example: build a CI report that only surfaces actionable findings
function buildCIReport(parsedFindings) {
const actionable = parsedFindings.filter((rule) => {
const action = severityToActionPriority(rule.severity, rule.confidence);
return action === 'block' || action === 'warn';
});
const blocking = actionable.filter(
(r) => severityToActionPriority(r.severity, r.confidence) === 'block'
);
return {
shouldFail: blocking.length > 0,
blockingRules: blocking.map((r) => ({
checkId: r.checkId,
occurrenceCount: r.occurrences.length,
files: [...new Set(r.occurrences.map((o) => o.path))],
message: r.message,
cwe: r.cwe,
autofixAvailable: r.hasAutofix,
})),
warningRules: actionable
.filter((r) => severityToActionPriority(r.severity, r.confidence) === 'warn')
.map((r) => ({
checkId: r.checkId,
occurrenceCount: r.occurrences.length,
message: r.message,
})),
suppressedCount: parsedFindings.length - actionable.length,
};
}
Rule configuration: --config options for common scenarios
Semgrep's rule ecosystem is organized into rulesets identified by the p/ prefix for community rulesets and specific language paths for language-level rules. The choice of ruleset determines both the coverage and the signal-to-noise ratio of a scan. The auto config is a good starting point for unknown codebases because Semgrep detects the languages present and downloads appropriate rules automatically — but it may include rules from categories other than security (correctness, performance) that require different triage.
For MCP security tools, the most actionable rulesets are language-specific security rules. p/javascript.lang.security covers XSS, prototype pollution, regex denial of service, and insecure use of eval. p/nodejs.lang.security adds Node.js-specific patterns: path traversal via path.join with user input, command injection via child_process.exec, and SSRF via http.get with user-controlled URLs. These language-specific rulesets have lower false-positive rates than broad security rulesets because the patterns are more precisely scoped to the language's idioms.
// Common config combinations for different scanning scenarios
const SCAN_CONFIGS = {
// Web application security audit — OWASP Top 10 + language-specific
webSecurity: [
'p/owasp-top-ten',
'p/javascript.lang.security',
'p/nodejs.lang.security',
],
// Secrets detection — hardcoded credentials, API keys, tokens
secrets: ['p/secrets'],
// Full security audit — comprehensive but higher noise
fullAudit: [
'p/r2c-security-audit',
'p/owasp-top-ten',
'p/javascript.lang.security',
],
// Infrastructure as code — Dockerfile, Terraform, k8s YAML
iac: [
'p/dockerfile',
'p/terraform',
'p/kubernetes',
],
// Pull request scan — fast, high-signal-only
pr: [
'p/javascript.lang.security',
'p/secrets',
],
};
// Build args for a given scenario
function buildSemgrepArgs(scenario, targetPath, options = {}) {
const configs = SCAN_CONFIGS[scenario] ?? ['auto'];
const args = ['scan', '--json', '--quiet'];
for (const c of configs) {
args.push('--config', c);
}
// Optionally add custom rules on top of the scenario preset
if (options.customRulesPath) {
args.push('--config', options.customRulesPath);
}
// Exclude specific rules that produce too much noise in this project
for (const ruleId of (options.excludeRules ?? [])) {
args.push('--exclude-rule', ruleId);
}
args.push(targetPath);
return args;
}
SARIF output for GitHub Code Scanning
SARIF (Static Analysis Results Interchange Format) is the standard format for uploading static analysis findings to GitHub Code Scanning. When Semgrep findings are uploaded as SARIF, they appear as inline annotations on pull request diffs in the GitHub UI — developers see the specific line flagged, the rule message, and any associated CWE or OWASP categories, without leaving the PR review interface. This integration significantly reduces the friction between finding identification and developer action.
Semgrep produces SARIF output via --sarif instead of --json. The SARIF file is then uploaded using the github/codeql-action/upload-sarif GitHub Actions step. Semgrep also supports direct upload to GitHub's Code Scanning API via --github-api-token combined with the semgrep ci command, which automatically detects PR context from CI environment variables and uploads results after scanning. For non-GitHub contexts — GitLab, Bitbucket, or standalone CI systems — SARIF can be parsed directly: the key fields are in runs[0].results[], with ruleId, message.text, and the location in locations[0].physicalLocation.
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
function runSemgrepSARIF(targetPath, config, outputPath) {
const args = [
'scan',
'--sarif',
'--output', outputPath,
'--quiet',
'--config', config,
targetPath,
];
const result = spawnSync('semgrep', args, {
encoding: 'utf8',
timeout: 300_000,
});
// Exit code 1 = findings found (not an error)
if (result.status === 2 || result.error) {
throw new Error(`Semgrep SARIF scan failed: ${result.stderr}`);
}
return { foundFindings: result.status === 1 };
}
// Parse SARIF for non-GitHub contexts
function parseSARIF(sarifFilePath) {
const sarif = JSON.parse(readFileSync(sarifFilePath, 'utf8'));
const run = sarif.runs?.[0];
if (!run) return [];
// Build a rule ID -> rule metadata map for efficient lookup
const rules = new Map(
(run.tool?.driver?.rules ?? []).map((r) => [r.id, r])
);
return (run.results ?? []).map((finding) => {
const rule = rules.get(finding.ruleId) ?? {};
const location = finding.locations?.[0]?.physicalLocation;
return {
ruleId: finding.ruleId,
message: finding.message?.text ?? '',
// Rule properties from the rules map
ruleDescription: rule.shortDescription?.text ?? '',
severity: rule.properties?.severity ?? 'WARNING',
confidence: rule.properties?.confidence ?? 'MEDIUM',
cwe: rule.properties?.cwe ?? [],
// Source location
filePath: location?.artifactLocation?.uri ?? '',
startLine: location?.region?.startLine ?? 0,
startColumn: location?.region?.startColumn ?? 0,
endLine: location?.region?.endLine ?? 0,
// Optional code snippet
snippet: location?.region?.snippet?.text ?? '',
};
});
}
// GitHub Actions workflow snippet for SARIF upload:
// - name: Run Semgrep
// run: semgrep scan --sarif --output semgrep.sarif --config p/owasp-top-ten ./src
//
// - name: Upload SARIF
// uses: github/codeql-action/upload-sarif@v3
// with:
// sarif_file: semgrep.sarif
// category: semgrep
Autofix: when extra.fix is present
A subset of Semgrep rules include autofix suggestions — the rule author has encoded not just the pattern to detect but also the correct replacement. The autofix is present in the finding as extra.fix (a string replacement) or extra.fix_regex (an object with regex and replacement keys for pattern-based replacement). Not all rules provide autofixes; autofix is only appropriate when the fix is deterministic — when there is exactly one correct way to write the code. Rules for SQL injection via string concatenation typically have autofixes (use a parameterized query placeholder), while rules for architectural decisions (missing authentication middleware) do not.
Autofixes can be applied in batch via the --autofix flag: semgrep scan --config p/javascript.lang.security --autofix ./src/. This applies all available autofixes in-place. In an MCP tool, the safe pattern is to collect autofixable findings, present them to the developer for review with the proposed fix text visible, and only invoke --autofix after explicit approval. Applying autofixes without review is risky because autofixes modify source files and the diff may not always be what the developer expects for their specific codebase context.
function getAutofixableFindings(parsedResults) {
// parsedResults is the raw results array from runSemgrep(), not the grouped output
return parsedResults.results
.filter((finding) => {
return Boolean(finding.extra?.fix || finding.extra?.fix_regex);
})
.map((finding) => ({
checkId: finding.check_id,
path: finding.path,
startLine: finding.start?.line,
endLine: finding.end?.line,
// The code that currently exists at this location
currentCode: finding.extra?.lines ?? '',
// The proposed replacement
fixType: finding.extra?.fix ? 'replacement' : 'regex',
fix: finding.extra?.fix ?? null,
fixRegex: finding.extra?.fix_regex ?? null,
// Show the diff to the developer for approval
proposedChange: finding.extra?.fix
? `Replace with: ${finding.extra.fix}`
: `Apply regex: ${JSON.stringify(finding.extra?.fix_regex)}`,
message: finding.extra?.message ?? '',
severity: finding.extra?.severity ?? 'INFO',
confidence: finding.extra?.metadata?.confidence ?? 'LOW',
}));
}
// Apply approved autofixes by running semgrep --autofix on specific files
function applyAutofixes(targetFiles, config = 'auto') {
const args = [
'scan',
'--autofix',
'--quiet',
'--config', config,
// Only scan the specific files we got approval for
...targetFiles,
];
const result = spawnSync('semgrep', args, {
encoding: 'utf8',
timeout: 120_000,
});
if (result.status === 2 || result.error) {
throw new Error(`Semgrep autofix failed: ${result.stderr}`);
}
return {
success: true,
// Exit 0 = no more findings after fix; exit 1 = findings remain
allFixed: result.status === 0,
};
}
Frequently asked questions
How is Semgrep different from ESLint security plugins?
ESLint operates on the JavaScript/TypeScript AST and is limited to syntactic patterns within a single file. ESLint security plugins can detect patterns like eval(userInput) within a function, but they cannot trace a value from an HTTP request handler through multiple function calls to a SQL query builder in a different module — that cross-function, cross-file analysis requires taint tracking. Semgrep supports taint tracking via its mode: taint rule type, which can follow user-controlled data from source (e.g., req.body.query) to sink (e.g., db.query()) across module boundaries. Semgrep also covers non-JavaScript files in the same tool — Dockerfiles, Terraform, Python, Go, and Kubernetes YAML — while ESLint is limited to JS/TS. For security scanning, Semgrep's cross-file taint analysis and multi-language coverage make it more comprehensive; for code quality and style, ESLint's deep JS ecosystem integration makes it the better choice.
What should I do with LOW confidence findings in an MCP tool?
Suppress LOW confidence findings from all automated actions: CI gating, ticket creation, and alert notifications. LOW confidence rules use speculative patterns where the false-positive rate can exceed 50% — blocking CI on these findings causes developer fatigue and trains teams to ignore the tool. In MCP tools, expose LOW confidence findings only in explicit "full audit" mode where a human is actively reviewing output and can evaluate each finding in context. Always display the confidence level prominently alongside the finding so developers understand why a finding may or may not be a real issue. Never batch-apply autofixes for LOW confidence findings even when autofixes are available — the high false-positive rate means the autofix may change correct code.
Why does Semgrep exit with code 1 even when it scanned normally and found issues?
Semgrep follows the convention used by diff and grep: exit code 0 means "ran successfully, found nothing", exit code 1 means "ran successfully, found something", and exit code 2 means "encountered an error and may not have produced valid output". This is counter-intuitive compared to most CLI tools where non-zero exit codes indicate failure. Your subprocess wrapper must check result.status !== 2 (or result.status !== null && result.status !== 2 to also catch signal termination) as the "scan ran successfully" condition, and separately check results.length === 0 as the "no issues found" condition. Treating exit code 1 as an error and throwing an exception will cause your MCP tool to crash on every scan that finds issues — which in a typical codebase scan is every scan.
How do I scan only changed files in a PR with Semgrep?
Use git diff --name-only origin/main...HEAD to get the list of changed files, filter to source code extensions, and pass them explicitly to Semgrep: semgrep scan --config auto file1.js file2.ts file3.py. Semgrep accepts individual file paths as positional arguments and scans only those files. This approach reduces scan time on large codebases from minutes to seconds for typical PRs that change 5–20 files. Alternatively, use semgrep ci — the CI-aware subcommand that reads PR context from environment variables (GITHUB_BASE_REF, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, and equivalents for other CI platforms) and automatically computes the changed file set. semgrep ci also handles SARIF upload and PR comment posting natively for supported platforms, reducing the workflow configuration required.
Further reading
- MCP Tools for Snyk — REST API, vulnerability severity, and fix prioritization
- MCP Tools for Trivy — container image scanning, multi-class results, and VEX suppression
- MCP Tools for Checkov — IaC security scanning, check IDs, and multi-framework output
- MCP Tools for Grype — SBOM-first vulnerability scanning and fix state
- MCP Tools for GitHub Actions — build, test, deploy, and verify
- MCP Server Error Handling — retry logic, circuit breakers, and structured errors
- MCP Server Health Checks — liveness, readiness, and protocol probes