feat(tkmind): add POST /agent/call_tool for sandbox page materialization

Expose app-visible MCP tool dispatch so local smoke can verify write_file
and edit_file without an LLM round trip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-08 21:37:27 +08:00
parent bb64736c60
commit df7150b687
+101
View File
@@ -8,11 +8,16 @@ use axum::{
Json, Router,
};
use goose_providers::model::ModelConfig;
use rmcp::model::CallToolRequestParams;
use serde::Deserialize;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use tracing::error;
use crate::agents::extension::ToolInfo;
use crate::agents::extension_manager::get_parameter_names;
use crate::agents::reply_parts::is_tool_visible_to_app;
use crate::agents::tool_execution::ToolCallContext;
use crate::agents::ExtensionLoadResult;
use crate::agents::platform_extensions::{chatrecall, projectmemory, PLATFORM_EXTENSIONS};
use crate::agents::ExtensionConfig;
@@ -94,6 +99,101 @@ pub struct RestartAgentResponse {
pub extension_results: Vec<ExtensionLoadResult>,
}
#[derive(Deserialize)]
pub struct CallToolRequest {
session_id: String,
name: String,
arguments: Value,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallToolResponse {
content: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
structured_content: Option<Value>,
is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_meta")]
_meta: Option<Value>,
}
async fn ensure_extensions_loaded(state: &Arc<AppState>, session_id: &str) {
if let Ok(Some(_results)) = state.take_extension_loading_task(session_id).await {
tracing::debug!(
"Awaited background extension loading for session {session_id} before serving request"
);
state.remove_extension_loading_task(session_id).await;
}
}
async fn call_tool(
State(state): State<Arc<AppState>>,
Json(payload): Json<CallToolRequest>,
) -> Result<Json<CallToolResponse>, ErrorResponse> {
ensure_extensions_loaded(&state, &payload.session_id).await;
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
.map_err(|status| ErrorResponse {
message: "Failed to get agent for route".into(),
status,
})?;
let tools = agent.list_tools(&payload.session_id, None).await;
if let Some(tool) = tools.iter().find(|t| *t.name == payload.name) {
if !is_tool_visible_to_app(tool) {
return Err(ErrorResponse {
message: format!("Tool '{}' cannot be called by the app", payload.name),
status: StatusCode::FORBIDDEN,
});
}
}
let arguments = match payload.arguments {
Value::Object(map) => Some(map),
_ => None,
};
let mut tool_call = CallToolRequestParams::new(payload.name);
if let Some(args) = arguments {
tool_call = tool_call.with_arguments(args);
}
let ctx = ToolCallContext::new(payload.session_id.clone(), None, None);
let tool_result = agent
.extension_manager
.dispatch_tool_call(&ctx, tool_call, CancellationToken::default())
.await
.map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let result = tool_result.result.await.map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let content = result
.content
.into_iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
Ok(Json(CallToolResponse {
content,
structured_content: result.structured_content,
is_error: result.is_error.unwrap_or(false),
_meta: result.meta.and_then(|m| serde_json::to_value(m).ok()),
}))
}
#[derive(Deserialize)]
pub struct StartAgentRequest {
working_dir: String,
@@ -562,6 +662,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
.route("/agent/resume", post(resume_agent))
.route("/agent/call_tool", post(call_tool))
.route("/agent/tools", get(get_tools))
.route("/agent/update_provider", post(update_agent_provider))
.route("/agent/update_session", post(update_session))