Skip to content

Commit 505596b

Browse files
h0tak88rclaude
andcommitted
feat: add MCP discovery scanner for domain/subdomain workflows
Probes discovered hosts for exposed MCP (Model Context Protocol) servers by checking common endpoints (/sse, /mcp, /message, etc.), verifies with JSON-RPC initialize, enumerates tools/resources/prompts, and flags unauthenticated or dangerous servers. Integrated as a workflow phase in both domain_run and subdomain_run. Supports standalone scan via POST /mcp-discovery API and rescan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 84de772 commit 505596b

9 files changed

Lines changed: 627 additions & 5 deletions

File tree

internal/api/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ func SetupAPI() *gin.Engine {
573573
api.POST("/dns-takeover", scanDNSTakeover)
574574
api.POST("/dns", scanDNS) // New unified DNS endpoint (supports takeover and dangling-ip)
575575
api.POST("/dns-cf1016", scanDNSCF1016) // Cloudflare 1016 dangling DNS scan
576+
api.POST("/mcp-discovery", scanMCPDiscovery)
576577
api.POST("/s3", scanS3)
577578
api.POST("/js-endpoints", scanJSEndpoints)
578579
api.POST("/github", scanGitHub)
@@ -1476,6 +1477,7 @@ func executeScan(scanID string, command []string, scanType string) {
14761477
if initialTotalPhases == 0 {
14771478
scanLabel := map[string]string{
14781479
"dns_cf1016": "CF1016 Dangling DNS", "dns-cf1016": "CF1016 Dangling DNS",
1480+
"mcp-discovery": "MCP Discovery",
14791481
"misconfig": "Misconfiguration", "s3": "S3 Bucket",
14801482
"github": "GitHub Recon", "github_org": "GitHub Org Recon",
14811483
"dns-takeover": "DNS Takeover", "dns-dangling-ip": "Dangling IP",

internal/api/scan_handlers.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
backupmod "github.com/h0tak88r/AutoAR/internal/scanner/backup"
2323
cf1016mod "github.com/h0tak88r/AutoAR/internal/scanner/cf1016"
2424
cnamesmod "github.com/h0tak88r/AutoAR/internal/scanner/cnames"
25+
"github.com/h0tak88r/AutoAR/internal/scanner/mcpdiscovery"
2526
dnsmod "github.com/h0tak88r/AutoAR/internal/scanner/dns"
2627
domainmod "github.com/h0tak88r/AutoAR/internal/scanner/domain"
2728
ffufmod "github.com/h0tak88r/AutoAR/internal/scanner/ffuf"
@@ -406,6 +407,29 @@ func scanDNS(c *gin.Context) {
406407

407408
// ── DNS CF1016 ────────────────────────────────────────────────────────────────
408409

410+
func scanMCPDiscovery(c *gin.Context) {
411+
var req ScanRequest
412+
if !bindOrBad(c, &req) {
413+
return
414+
}
415+
target := ""
416+
if req.Domain != nil && *req.Domain != "" {
417+
target = *req.Domain
418+
} else if req.Subdomain != nil && *req.Subdomain != "" {
419+
target = *req.Subdomain
420+
}
421+
if target == "" {
422+
c.JSON(http.StatusBadRequest, gin.H{"error": "domain or subdomain is required"})
423+
return
424+
}
425+
scanID := generateScanID()
426+
go RunScanInProcess(scanID, "mcp-discovery", target, func() error {
427+
_, err := mcpdiscovery.Run(mcpdiscovery.Options{Target: target, Threads: 15})
428+
return err
429+
})
430+
okStarted(c, scanID, fmt.Sprintf("MCP discovery scan started for %s", target))
431+
}
432+
409433
func scanDNSCF1016(c *gin.Context) {
410434
var req ScanRequest
411435
if !bindOrBad(c, &req) {
@@ -798,6 +822,13 @@ func runInProcessRescan(scanType, target string) (newScanID string, ok bool) {
798822
return err
799823
})
800824
return newScanID, true
825+
case "mcp-discovery":
826+
newScanID = generateScanID()
827+
go RunScanInProcess(newScanID, "mcp-discovery", target, func() error {
828+
_, err := mcpdiscovery.Run(mcpdiscovery.Options{Target: target, Threads: 15})
829+
return err
830+
})
831+
ok = true
801832
case "dns_cf1016", "dns-cf1016":
802833
go RunScanInProcess(newScanID, "dns_cf1016", target, func() error {
803834
clean := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")

internal/api/scan_results_api.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ var subdomainWorkflowPhaseSpecs = []workflowPhaseSpec{
180180
{Module: "s3-scan", Description: "[Stage 2] S3 bucket enumeration and scanning", PhaseKey: "s3"},
181181
{Module: "backup-detection", Description: "[Stage 2] Backup scan", PhaseKey: "backup"},
182182
{Module: "zerodays", Description: "[Stage 2] Zerodays scan", PhaseKey: "zerodays"},
183+
{Module: "mcp-discovery", Description: "[Stage 2] MCP server discovery", PhaseKey: "mcp-discovery"},
183184
{Module: "wordpress-confusion", Description: "[Stage 2] WordPress confusion", PhaseKey: "wp_confusion"},
184185
{Module: "dependency-confusion", Description: "[Stage 2] Dependency confusion", PhaseKey: "depconfusion"},
185186
{Module: "misconfig", Description: "[Stage 2] Misconfig scan", PhaseKey: "misconfig"},
@@ -201,6 +202,7 @@ var domainWorkflowPhaseSpecs = []workflowPhaseSpec{
201202
{Module: "url-collection", Description: "URL collection", PhaseKey: "urls"},
202203
{Module: "js-analysis", Description: "JavaScript scan", PhaseKey: "jsscan"},
203204
{Module: "dns-takeover", Description: "DNS takeover scan", PhaseKey: "dns"},
205+
{Module: "mcp-discovery", Description: "MCP server discovery", PhaseKey: "mcp-discovery"},
204206
{Module: "aem", Description: "AEM webapp discovery and scan", PhaseKey: "aem"},
205207
{Module: "wordpress-confusion", Description: "WordPress confusion scan", PhaseKey: "wp_confusion"},
206208
{Module: "dependency-confusion", Description: "Dependency confusion scan", PhaseKey: "depconfusion"},
@@ -392,6 +394,9 @@ func inferModuleFromFileName(name string) string {
392394
// katana crawler results — separate from general URL collection
393395
case strings.Contains(n, "katana"):
394396
return "katana"
397+
// MCP discovery
398+
case strings.Contains(n, "mcp-server") || strings.Contains(n, "mcp_discovery"):
399+
return "mcp-discovery"
395400
// js-endpoints: API path extraction results from JS files
396401
case strings.Contains(n, "js-endpoint"):
397402
return "js-endpoints"
@@ -457,7 +462,8 @@ func inferCategoryFromFileName(name string) string {
457462
strings.Contains(n, "aws-") || strings.Contains(n, "azure-") || strings.Contains(n, "gcp-") ||
458463
strings.Contains(n, "takeover") || strings.Contains(n, "dangling") || strings.Contains(n, "dnsreap") ||
459464
strings.Contains(n, "confusion") || strings.Contains(n, "depconf") || strings.Contains(n, "backup") ||
460-
strings.Contains(n, "aem") {
465+
strings.Contains(n, "aem") ||
466+
strings.Contains(n, "mcp-server") || strings.Contains(n, "mcp_discovery") {
461467
return "vulnerability"
462468
}
463469
// Recon outputs

internal/api/ui/pages/scans-page.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149
<span style="font-size:11px;color:var(--text-muted)">Phase ${currentPhase}${totalPhases > 0 ? '/' + totalPhases : ''} · ${pct}%</span>
150150
<div style="display:flex;gap:10px;align-items:center">
151151
${(() => {
152-
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'gf', 'ffuf', 'sqlmap', 'backup'].includes(scanType);
152+
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'gf', 'ffuf', 'sqlmap', 'backup', 'mcp-discovery'].includes(scanType);
153153
const label = isFindingType ? (filesUploaded === 1 ? 'finding' : 'findings') : (filesUploaded === 1 ? 'file' : 'files');
154154
const icon = isFindingType ? '🎯' : '📁';
155155
return filesUploaded > 0 ? `<span style="font-size:10px;color:var(--text-muted)">${icon} ${filesUploaded} ${label}</span>` : '';
@@ -218,7 +218,7 @@
218218
const elapsed = completedAt ? elapsedBetween(startedAt, completedAt) : elapsedStr(startedAt);
219219
const scanID = s.scan_id || s.ScanID || '';
220220
const filesUploaded = s.files_uploaded || s.FilesUploaded || 0;
221-
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'jwt', 'gf', 'ffuf', 'apkx', 'sqlmap', 'backup'].includes(scanType);
221+
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'jwt', 'gf', 'ffuf', 'apkx', 'sqlmap', 'backup', 'mcp-discovery'].includes(scanType);
222222
const label = isFindingType ? 'findings' : 'files';
223223
const icon = isFindingType ? '🎯' : '📁';
224224
const badgeHtml = filesUploaded > 0 ? `<span class="badge badge-running" style="font-size:10px;padding:2px 6px;margin-bottom:4px;display:inline-block;background:rgba(6,182,212,0.15);border:1px solid rgba(6,182,212,0.3);color:var(--accent-cyan);cursor:help" title="${filesUploaded} ${label} identified">${icon} ${filesUploaded} ${label}</span><br/>` : '';

internal/mcp/tools.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ func scanTypeLabel(t string) string {
132132
"sqlmap": "SQLMap",
133133
"backup": "Backup Detection",
134134
"zerodays": "ZeroDays",
135+
"mcp-discovery": "MCP Discovery",
135136
}
136137
if l, ok := m[strings.ToLower(t)]; ok {
137138
return l
@@ -143,7 +144,7 @@ func isScanFindingType(scanType string) bool {
143144
for _, ft := range []string{"dns_cf1016", "dns-cf1016", "dns", "dns-takeover", "dns-dangling-ip",
144145
"nuclei", "nuclei-full", "nuclei-cves", "nuclei-panels", "nuclei-vulnerabilities",
145146
"nuclei-default-logins", "misconfig", "s3", "github", "reflection",
146-
"zerodays", "jwt", "gf", "ffuf", "apkx", "sqlmap", "backup"} {
147+
"zerodays", "jwt", "gf", "ffuf", "apkx", "sqlmap", "backup", "mcp-discovery"} {
147148
if strings.EqualFold(scanType, ft) {
148149
return true
149150
}
@@ -948,7 +949,7 @@ func inferModuleFromName(name string) string {
948949
return "port-scan"
949950
case strings.Contains(n, "aem"):
950951
return "aem"
951-
case strings.Contains(n, "backup") || strings.Contains(n, "fuzzuli"):
952+
case strings.Contains(n, "mcp-server") || strings.Contains(n, "mcp_discovery") || strings.Contains(n, "backup") || strings.Contains(n, "fuzzuli"):
952953
return "backup-detection"
953954
// dalfox before reflection (which matches "xss")
954955
case strings.Contains(n, "dalfox"):

internal/scanner/domain/domain.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/h0tak88r/AutoAR/internal/scanner/gf"
2121
"github.com/h0tak88r/AutoAR/internal/scanner/jsscan"
2222
"github.com/h0tak88r/AutoAR/internal/scanner/livehosts"
23+
"github.com/h0tak88r/AutoAR/internal/scanner/mcpdiscovery"
2324
"github.com/h0tak88r/AutoAR/internal/scanner/misconfig"
2425
"github.com/h0tak88r/AutoAR/internal/scanner/nuclei"
2526
"github.com/h0tak88r/AutoAR/internal/scanner/ports"
@@ -129,6 +130,14 @@ func RunDomain(opts ScanOptions) (*Result, error) {
129130
{"urls", "URL collection", func() error { _, err := urls.CollectURLs(domain, 150, false); return err }, 0},
130131
{"jsscan", "JavaScript scan", func() error { _, err := jsscan.Run(jsscan.Options{Domain: domain, Threads: 150}); return err }, 0},
131132
{"dns", "DNS takeover scan", func() error { return dns.Takeover(domain) }, 0},
133+
{"mcp-discovery", "MCP server discovery", func() error {
134+
lh := ""
135+
if _, err := os.Stat(liveHostsFile); err == nil {
136+
lh = liveHostsFile
137+
}
138+
_, err := mcpdiscovery.Run(mcpdiscovery.Options{Target: domain, LiveHostsFile: lh, Threads: 15})
139+
return err
140+
}, 0},
132141
{"aem", "AEM webapp discovery and scan", func() error {
133142
lh := ""; if _, err := os.Stat(liveHostsFile); err == nil { lh = liveHostsFile }
134143
_, err := aemmod.Run(aemmod.Options{Domain: domain, LiveHostsFile: lh, Threads: 50})

0 commit comments

Comments
 (0)