mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 08:02:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
8dd46a4d14
commit
8121dc2b1c
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user