mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(aiagent): handle oauth for mcp tools (#7564)
* feat(aiagent): handle oauth for mcp tools * cleaning * cleaning * move oauth2 * cleaning * cimd * wrap path * fix frontend * fix * refresh resources * cleaning * update ee ref * update sqlx * cleaning * update * cleaning * update ref * cleaning * cleaning * update ref
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n variable.path,\n variable.account as account_id,\n (now() > account.expires_at) as \"is_expired: bool\"\n FROM variable\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n WHERE variable.path = $1 AND variable.workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "account_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5840477599957b528e10b638e0616b6fb9d04b78271b827c21a399a1474627d7"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8) RETURNING id",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,12 +18,13 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bbc28b92ae8ec3d120a8976be7d3966282fe6543e0eb957fc10864dbf58de58f"
|
||||
"hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n variable.account as account_id,\n (now() > account.expires_at) as \"is_expired: bool\"\n FROM variable\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n WHERE variable.path = $1 AND variable.workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "account_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b4fc94adfe55bb87d2c4b6b45ed2eb5ac25e84f3619a0b403a2d619a0eb51432"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "client",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "refresh_token",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "grant_type",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "cc_client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "cc_client_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "cc_token_url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "mcp_server_url",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e26ccc6607a9c78c1a8c1fd7b3bec931cf0ed27f79f852ae7f63a0ed6e12042f"
|
||||
}
|
||||
Generated
+3
@@ -10944,6 +10944,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"oauth2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.0",
|
||||
@@ -10959,6 +10960,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -15561,6 +15563,7 @@ name = "windmill-mcp"
|
||||
version = "1.605.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"oauth2",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp",
|
||||
"serde",
|
||||
|
||||
@@ -1 +1 @@
|
||||
27dc729e842c200c6b097dc69d99b6a0927465f2
|
||||
6262e373ede45b5b8d45505a63ff73968c04336a
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_account_mcp_server_url;
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS mcp_server_url;
|
||||
DROP TABLE IF EXISTS mcp_oauth_client;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE mcp_oauth_client (
|
||||
mcp_server_url TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT,
|
||||
client_secret_expires_at TIMESTAMP,
|
||||
token_endpoint TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mcp_oauth_client_expires ON mcp_oauth_client(client_secret_expires_at);
|
||||
|
||||
ALTER TABLE account ADD COLUMN mcp_server_url TEXT;
|
||||
CREATE INDEX idx_account_mcp_server_url ON account(mcp_server_url) WHERE mcp_server_url IS NOT NULL;
|
||||
@@ -36,7 +36,7 @@ sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws
|
||||
deno_core = ["dep:deno_core", "dep:deno_error"]
|
||||
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
|
||||
cloud = ["windmill-common/cloud"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
|
||||
python = []
|
||||
|
||||
[dependencies]
|
||||
@@ -158,5 +158,6 @@ tonic = { workspace = true, optional = true }
|
||||
deno_error = { workspace = true, optional = true }
|
||||
deno_core = { workspace = true, optional = true }
|
||||
backon = {workspace = true, optional = true}
|
||||
|
||||
[build-dependencies]
|
||||
deno_core = { workspace = true, optional = true }
|
||||
|
||||
@@ -4224,6 +4224,9 @@ paths:
|
||||
cc_token_url:
|
||||
type: string
|
||||
description: "OAuth token URL override for resource-level authentication (client_credentials flow only)"
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: "MCP server URL for MCP OAuth token refresh"
|
||||
required:
|
||||
- refresh_token
|
||||
- expires_in
|
||||
@@ -15842,6 +15845,98 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/EndpointTool"
|
||||
|
||||
/mcp/oauth/discover:
|
||||
post:
|
||||
summary: discover MCP server OAuth metadata
|
||||
operationId: discoverMcpOAuth
|
||||
tags:
|
||||
- mcp_oauth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- mcp_server_url
|
||||
properties:
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: URL of the MCP server to discover OAuth metadata from
|
||||
responses:
|
||||
"200":
|
||||
description: OAuth metadata from MCP server
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
scopes_supported:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
authorization_endpoint:
|
||||
type: string
|
||||
token_endpoint:
|
||||
type: string
|
||||
registration_endpoint:
|
||||
type: string
|
||||
supports_dynamic_registration:
|
||||
type: boolean
|
||||
|
||||
/mcp/oauth/start:
|
||||
get:
|
||||
summary: start MCP OAuth popup flow
|
||||
description: Opens in a popup, discovers OAuth metadata, registers client, and redirects to OAuth provider
|
||||
operationId: startMcpOAuthPopup
|
||||
tags:
|
||||
- mcp_oauth
|
||||
parameters:
|
||||
- name: mcp_server_url
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: URL of the MCP server to connect to
|
||||
- name: scopes
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of OAuth scopes to request
|
||||
responses:
|
||||
"302":
|
||||
description: Redirect to OAuth provider authorization URL
|
||||
|
||||
/mcp/oauth/callback:
|
||||
get:
|
||||
security: []
|
||||
summary: MCP OAuth callback
|
||||
description: Handles OAuth callback, exchanges code for tokens, returns HTML that posts message to opener
|
||||
operationId: mcpOAuthCallback
|
||||
tags:
|
||||
- mcp_oauth
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: OAuth authorization code
|
||||
- name: state
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: CSRF state token
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page with JavaScript that posts tokens to opener window and closes
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
|
||||
@@ -191,6 +191,10 @@ mod workspaces_oss;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp;
|
||||
#[cfg(all(feature = "mcp", feature = "private"))]
|
||||
mod mcp_oauth_ee;
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp_oauth_oss;
|
||||
|
||||
pub use apps::EditApp;
|
||||
pub const DEFAULT_BODY_LIMIT: usize = 2097152 * 100; // 200MB
|
||||
@@ -668,6 +672,15 @@ pub async fn run_server(
|
||||
#[cfg(not(feature = "oauth2"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/mcp/oauth", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
mcp_oauth_oss::global_service()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/r", {
|
||||
#[cfg(feature = "http_trigger")]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Re-export from EE when private feature is enabled
|
||||
#[cfg(feature = "private")]
|
||||
pub use crate::mcp_oauth_ee::*;
|
||||
|
||||
// OSS stub implementations when private feature is not enabled
|
||||
#[cfg(not(feature = "private"))]
|
||||
mod oss_impl {
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{Html, Redirect},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_common::error::{self, JsonResult};
|
||||
|
||||
/// Global routes for MCP OAuth (OSS stub - returns errors)
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/discover", post(discover_mcp_oauth))
|
||||
.route("/start", get(start_mcp_oauth))
|
||||
.route("/callback", get(mcp_oauth_callback))
|
||||
.route("/client-metadata.json", get(get_client_metadata))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ClientMetadata {
|
||||
pub client_name: &'static str,
|
||||
pub redirect_uris: Vec<String>,
|
||||
pub grant_types: Vec<&'static str>,
|
||||
pub response_types: Vec<&'static str>,
|
||||
pub token_endpoint_auth_method: &'static str,
|
||||
}
|
||||
|
||||
pub async fn get_client_metadata() -> Json<ClientMetadata> {
|
||||
Json(ClientMetadata {
|
||||
client_name: "Windmill",
|
||||
redirect_uris: vec![],
|
||||
grant_types: vec!["authorization_code", "refresh_token"],
|
||||
response_types: vec!["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DiscoverRequest {
|
||||
pub mcp_server_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DiscoverResponse {
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub registration_endpoint: Option<String>,
|
||||
pub supports_dynamic_registration: bool,
|
||||
}
|
||||
|
||||
pub async fn discover_mcp_oauth(
|
||||
Json(_req): Json<DiscoverRequest>,
|
||||
) -> JsonResult<DiscoverResponse> {
|
||||
Err(error::Error::BadRequest(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct StartPopupParams {
|
||||
pub mcp_server_url: String,
|
||||
#[serde(default)]
|
||||
pub scopes: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn start_mcp_oauth(
|
||||
Query(_params): Query<StartPopupParams>,
|
||||
) -> Result<Redirect, error::Error> {
|
||||
Err(error::Error::BadRequest(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CallbackParams {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
pub async fn mcp_oauth_callback(
|
||||
Query(_params): Query<CallbackParams>,
|
||||
) -> Result<Html<String>, error::Error> {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>MCP OAuth Error</title></head>
|
||||
<body>
|
||||
<script>
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({
|
||||
type: 'MCP_ERROR',
|
||||
error: "Not implemented in Windmill's Open Source repository"
|
||||
}, window.location.origin);
|
||||
}
|
||||
window.close();
|
||||
</script>
|
||||
<p>Not implemented in Windmill's Open Source repository</p>
|
||||
</body>
|
||||
</html>"#;
|
||||
Ok(Html(html.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub use oss_impl::*;
|
||||
@@ -68,6 +68,7 @@ fn is_public_route_whitelisted(path: &str) -> bool {
|
||||
"/api/oauth/login/*",
|
||||
"/api/oauth/connect/*",
|
||||
"/oauth/callback/*",
|
||||
"/api/mcp/oauth/callback",
|
||||
"/user/login_callback/*",
|
||||
"/api/workspaces/users",
|
||||
"/api/users/whoami",
|
||||
|
||||
@@ -1414,7 +1414,7 @@ async fn get_mcp_tools(
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:read:{}", path))?;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
// Fetch the MCP resource from database
|
||||
let resource_value_o = sqlx::query_scalar!(
|
||||
@@ -1435,9 +1435,53 @@ async fn get_mcp_tools(
|
||||
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?;
|
||||
|
||||
// Parse MCP resource
|
||||
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)))?;
|
||||
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)))?;
|
||||
|
||||
// Check if token needs refresh before creating MCP client
|
||||
#[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:");
|
||||
|
||||
// Query to check if token is expired
|
||||
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.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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP client connection
|
||||
let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id)
|
||||
|
||||
@@ -11,8 +11,10 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker"]
|
||||
auth = ["rmcp/auth", "dep:oauth2"]
|
||||
|
||||
[dependencies]
|
||||
oauth2 = { version = "5.0", optional = true }
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
anyhow.workspace = true
|
||||
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
|
||||
|
||||
@@ -47,6 +47,20 @@ pub mod server {
|
||||
pub use rmcp::ErrorData;
|
||||
}
|
||||
|
||||
// Re-export rmcp auth types when auth feature is enabled
|
||||
#[cfg(feature = "auth")]
|
||||
pub mod oauth {
|
||||
//! Re-exports of rmcp auth and oauth2 types for MCP OAuth implementations
|
||||
|
||||
pub use rmcp::transport::auth::AuthorizationManager;
|
||||
|
||||
// Re-export oauth2 types needed for MCP OAuth flow
|
||||
pub use oauth2::{
|
||||
basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
|
||||
RedirectUrl, Scope, TokenUrl,
|
||||
};
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -451,6 +451,80 @@ fn apply_tool_filters(
|
||||
tools
|
||||
}
|
||||
|
||||
/// Check if a token variable is expired and refresh it if needed via API call
|
||||
#[cfg(feature = "mcp")]
|
||||
async fn refresh_token_if_expired(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
token_path: &str,
|
||||
auth_token: &str,
|
||||
) -> Result<(), Error> {
|
||||
// Query variable with account join to check expiration
|
||||
let token_info = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
variable.path,
|
||||
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_path,
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let Some(token_info) = token_info else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(account_id) = token_info.account_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !token_info.is_expired.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Token variable {} is expired, triggering refresh",
|
||||
token_path
|
||||
);
|
||||
|
||||
// Call the API refresh endpoint
|
||||
let base_url = windmill_common::BASE_URL.read().await.clone();
|
||||
let refresh_url = format!(
|
||||
"{}/api/w/{}/oauth/refresh_token/{}",
|
||||
base_url, workspace_id, account_id
|
||||
);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RefreshRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
let response = windmill_common::utils::HTTP_CLIENT
|
||||
.post(&refresh_url)
|
||||
.header("Authorization", format!("Bearer {}", auth_token))
|
||||
.json(&RefreshRequest { path: token_path.to_string() })
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("Failed to call token refresh endpoint: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(Error::internal_err(format!(
|
||||
"Token refresh failed: {}",
|
||||
error_text
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load tools from MCP servers and return both the clients and tools
|
||||
/// Returns a map of resource name -> client, and a vector of tools
|
||||
#[cfg(feature = "mcp")]
|
||||
@@ -458,6 +532,7 @@ pub async fn load_mcp_tools(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
mcp_configs: Vec<McpResourceConfig>,
|
||||
auth_token: &str,
|
||||
) -> Result<(HashMap<String, Arc<McpClient>>, Vec<Tool>), Error> {
|
||||
let mut all_mcp_tools = Vec::new();
|
||||
let mut mcp_clients = HashMap::new();
|
||||
@@ -484,6 +559,19 @@ pub async fn load_mcp_tools(
|
||||
|
||||
let resource_name = mcp_resource.name.clone();
|
||||
|
||||
// Check 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:");
|
||||
if let Err(e) =
|
||||
refresh_token_if_expired(db, workspace_id, token_var_path, auth_token).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.",
|
||||
resource_name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new MCP client for this execution
|
||||
tracing::debug!("Creating fresh MCP client for {}", resource_name);
|
||||
let client = McpClient::from_resource(mcp_resource, db, workspace_id)
|
||||
@@ -561,6 +649,7 @@ pub async fn load_mcp_tools<T>(
|
||||
_db: &DB,
|
||||
_workspace_id: &str,
|
||||
_mcp_configs: Vec<McpResourceConfig>,
|
||||
_auth_token: &str,
|
||||
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
|
||||
Ok((HashMap::new(), Vec::new()))
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ pub async fn handle_ai_agent_job(
|
||||
let mut tools = tools;
|
||||
|
||||
let mcp_clients = if !mcp_configs.is_empty() {
|
||||
let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs).await?;
|
||||
let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs, &client.token).await?;
|
||||
tools.extend(mcp_tools);
|
||||
clients
|
||||
} else {
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pb-0 mb-1">
|
||||
<div class="flex flex-col flex-wrap sm:flex-row sm:items-center gap-2 pb-0 mb-1">
|
||||
{#if meta != undefined}
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
{#if !hideUser}
|
||||
@@ -462,7 +462,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-secondary">/</div>
|
||||
<label class="block grow w-full max-w-md">
|
||||
<label class="block grow min-w-32 max-w-md">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput
|
||||
bind:this={inputP}
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
appConnect?.open?.(resourceType)
|
||||
}
|
||||
|
||||
export async function refreshResources() {
|
||||
await loadResources(resourceType)
|
||||
}
|
||||
|
||||
let loading = $state(true)
|
||||
async function loadResources(resourceType: string | undefined) {
|
||||
loading = true
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
/>
|
||||
{:else if isMcpTool(tool)}
|
||||
<!-- MCP tool - use McpToolEditor -->
|
||||
<McpToolEditor bind:tool={tool as McpTool} {noEditor} />
|
||||
<McpToolEditor bind:tool={tool as McpTool} />
|
||||
{:else if isWebsearchTool(tool)}
|
||||
<WebsearchToolDisplay />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
McpOauthService,
|
||||
OauthService,
|
||||
ResourceService,
|
||||
VariableService,
|
||||
type DiscoverMcpOauthResponse
|
||||
} from '$lib/gen'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { sameTopDomainOrigin } from '$lib/cookies'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
onConnected: (resourcePath: string, resourceName: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
let { onConnected, onCancel }: Props = $props()
|
||||
|
||||
let serverUrl = $state('')
|
||||
let discoveryResult = $state<DiscoverMcpOauthResponse | null>(null)
|
||||
let selectedScopes = $state<string[]>([])
|
||||
let resourceName = $state('')
|
||||
let resourcePath = $state('')
|
||||
let pathError = $state('')
|
||||
let status = $state<'idle' | 'discovering' | 'discovered' | 'connecting'>('idle')
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
async function discoverOAuth() {
|
||||
status = 'discovering'
|
||||
error = null
|
||||
try {
|
||||
discoveryResult = await McpOauthService.discoverMcpOauth({
|
||||
requestBody: { mcp_server_url: serverUrl }
|
||||
})
|
||||
selectedScopes = discoveryResult?.scopes_supported ?? []
|
||||
try {
|
||||
const urlObj = new URL(serverUrl)
|
||||
resourceName = urlObj.hostname.replace(/\./g, '_')
|
||||
} catch {
|
||||
resourceName = 'mcp_server'
|
||||
}
|
||||
status = 'discovered'
|
||||
} catch (e) {
|
||||
console.error('Error discovering OAuth settings', e)
|
||||
const errorMessage = e.body?.message || e.body || e.message || 'Unknown error'
|
||||
error = `Failed to discover OAuth settings: ${errorMessage}`
|
||||
status = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function startOAuth() {
|
||||
const url = new URL(`/api/mcp/oauth/start`, window.location.origin)
|
||||
url.searchParams.set('mcp_server_url', serverUrl)
|
||||
url.searchParams.set('scopes', selectedScopes.join(','))
|
||||
|
||||
const popup = window.open(url.toString(), '_blank', 'popup=true')
|
||||
if (!popup) {
|
||||
error = 'Popup blocked. Please allow popups for this site.'
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleOAuthMessage)
|
||||
window.addEventListener('storage', handleStorageEvent)
|
||||
status = 'connecting'
|
||||
}
|
||||
|
||||
function handleOAuthMessage(event: MessageEvent) {
|
||||
if (!sameTopDomainOrigin(event.origin, window.location.origin)) return
|
||||
|
||||
if (event.data.type === 'MCP_CONNECTED') {
|
||||
cleanup()
|
||||
createMcpResource(event.data)
|
||||
} else if (event.data.type === 'MCP_ERROR') {
|
||||
cleanup()
|
||||
error = event.data.error
|
||||
status = 'discovered'
|
||||
}
|
||||
}
|
||||
|
||||
function handleStorageEvent(event: StorageEvent) {
|
||||
if (event.key === 'mcp-oauth-callback') {
|
||||
cleanup()
|
||||
try {
|
||||
const data = JSON.parse(event.newValue || '{}')
|
||||
localStorage.removeItem('mcp-oauth-callback')
|
||||
if (data.type === 'MCP_CONNECTED') {
|
||||
createMcpResource(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing MCP OAuth callback', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
window.removeEventListener('message', handleOAuthMessage)
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
}
|
||||
|
||||
async function createMcpResource(data: {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
mcp_server_url: string
|
||||
}) {
|
||||
try {
|
||||
let accountId: number | undefined
|
||||
if (data.expires_in && data.refresh_token) {
|
||||
const accountIdStr = await OauthService.createAccount({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
client: 'mcp',
|
||||
mcp_server_url: data.mcp_server_url
|
||||
}
|
||||
})
|
||||
accountId = Number(accountIdStr)
|
||||
}
|
||||
|
||||
await VariableService.createVariable({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: resourcePath,
|
||||
value: data.access_token,
|
||||
is_secret: true,
|
||||
is_oauth: true,
|
||||
account: accountId,
|
||||
description: `MCP OAuth token for ${data.mcp_server_url}`
|
||||
}
|
||||
})
|
||||
|
||||
await ResourceService.createResource({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
resource_type: 'mcp',
|
||||
path: resourcePath,
|
||||
value: {
|
||||
name: resourceName,
|
||||
url: data.mcp_server_url,
|
||||
token: `$var:${resourcePath}`
|
||||
},
|
||||
description: `MCP server connected via OAuth`
|
||||
}
|
||||
})
|
||||
|
||||
sendUserToast('Connected to MCP server')
|
||||
onConnected(resourcePath, resourceName)
|
||||
} catch (e: any) {
|
||||
error = e.body?.message || e.message || 'Failed to create resource'
|
||||
status = 'discovered'
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(cleanup)
|
||||
</script>
|
||||
|
||||
<div class="border rounded p-4 bg-surface-secondary flex flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-sm">Connect MCP Server with OAuth</span>
|
||||
<Button size="xs" color="light" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
|
||||
<Label label="MCP Server URL">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={serverUrl}
|
||||
placeholder="https://mcp.example.com"
|
||||
class="text-sm w-full"
|
||||
disabled={status === 'connecting'}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{#if status === 'idle'}
|
||||
<Button size="sm" onClick={discoverOAuth} disabled={!serverUrl}>Discover OAuth Settings</Button>
|
||||
{:else if status === 'discovering'}
|
||||
<div class="text-sm text-secondary">Discovering OAuth settings...</div>
|
||||
{:else if status === 'discovered' && discoveryResult}
|
||||
<div class="text-xs text-green-600 dark:text-green-400">
|
||||
✓ OAuth supported
|
||||
{#if discoveryResult.supports_dynamic_registration}
|
||||
(Dynamic Client Registration available)
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if discoveryResult.scopes_supported && discoveryResult.scopes_supported.length > 0}
|
||||
<Label label="Select Scopes">
|
||||
<div class="flex flex-col flex-wrap gap-2">
|
||||
{#each discoveryResult.scopes_supported as scope}
|
||||
<label class="flex flex-row items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(scope)}
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.checked) {
|
||||
selectedScopes = [...selectedScopes, scope]
|
||||
} else {
|
||||
selectedScopes = selectedScopes.filter((s) => s !== scope)
|
||||
}
|
||||
}}
|
||||
class="!w-4 !h-4"
|
||||
/>
|
||||
{scope}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</Label>
|
||||
{/if}
|
||||
|
||||
<Label label="Resource Name">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={resourceName}
|
||||
placeholder="my-mcp-server"
|
||||
class="text-sm w-full"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Path
|
||||
bind:path={resourcePath}
|
||||
bind:error={pathError}
|
||||
initialPath=""
|
||||
namePlaceholder={resourceName}
|
||||
kind="resource"
|
||||
/>
|
||||
|
||||
<Button size="sm" onClick={startOAuth} disabled={!resourcePath || pathError !== ''}>
|
||||
Connect with OAuth
|
||||
</Button>
|
||||
{:else if status === 'connecting'}
|
||||
<div class="text-sm text-secondary">Complete authentication in popup window...</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="text-xs text-red-600 dark:text-red-400">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
{
|
||||
initial: { workspace: get(workspaceStore), path: undefined, refreshCount: 0 },
|
||||
invalidateMs: 1000 * 60
|
||||
} // Cache for 60 seconds
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -28,15 +28,17 @@
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import McpOAuthConnect from './McpOAuthConnect.svelte'
|
||||
|
||||
interface Props {
|
||||
tool: McpTool
|
||||
noEditor: boolean
|
||||
}
|
||||
|
||||
let { tool = $bindable() }: Props = $props()
|
||||
|
||||
let showOAuthForm = $state(false)
|
||||
let refreshCount = $state(0)
|
||||
let resourcePicker: ResourcePicker | undefined = $state()
|
||||
|
||||
let tools = usePromise(
|
||||
async () =>
|
||||
@@ -48,18 +50,16 @@
|
||||
{ loadInit: false, clearValueOnRefresh: false }
|
||||
)
|
||||
|
||||
// Options for the multiselect
|
||||
let toolOptions = $derived(safeSelectItems((tools.value ?? []).map((t) => t.name)))
|
||||
let resourcePath = $derived(tool.value.resource_path)
|
||||
let error = $derived(tools.error?.body?.message || tools.error?.message)
|
||||
|
||||
// Watch for resource_path changes and refresh tools
|
||||
$effect(() => {
|
||||
// Track reactive dependencies
|
||||
tool.value.resource_path
|
||||
resourcePath
|
||||
$workspaceStore
|
||||
refreshCount
|
||||
// Trigger refresh when resource_path or workspace changes
|
||||
untrack(() => {
|
||||
if (tool.value.resource_path?.length > 0) {
|
||||
if (resourcePath?.length > 0) {
|
||||
tools.refresh()
|
||||
}
|
||||
})
|
||||
@@ -75,14 +75,20 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (tool.value.resource_path?.length > 0 && tool.summary?.length === 0) {
|
||||
if (resourcePath?.length > 0 && tool.summary?.length === 0) {
|
||||
tool.summary = `MCP: ${tool.value.resource_path}`
|
||||
}
|
||||
})
|
||||
|
||||
async function handleOAuthConnected(resourcePath: string, resourceName: string) {
|
||||
await resourcePicker?.refreshResources()
|
||||
tool.value.resource_path = resourcePath
|
||||
tool.summary = `MCP: ${resourceName}`
|
||||
showOAuthForm = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Explanatory Section -->
|
||||
<Alert type="info" title="MCP Client Configuration">
|
||||
{#snippet children()}
|
||||
<p class="mb-2 text-sm">
|
||||
@@ -97,15 +103,26 @@
|
||||
{/snippet}
|
||||
</Alert>
|
||||
|
||||
<!-- Resource Path Section -->
|
||||
<div class="w-full">
|
||||
<Label label="MCP Resource">
|
||||
<ResourcePicker resourceType="mcp" bind:value={tool.value.resource_path} />
|
||||
<ResourcePicker bind:this={resourcePicker} resourceType="mcp" bind:value={tool.value.resource_path} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{#if tool.value.resource_path?.length > 0}
|
||||
<!-- Summary Section -->
|
||||
{#if !resourcePath}
|
||||
{#if !showOAuthForm}
|
||||
<Button size="xs" color="light" onClick={() => (showOAuthForm = true)}>
|
||||
Connect with OAuth
|
||||
</Button>
|
||||
{:else}
|
||||
<McpOAuthConnect
|
||||
onConnected={handleOAuthConnected}
|
||||
onCancel={() => (showOAuthForm = false)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if resourcePath?.length > 0}
|
||||
<div class="w-full">
|
||||
<Label label="Summary">
|
||||
<input
|
||||
@@ -117,13 +134,12 @@
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- Available Tools Section -->
|
||||
<Section label="Available Tools">
|
||||
{#snippet action()}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
on:click={() => (refreshCount += 1)}
|
||||
onClick={() => (refreshCount += 1)}
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
disabled={tools.status === 'loading'}
|
||||
>
|
||||
@@ -131,39 +147,37 @@
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
{#if tools.error}
|
||||
<div class="text-xs text-red-600 p-2 border border-red-300 rounded bg-red-50">
|
||||
{tools.error?.body?.message ||
|
||||
tools.error?.message ||
|
||||
'Failed to load tools from MCP server'}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
|
||||
{#if tools.status === 'loading'}
|
||||
{#if error}
|
||||
<div class="text-xs text-red-600 dark:text-red-400 mb-4"
|
||||
>{`Failed to load tools from MCP server: ${error}`}</div
|
||||
>
|
||||
{:else if tools.status === 'loading'}
|
||||
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
|
||||
<div class="text-xs text-secondary italic">Loading tools...</div>
|
||||
{:else if (tools.value ?? []).length === 0}
|
||||
</div>
|
||||
{:else if (tools.value ?? []).length === 0 && !error}
|
||||
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
|
||||
<div class="text-xs text-secondary italic">
|
||||
{tools.error
|
||||
? 'Failed to load tools. Please check the resource path and try again.'
|
||||
: 'No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server.'}
|
||||
No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server.
|
||||
</div>
|
||||
{:else}
|
||||
</div>
|
||||
{:else if (tools.value ?? []).length > 0}
|
||||
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tools.value ?? [] as tool}
|
||||
{#each tools.value ?? [] as mcpTool}
|
||||
<div class="text-xs">
|
||||
<span class="font-semibold">{tool.name}</span>
|
||||
{#if tool.description}
|
||||
<span class="text-secondary">— {tool.description}</span>
|
||||
<span class="font-semibold">{mcpTool.name}</span>
|
||||
{#if mcpTool.description}
|
||||
<span class="text-secondary">— {mcpTool.description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<!-- Tool Filtering Section -->
|
||||
{#if tool.value.include_tools && tool.value.exclude_tools}
|
||||
<Section label="Tool Filtering">
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
|
||||
Reference in New Issue
Block a user