use serde_json::json; use sqlx::{Pool, Postgres}; #[cfg(feature = "mcp")] use uuid::Uuid; use windmill_test_utils::*; fn resource_url(port: u16, endpoint: &str, path: &str) -> String { format!("http://localhost:{port}/api/w/test-workspace/resources/{endpoint}/{path}") } fn client() -> reqwest::Client { reqwest::Client::new() } fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { builder.header("Authorization", "Bearer SECRET_TOKEN") } async fn authed_get(port: u16, endpoint: &str, path: &str) -> reqwest::Response { authed(client().get(resource_url(port, endpoint, path))) .send() .await .unwrap() } #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_resource_endpoints(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace/resources"); // --- exists --- let resp = authed_get(port, "exists", "u/test-user/simple_resource").await; assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, true); let resp = authed_get(port, "exists", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, false); // --- get --- let resp = authed_get(port, "get", "u/test-user/simple_resource").await; assert_eq!(resp.status(), 200); let body = resp.json::().await?; assert_eq!(body["path"], "u/test-user/simple_resource"); assert_eq!(body["resource_type"], "object"); assert_eq!(body["description"], "Simple resource"); assert_eq!(body["value"], json!({"host": "localhost", "port": 5432})); let resp = authed_get(port, "get", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); // --- get_value --- let resp = authed_get(port, "get_value", "u/test-user/simple_resource").await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"host": "localhost", "port": 5432}) ); let resp = authed_get(port, "get_value", "u/test-user/null_resource").await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, serde_json::Value::Null ); let resp = authed_get(port, "get_value", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); // --- get_value_interpolated --- let resp = authed_get( port, "get_value_interpolated", "u/test-user/simple_resource", ) .await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"host": "localhost", "port": 5432}) ); // $var: interpolation let resp = authed_get( port, "get_value_interpolated", "u/test-user/resource_with_var", ) .await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"host": "localhost", "password": "hunter2"}) ); // $res: interpolation let resp = authed_get( port, "get_value_interpolated", "u/test-user/resource_with_res", ) .await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"host": "localhost", "credentials": {"user": "admin", "password": "secret123"}}) ); // mixed $var: and $res: refs let resp = authed_get(port, "get_value_interpolated", "u/test-user/resource_mixed").await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"host": "localhost", "password": "hunter2", "credentials": {"user": "admin", "password": "secret123"}}) ); // chained $res: -> $var: let resp = authed_get( port, "get_value_interpolated", "u/test-user/chained_resource", ) .await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"service": "myapi", "auth": {"key": "sk-abc123"}}) ); // null value let resp = authed_get(port, "get_value_interpolated", "u/test-user/null_resource").await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, serde_json::Value::Null ); // not found let resp = authed_get(port, "get_value_interpolated", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); // array passthrough let resp = authed_get( port, "get_value_interpolated", "u/test-user/resource_with_array", ) .await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"hosts": ["host1", "host2"], "port": 5432}) ); // scalar $var: ref let resp = authed_get( port, "get_value_interpolated", "u/test-user/scalar_var_resource", ) .await; assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, json!("hunter2")); // --- list --- let resp = authed(client().get(format!("{base}/list"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert!( list.len() >= 10, "expected at least 10 resources from fixture, got {}", list.len() ); assert!(list .iter() .any(|r| r["path"] == "u/test-user/simple_resource")); // list with resource_type filter let resp = authed(client().get(format!("{base}/list?resource_type=mcp_server"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert!(list.iter().all(|r| r["resource_type"] == "mcp_server")); // --- list_search --- let resp = authed(client().get(format!("{base}/list_search"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert!(!list.is_empty()); // Values are capped so the search modal never has to hold a whole workspace of // resource content in memory. let find = |path: &str| { list.iter() .find(|r| r["path"] == path) .unwrap_or_else(|| panic!("{path} missing from list_search")) .clone() }; let oversized = find("u/test-user/oversized_resource"); assert_eq!(oversized["value"].as_str().unwrap().chars().count(), 4000); assert_eq!(oversized["truncated"], true); let simple = find("u/test-user/simple_resource"); assert!(simple["value"].as_str().unwrap().contains("\"host\"")); assert_eq!(simple["truncated"], false); // A null value must still come back as searchable text, not null. assert_eq!(find("u/test-user/null_resource")["value"], ""); // --- list_names --- let resp = authed(client().get(format!("{base}/list_names/object"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); resp.json::>().await?; // --- create --- let resp = authed(client().post(format!("{base}/create"))) .json(&json!({ "path": "u/test-user/new_resource", "value": {"url": "https://example.com"}, "description": "Created in test", "resource_type": "object" })) .send() .await .unwrap(); assert_eq!(resp.status(), 201); // verify it exists let resp = authed_get(port, "exists", "u/test-user/new_resource").await; assert_eq!(resp.json::().await?, true); // verify value let resp = authed_get(port, "get_value", "u/test-user/new_resource").await; assert_eq!(resp.status(), 200); assert_eq!( resp.json::().await?, json!({"url": "https://example.com"}) ); // create duplicate -> 400 let resp = authed(client().post(format!("{base}/create"))) .json(&json!({ "path": "u/test-user/new_resource", "value": {}, "resource_type": "object" })) .send() .await .unwrap(); assert_eq!(resp.status(), 400); // create with update_if_exists -> 201 let resp = authed(client().post(format!("{base}/create?update_if_exists=true"))) .json(&json!({ "path": "u/test-user/new_resource", "value": {"url": "https://updated.com"}, "description": "Updated via upsert", "resource_type": "object" })) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let resp = authed_get(port, "get_value", "u/test-user/new_resource").await; assert_eq!( resp.json::().await?, json!({"url": "https://updated.com"}) ); // --- update (description) --- let resp = authed(client().post(resource_url(port, "update", "u/test-user/new_resource"))) .json(&json!({"description": "Updated description"})) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "get", "u/test-user/new_resource").await; let body = resp.json::().await?; assert_eq!(body["description"], "Updated description"); // --- update (resource_type) --- // An update that only changes resource_type must persist it. let resp = authed(client().post(resource_url(port, "update", "u/test-user/new_resource"))) .json(&json!({"resource_type": "mcp_server"})) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "get", "u/test-user/new_resource").await; let body = resp.json::().await?; assert_eq!(body["resource_type"], "mcp_server"); // --- update_value --- let resp = authed(client().post(resource_url( port, "update_value", "u/test-user/new_resource", ))) .json(&json!({"value": {"url": "https://final.com"}})) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "get_value", "u/test-user/new_resource").await; assert_eq!( resp.json::().await?, json!({"url": "https://final.com"}) ); // --- delete --- let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "exists", "u/test-user/new_resource").await; assert_eq!(resp.json::().await?, false); // delete nonexistent -> 404 let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource"))) .send() .await .unwrap(); assert_eq!(resp.status(), 404); // --- file_resource_type_to_file_ext_map --- let resp = authed(client().get(format!("{base}/file_resource_type_to_file_ext_map"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let ext_map = resp.json::().await?; // Verify the map includes fileset type info with is_fileset flag (no format_extension) let fileset_info = &ext_map["test_fileset"]; assert_eq!(fileset_info["format_extension"], serde_json::Value::Null); assert_eq!(fileset_info["is_fileset"], true); // Verify non-fileset file type let file_info = &ext_map["test_file"]; assert_eq!(file_info["format_extension"], "txt"); assert_eq!(file_info["is_fileset"], false); // --- fileset resource value --- let resp = authed_get(port, "get_value", "u/test-user/fileset_resource").await; assert_eq!(resp.status(), 200); let fileset_val = resp.json::().await?; assert_eq!(fileset_val["config.yaml"], "key: value"); assert_eq!(fileset_val["data/input.json"], "{\"items\": []}"); // --- resource types --- // type/exists let resp = authed_get(port, "type/exists", "test_db").await; assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, true); let resp = authed_get(port, "type/exists", "nonexistent_type").await; assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, false); // type/get let resp = authed_get(port, "type/get", "test_db").await; assert_eq!(resp.status(), 200); let body = resp.json::().await?; assert_eq!(body["name"], "test_db"); assert_eq!(body["description"], "Test DB type"); let resp = authed_get(port, "type/get", "nonexistent_type").await; assert_eq!(resp.status(), 404); // type/list let resp = authed(client().get(format!("{base}/type/list"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert!(list.iter().any(|rt| rt["name"] == "test_db")); // type/listnames let resp = authed(client().get(format!("{base}/type/listnames"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let names = resp.json::>().await?; assert!(names.contains(&"test_db".to_string())); // type/create let resp = authed(client().post(format!("{base}/type/create"))) .json(&json!({ "name": "new_test_type", "description": "A new type", "schema": {"type": "object"} })) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let resp = authed_get(port, "type/exists", "new_test_type").await; assert_eq!(resp.json::().await?, true); // type/create duplicate -> 400 let resp = authed(client().post(format!("{base}/type/create"))) .json(&json!({ "name": "new_test_type", "description": "Duplicate", "schema": {"type": "object"} })) .send() .await .unwrap(); assert_eq!(resp.status(), 400); // type/update let resp = authed(client().post(resource_url(port, "type/update", "new_test_type"))) .json(&json!({"description": "Updated type desc"})) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "type/get", "new_test_type").await; let body = resp.json::().await?; assert_eq!(body["description"], "Updated type desc"); // display_name: an update that omits it, as a push from a CLI predating the field does, // keeps it; an explicit null clears it. for (update, expected) in [ ( json!({"display_name": "New Test Type"}), json!("New Test Type"), ), ( json!({"description": "Updated type desc"}), json!("New Test Type"), ), (json!({"display_name": null}), serde_json::Value::Null), ] { let resp = authed(client().post(resource_url(port, "type/update", "new_test_type"))) .json(&update) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "type/get", "new_test_type").await; let body = resp.json::().await?; assert_eq!(body["display_name"], expected); } // type/delete let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "type/exists", "new_test_type").await; assert_eq!(resp.json::().await?, false); // --- fileset resource type CRUD --- // type/get for fileset type - verify is_fileset is returned let resp = authed_get(port, "type/get", "test_fileset").await; assert_eq!(resp.status(), 200); let body = resp.json::().await?; assert_eq!(body["name"], "test_fileset"); assert_eq!(body["is_fileset"], true); assert_eq!(body["format_extension"], serde_json::Value::Null); // type/get for non-fileset type - verify is_fileset is false let resp = authed_get(port, "type/get", "test_db").await; assert_eq!(resp.status(), 200); let body = resp.json::().await?; assert_eq!(body["is_fileset"], false); // type/create fileset type (no format_extension needed) let resp = authed(client().post(format!("{base}/type/create"))) .json(&json!({ "name": "new_fileset_type", "description": "A fileset type", "schema": {}, "is_fileset": true })) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let resp = authed_get(port, "type/get", "new_fileset_type").await; let body = resp.json::().await?; assert_eq!(body["is_fileset"], true); assert_eq!(body["format_extension"], serde_json::Value::Null); // type/update - set is_fileset on existing type let resp = authed(client().post(resource_url(port, "type/update", "new_fileset_type"))) .json(&json!({"is_fileset": false})) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let resp = authed_get(port, "type/get", "new_fileset_type").await; let body = resp.json::().await?; assert_eq!(body["is_fileset"], false); // cleanup let resp = authed(client().delete(resource_url(port, "type/delete", "new_fileset_type"))) .send() .await .unwrap(); assert_eq!(resp.status(), 200); Ok(()) } /// Regression test: the resource-value interpolation cache /// (`get_value_interpolated?allow_cache=true`) must be identity-scoped. test-user-2 /// (folder access) warms the cache; test-user-3 (no access) must then be denied rather /// than served the cached, already-decrypted value. Pre-fix the unscoped key returned /// a 200 with the secret here. #[sqlx::test(migrations = "../migrations", fixtures("base", "resource_cache_rls"))] async fn test_resource_value_cache_is_identity_scoped(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let url = format!( "{}?allow_cache=true", resource_url(port, "get_value_interpolated", "f/secret/cache_target") ); let get = |token: &str| { client() .get(url.as_str()) .header("Authorization", format!("Bearer {token}")) }; // test-user-2 has folder access and WARMS the cache. let resp = get("SECRET_TOKEN_2").send().await?; assert_eq!(resp.status(), 200); assert!(resp.text().await?.contains("LEAKED_FOLDER_SECRET")); // test-user-3 has no folder access: must miss the cache and be denied (401), not leak. let resp = get("SECRET_TOKEN_3").send().await?; assert_eq!(resp.status(), 401); assert!(!resp.text().await?.contains("LEAKED_FOLDER_SECRET")); Ok(()) } /// A token scoped to one resource must not read, through the `$var:`/`$res:` references it /// writes into that resource, what its scopes would refuse to read directly. #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_interpolated_references_need_the_token_scopes( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); let resp = authed(client().post(format!("{base}/variables/create"))) .json(&json!({"path": "u/test-user/secret", "value": "CANARY", "is_secret": true, "description": ""})) .send() .await?; assert_eq!(resp.status(), 201); let resp = authed(client().post(format!("{base}/resources/create"))) .json(&json!({"path": "u/test-user/other", "value": {"pw": "OTHER"}, "resource_type": "object", "description": ""})) .send() .await?; assert_eq!(resp.status(), 201); let mint = |scopes: serde_json::Value| async move { let resp = authed(client().post(format!("http://localhost:{port}/api/users/tokens/create"))) .json( &json!({"label": "scoped", "scopes": scopes, "workspace_id": "test-workspace"}), ) .send() .await .unwrap(); assert_eq!(resp.status(), 201); resp.text().await.unwrap() }; let probe_only = mint(json!(["resources:write:u/test-user/probe"])).await; let with_var = mint(json!([ "resources:read:u/test-user/probe", "variables:read:u/test-user/secret" ])) .await; let read_probe = |value: serde_json::Value, token: String| { let base = base.clone(); let probe_only = probe_only.clone(); async move { let resp = client() .post(format!("{base}/resources/create?update_if_exists=true")) .bearer_auth(&probe_only) .json(&json!({"path": "u/test-user/probe", "value": value, "resource_type": "object", "description": ""})) .send() .await .unwrap(); assert_eq!(resp.status(), 201); let resp = client() .get(format!( "{base}/resources/get_value_interpolated/u/test-user/probe" )) .bearer_auth(&token) .send() .await .unwrap(); (resp.status().as_u16(), resp.text().await.unwrap()) } }; for value in [ json!({"v": "$var:u/test-user/secret"}), json!({"v": "$jsonvar:u/test-user/secret"}), json!({"v": ["$res:u/test-user/other"]}), ] { let (status, body) = read_probe(value.clone(), probe_only.clone()).await; assert_eq!(status, 403, "{value}: {body}"); assert!( !body.contains("CANARY") && !body.contains("OTHER"), "{body}" ); } let (status, body) = read_probe(json!({"v": "$var:u/test-user/secret"}), with_var.clone()).await; assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#)); // The resource's own linked secrets (at its path, or `_`) are part of it. for (path, value) in [ ("u/test-user/probe", "LINKED"), ("u/test-user/probe_key", "KEY"), ] { let resp = authed(client().post(format!("{base}/variables/create"))) .json(&json!({"path": path, "value": value, "is_secret": true, "description": ""})) .send() .await?; assert_eq!(resp.status(), 201); } let (status, body) = read_probe( json!({"pw": "$var:u/test-user/probe", "key": "$var:u/test-user/probe_key"}), probe_only.clone(), ) .await; assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body)?, json!({"pw": "LINKED", "key": "KEY"}) ); Ok(()) } /// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is /// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the /// placeholder is left unresolved (caching that would serve a stale placeholder to a later job /// read). Any other value — plain, or a non-`$WM_` `$`-string like `$HOME` (which is NOT /// interpolated, so it's constant) — is job-independent and IS cached, with the entry shared /// across job contexts (a read carrying a `job_id` still hits it, keeping the hit ratio up). /// We prove all three by warming each (no job_id), deleting the row directly (cache survives), /// then re-reading: the job-independent ones are still served from cache — even under a /// `job_id` — while the `$WM_*` one was never cached and 404s. #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_resource_cache_handles_job_context(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace/resources"); let plain = "u/test-user/plain_res"; let dollar = "u/test-user/dollar_res"; // non-$WM_ `$`-string: not interpolated, cacheable let jobctx = "u/test-user/jobctx_res"; for (path, value) in [ (plain, json!({"v": 1})), (dollar, json!({"d": "$HOME"})), (jobctx, json!({"j": "$WM_JOB_ID"})), ] { let resp = authed(client().post(format!("{base}/create"))) .json( &json!({ "path": path, "value": value, "description": "", "resource_type": "object" }), ) .send() .await?; assert_eq!(resp.status(), 201); } let get = |path: &str, query: &str| { let url = format!("{base}/get_value_interpolated/{path}?{query}"); async move { authed(client().get(url)).send().await.unwrap() } }; // Warm all three WITHOUT a job context (the placeholder is left unresolved for `jobctx`). for path in [plain, dollar, jobctx] { assert_eq!(get(path, "allow_cache=true").await.status(), 200); } // Delete the rows directly — bypasses the API/NOTIFY, so the in-memory cache survives. for path in [plain, dollar, jobctx] { sqlx::query("DELETE FROM resource WHERE workspace_id = 'test-workspace' AND path = $1") .bind(path) .execute(&db) .await?; } // Job-independent values are cached and still served even under a job_id (a random uuid is // fine: a cache hit short-circuits before any job lookup). `$HOME` is a non-`$WM_` string, // so it's not interpolated and stays cacheable. for path in [plain, dollar] { let resp = get( path, "allow_cache=true&job_id=11111111-1111-4111-8111-111111111111", ) .await; assert_eq!( resp.status(), 200, "job-independent resource ({path}) must stay cached and be served under a job_id" ); } // The `$WM_*` resource was never cached → the (now deleted) row is not found. let resp = get(jobctx, "allow_cache=true").await; assert_ne!( resp.status(), 200, "resource with a $WM_* contextual variable must not be cached" ); Ok(()) } /// Without an embeddings index the resource type search reads the table, so it has to apply the /// same scope as the index: the workspace's own types and the global ones in `admins`, never /// another workspace's. #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_resource_type_search_without_embeddings(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')") .execute(&db) .await?; for (workspace, name, description) in [ ( "test-workspace", "c_acme_billing", "Acme internal billing API", ), ( "other-workspace", "c_other_billing", "Another workspace's billing API", ), ] { sqlx::query( "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES ($1, $2, '{}'::jsonb, $3, 'test-user')", ) .bind(workspace) .bind(name) .bind(description) .execute(&db) .await?; } let search = |text: &str| { let url = format!( "http://localhost:{port}/api/w/test-workspace/embeddings/query_resource_types?text={text}" ); async move { let resp = authed(client().get(url)).send().await.unwrap(); assert_eq!(resp.status(), 200); resp.json::>() .await .unwrap() .into_iter() .map(|rt| rt["name"].as_str().unwrap().to_string()) .collect::>() } }; let billing = search("billing").await; assert_eq!(billing.first().map(String::as_str), Some("c_acme_billing")); assert!(!billing.contains(&"c_other_billing".to_string())); // `ai_skill` is seeded in `admins` by the migrations. assert!(search("skill").await.contains(&"ai_skill".to_string())); Ok(()) } #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // unauthenticated -> 401 let resp = client() .get(resource_url(port, "mcp_tools", "u/test-user/mcp_valid")) .send() .await .unwrap(); assert_eq!(resp.status(), 401); // not found -> 404 let resp = authed_get(port, "mcp_tools", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); // null value -> 400 let resp = authed_get(port, "mcp_tools", "u/test-user/mcp_null").await; assert_eq!(resp.status(), 400); // invalid format -> 400 with parse error message let resp = authed_get(port, "mcp_tools", "u/test-user/mcp_invalid_format").await; assert_eq!(resp.status(), 400); let body = resp.text().await?; assert!( body.contains("Failed to parse MCP resource"), "expected parse error, got: {body}" ); // valid MCP resource but unreachable server -> 500 let resp = authed_get(port, "mcp_tools", "u/test-user/mcp_valid").await; assert_eq!(resp.status(), 500); let body = resp.text().await?; assert!( body.contains("Failed to connect to MCP server"), "expected connection error, got: {body}" ); Ok(()) } #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_mcp_endpoint_tools_list(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let resp = authed(client().get(format!( "http://localhost:{port}/api/mcp/w/test-workspace/list_tools" ))) .send() .await?; assert_eq!(resp.status(), 200); let tools: Vec = resp.json().await?; let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); assert!( tool_names.contains(&"getJob"), "getJob not found in MCP endpoint tools: {tool_names:?}" ); assert!( tool_names.contains(&"getJobLogs"), "getJobLogs not found in MCP endpoint tools: {tool_names:?}" ); // Verify getJob has the expected path and method let get_job_tool = tools.iter().find(|t| t["name"] == "getJob").unwrap(); assert_eq!(get_job_tool["path"], "/w/{workspace}/jobs_u/get/{id}"); assert_eq!(get_job_tool["method"], "GET"); // Verify getJobLogs has the expected path and method let get_job_logs_tool = tools.iter().find(|t| t["name"] == "getJobLogs").unwrap(); assert_eq!( get_job_logs_tool["path"], "/w/{workspace}/jobs_u/get_logs/{id}" ); assert_eq!(get_job_logs_tool["method"], "GET"); Ok(()) } #[cfg(feature = "mcp")] async fn insert_completed_job_with_logs(db: &Pool) -> Uuid { let id = Uuid::new_v4(); sqlx::query( "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", ) .bind(id) .execute(db) .await .unwrap(); sqlx::query( "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')", ) .bind(id) .execute(db) .await .unwrap(); sqlx::query( "INSERT INTO job_logs (job_id, workspace_id, logs, log_offset) VALUES ($1, 'test-workspace', 'hello world test log', 0)", ) .bind(id) .execute(db) .await .unwrap(); id } #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_mcp_client_get_job_and_logs(db: Pool) -> anyhow::Result<()> { use rmcp::model::{ CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, InitializeRequestParams, }; use rmcp::service::{RoleClient, RunningService}; use rmcp::transport::streamable_http_client::{ StreamableHttpClientTransport, StreamableHttpClientTransportConfig, }; use rmcp::ServiceExt; initialize_tracing().await; set_jwt_secret().await; let server = ApiServer::start_mcp(db.clone()).await?; let port = server.addr.port(); let job_id = insert_completed_job_with_logs(&db).await; // Create a token with MCP scopes sqlx::query( "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])", ) .execute(&db) .await?; // Connect as MCP client let config = StreamableHttpClientTransportConfig::with_uri(format!( "http://localhost:{port}/api/mcp/w/test-workspace/mcp" )) .auth_header("MCP_TOKEN"); let transport = StreamableHttpClientTransport::from_config(config); let client_info = ClientInfo::new( ClientCapabilities::default(), Implementation::new("test-client", "0.0.1"), ); let client: RunningService = client_info.serve(transport).await?; // --- Test getJob --- let result = client .call_tool( CallToolRequestParams::new("getJob") .with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?), ) .await?; let text = result .content .first() .and_then(|c| c.as_text()) .expect("getJob should return text content"); let job: serde_json::Value = serde_json::from_str(&text.text)?; assert_eq!(job["id"], job_id.to_string()); assert_eq!(job["workspace_id"], "test-workspace"); assert_eq!(job["created_by"], "test-user"); assert_eq!(job["job_kind"], "script"); assert!( job["success"].as_bool().unwrap_or(false), "job should be successful: {job}" ); // --- Test getJobLogs --- let result = client .call_tool( CallToolRequestParams::new("getJobLogs") .with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?), ) .await?; let text = result .content .first() .and_then(|c| c.as_text()) .expect("getJobLogs should return text content"); // The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint let logs: String = serde_json::from_str(&text.text)?; assert!( logs.contains("hello world test log"), "expected logs to contain test log, got: {logs}" ); client.cancel().await?; Ok(()) }