Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/maple-agent/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,25 @@ A stdio server runs in the task's folder with the login shell's PATH, in its
own process group; it is stopped by closing its input, then SIGTERM, then
SIGKILL. For an HTTP server, `$NAME` and `${NAME}` in the URL and in header
values are filled in from the server's environment variables, and redirects
are not followed. Sign-in to servers (OAuth), and MCP resources and prompts,
are not supported yet.
are not followed. Generic remote-server OAuth, MCP resources and prompts are
not supported yet.

#### Vana on this computer

The Vana card in Settings > Integrations connects to the **Local MCP endpoint**
copied from Vana Desktop's Settings > Server > Connect apps. The port is chosen
by Vana; Maple accepts its exact `http://127.0.0.1:<port>/mcp` URL and performs
native OAuth with PKCE. Choose the shared data in Vana's approval screen, then
select Vana in Maple's per-task integration menu. Setup requires This Computer;
it cannot be forwarded through a remote execution computer.

The local host keeps account- and endpoint-bound OAuth credentials and renews
access tokens. No wallet key, developer controller, Node bridge, public MCP
URL or remote tunnel is required for the MCP data connection. Vana Desktop must
remain running. Disconnect the client in Vana to revoke access, then reconnect
in Maple when you want to authorize it again. Local MCP does not imply offline
Vana account/grant services or on-device model processing. See the
[integration guide](scripts/vana-poc/README.md).

#### Claude Code

