From a44370c5f155f3faea05ada33f05c9d1303561e3 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 23 Jul 2026 18:19:26 -0500 Subject: [PATCH] th-8b7d36: configure Smooth Operator's tools from th + Claude Desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on epic th-093ce3: you can now not only TALK to the org operator from th/Claude Desktop, but also configure what it's allowed to do from the same place. - `th api smooth-operator tools list|enable|disable` - MCP `operator_tools` (read-only) + `operator_tools_set` - shared client in smooai::smooth_operator (list_operator_tools / set_operator_tool / render_tool_catalog), reused by both surfaces Writes are read-modify-write deliberately: the PUT body is authoritative and the server treats an omitted tool as ENABLED, so a single-entry body would silently re-enable every other tool. We re-read the catalog, flip one, send all. Server side is smooai PR #3380 (admin-gated); until that ships these return a clean 404. Server instructions tell the model to confirm with the user before calling operator_tools_set — it changes what the AI may do for the whole org. 377 tests pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015ZctFb4oiWoJraHN2db1nX --- .changeset/operator-tools-surface.md | 15 ++ crates/smooth-cli/src/mcp_serve.rs | 79 ++++++- .../smooth-cli/src/smooai/smooth_operator.rs | 205 +++++++++++++++++- 3 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 .changeset/operator-tools-surface.md diff --git a/.changeset/operator-tools-surface.md b/.changeset/operator-tools-surface.md new file mode 100644 index 000000000..d0d2a4e58 --- /dev/null +++ b/.changeset/operator-tools-surface.md @@ -0,0 +1,15 @@ +--- +'@smooai/smooth': minor +--- + +Configure Smooth Operator's tools from `th` — and from Claude Desktop / Cursor. + +New `th api smooth-operator tools list|enable|disable` plus two MCP tools +(`operator_tools`, `operator_tools_set`) over the per-org operator tool-config +API, so you can ask "what can my operator do?" and "turn off email.send" from +the same chat that drives the operator. Org-admin only (the API enforces it). + +Writes are **read-modify-write by design**: the PUT body is authoritative and +the server treats any omitted tool as enabled, so sending a single-entry body +would silently re-enable everything else. `set_operator_tool` re-reads the full +catalog, flips one entry, and sends all of it. diff --git a/crates/smooth-cli/src/mcp_serve.rs b/crates/smooth-cli/src/mcp_serve.rs index e05abb79e..9fe4a49a3 100644 --- a/crates/smooth-cli/src/mcp_serve.rs +++ b/crates/smooth-cli/src/mcp_serve.rs @@ -20,6 +20,9 @@ //! WebSocket transport (see `smooai::smooth_operator_ws`), which never sends //! or takes a destructive action unless the caller passes `approve: true`; //! and `knowledge_search` — a fast read of the org knowledge base. +//! - **Org admin only**: `operator_tools` / `operator_tools_set` — see and +//! change which tools the org's operator may use at all (th-8b7d36), so the +//! operator can be configured from the same chat that drives it. use anyhow::Result; use rmcp::{ @@ -97,6 +100,26 @@ pub struct KnowledgeSearchArgs { pub max_results: Option, } +/// Arguments for `operator_tools`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OperatorToolsArgs { + /// Act on a specific org id. Defaults to your active org. + #[serde(default)] + pub org: Option, +} + +/// Arguments for `operator_tools_set`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OperatorToolsSetArgs { + /// Dotted tool id to change, e.g. `email.send` (see `operator_tools`). + pub tool_id: String, + /// True to let the operator use it; false to turn it off for the whole org. + pub enabled: bool, + /// Act on a specific org id. Defaults to your active org. + #[serde(default)] + pub org: Option, +} + /// Arguments for `ask_business` — one turn of the Smooth Operator org agent. #[derive(Debug, Deserialize, JsonSchema)] pub struct AskBusinessArgs { @@ -282,6 +305,47 @@ impl SmoothMcp { Ok(crate::smooai::smooth_operator_ws::render_operator_turn(&turn)) } + + /// The operator's tool catalog + which are enabled for the org. + /// + /// # Errors + /// MCP error if not signed in, not an org admin (the routes are admin-only), + /// no active org, or the request fails. + #[tool( + name = "operator_tools", + description = "List the tools your Smooth Operator can use and which are enabled for your org. Org admin only. Use operator_tools_set to change one.", + annotations(read_only_hint = true) + )] + pub async fn operator_tools(&self, params: Parameters) -> Result { + let org = crate::active_org::resolve(params.0.org).map_err(|e| ErrorData::invalid_request(format!("No active Smoo org. {e}"), None))?; + let tools = crate::smooai::smooth_operator::list_operator_tools(&org) + .await + .map_err(|e| ErrorData::internal_error(format!("{e:#}"), None))?; + Ok(crate::smooai::smooth_operator::render_tool_catalog(&tools)) + } + + /// Turn one operator tool on or off for the whole org. + /// + /// # Errors + /// MCP error for an unknown tool id, if not signed in, not an org admin, no + /// active org, or the request fails. + #[tool( + name = "operator_tools_set", + description = "Turn one Smooth Operator tool on or off for your whole org (e.g. disable email.send so the operator can never send mail). Org admin only. Changes what the AI is allowed to do — confirm with the user first." + )] + pub async fn operator_tools_set(&self, params: Parameters) -> Result { + let a = params.0; + let org = crate::active_org::resolve(a.org).map_err(|e| ErrorData::invalid_request(format!("No active Smoo org. {e}"), None))?; + let tools = crate::smooai::smooth_operator::set_operator_tool(&org, &a.tool_id, a.enabled) + .await + .map_err(|e| ErrorData::internal_error(format!("{e:#}"), None))?; + let verb = if a.enabled { "Enabled" } else { "Disabled" }; + Ok(format!( + "{verb} {}.\n\n{}", + a.tool_id, + crate::smooai::smooth_operator::render_tool_catalog(&tools) + )) + } } /// Map a "no pearl store here" open failure to an actionable MCP error. @@ -337,6 +401,10 @@ impl ServerHandler for SmoothMcp { without approval; when it pauses on one, relay the pending action and approve only if the user \ says so (call `ask_business` again with approve=true and the returned conversation_id). \ `knowledge_search` is a fast read of the org knowledge base.\n\n\ + ORG ADMIN — `operator_tools` shows which tools the operator may use at all, and \ + `operator_tools_set` turns one on/off for the WHOLE org (e.g. disable `email.send` so it can \ + never send mail). That changes what the AI is allowed to do for everyone — always confirm with \ + the user before calling `operator_tools_set`, and expect a 403 if they aren't an org admin.\n\n\ When an org tool reports the user isn't signed in, tell them to run `th auth login` — don't retry blindly." .to_string(), ) @@ -384,7 +452,16 @@ mod tests { // Tools are advertised — local (free) and org (gated) alike. let tools = client.list_tools(None).await.expect("list tools"); let names: Vec<&str> = tools.tools.iter().map(|t| t.name.as_ref()).collect(); - for expected in ["pearls_ready", "pearls_create", "remember", "recall", "knowledge_search", "ask_business"] { + for expected in [ + "pearls_ready", + "pearls_create", + "remember", + "recall", + "knowledge_search", + "ask_business", + "operator_tools", + "operator_tools_set", + ] { assert!(names.contains(&expected), "missing {expected} in {names:?}"); } diff --git a/crates/smooth-cli/src/smooai/smooth_operator.rs b/crates/smooth-cli/src/smooai/smooth_operator.rs index ae767a231..7fd519128 100644 --- a/crates/smooth-cli/src/smooai/smooth_operator.rs +++ b/crates/smooth-cli/src/smooai/smooth_operator.rs @@ -21,7 +21,7 @@ use anyhow::{Context, Result}; use clap::Subcommand; use owo_colors::OwoColorize; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; use super::print_json; use crate::smooai::user_client::UserClient; @@ -67,6 +67,12 @@ pub enum Cmd { #[arg(long)] json: bool, }, + /// List or configure which tools the org's Smooth Operator may use. + /// **Org admin only** — this decides what your AI is allowed to do. + Tools { + #[command(subcommand)] + cmd: ToolsCmd, + }, /// Print the message history of a smooth-operator conversation. History { /// The conversation id from a `chat` turn. @@ -80,6 +86,33 @@ pub enum Cmd { }, } +#[derive(Subcommand)] +pub enum ToolsCmd { + /// Show the operator's tool catalog and which are enabled for your org. + List { + /// Override the active org. Falls back to `SMOOAI_ORG_ID`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// Print raw JSON instead of the rendered view. + #[arg(long)] + json: bool, + }, + /// Turn a tool ON for the org (e.g. `email.send`). + Enable { + /// Dotted tool id, as shown by `tools list`. + tool_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Turn a tool OFF for the org — the operator can no longer use it at all. + Disable { + /// Dotted tool id, as shown by `tools list`. + tool_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, +} + // ---- API response shapes (subset of smooai SmoothOperatorHistory) ---- #[derive(Deserialize)] @@ -130,6 +163,11 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { "`confirm` is obsolete — the operator now confirms destructive actions inline over its WebSocket transport. \ Re-run the ask with `th api smooth-operator chat \"…\" --confirm` to approve actions during the turn." ), + Cmd::Tools { cmd } => match cmd { + ToolsCmd::List { org, json } => tools_list(&resolve_org(org)?, json).await, + ToolsCmd::Enable { tool_id, org } => tools_set(&resolve_org(org)?, &tool_id, true).await, + ToolsCmd::Disable { tool_id, org } => tools_set(&resolve_org(org)?, &tool_id, false).await, + }, Cmd::History { conversation_id, org, json } => history(&client, &resolve_org(org)?, &conversation_id, json).await, } } @@ -187,13 +225,176 @@ async fn history(client: &UserClient, org: &str, conversation_id: &str, json: bo Ok(()) } +// ---- Operator tool config (per-org: which tools the operator may use) ------- + +/// One entry of the operator's tool catalog, as returned by +/// `GET /organizations/{org}/smooth-operator/tools`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperatorTool { + /// Dotted tool name (`email.send`) — the id a write references. + pub id: String, + #[serde(default)] + pub description: String, + /// Requires human approval before it runs. + #[serde(default)] + pub destructive: bool, + /// The ORG's config: is this tool turned on? (Default on.) + #[serde(default = "yes")] + pub enabled: bool, + /// Whether the requesting user's feature entitlements + permissions would + /// expose it at all. Effective exposure = `available && enabled`. + #[serde(default)] + pub available: bool, +} + +const fn yes() -> bool { + true +} + +fn parse_catalog(raw: &Value) -> Result> { + serde_json::from_value(raw.get("tools").cloned().unwrap_or(Value::Null)).context("parse smooth-operator tool catalog") +} + +/// The org's operator tool catalog + current enabled state. Admin-only server-side. +/// +/// # Errors +/// Errors without a Smoo user session, if the caller isn't an org admin (403), +/// or if the request fails. +pub async fn list_operator_tools(org: &str) -> Result> { + let client = UserClient::from_user_session().await?; + let raw = client + .get(&format!("/organizations/{org}/smooth-operator/tools")) + .await + .context("GET smooth-operator tools")?; + parse_catalog(&raw) +} + +/// Turn one tool on/off for the org, returning the authoritative new catalog. +/// +/// **Read-modify-write, deliberately.** The PUT body is authoritative: the +/// server persists only the disabled subset, so any tool *omitted* from the body +/// is treated as ENABLED. Sending a one-entry body would therefore silently +/// re-enable every other tool. We re-read the full catalog, flip just this one, +/// and send all of it. +/// +/// # Errors +/// Errors on an unknown `tool_id`, without a Smoo user session, if the caller +/// isn't an org admin (403), or if a request fails. +pub async fn set_operator_tool(org: &str, tool_id: &str, enabled: bool) -> Result> { + let mut tools = list_operator_tools(org).await?; + if !tools.iter().any(|t| t.id == tool_id) { + anyhow::bail!("unknown tool `{tool_id}` — run `th api smooth-operator tools list` to see the catalog"); + } + for t in &mut tools { + if t.id == tool_id { + t.enabled = enabled; + } + } + let body = json!({ + "tools": tools.iter().map(|t| json!({ "toolId": t.id, "enabled": t.enabled })).collect::>(), + }); + let client = UserClient::from_user_session().await?; + let raw = client + .put(&format!("/organizations/{org}/smooth-operator/tools"), &body) + .await + .context("PUT smooth-operator tools")?; + parse_catalog(&raw) +} + +/// First sentence/line of a tool description, capped — enough to explain the +/// tool, short enough that a 45-tool catalog stays readable. Empty in, empty out. +fn summarize(description: &str) -> String { + let first = description.split(['\n', '.']).map(str::trim).find(|s| !s.is_empty()).unwrap_or_default(); + if first.is_empty() { + return String::new(); + } + let mut s: String = first.chars().take(70).collect(); + if first.chars().count() > 70 { + s.push('…'); + } + format!(" — {s}") +} + +/// Render the catalog as compact text (shared by the CLI and the MCP tool). +#[must_use] +pub fn render_tool_catalog(tools: &[OperatorTool]) -> String { + use std::fmt::Write as _; + let (on, off) = tools.iter().partition::, _>(|t| t.enabled); + let mut out = format!("{} tool(s): {} enabled, {} disabled\n", tools.len(), on.len(), off.len()); + for t in tools { + let state = if t.enabled { "on " } else { "OFF" }; + let flags = match (t.destructive, t.available) { + (true, true) => " [needs approval]", + (true, false) => " [needs approval; unavailable to you]", + (false, false) => " [unavailable to you]", + (false, true) => "", + }; + // A one-line gist so an MCP client can explain the tool without a + // second lookup; the full text is often a paragraph. + let gist = summarize(&t.description); + let _ = writeln!(out, " {state} {}{flags}{gist}", t.id); + } + out.trim_end().to_string() +} + +async fn tools_list(org: &str, json_out: bool) -> Result<()> { + let tools = list_operator_tools(org).await?; + if json_out { + println!( + "{}", + serde_json::to_string_pretty( + &json!({ "tools": tools.iter().map(|t| json!({"id": t.id, "enabled": t.enabled, "destructive": t.destructive, "available": t.available})).collect::>() }) + )? + ); + } else { + println!("{}", render_tool_catalog(&tools)); + } + Ok(()) +} + +async fn tools_set(org: &str, tool_id: &str, enabled: bool) -> Result<()> { + let tools = set_operator_tool(org, tool_id, enabled).await?; + let verb = if enabled { + "enabled".green().to_string() + } else { + "disabled".yellow().to_string() + }; + println!("{verb} {}", tool_id.bold()); + println!("{}", render_tool_catalog(&tools)); + Ok(()) +} + #[cfg(test)] mod tests { - use super::resolve_org; + use super::{parse_catalog, render_tool_catalog, resolve_org}; + use serde_json::json; #[test] fn resolve_org_prefers_flag_then_env() { assert_eq!(resolve_org(Some("org-flag".into())).unwrap(), "org-flag"); assert!(resolve_org(Some(" ".into())).is_err() || resolve_org(None).is_err()); } + + #[test] + fn parse_catalog_defaults_enabled_true() { + // `enabled` absent must mean ON — an absent org config means every tool + // is enabled, so defaulting to false would read as "everything is off". + let raw = json!({ "tools": [{ "id": "email.send" }] }); + let tools = parse_catalog(&raw).unwrap(); + assert_eq!(tools.len(), 1); + assert!(tools[0].enabled, "missing `enabled` must default to true"); + } + + #[test] + fn render_marks_disabled_and_destructive() { + let raw = json!({ "tools": [ + { "id": "crm.search_contacts", "enabled": true, "destructive": false, "available": true }, + { "id": "email.send", "enabled": false, "destructive": true, "available": true }, + ]}); + let out = render_tool_catalog(&parse_catalog(&raw).unwrap()); + assert!(out.contains("2 tool(s): 1 enabled, 1 disabled")); + assert!(out.contains("on crm.search_contacts")); + assert!(out.contains("OFF email.send [needs approval]")); + } }