@sigitcode-old / sigit / commits / b1b2528
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

Add --mcp-tools-only to headless runs

The flag blocks every tool whose name does not start with mcp__, so a headless run can be limited to the tools of the servers in mcp.toml. Calls to other tools get the same refusal as --deny-tool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.7.6 <noreply@sigit.si>

Seto Elkahfi committed Nov 4, 2026 at 09:56 UTC b1b25280914321fe0b235b903a9d19f638695da9
2 files changed +37 -5
crates/cli/src/headless.rs
+33 -5
@@ -30,7 +30,7 @@ use crate::{permissions, provider, session_store, settings, tools};
30 pub const USAGE: &str = "Usage: sigit run \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
31 [--output text|jsonl] [--quiet] [--resume <session-id>] \
32 [--allow-tool <name>]... [--deny-tool <name>]... \
33 - [--max-tool-rounds <n>] [--model <tier>]\n \
33 + [--mcp-tools-only] [--max-tool-rounds <n>] [--model <tier>]\n \
34 sigit -p \"<prompt>\" [same options]";
35
36 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -63,6 +63,8 @@ pub struct HeadlessConfig {
63 pub allow_tools: Vec<String>,
64 /// Tools blocked for the run, overriding even settings-level allow.
65 pub deny_tools: Vec<String>,
66 + /// Block every tool that is not provided by an MCP server (`mcp__<server>__<tool>`).
67 + pub mcp_tools_only: bool,
68 /// How many rounds of tool calls the run may make before the model is
69 /// asked for its final message. `--max-tool-rounds`, else
70 /// `SIGIT_MAX_TOOL_ROUNDS`, else the built-in cap.
@@ -149,6 +151,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
151 let mut resume_session: Option<String> = None;
152 let mut allow_tools: Vec<String> = Vec::new();
153 let mut deny_tools: Vec<String> = Vec::new();
154 + let mut mcp_tools_only = false;
155 let mut max_tool_rounds: Option<usize> = None;
156 let mut cloud_tier: Option<&'static str> = None;
157
@@ -209,6 +212,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
212 .ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
213 deny_tools.push(value.clone());
214 }
215 + "--mcp-tools-only" => mcp_tools_only = true,
216 "--max-tool-rounds" => {
217 let value = iter
218 .next()
@@ -276,6 +280,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
280 output,
281 allow_tools,
282 deny_tools,
283 + mcp_tools_only,
284 max_tool_rounds,
285 cloud_tier,
286 }))
@@ -305,10 +310,17 @@ fn not_preapproved_denial(tool_name: &str) -> String {
310 )
311 }
312
308 -/// Denial fed to the model when a tool was blocked with `--deny-tool`.
313 +/// Whether the command-line flags block a tool: it is named by `--deny-tool`, or
314 +/// `--mcp-tools-only` is set and it does not come from an MCP server.
315 +fn blocked_by_flags(denied: &HashSet<&str>, mcp_tools_only: bool, tool_name: &str) -> bool {
316 + denied.contains(tool_name) || (mcp_tools_only && !tool_name.starts_with("mcp__"))
317 +}
318 +
319 +/// Denial fed to the model when a tool was blocked with `--deny-tool` or
320 +/// `--mcp-tools-only`.
321 fn deny_flag_denial(tool_name: &str) -> String {
322 format!(
311 - "`{tool_name}` is blocked for this headless run (--deny-tool). Do not retry it; \
323 + "`{tool_name}` is blocked for this headless run (--deny-tool or --mcp-tools-only). Do not retry it; \
324 continue without this tool or report what remains to be done."
325 )
326 }
@@ -519,8 +531,8 @@ async fn run_prompt(
531
532 // The headless deny set outranks everything, including a
533 // settings-level allow and a --allow-tool grant for the same name.
522 - let output = if denied.contains(tc.name.as_str()) {
523 - log::info!("headless: {} blocked by --deny-tool", tc.name);
534 + let output = if blocked_by_flags(&denied, config.mcp_tools_only, &tc.name) {
535 + log::info!("headless: {} blocked by the tool flags", tc.name);
536 deny_flag_denial(&tc.name)
537 } else {
538 match permissions::decision_for(&config.session_id, &tc.name, &tc.arguments) {
@@ -787,6 +799,22 @@ mod tests {
799 assert_eq!(config.output, OutputFormat::Text);
800 assert_eq!(config.allow_tools, vec!["run_command", "edit_file"]);
801 assert_eq!(config.deny_tools, vec!["delete_file"]);
802 + assert!(!config.mcp_tools_only);
803 + }
804 +
805 + #[test]
806 + fn mcp_tools_only_blocks_everything_but_mcp_tools() {
807 + let config = parse_args(&args(&["-p", "x", "--mcp-tools-only"]))
808 + .unwrap()
809 + .unwrap();
810 + assert!(config.mcp_tools_only);
811 + let denied: HashSet<&str> = HashSet::new();
812 + assert!(blocked_by_flags(&denied, true, "run_command"));
813 + assert!(blocked_by_flags(&denied, true, "read_file"));
814 + assert!(!blocked_by_flags(&denied, true, "mcp__xcrs__ui_tap"));
815 + assert!(!blocked_by_flags(&denied, false, "run_command"));
816 + let denied: HashSet<&str> = ["mcp__xcrs__ui_tap"].into_iter().collect();
817 + assert!(blocked_by_flags(&denied, true, "mcp__xcrs__ui_tap"));
818 }
819
820 #[test]
docs/headless.md
+4
@@ -27,6 +27,10 @@ for interactive permission are denied in headless mode unless granted with
27 `--allow-tool <name>`. A repeatable `--deny-tool <name>` takes precedence over grants and
28 settings.
29
30 +`--mcp-tools-only` blocks every tool that does not come from an MCP server (names that
31 +start with `mcp__`), so a run can be limited to the tools of the servers in `mcp.toml`. The
32 +model still sees the built-in tools listed, and a call to one is refused.
33 +
34 ## Choosing the model
35
36 With no flag, a run uses the provider override (`OPENAI_BASE_URL` and `OPENAI_API_KEY`, or the