mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 16:02:47 +00:00
* feat: render mcp run tools as an mcp apps run card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: back off run card polling and pin its run summary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the run card polling after a failed get_run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add run details, run form, diff and item views to the mcp card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: guard the mcp card's run form and diff errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: honour the host's dark theme in the mcp card without its colours Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep untouched run form arguments and drop stale run refreshes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let the run form re-send references and label diff links by target Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: parse literals typed over run form references by field type Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: version the mcp card uri and declare its empty csp Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: keep the mcp card to runs, group loop and branch steps, restyle it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say when the mcp card cannot read a finished run's details Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show runs, run tables and deploy reviews through dedicated mcp tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: review the whole draft before deploying from the mcp card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: follow host card guidelines: header link, collapsible steps, review summary, no run form Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep fullscreen review diffs whole and match drafts by storage path first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link to windmill where a host has no fullscreen, cap inline logs, shrink the card back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say in the review summary why a draft cannot be deployed from the card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: link the review to compare and deploy, cap inline tables, open long results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: flag a cut result or log by what was kept, keep a lone log line Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mark the mcp card's fullscreen buttons with the expand icon Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: capitalize the mcp card's windmill link labels Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add a show_item card for workspace items, collapsible sections in fullscreen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: apply windmill brand guidelines to the mcp card and show errors as windmill does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show more windmill result kinds in the mcp run card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: tidy the mcp card layout and let oversized drafts deploy from the review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: name a shown run by its summary, cap long item values, drop variable cards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link an app draft's card to its editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show a running flow's progress in the mcp card, open cut blocks on click, link each step Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: read only a running step's log tail and skip the preprocessor in the progress bar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp card headers carry a kind icon, and a summary names the item over its path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: skip result streams in log tail reads, keep the cut-result link when it fits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link app drafts and agents to their own pages, offer Cancel only to tokens that can cancel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: simple inline mcp cards with a tabbed fullscreen run view and step drill-in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: draw the mcp card's tabs as windmill's underlined tabs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link to windmill for what an mcp card hides when the host has no fullscreen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp run card steps as a tree beside the selected step, nested progress, danger cancel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: draw image results in the mcp run card, and a collapse control in fullscreen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: copy controls on the mcp card's results, logs and code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp card links read as text, hover tint only on buttons Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: windmill's names, icons, status colours and type scale in the mcp card, within the hosts' rules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep images out of mcp run table rows, and re-read only single-field image results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: measure a re-read mcp image result as postgres prints it, so wrapped images draw Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp review deploys the selected drafts, loops list every iteration's result, branches and subflows named Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp review diffs open on demand, as on the compare page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp script cards fade their code in inline, item sections open whole in fullscreen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp item cards link what a schedule or trigger runs, show a trigger's key settings and a schedule's arguments as rows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp card lists a loop's latest 10 iterations, trigger topics by name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp trigger cards open their other settings from a last table row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp draft badge in windmill's indigo without a spinner, show-more as a list's last row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: consistent mcp cards: one expand icon and rule, aligned tables, path titles, error and empty states Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp item cards offer fullscreen for cut values and overflowing code, name script steps by path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp cards show ai agents: their answer, named tool calls, what a waiting agent asks, saved agents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp saved-agent runs label tool calls by tool, never by the internal root step Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: a saved agent's run card names its steps tab Tool calls Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an mcp card says an agent waits only while its run is going Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp card paths wrap at slashes beside their icon; oauth approval offers all workspaces to a user in none Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp card view read carries the cache hints 2026-07-28 clients require Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: mcp card leaves leaving fullscreen to the host's own close control Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp cards name a saved agent's test run by the agent's path, without .chat Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf: mcp get_run builds no card data; the run card refreshes through show_run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp show_deploy_review refuses a token that cannot deploy, not only hides from it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp session tools carry a title and explicit read-only, destructive and open-world hints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp run card reads only the end of a run's log; create_datatable_migration is destructive Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp run card counts log offsets from 1 and reads a finished run's archived log in full Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp item card rechecks clipped code on resize; oauth hint hides once the toggle is on Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mcp item card uncuts a code block once a wider card fits it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
779 lines
29 KiB
Rust
779 lines
29 KiB
Rust
//! 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::{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 views;
|
|
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 discarding a draft cannot undo: they reach the deployed workspace or a
|
|
/// live datatable, run code, cancel a job, or delete work. Stands in for the session's
|
|
/// confirmation card. The ChatGPT and Claude directories reject a write that deletes,
|
|
/// cancels or cannot be taken back without this hint, even when it could be redone.
|
|
const DESTRUCTIVE: &[&str] = &[
|
|
"deploy_workspace_item",
|
|
"delete_workspace_item",
|
|
"create_resource_type",
|
|
"set_workspace_settings",
|
|
"run_script",
|
|
"run_flow",
|
|
"run_agent",
|
|
"test_run_script",
|
|
"test_run_flow",
|
|
"test_run_agent",
|
|
"test_run_step",
|
|
"test_run_app_runnable",
|
|
"cancel_job",
|
|
"exec_datatable_sql",
|
|
"create_datatable_migration",
|
|
"run_datatable_migrations",
|
|
"rollback_datatable_migration",
|
|
"discard_local_draft",
|
|
"rebase_draft",
|
|
"delete_app_file",
|
|
"delete_app_runnable",
|
|
];
|
|
|
|
/// The destructive tools a card may call, each from a button the user presses. Every
|
|
/// other destructive tool is hidden from cards: a card acts as the user with no one
|
|
/// confirming.
|
|
const CARD_ACTIONS: &[&str] = &["deploy_workspace_item", "cancel_job"];
|
|
|
|
/// Tools that reach a system outside Windmill on the workspace's behalf: running code can
|
|
/// reach any system the workspace can, and `get_db_schema` connects to a resource's
|
|
/// database. Reading public reference data (hub, npm, docs) does not count.
|
|
const OPEN_WORLD: &[&str] = &[
|
|
"run_script",
|
|
"run_flow",
|
|
"run_agent",
|
|
"test_run_script",
|
|
"test_run_flow",
|
|
"test_run_agent",
|
|
"test_run_step",
|
|
"test_run_app_runnable",
|
|
"get_db_schema",
|
|
];
|
|
|
|
/// A tool's display name, which Claude shows in its tool list and approval prompts.
|
|
fn tool_title(name: &str) -> String {
|
|
let words: Vec<String> = name
|
|
.split('_')
|
|
.map(|word| match word {
|
|
"db" | "sql" | "json" => word.to_uppercase(),
|
|
_ => word.to_string(),
|
|
})
|
|
.collect();
|
|
let title = words.join(" ");
|
|
let mut chars = title.chars();
|
|
chars
|
|
.next()
|
|
.map(|first| first.to_uppercase().chain(chars).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// 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, flow or agent, the one write a read-and-run token keeps.
|
|
const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow", "run_agent"];
|
|
|
|
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)
|
|
}
|
|
|
|
/// The MCP Apps view a client that renders them draws these tools' results in. It draws
|
|
/// what the tool put in its result's `_meta` (`Ctx::view`), and acts by calling tools.
|
|
/// Hosts cache a view by its URI: bump the version when a change breaks the `_meta` shape
|
|
/// the deployed card reads.
|
|
pub(super) const CARD_URI: &str = "ui://windmill/card-v1";
|
|
const CARD_HTML: &str = include_str!("card.html");
|
|
|
|
/// The `ui://` views the session's tools render in, as `(uri, html)`.
|
|
pub(super) fn session_views() -> &'static [(&'static str, &'static str)] {
|
|
&[(CARD_URI, CARD_HTML)]
|
|
}
|
|
|
|
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| {
|
|
let name = def.name.as_str();
|
|
let mut meta = result_size_meta();
|
|
let destructive = DESTRUCTIVE.contains(&name);
|
|
if destructive && !CARD_ACTIONS.contains(&name) {
|
|
meta.0
|
|
.insert("ui".into(), json!({ "visibility": ["model"] }));
|
|
}
|
|
let title = tool_title(name);
|
|
Tool::new(
|
|
def.name.clone(),
|
|
def.description.clone(),
|
|
def.parameters.clone(),
|
|
)
|
|
.with_title(title.clone())
|
|
.with_annotations(
|
|
ToolAnnotations::with_title(title)
|
|
.read_only(def.read_only && !RUNS_A_JOB.contains(&name))
|
|
.destructive(destructive)
|
|
.open_world(OPEN_WORLD.contains(&name)),
|
|
)
|
|
.with_meta(meta)
|
|
})
|
|
.chain(
|
|
views::definitions()
|
|
.into_iter()
|
|
.filter(|(name, ..)| views::allowed(name, access))
|
|
.map(|(name, description, schema)| {
|
|
let mut meta = result_size_meta();
|
|
meta.0
|
|
.insert("ui".into(), json!({ "resourceUri": CARD_URI }));
|
|
let Value::Object(schema) = schema else {
|
|
unreachable!("a view tool's schema is an object")
|
|
};
|
|
let title = tool_title(name);
|
|
Tool::new(name, description, schema)
|
|
.with_title(title.clone())
|
|
.with_annotations(
|
|
ToolAnnotations::with_title(title)
|
|
.read_only(true)
|
|
.destructive(false)
|
|
.open_world(false),
|
|
)
|
|
.with_meta(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>,
|
|
/// What the card draws of this call, as `(key, value)`: returned in the result's
|
|
/// `_meta`, which the model never reads.
|
|
pub view: std::sync::Mutex<Option<(&'static str, Value)>>,
|
|
}
|
|
|
|
impl Ctx<'_> {
|
|
/// Set what the card draws of this call, replacing what an inner step set.
|
|
pub fn set_view(&self, key: &'static str, value: Value) {
|
|
*self.view.lock().unwrap() = Some((key, value));
|
|
}
|
|
|
|
/// 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/", "jobs/run/agent/"]
|
|
.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))
|
|
}
|
|
|
|
/// A generated session tool, refused when the token's access does not cover it.
|
|
async fn call_generated_tool(ctx: &Ctx<'_>, name: &str, args: &Value) -> BackendResult<ToolResult> {
|
|
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,
|
|
));
|
|
};
|
|
Ok(result)
|
|
}
|
|
|
|
pub(super) async fn call_session_tool(
|
|
ctx: &Ctx<'_>,
|
|
name: &str,
|
|
args: Value,
|
|
) -> BackendResult<CallToolResult> {
|
|
let result = if views::TOOLS.contains(&name) {
|
|
views::call(ctx, name, &args).await
|
|
} else {
|
|
call_generated_tool(ctx, name, &args).await?
|
|
};
|
|
windmill_common::feature_usage::log_feature_usage("mcp_workspace", "tool_call", name);
|
|
let mut result = 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())])
|
|
}
|
|
};
|
|
if let Some((key, view)) = ctx.view.lock().unwrap().take() {
|
|
result.meta = Some(MetaObject(Map::from_iter([(key.to_string(), view)])));
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
#[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/agent/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 no_card_can_call_a_destructive_tool_but_deploy_and_cancel() {
|
|
for tool in session_tools(SessionAccess::Full) {
|
|
let name = tool.name.as_ref();
|
|
if DESTRUCTIVE.contains(&name) {
|
|
let ui = tool.meta.as_ref().and_then(|m| m.0.get("ui"));
|
|
let model_only = ui == Some(&json!({ "visibility": ["model"] }));
|
|
assert_eq!(
|
|
model_only,
|
|
!matches!(name, "deploy_workspace_item" | "cancel_job"),
|
|
"{name}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The ChatGPT directory wants every hint explicit, and Claude's a title on every tool.
|
|
#[test]
|
|
fn every_session_tool_has_a_title_and_explicit_hints() {
|
|
for tool in session_tools(SessionAccess::Full) {
|
|
let a = tool.annotations.as_ref().expect("annotations");
|
|
assert!(tool.title.is_some() && a.title.is_some(), "{}", tool.name);
|
|
assert!(
|
|
a.read_only_hint.is_some()
|
|
&& a.destructive_hint.is_some()
|
|
&& a.open_world_hint.is_some(),
|
|
"{}",
|
|
tool.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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)
|
|
.chain(DESTRUCTIVE)
|
|
.chain(OPEN_WORLD)
|
|
.chain(RUNS_A_JOB)
|
|
{
|
|
assert!(
|
|
TOOL_DEFS.iter().any(|def| def.name == *name),
|
|
"`{name}` is not a session tool any more"
|
|
);
|
|
}
|
|
for name in views::TOOLS {
|
|
assert!(
|
|
!TOOL_DEFS.iter().any(|def| def.name == *name),
|
|
"`{name}` is generated too"
|
|
);
|
|
}
|
|
}
|
|
}
|