Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/operator-tools-surface.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 78 additions & 1 deletion crates/smooth-cli/src/mcp_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -97,6 +100,26 @@ pub struct KnowledgeSearchArgs {
pub max_results: Option<u64>,
}

/// 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<String>,
}

/// 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<String>,
}

/// Arguments for `ask_business` — one turn of the Smooth Operator org agent.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AskBusinessArgs {
Expand Down Expand Up @@ -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<OperatorToolsArgs>) -> Result<String, ErrorData> {
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<OperatorToolsSetArgs>) -> Result<String, ErrorData> {
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.
Expand Down Expand Up @@ -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(),
)
Expand Down Expand Up @@ -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:?}");
}

Expand Down
205 changes: 203 additions & 2 deletions crates/smooth-cli/src/smooai/smooth_operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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<String>,
/// 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<String>,
},
/// 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<String>,
},
}

// ---- API response shapes (subset of smooai SmoothOperatorHistory) ----

#[derive(Deserialize)]
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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<Vec<OperatorTool>> {
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<Vec<OperatorTool>> {
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<Vec<OperatorTool>> {
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::<Vec<_>>(),
});
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::<Vec<_>, _>(|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::<Vec<_>>() })
)?
);
} 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]"));
}
}
Loading