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