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
22 changes: 22 additions & 0 deletions .changeset/sep-ws-rewire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@smooai/smooth': minor
---

Talk to your org's Smooth Operator again — rewired over the SEP WebSocket.

The buffered REST route `POST /organizations/{org}/smooth-operator/chat` was
deleted upstream (SMOODEV-2673), so both `ask_business` (the MCP tool Claude
Desktop / Cursor use) and `th api smooth-operator chat` were pointed at a dead
endpoint and 404'd. New `smooai::smooth_operator_ws::operator_turn` mints a
short-lived socket token from api-prime, connects
`wss://smooth-operator.smoo.ai/ws`, creates/resumes a conversation session,
sends the message, and buffers the streamed turn into a final reply — hand-rolled
because the `smooth-operator` crates are server-side and ship no Rust client.

Destructive tools now confirm **inline**: the socket parks the turn
(`write_confirmation_required`) and the decision rides the same connection, so
approval is a flag (`approve: true` / `--confirm`) instead of a second call.
Without it the action is declined and reported, never silently run. The old
`th api smooth-operator confirm` subcommand is retired with an explanation.

Verified live against production on both surfaces.
20 changes: 18 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/smooth-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ base64 = "0.22"
tempfile = "3"
rmcp = { workspace = true, features = ["server", "macros", "transport-io"] }
schemars = "1"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }

[dev-dependencies]
# Pearl th-abc4e2: admin::tests verify the baked-in Supabase anon
Expand Down
147 changes: 29 additions & 118 deletions crates/smooth-cli/src/mcp_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@
//! - **Local, free, no sign-in**: `pearls_ready` / `pearls_create` (the
//! workspace pearl store) and `remember` / `recall` (local memory).
//! - **Your business, behind Sign in with Smoo (`th auth login`)**:
//! `ask_business` — one turn of the Smooth Operator org agent (the same
//! user-only `POST /organizations/{org}/smooth-operator/chat` the
//! `th api smooth-operator` CLI drives), which never sends or takes a
//! destructive action without explicit approval; and `knowledge_search` —
//! a fast read of the org knowledge base.
//! `ask_business` — one turn of the Smooth Operator org agent over its SEP
//! 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.

use anyhow::Result;
use rmcp::{
Expand All @@ -38,8 +37,6 @@ use serde_json::json;

use smooth_pearls::{MemoryStore, NewPearl, PearlType, Priority};

use crate::smooai::user_client::UserClient;

/// The Smooth MCP server. Stateless beyond the tool router — each tool opens
/// the pearl store fresh (matching `th pearls` CLI semantics), so the server
/// tracks the workspace it's launched in with no long-lived DB handle.
Expand Down Expand Up @@ -103,15 +100,14 @@ pub struct KnowledgeSearchArgs {
/// Arguments for `ask_business` — one turn of the Smooth Operator org agent.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AskBusinessArgs {
/// What to ask or tell your business, in plain language. Omit only when
/// approving a pending action (set `approve` + `conversation_id` instead).
#[serde(default)]
/// What to ask or tell your business, in plain language.
pub message: Option<String>,
/// Continue an existing conversation (returned by a previous call).
/// Continue an existing conversation (pass back the id a previous call returned).
#[serde(default)]
pub conversation_id: Option<String>,
/// Approve (true) or decline (false) an action the operator paused on.
/// Requires `conversation_id`. The operator never sends/acts without this.
/// Allow the operator to take destructive actions this turn — send email,
/// write to the CRM/knowledge base. Default false: it declines them and
/// tells you what it would have done, so you can re-run with approve=true.
#[serde(default)]
pub approve: Option<bool>,
/// Act on a specific org id. Defaults to your active org.
Expand Down Expand Up @@ -254,63 +250,37 @@ impl SmoothMcp {
/// Talk to your business — one turn of the Smooth Operator org agent.
///
/// The agent runs on your live Smoo org (CRM, inbox, knowledge, analytics)
/// and answers in plain language. It never sends email or takes a
/// destructive action without explicit approval: when it pauses on one,
/// this returns the pending action and a `conversation_id`; approve it by
/// calling again with `approve: true` and that `conversation_id`.
/// and answers in plain language, over its SEP WebSocket transport. It never
/// sends email or takes a destructive action unless you pass `approve: true`
/// — otherwise it declines the action, tells you what it would have done, and
/// returns a `conversation_id` so you can re-run with `approve: true` to
/// allow it (and continue the same thread).
///
/// # Errors
/// MCP error if not signed in to Smoo (user session), no active org, no
/// `message`/approval given, or the request fails.
/// `message`, or the operator call fails.
#[tool(
name = "ask_business",
description = "Talk to your business in plain language. Smooth Operator (the agent on your Smoo org) answers questions about revenue/CRM/knowledge and can draft — and, with your approval, send — email. Needs Sign in with Smoo (`th auth login`). To approve a paused action, call again with approve=true and the conversation_id it returned."
description = "Talk to your business in plain language. Smooth Operator (the agent on your Smoo org) answers questions about revenue/CRM/knowledge and can draft — and, only when you pass approve=true, send — email or other writes. Needs Sign in with Smoo (`th auth login`). Continue a thread by passing back the conversation_id it returns."
)]
pub async fn ask_business(&self, params: Parameters<AskBusinessArgs>) -> Result<String, ErrorData> {
let a = params.0;
// `from_user_session` IS the gate: the org agent is a user-only route and
// 401s under M2M, so this errors (with a login hint) when there's no
// Smoo user session.
let client = UserClient::from_user_session().await.map_err(|e| sign_in_err(&e))?;
let message = a
.message
.as_deref()
.filter(|m| !m.trim().is_empty())
.ok_or_else(|| ErrorData::invalid_params("Provide `message` to ask your business.".to_string(), None))?;
let org = crate::active_org::resolve(a.org).map_err(|e| ErrorData::invalid_request(format!("No active Smoo org. {e}"), None))?;

let turn = if let (Some(approve), Some(cid)) = (a.approve, a.conversation_id.as_deref()) {
// Resolve a paused action the operator asked us to confirm.
client
.post(
&format!("/organizations/{org}/smooth-operator/confirm"),
&json!({ "conversationId": cid, "approve": approve }),
)
.await
} else {
let message = a.message.as_deref().filter(|m| !m.trim().is_empty()).ok_or_else(|| {
ErrorData::invalid_params(
"Provide `message` to ask, or `approve` + `conversation_id` to resolve a pending action.".to_string(),
None,
)
})?;
let mut body = json!({ "message": message });
if let Some(cid) = &a.conversation_id {
body["conversationId"] = json!(cid);
}
client.post(&format!("/organizations/{org}/smooth-operator/chat"), &body).await
}
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;

// Defense-in-depth on the approval path: a paused action with no
// conversation id can't be approved, so fail loudly rather than emit a
// send-prompt the user can never act on. (The API always returns one;
// this only fires on a contract violation.)
let paused = turn.get("pendingAction").is_some_and(|v| !v.is_null());
let has_cid = turn.get("conversationId").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty());
if paused && !has_cid {
return Err(ErrorData::internal_error(
"The operator paused on an action but returned no conversation id, so it can't be approved. Try again.".to_string(),
None,
));
}
// The user session is minted into the SEP token inside `operator_turn`;
// it errors (with a `th auth login` hint) when there's no Smoo session.
// `approve` gates destructive tools inline: false (default) declines +
// surfaces; true allows them this turn.
let turn = crate::smooai::smooth_operator_ws::operator_turn(&org, message, a.conversation_id.as_deref(), a.approve.unwrap_or(false))
.await
.map_err(|e| ErrorData::internal_error(format!("{e:#}"), None))?;

Ok(render_turn(&turn))
Ok(crate::smooai::smooth_operator_ws::render_operator_turn(&turn))
}
}

