Merge branch 'main' into micn/embedded-llama-server

This commit is contained in:
Michael Neale
2025-10-16 09:29:30 +11:00
committed by GitHub
20 changed files with 510 additions and 241 deletions
Generated
+1
View File
@@ -2647,6 +2647,7 @@ dependencies = [
"regex",
"reqwest 0.12.12",
"rmcp",
"schemars",
"serde",
"serde_json",
"serde_urlencoded",
+1 -1
View File
@@ -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,
))
)]
+80 -17
View File
@@ -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<Recipe>,
#[serde(default)]
recipe_id: Option<String>,
#[serde(default)]
recipe_deeplink: Option<String>,
}
#[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<Arc<AppState>>,
Json(payload): Json<StartAgentRequest>,
) -> Result<Json<Session>, StatusCode> {
) -> Result<Json<Session>, 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<ResumeAgentRequest>,
) -> Result<Json<Session>, StatusCode> {
) -> Result<Json<Session>, 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))
}
+31 -55
View File
@@ -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<String>,
}
#[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<DecodeRecipeRequest>,
) -> Result<Json<DecodeRecipeResponse>, 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<Arc<AppState>>,
Json(request): Json<DeleteRecipeRequest>,
) -> 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<Arc<AppState>>,
payload: Result<Json<Value>, JsonRejection>,
) -> Result<StatusCode, ErrorResponse> {
) -> Result<Json<SaveRecipeResponse>, 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<SaveRecipeRequest, Er
})
}
async fn get_recipe_file_path_by_id(
state: Arc<AppState>,
id: &str,
) -> Result<PathBuf, ErrorResponse> {
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<PathBuf> = 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",
+79 -1
View File
@@ -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<Vec<RecipeManifestWithPath>> {
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<PathBuf, ErrorResponse> {
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<PathBuf> = 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<Recipe, ErrorResponse> {
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,
})
}
+1
View File
@@ -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"] }
@@ -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<TaskParameter>,
/// 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<ExecutionModeParam>,
}
/// Execution mode for tasks
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionModeParam {
Sequential,
Parallel,
}
impl From<ExecutionModeParam> 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activities: Option<Vec<String>>,
/// 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<bool>,
}
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),
+1 -1
View File
@@ -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,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

@@ -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.”
<!--truncate-->
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?”
<head>
<meta property="og:title" content="Designing AI for Users, Not Just LLMs" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://block.github.io/goose/blog/2025/10/14/designing-ai-for-humans" />
<meta property="og:description" content="Building intent-based AI experiences with MCP-UI." />
<meta property="og:image" content="https://block.github.io/goose/assets/images/design-ai-de5d0af69d8d21111dd271624ac7cab3.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Designing AI for Users, Not Just LLMs" />
<meta name="twitter:description" content="Building intent-based AI experiences with MCP-UI." />
<meta property="twitter:domain" content="block.github.io/goose" />
<meta name="twitter:image" content="https://block.github.io/goose/assets/images/design-ai-de5d0af69d8d21111dd271624ac7cab3.png" />
</head>
+63 -6
View File
@@ -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"
}
+20 -8
View File
@@ -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;
@@ -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({
@@ -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);
+59 -8
View File
@@ -47,15 +47,23 @@ export function useAgent(): UseAgentReturn {
const [agentState, setAgentState] = useState<AgentState>(AgentState.UNINITIALIZED);
const [sessionId, setSessionId] = useState<string | null>(null);
const initPromiseRef = useRef<Promise<ChatType> | null>(null);
const [recipeFromAppConfig, setRecipeFromAppConfig] = useState<Recipe | null>(
(window.appConfig.get('recipe') as Recipe) || null
const recipeIdFromConfig = useRef<string | null>(
(window.appConfig.get('recipeId') as string | null | undefined) ?? null
);
const recipeDeeplinkFromConfig = useRef<string | null>(
(window.appConfig.get('recipeDeeplink') as string | null | undefined) ?? null
);
const scheduledJobIdFromConfig = useRef<string | null>(
(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 {};
};
-19
View File
@@ -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);
+6 -55
View File
@@ -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<Recipe | null> {
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);
-5
View File
@@ -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);
+3 -2
View File
@@ -1,14 +1,15 @@
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api';
export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise<void> {
export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise<string> {
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) {
+1 -1
View File
@@ -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;