Files
Alexander Petric 51957f7d92 feat: mcp oauth gateway (#8443)
* feat: extract McpScopeSelector into reusable component

Extract scope selection UI from CreateToken.svelte and mcp_authorize page
into a shared McpScopeSelector.svelte component to reduce duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add MCP gateway endpoint for workspace-agnostic access

Add /api/mcp/gateway endpoint that allows MCP clients to connect without
knowing the workspace ID upfront. During OAuth, the user picks their
workspace on the consent page. The token is then scoped to that workspace.

This enables a single URL for the Anthropic connectors directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review nits

- Use onClick prop instead of legacy on:click directive in McpScopeSelector
- Remove unused catch variable in workspace loading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: deduplicate gateway OAuth handlers into shared inner functions

Extract build_oauth_metadata, build_protected_resource_metadata,
oauth_authorize_inner, and oauth_approve_inner so gateway handlers
are thin wrappers. Also revert formatting-only changes in auth.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: gate run_inline test helpers behind cfg(feature = "run_inline")

Imports and helper functions were not gated, causing unused-import and
dead-code errors when compiling without the run_inline feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update SQLx metadata

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-20 16:12:59 +00:00

266 lines
8.1 KiB
Rust

#[cfg(feature = "run_inline")]
use serde_json::json;
#[cfg(feature = "run_inline")]
use sqlx::{Pool, Postgres};
#[cfg(feature = "run_inline")]
use windmill_test_utils::*;
#[cfg(feature = "run_inline")]
async fn init_inline_utils(port: u16) -> anyhow::Result<()> {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let (killpill_tx, killpill_rx) = windmill_common::KillpillSender::new(1);
let base_internal_url = format!("http://localhost:{}", port);
windmill_worker::init_worker_internal_server_inline_utils(killpill_rx, base_internal_url)
.expect("Failed to initialize inline utils");
// Keep killpill_tx alive for the test duration
std::mem::forget(killpill_tx);
});
Ok(())
}
#[cfg(feature = "run_inline")]
fn run_inline_url(port: u16, endpoint: &str) -> String {
format!("http://localhost:{port}/api/w/test-workspace/jobs/run_inline/{endpoint}")
}
#[cfg(feature = "run_inline")]
fn client() -> reqwest::Client {
reqwest::Client::new()
}
#[cfg(feature = "run_inline")]
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
#[cfg(feature = "run_inline")]
fn new_script(
path: &str,
summary: &str,
content: &str,
language: &str,
schema_properties: serde_json::Value,
) -> serde_json::Value {
json!({
"path": path,
"summary": summary,
"description": "",
"content": content,
"language": language,
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": schema_properties,
"required": []
}
})
}
#[cfg(feature = "run_inline")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_run_inline_by_path(db: Pool<Postgres>) -> 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");
// Initialize inline utils for script execution
init_inline_utils(port).await?;
// Create a DuckDB script (one of the languages that supports inline execution)
// DuckDB requires parameter declarations in comments: -- $param_name (type)
let script_path = "u/test-user/inline_test";
let script_content = "-- $x (integer)
-- $y (integer)
SELECT $x + $y as result";
let resp = authed(client().post(format!("{base}/scripts/create")))
.json(&new_script(
script_path,
"Inline test script",
script_content,
"duckdb",
json!({
"x": {"type": "integer"},
"y": {"type": "integer"}
}),
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
// Test run_inline by path with args
let resp = authed(client().post(run_inline_url(port, &format!("p/{script_path}"))))
.json(&json!({
"args": {
"x": 5,
"y": 15
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "run_inline by path with args failed");
let result = resp.json::<serde_json::Value>().await?;
// DuckDB query should return array with one row containing result field
// The result structure is [{"result": 20}]
assert!(result.is_array(), "expected array result, got: {}", result);
let rows = result.as_array().unwrap();
assert_eq!(rows.len(), 1, "expected 1 row, got: {}", rows.len());
assert_eq!(
rows[0]["result"],
json!(20),
"expected result 20, got: {}",
rows[0]
);
Ok(())
}
#[cfg(feature = "run_inline")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_run_inline_by_hash(db: Pool<Postgres>) -> 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");
// Initialize inline utils for script execution
init_inline_utils(port).await?;
// Create a DuckDB script and get its hash
let script_path = "u/test-user/inline_hash_test";
let script_content = "-- $a (integer)
-- $b (integer)
SELECT $a * $b as product";
let resp = authed(client().post(format!("{base}/scripts/create")))
.json(&new_script(
script_path,
"Inline hash test script",
script_content,
"duckdb",
json!({
"a": {"type": "integer"},
"b": {"type": "integer"}
}),
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
// Get the script to retrieve its hash
let resp = authed(client().get(format!("{base}/scripts/get/p/{script_path}")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let script_data = resp.json::<serde_json::Value>().await?;
let hash = script_data["hash"]
.as_str()
.expect("hash should be present");
// Test run_inline by hash with args
let resp = authed(client().post(run_inline_url(port, &format!("h/{hash}"))))
.json(&json!({
"args": {
"a": 7,
"b": 3
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "run_inline by hash with args failed");
let result = resp.json::<serde_json::Value>().await?;
// Should return array with one row: [{"product": 21}]
assert!(result.is_array(), "expected array result");
let rows = result.as_array().unwrap();
assert_eq!(rows.len(), 1, "expected 1 row");
assert_eq!(
rows[0]["product"],
json!(21),
"expected product 21, got: {}",
rows[0]
);
Ok(())
}
#[cfg(feature = "run_inline")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_run_inline_preview(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Initialize inline utils for script execution
init_inline_utils(port).await?;
// Test run_inline preview with direct DuckDB content
let resp = authed(client().post(run_inline_url(port, "preview")))
.json(&json!({
"content": "-- $msg (text)\nSELECT 'Hello, ' || $msg || '!' as greeting",
"language": "duckdb",
"args": {
"msg": "World"
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "run_inline preview failed");
let result = resp.json::<serde_json::Value>().await?;
// Should return array with one row: [{"greeting": "Hello, World!"}]
assert!(result.is_array(), "expected array result");
let rows = result.as_array().unwrap();
assert_eq!(rows.len(), 1, "expected 1 row");
assert_eq!(
rows[0]["greeting"],
json!("Hello, World!"),
"expected 'Hello, World!', got: {}",
rows[0]
);
Ok(())
}
#[cfg(feature = "run_inline")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_run_inline_nonexistent_script(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Initialize inline utils
init_inline_utils(port).await?;
// Test run_inline by path with non-existent script - should return an error
let resp = authed(client().post(run_inline_url(port, "p/u/test-user/nonexistent_script")))
.json(&json!({
"args": null
}))
.send()
.await
.unwrap();
// Should return an error (script not found)
assert!(
resp.status().is_client_error() || resp.status().is_server_error(),
"expected error status for nonexistent script, got: {}",
resp.status()
);
Ok(())
}