From 72b43f71109547e73fa41013c7b2171686606512 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 14 Oct 2026 21:51:40 -0700 Subject: [PATCH] Move the MCP server into Rust, hosted by the app and `yaak mcp` (#752) --- Cargo.lock | 121 ++++ Cargo.toml | 2 + .../components/Settings/Settings.tsx | 14 +- .../components/Settings/SettingsAgents.tsx | 198 ++++++ .../components/SettingsDropdown.tsx | 5 +- crates-cli/yaak-cli/Cargo.toml | 2 + crates-cli/yaak-cli/src/cli.rs | 3 + crates-cli/yaak-cli/src/commands/mcp.rs | 659 ++++++++++++++++++ crates-cli/yaak-cli/src/commands/mod.rs | 1 + crates-cli/yaak-cli/src/context.rs | 12 +- crates-cli/yaak-cli/src/main.rs | 14 + crates-cli/yaak-cli/src/plugin_events.rs | 24 +- crates-cli/yaak-cli/tests/mcp_command.rs | 224 ++++++ crates-tauri/yaak-app-client/Cargo.toml | 1 + crates-tauri/yaak-app-client/src/lib.rs | 2 + crates-tauri/yaak-app-client/src/mcp_ext.rs | 192 +++++ .../yaak-app-client/src/models_ext.rs | 10 +- .../yaak-app-client/src/plugin_events.rs | 120 ++-- .../yaak-rpc-schema/bindings/gen_models.ts | 1 + crates/yaak-mcp/Cargo.toml | 28 + crates/yaak-mcp/src/http.rs | 124 ++++ crates/yaak-mcp/src/lib.rs | 63 ++ crates/yaak-mcp/src/params.rs | 281 ++++++++ crates/yaak-mcp/src/server.rs | 338 +++++++++ crates/yaak-mcp/tests/http.rs | 98 +++ crates/yaak-models/bindings/gen_models.ts | 1 + .../20261014000000_mcp-server-enabled.sql | 3 + crates/yaak-models/src/models.rs | 4 + crates/yaak-models/src/queries/settings.rs | 1 + crates/yaak-plugins/bindings/gen_models.ts | 1 + packages/platform/src/tauri/index.ts | 1 + packages/platform/src/types.ts | 2 + packages/platform/src/web/index.ts | 2 + plugins-external/mcp-server/README.md | 3 + plugins-external/mcp-server/src/index.ts | 7 + 35 files changed, 2501 insertions(+), 61 deletions(-) create mode 100644 apps/yaak-client/components/Settings/SettingsAgents.tsx create mode 100644 crates-cli/yaak-cli/src/commands/mcp.rs create mode 100644 crates-cli/yaak-cli/tests/mcp_command.rs create mode 100644 crates-tauri/yaak-app-client/src/mcp_ext.rs create mode 100644 crates/yaak-mcp/Cargo.toml create mode 100644 crates/yaak-mcp/src/http.rs create mode 100644 crates/yaak-mcp/src/lib.rs create mode 100644 crates/yaak-mcp/src/params.rs create mode 100644 crates/yaak-mcp/src/server.rs create mode 100644 crates/yaak-mcp/tests/http.rs create mode 100644 crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql diff --git a/Cargo.lock b/Cargo.lock index d6a1f96d6..255c7cac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1682,6 +1682,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1708,6 +1718,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1730,6 +1753,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -5717,6 +5751,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-slash" version = "0.2.1" @@ -6781,6 +6821,51 @@ dependencies = [ "libc", ] +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.0", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rolldown" version = "0.1.0" @@ -7452,6 +7537,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive 1.2.1", @@ -8162,6 +8248,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -11148,6 +11247,7 @@ dependencies = [ "yaak-license", "yaak-lifecycle", "yaak-mac-window", + "yaak-mcp", "yaak-models", "yaak-plugins", "yaak-rpc", @@ -11198,6 +11298,7 @@ dependencies = [ "predicates", "rand 0.8.7", "reqwest", + "rmcp", "rolldown", "schemars 0.8.22", "serde", @@ -11214,6 +11315,7 @@ dependencies = [ "yaak-crypto", "yaak-http", "yaak-lifecycle", + "yaak-mcp", "yaak-models", "yaak-plugins", "yaak-templates", @@ -11419,6 +11521,25 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "yaak-mcp" +version = "0.1.0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "log 0.4.34", + "reqwest", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-util", + "yaak-models", + "yaak-plugins", +] + [[package]] name = "yaak-models" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0d57dcb12..3780f5343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/yaak-grpc", "crates/yaak-http", "crates/yaak-lifecycle", + "crates/yaak-mcp", "crates/yaak-models", "crates/yaak-plugins", "crates/yaak-sse", @@ -89,6 +90,7 @@ yaak-git = { path = "crates/yaak-git" } yaak-grpc = { path = "crates/yaak-grpc" } yaak-http = { path = "crates/yaak-http" } yaak-lifecycle = { path = "crates/yaak-lifecycle" } +yaak-mcp = { path = "crates/yaak-mcp" } yaak-models = { path = "crates/yaak-models" } yaak-plugins = { path = "crates/yaak-plugins" } yaak-sse = { path = "crates/yaak-sse" } diff --git a/apps/yaak-client/components/Settings/Settings.tsx b/apps/yaak-client/components/Settings/Settings.tsx index a88e0b010..5c8c0fb68 100644 --- a/apps/yaak-client/components/Settings/Settings.tsx +++ b/apps/yaak-client/components/Settings/Settings.tsx @@ -8,6 +8,7 @@ import { useKeyPressEvent } from "react-use"; import { appInfo } from "../../lib/appInfo"; import { capitalize } from "../../lib/capitalize"; import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs"; +import { SettingsAgents } from "./SettingsAgents"; import { SettingsCertificates } from "./SettingsCertificates"; import { SettingsGeneral } from "./SettingsGeneral"; import { SettingsHotkeys } from "./SettingsHotkeys"; @@ -30,6 +31,7 @@ const TAB_SHORTCUTS = "shortcuts"; const TAB_PROXY = "proxy"; const TAB_CERTIFICATES = "certificates"; const TAB_PLUGINS = "plugins"; +const TAB_AGENTS = "agents"; const TAB_LICENSE = "license"; const tabs = [ TAB_GENERAL, @@ -37,6 +39,7 @@ const tabs = [ TAB_INTERFACE, TAB_SHORTCUTS, TAB_PLUGINS, + TAB_AGENTS, TAB_CERTIFICATES, TAB_PROXY, TAB_LICENSE, @@ -92,7 +95,9 @@ export default function Settings({ tab, hide }: Props) { (value): TabItem => ({ value, label: capitalize(value), - hidden: !appInfo.featureLicense && value === TAB_LICENSE, + hidden: + (!appInfo.featureLicense && value === TAB_LICENSE) || + (!platform.capabilities.agents && value === TAB_AGENTS), leftSlot: value === TAB_GENERAL ? ( @@ -108,6 +113,8 @@ export default function Settings({ tab, hide }: Props) { ) : value === TAB_PLUGINS ? ( + ) : value === TAB_AGENTS ? ( + ) : value === TAB_LICENSE ? ( ) : null, @@ -116,6 +123,8 @@ export default function Settings({ tab, hide }: Props) { ) : value === TAB_PLUGINS ? ( p.source !== "bundled").length} /> + ) : value === TAB_AGENTS && settings.mcpServerEnabled ? ( + ) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? ( ) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? ( @@ -139,6 +148,9 @@ export default function Settings({ tab, hide }: Props) { + + + diff --git a/apps/yaak-client/components/Settings/SettingsAgents.tsx b/apps/yaak-client/components/Settings/SettingsAgents.tsx new file mode 100644 index 000000000..84099f138 --- /dev/null +++ b/apps/yaak-client/components/Settings/SettingsAgents.tsx @@ -0,0 +1,198 @@ +import { useQuery } from "@tanstack/react-query"; +import { settingsAtom } from "@yaakapp-internal/models"; +import { platform } from "@yaakapp-internal/platform"; +import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import type { AppMetaData } from "@yaakapp-internal/rpc-schema"; +import { useAtomValue } from "jotai"; +import { useKeyValue } from "../../hooks/useKeyValue"; +import { appInfo } from "../../lib/appInfo"; +import { rpc } from "../../lib/rpc"; +import { CopyIconButton } from "../CopyIconButton"; +import { Button } from "../core/Button"; +import { IconButton } from "../core/IconButton"; +import { + ModelSettingRowBoolean, + SettingRow, + SettingValue, + SettingsList, + SettingsSection, +} from "../core/SettingRow"; + +const MCP_URL = "http://127.0.0.1:64343/mcp"; +const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli"; +const SKILL_INSTALL_COMMAND = "yaak agent install"; +const CLAUDE_CODE_COMMAND = "claude mcp add yaak -- yaak mcp"; + +export function SettingsAgents() { + const settings = useAtomValue(settingsAtom); + const { value: token } = useKeyValue({ + namespace: "no_sync", + key: "mcp_server_token", + fallback: null, + }); + const cliVersion = useQuery({ + queryKey: ["cli_version"], + queryFn: async () => (await rpc("cmd_metadata")).cliVersion, + initialData: appInfo.cliVersion, + }); + + return ( + +
+ Agents +

+ Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and + send your requests. +

+
+ + + + cliVersion.refetch()} + /> + {cliVersion.data == null && ( + + )} + + {cliVersion.data == null ? ( + + + + ) : ( + <> + + Teach the agents you have installed to drive the yaak{" "} + command. + + } + > + + + + + + + For other clients that read an mcpServers config, like + Cursor. + + } + snippet={mcpServersConfig("yaak", ["mcp"])} + /> + + )} + + + + + Serve Yaak's tools at {MCP_URL} while Yaak is open. + + } + /> + {settings.mcpServerEnabled && token != null && ( + <> + + + + + For clients that read an mcpServers config, like Claude + Desktop and Cursor. + + } + snippet={mcpServersConfig("npx", [ + "-y", + "mcp-remote", + MCP_URL, + "--header", + `Authorization: Bearer ${token}`, + ])} + /> + + )} + + +
+ ); +} + +function SnippetRow({ + title, + description, + snippet, +}: { + title: string; + description: React.ReactNode; + snippet: string; +}) { + return ( +
+
{title}
+
{description}
+
+
+          {snippet}
+        
+
+ +
+
+
+ ); +} + +function mcpServersConfig(command: string, args: string[]) { + return [ + "{", + ' "mcpServers": {', + ' "yaak": {', + ` "command": ${JSON.stringify(command)},`, + ` "args": [${args.map((a) => JSON.stringify(a)).join(", ")}]`, + " }", + " }", + "}", + ].join("\n"); +} diff --git a/apps/yaak-client/components/SettingsDropdown.tsx b/apps/yaak-client/components/SettingsDropdown.tsx index 2c9336bec..48a677f29 100644 --- a/apps/yaak-client/components/SettingsDropdown.tsx +++ b/apps/yaak-client/components/SettingsDropdown.tsx @@ -82,10 +82,9 @@ export function SettingsDropdown() { }, { label: "Install CLI", - hidden: appInfo.cliVersion != null, + hidden: appInfo.cliVersion != null || !platform.capabilities.agents, leftSlot: , - rightSlot: , - onSelect: () => platform.openUrl("https://yaak.app/docs/cli"), + onSelect: () => openSettings.mutate("agents"), }, { label: "Feedback", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index 50db83428..5378975dc 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -50,11 +50,13 @@ yaak-core = { workspace = true } yaak-crypto = { workspace = true } yaak-http = { workspace = true } yaak-lifecycle = { workspace = true } +yaak-mcp = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-templates = { workspace = true } [dev-dependencies] assert_cmd = "2" +rmcp = { version = "3.5.1", default-features = false, features = ["client"] } predicates = "3" tempfile = "3" diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index 4589936fd..b27b98be9 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -92,6 +92,9 @@ pub enum Commands { /// Response commands Response(ResponseArgs), + + /// Serve Yaak's MCP tools over stdio, for AI agents like Claude Code + Mcp, } #[derive(Args)] diff --git a/crates-cli/yaak-cli/src/commands/mcp.rs b/crates-cli/yaak-cli/src/commands/mcp.rs new file mode 100644 index 000000000..b2361b5f6 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/mcp.rs @@ -0,0 +1,659 @@ +use crate::context::{CliContext, CliExecutionContext}; +use crate::plugin_events::SendContexts; +use std::path::PathBuf; +use std::sync::Arc; +use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; +use yaak_crypto::manager::EncryptionManager; +use yaak_http::manager::HttpConnectionManager; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::{PluginContext, ShowToastRequest}; +use yaak_plugins::manager::PluginManager; + +pub async fn run(ctx: &CliContext, environment: Option<&str>, cookie_jar_id: Option<&str>) -> i32 { + let host = CliMcpHost { + query_manager: ctx.query_manager().clone(), + blob_manager: ctx.blob_manager().clone(), + plugin_manager: ctx.plugin_manager(), + encryption_manager: ctx.encryption_manager.clone(), + connection_manager: ctx.connection_manager().clone(), + response_dir: ctx.data_dir().join("responses"), + send_contexts: ctx.send_contexts().await, + environment_id: environment.map(str::to_string), + cookie_jar_id: cookie_jar_id.map(str::to_string), + }; + match yaak_mcp::serve_stdio(host).await { + Ok(()) => 0, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } +} + +/// Every workspace in the database is "open", and the global `--environment` +/// and `--cookie-jar` flags stand in for a window's selection. +struct CliMcpHost { + query_manager: QueryManager, + blob_manager: BlobManager, + plugin_manager: Arc, + encryption_manager: Arc, + connection_manager: Arc, + response_dir: PathBuf, + send_contexts: SendContexts, + environment_id: Option, + cookie_jar_id: Option, +} + +/// Registers a send's execution context for as long as the send runs. +struct SendContextGuard<'a> { + send_contexts: &'a SendContexts, + id: String, +} + +impl<'a> SendContextGuard<'a> { + fn new(send_contexts: &'a SendContexts, id: &str, context: CliExecutionContext) -> Self { + send_contexts.lock().expect("send contexts lock").insert(id.to_string(), context); + Self { send_contexts, id: id.to_string() } + } +} + +impl Drop for SendContextGuard<'_> { + fn drop(&mut self) { + self.send_contexts.lock().expect("send contexts lock").remove(&self.id); + } +} + +impl McpHost for CliMcpHost { + fn list_workspaces(&self) -> HostResult> { + let workspaces = + self.query_manager.connect().list_workspaces().map_err(|e| e.to_string())?; + Ok(workspaces.into_iter().map(|w| WorkspaceInfo { id: w.id, name: w.name }).collect()) + } + + fn active_environment_id(&self, workspace_id: &str) -> HostResult> { + let Some(environment_id) = &self.environment_id else { + return Ok(None); + }; + Ok(self + .query_manager + .connect() + .get_environment_for_workspace(workspace_id, environment_id) + .ok() + .map(|e| e.id)) + } + + fn list_http_requests(&self, workspace_id: &str) -> HostResult> { + self.query_manager.connect().list_http_requests(workspace_id).map_err(|e| e.to_string()) + } + + fn get_http_request(&self, id: &str) -> HostResult> { + Ok(self.query_manager.connect().get_http_request(id).ok()) + } + + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult { + self.query_manager + .with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to upsert HTTP request: {e}")) + } + + fn delete_http_request(&self, id: &str) -> HostResult { + self.query_manager + .with_tx(|tx| tx.delete_http_request_by_id(id, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to delete HTTP request: {e}")) + } + + fn list_folders(&self, workspace_id: &str) -> HostResult> { + self.query_manager.connect().list_folders(workspace_id).map_err(|e| e.to_string()) + } + + fn upsert_folder(&self, folder: &Folder) -> HostResult { + self.query_manager + .with_tx(|tx| tx.upsert_folder(folder, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to upsert folder: {e}")) + } + + fn delete_folder(&self, id: &str) -> HostResult { + self.query_manager + .with_tx(|tx| tx.delete_folder_by_id(id, &UpdateSource::Sync)) + .map_err(|e| format!("Failed to delete folder: {e}")) + } + + async fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> HostResult { + let environment_id = match environment_id { + Some(environment_id) => { + if request.workspace_id != workspace_id { + return Err("HTTP request does not belong to the selected workspace".into()); + } + let environment = self + .query_manager + .connect() + .get_environment_for_workspace(&request.workspace_id, &environment_id) + .map_err(|e| e.to_string())?; + Some(environment.id) + } + None => self.active_environment_id(&request.workspace_id)?, + }; + let cookie_jar_id = match &self.cookie_jar_id { + Some(id) => Some(id.clone()), + None => self + .query_manager + .connect() + .list_cookie_jars(&request.workspace_id) + .map_err(|e| format!("Failed to list cookie jars: {e}"))? + .into_iter() + .min_by_key(|jar| jar.created_at) + .map(|jar| jar.id), + }; + let plugin_context = + PluginContext::new(Some("cli".to_string()), Some(request.workspace_id.clone())); + let _send_context = SendContextGuard::new( + &self.send_contexts, + &plugin_context.id, + CliExecutionContext { + request_id: Some(request.id.clone()), + workspace_id: Some(request.workspace_id.clone()), + environment_id: environment_id.clone(), + cookie_jar_id: cookie_jar_id.clone(), + }, + ); + + let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams { + query_manager: &self.query_manager, + blob_manager: &self.blob_manager, + request, + environment_id: environment_id.as_deref(), + update_source: UpdateSource::Sync, + cookie_jar_id, + response_dir: &self.response_dir, + emit_events_to: None, + emit_response_body_chunks_to: None, + existing_response: None, + plugin_manager: self.plugin_manager.clone(), + encryption_manager: self.encryption_manager.clone(), + plugin_context: &plugin_context, + cancelled_rx: None, + connection_manager: &self.connection_manager, + }) + .await + .map_err(|e| e.to_string())?; + Ok(result.response) + } + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()> { + // stdout carries the protocol + eprintln!("[mcp] {}", toast.message); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::{CallToolRequestParams, CallToolResult}; + use rmcp::service::RunningService; + use rmcp::{RoleClient, ServiceExt}; + use serde_json::{Value, json}; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::sync::mpsc::UnboundedReceiver; + use yaak_mcp::YaakMcpServer; + use yaak_models::models::{Environment, EnvironmentVariable, HttpRequestHeader, Workspace}; + + struct Fixture { + client: RunningService, + query_manager: QueryManager, + plugin_manager: Arc, + workspace_id: String, + request_id: String, + environments: [String; 3], + received: UnboundedReceiver, + _dir: TempDir, + } + + impl Fixture { + async fn new(active_environment: Option<&str>) -> Self { + let dir = TempDir::new().unwrap(); + let (query_manager, blob_manager, _rx) = yaak_models::init_standalone( + dir.path().join("db.sqlite"), + dir.path().join("blobs.sqlite"), + ) + .unwrap(); + let (url, received) = echo_server().await; + let (workspace_id, request_id, environments) = seed(&query_manager, &url); + + 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 environment_id = active_environment.map(|name| match name { + "a" => environments[1].clone(), + other => other.to_string(), + }); + let host = CliMcpHost { + encryption_manager: Arc::new(EncryptionManager::new( + query_manager.clone(), + "yaak-test", + )), + query_manager: query_manager.clone(), + blob_manager, + plugin_manager: plugin_manager.clone(), + connection_manager: Arc::new(HttpConnectionManager::new()), + response_dir: dir.path().join("responses"), + send_contexts: SendContexts::default(), + environment_id, + cookie_jar_id: None, + }; + + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + tokio::spawn(async move { + let server = YaakMcpServer::new(host).serve(server_io).await.unwrap(); + let _ = server.waiting().await; + }); + let client = ().serve(client_io).await.unwrap(); + + Self { + client, + query_manager, + plugin_manager, + workspace_id, + request_id, + environments, + received, + _dir: dir, + } + } + + async fn call(&self, name: &'static str, arguments: Value) -> CallToolResult { + let Value::Object(arguments) = arguments else { + panic!("arguments must be an object") + }; + self.client + .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) + .await + .unwrap() + } + + async fn ok(&self, name: &'static str, arguments: Value) -> String { + let result = self.call(name, arguments).await; + assert_ne!(result.is_error, Some(true), "{name} failed: {}", text(&result)); + text(&result) + } + + async fn err(&self, name: &'static str, arguments: Value) -> String { + let result = self.call(name, arguments).await; + assert_eq!(result.is_error, Some(true), "{name} should fail: {}", text(&result)); + text(&result) + } + + async fn json(&self, name: &'static str, arguments: Value) -> Value { + serde_json::from_str(&self.ok(name, arguments).await).unwrap() + } + + async fn next_marker(&mut self) -> String { + let request = self.received.recv().await.unwrap().to_lowercase(); + let line = request.lines().find(|l| l.starts_with("x-environment:")).unwrap(); + line["x-environment:".len()..].trim().to_string() + } + + async fn finish(self) { + assert!(self.received.is_empty(), "unexpected requests reached the server"); + self.client.cancel().await.unwrap(); + self.plugin_manager.terminate().await; + } + } + + fn text(result: &CallToolResult) -> String { + result.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect() + } + + async fn echo_server() -> (String, UnboundedReceiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/echo", listener.local_addr().unwrap()); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let mut chunk = [0u8; 1024]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + tx.send(String::from_utf8(request).unwrap()).unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await + .unwrap(); + } + }); + (url, rx) + } + + /// A workspace with base environment "global", sub-environments "a" and + /// "b", and a request that echoes its environment's marker in a header. + fn seed(query_manager: &QueryManager, url: &str) -> (String, String, [String; 3]) { + query_manager + .with_tx(|db| { + let source = &UpdateSource::Background; + let workspace = db.upsert_workspace( + &Workspace { name: "Test".into(), ..Default::default() }, + source, + )?; + 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.clone(), + name: "Echo".into(), + url: url.to_string(), + 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>(( + workspace.id, + request.id, + [base.id, a.id, b.id], + )) + }) + .unwrap() + } + + #[tokio::test] + async fn advertises_the_plugin_tools() { + let fx = Fixture::new(None).await; + let tools = fx.client.list_all_tools().await.unwrap(); + let mut names = tools.iter().map(|t| t.name.to_string()).collect::>(); + names.sort(); + assert_eq!( + names, + [ + "create_folder", + "create_http_request", + "delete_folder", + "delete_http_request", + "get_environment_id", + "get_folder", + "get_http_request", + "get_workspace_id", + "list_folders", + "list_http_requests", + "list_workspaces", + "send_http_request", + "show_toast", + "update_folder", + "update_http_request", + ] + ); + let send = tools.iter().find(|t| t.name == "send_http_request").unwrap(); + let properties = send.input_schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("environmentId"), "{properties:?}"); + assert_eq!(send.input_schema["required"], json!(["id"])); + fx.finish().await; + } + + #[tokio::test] + async fn send_uses_the_given_environment() { + let mut fx = Fixture::new(Some("a")).await; + let response = fx + .json( + "send_http_request", + json!({ "id": fx.request_id, "workspaceId": fx.workspace_id, "environmentId": fx.environments[2] }), + ) + .await; + assert_eq!(response["status"], 200); + assert!(response.get("bodyPath").is_none(), "{response}"); + assert_eq!(fx.next_marker().await, "b"); + fx.finish().await; + } + + #[tokio::test] + async fn send_without_an_environment_uses_the_active_one() { + let mut fx = Fixture::new(Some("a")).await; + fx.ok("send_http_request", json!({ "id": fx.request_id })).await; + assert_eq!(fx.next_marker().await, "a"); + fx.finish().await; + + let mut fx = Fixture::new(None).await; + fx.ok("send_http_request", json!({ "id": fx.request_id })).await; + assert_eq!(fx.next_marker().await, "global"); + fx.finish().await; + } + + #[tokio::test] + async fn send_does_not_send_a_missing_request() { + let fx = Fixture::new(None).await; + let error = fx.err("send_http_request", json!({ "id": "rq_missing" })).await; + assert_eq!(error, "HTTP request with ID rq_missing not found"); + fx.finish().await; + } + + #[tokio::test] + async fn send_returns_host_environment_errors() { + let fx = Fixture::new(None).await; + let expected = fx + .query_manager + .connect() + .get_environment_for_workspace(&fx.workspace_id, "ev_missing") + .unwrap_err() + .to_string(); + let error = fx + .err("send_http_request", json!({ "id": fx.request_id, "environmentId": "ev_missing" })) + .await; + assert_eq!(error, expected); + fx.finish().await; + } + + #[tokio::test] + async fn concurrent_sends_keep_their_environments() { + let mut fx = Fixture::new(None).await; + let (a, b) = (fx.environments[1].clone(), fx.environments[2].clone()); + tokio::join!( + fx.ok("send_http_request", json!({ "id": fx.request_id, "environmentId": a })), + fx.ok("send_http_request", json!({ "id": fx.request_id, "environmentId": b })), + ); + let mut markers = vec![fx.next_marker().await, fx.next_marker().await]; + markers.sort(); + assert_eq!(markers, ["a", "b"]); + let responses = + fx.query_manager.connect().list_http_responses_for_request(&fx.request_id, None); + assert_eq!(responses.unwrap().len(), 2); + fx.finish().await; + } + + #[tokio::test] + async fn workspace_and_environment_tools() { + let fx = Fixture::new(Some("a")).await; + assert_eq!( + fx.json("list_workspaces", json!({})).await, + json!([{ "id": fx.workspace_id, "name": "Test" }]) + ); + assert_eq!(fx.ok("get_workspace_id", json!({})).await, fx.workspace_id); + assert_eq!(fx.ok("get_environment_id", json!({})).await, fx.environments[1]); + + let error = fx.err("list_http_requests", json!({ "workspaceId": "wk_missing" })).await; + assert!(error.starts_with("Workspace with ID \"wk_missing\" not found."), "{error}"); + + fx.query_manager + .with_tx(|db| { + db.upsert_workspace( + &Workspace { name: "Other".into(), ..Default::default() }, + &UpdateSource::Background, + ) + }) + .unwrap(); + let error = fx.err("get_workspace_id", json!({})).await; + assert!(error.starts_with("Multiple workspaces are open."), "{error}"); + let error = fx.err("list_http_requests", json!({})).await; + assert!(error.contains(&format!("Test (ID: {})", fx.workspace_id)), "{error}"); + let requests = + fx.json("list_http_requests", json!({ "workspaceId": fx.workspace_id })).await; + assert_eq!(requests.as_array().unwrap().len(), 1); + fx.finish().await; + + let fx = Fixture::new(None).await; + assert_eq!(fx.ok("get_environment_id", json!({})).await, "No environment selected"); + fx.finish().await; + } + + #[tokio::test] + async fn http_request_tools() { + let fx = Fixture::new(None).await; + let created = fx + .json( + "create_http_request", + json!({ + "name": "Created", + "url": "https://example.com/items", + "headers": [{ "name": "Accept", "value": "application/json" }], + "bodyType": "application/json", + "body": { "text": "{}" }, + }), + ) + .await; + let id = created["id"].as_str().unwrap().to_string(); + assert!(id.starts_with("rq_"), "{created}"); + assert_eq!(created["workspaceId"], fx.workspace_id); + assert_eq!(created["method"], "GET"); + assert_eq!(created["headers"][0]["enabled"], true); + assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, created); + + let updated = fx + .json( + "update_http_request", + json!({ "id": id, "url": "https://example.com/v2", "method": "POST" }), + ) + .await; + assert_eq!(updated["id"], id); + assert_eq!(updated["url"], "https://example.com/v2"); + assert_eq!(updated["method"], "POST"); + assert_eq!(updated["name"], "Created"); + assert_eq!(updated["body"], json!({ "text": "{}" })); + + let error = fx.err("update_http_request", json!({ "id": "rq_missing", "name": "x" })).await; + assert_eq!(error, "HTTP request with ID rq_missing not found"); + + let listed = fx.json("list_http_requests", json!({})).await; + assert_eq!(listed.as_array().unwrap().len(), 2); + + assert_eq!( + fx.ok("delete_http_request", json!({ "id": id })).await, + format!("Deleted: Created ({id})") + ); + assert_eq!(fx.json("get_http_request", json!({ "id": id })).await, Value::Null); + fx.finish().await; + } + + #[tokio::test] + async fn folder_tools() { + let fx = Fixture::new(None).await; + let parent = fx.json("create_folder", json!({ "name": "Parent" })).await; + let parent_id = parent["id"].as_str().unwrap().to_string(); + assert!(parent_id.starts_with("fl_"), "{parent}"); + let child = fx + .json( + "create_folder", + json!({ + "name": "Child", + "folderId": parent_id, + "sortPriority": 2.5, + "authenticationType": "bearer", + "authentication": { "token": "abc" }, + }), + ) + .await; + let child_id = child["id"].as_str().unwrap().to_string(); + assert_eq!(child["folderId"], parent_id); + assert_eq!(child["authentication"]["token"], "abc"); + assert_eq!(fx.json("get_folder", json!({ "id": child_id })).await, child); + assert_eq!(fx.json("list_folders", json!({})).await.as_array().unwrap().len(), 2); + + let updated = + fx.json("update_folder", json!({ "id": child_id, "description": "Nested" })).await; + assert_eq!(updated["description"], "Nested"); + assert_eq!(updated["name"], "Child"); + assert_eq!(updated["folderId"], parent_id); + + let error = fx.err("update_folder", json!({ "id": "fl_missing" })).await; + assert_eq!(error, "Folder with ID fl_missing not found"); + + assert_eq!( + fx.ok("delete_folder", json!({ "id": child_id })).await, + format!("Deleted: Child ({child_id})") + ); + assert_eq!(fx.json("get_folder", json!({ "id": child_id })).await, Value::Null); + fx.finish().await; + } + + #[tokio::test] + async fn show_toast_reports_the_message() { + let fx = Fixture::new(None).await; + assert_eq!( + fx.ok("show_toast", json!({ "message": "Hello", "icon": "info", "color": "success" })) + .await, + "✓ Toast shown: \"Hello\"" + ); + fx.finish().await; + } +} diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index 901371f1a..6f4e467b6 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod cookie_jar; pub mod environment; pub mod folder; pub mod import_export; +pub mod mcp; pub mod plugin; pub mod request; pub mod response; diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index 9822e0cb9..df276087f 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -1,4 +1,4 @@ -use crate::plugin_events::CliPluginEventBridge; +use crate::plugin_events::{CliPluginEventBridge, SendContexts}; use include_dir::{Dir, include_dir}; use std::fs; use std::path::{Path, PathBuf}; @@ -130,10 +130,18 @@ impl CliContext { &self.blob_manager } - pub fn connection_manager(&self) -> &HttpConnectionManager { + pub fn connection_manager(&self) -> &Arc { &self.connection_manager } + /// Where a send registers its execution context for the plugin calls it makes. + pub async fn send_contexts(&self) -> SendContexts { + match &*self.plugin_event_bridge.lock().await { + Some(bridge) => bridge.send_contexts(), + None => SendContexts::default(), + } + } + pub fn plugin_manager(&self) -> Arc { self.plugin_manager.clone().expect("Plugin manager was not initialized for this command") } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index de9dfdb1a..d911c1409 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -184,6 +184,20 @@ async fn main() { context.shutdown().await; exit_code } + Commands::Mcp => { + let mut context = CliContext::new(data_dir.clone(), app_id); + context + .init_plugins(CliExecutionContext { + environment_id: environment.clone(), + cookie_jar_id: cookie_jar.clone(), + ..CliExecutionContext::default() + }) + .await; + let exit_code = + commands::mcp::run(&context, environment.as_deref(), cookie_jar.as_deref()).await; + context.shutdown().await; + exit_code + } Commands::Environment(args) => { let context = CliContext::new(data_dir.clone(), app_id); let exit_code = commands::environment::run(&context, args); diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index 09d59d32f..b4f773a10 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -36,9 +36,14 @@ use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw}; +/// Execution contexts for sends in flight, by the id of the plugin context +/// each send was given, so plugin calls made during a send see its settings. +pub type SendContexts = Arc>>; + pub struct CliPluginEventBridge { rx_id: String, task: JoinHandle<()>, + send_contexts: SendContexts, } struct CliHostContext { @@ -49,6 +54,14 @@ struct CliHostContext { connection_manager: Arc, response_dir: PathBuf, execution_context: CliExecutionContext, + send_contexts: SendContexts, +} + +impl CliHostContext { + fn execution_context(&self, context: &PluginContext) -> CliExecutionContext { + let send_contexts = self.send_contexts.lock().expect("send contexts lock"); + send_contexts.get(&context.id).cloned().unwrap_or_else(|| self.execution_context.clone()) + } } impl CliPluginEventBridge { @@ -64,6 +77,7 @@ impl CliPluginEventBridge { let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let rx_id_for_task = rx_id.clone(); let pm = plugin_manager.clone(); + let send_contexts = SendContexts::default(); let host_context = Arc::new(CliHostContext { query_manager, blob_manager, @@ -72,6 +86,7 @@ impl CliPluginEventBridge { connection_manager, response_dir: data_dir.join("responses"), execution_context, + send_contexts: send_contexts.clone(), }); let task = tokio::spawn(async move { @@ -113,7 +128,11 @@ impl CliPluginEventBridge { pm.unsubscribe(&rx_id_for_task).await; }); - Self { rx_id, task } + Self { rx_id, task, send_contexts } + } + + pub fn send_contexts(&self) -> SendContexts { + self.send_contexts.clone() } pub async fn shutdown(self, plugin_manager: &PluginManager) { @@ -128,7 +147,7 @@ async fn build_plugin_reply( event: &InternalEvent, plugin_name: &str, ) -> Option { - let execution_context = &host_context.execution_context; + let execution_context = &host_context.execution_context(&event.context); let shared_workspace_id = event.context.workspace_id.as_deref().or(execution_context.workspace_id.as_deref()); @@ -1161,6 +1180,7 @@ mod environment_tests { environment_id: Some(a.id.clone()), ..Default::default() }, + send_contexts: SendContexts::default(), }; std::fs::create_dir_all(&host.response_dir).unwrap(); let event = |environment_id: Option<&str>| -> InternalEvent { diff --git a/crates-cli/yaak-cli/tests/mcp_command.rs b/crates-cli/yaak-cli/tests/mcp_command.rs new file mode 100644 index 000000000..b9d7a0e86 --- /dev/null +++ b/crates-cli/yaak-cli/tests/mcp_command.rs @@ -0,0 +1,224 @@ +mod common; + +use common::http_server::TestHttpServer; +use common::{query_manager, seed_workspace}; +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParams; +use serde_json::{Value, json}; +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; +use yaak_models::models::HttpRequest; +use yaak_models::util::UpdateSource; + +#[tokio::test] +async fn mcp_serves_tools_over_stdio() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let server = TestHttpServer::spawn_ok("hello from mcp"); + let request = HttpRequest { + id: "rq_mcp".to_string(), + workspace_id: "wk_test".to_string(), + name: "MCP Send".to_string(), + method: "GET".to_string(), + url: server.url.clone(), + ..Default::default() + }; + query_manager(data_dir) + .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) + .expect("Failed to seed request"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = ().serve(io).await.unwrap(); + + let call = async |name: &'static str, arguments: Value| { + let Value::Object(arguments) = arguments else { + unreachable!() + }; + let result = client + .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true), "{result:?}"); + let text = result.content[0].as_text().unwrap().text.clone(); + serde_json::from_str::(&text).unwrap() + }; + + assert_eq!(client.list_all_tools().await.unwrap().len(), 15); + assert_eq!( + call("list_workspaces", json!({})).await, + json!([{ "id": "wk_test", "name": "Seed Workspace" }]) + ); + let response = call("send_http_request", json!({ "id": "rq_mcp" })).await; + assert_eq!(response["status"], 200); + assert_eq!(response["requestId"], "rq_mcp"); + + client.cancel().await.unwrap(); + assert!(child.wait().await.unwrap().success()); +} + +/// Answers each request with its `X-Environment` header and reports the +/// headers it received. +fn marker_server() -> (String, std::sync::mpsc::Receiver) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let request = String::from_utf8_lossy(&request).to_lowercase(); + let marker = request + .lines() + .find_map(|l| l.strip_prefix("x-environment:")) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + let _ = tx.send(request); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{marker}", + marker.len() + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + (url, rx) +} + +#[tokio::test] +async fn mcp_send_settings_reach_template_functions() { + use yaak_models::models::{ + Cookie, CookieDomain, CookieExpires, CookieJar, Environment, EnvironmentVariable, + HttpRequestHeader, + }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + let (url, received) = marker_server(); + let header = |name: &str, value: &str| HttpRequestHeader { + enabled: true, + name: name.into(), + value: value.into(), + ..Default::default() + }; + let override_id = query_manager(data_dir) + .with_tx(|db| { + let source = &UpdateSource::Sync; + let vars = |value: &str| { + vec![EnvironmentVariable { + enabled: true, + name: "marker".into(), + value: value.into(), + ..Default::default() + }] + }; + let base = db.ensure_base_environment("wk_test")?; + let base = + db.upsert_environment(&Environment { variables: vars("global"), ..base }, source)?; + let chosen = db.upsert_environment( + &Environment { + name: "chosen".into(), + workspace_id: "wk_test".into(), + parent_model: "environment".into(), + parent_id: Some(base.id.clone()), + variables: vars("chosen"), + ..Default::default() + }, + source, + )?; + db.upsert_cookie_jar( + &CookieJar { + workspace_id: "wk_test".into(), + name: "Default".into(), + cookies: vec![Cookie { + name: "session".into(), + value: "from-jar".into(), + domain: CookieDomain::HostOnly("127.0.0.1".into()), + expires: CookieExpires::SessionEnd, + path: "/".into(), + secure: false, + http_only: false, + same_site: None, + }], + ..Default::default() + }, + source, + )?; + db.upsert_http_request( + &HttpRequest { + id: "rq_inner".into(), + workspace_id: "wk_test".into(), + method: "GET".into(), + url: format!("{url}/inner"), + headers: vec![header("X-Environment", "${[ marker ]}")], + ..Default::default() + }, + source, + )?; + db.upsert_http_request( + &HttpRequest { + id: "rq_outer".into(), + workspace_id: "wk_test".into(), + method: "GET".into(), + url: format!("{url}/outer"), + headers: vec![ + header( + "X-Nested", + "${[ response.body.raw(request='rq_inner', behavior='always') ]}", + ), + header("X-Cookie", "${[ cookie.value(name='session') ]}"), + ], + ..Default::default() + }, + source, + )?; + Ok::<_, yaak_models::error::Error>(chosen.id) + }) + .unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) + .arg("--data-dir") + .arg(data_dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = ().serve(io).await.unwrap(); + + let Value::Object(arguments) = json!({ "id": "rq_outer", "environmentId": override_id }) else { + unreachable!() + }; + let result = client + .call_tool(CallToolRequestParams::new("send_http_request").with_arguments(arguments)) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true), "{result:?}"); + + let outer = received.iter().find(|r| r.starts_with("get /outer")).unwrap(); + assert!(outer.contains("x-nested: chosen\r\n"), "{outer}"); + assert!(outer.contains("x-cookie: from-jar\r\n"), "{outer}"); + + client.cancel().await.unwrap(); + assert!(child.wait().await.unwrap().success()); +} diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index ebe58bfc1..dd3c2cd0e 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -90,6 +90,7 @@ yaak-http = { workspace = true } yaak-license = { workspace = true, optional = true } yaak-mac-window = { workspace = true } yaak-lifecycle = { workspace = true } +yaak-mcp = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-sse = { workspace = true } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 0ba49907c..df5778b7f 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -62,6 +62,7 @@ mod grpc; mod history; mod http_request; mod import; +mod mcp_ext; mod models_ext; mod notifications; mod plugin_events; @@ -1360,6 +1361,7 @@ pub fn run() { app.app_handle().set_native_titlebar(settings.use_native_titlebar); monitor_plugin_events(&app.app_handle().clone()); + mcp_ext::init(app.app_handle()); Ok(()) }) diff --git a/crates-tauri/yaak-app-client/src/mcp_ext.rs b/crates-tauri/yaak-app-client/src/mcp_ext.rs new file mode 100644 index 000000000..6e7ed28ed --- /dev/null +++ b/crates-tauri/yaak-app-client/src/mcp_ext.rs @@ -0,0 +1,192 @@ +//! The built-in MCP server: `yaak_mcp`'s tools on 127.0.0.1:64343, served +//! while the `mcpServerEnabled` setting is on, behind a bearer token kept in +//! key_values. + +use crate::models_ext::QueryManagerExt; +use crate::plugin_events::send_http_request_in_window; +use crate::{environment_from_window, workspace_from_window}; +use log::{error, info}; +use rand::Rng; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; +use tokio::sync::Mutex; +use yaak_mcp::http::{DEFAULT_PORT, HttpServer}; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::{Color, PluginContext, ShowToastRequest}; + +pub(crate) const TOKEN_NAMESPACE: &str = "no_sync"; +pub(crate) const TOKEN_KEY: &str = "mcp_server_token"; + +#[derive(Default)] +struct McpServerState(Mutex>); + +pub(crate) fn init(app_handle: &AppHandle) { + app_handle.manage(McpServerState::default()); + sync(app_handle); +} + +/// Start or stop the server to match the setting. +pub(crate) fn sync(app_handle: &AppHandle) { + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let Some(state) = app_handle.try_state::() else { + return; + }; + let mut server = state.0.lock().await; + let enabled = app_handle.db().get_settings().mcp_server_enabled; + match (enabled, server.is_some()) { + (true, false) => match start(&app_handle).await { + Ok(s) => { + info!("MCP server listening on http://{}{}", s.addr, yaak_mcp::http::PATH); + *server = Some(s); + } + Err(e) => { + error!("Failed to start MCP server: {e}"); + let _ = app_handle.emit( + "show_toast", + ShowToastRequest { + message: format!( + "Failed to start the MCP server on port {DEFAULT_PORT}: {e}" + ), + color: Some(Color::Danger), + timeout: Some(10000), + ..Default::default() + }, + ); + } + }, + (false, true) => { + if let Some(s) = server.take() { + s.stop().await; + info!("MCP server stopped"); + } + } + (true, true) | (false, false) => {} + } + }); +} + +async fn start(app_handle: &AppHandle) -> std::io::Result { + let host = Arc::new(AppMcpHost { app_handle: app_handle.clone() }); + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, DEFAULT_PORT)); + yaak_mcp::http::serve(host, addr, token(app_handle)).await +} + +/// The token, generated the first time the server starts. +fn token(app_handle: &AppHandle) -> String { + let existing = app_handle.db().get_key_value_str(TOKEN_NAMESPACE, TOKEN_KEY, ""); + if !existing.is_empty() { + return existing; + } + let mut rng = rand::rng(); + let token: String = (0..32).map(|_| format!("{:02x}", rng.random::())).collect(); + if let Err(e) = app_handle.with_tx(|tx| { + tx.set_key_value_str(TOKEN_NAMESPACE, TOKEN_KEY, &token, &UpdateSource::Background); + Ok(()) + }) { + error!("Failed to save MCP server token: {e}"); + } + token +} + +struct AppMcpHost { + app_handle: AppHandle, +} + +impl AppMcpHost { + fn window_for_workspace(&self, workspace_id: &str) -> Option> { + self.app_handle + .webview_windows() + .into_values() + .find(|w| workspace_from_window(w).is_some_and(|ws| ws.id == workspace_id)) + } +} + +impl McpHost for AppMcpHost { + fn list_workspaces(&self) -> HostResult> { + let mut workspaces: Vec = Vec::new(); + for window in self.app_handle.webview_windows().values() { + if let Some(w) = workspace_from_window(window) + && !workspaces.iter().any(|existing| existing.id == w.id) + { + workspaces.push(WorkspaceInfo { id: w.id, name: w.name }); + } + } + Ok(workspaces) + } + + fn active_environment_id(&self, workspace_id: &str) -> HostResult> { + Ok(self + .window_for_workspace(workspace_id) + .and_then(|w| environment_from_window(&w)) + .map(|e| e.id)) + } + + fn list_http_requests(&self, workspace_id: &str) -> HostResult> { + self.app_handle.db().list_http_requests(workspace_id).map_err(|e| e.to_string()) + } + + fn get_http_request(&self, id: &str) -> HostResult> { + Ok(self.app_handle.db().get_http_request(id).ok()) + } + + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult { + self.app_handle + .with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to upsert HTTP request: {e}")) + } + + fn delete_http_request(&self, id: &str) -> HostResult { + self.app_handle + .with_tx(|tx| tx.delete_http_request_by_id(id, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to delete HTTP request: {e}")) + } + + fn list_folders(&self, workspace_id: &str) -> HostResult> { + self.app_handle.db().list_folders(workspace_id).map_err(|e| e.to_string()) + } + + fn upsert_folder(&self, folder: &Folder) -> HostResult { + self.app_handle + .with_tx(|tx| tx.upsert_folder(folder, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to upsert folder: {e}")) + } + + fn delete_folder(&self, id: &str) -> HostResult { + self.app_handle + .with_tx(|tx| tx.delete_folder_by_id(id, &UpdateSource::Plugin)) + .map_err(|e| format!("Failed to delete folder: {e}")) + } + + async fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> HostResult { + let window = self + .window_for_workspace(workspace_id) + .ok_or_else(|| format!("No window is open for workspace {workspace_id}"))?; + let plugin_context = PluginContext { + label: Some(window.label().to_string()), + workspace_id: Some(workspace_id.to_string()), + ..PluginContext::new_empty() + }; + let sent = send_http_request_in_window( + &window, + request, + environment_id.as_deref(), + &plugin_context, + ) + .await + .map_err(|e| e.to_string())?; + Ok(sent.response) + } + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()> { + self.app_handle.emit("show_toast", toast).map_err(|e| e.to_string()) + } +} diff --git a/crates-tauri/yaak-app-client/src/models_ext.rs b/crates-tauri/yaak-app-client/src/models_ext.rs index 5584d14e9..f0a149a60 100644 --- a/crates-tauri/yaak-app-client/src/models_ext.rs +++ b/crates-tauri/yaak-app-client/src/models_ext.rs @@ -12,6 +12,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use yaak_models::blob_manager::BlobManager; use yaak_models::client_db::{ClientDb, WriteDb}; use yaak_models::error::Result; +use yaak_models::models::AnyModel; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; @@ -168,19 +169,26 @@ pub fn init() -> TauriPlugin { // Fast path for local app writes initiated by frontend windows. This keeps the // current sync-model UX snappy, while DB polling handles external writers (CLI). + // A thread of its own: `recv` blocks, and a runtime worker parked in it strands + // anything spawned from the loop in that worker's unstealable LIFO slot. let app_handle_local = app_handle.clone(); - tauri::async_runtime::spawn(async move { + std::thread::spawn(move || { while let Ok(payload) = rx.recv() { let mut batch: Vec = Vec::new(); + let mut settings_changed = matches!(payload.model, AnyModel::Settings(_)); if matches!(payload.update_source, UpdateSource::Window { .. }) { batch.push(payload); } // Coalesce any writes already queued into the same emit while let Ok(next) = rx.try_recv() { + settings_changed |= matches!(next.model, AnyModel::Settings(_)); if matches!(next.update_source, UpdateSource::Window { .. }) { batch.push(next); } } + if settings_changed { + crate::mcp_ext::sync(&app_handle_local); + } if batch.is_empty() { continue; } diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs index 2f4d0d206..5a079a51c 100644 --- a/crates-tauri/yaak-app-client/src/plugin_events.rs +++ b/crates-tauri/yaak-app-client/src/plugin_events.rs @@ -1,5 +1,5 @@ use crate::error::Result; -use crate::http_request::send_http_request_with_context; +use crate::http_request::{SentHttpRequest, send_http_request_with_context}; use crate::models_ext::BlobManagerExt; use crate::models_ext::QueryManagerExt; use crate::render::{render_grpc_request, render_http_request, render_json_value}; @@ -12,7 +12,7 @@ use base64::prelude::BASE64_STANDARD; use chrono::Utc; use log::error; use std::sync::Arc; -use tauri::{AppHandle, Emitter, Listener, Manager, Runtime}; +use tauri::{AppHandle, Emitter, Listener, Manager, Runtime, WebviewWindow}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_opener::OpenerExt; use yaak::plugin_events::{ @@ -21,7 +21,7 @@ use yaak::plugin_events::{ use yaak::response_body::FileResponseBodyStore; use yaak_crypto::manager::EncryptionManager; use yaak_http::cookies::get_cookie_value_from_jar; -use yaak_models::models::{HttpResponse, Plugin}; +use yaak_models::models::{HttpRequest, HttpResponse, Plugin}; use yaak_models::queries::any_request::AnyRequest; use yaak_models::util::UpdateSource; use yaak_plugins::error::Error::PluginErr; @@ -280,64 +280,20 @@ async fn handle_host_plugin_request( } HostRequest::SendHttpRequest(req) => { let window = get_window_from_plugin_context(app_handle, plugin_context)?; - let mut http_request = req.http_request.clone(); - let workspace = - workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); - let cookie_jar = cookie_jar_from_window(&window); - - if http_request.workspace_id.is_empty() { - 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 { - let blobs = window.blob_manager(); - window.with_tx(|tx| { - tx.upsert_http_response( - &HttpResponse { - request_id: http_request.id.clone(), - workspace_id: http_request.workspace_id.clone(), - ..Default::default() - }, - &UpdateSource::from_window_label(window.label()), - &blobs, - ) - })? - }; - - let http_response = send_http_request_with_context( + let sent = send_http_request_in_window( &window, - &http_request, - &http_response, - environment, - cookie_jar, - &mut tokio::sync::watch::channel(false).1, + req.http_request.clone(), + req.environment_id.as_deref(), plugin_context, ) .await?; // An ad-hoc request saves nothing, so the engine hands the body // back and this reply is the only place the plugin can get it. - let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b)); + let body = sent.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b)); Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse { - http_response: http_response.response, + http_response: sent.response, body, }))) } @@ -478,3 +434,63 @@ async fn handle_host_plugin_request( } } } + +/// Send on behalf of a plugin or MCP client, from `window`'s workspace and +/// with its selected environment unless `environment_id` overrides it. +pub(crate) async fn send_http_request_in_window( + window: &WebviewWindow, + mut http_request: HttpRequest, + environment_id: Option<&str>, + plugin_context: &yaak_plugins::events::PluginContext, +) -> Result { + let workspace = + workspace_from_window(window).expect("Failed to get workspace_id from window URL"); + let cookie_jar = cookie_jar_from_window(window); + + if http_request.workspace_id.is_empty() { + http_request.workspace_id = workspace.id.clone(); + } + + let environment = if let Some(environment_id) = environment_id { + 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 { + let blobs = window.blob_manager(); + window.with_tx(|tx| { + tx.upsert_http_response( + &HttpResponse { + request_id: http_request.id.clone(), + workspace_id: http_request.workspace_id.clone(), + ..Default::default() + }, + &UpdateSource::from_window_label(window.label()), + &blobs, + ) + })? + }; + + send_http_request_with_context( + window, + &http_request, + &http_response, + environment, + cookie_jar, + &tokio::sync::watch::channel(false).1, + plugin_context, + ) + .await +} diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index ec4a53a40..2a63036fa 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -398,6 +398,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-mcp/Cargo.toml b/crates/yaak-mcp/Cargo.toml new file mode 100644 index 000000000..f7aa25c68 --- /dev/null +++ b/crates/yaak-mcp/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "yaak-mcp" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +bytes = { version = "1" } +http-body-util = { version = "0.1" } +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +log = { workspace = true } +rmcp = { version = "3.5.1", default-features = false, features = [ + "server", + "macros", + "transport-io", + "transport-streamable-http-server", +] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "rt", "macros"] } +tokio-util = { version = "0.7" } +yaak-models = { workspace = true } +yaak-plugins = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/crates/yaak-mcp/src/http.rs b/crates/yaak-mcp/src/http.rs new file mode 100644 index 000000000..9b0f7e007 --- /dev/null +++ b/crates/yaak-mcp/src/http.rs @@ -0,0 +1,124 @@ +use crate::{McpHost, YaakMcpServer}; +use bytes::Bytes; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use log::{debug, warn}; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +pub const DEFAULT_PORT: u16 = 64343; +pub const PATH: &str = "/mcp"; + +/// A running server. Dropping it does not stop it; call [`HttpServer::stop`]. +pub struct HttpServer { + pub addr: SocketAddr, + cancel: CancellationToken, + task: JoinHandle<()>, +} + +impl HttpServer { + pub async fn stop(self) { + self.cancel.cancel(); + let _ = self.task.await; + } +} + +/// Serve the tools on `addr` at [`PATH`], answering only requests that carry +/// `Authorization: Bearer `. +pub async fn serve( + host: Arc, + addr: SocketAddr, + token: String, +) -> std::io::Result { + let listener = TcpListener::bind(addr).await?; + let addr = listener.local_addr()?; + let cancel = CancellationToken::new(); + + let config = + StreamableHttpServerConfig::default().with_cancellation_token(cancel.child_token()); + let mcp = StreamableHttpService::new( + move || Ok(YaakMcpServer::from_arc(host.clone())), + Arc::new(LocalSessionManager::default()), + config, + ); + let expected = Arc::new(format!("Bearer {token}")); + + let accept_cancel = cancel.clone(); + let task = tokio::spawn(async move { + loop { + let stream = tokio::select! { + _ = accept_cancel.cancelled() => break, + accepted = listener.accept() => match accepted { + Ok((stream, _)) => stream, + Err(e) => { + warn!("MCP server failed to accept a connection: {e}"); + continue; + } + }, + }; + + let mcp = mcp.clone(); + let expected = expected.clone(); + let conn_cancel = accept_cancel.clone(); + tokio::spawn(async move { + let service = service_fn(move |req: Request| { + let mcp = mcp.clone(); + let expected = expected.clone(); + async move { Ok::<_, Infallible>(route(&mcp, &expected, req).await) } + }); + let conn = http1::Builder::new().serve_connection(TokioIo::new(stream), service); + tokio::select! { + _ = conn_cancel.cancelled() => {} + result = conn => if let Err(e) = result { + debug!("MCP connection ended: {e}"); + }, + } + }); + } + }); + + Ok(HttpServer { addr, cancel, task }) +} + +async fn route( + mcp: &StreamableHttpService, + expected: &str, + req: Request, +) -> Response> +where + S: rmcp::ServerHandler + Send + 'static, +{ + if req.uri().path() != PATH { + return plain(StatusCode::NOT_FOUND, "Not found"); + } + let given = req.headers().get(hyper::header::AUTHORIZATION).map(|v| v.as_bytes()); + if !given.is_some_and(|given| constant_time_eq(given, expected.as_bytes())) { + let mut response = plain(StatusCode::UNAUTHORIZED, "Missing or invalid bearer token"); + response + .headers_mut() + .insert(hyper::header::WWW_AUTHENTICATE, "Bearer".parse().expect("valid header")); + return response; + } + mcp.handle(req).await +} + +fn plain(status: StatusCode, message: &'static str) -> Response> { + let mut response = Response::new(Full::new(Bytes::from_static(message.as_bytes())).boxed()); + *response.status_mut() = status; + response +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} diff --git a/crates/yaak-mcp/src/lib.rs b/crates/yaak-mcp/src/lib.rs new file mode 100644 index 000000000..af40d7ec9 --- /dev/null +++ b/crates/yaak-mcp/src/lib.rs @@ -0,0 +1,63 @@ +//! Yaak's MCP server: the tools, written once against [`McpHost`], and the +//! transports the app (streamable HTTP) and the CLI (stdio) serve them on. + +pub mod http; +mod params; +mod server; + +use serde::Serialize; +use std::future::Future; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_plugins::events::ShowToastRequest; + +pub use server::YaakMcpServer; + +pub type HostResult = Result; + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceInfo { + pub id: String, + pub name: String, +} + +/// What the tools need from whichever process hosts them. Errors are shown to +/// the MCP client as is. +pub trait McpHost: Send + Sync + 'static { + /// The workspaces a tool may act on: open windows in the app, every + /// workspace in the CLI. + fn list_workspaces(&self) -> HostResult>; + + /// The environment a send uses when the caller names none. + fn active_environment_id(&self, workspace_id: &str) -> HostResult>; + + fn list_http_requests(&self, workspace_id: &str) -> HostResult>; + fn get_http_request(&self, id: &str) -> HostResult>; + fn upsert_http_request(&self, request: &HttpRequest) -> HostResult; + fn delete_http_request(&self, id: &str) -> HostResult; + + fn list_folders(&self, workspace_id: &str) -> HostResult>; + fn upsert_folder(&self, folder: &Folder) -> HostResult; + fn delete_folder(&self, id: &str) -> HostResult; + + /// Send `request` from `workspace_id`, with `environment_id` overriding the + /// active environment for this send only. + fn send_http_request( + &self, + workspace_id: &str, + request: HttpRequest, + environment_id: Option, + ) -> impl Future> + Send; + + fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()>; +} + +/// Serve the tools on stdin and stdout until the client disconnects. +pub async fn serve_stdio(host: H) -> Result<(), String> { + use rmcp::ServiceExt; + let service = YaakMcpServer::new(host) + .serve(rmcp::transport::stdio()) + .await + .map_err(|e| format!("Failed to start MCP server: {e}"))?; + service.waiting().await.map_err(|e| format!("MCP server stopped: {e}"))?; + Ok(()) +} diff --git a/crates/yaak-mcp/src/params.rs b/crates/yaak-mcp/src/params.rs new file mode 100644 index 000000000..cccdc0bd5 --- /dev/null +++ b/crates/yaak-mcp/src/params.rs @@ -0,0 +1,281 @@ +//! Tool inputs, mirroring the zod schemas of the `@yaak/mcp-server` plugin +//! they replace. Optional fields skip serializing so that a tool's input, +//! serialized, holds only what the caller gave, ready to lay over a model. + +use rmcp::schemars::{self, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +const WORKSPACE_ID: &str = "Workspace ID (required if multiple workspaces are open)"; + +const BODY_TYPE: &str = r#"Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")"#; + +const BODY: &str = r#"Body content object. Structure varies by bodyType: +- "binary": { filePath: "/path/to/file" } +- "graphql": { query: "{ users { name } }", variables: "{\"id\": \"123\"}" } +- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] } +- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] } +- text-based (application/json, etc.): { text: "raw body content" }"#; + +const AUTHENTICATION_TYPE: &str = r#"Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent."#; + +const AUTHENTICATION: &str = r#"Authentication configuration object. Structure varies by authenticationType: +- "basic": { username: "user", password: "pass" } +- "bearer": { token: "abc123", prefix: "Bearer" } +- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... } +- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." } +- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" } +- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." } +- "none": {}"#; + +fn enabled_default() -> bool { + true +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct Pair { + pub name: String, + pub value: String, + #[serde(default = "enabled_default")] + pub enabled: bool, +} + +/// The given fields of a tool input, by their model names. +pub fn given_fields(input: &T) -> Map { + match serde_json::to_value(input) { + Ok(Value::Object(fields)) => fields, + _ => Map::new(), + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceParams { + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetHttpRequestParams { + #[schemars(description = "The HTTP request ID")] + pub id: String, + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SendHttpRequestParams { + #[schemars(description = "The HTTP request ID to send")] + pub id: String, + #[schemars( + description = "Environment ID for this send only; defaults to the active environment" + )] + pub environment_id: Option, + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateHttpRequestParams { + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request name (empty string to auto-generate from URL)")] + pub name: Option, + #[schemars(description = "Request URL")] + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "HTTP method (defaults to GET)")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request headers")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "URL query parameters")] + pub url_parameters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY_TYPE)] + pub body_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY)] + pub body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateHttpRequestParams { + #[serde(skip_serializing)] + #[schemars(description = "HTTP request ID to update")] + pub id: String, + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request name")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request URL")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "HTTP method")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Request headers")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "URL query parameters")] + pub url_parameters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY_TYPE)] + pub body_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = BODY)] + pub body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteHttpRequestParams { + #[schemars(description = "HTTP request ID to delete")] + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetFolderParams { + #[schemars(description = "The folder ID")] + pub id: String, + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateFolderParams { + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[schemars(description = "Folder name")] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID (for nested folders)")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Folder description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Sort priority for ordering")] + pub sort_priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Default headers to apply to requests in this folder")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateFolderParams { + #[serde(skip_serializing)] + #[schemars(description = "Folder ID to update")] + pub id: String, + #[serde(skip_serializing)] + #[schemars(description = WORKSPACE_ID)] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Folder name")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Parent folder ID (for nested folders)")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Folder description")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Sort priority for ordering")] + pub sort_priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = "Default headers to apply to requests in this folder")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION_TYPE)] + pub authentication_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(description = AUTHENTICATION)] + pub authentication: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteFolderParams { + #[schemars(description = "Folder ID to delete")] + pub id: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToastIcon { + AlertTriangle, + Check, + CheckCircle, + ChevronDown, + Copy, + Info, + Pin, + Search, + Trash, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToastColor { + Primary, + Secondary, + Info, + Success, + Notice, + Warning, + Danger, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ShowToastParams { + #[schemars(description = "The message to display")] + pub message: String, + #[schemars(description = "Icon name")] + pub icon: Option, + #[schemars(description = "Toast color")] + pub color: Option, + #[schemars(description = "Timeout in milliseconds")] + pub timeout: Option, +} diff --git a/crates/yaak-mcp/src/server.rs b/crates/yaak-mcp/src/server.rs new file mode 100644 index 000000000..6501f89db --- /dev/null +++ b/crates/yaak-mcp/src/server.rs @@ -0,0 +1,338 @@ +use crate::params::*; +use crate::{HostResult, McpHost, WorkspaceInfo}; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{Implementation, ServerCapabilities, ServerConfig}; +use rmcp::{ServerHandler, tool, tool_handler, tool_router}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; +use std::sync::Arc; +use yaak_models::models::{Folder, HttpRequest}; +use yaak_plugins::events::{Color, Icon, ShowToastRequest}; + +type ToolResult = Result; + +pub struct YaakMcpServer { + host: Arc, + tool_router: ToolRouter, +} + +impl Clone for YaakMcpServer { + fn clone(&self) -> Self { + Self { host: self.host.clone(), tool_router: self.tool_router.clone() } + } +} + +impl YaakMcpServer { + pub fn new(host: H) -> Self { + Self::from_arc(Arc::new(host)) + } + + pub fn from_arc(host: Arc) -> Self { + Self { host, tool_router: Self::tool_router() } + } + + fn resolve_workspace(&self, workspace_id: Option<&str>) -> HostResult { + let workspaces = self.host.list_workspaces()?; + + let Some(workspace_id) = workspace_id else { + if workspaces.len() > 1 { + let list = workspaces + .iter() + .enumerate() + .map(|(i, w)| format!("{}. {} (ID: {})", i + 1, w.name, w.id)) + .collect::>() + .join("\n"); + return Err(format!( + "Multiple workspaces are open. Please specify which workspace to use.\n\n\ + Currently open workspaces:\n{list}\n\n\ + You can use the list_workspaces tool to get workspace IDs, then use other tools \ + with the workspace context. For example, ask the user which workspace they want \ + to work with by presenting them with the numbered list above." + )); + } + return workspaces.into_iter().next().ok_or_else(|| "No workspace is open".to_string()); + }; + + if let Some(workspace) = workspaces.iter().find(|w| w.id == workspace_id) { + return Ok(workspace.clone()); + } + let list = + workspaces.iter().map(|w| format!("- {} (ID: {})", w.name, w.id)).collect::>(); + Err(format!( + "Workspace with ID \"{workspace_id}\" not found.\n\nAvailable workspaces:\n{}", + list.join("\n") + )) + } + + fn find_folder(&self, workspace_id: &str, id: &str) -> HostResult> { + Ok(self.host.list_folders(workspace_id)?.into_iter().find(|f| f.id == id)) + } +} + +#[tool_router] +impl YaakMcpServer { + #[tool( + name = "list_workspaces", + title = "List Workspaces", + description = "List all open workspaces in Yaak" + )] + async fn list_workspaces(&self) -> ToolResult { + to_json(&self.host.list_workspaces()?) + } + + #[tool( + name = "get_workspace_id", + title = "Get Workspace ID", + description = "Get the current workspace ID" + )] + async fn get_workspace_id(&self) -> ToolResult { + Ok(self.resolve_workspace(None)?.id) + } + + #[tool( + name = "get_environment_id", + title = "Get Environment ID", + description = "Get the current environment ID" + )] + async fn get_environment_id(&self) -> ToolResult { + let workspace = self.resolve_workspace(None)?; + Ok(self + .host + .active_environment_id(&workspace.id)? + .unwrap_or_else(|| "No environment selected".to_string())) + } + + #[tool( + name = "list_http_requests", + title = "List HTTP Requests", + description = "List all HTTP requests in a workspace" + )] + async fn list_http_requests(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.host.list_http_requests(&workspace.id)?) + } + + #[tool( + name = "get_http_request", + title = "Get HTTP Request", + description = "Get details of a specific HTTP request by ID" + )] + async fn get_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.host.get_http_request(&p.id)?) + } + + #[tool( + name = "send_http_request", + title = "Send HTTP Request", + description = "Send an HTTP request and get the response" + )] + async fn send_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let request = self + .host + .get_http_request(&p.id)? + .ok_or_else(|| format!("HTTP request with ID {} not found", p.id))?; + let response = + self.host.send_http_request(&workspace.id, request, p.environment_id).await?; + + // Plugins never saw where the host keeps the body, so neither do MCP clients + let mut response = serde_json::to_value(response).map_err(|e| e.to_string())?; + if let Value::Object(fields) = &mut response { + fields.remove("bodyPath"); + } + to_json(&response) + } + + #[tool( + name = "create_http_request", + title = "Create HTTP Request", + description = "Create a new HTTP request" + )] + async fn create_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let mut fields = Map::new(); + fields.insert("name".into(), json!("")); + fields.insert("method".into(), json!("GET")); + fields.insert("workspaceId".into(), json!(workspace.id)); + fields.extend(given_fields(&p)); + fields.insert("id".into(), json!("")); + let request: HttpRequest = from_fields(fields)?; + to_json(&self.host.upsert_http_request(&request)?) + } + + #[tool( + name = "update_http_request", + title = "Update HTTP Request", + description = "Update an existing HTTP request" + )] + async fn update_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + self.resolve_workspace(p.workspace_id.as_deref())?; + let existing = self + .host + .get_http_request(&p.id)? + .ok_or_else(|| format!("HTTP request with ID {} not found", p.id))?; + let request: HttpRequest = merge(&existing, given_fields(&p))?; + to_json(&self.host.upsert_http_request(&request)?) + } + + #[tool( + name = "delete_http_request", + title = "Delete HTTP Request", + description = "Delete an HTTP request by ID" + )] + async fn delete_http_request( + &self, + Parameters(p): Parameters, + ) -> ToolResult { + let request = self.host.delete_http_request(&p.id)?; + Ok(format!("Deleted: {} ({})", request.name, request.id)) + } + + #[tool( + name = "list_folders", + title = "List Folders", + description = "List all folders in a workspace" + )] + async fn list_folders(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.host.list_folders(&workspace.id)?) + } + + #[tool( + name = "get_folder", + title = "Get Folder", + description = "Get details of a specific folder by ID" + )] + async fn get_folder(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + to_json(&self.find_folder(&workspace.id, &p.id)?) + } + + #[tool( + name = "create_folder", + title = "Create Folder", + description = "Create a new folder in a workspace" + )] + async fn create_folder(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let mut fields = given_fields(&p); + fields.insert("workspaceId".into(), json!(workspace.id)); + fields.insert("id".into(), json!("")); + let folder: Folder = from_fields(fields)?; + to_json(&self.host.upsert_folder(&folder)?) + } + + #[tool( + name = "update_folder", + title = "Update Folder", + description = "Update an existing folder" + )] + async fn update_folder(&self, Parameters(p): Parameters) -> ToolResult { + let workspace = self.resolve_workspace(p.workspace_id.as_deref())?; + let existing = self + .find_folder(&workspace.id, &p.id)? + .ok_or_else(|| format!("Folder with ID {} not found", p.id))?; + let folder: Folder = merge(&existing, given_fields(&p))?; + to_json(&self.host.upsert_folder(&folder)?) + } + + #[tool( + name = "delete_folder", + title = "Delete Folder", + description = "Delete a folder by ID" + )] + async fn delete_folder(&self, Parameters(p): Parameters) -> ToolResult { + let folder = self.host.delete_folder(&p.id)?; + Ok(format!("Deleted: {} ({})", folder.name, folder.id)) + } + + #[tool( + name = "show_toast", + title = "Show Toast", + description = "Show a toast notification in Yaak" + )] + async fn show_toast(&self, Parameters(p): Parameters) -> ToolResult { + self.host.show_toast(ShowToastRequest { + message: p.message.clone(), + icon: p.icon.map(icon), + color: p.color.map(color), + timeout: Some(p.timeout.map_or(5000, |t| t as i32)), + })?; + Ok(format!("✓ Toast shown: \"{}\"", p.message)) + } +} + +#[tool_handler] +impl ServerHandler for YaakMcpServer { + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + info.server_info = Implementation::new("yaak-mcp-server", env!("CARGO_PKG_VERSION")); + info + } +} + +fn to_json(value: &T) -> ToolResult { + serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize result: {e}")) +} + +fn from_fields(fields: Map) -> HostResult { + serde_json::from_value(Value::Object(fields)).map_err(|e| format!("Invalid input: {e}")) +} + +/// The given fields laid over `existing`, keeping its id. +fn merge( + existing: &T, + given: Map, +) -> HostResult { + let Value::Object(mut fields) = serde_json::to_value(existing).map_err(|e| e.to_string())? + else { + return Err("Model did not serialize to an object".to_string()); + }; + let id = fields.get("id").cloned(); + fields.extend(given); + if let Some(id) = id { + fields.insert("id".into(), id); + } + from_fields(fields) +} + +fn icon(icon: ToastIcon) -> Icon { + match icon { + ToastIcon::AlertTriangle => Icon::AlertTriangle, + ToastIcon::Check => Icon::Check, + ToastIcon::CheckCircle => Icon::CheckCircle, + ToastIcon::ChevronDown => Icon::ChevronDown, + ToastIcon::Copy => Icon::Copy, + ToastIcon::Info => Icon::Info, + ToastIcon::Pin => Icon::Pin, + ToastIcon::Search => Icon::Search, + ToastIcon::Trash => Icon::Trash, + } +} + +fn color(color: ToastColor) -> Color { + match color { + ToastColor::Primary => Color::Primary, + ToastColor::Secondary => Color::Secondary, + ToastColor::Info => Color::Info, + ToastColor::Success => Color::Success, + ToastColor::Notice => Color::Notice, + ToastColor::Warning => Color::Warning, + ToastColor::Danger => Color::Danger, + } +} diff --git a/crates/yaak-mcp/tests/http.rs b/crates/yaak-mcp/tests/http.rs new file mode 100644 index 000000000..0462176fb --- /dev/null +++ b/crates/yaak-mcp/tests/http.rs @@ -0,0 +1,98 @@ +use reqwest::StatusCode; +use std::sync::Arc; +use yaak_mcp::{HostResult, McpHost, WorkspaceInfo}; +use yaak_models::models::{Folder, HttpRequest, HttpResponse}; +use yaak_plugins::events::ShowToastRequest; + +struct EmptyHost; + +impl McpHost for EmptyHost { + fn list_workspaces(&self) -> HostResult> { + Ok(Vec::new()) + } + fn active_environment_id(&self, _: &str) -> HostResult> { + Ok(None) + } + fn list_http_requests(&self, _: &str) -> HostResult> { + Ok(Vec::new()) + } + fn get_http_request(&self, _: &str) -> HostResult> { + Ok(None) + } + fn upsert_http_request(&self, r: &HttpRequest) -> HostResult { + Ok(r.clone()) + } + fn delete_http_request(&self, _: &str) -> HostResult { + Err("missing".into()) + } + fn list_folders(&self, _: &str) -> HostResult> { + Ok(Vec::new()) + } + fn upsert_folder(&self, f: &Folder) -> HostResult { + Ok(f.clone()) + } + fn delete_folder(&self, _: &str) -> HostResult { + Err("missing".into()) + } + async fn send_http_request( + &self, + _: &str, + _: HttpRequest, + _: Option, + ) -> HostResult { + Err("missing".into()) + } + fn show_toast(&self, _: ShowToastRequest) -> HostResult<()> { + Ok(()) + } +} + +const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; + +#[tokio::test] +async fn requires_the_bearer_token() { + let server = yaak_mcp::http::serve( + Arc::new(EmptyHost), + "127.0.0.1:0".parse().unwrap(), + "secret".to_string(), + ) + .await + .unwrap(); + let url = format!("http://{}/mcp", server.addr); + let client = reqwest::Client::new(); + let post = |url: &str, auth: Option<&str>| { + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INITIALIZE); + if let Some(auth) = auth { + request = request.header("Authorization", auth); + } + request.send() + }; + + for auth in [ + None, + Some("Bearer wrong"), + Some("Bearer secre"), + Some("secret"), + ] { + let response = post(&url, auth).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{auth:?}"); + assert_eq!(response.headers()["www-authenticate"], "Bearer"); + } + + let response = post(&url, Some("Bearer secret")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key("mcp-session-id")); + let body = response.text().await.unwrap(); + assert!(body.contains("yaak-mcp-server"), "{body}"); + + let other = format!("http://{}/other", server.addr); + assert_eq!(post(&other, Some("Bearer secret")).await.unwrap().status(), StatusCode::NOT_FOUND); + + let addr = server.addr; + server.stop().await; + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); +} diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index c4a4a1f4f..7bd40fce8 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -455,6 +455,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql b/crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql new file mode 100644 index 000000000..47a98f12d --- /dev/null +++ b/crates/yaak-models/migrations/20261014000000_mcp-server-enabled.sql @@ -0,0 +1,3 @@ +-- Add a setting to run the built-in MCP server +ALTER TABLE settings + ADD COLUMN mcp_server_enabled BOOLEAN DEFAULT FALSE NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 875012286..7c8279bc4 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -377,6 +377,7 @@ pub struct Settings { pub update_channel: String, pub hide_license_badge: bool, pub prompt_feedback: bool, + pub mcp_server_enabled: bool, pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, @@ -435,6 +436,7 @@ impl UpsertModelInfo for Settings { (UpdateChannel, self.update_channel.into()), (HideLicenseBadge, self.hide_license_badge.into()), (PromptFeedback, self.prompt_feedback.into()), + (McpServerEnabled, self.mcp_server_enabled.into()), (Autoupdate, self.autoupdate.into()), (AutoDownloadUpdates, self.auto_download_updates.into()), (ColoredMethods, self.colored_methods.into()), @@ -465,6 +467,7 @@ impl UpsertModelInfo for Settings { SettingsIden::UpdateChannel, SettingsIden::HideLicenseBadge, SettingsIden::PromptFeedback, + SettingsIden::McpServerEnabled, SettingsIden::Autoupdate, SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, @@ -506,6 +509,7 @@ impl UpsertModelInfo for Settings { auto_download_updates: row.get("auto_download_updates")?, hide_license_badge: row.get("hide_license_badge")?, prompt_feedback: row.get("prompt_feedback")?, + mcp_server_enabled: row.get("mcp_server_enabled")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), diff --git a/crates/yaak-models/src/queries/settings.rs b/crates/yaak-models/src/queries/settings.rs index 12fd175be..4c3bef0d2 100644 --- a/crates/yaak-models/src/queries/settings.rs +++ b/crates/yaak-models/src/queries/settings.rs @@ -54,6 +54,7 @@ fn default_settings() -> Settings { colored_methods: false, hide_license_badge: false, prompt_feedback: true, + mcp_server_enabled: false, auto_download_updates: true, check_notifications: true, hotkeys: HashMap::new(), diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index c03e2595a..af89495bd 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -398,6 +398,7 @@ export type Settings = { updateChannel: string; hideLicenseBadge: boolean; promptFeedback: boolean; + mcpServerEnabled: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/packages/platform/src/tauri/index.ts b/packages/platform/src/tauri/index.ts index c4280394a..bc939fe93 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -67,6 +67,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = { clipboardRead: true, systemFonts: true, license: true, + agents: true, }; function createWindow(): PlatformWindow { diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index 52119da75..51d4e0b0f 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -313,6 +313,8 @@ export interface PlatformCapabilities { systemFonts: boolean; /** Commercial licence activation. */ license: boolean; + /** Connecting local AI agents: the Yaak CLI on this machine and the app's own MCP server. */ + agents: boolean; } export type CapabilityName = keyof PlatformCapabilities; diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index 787a3a5b5..d8094174d 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -72,6 +72,8 @@ function capabilitiesFor(): PlatformCapabilities { clipboardRead: false, systemFonts: false, license: false, + // No local CLI to find and no app process to serve MCP from. + agents: false, }; } diff --git a/plugins-external/mcp-server/README.md b/plugins-external/mcp-server/README.md index ebd95982e..c2d9c84ba 100644 --- a/plugins-external/mcp-server/README.md +++ b/plugins-external/mcp-server/README.md @@ -2,6 +2,9 @@ Exposes Yaak's functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). +**Deprecated.** Yaak now has a built-in MCP server with the same tools. Uninstall this plugin, +then turn the server on in Settings > Agents, or run `yaak mcp` from the Yaak CLI. + ## Setup Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): diff --git a/plugins-external/mcp-server/src/index.ts b/plugins-external/mcp-server/src/index.ts index 01c15a9f6..2da1a6b6f 100644 --- a/plugins-external/mcp-server/src/index.ts +++ b/plugins-external/mcp-server/src/index.ts @@ -11,6 +11,13 @@ export const plugin: PluginDefinition = { // like show the startup toast. console.log("Initializing MCP Server plugin"); setTimeout(async () => { + void ctx.toast.show({ + message: + "The MCP Server plugin is deprecated. Yaak now has a built-in MCP server: uninstall this plugin, then turn it on in Settings > Agents.", + icon: "alert_triangle", + color: "notice", + timeout: 30000, + }); try { mcpServer = createMcpServer({ yaak: ctx }, serverPort); } catch (err) {