mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 00:03:46 +00:00
feat: serve the ai session toolset over mcp at /api/mcp (#11738)
* feat: add a workspace mcp server with the ai session toolset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep workspace mcp tool calls on their own routes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a token's job tag fence on workspace mcp tool calls Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: state the workspace mcp token rule in its refusal message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let the workspace mcp server span workspaces chosen per token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let the mcp url form choose between the tools and workspace servers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: serve the workspace mcp server as windmill mcp v2 at /api/mcp/v2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add read-and-run access to windmill mcp v2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: serve the full windmill mcp experience at /api/mcp, sign-in first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: align the mcp url form with the brand guidelines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: serve every mcp token from /api/mcp, with one access picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: rename mcp access levels and keep selections to scripts and flows on /api/mcp Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name the mcp access levels in the url caption Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: name the mcp server windmill Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: lead the mcp access picker with build and run, and spell out what a selection excludes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show the tools each mcp access level gives Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: list the mcp tools in a collapsible section under the access row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer all scripts and flows as typed mcp tools again, as wildcard scopes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: label the mcp selection access as scripts and flows as tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: link mcp run, app draft and deploy results to their windmill pages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mirror the session tool changes from main in the mcp server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an ai agent's identity when the ai chat deploys its resource Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: call the api in-process from the mcp session tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a draft that rebase_draft could not return whole Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let read-and-run mcp tokens run scripts and flows that take secrets Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: ask mcp clients to open windmill links in a browser the user sees Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: send mcp clients condensed instructions and serve the full rules on demand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: put the core rules first in the mcp instructions instead of condensing them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: go straight to the mcp sign-in page after login, without the workspace picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: no workspace error toast on the mcp sign-in page while logged out Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: open raw app previews on the preview alone, from mcp links and in sessions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: link mcp script, flow and agent drafts to their editors, and agents to their page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the session preview's saved sidebar choice, fold the build log in preview-only views Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: hold mcp tokens below full access to their level on the per-workspace urls Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: revoke tokens with the new mcp scopes when the migration is reverted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an mcp token's scopes when its editor is saved without changing access Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mint one jwt per mcp tool call, accept the old approve body, list the new scopes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: reset the mcp token editor's picker state for each token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: mark only mcp selections, read levels imply no endpoint tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to b3fd7d49f2ba58f5080fabc3a5686c34a0b2684c This commit updates the EE repository reference after PR #877 was merged in windmill-ee-private. Previous ee-repo-ref: 9e960246d51401a2b0487dcffcadd0b43cd67d39 New ee-repo-ref: b3fd7d49f2ba58f5080fabc3a5686c34a0b2684c Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
Ruben Fiszel
parent
91fcbe0943
commit
892334f008
@@ -66,6 +66,7 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
|
||||
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
|
||||
- **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `<!-- cli-only -->` / `<!-- chat-only -->` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details.
|
||||
- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise).
|
||||
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
|
||||
- **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server.
|
||||
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
|
||||
@@ -1 +1 @@
|
||||
b5294d38db082bdd104d5358a47695390694c05d
|
||||
b3fd7d49f2ba58f5080fabc3a5686c34a0b2684c
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- A server without these scopes ignores them, which would widen every token carrying one:
|
||||
-- a narrowed `mcp:all` to full access, a workspace list to all of the owner's workspaces.
|
||||
DELETE FROM token WHERE EXISTS (
|
||||
SELECT 1 FROM unnest(scopes) s
|
||||
WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%'
|
||||
);
|
||||
DELETE FROM mcp_oauth_refresh_token WHERE EXISTS (
|
||||
SELECT 1 FROM unnest(scopes) s
|
||||
WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%'
|
||||
);
|
||||
DELETE FROM mcp_oauth_server_code WHERE EXISTS (
|
||||
SELECT 1 FROM unnest(scopes) s
|
||||
WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%'
|
||||
);
|
||||
DELETE FROM mcp_oauth_server_code WHERE workspace_id IS NULL;
|
||||
DELETE FROM mcp_oauth_refresh_token WHERE workspace_id IS NULL;
|
||||
ALTER TABLE mcp_oauth_server_code ALTER COLUMN workspace_id SET NOT NULL;
|
||||
ALTER TABLE mcp_oauth_refresh_token ALTER COLUMN workspace_id SET NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- An MCP OAuth grant can span several workspaces (listed in its scopes) or all of the
|
||||
-- user's, in which case the token it mints is bound to none.
|
||||
ALTER TABLE mcp_oauth_server_code ALTER COLUMN workspace_id DROP NOT NULL;
|
||||
ALTER TABLE mcp_oauth_refresh_token ALTER COLUMN workspace_id DROP NOT NULL;
|
||||
@@ -1280,6 +1280,7 @@ pub async fn resolve_opt_job_authed(
|
||||
// could approve an OAuth flow that mints a new non-read-only
|
||||
// token.
|
||||
let is_mcp_transport = path == "/api/mcp/gateway"
|
||||
|| path == "/api/mcp"
|
||||
|| (path.starts_with("/api/mcp/w/")
|
||||
&& (path.ends_with("/mcp")
|
||||
|| path.ends_with("/sse")
|
||||
|
||||
@@ -1252,7 +1252,7 @@ async fn validate_dbt_relation(
|
||||
/// The schema the editor would have derived from `content`, for a deploy that sends
|
||||
/// none (MCP, a bare API call). `None` for a language whose parser this build lacks,
|
||||
/// or code that does not parse.
|
||||
fn infer_main_schema(
|
||||
pub fn infer_main_schema(
|
||||
language: &ScriptLang,
|
||||
kind: Option<&ScriptKind>,
|
||||
content: &str,
|
||||
|
||||
@@ -576,14 +576,14 @@ pub async fn run_server(
|
||||
|
||||
// Setup MCP server
|
||||
#[allow(unused_variables)]
|
||||
let (mcp_router, gateway_mcp_router, mcp_cancellation_token) = {
|
||||
let (mcp_router, gateway_mcp_router, gateway_session_router, mcp_cancellation_token) = {
|
||||
#[cfg(feature = "mcp")]
|
||||
if server_mode || mcp_mode {
|
||||
use mcp::{
|
||||
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
||||
extract_workspace_from_token, reject_token_query_param,
|
||||
};
|
||||
let (mcp_router, mcp_cancellation_token) = setup_mcp_server(
|
||||
let (mcp_router, session_router, mcp_cancellation_token) = setup_mcp_server(
|
||||
db.clone(),
|
||||
user_db,
|
||||
_base_internal_url.clone(),
|
||||
@@ -605,17 +605,32 @@ pub async fn run_server(
|
||||
.layer(axum::middleware::from_fn(
|
||||
add_www_authenticate_header_gateway,
|
||||
));
|
||||
// The workspace server. Its tools name their workspace; the token bounds which.
|
||||
let gateway_session_router = session_router
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(extract_workspace_from_token))
|
||||
.layer(axum::middleware::from_fn(reject_token_query_param))
|
||||
.layer(axum::middleware::from_fn(
|
||||
add_www_authenticate_header_gateway,
|
||||
))
|
||||
.layer(Extension(mcp::McpEndpoint("/api/mcp")));
|
||||
(
|
||||
workspaced_mcp_router,
|
||||
gateway_mcp_router,
|
||||
gateway_session_router,
|
||||
Some(mcp_cancellation_token),
|
||||
)
|
||||
} else {
|
||||
(Router::new(), Router::new(), None)
|
||||
(Router::new(), Router::new(), Router::new(), None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
(Router::new(), Router::new(), Option::<()>::None)
|
||||
(
|
||||
Router::new(),
|
||||
Router::new(),
|
||||
Router::new(),
|
||||
Option::<()>::None,
|
||||
)
|
||||
};
|
||||
|
||||
// Workers block on this before pulling their first job, so it is released ahead of
|
||||
@@ -628,15 +643,15 @@ pub async fn run_server(
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
let mcp_list_tools_service = {
|
||||
let (mcp_list_tools_service, mcp_toolset_service) = {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
mcp::list_tools_service()
|
||||
(mcp::list_tools_service(), mcp::toolset_service())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
Router::new()
|
||||
(Router::new(), Router::new())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -854,6 +869,7 @@ pub async fn run_server(
|
||||
.nest("/docs", docs::global_service())
|
||||
.nest("/indexer", indexer_oss::management_service())
|
||||
.nest("/mcp/w/{workspace_id}/list_tools", mcp_list_tools_service)
|
||||
.nest("/mcp/toolset", mcp_toolset_service)
|
||||
.nest("/db_health", db_health::global_service())
|
||||
.nest("/health/detailed", health::detailed_service())
|
||||
.nest(
|
||||
@@ -949,6 +965,7 @@ pub async fn run_server(
|
||||
mcp_router.clone().layer(mcp_cors.clone()),
|
||||
)
|
||||
.nest("/mcp/gateway", gateway_mcp_router.layer(mcp_cors.clone()))
|
||||
.merge(gateway_session_router.layer(mcp_cors.clone()))
|
||||
.nest("/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
@@ -1186,6 +1203,16 @@ pub async fn run_server(
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
||||
}
|
||||
})
|
||||
.route("/.well-known/oauth-protected-resource/api/mcp", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
get(mcp::oauth_server::gateway_session_protected_resource_metadata)
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
||||
}
|
||||
})
|
||||
// JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix)
|
||||
.route("/.well-known/jwks.json", {
|
||||
#[cfg(all(feature = "private", feature = "enterprise", feature = "openidconnect"))]
|
||||
@@ -1259,6 +1286,9 @@ pub async fn run_server(
|
||||
if let Some(name) = name.as_ref() {
|
||||
tracing::info!("server starting for name={name}");
|
||||
}
|
||||
#[cfg(feature = "mcp")]
|
||||
mcp::register_router(&_base_internal_url, Some(app.clone()));
|
||||
|
||||
let listener = listener.tap_io(move |tcp_stream| {
|
||||
let _ = tcp_stream.set_nodelay(!server_mode);
|
||||
});
|
||||
@@ -1296,7 +1326,10 @@ pub async fn run_server(
|
||||
}
|
||||
});
|
||||
|
||||
server.await?;
|
||||
let served = server.await;
|
||||
#[cfg(feature = "mcp")]
|
||||
mcp::register_router(&_base_internal_url, None);
|
||||
served?;
|
||||
let forks = &windmill_api_workspaces::workspaces::BACKGROUND_FORKS;
|
||||
forks.close();
|
||||
if !forks.is_empty() {
|
||||
|
||||
@@ -13,7 +13,8 @@ use windmill_mcp::common::types::{
|
||||
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo,
|
||||
};
|
||||
use windmill_mcp::server::{
|
||||
BackendResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter,
|
||||
BackendResult, CallToolResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter,
|
||||
SessionAccess, Tool,
|
||||
};
|
||||
|
||||
use crate::auth::AuthCache;
|
||||
@@ -23,6 +24,7 @@ use crate::jobs::{
|
||||
};
|
||||
|
||||
use super::auto_generated_endpoints::all_tools;
|
||||
use super::session;
|
||||
use super::utils::{
|
||||
build_query_string, build_request_body, create_http_request, get_hub_script_schema,
|
||||
get_item_paths, get_item_schema, get_items, get_resources, get_resources_types,
|
||||
@@ -34,8 +36,8 @@ use std::time::Duration;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use windmill_mcp::server::{
|
||||
LocalSessionManager, McpToken, MultiWorkspaceMcp, Runner, StreamableHttpServerConfig,
|
||||
StreamableHttpService,
|
||||
LocalSessionManager, McpToken, MultiWorkspaceMcp, NoEndpointTools, Runner, SessionRunner,
|
||||
StreamableHttpServerConfig, StreamableHttpService,
|
||||
};
|
||||
use windmill_mcp::WorkspaceId;
|
||||
|
||||
@@ -64,6 +66,32 @@ pub struct WindmillBackend {
|
||||
}
|
||||
|
||||
impl WindmillBackend {
|
||||
/// The instructions only read, so they are built at the token's own read-only flag.
|
||||
fn session_ctx<'a>(&'a self, auth: &'a ApiAuthed, w_id: &'a str) -> session::Ctx<'a> {
|
||||
let access = if auth.read_only {
|
||||
SessionAccess::ReadOnly
|
||||
} else {
|
||||
SessionAccess::Full
|
||||
};
|
||||
self.session_ctx_with(auth, w_id, access)
|
||||
}
|
||||
|
||||
fn session_ctx_with<'a>(
|
||||
&'a self,
|
||||
auth: &'a ApiAuthed,
|
||||
w_id: &'a str,
|
||||
access: SessionAccess,
|
||||
) -> session::Ctx<'a> {
|
||||
session::Ctx {
|
||||
auth,
|
||||
w_id,
|
||||
base_internal_url: &self.base_internal_url,
|
||||
access,
|
||||
origin: None,
|
||||
jwt: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
db: DB,
|
||||
user_db: UserDB,
|
||||
@@ -242,6 +270,7 @@ impl McpBackend for WindmillBackend {
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> BackendResult<Value> {
|
||||
windmill_common::feature_usage::log_feature_usage("mcp", "runnable_tool_call", "script");
|
||||
let push_args = prepare_push_args(&self.db, workspace_id, path, false, args, request)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
@@ -269,6 +298,7 @@ impl McpBackend for WindmillBackend {
|
||||
args: Value,
|
||||
request: &McpRequest<'_>,
|
||||
) -> BackendResult<Value> {
|
||||
windmill_common::feature_usage::log_feature_usage("mcp", "runnable_tool_call", "flow");
|
||||
let push_args = prepare_push_args(&self.db, workspace_id, path, true, args, request)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
@@ -295,6 +325,12 @@ impl McpBackend for WindmillBackend {
|
||||
endpoint_tool: &EndpointTool,
|
||||
args: Value,
|
||||
) -> BackendResult<Value> {
|
||||
// Whether the generated endpoint tools are used at all, and which ones.
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"mcp",
|
||||
"endpoint_tool_call",
|
||||
&endpoint_tool.name,
|
||||
);
|
||||
let args_map = match &args {
|
||||
Value::Object(map) => map,
|
||||
_ => {
|
||||
@@ -421,8 +457,57 @@ impl McpBackend for WindmillBackend {
|
||||
fn all_endpoint_tools(&self) -> Vec<EndpointTool> {
|
||||
all_tools()
|
||||
}
|
||||
|
||||
async fn session_instructions(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
) -> BackendResult<String> {
|
||||
Ok(session::session_instructions(&self.session_ctx(auth, workspace_id)).await)
|
||||
}
|
||||
|
||||
fn generic_session_instructions(&self) -> String {
|
||||
session::generic_session_instructions()
|
||||
}
|
||||
|
||||
async fn workspace_context(&self, auth: &ApiAuthed, workspace_id: &str) -> String {
|
||||
session::workspace_context(&self.session_ctx(auth, workspace_id)).await
|
||||
}
|
||||
|
||||
fn session_tools(&self, access: SessionAccess) -> Vec<Tool> {
|
||||
session::session_tools(access)
|
||||
}
|
||||
|
||||
async fn call_session_tool(
|
||||
&self,
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
args: Value,
|
||||
access: SessionAccess,
|
||||
origin: Option<&str>,
|
||||
) -> BackendResult<CallToolResult> {
|
||||
let ctx = session::Ctx { origin, ..self.session_ctx_with(auth, workspace_id, access) };
|
||||
session::call_session_tool(&ctx, name, args).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Set on a request whose token is scoped to a selection of scripts, flows and endpoints
|
||||
/// rather than `mcp:all`: on `/api/mcp`, the tools server answers it.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct McpSelection;
|
||||
|
||||
/// MCP scopes without `mcp:all`: favorites, chosen scripts, flows, endpoints or hub apps.
|
||||
fn scoped_to_selection(scopes: &[String]) -> bool {
|
||||
let mut mcp = scopes.iter().filter(|scope| scope.starts_with("mcp:"));
|
||||
mcp.clone().next().is_some() && !mcp.any(|scope| scope == "mcp:all")
|
||||
}
|
||||
|
||||
/// The path of a workspace-less MCP URL, when it is not the tools server's
|
||||
/// `/api/mcp/gateway`: each URL is its own OAuth protected resource.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct McpEndpoint(pub &'static str);
|
||||
|
||||
/// Extract workspace ID from path and store it in request extensions
|
||||
pub async fn extract_and_store_workspace_id(
|
||||
Path(params): Path<String>,
|
||||
@@ -454,7 +539,6 @@ pub async fn add_www_authenticate_header(
|
||||
.body(axum::body::Body::from("Missing workspace_id in request"))
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
// Only add header to 401 Unauthorized responses
|
||||
@@ -541,27 +625,32 @@ pub async fn extract_workspace_from_token(
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => match row.workspace_id {
|
||||
Some(workspace_id) => {
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(GatewayWorkspaceId(workspace_id.clone()));
|
||||
request.extensions_mut().insert(WorkspaceId(workspace_id));
|
||||
Ok(Some(row)) => {
|
||||
if scoped_to_selection(row.scopes.as_deref().unwrap_or_default()) {
|
||||
request.extensions_mut().insert(McpSelection);
|
||||
}
|
||||
None => {
|
||||
// Only enter multi-workspace mode for genuine MCP tokens; a
|
||||
// full-privilege global token without mcp scope is rejected
|
||||
// by the runner's mcp-scope check anyway.
|
||||
let is_mcp = row
|
||||
.scopes
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:")));
|
||||
if is_mcp {
|
||||
request.extensions_mut().insert(MultiWorkspaceMcp);
|
||||
request.extensions_mut().insert(McpToken(token));
|
||||
match row.workspace_id {
|
||||
Some(workspace_id) => {
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(GatewayWorkspaceId(workspace_id.clone()));
|
||||
request.extensions_mut().insert(WorkspaceId(workspace_id));
|
||||
}
|
||||
None => {
|
||||
// Only enter multi-workspace mode for genuine MCP tokens; a
|
||||
// full-privilege global token without mcp scope is rejected
|
||||
// by the runner's mcp-scope check anyway.
|
||||
let is_mcp = row
|
||||
.scopes
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:")));
|
||||
if is_mcp {
|
||||
request.extensions_mut().insert(MultiWorkspaceMcp);
|
||||
request.extensions_mut().insert(McpToken(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::error!("Gateway token workspace lookup failed: {}", e);
|
||||
@@ -580,11 +669,16 @@ pub async fn add_www_authenticate_header_gateway(
|
||||
use axum::http::StatusCode;
|
||||
use windmill_common::BASE_URL;
|
||||
|
||||
let endpoint = request
|
||||
.extensions()
|
||||
.get::<McpEndpoint>()
|
||||
.map_or("/api/mcp/gateway", |e| e.0);
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
if response.status() == StatusCode::UNAUTHORIZED {
|
||||
let base_url = BASE_URL.load();
|
||||
let resource_url = format!("{}/api/mcp/gateway", base_url);
|
||||
let resource_url = format!("{}{}", base_url, endpoint);
|
||||
let www_authenticate = format!("Bearer resource=\"{}\"", resource_url);
|
||||
|
||||
let (mut parts, body) = response.into_parts();
|
||||
@@ -606,11 +700,12 @@ pub async fn setup_mcp_server(
|
||||
user_db: UserDB,
|
||||
base_internal_url: String,
|
||||
auth_cache: Arc<AuthCache>,
|
||||
) -> anyhow::Result<(Router, CancellationToken)> {
|
||||
) -> anyhow::Result<(Router, Router, CancellationToken)> {
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let session_manager = Arc::new(LocalSessionManager::default());
|
||||
|
||||
let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache);
|
||||
let session_runner = SessionRunner::new(backend.clone());
|
||||
let runner = Runner::new(backend);
|
||||
|
||||
let service_config = StreamableHttpServerConfig::default()
|
||||
@@ -632,11 +727,38 @@ pub async fn setup_mcp_server(
|
||||
// much smaller default, which would 413 them with no way to raise it.
|
||||
.with_max_request_body_bytes(*crate::REQUEST_SIZE_LIMIT.read().await);
|
||||
|
||||
let session_service = StreamableHttpService::new(
|
||||
move || Ok(session_runner.clone()),
|
||||
session_manager.clone(),
|
||||
service_config.clone(),
|
||||
);
|
||||
let service =
|
||||
StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config);
|
||||
|
||||
let router = Router::new().route_service("/", service);
|
||||
Ok((router, cancellation_token))
|
||||
let router = Router::new().route_service("/", service.clone());
|
||||
// `/api/mcp` serves both servers: a token scoped to a selection of scripts, flows and
|
||||
// endpoints gets the tools server, any other the session's toolset. At `/mcp` itself,
|
||||
// beside the `/mcp/...` routes, so it is merged rather than nested.
|
||||
let session_router = Router::new().route(
|
||||
"/mcp",
|
||||
axum::routing::any(move |mut request: Request<axum::body::Body>| {
|
||||
let (service, session_service) = (service.clone(), session_service.clone());
|
||||
async move {
|
||||
use tower::ServiceExt;
|
||||
let response = if request.extensions().get::<McpSelection>().is_some() {
|
||||
request.extensions_mut().insert(NoEndpointTools);
|
||||
service.oneshot(request).await
|
||||
} else {
|
||||
session_service.oneshot(request).await
|
||||
};
|
||||
match response {
|
||||
Ok(response) => response.into_response(),
|
||||
Err(never) => match never {},
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
Ok((router, session_router, cancellation_token))
|
||||
}
|
||||
|
||||
/// HTTP handler to list MCP tools as JSON
|
||||
@@ -649,3 +771,62 @@ async fn list_mcp_tools_handler() -> JsonResult<Vec<EndpointTool>> {
|
||||
pub fn list_tools_service() -> Router {
|
||||
Router::new().route("/", get(list_mcp_tools_handler))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ToolsetQuery {
|
||||
access: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ToolsetEntry {
|
||||
name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
/// The tools `/api/mcp` offers an `mcp:all` token at each access level, for the token
|
||||
/// form to show: the server's own filter, so the list cannot drift from it.
|
||||
async fn list_toolset_handler(
|
||||
axum::extract::Query(query): axum::extract::Query<ToolsetQuery>,
|
||||
) -> JsonResult<Vec<ToolsetEntry>> {
|
||||
let access = match query.access.as_deref() {
|
||||
Some("read") => SessionAccess::ReadOnly,
|
||||
Some("read_run") => SessionAccess::ReadAndRun,
|
||||
_ => SessionAccess::Full,
|
||||
};
|
||||
Ok(Json(
|
||||
session::session_tools(access)
|
||||
.into_iter()
|
||||
.map(|tool| ToolsetEntry {
|
||||
name: tool.name.to_string(),
|
||||
description: tool.description.map(|d| d.to_string()).unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn toolset_service() -> Router {
|
||||
Router::new().route("/", get(list_toolset_handler))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::scoped_to_selection;
|
||||
|
||||
#[test]
|
||||
fn only_a_token_without_mcp_all_is_a_selection() {
|
||||
let selection = |scopes: &[&str]| {
|
||||
scoped_to_selection(&scopes.iter().map(|s| s.to_string()).collect::<Vec<_>>())
|
||||
};
|
||||
assert!(selection(&["mcp:favorites"]));
|
||||
assert!(selection(&["mcp:scripts:f/x/*", "mcp:endpoints:listJobs"]));
|
||||
assert!(selection(&["mcp:all:f/x/*"]));
|
||||
assert!(!selection(&["mcp:all"]));
|
||||
assert!(!selection(&[
|
||||
"mcp:all",
|
||||
"mcp:read_run",
|
||||
"mcp:workspaces:a,b"
|
||||
]));
|
||||
assert!(!selection(&[]));
|
||||
assert!(!selection(&["variables:read"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod auto_generated_endpoints;
|
||||
mod core;
|
||||
mod session;
|
||||
mod utils;
|
||||
|
||||
// Re-export only what's needed externally
|
||||
@@ -12,5 +13,6 @@ pub mod oauth_server;
|
||||
pub use core::{
|
||||
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
||||
extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service,
|
||||
reject_token_query_param, setup_mcp_server,
|
||||
reject_token_query_param, setup_mcp_server, toolset_service, McpEndpoint,
|
||||
};
|
||||
pub(crate) use session::register_router;
|
||||
|
||||
@@ -201,7 +201,7 @@ struct AuthorizationCode {
|
||||
code: String,
|
||||
client_id: String,
|
||||
user_email: String,
|
||||
workspace_id: String,
|
||||
workspace_id: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
redirect_uri: String,
|
||||
code_challenge: Option<String>,
|
||||
@@ -216,7 +216,7 @@ struct RefreshTokenRow {
|
||||
access_token_hash: String,
|
||||
client_id: String,
|
||||
user_email: String,
|
||||
workspace_id: String,
|
||||
workspace_id: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
token_family: sqlx::types::Uuid,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
@@ -232,6 +232,9 @@ fn supported_scopes() -> Vec<String> {
|
||||
"mcp:scripts:*".to_string(),
|
||||
"mcp:flows:*".to_string(),
|
||||
"mcp:endpoints:*".to_string(),
|
||||
"mcp:read_only".to_string(),
|
||||
"mcp:read_run".to_string(),
|
||||
"mcp:runnables_as_tools".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -795,10 +798,31 @@ async fn oauth_authorize_inner(
|
||||
}
|
||||
|
||||
/// Shared approve logic — validates form, checks membership, stores auth code.
|
||||
async fn ensure_member(db: &DB, workspace_id: &str, email: &str) -> Result<()> {
|
||||
let is_member = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)",
|
||||
workspace_id,
|
||||
email
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Database error: {}", e)))?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_member {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"User is not a member of workspace '{workspace_id}'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `workspace_id` is the workspace the minted token is bound to; without one, the token
|
||||
/// reaches what its scopes allow (`mcp:workspaces:`), or every workspace of its owner.
|
||||
async fn oauth_approve_inner(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
form: ApprovalForm,
|
||||
) -> Result<Json<ApprovalResponse>> {
|
||||
// The code approved here is exchanged for a database token carrying only this
|
||||
@@ -808,21 +832,8 @@ async fn oauth_approve_inner(
|
||||
// the gateway approve route reach the exchange through here.
|
||||
forbid_elevated_job_token(db, &authed.email, authed.job_id).await?;
|
||||
|
||||
// Verify user is a member of the workspace
|
||||
let is_member = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)",
|
||||
workspace_id,
|
||||
authed.email
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Database error: {}", e)))?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_member {
|
||||
return Err(Error::NotAuthorized(
|
||||
"User is not a member of this workspace".to_string(),
|
||||
));
|
||||
if let Some(workspace_id) = workspace_id {
|
||||
ensure_member(db, workspace_id, &authed.email).await?;
|
||||
}
|
||||
|
||||
// Verify client exists and redirect_uri is registered
|
||||
@@ -945,7 +956,7 @@ pub async fn workspaced_oauth_approve(
|
||||
authed: ApiAuthed,
|
||||
Json(form): Json<ApprovalForm>,
|
||||
) -> Result<Json<ApprovalResponse>> {
|
||||
oauth_approve_inner(&db, &authed, &workspace_id, form).await
|
||||
oauth_approve_inner(&db, &authed, Some(&workspace_id), form).await
|
||||
}
|
||||
|
||||
/// PKCE validation (S256 only)
|
||||
@@ -1027,6 +1038,16 @@ pub async fn gateway_protected_resource_metadata() -> Json<ProtectedResourceMeta
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /.well-known/oauth-protected-resource/api/mcp
|
||||
pub async fn gateway_session_protected_resource_metadata() -> Json<ProtectedResourceMetadata> {
|
||||
let base_url = BASE_URL.load();
|
||||
Json(build_protected_resource_metadata(
|
||||
"/api/mcp",
|
||||
"/api/mcp/gateway/oauth/server",
|
||||
&base_url,
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/mcp/gateway/oauth/server/authorize — gateway mode (no workspace in path)
|
||||
pub async fn gateway_oauth_authorize(
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1035,23 +1056,44 @@ pub async fn gateway_oauth_authorize(
|
||||
oauth_authorize_inner(&db, params, None).await
|
||||
}
|
||||
|
||||
/// POST /api/mcp/gateway/oauth/server/approve — workspace_id comes from JSON body
|
||||
/// POST /api/mcp/gateway/oauth/server/approve — the workspaces come from the JSON body
|
||||
pub async fn gateway_oauth_approve(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
Json(form): Json<GatewayApprovalForm>,
|
||||
) -> Result<Json<ApprovalResponse>> {
|
||||
if form.workspace_id.is_empty() {
|
||||
return Err(Error::BadRequest("workspace_id is required".to_string()));
|
||||
let mut scope = form.scope;
|
||||
let mut workspace_ids = form.workspace_ids;
|
||||
if workspace_ids.is_empty() {
|
||||
workspace_ids.extend(form.workspace_id);
|
||||
}
|
||||
let workspace_id = if form.all_workspaces {
|
||||
None
|
||||
} else {
|
||||
match workspace_ids.as_slice() {
|
||||
[] => {
|
||||
return Err(Error::BadRequest(
|
||||
"Pick at least one workspace, or all of them".to_string(),
|
||||
))
|
||||
}
|
||||
[only] => Some(only.as_str()),
|
||||
several => {
|
||||
for workspace_id in several {
|
||||
ensure_member(&db, workspace_id, &authed.email).await?;
|
||||
}
|
||||
scope.push_str(&format!(" mcp:workspaces:{}", several.join(",")));
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
oauth_approve_inner(
|
||||
&db,
|
||||
&authed,
|
||||
&form.workspace_id,
|
||||
workspace_id,
|
||||
ApprovalForm {
|
||||
client_id: form.client_id,
|
||||
redirect_uri: form.redirect_uri,
|
||||
scope: form.scope,
|
||||
scope,
|
||||
state: form.state,
|
||||
code_challenge: form.code_challenge,
|
||||
code_challenge_method: form.code_challenge_method,
|
||||
@@ -1060,7 +1102,8 @@ pub async fn gateway_oauth_approve(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Gateway approval form — same as ApprovalForm but includes workspace_id
|
||||
/// Gateway approval form — same as ApprovalForm, plus the workspaces the token reaches:
|
||||
/// one binds the token to it, several are listed in its scopes.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayApprovalForm {
|
||||
pub client_id: String,
|
||||
@@ -1069,7 +1112,14 @@ pub struct GatewayApprovalForm {
|
||||
pub state: String,
|
||||
pub code_challenge: String,
|
||||
pub code_challenge_method: String,
|
||||
pub workspace_id: String,
|
||||
#[serde(default)]
|
||||
pub workspace_ids: Vec<String>,
|
||||
/// The single workspace a consent page loaded before `workspace_ids` existed sends.
|
||||
#[serde(default)]
|
||||
pub workspace_id: Option<String>,
|
||||
/// Every workspace the user is a member of, including ones they join later.
|
||||
#[serde(default)]
|
||||
pub all_workspaces: bool,
|
||||
}
|
||||
|
||||
// ── Router constructors ──
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,969 @@
|
||||
//! Data tables, DuckLake and database resource schemas.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::runs::{backgrounded_summary, run_script_preview, wait_budget, wait_job};
|
||||
use super::workspace::{api_message, path_enc};
|
||||
use super::{array, enc, js_str, non_null, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult};
|
||||
|
||||
pub(super) const TOOLS: &[&str] = &[
|
||||
"list_datatables",
|
||||
"get_datatable_table_schema",
|
||||
"exec_datatable_sql",
|
||||
"list_data_metrics",
|
||||
"list_ducklakes",
|
||||
"get_db_schema",
|
||||
"list_datatable_migrations",
|
||||
"create_datatable_migration",
|
||||
"run_datatable_migrations",
|
||||
"rollback_datatable_migration",
|
||||
];
|
||||
|
||||
pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult {
|
||||
match name {
|
||||
"list_datatables" => Ok(list_datatables(ctx, args)
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("Error listing datatables: {e}"))),
|
||||
"get_datatable_table_schema" => get_datatable_table_schema(ctx, args).await,
|
||||
"exec_datatable_sql" => exec_datatable_sql(ctx, args).await,
|
||||
"list_data_metrics" => list_data_metrics(ctx, args).await,
|
||||
"list_ducklakes" => Ok(list_ducklakes(ctx)
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("Error listing DuckLake catalogs: {e}"))),
|
||||
"get_db_schema" => get_db_schema(ctx, args).await,
|
||||
"list_datatable_migrations" => list_datatable_migrations(ctx, args).await,
|
||||
"create_datatable_migration" => create_datatable_migration(ctx, args).await,
|
||||
"run_datatable_migrations" => run_datatable_migrations(ctx, args).await,
|
||||
"rollback_datatable_migration" => rollback_datatable_migration(ctx, args).await,
|
||||
_ => unreachable!("{name} is not a data tool"),
|
||||
}
|
||||
}
|
||||
|
||||
const NO_DATATABLES_CONFIGURED_MESSAGE: &str = "No datatables are configured in this workspace. Datatable operations (querying data, creating or altering tables) are blocked until a datatable exists. Datatables are not created by SQL — the user must set one up in the workspace settings (Workspace settings → Data Tables) first. Do not call exec_datatable_sql or assume a \"main\" datatable exists; instead tell the user this is a required prerequisite and ask them to configure a datatable in their workspace settings.";
|
||||
|
||||
const NO_DUCKLAKES_CONFIGURED_MESSAGE: &str = "No DuckLake catalogs are configured in this workspace. A data pipeline that materializes DuckLake tables or reads/writes S3 assets cannot run until object storage and a DuckLake catalog are set up. You can still draft the pipeline scripts, but tell the user how to enable it by role: a workspace admin adds object storage under Workspace settings → Object Storage (S3/Azure/GCS), then a DuckLake catalog on top of it; a user without admin rights should ask a workspace admin. Do not assume a default 'main' DuckLake exists.";
|
||||
|
||||
const NO_DATA_METRICS_NOTE: &str = "Nothing matched. That does not establish that nothing is declared: declarations whose producing script you cannot read are omitted from this list, not flagged. You may write your own aggregate, but tell the user you found no declared measure you can read rather than stating none is declared.";
|
||||
|
||||
const DEFAULT_DATA_METRICS_LIMIT: u64 = 200;
|
||||
/// Rows of a SELECT handed to the model.
|
||||
const MAX_ROWS: usize = 100;
|
||||
/// Seconds a query is awaited before it is left running for `get_run`.
|
||||
const MAX_SCHEMA_LENGTH: usize = 350_000;
|
||||
|
||||
/// The worker's error for a data table that is not configured in the workspace.
|
||||
static DATATABLE_NOT_FOUND: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)datatable\s+\S+\s+not found").expect("valid regex"));
|
||||
|
||||
fn datatable_not_configured_message(name: &str) -> String {
|
||||
format!(
|
||||
"Datatable \"{name}\" is not configured in this workspace, so this operation cannot run. \
|
||||
Datatables are not created by SQL — they must be set up first by the user in the workspace settings \
|
||||
(Workspace settings → Data Tables) before any table can be queried or created. \
|
||||
This is a required, blocking prerequisite: do not retry on this or another name. \
|
||||
Tell the user they need to configure a datatable (e.g. named \"{name}\") in their workspace settings, then try again."
|
||||
)
|
||||
}
|
||||
|
||||
/// The optional `role` argument, held to the rule the server applies to a role name.
|
||||
fn role_arg(args: &Value) -> Result<Option<&str>, String> {
|
||||
let role = opt_str_arg(args, "role");
|
||||
match role {
|
||||
Some(r)
|
||||
if r.is_empty()
|
||||
|| r.len() > 63
|
||||
|| !r
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
|
||||
{
|
||||
Err(format!(
|
||||
"Invalid `role` '{r}': only letters, digits, '_' and '-' are allowed (1 to 63 characters)"
|
||||
))
|
||||
}
|
||||
_ => Ok(role),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path?k=v&...` over the parameters that are set.
|
||||
fn with_query(path: String, params: &[(&str, Option<&str>)]) -> String {
|
||||
let query: Vec<String> = params
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.map(|v| format!("{k}={}", enc(v))))
|
||||
.collect();
|
||||
if query.is_empty() {
|
||||
path
|
||||
} else {
|
||||
format!("{path}?{}", query.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_datatables(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let role = role_arg(args)?;
|
||||
let datatable_name = opt_str_arg(args, "datatable_name");
|
||||
if role.is_some() && datatable_name.is_none() {
|
||||
return Err("`role` needs `datatable_name`, the datatable it is a role of".to_string());
|
||||
}
|
||||
let path = with_query(
|
||||
ctx.ws("workspaces/list_datatable_tables"),
|
||||
&[
|
||||
("datatable_name", datatable_name),
|
||||
("role_for", role.and(datatable_name)),
|
||||
("role", role),
|
||||
],
|
||||
);
|
||||
let metadata = ctx.get(&path).await?;
|
||||
let metadata = array(Some(&metadata));
|
||||
if metadata.is_empty() {
|
||||
return Ok(NO_DATATABLES_CONFIGURED_MESSAGE.to_string());
|
||||
}
|
||||
let listed: Vec<Value> = metadata
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let mut out = Map::new();
|
||||
out.insert("datatable_name".into(), d["datatable_name"].clone());
|
||||
out.insert("schemas".into(), d["schemas"].clone());
|
||||
if truthy(d.get("error")) {
|
||||
out.insert("error".into(), d["error"].clone());
|
||||
}
|
||||
if truthy(d.get("permissioned")) {
|
||||
for key in ["usable_roles", "default_role"] {
|
||||
if let Some(v) = d.get(key) {
|
||||
out.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
})
|
||||
.collect();
|
||||
Ok(pretty(&Value::Array(listed)))
|
||||
}
|
||||
|
||||
async fn get_datatable_table_schema(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let datatable_name = str_arg(args, "datatable_name")?;
|
||||
let schema_name = str_arg(args, "schema_name")?;
|
||||
let table_name = str_arg(args, "table_name")?;
|
||||
let role = role_arg(args)?;
|
||||
let path = with_query(
|
||||
ctx.ws("workspaces/get_datatable_table_schema"),
|
||||
&[
|
||||
("datatable_name", Some(datatable_name)),
|
||||
("schema_name", Some(schema_name)),
|
||||
("table_name", Some(table_name)),
|
||||
("role", role),
|
||||
],
|
||||
);
|
||||
Ok(match ctx.get(&path).await {
|
||||
Ok(schema) => pretty(&json!({
|
||||
"datatable_name": datatable_name,
|
||||
"schema_name": schema_name,
|
||||
"table_name": table_name,
|
||||
"columns": schema["columns"],
|
||||
})),
|
||||
Err(e) if DATATABLE_NOT_FOUND.is_match(&e) => {
|
||||
datatable_not_configured_message(datatable_name)
|
||||
}
|
||||
Err(e) => format!("Error getting table schema: {e}"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_ducklakes(ctx: &Ctx<'_>) -> ToolResult {
|
||||
let ducklakes = ctx.get(&ctx.ws("workspaces/list_ducklakes")).await?;
|
||||
if ducklakes.as_array().is_none_or(Vec::is_empty) {
|
||||
return Ok(NO_DUCKLAKES_CONFIGURED_MESSAGE.to_string());
|
||||
}
|
||||
Ok(pretty(&json!({ "ducklakes": ducklakes })))
|
||||
}
|
||||
|
||||
async fn list_data_metrics(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let limit = match args.get("limit") {
|
||||
None | Some(Value::Null) => DEFAULT_DATA_METRICS_LIMIT,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| (1..=1000).contains(n))
|
||||
.ok_or("`limit` must be an integer between 1 and 1000")?,
|
||||
};
|
||||
let table = opt_str_arg(args, "table");
|
||||
let path = with_query(
|
||||
ctx.ws("data_metrics/list"),
|
||||
&[
|
||||
("table", table),
|
||||
("path_prefix", opt_str_arg(args, "path_prefix")),
|
||||
("per_page", Some(&limit.to_string())),
|
||||
],
|
||||
);
|
||||
let page = ctx.get(&path).await?;
|
||||
let metrics = page.get("metrics").cloned().unwrap_or_else(|| json!([]));
|
||||
let note = if truthy(page.get("next_cursor")) {
|
||||
Some(format!("More declarations exist beyond the first {limit}. Re-call with table/path_prefix to target what you are looking for, or with a higher limit (max 1000) for the rest of the list, rather than concluding a measure is undeclared."))
|
||||
} else if metrics.as_array().is_some_and(Vec::is_empty) {
|
||||
// The endpoint expands a table filter only once it contains a `/`, so a bare
|
||||
// table name matches nothing whatever is declared.
|
||||
let bare = table.map(|t| t.replacen("ducklake://", "", 1));
|
||||
Some(match (table, bare) {
|
||||
(Some(table), Some(bare)) if !bare.is_empty() && !bare.contains('/') => format!("Nothing matched `{table}`: a table filter must name its lake, as `<lake>/{bare}`. Re-call with the lake before concluding anything about what is declared."),
|
||||
_ => NO_DATA_METRICS_NOTE.to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut out = Map::new();
|
||||
out.insert("metrics".into(), metrics);
|
||||
if let Some(note) = note {
|
||||
out.insert("note".into(), note.into());
|
||||
}
|
||||
Ok(pretty(&Value::Object(out)))
|
||||
}
|
||||
|
||||
async fn exec_datatable_sql(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let name = str_arg(args, "datatable_name")?;
|
||||
let sql = str_arg(args, "sql")?;
|
||||
let role = role_arg(args)?;
|
||||
let failed = |e: String| format!("Failed to execute test run: {e}");
|
||||
|
||||
let database = match role {
|
||||
None => format!("datatable://{name}"),
|
||||
// A stored name holding a `?` is read whole by the server, so a role cannot follow it.
|
||||
Some(role) if name.contains('?') => {
|
||||
return Err(failed(format!("Data table '{name}' has a '?' in its name, so it can only be used here as its default role. Rename it to connect as role '{role}'.")));
|
||||
}
|
||||
Some(role) => format!("datatable://{name}?role={role}"),
|
||||
};
|
||||
let id = run_script_preview(
|
||||
ctx,
|
||||
None,
|
||||
"postgresql",
|
||||
sql,
|
||||
json!({ "database": database }),
|
||||
)
|
||||
.await
|
||||
.map_err(failed)?;
|
||||
|
||||
match wait_job(ctx, &id, wait_budget(args), false).await {
|
||||
Some(job) => Ok(format_datatable_sql_completion(&job, name)),
|
||||
None => Ok(backgrounded_summary(&id, &format!("SQL · {name}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_datatable_sql_completion(job: &Value, datatable_name: &str) -> String {
|
||||
if job["success"] == true {
|
||||
let rows = array(job.get("result"));
|
||||
let row_count = rows.len();
|
||||
return pretty(&if row_count > MAX_ROWS {
|
||||
json!({
|
||||
"success": true,
|
||||
"rowCount": row_count,
|
||||
"result": &rows[..MAX_ROWS],
|
||||
"note": format!("Showing first {MAX_ROWS} of {row_count} rows"),
|
||||
})
|
||||
} else {
|
||||
json!({ "success": true, "rowCount": row_count, "result": rows })
|
||||
});
|
||||
}
|
||||
let error = match job.get("result") {
|
||||
None => json!("Unknown error"),
|
||||
Some(result) => match result.pointer("/error/message") {
|
||||
Some(message) if !message.is_null() => message.clone(),
|
||||
_ => match result {
|
||||
Value::String(_) => result.clone(),
|
||||
other => Value::String(other.to_string()),
|
||||
},
|
||||
},
|
||||
};
|
||||
let error = match error.as_str() {
|
||||
Some(raw) if DATATABLE_NOT_FOUND.is_match(raw) => {
|
||||
Value::String(datatable_not_configured_message(datatable_name))
|
||||
}
|
||||
_ => error,
|
||||
};
|
||||
json!({ "success": false, "error": error }).to_string()
|
||||
}
|
||||
|
||||
/// The optional `version` argument: a migration's timestamp.
|
||||
fn version_arg(args: &Value) -> Result<Option<i64>, String> {
|
||||
match args.get("version") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_i64()
|
||||
.or_else(|| v.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64))
|
||||
.map(Some)
|
||||
.ok_or_else(|| "`version` must be an integer".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `workspaces/<endpoint>/<datatable>`, the shape of every migration route.
|
||||
fn migrations_path(ctx: &Ctx<'_>, endpoint: &str, datatable_name: &str) -> String {
|
||||
ctx.ws(&format!("workspaces/{endpoint}/{}", enc(datatable_name)))
|
||||
}
|
||||
|
||||
async fn migrations_status(ctx: &Ctx<'_>, datatable_name: &str) -> Result<Value, String> {
|
||||
ctx.get(&migrations_path(
|
||||
ctx,
|
||||
"datatable_migrations_status",
|
||||
datatable_name,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| api_message(&e).to_string())
|
||||
}
|
||||
|
||||
/// `POST` without a body: applying and rolling back take their target from the query.
|
||||
async fn post_migrations(ctx: &Ctx<'_>, path: &str) -> Result<Value, String> {
|
||||
let text = ctx
|
||||
.request("POST", path, None)
|
||||
.await
|
||||
.map_err(|e| api_message(&e).to_string())?;
|
||||
serde_json::from_str(&text).map_err(|e| format!("unexpected response: {e}"))
|
||||
}
|
||||
|
||||
fn migration_label(version: &Value, name: &Value) -> String {
|
||||
format!("{} ({})", js_str(version), js_str(name))
|
||||
}
|
||||
|
||||
fn labels(migrations: Option<&Value>) -> String {
|
||||
array(migrations)
|
||||
.iter()
|
||||
.map(|m| migration_label(&m["version"], &m["name"]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn migration_status(status: &Value) -> &'static str {
|
||||
match status.as_str() {
|
||||
Some("ran") => "applied",
|
||||
Some("not_run") => "pending",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a leading comment line whose first word is `keyword`: an annotation attempt.
|
||||
fn annotation_attempt<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
|
||||
let body = line.strip_prefix("--")?.trim_start();
|
||||
let after = body
|
||||
.get(..keyword.len())
|
||||
.filter(|w| w.eq_ignore_ascii_case(keyword))
|
||||
.map(|_| &body[keyword.len()..])?;
|
||||
match after.chars().next() {
|
||||
Some(c) if !(c.is_whitespace() || c == ':' || c == '=') => None,
|
||||
_ => Some(after),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines of `sql`'s leading comment block, where the server reads its annotations.
|
||||
fn leading_block(sql: &str) -> impl Iterator<Item = &str> {
|
||||
sql.split('\n')
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.take_while(|l| l.starts_with("--"))
|
||||
}
|
||||
|
||||
/// `sql` with every `keyword` attempt of its leading comment block removed, and `line`, if
|
||||
/// any, on top.
|
||||
fn with_annotation(sql: &str, keyword: &str, line: Option<String>) -> String {
|
||||
let lines: Vec<&str> = sql.split('\n').collect();
|
||||
let leading = lines
|
||||
.iter()
|
||||
.position(|l| !l.trim().is_empty() && !l.trim().starts_with("--"))
|
||||
.unwrap_or(lines.len());
|
||||
let rest = lines[..leading]
|
||||
.iter()
|
||||
.filter(|l| annotation_attempt(l.trim(), keyword).is_none())
|
||||
.chain(&lines[leading..])
|
||||
.skip_while(|l| l.trim().is_empty())
|
||||
.map(|l| l.to_string());
|
||||
line.into_iter().chain(rest).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// `sql` declaring `role`: the server reads the role a migration runs as from a `-- role <name>`
|
||||
/// annotation in its leading comment block, so any attempt there is replaced by one on top.
|
||||
fn with_migration_role(sql: &str, role: Option<&str>) -> String {
|
||||
match role {
|
||||
Some(role) => with_annotation(sql, "role", Some(format!("-- role {role}"))),
|
||||
None => sql.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_annotatable_schema_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 63
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '-'))
|
||||
}
|
||||
|
||||
/// The scope `sql`'s `-- schemas a, b` annotation declares, lowercased, as the server's
|
||||
/// `parse_migration_schemas` reads it. None for the whole database, which is also what a
|
||||
/// malformed annotation stands for here.
|
||||
fn declared_schemas(sql: &str) -> Option<Vec<String>> {
|
||||
let after = leading_block(sql).find_map(|l| annotation_attempt(l, "schemas"))?;
|
||||
let rest = after.trim_start();
|
||||
let rest = rest.strip_prefix([':', '=']).unwrap_or(rest).trim();
|
||||
let rest = rest.strip_suffix(';').unwrap_or(rest);
|
||||
let mut schemas: Vec<String> = Vec::new();
|
||||
for name in rest.split(',').map(str::trim) {
|
||||
if !is_annotatable_schema_name(name) {
|
||||
return None;
|
||||
}
|
||||
let name = name.to_ascii_lowercase();
|
||||
if !schemas.contains(&name) {
|
||||
schemas.push(name);
|
||||
}
|
||||
}
|
||||
Some(schemas)
|
||||
}
|
||||
|
||||
/// Whether a listed migration's `schemas` overlaps `own`; an absent scope is the whole database.
|
||||
fn scopes_overlap(theirs: &Value, own: Option<&[String]>) -> bool {
|
||||
match (theirs.as_array(), own) {
|
||||
(Some(theirs), Some(own)) => theirs
|
||||
.iter()
|
||||
.any(|s| own.iter().any(|o| s.as_str() == Some(o))),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The optional `schemas` argument, held to the rule the server applies to a scope name.
|
||||
fn schemas_arg(args: &Value) -> Result<Option<Vec<String>>, String> {
|
||||
let Some(value) = non_null(args.get("schemas")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let names = value
|
||||
.as_array()
|
||||
.ok_or("`schemas` must be an array of schema names")?;
|
||||
names
|
||||
.iter()
|
||||
.map(|name| match name.as_str() {
|
||||
Some(name) if is_annotatable_schema_name(name) => Ok(name.to_ascii_lowercase()),
|
||||
_ => Err(format!(
|
||||
"Invalid schema name '{}' in `schemas`: only letters, digits, '_', '$' and '-' are allowed (1 to 63 characters)",
|
||||
js_str(name)
|
||||
)),
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn list_datatable_migrations(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let datatable_name = str_arg(args, "datatable_name")?;
|
||||
let version = version_arg(args)?;
|
||||
let status = match migrations_status(ctx, datatable_name).await {
|
||||
Ok(status) => status,
|
||||
Err(e) => return Ok(format!("Error listing migrations: {e}")),
|
||||
};
|
||||
if !truthy(status.get("enabled")) {
|
||||
return Ok(format!("Migrations are not enabled on datatable \"{datatable_name}\": its schema is changed with plain SQL and there is no history to list. Enabling them is a workspace admin's decision, made in Workspace settings → Data Tables."));
|
||||
}
|
||||
let migrations = array(status.get("migrations"));
|
||||
let Some(version) = version else {
|
||||
let mut out = Map::new();
|
||||
out.insert(
|
||||
"migrations".into(),
|
||||
migrations
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"version": m["timestamp"],
|
||||
"name": m["name"],
|
||||
"status": migration_status(&m["status"]),
|
||||
"schemas": non_null(m.get("schemas")).cloned().unwrap_or_else(|| json!("whole database")),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if truthy(status.get("error")) {
|
||||
out.insert("error".into(), status["error"].clone());
|
||||
}
|
||||
return Ok(pretty(&Value::Object(out)));
|
||||
};
|
||||
let Some(migration) = migrations
|
||||
.iter()
|
||||
.find(|m| m["timestamp"].as_i64() == Some(version))
|
||||
else {
|
||||
return Ok(format!(
|
||||
"No migration {version} on datatable \"{datatable_name}\""
|
||||
));
|
||||
};
|
||||
let mut out = Map::new();
|
||||
out.insert("version".into(), version.into());
|
||||
out.insert("name".into(), migration["name"].clone());
|
||||
out.insert(
|
||||
"status".into(),
|
||||
migration_status(&migration["status"]).into(),
|
||||
);
|
||||
out.insert("code_up".into(), migration["code_up"].clone());
|
||||
if let Some(code_down) = non_null(migration.get("code_down")) {
|
||||
out.insert("code_down".into(), code_down.clone());
|
||||
}
|
||||
Ok(pretty(&Value::Object(out)))
|
||||
}
|
||||
|
||||
/// Saves the definition and answers its version, or the message the tool returns instead.
|
||||
async fn save_migration(
|
||||
ctx: &Ctx<'_>,
|
||||
datatable_name: &str,
|
||||
version: Option<i64>,
|
||||
mut definition: Map<String, Value>,
|
||||
) -> Result<Result<i64, String>, String> {
|
||||
let Some(version) = version else {
|
||||
let created = ctx
|
||||
.post(
|
||||
&migrations_path(ctx, "create_datatable_migration", datatable_name),
|
||||
Value::Object(definition),
|
||||
)
|
||||
.await?;
|
||||
return created["timestamp"]
|
||||
.as_i64()
|
||||
.map(Ok)
|
||||
.ok_or_else(|| "the created migration has no version".to_string());
|
||||
};
|
||||
// The server inserts at a version it does not know, which would wedge a new migration
|
||||
// into the middle of the history, and stores a down left out as none.
|
||||
let status = migrations_status(ctx, datatable_name).await?;
|
||||
let existing = array(status.get("migrations"))
|
||||
.iter()
|
||||
.find(|m| m["timestamp"].as_i64() == Some(version));
|
||||
let Some(existing) = existing.filter(|m| m["status"] == "not_run") else {
|
||||
return Ok(Err(format!("Migration {version} is not a pending migration of datatable \"{datatable_name}\", so it cannot be rewritten. Omit `version` to add a new migration.")));
|
||||
};
|
||||
definition.insert("timestamp".into(), version.into());
|
||||
if let Some(code_down) = non_null(existing.get("code_down")) {
|
||||
definition
|
||||
.entry("code_down")
|
||||
.or_insert_with(|| code_down.clone());
|
||||
}
|
||||
ctx.post(
|
||||
&migrations_path(ctx, "upsert_datatable_migration", datatable_name),
|
||||
Value::Object(definition),
|
||||
)
|
||||
.await?;
|
||||
Ok(Ok(version))
|
||||
}
|
||||
|
||||
/// The labels of the migrations before `version`, in a scope overlapping `scope`, that are
|
||||
/// not applied yet.
|
||||
async fn earlier_pending(
|
||||
ctx: &Ctx<'_>,
|
||||
datatable_name: &str,
|
||||
version: i64,
|
||||
scope: Option<&[String]>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let status = migrations_status(ctx, datatable_name).await?;
|
||||
let earlier: Vec<&Value> = array(status.get("migrations"))
|
||||
.iter()
|
||||
.filter(|m| m["timestamp"].as_i64().is_some_and(|t| t < version))
|
||||
.filter(|m| scopes_overlap(&m["schemas"], scope))
|
||||
.collect();
|
||||
// Unreadable is not pending: reporting it so would send the user to apply migrations
|
||||
// that already ran.
|
||||
if truthy(status.get("error")) {
|
||||
return Err(js_str(&status["error"]));
|
||||
}
|
||||
if earlier.iter().any(|m| m["status"] == "unknown") {
|
||||
return Err("the applied migrations could not be read".to_string());
|
||||
}
|
||||
Ok(earlier
|
||||
.into_iter()
|
||||
.filter(|m| m["status"] == "not_run")
|
||||
.map(|m| migration_label(&m["timestamp"], &m["name"]))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_datatable_migration(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let datatable_name = str_arg(args, "datatable_name")?;
|
||||
let name = str_arg(args, "name")?;
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid `name` '{name}': only letters, digits, '_' and '-' are allowed"
|
||||
));
|
||||
}
|
||||
let role = role_arg(args)?;
|
||||
let version = version_arg(args)?;
|
||||
let code_up = str_arg(args, "code_up")?;
|
||||
// What the caller declares, else what `code_up` does; none is the whole database.
|
||||
let scope = schemas_arg(args)?
|
||||
.or_else(|| declared_schemas(code_up))
|
||||
.unwrap_or_default();
|
||||
let header = (!scope.is_empty()).then(|| format!("-- schemas {}", scope.join(", ")));
|
||||
|
||||
let mut definition = Map::new();
|
||||
definition.insert("name".into(), name.into());
|
||||
definition.insert(
|
||||
"code_up".into(),
|
||||
with_annotation(&with_migration_role(code_up, role), "schemas", header).into(),
|
||||
);
|
||||
if let Some(code_down) = opt_str_arg(args, "code_down") {
|
||||
// A rollback has to run as the role that created what it drops.
|
||||
definition.insert(
|
||||
"code_down".into(),
|
||||
with_migration_role(code_down, role).into(),
|
||||
);
|
||||
}
|
||||
let version = match save_migration(ctx, datatable_name, version, definition).await {
|
||||
Ok(Ok(version)) => version,
|
||||
Ok(Err(message)) => return Ok(message),
|
||||
Err(e) => return Ok(format!("Error saving migration: {}", api_message(&e))),
|
||||
};
|
||||
let label = format!("{version} ({name})");
|
||||
if args.get("apply") == Some(&Value::Bool(false)) {
|
||||
return Ok(format!(
|
||||
"Saved migration {label} as pending. It is not applied yet."
|
||||
));
|
||||
}
|
||||
|
||||
// Applying it alone would run it ahead of migrations it may depend on.
|
||||
let own_scope = (!scope.is_empty()).then_some(scope.as_slice());
|
||||
let earlier = match earlier_pending(ctx, datatable_name, version, own_scope).await {
|
||||
Ok(earlier) => earlier,
|
||||
Err(e) => return Ok(format!("Saved migration {label} as pending, but could not check what else is pending, so it was not applied: {e}")),
|
||||
};
|
||||
if !earlier.is_empty() {
|
||||
return Ok(format!("Saved migration {label} as pending without applying it: earlier migrations are not applied yet ({}) and it may depend on them. Tell the user, then apply them all in order with run_datatable_migrations.", earlier.join(", ")));
|
||||
}
|
||||
|
||||
let run = format!(
|
||||
"{}?only={version}",
|
||||
migrations_path(ctx, "run_datatable_migrations", datatable_name)
|
||||
);
|
||||
Ok(match post_migrations(ctx, &run).await {
|
||||
Ok(_) => format!("Applied migration {label} on datatable \"{datatable_name}\"."),
|
||||
Err(e) => format!("Migration {label} is saved but failed to apply: {e}. It stays pending and blocks later migrations. If nothing of it was applied, fix it by calling this tool again with version {version}; otherwise, or to drop it, tell the user, who can edit or delete it in Workspace settings → Data Tables."),
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_datatable_migrations(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let datatable_name = str_arg(args, "datatable_name")?;
|
||||
let path = migrations_path(ctx, "run_datatable_migrations", datatable_name);
|
||||
Ok(match post_migrations(ctx, &path).await {
|
||||
Ok(result) => {
|
||||
let applied = array(result.get("applied"));
|
||||
if applied.is_empty() {
|
||||
format!("No pending migration to apply on datatable \"{datatable_name}\".")
|
||||
} else {
|
||||
format!(
|
||||
"Applied {} migration(s) on datatable \"{datatable_name}\": {}.",
|
||||
applied.len(),
|
||||
labels(result.get("applied"))
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Error applying migrations: {e}. Migrations before the failing one may have been applied: list them to see."),
|
||||
})
|
||||
}
|
||||
|
||||
async fn rollback_datatable_migration(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let datatable_name = str_arg(args, "datatable_name")?;
|
||||
let version = version_arg(args)?.ok_or("Missing required number argument `version`")?;
|
||||
let path = format!(
|
||||
"{}?only={version}",
|
||||
migrations_path(ctx, "rollback_datatable_migrations", datatable_name)
|
||||
);
|
||||
Ok(match post_migrations(ctx, &path).await {
|
||||
Ok(result) if array(result.get("rolled_back")).is_empty() => format!("Migration {version} is not applied on datatable \"{datatable_name}\", so there is nothing to roll back."),
|
||||
Ok(result) => format!(
|
||||
"Rolled back migration {} on datatable \"{datatable_name}\". It is pending again.",
|
||||
labels(result.get("rolled_back"))
|
||||
),
|
||||
Err(e) => format!("Error rolling back: {e}"),
|
||||
})
|
||||
}
|
||||
|
||||
const POSTGRESQL_SCHEMA_QUERY: &str = "
|
||||
SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns
|
||||
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
|
||||
AND NOT starts_with(nsp.nspname, 'pg_') AND has_schema_privilege(nsp.oid, 'USAGE')";
|
||||
const MYSQL_SCHEMA_QUERY: &str = "SELECT DATABASE() AS default_db_name, TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT FROM information_schema.columns WHERE table_schema = DATABASE() OR table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', '_vt');";
|
||||
const SNOWFLAKE_SCHEMA_QUERY: &str = "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE from information_schema.columns where table_schema != 'INFORMATION_SCHEMA'";
|
||||
const MSSQL_SCHEMA_QUERY: &str = "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'sys'";
|
||||
const BIGQUERY_SCHEMA_SCRIPT: &str = r#"import { BigQuery } from '@google-cloud/bigquery@7.5.0';
|
||||
export async function main(args: bigquery) {
|
||||
const bq = new BigQuery({
|
||||
credentials: args
|
||||
})
|
||||
const [datasets] = await bq.getDatasets();
|
||||
if (!datasets) return {}
|
||||
const schema = {} as any
|
||||
let queries = datasets.map(dataset => `
|
||||
SELECT
|
||||
table_name,
|
||||
'${dataset.id}' as dataset,
|
||||
ARRAY_AGG(STRUCT(
|
||||
if(is_nullable = 'YES', true, false) AS required,
|
||||
column_name AS name,
|
||||
data_type AS type,
|
||||
if(column_default = 'NULL', null, column_default) AS \`default\`
|
||||
) ORDER BY ordinal_position)
|
||||
AS schema
|
||||
FROM \`${dataset.id}\`.INFORMATION_SCHEMA.COLUMNS
|
||||
GROUP BY table_name`
|
||||
)
|
||||
let query = queries.join('\nUNION ALL \n')
|
||||
const [rows] = await bq.query(query)
|
||||
for (const row of rows) {
|
||||
schema[row.dataset] ??= {}
|
||||
schema[row.dataset][row.table_name] = {}
|
||||
for (const col of row.schema) {
|
||||
const colName = col.name
|
||||
delete col.name
|
||||
if (col.default === null) {
|
||||
delete col.default
|
||||
}
|
||||
schema[row.dataset][row.table_name][colName] = col
|
||||
}
|
||||
}
|
||||
return schema
|
||||
}"#;
|
||||
|
||||
/// How long a schema introspection is awaited.
|
||||
const DB_SCHEMA_WAIT_SECS: u64 = 120;
|
||||
|
||||
async fn get_db_schema(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let path = opt_str_arg(args, "resourcePath")
|
||||
.filter(|p| !p.is_empty())
|
||||
.ok_or("Database path not provided")?;
|
||||
let resource = ctx
|
||||
.get(&ctx.ws(&format!("resources/get/{}", path_enc(path))))
|
||||
.await?;
|
||||
let resource_type = resource["resource_type"].as_str().unwrap_or_default();
|
||||
let not_found = || "Database not found".to_string();
|
||||
let (language, content, arg_name) = match resource_type {
|
||||
"postgresql" => ("postgresql", POSTGRESQL_SCHEMA_QUERY, "database"),
|
||||
"mysql" => ("mysql", MYSQL_SCHEMA_QUERY, "database"),
|
||||
"ms_sql_server" | "mssql" => ("mssql", MSSQL_SCHEMA_QUERY, "database"),
|
||||
"snowflake" | "snowflake_oauth" => ("snowflake", SNOWFLAKE_SCHEMA_QUERY, "database"),
|
||||
"bigquery" => ("bun", BIGQUERY_SCHEMA_SCRIPT, "args"),
|
||||
_ => return Err(not_found()),
|
||||
};
|
||||
let id = run_script_preview(
|
||||
ctx,
|
||||
None,
|
||||
language,
|
||||
content,
|
||||
json!({ arg_name: format!("$res:{path}") }),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| not_found())?;
|
||||
let job = wait_job(ctx, &id, DB_SCHEMA_WAIT_SECS, false)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"The schema query did not finish within {DB_SCHEMA_WAIT_SECS} seconds (job {id})"
|
||||
)
|
||||
})?;
|
||||
if job["success"] != true {
|
||||
return Err(not_found());
|
||||
}
|
||||
let mut stringified =
|
||||
stringify_db_schema(resource_type, &job["result"]).ok_or_else(not_found)?;
|
||||
if stringified.chars().count() > MAX_SCHEMA_LENGTH {
|
||||
stringified = stringified
|
||||
.chars()
|
||||
.take(MAX_SCHEMA_LENGTH)
|
||||
.collect::<String>()
|
||||
+ "...";
|
||||
}
|
||||
Ok(format!(
|
||||
"DATABASE SCHEMA (each column is in the format [name, type, required, default?]):\n{stringified}"
|
||||
))
|
||||
}
|
||||
|
||||
/// The object under `key`, created empty where a JavaScript object would first get it.
|
||||
fn slot(map: &mut Map<String, Value>, key: String) -> &mut Map<String, Value> {
|
||||
map.entry(key)
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.expect("only objects are inserted")
|
||||
}
|
||||
|
||||
/// The introspection result as `{schema: {table: [[name, type, required, default?]]}}`,
|
||||
/// narrowed to the default schema's tables when the database has one.
|
||||
fn stringify_db_schema(resource_type: &str, result: &Value) -> Option<String> {
|
||||
let mut schemas: Map<String, Value> = Map::new();
|
||||
let column = |name: String, ty: &Value, required: Value, default: Option<&Value>| {
|
||||
let mut col = vec![Value::String(name), ty.clone(), required];
|
||||
if truthy(default) {
|
||||
col.extend(default.cloned());
|
||||
}
|
||||
Value::Array(col)
|
||||
};
|
||||
if resource_type == "bigquery" {
|
||||
for (dataset, tables) in result.as_object()? {
|
||||
let dataset = slot(&mut schemas, dataset.clone());
|
||||
for (table, columns) in tables.as_object()? {
|
||||
let table = slot(dataset, table.clone());
|
||||
for (name, col) in columns.as_object()? {
|
||||
let entry = column(
|
||||
name.clone(),
|
||||
&col["type"],
|
||||
col["required"].clone(),
|
||||
col.get("default"),
|
||||
);
|
||||
table.insert(name.clone(), entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for row in result.as_array()? {
|
||||
let (schema, table, name, ty, required, default) = match resource_type {
|
||||
"postgresql" => (
|
||||
"table_schema",
|
||||
"table_name",
|
||||
"column_name",
|
||||
"udt_name",
|
||||
row["is_nullable"] == "NO",
|
||||
row.get("column_default"),
|
||||
),
|
||||
// `required` reads IS_NULLABLE the way the session does: "YES" is true.
|
||||
"snowflake" | "snowflake_oauth" => (
|
||||
"TABLE_SCHEMA",
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"DATA_TYPE",
|
||||
row["IS_NULLABLE"] == "YES",
|
||||
row.get("COLUMN_DEFAULT"),
|
||||
),
|
||||
// These queries select neither nullability nor a default the session reads.
|
||||
_ => (
|
||||
"TABLE_SCHEMA",
|
||||
"TABLE_NAME",
|
||||
"COLUMN_NAME",
|
||||
"DATA_TYPE",
|
||||
false,
|
||||
None,
|
||||
),
|
||||
};
|
||||
let schema = slot(&mut schemas, js_str(&row[schema]));
|
||||
// A schema with no table comes back as one row of nulls.
|
||||
if resource_type == "postgresql" && !truthy(row.get(table)) && !truthy(row.get(name)) {
|
||||
continue;
|
||||
}
|
||||
let name = js_str(&row[name]);
|
||||
let entry = column(name.clone(), &row[ty], json!(required), default);
|
||||
slot(schema, js_str(&row[table])).insert(name, entry);
|
||||
}
|
||||
}
|
||||
|
||||
for table in schemas.values_mut().filter_map(Value::as_object_mut) {
|
||||
for columns in table.values_mut() {
|
||||
if let Some(by_name) = columns.as_object_mut() {
|
||||
*columns = Value::Array(
|
||||
std::mem::take(by_name)
|
||||
.into_iter()
|
||||
.map(|(_, c)| c)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let default_schema = ["public", "PUBLIC", "dbo"]
|
||||
.into_iter()
|
||||
.find(|k| schemas.contains_key(*k))
|
||||
.or_else(|| {
|
||||
(resource_type == "mysql" && schemas.len() == 1)
|
||||
.then(|| schemas.keys().next().map(String::as_str))
|
||||
.flatten()
|
||||
})
|
||||
.map(str::to_string);
|
||||
Some(match default_schema {
|
||||
Some(key) => schemas[&key].to_string(),
|
||||
None => Value::Object(schemas).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn postgres_schema_is_compact_and_narrowed_to_public() {
|
||||
let rows = json!([
|
||||
{ "table_schema": "empty", "table_name": null, "column_name": null },
|
||||
{ "table_schema": "public", "table_name": "t", "column_name": "id", "udt_name": "int4", "is_nullable": "NO", "column_default": "nextval('s')" },
|
||||
{ "table_schema": "public", "table_name": "t", "column_name": "name", "udt_name": "text", "is_nullable": "YES", "column_default": null },
|
||||
{ "table_schema": "other", "table_name": "u", "column_name": "x", "udt_name": "bool", "is_nullable": "YES", "column_default": "" },
|
||||
]);
|
||||
assert_eq!(
|
||||
stringify_db_schema("postgresql", &rows).unwrap(),
|
||||
r#"{"t":[["id","int4",true,"nextval('s')"],["name","text",false]]}"#
|
||||
);
|
||||
let rows = json!([rows[0], rows[3]]);
|
||||
assert_eq!(
|
||||
stringify_db_schema("postgresql", &rows).unwrap(),
|
||||
r#"{"empty":{},"other":{"u":[["x","bool",false]]}}"#
|
||||
);
|
||||
assert_eq!(stringify_db_schema("postgresql", &json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_role_replaces_a_leading_annotation_only() {
|
||||
assert_eq!(with_migration_role("BEGIN;\nEND;", None), "BEGIN;\nEND;");
|
||||
assert_eq!(
|
||||
with_migration_role(
|
||||
"\n-- note\n--ROLE: old;\n-- roles kept\nBEGIN;\n-- role below\nEND;",
|
||||
Some("app_rw")
|
||||
),
|
||||
"-- role app_rw\n-- note\n-- roles kept\nBEGIN;\n-- role below\nEND;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_scope_is_read_from_the_leading_block_and_overlaps() {
|
||||
let sql = "-- role r\n--SCHEMAS: Sales, billing, sales;\nBEGIN;\n-- schemas other\nEND;";
|
||||
let scope = declared_schemas(sql).unwrap();
|
||||
assert_eq!(scope, ["sales", "billing"]);
|
||||
assert_eq!(declared_schemas("BEGIN;\n-- schemas a\nEND;"), None);
|
||||
assert_eq!(declared_schemas("-- schemas a b\nBEGIN;"), None);
|
||||
assert_eq!(declared_schemas("-- schemasx a\nBEGIN;"), None);
|
||||
assert_eq!(
|
||||
with_annotation(
|
||||
&with_migration_role(sql, Some("w")),
|
||||
"schemas",
|
||||
Some("-- schemas sales".into())
|
||||
),
|
||||
"-- schemas sales\n-- role w\nBEGIN;\n-- schemas other\nEND;"
|
||||
);
|
||||
assert_eq!(
|
||||
with_annotation(sql, "schemas", None),
|
||||
"-- role r\nBEGIN;\n-- schemas other\nEND;"
|
||||
);
|
||||
|
||||
assert!(scopes_overlap(&json!(["billing", "x"]), Some(&scope)));
|
||||
assert!(!scopes_overlap(&json!(["x"]), Some(&scope)));
|
||||
assert!(scopes_overlap(&Value::Null, Some(&scope)));
|
||||
assert!(scopes_overlap(&json!(["x"]), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_completion_caps_rows_and_rewrites_the_missing_datatable_error() {
|
||||
let rows: Vec<Value> = (0..101).map(|i| json!({ "i": i })).collect();
|
||||
let out: Value = serde_json::from_str(&format_datatable_sql_completion(
|
||||
&json!({ "success": true, "result": rows }),
|
||||
"main",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(out["rowCount"], 101);
|
||||
assert_eq!(out["result"].as_array().unwrap().len(), 100);
|
||||
assert_eq!(out["note"], "Showing first 100 of 101 rows");
|
||||
|
||||
assert_eq!(
|
||||
format_datatable_sql_completion(&json!({ "success": true, "result": [] }), "main"),
|
||||
"{\n \"success\": true,\n \"rowCount\": 0,\n \"result\": []\n}"
|
||||
);
|
||||
assert_eq!(
|
||||
format_datatable_sql_completion(
|
||||
&json!({ "success": false, "result": { "error": { "message": "boom" } } }),
|
||||
"main"
|
||||
),
|
||||
r#"{"success":false,"error":"boom"}"#
|
||||
);
|
||||
let missing = format_datatable_sql_completion(
|
||||
&json!({ "success": false, "result": { "error": { "message": "datatable main not found" } } }),
|
||||
"main",
|
||||
);
|
||||
assert!(missing.contains("Datatable \\\"main\\\" is not configured"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
//! `get_instructions` and `read_skill`: guidance the model pulls on demand.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{opt_str_arg, str_arg, Ctx, ToolResult};
|
||||
|
||||
pub(super) const TOOLS: &[&str] = &["get_instructions", "read_skill"];
|
||||
|
||||
pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult {
|
||||
match name {
|
||||
"get_instructions" => get_instructions(ctx, args),
|
||||
"read_skill" => read_skill(ctx, args).await,
|
||||
_ => unreachable!("{name} is not a guidance tool"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each subject's text per language, as the session renders it; `""` is the text for
|
||||
/// a call that names no language.
|
||||
#[derive(Deserialize)]
|
||||
struct Instructions {
|
||||
index: HashMap<String, HashMap<String, String>>,
|
||||
texts: HashMap<String, String>,
|
||||
}
|
||||
|
||||
static INSTRUCTIONS: LazyLock<Instructions> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("gen/instructions.json"))
|
||||
.expect("gen/instructions.json is valid")
|
||||
});
|
||||
|
||||
fn get_instructions(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let subject = str_arg(args, "subject")?;
|
||||
if subject == "workspace_settings" && !ctx.auth.is_admin {
|
||||
return Ok("Only a workspace admin can read or change the workspace settings. Tell the user plainly that you cannot, and suggest asking a workspace admin.".to_string());
|
||||
}
|
||||
let by_language = INSTRUCTIONS
|
||||
.index
|
||||
.get(subject)
|
||||
.ok_or_else(|| format!("Unknown subject `{subject}`"))?;
|
||||
let language = opt_str_arg(args, "language").unwrap_or("");
|
||||
let id = by_language
|
||||
.get(language)
|
||||
.ok_or_else(|| format!("Unknown language `{language}`"))?;
|
||||
Ok(INSTRUCTIONS.texts[id].clone())
|
||||
}
|
||||
|
||||
const SKILL_RESOURCE_TYPE: &str = "ai_skill";
|
||||
const SKILLS_PAGE_SIZE: usize = 100;
|
||||
/// Guard against a paging bug looping forever, not a product cap.
|
||||
const MAX_SKILLS_PAGES: usize = 100;
|
||||
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1024;
|
||||
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
|
||||
const TRUNCATED: &str = "… [truncated]";
|
||||
|
||||
pub(super) struct Skill {
|
||||
pub path: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Every skill the caller can read. The session lists only the ones its user left
|
||||
/// switched on, a preference kept in the browser; here they are all in play.
|
||||
pub(super) async fn list_skills(ctx: &Ctx<'_>) -> Result<Vec<Skill>, String> {
|
||||
let mut skills = Vec::new();
|
||||
for page in 1..=MAX_SKILLS_PAGES {
|
||||
let rows = ctx
|
||||
.get(&ctx.ws(&format!(
|
||||
"resources/list?resource_type={SKILL_RESOURCE_TYPE}&page={page}&per_page={SKILLS_PAGE_SIZE}"
|
||||
)))
|
||||
.await?;
|
||||
let rows = rows.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
for row in rows {
|
||||
let Some(path) = row.get("path").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let description = row
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = if description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
|
||||
let cut: String = description
|
||||
.chars()
|
||||
.take(MAX_SKILL_DESCRIPTION_CHARS)
|
||||
.collect();
|
||||
format!("{cut}{TRUNCATED}")
|
||||
} else {
|
||||
description.to_string()
|
||||
};
|
||||
skills.push(Skill { path: path.to_string(), description });
|
||||
}
|
||||
if rows.len() < SKILLS_PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
async fn read_skill(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let path = str_arg(args, "path")?;
|
||||
// Checked against the listing: the path is the model's, and without this the tool
|
||||
// reads any resource holding a string `content`.
|
||||
let listed = match list_skills(ctx).await {
|
||||
Ok(skills) => skills.iter().any(|skill| skill.path == path),
|
||||
Err(e) => return Ok(read_failed(path, &e)),
|
||||
};
|
||||
if !listed {
|
||||
return Ok(format!(
|
||||
"\"{path}\" is not one of the skills available here. Only the paths listed under \"Skills\" in the instructions can be read."
|
||||
));
|
||||
}
|
||||
let value = match ctx
|
||||
.get(&ctx.ws(&format!("resources/get_value/{path}")))
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(e) => return Ok(read_failed(path, &e)),
|
||||
};
|
||||
let Some(body) = value.get("content").and_then(Value::as_str) else {
|
||||
return Ok(read_failed(
|
||||
path,
|
||||
&format!("resource {path} has no string \"content\" — is it an {SKILL_RESOURCE_TYPE}?"),
|
||||
));
|
||||
};
|
||||
let body = if body.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
|
||||
let mut end = MAX_SKILL_INSTRUCTIONS_BYTES;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}{TRUNCATED}", &body[..end])
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
Ok(format!("Skill: {path}\n\nInstructions:\n{body}"))
|
||||
}
|
||||
|
||||
fn read_failed(path: &str, message: &str) -> String {
|
||||
format!(
|
||||
"Failed to read skill \"{path}\": {message}. Check the path against the Skills list in the instructions."
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! The instructions a client receives on connecting: the session's system prompt as
|
||||
//! rendered for an external client (`gen/prompt.json`), filled in for the caller. What
|
||||
//! differs per workspace is cut out of it, so a connection spanning several workspaces
|
||||
//! gets the rest up front and each workspace's part from `get_workspace_context`.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
use windmill_mcp::server::McpAuth;
|
||||
|
||||
use super::{guidance::list_skills, Ctx};
|
||||
|
||||
/// The prompt with a `{{folder_guidance}}` slot, the guidance once per folder situation,
|
||||
/// and the sections appended when the workspace has something to put there.
|
||||
#[derive(Deserialize)]
|
||||
struct PromptParts {
|
||||
base: String,
|
||||
folder_guidance: FolderGuidance,
|
||||
skills_header: String,
|
||||
skill_line: String,
|
||||
workspace_instructions: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FolderGuidance {
|
||||
admin: String,
|
||||
admin_no_folders: String,
|
||||
member: String,
|
||||
member_no_folders: String,
|
||||
member_readonly: String,
|
||||
member_no_folders_readonly: String,
|
||||
}
|
||||
|
||||
static PARTS: LazyLock<PromptParts> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("gen/prompt.json")).expect("gen/prompt.json is valid")
|
||||
});
|
||||
|
||||
/// Folders named in the prompt before the rest are counted rather than listed, as the
|
||||
/// session does, so a folder-heavy workspace cannot dominate the instructions.
|
||||
const MAX_LISTED_FOLDERS: usize = 40;
|
||||
|
||||
fn folder_list(names: &[&str]) -> String {
|
||||
let shown = names
|
||||
.iter()
|
||||
.take(MAX_LISTED_FOLDERS)
|
||||
.map(|n| format!("`f/{n}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if names.len() > MAX_LISTED_FOLDERS {
|
||||
format!("{shown} (+{} more)", names.len() - MAX_LISTED_FOLDERS)
|
||||
} else {
|
||||
shown
|
||||
}
|
||||
}
|
||||
|
||||
fn folder_guidance(auth: &crate::db::ApiAuthed) -> String {
|
||||
let writable: Vec<&str> = auth
|
||||
.folders()
|
||||
.iter()
|
||||
.filter(|(_, write, _)| *write)
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
.collect();
|
||||
let readonly: Vec<&str> = auth
|
||||
.folders()
|
||||
.iter()
|
||||
.filter(|(_, write, _)| !*write)
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
.collect();
|
||||
|
||||
let guidance = &PARTS.folder_guidance;
|
||||
let template = match (auth.is_admin(), writable.is_empty(), readonly.is_empty()) {
|
||||
(true, false, _) => &guidance.admin,
|
||||
(true, true, _) => &guidance.admin_no_folders,
|
||||
(false, false, true) => &guidance.member,
|
||||
(false, false, false) => &guidance.member_readonly,
|
||||
(false, true, true) => &guidance.member_no_folders,
|
||||
(false, true, false) => &guidance.member_no_folders_readonly,
|
||||
};
|
||||
template
|
||||
.replace("{{username}}", auth.username())
|
||||
.replace("{{writable_folders}}", &folder_list(&writable))
|
||||
.replace("{{readonly_folders}}", &folder_list(&readonly))
|
||||
}
|
||||
|
||||
/// The workspace's skills and its admin-written instructions.
|
||||
async fn appendix(ctx: &Ctx<'_>) -> String {
|
||||
let mut text = String::new();
|
||||
// Either lookup failing leaves its section out: a client that cannot connect at all
|
||||
// is worse than one that connects without the skills list.
|
||||
if let Ok(skills) = list_skills(ctx).await {
|
||||
if !skills.is_empty() {
|
||||
text.push_str(&PARTS.skills_header);
|
||||
let lines = skills
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
PARTS
|
||||
.skill_line
|
||||
.replace("{{skill_path}}", &skill.path)
|
||||
.replace("{{skill_description}}", &skill.description)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
text.push_str(&lines);
|
||||
}
|
||||
}
|
||||
if let Ok(info) = ctx.get(&ctx.ws("workspaces/get_copilot_info")).await {
|
||||
let workspace = info
|
||||
.pointer("/custom_prompts/global")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(workspace) = workspace {
|
||||
text.push_str(
|
||||
&PARTS
|
||||
.workspace_instructions
|
||||
.replace("{{workspace_instructions}}", workspace),
|
||||
);
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The whole prompt, for a connection that reaches one workspace.
|
||||
pub(in crate::mcp) async fn session_instructions(ctx: &Ctx<'_>) -> String {
|
||||
let mut prompt = PARTS
|
||||
.base
|
||||
.replace("{{folder_guidance}}", &folder_guidance(ctx.auth))
|
||||
.replace("{{username}}", ctx.auth.username());
|
||||
prompt.push_str(&appendix(ctx).await);
|
||||
prompt
|
||||
}
|
||||
|
||||
/// The prompt without anything specific to a workspace, for a connection that reaches
|
||||
/// several: usernames are per workspace, so paths read `u/<username>/...`.
|
||||
pub(in crate::mcp) fn generic_session_instructions() -> String {
|
||||
PARTS
|
||||
.base
|
||||
.replace("{{folder_guidance}}", "")
|
||||
.replace("{{username}}", "<username>")
|
||||
}
|
||||
|
||||
/// What `generic_session_instructions` leaves out, for one workspace.
|
||||
pub(in crate::mcp) async fn workspace_context(ctx: &Ctx<'_>) -> String {
|
||||
let username = ctx.auth.username();
|
||||
format!(
|
||||
"Workspace `{}`. Your username here is \"{username}\", so your personal paths are \
|
||||
`u/{username}/<name>`.\n{}{}",
|
||||
ctx.w_id,
|
||||
folder_guidance(ctx.auth).trim_end(),
|
||||
appendix(ctx).await
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
//! Draft writes for the item kinds that are plain records: schedules, triggers, resources, variables.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::workspace::{finish_draft_write, persist_draft, write_base, Persist};
|
||||
use super::{non_null, put, tool_parameters, Ctx, ToolResult, SCHEMAS};
|
||||
|
||||
pub(super) const TOOLS: &[&str] = &[
|
||||
"write_schedule",
|
||||
"write_trigger",
|
||||
"write_resource",
|
||||
"write_variable",
|
||||
];
|
||||
|
||||
pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult {
|
||||
match name {
|
||||
"write_schedule" => write_schedule(ctx, args).await,
|
||||
"write_trigger" => write_trigger(ctx, args).await,
|
||||
"write_resource" => write_resource(ctx, args).await,
|
||||
"write_variable" => write_variable(ctx, args).await,
|
||||
_ => unreachable!("{name} is not an items tool"),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Argument parsing. The session parses arguments with zod schemas; the same schemas
|
||||
// reach this server as JSON Schema (`gen/tools.json`, `gen/schemas.json`), and `parse`
|
||||
// applies them with zod's semantics rather than JSON Schema's: an undeclared key is
|
||||
// dropped instead of refused, a `default` fills a missing key, a union yields the
|
||||
// first branch that accepts the value.
|
||||
// ============================================================================
|
||||
|
||||
fn tool_schema(name: &str) -> Value {
|
||||
tool_parameters(name)
|
||||
.map(|parameters| Value::Object(parameters.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A schedule's whole schema: the fields `write_schedule` lists plus the ones it takes
|
||||
/// through `advanced`, which the session spreads to the top level before parsing.
|
||||
static SCHEDULE_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
|
||||
let mut schema = tool_schema("write_schedule");
|
||||
let advanced: Value = serde_json::from_str(&SCHEMAS.schedule).unwrap_or_default();
|
||||
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
|
||||
properties.remove("advanced");
|
||||
if let Some(advanced) = advanced.get("properties").and_then(Value::as_object) {
|
||||
properties.extend(advanced.clone());
|
||||
}
|
||||
}
|
||||
schema
|
||||
});
|
||||
|
||||
fn received(value: Option<&Value>) -> &'static str {
|
||||
match value {
|
||||
None => "undefined",
|
||||
Some(Value::Null) => "null",
|
||||
Some(Value::Bool(_)) => "boolean",
|
||||
Some(Value::Number(_)) => "number",
|
||||
Some(Value::String(_)) => "string",
|
||||
Some(Value::Array(_)) => "array",
|
||||
Some(Value::Object(_)) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn join(path: &str, key: &str) -> String {
|
||||
if path.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{path}.{key}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` as `schema` accepts it, `None` standing for a missing one. Each violation is
|
||||
/// pushed to `issues` as `<dotted path>: <message>`.
|
||||
fn parse(
|
||||
schema: &Value,
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
issues: &mut Vec<String>,
|
||||
) -> Option<Value> {
|
||||
let mut issue = |message: String| {
|
||||
let at = if path.is_empty() { "<root>" } else { path };
|
||||
issues.push(format!("{at}: {message}"));
|
||||
None
|
||||
};
|
||||
if value.is_none() {
|
||||
if let Some(default) = schema.get("default") {
|
||||
return Some(default.clone());
|
||||
}
|
||||
}
|
||||
if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) {
|
||||
let is_null = |branch: &&Value| branch.get("type").and_then(Value::as_str) == Some("null");
|
||||
if value.is_some_and(Value::is_null) && branches.iter().any(|b| is_null(&b)) {
|
||||
return Some(Value::Null);
|
||||
}
|
||||
let branches: Vec<&Value> = branches.iter().filter(|b| !is_null(b)).collect();
|
||||
// A nullable field reports what its one real type refuses.
|
||||
if let [only] = branches[..] {
|
||||
return parse(only, value, path, issues);
|
||||
}
|
||||
for branch in branches {
|
||||
let mut refused = Vec::new();
|
||||
let parsed = parse(branch, value, path, &mut refused);
|
||||
if refused.is_empty() {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return issue("Invalid input".to_string());
|
||||
}
|
||||
if let Some(options) = schema.get("enum").and_then(Value::as_array) {
|
||||
if value.is_some_and(|v| options.contains(v)) {
|
||||
return value.cloned();
|
||||
}
|
||||
let options: Vec<String> = options.iter().map(Value::to_string).collect();
|
||||
return issue(format!(
|
||||
"Invalid option: expected one of {}",
|
||||
options.join("|")
|
||||
));
|
||||
}
|
||||
let Some(kind) = schema.get("type").and_then(Value::as_str) else {
|
||||
return value.cloned();
|
||||
};
|
||||
match (kind, value) {
|
||||
("boolean", Some(Value::Bool(_))) | ("null", Some(Value::Null)) => value.cloned(),
|
||||
("string", Some(Value::String(text))) => {
|
||||
let matches = schema
|
||||
.get("pattern")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|pattern| regex::Regex::new(pattern).ok())
|
||||
.is_none_or(|pattern| pattern.is_match(text));
|
||||
if matches {
|
||||
value.cloned()
|
||||
} else if schema.get("format").and_then(Value::as_str) == Some("date-time") {
|
||||
issue("Invalid ISO datetime".to_string())
|
||||
} else {
|
||||
issue("Invalid string".to_string())
|
||||
}
|
||||
}
|
||||
("number" | "integer", Some(Value::Number(number))) => {
|
||||
let n = number.as_f64().unwrap_or_default();
|
||||
let bound = |key: &str| schema.get(key).and_then(Value::as_f64);
|
||||
if kind == "integer" && n.fract() != 0.0 {
|
||||
issue("Invalid input: expected int, received number".to_string())
|
||||
} else if let Some(min) = bound("minimum").filter(|min| n < *min) {
|
||||
issue(format!("Too small: expected number to be >={min}"))
|
||||
} else if let Some(max) = bound("maximum").filter(|max| n > *max) {
|
||||
issue(format!("Too big: expected number to be <={max}"))
|
||||
} else {
|
||||
value.cloned()
|
||||
}
|
||||
}
|
||||
("array", Some(Value::Array(items))) => {
|
||||
let any = json!({});
|
||||
let item_schema = schema.get("items").unwrap_or(&any);
|
||||
Some(Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, item)| {
|
||||
parse(item_schema, Some(item), &join(path, &i.to_string()), issues)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
("object", Some(Value::Object(fields))) => {
|
||||
let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
|
||||
return value.cloned();
|
||||
};
|
||||
let required = schema.get("required").and_then(Value::as_array);
|
||||
let mut out = Map::new();
|
||||
for (key, property) in properties {
|
||||
let field = fields.get(key);
|
||||
let optional = !required.is_some_and(|r| r.iter().any(|k| k == key));
|
||||
if field.is_none() && optional && property.get("default").is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(parsed) = parse(property, field, &join(path, key), issues) {
|
||||
out.insert(key.clone(), parsed);
|
||||
}
|
||||
}
|
||||
if schema.get("additionalProperties") != Some(&Value::Bool(false)) {
|
||||
for (key, field) in fields {
|
||||
if !properties.contains_key(key) {
|
||||
out.insert(key.clone(), field.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Value::Object(out))
|
||||
}
|
||||
_ => {
|
||||
let expected = match kind {
|
||||
"integer" => "number",
|
||||
"object" if schema.get("properties").is_none() => "record",
|
||||
other => other,
|
||||
};
|
||||
issue(format!(
|
||||
"Invalid input: expected {expected}, received {}",
|
||||
received(value)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args(tool: &str, args: &Value) -> Result<Value, String> {
|
||||
parse_with(&tool_schema(tool), args)
|
||||
}
|
||||
|
||||
fn parse_with(schema: &Value, args: &Value) -> Result<Value, String> {
|
||||
let mut issues = Vec::new();
|
||||
let parsed = parse(schema, Some(args), "", &mut issues);
|
||||
match parsed {
|
||||
Some(parsed) if issues.is_empty() => Ok(parsed),
|
||||
_ => Err(format!("Invalid arguments. Issues: {}", issues.join("; "))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Key paths `supplied` carries that parsing dropped. Every sub-field of a schedule's
|
||||
/// `retry` is optional, so a guessed shape parses clean, loses its misspelled keys and
|
||||
/// would save a policy that does nothing.
|
||||
fn dropped_keys(supplied: &Value, parsed: Option<&Value>, prefix: &str, out: &mut Vec<String>) {
|
||||
let Value::Object(supplied) = supplied else {
|
||||
if parsed.is_none() && !prefix.is_empty() {
|
||||
out.push(prefix.to_string());
|
||||
}
|
||||
return;
|
||||
};
|
||||
match parsed {
|
||||
Some(parsed @ (Value::Object(_) | Value::Array(_))) => {
|
||||
for (key, value) in supplied {
|
||||
dropped_keys(value, parsed.get(key), &join(prefix, key), out);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !supplied.is_empty() && !prefix.is_empty() {
|
||||
out.push(prefix.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_dropped_schedule_options(dropped: &[String]) -> String {
|
||||
let is_field = |path: &&String| {
|
||||
let field = path.split('.').next().unwrap_or_default();
|
||||
field != "override" && SCHEDULE_SCHEMA["properties"].get(field).is_some()
|
||||
};
|
||||
let (misshaped, unknown): (Vec<&String>, Vec<&String>) = dropped.iter().partition(is_field);
|
||||
let list = |paths: &[&String]| {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| p.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
if !misshaped.is_empty() {
|
||||
parts.push(format!("These schedule options do not match their schema and would have been dropped: {}. Call get_schedule_schema for their exact shape.", list(&misshaped)));
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
parts.push(format!(
|
||||
"These are not schedule fields: {}.",
|
||||
list(&unknown)
|
||||
));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Draft values
|
||||
// ============================================================================
|
||||
|
||||
fn into_object(value: Option<Value>) -> Map<String, Value> {
|
||||
match value {
|
||||
Some(Value::Object(map)) => map,
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A schedule's or trigger's draft: the base without what the server stamps on a
|
||||
/// deployed row, then the fields of this write.
|
||||
fn merge_config(base: Option<Value>, config: Value, path: &str) -> Map<String, Value> {
|
||||
let mut draft = into_object(base);
|
||||
for key in ["workspace_id", "edited_by", "edited_at", "email", "error"] {
|
||||
draft.remove(key);
|
||||
}
|
||||
draft.extend(into_object(Some(config)));
|
||||
draft.insert("path".into(), json!(path));
|
||||
draft
|
||||
}
|
||||
|
||||
fn resource_to_draft_state(resource: &Value) -> Value {
|
||||
let mut state = Map::new();
|
||||
state.insert("path".into(), resource["path"].clone());
|
||||
state.insert(
|
||||
"description".into(),
|
||||
non_null(resource.get("description"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!("")),
|
||||
);
|
||||
state.insert(
|
||||
"args".into(),
|
||||
non_null(resource.get("value"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
put(
|
||||
&mut state,
|
||||
"labels",
|
||||
non_null(resource.get("labels")).cloned(),
|
||||
);
|
||||
state.insert(
|
||||
"wsSpecific".into(),
|
||||
non_null(resource.get("ws_specific"))
|
||||
.cloned()
|
||||
.unwrap_or(json!(false)),
|
||||
);
|
||||
state.insert("resource_type".into(), resource["resource_type"].clone());
|
||||
Value::Object(state)
|
||||
}
|
||||
|
||||
fn resource_draft(args: &Value, base: Option<Value>) -> Value {
|
||||
let mut draft = into_object(base);
|
||||
let merged = |arg: &str, key: &str| {
|
||||
non_null(args.get(arg))
|
||||
.or(non_null(draft.get(key)))
|
||||
.cloned()
|
||||
};
|
||||
let description = merged("description", "description").unwrap_or_else(|| json!(""));
|
||||
let value = merged("value", "args").unwrap_or_else(|| json!({}));
|
||||
let labels = merged("labels", "labels");
|
||||
let ws_specific = merged("ws_specific", "wsSpecific").unwrap_or(json!(false));
|
||||
let resource_type = merged("resource_type", "resource_type");
|
||||
draft.insert("path".into(), args["path"].clone());
|
||||
draft.insert("description".into(), description);
|
||||
draft.insert("args".into(), value);
|
||||
put(&mut draft, "labels", labels);
|
||||
draft.insert("wsSpecific".into(), ws_specific);
|
||||
put(&mut draft, "resource_type", resource_type);
|
||||
Value::Object(draft)
|
||||
}
|
||||
|
||||
fn variable_to_draft_state(variable: &Value) -> Value {
|
||||
let field = |key: &str| non_null(variable.get(key)).cloned();
|
||||
// An OAuth variable's value belongs to the refresh flow, and the API hands back a
|
||||
// freshly refreshed live token for an expired one even undecrypted: carried into a
|
||||
// draft, it would pin a rotating value to a static one at the next deploy.
|
||||
let oauth_managed = variable.get("is_oauth").and_then(Value::as_bool) == Some(true)
|
||||
|| field("account").is_some();
|
||||
let value = if oauth_managed { None } else { field("value") };
|
||||
let mut state = Map::new();
|
||||
state.insert("path".into(), variable["path"].clone());
|
||||
state.insert(
|
||||
"variable".into(),
|
||||
json!({
|
||||
"value": value.unwrap_or_else(|| json!("")),
|
||||
"is_secret": variable["is_secret"],
|
||||
"description": field("description").unwrap_or_else(|| json!("")),
|
||||
}),
|
||||
);
|
||||
put(&mut state, "labels", field("labels"));
|
||||
state.insert(
|
||||
"wsSpecific".into(),
|
||||
field("ws_specific").unwrap_or(json!(false)),
|
||||
);
|
||||
for key in ["account", "is_oauth", "expires_at"] {
|
||||
put(&mut state, key, field(key));
|
||||
}
|
||||
Value::Object(state)
|
||||
}
|
||||
|
||||
/// The draft a `write_variable` call leaves. A variable's value is never read back, so
|
||||
/// an omitted `value` keeps whatever the base holds: `""` for "nothing staged", or the
|
||||
/// `$encrypted:` marker the draft store turned a staged secret into.
|
||||
fn variable_draft(args: &Value, base: Option<Value>) -> Result<Value, String> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
let arg = |key: &str| non_null(args.get(key)).cloned();
|
||||
let stored = |key: &str| {
|
||||
base.as_ref()
|
||||
.and_then(|b| b.get("variable"))
|
||||
.and_then(|v| non_null(v.get(key)).cloned())
|
||||
};
|
||||
let value = arg("value");
|
||||
if base.is_none() && (value.is_none() || arg("is_secret").is_none()) {
|
||||
return Err(format!("Variable \"{path}\" does not exist yet, so creating it requires both value and is_secret."));
|
||||
}
|
||||
let was_secret = stored("is_secret").and_then(|s| s.as_bool());
|
||||
let is_secret = arg("is_secret")
|
||||
.and_then(|s| s.as_bool())
|
||||
.or(was_secret)
|
||||
.unwrap_or(false);
|
||||
let empty = json!("");
|
||||
// "" is how a secret draft says "nothing staged", so it cannot also mean "set the
|
||||
// secret to empty".
|
||||
if is_secret && value.as_ref() == Some(&empty) {
|
||||
return Err(format!("An empty string is not a valid value for secret variable \"{path}\". Omit value to keep the stored secret, or pass the real new one."));
|
||||
}
|
||||
let next_value = value.clone().or_else(|| stored("value"));
|
||||
if is_secret && was_secret == Some(false) && next_value.as_ref() == Some(&empty) {
|
||||
return Err(format!("Cannot make variable \"{path}\" secret without a value: it currently holds an empty one, so there would be nothing to encrypt. Pass the value it should hold."));
|
||||
}
|
||||
// The stored value of a secret cannot be read, and an `$encrypted:` marker is only
|
||||
// decrypted at deploy while the target stays secret.
|
||||
if !is_secret && was_secret == Some(true) && value.is_none() {
|
||||
return Err(format!("Cannot turn secret variable \"{path}\" into a non-secret one without a value: its stored value cannot be read, so it would be replaced by an empty one. Pass the new plaintext value, or leave is_secret unset to keep it secret."));
|
||||
}
|
||||
let description = arg("description").or_else(|| stored("description"));
|
||||
|
||||
let mut draft = into_object(base);
|
||||
let merged = |arg_key: &str, key: &str| {
|
||||
non_null(args.get(arg_key))
|
||||
.or(non_null(draft.get(key)))
|
||||
.cloned()
|
||||
};
|
||||
let labels = merged("labels", "labels");
|
||||
let ws_specific = merged("ws_specific", "wsSpecific").unwrap_or(json!(false));
|
||||
let extras = ["account", "is_oauth", "expires_at"].map(|key| (key, merged(key, key)));
|
||||
draft.insert("path".into(), json!(path));
|
||||
draft.insert(
|
||||
"variable".into(),
|
||||
json!({
|
||||
"value": next_value.unwrap_or(empty),
|
||||
"is_secret": is_secret,
|
||||
"description": description.unwrap_or_else(|| json!("")),
|
||||
}),
|
||||
);
|
||||
put(&mut draft, "labels", labels);
|
||||
draft.insert("wsSpecific".into(), ws_specific);
|
||||
for (key, value) in extras {
|
||||
put(&mut draft, key, value);
|
||||
}
|
||||
Ok(Value::Object(draft))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tools
|
||||
// ============================================================================
|
||||
|
||||
/// The shared write: build on the caller's draft, else on the deployed item (shaped
|
||||
/// into a draft value by `from_deployed`), else from nothing.
|
||||
async fn write_draft(
|
||||
ctx: &Ctx<'_>,
|
||||
item_type: &str,
|
||||
trigger_kind: Option<&str>,
|
||||
path: &str,
|
||||
force: bool,
|
||||
from_deployed: impl FnOnce(Value) -> Value,
|
||||
build: impl FnOnce(Option<Value>) -> Result<Value, String>,
|
||||
) -> ToolResult {
|
||||
let base = write_base(ctx, item_type, path, trigger_kind).await?;
|
||||
let existed = base.base.is_some();
|
||||
let from_draft = base.from_draft;
|
||||
let draft = build(
|
||||
base.base
|
||||
.map(|b| if from_draft { b } else { from_deployed(b) }),
|
||||
)?;
|
||||
let persisted = persist_draft(
|
||||
ctx,
|
||||
item_type,
|
||||
path,
|
||||
trigger_kind,
|
||||
&draft,
|
||||
base.last_sync.as_deref(),
|
||||
force,
|
||||
)
|
||||
.await?;
|
||||
Ok(match persisted {
|
||||
Persist::Saved(item) => finish_draft_write(ctx, &item, existed),
|
||||
Persist::Failed(text) => text,
|
||||
})
|
||||
}
|
||||
|
||||
fn take_override(parsed: &mut Value) -> bool {
|
||||
parsed
|
||||
.as_object_mut()
|
||||
.and_then(|o| o.remove("override"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn write_schedule(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
// A named argument outranks the same key inside `advanced`.
|
||||
let mut merged = into_object(args.get("advanced").cloned());
|
||||
for (key, value) in args.as_object().into_iter().flatten() {
|
||||
if key != "advanced" {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
let merged = Value::Object(merged);
|
||||
let mut parsed = parse_with(&SCHEDULE_SCHEMA, &merged)?;
|
||||
let mut dropped = Vec::new();
|
||||
dropped_keys(&merged, Some(&parsed), "", &mut dropped);
|
||||
if !dropped.is_empty() {
|
||||
return Err(describe_dropped_schedule_options(&dropped));
|
||||
}
|
||||
let force = take_override(&mut parsed);
|
||||
let path = parsed["path"].as_str().unwrap_or_default().to_string();
|
||||
write_draft(
|
||||
ctx,
|
||||
"schedule",
|
||||
None,
|
||||
&path,
|
||||
force,
|
||||
|deployed| deployed,
|
||||
|base| Ok(Value::Object(merge_config(base, parsed, &path))),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_trigger(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let mut parsed = parse_args("write_trigger", args)?;
|
||||
let force = take_override(&mut parsed);
|
||||
let kind = parsed["kind"].as_str().unwrap_or_default();
|
||||
let schema: Value = SCHEMAS
|
||||
.trigger
|
||||
.get(kind)
|
||||
.and_then(|schema| serde_json::from_str(schema).ok())
|
||||
.ok_or_else(|| format!("Unknown trigger kind `{kind}`"))?;
|
||||
// The draft is filed under `kind`, so a config of another kind is refused here
|
||||
// rather than stored as a draft no editor or deploy can read.
|
||||
let mut issues = Vec::new();
|
||||
let config = parse(&schema, parsed.get("config"), "", &mut issues);
|
||||
let config = match config {
|
||||
Some(config) if issues.is_empty() => config,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid config for a \"{kind}\" trigger. Call get_trigger_schema with kind \"{kind}\" for its exact fields. Issues: {}",
|
||||
issues.join("; ")
|
||||
))
|
||||
}
|
||||
};
|
||||
let path = config["path"].as_str().unwrap_or_default().to_string();
|
||||
write_draft(
|
||||
ctx,
|
||||
"trigger",
|
||||
Some(kind),
|
||||
&path,
|
||||
force,
|
||||
|deployed| deployed,
|
||||
|base| {
|
||||
let mut draft = merge_config(base, config, &path);
|
||||
// Optional in the schema, but a NOT NULL column. Defaulted on the merged
|
||||
// draft so an omitted field keeps the existing trigger's value.
|
||||
if kind == "email" && non_null(draft.get("workspaced_local_part")).is_none() {
|
||||
draft.insert("workspaced_local_part".into(), json!(false));
|
||||
}
|
||||
Ok(Value::Object(draft))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_resource(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let mut parsed = parse_args("write_resource", args)?;
|
||||
let force = take_override(&mut parsed);
|
||||
let path = parsed["path"].as_str().unwrap_or_default().to_string();
|
||||
write_draft(
|
||||
ctx,
|
||||
"resource",
|
||||
None,
|
||||
&path,
|
||||
force,
|
||||
|deployed| resource_to_draft_state(&deployed),
|
||||
|base| Ok(resource_draft(&parsed, base)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_variable(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let mut parsed = parse_args("write_variable", args)?;
|
||||
let force = take_override(&mut parsed);
|
||||
let path = parsed["path"].as_str().unwrap_or_default().to_string();
|
||||
// A variable's value is never interpolated, so its own `$var:` reference is the
|
||||
// reference syntax echoed back, not a value.
|
||||
if let Some(value) = parsed.get("value").and_then(Value::as_str) {
|
||||
if value == format!("$var:{path}") {
|
||||
return Err(format!("\"{value}\" is not a valid value for variable \"{path}\" — it is a self-reference. Omit value to keep the current one."));
|
||||
}
|
||||
}
|
||||
write_draft(
|
||||
ctx,
|
||||
"variable",
|
||||
None,
|
||||
&path,
|
||||
force,
|
||||
|deployed| variable_to_draft_state(&deployed),
|
||||
|base| variable_draft(&parsed, base),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn issues_of(schema: &Value, value: Value) -> (Option<Value>, Vec<String>) {
|
||||
let mut issues = Vec::new();
|
||||
let parsed = parse(schema, Some(&value), "", &mut issues);
|
||||
(parsed, issues)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_config_parses_like_the_session() {
|
||||
let kafka: Value = serde_json::from_str(&SCHEMAS.trigger["kafka"]).unwrap();
|
||||
let (parsed, issues) = issues_of(
|
||||
&kafka,
|
||||
json!({
|
||||
"path": "p", "script_path": "s", "is_flow": false, "kafka_resource_path": "r",
|
||||
"group_id": "g", "topics": ["a"], "unknown_field": 1,
|
||||
"filters": [{"key": "k", "value": 1, "zzz": 2}, {"any_of": [{"a": 1}]}],
|
||||
}),
|
||||
);
|
||||
assert_eq!(issues, Vec::<String>::new());
|
||||
let parsed = parsed.unwrap();
|
||||
assert_eq!(parsed.get("unknown_field"), None);
|
||||
assert_eq!(
|
||||
parsed["filters"],
|
||||
json!([{"key": "k", "value": 1}, {"any_of": [{"a": 1}]}])
|
||||
);
|
||||
assert_eq!(parsed["filter_logic"], "and");
|
||||
assert_eq!(parsed["auto_commit"], true);
|
||||
|
||||
let (_, issues) = issues_of(
|
||||
&kafka,
|
||||
json!({
|
||||
"path": 5, "is_flow": "x", "topics": "a", "filters": [3], "group_id": null,
|
||||
"auto_offset_reset": "x", "kafka_resource_path": "r",
|
||||
"retry": {"exponential": {"seconds": 0, "random_factor": 1.5}, "retry_if": {}},
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
issues,
|
||||
[
|
||||
"path: Invalid input: expected string, received number",
|
||||
"script_path: Invalid input: expected string, received undefined",
|
||||
"is_flow: Invalid input: expected boolean, received string",
|
||||
"group_id: Invalid input: expected string, received null",
|
||||
"topics: Invalid input: expected array, received string",
|
||||
"filters.0: Invalid input",
|
||||
"auto_offset_reset: Invalid option: expected one of \"latest\"|\"earliest\"",
|
||||
"retry.exponential.seconds: Too small: expected number to be >=1",
|
||||
"retry.exponential.random_factor: Invalid input: expected int, received number",
|
||||
"retry.retry_if.expr: Invalid input: expected string, received undefined",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_reports_what_parsing_would_drop() {
|
||||
let merged = json!({
|
||||
"path": "p", "schedule": "s", "timezone": "UTC", "script_path": "x",
|
||||
"is_flow": false, "args": null, "bogus": 1, "override": true,
|
||||
"retry": {"constant": {"attempts": 2, "secs": 3}},
|
||||
});
|
||||
let parsed = parse_with(&SCHEDULE_SCHEMA, &merged).unwrap();
|
||||
let mut dropped = Vec::new();
|
||||
dropped_keys(&merged, Some(&parsed), "", &mut dropped);
|
||||
assert_eq!(
|
||||
describe_dropped_schedule_options(&dropped),
|
||||
"These schedule options do not match their schema and would have been dropped: retry.constant.secs. Call get_schedule_schema for their exact shape. These are not schedule fields: bogus."
|
||||
);
|
||||
assert!(parse_with(
|
||||
&SCHEDULE_SCHEMA,
|
||||
&json!({"path": "p", "paused_until": "tomorrow"})
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("paused_until: Invalid ISO datetime"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_value_rules() {
|
||||
let secret = json!({
|
||||
"path": "p",
|
||||
"variable": {"value": "$encrypted:abc", "is_secret": true, "description": "d"},
|
||||
"wsSpecific": false,
|
||||
});
|
||||
let plain_empty =
|
||||
json!({"path": "p", "variable": {"value": "", "is_secret": false, "description": ""}});
|
||||
|
||||
assert!(variable_draft(&json!({"path": "p", "value": "v"}), None).is_err());
|
||||
let kept = variable_draft(
|
||||
&json!({"path": "p", "description": "new"}),
|
||||
Some(secret.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
kept["variable"],
|
||||
json!({"value": "$encrypted:abc", "is_secret": true, "description": "new"})
|
||||
);
|
||||
assert!(variable_draft(&json!({"path": "p", "value": ""}), Some(secret.clone())).is_err());
|
||||
assert!(variable_draft(
|
||||
&json!({"path": "p", "is_secret": false}),
|
||||
Some(secret.clone())
|
||||
)
|
||||
.is_err());
|
||||
let revealed = variable_draft(
|
||||
&json!({"path": "p", "is_secret": false, "value": "v"}),
|
||||
Some(secret),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(revealed["variable"]["value"], "v");
|
||||
assert!(
|
||||
variable_draft(&json!({"path": "p", "is_secret": true}), Some(plain_empty)).is_err()
|
||||
);
|
||||
|
||||
let oauth = variable_to_draft_state(&json!({
|
||||
"path": "p", "value": "live-token", "is_secret": true, "account": 3,
|
||||
"description": null, "expires_at": null,
|
||||
}));
|
||||
assert_eq!(
|
||||
oauth,
|
||||
json!({
|
||||
"path": "p",
|
||||
"variable": {"value": "", "is_secret": true, "description": ""},
|
||||
"wsSpecific": false,
|
||||
"account": 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Lookups that touch no draft: hub, docs, npm, resource types, workers, folders.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use futures::future::join_all;
|
||||
use serde_json::{json, Map, Value};
|
||||
use windmill_common::utils::HTTP_CLIENT;
|
||||
|
||||
use super::{
|
||||
array, enc, is_not_found, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult, SCHEMAS,
|
||||
};
|
||||
|
||||
pub(super) const TOOLS: &[&str] = &[
|
||||
"search_hub_scripts",
|
||||
"get_hub_integration",
|
||||
"search_npm_packages",
|
||||
"search_docs",
|
||||
"read_docs_page",
|
||||
"search_resource_types",
|
||||
"create_resource_type",
|
||||
"list_workers",
|
||||
"create_folder",
|
||||
"get_trigger_schema",
|
||||
"get_schedule_schema",
|
||||
];
|
||||
|
||||
pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult {
|
||||
match name {
|
||||
"search_hub_scripts" => search_hub_scripts(ctx, args).await,
|
||||
"get_hub_integration" => get_hub_integration(ctx, args).await,
|
||||
"search_npm_packages" => search_npm_packages(args).await,
|
||||
"search_docs" => search_docs(ctx, args).await,
|
||||
"read_docs_page" => read_docs_page(ctx, args).await,
|
||||
"search_resource_types" => search_resource_types(ctx, args).await,
|
||||
"create_resource_type" => create_resource_type(ctx, args).await,
|
||||
"list_workers" => list_workers(ctx).await,
|
||||
"create_folder" => create_folder(ctx, args).await,
|
||||
"get_trigger_schema" => get_trigger_schema(args),
|
||||
"get_schedule_schema" => Ok(SCHEMAS.schedule.clone()),
|
||||
_ => unreachable!("{name} is not a misc tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_trigger_schema(args: &Value) -> ToolResult {
|
||||
let kind = str_arg(args, "kind")?;
|
||||
SCHEMAS
|
||||
.trigger
|
||||
.get(kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Unknown trigger kind `{kind}`"))
|
||||
}
|
||||
|
||||
/// Copies `keys` from `from` into `to`, each only when truthy.
|
||||
fn copy_truthy(to: &mut Map<String, Value>, from: &Value, keys: &[&str]) {
|
||||
for key in keys {
|
||||
if truthy(from.get(key)) {
|
||||
to.insert(key.to_string(), from[key].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `keys` from `from` into `to`, each only when present (`null` included).
|
||||
fn copy_present(to: &mut Map<String, Value>, from: &Value, keys: &[&str]) {
|
||||
for key in keys {
|
||||
if let Some(value) = from.get(key) {
|
||||
to.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn words(text: &str) -> impl Iterator<Item = String> + '_ {
|
||||
text.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_BROWSED_HUB_SCRIPTS: usize = 20;
|
||||
const MAX_SUGGESTED_INTEGRATIONS: usize = 5;
|
||||
const MAX_INTEGRATION_EXAMPLES: usize = 5;
|
||||
|
||||
/// An unreachable hub degrades to "no suggestions" rather than a tool error.
|
||||
async fn hub_integrations(ctx: &Ctx<'_>) -> Vec<Value> {
|
||||
match ctx.get("/integrations/hub/list?kind=script").await {
|
||||
Ok(Value::Array(integrations)) => integrations,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A token matches a whole word of the slug or display name, or extends one from four
|
||||
/// characters on: a bare substring test makes `for` hit sales*for*ce.
|
||||
fn token_matches_integration(token: &str, integration: &Value) -> bool {
|
||||
let name = integration.get("name").and_then(Value::as_str);
|
||||
let display_name = integration.get("display_name").and_then(Value::as_str);
|
||||
let words: Vec<String> = [name, display_name]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(words)
|
||||
.collect();
|
||||
if words.iter().any(|w| w == token) {
|
||||
return true;
|
||||
}
|
||||
token.len() >= 4
|
||||
&& words
|
||||
.iter()
|
||||
.any(|w| w.len() >= 4 && (w.starts_with(token) || token.starts_with(w.as_str())))
|
||||
}
|
||||
|
||||
/// Ranked by how many query words each integration accounts for, so "google drive"
|
||||
/// puts `gdrive` ahead of every other `g*` integration matching on "google" alone.
|
||||
fn suggest_hub_integrations(integrations: &[Value], query: &str) -> Vec<String> {
|
||||
let tokens: Vec<String> = words(query).filter(|t| t.len() >= 2).collect();
|
||||
let mut ranked: Vec<(usize, &str)> = integrations
|
||||
.iter()
|
||||
.filter_map(|integration| {
|
||||
let matched = tokens
|
||||
.iter()
|
||||
.filter(|t| token_matches_integration(t, integration))
|
||||
.count();
|
||||
let name = integration.get("name")?.as_str()?;
|
||||
(matched > 0).then_some((matched, name))
|
||||
})
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
ranked
|
||||
.into_iter()
|
||||
.take(MAX_SUGGESTED_INTEGRATIONS)
|
||||
.map(|(_, name)| name.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The hub resolves a script by its version id alone; the other segments are descriptive.
|
||||
fn hub_script_path(script: &Value) -> String {
|
||||
let summary = script
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
format!(
|
||||
"hub/{}/{}/{}",
|
||||
script.get("version_id").unwrap_or(&Value::Null),
|
||||
script
|
||||
.get("app")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
summary.split_whitespace().collect::<Vec<_>>().join("_")
|
||||
)
|
||||
}
|
||||
|
||||
async fn search_hub_scripts(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let query = opt_str_arg(args, "query").filter(|s| !s.is_empty());
|
||||
let app = opt_str_arg(args, "integration").filter(|s| !s.is_empty());
|
||||
if query.is_none() && app.is_none() {
|
||||
return Ok(
|
||||
"Pass query (what the script should do), integration (a slug to list), or both."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let app_param = app
|
||||
.map(|app| format!("&app={}", enc(app)))
|
||||
.unwrap_or_default();
|
||||
// Listing an integration goes through the top-scripts endpoint: it applies no
|
||||
// similarity floor, so near-misses worth reading as examples survive.
|
||||
let scripts = match query {
|
||||
Some(query) => {
|
||||
ctx.get(&format!(
|
||||
"/embeddings/query_hub_scripts?text={}&kind=script{app_param}",
|
||||
enc(query)
|
||||
))
|
||||
.await?
|
||||
}
|
||||
None => ctx
|
||||
.get(&format!(
|
||||
"/scripts/hub/top?limit={MAX_BROWSED_HUB_SCRIPTS}&kind=script{app_param}"
|
||||
))
|
||||
.await?
|
||||
.get("asks")
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let scripts = array(Some(&scripts));
|
||||
let integrations = hub_integrations(ctx).await;
|
||||
|
||||
if scripts.is_empty() {
|
||||
let subject = [app, query]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
return Ok(json!({
|
||||
"results": [],
|
||||
"suggested_integrations": suggest_hub_integrations(&integrations, &subject)
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
|
||||
// Lowercased because a hub slug is case-sensitive and both casings can exist.
|
||||
let documented: HashSet<String> = integrations
|
||||
.iter()
|
||||
.filter(|i| i.get("documented") == Some(&Value::Bool(true)))
|
||||
.filter_map(|i| Some(i.get("name")?.as_str()?.to_lowercase()))
|
||||
.collect();
|
||||
let results: Vec<Value> = scripts
|
||||
.iter()
|
||||
.map(|script| {
|
||||
let mut result = Map::new();
|
||||
result.insert("path".to_string(), hub_script_path(script).into());
|
||||
copy_present(&mut result, script, &["summary"]);
|
||||
let app = script.get("app").cloned().unwrap_or_default();
|
||||
let is_documented =
|
||||
documented.contains(&app.as_str().unwrap_or_default().to_lowercase());
|
||||
result.insert("integration".to_string(), app);
|
||||
if is_documented {
|
||||
result.insert("documented".to_string(), true.into());
|
||||
}
|
||||
copy_truthy(&mut result, script, &["description"]);
|
||||
Value::Object(result)
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "results": results }).to_string())
|
||||
}
|
||||
|
||||
/// How the authored notes were checked is provenance for a human reviewing the hub;
|
||||
/// the verdict and the fields it flags as shaky are what a caller can act on.
|
||||
fn without_provenance(meta: &Value) -> Value {
|
||||
let Value::Object(meta) = meta else {
|
||||
return meta.clone();
|
||||
};
|
||||
let mut rest = meta.clone();
|
||||
let validation = rest.shift_remove("validation");
|
||||
let kept: Map<String, Value> = validation
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(key, _)| *key == "status" || *key == "low_confidence_fields")
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
if !kept.is_empty() {
|
||||
rest.insert("validation".to_string(), Value::Object(kept));
|
||||
}
|
||||
Value::Object(rest)
|
||||
}
|
||||
|
||||
async fn get_hub_integration(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let integration = str_arg(args, "integration")?;
|
||||
let doc = match ctx
|
||||
.get(&format!("/integrations/hub/{}/meta", enc(integration)))
|
||||
.await
|
||||
{
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
// Only a 404 means the integration is absent. Reporting a timeout the same
|
||||
// way would teach the model that a real integration does not exist.
|
||||
let error = if is_not_found(&e) {
|
||||
format!("No hub metadata for \"{integration}\".")
|
||||
} else {
|
||||
format!("Could not reach the hub for \"{integration}\"; it may still exist. Read its scripts instead, or try again.")
|
||||
};
|
||||
let integrations = hub_integrations(ctx).await;
|
||||
return Ok(json!({
|
||||
"error": error,
|
||||
"suggested_integrations": suggest_hub_integrations(&integrations, integration)
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
// The hub owns this response's shape and can be older than this server, so every
|
||||
// section is optional: a hub that sends less returns less.
|
||||
let mut out = Map::new();
|
||||
if let Some(app) = doc.get("app") {
|
||||
out.insert("integration".to_string(), app.clone());
|
||||
}
|
||||
copy_present(&mut out, &doc, &["display_name"]);
|
||||
copy_truthy(&mut out, &doc, &["description", "docs_url"]);
|
||||
if truthy(doc.get("meta")) {
|
||||
out.insert(
|
||||
"verified_provider_notes".to_string(),
|
||||
without_provenance(&doc["meta"]),
|
||||
);
|
||||
}
|
||||
let derived = doc.get("derived").filter(|d| truthy(Some(d)));
|
||||
if let Some(derived) = derived {
|
||||
let mut observed = Map::new();
|
||||
let hosts: Vec<Value> = array(derived.get("api_hosts"))
|
||||
.iter()
|
||||
.filter_map(|h| h.get("host").cloned())
|
||||
.collect();
|
||||
observed.insert("api_hosts".to_string(), hosts.into());
|
||||
copy_present(&mut observed, derived, &["style"]);
|
||||
let languages: Vec<&String> = derived
|
||||
.get("languages")
|
||||
.and_then(Value::as_object)
|
||||
.map(|l| l.keys().collect())
|
||||
.unwrap_or_default();
|
||||
observed.insert("languages".to_string(), json!(languages));
|
||||
copy_present(&mut observed, derived, &["script_counts"]);
|
||||
out.insert("observed_from_scripts".to_string(), observed.into());
|
||||
}
|
||||
if doc.get("curated") == Some(&Value::Bool(true)) {
|
||||
out.insert(
|
||||
"scripts_note".to_string(),
|
||||
"These scripts were pruned to idiomatic actions rather than generated one per API endpoint, so they are worth following as style examples.".into(),
|
||||
);
|
||||
}
|
||||
let resource_types: Vec<Value> = array(doc.get("resource_types"))
|
||||
.iter()
|
||||
.map(|rt| {
|
||||
let mut entry = Map::new();
|
||||
copy_present(&mut entry, rt, &["name"]);
|
||||
copy_truthy(&mut entry, rt, &["description"]);
|
||||
// The hub keeps a schema in a text column and hands it back as a JSON string.
|
||||
match rt.get("schema") {
|
||||
Some(Value::String(schema)) => {
|
||||
let parsed = serde_json::from_str(schema)
|
||||
.unwrap_or_else(|_| Value::String(schema.clone()));
|
||||
entry.insert("schema".to_string(), parsed);
|
||||
}
|
||||
Some(schema) => {
|
||||
entry.insert("schema".to_string(), schema.clone());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Value::Object(entry)
|
||||
})
|
||||
.collect();
|
||||
out.insert("resource_types".to_string(), resource_types.into());
|
||||
let examples: Vec<Value> = array(derived.and_then(|d| d.get("top_scripts")))
|
||||
.iter()
|
||||
.take(MAX_INTEGRATION_EXAMPLES)
|
||||
.map(|script| {
|
||||
let mut entry = Map::new();
|
||||
copy_present(&mut entry, script, &["path", "summary"]);
|
||||
copy_truthy(&mut entry, script, &["description", "language"]);
|
||||
Value::Object(entry)
|
||||
})
|
||||
.collect();
|
||||
out.insert("example_scripts".to_string(), examples.into());
|
||||
Ok(Value::Object(out).to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// npm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NPM_SCORE_THRESHOLD: f64 = 1000.0;
|
||||
/// The session caps a package's readme and types at 1% of its model's context window
|
||||
/// each. The caller's model is unknown here, so this is 1% of the window the session
|
||||
/// assumes for an unknown model (128000).
|
||||
const NPM_TEXT_LIMIT: usize = 1280;
|
||||
|
||||
fn take_chars(text: &str, limit: usize) -> String {
|
||||
text.chars().take(limit).collect()
|
||||
}
|
||||
|
||||
async fn fetch(url: &str) -> Result<reqwest::Response, reqwest::Error> {
|
||||
HTTP_CLIENT.get(url).send().await
|
||||
}
|
||||
|
||||
/// The package's declared entry declaration file. The session walks every `.d.ts` of the
|
||||
/// package in the browser and keeps the same number of characters of the result.
|
||||
async fn npm_package_types(name: &str, version: &str) -> Option<String> {
|
||||
let base = format!("https://unpkg.com/{name}@{version}");
|
||||
let manifest: Value = fetch(&format!("{base}/package.json"))
|
||||
.await
|
||||
.ok()?
|
||||
.error_for_status()
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
let entry = ["types", "typings"]
|
||||
.iter()
|
||||
.find_map(|key| manifest.get(key)?.as_str())
|
||||
.unwrap_or("index.d.ts")
|
||||
.trim_start_matches("./");
|
||||
let types = fetch(&format!("{base}/{entry}"))
|
||||
.await
|
||||
.ok()?
|
||||
.error_for_status()
|
||||
.ok()?
|
||||
.text()
|
||||
.await
|
||||
.ok()?;
|
||||
Some(format!("// /node_modules/{name}/{entry}\n{types}"))
|
||||
}
|
||||
|
||||
async fn npm_search(query: &str) -> Result<Vec<Value>, String> {
|
||||
let data: Value = fetch(&format!(
|
||||
"https://registry.npmjs.org/-/v1/search?text={}&size=2",
|
||||
enc(query)
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let packages = data
|
||||
.get("objects")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("no `objects` in the registry answer")?
|
||||
.iter()
|
||||
.filter(|o| {
|
||||
o.get("searchScore").and_then(Value::as_f64).unwrap_or(0.0) >= NPM_SCORE_THRESHOLD
|
||||
})
|
||||
.filter_map(|o| {
|
||||
let package = o.get("package")?;
|
||||
Some((
|
||||
package.get("name")?.as_str()?,
|
||||
package.get("version").and_then(Value::as_str),
|
||||
))
|
||||
});
|
||||
Ok(join_all(packages.map(|(name, version)| async move {
|
||||
let documentation = match fetch(&format!("https://unpkg.com/{name}/readme.md")).await {
|
||||
Ok(response) => response.text().await.unwrap_or_default(),
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
let types = npm_package_types(name, version.unwrap_or("latest"))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"package": name,
|
||||
"documentation": take_chars(&documentation, NPM_TEXT_LIMIT),
|
||||
"types": take_chars(&types, NPM_TEXT_LIMIT),
|
||||
})
|
||||
}))
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn search_npm_packages(args: &Value) -> ToolResult {
|
||||
let query = str_arg(args, "query")?;
|
||||
Ok(match npm_search(query).await {
|
||||
Ok(results) => Value::Array(results).to_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!("npm package search failed: {e}");
|
||||
"Error searching external integration resources".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `text` of a `/docs/*` answer, or the session's wording for a failed lookup.
|
||||
fn docs_text(result: Result<Value, String>, action: &str) -> String {
|
||||
match result {
|
||||
Ok(response) => response
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
Err(e) => format!("Failed to {action}: {e}, pursuing with the user request..."),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_docs(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let query = opt_str_arg(args, "query").unwrap_or_default().trim();
|
||||
if query.is_empty() {
|
||||
return Ok(
|
||||
"No search query was provided. Provide a `query` of one or more keywords.".to_string(),
|
||||
);
|
||||
}
|
||||
let result = ctx.get(&format!("/docs/search?query={}", enc(query))).await;
|
||||
Ok(docs_text(result, "search documentation"))
|
||||
}
|
||||
|
||||
async fn read_docs_page(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let url = opt_str_arg(args, "url").unwrap_or_default();
|
||||
if url.trim().is_empty() {
|
||||
return Ok("No documentation page URL was provided. Provide a `url` — e.g. a `Source` URL returned by search_docs.".to_string());
|
||||
}
|
||||
let section = opt_str_arg(args, "section")
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| format!("§ion={}", enc(s)))
|
||||
.unwrap_or_default();
|
||||
let result = ctx
|
||||
.get(&format!("/docs/page?url={}{section}", enc(url)))
|
||||
.await;
|
||||
Ok(docs_text(result, "read documentation page"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The hub publishes hundreds of resource types and an instance seeds only a few of its
|
||||
/// own, so fewer than this means the instance never synced with the hub.
|
||||
const UNSYNCED_RESOURCE_TYPE_COUNT: usize = 20;
|
||||
|
||||
const HUB_SYNC_INSTRUCTIONS: &str = "Tell the user: no resource of a missing type can be created until a superadmin syncs resource types from the hub, with \"Sync resource types with Hub\" in the Add resource dialog of the Resources page.";
|
||||
|
||||
async fn search_resource_types(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let query = str_arg(args, "query")?;
|
||||
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(5);
|
||||
let search_route = ctx.ws(&format!(
|
||||
"embeddings/query_resource_types?text={}&limit={limit}",
|
||||
enc(query)
|
||||
));
|
||||
let names_route = ctx.ws("resources/type/listnames");
|
||||
let (results, names) =
|
||||
futures::future::join(ctx.get(&search_route), ctx.get(&names_route)).await;
|
||||
let results = results?;
|
||||
let results = array(Some(&results));
|
||||
let count = names
|
||||
.map(|names| {
|
||||
array(Some(&names))
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<HashSet<_>>()
|
||||
.len()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let listing: Vec<Value> = results
|
||||
.iter()
|
||||
.map(|rt| {
|
||||
let mut entry = Map::new();
|
||||
copy_present(&mut entry, rt, &["name", "schema"]);
|
||||
Value::Object(entry)
|
||||
})
|
||||
.collect();
|
||||
let listing = pretty(&Value::Array(listing));
|
||||
// The similarity search still returns the nearest of the few types there are, so the
|
||||
// note cannot wait for an empty result; it replaces the "no dedicated type" one, which
|
||||
// would have the model create a duplicate of a type a sync brings.
|
||||
if count > 0 && count < UNSYNCED_RESOURCE_TYPE_COUNT {
|
||||
return Ok(format!("{listing}\n\nOnly {count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. {HUB_SYNC_INSTRUCTIONS}"));
|
||||
}
|
||||
// The search is semantic and always returns its closest types, so a miss reads
|
||||
// like a hit unless the absence is spelled out.
|
||||
let words: Vec<String> = words(query).filter(|w| w.len() >= 3).collect();
|
||||
let named = results.iter().any(|rt| {
|
||||
let name = rt
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
words.iter().any(|w| name.contains(w.as_str()))
|
||||
});
|
||||
Ok(if named || words.is_empty() {
|
||||
listing
|
||||
} else {
|
||||
format!("{listing}\n\nNo resource type name contains \"{query}\": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_<name> type.")
|
||||
})
|
||||
}
|
||||
|
||||
/// The prefix the Add resource type form adds: a hub type of the same name, synced later,
|
||||
/// would otherwise clash with this one.
|
||||
fn custom_resource_type_name(name: &str) -> String {
|
||||
if name.starts_with("c_") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("c_{name}")
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_resource_type(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let name = custom_resource_type_name(str_arg(args, "name")?);
|
||||
let description = str_arg(args, "description")?;
|
||||
let schema = args
|
||||
.get("schema")
|
||||
.filter(|s| s.is_object())
|
||||
.ok_or("Missing required object argument `schema`")?;
|
||||
ctx.post(
|
||||
&ctx.ws("resources/type/create"),
|
||||
json!({ "name": name, "description": description, "schema": schema }),
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": format!("Created resource type {name}. Refer to it by this name.")
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKER_PAGE_SIZE: usize = 100;
|
||||
// `GET /workers/list` hides workers from a caller without the devops role by returning
|
||||
// an empty list, not an error, when HIDE_WORKERS_FOR_NON_ADMINS is set. The flag is not
|
||||
// visible here, so absence is only provable for a devops or superadmin caller.
|
||||
const NO_WORKERS_VISIBLE_MESSAGE: &str = "No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves if they can open it, or ask a workspace admin otherwise. Never state that no workers are online or that the instance has none.";
|
||||
const NO_WORKERS_CONNECTED_MESSAGE: &str = "No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts.";
|
||||
|
||||
async fn list_workers(ctx: &Ctx<'_>) -> ToolResult {
|
||||
let pings = ctx
|
||||
.get(&format!("/workers/list?per_page={WORKER_PAGE_SIZE}"))
|
||||
.await?;
|
||||
let pings = array(Some(&pings));
|
||||
if pings.is_empty() {
|
||||
let me = ctx.get("/users/whoami").await.unwrap_or_default();
|
||||
let sees_all = truthy(me.get("super_admin")) || truthy(me.get("devops"));
|
||||
return Ok(if sees_all {
|
||||
NO_WORKERS_CONNECTED_MESSAGE
|
||||
} else {
|
||||
NO_WORKERS_VISIBLE_MESSAGE
|
||||
}
|
||||
.to_string());
|
||||
}
|
||||
let workers: Vec<Value> = pings
|
||||
.iter()
|
||||
.map(|ping| {
|
||||
let mut worker = Map::new();
|
||||
copy_present(
|
||||
&mut worker,
|
||||
ping,
|
||||
&[
|
||||
"worker",
|
||||
"worker_group",
|
||||
"custom_tags",
|
||||
"last_ping",
|
||||
"jobs_executed",
|
||||
],
|
||||
);
|
||||
Value::Object(worker)
|
||||
})
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let listed = workers.len();
|
||||
out.insert("workers".to_string(), workers.into());
|
||||
if listed == WORKER_PAGE_SIZE {
|
||||
out.insert(
|
||||
"note".to_string(),
|
||||
format!("Only the first {WORKER_PAGE_SIZE} workers are listed; more may be connected.")
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(pretty(&Value::Object(out)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn create_folder(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let name = str_arg(args, "name")?;
|
||||
// Same rule as the API's own check, so an invalid name gets this wording.
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Ok(json!({
|
||||
"success": false,
|
||||
"error": "Folder name can only contain alphanumeric characters, underscores, and hyphens."
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
let mut body = Map::new();
|
||||
body.insert("name".to_string(), name.into());
|
||||
copy_present(&mut body, args, &["summary"]);
|
||||
Ok(
|
||||
match ctx.post(&ctx.ws("folders/create"), body.into()).await {
|
||||
Ok(_) => json!({
|
||||
"success": true,
|
||||
"message": format!("Created folder `f/{name}`. You can now write items to `f/{name}/<name>`.")
|
||||
}),
|
||||
Err(error) => json!({ "success": false, "error": error }),
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suggestions_rank_by_matched_words() {
|
||||
let integrations = vec![
|
||||
json!({ "name": "gcal", "display_name": "Google Calendar" }),
|
||||
json!({ "name": "gdrive", "display_name": "Google Drive" }),
|
||||
json!({ "name": "salesforce", "display_name": null }),
|
||||
json!({ "name": "s3", "display_name": "S3" }),
|
||||
];
|
||||
assert_eq!(
|
||||
suggest_hub_integrations(&integrations, "google drive"),
|
||||
["gdrive", "gcal"]
|
||||
);
|
||||
// A short token must equal a word; a long one may extend it.
|
||||
assert!(suggest_hub_integrations(&integrations, "for").is_empty());
|
||||
assert_eq!(
|
||||
suggest_hub_integrations(&integrations, "sales"),
|
||||
["salesforce"]
|
||||
);
|
||||
assert_eq!(suggest_hub_integrations(&integrations, "s3 bucket"), ["s3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_keeps_only_the_verdict() {
|
||||
let meta = json!({
|
||||
"pagination": "cursor",
|
||||
"validation": { "status": "verified", "method": "live", "low_confidence_fields": ["a"] }
|
||||
});
|
||||
assert_eq!(
|
||||
without_provenance(&meta),
|
||||
json!({
|
||||
"pagination": "cursor",
|
||||
"validation": { "status": "verified", "low_confidence_fields": ["a"] }
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
without_provenance(&json!({ "a": 1, "validation": { "method": "live" } })),
|
||||
json!({ "a": 1 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_resource_types_carry_the_c_prefix_once() {
|
||||
assert_eq!(custom_resource_type_name("acme"), "c_acme");
|
||||
assert_eq!(custom_resource_type_name("c_acme"), "c_acme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_path_from_summary() {
|
||||
let script = json!({ "version_id": 28479, "app": "stripe", "summary": "Cancel Or Reverse a Payout" });
|
||||
assert_eq!(
|
||||
hub_script_path(&script),
|
||||
"hub/28479/stripe/cancel_or_reverse_a_payout"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
//! The Windmill MCP server's toolset (`/api/mcp`, tokens with `mcp:all`): an AI session's toolset, for a client
|
||||
//! outside the Windmill UI.
|
||||
//!
|
||||
//! Everything a model reads here — tool names, descriptions, input schemas, the
|
||||
//! instructions, the `get_instructions` texts — is generated from the session's own
|
||||
//! definitions into `gen/` (`npm run gen:mcp-session` in `frontend/`). What a tool
|
||||
//! *does* is written here, mirroring its session counterpart in
|
||||
//! `frontend/src/lib/components/copilot/chat/global/core.ts`: a change to one side's
|
||||
//! behaviour belongs on the other.
|
||||
//!
|
||||
//! A tool acts through the API as the caller, the way the session's does, so every
|
||||
//! permission check is the API's own. It calls the server's router in-process rather
|
||||
//! than over a socket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use windmill_mcp::server::{
|
||||
BackendResult, CallToolResult, ContentBlock, ErrorData, MetaObject, SessionAccess, Tool,
|
||||
ToolAnnotations,
|
||||
};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
|
||||
use super::utils::{mint_caller_jwt, send_as_caller};
|
||||
|
||||
/// Each running server's fully layered router, by its `base_internal_url`. Keyed rather
|
||||
/// than global because the backend tests run one server per test in a single process,
|
||||
/// each on its own database: a tool must reach the server it was called on.
|
||||
static IN_PROCESS_ROUTERS: LazyLock<std::sync::RwLock<HashMap<String, axum::Router>>> =
|
||||
LazyLock::new(Default::default);
|
||||
|
||||
/// Let session tools of the server at `base_internal_url` call its API in-process.
|
||||
/// Without a registration they fall back to HTTP.
|
||||
pub(crate) fn register_router(base_internal_url: &str, router: Option<axum::Router>) {
|
||||
let mut routers = IN_PROCESS_ROUTERS.write().unwrap();
|
||||
match router {
|
||||
Some(router) => routers.insert(base_internal_url.to_string(), router),
|
||||
None => routers.remove(base_internal_url),
|
||||
};
|
||||
}
|
||||
|
||||
mod apps;
|
||||
mod data;
|
||||
mod deploy;
|
||||
mod flows;
|
||||
mod guidance;
|
||||
mod instructions;
|
||||
mod items;
|
||||
mod misc;
|
||||
mod runs;
|
||||
mod settings;
|
||||
mod workspace;
|
||||
|
||||
pub(super) use instructions::{
|
||||
generic_session_instructions, session_instructions, workspace_context,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ToolDef {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Map<String, Value>,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
static TOOL_DEFS: LazyLock<Vec<ToolDef>> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("gen/tools.json")).expect("gen/tools.json is valid")
|
||||
});
|
||||
|
||||
/// A tool's input schema, as generated from the session's zod schema.
|
||||
pub(super) fn tool_parameters(name: &str) -> Option<&'static Map<String, Value>> {
|
||||
TOOL_DEFS
|
||||
.iter()
|
||||
.find(|def| def.name == name)
|
||||
.map(|def| &def.parameters)
|
||||
}
|
||||
|
||||
/// The JSON Schema text of each trigger kind's config, and of a schedule's `advanced` bag,
|
||||
/// as the session renders them from its request schemas.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct Schemas {
|
||||
pub trigger: HashMap<String, String>,
|
||||
pub schedule: String,
|
||||
}
|
||||
|
||||
pub(super) static SCHEMAS: LazyLock<Schemas> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("gen/schemas.json")).expect("gen/schemas.json is valid")
|
||||
});
|
||||
|
||||
/// Session tools that need the Windmill UI and have no meaning for an external client.
|
||||
/// Every generated tool is either here or implemented by a group below.
|
||||
#[cfg(test)]
|
||||
const EDITOR_ONLY: &[&str] = &[
|
||||
// The side panel, the question card and the browser-held user instructions.
|
||||
"open_page",
|
||||
"askUserQuestion",
|
||||
"update_user_instructions",
|
||||
// Files attached to the chat.
|
||||
"search_files",
|
||||
"read_file",
|
||||
];
|
||||
|
||||
/// Tools whose effect reaches the deployed workspace and cannot be undone by discarding
|
||||
/// a draft. Stands in for the session's confirmation card.
|
||||
const DESTRUCTIVE: &[&str] = &[
|
||||
"deploy_workspace_item",
|
||||
"delete_workspace_item",
|
||||
"create_resource_type",
|
||||
"set_workspace_settings",
|
||||
];
|
||||
|
||||
/// Tools the session counts as reads that still start a job. A read-only token runs
|
||||
/// nothing on the tools server, so it runs nothing here either.
|
||||
const RUNS_A_JOB: &[&str] = &["get_db_schema"];
|
||||
|
||||
/// Tools that run a deployed script or flow, the one write a read-and-run token keeps.
|
||||
const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow"];
|
||||
|
||||
fn allowed(def: &ToolDef, access: SessionAccess) -> bool {
|
||||
let read = def.read_only && !RUNS_A_JOB.contains(&def.name.as_str());
|
||||
match access {
|
||||
SessionAccess::Full => true,
|
||||
SessionAccess::ReadAndRun => read || RUNS_DEPLOYED.contains(&def.name.as_str()),
|
||||
SessionAccess::ReadOnly => read,
|
||||
}
|
||||
}
|
||||
|
||||
/// Each group's tools and its dispatcher, tried in order.
|
||||
macro_rules! groups {
|
||||
($($group:ident),*) => {
|
||||
fn implemented() -> impl Iterator<Item = &'static str> {
|
||||
std::iter::empty()$(.chain($group::TOOLS.iter().copied()))*
|
||||
}
|
||||
|
||||
async fn dispatch(ctx: &Ctx<'_>, name: &str, args: &Value) -> Option<ToolResult> {
|
||||
$(if $group::TOOLS.contains(&name) {
|
||||
return Some($group::call(ctx, name, args).await);
|
||||
})*
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
groups!(guidance, workspace, items, misc, flows, apps, runs, deploy, data, settings);
|
||||
|
||||
/// Claude clients keep an MCP result in context only up to a token budget well under the
|
||||
/// authoring references `get_instructions` returns, unless the tool raises it. Above the
|
||||
/// runner's own cap on a result, so that cap is the one that applies.
|
||||
fn result_size_meta() -> MetaObject {
|
||||
let mut meta = Map::new();
|
||||
meta.insert("anthropic/maxResultSizeChars".to_string(), 100_000.into());
|
||||
MetaObject(meta)
|
||||
}
|
||||
|
||||
pub(super) fn session_tools(access: SessionAccess) -> Vec<Tool> {
|
||||
TOOL_DEFS
|
||||
.iter()
|
||||
.filter(|def| implemented().any(|name| name == def.name))
|
||||
.filter(|def| allowed(def, access))
|
||||
.map(|def| {
|
||||
Tool::new(
|
||||
def.name.clone(),
|
||||
def.description.clone(),
|
||||
def.parameters.clone(),
|
||||
)
|
||||
.with_annotations(
|
||||
ToolAnnotations::new()
|
||||
.read_only(def.read_only)
|
||||
.destructive(DESTRUCTIVE.contains(&def.name.as_str())),
|
||||
)
|
||||
.with_meta(result_size_meta())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a tool hands back: the text the model reads, or the message of a failed call.
|
||||
pub(super) type ToolResult = Result<String, String>;
|
||||
|
||||
/// The caller, and the API reached as them.
|
||||
pub(super) struct Ctx<'a> {
|
||||
pub auth: &'a ApiAuthed,
|
||||
pub w_id: &'a str,
|
||||
pub base_internal_url: &'a str,
|
||||
pub access: SessionAccess,
|
||||
/// The scheme and host the MCP client reached, when known.
|
||||
pub origin: Option<&'a str>,
|
||||
/// Minted once per tool call: every JWT the API sees is cached by the auth layer, and a
|
||||
/// tool can make dozens of calls, which would evict other users' cached sessions.
|
||||
pub jwt: tokio::sync::OnceCell<String>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// Call the API as the caller. `path` follows `/api`; a non-2xx answer is the error.
|
||||
pub async fn request(&self, method: &str, path: &str, body: Option<Value>) -> ToolResult {
|
||||
let url = guarded_url(self.base_internal_url, self.w_id, method, path, self.access)?;
|
||||
// No route scope: this server only admits `mcp:all`, several routes (hub, embeddings)
|
||||
// map to none, and `guarded_url` is what keeps a call on its route. The tag fence
|
||||
// is not a route scope and stays: it limits which jobs the caller runs and reads.
|
||||
let tag_scopes: Vec<String> = self
|
||||
.auth
|
||||
.scopes
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|s| s.starts_with("if_jobs:filter_tags:"))
|
||||
.cloned()
|
||||
.collect();
|
||||
let scopes = (!tag_scopes.is_empty()).then_some(tag_scopes);
|
||||
let router = IN_PROCESS_ROUTERS
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(self.base_internal_url)
|
||||
.cloned();
|
||||
let (status, text) = match router {
|
||||
Some(router) => self.call_router(router, method, &url, body, scopes).await?,
|
||||
None => {
|
||||
let response = send_as_caller(method, &url, self.w_id, self.auth, body, scopes)
|
||||
.await
|
||||
.map_err(|e| e.message.to_string())?;
|
||||
let status = response.status();
|
||||
(status, response.text().await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
if status.is_success() {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(format!("{}: {}", status.as_u16(), text))
|
||||
}
|
||||
}
|
||||
|
||||
/// `request` through the server's own router: the same layers and handlers as over
|
||||
/// HTTP, without a socket.
|
||||
async fn call_router(
|
||||
&self,
|
||||
router: axum::Router,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Value>,
|
||||
scopes: Option<Vec<String>>,
|
||||
) -> Result<(http::StatusCode, String), String> {
|
||||
use tower::ServiceExt;
|
||||
let parsed = reqwest::Url::parse(url).map_err(|e| e.to_string())?;
|
||||
// The router does not see the base URL's own path prefix.
|
||||
let at = reqwest::Url::parse(self.base_internal_url)
|
||||
.map_or(0, |base| base.path().trim_end_matches('/').len());
|
||||
let uri = match parsed.query() {
|
||||
Some(q) => format!("{}?{q}", &parsed.path()[at..]),
|
||||
None => parsed.path()[at..].to_string(),
|
||||
};
|
||||
let token = self
|
||||
.jwt
|
||||
.get_or_try_init(|| mint_caller_jwt(self.w_id, self.auth, scopes))
|
||||
.await
|
||||
.map_err(|e| e.message.to_string())?;
|
||||
let mut request = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
|
||||
let body = match body {
|
||||
Some(body) => {
|
||||
request = request.header(http::header::CONTENT_TYPE, "application/json");
|
||||
axum::body::Body::from(serde_json::to_vec(&body).map_err(|e| e.to_string())?)
|
||||
}
|
||||
None => axum::body::Body::empty(),
|
||||
};
|
||||
let request = request.body(body).map_err(|e| e.to_string())?;
|
||||
// The total timeout of the HTTP client this replaces.
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
let response = router.oneshot(request).await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((status, String::from_utf8_lossy(&bytes).into_owned()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Failed to execute request: {method} {url} timed out"))?
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str) -> Result<Value, String> {
|
||||
let text = self.request("GET", path, None).await?;
|
||||
serde_json::from_str(&text).map_err(|e| format!("unexpected response from {path}: {e}"))
|
||||
}
|
||||
|
||||
/// `POST` a JSON body. Several endpoints answer with plain text (a path, a job id):
|
||||
/// that comes back as a JSON string.
|
||||
pub async fn post(&self, path: &str, body: Value) -> Result<Value, String> {
|
||||
let text = self.request("POST", path, Some(body)).await?;
|
||||
Ok(serde_json::from_str(&text).unwrap_or(Value::String(text)))
|
||||
}
|
||||
|
||||
pub async fn delete(&self, path: &str) -> Result<Value, String> {
|
||||
let text = self.request("DELETE", path, None).await?;
|
||||
Ok(serde_json::from_str(&text).unwrap_or(Value::String(text)))
|
||||
}
|
||||
|
||||
/// A workspace-scoped path: `ws("scripts/list")` is `/w/<workspace>/scripts/list`.
|
||||
pub fn ws(&self, rest: &str) -> String {
|
||||
format!("/w/{}/{}", self.w_id, rest)
|
||||
}
|
||||
|
||||
/// A Windmill page in this workspace, as a link the user can open: an external client
|
||||
/// has no preview panel, so a link is how it shows what a tool made or ran.
|
||||
pub fn page_url(&self, page: &str) -> String {
|
||||
let (page, hash) = page
|
||||
.split_once('#')
|
||||
.map_or((page, None), |(p, h)| (p, Some(h)));
|
||||
let sep = if page.contains('?') { '&' } else { '?' };
|
||||
let hash = hash.map(|h| format!("#{h}")).unwrap_or_default();
|
||||
// An instance that never set its base URL holds this placeholder, which no client
|
||||
// can open; the host the client reached is the better guess then.
|
||||
let base = windmill_common::BASE_URL.load();
|
||||
let base = match self.origin {
|
||||
Some(origin) if base.as_str() == "http://localhost" => origin,
|
||||
_ => base.as_str(),
|
||||
};
|
||||
format!("{base}{page}{sep}workspace={}{hash}", enc(self.w_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes outside the workspace that a tool reads.
|
||||
const INSTANCE_ROUTES: &[&str] = &[
|
||||
"/docs/",
|
||||
"/embeddings/",
|
||||
"/integrations/hub/",
|
||||
"/scripts/hub/",
|
||||
"/users/whoami",
|
||||
"/workers/list",
|
||||
"/workspaces/users",
|
||||
];
|
||||
|
||||
/// The URL of an API call, refused unless it lands on the route its handler wrote.
|
||||
///
|
||||
/// The JWT behind a call carries the caller's full rights, and paths embed arguments a
|
||||
/// model chose: a `..` segment, or an id carrying `#`, would otherwise turn a read of
|
||||
/// one job into a run of any script. A read-only or read-and-run token is held to its rule
|
||||
/// here too, since that JWT carries neither.
|
||||
fn guarded_url(
|
||||
base: &str,
|
||||
w_id: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
access: SessionAccess,
|
||||
) -> Result<String, String> {
|
||||
let refused = || format!("Refusing to call `{path}`: not a route of this tool");
|
||||
let route = path.split('?').next().unwrap_or_default();
|
||||
let in_workspace = route
|
||||
.strip_prefix("/w/")
|
||||
.and_then(|rest| rest.strip_prefix(w_id))
|
||||
.is_some_and(|rest| rest.starts_with('/'));
|
||||
let workspace_is_plain = w_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||
if path.contains('#')
|
||||
|| !workspace_is_plain
|
||||
|| !(in_workspace || INSTANCE_ROUTES.iter().any(|r| route.starts_with(r)))
|
||||
{
|
||||
return Err(refused());
|
||||
}
|
||||
let url = format!("{base}/api{path}");
|
||||
let parsed = reqwest::Url::parse(&url).map_err(|_| refused())?;
|
||||
// Parsing resolves dot segments, encoded or not: a path that changed went elsewhere.
|
||||
if !parsed.path().ends_with(&format!("/api{route}")) {
|
||||
return Err(refused());
|
||||
}
|
||||
// Runs of deployed items by path only: the preview routes take code, so they are writes.
|
||||
// A run also stores its secret arguments as expiring variables; the write tools that
|
||||
// could create other variables are not offered to a read-and-run token.
|
||||
let run_writes = method == "POST"
|
||||
&& ["jobs/run/p/", "jobs/run/f/"]
|
||||
.iter()
|
||||
.any(|run| route.starts_with(&format!("/w/{w_id}/{run}")))
|
||||
|| method == "POST" && route == format!("/w/{w_id}/variables/create");
|
||||
let held_to_reads = match access {
|
||||
SessionAccess::Full => false,
|
||||
SessionAccess::ReadAndRun => !run_writes,
|
||||
SessionAccess::ReadOnly => true,
|
||||
};
|
||||
if held_to_reads {
|
||||
windmill_api_auth::scopes::check_read_only_for_route(parsed.path(), method)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// A string argument, or the error the model gets for leaving it out.
|
||||
pub(super) fn str_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> {
|
||||
args.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("Missing required string argument `{name}`"))
|
||||
}
|
||||
|
||||
pub(super) fn opt_str_arg<'a>(args: &'a Value, name: &str) -> Option<&'a str> {
|
||||
args.get(name).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// `JSON.stringify(value, null, 2)`, the form the session's tools return objects in.
|
||||
pub(super) fn pretty(value: &Value) -> String {
|
||||
serde_json::to_string_pretty(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Percent-encode one query-string value.
|
||||
pub(super) fn enc(value: &str) -> String {
|
||||
urlencoding::encode(value).into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn bool_arg(args: &Value, name: &str) -> bool {
|
||||
args.get(name).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a `Ctx` error is the API answering 404.
|
||||
pub(super) fn is_not_found(error: &str) -> bool {
|
||||
error.starts_with("404: ")
|
||||
}
|
||||
|
||||
/// JavaScript truthiness, for the fields the session keeps only `if (value)`.
|
||||
pub(super) fn truthy(value: Option<&Value>) -> bool {
|
||||
match value {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(Value::Number(n)) => n.as_f64().is_some_and(|n| n != 0.0),
|
||||
Some(Value::String(s)) => !s.is_empty(),
|
||||
Some(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `value ?? undefined`: the value unless it is missing or null.
|
||||
pub(super) fn non_null(value: Option<&Value>) -> Option<&Value> {
|
||||
value.filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
/// Set `key` unless the value is JS `undefined`, which `JSON.stringify` drops.
|
||||
pub(super) fn put(map: &mut Map<String, Value>, key: &str, value: Option<Value>) {
|
||||
if let Some(value) = value {
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The elements of an array; none for anything else.
|
||||
pub(super) fn array(value: Option<&Value>) -> &[Value] {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A value as a JS template literal prints it.
|
||||
pub(super) fn js_str(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `String.length`: UTF-16 code units.
|
||||
pub(super) fn js_len(text: &str) -> usize {
|
||||
text.encode_utf16().count()
|
||||
}
|
||||
|
||||
/// `text.slice(0, max)` in UTF-16 units, never splitting a character.
|
||||
pub(super) fn js_head(text: &str, max: usize) -> &str {
|
||||
if text.len() <= max {
|
||||
return text;
|
||||
}
|
||||
let mut units = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
units += c.len_utf16();
|
||||
if units > max {
|
||||
return &text[..i];
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// `text.slice(-max)` in UTF-16 units, never splitting a character.
|
||||
pub(super) fn js_tail(text: &str, max: usize) -> &str {
|
||||
if text.len() <= max {
|
||||
return text;
|
||||
}
|
||||
let mut units = 0;
|
||||
for (i, c) in text.char_indices().rev() {
|
||||
units += c.len_utf16();
|
||||
if units > max {
|
||||
return &text[i + c.len_utf8()..];
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// `*` any characters but `/`, `**` any characters, `?` one character but `/`. A pattern
|
||||
/// without `/` is matched against the file name alone, so `*.tsx` finds nested files.
|
||||
pub(super) fn matches_glob(subject: &str, glob: &str) -> bool {
|
||||
let subject = if glob.contains('/') {
|
||||
subject
|
||||
} else {
|
||||
subject.rsplit('/').next().unwrap_or(subject)
|
||||
};
|
||||
let mut pattern = String::from("^");
|
||||
let mut chars = glob.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'*' if chars.peek() == Some(&'*') => {
|
||||
chars.next();
|
||||
pattern.push_str(".*");
|
||||
}
|
||||
'*' => pattern.push_str("[^/]*"),
|
||||
'?' => pattern.push_str("[^/]"),
|
||||
c => pattern.push_str(®ex::escape(c.encode_utf8(&mut [0; 4]))),
|
||||
}
|
||||
}
|
||||
pattern.push('$');
|
||||
regex::Regex::new(&pattern).is_ok_and(|re| re.is_match(subject))
|
||||
}
|
||||
|
||||
pub(super) async fn call_session_tool(
|
||||
ctx: &Ctx<'_>,
|
||||
name: &str,
|
||||
args: Value,
|
||||
) -> BackendResult<CallToolResult> {
|
||||
let Some(def) = TOOL_DEFS.iter().find(|def| def.name == name) else {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("Unknown tool '{name}'"),
|
||||
None,
|
||||
));
|
||||
};
|
||||
if !allowed(def, ctx.access) {
|
||||
let token = match ctx.access {
|
||||
SessionAccess::ReadAndRun => "can only read and run deployed scripts and flows",
|
||||
_ => "is read-only",
|
||||
};
|
||||
return Err(ErrorData::invalid_request(
|
||||
format!("Tool '{name}' changes or runs something, and this token {token}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let Some(result) = dispatch(ctx, name, &args).await else {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!("Tool '{name}' is not available on this server"),
|
||||
None,
|
||||
));
|
||||
};
|
||||
windmill_common::feature_usage::log_feature_usage("mcp_workspace", "tool_call", name);
|
||||
Ok(match result {
|
||||
Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]),
|
||||
// Without the status code `Ctx` prefixes an API failure with, as the session reads it.
|
||||
Err(message) => {
|
||||
let body = workspace::api_message(&message);
|
||||
let text = if body.is_empty() { &message } else { body };
|
||||
CallToolResult::error(vec![ContentBlock::text(text.to_string())])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_call_stays_on_the_route_its_handler_wrote() {
|
||||
use SessionAccess::*;
|
||||
let url = |method, path, access| guarded_url("http://h", "ws", method, path, access);
|
||||
assert!(url("GET", "/w/ws/jobs_u/get/0191?no_logs=true", Full).is_ok());
|
||||
assert!(url("GET", "/docs/search?query=a%2Fb", ReadOnly).is_ok());
|
||||
for path in [
|
||||
"/w/ws/jobs_u/get/../../jobs/run_wait_result/p/u/a/b",
|
||||
"/w/ws/jobs_u/get/%2e%2e/%2E%2E/variables/get_value/u/a/b",
|
||||
"/w/ws/jobs_u/get/x#",
|
||||
"/w/other/scripts/list",
|
||||
"/w/ws",
|
||||
"/settings/global/x",
|
||||
"/docs/../users/tokens/list",
|
||||
] {
|
||||
assert!(url("GET", path, Full).is_err(), "{path} should be refused");
|
||||
}
|
||||
assert!(guarded_url("http://h", "a/../b", "GET", "/w/a/../b/scripts/list", Full).is_err());
|
||||
// A read-only token neither writes nor runs, whatever the tool.
|
||||
assert!(url("POST", "/w/ws/drafts/update", ReadOnly).is_err());
|
||||
assert!(url("GET", "/w/ws/jobs/run_wait_result/p/u/a/b", ReadOnly).is_err());
|
||||
assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadOnly).is_err());
|
||||
// A read-and-run token runs deployed items by path, and writes nothing else: a
|
||||
// preview takes code.
|
||||
assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadAndRun).is_ok());
|
||||
assert!(url("POST", "/w/ws/jobs/run/f/u/a/b", ReadAndRun).is_ok());
|
||||
assert!(url("POST", "/w/ws/jobs/run/preview", ReadAndRun).is_err());
|
||||
assert!(url("POST", "/w/ws/drafts/update", ReadAndRun).is_err());
|
||||
assert!(url("POST", "/w/ws/variables/create", ReadAndRun).is_ok());
|
||||
assert!(url("POST", "/w/ws/variables/update/u/a/b", ReadAndRun).is_err());
|
||||
assert!(url("POST", "/w/ws/variables/create", ReadOnly).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_session_tool_is_implemented_or_editor_only() {
|
||||
let implemented: Vec<&str> = implemented().collect();
|
||||
for def in TOOL_DEFS.iter() {
|
||||
let name = def.name.as_str();
|
||||
assert!(
|
||||
implemented.contains(&name) != EDITOR_ONLY.contains(&name),
|
||||
"session tool `{name}` must be implemented here or listed in EDITOR_ONLY, not both"
|
||||
);
|
||||
}
|
||||
for name in implemented.iter().chain(EDITOR_ONLY) {
|
||||
assert!(
|
||||
TOOL_DEFS.iter().any(|def| def.name == *name),
|
||||
"`{name}` is not a session tool any more"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
//! The workspace settings document, in the layout of the `settings.yaml` the CLI syncs.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::workspace::api_message;
|
||||
use super::{array, js_head, js_len, js_str, str_arg, Ctx, ToolResult};
|
||||
|
||||
pub(super) const TOOLS: &[&str] = &["get_workspace_settings", "set_workspace_settings"];
|
||||
|
||||
pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult {
|
||||
match name {
|
||||
"get_workspace_settings" => get_workspace_settings(ctx, args).await,
|
||||
"set_workspace_settings" => set_workspace_settings(ctx, args).await,
|
||||
_ => unreachable!("{name} is not a settings tool"),
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_READ_CHARS: usize = 20_000;
|
||||
|
||||
async fn get_workspace_settings(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let doc = ctx
|
||||
.get(&ctx.ws("workspaces/settings_document?secrets=redacted"))
|
||||
.await?;
|
||||
let keys: Vec<&str> = array(args.get("keys"))
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let mut picked = doc.clone();
|
||||
let mut note = String::new();
|
||||
if !keys.is_empty() {
|
||||
picked = Value::Object(
|
||||
keys.iter()
|
||||
.filter_map(|k| Some((k.to_string(), doc.get(k)?.clone())))
|
||||
.collect::<Map<_, _>>(),
|
||||
);
|
||||
let unset: Vec<&str> = keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|k| doc.get(k).is_none())
|
||||
.collect();
|
||||
if !unset.is_empty() {
|
||||
note = format!(
|
||||
"\n# Not in the document (unset, or not a setting): {}",
|
||||
unset.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut text = serde_yml::to_string(&picked).map_err(|e| e.to_string())?;
|
||||
text.push_str(¬e);
|
||||
Ok(if js_len(&text) > MAX_READ_CHARS {
|
||||
format!(
|
||||
"{}\n… truncated at {MAX_READ_CHARS} chars; read the keys you need with `keys`.",
|
||||
js_head(&text, MAX_READ_CHARS)
|
||||
)
|
||||
} else {
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_settings_yaml(text: &str) -> Result<Value, String> {
|
||||
match serde_yml::from_str::<Value>(text) {
|
||||
Ok(doc @ Value::Object(_)) => Ok(doc),
|
||||
Ok(_) => Err("The yaml must be a mapping of setting keys to values.".to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dry run stands in for the session's confirmation card: it reports a no-op or an
|
||||
/// invalid document before anything is written.
|
||||
async fn set_workspace_settings(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
|
||||
let route = ctx.ws("workspaces/settings_document");
|
||||
let document = match parse_settings_yaml(str_arg(args, "yaml")?) {
|
||||
Ok(document) => document,
|
||||
Err(e) => return Ok(format!("Nothing was changed: {e}")),
|
||||
};
|
||||
let dry_run = json!({ "document": document, "replace": false, "dry_run": true });
|
||||
let dry_run = match ctx.post(&route, dry_run).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => return Ok(format!("Nothing was changed: {}", api_message(&e))),
|
||||
};
|
||||
if array(dry_run.get("changes")).is_empty() {
|
||||
let warnings: Vec<String> = array(dry_run.get("warnings")).iter().map(js_str).collect();
|
||||
let warnings = if warnings.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" Warnings: {}", warnings.join(" "))
|
||||
};
|
||||
return Ok(format!(
|
||||
"The settings already hold these values: nothing to change.{warnings}"
|
||||
));
|
||||
}
|
||||
let applied = match ctx
|
||||
.post(&route, json!({ "document": document, "replace": false }))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => return Ok(json!({ "success": false, "error": api_message(&e) }).to_string()),
|
||||
};
|
||||
let keys: Vec<String> = array(applied.get("changes"))
|
||||
.iter()
|
||||
.map(|c| js_str(c.get("key").unwrap_or(&Value::Null)))
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
out.insert("success".into(), json!(true));
|
||||
out.insert(
|
||||
"message".into(),
|
||||
json!(format!(
|
||||
"These settings are now applied: {}.",
|
||||
keys.join(", ")
|
||||
)),
|
||||
);
|
||||
if let Some(warnings) = applied
|
||||
.get("warnings")
|
||||
.filter(|w| !array(Some(w)).is_empty())
|
||||
{
|
||||
out.insert("warnings".into(), warnings.clone());
|
||||
}
|
||||
Ok(Value::Object(out).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_yaml_must_be_a_mapping() {
|
||||
assert_eq!(
|
||||
parse_settings_yaml("webhook: null\ncolor: red").unwrap(),
|
||||
json!({ "webhook": null, "color": "red" })
|
||||
);
|
||||
for text in ["- a\n- b", "just text", "42"] {
|
||||
assert_eq!(
|
||||
parse_settings_yaml(text).unwrap_err(),
|
||||
"The yaml must be a mapping of setting keys to values.",
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
assert!(parse_settings_yaml("a: [").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -700,6 +700,47 @@ pub async fn create_http_request(
|
||||
workspace_id: &str,
|
||||
api_authed: &ApiAuthed,
|
||||
body_json: Option<Value>,
|
||||
) -> BackendResult<reqwest::Response> {
|
||||
// Scope the minted JWT to the proxied route so a scope-restricted MCP token
|
||||
// can't be widened into a full-privilege blank check. See
|
||||
// `jwt_scopes_for_proxied_route`.
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
|
||||
let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?;
|
||||
send_as_caller(method, url, workspace_id, api_authed, body_json, scopes).await
|
||||
}
|
||||
|
||||
/// A JWT that acts as `api_authed`, carrying `scopes`; `None` is the caller's full rights.
|
||||
pub(crate) async fn mint_caller_jwt(
|
||||
workspace_id: &str,
|
||||
api_authed: &ApiAuthed,
|
||||
scopes: Option<Vec<String>>,
|
||||
) -> BackendResult<String> {
|
||||
// Carry the caller's job provenance into the JWT: a job's WM_TOKEN is capped at
|
||||
// workspace admin (GHSA-hfh4-cx4h-3fcr), and dropping `job_id` here would re-mint an
|
||||
// uncapped token that satisfies require_super_admin / require_devops_role.
|
||||
create_jwt_token(
|
||||
Authed::from(api_authed.clone()),
|
||||
workspace_id,
|
||||
3600,
|
||||
api_authed.job_id,
|
||||
None,
|
||||
None,
|
||||
scopes,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
|
||||
}
|
||||
|
||||
/// Call the API as `api_authed` with a JWT carrying `scopes`; `None` is the caller's
|
||||
/// full rights, for a server whose own code picks every route it calls.
|
||||
pub async fn send_as_caller(
|
||||
method: &str,
|
||||
url: &str,
|
||||
workspace_id: &str,
|
||||
api_authed: &ApiAuthed,
|
||||
body_json: Option<Value>,
|
||||
scopes: Option<Vec<String>>,
|
||||
) -> BackendResult<reqwest::Response> {
|
||||
let client = &HTTP_CLIENT;
|
||||
let mut request_builder = match method {
|
||||
@@ -716,29 +757,7 @@ pub async fn create_http_request(
|
||||
}
|
||||
};
|
||||
|
||||
// Scope the minted JWT to the proxied route so a scope-restricted MCP token
|
||||
// can't be widened into a full-privilege blank check. See
|
||||
// `jwt_scopes_for_proxied_route`.
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
|
||||
let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?;
|
||||
|
||||
// Add authorization header. Carry the caller's job provenance into the proxy
|
||||
// JWT: a job's WM_TOKEN is capped at workspace admin (GHSA-hfh4-cx4h-3fcr), and
|
||||
// dropping `job_id` here would re-mint an uncapped token that satisfies
|
||||
// require_super_admin / require_devops_role on the proxied route.
|
||||
let authed = Authed::from(api_authed.clone());
|
||||
let token = create_jwt_token(
|
||||
authed,
|
||||
workspace_id,
|
||||
3600,
|
||||
api_authed.job_id,
|
||||
None,
|
||||
None,
|
||||
scopes,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
let token = mint_caller_jwt(workspace_id, api_authed, scopes).await?;
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
|
||||
|
||||
// Add body if present
|
||||
|
||||
@@ -20,9 +20,26 @@ pub struct McpScopeConfig {
|
||||
pub granular: bool,
|
||||
/// Hub app filter (if any)
|
||||
pub hub_apps: Option<String>,
|
||||
/// The workspaces a token without a bound workspace may act in
|
||||
/// (`mcp:workspaces:a,b`); `None` is every workspace its owner belongs to.
|
||||
pub workspaces: Option<Vec<String>>,
|
||||
/// `mcp:read_run`: reads, plus running deployed scripts and flows, and nothing else.
|
||||
pub read_run: bool,
|
||||
/// `mcp:read_only`: reads alone, as the token's read-only flag does. An OAuth grant
|
||||
/// carries scopes and no flag, so this is how a sign-in asks for read-only access.
|
||||
pub read_only: bool,
|
||||
/// No endpoint tools on any URL: set by `mcp:read_only`, `mcp:read_run` and
|
||||
/// `mcp:runnables_as_tools`, and by the server on `/api/mcp` for a selection.
|
||||
pub no_endpoints: bool,
|
||||
}
|
||||
|
||||
impl McpScopeConfig {
|
||||
pub fn allows_workspace(&self, workspace_id: &str) -> bool {
|
||||
self.workspaces
|
||||
.as_ref()
|
||||
.is_none_or(|ws| ws.iter().any(|w| w == workspace_id))
|
||||
}
|
||||
|
||||
/// Whether the token grants access to *any* concrete resource of this type by
|
||||
/// path. Used to decide whether to advertise the run-by-path tools in
|
||||
/// multi-workspace mode (a `mcp:scripts:*`-only token should see
|
||||
@@ -68,6 +85,21 @@ impl McpScopeConfig {
|
||||
/// semantics), this requires every requested pattern to be covered by some
|
||||
/// caller pattern — so `mcp:scripts:f/x` cannot widen into `mcp:scripts:*`.
|
||||
pub fn contains(&self, requested: &McpScopeConfig) -> bool {
|
||||
if let Some(caller_ws) = self.workspaces.as_ref() {
|
||||
match requested.workspaces.as_ref() {
|
||||
Some(req_ws) if req_ws.iter().all(|w| caller_ws.contains(w)) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if self.read_only && !requested.read_only {
|
||||
return false;
|
||||
}
|
||||
if self.read_run && !(requested.read_run || requested.read_only) {
|
||||
return false;
|
||||
}
|
||||
if self.no_endpoints && !requested.no_endpoints {
|
||||
return false;
|
||||
}
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
@@ -125,6 +157,10 @@ fn pattern_covers(caller: &str, requested: &str) -> bool {
|
||||
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Marks a selection made as "scripts and flows as tools": it gets no endpoint tools on any
|
||||
/// URL, which an older selection, carrying none of the newer scopes, still does.
|
||||
pub const RUNNABLES_AS_TOOLS_SCOPE: &str = "mcp:runnables_as_tools";
|
||||
|
||||
/// Parse MCP scopes from token scope strings
|
||||
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
let mut config = McpScopeConfig::default();
|
||||
@@ -180,6 +216,31 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if scope == "mcp:read_run" {
|
||||
config.read_run = true;
|
||||
config.no_endpoints = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if scope == "mcp:read_only" {
|
||||
config.read_only = true;
|
||||
config.no_endpoints = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if scope == RUNNABLES_AS_TOOLS_SCOPE {
|
||||
config.no_endpoints = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ids) = scope.strip_prefix("mcp:workspaces:") {
|
||||
config
|
||||
.workspaces
|
||||
.get_or_insert_with(Vec::new)
|
||||
.extend(parse_resource_list(ids));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(resources) = scope.strip_prefix("mcp:endpoints:") {
|
||||
// New granular endpoint scope: mcp:endpoints:name1,name2
|
||||
config.endpoints.extend(parse_resource_list(resources));
|
||||
@@ -409,4 +470,38 @@ mod tests {
|
||||
// mcp:all grants all endpoints.
|
||||
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:endpoints:getResource"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspaces_scope() {
|
||||
let two = cfg(&["mcp:all", "mcp:workspaces:dev,staging"]);
|
||||
assert!(two.allows_workspace("dev") && !two.allows_workspace("prod"));
|
||||
assert!(cfg(&["mcp:all"]).allows_workspace("prod"));
|
||||
// A workspace list only narrows: it cannot be dropped or widened by a grant.
|
||||
assert!(two.contains(&cfg(&["mcp:all", "mcp:workspaces:dev"])));
|
||||
assert!(!two.contains(&cfg(&["mcp:all", "mcp:workspaces:dev,prod"])));
|
||||
assert!(!two.contains(&cfg(&["mcp:all"])));
|
||||
assert!(cfg(&["mcp:all"]).contains(&two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_run_scope_only_narrows() {
|
||||
let read_run = cfg(&["mcp:all", "mcp:read_run"]);
|
||||
assert!(read_run.read_run && read_run.all);
|
||||
assert!(read_run.contains(&cfg(&["mcp:all", "mcp:read_run"])));
|
||||
assert!(!read_run.contains(&cfg(&["mcp:all"])));
|
||||
assert!(read_run.contains(&cfg(&["mcp:all", "mcp:read_only"])));
|
||||
let read_only = cfg(&["mcp:all", "mcp:read_only"]);
|
||||
assert!(!read_only.contains(&cfg(&["mcp:all", "mcp:read_run"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runnables_as_tools_scope_only_narrows() {
|
||||
let fenced = cfg(&["mcp:favorites", "mcp:runnables_as_tools"]);
|
||||
assert!(fenced.no_endpoints);
|
||||
assert!(cfg(&["mcp:all", "mcp:read_only"]).no_endpoints);
|
||||
assert!(cfg(&["mcp:all", "mcp:read_run"]).no_endpoints);
|
||||
assert!(fenced.contains(&cfg(&["mcp:favorites", "mcp:runnables_as_tools"])));
|
||||
assert!(!fenced.contains(&cfg(&["mcp:favorites"])));
|
||||
assert!(cfg(&["mcp:all"]).contains(&fenced));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ pub struct WorkspaceId(pub String);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MultiWorkspaceMcp;
|
||||
|
||||
/// Marker extension: the tools server lists a token's scripts and flows and no endpoint
|
||||
/// tools. Set on `/api/mcp`, where the full toolset is what reaches the API.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NoEndpointTools;
|
||||
|
||||
/// Raw bearer token wrapper for Axum extensions. In multi-workspace mode the
|
||||
/// runner needs the raw token to re-resolve auth for each requested workspace.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! (typically windmill-api) to provide the actual functionality for the MCP server.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rmcp::model::{CallToolResult, Tool};
|
||||
use rmcp::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -16,6 +17,15 @@ use crate::server::endpoints::EndpointTool;
|
||||
/// Result type for backend operations using rmcp's ErrorData directly
|
||||
pub type BackendResult<T> = Result<T, ErrorData>;
|
||||
|
||||
/// What a v2 token may do: read, read and run deployed scripts and flows (`mcp:read_run`),
|
||||
/// or everything its owner can.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SessionAccess {
|
||||
ReadOnly,
|
||||
ReadAndRun,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// What the backend needs about the HTTP request a tool call arrived on, in order
|
||||
/// to hand a runnable the headers of the call that triggered it.
|
||||
pub struct McpRequest<'a> {
|
||||
@@ -234,4 +244,35 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
|
||||
|
||||
/// Get all available endpoint tools
|
||||
fn all_endpoint_tools(&self) -> Vec<EndpointTool>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Workspace server (`SessionRunner`): the AI-session toolset
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The instructions a client reaching one workspace receives on connecting, built for
|
||||
/// this user.
|
||||
async fn session_instructions(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
) -> BackendResult<String>;
|
||||
|
||||
/// The instructions for a client reaching several workspaces: what holds in all of them.
|
||||
fn generic_session_instructions(&self) -> String;
|
||||
|
||||
/// What `generic_session_instructions` leaves out, for one workspace.
|
||||
async fn workspace_context(&self, auth: &Self::Auth, workspace_id: &str) -> String;
|
||||
|
||||
/// The tools a token with this access is offered.
|
||||
fn session_tools(&self, access: SessionAccess) -> Vec<Tool>;
|
||||
|
||||
async fn call_session_tool(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
args: Value,
|
||||
access: SessionAccess,
|
||||
origin: Option<&str>,
|
||||
) -> BackendResult<CallToolResult>;
|
||||
}
|
||||
|
||||
@@ -113,9 +113,27 @@ pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool {
|
||||
return mcp_tool;
|
||||
}
|
||||
|
||||
// Surface the requirement in the prose description too (the schema is
|
||||
// authoritative, but some models/clients lean on the text). Kept terse — this
|
||||
// repeats across every workspace-scoped tool in the list.
|
||||
if let Some(desc) = mcp_tool.description.take() {
|
||||
mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into());
|
||||
} else {
|
||||
mcp_tool.description = Some("Requires `workspace_id`.".into());
|
||||
}
|
||||
with_workspace_id_arg(mcp_tool)
|
||||
}
|
||||
|
||||
/// The tool with a required `workspace_id` argument, which the caller strips before
|
||||
/// dispatching.
|
||||
pub fn with_workspace_id_arg(mut mcp_tool: Tool) -> Tool {
|
||||
let mut schema = (*mcp_tool.input_schema).clone();
|
||||
|
||||
if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
|
||||
if let Some(props) = schema
|
||||
.entry("properties")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
{
|
||||
props.insert(
|
||||
"workspace_id".to_string(),
|
||||
serde_json::json!({
|
||||
@@ -136,15 +154,6 @@ pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the requirement in the prose description too (the schema is
|
||||
// authoritative, but some models/clients lean on the text). Kept terse — this
|
||||
// repeats across every workspace-scoped tool in the list.
|
||||
if let Some(desc) = mcp_tool.description.take() {
|
||||
mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into());
|
||||
} else {
|
||||
mcp_tool.description = Some("Requires `workspace_id`.".into());
|
||||
}
|
||||
|
||||
mcp_tool.input_schema = Arc::new(schema);
|
||||
mcp_tool
|
||||
}
|
||||
|
||||
@@ -9,16 +9,18 @@ pub mod backend;
|
||||
pub mod endpoints;
|
||||
mod list_changes;
|
||||
pub mod runner;
|
||||
mod session;
|
||||
pub mod tools;
|
||||
|
||||
// Re-export main types
|
||||
pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo};
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter};
|
||||
pub use crate::common::types::{McpToken, MultiWorkspaceMcp, NoEndpointTools, WorkspaceInfo};
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter, SessionAccess};
|
||||
pub use endpoints::{
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only,
|
||||
list_workspaces_tool, non_empty_body_fields, EndpointTool,
|
||||
list_workspaces_tool, non_empty_body_fields, with_workspace_id_arg, EndpointTool,
|
||||
};
|
||||
pub use runner::{has_endpoint_path_policy, Runner};
|
||||
pub use runner::{has_endpoint_path_policy, Runner, MAX_TOOL_RESULT_BYTES};
|
||||
pub use session::SessionRunner;
|
||||
pub use tools::create_tool_from_item;
|
||||
|
||||
// Re-export rmcp types for convenience
|
||||
@@ -26,8 +28,8 @@ pub use rmcp::handler::server::ServerHandler;
|
||||
pub use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams,
|
||||
InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
|
||||
ToolAnnotations,
|
||||
ListToolsResult, MetaObject, PaginatedRequestParams, ProtocolVersion, ServerCapabilities,
|
||||
ServerInfo, Tool, ToolAnnotations,
|
||||
};
|
||||
pub use rmcp::service::{RequestContext, RoleServer};
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::common::transform::{
|
||||
short_name_last_segments,
|
||||
};
|
||||
use crate::common::types::{
|
||||
McpToken, MultiWorkspaceMcp, ResourceInfo, SchemaType, ToolableItem, WorkspaceId,
|
||||
McpToken, MultiWorkspaceMcp, NoEndpointTools, ResourceInfo, SchemaType, ToolableItem,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter};
|
||||
use crate::server::endpoints::{
|
||||
@@ -37,7 +38,7 @@ use tokio::sync::broadcast;
|
||||
/// Protocol revisions this server is willing to speak. `2026-07-28` is served
|
||||
/// statelessly with per-request metadata; the older revisions keep the
|
||||
/// `initialize` handshake, so both eras are answered on the same endpoint.
|
||||
const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[
|
||||
pub(super) const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[
|
||||
ProtocolVersion::V_2024_11_05,
|
||||
ProtocolVersion::V_2025_03_26,
|
||||
ProtocolVersion::V_2025_06_18,
|
||||
@@ -53,12 +54,12 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[
|
||||
/// covers only some of what the listing depends on (not resources, favorites or
|
||||
/// permissions), lags by up to a poll interval, and reaches only clients that hold a
|
||||
/// `subscriptions/listen` stream.
|
||||
const LIST_TTL_MS: u64 = 0;
|
||||
pub(super) const LIST_TTL_MS: u64 = 0;
|
||||
|
||||
/// Every listing is filtered by the caller's token scopes and workspace
|
||||
/// membership, so no two callers necessarily see the same tools — a shared
|
||||
/// cache entry would leak one token's view to another.
|
||||
const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private;
|
||||
pub(super) const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private;
|
||||
|
||||
/// How long a `subscriptions/listen` stream waits after a change before notifying,
|
||||
/// collecting the rest of the burst into the same notification.
|
||||
@@ -70,9 +71,9 @@ use http::request::Parts as HttpParts;
|
||||
/// Maximum size in bytes for tool result text content.
|
||||
/// Anthropic enforces a 25,000 token limit per tool result.
|
||||
/// At ~3.5 bytes/token, 87,500 bytes is a safe upper bound.
|
||||
const MAX_TOOL_RESULT_BYTES: usize = 87_500;
|
||||
pub const MAX_TOOL_RESULT_BYTES: usize = 87_500;
|
||||
|
||||
fn truncate_tool_result(text: String) -> String {
|
||||
pub(super) fn truncate_tool_result(text: String) -> String {
|
||||
if text.len() <= MAX_TOOL_RESULT_BYTES {
|
||||
return text;
|
||||
}
|
||||
@@ -103,7 +104,7 @@ impl<B: McpBackend> Clone for Runner<B> {
|
||||
|
||||
/// Whether the request targets one bound workspace or spans every workspace the
|
||||
/// token can access.
|
||||
enum McpMode {
|
||||
pub(super) enum McpMode {
|
||||
/// A single workspace, resolved from the URL path or the token's bound
|
||||
/// workspace. Tools operate against this workspace implicitly.
|
||||
Single(String),
|
||||
@@ -114,10 +115,12 @@ enum McpMode {
|
||||
}
|
||||
|
||||
/// Everything a request carries besides its MCP payload.
|
||||
struct McpContext<A> {
|
||||
auth: A,
|
||||
mode: McpMode,
|
||||
headers: http::HeaderMap,
|
||||
pub(super) struct McpContext<A> {
|
||||
pub(super) auth: A,
|
||||
pub(super) mode: McpMode,
|
||||
pub(super) headers: http::HeaderMap,
|
||||
/// See `McpScopeConfig::no_endpoints`.
|
||||
pub(super) no_endpoints: bool,
|
||||
}
|
||||
|
||||
impl<B: McpBackend> Runner<B> {
|
||||
@@ -128,7 +131,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
|
||||
/// Extract authentication, the workspace mode and the HTTP request itself
|
||||
/// from the request context
|
||||
fn extract_context(
|
||||
pub(super) fn extract_context(
|
||||
context: &RequestContext<RoleServer>,
|
||||
) -> Result<McpContext<B::Auth>, ErrorData> {
|
||||
let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| {
|
||||
@@ -165,13 +168,31 @@ impl<B: McpBackend> Runner<B> {
|
||||
ErrorData::internal_error("WorkspaceId not found", None)
|
||||
})
|
||||
.map(|w_id| w_id.0.clone())?;
|
||||
// A token without a bound workspace reaches a workspace's own URL too.
|
||||
let scope_config = parse_mcp_scopes(auth.scopes().unwrap_or(&[]))
|
||||
.map_err(|e| ErrorData::internal_error(e, None))?;
|
||||
if !scope_config.allows_workspace(&workspace_id) {
|
||||
return Err(workspace_not_granted(&workspace_id));
|
||||
}
|
||||
McpMode::Single(workspace_id)
|
||||
};
|
||||
|
||||
Ok(McpContext { auth: auth.clone(), mode, headers: http_parts.headers.clone() })
|
||||
Ok(McpContext {
|
||||
auth: auth.clone(),
|
||||
mode,
|
||||
headers: http_parts.headers.clone(),
|
||||
no_endpoints: http_parts.extensions.get::<NoEndpointTools>().is_some(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn workspace_not_granted(workspace_id: &str) -> ErrorData {
|
||||
ErrorData::invalid_params(
|
||||
format!("This token was not granted workspace '{workspace_id}'"),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// How an endpoint tool interacts with the token's `mcp:scripts:` / `mcp:flows:`
|
||||
/// path scopes. Mirrored in
|
||||
/// `frontend/src/lib/components/mcp/endpointScopePolicy.ts` so the scope picker
|
||||
@@ -301,6 +322,9 @@ fn endpoint_tool_in_scope(
|
||||
scope_config: &crate::common::scope::McpScopeConfig,
|
||||
endpoint_tool: &EndpointTool,
|
||||
) -> bool {
|
||||
if scope_config.no_endpoints {
|
||||
return false;
|
||||
}
|
||||
let endpoint_allowed =
|
||||
!scope_config.granular || scope_config.is_allowed("endpoint", &endpoint_tool.name);
|
||||
match endpoint_path_policy(&endpoint_tool.name) {
|
||||
@@ -327,6 +351,15 @@ fn authorize_endpoint_call(
|
||||
args: &mut Value,
|
||||
read_only: bool,
|
||||
) -> Result<(), ErrorData> {
|
||||
if scope_config.no_endpoints {
|
||||
return Err(ErrorData::invalid_params(
|
||||
format!(
|
||||
"Tool '{}' is not available to this token",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
// A destination the caller omitted, or left empty because it had to fill in every
|
||||
// argument, means "leave the item where it is". Resolved to the path the item is
|
||||
// already at, so the endpoint receives a body naming its own path rather than one
|
||||
@@ -558,14 +591,16 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
let McpContext { auth, mode, .. } = Self::extract_context(&context)?;
|
||||
let McpContext { auth, mode, no_endpoints, .. } = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes to determine what to expose
|
||||
let scopes = auth.scopes().unwrap_or(&[]);
|
||||
let scope_config =
|
||||
let mut scope_config =
|
||||
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
|
||||
scope_config.no_endpoints |= no_endpoints;
|
||||
|
||||
let read_only = auth.read_only();
|
||||
// v1 has no read-and-run access: such a token reads only.
|
||||
let read_only = auth.read_only() || scope_config.read_run || scope_config.read_only;
|
||||
|
||||
match mode {
|
||||
McpMode::Single(workspace_id) => {
|
||||
@@ -586,13 +621,14 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let McpContext { auth, mode, headers } = Self::extract_context(&context)?;
|
||||
let McpContext { auth, mode, headers, no_endpoints } = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
let scopes = auth.scopes().unwrap_or(&[]);
|
||||
let scope_config =
|
||||
let mut scope_config =
|
||||
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
|
||||
let read_only = auth.read_only();
|
||||
scope_config.no_endpoints |= no_endpoints;
|
||||
let read_only = auth.read_only() || scope_config.read_run || scope_config.read_only;
|
||||
|
||||
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
|
||||
let mcp_request = McpRequest { headers: &headers, tool_name: request.name.as_ref() };
|
||||
@@ -1088,11 +1124,12 @@ impl<B: McpBackend> Runner<B> {
|
||||
mut args: Value,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
if name.as_ref() == "list_workspaces" {
|
||||
let workspaces = self
|
||||
let mut workspaces = self
|
||||
.backend
|
||||
.list_accessible_workspaces(base_auth)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
workspaces.retain(|w| scope_config.allows_workspace(&w.id));
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()),
|
||||
)]));
|
||||
@@ -1140,6 +1177,9 @@ impl<B: McpBackend> Runner<B> {
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
if !scope_config.allows_workspace(&workspace_id) {
|
||||
return Err(workspace_not_granted(&workspace_id));
|
||||
}
|
||||
|
||||
let resolved = self
|
||||
.backend
|
||||
@@ -1281,6 +1321,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_without_endpoints_gets_none() {
|
||||
// The scope withholds them on every URL, as the server does on `/api/mcp`.
|
||||
let selection = cfg(&["mcp:favorites", "mcp:endpoints:*", "mcp:runnables_as_tools"]);
|
||||
assert!(!endpoint_tool_in_scope(&selection, &ep("listJobs", "GET")));
|
||||
assert!(
|
||||
authorize_endpoint_call(&selection, &ep("listJobs", "GET"), &mut json!({}), false)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_refuses_non_get_endpoint() {
|
||||
// Reaches the read-only check via mcp:all so scope isn't the blocker.
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! The Windmill MCP server's toolset: an AI session's tools and guidance for an external
|
||||
//! client. `/api/mcp` serves it to tokens with `mcp:all`, and the tools server (`Runner`)
|
||||
//! to tokens scoped to a selection of scripts and flows.
|
||||
//!
|
||||
//! Every tool takes the workspace it acts in, whether the token reaches one workspace or
|
||||
//! several, so a client's tool schemas do not change with the token it signed in with.
|
||||
|
||||
use crate::common::scope::{parse_mcp_scopes, McpScopeConfig};
|
||||
use crate::common::types::WorkspaceInfo;
|
||||
use crate::server::backend::{McpAuth, McpBackend, SessionAccess};
|
||||
use crate::server::endpoints::{list_workspaces_tool, with_workspace_id_arg};
|
||||
use crate::server::runner::{
|
||||
truncate_tool_result, workspace_not_granted, McpContext, McpMode, Runner, LIST_CACHE_SCOPE,
|
||||
LIST_TTL_MS, SUPPORTED_PROTOCOL_VERSIONS,
|
||||
};
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, DiscoverResult,
|
||||
Implementation, InitializeRequestParams, InitializeResult, ListToolsResult,
|
||||
PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `mcp:all`, and no scope from outside `mcp:` that confines the token to some routes
|
||||
/// or paths (`variables:read:f/x/*`): the tools call the API without a route scope, so
|
||||
/// that confinement would be lost here. The job tag fence is carried through instead.
|
||||
fn admits(scopes: &[String], all: bool) -> bool {
|
||||
all && scopes
|
||||
.iter()
|
||||
.all(|s| s.starts_with("mcp:") || s.starts_with("if_jobs:filter_tags:"))
|
||||
}
|
||||
|
||||
fn access<A: McpAuth>(auth: &A, scope: &McpScopeConfig) -> SessionAccess {
|
||||
if auth.read_only() || scope.read_only {
|
||||
SessionAccess::ReadOnly
|
||||
} else if scope.read_run {
|
||||
SessionAccess::ReadAndRun
|
||||
} else {
|
||||
SessionAccess::Full
|
||||
}
|
||||
}
|
||||
|
||||
const GET_WORKSPACE_CONTEXT: &str = "get_workspace_context";
|
||||
|
||||
/// Workspaces named in the instructions before the rest are left to `list_workspaces`.
|
||||
const MAX_LISTED_WORKSPACES: usize = 40;
|
||||
|
||||
fn get_workspace_context_tool() -> Tool {
|
||||
let schema = serde_json::json!({ "type": "object", "properties": {} });
|
||||
with_workspace_id_arg(
|
||||
Tool::new(
|
||||
Cow::Borrowed(GET_WORKSPACE_CONTEXT),
|
||||
"Your username in a workspace, the folders you can write to there, and the \
|
||||
workspace's skills and instructions. Call it before your first action in a \
|
||||
workspace.",
|
||||
Arc::new(schema.as_object().unwrap().clone()),
|
||||
)
|
||||
.with_annotations(ToolAnnotations::new().read_only(true)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A request's caller, and the workspaces its token reaches.
|
||||
struct Caller<A> {
|
||||
auth: A,
|
||||
mode: McpMode,
|
||||
scope: McpScopeConfig,
|
||||
/// The scheme and host the client reached, for links to Windmill pages.
|
||||
origin: Option<String>,
|
||||
}
|
||||
|
||||
/// `x-forwarded-*` first, since a proxy in front rewrites the host the request names.
|
||||
fn request_origin(headers: &http::HeaderMap) -> Option<String> {
|
||||
let header = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
let host = header("x-forwarded-host").or_else(|| header("host"))?;
|
||||
let scheme = header("x-forwarded-proto").unwrap_or("http");
|
||||
Some(format!("{scheme}://{host}"))
|
||||
}
|
||||
|
||||
pub struct SessionRunner<B: McpBackend> {
|
||||
backend: Arc<B>,
|
||||
}
|
||||
|
||||
impl<B: McpBackend> Clone for SessionRunner<B> {
|
||||
fn clone(&self) -> Self {
|
||||
Self { backend: self.backend.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: McpBackend> SessionRunner<B> {
|
||||
pub fn new(backend: B) -> Self {
|
||||
Self { backend: Arc::new(backend) }
|
||||
}
|
||||
|
||||
/// This server writes drafts and deploys, so it takes the scope that already reaches
|
||||
/// the create/update endpoint tools and nothing narrower: a token scoped to a few
|
||||
/// scripts was never consented for authoring.
|
||||
fn caller(context: &RequestContext<RoleServer>) -> Result<Caller<B::Auth>, ErrorData> {
|
||||
let McpContext { auth, mode, headers, .. } = Runner::<B>::extract_context(context)?;
|
||||
let scope = parse_mcp_scopes(auth.scopes().unwrap_or(&[]))
|
||||
.map_err(|e| ErrorData::internal_error(e, None))?;
|
||||
if !admits(auth.scopes().unwrap_or(&[]), scope.all) {
|
||||
return Err(ErrorData::invalid_request(
|
||||
"This server needs an `mcp:all` token with no other route or path scope",
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(Caller { auth, mode, scope, origin: request_origin(&headers) })
|
||||
}
|
||||
|
||||
async fn workspaces(&self, caller: &Caller<B::Auth>) -> Result<Vec<WorkspaceInfo>, ErrorData> {
|
||||
let mut workspaces = self
|
||||
.backend
|
||||
.list_accessible_workspaces(&caller.auth)
|
||||
.await?;
|
||||
match &caller.mode {
|
||||
McpMode::Single(bound) => workspaces.retain(|w| &w.id == bound),
|
||||
McpMode::Multi(_) => workspaces.retain(|w| caller.scope.allows_workspace(&w.id)),
|
||||
}
|
||||
Ok(workspaces)
|
||||
}
|
||||
|
||||
/// The caller as they are in `workspace_id`, if the token reaches it.
|
||||
async fn auth_in(
|
||||
&self,
|
||||
caller: &Caller<B::Auth>,
|
||||
workspace_id: &str,
|
||||
) -> Result<B::Auth, ErrorData> {
|
||||
match &caller.mode {
|
||||
McpMode::Single(bound) if bound == workspace_id => Ok(caller.auth.clone()),
|
||||
McpMode::Multi(token) if caller.scope.allows_workspace(workspace_id) => {
|
||||
self.backend
|
||||
.resolve_workspace_auth(token, workspace_id)
|
||||
.await
|
||||
}
|
||||
_ => Err(workspace_not_granted(workspace_id)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn info_for(
|
||||
&self,
|
||||
context: &RequestContext<RoleServer>,
|
||||
) -> Result<ServerInfo, ErrorData> {
|
||||
let caller = Self::caller(context)?;
|
||||
let workspaces = self.workspaces(&caller).await?;
|
||||
let instructions = match workspaces.as_slice() {
|
||||
[only] => {
|
||||
let auth = self.auth_in(&caller, &only.id).await?;
|
||||
format!(
|
||||
"Every tool takes a `workspace_id`: pass `{}`, the workspace this connection \
|
||||
works in.\n\n{}",
|
||||
only.id,
|
||||
self.backend.session_instructions(&auth, &only.id).await?
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let mut listed = workspaces
|
||||
.iter()
|
||||
.take(MAX_LISTED_WORKSPACES)
|
||||
.map(|w| format!("`{}` ({})", w.id, w.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if workspaces.len() > MAX_LISTED_WORKSPACES {
|
||||
listed.push_str(&format!(
|
||||
" (+{} more)",
|
||||
workspaces.len() - MAX_LISTED_WORKSPACES
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"This connection works across several Windmill workspaces: {listed}. Every \
|
||||
tool takes a `workspace_id` naming the one it acts in; list_workspaces lists \
|
||||
them. Before your first action in a workspace, call get_workspace_context \
|
||||
with its id: it gives your username there (the `<username>` in the paths \
|
||||
below), the folders you can write to, and the workspace's skills and \
|
||||
instructions. When the user has not said which workspace, ask rather than \
|
||||
guess, and name the workspace whenever you change something.\n\n{}",
|
||||
self.backend.generic_session_instructions()
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(self.get_info().with_instructions(instructions))
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: McpBackend> ServerHandler for SessionRunner<B> {
|
||||
/// Without instructions: those are built per caller, in `initialize` and `discover`.
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let server_info = Implementation::new("windmill", env!("CARGO_PKG_VERSION"))
|
||||
.with_title("Windmill")
|
||||
.with_website_url("https://windmill.dev");
|
||||
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(server_info)
|
||||
}
|
||||
|
||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||
Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS)
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
request: InitializeRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
context.peer.set_peer_info(request.clone());
|
||||
let mut info = self.info_for(&context).await?;
|
||||
if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) {
|
||||
info.protocol_version = request.protocol_version;
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
async fn discover(
|
||||
&self,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<DiscoverResult, ErrorData> {
|
||||
Ok(DiscoverResult::from_server_info(
|
||||
SUPPORTED_PROTOCOL_VERSIONS.to_vec(),
|
||||
self.info_for(&context).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
let caller = Self::caller(&context)?;
|
||||
let mut tools = vec![list_workspaces_tool()];
|
||||
// A bound token's one workspace is described in the instructions already.
|
||||
if let McpMode::Multi(_) = caller.mode {
|
||||
tools.push(get_workspace_context_tool());
|
||||
}
|
||||
tools.extend(
|
||||
self.backend
|
||||
.session_tools(access(&caller.auth, &caller.scope))
|
||||
.into_iter()
|
||||
.map(with_workspace_id_arg),
|
||||
);
|
||||
// Without these a `2026-07-28` client rejects the whole listing.
|
||||
Ok(ListToolsResult::with_all_items(tools)
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let caller = Self::caller(&context)?;
|
||||
if request.name == "list_workspaces" {
|
||||
let workspaces = self.workspaces(&caller).await?;
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
serde_json::to_string_pretty(&workspaces).unwrap_or_default(),
|
||||
)])
|
||||
.into());
|
||||
}
|
||||
let mut args = request.arguments.unwrap_or_default();
|
||||
let workspace_id =
|
||||
match args.remove("workspace_id") {
|
||||
Some(Value::String(w)) if !w.is_empty() => w,
|
||||
_ => return Err(ErrorData::invalid_params(
|
||||
"Missing required `workspace_id` argument. Call list_workspaces to see the \
|
||||
workspaces you can act in.",
|
||||
None,
|
||||
)),
|
||||
};
|
||||
let auth = self.auth_in(&caller, &workspace_id).await?;
|
||||
if request.name == GET_WORKSPACE_CONTEXT {
|
||||
let text = self.backend.workspace_context(&auth, &workspace_id).await;
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into());
|
||||
}
|
||||
let mut result = self
|
||||
.backend
|
||||
.call_session_tool(
|
||||
&auth,
|
||||
&workspace_id,
|
||||
request.name.as_ref(),
|
||||
Value::Object(args),
|
||||
access(&auth, &caller.scope),
|
||||
caller.origin.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
for block in result.content.iter_mut() {
|
||||
if let Some(text) = block.as_text() {
|
||||
*block = ContentBlock::text(truncate_tool_result(text.text.clone()));
|
||||
}
|
||||
}
|
||||
Ok(result.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn origin_prefers_forwarded_headers() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert("host", "backend:8000".parse().unwrap());
|
||||
assert_eq!(
|
||||
request_origin(&headers).as_deref(),
|
||||
Some("http://backend:8000")
|
||||
);
|
||||
headers.insert("x-forwarded-host", "wm.example.com, proxy".parse().unwrap());
|
||||
headers.insert("x-forwarded-proto", "https".parse().unwrap());
|
||||
assert_eq!(
|
||||
request_origin(&headers).as_deref(),
|
||||
Some("https://wm.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_unnarrowed_mcp_all_token_is_admitted() {
|
||||
let admitted = |scopes: &[&str]| {
|
||||
let scopes: Vec<String> = scopes.iter().map(|s| s.to_string()).collect();
|
||||
let all = parse_mcp_scopes(&scopes).is_ok_and(|c| c.all);
|
||||
admits(&scopes, all)
|
||||
};
|
||||
assert!(admitted(&["mcp:all"]));
|
||||
assert!(admitted(&["mcp:all", "if_jobs:filter_tags:gpu"]));
|
||||
assert!(admitted(&["mcp:all", "mcp:workspaces:dev"]));
|
||||
assert!(!admitted(&["mcp:all", "variables:read:u/a/safe/*"]));
|
||||
assert!(!admitted(&["mcp:all:f/x/*"]));
|
||||
assert!(!admitted(&["mcp:favorites"]));
|
||||
assert!(!admitted(&[]));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"package": "svelte-package -o package && node scripts/package-system-prompts.js && node scripts/fix-svelte-module-imports.js",
|
||||
"generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false",
|
||||
"generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript",
|
||||
"gen:mcp-session": "WM_GEN_MCP_SESSION=1 vitest run --project server src/lib/components/copilot/chat/global/mcpSessionGen.test.ts",
|
||||
"pretest": "tsc --incremental -p tests/tsconfig.json",
|
||||
"filter-classes": "node filterTailwindClasses.js",
|
||||
"test:unit": "vitest",
|
||||
|
||||
@@ -350,9 +350,10 @@
|
||||
} else {
|
||||
goto(resolvedRd ?? '/')
|
||||
}
|
||||
// See (root)/+layout.svelte for why /projects/import skips the picker.
|
||||
// See (root)/+layout.svelte for why these skip the picker.
|
||||
} else if (
|
||||
resolvedRd?.startsWith('/user/workspaces') ||
|
||||
resolvedRd?.startsWith('/oauth/mcp_authorize') ||
|
||||
resolvedRd?.startsWith('/projects/import')
|
||||
) {
|
||||
goto(resolvedRd)
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import TokensTable from './settings/TokensTable.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, tick } from 'svelte'
|
||||
import CreateToken from './settings/CreateToken.svelte'
|
||||
import UserInfoSettings from './settings/UserInfoSettings.svelte'
|
||||
import AIUserSettings from './settings/AIUserSettings.svelte'
|
||||
import AiUsagePanel from './workspaceSettings/AiUsagePanel.svelte'
|
||||
@@ -36,14 +37,18 @@
|
||||
}: Props = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let openWithMcpMode = $state(false)
|
||||
let mcpSection: HTMLDivElement | undefined = $state()
|
||||
let tokensTable: TokensTable | undefined = $state()
|
||||
let darkVariant = $state<DarkModeVariant>(getDarkModeVariant())
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export function openDrawer(mcpMode: boolean = false) {
|
||||
openWithMcpMode = mcpMode
|
||||
export async function openDrawer(mcpMode: boolean = false) {
|
||||
drawer?.openDrawer()
|
||||
if (mcpMode) {
|
||||
await tick()
|
||||
mcpSection?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDrawer() {
|
||||
@@ -120,10 +125,22 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showMcpMode}
|
||||
<div bind:this={mcpSection} class="flex flex-col p-4 border border-border-light rounded-md">
|
||||
<h2 class="text-emphasis text-sm font-semibold mb-1">
|
||||
Build and run from your AI client
|
||||
</h2>
|
||||
<div class="text-xs text-secondary mb-2">
|
||||
Connect Claude, ChatGPT or Cursor to Windmill over MCP: build scripts, flows and apps,
|
||||
run your automations and check their runs, from your chat.
|
||||
</div>
|
||||
<CreateToken mcpOnly onTokenCreated={() => tokensTable?.listTokens()} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grow min-h-0">
|
||||
<TokensTable
|
||||
{showMcpMode}
|
||||
{openWithMcpMode}
|
||||
bind:this={tokensTable}
|
||||
defaultNewTokenLabel={newTokenLabel}
|
||||
defaultNewTokenWorkspace={newTokenWorkspace}
|
||||
{scopes}
|
||||
|
||||
@@ -329,7 +329,10 @@ const INSTRUCTION_SUBJECTS = [
|
||||
// annotated scripts in a folder, so it gets authoring guidance, not a CRUD type.
|
||||
// `workspace_settings` is the settings document an admin reads and changes.
|
||||
const INSTRUCTION_SUBJECTS_EXTRA = ['datatable', 'pipeline', 'workspace_settings'] as const
|
||||
const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const
|
||||
export const ALL_INSTRUCTION_SUBJECTS = [
|
||||
...INSTRUCTION_SUBJECTS,
|
||||
...INSTRUCTION_SUBJECTS_EXTRA
|
||||
] as const
|
||||
const MAX_LIST_LIMIT = 100
|
||||
type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
|
||||
type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0]
|
||||
@@ -1428,7 +1431,8 @@ type FolderPromptContext = {
|
||||
function buildFolderGuidance(
|
||||
username: string,
|
||||
ctx: FolderPromptContext | undefined,
|
||||
canCreateFolder: boolean
|
||||
canCreateFolder: boolean,
|
||||
external = false
|
||||
): string {
|
||||
if (!ctx) return ''
|
||||
const MAX = 40
|
||||
@@ -1456,7 +1460,7 @@ function buildFolderGuidance(
|
||||
const lines: string[] = []
|
||||
if (writable.length > 0) {
|
||||
lines.push(
|
||||
`- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}`
|
||||
`- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use${external ? '' : ' (askUserQuestion)'} rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}`
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
@@ -1484,7 +1488,10 @@ const buildGlobalSystemPrompt = (
|
||||
folderCtx?: FolderPromptContext,
|
||||
skills: AiSkillListItem[] = [],
|
||||
mcpServers: McpServer[] = [],
|
||||
access?: SessionAccess
|
||||
access?: SessionAccess,
|
||||
// The prompt as an external MCP client reads it: the same rules, minus the tools and
|
||||
// surfaces only the Windmill UI has (pages, the run form, questions, the open editor).
|
||||
external = false
|
||||
) => {
|
||||
// Each `can*` mirrors the capability the matching tools declare in `requires`, so a
|
||||
// rule cannot outlive the tool it describes. An unresolved profile keeps every block.
|
||||
@@ -1555,7 +1562,7 @@ const buildGlobalSystemPrompt = (
|
||||
// Each gated block carries its own leading newline, so dropping one leaves no blank
|
||||
// line behind and a full-access prompt is byte-for-byte the ungated text.
|
||||
const when = (cond: boolean, block: string) => (cond ? block : '')
|
||||
const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder)
|
||||
const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder, external)
|
||||
const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : ''
|
||||
// A grant to `g/<group>` reaches the user only through membership, which no permission
|
||||
// list shows. Outside the folder guidance, which a role that cannot draft never gets.
|
||||
@@ -1599,8 +1606,45 @@ const buildGlobalSystemPrompt = (
|
||||
get(enterpriseLicense) ? 'Enterprise Edition' : 'Community Edition'
|
||||
}. Use that to judge whether a feature is available before promising it.`
|
||||
: ''
|
||||
// The core rules, placed differently for an MCP client (see `coreRules`).
|
||||
const draftsOnlyRule = when(
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.`
|
||||
)
|
||||
const findItemsRule =
|
||||
'- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.'
|
||||
const deployRule = when(
|
||||
canWriteAnyDraft && canDeployAnyKind,
|
||||
`
|
||||
- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.`
|
||||
)
|
||||
const instructionsRule =
|
||||
'- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.'
|
||||
const testDraftRule = `- After creating or editing a script${canWriteFlowDraft ? ' or flow' : ''} draft, run ${testRunTools} with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.`
|
||||
const testRunList = canRunFlowPreview
|
||||
? 'test_run_script, test_run_flow, test_run_step'
|
||||
: 'test_run_script, test_run_step'
|
||||
const runRule = `${when(
|
||||
canRunPreview,
|
||||
`
|
||||
- Default to ${testRunTools} for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. ${
|
||||
external
|
||||
? `${testRunList}, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.`
|
||||
: `${testRunList}, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.`
|
||||
}`
|
||||
)}${when(
|
||||
!canRunPreview,
|
||||
`
|
||||
- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.`
|
||||
)}`
|
||||
|
||||
return `You are Windmill's global workspace assistant.
|
||||
const intro = `${external ? 'You are working in a Windmill workspace through its MCP server.' : "You are Windmill's global workspace assistant."}${when(
|
||||
external,
|
||||
`
|
||||
|
||||
The user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call "the preview"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.`
|
||||
)}
|
||||
|
||||
The current user's workspace username is "${username}".${instanceLine}
|
||||
|
||||
@@ -1632,7 +1676,8 @@ ${
|
||||
: ''
|
||||
}.`
|
||||
)}`
|
||||
}${when(
|
||||
}`
|
||||
const paths = `${when(
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
|
||||
@@ -1648,24 +1693,27 @@ Path conventions:
|
||||
)}${when(
|
||||
canCreateFolder && !canWriteAnyDraft,
|
||||
'\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.'
|
||||
)}
|
||||
)}`
|
||||
// An MCP client may keep only the first 4096 characters of its instructions, so there
|
||||
// these rules come before the path conventions and are left out of the later list.
|
||||
const coreRules = `${draftsOnlyRule}
|
||||
${findItemsRule}${deployRule}${when(canWriteDraft, `\n${instructionsRule}`)}${when(
|
||||
canRunPreview && canWriteDraft,
|
||||
`\n${testDraftRule}`
|
||||
)}${runRule}`
|
||||
|
||||
Rules:${when(
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.`
|
||||
)}
|
||||
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
|
||||
return `${intro}${when(external, `\n\nRules:${coreRules}`)}${paths}
|
||||
|
||||
${external ? 'More rules:' : `Rules:${draftsOnlyRule}\n${findItemsRule}`}
|
||||
${groupMembershipRule}- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed.
|
||||
- An agent, as the Agents page lists it, is a resource of type \`ai_agent\`: read it with read_workspace_item type "resource".${when(
|
||||
canDraftResources,
|
||||
" You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there."
|
||||
)}
|
||||
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when(
|
||||
canWriteAnyDraft && canDeployAnyKind,
|
||||
`
|
||||
- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.`
|
||||
)}${when(
|
||||
!external,
|
||||
`
|
||||
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".`
|
||||
)}${activePreviewRule}${when(!external, deployRule)}${when(
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft).${when(
|
||||
@@ -1703,12 +1751,10 @@ ${groupMembershipRule}- Listings and reads return only what this user can see: i
|
||||
- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing.
|
||||
- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API.
|
||||
- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself.
|
||||
${when(canCreateResourceType, '- create_resource_type creates a resource type the workspace lacks. The user confirms the schema in the call itself, so that is where you propose it rather than asking first in chat.\n')}${when(!canCreateResourceType, '- This user cannot create a resource type here, in chat or on the Resources page, since the workspace refuses their direct deployment: when one is needed, give them its name and JSON Schema to pass to a workspace admin, who can add it.\n')}${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
|
||||
${pipelineBullet}`
|
||||
${when(canCreateResourceType, `- create_resource_type creates a resource type the workspace lacks. ${external ? 'Show the user the schema you propose and create it once they agree.' : 'The user confirms the schema in the call itself, so that is where you propose it rather than asking first in chat.'}\n`)}${when(!canCreateResourceType, '- This user cannot create a resource type here, in chat or on the Resources page, since the workspace refuses their direct deployment: when one is needed, give them its name and JSON Schema to pass to a workspace admin, who can add it.\n')}${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}${when(!external, `${instructionsRule}\n`)}${pipelineBullet}`
|
||||
)}${when(
|
||||
canRunPreview && canWriteDraft,
|
||||
`
|
||||
- After creating or editing a script${canWriteFlowDraft ? ' or flow' : ''} draft, run ${testRunTools} with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
|
||||
`${when(!external, `\n${testDraftRule}`)}
|
||||
- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.`
|
||||
)}${when(
|
||||
buildsFlows && (!access || access.has('run_flow_preview')),
|
||||
@@ -1716,23 +1762,21 @@ ${pipelineBullet}`
|
||||
- After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.`
|
||||
)}
|
||||
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run.
|
||||
- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).
|
||||
- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).${when(
|
||||
!external,
|
||||
`
|
||||
- Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself.
|
||||
- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.${when(
|
||||
canWriteDraft,
|
||||
- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.`
|
||||
)}${when(
|
||||
canWriteDraft && !external,
|
||||
`
|
||||
- Do not offer or open the Compare & Deploy page for normal draft review. Only use open_page with page "compare" when the user explicitly asks to deploy a forked workspace's changes to its parent workspace.`
|
||||
)}${when(
|
||||
canRunPreview,
|
||||
)}${when(!external, runRule)}${when(
|
||||
!external,
|
||||
`
|
||||
- Default to ${testRunTools} for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. ${canRunFlowPreview ? 'test_run_script, test_run_flow, test_run_step' : 'test_run_script, test_run_step'}, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.`
|
||||
)}${when(
|
||||
!canRunPreview,
|
||||
`
|
||||
- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.`
|
||||
)}
|
||||
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
|
||||
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
|
||||
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.`
|
||||
)}
|
||||
- Keep context targeted.${
|
||||
previewTools
|
||||
? `${when(
|
||||
@@ -1792,7 +1836,7 @@ Raw apps:
|
||||
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${
|
||||
buildsApps
|
||||
? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.'
|
||||
: 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.'
|
||||
: `When the app has to store data and the user did not say where, settle the data setup with ${external ? 'the user' : 'askUserQuestion'} BEFORE init_app or any SQL (get_instructions subject "app" lists the questions${external ? '' : ', one askUserQuestion each'}), then pass the outcome as init_app's data.`
|
||||
}
|
||||
${when(canDeployAnyKind, '- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.')}`
|
||||
)}
|
||||
@@ -2699,14 +2743,15 @@ ${getScriptPrompt(selected)}`
|
||||
|
||||
async function getFlowInstructions(
|
||||
workspace: string | undefined,
|
||||
composeOnly: boolean
|
||||
composeOnly: boolean,
|
||||
external = false
|
||||
): Promise<string> {
|
||||
const aiAgentProviders = formatAiAgentProvidersPrompt(
|
||||
await getAiAgentProviderCatalog(workspace),
|
||||
{
|
||||
canAskUser: true
|
||||
}
|
||||
)
|
||||
// An external client's catalog is rendered where no workspace is known, so it gets none.
|
||||
const aiAgentProviders = external
|
||||
? ''
|
||||
: formatAiAgentProvidersPrompt(await getAiAgentProviderCatalog(workspace), {
|
||||
canAskUser: true
|
||||
})
|
||||
return `# Global draft flow instructions
|
||||
|
||||
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
|
||||
@@ -2751,7 +2796,11 @@ ${composeOnly ? 'This reference is written for every author. For this user, skip
|
||||
|
||||
type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number]
|
||||
|
||||
function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string {
|
||||
function getAppInstructions(
|
||||
language: ScriptLang | undefined,
|
||||
composeOnly: boolean,
|
||||
external = false
|
||||
): string {
|
||||
// getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with
|
||||
// it: telling the model to re-fetch a reference it is already holding wastes a turn, and
|
||||
// naming the wrong SDK contradicts the text right below the sentence.
|
||||
@@ -2766,7 +2815,7 @@ function getAppInstructions(language: ScriptLang | undefined, composeOnly: boole
|
||||
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${
|
||||
composeOnly
|
||||
? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.'
|
||||
: ' and, for an app that stores data, its `data` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with `askUserQuestion`, one question per call with the suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool.'
|
||||
: ` and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. ${external ? 'Ask the user each of its questions, suggested answer first,' : 'Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first,'} before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool.`
|
||||
} \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
|
||||
${
|
||||
composeOnly
|
||||
@@ -2835,21 +2884,22 @@ function getPipelineInstructions(): string {
|
||||
return getPipelinePrompt()
|
||||
}
|
||||
|
||||
function getInstructions(
|
||||
export function getInstructions(
|
||||
subject: InstructionSubject,
|
||||
language: ScriptLang | undefined,
|
||||
workspace: string | undefined,
|
||||
composeOnly: boolean
|
||||
composeOnly: boolean,
|
||||
external = false
|
||||
): string | Promise<string> {
|
||||
switch (subject) {
|
||||
case 'script':
|
||||
return getScriptInstructions(language)
|
||||
case 'flow':
|
||||
return getFlowInstructions(workspace, composeOnly)
|
||||
return getFlowInstructions(workspace, composeOnly, external)
|
||||
case 'resource':
|
||||
return getResourceInstructions()
|
||||
case 'app':
|
||||
return getAppInstructions(language, composeOnly)
|
||||
return getAppInstructions(language, composeOnly, external)
|
||||
case 'datatable':
|
||||
return getDatatableInstructions(language)
|
||||
case 'pipeline':
|
||||
@@ -9060,8 +9110,19 @@ async function deployDraft(
|
||||
break
|
||||
}
|
||||
case 'resource': {
|
||||
const requestBody = draft.value as any
|
||||
let requestBody = draft.value as any
|
||||
if (await ResourceService.existsResource({ workspace, path })) {
|
||||
// The server resets an agent's identity to whoever deploys, unless told to keep
|
||||
// it: the identity is the deployed one, never the draft's.
|
||||
const deployed = await ResourceService.getResource({ workspace, path })
|
||||
const obo = (deployed.value as any)?.on_behalf_of
|
||||
if (deployed.resource_type === 'ai_agent' && obo) {
|
||||
requestBody = {
|
||||
...requestBody,
|
||||
value: { ...requestBody.value, on_behalf_of: obo },
|
||||
preserve_on_behalf_of: true
|
||||
}
|
||||
}
|
||||
await ResourceService.updateResource({ workspace, path, requestBody })
|
||||
} else {
|
||||
await ResourceService.createResource({ workspace, requestBody })
|
||||
@@ -9500,6 +9561,8 @@ export function prepareGlobalSystemMessage(
|
||||
/** Capabilities of the chat's operating workspace; undefined keeps every block. Must
|
||||
* match the profile the toolset was filtered with, or the prompt names withheld tools. */
|
||||
access?: SessionAccess
|
||||
/** Render for an external MCP client rather than the Windmill chat. */
|
||||
external?: boolean
|
||||
}
|
||||
): ChatCompletionSystemMessageParam {
|
||||
const user = opts?.user ?? get(userStore)
|
||||
@@ -9518,7 +9581,8 @@ export function prepareGlobalSystemMessage(
|
||||
folderCtx,
|
||||
opts?.skills ?? [],
|
||||
opts?.mcpServers ?? [],
|
||||
opts?.access
|
||||
opts?.access,
|
||||
opts?.external ?? false
|
||||
)
|
||||
if (instructions?.workspace?.trim()) {
|
||||
content = `${content}\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n${instructions.workspace.trim()}`
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Generates what the Windmill MCP server's toolset (backend/windmill-api/src/mcp/session)
|
||||
* shows a model, from the same definitions an AI session uses: tool names, descriptions and
|
||||
* input schemas, the system prompt, and the `get_instructions` texts. The server implements
|
||||
* the tools in Rust; everything the model reads comes from here, so the two cannot drift
|
||||
* apart in wording.
|
||||
*
|
||||
* Run `npm run gen:mcp-session` after changing a GLOBAL tool definition, the global system
|
||||
* prompt, or anything under system_prompts/. Without that env var this is the freshness check.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
ALL_INSTRUCTION_SUBJECTS,
|
||||
getInstructions,
|
||||
globalToolsFor,
|
||||
prepareGlobalSystemMessage
|
||||
} from './core'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates'
|
||||
|
||||
const OUT_DIR = fileURLToPath(
|
||||
new URL('../../../../../../../backend/windmill-api/src/mcp/session/gen/', import.meta.url)
|
||||
)
|
||||
|
||||
// Sentences that describe the chat's run form, which an MCP client does not have. Each
|
||||
// must still be found in the description it is listed for: a reworded description fails
|
||||
// generation rather than shipping a promise of a form.
|
||||
const DESCRIPTION_REWRITES: [tool: string, from: string, to: string][] = [
|
||||
[
|
||||
'test_run_script',
|
||||
' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.',
|
||||
' It runs with exactly the `args` you send.'
|
||||
],
|
||||
[
|
||||
'test_run_flow',
|
||||
' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.',
|
||||
' It runs with exactly the `args` you send.'
|
||||
],
|
||||
[
|
||||
'test_run_step',
|
||||
' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.',
|
||||
' It runs with exactly the `args` you send.'
|
||||
],
|
||||
[
|
||||
'run_script',
|
||||
" Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the script's own deployed page instead; either way this call waits for them and runs what they confirm.",
|
||||
' It runs with exactly the `args` you send.'
|
||||
],
|
||||
[
|
||||
'run_flow',
|
||||
" Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow's own deployed page instead; either way this call waits for them and runs what they confirm.",
|
||||
' It runs with exactly the `args` you send.'
|
||||
],
|
||||
['create_resource_type', ' once the user confirms the schema', ''],
|
||||
['set_workspace_settings', ' The user reviews the changes before they apply.', '']
|
||||
]
|
||||
|
||||
// A rewrite only proves its old sentence is still there; this catches one added next to it.
|
||||
const CHAT_ONLY_PROMISE = /argument form|the user (confirms|reviews|approves)/i
|
||||
|
||||
function toolDefs() {
|
||||
return globalToolsFor({ sessionPreview: false }).map((tool) => {
|
||||
const fn = tool.def.function
|
||||
let description = fn.description ?? ''
|
||||
for (const [name, from, to] of DESCRIPTION_REWRITES) {
|
||||
if (name !== fn.name) continue
|
||||
if (!description.includes(from)) {
|
||||
throw new Error(`${name}: its description no longer contains "${from}"`)
|
||||
}
|
||||
description = description.replace(from, to)
|
||||
}
|
||||
if (CHAT_ONLY_PROMISE.test(description)) {
|
||||
throw new Error(`${fn.name}: its description promises a chat-only confirmation`)
|
||||
}
|
||||
return {
|
||||
name: fn.name,
|
||||
description,
|
||||
parameters: fn.parameters,
|
||||
// The session's own read-only marker: what plan mode lets through.
|
||||
read_only: tool.planModeSafe === true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const WRITABLE = '{{writable_folders}}'
|
||||
const READONLY = '{{readonly_folders}}'
|
||||
|
||||
function prompt(
|
||||
user: { is_admin: boolean; folders?: string[]; folders_read?: string[] },
|
||||
extra: Parameters<typeof prepareGlobalSystemMessage>[1] = {},
|
||||
instructions?: { workspace?: string }
|
||||
): string {
|
||||
const content = prepareGlobalSystemMessage(instructions, {
|
||||
user: { username: '{{username}}', ...user } as any,
|
||||
external: true,
|
||||
...extra
|
||||
}).content as string
|
||||
// The builder wraps each folder as `f/<name>`; the server substitutes a whole list.
|
||||
return content
|
||||
.replaceAll('`f/' + WRITABLE + '`', WRITABLE)
|
||||
.replaceAll('`f/' + READONLY + '`', READONLY)
|
||||
}
|
||||
|
||||
function suffixOf(longer: string, base: string, what: string): string {
|
||||
if (!longer.startsWith(base)) throw new Error(`${what} is no longer appended to the prompt`)
|
||||
return longer.slice(base.length)
|
||||
}
|
||||
|
||||
function promptParts() {
|
||||
const member = prompt({ is_admin: false, folders: [WRITABLE] })
|
||||
const skills = suffixOf(
|
||||
prompt(
|
||||
{ is_admin: false, folders: [WRITABLE] },
|
||||
{ skills: [{ path: '{{skill_path}}', name: '', description: '{{skill_description}}' }] }
|
||||
),
|
||||
member,
|
||||
'the skills section'
|
||||
)
|
||||
const skillLine = skills.split('\n').pop()!
|
||||
// The folder guidance differs per workspace, so it is cut out of the prompt: the server
|
||||
// puts it back for a single workspace, and serves it from `get_workspace_context` otherwise.
|
||||
const withoutFolders = prompt({ is_admin: false })
|
||||
const variants = {
|
||||
admin: prompt({ is_admin: true, folders: [WRITABLE] }),
|
||||
admin_no_folders: prompt({ is_admin: true, folders: [] }),
|
||||
member,
|
||||
member_no_folders: prompt({ is_admin: false, folders: [] }),
|
||||
member_readonly: prompt({
|
||||
is_admin: false,
|
||||
folders: [WRITABLE],
|
||||
folders_read: [WRITABLE, READONLY]
|
||||
}),
|
||||
member_no_folders_readonly: prompt({ is_admin: false, folders: [], folders_read: [READONLY] })
|
||||
}
|
||||
let at: number | undefined
|
||||
const folder_guidance = Object.fromEntries(
|
||||
Object.entries(variants).map(([name, full]) => {
|
||||
let i = 0
|
||||
while (full[i] === withoutFolders[i]) i++
|
||||
const rest = withoutFolders.slice(i)
|
||||
if ((at !== undefined && at !== i) || !full.endsWith(rest)) {
|
||||
throw new Error(`the folder guidance (${name}) is no longer one inserted block`)
|
||||
}
|
||||
at = i
|
||||
return [name, full.slice(i, full.length - rest.length)]
|
||||
})
|
||||
)
|
||||
return {
|
||||
base: withoutFolders.slice(0, at) + '{{folder_guidance}}' + withoutFolders.slice(at),
|
||||
folder_guidance,
|
||||
skills_header: skills.slice(0, skills.length - skillLine.length),
|
||||
skill_line: skillLine,
|
||||
workspace_instructions: suffixOf(
|
||||
prompt(
|
||||
{ is_admin: false, folders: [WRITABLE] },
|
||||
{},
|
||||
{ workspace: '{{workspace_instructions}}' }
|
||||
),
|
||||
member,
|
||||
'the workspace instructions'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What `get_trigger_schema` and `get_schedule_schema` answer: derived from the generated
|
||||
* request schemas, so the tools themselves are the only faithful source. */
|
||||
async function schemas() {
|
||||
const tools = globalToolsFor({ sessionPreview: false })
|
||||
const tool = (name: string) => tools.find((t) => t.def.function.name === name)!
|
||||
const run = (name: string, args: unknown) =>
|
||||
tool(name).fn({
|
||||
args,
|
||||
workspace: '',
|
||||
toolId: '',
|
||||
toolCallbacks: {} as any,
|
||||
helpers: {} as any
|
||||
})
|
||||
const kinds: string[] = (tool('get_trigger_schema').def.function.parameters as any).properties
|
||||
.kind.enum
|
||||
const trigger: Record<string, string> = {}
|
||||
for (const kind of kinds) {
|
||||
trigger[kind] = await run('get_trigger_schema', { kind })
|
||||
}
|
||||
return { trigger, schedule: await run('get_schedule_schema', {}) }
|
||||
}
|
||||
|
||||
/** Every file to write, keyed by name. */
|
||||
async function generate(): Promise<Record<string, string>> {
|
||||
const tools = toolDefs()
|
||||
const files: Record<string, string> = {
|
||||
'tools.json': JSON.stringify(tools, null, '\t') + '\n',
|
||||
'prompt.json': JSON.stringify(promptParts(), null, '\t') + '\n'
|
||||
}
|
||||
const languages: (ScriptLang | undefined)[] = [
|
||||
undefined,
|
||||
...(tools.find((t) => t.name === 'get_instructions')!.parameters as any).properties.language
|
||||
.enum
|
||||
]
|
||||
// Keyed by subject then language ('' when none was given); identical texts are stored once.
|
||||
const index: Record<string, Record<string, string>> = {}
|
||||
const texts: Record<string, string> = {}
|
||||
for (const subject of ALL_INSTRUCTION_SUBJECTS) {
|
||||
index[subject] = {}
|
||||
const byText = new Map<string, string>()
|
||||
for (const language of languages) {
|
||||
const text = await getInstructions(subject, language, undefined, false, true)
|
||||
let id = byText.get(text)
|
||||
if (!id) {
|
||||
id = `${subject}${language ? '.' + language : ''}`
|
||||
byText.set(text, id)
|
||||
texts[id] = text
|
||||
}
|
||||
index[subject][language ?? ''] = id
|
||||
}
|
||||
}
|
||||
// What `init_app` seeds a new raw app with.
|
||||
files['app_templates.json'] =
|
||||
JSON.stringify(
|
||||
{ templates: FRAMEWORK_TEMPLATES, starter_runnables: STARTER_RUNNABLES },
|
||||
null,
|
||||
'\t'
|
||||
) + '\n'
|
||||
files['schemas.json'] = JSON.stringify(await schemas(), null, '\t') + '\n'
|
||||
files['instructions.json'] = JSON.stringify({ index, texts }, null, '\t') + '\n'
|
||||
return files
|
||||
}
|
||||
|
||||
function committedFiles(dir = OUT_DIR, prefix = ''): string[] {
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? committedFiles(join(dir, e.name), `${prefix}${e.name}/`) : [prefix + e.name]
|
||||
)
|
||||
}
|
||||
|
||||
describe('MCP session server generated definitions', () => {
|
||||
it('match the session tools and prompts (npm run gen:mcp-session)', async () => {
|
||||
const files = await generate()
|
||||
expect(Object.keys(files)).toContain('tools.json')
|
||||
if (process.env.WM_GEN_MCP_SESSION) {
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(OUT_DIR, name), content)
|
||||
}
|
||||
return
|
||||
}
|
||||
expect(committedFiles().sort()).toEqual(Object.keys(files).sort())
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
expect(readFileSync(join(OUT_DIR, name), 'utf8'), name).toBe(content)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,11 @@
|
||||
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
|
||||
import { Bot, ExternalLink, Terminal } from 'lucide-svelte'
|
||||
import { shell } from 'svelte-highlight/languages'
|
||||
import { mcpTokenUrlDisabled } from '$lib/mcpAuth'
|
||||
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
|
||||
|
||||
type ConnectTab = 'cli' | 'mcp'
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let tokenUrlDisabled = $state(false)
|
||||
let selectedTab: ConnectTab = $state('cli')
|
||||
let openVersion = $state(0)
|
||||
|
||||
@@ -27,10 +25,6 @@ wmill sync pull`)
|
||||
export function openDrawer(tab: ConnectTab = 'cli') {
|
||||
selectedTab = tab
|
||||
openVersion += 1
|
||||
// Falls back like CreateToken below, which shows the bare URL when the read fails.
|
||||
void mcpTokenUrlDisabled()
|
||||
.then((v) => (tokenUrlDisabled = v))
|
||||
.catch(() => (tokenUrlDisabled = true))
|
||||
drawer?.openDrawer()
|
||||
}
|
||||
|
||||
@@ -105,15 +99,12 @@ wmill sync pull`)
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold text-emphasis">MCP URL</h3>
|
||||
<h3 class="text-sm font-semibold text-emphasis"
|
||||
>Build and run from your AI client</h3
|
||||
>
|
||||
<p class="text-xs text-secondary max-w-xl">
|
||||
{#if tokenUrlDisabled}
|
||||
The MCP server URL for the current workspace. Your client signs in to
|
||||
Windmill to use it.
|
||||
{:else}
|
||||
Generate an MCP server URL for the current workspace and choose which
|
||||
scripts, flows, and endpoints the client can access.
|
||||
{/if}
|
||||
Connect Claude, ChatGPT or Cursor to Windmill: build scripts, flows and
|
||||
apps, run your automations and check their runs, from your chat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts" module>
|
||||
export type McpAccess = 'read' | 'read_run' | 'full' | 'selected'
|
||||
|
||||
/** Marks a selection, so the per-workspace URLs add no API endpoint tools to it. */
|
||||
export const RUNNABLES_AS_TOOLS = 'mcp:runnables_as_tools'
|
||||
|
||||
const ACCESS_SCOPES: Record<Exclude<McpAccess, 'selected'>, string[]> = {
|
||||
read: ['mcp:all', 'mcp:read_only'],
|
||||
read_run: ['mcp:all', 'mcp:read_run'],
|
||||
full: ['mcp:all']
|
||||
}
|
||||
|
||||
/** The access a token's MCP scopes give: `mcp:all` and how it is narrowed, or else a
|
||||
* selection of scripts, flows and endpoints. */
|
||||
export function mcpAccessOf(scopes: string[]): McpAccess {
|
||||
scopes = scopes.filter((s) => s !== RUNNABLES_AS_TOOLS)
|
||||
if (!scopes.includes('mcp:all')) return scopes.length > 0 ? 'selected' : 'full'
|
||||
if (scopes.includes('mcp:read_only')) return 'read'
|
||||
if (scopes.includes('mcp:read_run')) return 'read_run'
|
||||
return 'full'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import McpScopeSelector from './McpScopeSelector.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
|
||||
interface Props {
|
||||
/** The workspace the selection picker browses. */
|
||||
workspaceId: string
|
||||
/** The token's MCP scopes, the workspace list aside. */
|
||||
scopes?: string[]
|
||||
access?: McpAccess
|
||||
initialScopes?: string[]
|
||||
}
|
||||
|
||||
let {
|
||||
workspaceId,
|
||||
scopes = $bindable(),
|
||||
access = $bindable(),
|
||||
initialScopes = []
|
||||
}: Props = $props()
|
||||
|
||||
const initialAccess = mcpAccessOf(initialScopes)
|
||||
access = initialAccess
|
||||
const initialSelection =
|
||||
initialAccess === 'selected'
|
||||
? initialScopes.filter((s) => s !== RUNNABLES_AS_TOOLS).join(' ')
|
||||
: undefined
|
||||
let selection = $state(initialSelection ?? 'mcp:favorites')
|
||||
|
||||
$effect(() => {
|
||||
scopes =
|
||||
access === 'selected' || access === undefined
|
||||
? [
|
||||
...selection.split(' ').filter((s) => s.length > 0 && s !== RUNNABLES_AS_TOOLS),
|
||||
RUNNABLES_AS_TOOLS
|
||||
]
|
||||
: ACCESS_SCOPES[access]
|
||||
})
|
||||
|
||||
// The tools each access level gives, as the server lists them.
|
||||
type ToolsetEntry = { name: string; description: string }
|
||||
let toolsCollapsed = $state(true)
|
||||
const toolsets = new SvelteMap<string, ToolsetEntry[]>()
|
||||
const toolsetAccess = $derived(access === 'selected' ? undefined : access)
|
||||
$effect(() => {
|
||||
const level = toolsetAccess
|
||||
if (toolsCollapsed || !level || toolsets.has(level)) return
|
||||
fetch(`/api/mcp/toolset?access=${level}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((tools: ToolsetEntry[]) => toolsets.set(level, tools))
|
||||
.catch(() => toolsets.set(level, []))
|
||||
})
|
||||
const tools = $derived(toolsetAccess ? toolsets.get(toolsetAccess) : undefined)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Access</span>
|
||||
<ToggleButtonGroup bind:selected={access} allowEmpty={false}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="full"
|
||||
label="Build and run"
|
||||
tooltip="Everything you can do yourself: explore, write, test and deploy drafts, and run."
|
||||
/>
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="read_run"
|
||||
label="Read and run"
|
||||
tooltip="Explore, and run deployed scripts and flows. The client changes nothing itself; the scripts and flows it runs can."
|
||||
/>
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="read"
|
||||
label="Read only"
|
||||
tooltip="Explore and read. Nothing is run or changed."
|
||||
/>
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="selected"
|
||||
label="Scripts and flows as tools"
|
||||
tooltip="The scripts and flows you pick, each as its own tool, and nothing else. To share a few automations."
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{#if toolsetAccess}
|
||||
<Section label="Tools the client gets" small collapsable bind:collapsed={toolsCollapsed}>
|
||||
<div class="flex flex-col gap-1 -mt-2">
|
||||
<span class="text-2xs text-secondary">
|
||||
{tools ? `${tools.length} tools. Hover one for what it does.` : 'Loading tools…'}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each tools ?? [] as tool (tool.name)}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
<div class="text-xs max-w-md">{tool.description}</div>
|
||||
{/snippet}
|
||||
<Badge rounded small color="gray">{tool.name}</Badge>
|
||||
</Popover>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
{/if}
|
||||
{#if access === 'selected'}
|
||||
<p class="text-2xs text-secondary -mt-2">
|
||||
The client can run these scripts and flows and nothing else: it cannot explore the workspace,
|
||||
read runs, build, or run anything outside the selection.
|
||||
</p>
|
||||
<McpScopeSelector
|
||||
{workspaceId}
|
||||
bind:scope={selection}
|
||||
initialScope={initialSelection}
|
||||
runnablesOnly
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -25,9 +25,19 @@
|
||||
scope: string
|
||||
initialScope?: string
|
||||
readOnly?: boolean
|
||||
/** Scripts and flows alone, for `/api/mcp`: a selection gets no endpoint tools there, so
|
||||
* endpoints are left out, and since `mcp:all` is the full toolset, "All scripts/flows"
|
||||
* is written as `mcp:scripts:* mcp:flows:*`. */
|
||||
runnablesOnly?: boolean
|
||||
}
|
||||
|
||||
let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props()
|
||||
let {
|
||||
workspaceId,
|
||||
scope = $bindable(),
|
||||
initialScope,
|
||||
readOnly = false,
|
||||
runnablesOnly = false
|
||||
}: Props = $props()
|
||||
|
||||
// Mirrors ITEMS_FETCH_MAX_LIMIT in backend/windmill-api/src/mcp/utils.rs: the
|
||||
// server exposes at most this many scripts and this many flows (per type) as
|
||||
@@ -147,8 +157,15 @@
|
||||
scriptFolders.length === flowFolders.length &&
|
||||
scriptFolders.every((f, i) => f === flowFolders[i])
|
||||
const endpointsIsAll = byKind.endpoints?.length === 1 && byKind.endpoints[0] === '*'
|
||||
// Without endpoints, scopes say "folder" or "all" with the scripts and flows alone.
|
||||
const endpointsAsExpected = runnablesOnly ? !byKind.endpoints : endpointsIsAll
|
||||
const isWildcard = (patterns?: string[]) => patterns?.length === 1 && patterns[0] === '*'
|
||||
|
||||
if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsIsAll) {
|
||||
if (runnablesOnly && isWildcard(byKind.scripts) && isWildcard(byKind.flows)) {
|
||||
return { ...empty, mode: 'all', hubApps }
|
||||
}
|
||||
|
||||
if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsAsExpected) {
|
||||
return {
|
||||
mode: 'folder',
|
||||
folders: scriptFolders.filter((f): f is string => !!f),
|
||||
@@ -205,14 +222,21 @@
|
||||
scopeParts.push(`mcp:flows:${flowPaths.join(',')}`)
|
||||
}
|
||||
|
||||
if (selectedEndpoints.length > 0) {
|
||||
if (!runnablesOnly && selectedEndpoints.length > 0) {
|
||||
scopeParts.push(`mcp:endpoints:${selectedEndpoints.join(',')}`)
|
||||
}
|
||||
} else if (selectedMode === 'folder') {
|
||||
const folderPaths = selectedFolders.map((f) => `f/${f}/*`).join(',')
|
||||
if (selectedFolders.length > 0) {
|
||||
scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`, `mcp:endpoints:*`]
|
||||
scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`]
|
||||
if (!runnablesOnly) {
|
||||
scopeParts.push(`mcp:endpoints:*`)
|
||||
}
|
||||
}
|
||||
} else if (selectedMode === 'all' && runnablesOnly) {
|
||||
// `mcp:all` is the full toolset on `/api/mcp`: every script and flow as its own
|
||||
// tool is said with wildcards instead.
|
||||
scopeParts = ['mcp:scripts:*', 'mcp:flows:*']
|
||||
} else {
|
||||
scopeParts = [`mcp:${selectedMode}`]
|
||||
}
|
||||
@@ -656,16 +680,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(selectableEndpointTools.map((e) => e.name))}
|
||||
placeholder="Select endpoints"
|
||||
bind:value={selectedEndpoints}
|
||||
/>
|
||||
</div>
|
||||
{#if !runnablesOnly}
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(selectableEndpointTools.map((e) => e.name))}
|
||||
placeholder="Select endpoints"
|
||||
bind:value={selectedEndpoints}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if withheldEndpoints.length > 0}
|
||||
{#if !runnablesOnly && withheldEndpoints.length > 0}
|
||||
<Alert type="warning" size="xs" title="Some selected endpoints will not be exposed">
|
||||
<span class="text-xs">
|
||||
Endpoints that cannot be limited to specific script paths are withheld from a token
|
||||
@@ -675,7 +701,7 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if runByPathTools.length > 0}
|
||||
{#if !runnablesOnly && runByPathTools.length > 0}
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<span class="block text-xs font-semibold">Run tools</span>
|
||||
<p class="text-xs text-tertiary">
|
||||
@@ -701,8 +727,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="text-xs text-primary mt-2">
|
||||
Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length}
|
||||
endpoints
|
||||
Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows{#if !runnablesOnly},
|
||||
{selectedEndpoints.length}
|
||||
endpoints{/if}
|
||||
</div>
|
||||
|
||||
<!-- Wildcard Patterns Section -->
|
||||
@@ -785,23 +812,25 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each exposedEndpointTools as endpoint (endpoint.name)}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs">{endpoint.description}</div>
|
||||
<div class="text-xs">
|
||||
{endpoint.method}
|
||||
{endpoint.path}
|
||||
{#if !runnablesOnly}
|
||||
<span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each exposedEndpointTools as endpoint (endpoint.name)}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs">{endpoint.description}</div>
|
||||
<div class="text-xs">
|
||||
{endpoint.method}
|
||||
{endpoint.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
<Badge rounded small color="green">{endpoint.name}</Badge>
|
||||
</Popover>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
<Badge rounded small color="green">{endpoint.name}</Badge>
|
||||
</Popover>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
* still toggle the mode after mount; this prop only seeds the
|
||||
* initial state. */
|
||||
defaultSplitWithPreview?: boolean
|
||||
/** Open on the preview alone, with the file sidebar and the build logs retracted,
|
||||
* whatever the width, as a narrow container does. The saved sidebar preference is
|
||||
* left as it is. */
|
||||
compact?: boolean
|
||||
/** User-typed path when it differs from `savedApp.path`. The route injects
|
||||
* it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */
|
||||
pendingDraftPath?: string | undefined
|
||||
@@ -230,6 +234,7 @@
|
||||
autosaveWorkspace = undefined,
|
||||
autosavePath = undefined,
|
||||
defaultSplitWithPreview = true,
|
||||
compact = false,
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onDetails,
|
||||
onResetToDeployed,
|
||||
@@ -538,7 +543,9 @@ addEventListener('message', function (e) {
|
||||
call: "createRoot(document.getElementById('root')!).render(<App />)"
|
||||
}
|
||||
})
|
||||
let logsCollapsed = $state(false)
|
||||
// Folded when the editor opens on the preview alone, where the log matters only on a
|
||||
// failed build (the Preview tab turns red then); shown beside the code in split view.
|
||||
let logsCollapsed = $state(untrack(() => !defaultSplitWithPreview || compact))
|
||||
let logsDiv: HTMLDivElement | undefined = $state(undefined)
|
||||
$effect(() => {
|
||||
if (logsDiv && logs && !logsCollapsed) {
|
||||
@@ -566,7 +573,7 @@ addEventListener('message', function (e) {
|
||||
let activeTabId: string = $state(PREVIEW_TAB_ID)
|
||||
// Seed from the prop, then own the state locally so the user's toggle
|
||||
// after mount sticks even if the prop reference changes.
|
||||
let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview))
|
||||
let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview && !compact))
|
||||
const activeTabKind = $derived<'file' | 'runnable' | 'preview'>(
|
||||
activeTabId === PREVIEW_TAB_ID
|
||||
? 'preview'
|
||||
@@ -840,7 +847,7 @@ addEventListener('message', function (e) {
|
||||
const w = rootWidth
|
||||
if (appliedNarrowDefault || w <= 0) return
|
||||
appliedNarrowDefault = true
|
||||
if (w < NARROW_PX) {
|
||||
if (compact || w < NARROW_PX) {
|
||||
splitWithPreview = false
|
||||
sidebarCollapsed.setWithoutPersist(true)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { userWorkspaces, usersWorkspaceStore, type UserWorkspace } from '$lib/stores'
|
||||
import { Alert, Button, Skeleton } from '../common'
|
||||
import { Button, Skeleton } from '../common'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import { SettingService, UserService, type NewToken } from '$lib/gen'
|
||||
import { mcpTokenUrlDisabled } from '$lib/mcpAuth'
|
||||
import TokenDisplay from './TokenDisplay.svelte'
|
||||
import ScopesPicker from './ScopesPicker.svelte'
|
||||
import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte'
|
||||
import { shell } from 'svelte-highlight/languages'
|
||||
import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration'
|
||||
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import Section from '../Section.svelte'
|
||||
import ClipboardPanel from '../details/ClipboardPanel.svelte'
|
||||
import McpAccessPicker, { type McpAccess } from '../mcp/McpAccessPicker.svelte'
|
||||
import MultiSelect from '../select/MultiSelect.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -44,16 +46,20 @@
|
||||
displayCreateToken = true
|
||||
}: Props = $props()
|
||||
|
||||
// Sentinel workspace value meaning "all workspaces the user can access".
|
||||
// Produces a workspace-less MCP token served through the /api/mcp/gateway
|
||||
// endpoint, where tools take an explicit workspace_id argument.
|
||||
const ALL_WORKSPACES = '*'
|
||||
|
||||
let newToken = $state<string | undefined>(undefined)
|
||||
let newMcpToken = $state<string | undefined>(undefined)
|
||||
let newMcpTokenUrl = $state('')
|
||||
// What an MCP token may do, as the access picker sets it.
|
||||
let mcpAccess = $state<McpAccess>('full')
|
||||
let mcpScopes = $state<string[]>(['mcp:all'])
|
||||
// What the user picked; `newTokenExpiration` is that pick held within the ceiling.
|
||||
let pickedExpiration = $state<number | undefined>(undefined)
|
||||
let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace))
|
||||
// The workspaces an MCP token reaches. One binds the token to it; several leave it unbound
|
||||
// and are listed in its scopes; all of them leaves it unbound with no list, so it also
|
||||
// reaches the workspaces its owner joins later.
|
||||
let mcpWorkspaces = $state<string[]>([])
|
||||
let mcpAllWorkspaces = $state(false)
|
||||
let mcpCreationMode = $state(false)
|
||||
let lastRequestedMcpMode = $state<boolean | undefined>(undefined)
|
||||
let mcpLabelAutofilled = $state(false)
|
||||
@@ -136,10 +142,11 @@
|
||||
mcpCreationMode = true
|
||||
void loadMcpUrlPolicy()
|
||||
resetExpirationOnModeChange()
|
||||
newTokenWorkspace = defaultNewTokenWorkspace ?? $operatingWorkspace
|
||||
const current = defaultNewTokenWorkspace ?? $operatingWorkspace
|
||||
mcpWorkspaces = current ? [current] : []
|
||||
mcpAllWorkspaces = false
|
||||
newToken = undefined
|
||||
newMcpToken = undefined
|
||||
readOnly = false
|
||||
if (!newTokenLabel) {
|
||||
newTokenLabel = 'MCP token'
|
||||
mcpLabelAutofilled = true
|
||||
@@ -167,7 +174,9 @@
|
||||
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
|
||||
}
|
||||
|
||||
const tokenScopes = scopes ?? pickedScopes ?? undefined
|
||||
const pickedOrFixed = scopes ?? (mcpMode ? mcpScopes : pickedScopes) ?? undefined
|
||||
const tokenScopes =
|
||||
pickedOrFixed && workspacesScope ? [...pickedOrFixed, workspacesScope] : pickedOrFixed
|
||||
|
||||
const createdToken = await UserService.createToken({
|
||||
requestBody: {
|
||||
@@ -175,13 +184,14 @@
|
||||
expiration: date?.toISOString(),
|
||||
scopes: tokenScopes,
|
||||
workspace_id: tokenWorkspaceId,
|
||||
read_only: readOnly
|
||||
read_only: mcpMode ? false : readOnly
|
||||
} as NewToken
|
||||
})
|
||||
|
||||
if (mcpMode) {
|
||||
newToken = undefined
|
||||
newMcpToken = `${createdToken}`
|
||||
newMcpTokenUrl = `${mcpUrl}?token=${createdToken}`
|
||||
} else {
|
||||
newMcpToken = undefined
|
||||
newToken = `${createdToken}`
|
||||
@@ -197,13 +207,15 @@
|
||||
}
|
||||
|
||||
const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $operatingWorkspace))
|
||||
const isAllWorkspaces = $derived(newTokenWorkspace === ALL_WORKSPACES)
|
||||
const tokenWorkspaceId = $derived(
|
||||
isAllWorkspaces
|
||||
? undefined
|
||||
: mcpCreationMode
|
||||
? newTokenWorkspace || $operatingWorkspace
|
||||
: newTokenWorkspace
|
||||
const mcpBoundWorkspace = $derived(
|
||||
!mcpAllWorkspaces && mcpWorkspaces.length === 1 ? mcpWorkspaces[0] : undefined
|
||||
)
|
||||
const mcpReachesWorkspaces = $derived(mcpAllWorkspaces || mcpWorkspaces.length > 0)
|
||||
const tokenWorkspaceId = $derived(mcpCreationMode ? mcpBoundWorkspace : newTokenWorkspace)
|
||||
const workspacesScope = $derived(
|
||||
mcpCreationMode && !mcpAllWorkspaces && mcpWorkspaces.length > 1
|
||||
? `mcp:workspaces:${mcpWorkspaces.join(',')}`
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Mirrors the server's exemption for tokens owned by a service account: one in the workspace
|
||||
@@ -236,25 +248,21 @@
|
||||
? maxExpirationSecs
|
||||
: pickedExpiration
|
||||
)
|
||||
// The workspace used to browse scripts/flows/endpoints in the scope picker.
|
||||
// For an all-workspaces token there is no single workspace, so fall back to
|
||||
// the current one just for populating the endpoint list.
|
||||
// The workspace used to browse scripts/flows/endpoints in the scope picker: the first
|
||||
// picked one, or the current one for a token reaching all of them.
|
||||
const scopeWorkspaceId = $derived(
|
||||
isAllWorkspaces ? $operatingWorkspace || '' : newTokenWorkspace || $operatingWorkspace || ''
|
||||
(mcpCreationMode && !mcpAllWorkspaces ? mcpWorkspaces[0] : newTokenWorkspace) ||
|
||||
$operatingWorkspace ||
|
||||
''
|
||||
)
|
||||
// Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL
|
||||
// and is copyable, so the OAuth panel withholds it rather than showing a broken one. The
|
||||
// token branch guards the same case by disabling its generate button.
|
||||
// On the browsing origin, not the API base url: the OAuth metadata the server
|
||||
// publishes for this URL is built from `base_url`, and a client rejects a mismatch.
|
||||
const mcpUrl = $derived(
|
||||
isAllWorkspaces
|
||||
? `${window.location.origin}/api/mcp/gateway`
|
||||
: newTokenWorkspace
|
||||
? `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp`
|
||||
: undefined
|
||||
// One URL whatever the access: the token's scopes decide which tools it gets.
|
||||
const mcpUrl = `${window.location.origin}/api/mcp`
|
||||
// Selected scripts and flows are listed per workspace: across several, a path could be two.
|
||||
const selectionNeedsOneWorkspace = $derived(
|
||||
mcpAccess === 'selected' && (mcpAllWorkspaces || mcpWorkspaces.length !== 1)
|
||||
)
|
||||
const mcpBaseUrl = $derived(`${mcpUrl ?? ''}?token=`)
|
||||
|
||||
$effect(() => {
|
||||
const requestedMcpMode = mcpOnly || openWithMcpMode
|
||||
@@ -278,13 +286,87 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet mcpTokenForm()}
|
||||
<div class="flex flex-col gap-3">
|
||||
<McpAccessPicker
|
||||
workspaceId={scopeWorkspaceId}
|
||||
bind:scopes={mcpScopes}
|
||||
bind:access={mcpAccess}
|
||||
/>
|
||||
{#if !lockWorkspace}
|
||||
{@render mcpWorkspacePicker()}
|
||||
{#if selectionNeedsOneWorkspace}
|
||||
<p class="text-2xs text-secondary">
|
||||
Selected scripts and flows come from one workspace: pick one.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !mcpOnly}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold"
|
||||
>Label <span class="text-xs text-primary">(optional)</span></span
|
||||
>
|
||||
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if maxExpirationSecs != undefined}
|
||||
<div>
|
||||
<span class="block mb-1 text-xs text-emphasis font-semibold">Expires In</span>
|
||||
<Select
|
||||
bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)}
|
||||
placeholder="Pick an expiration"
|
||||
inputClass="w-full"
|
||||
items={expirationItems}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-tertiary"
|
||||
>This instance limits tokens to {maxExpirationLabel}.</p
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex">
|
||||
<Button
|
||||
onclick={() => createToken(true)}
|
||||
disabled={!mcpReachesWorkspaces || selectionNeedsOneWorkspace || mcpScopes.length === 0}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
>
|
||||
Generate URL with token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet mcpWorkspacePicker()}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="block text-emphasis text-xs font-semibold">Workspaces</span>
|
||||
<Toggle
|
||||
bind:checked={mcpAllWorkspaces}
|
||||
options={{ right: 'All my workspaces, including ones I join later' }}
|
||||
size="xs"
|
||||
/>
|
||||
{#if !mcpAllWorkspaces}
|
||||
<MultiSelect
|
||||
bind:value={mcpWorkspaces}
|
||||
items={workspaces.map((w) => ({ label: w.name, value: w.id }))}
|
||||
placeholder="Pick workspaces"
|
||||
reorderable={false}
|
||||
/>
|
||||
{/if}
|
||||
{#if mcpAllWorkspaces || mcpWorkspaces.length > 1}
|
||||
<p class="text-xs text-tertiary">
|
||||
Tools take a <code>workspace_id</code> argument naming the workspace they act in.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
<!-- Stays bounded by the panel width: a content-driven width (min-w-min) would let a long
|
||||
scope chip stretch this card and push the rest of the form out of view. -->
|
||||
<div class="p-4 rounded-md mb-6 bg-surface-tertiary">
|
||||
<h3 class="pb-2 font-semibold text-emphasis text-sm">
|
||||
{mcpCreationMode && mcpUrlPolicy !== 'token' ? 'MCP URL' : title}
|
||||
</h3>
|
||||
<div class="p-4 rounded-md bg-surface-tertiary {!mcpOnly || newMcpToken ? 'mb-6' : ''}">
|
||||
{#if !mcpOnly}
|
||||
<h3 class="pb-2 font-semibold text-emphasis text-sm">{title}</h3>
|
||||
{/if}
|
||||
|
||||
{#if showMcpMode && !mcpOnly}
|
||||
<div
|
||||
@@ -316,37 +398,30 @@
|
||||
|
||||
{#if mcpCreationMode && mcpUrlPolicy === 'loading'}
|
||||
<Skeleton layout={[[2], 0.5, [1]]} />
|
||||
{:else if mcpCreationMode && mcpUrlPolicy === 'oauth'}
|
||||
{#if !lockWorkspace}
|
||||
<div class="mb-4 max-w-md">
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
||||
<!-- No all-workspaces entry: the gateway's consent screen binds the token it issues
|
||||
to the one workspace picked there, so OAuth has no multi-workspace grant to offer. -->
|
||||
<Select
|
||||
bind:value={newTokenWorkspace}
|
||||
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
|
||||
/>
|
||||
{:else if mcpCreationMode}
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">MCP URL</span>
|
||||
<ClipboardPanel content={mcpUrl} size="sm" />
|
||||
<p class="text-2xs text-secondary">
|
||||
Add it to your client as a remote MCP server. It opens a Windmill page where you sign in
|
||||
and choose the workspaces and what the client may do: build and run, read and run, read
|
||||
only, or only the scripts and flows you pick.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mcpUrl}
|
||||
<CopyableCodeBlock code={mcpUrl} language={shell} wrap />
|
||||
{#if mcpUrlPolicy === 'token'}
|
||||
<Section label="Use a token instead of signing in" small collapsable collapsed>
|
||||
{@render mcpTokenForm()}
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2">
|
||||
<Alert type="info" title="This instance requires MCP clients to sign in" size="xs">
|
||||
Paste this URL into your client. It opens a Windmill page where you approve the access
|
||||
it asks for, and no token needs to be copied around.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-tertiary">Pick a workspace to get its MCP URL.</p>
|
||||
{/if}
|
||||
|
||||
{#if !mcpOnly}
|
||||
<div class="mt-4 flex justify-end gap-2 flex-row">
|
||||
<Button onClick={exitMcpMode} variant="default">Cancel</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !mcpOnly}
|
||||
<div class="flex justify-end">
|
||||
<Button onClick={exitMcpMode} variant="default">Cancel</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if scopes != undefined}
|
||||
<div class="mb-4">
|
||||
@@ -370,7 +445,7 @@
|
||||
|
||||
{#if !scopes || scopes.length === 0}
|
||||
<ScopesPicker
|
||||
mode={mcpCreationMode ? 'mcp' : 'standard'}
|
||||
mode="standard"
|
||||
workspaceId={scopeWorkspaceId}
|
||||
bind:value={pickedScopes}
|
||||
bind:readOnly
|
||||
@@ -378,31 +453,6 @@
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{#if mcpCreationMode}
|
||||
{#if !lockWorkspace}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
||||
<Select
|
||||
bind:value={newTokenWorkspace}
|
||||
items={[
|
||||
{
|
||||
label: 'All workspaces',
|
||||
value: ALL_WORKSPACES,
|
||||
subtitle: 'Multi-workspace'
|
||||
},
|
||||
...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))
|
||||
]}
|
||||
/>
|
||||
{#if isAllWorkspaces}
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
This token works across every workspace you can access. Tools take a
|
||||
<code>workspace_id</code> argument; call <code>list_workspaces</code> to discover them.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !mcpOnly}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold"
|
||||
@@ -412,27 +462,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !mcpCreationMode || maxExpirationSecs != undefined}
|
||||
<div>
|
||||
<span class="block mb-1 text-xs text-emphasis font-semibold">
|
||||
Expires In
|
||||
{#if maxExpirationSecs == undefined}
|
||||
<span class="text-xs text-primary">(optional)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Select
|
||||
bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)}
|
||||
placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'}
|
||||
inputClass="w-full"
|
||||
items={expirationItems}
|
||||
/>
|
||||
{#if maxExpirationSecs != undefined}
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
This instance limits tokens to {maxExpirationLabel}.
|
||||
</p>
|
||||
<div>
|
||||
<span class="block mb-1 text-xs text-emphasis font-semibold">
|
||||
Expires In
|
||||
{#if maxExpirationSecs == undefined}
|
||||
<span class="text-xs text-primary">(optional)</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
<Select
|
||||
bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)}
|
||||
placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'}
|
||||
inputClass="w-full"
|
||||
items={expirationItems}
|
||||
/>
|
||||
{#if maxExpirationSecs != undefined}
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
This instance limits tokens to {maxExpirationLabel}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2 flex-row">
|
||||
@@ -446,13 +494,7 @@
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => createToken(mcpCreationMode)}
|
||||
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
|
||||
variant="accent"
|
||||
>
|
||||
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
|
||||
</Button>
|
||||
<Button on:click={() => createToken()} variant="accent">New token</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -462,6 +504,6 @@
|
||||
{/if}
|
||||
|
||||
{#if newMcpToken && displayCreateToken}
|
||||
<TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} />
|
||||
<TokenDisplay token={newMcpToken} mcpUrl={newMcpTokenUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Modal from '../common/modal/Modal.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import ScopesPicker from './ScopesPicker.svelte'
|
||||
import McpAccessPicker, { mcpAccessOf } from '../mcp/McpAccessPicker.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -41,8 +42,26 @@
|
||||
|
||||
let labelValue = $state('')
|
||||
let pickedScopes = $state<string[] | null>(null)
|
||||
let mcpScopes = $state<string[]>([])
|
||||
let saving = $state(false)
|
||||
|
||||
// The access picker re-derives a token's scopes from what it can show, which drops what it
|
||||
// does not model (an old token's endpoint list, say). So they are rewritten only once the
|
||||
// user has used the picker; until then the token keeps exactly the scopes it had.
|
||||
let mcpTouched = $state(false)
|
||||
// Carried over as is, the picker knowing nothing of them: dropping the workspace list
|
||||
// would widen the token to every workspace.
|
||||
const isWorkspaceList = (s: string) => s.startsWith('mcp:workspaces:')
|
||||
const keptAside = (s: string) =>
|
||||
isWorkspaceList(s) || (mcpAccessOf(mcpScopes) !== 'selected' && s.startsWith('mcp:hub:'))
|
||||
const scopesToSave = $derived(
|
||||
isMcp
|
||||
? mcpTouched
|
||||
? [...mcpScopes, ...(initialScopes ?? []).filter(keptAside)]
|
||||
: (initialScopes ?? [])
|
||||
: pickedScopes
|
||||
)
|
||||
|
||||
// Seed the label field from the token whenever the modal opens or the token
|
||||
// identity changes (untracked read so typing doesn't reset it). ScopesPicker
|
||||
// seeds itself via the `{#key tokenPrefix}` remount below.
|
||||
@@ -51,6 +70,7 @@
|
||||
if (open) {
|
||||
untrack(() => {
|
||||
labelValue = initialLabel ?? ''
|
||||
mcpTouched = false
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -69,7 +89,7 @@
|
||||
try {
|
||||
const trimmed = labelValue.trim()
|
||||
const labelChanged = labelEditable && trimmed !== (initialLabel ?? '')
|
||||
const scopesChanged = !scopesEqual(pickedScopes, initialScopes ?? null)
|
||||
const scopesChanged = !scopesEqual(scopesToSave, initialScopes ?? null)
|
||||
|
||||
if (labelChanged) {
|
||||
await UserService.updateTokenLabel({
|
||||
@@ -80,7 +100,7 @@
|
||||
if (scopesChanged) {
|
||||
await UserService.updateTokenScopes({
|
||||
tokenPrefix,
|
||||
requestBody: { scopes: pickedScopes }
|
||||
requestBody: { scopes: scopesToSave }
|
||||
})
|
||||
}
|
||||
if (labelChanged || scopesChanged) {
|
||||
@@ -95,7 +115,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && !pickedScopes))
|
||||
const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && mcpScopes.length === 0))
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Edit token" class="!max-w-3xl">
|
||||
@@ -117,14 +137,30 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Scopes</span>
|
||||
{#if !isMcp}
|
||||
<span class="text-xs font-semibold text-emphasis">Scopes</span>
|
||||
{/if}
|
||||
{#key tokenPrefix}
|
||||
<ScopesPicker
|
||||
mode={isMcp ? 'mcp' : 'standard'}
|
||||
workspaceId={mcpWorkspaceId}
|
||||
{initialScopes}
|
||||
bind:value={pickedScopes}
|
||||
/>
|
||||
{#if isMcp}
|
||||
<div
|
||||
role="group"
|
||||
onpointerdown={() => (mcpTouched = true)}
|
||||
onkeydown={() => (mcpTouched = true)}
|
||||
>
|
||||
<McpAccessPicker
|
||||
workspaceId={mcpWorkspaceId}
|
||||
initialScopes={(initialScopes ?? []).filter((s) => !isWorkspaceList(s))}
|
||||
bind:scopes={mcpScopes}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<ScopesPicker
|
||||
mode="standard"
|
||||
workspaceId={mcpWorkspaceId}
|
||||
{initialScopes}
|
||||
bind:value={pickedScopes}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
<!-- Additional info using alert info styling -->
|
||||
<div class="mt-1 {alertStyles.bgClass} rounded-md p-2">
|
||||
<p class="text-xs {alertStyles.descriptionClass}">
|
||||
<strong>Next steps:</strong> Use this URL in your MCP-compatible client (like Claude Desktop)
|
||||
to access your Windmill scripts and flows as tools.
|
||||
<strong>Next steps:</strong> Add this URL to your MCP-compatible client (like Claude Desktop)
|
||||
as a remote MCP server.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
editModalOpen = true
|
||||
}
|
||||
|
||||
async function listTokens(): Promise<void> {
|
||||
export async function listTokens(): Promise<void> {
|
||||
tokens = await UserService.listTokens({
|
||||
excludeEphemeral: true,
|
||||
page: tokenPage,
|
||||
|
||||
@@ -647,6 +647,7 @@
|
||||
<RawAppEditor
|
||||
bind:this={rawAppEditor}
|
||||
ownsPageHeader
|
||||
compact={page.url.searchParams.get('view') === 'preview'}
|
||||
onDetails={(e) => goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)}
|
||||
onSavedNewAppPath={(savedPath) => {
|
||||
draftSync.remove()
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { page } from '$app/state'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import McpAccessPicker, { type McpAccess } from '$lib/components/mcp/McpAccessPicker.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import McpScopeSelector from '$lib/components/mcp/McpScopeSelector.svelte'
|
||||
import Check from 'lucide-svelte/icons/check'
|
||||
|
||||
// Get OAuth params from URL
|
||||
@@ -18,7 +19,12 @@
|
||||
let clientId = page.url.searchParams.get('client_id') || ''
|
||||
let clientName = page.url.searchParams.get('client_name') || 'Unknown Client'
|
||||
let redirectUri = page.url.searchParams.get('redirect_uri') || ''
|
||||
let scope = $state(page.url.searchParams.get('scope') || 'mcp:all')
|
||||
// What the client asked for seeds the access picker; what is approved is the picker's.
|
||||
const requestedScopes = (page.url.searchParams.get('scope') || 'mcp:all')
|
||||
.split(' ')
|
||||
.filter((s) => s.startsWith('mcp:'))
|
||||
let scopes = $state<string[]>([])
|
||||
let access = $state<McpAccess>()
|
||||
let oauthState = page.url.searchParams.get('state') || ''
|
||||
let codeChallenge = page.url.searchParams.get('code_challenge') || ''
|
||||
let codeChallengeMethod = page.url.searchParams.get('code_challenge_method') || ''
|
||||
@@ -41,9 +47,21 @@
|
||||
let success = $state(false)
|
||||
let successRedirectUrl = $state('')
|
||||
|
||||
// Gateway mode: fetch user's workspaces for the picker
|
||||
// Gateway mode: fetch user's workspaces for the picker. One picked workspace binds the
|
||||
// token to it; several, or all of them, leave it unbound and its tools name their workspace.
|
||||
let workspaces: Array<{ value: string; label: string }> = $state([])
|
||||
let pickedWorkspaces = $state<string[]>([])
|
||||
let allWorkspaces = $state(false)
|
||||
let loadingWorkspaces = $state(false)
|
||||
const grantsWorkspace = $derived(!isGateway || allWorkspaces || pickedWorkspaces.length > 0)
|
||||
// Selected scripts and flows are listed per workspace: across several, a path could be two.
|
||||
const selectionNeedsOneWorkspace = $derived(
|
||||
isGateway && access === 'selected' && (allWorkspaces || pickedWorkspaces.length !== 1)
|
||||
)
|
||||
// The workspace the scope picker browses scripts and flows in.
|
||||
const scopeWorkspaceId = $derived(
|
||||
isGateway ? (pickedWorkspaces[0] ?? (allWorkspaces ? workspaces[0]?.value : '')) : workspaceId
|
||||
)
|
||||
|
||||
if (isGateway) {
|
||||
loadingWorkspaces = true
|
||||
@@ -57,10 +75,12 @@
|
||||
}))
|
||||
// Auto-select if only one workspace
|
||||
if (workspaces.length === 1) {
|
||||
workspaceId = workspaces[0].value
|
||||
pickedWorkspaces = [workspaces[0].value]
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((e) => {
|
||||
// Logged out: the root layout is already sending the browser to the login page.
|
||||
if (e?.status === 401) return
|
||||
sendUserToast('Failed to load workspaces', true)
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -83,7 +103,7 @@
|
||||
|
||||
async function onApprove() {
|
||||
if (!redirectUriValid) return
|
||||
if (!workspaceId) {
|
||||
if (!grantsWorkspace) {
|
||||
sendUserToast('Please select a workspace', true)
|
||||
return
|
||||
}
|
||||
@@ -95,18 +115,19 @@
|
||||
? `/api/mcp/gateway/oauth/server/approve`
|
||||
: `/api/w/${workspaceId}/mcp/oauth/server/approve`
|
||||
|
||||
const body: Record<string, string> = {
|
||||
const body: Record<string, unknown> = {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scope,
|
||||
scope: scopes.join(' '),
|
||||
state: oauthState,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod
|
||||
}
|
||||
|
||||
// Gateway mode sends workspace_id in the body
|
||||
// Gateway mode sends the workspaces in the body
|
||||
if (isGateway) {
|
||||
body.workspace_id = workspaceId
|
||||
body.workspace_ids = allWorkspaces ? [] : pickedWorkspaces
|
||||
body.all_workspaces = allWorkspaces
|
||||
}
|
||||
|
||||
const response = await fetch(approveUrl, {
|
||||
@@ -170,8 +191,8 @@
|
||||
<p class="text-center text-sm text-primary mb-6">
|
||||
<span class="font-semibold text-accent">{clientName}</span>
|
||||
is requesting access to
|
||||
{#if isGateway && !workspaceId}
|
||||
your Windmill workspace.
|
||||
{#if isGateway}
|
||||
your Windmill workspaces.
|
||||
{:else}
|
||||
your
|
||||
<span class="font-semibold text-accent">{workspaceId}</span>
|
||||
@@ -180,30 +201,53 @@
|
||||
</p>
|
||||
|
||||
{#if isGateway}
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-emphasis mb-2">Select a workspace:</p>
|
||||
<div class="mb-6 flex flex-col gap-2">
|
||||
<p class="text-xs font-semibold text-emphasis">Workspaces it may work in:</p>
|
||||
{#if loadingWorkspaces}
|
||||
<p class="text-xs text-secondary">Loading workspaces...</p>
|
||||
{:else if workspaces.length === 0}
|
||||
<p class="text-xs text-secondary">No workspaces available.</p>
|
||||
{:else}
|
||||
<Select items={workspaces} bind:value={workspaceId} placeholder="Choose a workspace" />
|
||||
<Toggle
|
||||
bind:checked={allWorkspaces}
|
||||
options={{ right: 'All my workspaces, including ones I join later' }}
|
||||
size="xs"
|
||||
/>
|
||||
{#if !allWorkspaces}
|
||||
<MultiSelect
|
||||
items={workspaces}
|
||||
bind:value={pickedWorkspaces}
|
||||
placeholder="Choose workspaces"
|
||||
reorderable={false}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if workspaceId}
|
||||
<div class="mb-6">
|
||||
<McpScopeSelector {workspaceId} bind:scope />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-6 flex flex-col gap-1">
|
||||
<McpAccessPicker
|
||||
workspaceId={scopeWorkspaceId ?? ''}
|
||||
initialScopes={requestedScopes}
|
||||
bind:scopes
|
||||
bind:access
|
||||
/>
|
||||
{#if selectionNeedsOneWorkspace}
|
||||
<p class="text-2xs text-secondary">
|
||||
Selected scripts and flows come from one workspace: pick one.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-around gap-x-4">
|
||||
<Button variant="border" size="lg" disabled={loading} onClick={onDeny}>Deny</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
disabled={loading || (isGateway && !workspaceId)}
|
||||
disabled={loading ||
|
||||
!grantsWorkspace ||
|
||||
selectionNeedsOneWorkspace ||
|
||||
scopes.length === 0}
|
||||
{loading}
|
||||
onClick={onApprove}>Approve</Button
|
||||
>
|
||||
|
||||
@@ -10,6 +10,10 @@ are built from it:
|
||||
`cli/src/guidance/skills.gen.ts`, next to the `AGENTS.wmill.md` template in
|
||||
`cli/src/guidance/core.ts`.
|
||||
|
||||
The workspace MCP server (`backend/windmill-api/src/mcp/session/`) is a third reader, one step
|
||||
removed: it serves the global chat's own `get_instructions` texts and system prompt, rendered for
|
||||
an external client by `npm run gen:mcp-session` in `frontend/`. Rerun that after `generate.py`.
|
||||
|
||||
Guidance written in one consumer only is guidance the other never gets. Write it here.
|
||||
|
||||
## Structure
|
||||
|
||||
Reference in New Issue
Block a user