MCP Apps Plumbing (#6286)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -537,6 +537,7 @@ mod tests {
|
||||
id: "test-id".to_string(),
|
||||
tool_call: Ok(tool_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let result = tool_request_to_markdown(&tool_request, true);
|
||||
@@ -561,6 +562,7 @@ mod tests {
|
||||
id: "test-id".to_string(),
|
||||
tool_call: Ok(tool_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let result = tool_request_to_markdown(&tool_request, true);
|
||||
@@ -701,6 +703,7 @@ mod tests {
|
||||
id: "shell-cat".to_string(),
|
||||
tool_call: Ok(tool_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let python_code = r#"#!/usr/bin/env python3
|
||||
@@ -754,6 +757,7 @@ if __name__ == "__main__":
|
||||
id: "git-status".to_string(),
|
||||
tool_call: Ok(git_status_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs";
|
||||
@@ -799,6 +803,7 @@ if __name__ == "__main__":
|
||||
id: "cargo-build".to_string(),
|
||||
tool_call: Ok(cargo_build_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let build_output = r#" Compiling goose-cli v0.1.0 (/Users/user/goose)
|
||||
@@ -850,6 +855,7 @@ warning: unused variable `x`
|
||||
id: "curl-api".to_string(),
|
||||
tool_call: Ok(curl_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let api_response = r#"{
|
||||
@@ -905,6 +911,7 @@ warning: unused variable `x`
|
||||
id: "editor-write".to_string(),
|
||||
tool_call: Ok(editor_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let text_content = TextContent {
|
||||
@@ -952,6 +959,7 @@ warning: unused variable `x`
|
||||
id: "editor-view".to_string(),
|
||||
tool_call: Ok(editor_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let python_code = r#"import os
|
||||
@@ -1008,6 +1016,7 @@ def process_data(data: List[Dict]) -> List[Dict]:
|
||||
id: "shell-error".to_string(),
|
||||
tool_call: Ok(error_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory
|
||||
@@ -1050,6 +1059,7 @@ Command failed with exit code 2"#;
|
||||
id: "script-exec".to_string(),
|
||||
tool_call: Ok(script_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ]
|
||||
@@ -1103,6 +1113,7 @@ Command failed with exit code 2"#;
|
||||
id: "multi-cmd".to_string(),
|
||||
tool_call: Ok(multi_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let multi_output = r#"total 24
|
||||
@@ -1154,6 +1165,7 @@ drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc
|
||||
id: "grep-search".to_string(),
|
||||
tool_call: Ok(grep_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result<Response> {
|
||||
@@ -1204,6 +1216,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul
|
||||
id: "json-test".to_string(),
|
||||
tool_call: Ok(tool_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let json_output = r#"{"status": "success", "data": {"count": 42}}"#;
|
||||
@@ -1245,6 +1258,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul
|
||||
id: "npm-install".to_string(),
|
||||
tool_call: Ok(npm_call),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let npm_output = r#"added 57 packages, and audited 58 packages in 3s
|
||||
|
||||
@@ -12,6 +12,7 @@ use axum::{
|
||||
};
|
||||
use goose::config::PermissionManager;
|
||||
|
||||
use base64::Engine;
|
||||
use goose::agents::ExtensionConfig;
|
||||
use goose::config::{Config, GooseMode};
|
||||
use goose::model::ModelConfig;
|
||||
@@ -94,9 +95,15 @@ pub struct ReadResourceRequest {
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadResourceResponse {
|
||||
html: String,
|
||||
uri: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
text: String,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<serde_json::Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
@@ -587,13 +594,15 @@ async fn read_resource(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<ReadResourceRequest>,
|
||||
) -> Result<Json<ReadResourceResponse>, StatusCode> {
|
||||
use rmcp::model::ResourceContents;
|
||||
|
||||
let agent = state
|
||||
.get_agent_for_route(payload.session_id.clone())
|
||||
.await?;
|
||||
|
||||
let html = agent
|
||||
let read_result = agent
|
||||
.extension_manager
|
||||
.read_ui_resource(
|
||||
.read_resource(
|
||||
&payload.uri,
|
||||
&payload.extension_name,
|
||||
CancellationToken::default(),
|
||||
@@ -601,7 +610,43 @@ async fn read_resource(
|
||||
.await
|
||||
.map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(ReadResourceResponse { html }))
|
||||
let content = read_result
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let (uri, mime_type, text, meta) = match content {
|
||||
ResourceContents::TextResourceContents {
|
||||
uri,
|
||||
mime_type,
|
||||
text,
|
||||
meta,
|
||||
} => (uri, mime_type, text, meta),
|
||||
ResourceContents::BlobResourceContents {
|
||||
uri,
|
||||
mime_type,
|
||||
blob,
|
||||
meta,
|
||||
} => {
|
||||
let decoded = match base64::engine::general_purpose::STANDARD.decode(&blob) {
|
||||
Ok(bytes) => {
|
||||
String::from_utf8(bytes).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
}
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
(uri, mime_type, decoded, meta)
|
||||
}
|
||||
};
|
||||
|
||||
let meta_map = meta.map(|m| m.0);
|
||||
|
||||
Ok(Json(ReadResourceResponse {
|
||||
uri,
|
||||
mime_type,
|
||||
text,
|
||||
meta: meta_map,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -649,7 +694,7 @@ async fn call_tool(
|
||||
content: result.content,
|
||||
structured_content: result.structured_content,
|
||||
is_error: result.is_error.unwrap_or(false),
|
||||
_meta: None,
|
||||
_meta: result.meta.and_then(|m| serde_json::to_value(m).ok()),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1176,6 +1176,7 @@ impl Agent {
|
||||
request.id.clone(),
|
||||
request.tool_call.clone(),
|
||||
request.metadata.as_ref(),
|
||||
request.tool_meta.clone(),
|
||||
);
|
||||
messages_to_add.push(request_msg);
|
||||
let final_response = tool_response_messages[idx]
|
||||
|
||||
@@ -41,8 +41,8 @@ use crate::oauth::oauth_flow;
|
||||
use crate::prompt_template;
|
||||
use crate::subprocess::configure_command_no_window;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, RawContent,
|
||||
Resource, ResourceContents, ServerInfo, Tool,
|
||||
CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Resource,
|
||||
ResourceContents, ServerInfo, Tool,
|
||||
};
|
||||
use rmcp::transport::auth::AuthClient;
|
||||
use schemars::_private::NoSerialize;
|
||||
@@ -713,14 +713,13 @@ impl ExtensionManager {
|
||||
input_schema: tool.input_schema,
|
||||
annotations: tool.annotations,
|
||||
output_schema: tool.output_schema,
|
||||
icons: None,
|
||||
title: None,
|
||||
meta: None,
|
||||
icons: tool.icons,
|
||||
title: tool.title,
|
||||
meta: tool.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Exit loop when there are no more pages
|
||||
if client_tools.next_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
@@ -773,7 +772,7 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
// Function that gets executed for read_resource tool
|
||||
pub async fn read_resource(
|
||||
pub async fn read_resource_tool(
|
||||
&self,
|
||||
params: Value,
|
||||
cancellation_token: CancellationToken,
|
||||
@@ -784,14 +783,17 @@ impl ExtensionManager {
|
||||
|
||||
// If extension name is provided, we can just look it up
|
||||
if extension_name.is_some() {
|
||||
let result = self
|
||||
.read_resource_from_extension(
|
||||
uri,
|
||||
extension_name.unwrap(),
|
||||
cancellation_token.clone(),
|
||||
true,
|
||||
)
|
||||
let read_result = self
|
||||
.read_resource(uri, extension_name.unwrap(), cancellation_token.clone())
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for content in read_result.contents {
|
||||
if let ResourceContents::TextResourceContents { text, .. } = content {
|
||||
let content_str = format!("{}\n\n{}", uri, text);
|
||||
result.push(Content::text(content_str));
|
||||
}
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -804,16 +806,20 @@ impl ExtensionManager {
|
||||
let extension_names: Vec<String> = self.extensions.lock().await.keys().cloned().collect();
|
||||
|
||||
for extension_name in extension_names {
|
||||
let result = self
|
||||
.read_resource_from_extension(
|
||||
uri,
|
||||
&extension_name,
|
||||
cancellation_token.clone(),
|
||||
true,
|
||||
)
|
||||
let read_result = self
|
||||
.read_resource(uri, &extension_name, cancellation_token.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) => return Ok(result),
|
||||
match read_result {
|
||||
Ok(read_result) => {
|
||||
let mut result = Vec::new();
|
||||
for content in read_result.contents {
|
||||
if let ResourceContents::TextResourceContents { text, .. } = content {
|
||||
let content_str = format!("{}\n\n{}", uri, text);
|
||||
result.push(Content::text(content_str));
|
||||
}
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
@@ -839,13 +845,12 @@ impl ExtensionManager {
|
||||
))
|
||||
}
|
||||
|
||||
async fn read_resource_from_extension(
|
||||
pub async fn read_resource(
|
||||
&self,
|
||||
uri: &str,
|
||||
extension_name: &str,
|
||||
cancellation_token: CancellationToken,
|
||||
format_with_uri: bool,
|
||||
) -> Result<Vec<Content>, ErrorData> {
|
||||
) -> Result<rmcp::model::ReadResourceResult, ErrorData> {
|
||||
let available_extensions = self
|
||||
.extensions
|
||||
.lock()
|
||||
@@ -865,7 +870,7 @@ impl ExtensionManager {
|
||||
.ok_or(ErrorData::new(ErrorCode::INVALID_PARAMS, error_msg, None))?;
|
||||
|
||||
let client_guard = client.lock().await;
|
||||
let read_result = client_guard
|
||||
client_guard
|
||||
.read_resource(uri, cancellation_token)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
@@ -874,21 +879,7 @@ impl ExtensionManager {
|
||||
format!("Could not read resource with uri: {}", uri),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for content in read_result.contents {
|
||||
if let ResourceContents::TextResourceContents { text, .. } = content {
|
||||
let content_str = if format_with_uri {
|
||||
format!("{}\n\n{}", uri, text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
result.push(Content::text(content_str));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_ui_resources(&self) -> Result<Vec<(String, Resource)>, ErrorData> {
|
||||
@@ -925,31 +916,6 @@ impl ExtensionManager {
|
||||
Ok(ui_resources)
|
||||
}
|
||||
|
||||
pub async fn read_ui_resource(
|
||||
&self,
|
||||
uri: &str,
|
||||
extension_name: &str,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<String, ErrorData> {
|
||||
let contents = self
|
||||
.read_resource_from_extension(uri, extension_name, cancellation_token, false)
|
||||
.await?;
|
||||
|
||||
contents
|
||||
.into_iter()
|
||||
.find_map(|c| match c.raw {
|
||||
RawContent::Text(text_content) => Some(text_content.text),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ErrorData::new(
|
||||
ErrorCode::RESOURCE_NOT_FOUND,
|
||||
format!("No text content in resource '{}'", uri),
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_resources_from_extension(
|
||||
&self,
|
||||
extension_name: &str,
|
||||
|
||||
@@ -257,7 +257,7 @@ impl ExtensionManagerClient {
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
|
||||
match extension_manager
|
||||
.read_resource(params, tokio_util::sync::CancellationToken::default())
|
||||
.read_resource_tool(params, tokio_util::sync::CancellationToken::default())
|
||||
.await
|
||||
{
|
||||
Ok(content) => Ok(content),
|
||||
|
||||
@@ -274,6 +274,10 @@ impl Agent {
|
||||
let schema_value = Value::Object(tool.input_schema.as_ref().clone());
|
||||
tool_call.arguments =
|
||||
coerce_tool_arguments(tool_call.arguments.clone(), &schema_value);
|
||||
|
||||
if let Some(ref meta) = tool.meta {
|
||||
coerced_req.tool_meta = serde_json::to_value(meta).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,22 +290,29 @@ impl Agent {
|
||||
|
||||
// Create a filtered message with frontend tool requests removed
|
||||
let mut filtered_content = Vec::new();
|
||||
let mut tool_request_index = 0;
|
||||
|
||||
// Process each content item one by one
|
||||
for content in &response.content {
|
||||
let should_include = match content {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
if let Ok(tool_call) = &req.tool_call {
|
||||
!self.is_frontend_tool(&tool_call.name).await
|
||||
} else {
|
||||
true
|
||||
match content {
|
||||
MessageContent::ToolRequest(_) => {
|
||||
if tool_request_index < tool_requests.len() {
|
||||
let coerced_req = &tool_requests[tool_request_index];
|
||||
tool_request_index += 1;
|
||||
|
||||
let should_include = if let Ok(tool_call) = &coerced_req.tool_call {
|
||||
!self.is_frontend_tool(&tool_call.name).await
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if should_include {
|
||||
filtered_content.push(MessageContent::ToolRequest(coerced_req.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
|
||||
if should_include {
|
||||
filtered_content.push(content.clone());
|
||||
_ => {
|
||||
filtered_content.push(content.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ pub struct ToolRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Option<ProviderMetadata>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Object)]
|
||||
pub tool_meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ToolRequest {
|
||||
@@ -259,6 +262,7 @@ impl MessageContent {
|
||||
id: id.into(),
|
||||
tool_call,
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,6 +275,7 @@ impl MessageContent {
|
||||
id: id.into(),
|
||||
tool_call,
|
||||
metadata: metadata.cloned(),
|
||||
tool_meta: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -667,10 +672,14 @@ impl Message {
|
||||
id: S,
|
||||
tool_call: ToolResult<CallToolRequestParam>,
|
||||
metadata: Option<&ProviderMetadata>,
|
||||
tool_meta: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
self.with_content(MessageContent::tool_request_with_metadata(
|
||||
id, tool_call, metadata,
|
||||
))
|
||||
self.with_content(MessageContent::ToolRequest(ToolRequest {
|
||||
id: id.into(),
|
||||
tool_call,
|
||||
metadata: metadata.cloned(),
|
||||
tool_meta,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Add a tool response to the message
|
||||
|
||||
@@ -114,6 +114,7 @@ mod tests {
|
||||
arguments: Some(object!({"command": "rm -rf /"})),
|
||||
}),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
}];
|
||||
|
||||
let results = inspector.inspect(&tool_requests, &[]).await.unwrap();
|
||||
|
||||
@@ -296,6 +296,7 @@ mod tests {
|
||||
arguments: Some(object!({})),
|
||||
}),
|
||||
metadata: None,
|
||||
tool_meta: None,
|
||||
};
|
||||
|
||||
let permission_result = PermissionCheckResult {
|
||||
|
||||
+18
-2
@@ -4571,10 +4571,23 @@
|
||||
"ReadResourceResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"html"
|
||||
"uri",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"html": {
|
||||
"_meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": {},
|
||||
"nullable": true
|
||||
},
|
||||
"mimeType": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"uri": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -5690,6 +5703,9 @@
|
||||
"toolCall"
|
||||
],
|
||||
"properties": {
|
||||
"_meta": {
|
||||
"type": "object"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -686,7 +686,12 @@ export type ReadResourceRequest = {
|
||||
};
|
||||
|
||||
export type ReadResourceResponse = {
|
||||
html: string;
|
||||
_meta?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
mimeType?: string | null;
|
||||
text: string;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export type Recipe = {
|
||||
@@ -1039,6 +1044,9 @@ export type ToolPermission = {
|
||||
};
|
||||
|
||||
export type ToolRequest = {
|
||||
_meta?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
id: string;
|
||||
metadata?: {
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -151,6 +151,7 @@ export default function GooseMessage({
|
||||
{toolRequests.map((toolRequest) => (
|
||||
<div className="goose-message-tool" key={toolRequest.id}>
|
||||
<ToolCallWithResponse
|
||||
sessionId={sessionId}
|
||||
isCancelledMessage={false}
|
||||
toolRequest={toolRequest}
|
||||
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
||||
|
||||
@@ -6,14 +6,25 @@
|
||||
* @see SEP-1865 https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useSandboxBridge } from './useSandboxBridge';
|
||||
import { McpAppResource, ToolInput, ToolInputPartial, ToolResult, ToolCancelled } from './types';
|
||||
import {
|
||||
ToolInput,
|
||||
ToolInputPartial,
|
||||
ToolResult,
|
||||
ToolCancelled,
|
||||
CspMetadata,
|
||||
McpMethodParams,
|
||||
McpMethodResponse,
|
||||
} from './types';
|
||||
import { cn } from '../../utils';
|
||||
import { DEFAULT_IFRAME_HEIGHT } from './utils';
|
||||
import { readResource, callTool } from '../../api';
|
||||
|
||||
interface McpAppRendererProps {
|
||||
resource: McpAppResource;
|
||||
resourceUri: string;
|
||||
extensionName: string;
|
||||
sessionId: string;
|
||||
toolInput?: ToolInput;
|
||||
toolInputPartial?: ToolInputPartial;
|
||||
toolResult?: ToolResult;
|
||||
@@ -22,60 +33,126 @@ interface McpAppRendererProps {
|
||||
}
|
||||
|
||||
export default function McpAppRenderer({
|
||||
resource,
|
||||
resourceUri,
|
||||
extensionName,
|
||||
sessionId,
|
||||
toolInput,
|
||||
toolInputPartial,
|
||||
toolResult,
|
||||
toolCancelled,
|
||||
append,
|
||||
}: McpAppRendererProps) {
|
||||
const prefersBorder = resource._meta?.ui?.prefersBorder ?? true;
|
||||
const [resourceHtml, setResourceHtml] = useState<string | null>(null);
|
||||
const [resourceCsp, setResourceCsp] = useState<CspMetadata | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
|
||||
|
||||
// Handle MCP requests from the guest app
|
||||
useEffect(() => {
|
||||
const fetchResource = async () => {
|
||||
try {
|
||||
const response = await readResource({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
uri: resourceUri,
|
||||
extension_name: extensionName,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const content = response.data;
|
||||
|
||||
setResourceHtml(content.text);
|
||||
|
||||
const meta = content._meta as { ui?: { csp?: CspMetadata } } | undefined;
|
||||
setResourceCsp(meta?.ui?.csp || null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load resource');
|
||||
}
|
||||
};
|
||||
|
||||
fetchResource();
|
||||
}, [resourceUri, extensionName, sessionId]);
|
||||
|
||||
const handleMcpRequest = useCallback(
|
||||
async (method: string, params: unknown, id?: string | number): Promise<unknown> => {
|
||||
console.log(`[MCP App] Request: ${method}`, { params, id });
|
||||
|
||||
async (
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
_id?: string | number
|
||||
): Promise<unknown> => {
|
||||
switch (method) {
|
||||
case 'ui/open-link':
|
||||
if (params && typeof params === 'object' && 'url' in params) {
|
||||
const { url } = params as { url: string };
|
||||
window.electron.openExternal(url).catch(console.error);
|
||||
return { status: 'success', message: 'Link opened successfully' };
|
||||
}
|
||||
throw new Error('Invalid params for ui/open-link');
|
||||
case 'ui/open-link': {
|
||||
const { url } = params as McpMethodParams['ui/open-link'];
|
||||
await window.electron.openExternal(url);
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Link opened successfully',
|
||||
} satisfies McpMethodResponse['ui/open-link'];
|
||||
}
|
||||
|
||||
case 'ui/message':
|
||||
if (params && typeof params === 'object' && 'content' in params) {
|
||||
const content = params.content as { type: string; text: string };
|
||||
if (!append) {
|
||||
throw new Error('Message handler not available in this context');
|
||||
}
|
||||
if (!content.text) {
|
||||
throw new Error('Missing message text');
|
||||
}
|
||||
append(content.text);
|
||||
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
|
||||
return { status: 'success', message: 'Message appended successfully' };
|
||||
case 'ui/message': {
|
||||
const { content } = params as McpMethodParams['ui/message'];
|
||||
if (!append) {
|
||||
throw new Error('Message handler not available in this context');
|
||||
}
|
||||
throw new Error('Invalid params for ui/message');
|
||||
append(content.text);
|
||||
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Message appended successfully',
|
||||
} satisfies McpMethodResponse['ui/message'];
|
||||
}
|
||||
|
||||
case 'tools/call': {
|
||||
const { name, arguments: args } = params as McpMethodParams['tools/call'];
|
||||
const fullToolName = `${extensionName}__${name}`;
|
||||
const response = await callTool({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
name: fullToolName,
|
||||
arguments: args || {},
|
||||
},
|
||||
});
|
||||
return {
|
||||
content: response.data?.content || [],
|
||||
isError: response.data?.is_error || false,
|
||||
structuredContent: (response.data as Record<string, unknown>)?.structured_content as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
} satisfies McpMethodResponse['tools/call'];
|
||||
}
|
||||
|
||||
case 'resources/read': {
|
||||
const { uri } = params as McpMethodParams['resources/read'];
|
||||
const response = await readResource({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
uri,
|
||||
extension_name: extensionName,
|
||||
},
|
||||
});
|
||||
return {
|
||||
contents: response.data ? [response.data] : [],
|
||||
} satisfies McpMethodResponse['resources/read'];
|
||||
}
|
||||
|
||||
case 'notifications/message': {
|
||||
const { level, logger, data } = params as McpMethodParams['notifications/message'];
|
||||
console.log(
|
||||
`[MCP App Notification]${logger ? ` [${logger}]` : ''} ${level || 'info'}:`,
|
||||
data
|
||||
);
|
||||
return {} satisfies McpMethodResponse['notifications/message'];
|
||||
}
|
||||
|
||||
case 'notifications/message':
|
||||
case 'tools/call':
|
||||
case 'resources/list':
|
||||
case 'resources/templates/list':
|
||||
case 'resources/read':
|
||||
case 'prompts/list':
|
||||
case 'ping':
|
||||
console.warn(`[MCP App] TODO: ${method} not yet implemented`);
|
||||
throw new Error(`Method not implemented: ${method}`);
|
||||
return {} satisfies McpMethodResponse['ping'];
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
},
|
||||
[append]
|
||||
[append, sessionId, extensionName]
|
||||
);
|
||||
|
||||
const handleSizeChanged = useCallback((height: number, _width?: number) => {
|
||||
@@ -84,9 +161,9 @@ export default function McpAppRenderer({
|
||||
}, []);
|
||||
|
||||
const { iframeRef, proxyUrl } = useSandboxBridge({
|
||||
resourceHtml: resource.text || '',
|
||||
resourceCsp: resource._meta?.ui?.csp || null,
|
||||
resourceUri: resource.uri,
|
||||
resourceHtml: resourceHtml || '',
|
||||
resourceCsp,
|
||||
resourceUri,
|
||||
toolInput,
|
||||
toolInputPartial,
|
||||
toolResult,
|
||||
@@ -95,17 +172,26 @@ export default function McpAppRenderer({
|
||||
onSizeChanged: handleSizeChanged,
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return null;
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-3 p-4 border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<div className="text-red-700 dark:text-red-300">Failed to load MCP app: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resourceHtml) {
|
||||
return (
|
||||
<div className="mt-3 p-4 border border-borderSubtle rounded-lg bg-bgApp">
|
||||
<div className="flex items-center justify-center" style={{ minHeight: '200px' }}>
|
||||
Loading MCP app...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 bg-bgApp',
|
||||
prefersBorder && 'border border-borderSubtle rounded-lg overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<div className={cn('mt-3 bg-bgApp', 'border border-borderSubtle rounded-lg overflow-hidden')}>
|
||||
{proxyUrl ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
import { McpAppResource } from './types';
|
||||
|
||||
interface MockResourceListItem {
|
||||
uri: McpAppResource['uri'];
|
||||
name: McpAppResource['name'];
|
||||
description: McpAppResource['description'];
|
||||
mimeType: McpAppResource['mimeType'];
|
||||
}
|
||||
|
||||
interface MockListedResources {
|
||||
resources: MockResourceListItem[];
|
||||
}
|
||||
|
||||
interface MockReadResources {
|
||||
contents: McpAppResource[];
|
||||
}
|
||||
|
||||
const UI_RESOURCE_URI = 'ui://weather-server/dashboard-template' as const;
|
||||
|
||||
export const mockToolListResult = {
|
||||
name: 'get_weather',
|
||||
description: 'Get current weather for a location',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string' },
|
||||
},
|
||||
},
|
||||
_meta: {
|
||||
'ui/resourceUri': UI_RESOURCE_URI,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockResourceListResult: MockListedResources = {
|
||||
resources: [
|
||||
{
|
||||
uri: UI_RESOURCE_URI,
|
||||
name: 'weather_dashboard',
|
||||
description: 'Interactive weather dashboard widget',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockAppHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" id="prism-dark" />
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" id="prism-light" disabled />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #18181b;
|
||||
--bg-terminal: #0a0a0a;
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a1a1aa;
|
||||
--border: #3f3f46;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--bg-primary: #fafafa;
|
||||
--bg-terminal: #f4f4f5;
|
||||
--text-primary: #18181b;
|
||||
--text-secondary: #52525b;
|
||||
--border: #e4e4e7;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
--bg-primary: #18181b;
|
||||
--bg-terminal: #0a0a0a;
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a1a1aa;
|
||||
--border: #3f3f46;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 24px 0 24px;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
font-family: "Instrument Serif", system-ui, sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
h1 {
|
||||
font-size: min(max(4rem, 8vw), 8rem);
|
||||
text-align: center;
|
||||
line-height: 0.95;
|
||||
margin: 2rem auto 3rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.host-info-subtitle {
|
||||
text-align: center;
|
||||
margin: 0 0 2rem 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.actions-heading {
|
||||
margin: 0 0 0.75rem 0;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.actions-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.actions-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--bg-primary);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.action-btn code {
|
||||
color: var(--bg-primary);
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.action-btn:hover code {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.action-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.action-btn:disabled:hover {
|
||||
background: var(--bg-primary);
|
||||
transform: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.theme-light .action-btn {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.theme-light .action-btn:hover {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.terminal {
|
||||
margin: 1.5rem -24px 0 -24px;
|
||||
background: var(--bg-terminal);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.theme-light .terminal {
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.terminal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
.terminal-grid > .terminal-section {
|
||||
padding: 1.5rem 24px;
|
||||
}
|
||||
.terminal-grid > .terminal-section:not(:last-child) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.terminal-grid > .terminal-section:not(:last-child) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
.terminal-section h2 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #71717a;
|
||||
}
|
||||
.terminal-section pre[class*="language-"] {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.6 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.terminal-section code[class*="language-"] {
|
||||
font-family: ui-monospace, monospace !important;
|
||||
font-size: 0.875rem !important;
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
/* Dark mode: custom terminal colors */
|
||||
.theme-dark .terminal-section code[class*="language-"] { color: #e4e4e7 !important; }
|
||||
.theme-dark .terminal-section .token.property { color: #a78bfa !important; }
|
||||
.theme-dark .terminal-section .token.string { color: #86efac !important; }
|
||||
.theme-dark .terminal-section .token.number { color: #fcd34d !important; }
|
||||
.theme-dark .terminal-section .token.boolean { color: #67e8f9 !important; }
|
||||
.theme-dark .terminal-section .token.null { color: #f87171 !important; }
|
||||
.theme-dark .terminal-section .token.punctuation { color: #a1a1aa !important; }
|
||||
/* Light mode: use Prism default light theme colors */
|
||||
.theme-light .terminal-section code[class*="language-"] { color: #383a42 !important; }
|
||||
.theme-light .terminal-section .token.property { color: #7c3aed !important; }
|
||||
.theme-light .terminal-section .token.string { color: #16a34a !important; }
|
||||
.theme-light .terminal-section .token.number { color: #ca8a04 !important; }
|
||||
.theme-light .terminal-section .token.boolean { color: #0891b2 !important; }
|
||||
.theme-light .terminal-section .token.null { color: #dc2626 !important; }
|
||||
.theme-light .terminal-section .token.punctuation { color: #71717a !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p class="host-info-subtitle" id="host-info-subtitle">Connecting...</p>
|
||||
<h1>MCP App Demo</h1>
|
||||
<div class="actions">
|
||||
<h2 class="actions-heading">Host Requests</h2>
|
||||
<div class="actions-buttons">
|
||||
<button class="action-btn" id="btn-open-link">
|
||||
Open Link <code>ui/open-link</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-message">
|
||||
Send Message <code>ui/message</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-size-change">
|
||||
Size Change <code>ui/notifications/size-changed</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<h2 class="actions-heading">Server Requests</h2>
|
||||
<div class="actions-buttons">
|
||||
<button class="action-btn" id="btn-tools-call">
|
||||
Call Tool <code>tools/call</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-resources-list">
|
||||
List Resources <code>resources/list</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-resources-templates-list">
|
||||
List Templates <code>resources/templates/list</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-resources-read">
|
||||
Read Resource <code>resources/read</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-prompts-list">
|
||||
List Prompts <code>prompts/list</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-notifications-message">
|
||||
Log Message <code>notifications/message</code>
|
||||
</button>
|
||||
<button class="action-btn" id="btn-ping">
|
||||
Ping <code>ping</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal">
|
||||
<div class="terminal-grid">
|
||||
<div class="terminal-section">
|
||||
<h2>Host Data</h2>
|
||||
<pre class="language-json"><code class="language-json" id="host-info-content">Initializing...</code></pre>
|
||||
</div>
|
||||
<div class="terminal-section">
|
||||
<h2>Tool Data</h2>
|
||||
<pre class="language-json"><code class="language-json" id="tool-data-content">Waiting...</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
let requestId = 1;
|
||||
const pendingRequests = new Map();
|
||||
let currentHostInfo = null;
|
||||
let currentToolData = {
|
||||
toolInput: null,
|
||||
toolInputPartial: null,
|
||||
toolResult: null,
|
||||
toolCancelled: null
|
||||
};
|
||||
|
||||
function setTheme(theme) {
|
||||
document.body.classList.remove('theme-light', 'theme-dark');
|
||||
const prismDark = document.getElementById('prism-dark');
|
||||
const prismLight = document.getElementById('prism-light');
|
||||
|
||||
if (theme === 'light') {
|
||||
document.body.classList.add('theme-light');
|
||||
prismDark.disabled = true;
|
||||
prismLight.disabled = false;
|
||||
} else {
|
||||
document.body.classList.add('theme-dark');
|
||||
prismDark.disabled = false;
|
||||
prismLight.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function sendSizeChanged() {
|
||||
const width = document.body.scrollWidth;
|
||||
const height = document.body.scrollHeight;
|
||||
window.parent.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/size-changed',
|
||||
params: { width, height }
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function sendRequest(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = requestId++;
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
window.parent.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: id,
|
||||
method: method,
|
||||
params: params
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
function sendNotification(method, params) {
|
||||
window.parent.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: method,
|
||||
params: params
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function renderJson(elementId, data) {
|
||||
const container = document.getElementById(elementId);
|
||||
if (!container) return;
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
container.textContent = json;
|
||||
if (typeof Prism !== 'undefined') {
|
||||
Prism.highlightElement(container);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHostInfo() {
|
||||
renderJson('host-info-content', currentHostInfo);
|
||||
sendSizeChanged();
|
||||
}
|
||||
|
||||
function renderToolData() {
|
||||
renderJson('tool-data-content', currentToolData);
|
||||
sendSizeChanged();
|
||||
}
|
||||
|
||||
function initializeHostInfo(result) {
|
||||
// Update subtitle with host info
|
||||
const subtitle = document.getElementById('host-info-subtitle');
|
||||
if (subtitle && result.hostInfo) {
|
||||
const name = result.hostInfo.name || 'Unknown Host';
|
||||
const version = result.hostInfo.version || '';
|
||||
subtitle.textContent = version ? name + ' v' + version : name;
|
||||
}
|
||||
|
||||
currentHostInfo = {
|
||||
protocolVersion: result.protocolVersion,
|
||||
hostInfo: result.hostInfo,
|
||||
hostCapabilities: result.hostCapabilities,
|
||||
hostContext: result.hostContext || {},
|
||||
};
|
||||
renderHostInfo();
|
||||
}
|
||||
|
||||
function updateHostContext(params) {
|
||||
if (currentHostInfo && currentHostInfo.hostContext) {
|
||||
Object.assign(currentHostInfo.hostContext, params);
|
||||
renderHostInfo();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== 'object' || data.jsonrpc !== '2.0') return;
|
||||
|
||||
// Handle response to our request
|
||||
if ('id' in data && pendingRequests.has(data.id)) {
|
||||
const { resolve, reject } = pendingRequests.get(data.id);
|
||||
pendingRequests.delete(data.id);
|
||||
if (data.error) {
|
||||
reject(data.error);
|
||||
} else {
|
||||
resolve(data.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle host-context-changed notification
|
||||
if (data.method === 'ui/notifications/host-context-changed') {
|
||||
if (data.params && data.params.theme) {
|
||||
setTheme(data.params.theme);
|
||||
}
|
||||
updateHostContext(data.params);
|
||||
}
|
||||
|
||||
// Handle tool-input notification
|
||||
if (data.method === 'ui/notifications/tool-input') {
|
||||
currentToolData.toolInput = data.params;
|
||||
renderToolData();
|
||||
}
|
||||
|
||||
// Handle tool-result notification
|
||||
if (data.method === 'ui/notifications/tool-result') {
|
||||
currentToolData.toolResult = data.params;
|
||||
renderToolData();
|
||||
}
|
||||
|
||||
// Handle tool-input-partial notification
|
||||
if (data.method === 'ui/notifications/tool-input-partial') {
|
||||
currentToolData.toolInputPartial = data.params;
|
||||
renderToolData();
|
||||
}
|
||||
|
||||
// Handle tool-cancelled notification
|
||||
if (data.method === 'ui/notifications/tool-cancelled') {
|
||||
currentToolData.toolCancelled = data.params;
|
||||
renderToolData();
|
||||
}
|
||||
|
||||
// Handle resource-teardown request
|
||||
if (data.method === 'ui/resource-teardown') {
|
||||
console.log('[MockApp] ui/resource-teardown received:', data.params);
|
||||
// Send response back to host
|
||||
if ('id' in data) {
|
||||
window.parent.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
result: {}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const result = await sendRequest('ui/initialize', {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'MockMcpApp', version: '1.0.0' }
|
||||
});
|
||||
|
||||
// Apply initial theme
|
||||
if (result.hostContext && result.hostContext.theme) {
|
||||
setTheme(result.hostContext.theme);
|
||||
}
|
||||
|
||||
initializeHostInfo(result);
|
||||
|
||||
// Send initialized notification
|
||||
sendNotification('ui/notifications/initialized');
|
||||
} catch (error) {
|
||||
document.getElementById('host-info-content').textContent = 'Error: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
// Action button: Open Link
|
||||
document.getElementById('btn-open-link').addEventListener('click', function() {
|
||||
sendRequest('ui/open-link', {
|
||||
url: 'https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx'
|
||||
})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] ui/open-link response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] ui/open-link error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: Send Message
|
||||
document.getElementById('btn-message').addEventListener('click', function() {
|
||||
sendRequest('ui/message', {
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Hello from MCP App Demo! This message was sent via ui/message.'
|
||||
}
|
||||
})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] ui/message response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] ui/message error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: Size Change (reports actual body size)
|
||||
document.getElementById('btn-size-change').addEventListener('click', function() {
|
||||
sendSizeChanged();
|
||||
console.log('[MockApp] ui/notifications/size-changed sent (no response expected)');
|
||||
});
|
||||
|
||||
// Action button: Call Tool
|
||||
document.getElementById('btn-tools-call').addEventListener('click', function() {
|
||||
sendRequest('tools/call', {
|
||||
name: 'example_tool',
|
||||
arguments: { param1: 'value1', param2: 42 }
|
||||
})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] tools/call response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] tools/call error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: List Resources
|
||||
document.getElementById('btn-resources-list').addEventListener('click', function() {
|
||||
sendRequest('resources/list', {})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] resources/list response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] resources/list error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: List Resource Templates
|
||||
document.getElementById('btn-resources-templates-list').addEventListener('click', function() {
|
||||
sendRequest('resources/templates/list', {})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] resources/templates/list response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] resources/templates/list error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: Read Resource
|
||||
document.getElementById('btn-resources-read').addEventListener('click', function() {
|
||||
sendRequest('resources/read', {
|
||||
uri: 'resource://example/demo-resource'
|
||||
})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] resources/read response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] resources/read error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: List Prompts
|
||||
document.getElementById('btn-prompts-list').addEventListener('click', function() {
|
||||
sendRequest('prompts/list', {})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] prompts/list response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] prompts/list error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: Log Message
|
||||
// Note: Per spec this is a notification (no response expected), but we send as request
|
||||
// during development to get feedback on whether the host handled it.
|
||||
document.getElementById('btn-notifications-message').addEventListener('click', function() {
|
||||
sendRequest('notifications/message', {
|
||||
level: 'info',
|
||||
data: 'This is a log message from the MCP App Demo!',
|
||||
logger: 'MockMcpApp'
|
||||
})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] notifications/message response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] notifications/message error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Action button: Ping
|
||||
document.getElementById('btn-ping').addEventListener('click', function() {
|
||||
sendRequest('ping', {})
|
||||
.then(function(result) {
|
||||
console.log('[MockApp] ping response:', result);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[MockApp] ping error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Send initial size
|
||||
sendSizeChanged();
|
||||
|
||||
// Observe size changes
|
||||
const resizeObserver = new ResizeObserver(sendSizeChanged);
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
// Start initialization
|
||||
initialize();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const mockResourceReadResult: MockReadResources = {
|
||||
contents: [
|
||||
{
|
||||
uri: UI_RESOURCE_URI,
|
||||
name: 'Demo MCP App',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
text: mockAppHtml,
|
||||
_meta: {
|
||||
ui: {
|
||||
csp: {
|
||||
connectDomains: ['https://api.openweathermap.org'],
|
||||
resourceDomains: [
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
],
|
||||
},
|
||||
prefersBorder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,23 +1,34 @@
|
||||
// Re-export generated types from Rust
|
||||
export type {
|
||||
McpAppResource,
|
||||
CspMetadata,
|
||||
UiMetadata,
|
||||
ResourceMetadata,
|
||||
CallToolResponse as ToolResult,
|
||||
} from '../../api/types.gen';
|
||||
export type { CspMetadata, CallToolResponse as ToolResult } from '../../api/types.gen';
|
||||
|
||||
export type McpMethodParams = {
|
||||
'ui/open-link': { url: string };
|
||||
'ui/message': { content: { type: string; text: string } };
|
||||
'tools/call': { name: string; arguments?: Record<string, unknown> };
|
||||
'resources/read': { uri: string };
|
||||
'notifications/message': { level?: string; logger?: string; data: unknown };
|
||||
ping: Record<string, never>;
|
||||
};
|
||||
|
||||
export type McpMethodResponse = {
|
||||
'ui/open-link': { status: string; message: string };
|
||||
'ui/message': { status: string; message: string };
|
||||
'tools/call': { content: unknown[]; isError: boolean; structuredContent?: Record<string, unknown> };
|
||||
'resources/read': { contents: unknown[] };
|
||||
'notifications/message': Record<string, never>;
|
||||
ping: Record<string, never>;
|
||||
};
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id?: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { fetchMcpAppProxyUrl } from './utils';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import packageJson from '../../../package.json';
|
||||
import { errorMessage } from '../../utils/conversionUtils';
|
||||
|
||||
interface SandboxBridgeOptions {
|
||||
resourceHtml: string;
|
||||
@@ -22,7 +23,11 @@ interface SandboxBridgeOptions {
|
||||
toolInputPartial?: ToolInputPartial;
|
||||
toolResult?: ToolResult;
|
||||
toolCancelled?: ToolCancelled;
|
||||
onMcpRequest: (method: string, params: unknown, id?: string | number) => Promise<unknown>;
|
||||
onMcpRequest: (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
id?: string | number
|
||||
) => Promise<unknown>;
|
||||
onSizeChanged?: (height: number, width?: number) => void;
|
||||
}
|
||||
|
||||
@@ -48,14 +53,13 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const isGuestInitializedRef = useRef(false);
|
||||
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
|
||||
const [isGuestInitialized, setIsGuestInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMcpAppProxyUrl(resourceCsp).then(setProxyUrl);
|
||||
}, [resourceCsp]);
|
||||
|
||||
// Reset initialization state when resource changes
|
||||
useEffect(() => {
|
||||
setIsGuestInitialized(false);
|
||||
isGuestInitializedRef.current = false;
|
||||
}, [resourceUri]);
|
||||
|
||||
@@ -71,26 +75,26 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
||||
if ('method' in data && !('id' in data)) {
|
||||
const msg = data as JsonRpcNotification;
|
||||
|
||||
if (msg.method === 'ui/notifications/sandbox-ready') {
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/sandbox-resource-ready',
|
||||
params: { html: resourceHtml, csp: resourceCsp },
|
||||
});
|
||||
return;
|
||||
}
|
||||
switch (msg.method) {
|
||||
case 'ui/notifications/sandbox-ready':
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/sandbox-resource-ready',
|
||||
params: { html: resourceHtml, csp: resourceCsp },
|
||||
});
|
||||
break;
|
||||
|
||||
if (msg.method === 'ui/notifications/initialized') {
|
||||
setIsGuestInitialized(true);
|
||||
isGuestInitializedRef.current = true;
|
||||
return;
|
||||
}
|
||||
case 'ui/notifications/initialized':
|
||||
isGuestInitializedRef.current = true;
|
||||
break;
|
||||
|
||||
if (msg.method === 'ui/notifications/size-changed') {
|
||||
const params = msg.params as { height: number; width?: number };
|
||||
onSizeChanged?.(params.height, params.width);
|
||||
return;
|
||||
case 'ui/notifications/size-changed': {
|
||||
const params = msg.params as { height: number; width?: number };
|
||||
onSizeChanged?.(params.height, params.width);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle requests (with id)
|
||||
@@ -140,7 +144,6 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate other requests to handler
|
||||
const result = await onMcpRequest(msg.method, msg.params, msg.id);
|
||||
if (msg.id !== undefined) {
|
||||
sendToSandbox({ jsonrpc: '2.0', id: msg.id, result });
|
||||
@@ -153,7 +156,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
||||
id: msg.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: errorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -174,56 +177,52 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
||||
|
||||
// Send tool input notification when it changes
|
||||
useEffect(() => {
|
||||
if (!isGuestInitialized || !toolInput) return;
|
||||
if (!isGuestInitializedRef.current || !toolInput) return;
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-input',
|
||||
params: { arguments: toolInput.arguments },
|
||||
});
|
||||
}, [isGuestInitialized, toolInput, sendToSandbox]);
|
||||
}, [toolInput, sendToSandbox]);
|
||||
|
||||
// Send partial tool input (streaming) notification when it changes
|
||||
useEffect(() => {
|
||||
if (!isGuestInitialized || !toolInputPartial) return;
|
||||
if (!isGuestInitializedRef.current || !toolInputPartial) return;
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-input-partial',
|
||||
params: { arguments: toolInputPartial.arguments },
|
||||
});
|
||||
}, [isGuestInitialized, toolInputPartial, sendToSandbox]);
|
||||
}, [toolInputPartial, sendToSandbox]);
|
||||
|
||||
// Send tool result notification when it changes
|
||||
useEffect(() => {
|
||||
if (!isGuestInitialized || !toolResult) return;
|
||||
if (!isGuestInitializedRef.current || !toolResult) return;
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: toolResult,
|
||||
});
|
||||
}, [isGuestInitialized, toolResult, sendToSandbox]);
|
||||
}, [toolResult, sendToSandbox]);
|
||||
|
||||
// Send tool cancelled notification when it changes
|
||||
useEffect(() => {
|
||||
if (!isGuestInitialized || !toolCancelled) return;
|
||||
if (!isGuestInitializedRef.current || !toolCancelled) return;
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-cancelled',
|
||||
params: toolCancelled.reason ? { reason: toolCancelled.reason } : {},
|
||||
});
|
||||
}, [isGuestInitialized, toolCancelled, sendToSandbox]);
|
||||
}, [toolCancelled, sendToSandbox]);
|
||||
|
||||
// Send theme changes when it changes
|
||||
useEffect(() => {
|
||||
if (!isGuestInitialized) return;
|
||||
if (!isGuestInitializedRef.current) return;
|
||||
sendToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/host-context-changed',
|
||||
params: { theme: resolvedTheme },
|
||||
});
|
||||
}, [isGuestInitialized, resolvedTheme, sendToSandbox]);
|
||||
}, [resolvedTheme, sendToSandbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuestInitialized || !iframeRef.current) return;
|
||||
if (!isGuestInitializedRef.current || !iframeRef.current) return;
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
let lastWidth = iframe.clientWidth;
|
||||
@@ -254,9 +253,8 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
||||
|
||||
observer.observe(iframe);
|
||||
return () => observer.disconnect();
|
||||
}, [isGuestInitialized, sendToSandbox]);
|
||||
}, [sendToSandbox]);
|
||||
|
||||
// Cleanup on unmount - use ref to capture latest initialized state
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isGuestInitializedRef.current) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TooltipWrapper } from './settings/providers/subcomponents/buttons/Toolt
|
||||
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
|
||||
import { isUIResource } from '@mcp-ui/client';
|
||||
import { CallToolResponse, Content, EmbeddedResource } from '../api';
|
||||
import McpAppRenderer from './McpApps/McpAppRenderer';
|
||||
|
||||
interface ToolGraphNode {
|
||||
tool: string;
|
||||
@@ -23,13 +24,36 @@ interface ToolGraphNode {
|
||||
depends_on: number[];
|
||||
}
|
||||
|
||||
type ToolResultWithMeta = {
|
||||
status?: string;
|
||||
value?: CallToolResponse & {
|
||||
_meta?: {
|
||||
'ui/resourceUri'?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ToolRequestWithMeta = ToolRequestMessageContent & {
|
||||
_meta?: {
|
||||
'ui/resourceUri'?: string;
|
||||
};
|
||||
toolCall: {
|
||||
status: 'success';
|
||||
value: {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
interface ToolCallWithResponseProps {
|
||||
sessionId?: string;
|
||||
isCancelledMessage: boolean;
|
||||
toolRequest: ToolRequestMessageContent;
|
||||
toolResponse?: ToolResponseMessageContent;
|
||||
notifications?: NotificationEvent[];
|
||||
isStreamingMessage?: boolean;
|
||||
append?: (value: string) => void; // Function to append messages to the chat
|
||||
append?: (value: string) => void;
|
||||
}
|
||||
|
||||
function getToolResultContent(toolResult: Record<string, unknown>): Content[] {
|
||||
@@ -47,7 +71,57 @@ function isEmbeddedResource(content: Content): content is EmbeddedResource {
|
||||
return 'resource' in content && typeof (content as Record<string, unknown>).resource === 'object';
|
||||
}
|
||||
|
||||
function maybeRenderMCPApp(
|
||||
toolRequest: ToolRequestMessageContent,
|
||||
toolResponse: ToolResponseMessageContent | undefined,
|
||||
sessionId: string,
|
||||
append?: (value: string) => void
|
||||
): React.ReactNode {
|
||||
const requestWithMeta = toolRequest as ToolRequestWithMeta;
|
||||
let resourceUri = requestWithMeta._meta?.['ui/resourceUri'];
|
||||
|
||||
if (!resourceUri && toolResponse) {
|
||||
const resultWithMeta = toolResponse.toolResult as ToolResultWithMeta;
|
||||
if (resultWithMeta?.status === 'success' && resultWithMeta.value) {
|
||||
resourceUri = resultWithMeta.value._meta?.['ui/resourceUri'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!resourceUri) return null;
|
||||
if (requestWithMeta.toolCall.status !== 'success') return null;
|
||||
|
||||
const extensionName = requestWithMeta.toolCall.value.name.split('__')[0];
|
||||
|
||||
let toolResult: CallToolResponse | undefined;
|
||||
if (toolResponse) {
|
||||
const resultWithMeta = toolResponse.toolResult as ToolResultWithMeta;
|
||||
if (resultWithMeta?.status === 'success' && resultWithMeta.value) {
|
||||
toolResult = resultWithMeta.value;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<McpAppRenderer
|
||||
resourceUri={resourceUri}
|
||||
toolInput={{ arguments: requestWithMeta.toolCall.value.arguments || {} }}
|
||||
toolResult={toolResult}
|
||||
extensionName={extensionName}
|
||||
sessionId={sessionId}
|
||||
append={append}
|
||||
/>
|
||||
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
|
||||
<FlaskConical className="mr-2" size={20} />
|
||||
<div className="text-sm font-sans">
|
||||
MCP Apps are experimental and may change at any time.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolCallWithResponse({
|
||||
sessionId,
|
||||
isCancelledMessage,
|
||||
toolRequest,
|
||||
toolResponse,
|
||||
@@ -107,20 +181,7 @@ export default function ToolCallWithResponse({
|
||||
}
|
||||
})}
|
||||
|
||||
{/* MCP Apps - This data will be coming from a resources/read result. */}
|
||||
{/* TODO Hook this up */}
|
||||
{/* {toolResponse?.toolResult &&
|
||||
mockResourceReadResult.contents.map((content) => {
|
||||
return (
|
||||
<McpAppRenderer
|
||||
resource={content}
|
||||
key={content.uri}
|
||||
toolInput={{ arguments: toolCall.arguments }}
|
||||
toolResult={toolResponse.toolResult.value as unknown as ToolResult}
|
||||
append={append}
|
||||
/>
|
||||
);
|
||||
})} */}
|
||||
{sessionId && maybeRenderMCPApp(toolRequest, toolResponse, sessionId, append)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,11 +35,7 @@ export default function ExtensionsView({
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { addExtension } = useConfig();
|
||||
const chatContext = useChatContext();
|
||||
const sessionId = chatContext?.chat.sessionId || '';
|
||||
|
||||
if (!sessionId) {
|
||||
console.error('ExtensionsView: No session ID available');
|
||||
}
|
||||
const sessionId = chatContext?.chat.sessionId;
|
||||
|
||||
// Only trigger refresh when deep link config changes AND we don't need to show env vars
|
||||
useEffect(() => {
|
||||
@@ -80,20 +76,10 @@ export default function ExtensionsView({
|
||||
// Close the modal immediately
|
||||
handleModalClose();
|
||||
|
||||
if (!sessionId) {
|
||||
console.warn('Cannot activate extension without session');
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionConfig = createExtensionConfig(formData);
|
||||
|
||||
try {
|
||||
await activateExtension({
|
||||
addToConfig: addExtension,
|
||||
extensionConfig: extensionConfig,
|
||||
sessionId: sessionId,
|
||||
});
|
||||
await activateExtension(extensionConfig, addExtension, sessionId);
|
||||
// Trigger a refresh of the extensions list
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { MessageSquare, AlertCircle } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import MarkdownContent from '../MarkdownContent';
|
||||
import ToolCallWithResponse from '../ToolCallWithResponse';
|
||||
@@ -44,34 +43,6 @@ export const getToolResponsesMap = (
|
||||
return responseMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the SessionHeaderCard component
|
||||
*/
|
||||
export interface SessionHeaderCardProps {
|
||||
onBack: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common header card for session views
|
||||
*/
|
||||
export const SessionHeaderCard: React.FC<SessionHeaderCardProps> = ({ onBack, children }) => {
|
||||
return (
|
||||
<Card className="rounded-none px-8 pt-6 pb-4 bg-background-defaultInverse text-textProminentInverse flex items-center">
|
||||
<BackButton
|
||||
showText={false}
|
||||
onClick={onBack}
|
||||
size="lg"
|
||||
className="!text-textProminentInverse dark:!text-textStandardInverse"
|
||||
/>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the SessionMessages component
|
||||
*/
|
||||
interface SessionMessagesProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
import { ExtensionConfig } from '../../../api';
|
||||
|
||||
interface ExtensionSectionProps {
|
||||
sessionId: string; // Add required sessionId prop
|
||||
sessionId?: string;
|
||||
deepLinkConfig?: ExtensionConfig;
|
||||
showEnvVars?: boolean;
|
||||
hideButtons?: boolean;
|
||||
@@ -110,7 +110,7 @@ export default function ExtensionsSection({
|
||||
extensionConfig: extensionConfig,
|
||||
addToConfig: addExtension,
|
||||
toastOptions: { silent: false },
|
||||
sessionId: sessionId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
setPendingActivationExtensions((prev) => {
|
||||
@@ -134,11 +134,7 @@ export default function ExtensionsSection({
|
||||
|
||||
const extensionConfig = createExtensionConfig(formData);
|
||||
try {
|
||||
await activateExtension({
|
||||
addToConfig: addExtension,
|
||||
extensionConfig: extensionConfig,
|
||||
sessionId: sessionId,
|
||||
});
|
||||
await activateExtension(extensionConfig, addExtension, sessionId);
|
||||
setPendingActivationExtensions((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(extensionConfig.name);
|
||||
@@ -205,7 +201,7 @@ export default function ExtensionsSection({
|
||||
await deleteExtension({
|
||||
name,
|
||||
removeFromConfig: removeExtension,
|
||||
sessionId: sessionId,
|
||||
sessionId,
|
||||
extensionConfig: extensionToDelete ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,11 +13,7 @@ function isBuiltinExtension(config: ExtensionConfig): boolean {
|
||||
return config.type === 'builtin';
|
||||
}
|
||||
|
||||
interface ActivateExtensionProps {
|
||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
extensionConfig: ExtensionConfig;
|
||||
sessionId: string;
|
||||
}
|
||||
type AddExtension = (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
|
||||
type ExtensionError = {
|
||||
message?: string;
|
||||
@@ -60,40 +56,39 @@ async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOptions =
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates an extension by adding it to both the config system and the API.
|
||||
* @param props The extension activation properties
|
||||
* @returns Promise that resolves when activation is complete
|
||||
* Activates an extension by adding it config and if a session is set, to the agent
|
||||
*/
|
||||
export async function activateExtension({
|
||||
addToConfig,
|
||||
extensionConfig,
|
||||
sessionId,
|
||||
}: ActivateExtensionProps): Promise<void> {
|
||||
export async function activateExtension(
|
||||
extensionConfig: ExtensionConfig,
|
||||
addExtension: AddExtension,
|
||||
sessionId?: string
|
||||
) {
|
||||
const isBuiltin = isBuiltinExtension(extensionConfig);
|
||||
|
||||
try {
|
||||
// AddToAgent
|
||||
await addToAgent(extensionConfig, sessionId, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension to agent:', error);
|
||||
await addToConfig(extensionConfig.name, extensionConfig, false);
|
||||
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
throw error;
|
||||
if (sessionId) {
|
||||
try {
|
||||
await addToAgent(extensionConfig, sessionId, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension to agent:', error);
|
||||
await addExtension(extensionConfig.name, extensionConfig, false);
|
||||
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await addToConfig(extensionConfig.name, extensionConfig, true);
|
||||
await addExtension(extensionConfig.name, extensionConfig, true);
|
||||
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension to config:', error);
|
||||
// remove from Agent
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, sessionId, true);
|
||||
} catch (removeError) {
|
||||
console.error('Failed to remove extension from agent after config failure:', removeError);
|
||||
if (sessionId) {
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, sessionId, true);
|
||||
} catch (removeError) {
|
||||
console.error('Failed to remove extension from agent after config failure:', removeError);
|
||||
}
|
||||
}
|
||||
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
// Rethrow the error to inform the caller
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -135,7 +130,7 @@ interface UpdateExtensionProps {
|
||||
removeFromConfig: (name: string) => Promise<void>;
|
||||
extensionConfig: ExtensionConfig;
|
||||
originalName?: string;
|
||||
sessionId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,7 +156,9 @@ export async function updateExtension({
|
||||
|
||||
// First remove the old extension from agent (using original name)
|
||||
try {
|
||||
await removeFromAgent(originalName!, sessionId, false);
|
||||
if (sessionId) {
|
||||
await removeFromAgent(originalName!, sessionId, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove old extension from agent during rename:', error);
|
||||
// Continue with the process even if agent removal fails
|
||||
@@ -182,7 +179,7 @@ export async function updateExtension({
|
||||
};
|
||||
|
||||
// Add new extension with sanitized name
|
||||
if (enabled) {
|
||||
if (enabled && sessionId) {
|
||||
try {
|
||||
await addToAgent(sanitizedExtensionConfig, sessionId, false);
|
||||
} catch (error) {
|
||||
@@ -211,7 +208,7 @@ export async function updateExtension({
|
||||
name: sanitizedNewName,
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
if (enabled && sessionId) {
|
||||
try {
|
||||
await addToAgent(sanitizedExtensionConfig, sessionId, false);
|
||||
} catch (error) {
|
||||
@@ -255,7 +252,7 @@ interface ToggleExtensionProps {
|
||||
extensionConfig: ExtensionConfig;
|
||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
toastOptions?: ToastServiceOptions;
|
||||
sessionId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,7 +271,9 @@ export async function toggleExtension({
|
||||
if (toggle == 'toggleOn') {
|
||||
try {
|
||||
// add to agent with toast options
|
||||
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
|
||||
if (sessionId) {
|
||||
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding extension to agent. Attempting to toggle back off.');
|
||||
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
@@ -301,7 +300,9 @@ export async function toggleExtension({
|
||||
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
// remove from agent
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||
if (sessionId) {
|
||||
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||
}
|
||||
} catch (removeError) {
|
||||
console.error('Failed to remove extension from agent after config failure:', removeError);
|
||||
}
|
||||
@@ -311,7 +312,9 @@ export async function toggleExtension({
|
||||
// enabled to disabled
|
||||
let agentRemoveError = null;
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||
if (sessionId) {
|
||||
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||
}
|
||||
} catch (error) {
|
||||
// note there was an error, but attempt to remove from config anyway
|
||||
console.error('Error removing extension from agent', extensionConfig.name, error);
|
||||
@@ -347,7 +350,7 @@ export async function toggleExtension({
|
||||
interface DeleteExtensionProps {
|
||||
name: string;
|
||||
removeFromConfig: (name: string) => Promise<void>;
|
||||
sessionId: string;
|
||||
sessionId?: string;
|
||||
extensionConfig?: ExtensionConfig;
|
||||
}
|
||||
|
||||
@@ -364,7 +367,9 @@ export async function deleteExtension({
|
||||
|
||||
let agentRemoveError = null;
|
||||
try {
|
||||
await removeFromAgent(name, sessionId, true);
|
||||
if (sessionId) {
|
||||
await removeFromAgent(name, sessionId, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove extension from agent during deletion:', error);
|
||||
agentRemoveError = error;
|
||||
|
||||
Reference in New Issue
Block a user