mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-16 14:51:37 +02:00
Fix MCP send_http_request ignoring environmentId (#654)
This commit is contained in:
@@ -182,6 +182,31 @@ async fn build_plugin_reply(
|
||||
http_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let environment_id = if let Some(environment_id) =
|
||||
send_http_request_request.environment_id.as_deref()
|
||||
{
|
||||
if shared_workspace_id.is_some_and(|id| id != http_request.workspace_id) {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "HTTP request does not belong to the selected workspace"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
match host_context
|
||||
.query_manager
|
||||
.connect()
|
||||
.get_environment_for_workspace(&http_request.workspace_id, environment_id)
|
||||
{
|
||||
Ok(environment) => Some(environment.id),
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: err.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execution_context.environment_id.clone()
|
||||
};
|
||||
|
||||
let cookie_jar_id =
|
||||
if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() {
|
||||
Some(cookie_jar_id)
|
||||
@@ -211,7 +236,7 @@ async fn build_plugin_reply(
|
||||
query_manager: &host_context.query_manager,
|
||||
blob_manager: &host_context.blob_manager,
|
||||
request: http_request,
|
||||
environment_id: execution_context.environment_id.as_deref(),
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: UpdateSource::Plugin,
|
||||
cookie_jar_id,
|
||||
response_dir: &host_context.response_dir,
|
||||
@@ -1003,3 +1028,180 @@ fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String {
|
||||
}
|
||||
base.name.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod environment_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use yaak_models::models::{EnvironmentVariable, HttpRequest, HttpRequestHeader};
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_send_uses_override_and_fallback_and_rejects_invalid_ids_before_sending() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..n]);
|
||||
if request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
eprintln!("Local HTTP test received on {address}:\n{request}");
|
||||
received_tx.send(request).unwrap();
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&dir.path().join("db.sqlite"),
|
||||
&dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.unwrap();
|
||||
let (base, a, b, request) = query_manager
|
||||
.with_tx(|db| {
|
||||
let source = &UpdateSource::Background;
|
||||
let workspace = db.list_workspaces()?.remove(0);
|
||||
let vars = |value: &str| {
|
||||
vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "marker".into(),
|
||||
value: value.into(),
|
||||
..Default::default()
|
||||
}]
|
||||
};
|
||||
let base = db.ensure_base_environment(&workspace.id)?;
|
||||
let base = db.upsert_environment(
|
||||
&Environment { variables: vars("global"), ..base },
|
||||
source,
|
||||
)?;
|
||||
let sub = |name: &str| {
|
||||
db.upsert_environment(
|
||||
&Environment {
|
||||
name: name.into(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
parent_model: "environment".into(),
|
||||
parent_id: Some(base.id.clone()),
|
||||
variables: vars(name),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)
|
||||
};
|
||||
let a = sub("a")?;
|
||||
let b = sub("b")?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id,
|
||||
url: format!("http://{address}/echo"),
|
||||
method: "GET".into(),
|
||||
headers: vec![HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "X-Environment".into(),
|
||||
value: "${[ marker ]}".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, yaak_models::error::Error>((base, a, b, request))
|
||||
})
|
||||
.unwrap();
|
||||
let plugin_dir = dir.path().join("plugins");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let plugin_manager = Arc::new(
|
||||
PluginManager::new(
|
||||
plugin_dir.clone(),
|
||||
plugin_dir,
|
||||
PathBuf::from("node"),
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"),
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let host = CliHostContext {
|
||||
encryption_manager: Arc::new(EncryptionManager::new(
|
||||
query_manager.clone(),
|
||||
"yaak-test",
|
||||
)),
|
||||
query_manager,
|
||||
blob_manager,
|
||||
plugin_manager: plugin_manager.clone(),
|
||||
connection_manager: Arc::new(HttpConnectionManager::new()),
|
||||
response_dir: dir.path().join("responses"),
|
||||
execution_context: CliExecutionContext {
|
||||
workspace_id: Some(base.workspace_id.clone()),
|
||||
environment_id: Some(a.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
std::fs::create_dir_all(&host.response_dir).unwrap();
|
||||
let event = |environment_id: Option<&str>| -> InternalEvent {
|
||||
let mut payload =
|
||||
json!({ "type": "send_http_request_request", "httpRequest": request });
|
||||
if let Some(id) = environment_id {
|
||||
payload["environmentId"] = json!(id);
|
||||
}
|
||||
serde_json::from_value(json!({
|
||||
"id": "test", "pluginRefId": "test", "pluginName": "test", "replyId": null,
|
||||
"context": PluginContext::new_empty(), "payload": payload
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
for (id, marker) in [
|
||||
(Some(b.id.as_str()), "b"),
|
||||
(None, "a"),
|
||||
(Some(base.id.as_str()), "global"),
|
||||
] {
|
||||
let reply =
|
||||
timeout(Duration::from_secs(10), build_plugin_reply(&host, &event(id), "test"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(reply, Some(InternalEventPayload::SendHttpRequestResponse(ref r)) if r.http_response.status == 200),
|
||||
"{reply:?}"
|
||||
);
|
||||
let received = received_rx.recv().await.unwrap().to_lowercase();
|
||||
assert!(received.contains(&format!("x-environment: {marker}\r\n")), "{received}");
|
||||
assert_eq!(host.execution_context.environment_id.as_deref(), Some(a.id.as_str()));
|
||||
}
|
||||
for id in ["", "ev_missing"] {
|
||||
let reply = build_plugin_reply(&host, &event(Some(id)), "test").await;
|
||||
assert!(matches!(reply, Some(InternalEventPayload::ErrorResponse(_))), "{reply:?}");
|
||||
}
|
||||
assert!(received_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
host.query_manager
|
||||
.connect()
|
||||
.list_http_responses_for_request(&request.id, None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
plugin_manager.terminate().await;
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +284,26 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let cookie_jar = cookie_jar_from_window(&window);
|
||||
let environment = environment_from_window(&window);
|
||||
|
||||
if http_request.workspace_id.is_empty() {
|
||||
http_request.workspace_id = workspace.id;
|
||||
http_request.workspace_id = workspace.id.clone();
|
||||
}
|
||||
|
||||
let environment =
|
||||
if let Some(environment_id) = req.environment_id.as_deref() {
|
||||
if http_request.workspace_id != workspace.id {
|
||||
return Err(crate::error::Error::GenericError(
|
||||
"HTTP request does not belong to the selected workspace".to_string(),
|
||||
));
|
||||
}
|
||||
Some(window.db().get_environment_for_workspace(
|
||||
&http_request.workspace_id,
|
||||
environment_id,
|
||||
)?)
|
||||
} else {
|
||||
environment_from_window(&window)
|
||||
};
|
||||
|
||||
let http_response = if http_request.id.is_empty() {
|
||||
HttpResponse::default()
|
||||
} else {
|
||||
|
||||
@@ -33,6 +33,9 @@ pub enum Error {
|
||||
#[error("No base environment for {0}")]
|
||||
MissingBaseEnvironment(String),
|
||||
|
||||
#[error("Invalid environment selection: {0}")]
|
||||
InvalidEnvironment(String),
|
||||
|
||||
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
|
||||
MultipleBaseEnvironments(String),
|
||||
|
||||
|
||||
@@ -11,6 +11,27 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(EnvironmentIden::Id, id)
|
||||
}
|
||||
|
||||
/// Resolve an explicit execution environment, rejecting foreign and folder environments.
|
||||
/// Folder variables are inherited from the request's folder, never selected globally.
|
||||
pub fn get_environment_for_workspace(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
environment_id: &str,
|
||||
) -> Result<Environment> {
|
||||
let environment = self.get_environment(environment_id)?;
|
||||
if environment.workspace_id != workspace_id {
|
||||
return Err(crate::error::Error::InvalidEnvironment(format!(
|
||||
"Environment {environment_id} does not belong to workspace {workspace_id}"
|
||||
)));
|
||||
}
|
||||
if !matches!(environment.parent_model.as_str(), "workspace" | "environment") {
|
||||
return Err(crate::error::Error::InvalidEnvironment(format!(
|
||||
"Environment {environment_id} is not a workspace environment"
|
||||
)));
|
||||
}
|
||||
Ok(environment)
|
||||
}
|
||||
|
||||
pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result<Option<Environment>> {
|
||||
let mut environments: Vec<Environment> =
|
||||
self.find_many(EnvironmentIden::ParentId, folder_id, None)?;
|
||||
@@ -196,3 +217,142 @@ impl<'a> WriteDb<'a> {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::error::Error;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{Environment, EnvironmentVariable, Folder, Workspace};
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::render::make_vars_hashmap;
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
fn fixture() -> (QueryManager, Environment, Environment, Environment) {
|
||||
let (manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||
let source = &UpdateSource::Background;
|
||||
let (base, staging, folder) = manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.list_workspaces()?.remove(0);
|
||||
let variable = |name: &str, value: &str| EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let base = db.ensure_base_environment(&workspace.id)?;
|
||||
let base = db.upsert_environment(
|
||||
&Environment {
|
||||
variables: vec![
|
||||
variable("marker", "global"),
|
||||
variable("global_only", "inherited"),
|
||||
],
|
||||
..base
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
let staging = db.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
parent_model: "environment".into(),
|
||||
parent_id: Some(base.id.clone()),
|
||||
name: "Staging".into(),
|
||||
variables: vec![variable("marker", "staging")],
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
let folder = db.upsert_folder(
|
||||
&Folder {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
let folder = db.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id,
|
||||
parent_model: "folder".into(),
|
||||
parent_id: Some(folder.id),
|
||||
variables: vec![variable("marker", "folder")],
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, Error>((base, staging, folder))
|
||||
})
|
||||
.unwrap();
|
||||
(manager, base, staging, folder)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_environment_preserves_global_and_folder_inheritance() {
|
||||
let (manager, base, staging, folder) = fixture();
|
||||
let db = manager.connect();
|
||||
let selected = db.get_environment_for_workspace(&base.workspace_id, &staging.id).unwrap();
|
||||
let vars = make_vars_hashmap(
|
||||
db.resolve_environments(&base.workspace_id, None, Some(&selected.id)).unwrap(),
|
||||
);
|
||||
assert_eq!(vars["marker"], "staging");
|
||||
assert_eq!(vars["global_only"], "inherited");
|
||||
|
||||
let vars = make_vars_hashmap(
|
||||
db.resolve_environments(
|
||||
&base.workspace_id,
|
||||
folder.parent_id.as_deref(),
|
||||
Some(&selected.id),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(vars["marker"], "folder");
|
||||
assert_eq!(vars["global_only"], "inherited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_environment_can_be_selected_explicitly() {
|
||||
let (manager, base, _staging, _folder) = fixture();
|
||||
let db = manager.connect();
|
||||
let selected = db.get_environment_for_workspace(&base.workspace_id, &base.id).unwrap();
|
||||
let vars = make_vars_hashmap(
|
||||
db.resolve_environments(&base.workspace_id, None, Some(&selected.id)).unwrap(),
|
||||
);
|
||||
assert_eq!(vars["marker"], "global");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_environment_rejects_empty_and_missing_ids() {
|
||||
let (manager, base, _staging, _folder) = fixture();
|
||||
for id in ["", "ev_missing"] {
|
||||
assert!(matches!(
|
||||
manager.connect().get_environment_for_workspace(&base.workspace_id, id),
|
||||
Err(Error::ModelNotFound(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_environment_rejects_another_workspace() {
|
||||
let (manager, _base, staging, _folder) = fixture();
|
||||
let other = manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace { name: "Other".into(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
manager.connect().get_environment_for_workspace(&other.id, &staging.id),
|
||||
Err(Error::InvalidEnvironment(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_environment_rejects_folder_variables() {
|
||||
let (manager, base, _staging, folder) = fixture();
|
||||
assert!(matches!(
|
||||
manager.connect().get_environment_for_workspace(&base.workspace_id, &folder.id),
|
||||
Err(Error::InvalidEnvironment(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -572,7 +572,12 @@ export type RenderHttpRequestResponse = { httpRequest: HttpRequest, };
|
||||
|
||||
export type RenderPurpose = "send" | "preview";
|
||||
|
||||
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };
|
||||
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>,
|
||||
/**
|
||||
* Override the environment for this send without changing the active selection.
|
||||
* When omitted, use the host's active environment.
|
||||
*/
|
||||
environmentId?: string, };
|
||||
|
||||
export type SendHttpRequestResponse = { httpResponse: HttpResponse,
|
||||
/**
|
||||
|
||||
@@ -296,6 +296,36 @@ pub struct ExportHttpRequestResponse {
|
||||
pub struct SendHttpRequestRequest {
|
||||
#[ts(type = "Partial<HttpRequest>")]
|
||||
pub http_request: HttpRequest,
|
||||
/// Override the environment for this send without changing the active selection.
|
||||
/// When omitted, use the host's active environment.
|
||||
#[ts(optional)]
|
||||
pub environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod send_http_request_tests {
|
||||
use super::SendHttpRequestRequest;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn environment_override_survives_the_plugin_wire_format() {
|
||||
let request: SendHttpRequestRequest = serde_json::from_value(json!({
|
||||
"httpRequest": { "id": "rq_test" }, "environmentId": "ev_staging"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(request.environment_id.as_deref(), Some("ev_staging"));
|
||||
assert_eq!(serde_json::to_value(request).unwrap()["environmentId"], "ev_staging");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_plugins_can_omit_the_environment_override() {
|
||||
let request: SendHttpRequestRequest = serde_json::from_value(json!({
|
||||
"httpRequest": { "id": "rq_test" }
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(request.environment_id, None);
|
||||
assert_eq!(request.http_request.id, "rq_test");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
|
||||
+6
-1
@@ -572,7 +572,12 @@ export type RenderHttpRequestResponse = { httpRequest: HttpRequest, };
|
||||
|
||||
export type RenderPurpose = "send" | "preview";
|
||||
|
||||
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };
|
||||
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>,
|
||||
/**
|
||||
* Override the environment for this send without changing the active selection.
|
||||
* When omitted, use the host's active environment.
|
||||
*/
|
||||
environmentId?: string, };
|
||||
|
||||
export type SendHttpRequestResponse = { httpResponse: HttpResponse,
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { EventChannel } from "../src/EventChannel";
|
||||
import { PluginInstance } from "../src/PluginInstance";
|
||||
|
||||
test("the runtime forwards per-send environments without changing plugin context", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "yaak-send-environment-"));
|
||||
await mkdir(path.join(dir, "build"));
|
||||
await writeFile(
|
||||
path.join(dir, "build/index.js"),
|
||||
`exports.plugin = { async init(ctx) {
|
||||
await Promise.all(["ev_a", "ev_b", undefined].map(environmentId =>
|
||||
ctx.httpRequest.send({ httpRequest: { id: "rq_test" }, environmentId })
|
||||
));
|
||||
} };`,
|
||||
);
|
||||
const channel = new EventChannel();
|
||||
const context = { workspaceId: "wk_test", label: "test-window" };
|
||||
const bootRequest = { dir, watch: false };
|
||||
const instance = new PluginInstance({ bootRequest, pluginRefId: "test", context }, channel);
|
||||
const sends: InternalEvent[] = [];
|
||||
const booted = new Promise<void>((resolve, reject) => {
|
||||
channel.listen((event) => {
|
||||
if (event.payload.type === "send_http_request_request") {
|
||||
// Match the JSON transport used by the real plugin host.
|
||||
sends.push(JSON.parse(JSON.stringify(event)) as InternalEvent);
|
||||
instance.postMessage({
|
||||
...event,
|
||||
id: `reply-${event.id}`,
|
||||
replyId: event.id,
|
||||
payload: { type: "error_response", error: "test host received send" },
|
||||
});
|
||||
} else if (event.payload.type === "error_response") {
|
||||
// The stub host rejects the sends after capturing them.
|
||||
if (event.payload.error === "test host received send") resolve();
|
||||
else reject(new Error(event.payload.error));
|
||||
} else if (event.payload.type === "boot_response") {
|
||||
reject(new Error("Expected the test host's send error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
instance.postMessage({
|
||||
id: "boot",
|
||||
replyId: null,
|
||||
pluginRefId: "test",
|
||||
pluginName: "test",
|
||||
context,
|
||||
payload: { type: "boot_request", ...bootRequest },
|
||||
});
|
||||
await booted;
|
||||
expect(sends.map((event) => event.payload)).toEqual([
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" }, environmentId: "ev_a" },
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" }, environmentId: "ev_b" },
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" } },
|
||||
]);
|
||||
expect(sends.map((event) => event.context)).toEqual([context, context, context]);
|
||||
} finally {
|
||||
await instance.terminate();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -33,3 +33,26 @@ Restart Claude Desktop and make sure Yaak is running.
|
||||
- `get_environment_id` - Get the current environment ID
|
||||
- `copy_to_clipboard` - Copy text to the system clipboard
|
||||
- `show_toast` - Show a toast notification in Yaak
|
||||
|
||||
## Environment Selection
|
||||
|
||||
`send_http_request` accepts an optional `environmentId` for that execution. It overrides the
|
||||
active environment without changing the selection in Yaak. Omitting it uses the host's
|
||||
active environment, as before.
|
||||
|
||||
The ID must identify a base or sub-environment in the request's workspace. An empty,
|
||||
unknown, foreign-workspace, or folder environment ID returns an error before sending.
|
||||
Use the base environment ID to run with global variables only. Folder variables still
|
||||
apply according to the request's folder hierarchy.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "rq_example",
|
||||
"workspaceId": "wk_example",
|
||||
"environmentId": "ev_staging"
|
||||
}
|
||||
```
|
||||
|
||||
This requires a Yaak host with support for the `environmentId` plugin API field;
|
||||
older hosts ignore it. `get_environment_id` reports the active selection, not the
|
||||
override used by a previous send.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
import { registerHttpRequestTools } from "./httpRequest";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0)) await cleanup();
|
||||
});
|
||||
|
||||
async function fixture({ missing = false, sendError = false } = {}) {
|
||||
const httpRequest = { id: "rq_test", workspaceId: "wk_test", url: "http://localhost/echo" };
|
||||
const send = vi.fn(async (_args: { httpRequest: unknown; environmentId?: string }) => {
|
||||
if (sendError) throw new Error("Environment not found");
|
||||
return { httpResponse: { id: "rs_test", status: 200 } };
|
||||
});
|
||||
// Only the host is mocked; calls go through the real MCP client, schema and transport.
|
||||
const yaak = {
|
||||
workspace: {
|
||||
list: async () => [{ id: "wk_test", name: "Test Workspace" }],
|
||||
withContext: () => yaak,
|
||||
},
|
||||
httpRequest: { getById: async () => (missing ? null : httpRequest), send },
|
||||
};
|
||||
const server = new McpServer({ name: "yaak-test", version: "0.0.0" });
|
||||
registerHttpRequestTools(server, { yaak: yaak as unknown as Context });
|
||||
const client = new Client({ name: "test", version: "0.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
cleanups.push(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
return { client, send, httpRequest };
|
||||
}
|
||||
|
||||
test("forwards the advertised environmentId to the host", async () => {
|
||||
const { client, send, httpRequest } = await fixture();
|
||||
const { tools } = await client.listTools();
|
||||
expect(
|
||||
tools.find((tool) => tool.name === "send_http_request")?.inputSchema.properties,
|
||||
).toHaveProperty("environmentId");
|
||||
const result = await client.callTool({
|
||||
name: "send_http_request",
|
||||
arguments: { id: httpRequest.id, workspaceId: "wk_test", environmentId: "ev_staging" },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith({ httpRequest, environmentId: "ev_staging" });
|
||||
});
|
||||
|
||||
test("leaves the environment unspecified when omitted", async () => {
|
||||
const { client, send, httpRequest } = await fixture();
|
||||
const result = await client.callTool({
|
||||
name: "send_http_request",
|
||||
arguments: { id: httpRequest.id },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ httpRequest }));
|
||||
expect(send.mock.calls[0]?.[0]).not.toHaveProperty("environmentId", expect.any(String));
|
||||
});
|
||||
|
||||
test("does not send a missing request", async () => {
|
||||
const { client, send } = await fixture({ missing: true });
|
||||
const result = await client.callTool({
|
||||
name: "send_http_request",
|
||||
arguments: { id: "rq_missing" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns host environment errors to the MCP client", async () => {
|
||||
const { client } = await fixture({ sendError: true });
|
||||
const result = await client.callTool({
|
||||
name: "send_http_request",
|
||||
arguments: { id: "rq_test", environmentId: "ev_missing" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toEqual([{ type: "text", text: "Environment not found" }]);
|
||||
});
|
||||
|
||||
test("keeps concurrent environment overrides separate", async () => {
|
||||
const { client, send, httpRequest } = await fixture();
|
||||
await Promise.all(
|
||||
["ev_a", "ev_b"].map((environmentId) =>
|
||||
client.callTool({
|
||||
name: "send_http_request",
|
||||
arguments: { id: httpRequest.id, environmentId },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send).toHaveBeenCalledWith({ httpRequest, environmentId: "ev_a" });
|
||||
expect(send).toHaveBeenCalledWith({ httpRequest, environmentId: "ev_b" });
|
||||
});
|
||||
@@ -69,18 +69,24 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
description: "Send an HTTP request and get the response",
|
||||
inputSchema: {
|
||||
id: z.string().describe("The HTTP request ID to send"),
|
||||
environmentId: z.string().optional().describe("Optional environment ID to use"),
|
||||
environmentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Environment ID for this send only; defaults to the active environment"),
|
||||
workspaceId: workspaceIdSchema,
|
||||
},
|
||||
},
|
||||
async ({ id, workspaceId }) => {
|
||||
async ({ id, workspaceId, environmentId }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const httpRequest = await workspaceCtx.yaak.httpRequest.getById({ id });
|
||||
if (httpRequest == null) {
|
||||
throw new Error(`HTTP request with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const { httpResponse: response } = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
|
||||
const { httpResponse: response } = await workspaceCtx.yaak.httpRequest.send({
|
||||
httpRequest,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
||||
Reference in New Issue
Block a user