From ab616fd91586d0f1b43a0c3103f745cb3bccaab9 Mon Sep 17 00:00:00 2001 From: Ricardo Lopes Temoteo Date: Tue, 15 Sep 2026 18:04:38 -0300 Subject: [PATCH] Fix MCP send_http_request ignoring environmentId (#654) --- crates-cli/yaak-cli/src/plugin_events.rs | 204 +++++++++++++++++- .../yaak-app-client/src/plugin_events.rs | 18 +- crates/yaak-models/src/error.rs | 3 + .../yaak-models/src/queries/environments.rs | 160 ++++++++++++++ crates/yaak-plugins/bindings/gen_events.ts | 7 +- crates/yaak-plugins/src/events.rs | 30 +++ .../src/bindings/gen_events.ts | 7 +- .../tests/sendHttpRequest.test.ts | 65 ++++++ plugins-external/mcp-server/README.md | 23 ++ .../mcp-server/src/tools/httpRequest.test.ts | 98 +++++++++ .../mcp-server/src/tools/httpRequest.ts | 12 +- 11 files changed, 619 insertions(+), 8 deletions(-) create mode 100644 packages/plugin-runtime/tests/sendHttpRequest.test.ts create mode 100644 plugins-external/mcp-server/src/tools/httpRequest.test.ts diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index 81498b06..24cad0db 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -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(); + } +} diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs index 3f891922..2f4d0d20 100644 --- a/crates-tauri/yaak-app-client/src/plugin_events.rs +++ b/crates-tauri/yaak-app-client/src/plugin_events.rs @@ -284,12 +284,26 @@ async fn handle_host_plugin_request( 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 { diff --git a/crates/yaak-models/src/error.rs b/crates/yaak-models/src/error.rs index 78c861f9..06ac848b 100644 --- a/crates/yaak-models/src/error.rs +++ b/crates/yaak-models/src/error.rs @@ -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), diff --git a/crates/yaak-models/src/queries/environments.rs b/crates/yaak-models/src/queries/environments.rs index 5163fce2..75bea6c1 100644 --- a/crates/yaak-models/src/queries/environments.rs +++ b/crates/yaak-models/src/queries/environments.rs @@ -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 { + 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> { let mut environments: Vec = 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(_)) + )); + } +} diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index 36b77b98..ae564221 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -572,7 +572,12 @@ export type RenderHttpRequestResponse = { httpRequest: HttpRequest, }; export type RenderPurpose = "send" | "preview"; -export type SendHttpRequestRequest = { httpRequest: Partial, }; +export type SendHttpRequestRequest = { httpRequest: Partial, +/** + * 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, /** diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index be7eecf0..345620fe 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -296,6 +296,36 @@ pub struct ExportHttpRequestResponse { pub struct SendHttpRequestRequest { #[ts(type = "Partial")] 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, +} + +#[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)] diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index 36b77b98..ae564221 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -572,7 +572,12 @@ export type RenderHttpRequestResponse = { httpRequest: HttpRequest, }; export type RenderPurpose = "send" | "preview"; -export type SendHttpRequestRequest = { httpRequest: Partial, }; +export type SendHttpRequestRequest = { httpRequest: Partial, +/** + * 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, /** diff --git a/packages/plugin-runtime/tests/sendHttpRequest.test.ts b/packages/plugin-runtime/tests/sendHttpRequest.test.ts new file mode 100644 index 00000000..b781e36a --- /dev/null +++ b/packages/plugin-runtime/tests/sendHttpRequest.test.ts @@ -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((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 }); + } +}); diff --git a/plugins-external/mcp-server/README.md b/plugins-external/mcp-server/README.md index fe2378aa..ebd95982 100644 --- a/plugins-external/mcp-server/README.md +++ b/plugins-external/mcp-server/README.md @@ -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. diff --git a/plugins-external/mcp-server/src/tools/httpRequest.test.ts b/plugins-external/mcp-server/src/tools/httpRequest.test.ts new file mode 100644 index 00000000..0b6cdd5f --- /dev/null +++ b/plugins-external/mcp-server/src/tools/httpRequest.test.ts @@ -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> = []; +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" }); +}); diff --git a/plugins-external/mcp-server/src/tools/httpRequest.ts b/plugins-external/mcp-server/src/tools/httpRequest.ts index 545c7504..647692ce 100644 --- a/plugins-external/mcp-server/src/tools/httpRequest.ts +++ b/plugins-external/mcp-server/src/tools/httpRequest.ts @@ -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: [