Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ members = [
"crates/concurrency-scan",
"crates/fail-loud-scan",
"crates/mcp-server",
"crates/mcp-probe",
"crates/dep-tree",
"crates/outline",
"crates/format-fix",
Expand Down
1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ forge-concurrency-scan = { path = "../concurrency-scan" }
forge-outline = { path = "../outline" }
forge-dep-tree = { path = "../dep-tree" }
forge-mcp-server = { path = "../mcp-server" }
forge-mcp-probe = { path = "../mcp-probe" }
forge-format-fix = { path = "../format-fix" }
forge-merge-check = { path = "../merge-check" }
forge-devtools = { path = "../devtools" }
Expand Down
69 changes: 69 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,22 @@ enum Commands {
kinds: Option<String>,
},

/// Probe a live MCP draft HTTP endpoint for the documented supported subset
McpProbe {
/// MCP HTTP endpoint, including the /mcp path
#[arg(long)]
endpoint: String,
/// HTTP Basic auth username (requires --password)
#[arg(long)]
user: Option<String>,
/// HTTP Basic auth password (requires --user)
#[arg(long)]
password: Option<String>,
/// Request timeout in milliseconds
#[arg(long, default_value_t = 15_000)]
timeout_ms: u64,
},

/// Scan for leaked API keys, credentials, and private keys
SecretScan {
/// Directory to scan (defaults to current dir)
Expand Down Expand Up @@ -3496,6 +3512,37 @@ fn run_mcp_server() -> anyhow::Result<()> {
}
);

// mcp_probe — black-box draft HTTP capability probe
register_tool!(server, "mcp_probe",
"Probe a live MCP draft HTTP endpoint for Ferrosa Memory's documented supported subset. Calls server/discover with draft headers and request metadata, then reports pass, fail, or unsupported checks for the advertised protocol version, server identity, methods, and task-resource SSE subscription semantics. This is not a full MCP draft conformance certification.",
serde_json::json!({
"type": "object",
"properties": {
"endpoint": {"type": "string", "description": "MCP HTTP endpoint including /mcp"},
"user": {"type": "string", "description": "HTTP Basic auth username; requires password"},
"password": {"type": "string", "description": "HTTP Basic auth password; requires user"},
"timeout_ms": {"type": "integer", "minimum": 1, "description": "Request timeout in milliseconds (default: 15000)"}
},
"required": ["endpoint"]
}),
|args| {
let endpoint = args.get("endpoint").and_then(|value| value.as_str()).ok_or("endpoint is required")?;
let user = args.get("user").and_then(|value| value.as_str());
let password = args.get("password").and_then(|value| value.as_str());
let basic_auth = match (user, password) {
(Some(user), Some(pass)) => Some(forge_mcp_probe::BasicAuth { user: user.to_string(), pass: pass.to_string() }),
(None, None) => None,
_ => return Err("user and password must be provided together".to_string()),
};
let mut config = forge_mcp_probe::ProbeConfig::new(endpoint);
config.basic_auth = basic_auth;
if let Some(timeout_ms) = args.get("timeout_ms").and_then(|value| value.as_u64()) {
config.timeout = std::time::Duration::from_millis(timeout_ms);
}
serde_json::to_string_pretty(&forge_mcp_probe::probe(&config)).map_err(|error| error.to_string())
}
);

// secret_scan — scan for leaked API keys, credentials, private keys
register_path_tool!(server, "secret_scan",
"Scan a directory for leaked API keys, AWS/GCP credentials, GitHub/Slack/Stripe tokens, JWTs, private key headers, and password assignments. Use before committing config changes, during secure-review, or as part of pipeline-defense. Returns masked snippets (never full plaintext) with severity per finding. Respects .gitignore, skips binary files. Prefer this over hand-rolled grep patterns.",
Expand Down Expand Up @@ -5602,6 +5649,28 @@ fn main() -> anyhow::Result<()> {
println!("{}", forge_shared::emit_json(&report, cli.pretty)?);
}

Commands::McpProbe {
endpoint,
user,
password,
timeout_ms,
} => {
let basic_auth = match (user, password) {
(Some(user), Some(pass)) => Some(forge_mcp_probe::BasicAuth { user, pass }),
(None, None) => None,
_ => anyhow::bail!("--user and --password must be provided together"),
};
let mut config = forge_mcp_probe::ProbeConfig::new(endpoint);
config.basic_auth = basic_auth;
config.timeout = std::time::Duration::from_millis(timeout_ms);
let report = forge_mcp_probe::probe(&config);
let passed = report.overall == forge_mcp_probe::ProbeStatus::Pass;
println!("{}", forge_shared::emit_json(&report, cli.pretty)?);
if !passed {
std::process::exit(1);
}
}

Commands::SecretScan { path } => {
let opts = forge_secret_scan::Options {
min_entropy: None,
Expand Down
15 changes: 15 additions & 0 deletions crates/mcp-probe/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "forge-mcp-probe"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ureq = { version = "3", features = ["json", "platform-verifier"] }
base64 = "0.22"

[dev-dependencies]
tempfile = { workspace = true }
Loading