mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 08:05:44 +00:00
* feat: let the global AI chat call connected MCP servers as the user Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review findings on the chat MCP tools Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: connect MCP servers from a predefined list in chat and agent steps Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: show the OAuth redirect URL in the instance connect settings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clarify the OAuth redirect URL copy in instance settings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: match the instance settings warning style and drop the redirect tooltip Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: use the standard warning alert for the redirect url mismatch Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: correct the GitHub token guidance in the MCP registry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: warn when an OAuth connect lacks the scopes an MCP server needs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: request the connect's scopes when the oauth popup is opened directly Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: connect an oauth-app MCP server without leaving the panel Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: seed connect scopes from the instance config only Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: make the chat use only the MCP servers you turn on Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: align the MCP connect UI with the design system * feat: make a pasted url the default way to connect an mcp server * feat: show provider icons on the suggested mcp servers * fix: make both mcp sign-in paths behave the same and stop reloading on toggle * fix: clarify the mcp tool step's server field and drop its info alert * fix: name the mcp resource in the tool step and move the transport note into the connect box * fix: drop the redundant description on the mcp resource field * fix: make the mcp connections trigger icon-only * fix: scope enabled mcp servers to the account and address review nits * fix: wait for connect scopes and create session connections in the operating workspace * feat: move mcp connections into the chat's plus menu and fix review findings * fix: show mcp servers as checkboxes so off reads as a state * feat: give menu rows an on/off switch and use it for mcp servers * fix: lead the mcp menu rows with the switch * feat: keep the menu open while toggling and simplify the connect card * fix: ask for the server before the credential in the connect card * fix: show one credential path at a time in the connect card * fix: label the path field and move token guidance into its tooltip * fix: open straight into connect and keep the server menu scannable * feat: warn when an mcp connection lands outside your own space * refactor: require the workspace on the mcp connect components and rename the oauth child * fix: replace the oauth variable on reconnect and bound every mcp result * feat: show a connected server's provider icon in the connections list * feat: resolve mcp provider icons from the url and clarify the path field * style: align the mcp connect card with the design system surfaces * style: drop the redundant oauth support line and name the scopes oauth scopes * feat: keep the mcp connect card open in the connections drawer * feat: preopen the mcp connect card under the agent step resource picker * feat: resolve a typed mcp url to its registry entry and describe the token field * style: name both mcp connect actions connect * style: name the mcp oauth actions connect with the provider * style: say in the path description what the connect action will save * style: name the resource type in the mcp connect path description * feat: cache mcp provider icons and confirm disconnect in a modal * fix: keep the mcp menu switches live and the disconnect modal above the drawer * style: fall back to the plug icon in the mcp menu rows * fix: never destroy a foreign variable or resource when connecting an mcp server * fix: prove a token variable is ours before writing it and bound mcp search failures * fix: pin an mcp oauth popup to the target it was opened for * fix: bind an mcp credential to the server and popup it was requested for * fix: bound mcp tool calls with a deadline and drop stale server listings * fix: keep the disconnect confirmation handler returning void * fix: tie the mcp tool cache to the resource revision and the grant to its scopes * fix: verify mcp read-only server-side, keep oauth connector mounted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
234 lines
8.4 KiB
Rust
234 lines
8.4 KiB
Rust
use axum::{
|
|
extract::{Extension, Path},
|
|
Json,
|
|
};
|
|
use serde::Deserialize;
|
|
use serde_json::value::RawValue;
|
|
use windmill_api_auth::{check_scopes, ApiAuthed};
|
|
use windmill_common::{
|
|
db::{DbWithOptAuthed, UserDB, DB},
|
|
error::{Error, JsonResult, Result},
|
|
utils::{not_found_if_none, StripPath},
|
|
};
|
|
use windmill_store::{resources::explain_resource_perm_error, variables::get_value_internal};
|
|
|
|
/// A connected MCP server is a third party the user chose, reached over a
|
|
/// connection this request holds open: without a deadline one that never answers
|
|
/// pins an API worker and the chat turn behind it for as long as it likes.
|
|
const MCP_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
|
|
/// Best-effort courtesy to the server, so it cannot extend the deadline above.
|
|
const MCP_SHUTDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5);
|
|
|
|
async fn with_deadline<T>(
|
|
what: &str,
|
|
fut: impl std::future::Future<Output = Result<T>>,
|
|
) -> Result<T> {
|
|
tokio::time::timeout(MCP_DEADLINE, fut)
|
|
.await
|
|
.map_err(|_| {
|
|
Error::ExecutionErr(format!(
|
|
"MCP server did not answer within {}s ({what})",
|
|
MCP_DEADLINE.as_secs()
|
|
))
|
|
})?
|
|
}
|
|
|
|
/// Connect to the MCP server described by the `mcp` resource at `path`.
|
|
///
|
|
/// The caller is responsible for the scope check; everything else (resource
|
|
/// visibility, token resolution) goes through the caller's permissioned path so
|
|
/// the endpoint can never act as a confused deputy for a resource or secret the
|
|
/// caller cannot read.
|
|
async fn connect_mcp_client(
|
|
authed: &ApiAuthed,
|
|
db: &DB,
|
|
user_db: &UserDB,
|
|
w_id: &str,
|
|
path: &str,
|
|
) -> Result<windmill_mcp::McpClient> {
|
|
let mut tx = user_db.clone().begin(authed).await?;
|
|
|
|
let resource_value_o = sqlx::query_scalar!(
|
|
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
|
|
path,
|
|
w_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
if resource_value_o.is_none() {
|
|
explain_resource_perm_error(path, w_id, db, authed).await?;
|
|
}
|
|
|
|
let resource_value = not_found_if_none(resource_value_o, "Resource", path)?
|
|
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?;
|
|
|
|
let mcp_resource = serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
|
|
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
|
|
|
|
#[cfg(feature = "oauth2")]
|
|
{
|
|
tracing::info!("Checking if token needs refresh before creating MCP client");
|
|
if let Some(ref token_path) = mcp_resource.token {
|
|
let token_var_path = token_path.trim_start_matches("$var:");
|
|
|
|
let token_info = sqlx::query!(
|
|
r#"
|
|
SELECT
|
|
variable.account as account_id,
|
|
(now() > account.expires_at) as "is_expired: bool"
|
|
FROM variable
|
|
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
|
|
WHERE variable.path = $1 AND variable.workspace_id = $2
|
|
"#,
|
|
token_var_path,
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
if let Some(info) = token_info {
|
|
if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) {
|
|
let refresh_tx = user_db.clone().begin(authed).await?;
|
|
if let Err(e) = crate::oauth2_oss::_refresh_token(
|
|
refresh_tx,
|
|
token_var_path,
|
|
w_id,
|
|
account_id,
|
|
db,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to refresh token for MCP resource: {}. Proceeding with possibly expired token.",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve the token through the caller's permissioned (RLS + audit) path so
|
|
// a developer cannot exfiltrate a secret they are not allowed to read by
|
|
// pointing an MCP resource's token at it.
|
|
let token = if let Some(token_path) = &mcp_resource.token {
|
|
let token_var_path = token_path.trim_start_matches("$var:");
|
|
if token_var_path.trim().is_empty() {
|
|
None
|
|
} else {
|
|
let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone()));
|
|
Some(get_value_internal(&db_authed, w_id, token_var_path, false).await?)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
windmill_mcp::McpClient::from_resource(mcp_resource, token)
|
|
.await
|
|
.map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))
|
|
}
|
|
|
|
async fn shutdown_mcp_client(client: windmill_mcp::McpClient) {
|
|
match tokio::time::timeout(MCP_SHUTDOWN_DEADLINE, client.shutdown()).await {
|
|
Ok(Err(e)) => tracing::warn!("Failed to shutdown MCP client: {}", e),
|
|
Err(_) => tracing::warn!("MCP client shutdown timed out"),
|
|
Ok(Ok(())) => {}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn get_mcp_tools(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
) -> JsonResult<Vec<serde_json::Value>> {
|
|
let path = path.to_path();
|
|
check_scopes(&authed, || format!("resources:read:{}", path))?;
|
|
|
|
let client = with_deadline(
|
|
"listing tools",
|
|
connect_mcp_client(&authed, &db, &user_db, &w_id, path),
|
|
)
|
|
.await?;
|
|
|
|
let tools: Vec<serde_json::Value> = client
|
|
.available_tools()
|
|
.iter()
|
|
.map(|tool| {
|
|
serde_json::to_value(tool)
|
|
.map_err(|e| Error::ExecutionErr(format!("Failed to serialize MCP tool: {}", e)))
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
shutdown_mcp_client(client).await;
|
|
|
|
Ok(Json(tools))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct CallMcpToolRequest {
|
|
tool: String,
|
|
arguments: Option<Box<RawValue>>,
|
|
/// Set by a caller that skipped the user's confirmation because it had
|
|
/// listed the tool as read-only. Verified below against the live listing.
|
|
read_only: Option<bool>,
|
|
}
|
|
|
|
/// `readOnlyHint` is the server's own claim, so this cannot tell a hostile
|
|
/// server from an honest one; what it guarantees is that the claim comes from
|
|
/// the server about to be called, not from a listing of whatever the resource
|
|
/// pointed at when the caller cached it.
|
|
fn tool_is_read_only(client: &windmill_mcp::McpClient, tool: &str) -> bool {
|
|
client
|
|
.available_tools()
|
|
.iter()
|
|
.find(|t| t.name.as_ref() == tool)
|
|
.and_then(|t| t.annotations.as_ref())
|
|
.and_then(|a| a.read_only_hint)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub(crate) async fn call_mcp_tool(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
Json(req): Json<CallMcpToolRequest>,
|
|
) -> JsonResult<serde_json::Value> {
|
|
let path = path.to_path();
|
|
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
|
|
|
let arguments = req.arguments.as_ref().map(|a| a.get()).unwrap_or("{}");
|
|
// One deadline over the whole exchange (connect, then call), so a server that
|
|
// stalls after answering the handshake is bounded too.
|
|
let (client, result) = with_deadline(&format!("calling {}", req.tool), async {
|
|
let client = connect_mcp_client(&authed, &db, &user_db, &w_id, path).await?;
|
|
if req.read_only == Some(true) && !tool_is_read_only(&client, &req.tool) {
|
|
return Ok((client, None));
|
|
}
|
|
let result = client.call_tool(&req.tool, arguments).await;
|
|
Ok((client, Some(result)))
|
|
})
|
|
.await?;
|
|
|
|
shutdown_mcp_client(client).await;
|
|
|
|
let Some(result) = result else {
|
|
return Err(Error::BadRequest(format!(
|
|
"MCP tool {} is not marked read-only by the server, it must be called as a tool that modifies data",
|
|
req.tool
|
|
)));
|
|
};
|
|
|
|
// A tool that ran but reported failure comes back as `Ok` with `isError:
|
|
// true` in the payload; forwarding it verbatim lets the caller show the
|
|
// server's own error text instead of a generic 500.
|
|
let result = result
|
|
.map_err(|e| Error::ExecutionErr(format!("Failed to call MCP tool {}: {}", req.tool, e)))?;
|
|
|
|
Ok(Json(result))
|
|
}
|