Expand Down Expand Up @@ -343,37 +313,6 @@ fn sign_in_err(e: &anyhow::Error) -> ErrorData {
)
}

/// Render one Smooth Operator turn for the MCP client: the reply, any tools it
/// used, an approval prompt for a paused action, and the conversation id so the
/// caller can continue or approve.
fn render_turn(turn: &serde_json::Value) -> String {
let reply = turn.get("reply").and_then(|v| v.as_str()).unwrap_or_default();
let cid = turn.get("conversationId").and_then(|v| v.as_str()).unwrap_or_default();

let mut out = String::new();
if !reply.is_empty() {
out.push_str(reply);
out.push('\n');
}
if let Some(tools) = turn.get("toolCalls").and_then(|v| v.as_array()).filter(|t| !t.is_empty()) {
let names: Vec<&str> = tools.iter().filter_map(|t| t.get("name").and_then(|v| v.as_str())).collect();
if !names.is_empty() {
let _ = writeln!(out, "\n_(used: {})_", names.join(", "));
}
}
if let Some(pa) = turn.get("pendingAction").filter(|v| !v.is_null()) {
let summary = pa.get("summary").and_then(|v| v.as_str()).unwrap_or("an action");
let _ = writeln!(
out,
"\n⏸ Needs your approval before it happens: {summary}\n To approve, call ask_business again with approve=true and conversation_id=\"{cid}\" (or approve=false to decline)."
);
}
if !cid.is_empty() {
let _ = writeln!(out, "\n[conversation_id: {cid}]");
}
out.trim().to_string()
}

#[tool_handler(router = self.tool_router)]
impl ServerHandler for SmoothMcp {
fn get_info(&self) -> ServerInfo {
Expand Down Expand Up @@ -473,32 +412,4 @@ mod tests {
client.cancel().await.expect("client shutdown");
server.abort();
}

/// The turn renderer surfaces reply, tool calls, a pending-action approval
/// prompt, and the conversation id — the safety-critical "never send without
/// approval" path is a pure function, so test it directly.
#[test]
fn render_turn_surfaces_pending_action_and_conversation() {
let turn = serde_json::json!({
"conversationId": "c-42",
"reply": "Drafted the renewal.",
"toolCalls": [{ "name": "knowledge.search" }, { "name": "email.draft" }],
"pendingAction": { "name": "email.send", "summary": "send the renewal to Acme" }
});
let out = render_turn(&turn);
assert!(out.contains("Drafted the renewal."));
assert!(out.contains("knowledge.search") && out.contains("email.draft"));
assert!(out.contains("Needs your approval") && out.contains("send the renewal to Acme"));
assert!(out.contains("approve=true") && out.contains("c-42"));
assert!(out.contains("[conversation_id: c-42]"));
}

#[test]
fn render_turn_plain_reply_has_no_approval_prompt() {
let turn = serde_json::json!({ "conversationId": "c-1", "reply": "Revenue is up 12%." });
let out = render_turn(&turn);
assert!(out.contains("Revenue is up 12%."));
assert!(!out.contains("Needs your approval"));
assert!(out.contains("[conversation_id: c-1]"));
}
}
1 change: 1 addition & 0 deletions crates/smooth-cli/src/smooai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod observability;
pub mod products;
pub mod profile;
pub mod smooth_operator;
pub mod smooth_operator_ws;
pub mod teams;
pub mod testing;
pub mod user_client;
Expand Down
Loading
Loading