feat: Add MCP endpoints (#5639)
This commit is contained in:
89
backend/Cargo.lock
generated
89
backend/Cargo.lock
generated
@@ -6081,7 +6081,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.0",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6798,7 +6798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9870,6 +9870,40 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/windmill-labs/rust-sdk#e6c368965711a2afe45218149d1be5324b098a4d"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"futures",
|
||||
"paste",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.0",
|
||||
"rmcp-macros",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/windmill-labs/rust-sdk#e6c368965711a2afe45218149d1be5324b098a4d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.8.1"
|
||||
@@ -13771,7 +13805,7 @@ dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
"once_cell",
|
||||
"parking_lot 0.12.3",
|
||||
"parking_lot 0.11.2",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"ron",
|
||||
@@ -13813,7 +13847,7 @@ dependencies = [
|
||||
"ndk-sys",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"parking_lot 0.12.3",
|
||||
"parking_lot 0.11.2",
|
||||
"profiling",
|
||||
"range-alloc",
|
||||
"raw-window-handle",
|
||||
@@ -14020,6 +14054,7 @@ dependencies = [
|
||||
"rdkafka",
|
||||
"regex",
|
||||
"reqwest 0.12.15",
|
||||
"rmcp",
|
||||
"rsa",
|
||||
"rumqttc",
|
||||
"rust-embed",
|
||||
@@ -14045,7 +14080,7 @@ dependencies = [
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tonic",
|
||||
"tower 0.5.2",
|
||||
"tower 0.4.13",
|
||||
"tower-cookies",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@@ -14624,19 +14659,6 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.0",
|
||||
"windows-interface 0.59.1",
|
||||
"windows-link",
|
||||
"windows-result 0.3.2",
|
||||
"windows-strings 0.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.56.0"
|
||||
@@ -14670,17 +14692,6 @@ dependencies = [
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.56.0"
|
||||
@@ -14714,17 +14725,6 @@ dependencies = [
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.1"
|
||||
@@ -14788,15 +14788,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
|
||||
@@ -72,6 +72,7 @@ dind = ["windmill-worker/dind"]
|
||||
websocket = ["windmill-api/websocket"]
|
||||
http_trigger = ["windmill-api/http_trigger"]
|
||||
postgres_trigger = ["windmill-api/postgres_trigger"]
|
||||
mcp = ["windmill-api/mcp"]
|
||||
mqtt_trigger = ["windmill-api/mqtt_trigger"]
|
||||
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
|
||||
gcp_trigger = ["windmill-api/gcp_trigger"]
|
||||
|
||||
@@ -35,8 +35,10 @@ sqs_trigger = ["dep:aws-sdk-sqs", "dep:thiserror", "dep:aws-config"]
|
||||
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:rmcp"]
|
||||
|
||||
[dependencies]
|
||||
rmcp = { git = "https://github.com/windmill-labs/rust-sdk", features = ["transport-sse-server"], optional = true }
|
||||
windmill-queue.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-audit.workspace = true
|
||||
|
||||
@@ -4622,7 +4622,6 @@ pub async fn run_wait_result_script_by_hash(
|
||||
check_license_key_valid().await?;
|
||||
|
||||
let args = args.to_push_args_owned(&authed, &db, &w_id).await?;
|
||||
|
||||
check_queue_too_long(&db, run_query.queue_limit).await?;
|
||||
|
||||
let hash = script_hash.0;
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::oauth2_ee::SlackVerifier;
|
||||
#[cfg(feature = "smtp")]
|
||||
use crate::smtp_server_ee::SmtpServer;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
use crate::mcp::{setup_mcp_server, Runner as McpRunner};
|
||||
use crate::tracing_init::MyOnFailure;
|
||||
use crate::{
|
||||
tracing_init::{MyMakeSpan, MyOnResponse},
|
||||
@@ -27,6 +29,7 @@ use crate::{
|
||||
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
use agent_workers_ee::AgentCache;
|
||||
|
||||
use anyhow::Context;
|
||||
use argon2::Argon2;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
@@ -139,6 +142,9 @@ mod workspaces_ee;
|
||||
mod workspaces_export;
|
||||
mod workspaces_extra;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp;
|
||||
|
||||
pub const DEFAULT_BODY_LIMIT: usize = 2097152 * 100; // 200MB
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -448,6 +454,23 @@ pub async fn run_server(
|
||||
}
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.context("binding main windmill server")?;
|
||||
let port = listener.local_addr().map(|x| x.port()).unwrap_or(8000);
|
||||
let ip = listener
|
||||
.local_addr()
|
||||
.map(|x| x.ip().to_string())
|
||||
.unwrap_or("localhost".to_string());
|
||||
|
||||
// Setup MCP server
|
||||
#[cfg(feature = "mcp")]
|
||||
let (mcp_sse_server, mcp_router) = setup_mcp_server(addr, "/api/w/:workspace_id/mcp")?;
|
||||
#[cfg(feature = "mcp")]
|
||||
let mcp_main_ct = mcp_sse_server.config.ct.clone(); // Token to signal shutdown *to* MCP
|
||||
#[cfg(feature = "mcp")]
|
||||
let mcp_service_ct = mcp_sse_server.with_service(McpRunner::new); // Token to wait for MCP *service* shutdown
|
||||
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
let (agent_workers_router, agent_workers_bg_processor, agent_workers_killpill_tx) =
|
||||
agent_workers_ee::workspaced_service(db.clone(), _base_internal_url.clone());
|
||||
@@ -586,6 +609,17 @@ pub async fn run_server(
|
||||
.layer(from_extractor::<OptAuthed>())
|
||||
.layer(cors.clone()),
|
||||
)
|
||||
.nest("/w/:workspace_id/mcp", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
mcp_router
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
})
|
||||
.layer(from_extractor::<OptAuthed>())
|
||||
.nest(
|
||||
"/w/:workspace_id/jobs_u",
|
||||
jobs::workspace_unauthed_service().layer(cors.clone()),
|
||||
@@ -694,14 +728,6 @@ pub async fn run_server(
|
||||
.on_failure(MyOnFailure {}),
|
||||
)
|
||||
};
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.context("binding main windmill server")?;
|
||||
let port = listener.local_addr().map(|x| x.port()).unwrap_or(8000);
|
||||
let ip = listener
|
||||
.local_addr()
|
||||
.map(|x| x.ip().to_string())
|
||||
.unwrap_or("localhost".to_string());
|
||||
|
||||
let server = axum::serve(listener, app.into_make_service());
|
||||
|
||||
@@ -723,10 +749,18 @@ pub async fn run_server(
|
||||
tracing::error!("Error killing agent workers: {e:#}");
|
||||
}
|
||||
tracing::info!("Graceful shutdown of server");
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
tracing::info!("Received shutdown signal, cancelling MCP server...");
|
||||
mcp_main_ct.cancel();
|
||||
tracing::info!("Waiting for MCP service cancellation...");
|
||||
mcp_service_ct.cancelled().await;
|
||||
tracing::info!("MCP service cancelled.");
|
||||
}
|
||||
});
|
||||
|
||||
server.await?;
|
||||
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
for (i, bg_processor) in agent_workers_bg_processor.into_iter().enumerate() {
|
||||
tracing::info!("server off. shutting down agent worker bg processor {i}");
|
||||
|
||||
642
backend/windmill-api/src/mcp.rs
Normal file
642
backend/windmill-api/src/mcp.rs
Normal file
@@ -0,0 +1,642 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::to_bytes;
|
||||
use axum::Router;
|
||||
use rmcp::transport::sse_server::{SseServer, SseServerConfig};
|
||||
use rmcp::{
|
||||
handler::server::ServerHandler,
|
||||
model::*,
|
||||
service::{RequestContext, RoleServer},
|
||||
Error,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::FromRow;
|
||||
use tokio::try_join;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::DB;
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::jobs::{
|
||||
run_wait_result_flow_by_path_internal, run_wait_result_script_by_path_internal, RunJobQuery,
|
||||
};
|
||||
use windmill_common::utils::StripPath;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Runner {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
struct Schema {
|
||||
#[serde(default)]
|
||||
properties: HashMap<String, SchemaProperty>,
|
||||
#[serde(flatten)]
|
||||
other: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
struct SchemaProperty {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
format: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
r#type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[allow(non_snake_case)]
|
||||
oneOf: Option<Vec<Value>>,
|
||||
#[serde(flatten)]
|
||||
other: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
struct ScriptInfo {
|
||||
path: String,
|
||||
summary: Option<String>,
|
||||
description: Option<String>,
|
||||
schema: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
struct FlowInfo {
|
||||
path: String,
|
||||
summary: Option<String>,
|
||||
description: Option<String>,
|
||||
schema: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
struct ResourceInfo {
|
||||
path: String,
|
||||
description: Option<String>,
|
||||
resource_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
struct ResourceType {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
struct ResourceCache {
|
||||
resource_type: ResourceType,
|
||||
resources: Vec<ResourceInfo>,
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn transform_path(path: &str, type_str: &str) -> Result<String, String> {
|
||||
if type_str != "script" && type_str != "flow" {
|
||||
return Err(format!("Invalid type: {}", type_str));
|
||||
}
|
||||
|
||||
// Only apply special underscore escaping for paths starting with "f/"
|
||||
let transformed = if path.starts_with("f/") {
|
||||
let escaped_path = path.replace('_', "__");
|
||||
escaped_path.replace('/', "_")
|
||||
} else {
|
||||
path.replace('/', "_")
|
||||
};
|
||||
|
||||
Ok(format!("{}-{}", type_str, transformed))
|
||||
}
|
||||
|
||||
fn reverse_transform(transformed_path: &str) -> Result<(&str, String), String> {
|
||||
let prefix = if transformed_path.starts_with("script-") {
|
||||
"script-"
|
||||
} else if transformed_path.starts_with("flow-") {
|
||||
"flow-"
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Invalid prefix in transformed path: {}",
|
||||
transformed_path
|
||||
));
|
||||
};
|
||||
|
||||
let type_str = &prefix[..prefix.len() - 1]; // "script" or "flow"
|
||||
let mangled_path = &transformed_path[prefix.len()..];
|
||||
|
||||
// Check if this path was previously transformed with special underscore handling
|
||||
let is_special_path = mangled_path.starts_with("f_");
|
||||
|
||||
let original_path = if is_special_path {
|
||||
const TEMP_PLACEHOLDER: &str = "@@UNDERSCORE@@";
|
||||
let path_with_placeholder = mangled_path.replace("__", TEMP_PLACEHOLDER);
|
||||
let path_with_slashes = path_with_placeholder.replace('_', "/");
|
||||
path_with_slashes.replace(TEMP_PLACEHOLDER, "_")
|
||||
} else {
|
||||
mangled_path.replacen('_', "/", 2)
|
||||
};
|
||||
|
||||
Ok((type_str, original_path))
|
||||
}
|
||||
|
||||
async fn inner_get_resource_type_info(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<ResourceType, Error> {
|
||||
let mut sqlb = SqlBuilder::select_from("resource_type as o");
|
||||
sqlb.fields(&["o.name", "o.description"]);
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.name = ?".bind(&resource_type));
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
Error::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, ResourceType>(&sql)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch resource info: {}", _e);
|
||||
Error::internal_error("failed to fetch resource info", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to commit transaction", None))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn inner_get_resources(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<Vec<ResourceInfo>, Error> {
|
||||
let mut sqlb = SqlBuilder::select_from("resource as o");
|
||||
sqlb.fields(&["o.path", "o.description", "o.resource_type"]);
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.resource_type = ?".bind(&resource_type));
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
Error::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, ResourceInfo>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch resources: {}", _e);
|
||||
Error::internal_error("failed to fetch resources", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to commit transaction", None))?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn inner_get_flows(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
scope_type: &str,
|
||||
) -> Result<Vec<FlowInfo>, Error> {
|
||||
let mut sqlb = SqlBuilder::select_from("flow as o");
|
||||
sqlb.fields(&["o.path", "o.summary", "o.description", "o.schema"]);
|
||||
if scope_type == "favorites" {
|
||||
sqlb.join("favorite")
|
||||
.on("favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?"
|
||||
.bind(&authed.username));
|
||||
}
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id))
|
||||
.and_where("o.archived = false")
|
||||
.and_where("o.draft_only IS NOT TRUE")
|
||||
.order_by("o.edited_at", false)
|
||||
.limit(100);
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
Error::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, FlowInfo>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch flows: {}", _e);
|
||||
Error::internal_error("failed to fetch flows", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to commit transaction", None))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn inner_get_scripts(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
scope_type: &str,
|
||||
) -> Result<Vec<ScriptInfo>, Error> {
|
||||
let mut sqlb = SqlBuilder::select_from("script as o");
|
||||
sqlb.fields(&["o.path", "o.summary", "o.description", "o.schema"]);
|
||||
if scope_type == "favorites" {
|
||||
sqlb.join("favorite")
|
||||
.on("favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?"
|
||||
.bind(&authed.username));
|
||||
}
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id))
|
||||
.and_where("o.archived = false")
|
||||
.and_where("o.draft_only IS NOT TRUE")
|
||||
.order_by("o.created_at", false)
|
||||
.limit(100);
|
||||
let sql = sqlb.sql().map_err(|_e| {
|
||||
tracing::error!("failed to build sql: {}", _e);
|
||||
Error::internal_error("failed to build sql", None)
|
||||
})?;
|
||||
let mut tx = user_db
|
||||
.clone()
|
||||
.begin(authed)
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to begin transaction", None))?;
|
||||
let rows = sqlx::query_as::<_, ScriptInfo>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|_e| {
|
||||
tracing::error!("Failed to fetch scripts: {}", _e);
|
||||
Error::internal_error("failed to fetch scripts", None)
|
||||
})?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_e| Error::internal_error("failed to commit transaction", None))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn transform_schema_for_resources(
|
||||
schema: &mut Schema,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
resources_info: &mut HashMap<String, ResourceCache>,
|
||||
) -> Result<(), Error> {
|
||||
for (_key, prop) in schema.properties.iter_mut() {
|
||||
if let Some(format) = &prop.format {
|
||||
if format.contains("resource") {
|
||||
let resource_type_key =
|
||||
format.split("-").last().unwrap_or_default().to_string();
|
||||
|
||||
if !resources_info.contains_key(&resource_type_key) {
|
||||
let fetch_result = async {
|
||||
let resource_type_info_future = Runner::inner_get_resource_type_info(
|
||||
user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&resource_type_key,
|
||||
);
|
||||
let resources_data_future = Runner::inner_get_resources(
|
||||
user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&resource_type_key,
|
||||
);
|
||||
let (resource_type_info, resources_data) =
|
||||
try_join!(resource_type_info_future, resources_data_future)?;
|
||||
Ok::<_, Error>(ResourceCache {
|
||||
resource_type: resource_type_info,
|
||||
resources: resources_data,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
match fetch_result {
|
||||
Ok(cache_data) => {
|
||||
resources_info.insert(resource_type_key.clone(), cache_data);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch resource cache data: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(resource_cache) = resources_info.get(&resource_type_key) {
|
||||
let resources_count = resource_cache.resources.len();
|
||||
|
||||
prop.r#type = Some("string".to_string());
|
||||
prop.description = Some(format!(
|
||||
"This is a resource named {} with the following description: {}.\nThe path of the resource should be used to specify the resource.\n{}",
|
||||
resource_cache.resource_type.name,
|
||||
resource_cache.resource_type.description.as_deref().unwrap_or("No description"),
|
||||
if resources_count == 0 {
|
||||
"This resource does not have any available instances, you should create one from your windmill workspace"
|
||||
} else if resources_count > 1 {
|
||||
"This resource has multiple available instances, you should precisely select the one you want to use"
|
||||
} else {
|
||||
"There is 1 resource available"
|
||||
}
|
||||
));
|
||||
|
||||
if resources_count > 0 {
|
||||
prop.oneOf = Some(
|
||||
resource_cache
|
||||
.resources
|
||||
.iter()
|
||||
.map(|resource| {
|
||||
serde_json::Value::Object(serde_json::Map::from_iter(
|
||||
[
|
||||
(
|
||||
"const".to_string(),
|
||||
serde_json::Value::String(format!(
|
||||
"$res:{}",
|
||||
resource.path.clone()
|
||||
)),
|
||||
),
|
||||
(
|
||||
"title".to_string(),
|
||||
serde_json::Value::String(
|
||||
resource
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("No description")
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
]
|
||||
.into_iter(),
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"Resource cache entry unexpectedly missing for key: {}",
|
||||
resource_type_key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for Runner {
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParam,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, Error> {
|
||||
let parse_args = |args_opt: Option<JsonObject>| -> Result<Value, Error> {
|
||||
args_opt.map(Value::Object).ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"Missing arguments for tool",
|
||||
Some(request.name.clone().into()),
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
let authed = context
|
||||
.req_extensions
|
||||
.get::<ApiAuthed>()
|
||||
.ok_or_else(|| Error::internal_error("ApiAuthed not found", None))?;
|
||||
let db = context
|
||||
.req_extensions
|
||||
.get::<DB>()
|
||||
.ok_or_else(|| Error::internal_error("DB not found", None))?;
|
||||
let user_db = context
|
||||
.req_extensions
|
||||
.get::<UserDB>()
|
||||
.ok_or_else(|| Error::internal_error("UserDB not found", None))?;
|
||||
let args = parse_args(request.arguments)?;
|
||||
|
||||
let (tool_type, path) = Runner::reverse_transform(&request.name).unwrap_or_default();
|
||||
|
||||
// Convert Value to PushArgsOwned
|
||||
let push_args = if let Value::Object(map) = args.clone() {
|
||||
let mut args_hash = HashMap::new();
|
||||
for (k, v) in map {
|
||||
args_hash.insert(k, to_raw_value(&v));
|
||||
}
|
||||
windmill_queue::PushArgsOwned { extra: None, args: args_hash }
|
||||
} else {
|
||||
windmill_queue::PushArgsOwned::default()
|
||||
};
|
||||
|
||||
let w_id = context.workspace_id.clone();
|
||||
let script_or_flow_path = StripPath(path);
|
||||
let run_query = RunJobQuery::default();
|
||||
|
||||
let result = if tool_type == "script" {
|
||||
run_wait_result_script_by_path_internal(
|
||||
db.clone(),
|
||||
run_query,
|
||||
script_or_flow_path,
|
||||
authed.clone(),
|
||||
user_db.clone(),
|
||||
w_id.clone(),
|
||||
push_args,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
run_wait_result_flow_by_path_internal(
|
||||
db.clone(),
|
||||
run_query,
|
||||
script_or_flow_path,
|
||||
authed.clone(),
|
||||
user_db.clone(),
|
||||
push_args,
|
||||
w_id.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
// Extract the response body as bytes, then convert to a string
|
||||
let body_bytes = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_error(format!("Failed to read response body: {}", e), None)
|
||||
})?;
|
||||
let body_str = String::from_utf8(body_bytes.to_vec()).map_err(|e| {
|
||||
Error::internal_error(format!("Failed to decode response body: {}", e), None)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body_str)]))
|
||||
}
|
||||
Err(e) => Err(Error::internal_error(
|
||||
format!("Failed to run script: {}", e),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
mut _context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, Error> {
|
||||
let workspace_id = _context.workspace_id.clone();
|
||||
let user_db = _context
|
||||
.req_extensions
|
||||
.get::<UserDB>()
|
||||
.ok_or_else(|| Error::internal_error("UserDB not found", None))?;
|
||||
let authed = _context
|
||||
.req_extensions
|
||||
.get::<ApiAuthed>()
|
||||
.ok_or_else(|| Error::internal_error("ApiAuthed not found", None))?;
|
||||
let scope = authed
|
||||
.scopes
|
||||
.as_ref()
|
||||
.and_then(|scopes| scopes.iter().find(|scope| scope.starts_with("mcp:")));
|
||||
let scope_type = scope.map_or("all", |scope| scope.split(":").last().unwrap_or("all"));
|
||||
let mut resources_info: HashMap<String, ResourceCache> = HashMap::new();
|
||||
|
||||
let scripts_fn = Runner::inner_get_scripts(user_db, authed, &workspace_id, scope_type);
|
||||
let flows_fn = Runner::inner_get_flows(user_db, authed, &workspace_id, scope_type);
|
||||
let (scripts, flows) = try_join!(scripts_fn, flows_fn)?;
|
||||
|
||||
let mut script_tools: Vec<Tool> = Vec::with_capacity(scripts.len());
|
||||
for script in scripts {
|
||||
let name = Runner::transform_path(&script.path, "script").unwrap_or_default();
|
||||
let description = format!(
|
||||
"This is a script named {} with the following description: {}.",
|
||||
script.summary.unwrap_or_default(),
|
||||
script.description.unwrap_or_default()
|
||||
);
|
||||
let mut schema: Schema = script.schema.map_or_else(Schema::default, |v| {
|
||||
serde_json::from_value(v).unwrap_or_default()
|
||||
});
|
||||
Runner::transform_schema_for_resources(
|
||||
&mut schema,
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
&mut resources_info,
|
||||
)
|
||||
.await?;
|
||||
script_tools.push(Tool {
|
||||
name: Cow::Owned(name),
|
||||
description: Some(Cow::Owned(description)),
|
||||
input_schema: {
|
||||
let value = serde_json::to_value(schema).unwrap_or_default();
|
||||
if let serde_json::Value::Object(map) = value {
|
||||
Arc::new(map)
|
||||
} else {
|
||||
Arc::new(serde_json::Map::new())
|
||||
}
|
||||
},
|
||||
annotations: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut flow_tools: Vec<Tool> = Vec::with_capacity(flows.len());
|
||||
for flow in flows {
|
||||
let name = Runner::transform_path(&flow.path, "flow").unwrap_or_default();
|
||||
let description = format!(
|
||||
"This is a flow named {} with the following description: {}.",
|
||||
flow.summary.unwrap_or_default(),
|
||||
flow.description.unwrap_or_default()
|
||||
);
|
||||
let mut schema: Schema = flow.schema.map_or_else(Schema::default, |v| {
|
||||
serde_json::from_value(v).unwrap_or_default()
|
||||
});
|
||||
Runner::transform_schema_for_resources(
|
||||
&mut schema,
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
&mut resources_info,
|
||||
)
|
||||
.await?;
|
||||
flow_tools.push(Tool {
|
||||
name: Cow::Owned(name),
|
||||
description: Some(Cow::Owned(description)),
|
||||
input_schema: {
|
||||
let value = serde_json::to_value(schema).unwrap_or_default();
|
||||
if let serde_json::Value::Object(map) = value {
|
||||
Arc::new(map)
|
||||
} else {
|
||||
Arc::new(serde_json::Map::new())
|
||||
}
|
||||
},
|
||||
annotations: None,
|
||||
});
|
||||
}
|
||||
|
||||
let tools = [script_tools, flow_tools].concat();
|
||||
Ok(ListToolsResult { tools, next_cursor: None })
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_tool_list_changed()
|
||||
.build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some("This server provides a runner tool that can run scripts. Use 'get_scripts' to get the list of scripts.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
_request: InitializeRequestParam,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, Error> {
|
||||
Ok(self.get_info())
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, Error> {
|
||||
Ok(ListResourcesResult { resources: vec![], next_cursor: None })
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListPromptsResult, Error> {
|
||||
Ok(ListPromptsResult::default())
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, Error> {
|
||||
Ok(ListResourceTemplatesResult::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_mcp_server(addr: SocketAddr, path: &str) -> anyhow::Result<(SseServer, Router)> {
|
||||
let config = SseServerConfig {
|
||||
bind: addr,
|
||||
sse_path: "/sse".to_string(),
|
||||
post_path: "/message".to_string(),
|
||||
full_message_path: path.to_string(),
|
||||
ct: CancellationToken::new(),
|
||||
sse_keep_alive: None,
|
||||
};
|
||||
|
||||
Ok(SseServer::new(config))
|
||||
}
|
||||
295
frontend/src/lib/components/TokensTable.svelte
Normal file
295
frontend/src/lib/components/TokensTable.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import TableCustom from '$lib/components/TableCustom.svelte'
|
||||
import { displayDate, copyToClipboard } from '$lib/utils'
|
||||
import type { TruncatedToken, NewToken } from '$lib/gen'
|
||||
import { UserService } from '$lib/gen'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Clipboard, Plus } from 'lucide-svelte'
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import ClipboardPanel from './details/ClipboardPanel.svelte'
|
||||
|
||||
// --- Props ---
|
||||
interface Props {
|
||||
tokens?: TruncatedToken[]
|
||||
showMcpMode?: boolean
|
||||
openWithMcpMode?: boolean
|
||||
defaultNewTokenLabel?: string
|
||||
defaultNewTokenWorkspace?: string
|
||||
onDeleteToken: (tokenPrefix: string) => void
|
||||
tokenPage?: number
|
||||
onNextPage: () => void
|
||||
onPreviousPage: () => void
|
||||
onListTokens: () => void
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
let {
|
||||
tokens = [],
|
||||
showMcpMode = false,
|
||||
openWithMcpMode = false,
|
||||
onDeleteToken,
|
||||
defaultNewTokenLabel,
|
||||
defaultNewTokenWorkspace,
|
||||
tokenPage = 1,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
onListTokens,
|
||||
scopes
|
||||
}: Props = $props()
|
||||
|
||||
// --- Local State ---
|
||||
let newTokenLabel = $state<string | undefined>(defaultNewTokenLabel)
|
||||
let newToken = $state<string | undefined>(undefined)
|
||||
let newTokenExpiration = $state<number | undefined>(undefined)
|
||||
let newTokenWorkspace = $state<string | undefined>(defaultNewTokenWorkspace)
|
||||
let displayCreateToken = $state(scopes != undefined)
|
||||
let mcpCreationMode = $state(false)
|
||||
let newMcpScope = $state('favorites')
|
||||
let newMcpToken = $state<string | undefined>(undefined)
|
||||
|
||||
const mcpBaseUrl = $derived(`${window.location.origin}/api/w/${$workspaceStore}/mcp/sse?token=`)
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$effect(() => {
|
||||
if (openWithMcpMode) {
|
||||
handleCreateClick('mcpUrl')
|
||||
}
|
||||
})
|
||||
|
||||
// --- Functions ---
|
||||
async function createToken(mcpMode: boolean = false): Promise<void> {
|
||||
try {
|
||||
let date: Date | undefined
|
||||
if (newTokenExpiration) {
|
||||
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
|
||||
}
|
||||
|
||||
let tokenScopes = mcpMode ? [`mcp:${newMcpScope}`] : scopes
|
||||
|
||||
const createdToken = await UserService.createToken({
|
||||
requestBody: {
|
||||
label: newTokenLabel,
|
||||
expiration: date?.toISOString(),
|
||||
scopes: tokenScopes,
|
||||
workspace_id: newTokenWorkspace || $workspaceStore
|
||||
} as NewToken
|
||||
})
|
||||
|
||||
if (mcpMode) {
|
||||
newMcpToken = `${createdToken}`
|
||||
} else {
|
||||
newToken = `${createdToken}`
|
||||
}
|
||||
|
||||
dispatch('tokenCreated', newToken ?? newMcpToken)
|
||||
onListTokens()
|
||||
mcpCreationMode = false
|
||||
displayCreateToken = false
|
||||
} catch (err) {
|
||||
console.error('Failed to create token:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateClick(type: 'token' | 'mcpUrl') {
|
||||
mcpCreationMode = type === 'mcpUrl'
|
||||
displayCreateToken = true
|
||||
newMcpToken = undefined
|
||||
newToken = undefined
|
||||
newTokenExpiration = undefined
|
||||
newTokenLabel = type === 'mcpUrl' ? 'MCP token' : undefined
|
||||
}
|
||||
|
||||
function handleCreateTokenClick() {
|
||||
handleCreateClick('token')
|
||||
}
|
||||
|
||||
function handleCopyClick() {
|
||||
copyToClipboard(newToken ?? '')
|
||||
}
|
||||
|
||||
function handleDeleteClick(tokenPrefix: string) {
|
||||
onDeleteToken(tokenPrefix)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 pt-8 pb-1" class:pt-8={scopes == undefined}>
|
||||
<h2 class="py-0 my-0 border-b pt-3">Tokens</h2>
|
||||
<div class="flex justify-end border-b pb-1 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
btnClasses={displayCreateToken ? 'hidden' : ''}
|
||||
on:click={handleCreateTokenClick}
|
||||
>
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xs text-tertiary italic pb-6">
|
||||
Authenticate to the Windmill API with access tokens.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if newToken}
|
||||
<div
|
||||
class="border rounded-md mb-6 px-2 py-2 bg-green-50 dark:bg-green-200 dark:text-green-800 flex flex-row flex-wrap"
|
||||
>
|
||||
<div>
|
||||
Added token: <button onclick={handleCopyClick} class="inline-flex gap-2 items-center">
|
||||
{newToken}
|
||||
<Clipboard size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="pt-1 text-xs ml-2">
|
||||
Make sure to copy your personal access token now. You won't be able to see it again!
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if newMcpToken}
|
||||
<div
|
||||
class="border rounded-md mb-6 px-2 py-2 bg-green-50 dark:bg-green-200 dark:text-green-800 flex flex-row flex-wrap"
|
||||
>
|
||||
<p class="text-sm mb-2">New MCP URL:</p>
|
||||
<ClipboardPanel content={`${mcpBaseUrl}${newMcpToken}`} />
|
||||
<p class="pt-1 text-xs">
|
||||
Make sure to copy this URL now. You won't be able to see it again!
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if displayCreateToken}
|
||||
<div class="py-3 px-3 border rounded-md mb-6 bg-surface-secondary min-w-min">
|
||||
<h3 class="pb-3 font-semibold">Add a new token</h3>
|
||||
{#if scopes != undefined}
|
||||
{#each scopes as scope}
|
||||
<div class="flex flex-col mb-4">
|
||||
<label for="label">Scope</label>
|
||||
<input disabled type="text" value={scope} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if showMcpMode}
|
||||
<Toggle
|
||||
on:change={(e) => {
|
||||
mcpCreationMode = e.detail
|
||||
if (e.detail) {
|
||||
newTokenLabel = 'MCP token'
|
||||
newTokenExpiration = undefined
|
||||
newTokenWorkspace = $workspaceStore
|
||||
}
|
||||
}}
|
||||
checked={mcpCreationMode}
|
||||
options={{
|
||||
right: 'Generate MCP URL',
|
||||
rightTooltip:
|
||||
'Generate a new MCP URL to make your scripts and flows available as tools.'
|
||||
}}
|
||||
class="mb-4"
|
||||
size="xs"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-row flex-wrap gap-x-2 w-full justify-between">
|
||||
{#if mcpCreationMode}
|
||||
<div class="flex flex-col">
|
||||
<label for="label">Scope</label>
|
||||
<ToggleButtonGroup bind:selected={newMcpScope} allowEmpty={false} let:item>
|
||||
<ToggleButton {item} value="favorites" label="Favorites Only" />
|
||||
<ToggleButton {item} value="all" label="All Resources" />
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="label">Workspace</label>
|
||||
<select bind:value={newTokenWorkspace} disabled={$userWorkspaces.length === 1}>
|
||||
{#each $userWorkspaces as workspace}
|
||||
<option value={workspace.id}>{workspace.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<label for="label">Label <span class="text-xs text-tertiary">(optional)</span></label>
|
||||
<input type="text" bind:value={newTokenLabel} />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="expires"
|
||||
>Expires In <span class="text-xs text-tertiary">(optional)</span>
|
||||
</label>
|
||||
<select bind:value={newTokenExpiration} disabled={mcpCreationMode}>
|
||||
<option value={undefined}>No expiration</option>
|
||||
<option value={15 * 60}>15m</option>
|
||||
<option value={30 * 60}>30m</option>
|
||||
<option value={1 * 60 * 60}>1h</option>
|
||||
<option value={1 * 24 * 60 * 60}>1d</option>
|
||||
<option value={7 * 24 * 60 * 60}>7d</option>
|
||||
<option value={30 * 24 * 60 * 60}>30d</option>
|
||||
<option value={90 * 24 * 60 * 60}>90d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
btnClasses="!mt-2"
|
||||
on:click={() => createToken(mcpCreationMode)}
|
||||
disabled={mcpCreationMode && newTokenWorkspace === undefined}
|
||||
>
|
||||
New token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto">
|
||||
<TableCustom>
|
||||
<tr slot="header-row">
|
||||
<th>prefix</th>
|
||||
<th>label</th>
|
||||
<th>expiration</th>
|
||||
<th>scopes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#if tokens && tokens.length > 0}
|
||||
{#each tokens as { token_prefix, expiration, label, scopes }}
|
||||
<tr>
|
||||
<td class="grow">{token_prefix}****</td>
|
||||
<td class="grow">{label ?? ''}</td>
|
||||
<td class="grow">{displayDate(expiration ?? '')}</td>
|
||||
<td class="grow">{scopes?.join(', ') ?? ''}</td>
|
||||
<td class="grow">
|
||||
<button
|
||||
class="text-red-500 text-xs underline"
|
||||
onclick={() => handleDeleteClick(token_prefix)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if tokens && tokens.length === 0}
|
||||
<tr class="px-6">
|
||||
<td class="text-secondary italic text-xs"> There are no tokens yet</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td>Loading...</td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
<div class="flex flex-row-reverse gap-2 w-full">
|
||||
{#if tokens?.length == 100}
|
||||
<button class="p-1 underline text-sm whitespace-nowrap text-center" onclick={onNextPage}>
|
||||
Next
|
||||
</button>
|
||||
{/if}
|
||||
{#if tokenPage > 1}
|
||||
<button class="p-1 underline text-sm whitespace-nowrap text-center" onclick={onPreviousPage}>
|
||||
Previous
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,38 +5,38 @@
|
||||
stepInputCompletionEnabled,
|
||||
usersWorkspaceStore
|
||||
} from '$lib/stores'
|
||||
import type { TruncatedToken, NewToken } from '$lib/gen'
|
||||
import type { TruncatedToken } from '$lib/gen'
|
||||
import { UserService } from '$lib/gen'
|
||||
import { displayDate, copyToClipboard, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import TableCustom from '$lib/components/TableCustom.svelte'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import Version from './Version.svelte'
|
||||
import { Clipboard, Plus } from 'lucide-svelte'
|
||||
import DarkModeToggle from './sidebar/DarkModeToggle.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import TokensTable from './TokensTable.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let scopes: string[] | undefined = undefined
|
||||
export let newTokenLabel: string | undefined = undefined
|
||||
export let newTokenWorkspace: string | undefined = undefined
|
||||
export let newToken: string | undefined = undefined
|
||||
export let showMcpMode: boolean = false
|
||||
|
||||
let newPassword: string | undefined
|
||||
let passwordError: string | undefined
|
||||
let tokens: TruncatedToken[]
|
||||
let newTokenExpiration: number | undefined
|
||||
let displayCreateToken = scopes != undefined
|
||||
let login_type = 'none'
|
||||
let drawer: Drawer
|
||||
let tokenPage = 1
|
||||
let openWithMcpMode = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export function openDrawer() {
|
||||
export function openDrawer(mcpMode: boolean = false) {
|
||||
openWithMcpMode = mcpMode
|
||||
loadLoginType()
|
||||
listTokens()
|
||||
drawer?.openDrawer()
|
||||
@@ -68,28 +68,12 @@
|
||||
login_type = (await UserService.globalWhoami()).login_type
|
||||
}
|
||||
|
||||
async function createToken(): Promise<void> {
|
||||
newToken = undefined
|
||||
let date: Date | undefined
|
||||
if (newTokenExpiration) {
|
||||
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
|
||||
}
|
||||
newToken = await UserService.createToken({
|
||||
requestBody: {
|
||||
label: newTokenLabel,
|
||||
expiration: date?.toISOString(),
|
||||
scopes,
|
||||
workspace_id: newTokenWorkspace
|
||||
} as NewToken
|
||||
})
|
||||
dispatch('tokenCreated', newToken)
|
||||
listTokens()
|
||||
displayCreateToken = false
|
||||
}
|
||||
|
||||
let tokenPage = 1
|
||||
async function listTokens(): Promise<void> {
|
||||
tokens = await UserService.listTokens({ excludeEphemeral: true, page: tokenPage, perPage: 100 })
|
||||
tokens = await UserService.listTokens({
|
||||
excludeEphemeral: true,
|
||||
page: tokenPage,
|
||||
perPage: 100
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteToken(tokenPrefix: string) {
|
||||
@@ -111,6 +95,21 @@
|
||||
storeLocalSetting(setting, value.toString())
|
||||
}
|
||||
|
||||
function handleNextPage() {
|
||||
tokenPage += 1
|
||||
listTokens()
|
||||
}
|
||||
|
||||
function handlePreviousPage() {
|
||||
tokenPage -= 1
|
||||
listTokens()
|
||||
}
|
||||
|
||||
function handleTokenCreated(event: CustomEvent<string>) {
|
||||
newToken = event.detail
|
||||
dispatch('tokenCreated', newToken)
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
</script>
|
||||
|
||||
@@ -221,157 +220,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 pt-8 pb-1" class:pt-8={scopes == undefined}>
|
||||
<h2 class="py-0 my-0 border-b pt-3">Tokens</h2>
|
||||
<div class="flex justify-end border-b pb-1">
|
||||
<Button
|
||||
size="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
btnClasses={displayCreateToken ? 'hidden' : ''}
|
||||
on:click={() => {
|
||||
displayCreateToken = !displayCreateToken
|
||||
newToken = undefined
|
||||
newTokenExpiration = undefined
|
||||
newTokenLabel = undefined
|
||||
}}
|
||||
>
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xs text-tertiary italic pb-6">
|
||||
Authentify to the Windmill API with access tokens.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="{newToken
|
||||
? ''
|
||||
: 'hidden'} border rounded-md mb-6 px-2 py-2 bg-green-50 dark:bg-green-200 dark:text-green-800 flex flex-row flex-wrap"
|
||||
>
|
||||
<div>
|
||||
Added token: <button
|
||||
on:click={() => copyToClipboard(newToken ?? '')}
|
||||
class="inline-flex gap-2 items-center"
|
||||
>
|
||||
{newToken}
|
||||
<Clipboard size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="pt-1 text-xs ml-2">
|
||||
Make sure to copy your personal access token now. You won’t be able to see it again!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token creation interface -->
|
||||
<div
|
||||
class="{displayCreateToken
|
||||
? ''
|
||||
: 'hidden'} py-3 px-3 border rounded-md mb-6 bg-surface-secondary min-w-min"
|
||||
>
|
||||
<h3 class="pb-3 font-semibold">Add a new token</h3>
|
||||
{#if scopes != undefined}
|
||||
{#each scopes as scope}
|
||||
<div class="flex flex-col mb-4">
|
||||
<label for="label"
|
||||
>Scope <Tooltip
|
||||
>This token can only be used within its scope. For flows and scripts, it
|
||||
allows you to share them without fear of being impersonated. Once a script is
|
||||
triggered, the script itself uses a new ephemeral token to be able to act on
|
||||
behalf of the token owner.</Tooltip
|
||||
></label
|
||||
>
|
||||
<input disabled type="text" value={scope} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="flex flex-row flex-wrap gap-x-2 w-full justify-between">
|
||||
<div class="flex flex-col">
|
||||
<label for="label"
|
||||
>Label <span class="text-xs text-tertiary">(optional)</span></label
|
||||
>
|
||||
<input type="text" bind:value={newTokenLabel} />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="expires"
|
||||
>Expires In <span class="text-xs text-tertiary">(optional)</span>
|
||||
</label>
|
||||
<select bind:value={newTokenExpiration}>
|
||||
<option value={undefined}>No expiration</option>
|
||||
<option value={15 * 60}>15m</option>
|
||||
<option value={30 * 60}>30m</option>
|
||||
<option value={1 * 60 * 60}>1h</option>
|
||||
<option value={1 * 24 * 60 * 60}>1d</option>
|
||||
<option value={7 * 24 * 60 * 60}>7d</option>
|
||||
<option value={30 * 24 * 60 * 60}>30d</option>
|
||||
<option value={90 * 24 * 60 * 60}>90d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button btnClasses="!mt-2" on:click={createToken}>New token</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<TableCustom>
|
||||
<tr slot="header-row">
|
||||
<th>prefix</th>
|
||||
<th>label</th>
|
||||
<th>expiration</th>
|
||||
<th>scopes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#if tokens && tokens.length > 0}
|
||||
{#each tokens as { token_prefix, expiration, label, scopes }}
|
||||
<tr>
|
||||
<td class="grow">{token_prefix}****</td>
|
||||
<td class="grow">{label ?? ''}</td>
|
||||
<td class="grow">{displayDate(expiration ?? '')}</td>
|
||||
<td class="grow">{scopes?.join(', ') ?? ''}</td>
|
||||
<td class="grow"
|
||||
><button
|
||||
class="text-red-500 text-xs underline"
|
||||
on:click={() => {
|
||||
deleteToken(token_prefix)
|
||||
}}>Delete</button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if tokens && tokens.length === 0}
|
||||
<tr class="px-6"
|
||||
><td class="text-secondary italic text-xs"> There are no tokens yet</td></tr
|
||||
>
|
||||
{:else}
|
||||
<tr><td>Loading...</td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
<div class="flex flex-row-reverse gap-2 w-full">
|
||||
{#if tokens?.length == 100}
|
||||
<button
|
||||
class=" p-1 underline text-sm whitespace-nowrap text-center"
|
||||
on:click={() => {
|
||||
tokenPage += 1
|
||||
listTokens()
|
||||
}}
|
||||
>Next
|
||||
</button>
|
||||
{/if}
|
||||
{#if tokenPage > 1}
|
||||
<button
|
||||
class="p-1 underline text-sm whitespace-nowrap text-center"
|
||||
on:click={() => {
|
||||
tokenPage -= 1
|
||||
listTokens()
|
||||
}}
|
||||
>Previous
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<TokensTable
|
||||
{tokens}
|
||||
{showMcpMode}
|
||||
{openWithMcpMode}
|
||||
defaultNewTokenLabel={newTokenLabel}
|
||||
defaultNewTokenWorkspace={newTokenWorkspace}
|
||||
onDeleteToken={deleteToken}
|
||||
{tokenPage}
|
||||
onNextPage={handleNextPage}
|
||||
onPreviousPage={handlePreviousPage}
|
||||
onListTokens={listTokens}
|
||||
{scopes}
|
||||
on:tokenCreated={handleTokenCreated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
@@ -89,7 +89,12 @@
|
||||
placeholder="paste your token here once created to alter examples below"
|
||||
class="!text-xs"
|
||||
/>
|
||||
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => userSettings.openDrawer()}
|
||||
>
|
||||
Create an Email-specific Token
|
||||
<Tooltip light>
|
||||
The token will have a scope such that it can only be used to trigger this script. It
|
||||
|
||||
@@ -126,15 +126,15 @@ async function triggerJob() {
|
||||
cleanedRunnableArgs ?? {},
|
||||
null,
|
||||
2
|
||||
).replaceAll('\n', '\n\t')});`
|
||||
).replaceAll('\n', '\n\t')});`
|
||||
}
|
||||
const endpoint = \`${url}\`;
|
||||
|
||||
return await fetch(endpoint, {
|
||||
method: '${requestType === 'get_path' ? 'GET' : 'POST'}',
|
||||
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t')}${
|
||||
requestType === 'get_path' ? '' : `,\n\t\tbody`
|
||||
}
|
||||
requestType === 'get_path' ? '' : `,\n\t\tbody`
|
||||
}
|
||||
});
|
||||
}`
|
||||
}
|
||||
@@ -244,12 +244,12 @@ done`
|
||||
requestType === 'get_path'
|
||||
? `&payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}`
|
||||
: ''
|
||||
}`
|
||||
}`
|
||||
: `${
|
||||
requestType === 'get_path'
|
||||
? `?payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}`
|
||||
: ''
|
||||
}`)
|
||||
}`)
|
||||
</script>
|
||||
|
||||
<UserSettings
|
||||
@@ -299,7 +299,12 @@ done`
|
||||
placeholder="paste your token here once created to alter examples below"
|
||||
class="!text-xs !font-normal"
|
||||
/>
|
||||
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => userSettings.openDrawer()}
|
||||
>
|
||||
Create a Webhook-specific Token
|
||||
<Tooltip light>
|
||||
The token will have a scope such that it can only be used to trigger this script. It
|
||||
|
||||
@@ -70,8 +70,9 @@
|
||||
$: $page.url && onQueryChange()
|
||||
|
||||
function onQueryChangeUserSettings() {
|
||||
if (userSettings && $page.url.hash === USER_SETTINGS_HASH) {
|
||||
userSettings.openDrawer()
|
||||
if (userSettings && $page.url.hash.startsWith(USER_SETTINGS_HASH)) {
|
||||
const mcpMode = $page.url.hash.includes('-mcp')
|
||||
userSettings.openDrawer(mcpMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +349,7 @@
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<UserSettings bind:this={userSettings} />
|
||||
<UserSettings bind:this={userSettings} showMcpMode={true} />
|
||||
{#if $page.status == 404}
|
||||
<CenteredModal title="Page not found, redirecting you to login">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
size="md"
|
||||
btnClasses="whitespace-normal break-words flex flex-wrap"
|
||||
disabled={itemPath == undefined}
|
||||
on:click={userSettings.openDrawer}
|
||||
on:click={() => userSettings.openDrawer()}
|
||||
>
|
||||
Generate webhook-specific Token
|
||||
</Button>
|
||||
|
||||
@@ -28,15 +28,18 @@
|
||||
|
||||
let invites: WorkspaceInvite[] = []
|
||||
let list_all_as_super_admin: boolean = false
|
||||
let workspaces: { id: string; name: string; username: string; color?: string | null }[] | undefined = undefined
|
||||
let workspaces:
|
||||
| { id: string; name: string; username: string; color?: string | null }[]
|
||||
| undefined = undefined
|
||||
|
||||
let userSettings: UserSettings
|
||||
let superadminSettings: SuperadminSettings
|
||||
|
||||
$: rd = $page.url.searchParams.get('rd')
|
||||
|
||||
$: if (userSettings && $page.url.hash === USER_SETTINGS_HASH) {
|
||||
userSettings.openDrawer()
|
||||
$: if (userSettings && $page.url.hash.startsWith(USER_SETTINGS_HASH)) {
|
||||
const mcpMode = $page.url.hash.includes('-mcp')
|
||||
userSettings.openDrawer(mcpMode)
|
||||
}
|
||||
|
||||
async function loadInvites() {
|
||||
@@ -197,8 +200,8 @@
|
||||
>
|
||||
{#if workspace.color}
|
||||
<span
|
||||
class="inline-block w-3 h-3 mr-2 rounded-full border border-gray-400"
|
||||
style="background-color: {workspace.color}"
|
||||
class="inline-block w-3 h-3 mr-2 rounded-full border border-gray-400"
|
||||
style="background-color: {workspace.color}"
|
||||
></span>
|
||||
{/if}
|
||||
<span class="font-mono">{workspace.id}</span> - {workspace.name} as
|
||||
@@ -295,7 +298,12 @@
|
||||
Superadmin settings
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="border" size="sm" on:click={userSettings.openDrawer} startIcon={{ icon: Cog }}>
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
on:click={() => userSettings.openDrawer()}
|
||||
startIcon={{ icon: Cog }}
|
||||
>
|
||||
User settings
|
||||
</Button>
|
||||
|
||||
@@ -321,4 +329,4 @@
|
||||
</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<UserSettings bind:this={userSettings} />
|
||||
<UserSettings bind:this={userSettings} showMcpMode={true} />
|
||||
|
||||
Reference in New Issue
Block a user