From 8121dc2b1cd322914a19469cb2e764f44fc525a3 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 27 Oct 2026 23:42:14 +0100 Subject: [PATCH] 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 * fix: back off run card polling and pin its run summary Co-Authored-By: Claude Opus 5.5 * fix: keep the run card polling after a failed get_run Co-Authored-By: Claude Opus 5.5 * feat: add run details, run form, diff and item views to the mcp card Co-Authored-By: Claude Opus 5.5 * fix: guard the mcp card's run form and diff errors Co-Authored-By: Claude Opus 5.5 * fix: honour the host's dark theme in the mcp card without its colours Co-Authored-By: Claude Opus 5.5 * fix: keep untouched run form arguments and drop stale run refreshes Co-Authored-By: Claude Opus 5.5 * fix: let the run form re-send references and label diff links by target Co-Authored-By: Claude Opus 5.5 * fix: parse literals typed over run form references by field type Co-Authored-By: Claude Opus 5.5 * feat: version the mcp card uri and declare its empty csp Co-Authored-By: Claude Opus 5.5 * feat: keep the mcp card to runs, group loop and branch steps, restyle it Co-Authored-By: Claude Opus 5.5 * fix: say when the mcp card cannot read a finished run's details Co-Authored-By: Claude Opus 5.5 * feat: show runs, run tables and deploy reviews through dedicated mcp tools Co-Authored-By: Claude Opus 5.5 * fix: review the whole draft before deploying from the mcp card Co-Authored-By: Claude Opus 5.5 * feat: follow host card guidelines: header link, collapsible steps, review summary, no run form Co-Authored-By: Claude Opus 5.5 * fix: keep fullscreen review diffs whole and match drafts by storage path first Co-Authored-By: Claude Opus 5.5 * fix: link to windmill where a host has no fullscreen, cap inline logs, shrink the card back Co-Authored-By: Claude Opus 5.5 * fix: say in the review summary why a draft cannot be deployed from the card Co-Authored-By: Claude Opus 5.5 * feat: link the review to compare and deploy, cap inline tables, open long results Co-Authored-By: Claude Opus 5.5 * fix: flag a cut result or log by what was kept, keep a lone log line Co-Authored-By: Claude Opus 5.5 * fix: mark the mcp card's fullscreen buttons with the expand icon Co-Authored-By: Claude Opus 5.5 * fix: capitalize the mcp card's windmill link labels Co-Authored-By: Claude Opus 5.5 * feat: add a show_item card for workspace items, collapsible sections in fullscreen Co-Authored-By: Claude Opus 5.5 * feat: apply windmill brand guidelines to the mcp card and show errors as windmill does Co-Authored-By: Claude Opus 5.5 * feat: show more windmill result kinds in the mcp run card Co-Authored-By: Claude Opus 5.5 * fix: tidy the mcp card layout and let oversized drafts deploy from the review Co-Authored-By: Claude Opus 5.5 * feat: name a shown run by its summary, cap long item values, drop variable cards Co-Authored-By: Claude Opus 5.5 * fix: link an app draft's card to its editor Co-Authored-By: Claude Opus 5.5 * 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 * fix: read only a running step's log tail and skip the preprocessor in the progress bar Co-Authored-By: Claude Opus 5.5 * feat: mcp card headers carry a kind icon, and a summary names the item over its path Co-Authored-By: Claude Opus 5.5 * fix: skip result streams in log tail reads, keep the cut-result link when it fits Co-Authored-By: Claude Opus 5.5 * 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 * feat: simple inline mcp cards with a tabbed fullscreen run view and step drill-in Co-Authored-By: Claude Opus 5.5 * fix: draw the mcp card's tabs as windmill's underlined tabs Co-Authored-By: Claude Opus 5.5 * fix: link to windmill for what an mcp card hides when the host has no fullscreen Co-Authored-By: Claude Opus 5.5 * feat: mcp run card steps as a tree beside the selected step, nested progress, danger cancel Co-Authored-By: Claude Opus 5.5 * feat: draw image results in the mcp run card, and a collapse control in fullscreen Co-Authored-By: Claude Opus 5.5 * feat: copy controls on the mcp card's results, logs and code Co-Authored-By: Claude Opus 5.5 * fix: mcp card links read as text, hover tint only on buttons Co-Authored-By: Claude Opus 5.5 * 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 * fix: keep images out of mcp run table rows, and re-read only single-field image results Co-Authored-By: Claude Opus 5.5 * fix: measure a re-read mcp image result as postgres prints it, so wrapped images draw Co-Authored-By: Claude Opus 5.5 * feat: mcp review deploys the selected drafts, loops list every iteration's result, branches and subflows named Co-Authored-By: Claude Opus 5.5 * feat: mcp review diffs open on demand, as on the compare page Co-Authored-By: Claude Opus 5.5 * feat: mcp script cards fade their code in inline, item sections open whole in fullscreen Co-Authored-By: Claude Opus 5.5 * 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 * fix: mcp card lists a loop's latest 10 iterations, trigger topics by name Co-Authored-By: Claude Opus 5.5 * feat: mcp trigger cards open their other settings from a last table row Co-Authored-By: Claude Opus 5.5 * 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 * fix: consistent mcp cards: one expand icon and rule, aligned tables, path titles, error and empty states Co-Authored-By: Claude Opus 5.5 * fix: mcp item cards offer fullscreen for cut values and overflowing code, name script steps by path Co-Authored-By: Claude Opus 5.5 * 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 * fix: mcp saved-agent runs label tool calls by tool, never by the internal root step Co-Authored-By: Claude Opus 5.5 * feat: a saved agent's run card names its steps tab Tool calls Co-Authored-By: Claude Opus 5.5 * fix: an mcp card says an agent waits only while its run is going Co-Authored-By: Claude Opus 5.5 * 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 * fix: mcp card view read carries the cache hints 2026-07-28 clients require Co-Authored-By: Claude Opus 5.5 * feat: mcp card leaves leaving fullscreen to the host's own close control Co-Authored-By: Claude Opus 5.5 * fix: mcp cards name a saved agent's test run by the agent's path, without .chat Co-Authored-By: Claude Opus 5.5 * perf: mcp get_run builds no card data; the run card refreshes through show_run Co-Authored-By: Claude Opus 5.5 * fix: mcp show_deploy_review refuses a token that cannot deploy, not only hides from it Co-Authored-By: Claude Opus 5.5 * fix: mcp session tools carry a title and explicit read-only, destructive and open-world hints Co-Authored-By: Claude Opus 5.5 * 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 * 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 * fix: mcp item card rechecks clipped code on resize; oauth hint hides once the toggle is on Co-Authored-By: Claude Opus 5.5 * fix: mcp item card uncuts a code block once a wider card fits it Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- backend/windmill-api/src/mcp/core.rs | 5 + backend/windmill-api/src/mcp/session/apps.rs | 2 +- .../windmill-api/src/mcp/session/card.html | 1776 +++++++++++++++++ .../windmill-api/src/mcp/session/deploy.rs | 239 ++- backend/windmill-api/src/mcp/session/mod.rs | 200 +- backend/windmill-api/src/mcp/session/runs.rs | 782 +++++++- backend/windmill-api/src/mcp/session/views.rs | 121 ++ .../windmill-api/src/mcp/session/workspace.rs | 60 +- backend/windmill-mcp/src/server/backend.rs | 4 + backend/windmill-mcp/src/server/session.rs | 115 +- .../oauth/mcp_authorize/+page@(root).svelte | 8 +- 12 files changed, 3266 insertions(+), 48 deletions(-) create mode 100644 backend/windmill-api/src/mcp/session/card.html create mode 100644 backend/windmill-api/src/mcp/session/views.rs diff --git a/AGENTS.md b/AGENTS.md index ef87f1c4dc..3dd08dea24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` / `` 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` 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. diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 02219e3905..8124b6d2bb 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -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, diff --git a/backend/windmill-api/src/mcp/session/apps.rs b/backend/windmill-api/src/mcp/session/apps.rs index 66ad43de47..5e69fa348c 100644 --- a/backend/windmill-api/src/mcp/session/apps.rs +++ b/backend/windmill-api/src/mcp/session/apps.rs @@ -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())) } diff --git a/backend/windmill-api/src/mcp/session/card.html b/backend/windmill-api/src/mcp/session/card.html new file mode 100644 index 0000000000..3052e89701 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/card.html @@ -0,0 +1,1776 @@ + + + + + +Windmill + + + + +
+
+ +
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+
+ +
+ + + +
+
+ + + diff --git a/backend/windmill-api/src/mcp/session/deploy.rs b/backend/windmill-api/src/mcp/session/deploy.rs index ba65488c75..5512c92913 100644 --- a/backend/windmill-api/src/mcp/session/deploy.rs +++ b/backend/windmill-api/src/mcp/session/deploy.rs @@ -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 { +pub(super) fn deployed_page(item_type: &str, path: &str, value: &Value) -> Option { 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, } @@ -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)> = Vec::new(); + let mut missing: Vec = 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. diff --git a/backend/windmill-api/src/mcp/session/mod.rs b/backend/windmill-api/src/mcp/session/mod.rs index d00ea10e0a..bdfefbfdb2 100644 --- a/backend/windmill-api/src/mcp/session/mod.rs +++ b/backend/windmill-api/src/mcp/session/mod.rs @@ -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 = 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_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, + /// 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>, } 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) -> 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 { +/// 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 { 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 { + 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" + ); + } } } diff --git a/backend/windmill-api/src/mcp/session/runs.rs b/backend/windmill-api/src/mcp/session/runs.rs index e3d3b87768..273a273199 100644 --- a/backend/windmill-api/src/mcp/session/runs.rs +++ b/backend/windmill-api/src/mcp/session/runs.rs @@ -1497,6 +1497,10 @@ const LOGS_UNREADABLE: &str = "Logs could not be read for this run."; struct RunTree<'a> { entries: &'a [Value], children: Vec>, + /// 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, } 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 = 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 = match latest { + Some(limit) => by_index[by_index.len().saturating_sub(limit)..].to_vec(), + None => { + let mut shown: Vec = 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 { + 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::() + } + Value::Object(map) => { + 2 + separators(map.len()) + + map + .iter() + .map(|(k, v)| text(&Value::String(k.clone())) + 2 + jsonb_text_len(v)) + .sum::() + } + _ => 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 = 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) { + 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 { + 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) -> Map { + 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) { + 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 { + 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::>()[..] 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 { + let [(kind, value)] = result.as_object()?.iter().collect::>()[..] 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 { + 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, 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"); + } } diff --git a/backend/windmill-api/src/mcp/session/views.rs b/backend/windmill-api/src/mcp/session/views.rs new file mode 100644 index 0000000000..474171c2ee --- /dev/null +++ b/backend/windmill-api/src/mcp/session/views.rs @@ -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)); + } +} diff --git a/backend/windmill-api/src/mcp/session/workspace.rs b/backend/windmill-api/src/mcp/session/workspace.rs index 43ab2c33ec..ef31947243 100644 --- a/backend/windmill-api/src/mcp/session/workspace.rs +++ b/backend/windmill-api/src/mcp/session/workspace.rs @@ -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 { +pub(super) fn draft_page(item_type: &str, path: &str, value: &Value) -> Option { 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| 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 { + 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; diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index f98637fb8d..9a305843d2 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -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; + /// 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, diff --git a/backend/windmill-mcp/src/server/session.rs b/backend/windmill-mcp/src/server/session.rs index 1130a01e84..d9bb9e829b 100644 --- a/backend/windmill-mcp/src/server/session.rs +++ b/backend/windmill-mcp/src/server/session.rs @@ -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(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 ServerHandler for SessionRunner { 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 ServerHandler for SessionRunner { .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, + _context: RequestContext, + ) -> Result { + 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, + ) -> Result { + 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 ServerHandler for SessionRunner { 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(); diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte index 3daaeba05c..5b2a886f25 100644 --- a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte @@ -212,15 +212,17 @@

Workspaces it may work in:

{#if loadingWorkspaces}

Loading workspaces...

- {:else if workspaces.length === 0} -

No workspaces available.

{:else} - {#if !allWorkspaces} + {#if workspaces.length === 0 && !allWorkspaces} +

+ You are not in any workspace yet. Turn this on to give it the ones you join later. +

+ {:else if !allWorkspaces}