feat(apps): add support for MCP apps to sample (#7039)

Co-authored-by: Andrew Harvard <aharvard@block.xyz>
This commit is contained in:
Alex Hancock
2026-02-18 21:10:17 -05:00
committed by GitHub
parent f425ea7474
commit 3600c84e4b
6 changed files with 440 additions and 45 deletions
+2
View File
@@ -9,6 +9,7 @@ pub mod prompts;
pub mod recipe;
pub mod recipe_utils;
pub mod reply;
pub mod sampling;
pub mod schedule;
pub mod session;
pub mod setup;
@@ -39,4 +40,5 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
.merge(tunnel::routes(state.clone()))
.merge(mcp_ui_proxy::routes(secret_key.clone()))
.merge(mcp_app_proxy::routes(secret_key))
.merge(sampling::routes(state))
}
@@ -0,0 +1,87 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::post,
Json, Router,
};
use goose::conversation::message::Message;
use rmcp::model::{
CreateMessageRequestParams, CreateMessageResult, Role, SamplingContent, SamplingMessage,
SamplingMessageContent,
};
use std::sync::Arc;
use crate::state::AppState;
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route(
"/sessions/{session_id}/sampling/message",
post(create_message),
)
.with_state(state)
}
async fn create_message(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(request): Json<CreateMessageRequestParams>,
) -> Result<Json<CreateMessageResult>, StatusCode> {
let agent = state.get_agent_for_route(session_id.clone()).await?;
let provider = agent.provider().await.map_err(|e| {
tracing::error!("Failed to get provider: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let messages: Vec<Message> = request
.messages
.iter()
.map(|msg| {
let base = match msg.role {
Role::User => Message::user(),
Role::Assistant => Message::assistant(),
};
content_to_message(base, &msg.content)
})
.collect();
let system = request
.system_prompt
.as_deref()
.unwrap_or("You are a helpful AI assistant.");
let model_config = provider.get_model_config();
let (response, usage) = provider
.complete(&model_config, &session_id, system, &messages, &[])
.await
.map_err(|e| {
tracing::error!("Sampling completion failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let text = response.as_concat_text();
Ok(Json(CreateMessageResult {
model: usage.model,
stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()),
message: SamplingMessage::new(Role::Assistant, SamplingMessageContent::text(&text)),
}))
}
fn content_to_message(base: Message, content: &SamplingContent<SamplingMessageContent>) -> Message {
let items = match content {
SamplingContent::Single(item) => vec![item],
SamplingContent::Multiple(items) => items.iter().collect(),
};
let mut msg = base;
for item in items {
msg = match item {
SamplingMessageContent::Text(text) => msg.with_text(&text.text),
SamplingMessageContent::Image(image) => msg.with_image(&image.data, &image.mime_type),
_ => msg,
};
}
msg
}
+7 -4
View File
@@ -7,6 +7,7 @@ use tracing::warn;
use super::app::GooseApp;
static CLOCK_HTML: &str = include_str!("../goose_apps/clock.html");
static CHAT_HTML: &str = include_str!("../goose_apps/chat.html");
const APPS_EXTENSION_NAME: &str = "apps";
pub struct McpAppCache {
@@ -23,10 +24,12 @@ impl McpAppCache {
}
fn ensure_default_apps(&self) {
if self.get_app(APPS_EXTENSION_NAME, "apps://clock").is_none() {
if let Ok(mut clock_app) = GooseApp::from_html(CLOCK_HTML) {
clock_app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()];
let _ = self.store_app(&clock_app);
for (uri, html) in [("apps://clock", CLOCK_HTML), ("apps://chat", CHAT_HTML)] {
if self.get_app(APPS_EXTENSION_NAME, uri).is_none() {
if let Ok(mut app) = GooseApp::from_html(html) {
app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()];
let _ = self.store_app(&app);
}
}
}
}
+184
View File
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chat</title>
<script type="application/ld+json">
{
"@context": "https://goose.ai/schema",
"@type": "GooseApp",
"name": "chat",
"description": "Simple Chat UI",
"width": 400,
"height": 500,
"resizable": true
}
</script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
body { display: flex; flex-direction: column; background: #fff; }
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 80%;
padding: 10px 14px;
border-radius: 16px;
line-height: 1.4;
font-size: 14px;
word-wrap: break-word;
}
.message.user {
align-self: flex-end;
background: #000;
color: #fff;
}
.message.assistant {
align-self: flex-start;
background: #f0f0f0;
color: #000;
}
.message.loading {
font-style: italic;
color: #666;
}
.input-area {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid #e0e0e0;
background: #fafafa;
}
#messageInput {
flex: 1;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 14px;
outline: none;
}
#messageInput:focus { border-color: #999; }
#sendBtn {
padding: 10px 20px;
background: #000;
color: #fff;
border: none;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
}
#sendBtn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="Type a message..." />
<button id="sendBtn">Send</button>
</div>
<script>
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const conversationHistory = [];
const pendingRequests = new Map();
let requestId = 0;
function addMessage(role, text, isLoading = false) {
const div = document.createElement('div');
div.className = `message ${role}${isLoading ? ' loading' : ''}`;
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
function request(method, params) {
return new Promise((resolve, reject) => {
const id = ++requestId;
pendingRequests.set(id, { resolve, reject });
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
});
}
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || typeof data !== 'object') return;
if ('id' in data && pendingRequests.has(data.id)) {
const { resolve, reject } = pendingRequests.get(data.id);
pendingRequests.delete(data.id);
if (data.error) {
reject(new Error(data.error.message || 'Unknown error'));
} else {
resolve(data.result);
}
}
});
request('ui/initialize', {}).then(() => {
window.parent.postMessage({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} }, '*');
});
async function sendMessage() {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
sendBtn.disabled = true;
addMessage('user', text);
conversationHistory.push({ role: 'user', content: { type: 'text', text } });
const loadingEl = addMessage('assistant', 'Thinking...', true);
try {
const response = await request('sampling/createMessage', {
messages: conversationHistory,
systemPrompt: 'You are a helpful assistant. Keep responses concise.',
maxTokens: 1000
});
const responseText = response.content.text;
conversationHistory.push({ role: 'assistant', content: { type: 'text', text: responseText } });
loadingEl.textContent = responseText;
loadingEl.classList.remove('loading');
} catch (err) {
loadingEl.textContent = 'Error: ' + err.message;
loadingEl.classList.remove('loading');
}
sendBtn.disabled = false;
inputEl.focus();
}
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
inputEl.focus();
</script>
</body>
</html>