Move the MCP server into Rust, hosted by the app and yaak mcp (#752)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-10-14 21:51:40 -07:00
1 parent b6c1f700f1
commit 72b43f7110
35 files changed
+2501 -61

No files matched your search

Generated
+121
View File
@@ -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"
+2
View File
@@ -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" }
@@ -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 ? (
<Icon icon="settings" className="text-secondary" />
@@ -108,6 +113,8 @@ export default function Settings({ tab, hide }: Props) {
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
@@ -116,6 +123,8 @@ export default function Settings({ tab, hide }: Props) {
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_AGENTS && settings.mcpServerEnabled ? (
<CountBadge count />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
@@ -139,6 +148,9 @@ export default function Settings({ tab, hide }: Props) {
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
@@ -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<string | null>({
namespace: "no_sync",
key: "mcp_server_token",
fallback: null,
});
const cliVersion = useQuery({
queryKey: ["cli_version"],
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
initialData: appInfo.cliVersion,
});
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and
send your requests.
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection
title="Yaak CLI"
description="The recommended way to connect an agent. It works with the app closed."
>
<SettingRow
title="Status"
description={
cliVersion.data == null
? "The Yaak CLI is not installed."
: `Yaak CLI ${cliVersion.data} is installed.`
}
controlClassName="gap-2"
>
<IconButton
size="sm"
icon="refresh"
title="Check again"
spin={cliVersion.isFetching}
onClick={() => cliVersion.refetch()}
/>
{cliVersion.data == null && (
<Button
variant="border"
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/docs/cli")}
rightSlot={<Icon icon="external_link" />}
>
Install CLI
</Button>
)}
</SettingRow>
{cliVersion.data == null ? (
<SettingRow title="Install" description="Install it with npm.">
<SettingValue value={CLI_INSTALL_COMMAND} />
</SettingRow>
) : (
<>
<SettingRow
title="Agent skill"
description={
<>
Teach the agents you have installed to drive the <InlineCode>yaak</InlineCode>{" "}
command.
</>
}
>
<SettingValue value={SKILL_INSTALL_COMMAND} />
</SettingRow>
<SettingRow title="Claude Code" description="Add Yaak's MCP tools to Claude Code.">
<SettingValue value={CLAUDE_CODE_COMMAND} />
</SettingRow>
<SnippetRow
title="MCP client config"
description={
<>
For other clients that read an <InlineCode>mcpServers</InlineCode> config, like
Cursor.
</>
}
snippet={mcpServersConfig("yaak", ["mcp"])}
/>
</>
)}
</SettingsSection>
<SettingsSection
title="MCP server in the app"
description="For clients that connect over HTTP, or to act on the workspaces open in Yaak."
>
<ModelSettingRowBoolean
model={settings}
modelKey="mcpServerEnabled"
title="Enable MCP server"
description={
<>
Serve Yaak's tools at <InlineCode>{MCP_URL}</InlineCode> while Yaak is open.
</>
}
/>
{settings.mcpServerEnabled && token != null && (
<>
<SettingRow
title="Token"
description="Clients send this as a bearer token."
controlClassName="min-w-0 max-w-[min(24rem,40vw)] gap-2"
>
<SettingValue value={token} />
</SettingRow>
<SnippetRow
title="MCP client config"
description={
<>
For clients that read an <InlineCode>mcpServers</InlineCode> config, like Claude
Desktop and Cursor.
</>
}
snippet={mcpServersConfig("npx", [
"-y",
"mcp-remote",
MCP_URL,
"--header",
`Authorization: Bearer ${token}`,
])}
/>
</>
)}
</SettingsSection>
</SettingsList>
</VStack>
);
}
function SnippetRow({
title,
description,
snippet,
}: {
title: string;
description: React.ReactNode;
snippet: string;
}) {
return (
<div className="py-4 border-b border-border-subtle">
<div className="text text-text">{title}</div>
<div className="mt-1 text-sm text-text-subtle">{description}</div>
<div className="mt-3 relative rounded-md border border-border-subtle bg-surface-highlight">
<pre className="p-3 pr-10 font-mono text-editor text-text-subtle select-text whitespace-pre-wrap wrap-break-word">
{snippet}
</pre>
<div className="absolute top-1.5 right-1.5">
<CopyIconButton size="xs" text={snippet} title={`Copy ${title.toLowerCase()}`} />
</div>
</div>
</div>
);
}
function mcpServersConfig(command: string, args: string[]) {
return [
"{",
' "mcpServers": {',
' "yaak": {',
` "command": ${JSON.stringify(command)},`,
` "args": [${args.map((a) => JSON.stringify(a)).join(", ")}]`,
" }",
" }",
"}",
].join("\n");
}
@@ -82,10 +82,9 @@ export function SettingsDropdown() {
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
onSelect: () => openSettings.mutate("agents"),
},
{
label: "Feedback",
+2
View File
@@ -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"
+3
View File
@@ -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)]
+659
View File
@@ -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<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
response_dir: PathBuf,
send_contexts: SendContexts,
environment_id: Option<String>,
cookie_jar_id: Option<String>,
}
/// 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<Vec<WorkspaceInfo>> {
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<Option<String>> {
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<Vec<HttpRequest>> {
self.query_manager.connect().list_http_requests(workspace_id).map_err(|e| e.to_string())
}
fn get_http_request(&self, id: &str) -> HostResult<Option<HttpRequest>> {
Ok(self.query_manager.connect().get_http_request(id).ok())
}
fn upsert_http_request(&self, request: &HttpRequest) -> HostResult<HttpRequest> {
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<HttpRequest> {
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<Vec<Folder>> {
self.query_manager.connect().list_folders(workspace_id).map_err(|e| e.to_string())
}
fn upsert_folder(&self, folder: &Folder) -> HostResult<Folder> {
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<Folder> {
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<String>,
) -> HostResult<HttpResponse> {
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<RoleClient, ()>,
query_manager: QueryManager,
plugin_manager: Arc<PluginManager>,
workspace_id: String,
request_id: String,
environments: [String; 3],
received: UnboundedReceiver<String>,
_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<String>) {
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::<Vec<_>>();
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;
}
}
+1
View File
@@ -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;
+10 -2
View File
@@ -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<HttpConnectionManager> {
&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<PluginManager> {
self.plugin_manager.clone().expect("Plugin manager was not initialized for this command")
}
+14
View File
@@ -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);
+22 -2
View File
@@ -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<std::sync::Mutex<HashMap<String, CliExecutionContext>>>;
pub struct CliPluginEventBridge {
rx_id: String,
task: JoinHandle<()>,
send_contexts: SendContexts,
}
struct CliHostContext {
@@ -49,6 +54,14 @@ struct CliHostContext {
connection_manager: Arc<HttpConnectionManager>,
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<InternalEventPayload> {
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 {
+224
View File
@@ -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::<Value>(&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<String>) {
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());
}
+1
View File
@@ -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 }
+2
View File
@@ -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(())
})
+192
View File
@@ -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<Option<HttpServer>>);
pub(crate) fn init<R: Runtime>(app_handle: &AppHandle<R>) {
app_handle.manage(McpServerState::default());
sync(app_handle);
}
/// Start or stop the server to match the setting.
pub(crate) fn sync<R: Runtime>(app_handle: &AppHandle<R>) {
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
let Some(state) = app_handle.try_state::<McpServerState>() 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<R: Runtime>(app_handle: &AppHandle<R>) -> std::io::Result<HttpServer> {
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<R: Runtime>(app_handle: &AppHandle<R>) -> 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::<u8>())).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<R: Runtime> {
app_handle: AppHandle<R>,
}
impl<R: Runtime> AppMcpHost<R> {
fn window_for_workspace(&self, workspace_id: &str) -> Option<WebviewWindow<R>> {
self.app_handle
.webview_windows()
.into_values()
.find(|w| workspace_from_window(w).is_some_and(|ws| ws.id == workspace_id))
}
}
impl<R: Runtime> McpHost for AppMcpHost<R> {
fn list_workspaces(&self) -> HostResult<Vec<WorkspaceInfo>> {
let mut workspaces: Vec<WorkspaceInfo> = 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<Option<String>> {
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<Vec<HttpRequest>> {
self.app_handle.db().list_http_requests(workspace_id).map_err(|e| e.to_string())
}
fn get_http_request(&self, id: &str) -> HostResult<Option<HttpRequest>> {
Ok(self.app_handle.db().get_http_request(id).ok())
}
fn upsert_http_request(&self, request: &HttpRequest) -> HostResult<HttpRequest> {
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<HttpRequest> {
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<Vec<Folder>> {
self.app_handle.db().list_folders(workspace_id).map_err(|e| e.to_string())
}
fn upsert_folder(&self, folder: &Folder) -> HostResult<Folder> {
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<Folder> {
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<String>,
) -> HostResult<HttpResponse> {
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())
}
}
@@ -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<R: Runtime>() -> TauriPlugin<R> {
// 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<ModelPayload> = 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;
}
@@ -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<R: Runtime>(
}
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<R: Runtime>(
}
}
}
/// 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<R: Runtime>(
window: &WebviewWindow<R>,
mut http_request: HttpRequest,
environment_id: Option<&str>,
plugin_context: &yaak_plugins::events::PluginContext,
) -> Result<SentHttpRequest> {
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
}
+1
View File
@@ -398,6 +398,7 @@ export type Settings = {
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
mcpServerEnabled: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
+28
View File
@@ -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"] }
+124
View File
@@ -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 <token>`.
pub async fn serve<H: McpHost>(
host: Arc<H>,
addr: SocketAddr,
token: String,
) -> std::io::Result<HttpServer> {
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<Incoming>| {
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<S>(
mcp: &StreamableHttpService<S, LocalSessionManager>,
expected: &str,
req: Request<Incoming>,
) -> Response<BoxBody<Bytes, Infallible>>
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<BoxBody<Bytes, Infallible>> {
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
}
+63
View File
@@ -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<T> = Result<T, String>;
#[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<Vec<WorkspaceInfo>>;
/// The environment a send uses when the caller names none.
fn active_environment_id(&self, workspace_id: &str) -> HostResult<Option<String>>;
fn list_http_requests(&self, workspace_id: &str) -> HostResult<Vec<HttpRequest>>;
fn get_http_request(&self, id: &str) -> HostResult<Option<HttpRequest>>;
fn upsert_http_request(&self, request: &HttpRequest) -> HostResult<HttpRequest>;
fn delete_http_request(&self, id: &str) -> HostResult<HttpRequest>;
fn list_folders(&self, workspace_id: &str) -> HostResult<Vec<Folder>>;
fn upsert_folder(&self, folder: &Folder) -> HostResult<Folder>;
fn delete_folder(&self, id: &str) -> HostResult<Folder>;
/// 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<String>,
) -> impl Future<Output = HostResult<HttpResponse>> + Send;
fn show_toast(&self, toast: ShowToastRequest) -> HostResult<()>;
}
/// Serve the tools on stdin and stdout until the client disconnects.
pub async fn serve_stdio<H: McpHost>(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(())
}
+281
View File
@@ -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<T: Serialize>(input: &T) -> Map<String, Value> {
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<String>,
}
#[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<String>,
}
#[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<String>,
#[schemars(description = WORKSPACE_ID)]
pub workspace_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateHttpRequestParams {
#[serde(skip_serializing)]
#[schemars(description = WORKSPACE_ID)]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request name (empty string to auto-generate from URL)")]
pub name: Option<String>,
#[schemars(description = "Request URL")]
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "HTTP method (defaults to GET)")]
pub method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Parent folder ID")]
pub folder_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request description")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request headers")]
pub headers: Option<Vec<Pair>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "URL query parameters")]
pub url_parameters: Option<Vec<Pair>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = BODY_TYPE)]
pub body_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = BODY)]
pub body: Option<Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION_TYPE)]
pub authentication_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION)]
pub authentication: Option<Map<String, Value>>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request name")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request URL")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "HTTP method")]
pub method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Parent folder ID")]
pub folder_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request description")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Request headers")]
pub headers: Option<Vec<Pair>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "URL query parameters")]
pub url_parameters: Option<Vec<Pair>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = BODY_TYPE)]
pub body_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = BODY)]
pub body: Option<Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION_TYPE)]
pub authentication_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION)]
pub authentication: Option<Map<String, Value>>,
}
#[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<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateFolderParams {
#[serde(skip_serializing)]
#[schemars(description = WORKSPACE_ID)]
pub workspace_id: Option<String>,
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Folder description")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Sort priority for ordering")]
pub sort_priority: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Default headers to apply to requests in this folder")]
pub headers: Option<Vec<Pair>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION_TYPE)]
pub authentication_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION)]
pub authentication: Option<Map<String, Value>>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Folder name")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Parent folder ID (for nested folders)")]
pub folder_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Folder description")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Sort priority for ordering")]
pub sort_priority: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Default headers to apply to requests in this folder")]
pub headers: Option<Vec<Pair>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION_TYPE)]
pub authentication_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = AUTHENTICATION)]
pub authentication: Option<Map<String, Value>>,
}
#[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<ToastIcon>,
#[schemars(description = "Toast color")]
pub color: Option<ToastColor>,
#[schemars(description = "Timeout in milliseconds")]
pub timeout: Option<f64>,
}
+338
View File
@@ -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<String, String>;
pub struct YaakMcpServer<H: McpHost> {
host: Arc<H>,
tool_router: ToolRouter<Self>,
}
impl<H: McpHost> Clone for YaakMcpServer<H> {
fn clone(&self) -> Self {
Self { host: self.host.clone(), tool_router: self.tool_router.clone() }
}
}
impl<H: McpHost> YaakMcpServer<H> {
pub fn new(host: H) -> Self {
Self::from_arc(Arc::new(host))
}
pub fn from_arc(host: Arc<H>) -> Self {
Self { host, tool_router: Self::tool_router() }
}
fn resolve_workspace(&self, workspace_id: Option<&str>) -> HostResult<WorkspaceInfo> {
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::<Vec<_>>()
.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::<Vec<_>>();
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<Option<Folder>> {
Ok(self.host.list_folders(workspace_id)?.into_iter().find(|f| f.id == id))
}
}
#[tool_router]
impl<H: McpHost> YaakMcpServer<H> {
#[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<WorkspaceParams>) -> 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<GetHttpRequestParams>,
) -> 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<SendHttpRequestParams>,
) -> 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<CreateHttpRequestParams>,
) -> 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<UpdateHttpRequestParams>,
) -> 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<DeleteHttpRequestParams>,
) -> 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<WorkspaceParams>) -> 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<GetFolderParams>) -> 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<CreateFolderParams>) -> 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<UpdateFolderParams>) -> 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<DeleteFolderParams>) -> 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<ShowToastParams>) -> 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<H: McpHost> ServerHandler for YaakMcpServer<H> {
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<T: Serialize>(value: &T) -> ToolResult {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize result: {e}"))
}
fn from_fields<T: DeserializeOwned>(fields: Map<String, Value>) -> HostResult<T> {
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<T: Serialize + DeserializeOwned>(
existing: &T,
given: Map<String, Value>,
) -> HostResult<T> {
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,
}
}
+98
View File
@@ -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<Vec<WorkspaceInfo>> {
Ok(Vec::new())
}
fn active_environment_id(&self, _: &str) -> HostResult<Option<String>> {
Ok(None)
}
fn list_http_requests(&self, _: &str) -> HostResult<Vec<HttpRequest>> {
Ok(Vec::new())
}
fn get_http_request(&self, _: &str) -> HostResult<Option<HttpRequest>> {
Ok(None)
}
fn upsert_http_request(&self, r: &HttpRequest) -> HostResult<HttpRequest> {
Ok(r.clone())
}
fn delete_http_request(&self, _: &str) -> HostResult<HttpRequest> {
Err("missing".into())
}
fn list_folders(&self, _: &str) -> HostResult<Vec<Folder>> {
Ok(Vec::new())
}
fn upsert_folder(&self, f: &Folder) -> HostResult<Folder> {
Ok(f.clone())
}
fn delete_folder(&self, _: &str) -> HostResult<Folder> {
Err("missing".into())
}
async fn send_http_request(
&self,
_: &str,
_: HttpRequest,
_: Option<String>,
) -> HostResult<HttpResponse> {
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());
}
+1
View File
@@ -455,6 +455,7 @@ export type Settings = {
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
mcpServerEnabled: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
@@ -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;
+4
View File
@@ -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(),
@@ -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(),
+1
View File
@@ -398,6 +398,7 @@ export type Settings = {
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
mcpServerEnabled: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
+1
View File
@@ -67,6 +67,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
clipboardRead: true,
systemFonts: true,
license: true,
agents: true,
};
function createWindow(): PlatformWindow {
+2
View File
@@ -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;
+2
View File
@@ -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,
};
}
+3
View File
@@ -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):
+7
View File
@@ -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) {