-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(apps): add support for MCP apps to sample #7039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+21
|
||||||||||||||||||||||||||||||
| .with_state(state) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| #[utoipa::path( | |
| post, | |
| path = "/sessions/{session_id}/sampling/message", | |
| params( | |
| ("session_id" = String, Path, description = "Session identifier") | |
| ), | |
| request_body = CreateMessageRequestParams, | |
| responses( | |
| (status = 200, description = "Sampling message created", body = CreateMessageResult), | |
| (status = 500, description = "Internal server error") | |
| ) | |
| )] |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this repo, rmcp sampling message content is handled as a list of blocks (msg.content.first() in crates/goose/src/agents/mcp_client.rs), but this route treats msg.content as a single Content; align the conversion with mcp_client.rs (iterate/take first block).
| content_to_message(base, &msg.content) | |
| if let Some(first_content) = msg.content.first() { | |
| content_to_message(base, first_content) | |
| } else { | |
| base | |
| } |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The request accepts maxTokens but it’s currently ignored (the provider call uses the default model config), so clients won’t be able to constrain output length as requested; either plumb request.max_tokens into an overridden ModelConfig via complete_with_model(...) or remove the parameter from the client contract.
Copilot
AI
Feb 13, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The max_tokens parameter from the sampling request is received but never used. The CreateMessageRequestParams includes this field, but it's not passed to the provider's complete method. This means MCP apps cannot control the maximum length of responses. Consider passing request.max_tokens to the provider if the API supports it, or document that this parameter is currently ignored.
Copilot
AI
Feb 13, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sampling route lacks test coverage. Other similar routes in the codebase (e.g., action_required.rs at lines 63-100, reply.rs at lines 468-506) have integration tests. Consider adding tests to verify the sampling endpoint works correctly, especially given the data transformation between MCP protocol types and the response format expected by the frontend.
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn content_to_message_with_empty_multiple_does_not_panic() { | |
| let base = Message::user(); | |
| let content: SamplingContent<SamplingMessageContent> = SamplingContent::Multiple(vec![]); | |
| let _result = content_to_message(base, &content); | |
| } | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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()]; | ||||||||||||||||||||||||||
|
Comment on lines
+27
to
+30
|
||||||||||||||||||||||||||
| 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()]; | |
| for html in [CLOCK_HTML, CHAT_HTML] { | |
| if let Ok(mut app) = GooseApp::from_html(html) { | |
| app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()]; | |
| if self | |
| .get_app(APPS_EXTENSION_NAME, &app.resource.uri) | |
| .is_none() | |
| { |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+124
to
+137
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+162
to
+165
|
||||||||||||||||||||||||||||||||||||||||||||||
| const responseText = response.content.text; | |
| conversationHistory.push({ role: 'assistant', content: { type: 'text', text: responseText } }); | |
| loadingEl.textContent = responseText; | |
| const message = response && response.message ? response.message : response; | |
| let responseText = ''; | |
| if (message && Array.isArray(message.content)) { | |
| const textPart = message.content.find( | |
| (part) => part && typeof part === 'object' && typeof part.text === 'string' | |
| ); | |
| if (textPart) { | |
| responseText = textPart.text; | |
| } | |
| } else if (message && message.content && typeof message.content === 'object') { | |
| if (typeof message.content.text === 'string') { | |
| responseText = message.content.text; | |
| } | |
| } | |
| conversationHistory.push({ role: 'assistant', content: { type: 'text', text: responseText } }); | |
| loadingEl.textContent = responseText || '[no response text]'; |
Copilot
AI
Feb 18, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The response from sampling/createMessage is an MCP CreateMessageResult with a nested message field, so response.content.text will be undefined and the demo UI won't render replies. Read response.message.content (and handle non-text content) or adjust the host to return the flattened shape.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This endpoint is intended to support image sampling, but there’s no explicit body-size limit layer here (unlike
/replyand dictation), so base64 image requests may be rejected by the default request body limit; consider adding aDefaultBodyLimit::max(...)appropriate for image payloads.