diff --git a/Cargo.lock b/Cargo.lock index 3c42665437..d460eace00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2647,6 +2647,7 @@ dependencies = [ "regex", "reqwest 0.12.12", "rmcp", + "schemars", "serde", "serde_json", "serde_urlencoded", diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 42df6681f1..695762df4e 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -466,6 +466,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, super::routes::recipe::SaveRecipeRequest, + super::routes::recipe::SaveRecipeResponse, super::routes::errors::ErrorResponse, super::routes::recipe::ParseRecipeRequest, super::routes::recipe::ParseRecipeResponse, @@ -489,7 +490,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::UpdateRouterToolSelectorRequest, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, - super::routes::agent::ErrorResponse, super::routes::setup::SetupResponse, )) )] diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index a6c76009e2..d488167ab5 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,3 +1,5 @@ +use crate::routes::errors::ErrorResponse; +use crate::routes::recipe_utils::{load_recipe_by_id, validate_recipe}; use crate::state::AppState; use axum::{ extract::{Query, State}, @@ -10,6 +12,7 @@ use goose::config::PermissionManager; use goose::model::ModelConfig; use goose::providers::create; use goose::recipe::{Recipe, Response}; +use goose::recipe_deeplink; use goose::session::{Session, SessionManager}; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, @@ -20,6 +23,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::Arc; +use tracing::error; #[derive(Deserialize, utoipa::ToSchema)] pub struct ExtendPromptRequest { @@ -70,7 +74,12 @@ pub struct UpdateRouterToolSelectorRequest { #[derive(Deserialize, utoipa::ToSchema)] pub struct StartAgentRequest { working_dir: String, + #[serde(default)] recipe: Option, + #[serde(default)] + recipe_id: Option, + #[serde(default)] + recipe_deeplink: Option, } #[derive(Deserialize, utoipa::ToSchema)] @@ -78,44 +87,92 @@ pub struct ResumeAgentRequest { session_id: String, } -#[derive(Serialize, utoipa::ToSchema)] -pub struct ErrorResponse { - error: String, -} - #[utoipa::path( post, path = "/agent/start", request_body = StartAgentRequest, responses( (status = 200, description = "Agent started successfully", body = Session), - (status = 400, description = "Bad request - invalid working directory"), + (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error") + (status = 500, description = "Internal server error", body = ErrorResponse) ) )] async fn start_agent( State(state): State>, Json(payload): Json, -) -> Result, StatusCode> { +) -> Result, ErrorResponse> { + let StartAgentRequest { + working_dir, + recipe, + recipe_id, + recipe_deeplink, + } = payload; + + let resolved_recipe = if let Some(deeplink) = recipe_deeplink { + match recipe_deeplink::decode(&deeplink) { + Ok(recipe) => Some(recipe), + Err(err) => { + error!("Failed to decode recipe deeplink: {}", err); + return Err(ErrorResponse { + message: err.to_string(), + status: StatusCode::BAD_REQUEST, + }); + } + } + } else if let Some(id) = recipe_id { + match load_recipe_by_id(state.as_ref(), &id).await { + Ok(recipe) => Some(recipe), + Err(err) => return Err(err), + } + } else { + recipe + }; + + if let Some(ref recipe) = resolved_recipe { + if let Err(err) = validate_recipe(recipe) { + return Err(ErrorResponse { + message: err.message, + status: err.status, + }); + } + } + let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; let description = format!("New session {}", counter); - let mut session = - SessionManager::create_session(PathBuf::from(&payload.working_dir), description) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut session = SessionManager::create_session(PathBuf::from(&working_dir), description) + .await + .map_err(|err| { + error!("Failed to create session: {}", err); + ErrorResponse { + message: format!("Failed to create session: {}", err), + status: StatusCode::BAD_REQUEST, + } + })?; - if let Some(recipe) = payload.recipe { + if let Some(recipe) = resolved_recipe { SessionManager::update_session(&session.id) .recipe(Some(recipe)) .apply() .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|err| { + error!("Failed to update session with recipe: {}", err); + ErrorResponse { + message: format!("Failed to update session with recipe: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; session = SessionManager::get_session(&session.id, false) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|err| { + error!("Failed to get updated session: {}", err); + ErrorResponse { + message: format!("Failed to get updated session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; } Ok(Json(session)) @@ -134,10 +191,16 @@ async fn start_agent( )] async fn resume_agent( Json(payload): Json, -) -> Result, StatusCode> { +) -> Result, ErrorResponse> { let session = SessionManager::get_session(&payload.session_id, true) .await - .map_err(|_| StatusCode::NOT_FOUND)?; + .map_err(|err| { + error!("Failed to resume session {}: {}", payload.session_id, err); + ErrorResponse { + message: format!("Failed to resume session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; Ok(Json(session)) } diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 1c6da4dbbb..5b39ccba86 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::fs; -use std::path::PathBuf; use std::sync::Arc; use axum::extract::rejection::JsonRejection; @@ -38,7 +37,10 @@ fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String { } use crate::routes::errors::ErrorResponse; -use crate::routes::recipe_utils::get_all_recipes_manifests; +use crate::routes::recipe_utils::{ + get_all_recipes_manifests, get_recipe_file_path_by_id, short_id_from_path, validate_recipe, + RecipeValidationError, +}; use crate::state::AppState; #[derive(Debug, Deserialize, ToSchema)] @@ -97,6 +99,11 @@ pub struct SaveRecipeRequest { recipe: Recipe, id: Option, } + +#[derive(Debug, Serialize, ToSchema)] +pub struct SaveRecipeResponse { + id: String, +} #[derive(Debug, Deserialize, ToSchema)] pub struct ParseRecipeRequest { pub content: String, @@ -231,7 +238,10 @@ async fn decode_recipe( Json(request): Json, ) -> Result, StatusCode> { match recipe_deeplink::decode(&request.deeplink) { - Ok(recipe) => Ok(Json(DecodeRecipeResponse { recipe })), + Ok(recipe) => match validate_recipe(&recipe) { + Ok(_) => Ok(Json(DecodeRecipeResponse { recipe })), + Err(RecipeValidationError { status, .. }) => Err(status), + }, Err(err) => { tracing::error!("Failed to decode deeplink: {}", err); Err(StatusCode::BAD_REQUEST) @@ -309,7 +319,7 @@ async fn delete_recipe( State(state): State>, Json(request): Json, ) -> StatusCode { - let file_path = match get_recipe_file_path_by_id(state.clone(), &request.id).await { + let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await { Ok(path) => path, Err(err) => return err.status, }; @@ -326,8 +336,10 @@ async fn delete_recipe( path = "/recipes/save", request_body = SaveRecipeRequest, responses( - (status = 204, description = "Recipe saved to file successfully"), + (status = 204, description = "Recipe saved to file successfully", body = SaveRecipeResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "Recipe Management" @@ -335,18 +347,20 @@ async fn delete_recipe( async fn save_recipe( State(state): State>, payload: Result, JsonRejection>, -) -> Result { +) -> Result, ErrorResponse> { let Json(raw_json) = payload.map_err(json_rejection_to_error_response)?; let request = deserialize_save_recipe_request(raw_json)?; - validate_recipe(&request.recipe)?; + ensure_recipe_valid(&request.recipe)?; let file_path = match request.id.as_ref() { - Some(id) => Some(get_recipe_file_path_by_id(state.clone(), id).await?), + Some(id) => Some(get_recipe_file_path_by_id(state.as_ref(), id).await?), None => None, }; - match local_recipes::save_recipe_to_file(request.recipe, file_path) { - Ok(_) => Ok(StatusCode::NO_CONTENT), + match local_recipes::save_recipe_to_file(request.recipe, file_path.clone()) { + Ok(save_file_path) => Ok(Json(SaveRecipeResponse { + id: short_id_from_path(&save_file_path.display().to_string()), + })), Err(e) => Err(ErrorResponse { message: e.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, @@ -361,16 +375,13 @@ fn json_rejection_to_error_response(rejection: JsonRejection) -> ErrorResponse { } } -fn validate_recipe(recipe: &Recipe) -> Result<(), ErrorResponse> { - let recipe_json = serde_json::to_string(recipe).map_err(|err| ErrorResponse { - message: err.to_string(), - status: StatusCode::BAD_REQUEST, - })?; - - validate_recipe_template_from_content(&recipe_json, None).map_err(|err| ErrorResponse { - message: err.to_string(), - status: StatusCode::BAD_REQUEST, - })?; +fn ensure_recipe_valid(recipe: &Recipe) -> Result<(), ErrorResponse> { + if let Err(err) = validate_recipe(recipe) { + return Err(ErrorResponse { + message: err.message, + status: err.status, + }); + } Ok(()) } @@ -398,41 +409,6 @@ fn deserialize_save_recipe_request(value: Value) -> Result, - id: &str, -) -> Result { - let cached_path = { - let map = state.recipe_file_hash_map.lock().await; - map.get(id).cloned() - }; - - if let Some(path) = cached_path { - return Ok(path); - } - - let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default(); - let mut recipe_file_hash_map = HashMap::new(); - let mut resolved_path: Option = None; - - for recipe_manifest_with_path in &recipe_manifest_with_paths { - if recipe_manifest_with_path.id == id { - resolved_path = Some(recipe_manifest_with_path.file_path.clone()); - } - recipe_file_hash_map.insert( - recipe_manifest_with_path.id.clone(), - recipe_manifest_with_path.file_path.clone(), - ); - } - - state.set_recipe_file_hash_map(recipe_file_hash_map).await; - - resolved_path.ok_or_else(|| ErrorResponse { - message: format!("Recipe not found: {}", id), - status: StatusCode::NOT_FOUND, - }) -} - #[utoipa::path( post, path = "/recipes/parse", diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 49181a2469..2c4b02a3ed 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -1,12 +1,24 @@ +use std::collections::HashMap; use std::fs; use std::hash::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use anyhow::Result; +use axum::http::StatusCode; +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; use goose::recipe::local_recipes::list_local_recipes; +use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; +use serde_json; +use tracing::error; + +pub struct RecipeValidationError { + pub status: StatusCode, + pub message: String, +} pub struct RecipeManifestWithPath { pub id: String, @@ -15,7 +27,7 @@ pub struct RecipeManifestWithPath { pub last_modified: String, } -fn short_id_from_path(path: &str) -> String { +pub fn short_id_from_path(path: &str) -> String { let mut hasher = DefaultHasher::new(); path.hash(&mut hasher); let h = hasher.finish(); @@ -44,3 +56,69 @@ pub fn get_all_recipes_manifests() -> Result> { Ok(recipe_manifests_with_path) } + +pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> { + let recipe_json = serde_json::to_string(recipe).map_err(|err| { + let message = err.to_string(); + error!("Failed to serialize recipe for validation: {}", message); + RecipeValidationError { + status: StatusCode::BAD_REQUEST, + message, + } + })?; + + validate_recipe_template_from_content(&recipe_json, None).map_err(|err| { + let message = err.to_string(); + error!("Recipe validation failed: {}", message); + RecipeValidationError { + status: StatusCode::BAD_REQUEST, + message, + } + })?; + + Ok(()) +} + +pub async fn get_recipe_file_path_by_id( + state: &AppState, + id: &str, +) -> Result { + let cached_path = { + let map = state.recipe_file_hash_map.lock().await; + map.get(id).cloned() + }; + + if let Some(path) = cached_path { + return Ok(path); + } + + let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default(); + let mut recipe_file_hash_map = HashMap::new(); + let mut resolved_path: Option = None; + + for recipe_manifest_with_path in &recipe_manifest_with_paths { + if recipe_manifest_with_path.id == id { + resolved_path = Some(recipe_manifest_with_path.file_path.clone()); + } + recipe_file_hash_map.insert( + recipe_manifest_with_path.id.clone(), + recipe_manifest_with_path.file_path.clone(), + ); + } + + state.set_recipe_file_hash_map(recipe_file_hash_map).await; + + resolved_path.ok_or_else(|| ErrorResponse { + message: format!("Recipe not found: {}", id), + status: StatusCode::NOT_FOUND, + }) +} + +pub async fn load_recipe_by_id(state: &AppState, id: &str) -> Result { + let path = get_recipe_file_path_by_id(state, id).await?; + + Recipe::from_file_path(&path).map_err(|err| ErrorResponse { + message: format!("Failed to load recipe: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + }) +} diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 8ef8c2c552..d022cdde68 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -104,6 +104,7 @@ oauth2 = "5.0.0" sysinfo = "0.37.2" zip = "6.0.0" which = "8.0.0" +schemars = { version = "1.0.4", default-features = false, features = ["derive"] } [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs b/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs index 4174fc1055..721ccd45dd 100644 --- a/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs +++ b/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs @@ -12,72 +12,96 @@ use crate::agents::tool_execution::ToolCallResult; use crate::recipe::{Recipe, RecipeBuilder}; use anyhow::{anyhow, Result}; use rmcp::model::{Content, ErrorCode, ErrorData, Tool, ToolAnnotations}; -use rmcp::object; +use rmcp::schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::borrow::Cow; pub const DYNAMIC_TASK_TOOL_NAME_PREFIX: &str = "dynamic_task__create_task"; +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CreateDynamicTaskParams { + /// Array of tasks. Each task must have either 'instructions' OR 'prompt' field (at least one is required). + #[schemars(length(min = 1))] + pub task_parameters: Vec, + + /// How to execute multiple tasks (default: parallel for multiple tasks, sequential for single task) + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_mode: Option, +} + +/// Execution mode for tasks +#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum ExecutionModeParam { + Sequential, + Parallel, +} + +impl From for ExecutionMode { + fn from(mode: ExecutionModeParam) -> Self { + match mode { + ExecutionModeParam::Sequential => ExecutionMode::Sequential, + ExecutionModeParam::Parallel => ExecutionMode::Parallel, + } + } +} + +/// Parameters for a single task +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct TaskParameter { + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub retry: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub activities: Option>, + + /// If true, return only the last message from the subagent (default: false, returns full conversation) + #[serde(skip_serializing_if = "Option::is_none")] + pub return_last_only: Option, +} + pub fn create_dynamic_task_tool() -> Tool { + let schema = schema_for!(CreateDynamicTaskParams); + let schema_value = + serde_json::to_value(schema).expect("Failed to serialize CreateDynamicTaskParams schema"); + + let input_schema = schema_value + .as_object() + .expect("Schema should be an object") + .clone(); + Tool::new( DYNAMIC_TASK_TOOL_NAME_PREFIX.to_string(), "Create tasks with instructions or prompt. For simple tasks, only include the instructions field. Extensions control: omit field = use all current extensions; empty array [] = no extensions; array with names = only those extensions. Specify extensions as shortnames (the prefixes for your tools). Specify return_last_only as true and have your subagent summarize its work in its last message to conserve your own context. Optional: title, description, extensions, settings, retry, response schema, context, activities. Arrays for multiple tasks.".to_string(), - object!({ - "type": "object", - "properties": { - "task_parameters": { - "type": "array", - "description": "Array of tasks. Each task must have either 'instructions' OR 'prompt' field (at least one is required).", - "items": { - "type": "object", - "properties": { - // Either instructions or prompt is required (validated at runtime) - "instructions": { - "type": "string", - "description": "Task instructions (required if prompt is not provided)" - }, - "prompt": { - "type": "string", - "description": "Initial prompt (required if instructions is not provided)" - }, - // Optional - auto-generated if not provided - "title": {"type": "string"}, - "description": {"type": "string"}, - "extensions": { - "type": "array", - "items": {"type": "object"} - }, - "settings": {"type": "object"}, - "parameters": { - "type": "array", - "items": {"type": "object"} - }, - "response": {"type": "object"}, - "retry": {"type": "object"}, - "context": { - "type": "array", - "items": {"type": "string"} - }, - "activities": { - "type": "array", - "items": {"type": "string"} - }, - "return_last_only": { - "type": "boolean", - "description": "If true, return only the last message from the subagent (default: false, returns full conversation)" - } - } - }, - "minItems": 1 - }, - "execution_mode": { - "type": "string", - "enum": ["sequential", "parallel"], - "description": "How to execute multiple tasks (default: parallel for multiple tasks, sequential for single task)" - } - }, - "required": ["task_parameters"] - }) + input_schema, ).annotate(ToolAnnotations { title: Some("Create Dynamic Tasks".to_string()), read_only_hint: Some(false), diff --git a/crates/goose/src/recipe_deeplink.rs b/crates/goose/src/recipe_deeplink.rs index 7123f4ef3e..fc6779beb8 100644 --- a/crates/goose/src/recipe_deeplink.rs +++ b/crates/goose/src/recipe_deeplink.rs @@ -6,7 +6,7 @@ use crate::recipe::Recipe; #[derive(Error, Debug)] pub enum DecodeError { - #[error("All decoding methods failed")] + #[error("Failed to decode recipe deeplink")] AllMethodsFailed, } diff --git a/documentation/blog/2025-10-14-designing-ai-for-humans/design-ai.png b/documentation/blog/2025-10-14-designing-ai-for-humans/design-ai.png new file mode 100644 index 0000000000..94fafe657a Binary files /dev/null and b/documentation/blog/2025-10-14-designing-ai-for-humans/design-ai.png differ diff --git a/documentation/blog/2025-10-14-designing-ai-for-humans/index.md b/documentation/blog/2025-10-14-designing-ai-for-humans/index.md new file mode 100644 index 0000000000..dff60c373e --- /dev/null +++ b/documentation/blog/2025-10-14-designing-ai-for-humans/index.md @@ -0,0 +1,70 @@ +--- +title: "Designing AI for Users, Not Just LLMs" +description: Building intent-based AI experiences with MCP-UI. +authors: + - ebony +--- + +![Designing AI For Users](design-ai.png) + + +My mom was doing her usual Sunday ritual she had her pen, paper, calculator, and a pile of receipts. I’ve tried to get her to use every budgeting app out there, but she’s old school and always says the same thing: +> “They’re all too complicated.” + + + +Last month, halfway through crunching numbers, she sighed and said, +> “I just wish I could see where my money’s going.” + +So I opened [**goose**](http://block.github.io/goose/docs/quickstart), added her notes, and turned on [**Auto Visualiser**](https://block.github.io/goose/docs/mcp/autovisualiser-mcp). In seconds, her budget became this colorful, interactive chart. She hovered over each slice, analyzing where her money was going. + +Now, I'm not saying this is some groundbreaking use case. There are plenty of apps that do this. What stood out to me was how simple it felt. My mom didn’t need to learn anything new or adapt to someone else’s design. The visualization just appeared, and she got it immediately. + +It wasn’t about the tech working. It was about AI finally showing up in a way that made sense to her. + +### **When AI Starts Showing, Not Just Talking** + +We’ve made huge progress with agentic AI. Agents can plan, reason, and act, but they often still communicate like terminals with walls of text, and no real interaction. That’s where [**MCP-UI**](https://mcpui.dev/guide/getting-started) changes everything. + +MCP-UI gives agents a visual language *and* a way for users to interact directly within the chat window. Before this, conversations with AI consisted of a chain of prompts and responses. Now, users can actually engage with their agent through the interface itself. A button can launch a new prompt without the user typing anything. A dropdown can run a tool call in the background. A link can open a page or resource for them instantly. Even notifications can be exchanged between this embedded UI and the host application to keep everything in sync. + +This is what turns an AI chat into an interface layer. Instead of describing what it can do, the agent can present real, clickable options and respond to them in real time. That makes AI conversations feel fluid and interactive. + +Instead of saying, ‘Here’s your data,’ your agent can show it, let you act on it, and react to what you choose. + + +### **Why This Matters for Builders** + +As developers, we often design for the model first, thinking about prompt structure or JSON formatting. But the next big step for agents is how well they can anticipate what the user wants to do and needs to see. + +We’re already seeing this shift across the industry. Google’s new AI Mode is transforming search into an intent-driven experience. Instead of sending users to links, it now shows tickets, seat maps, and purchase buttons directly in the results. The web is becoming dynamic, adapting to what users are trying to do. + +MCP-UI brings that same evolution into the agent world. It extends the [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) so your MCP server can return more than just data. It can render interactive components right inside your agent's chat. Whether it’s a chart, button, table, or form, the agent can display live, usable views of your service instead of describing them in text. + +goose’s built-in Auto Visualiser is one example of this in action, automatically turning structured output into interactive visuals using MCP-UI behind the scenes. + +But the potential goes much further. When developers build their own MCP servers, they get full control over *how* their data appears. They can design interfaces that reflect their brand or product style, ensuring users interacting with their API through an AI agent still get a familiar experience. Imagine a Shopify MCP server returning product listings that look like their storefront, or a Notion MCP server displaying content in its block layout inside the chat. + +Both point to the same future: one where AI doesn’t just reply with text, but responds with the right interface for the moment. Instead of fixed screens, we get dynamic, intent-based experiences that adapt to what the user needs in real time. + +That’s what Agentic UX is really about: building AI that responds to what the user intends to do. + +If you’re building your own MCP server, start by thinking about the experience you want your users to have. Then experiment with MCP-UI and design the flow you’d expect if you were the one using it. For a full walkthrough, see [How To Make An MCP Server MCP-UI Compatible](https://block.github.io/goose/blog/2025/09/08/turn-any-mcp-server-mcp-ui-compatible). + +--- + +I have a feeling that next month, when my mom sits down to balance her bills, she’s going to ask me: +> “You brought that goose thing again?” + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index cb12e582cf..29cc112d84 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -191,13 +191,27 @@ } }, "400": { - "description": "Bad request - invalid working directory" + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "401": { "description": "Unauthorized - invalid secret key" }, "500": { - "description": "Internal server error" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } @@ -1235,10 +1249,34 @@ }, "responses": { "204": { - "description": "Recipe saved to file successfully" + "description": "Recipe saved to file successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeResponse" + } + } + } }, "401": { - "description": "Unauthorized - Invalid or missing API key" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "500": { "description": "Internal server error", @@ -2485,10 +2523,10 @@ "ErrorResponse": { "type": "object", "required": [ - "error" + "message" ], "properties": { - "error": { + "message": { "type": "string" } } @@ -3978,6 +4016,17 @@ } } }, + "SaveRecipeResponse": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "ScanRecipeRequest": { "type": "object", "required": [ @@ -4310,6 +4359,14 @@ ], "nullable": true }, + "recipe_deeplink": { + "type": "string", + "nullable": true + }, + "recipe_id": { + "type": "string", + "nullable": true + }, "working_dir": { "type": "string" } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a20a4f34df..e8e9797db8 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -177,7 +177,7 @@ export type Envs = { }; export type ErrorResponse = { - error: string; + message: string; }; export type ExtendPromptRequest = { @@ -667,6 +667,10 @@ export type SaveRecipeRequest = { recipe: Recipe; }; +export type SaveRecipeResponse = { + id: string; +}; + export type ScanRecipeRequest = { recipe: Recipe; }; @@ -764,6 +768,8 @@ export type SetupResponse = { export type StartAgentRequest = { recipe?: Recipe | null; + recipe_deeplink?: string | null; + recipe_id?: string | null; working_dir: string; }; @@ -1041,9 +1047,9 @@ export type StartAgentData = { export type StartAgentErrors = { /** - * Bad request - invalid working directory + * Bad request */ - 400: unknown; + 400: ErrorResponse; /** * Unauthorized - invalid secret key */ @@ -1051,9 +1057,11 @@ export type StartAgentErrors = { /** * Internal server error */ - 500: unknown; + 500: ErrorResponse; }; +export type StartAgentError = StartAgentErrors[keyof StartAgentErrors]; + export type StartAgentResponses = { /** * Agent started successfully @@ -1873,9 +1881,13 @@ export type SaveRecipeData = { export type SaveRecipeErrors = { /** - * Unauthorized - Invalid or missing API key + * Unauthorized */ - 401: unknown; + 401: ErrorResponse; + /** + * Not found + */ + 404: ErrorResponse; /** * Internal server error */ @@ -1888,10 +1900,10 @@ export type SaveRecipeResponses = { /** * Recipe saved to file successfully */ - 204: void; + 204: SaveRecipeResponse; }; -export type SaveRecipeResponse = SaveRecipeResponses[keyof SaveRecipeResponses]; +export type SaveRecipeResponse2 = SaveRecipeResponses[keyof SaveRecipeResponses]; export type ScanRecipeData = { body: ScanRecipeRequest; diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index 3f02f062e0..2e0eadb592 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -295,7 +295,7 @@ export default function CreateEditRecipeModal({ try { const recipe = getCurrentRecipe(); - await saveRecipe(recipe, recipeId); + let saved_recipe_id = await saveRecipe(recipe, recipeId); // Close modal first onClose(true); @@ -306,9 +306,9 @@ export default function CreateEditRecipeModal({ undefined, undefined, undefined, - recipe, undefined, - recipeId ?? undefined + undefined, + saved_recipe_id ); toastSuccess({ diff --git a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx index 2c6c2b916d..9f36ff1d2a 100644 --- a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx +++ b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx @@ -183,13 +183,21 @@ export default function CreateRecipeFromSessionModal({ extensions: [], // Will be populated based on current extensions }; - await saveRecipe(recipe, null); + let recipeId = await saveRecipe(recipe, null); onRecipeCreated?.(recipe); onClose(); if (runAfterSave) { - window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe); + window.electron.createChatWindow( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + recipeId + ); } } catch (error) { console.error('Failed to create recipe:', error); diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index cb9716cfce..b5907ec9ce 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -47,15 +47,23 @@ export function useAgent(): UseAgentReturn { const [agentState, setAgentState] = useState(AgentState.UNINITIALIZED); const [sessionId, setSessionId] = useState(null); const initPromiseRef = useRef | null>(null); - const [recipeFromAppConfig, setRecipeFromAppConfig] = useState( - (window.appConfig.get('recipe') as Recipe) || null + const recipeIdFromConfig = useRef( + (window.appConfig.get('recipeId') as string | null | undefined) ?? null + ); + const recipeDeeplinkFromConfig = useRef( + (window.appConfig.get('recipeDeeplink') as string | null | undefined) ?? null + ); + const scheduledJobIdFromConfig = useRef( + (window.appConfig.get('scheduledJobId') as string | null | undefined) ?? null ); const { getExtensions, addExtension, read } = useConfig(); const resetChat = useCallback(() => { setSessionId(null); setAgentState(AgentState.UNINITIALIZED); - setRecipeFromAppConfig(null); + recipeIdFromConfig.current = null; + recipeDeeplinkFromConfig.current = null; + scheduledJobIdFromConfig.current = null; }, []); const agentIsInitialized = agentState === AgentState.INITIALIZED; @@ -110,7 +118,11 @@ export function useAgent(): UseAgentReturn { : await startAgent({ body: { working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, - recipe: recipeFromAppConfig ?? initContext.recipe, + ...buildRecipeInput( + initContext.recipe, + recipeIdFromConfig.current, + recipeDeeplinkFromConfig.current + ), }, throwOnError: true, }); @@ -121,6 +133,18 @@ export function useAgent(): UseAgentReturn { } setSessionId(agentSession.id); + if (!initContext.recipe && agentSession.recipe && scheduledJobIdFromConfig.current) { + agentSession.recipe = { + ...agentSession.recipe, + scheduledJobId: scheduledJobIdFromConfig.current, + isScheduledExecution: true, + } as Recipe; + scheduledJobIdFromConfig.current = null; + } + + recipeIdFromConfig.current = null; + recipeDeeplinkFromConfig.current = null; + agentWaitingMessage('Agent is loading config'); await initConfig(); @@ -169,10 +193,17 @@ export function useAgent(): UseAgentReturn { return initChat; } catch (error) { - if ((error + '').includes('Failed to create provider')) { + if ( + (error + '').includes('Failed to create provider') || + error instanceof NoProviderOrModelError + ) { setAgentState(AgentState.NO_PROVIDER); - } else { - setAgentState(AgentState.ERROR); + throw error; + } + setAgentState(AgentState.ERROR); + if (typeof error === 'object' && error !== null && 'message' in error) { + let error_message = error.message as string; + throw new Error(error_message); } throw error; } finally { @@ -184,7 +215,7 @@ export function useAgent(): UseAgentReturn { initPromiseRef.current = initPromise; return initPromise; }, - [agentIsInitialized, sessionId, read, recipeFromAppConfig, getExtensions, addExtension] + [agentIsInitialized, sessionId, read, getExtensions, addExtension] ); return { @@ -220,3 +251,23 @@ const handleConfigRecovery = async () => { } } }; + +const buildRecipeInput = ( + recipeOverride?: Recipe, + recipeId?: string | null, + recipeDeeplink?: string | null +) => { + if (recipeId) { + return { recipe_id: recipeId }; + } + + if (recipeDeeplink) { + return { recipe_deeplink: recipeDeeplink }; + } + + if (recipeOverride) { + return { recipe: recipeOverride }; + } + + return {}; +}; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index 4b8bd5ce1f..c9801af026 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -68,13 +68,6 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { } return; } - - // If we have a recipe from app config (deeplink), persist it - // But only if the chat context doesn't explicitly have null (which indicates it was cleared) - const appRecipe = window.appConfig.get('recipe') as Recipe | null; - if (appRecipe && chatContext.chat.recipe === undefined) { - chatContext.setRecipe(appRecipe); - } }, [chatContext, recipe]); useEffect(() => { @@ -87,18 +80,6 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { if (finalRecipe) { hasCheckedRecipeRef.current = true; - // If the recipe comes from session metadata (not from navigation state), - // it means it was already accepted in a previous session, so auto-accept it - const hasMessages = chat.messages.length > 0; - const isFromSessionMetadata = !recipe && finalRecipe && hasMessages; - - if (isFromSessionMetadata) { - // Recipe loaded from session metadata should be automatically accepted - setRecipeAccepted(true); - setIsRecipeWarningModalOpen(false); - return; - } - try { const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipe); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f32d57340e..6907e824f2 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -50,25 +50,9 @@ import { import { UPDATES_ENABLED } from './updates'; import { Recipe } from './recipe'; import './utils/recipeHash'; -import { decodeRecipe } from './api'; import { Client, createClient, createConfig } from './api/client'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; -async function decodeRecipeMain(client: Client, deeplink: string): Promise { - try { - return ( - await decodeRecipe({ - client, - throwOnError: true, - body: { deeplink }, - }) - ).data.recipe; - } catch (e) { - console.error('Failed to decode recipe:', e); - } - return null; -} - // Updater functions (moved here to keep updates.ts minimal for release replacement) function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled @@ -544,12 +528,6 @@ const createChat = async ( } // Create window config with loading state for recipe deeplinks - let isLoadingRecipe = false; - if (!recipe && recipeDeeplink) { - isLoadingRecipe = true; - console.log('[Main] Creating window with recipe loading state for deeplink:', recipeDeeplink); - } - // Load and manage window state const mainWindowState = windowStateKeeper({ defaultWidth: 940, // large enough to show the sidebar on launch @@ -584,8 +562,9 @@ const createChat = async ( REQUEST_DIR: dir, GOOSE_BASE_URL_SHARE: baseUrlShare, GOOSE_VERSION: version, - recipe: recipe, recipeId: recipeId, + recipeDeeplink: recipeDeeplink, + scheduledJobId: scheduledJobId, }), ], partition: 'persist:goose', // Add this line to ensure persistence @@ -697,7 +676,10 @@ const createChat = async ( if (viewType) { appPath = routeMap[viewType] || '/'; } - if (appPath === '/' && (recipe !== undefined || recipeDeeplink !== undefined)) { + if ( + appPath === '/' && + (recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined) + ) { appPath = '/pair'; } @@ -747,37 +729,6 @@ const createChat = async ( windowMap.set(windowId, mainWindow); - // Handle recipe decoding in the background after window is created - if (isLoadingRecipe && recipeDeeplink) { - console.log('[Main] Starting background recipe decoding for:', recipeDeeplink); - - // Decode recipe asynchronously after window is created - decodeRecipeMain(goosedClient, recipeDeeplink) - .then((decodedRecipe) => { - if (decodedRecipe) { - console.log('[Main] Recipe decoded successfully, updating window config'); - - // Handle scheduled job parameters if present - if (scheduledJobId) { - decodedRecipe.scheduledJobId = scheduledJobId; - decodedRecipe.isScheduledExecution = true; - } - - // Send the decoded recipe to the renderer process - mainWindow.webContents.send('recipe-decoded', decodedRecipe); - } else { - console.error('[Main] Failed to decode recipe from deeplink'); - // Send error to renderer - mainWindow.webContents.send('recipe-decode-error', 'Failed to decode recipe'); - } - }) - .catch((error) => { - console.error('[Main] Error decoding recipe:', error); - // Send error to renderer - mainWindow.webContents.send('recipe-decode-error', error.message || 'Unknown error'); - }); - } - // Handle window closure mainWindow.on('closed', () => { windowMap.delete(windowId); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 4c103a61bf..58f1b6c5b7 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -255,11 +255,6 @@ const appConfigAPI: AppConfigAPI = { getAll: () => config, }; -// Listen for recipe updates and update config directly -ipcRenderer.on('recipe-decoded', (_, decodedRecipe) => { - config.recipe = decodedRecipe; -}); - // Expose the APIs contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('appConfig', appConfigAPI); diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 35083f44ec..6117827b53 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,14 +1,15 @@ import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api'; -export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise { +export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise { try { - await saveRecipeApi({ + let response = await saveRecipeApi({ body: { recipe, id: recipeId, }, throwOnError: true, }); + return response.data.id; } catch (error) { let error_message = 'unknown error'; if (typeof error === 'object' && error !== null && 'message' in error) { diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index d496a8959a..e707e08fd0 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -225,7 +225,7 @@ export const initializeSystem = async ( } // Get recipe - prefer from options (session metadata) over app config - const recipe = options?.recipe || window.appConfig?.get?.('recipe'); + const recipe = options?.recipe; const recipe_instructions = (recipe as { instructions?: string })?.instructions; const responseConfig = (recipe as { response?: { json_schema?: unknown } })?.response; const subRecipes = (recipe as { sub_recipes?: SubRecipe[] })?.sub_recipes;