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 {
|
||||
|
||||
Reference in New Issue
Block a user