Expand Down
1 change: 1 addition & 0 deletions apps/maple-agent/app/assets/icons/external-link.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/maple-agent/app/assets/icons/vana-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/maple-agent/app/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ assets!(
"icons/cua-mark-black.svg",
"icons/cua-mark-white.svg",
"icons/ellipsis.svg",
"icons/external-link.svg",
"icons/folder-open.svg",
"icons/folder-plus.svg",
"icons/folder.svg",
Expand Down Expand Up @@ -58,6 +59,8 @@ assets!(
"icons/trash-2.svg",
"icons/undo-2.svg",
"icons/users.svg",
// Official wordmark from https://vana.org/ (2026-10-13), for the Vana MCP catalog card.
"icons/vana-mark.svg",
"icons/volume-2.svg",
"icons/x.svg",
"icons/zap.svg",
Expand Down
87 changes: 87 additions & 0 deletions apps/maple-agent/app/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,25 @@ impl AgentBackend {
}
}

/// Vana's loopback endpoint belongs to this computer, never the selected
/// remote execution host. This is presentation only; dispatch also enforces
/// the local transport boundary before decoding the request.
pub fn vana_local_available(&self) -> bool {
self.remote_host.is_none() && !self.retired_after_update.load(Ordering::Acquire)
}

pub async fn connect_vana_local(
&self,
user_id: &str,
endpoint: String,
) -> Result<Vec<maple_agent::store::records::McpDefinition>, String> {
self.ensure_active()?;
if self.remote_host.is_some() {
return Err("Connect Vana from This Computer, where Vana Desktop is running.".into());
}
self.call("connect_vana_local", (user_id, endpoint)).await
}

pub async fn remote_setting<R: DeserializeOwned>(
&self,
method: &str,
Expand Down Expand Up @@ -650,6 +669,12 @@ async fn dispatch_special(
serde_json::to_value(value).map_err(|_| "Could not encode host response".to_string())
}
match request.method.as_str() {
"connect_vana_local" => {
let (user, endpoint): (String, String) =
serde_json::from_value(request.params).map_err(invalid)?;
backend.authorize_account(&user)?;
encode(backend.connect_vana_local(&user, endpoint).await?)
}
"save_mcp_servers" => {
let (user, servers): (String, Vec<AgentMcpServer>) =
serde_json::from_value(request.params).map_err(invalid)?;
Expand Down Expand Up @@ -782,6 +807,11 @@ pub(crate) async fn dispatch_remote_inner(
backend: &LocalBackend,
request: Request,
) -> Result<serde_json::Value, String> {
// An authenticated remote request must not open a browser or approve
// access to this computer's Personal Server, even with malformed params.
if request.method == "connect_vana_local" {
return Err("Vana connection setup requires the local desktop.".into());
}
if paging::is_paging(&request.method) {
return paging::dispatch(backend, request, maple_agent::store::PageTransport::Remote).await;
}
Expand All @@ -802,6 +832,63 @@ mod tests {
Arc::new(AgentBackend::for_test("http://127.0.0.1:1".into(), String::new()).unwrap())
}

#[test]
fn vana_local_setup_refuses_remote_and_retired_facades_before_dispatch() {
let controller = fixture();
assert!(controller.vana_local_available());
let runtime = controller.runtime_handle();
runtime.block_on(async {
let remote = controller
.with_remote_connection("fixture-peer".into(), || async {
Ok(in_process_client(&controller))
})
.await
.unwrap();
assert!(!remote.vana_local_available());
assert_eq!(
remote
.connect_vana_local("fixture-user", "http://127.0.0.1:12345/mcp".into())
.await
.err(),
Some("Connect Vana from This Computer, where Vana Desktop is running.".into())
);
controller.retire_after_update();
assert!(!controller.vana_local_available());
assert_eq!(
controller
.connect_vana_local("fixture-user", "http://127.0.0.1:12345/mcp".into())
.await
.err(),
Some(RETIRED_AFTER_UPDATE.into())
);
});
}

#[tokio::test]
async fn vana_local_setup_refuses_remote_dispatch_before_parameter_decoding() {
let root = tempfile::tempdir().unwrap();
let backend = LocalBackend::isolated_for_test(root.path());
for params in [
serde_json::Value::Null,
serde_json::json!(["fixture-user", "http://127.0.0.1:12345/mcp"]),
] {
let result = dispatch_remote_inner(
&backend,
Request {
id: 1,
method: "connect_vana_local".into(),
send_id: None,
params,
},
)
.await;
assert_eq!(
result.err(),
Some("Vana connection setup requires the local desktop.".into())
);
}
}

fn in_process_client(controller: &AgentBackend) -> BackendClient {
let _runtime = controller.runtime.enter();
let mut backend = LocalBackend::new(controller.api_url.clone(), String::new()).unwrap();
Expand Down
91 changes: 91 additions & 0 deletions apps/maple-agent/app/src/backend/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2571,6 +2571,25 @@ impl LocalBackend {
.await
}

/// The local host owns the entire OAuth attempt and its credentials. Only
/// public MCP metadata returns to the desktop after the approved setup is
/// installed by the account-bound runtime handle.
pub async fn connect_vana_local(
&self,
user_id: &str,
endpoint: String,
) -> Result<Vec<maple_agent::store::records::McpDefinition>, String> {
self.authorize_account(user_id)?;
let handle = self.service.handle_for_user(user_id).await?;
let browser_endpoint = endpoint.clone();
handle
.connect_vana_local(
endpoint,
Arc::new(move |url| open_vana_authorization(&browser_endpoint, &url)),
)
.await
}

pub async fn list_project_mcp_servers(
&self,
user_id: &str,
Expand Down Expand Up @@ -3049,6 +3068,45 @@ pub fn open_in_browser(url: &str, what: &str) -> Result<(), String> {
})
}

/// OAuth metadata is untrusted even when it comes from a user-selected local
/// endpoint. The browser may only open its exact loopback origin; never launch
/// arbitrary protocols or disclose the authorization query in an error/log.
fn validate_vana_authorization(endpoint: &str, address: &str) -> Result<(), String> {
const INVALID: &str =
"Vana returned an unexpected sign-in address. Copy its Local MCP endpoint again.";
if endpoint.len() > 4096 || address.len() > 16 * 1024 {
return Err(INVALID.into());
}
let endpoint = reqwest::Url::parse(endpoint.trim()).map_err(|_| INVALID.to_string())?;
let address = reqwest::Url::parse(address).map_err(|_| INVALID.to_string())?;
if endpoint.scheme() != "http"
|| endpoint.host_str() != Some("127.0.0.1")
|| !endpoint.port().is_some_and(|port| port != 0)
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.path() != "/mcp"
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
|| address.origin() != endpoint.origin()
|| !address.username().is_empty()
|| address.password().is_some()
|| address.fragment().is_some()
{
return Err(INVALID.into());
}
Ok(())
}

fn open_vana_authorization(endpoint: &str, address: &str) -> Result<(), String> {
validate_vana_authorization(endpoint, address)?;
// This runs in the local host's IPC worker, never the GPUI thread. A
// webbrowser error may contain the full URL; keep it out of logs and IPC.
webbrowser::open(address).map_err(|_| {
"Could not open your browser for Vana sign-in. Check your default browser and try again."
.to_string()
})
}

/// The server's API key name rule: 1 to 50 characters after trimming.
pub const MAX_API_KEY_NAME_LENGTH: usize = 50;

Expand Down Expand Up @@ -3116,6 +3174,39 @@ mod project_names_tests;
mod tests {
use super::*;

#[test]
fn vana_local_browser_only_opens_the_requested_loopback_origin() {
let endpoint = "http://127.0.0.1:47321/mcp";
assert!(
validate_vana_authorization(
endpoint,
"http://127.0.0.1:47321/mcp/oauth/authorize?client_id=fixture&state=synthetic-state",
)
.is_ok()
);
for address in [
"https://example.com/authorize?state=synthetic-state",
"http://127.0.0.1:47322/mcp/oauth/authorize",
"http://localhost:47321/mcp/oauth/authorize",
"http://user:password@127.0.0.1:47321/mcp/oauth/authorize",
"http://127.0.0.1:47321/mcp/oauth/authorize#unexpected",
"vana://mcp-authorization?fixture=synthetic",
] {
assert!(validate_vana_authorization(endpoint, address).is_err());
}
for endpoint in [
"https://example.com/mcp",
"http://127.0.0.1:0/mcp",
"http://127.0.0.1:47321/mcp?unexpected=true",
"http://127.0.0.1:47321/mcp#unexpected",
"http://127.0.0.1:47321/another-resource",
] {
assert!(
validate_vana_authorization(endpoint, "http://127.0.0.1:47321/authorize").is_err()
);
}
}

#[tokio::test]
async fn remote_mcp_host_boundary_accepts_write_only_values_and_keeps_account_fences() {
let root = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading