453
"kill_command" => exec_kill_command(arguments),
454
"skill" => crate::skills::activate_skill(arguments),
455
TASK_TOOL_NAME => exec_task(arguments).await,
456
+ WEB_SEARCH_TOOL_NAME => exec_web_search(arguments).await,
457
// Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
458
// and forwarded to the owning server.
459
_ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
461
}
462
}
463
464
+// ── web_search ───────────────────────────────────────────────────────────────
465
+//
466
+// siGit Code Cloud runs a Brave-Search-backed `web_search` tool on its official
467
+// MCP server (`mcp.rs`'s baked-in "sigit" server, discovered as
468
+// `mcp__sigit__web_search`) — the API key and billing live server-side, so
469
+// there is no on-device/local equivalent. This is a thin native wrapper
470
+// around that MCP tool rather than a second implementation of it, for two
471
+// reasons: (1) `mcp.rs` already owns the auth, transport, and error handling,
472
+// so re-doing that here would just be a second copy of the same HTTP/JSON-RPC
473
+// code; (2) every `mcp__*` tool is classified `Mutating` by
474
+// `permissions::classify` (an unknown external tool's side effects can't be
475
+// assumed safe), which would put an "ask" prompt in front of a harmless
476
+// read-only search on every call. Presenting it under the clean `web_search`
477
+// name instead lets `permissions::classify` treat it as read-only, matching
478
+// `read_website`'s zero-friction behavior, while `execute_tool_impl` still
479
+// delegates the actual call to the exact same `mcp::call_tool` codepath as
480
+// any other MCP tool.
481
+//
482
+// Advertised only when the official server has actually discovered a
483
+// `web_search` tool (see `web_search_available`) — the same conditional
484
+// pattern as `skill` and `task` — so a signed-out or MCP-disabled session
485
+// never sees a capability it can't use.
486
+
487
+pub const WEB_SEARCH_TOOL_NAME: &str = "web_search";
488
+
489
+/// The discovered MCP tool this delegates to: siGit Code Cloud's official
490
+/// server is always registered under the name "sigit" (see `mcp.rs`), so its
491
+/// tools are namespaced `mcp__sigit__<tool>`.
492
+const WEB_SEARCH_MCP_DELEGATE: &str = "mcp__sigit__web_search";
493
+
494
+/// Whether `web_search` should be offered this turn: the official MCP server
495
+/// has to have actually discovered a `web_search` tool, which in turn only
496
+/// happens when the user is signed in to siGit Code Cloud (an unauthenticated
497
+/// request to the official server fails during `mcp::init`'s discovery and
498
+/// contributes no tools — see `mcp.rs`). No separate sign-in check is needed
499
+/// here: this is exactly the same conditional-advertisement pattern as
500
+/// `subagent_available`, just checking a tool name instead of a factory.
501
+pub fn web_search_available() -> bool {
502
+ crate::mcp::tool_specs()
503
+ .iter()
504
+ .any(|spec| spec.name == WEB_SEARCH_MCP_DELEGATE)
505
+}
506
+
507
+/// Whether `name` is the raw MCP tool [`web_search_tool_spec`] delegates to.
508
+/// Callers assembling the full tool list should exclude this name from
509
+/// whatever they append from `mcp::tool_specs()`, so the model sees the one
510
+/// clean `web_search` option rather than that *and* the raw `mcp__*` name for
511
+/// the same underlying tool.
512
+pub fn is_web_search_delegate(name: &str) -> bool {
513
+ name == WEB_SEARCH_MCP_DELEGATE
514
+}
515
+
516
+/// Spec for the `web_search` tool. Lives in the `*_as_specs`/`build_tool_specs`
517
+/// layer (like `skill` and `task`), not in [`all_tools`], because it is only
518
+/// offered when [`web_search_available`] is true. The schema mirrors
519
+/// siGit Code Cloud's actual `web_search` MCP tool exactly (`query` + `count`)
520
+/// so arguments pass through to [`exec_web_search`] unchanged.
521
+pub fn web_search_tool_spec() -> ToolSpec {
522
+ ToolSpec {
523
+ name: WEB_SEARCH_TOOL_NAME.to_string(),
524
+ description: "Search the public web and return matching pages as title, URL, and \
525
+ snippet. Use this to find pages when you don't already know the URL — for \
526
+ current events, library/API documentation, error messages, or anything else \
527
+ outside this repository. Once you have a promising URL, use read_website to \
528
+ fetch its full content. Requires a signed-in siGit Code Cloud account (the \
529
+ search runs server-side); rate-limited per account."
530
+ .to_string(),
531
+ parameters_schema: json!({
532
+ "type": "object",
533
+ "properties": {
534
+ "query": {
535
+ "type": "string",
536
+ "description": "The search query."
537
+ },
538
+ "count": {
539
+ "type": "integer",
540
+ "description": "Number of results to return (default 5, max 10)."
541
+ }
542
+ },
543
+ "required": ["query"],
544
+ "additionalProperties": false
545
+ })
546
+ .to_string(),
547
+ }
548
+}
549
+
550
+/// The `web_search` tool entry point used by [`execute_tool`]: forward
551
+/// verbatim to the discovered MCP tool. `mcp::call_tool` already handles a
552
+/// missing/uninitialized server gracefully (an in-band error string, never a
553
+/// panic), so there's nothing extra to do here — see the module doc above for
554
+/// why this delegates instead of reimplementing the call.
555
+async fn exec_web_search(arguments: &str) -> String {
556
+ crate::mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, arguments).await
557
+}
558
+
559
// ── task (subagent) ──────────────────────────────────────────────────────────
560
//
561
// The `task` tool delegates a self-contained research question to a *nested*
3257
);
3258
}
3259
3260
+ // ── web_search tests ─────────────────────────────────────────────────
3261
+
3262
+ #[test]
3263
+ fn test_web_search_tool_spec_matches_the_cloud_mcp_tools_contract() {
3264
+ let spec = web_search_tool_spec();
3265
+ assert_eq!(spec.name, "web_search");
3266
+ let schema: Value = serde_json::from_str(&spec.parameters_schema).unwrap();
3267
+ assert_eq!(schema["required"], serde_json::json!(["query"]));
3268
+ assert!(schema["properties"]["query"].is_object());
3269
+ assert!(schema["properties"]["count"].is_object());
3270
+ }
3271
+
3272
+ #[test]
3273
+ fn test_is_web_search_delegate() {
3274
+ assert!(is_web_search_delegate("mcp__sigit__web_search"));
3275
+ assert!(!is_web_search_delegate("web_search"));
3276
+ assert!(!is_web_search_delegate("mcp__other__web_search"));
3277
+ }
3278
+
3279
+ #[test]
3280
+ fn test_web_search_unavailable_without_mcp_init() {
3281
+ // `mcp::init()` is never called anywhere in this test binary (it does
3282
+ // real network I/O and is a one-shot `OnceLock`, same constraint as
3283
+ // `SUBAGENT_FACTORY`), so the official server's tools — including
3284
+ // web_search — are never discovered here. This proves the
3285
+ // conditional-advertisement gate fails closed, matching a
3286
+ // signed-out/MCP-disabled session, rather than defaulting to "always
3287
+ // offered".
3288
+ assert!(!web_search_available());
3289
+ }
3290
+
3291
+ #[tokio::test]
3292
+ async fn test_web_search_delegates_to_mcp_and_degrades_gracefully_when_uninitialized() {
3293
+ // With MCP uninitialized (see the test above), `mcp::call_tool`
3294
+ // returns its own in-band error string rather than panicking — proves
3295
+ // execute_tool's WEB_SEARCH_TOOL_NAME arm actually reaches
3296
+ // mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, ...) instead of, say, silently
3297
+ // no-op'ing or hitting the `Unknown tool` fallback.
3298
+ let args = serde_json::json!({ "query": "siGit Code" }).to_string();
3299
+ let result = execute_tool(WEB_SEARCH_TOOL_NAME, &args).await;
3300
+ assert_eq!(result, "Error: MCP is not initialized.");
3301
+ }
3302
+
3303
#[test]
3304
fn test_command_output_missing_task_id() {
3305
let result = exec_command_output("{}");