feat: show runs, run tables, deploy reviews and items as mcp apps cards (#11969)

* 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>
This commit is contained in:
hugocasa
2026-10-27 23:42:14 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8dd46a4d14
commit 8121dc2b1c
12 changed files with 3266 additions and 48 deletions
+1 -1
View File
@@ -66,7 +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, except that they give the session toolset to an `mcp:all` token carrying `mcp:session` (added by the OAuth approve, kept across scope edits) or `mcp:read_only`/`mcp:read_run`. 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).
- **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, except that they give the session toolset to an `mcp:all` token carrying `mcp:session` (added by the OAuth approve, kept across scope edits) or `mcp:read_only`/`mcp:read_run`. 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). For MCP Apps clients the server adds tools of its own, defined in Rust (`session/views.rs`) rather than generated: `show_run`, `show_runs`, `show_deploy_review` and `show_item` draw a card in the `ui://windmill/card-v<N>` view (`session/card.html`; bump `CARD_URI` when the `_meta` it reads changes shape, since hosts cache a view by URI) from their result's `_meta` (`windmill/run`, `windmill/runs`, `windmill/review`, `windmill/item`), which the model never reads. A host draws a card for every call of a tool that declares one, so only these show tools do: the tools the model reads and runs with draw nothing, and `get_run` builds no card data. The card acts only by calling the session's tools (`show_run` to refresh, `cancel_job`, `show_deploy_review` to re-read a draft, `deploy_workspace_item` from the review card's Deploy button, which the user presses), so it sees nothing the model could not. Every `DESTRUCTIVE` tool but those in `CARD_ACTIONS` is hidden from it. Every session tool carries a `title` and explicit read-only, destructive and open-world hints, which the Claude and ChatGPT directories require (`DESTRUCTIVE`, `OPEN_WORLD`). A client that declares capabilities without the MCP Apps extension is not offered the show tools. It exists only on the MCP side, so `core.ts` has nothing to mirror.
- **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.
+5
View File
@@ -90,6 +90,7 @@ impl WindmillBackend {
access,
origin: None,
jwt: Default::default(),
view: Default::default(),
}
}
@@ -479,6 +480,10 @@ impl McpBackend for WindmillBackend {
session::session_tools(access)
}
fn session_views(&self) -> &'static [(&'static str, &'static str)] {
session::session_views()
}
async fn call_session_tool(
&self,
auth: &ApiAuthed,
+1 -1
View File
@@ -286,7 +286,7 @@ async fn save_app(
}
}
fn sha256_hex(text: &str) -> String {
pub(super) fn sha256_hex(text: &str) -> String {
hex::encode(Sha256::digest(text.as_bytes()))
}
File diff suppressed because it is too large Load Diff
+234 -5
View File
@@ -9,12 +9,12 @@ use serde_json::{json, Map, Value};
use super::flows::drop_stale_step_locks;
use super::workspace::{
api_message, app_source_to_draft_value, delete_draft, deployed_item_exists, draft_kind,
get_deployed, get_draft_item, item_exists, item_type_arg, path_enc, service, unified_patch,
ITEM_TYPES, TRIGGER_KINDS,
draft_page, get_deployed, get_draft_item, item_exists, item_type_arg, path_enc, service,
unified_patch, ITEM_TYPES, TRIGGER_KINDS,
};
use super::{
array, bool_arg, is_not_found, js_str, matches_glob, non_null, opt_str_arg, pretty, str_arg,
truthy, Ctx, ToolResult,
array, bool_arg, enc, is_not_found, js_str, matches_glob, non_null, opt_str_arg, pretty,
str_arg, truthy, Ctx, ToolResult,
};
pub(super) const TOOLS: &[&str] = &["deploy_workspace_item", "delete_workspace_item", "diff"];
@@ -474,7 +474,7 @@ fn mutation_result(
}
/// Where a deployed item is seen in Windmill.
fn deployed_page(item_type: &str, path: &str, value: &Value) -> Option<String> {
pub(super) fn deployed_page(item_type: &str, path: &str, value: &Value) -> Option<String> {
Some(match item_type {
"script" => format!("/scripts/get/{path}"),
"flow" => format!("/flows/get/{path}"),
@@ -1396,6 +1396,8 @@ struct Materialized {
value_masked: bool,
value_uncomparable: bool,
no_deployed: bool,
/// The item is an AI agent, which has its own pages.
agent: bool,
error: Option<String>,
}
@@ -1414,6 +1416,8 @@ async fn materialize(ctx: &Ctx<'_>, kind: &str, path: &str) -> Materialized {
}
fn materialized_of(kind: &str, sides: DraftSides) -> Materialized {
let agent =
super::workspace::is_agent(&sides.draft) || super::workspace::is_agent(&sides.deployed);
let mut before = (!sides.no_deployed).then_some(sides.deployed);
let mut after = Some(sides.draft);
let value_masked = kind == "variable";
@@ -1423,6 +1427,7 @@ fn materialized_of(kind: &str, sides: DraftSides) -> Materialized {
value_masked,
value_uncomparable,
no_deployed: sides.no_deployed,
agent,
error: None,
}
}
@@ -1958,6 +1963,208 @@ async fn parent_comparison_unavailable(ctx: &Ctx<'_>) -> String {
}
}
/// Items `show_deploy_review` draws at most, each one diff.
const MAX_REVIEW_ITEMS: usize = 10;
/// A patch the review card shows whole, or not at all: past this it shows the counts only.
const MAX_REVIEW_PATCH_CHARS: usize = 60_000;
/// Lines a patch adds and removes, as its hunks count them.
fn changed_line_counts(patch: &str) -> (usize, usize) {
let mut in_hunk = false;
let (mut added, mut removed) = (0, 0);
for line in patch.lines() {
if line.starts_with("@@") {
in_hunk = true;
} else if line.starts_with("=== ") {
in_hunk = false;
} else if in_hunk && line.starts_with('+') {
added += 1;
} else if in_hunk && line.starts_with('-') {
removed += 1;
}
}
(added, removed)
}
/// The whole of a draft's diff as the user reviews it: unlike the model's `diff` text it
/// is never paged, and a multi-file app's files are all in it.
fn review_patch(m: &Materialized) -> String {
let mut out = String::new();
if m.value_masked {
out.push_str(if m.value_uncomparable {
SECRET_UNCOMPARABLE_NOTE
} else {
VARIABLE_MASKED_NOTE
});
}
if m.no_deployed {
out.push_str("No deployed version yet: the entire draft is new.\n\n");
}
out.push_str(&m.parts.patch);
for (name, file) in m.parts.files.iter().flatten() {
out.push_str(&format!("\n=== {name} ({})\n{}", file.status, file.patch));
}
out
}
/// Drafts with their diffs, each with a Deploy button the user presses: the card calls
/// `deploy_workspace_item` itself, so that click is the confirmation. The card checks the
/// draft is still the one it shows by the diff's digest, also for a diff too large to show.
pub(super) async fn show_deploy_review(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
let (rows, _) = list_draft_rows(ctx).await?;
let addressed = |row: &DraftRow| row.addressing.map(|(t, k)| (t, k.map(str::to_string)));
let mut picked: Vec<(&DraftRow, &'static str, Option<String>)> = Vec::new();
let mut missing: Vec<Value> = Vec::new();
match args
.get("items")
.and_then(Value::as_array)
.filter(|i| !i.is_empty())
{
Some(items) => {
for item in items {
let (Some(item_type), Some(path)) =
(opt_str_arg(item, "type"), opt_str_arg(item, "path"))
else {
continue;
};
let kind = opt_str_arg(item, "trigger_kind").map(str::to_string);
let row = find_draft(&rows, item_type, kind.as_deref(), path);
match (row, addressed_type(item_type)) {
(Some(row), Some(item_type)) => picked.push((row, item_type, kind)),
_ => missing.push(json!({
"type": item_type,
"trigger_kind": kind,
"path": path,
"error": format!("No draft of {item_type} \"{path}\" to deploy."),
})),
}
}
}
None => picked.extend(rows.iter().filter_map(|row| {
let (item_type, kind) = addressed(row)?;
Some((row, item_type, kind))
})),
}
let total = picked.len() + missing.len();
let mut items = Vec::new();
for (row, item_type, trigger_kind) in picked.into_iter().take(MAX_REVIEW_ITEMS) {
// A draft is deployed by the path it is stored under, which a staged rename or a new
// editor draft keeps apart from the name it is known by.
let path = row.path.as_str();
let m = materialize(ctx, &row.kind, path).await;
let kind_value = if m.agent {
json!({ "resource_type": "ai_agent" })
} else {
Value::Null
};
// An app's draft opens in its own editor: its page shows only the deployed version.
let editor = match row.kind.as_str() {
"raw_app" => Some(format!("/apps_raw/edit/{path}")),
"app" => Some(format!("/apps/edit/{path}")),
_ => draft_page(item_type, path, &kind_value),
};
let has_editor = editor.is_some();
let page = editor.or_else(|| deployed_page(item_type, &row.display_path, &kind_value));
let mut item = json!({
"type": item_type,
"trigger_kind": trigger_kind,
"path": path,
"name": row.display_path,
"url": page.map(|page| ctx.page_url(&page)),
"editor": has_editor,
});
if row.kind == "app" {
// `deploy_workspace_item` deploys raw apps only.
item["note"] = json!("A classic app: deploy it from its editor in Windmill.");
} else if let Some(error) = &m.error {
item["error"] = json!(format!("Could not diff it: {error}"));
} else if !m.parts.has_changes && !m.value_uncomparable {
item["note"] = json!("The draft matches the deployed version: nothing to deploy.");
} else {
let patch = review_patch(&m);
let (added, removed) = changed_line_counts(&patch);
item["added"] = added.into();
item["removed"] = removed.into();
item["digest"] = super::apps::sha256_hex(&patch).into();
if patch.chars().count() > MAX_REVIEW_PATCH_CHARS {
item["too_large"] = true.into();
} else {
item["patch"] = json!(patch);
}
}
items.push(item);
}
items.extend(
missing
.into_iter()
.take(MAX_REVIEW_ITEMS.saturating_sub(items.len())),
);
let reviewable = items
.iter()
.filter(|item| item.get("digest").is_some())
.count();
ctx.set_view(
"windmill/review",
json!({
"workspace": ctx.w_id,
"items": items,
// Windmill's own "Review & deploy drafts" page.
"url": ctx.page_url(&format!("/forks/compare?workspace_id={}&mode=draft", enc(ctx.w_id))),
}),
);
if total == 0 {
return Ok("No drafts to review: nothing differs from the deployed state.".to_string());
}
let mut text = format!(
"Showing {} draft(s) to the user, {reviewable} with a diff they can deploy from the card. Nothing was deployed: you are told when the user deploys one.",
items.len()
);
if total > items.len() {
text.push_str(&format!(
" Only the first {MAX_REVIEW_ITEMS} of {total} are shown."
));
}
let rest: Vec<&str> = items
.iter()
.filter(|item| item.get("digest").is_none())
.filter_map(|item| item.get("name").unwrap_or(&item["path"]).as_str())
.collect();
if !rest.is_empty() {
text.push_str(&format!(
" No Deploy is offered for {} (nothing to deploy, or not deployable from the card).",
rest.join(", ")
));
}
Ok(text)
}
/// The draft a tool names by path. An exact storage path wins over a display name, as in
/// `diff`: a staged rename shows one draft under the path another is stored at.
fn find_draft<'a>(
rows: &'a [DraftRow],
item_type: &str,
trigger_kind: Option<&str>,
path: &str,
) -> Option<&'a DraftRow> {
let named = |row: &&DraftRow| {
row.addressing
.is_some_and(|(t, k)| t == item_type && k == trigger_kind)
};
rows.iter()
.filter(named)
.find(|row| row.path == path)
.or_else(|| {
rows.iter()
.filter(named)
.find(|row| row.display_path == path)
})
}
/// The item type a tool names, as the `'static` the draft listing addresses drafts by.
fn addressed_type(item_type: &str) -> Option<&'static str> {
ITEM_TYPES.iter().copied().find(|t| *t == item_type)
}
async fn diff(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
if args.get("search").is_some_and(|s| !s.is_null()) {
return diff_search(ctx, args).await;
@@ -1976,6 +2183,28 @@ async fn diff(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
mod tests {
use super::*;
#[test]
fn a_draft_is_found_by_its_storage_path_before_a_display_name() {
let row = |path: &str, display_path: &str| DraftRow {
kind: "script".into(),
path: path.into(),
display_path: display_path.into(),
addressing: Some(("script", None)),
};
// A rename of `x` to `y` is staged, and another draft is stored at `y`.
let rows = [row("u/a/x", "u/a/y"), row("u/a/y", "u/a/y")];
assert_eq!(
find_draft(&rows, "script", None, "u/a/y").unwrap().path,
"u/a/y"
);
assert_eq!(
find_draft(&rows[..1], "script", None, "u/a/y")
.unwrap()
.path,
"u/a/x"
);
}
#[test]
fn yaml_matches_the_session_emitter() {
// Expected texts are what the session's `yaml` library prints for these values.
+184 -16
View File
@@ -16,7 +16,7 @@ use std::collections::HashMap;
use std::sync::LazyLock;
use serde::Deserialize;
use serde_json::{Map, Value};
use serde_json::{json, Map, Value};
use windmill_mcp::server::{
BackendResult, CallToolResult, ContentBlock, ErrorData, MetaObject, SessionAccess, Tool,
ToolAnnotations,
@@ -52,6 +52,7 @@ mod items;
mod misc;
mod runs;
mod settings;
mod views;
mod workspace;
pub(super) use instructions::{
@@ -103,15 +104,71 @@ const EDITOR_ONLY: &[&str] = &[
"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.
/// 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"];
@@ -154,24 +211,69 @@ fn result_size_meta() -> MetaObject {
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::new()
.read_only(def.read_only)
.destructive(DESTRUCTIVE.contains(&def.name.as_str())),
ToolAnnotations::with_title(title)
.read_only(def.read_only && !RUNS_A_JOB.contains(&name))
.destructive(destructive)
.open_world(OPEN_WORLD.contains(&name)),
)
.with_meta(result_size_meta())
.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()
}
@@ -189,9 +291,17 @@ pub(super) struct Ctx<'a> {
/// 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)?;
@@ -513,11 +623,8 @@ pub(super) fn matches_glob(subject: &str, glob: &str) -> bool {
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> {
/// 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}'"),
@@ -534,14 +641,27 @@ pub(super) async fn call_session_tool(
None,
));
}
let Some(result) = dispatch(ctx, name, &args).await else {
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);
Ok(match result {
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) => {
@@ -549,7 +669,11 @@ pub(super) async fn call_session_tool(
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)]
@@ -590,6 +714,38 @@ mod tests {
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();
@@ -600,11 +756,23 @@ mod tests {
"session tool `{name}` must be implemented here or listed in EDITOR_ONLY, not both"
);
}
for name in implemented.iter().chain(EDITOR_ONLY) {
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"
);
}
}
}
+767 -15
View File
@@ -1497,6 +1497,10 @@ const LOGS_UNREADABLE: &str = "Logs could not be read for this run.";
struct RunTree<'a> {
entries: &'a [Value],
children: Vec<Vec<usize>>,
/// The card lists a top-level loop's latest iterations, up to this many, where the model
/// reads its failed ones and its last. Nested loops keep the model's: at every depth the
/// listing would multiply.
card_iterations: Option<usize>,
}
impl<'a> RunTree<'a> {
@@ -1513,7 +1517,7 @@ impl<'a> RunTree<'a> {
}
stack.push(i);
}
RunTree { entries, children }
RunTree { entries, children, card_iterations: None }
}
fn status(&self, i: usize) -> &str {
@@ -1654,14 +1658,22 @@ impl<'a> RunTree<'a> {
let skipped = count("skipped");
// Skipped is terminal: only running, queued and suspended are unfinished.
let unfinished = by_index.len() - ok - failed.len() - skipped;
let mut shown: Vec<usize> = failed
.iter()
.copied()
.take(MAX_FAILED_ITERATIONS_SHOWN)
.collect();
if !shown.contains(&last) {
shown.push(last);
}
let top_level = first["depth"].as_i64().is_some_and(|d| d <= 1);
let latest = self.card_iterations.filter(|_| top_level);
let shown: Vec<usize> = match latest {
Some(limit) => by_index[by_index.len().saturating_sub(limit)..].to_vec(),
None => {
let mut shown: Vec<usize> = failed
.iter()
.copied()
.take(MAX_FAILED_ITERATIONS_SHOWN)
.collect();
if !shown.contains(&last) {
shown.push(last);
}
shown
}
};
out.insert(
"type".into(),
@@ -1695,6 +1707,10 @@ impl<'a> RunTree<'a> {
(by_index.len() - shown.len()).into(),
);
}
if latest.is_some() {
// The ones left out all came before the ones listed.
out.insert("iterations_latest".into(), true.into());
}
out
}
@@ -1804,6 +1820,12 @@ fn stringify(value: &Value) -> String {
}
async fn get_run(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
read_run(ctx, args, false).await
}
/// `get_run`'s answer, and with `card` what the run card draws of it, which costs more reads
/// than the model's answer: only `show_run`, the card's own refresh, asks for it.
async fn read_run(ctx: &Ctx<'_>, args: &Value, card: bool) -> ToolResult {
let id = str_arg(args, "id")?;
let results_route = |max: usize| {
ctx.ws(&format!(
@@ -1828,12 +1850,69 @@ async fn get_run(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
enc(id)
));
let tree_route = results_route(TREE_RESULT_HEAD_CHARS);
let (job, logs, results) = tokio::join!(
ctx.get(&job_route),
ctx.request("GET", &logs_route, None),
ctx.get(&tree_route),
);
// The card reads only the end of the log, below; the model's answer, the whole of it.
let logs = async {
match card {
true => Err(String::new()),
false => ctx.request("GET", &logs_route, None).await,
}
};
let (job, logs, results) = tokio::join!(ctx.get(&job_route), logs, ctx.get(&tree_route));
let results = results.unwrap_or_else(|_| json!({ "entries": [] }));
if let (true, Ok(job)) = (card, &job) {
let mut details = Map::new();
let entries = results["entries"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
let mut steps = card_steps(entries, job);
details.extend(agent_card(job, &mut steps));
details.insert("step_list".into(), steps.into());
let mut logs = log_tail(ctx, id, CARD_LOG_CHARS).await;
// `getupdate` reads only what is still in the database, and archiving can leave
// little there. A finished run is not refreshed again, so it can afford the full read.
let short = |(tail, cut): &(String, bool)| *cut && js_len(tail) < CARD_LOG_CHARS as usize;
if job.get("success").is_some() && logs.as_ref().is_some_and(short) {
if let Ok(full) = ctx.request("GET", &logs_route, None).await {
let tail = js_tail(&full, CARD_LOG_CHARS as usize);
logs = Some((tail.to_string(), tail.len() < full.len()));
}
}
if let Some((tail, cut)) = logs.as_ref().filter(|(tail, _)| !tail.trim().is_empty()) {
if *cut {
details.insert("logs_cut".into(), true.into());
}
details.insert("logs".into(), tail.clone().into());
}
// While a run goes on, its latest log line says what it is doing: a flow's is in the
// deepest step still running, a script's in its own log.
if job.get("success").is_none() {
let running = entries
.iter()
.skip(1)
.filter(|e| e["status"] == "running")
.filter_map(|e| e["job_id"].as_str())
.last();
let live = match running {
Some(step) => log_tail(ctx, step, 400).await.map(|(tail, _)| tail),
None => logs.map(|(tail, _)| tail),
};
if let Some(line) = live
.as_deref()
.and_then(|l| l.lines().rev().find(|l| !l.trim().is_empty()))
{
details.insert("live_log".into(), js_head(line.trim(), 300).into());
}
}
match big_image_result(ctx, id, job, entries.first()).await {
Some(image) => {
let mut job = job.clone();
job["result"] = image;
report_run(ctx, id, Some(&job), details);
}
None => report_run(ctx, id, Some(job), details),
}
}
Ok(format!(
"{}\n\nRun page: {}",
shape_run(id, &job?, logs, &results),
@@ -1841,6 +1920,559 @@ async fn get_run(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
))
}
/// The job read swaps a result past ~90 KB for a marker, so an image under the card's cap
/// is read again by its one field. The step tree's head of the result says which it is.
async fn big_image_result(
ctx: &Ctx<'_>,
id: &str,
job: &Value,
root: Option<&Value>,
) -> Option<Value> {
if job.get("result")? != TOO_BIG {
return None;
}
let root = root?;
let kind = root["result_prefix"]
.as_str()?
.strip_prefix("{\"")?
.split('"')
.next()?;
if !["png", "jpeg", "gif", "svg"].contains(&kind)
|| root["result_length"].as_u64()? > CARD_IMAGE_CHARS as u64 + 64
{
return None;
}
let route = ctx.ws(&format!(
"jobs/completed/get_result/{}?json_path={kind}",
enc(id)
));
let field = ctx.get(&route).await.ok()?;
// The field alone must be the whole result, as `card_image` requires: a result with more
// keys is no image, and keeps its marker. The length is the result's text, in characters.
let whole = json!({ kind: field });
(jsonb_text_len(&whole) == root["result_length"].as_u64()?).then_some(whole)
}
/// The length in characters of a value as PostgreSQL prints a jsonb: `", "` between items
/// and `": "` after keys. Key order, which jsonb changes, does not change it.
fn jsonb_text_len(value: &Value) -> u64 {
let text = |v: &Value| serde_json::to_string(v).map_or(0, |t| t.chars().count() as u64);
let separators = |n: usize| 2 * n.saturating_sub(1) as u64;
match value {
Value::Array(items) => {
2 + separators(items.len()) + items.iter().map(jsonb_text_len).sum::<u64>()
}
Value::Object(map) => {
2 + separators(map.len())
+ map
.iter()
.map(|(k, v)| text(&Value::String(k.clone())) + 2 + jsonb_text_len(v))
.sum::<u64>()
}
_ => text(value),
}
}
/// The end of a job's log, read without the rest of it: an offset past the end returns
/// no log but where it ends, and a second read starts just before that. A log grows as long
/// as its job runs, and the card asks again on every refresh. The result stream's offset is
/// past its end too, or each read would carry the whole stream. Also says whether the log
/// goes back further.
async fn log_tail(ctx: &Ctx<'_>, job: &str, chars: i64) -> Option<(String, bool)> {
let update = |offset: i64| {
ctx.ws(&format!(
"jobs_u/getupdate/{}?log_offset={offset}&stream_offset={}",
enc(job),
i32::MAX
))
};
let end = ctx.get(&update(i32::MAX as i64)).await.ok()?["log_offset"].as_i64()?;
// Offsets count from 1, and `end` is one past the last character.
let start = (end - chars).max(1);
let tail = ctx.get(&update(start)).await.ok()?;
tail["new_logs"]
.as_str()
.map(|logs| (logs.to_string(), start > 1))
}
/// The card of one run: what `get_run` hands it, under a one-line answer, since the model
/// shows a run it already read.
pub(super) async fn show_run(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
let id = str_arg(args, "id")?;
read_run(ctx, args, true).await?;
let Some((_, Value::Object(mut run))) = ctx.view.lock().unwrap().take() else {
return Err(format!("Run {id} could not be read."));
};
// A run knows its path alone: the card names it as an item does, by its summary.
let get = match run.get("job_kind").and_then(Value::as_str) {
Some("script") => Some("scripts/get/p"),
Some("flow") => Some("flows/get"),
_ => None,
};
if let (Some(get), Some(path)) = (get, run.get("path").and_then(Value::as_str)) {
let route = ctx.ws(&format!("{get}/{}", path_enc(path)));
if let Ok(item) = ctx.get(&route).await {
if let Some(summary) = item["summary"].as_str().filter(|s| !s.is_empty()) {
run.insert("summary".into(), summary.into());
}
}
}
let status = run["status"].as_str().unwrap_or("running").to_string();
ctx.set_view("windmill/run", Value::Object(run));
Ok(format!(
"Showing run {id} ({status}) to the user as a live card."
))
}
/// Runs `show_runs` draws at most: each is one job read.
const MAX_RUNS_SHOWN: usize = 20;
pub(super) async fn show_runs(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
let ids: Vec<&str> = args
.get("ids")
.and_then(Value::as_array)
.map(|ids| ids.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if ids.is_empty() {
return Err("ids is required: the runs to show, from list_runs.".to_string());
}
let shown = &ids[..ids.len().min(MAX_RUNS_SHOWN)];
let routes: Vec<String> = shown
.iter()
.map(|id| ctx.ws(&format!("jobs_u/get/{}?no_logs=true&no_code=true", enc(id))))
.collect();
let jobs = futures::future::join_all(routes.iter().map(|route| ctx.get(route))).await;
let mut runs = Vec::new();
let mut missing = Vec::new();
for (id, job) in shown.iter().zip(jobs) {
match job {
Ok(job) => {
let mut run = run_card(id, Some(&job));
// A table row draws neither: an image would ride along unseen.
run.remove("result");
run.remove("image");
run.insert(
"url".into(),
ctx.page_url(&format!("/run/{}", enc(id))).into(),
);
runs.push(Value::Object(run));
}
Err(_) => missing.push(*id),
}
}
ctx.set_view(
"windmill/runs",
json!({ "workspace": ctx.w_id, "runs": runs, "url": ctx.page_url("/runs") }),
);
let mut text = format!("Showing {} run(s) to the user as a table.", runs.len());
if ids.len() > shown.len() {
text.push_str(&format!(" Only the first {MAX_RUNS_SHOWN} are shown."));
}
if !missing.is_empty() {
text.push_str(&format!(" Not found: {}.", missing.join(", ")));
}
Ok(text)
}
/// Result head the run card shows; the full result is one click away on the run page.
const CARD_RESULT_CHARS: usize = 1500;
/// A loop's iterations the card lists, the latest ones: the rest are on the run page.
const CARD_ITERATIONS: usize = 10;
/// Past this an image result is named and linked rather than drawn: every refresh of the
/// card carries it again.
const CARD_IMAGE_CHARS: usize = 1_000_000;
/// What the job read returns in place of a result too big to send.
const TOO_BIG: &str = "WINDMILL_TOO_BIG";
const CARD_STEP_RESULT_CHARS: usize = 300;
const CARD_ARGS_CHARS: usize = 4000;
const CARD_LOG_CHARS: i64 = 3000;
/// Hand the run card the run `id`, read as `job` (`None` while it is still running), with
/// `extra` fields beside it.
fn report_run(ctx: &Ctx<'_>, id: &str, job: Option<&Value>, extra: Map<String, Value>) {
let mut run = run_card(id, job);
run.extend(extra);
run.insert("workspace".into(), ctx.w_id.into());
run.insert(
"url".into(),
ctx.page_url(&format!("/run/{}", enc(id))).into(),
);
// `cancel_job` writes: a token that cannot call it gets no Cancel button.
run.insert(
"can_cancel".into(),
matches!(ctx.access, super::SessionAccess::Full).into(),
);
ctx.set_view("windmill/run", Value::Object(run));
}
/// A flow run's top-level steps for the card's details, grouped as the model reads them:
/// a loop as its iteration counts, a parallel step as its branches, a retry as its attempts.
fn card_steps(entries: &[Value], job: &Value) -> Vec<Value> {
if entries.is_empty() {
return Vec::new();
}
let mut tree = RunTree::new(entries);
tree.card_iterations = Some(CARD_ITERATIONS);
let mut steps = tree.shape_children(0, (CARD_STEP_RESULT_CHARS, CARD_STEP_RESULT_CHARS));
let mut modules = HashMap::new();
collect_modules(&job["raw_flow"], &mut modules);
name_steps(&mut steps, &modules);
// Which branch a top-level branch-one step took, which only the flow's status says.
for status in job["flow_status"]["modules"]
.as_array()
.into_iter()
.flatten()
{
let chosen = &status["branch_chosen"];
let Some(step) = steps.iter_mut().find(|s| s["step"] == status["id"]) else {
continue;
};
let taken = match (chosen["type"].as_str(), chosen["branch"].as_u64()) {
(Some("default"), _) => "Default branch".to_string(),
(Some("branch"), Some(i)) => modules
.get(status["id"].as_str().unwrap_or_default())
.and_then(|m| m["value"]["branches"][i as usize]["summary"].as_str())
.filter(|s| !s.is_empty())
.map_or_else(|| format!("Branch {}", i + 1), str::to_string),
_ => continue,
};
step["branch_taken"] = taken.into();
}
steps
}
/// What the card says of a run's agents, from the flow's status: the tool each call ran, an
/// approval or a question one of them waits on, and whether the run is a saved agent's.
fn agent_card(job: &Value, steps: &mut Vec<Value>) -> Map<String, Value> {
let mut out = Map::new();
let mut tools = HashMap::new();
for status in job["flow_status"]["modules"]
.as_array()
.into_iter()
.flatten()
{
let actions = status["agent_actions"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
for action in actions {
if let (Some(job), Some(name)) =
(action["job_id"].as_str(), action["function_name"].as_str())
{
tools.insert(job.to_string(), name.to_string());
}
}
// A request is answered once its `agent_actions_success` entry is written.
let answered = status["agent_actions_success"]
.as_array()
.map_or(0, Vec::len);
let waiting = actions.iter().enumerate().rev().find(|(i, a)| {
*i >= answered && matches!(a["type"].as_str(), Some("approval" | "question"))
});
// A run canceled or timed out while waiting keeps the request unanswered: only a run
// still going waits on it.
if let Some((_, waiting)) = waiting.filter(|_| job.get("success").is_none()) {
out.entry("waiting").or_insert_with(|| waiting.clone());
}
}
name_tool_calls(steps, &tools);
let modules = job["raw_flow"]["modules"].as_array();
if modules.is_some_and(|m| m.len() == 1 && m[0]["id"] == crate::agent_runs::AGENT_NODE_ID) {
out.insert("agent_run".into(), true.into());
// Its one step is internal: the run's steps are its tool calls.
if let [root] = &mut steps[..] {
let calls = root.get_mut("steps").map(Value::take);
*steps = calls
.and_then(|c| serde_json::from_value(c).ok())
.unwrap_or_default();
}
}
out
}
fn name_tool_calls(steps: &mut [Value], tools: &HashMap<String, String>) {
for step in steps {
if let Some(name) = step["job_id"].as_str().and_then(|job| tools.get(job)) {
step["tool"] = name.clone().into();
}
for key in ["branches", "iterations_shown", "steps"] {
if let Some(children) = step.get_mut(key).and_then(Value::as_array_mut) {
name_tool_calls(children, tools);
}
}
}
}
/// A flow's modules by id, at any depth, for the card to name its steps as Windmill's graph
/// does: by summary, else by kind.
fn collect_modules<'a>(value: &'a Value, out: &mut HashMap<&'a str, &'a Value>) {
let visit = |module: &'a Value, out: &mut HashMap<&'a str, &'a Value>| {
if let Some(id) = module["id"].as_str() {
out.insert(id, module);
}
collect_modules(&module["value"], out);
};
for key in ["modules", "default"] {
for module in value[key].as_array().into_iter().flatten() {
visit(module, out);
}
}
for branch in value["branches"].as_array().into_iter().flatten() {
collect_modules(branch, out);
}
for key in ["failure_module", "preprocessor_module"] {
if value[key].is_object() {
visit(&value[key], out);
}
}
}
/// Puts each step's summary and module kind on it, the steps of iterations and branches
/// included; an iteration or a branch is named by its place, so it gets neither.
fn name_steps(steps: &mut [Value], modules: &HashMap<&str, &Value>) {
for step in steps {
let id = step["step"].as_str().unwrap_or_default();
if let Some(module) = modules.get(id.rsplit('/').next().unwrap_or(id)) {
if let Some(summary) = module["summary"].as_str().filter(|s| !s.is_empty()) {
step["summary"] = summary.into();
}
let value = &module["value"];
step["module"] = json!({
"type": value["type"],
"language": value["language"],
"parallel": value["parallel"],
"path": value["path"],
});
// A branch is named by its summary, as the graph names it.
for (part, branch) in step
.get_mut("branches")
.and_then(Value::as_array_mut)
.into_iter()
.flatten()
.zip(value["branches"].as_array().into_iter().flatten())
{
if let Some(summary) = branch["summary"].as_str().filter(|s| !s.is_empty()) {
part["summary"] = summary.into();
}
}
// An inner flow's steps are its own flow's modules, which this one does not hold.
if value["type"] == "flow" {
continue;
}
}
// `get_mut`: indexing a value mutably would add the keys it looks for.
for key in ["branches", "iterations_shown"] {
for part in step
.get_mut(key)
.and_then(Value::as_array_mut)
.into_iter()
.flatten()
{
if let Some(children) = part.get_mut("steps").and_then(Value::as_array_mut) {
name_steps(children, modules);
}
}
}
if let Some(children) = step.get_mut("steps").and_then(Value::as_array_mut) {
name_steps(children, modules);
}
}
}
/// A result Windmill shows as a file, an image, a page or a map, named for the card: its
/// content (often base64) is no use cut to the card's head, and the run page shows it.
fn describe_result(result: &Value) -> Option<String> {
let s3 = |value: &Value| value.get("s3").and_then(Value::as_str).map(str::to_string);
if let Some(items) = result.as_array().filter(|a| !a.is_empty()) {
return items
.iter()
.all(|item| s3(item).is_some())
.then(|| format!("{} files in object storage", items.len()));
}
let object = result.as_object()?;
if let Some(path) = s3(result) {
return Some(format!("File {path} in object storage"));
}
if let Some(name) = object
.get("filename")
.and_then(Value::as_str)
.filter(|_| object.contains_key("file"))
{
return Some(format!("File {name}"));
}
let [(key, _)] = object.iter().collect::<Vec<_>>()[..] else {
return None;
};
Some(match key.as_str() {
"png" | "jpeg" | "gif" => format!("{} image", key.to_uppercase()),
"svg" => "SVG image".to_string(),
"pdf" => "PDF document".to_string(),
"file" => "File".to_string(),
"html" => "HTML page".to_string(),
"map" => "Map".to_string(),
_ => return None,
})
}
/// An image result as Windmill draws it, for the card to draw too: base64 for PNG, JPEG and
/// GIF, the markup itself for SVG, either bare or as a file's `content`.
fn card_image(result: &Value) -> Option<Value> {
let [(kind, value)] = result.as_object()?.iter().collect::<Vec<_>>()[..] else {
return None;
};
let data = value
.as_str()
.or_else(|| value.get("content").and_then(Value::as_str))
.filter(|d| !d.is_empty() && d.len() <= CARD_IMAGE_CHARS)?;
let base64 = |d: &str| {
d.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'\n' | b'\r'))
};
match kind.as_str() {
"png" | "jpeg" | "gif" if base64(data) => Some(json!({ "type": kind, "data": data })),
"svg" => Some(json!({ "type": "svg", "data": data })),
_ => None,
}
}
/// The envelope an AI agent step returns (`AIAgentResult`): its answer and its conversation,
/// and nothing a script would also return.
fn is_agent_envelope(result: &Value) -> bool {
const KEYS: [&str; 5] = ["output", "messages", "usage", "wm_stream", "reasoning"];
result.as_object().is_some_and(|o| {
o.contains_key("output")
&& o.get("messages").is_some_and(Value::is_array)
&& o.keys().all(|k| KEYS.contains(&k.as_str()))
})
}
/// What the run card draws of a run: its summary, the head of its result (a failure's
/// message alone), and a running flow's top-level step.
fn run_card(id: &str, job: Option<&Value>) -> Map<String, Value> {
let mut run = job.map(summarize_run).unwrap_or_default();
run.insert("id".into(), id.into());
run.entry("status").or_insert("running".into());
if let Some(job) = job {
let too_big = job.get("result").is_some_and(|r| r == TOO_BIG);
let result = job.get("result").filter(|r| !r.is_null() && !too_big);
// An agent returns its answer inside the envelope of its conversation: the card shows
// the answer, and how many tools it called and tokens it used.
let envelope = result.filter(|r| is_agent_envelope(r));
if let Some(envelope) = envelope {
let calls = envelope["messages"]
.as_array()
.map_or(0, |m| m.iter().filter(|m| m["role"] == "tool").count());
run.insert("agent_tool_calls".into(), calls.into());
if let Some(tokens) = envelope["usage"]["total_tokens"].as_u64() {
run.insert("agent_tokens".into(), tokens.into());
}
}
let result = envelope.map(|e| &e["output"]).or(result);
if too_big {
run.insert("result".into(), "Too large to show here.".into());
run.insert("result_cut".into(), true.into());
}
let error = result
.map(|r| &r["error"])
.filter(|e| e["message"].is_string() && job.get("success") == Some(&json!(false)));
// An error reads as Windmill shows it: `name: message`, then its stack.
if let Some(stack) = error
.and_then(|e| e["stack"].as_str())
.filter(|s| !s.is_empty())
{
run.insert("stack".into(), js_head(stack, CARD_RESULT_CHARS).into());
}
let described = result.filter(|_| error.is_none()).and_then(describe_result);
if let Some(label) = &described {
run.insert("result_kind".into(), "file".into());
run.insert("result".into(), label.clone().into());
if let Some(image) = result.and_then(card_image) {
run.insert("image".into(), image);
}
} else if let Some(text) = error
.map(|e| match e["name"].as_str().filter(|n| !n.is_empty()) {
Some(name) => format!("{name}: {}", e["message"].as_str().unwrap_or_default()),
None => e["message"].as_str().unwrap_or_default().to_string(),
})
.or_else(|| {
// Markdown is shown as its text.
let markdown = result
.and_then(|r| r.get("markdown"))
.and_then(Value::as_str);
markdown
.map(String::from)
.or_else(|| result.map(|r| format_result(Some(r))))
})
{
let head = js_head(&text, CARD_RESULT_CHARS);
if head.len() < text.len() {
run.insert("result_cut".into(), true.into());
}
run.insert("result".into(), head.into());
}
// The arguments it ran with, as the model reads them in `get_run`; past the card's
// size the run page has them.
if let Some(args) = job
.get("args")
.filter(|a| a.as_object().is_some_and(|o| !o.is_empty()))
{
if js_len(&args.to_string()) <= CARD_ARGS_CHARS {
run.insert("args".into(), args.clone());
} else {
run.insert("args_cut".into(), true.into());
}
}
// 1-based, of how many: `step` already points past the last module once it is done.
let status = &job["flow_status"];
if let Some(modules) = status["modules"]
.as_array()
.filter(|_| job.get("success").is_none())
{
let step = status["step"].as_u64().unwrap_or(0) as usize;
run.insert("steps".into(), modules.len().into());
run.insert("step".into(), (step + 1).min(modules.len()).into());
if let Some(step_id) = modules.get(step).and_then(|m| m.get("id")) {
run.insert("step_id".into(), step_id.clone());
}
// How far into its loop or its branches the current step is. Run in parallel, its
// index only counts what started, so what finished is said instead.
let module = modules.get(step).unwrap_or(&Value::Null);
let iterator = &module["iterator"];
let iterations = iterator["itered_len"]
.as_u64()
.or_else(|| iterator["itered"].as_array().map(|a| a.len() as u64));
let done = (module["parallel"] == Value::Bool(true)).then(|| {
module["flow_jobs_success"]
.as_array()
.map_or(0, |a| a.iter().filter(|v| !v.is_null()).count())
});
let progress = match (iterator["index"].as_u64(), iterations, done) {
(_, Some(n), Some(done)) if n > 0 => Some(format!("{done} of {n} iterations done")),
(Some(i), Some(n), None) if n > 0 => {
Some(format!("iteration {} of {n}", (i + 1).min(n)))
}
_ => match (
module["branchall"]["branch"].as_u64(),
module["branchall"]["len"].as_u64(),
done,
) {
(_, Some(n), Some(done)) if n > 0 => {
Some(format!("{done} of {n} branches done"))
}
(Some(b), Some(n), None) if n > 0 => {
Some(format!("branch {} of {n}", (b + 1).min(n)))
}
_ => None,
},
};
if let Some(progress) = progress {
run.insert("step_progress".into(), progress.into());
}
}
}
run
}
/// A run as `get_run` reports it: its summary, args, result and logs around the step
/// tree. `logs` is the log read's own outcome, so a failed read is told apart from an
/// empty log.
@@ -1877,7 +2509,7 @@ fn shape_run(id: &str, job: &Value, logs: Result<String, String>, results: &Valu
}
}
if let Some(result) = job.get("result") {
if result == "WINDMILL_TOO_BIG" {
if result == TOO_BIG {
run.insert("result_truncated".into(), true.into());
} else {
run.insert("result".into(), cap(&stringify(result), false).into());
@@ -2133,4 +2765,124 @@ mod tests {
assert_eq!(transforms["max_iterations"]["value"], 10);
assert!(!transforms.contains_key("memory_id"));
}
#[test]
fn run_card_shows_a_failure_message_and_a_running_flow_step() {
let failed = json!({
"type": "CompletedJob", "success": false, "duration_ms": 3600,
"result": { "error": { "name": "Error", "message": "duplicate key" } },
});
let card = run_card("j", Some(&failed));
assert_eq!(card["status"], "failure");
assert_eq!(card["result"], "Error: duplicate key");
let running = json!({
"type": "QueuedJob", "running": true,
"flow_status": { "step": 1, "modules": [{ "id": "a" }, { "id": "b" }] },
});
let card = run_card("j", Some(&running));
assert_eq!(
(card["step"].clone(), card["steps"].clone()),
(json!(2), json!(2))
);
assert_eq!(card["step_id"], "b");
// Not read yet: still running, by id alone.
assert_eq!(run_card("j", None)["status"], "running");
let image =
json!({ "type": "CompletedJob", "success": true, "result": { "png": "iVBORw0KGgo" } });
let card = run_card("j", Some(&image));
assert_eq!(
(&card["result_kind"], &card["result"]),
(&json!("file"), &json!("PNG image"))
);
assert_eq!(
card["image"],
json!({ "type": "png", "data": "iVBORw0KGgo" })
);
// Not base64: never put in a data URL.
let odd = json!({ "type": "CompletedJob", "success": true, "result": { "png": "a\"b" } });
assert!(run_card("j", Some(&odd)).get("image").is_none());
// Cut by UTF-16 units: 800 emoji are 1,600 of them, past the head's 1,500.
let emoji = json!({ "type": "CompletedJob", "success": true, "result": "😀".repeat(800) });
assert_eq!(run_card("j", Some(&emoji))["result_cut"], true);
}
#[test]
fn agent_card_names_tool_calls_and_says_what_waits() {
let job = json!({
"raw_flow": { "modules": [{ "id": crate::agent_runs::AGENT_NODE_ID }] },
"flow_status": { "modules": [{
"id": crate::agent_runs::AGENT_NODE_ID,
"agent_actions": [
{ "type": "tool_call", "job_id": "j1", "function_name": "lookup_order", "module_id": "t" },
{ "type": "question", "call_id": "c", "question": "Which region?", "expires_at": "x" }
],
"agent_actions_success": [true]
}] }
});
let mut steps = vec![json!({
"step": crate::agent_runs::AGENT_NODE_ID,
"steps": [{ "step": "x/", "job_id": "j1" }]
})];
let card = agent_card(&job, &mut steps);
assert_eq!(card["agent_run"], true);
assert_eq!(card["waiting"]["question"], "Which region?");
// A saved agent's one internal step gives way to its tool calls, named.
assert_eq!(steps.len(), 1);
assert_eq!(steps[0]["tool"], "lookup_order");
// A run that ended no longer waits, answered or not.
let mut ended = job.clone();
ended["success"] = json!(false);
assert!(agent_card(&ended, &mut vec![]).get("waiting").is_none());
// An answered request no longer waits.
let mut answered = job.clone();
answered["flow_status"]["modules"][0]["agent_actions_success"] = json!([true, true]);
assert!(agent_card(&answered, &mut vec![]).get("waiting").is_none());
// The agent's answer, out of its envelope.
assert!(is_agent_envelope(
&json!({ "output": "hi", "messages": [] })
));
assert!(!is_agent_envelope(
&json!({ "output": "hi", "messages": [], "extra": 1 })
));
}
#[test]
fn jsonb_text_len_matches_postgres_printing() {
// As `select '{"png": {"content": "iVBO", "filename": "a.png"}}'::jsonb::text`.
let wrapped = json!({ "png": { "content": "iVBO", "filename": "a.png" } });
let printed = r#"{"png": {"content": "iVBO", "filename": "a.png"}}"#;
assert_eq!(jsonb_text_len(&wrapped), printed.chars().count() as u64);
assert_eq!(
jsonb_text_len(&json!([1, "a\"b"])),
r#"[1, "a\"b"]"#.len() as u64
);
}
#[test]
fn steps_are_named_from_the_flow_at_any_depth() {
let flow = json!({ "modules": [{
"id": "loop", "summary": "Each customer",
"value": { "type": "forloopflow", "parallel": true, "modules": [
{ "id": "inner", "value": { "type": "rawscript", "language": "python3" } }
] }
}] });
let mut steps = vec![json!({
"step": "loop", "iterations": 1,
"iterations_shown": [{ "step": "loop", "iteration": 1, "steps": [{ "step": "loop/inner" }] }]
})];
let mut modules = HashMap::new();
collect_modules(&flow, &mut modules);
name_steps(&mut steps, &modules);
assert_eq!(steps[0]["summary"], "Each customer");
assert_eq!(steps[0]["module"]["parallel"], true);
// An iteration is named by its place, its steps by their module.
assert!(steps[0]["iterations_shown"][0].get("module").is_none());
assert!(steps[0].get("branches").is_none());
let inner = &steps[0]["iterations_shown"][0]["steps"][0];
assert_eq!(inner["module"]["language"], "python3");
}
}
@@ -0,0 +1,121 @@
//! Tools that show something to the user as a card, for MCP Apps clients. They exist only
//! on the MCP side: the Windmill chat draws its own cards. The model reads and runs through
//! the other tools, which draw nothing, and calls one of these when it means the user to
//! see a result: a host draws a card for every call of a tool that has one.
use serde_json::{json, Value};
use super::{deploy, runs, workspace, Ctx, SessionAccess, ToolResult};
pub(super) const TOOLS: &[&str] = &["show_run", "show_runs", "show_deploy_review", "show_item"];
/// The review card's one action deploys, which only a full token may do: such a token is
/// neither offered the tool nor answered when it calls it by name.
pub(super) fn allowed(name: &str, access: SessionAccess) -> bool {
name != "show_deploy_review" || access == SessionAccess::Full
}
pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult {
if !allowed(name, ctx.access) {
return Err(format!(
"{name} shows drafts to deploy, and this token cannot deploy."
));
}
match name {
"show_run" => runs::show_run(ctx, args).await,
"show_runs" => runs::show_runs(ctx, args).await,
"show_deploy_review" => deploy::show_deploy_review(ctx, args).await,
"show_item" => workspace::show_item(ctx, args).await,
_ => unreachable!("{name} is not a view tool"),
}
}
/// Each tool as `(name, description, input schema)`. All only read: the review card's
/// Deploy button calls `deploy_workspace_item`, which the token's own access gates.
pub(super) fn definitions() -> [(&'static str, &'static str, Value); 4] {
[
(
"show_run",
"Show a run to the user as a live card in the conversation: status, result, steps and logs, refreshing until it finishes, with a link to it in Windmill and a button to cancel it while it runs. Call it after starting a run the user asked for, or when they ask to see one. Do not call it for runs you start to check your own work: read those with get_run.",
json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "The run (job) id, as run_script, run_flow, test_run_script, test_run_flow or list_runs returned it." }
},
"required": ["id"],
"additionalProperties": false
}),
),
(
"show_runs",
"Show runs to the user as a table in the conversation: status, path, duration, when, and a link to each. Pass ids from list_runs. Call it when the user asks to see runs; to inspect runs yourself, use list_runs and get_run.",
json!({
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"maxItems": 20,
"description": "Run (job) ids from list_runs, in the order to show them."
}
},
"required": ["ids"],
"additionalProperties": false
}),
),
(
"show_deploy_review",
"Show the user drafts to review before they are deployed: each draft's diff against the deployed version, with a Deploy button the user presses. Call it when changes are ready and the user should approve them, instead of deploying them yourself. This tool deploys nothing; you are told when the user deploys one. If the user says they see no card, show them the diff and deploy with deploy_workspace_item once they agree.",
json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"maxItems": 10,
"description": "The drafts to review. Omit for all of the user's drafts (the first 10).",
"items": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["script", "flow", "schedule", "trigger", "resource", "variable", "app"] },
"path": { "type": "string" },
"trigger_kind": { "type": "string", "description": "Required when type is trigger." }
},
"required": ["type", "path"],
"additionalProperties": false
}
}
},
"additionalProperties": false
}),
),
(
"show_item",
"Show the user a workspace item as a card in the conversation: a script, flow, schedule, trigger, resource or app, with its key settings and a link to it in Windmill (its editor, for a draft). Call it when the user asks to see an item; to read one yourself, use read_workspace_item.",
json!({
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["script", "flow", "schedule", "trigger", "resource", "app"] },
"path": { "type": "string", "description": "Workspace path of the item." },
"trigger_kind": { "type": "string", "description": "Required when type is trigger.", "enum": ["http", "websocket", "kafka", "nats", "postgres", "mqtt", "amqp", "sqs", "gcp", "azure", "email"] }
},
"required": ["type", "path"],
"additionalProperties": false
}),
),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_full_token_gets_the_deploy_review() {
use SessionAccess::*;
assert!(allowed("show_deploy_review", Full));
assert!(!allowed("show_deploy_review", ReadAndRun));
assert!(!allowed("show_deploy_review", ReadOnly));
assert!(allowed("show_run", ReadOnly));
}
}
@@ -1023,7 +1023,7 @@ pub(super) fn is_agent(value: &Value) -> bool {
}
/// The editor that shows the user's draft: a detail page shows only the deployed version.
fn draft_page(item_type: &str, path: &str, value: &Value) -> Option<String> {
pub(super) fn draft_page(item_type: &str, path: &str, value: &Value) -> Option<String> {
Some(match item_type {
"script" => format!("/scripts/edit/{path}?nomenubar=true"),
"flow" => format!("/flows/edit/{path}?nomenubar=true"),
@@ -1414,6 +1414,64 @@ async fn list_workspace_items(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
Ok(pretty(&Value::Array(results)))
}
/// The card of one workspace item: what `read_workspace_item` returns, under a one-line
/// answer, with the item's page and the editor its draft opens in.
pub(super) async fn show_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
let item_type = item_type_arg(args)?;
// A variable's value never reaches a tool, which leaves its card nothing to show.
if item_type == "variable" {
return Err("show_item does not show variables.".to_string());
}
let path = str_arg(args, "path")?;
let text = read_workspace_item(ctx, args).await?;
let item: Value = serde_json::from_str(&text).map_err(|_| text.clone())?;
if item.get("success") == Some(&Value::Bool(false)) {
return Err(item["error"].as_str().unwrap_or(&text).to_string());
}
// Triggers have no page of their own: their kind's list is where one is seen.
let page = match (item_type, opt_str_arg(args, "trigger_kind")) {
("trigger", Some("http")) => Some("/routes".to_string()),
("trigger", Some(kind)) => Some(format!("/{kind}_triggers")),
// The value tells an AI agent, which has pages of its own, from a resource.
_ => super::deploy::deployed_page(item_type, path, &item["value"]),
};
let url = |page: Option<String>| page.map(|page| ctx.page_url(&page));
let edit_url = url(edit_page(item_type, path, &item));
// What a schedule or a trigger runs, on its own page.
let value = &item["value"];
let runs_url = url(value["script_path"].as_str().and_then(|target| {
let kind = if truthy(value.get("is_flow")) {
"flow"
} else {
"script"
};
super::deploy::deployed_page(kind, target, &Value::Null)
}));
ctx.set_view(
"windmill/item",
json!({
"workspace": ctx.w_id,
"item": item,
"url": url(page),
"edit_url": edit_url,
"runs_url": runs_url,
}),
);
Ok(format!(
"Showing {item_type} \"{path}\" to the user as a card."
))
}
/// The editor an item's draft opens in. An app's is its own editor: its page shows only the
/// deployed version, and a new app has none.
fn edit_page(item_type: &str, path: &str, item: &Value) -> Option<String> {
match item_type {
"app" if truthy(item.get("rawApp")) => Some(format!("/apps_raw/edit/{path}")),
"app" => Some(format!("/apps/edit/{path}")),
_ => draft_page(item_type, path, &item["value"]),
}
}
async fn read_workspace_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
if let Some(access_type) = opt_str_arg(args, "type").filter(|t| ACCESS_TYPES.contains(t)) {
return read_access_entity(ctx, access_type, str_arg(args, "path")?).await;
@@ -266,6 +266,10 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
/// The tools a token with this access is offered.
fn session_tools(&self, access: SessionAccess) -> Vec<Tool>;
/// The MCP Apps views (`ui://` HTML) session tools render their results in, as
/// `(uri, html)`.
fn session_views(&self) -> &'static [(&'static str, &'static str)];
async fn call_session_tool(
&self,
auth: &Self::Auth,
+109 -6
View File
@@ -15,9 +15,11 @@ use crate::server::runner::{
};
use rmcp::handler::server::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, DiscoverResult,
Implementation, InitializeRequestParams, InitializeResult, ListToolsResult,
PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
CallToolRequestParams, CallToolResponse, CallToolResult, ClientCapabilities, ContentBlock,
DiscoverResult, Implementation, InitializeRequestParams, InitializeResult, ListResourcesResult,
ListToolsResult, MetaObject, PaginatedRequestParams, ProtocolVersion,
ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource,
ResourceContents, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::ErrorData;
@@ -46,6 +48,34 @@ fn access<A: McpAuth>(auth: &A, scope: &McpScopeConfig) -> SessionAccess {
const GET_WORKSPACE_CONTEXT: &str = "get_workspace_context";
/// The MIME type of an MCP Apps view (the `io.modelcontextprotocol/ui` extension).
const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app";
const MCP_APPS_EXTENSION: &str = "io.modelcontextprotocol/ui";
/// A tool that only shows a card does nothing for a client that declares it draws none.
/// Only a request's own capabilities count: a legacy client's are never seen (the
/// transport keeps no session), and it keeps the tools.
fn declines_cards(capabilities: Option<&ClientCapabilities>) -> bool {
capabilities.is_some_and(|caps| {
!caps
.extensions
.as_ref()
.is_some_and(|e| e.contains_key(MCP_APPS_EXTENSION))
})
}
/// The views load nothing from outside their own page and reach Windmill only through
/// tools, so they declare no domain; a host reviewing the declared policy holds them to it.
fn mcp_app_meta() -> MetaObject {
let ui = serde_json::json!({
"prefersBorder": true,
"csp": { "connectDomains": [], "resourceDomains": [], "frameDomains": [] },
// The card's copy controls; a host that grants nothing leaves its fallback.
"permissions": { "clipboardWrite": {} },
});
MetaObject(serde_json::Map::from_iter([("ui".to_string(), ui)]))
}
/// Workspaces named in the instructions before the rest are left to `list_workspaces`.
const MAX_LISTED_WORKSPACES: usize = 40;
@@ -59,7 +89,13 @@ fn get_workspace_context_tool() -> Tool {
workspace.",
Arc::new(schema.as_object().unwrap().clone()),
)
.with_annotations(ToolAnnotations::new().read_only(true)),
.with_title("Get workspace context")
.with_annotations(
ToolAnnotations::with_title("Get workspace context")
.read_only(true)
.destructive(false)
.open_world(false),
),
)
}
@@ -198,8 +234,13 @@ impl<B: McpBackend> ServerHandler for SessionRunner<B> {
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)
InitializeResult::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
.with_server_info(server_info)
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
@@ -246,12 +287,65 @@ impl<B: McpBackend> ServerHandler for SessionRunner<B> {
.into_iter()
.map(with_workspace_id_arg),
);
if declines_cards(context.meta.client_capabilities().as_ref()) {
tools.retain(|tool| {
!tool.meta.as_ref().is_some_and(|m| {
m.0.get("ui")
.is_some_and(|ui| ui.get("resourceUri").is_some())
})
});
}
// 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))
}
/// The MCP Apps views a client draws tool results in. Static pages: what they show
/// comes from tool results, which every request authorizes on its own.
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
let views = self
.backend
.session_views()
.iter()
.map(|(uri, _)| Resource::new(*uri, *uri).with_mime_type(MCP_APP_MIME_TYPE))
.collect();
Ok(ListResourcesResult::with_all_items(views)
.with_ttl_ms(LIST_TTL_MS)
.with_cache_scope(LIST_CACHE_SCOPE))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, ErrorData> {
let Some((uri, html)) = self
.backend
.session_views()
.iter()
.find(|(uri, _)| *uri == request.uri)
else {
return Err(ErrorData::resource_not_found(
format!("No resource at {}", request.uri),
None,
));
};
// Without these a `2026-07-28` client rejects the view and draws no card.
Ok(
ReadResourceResult::new(vec![ResourceContents::text(*html, *uri)
.with_mime_type(MCP_APP_MIME_TYPE)
.with_meta(mcp_app_meta())])
.with_ttl_ms(LIST_TTL_MS)
.with_cache_scope(LIST_CACHE_SCOPE)
.into(),
)
}
async fn call_tool(
&self,
request: CallToolRequestParams,
@@ -304,6 +398,15 @@ impl<B: McpBackend> ServerHandler for SessionRunner<B> {
mod tests {
use super::*;
#[test]
fn only_a_client_declaring_no_cards_loses_the_card_tools() {
let mut caps: ClientCapabilities = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(declines_cards(Some(&caps)));
assert!(!declines_cards(None));
caps.extensions = Some([(MCP_APPS_EXTENSION.to_string(), Default::default())].into());
assert!(!declines_cards(Some(&caps)));
}
#[test]
fn origin_prefers_forwarded_headers() {
let mut headers = http::HeaderMap::new();
@@ -212,15 +212,17 @@
<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}
<Toggle
bind:checked={allWorkspaces}
options={{ right: 'All my workspaces, including ones I join later' }}
size="xs"
/>
{#if !allWorkspaces}
{#if workspaces.length === 0 && !allWorkspaces}
<p class="text-xs text-secondary">
You are not in any workspace yet. Turn this on to give it the ones you join later.
</p>
{:else if !allWorkspaces}
<MultiSelect
items={workspaces}
bind:value={pickedWorkspaces}