mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Move the MCP server into Rust, hosted by the app and yaak mcp (#752)
This commit is contained in:
1 parent
b6c1f700f1
commit
72b43f7110
35 files changed
+2501
-61
No files matched your search
Generated
+121
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -398,6 +398,7 @@ export type Settings = {
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
mcpServerEnabled: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -398,6 +398,7 @@ export type Settings = {
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
mcpServerEnabled: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
|
||||
@@ -67,6 +67,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
|
||||
clipboardRead: true,
|
||||
systemFonts: true,
|
||||
license: true,
|
||||
agents: true,
|
||||
};
|
||||
|
||||
function createWindow(): PlatformWindow {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user