diff --git a/CHANGELOG.md b/CHANGELOG.md index 384ea4d..4b7b193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Added +- `kagi mcp --tools` and `--exclude-tools` select which tools are exposed; `KAGI_MCP_TOOLS` provides an environment allowlist. Hidden tools cannot be called, and mutating tools still require explicit opt-in (#201). + - Static Linux release binaries for `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`, with archives, bare binaries, and SHA-256 checksums (#200). ## [0.21.1] diff --git a/docs/content/docs/commands/mcp.mdx b/docs/content/docs/commands/mcp.mdx index 34de3f2..f9bf10e 100644 --- a/docs/content/docs/commands/mcp.mdx +++ b/docs/content/docs/commands/mcp.mdx @@ -15,6 +15,7 @@ MCP specification's `initialize` handshake, `ping`, `tools/list`, and ```bash kagi mcp [--default-output json|toon|pretty|compact|markdown|csv] [--enable-mutating-tools] + [--tools NAME,... | --exclude-tools NAME,...] kagi mcp install [--target CLIENT]... [--all] [--dry-run] kagi mcp setup [--target CLIENT]... [--all] [--dry-run] kagi mcp auth [--target CLIENT]... [--all] [--dry-run] @@ -156,6 +157,46 @@ Expose account and local-state mutation tools only when you want an agent to man codex mcp add kagi-admin -- "$(command -v kagi)" mcp --enable-mutating-tools ``` +## Choose which tools to expose + +Expose only the tools your agent needs: + +```bash +kagi mcp --tools kagi_search,kagi_batch_search,kagi_quick +codex mcp add kagi-search -- "$(command -v kagi)" mcp --tools kagi_search,kagi_quick +``` + +Or expose the enabled catalog except selected tools: + +```bash +kagi mcp --exclude-tools kagi_fastgpt,kagi_extract +``` + +For clients where changing server arguments is awkward, set the comma-separated +`KAGI_MCP_TOOLS` environment variable in the server configuration: + +```bash +KAGI_MCP_TOOLS=kagi_search,kagi_batch_search,kagi_quick kagi mcp +``` + +`--tools` and `--exclude-tools` cannot be combined. Either flag overrides +`KAGI_MCP_TOOLS`. Names are case-sensitive; surrounding whitespace is trimmed and +duplicates are ignored. Unknown or empty names are startup errors. Omitting both +flags and the environment variable preserves the default catalog. + +Filters apply to both stable and draft `tools/list` and `tools/call`. Hidden tools +cannot be called directly; they return an unknown-tool JSON-RPC error. Excluding +all enabled tools produces an empty catalog. To include a mutating tool, also pass +`--enable-mutating-tools`; an allowlist alone does not enable mutations: + +```bash +kagi mcp --enable-mutating-tools --tools kagi_lens_list,kagi_lens_create +``` + +Restart your MCP client after changing the filter so it refreshes its tool list. +The setup commands install plain `kagi mcp`; customize the saved arguments or +server environment to apply a filter. + ## Tools Default read/query tools: diff --git a/src/cli.rs b/src/cli.rs index 7471381..d73fbfd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1444,6 +1444,24 @@ pub struct McpArgs { #[arg(long, value_name = "FORMAT", value_enum)] pub default_output: Option, + /// Expose only these comma-separated MCP tool names (or use KAGI_MCP_TOOLS) + #[arg( + long, + value_name = "NAME,...", + value_delimiter = ',', + conflicts_with = "exclude_tools" + )] + pub tools: Option>, + + /// Expose all enabled MCP tools except these comma-separated names + #[arg( + long, + value_name = "NAME,...", + value_delimiter = ',', + conflicts_with = "tools" + )] + pub exclude_tools: Option>, + /// Expose MCP tools that mutate Kagi account or local CLI state #[arg(long)] pub enable_mutating_tools: bool, diff --git a/src/main.rs b/src/main.rs index 1c7bf18..7e25b6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3254,6 +3254,57 @@ impl McpServerConfig { } } + fn with_tool_filter( + mut self, + tools: Option<&[String]>, + exclude_tools: &[String], + ) -> Result { + if tools.is_none() && exclude_tools.is_empty() { + return Ok(self); + } + + let all_tools = build_mcp_tool_definitions(true); + let normalize_names = |names: &[String]| -> Result, KagiError> { + names + .iter() + .map(|name| { + let name = name.trim(); + if name.is_empty() { + return Err(KagiError::Config( + "MCP tool filters must not contain empty names".into(), + )); + } + if !all_tools.iter().any(|tool| tool["name"].as_str() == Some(name)) { + return Err(KagiError::Config(format!( + "Unknown MCP tool `{name}`. Run `kagi mcp` and call tools/list to inspect available tools" + ))); + } + Ok(name.to_string()) + }) + .collect() + }; + let included = tools.map(normalize_names).transpose()?; + let excluded = normalize_names(exclude_tools)?; + if let Some(included) = &included { + for name in included { + if !self + .tool_definitions + .iter() + .any(|tool| tool["name"].as_str() == Some(name.as_str())) + { + return Err(KagiError::Config(format!( + "MCP tool `{name}` requires --enable-mutating-tools" + ))); + } + } + } + self.tool_definitions.retain(|tool| { + let name = tool["name"].as_str().expect("MCP tool has a name"); + included.as_ref().is_none_or(|names| names.contains(name)) && !excluded.contains(name) + }); + Ok(self) + } + fn default_output_or(&self, fallback: OutputFormat) -> OutputFormat { self.default_output.clone().unwrap_or(fallback) } @@ -3261,7 +3312,26 @@ impl McpServerConfig { async fn run_mcp(args: McpArgs, profile: Option<&str>) -> Result<(), KagiError> { let _json_lines = args.json_lines; - let config = McpServerConfig::new(args.default_output, args.enable_mutating_tools); + // Explicit CLI filters override the environment, including --exclude-tools. + let env_tools = if args.tools.is_none() && args.exclude_tools.is_none() { + match env::var("KAGI_MCP_TOOLS") { + Ok(value) => Some(value.split(',').map(str::to_string).collect::>()), + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => { + return Err(KagiError::Config( + "KAGI_MCP_TOOLS must be valid UTF-8".into(), + )); + } + } + } else { + None + }; + let tools = args.tools.or(env_tools); + let config = McpServerConfig::new(args.default_output, args.enable_mutating_tools) + .with_tool_filter( + tools.as_deref(), + args.exclude_tools.as_deref().unwrap_or_default(), + )?; let stdin = io::stdin(); for line in stdin.lock().lines() { let line = diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index 2a06e68..4b90a56 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -27,6 +27,7 @@ fn run_kagi(args: &[&str], envs: &[(&str, &str)], cwd: &Path) -> Output { "KAGI_NEWS_BASE_URL", "KAGI_TRANSLATE_BASE_URL", "KAGI_ERROR_FORMAT", + "KAGI_MCP_TOOLS", "KAGI_CACHE_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", @@ -63,6 +64,7 @@ fn run_kagi_with_stdin(args: &[&str], stdin: &str, envs: &[(&str, &str)], cwd: & "KAGI_NEWS_BASE_URL", "KAGI_TRANSLATE_BASE_URL", "KAGI_ERROR_FORMAT", + "KAGI_MCP_TOOLS", "KAGI_CACHE_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", @@ -4205,3 +4207,246 @@ fn concurrent_site_pref_sets_preserve_all_domains() { ); } } + +#[test] +fn mcp_tool_allowlist_filters_both_protocols_and_deduplicates() { + let tempdir = TempDir::new().expect("tempdir"); + let requests = [ + mcp_stable_request(json!(1), "tools/list", json!({})), + mcp_request(json!(2), "tools/list", json!({})), + ]; + let stdin = requests + .iter() + .map(|r| format!("{r}\n")) + .collect::(); + let output = run_kagi_with_stdin( + &[ + "mcp", + "--tools", + " kagi_search,kagi_quick,kagi_batch_search,kagi_search ", + ], + &stdin, + &[], + tempdir.path(), + ); + assert_success(&output); + for response in mcp_responses(&output.stdout) { + let names: Vec<&str> = response["result"]["tools"] + .as_array() + .expect("tools") + .iter() + .map(|tool| tool["name"].as_str().expect("name")) + .collect(); + assert_eq!(names, ["kagi_batch_search", "kagi_quick", "kagi_search"]); + } +} + +#[test] +fn mcp_tool_exclusions_preserve_remaining_catalog_and_mutation_gate() { + let tempdir = TempDir::new().expect("tempdir"); + let stdin = format!("{}\n", mcp_request(json!(1), "tools/list", json!({}))); + for mutating in [false, true] { + let mut args = vec!["mcp"]; + if mutating { + args.push("--enable-mutating-tools"); + } + let baseline = run_kagi_with_stdin(&args, &stdin, &[], tempdir.path()); + assert_success(&baseline); + let baseline = mcp_responses(&baseline.stdout); + let expected: Vec = baseline[0]["result"]["tools"] + .as_array() + .expect("tools") + .iter() + .filter(|tool| tool["name"] != "kagi_search" && tool["name"] != "kagi_lens_create") + .cloned() + .collect(); + args.extend([ + "--exclude-tools", + "kagi_search, kagi_lens_create,kagi_search", + ]); + let filtered = run_kagi_with_stdin(&args, &stdin, &[], tempdir.path()); + assert_success(&filtered); + assert_eq!( + mcp_responses(&filtered.stdout)[0]["result"]["tools"], + json!(expected) + ); + } +} + +#[test] +fn mcp_tool_filters_reject_invalid_names_before_reading_requests() { + let tempdir = TempDir::new().expect("tempdir"); + for flag in ["--tools", "--exclude-tools"] { + for (value, message) in [ + ("kagi_serach", "Unknown MCP tool"), + ("kagi_search,", "empty names"), + (" ", "empty names"), + ] { + let output = run_kagi_with_stdin(&["mcp", flag, value], "", &[], tempdir.path()); + assert!(!output.status.success(), "accepted {flag} {value:?}"); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains(message)); + } + } + let output = run_kagi( + &[ + "mcp", + "--tools", + "kagi_search", + "--exclude-tools", + "kagi_quick", + ], + &[], + tempdir.path(), + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be used with")); + for value in ["kagi_serach", "", "kagi_search,,kagi_quick"] { + let output = + run_kagi_with_stdin(&["mcp"], "", &[("KAGI_MCP_TOOLS", value)], tempdir.path()); + assert!(!output.status.success(), "accepted env {value:?}"); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn mcp_tool_allowlist_requires_explicit_mutation_permission() { + let tempdir = TempDir::new().expect("tempdir"); + let stdin = format!( + "{}\n", + mcp_stable_request(json!(1), "tools/list", json!({})) + ); + let output = run_kagi_with_stdin( + &["mcp", "--tools", "kagi_lens_create"], + &stdin, + &[], + tempdir.path(), + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("requires --enable-mutating-tools")); + let output = run_kagi_with_stdin( + &[ + "mcp", + "--enable-mutating-tools", + "--tools", + "kagi_lens_create", + ], + &stdin, + &[], + tempdir.path(), + ); + assert_success(&output); + let responses = mcp_responses(&output.stdout); + let tools = responses[0]["result"]["tools"].as_array().expect("tools"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "kagi_lens_create"); +} + +#[test] +fn mcp_tool_environment_allowlist_and_cli_precedence() { + let tempdir = TempDir::new().expect("tempdir"); + let stdin = format!( + "{}\n", + mcp_stable_request(json!(1), "tools/list", json!({})) + ); + let output = run_kagi_with_stdin( + &["mcp"], + &stdin, + &[("KAGI_MCP_TOOLS", " kagi_quick,kagi_quick ")], + tempdir.path(), + ); + assert_success(&output); + let responses = mcp_responses(&output.stdout); + let tools = responses[0]["result"]["tools"].as_array().expect("tools"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "kagi_quick"); + for flag in ["--tools", "--exclude-tools"] { + let output = run_kagi_with_stdin( + &["mcp", flag, "kagi_search"], + &stdin, + &[("KAGI_MCP_TOOLS", "invalid")], + tempdir.path(), + ); + assert_success(&output); + let responses = mcp_responses(&output.stdout); + let tools = responses[0]["result"]["tools"].as_array().expect("tools"); + if flag == "--tools" { + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "kagi_search"); + } else { + assert!(tools.len() > 1); + assert!(!tools.iter().any(|tool| tool["name"] == "kagi_search")); + } + } +} + +#[test] +fn mcp_tool_filters_block_hidden_calls_and_keep_server_alive() { + let tempdir = TempDir::new().expect("tempdir"); + for args in [ + vec!["mcp", "--tools", "kagi_news_filter_presets"], + vec!["mcp", "--exclude-tools", "kagi_search"], + ] { + for draft in [false, true] { + let request = if draft { + mcp_request + } else { + mcp_stable_request + }; + let stdin = format!( + "{}\n{}\n", + request( + json!(1), + "tools/call", + json!({"name": "kagi_search", "arguments": {"query": "rust"}}) + ), + request( + json!(2), + "tools/call", + json!({"name": "kagi_news_filter_presets", "arguments": {}}) + ) + ); + let output = run_kagi_with_stdin(&args, &stdin, &[], tempdir.path()); + assert_success(&output); + let responses = mcp_responses(&output.stdout); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["error"]["code"], -32602); + assert_eq!( + responses[0]["error"]["message"], + "Unknown tool: kagi_search" + ); + assert_eq!(responses[1]["result"]["isError"], false); + assert!(responses[1]["result"]["structuredContent"].is_object()); + } + } +} + +#[test] +fn mcp_tool_exclusions_can_produce_an_empty_catalog() { + let tempdir = TempDir::new().expect("tempdir"); + let stdin = format!( + "{}\n", + mcp_stable_request(json!(1), "tools/list", json!({})) + ); + let output = run_kagi_with_stdin(&["mcp"], &stdin, &[], tempdir.path()); + assert_success(&output); + let responses = mcp_responses(&output.stdout); + let names = responses[0]["result"]["tools"] + .as_array() + .expect("tools") + .iter() + .map(|tool| tool["name"].as_str().expect("name")) + .collect::>() + .join(","); + let output = run_kagi_with_stdin( + &["mcp", "--exclude-tools", &names], + &stdin, + &[], + tempdir.path(), + ); + assert_success(&output); + assert_eq!( + mcp_responses(&output.stdout)[0]["result"]["tools"], + json!([]) + ); +}