feat(apps): add support for MCP apps to sample#7039
Conversation
|
cc @liady |
There was a problem hiding this comment.
Pull request overview
Adds MCP “sampling” support so sandboxed MCP Apps can ask the running goosed session/provider to generate model responses, and includes a simple built-in chat MCP App as a demo.
Changes:
- Adds a new MCP method (
sampling/createMessage) to the desktop MCP Apps bridge and types. - Adds a new
goose-serverroute (POST /sessions/{id}/sampling/message) to run provider completions for sampling. - Adds a built-in
chatMCP App and wires it into the default apps cache.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/desktop/src/components/McpApps/types.ts | Introduces sampling request/response types for MCP Apps. |
| ui/desktop/src/components/McpApps/McpAppRenderer.tsx | Forwards sampling/createMessage from the iframe to goosed via authenticated HTTP. |
| crates/goose/src/goose_apps/chat.html | New built-in demo MCP App that uses sampling to implement a simple chat UI. |
| crates/goose/src/goose_apps/cache.rs | Adds the new chat app to the default apps prepopulation logic. |
| crates/goose-server/src/routes/sampling.rs | New backend endpoint that converts MCP sampling messages to goose messages and calls the provider. |
| crates/goose-server/src/routes/mod.rs | Registers the new sampling routes with the server. |
| 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); |
There was a problem hiding this comment.
The message event handler accepts responses from any source/origin; add a check (at least event.source === window.parent) to prevent other windows/iframes from spoofing JSON-RPC responses into the app.
| const responseText = response.content.text; | ||
| conversationHistory.push({ role: 'assistant', content: { type: 'text', text: responseText } }); |
There was a problem hiding this comment.
sampling/createMessage returns an MCP CreateMessageResult (with a nested message field), but this code reads response.content.text; update the sample to use response.message.content.text (and adjust how you append to conversationHistory) so it matches the server response.
| const responseText = response.content.text; | |
| conversationHistory.push({ role: 'assistant', content: { type: 'text', text: responseText } }); | |
| const responseText = response.message.content.text; | |
| conversationHistory.push(response.message); |
| Router::new() | ||
| .route( | ||
| "/sessions/{session_id}/sampling/message", | ||
| post(create_message), | ||
| ) |
There was a problem hiding this comment.
This endpoint is intended to support image sampling, but there’s no explicit body-size limit layer here (unlike /reply and dictation), so base64 image requests may be rejected by the default request body limit; consider adding a DefaultBodyLimit::max(...) appropriate for image payloads.
| let (response, usage) = provider | ||
| .complete(&session_id, system, &messages, &[]) | ||
| .await | ||
| .map_err(|e| { | ||
| tracing::error!("Sampling completion failed: {}", e); |
There was a problem hiding this comment.
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.
| stopReason: string; | ||
| role: 'assistant'; | ||
| content: { type: 'text'; text: string }; |
There was a problem hiding this comment.
The sampling/createMessage response type here doesn’t match the backend/MCP CreateMessageResult shape (it returns a message: { role, content } object and stopReason may be optional), so consumers like the sample app will read the wrong fields.
| stopReason: string; | |
| role: 'assistant'; | |
| content: { type: 'text'; text: string }; | |
| stopReason?: string; | |
| message: { | |
| role: 'assistant'; | |
| content: { type: 'text'; text: string }; | |
| }; |
| 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()]; |
There was a problem hiding this comment.
This default-app cache check uses apps://... URIs, but GooseApp::from_html() sets resource.uri to ui://apps/{name} and store_app() keys the cache by app.resource.uri, so this get_app() call will never hit and will rewrite the default apps on every startup.
| 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() | |
| { |
| case 'sampling/createMessage': { | ||
| if (!sessionId || !apiHost || !secretKey) { | ||
| throw new Error('Session not initialized for sampling request'); | ||
| } | ||
| const { messages, systemPrompt, maxTokens } = | ||
| params as McpMethodParams['sampling/createMessage']; | ||
| const response = await fetch(`${apiHost}/sessions/${sessionId}/sampling/message`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'X-Secret-Key': secretKey, | ||
| }, | ||
| body: JSON.stringify({ | ||
| messages: messages.map((m) => ({ | ||
| role: m.role, | ||
| content: m.content, | ||
| })), | ||
| systemPrompt, | ||
| maxTokens, | ||
| }), | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(`Sampling request failed: ${response.statusText}`); | ||
| } | ||
| return (await response.json()) as McpMethodResponse['sampling/createMessage']; | ||
| } |
There was a problem hiding this comment.
this is fantastic!
FYI, @alexhancock I'm working to integrate <AppRenderer/> from @mcp-ui/client over here. It has dedicated props for callbacks that map to a subset of JSON RPC messages supported by the MCP Apps spec. Don't think sampling/createMessage is supported on that side of things yet. Maybe I'm wrong?
@liady, how can we support sampling/createMessage while using <AppRenderer />?
++ @idosal, @ochafik, @infoxicator for input as well
There was a problem hiding this comment.
I discussed with @idosal that we might be able to have a generic onRequest prop added <AppRender />, as a catch-all that can allow goose to listen for unsupported/non-standard JSON RPC methods. Not to say that sampling/createMessage is nonstandard, just not supported by the client SDK...
There was a problem hiding this comment.
Yeah, I can wait for #7013 to go in and then adjust however needed. Feels like it would only be minor changes to use a better set of types etc.
Will stay tuned to this thread
6148d1b to
6fe5277
Compare
|
Awesome stuff @alexhancock! looks like you did manage to spin something cool on the plane ride back! |
6fe5277 to
89732b1
Compare
| body: JSON.stringify({ | ||
| messages: messages.map((m) => ({ | ||
| role: m.role, | ||
| content: m.content, |
There was a problem hiding this comment.
This request forwards content: m.content as-is, but MCP sampling messages typically use content as an array of blocks; if m.content isn’t serialized as an array, CreateMessageRequestParams deserialization will fail on the server.
| content: m.content, | |
| content: Array.isArray(m.content) | |
| ? m.content | |
| : [ | |
| { | |
| type: 'text', | |
| text: String(m.content ?? ''), | |
| }, | |
| ], |
| if (!response.ok) { | ||
| throw new Error(`Sampling request failed: ${response.statusText}`); | ||
| } | ||
| return (await response.json()) as McpMethodResponse['sampling/createMessage']; |
There was a problem hiding this comment.
Casting await response.json() directly to McpMethodResponse['sampling/createMessage'] is unsafe here because the backend returns an MCP CreateMessageResult (nested message), so callers will read the wrong fields unless you validate/transform the response.
| return (await response.json()) as McpMethodResponse['sampling/createMessage']; | |
| const raw = (await response.json()) as unknown; | |
| if ( | |
| !raw || | |
| typeof raw !== 'object' || | |
| !('message' in raw) || | |
| raw.message == null | |
| ) { | |
| throw new Error('Invalid sampling response format: missing message'); | |
| } | |
| const { message } = raw as { message: McpMethodResponse['sampling/createMessage'] }; | |
| return message; |
| const id = ++requestId; | ||
| pendingRequests.set(id, { resolve, reject }); | ||
| window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*'); | ||
| }); |
There was a problem hiding this comment.
Using '*' as the targetOrigin for postMessage allows the JSON-RPC request to be sent to any embedding origin; use the known proxy origin (or at least window.location.origin) as the targetOrigin.
| addMessage('user', text); | ||
| conversationHistory.push({ role: 'user', content: { type: 'text', text } }); | ||
|
|
There was a problem hiding this comment.
conversationHistory pushes sampling messages with content as an object, but MCP sampling expects content to be an array of blocks, so the request shape won’t match what the host/server expects.
| Role::User => Message::user(), | ||
| Role::Assistant => Message::assistant(), | ||
| }; | ||
| content_to_message(base, &msg.content) |
There was a problem hiding this comment.
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 | |
| } |
| fn content_to_message(base: Message, content: &Content) -> Message { | ||
| match &content.raw { | ||
| RawContent::Text(text) => base.with_text(&text.text), | ||
| RawContent::Image(image) => base.with_image(&image.data, &image.mime_type), | ||
| _ => base, | ||
| } |
There was a problem hiding this comment.
content_to_message assumes a single Content (content.raw), but sampling content is block-based in rmcp; update this helper to accept the sampling content block list and convert supported blocks (text/image) consistently.
| fn content_to_message(base: Message, content: &Content) -> Message { | |
| match &content.raw { | |
| RawContent::Text(text) => base.with_text(&text.text), | |
| RawContent::Image(image) => base.with_image(&image.data, &image.mime_type), | |
| _ => base, | |
| } | |
| fn content_to_message(mut base: Message, contents: &[Content]) -> Message { | |
| for content in contents { | |
| match &content.raw { | |
| RawContent::Text(text) => { | |
| base = base.with_text(&text.text); | |
| } | |
| RawContent::Image(image) => { | |
| base = base.with_image(&image.data, &image.mime_type); | |
| } | |
| _ => { | |
| // Ignore unsupported content types, preserving existing behavior. | |
| } | |
| } | |
| } | |
| base |
| Ok(Json(CreateMessageResult { | ||
| model: usage.model, | ||
| stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), | ||
| message: SamplingMessage { | ||
| role: Role::Assistant, |
There was a problem hiding this comment.
The response construction uses SamplingMessage { content: Content::text(...) }, but goose’s existing MCP sampling implementation builds responses with SamplingMessage::new(..., SamplingMessageContent::text/ Image ...); using the wrong content type/shape here will break clients expecting MCP CreateMessageResult semantics.
| Router::new() | ||
| .route( | ||
| "/sessions/{session_id}/sampling/message", | ||
| post(create_message), | ||
| ) |
There was a problem hiding this comment.
Add an integration test for /sessions/{session_id}/sampling/message (similar to other route tests) to lock in the MCP request/response shape and prevent regressions.
|
|
||
| export type SamplingMessage = { | ||
| role: 'user' | 'assistant'; | ||
| content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; |
There was a problem hiding this comment.
SamplingMessage.content should be an array of content blocks (the rmcp sampling types are used as a list in crates/goose/src/agents/mcp_client.rs via msg.content.first()), so modeling it as a single block will break interop/deserialization.
| content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; | |
| content: ContentBlock[]; |
89732b1 to
648ed51
Compare
|
interesting - I like this of course, but i think we can do this simpler by just adding a tool to the apps platform extension, call_llm (or sample but I always found that confusing). we already have the plumbing in mcp apps for an app to call the mcp server that created it, so that should then all work. all we need to do is then add this to the prompt we use to generate or update the apps. let me know if you think this makes sense or we can chat about this? |
It does makes sense in the context of goose and the presence of that server, but I was trying to think of a way to do it that could work when the app is run in hosts other than goose as well. Would still need plumbing added in other hosts of course, but an app sending a |
|
yeah, but it will only work in other providers if this becomes part of the standard. we should push for that! but until that happens, what do we do? /cc @aharvard ? |
|
@idosal do you think adding sampling to the protocol like this would make sense? |
Wire up the onFallbackRequest callback on AppRenderer to handle unrecognized MCP JSON-RPC requests. This is a stub that logs the request method and returns success, with a todo to implement sampling/createMessage per #7039. - Import RequestHandlerExtra from @mcp-ui/client - Import JSONRPCRequest from @modelcontextprotocol/sdk/types.js - Add handleFallbackRequest callback with correct types - Pass handler to AppRenderer's onFallbackRequest prop
Add a fallback request handler to gracefully handle unrecognized MCP protocol requests from guest apps (e.g. sampling/createMessage). Changes: - Import RequestHandlerExtra and JSONRPCRequest types - Add handleFallbackRequest callback that logs and returns success - Wire onFallbackRequest prop to AppRenderer Ref: #7039
Add a fallback request handler to McpAppRenderer that catches unhandled MCP requests from the app iframe. This is a stub that logs the request method and returns success, with a todo to implement sampling/createMessage per #7039. Changes: - Import RequestHandlerExtra from @mcp-ui/client - Import JSONRPCRequest from @modelcontextprotocol/sdk/types.js - Add handleFallbackRequest useCallback - Wire onFallbackRequest prop to AppRenderer
Upgrade @mcp-ui/client from ^6.0.0 to ^6.1.0 to get the new onFallbackRequest prop on AppRenderer. Add a fallback request handler that catches unhandled MCP requests from the app iframe. This is a stub that logs the request method and returns success, with a todo to implement sampling/createMessage per #7039. Changes: - Upgrade @mcp-ui/client to ^6.1.0 - Import RequestHandlerExtra from @mcp-ui/client - Import JSONRPCRequest from @modelcontextprotocol/sdk/types.js - Add handleFallbackRequest async callback - Wire onFallbackRequest prop to AppRenderer
As to what we do, we can now use the new @alexhancock I upgraded to Handling |
648ed51 to
e9f1981
Compare
| role: 'assistant'; | ||
| content: { type: 'text'; text: string }; |
There was a problem hiding this comment.
The response type definition doesn't match the actual API response structure. The backend returns CreateMessageResult which has a nested message field containing the role and content. The correct structure should be:
{
model: string;
stopReason: string;
message: {
role: 'assistant';
content: { type: 'text'; text: string };
};
}Without this fix, accessing the response in consuming code will fail.
| role: 'assistant'; | |
| content: { type: 'text'; text: string }; | |
| message: { | |
| role: 'assistant'; | |
| content: { type: 'text'; text: string }; | |
| }; |
| 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 (response, usage) = provider | ||
| .complete(&session_id, system, &messages, &[]) | ||
| .await | ||
| .map_err(|e| { | ||
| tracing::error!("Sampling completion failed: {}", e); | ||
| StatusCode::INTERNAL_SERVER_ERROR | ||
| })?; |
There was a problem hiding this comment.
The maxTokens parameter is accepted but never used in the sampling implementation. It's not passed to provider.complete(), which means token limits will fall back to provider defaults. Either remove this parameter from the API or pass it through to the provider's complete method.
e9f1981 to
2cc164e
Compare
| }); | ||
| } |
There was a problem hiding this comment.
If the API response doesn't match either expected format (neither response.content?.type === 'text' nor response.message?.content), no assistant message is added but the UI acts as if the request succeeded. Consider adding an else clause to show an error message when the response format is unexpected, so users understand why they didn't receive a response.
| }); | |
| } | |
| }); | |
| } else { | |
| this.showError('Unexpected response content type'); | |
| } | |
| } else { | |
| this.showError('Unexpected response format from server'); |
| let (response, usage) = provider | ||
| .complete(&session_id, system, &messages, &[]) | ||
| .await | ||
| .map_err(|e| { | ||
| tracing::error!("Sampling completion failed: {}", e); | ||
| StatusCode::INTERNAL_SERVER_ERROR | ||
| })?; |
There was a problem hiding this comment.
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.
| let meta = content.csp.map(|csp| ResourceMetadata { | ||
| ui: Some(UiMetadata { | ||
| csp: Some(CspMetadata { | ||
| connect_domains: Some(csp.connect_domains), | ||
| resource_domains: Some(csp.resource_domains), | ||
| frame_domains: None, | ||
| base_uri_domains: None, | ||
| }), | ||
| ..Default::default() | ||
| }), | ||
| }); |
There was a problem hiding this comment.
When CSP is provided during app creation, the code unconditionally replaces all metadata. This would lose any existing UI metadata fields like permissions, domain, or prefers_border if they were set. Consider merging the CSP into existing metadata rather than replacing it entirely, or document that CSP replacement is intentional behavior.
| app.resource.meta = content.csp.map(|csp| ResourceMetadata { | ||
| ui: Some(UiMetadata { | ||
| csp: Some(CspMetadata { | ||
| connect_domains: Some(csp.connect_domains), | ||
| resource_domains: Some(csp.resource_domains), | ||
| frame_domains: None, | ||
| base_uri_domains: None, | ||
| }), | ||
| ..Default::default() | ||
| }), | ||
| }); |
There was a problem hiding this comment.
Same metadata replacement issue as in create_app_content. When CSP is provided during app update, all existing metadata is replaced, potentially losing permissions, domain, and prefers_border settings. This should merge CSP into existing metadata structure.
| app.resource.meta = content.csp.map(|csp| ResourceMetadata { | |
| ui: Some(UiMetadata { | |
| csp: Some(CspMetadata { | |
| connect_domains: Some(csp.connect_domains), | |
| resource_domains: Some(csp.resource_domains), | |
| frame_domains: None, | |
| base_uri_domains: None, | |
| }), | |
| ..Default::default() | |
| }), | |
| }); | |
| if let Some(new_csp) = content.csp { | |
| // Merge new CSP into existing metadata instead of replacing it. | |
| let mut meta = app.resource.meta.take().unwrap_or_default(); | |
| let mut ui = meta.ui.take().unwrap_or_default(); | |
| let mut csp_meta = ui.csp.take().unwrap_or_default(); | |
| csp_meta.connect_domains = Some(new_csp.connect_domains); | |
| csp_meta.resource_domains = Some(new_csp.resource_domains); | |
| // Preserve other CSP fields like frame_domains and base_uri_domains. | |
| ui.csp = Some(csp_meta); | |
| meta.ui = Some(ui); | |
| app.resource.meta = Some(meta); | |
| } |
6c95970 to
b857bf8
Compare
Co-authored-by: Andrew Harvard <aharvard@block.xyz>
b857bf8 to
fdb6520
Compare
| systemPrompt, | ||
| maxTokens, |
There was a problem hiding this comment.
The request body sends fields in camelCase (systemPrompt, maxTokens), but CreateMessageRequestParams from rmcp likely expects snake_case (system_prompt, max_tokens) following Rust conventions. This mismatch may cause deserialization to fail or the fields to be silently ignored. Verify the expected format from the rmcp library and ensure the frontend matches it.
| systemPrompt, | |
| maxTokens, | |
| system_prompt: systemPrompt, | |
| max_tokens: maxTokens, |
| const responseText = response.content.text; | ||
| conversationHistory.push({ role: 'assistant', content: { type: 'text', text: responseText } }); | ||
|
|
||
| loadingEl.textContent = responseText; |
There was a problem hiding this comment.
The response structure doesn't match what the frontend expects. The CreateMessageResult from rmcp returns { model, stop_reason, message: { role, content } }, but the code tries to access response.content.text. This should be response.message.content.text or similar, depending on how SamplingMessage serializes its content field (it may be an array or object).
| 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]'; |
| }; | ||
| } | ||
| msg | ||
| } |
There was a problem hiding this comment.
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); | |
| } | |
| } |
Summary
sampling/createMessagereqsgoosedfor handlingDemo
Request Flow