Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ecf826c
docs(acp): add MCP-over-ACP browser-control implementation blueprint …
brettchien Jul 19, 2026
21cfbb5
docs(adr): resolve MCP-over-ACP browser-control design (D1-D4) + flow…
brettchien Jul 19, 2026
b14552a
feat(gateway/acp): server-initiated request direction (T1.2/1.3)
brettchien Jul 19, 2026
4d6055d
refactor(gateway/acp): construct trivial responses from generated typ…
brettchien Jul 19, 2026
54ac761
feat(gateway/acp): MCP-over-ACP tunnel frame API (T4.1)
brettchien Jul 19, 2026
f6c9294
feat(core): scaffold MCP proxy server deps + static browser toolset (…
brettchien Jul 19, 2026
b8a01cb
feat(core): MCP proxy ServerHandler + loopback server (T5.1b)
brettchien Jul 19, 2026
cc9dbf7
feat(core): bearer gate on the MCP proxy server (T5.2 part 1)
brettchien Jul 19, 2026
63d6566
docs(adr): correct runtime diagram — mcp/message flattens, no inner id
brettchien Jul 19, 2026
d91814e
feat(gateway/acp): parse + record client-declared type:acp mcpServers…
brettchien Jul 19, 2026
9661191
docs: MCP-over-ACP tunnel contract for the extension (T4.3)
brettchien Jul 19, 2026
767c9ee
feat(gateway/acp): TunnelHandle — per-connection MCP tunnel handle (T…
brettchien Jul 19, 2026
daa23da
feat(gateway/acp): tunnel registry + establish_and_register_tunnel (T…
brettchien Jul 19, 2026
eedf6ce
feat(gateway): thread AcpTunnelRegistry through AppState (T5.3)
brettchien Jul 19, 2026
b88a176
feat(gateway/acp): open MCP tunnels on session/new + cleanup (T5.3)
brettchien Jul 19, 2026
4af5ad9
feat(core): BrowserTunnel trait + ProxyHandler forwards tool calls (T…
brettchien Jul 19, 2026
c908496
feat(core): start_session_server — per-session server + .cursor/mcp.j…
brettchien Jul 19, 2026
6cbd03f
feat(core): start per-session MCP proxy on agent spawn (T5.2/D5-a)
brettchien Jul 19, 2026
e582383
feat(root): wire the browser tunnel bridge end-to-end (T5.3, D6-a')
brettchien Jul 19, 2026
c9896ce
docs(adr): record the as-built OpenAB side (D5-a + D6-a', end-to-end)
brettchien Jul 19, 2026
059da24
test(e2e): MCP-over-ACP tunnel producer section in acp-ws-smoke (T7)
brettchien Jul 19, 2026
6d5d1ce
test(e2e): complete the MCP-over-ACP tunnel suite (fan-out + filtering)
brettchien Jul 19, 2026
ddc9620
fix(acp-mcp): address Mira review nits — constant-time bearer + strin…
brettchien Jul 19, 2026
6476dae
fix(acp-mcp): establish the browser tunnel on session/resume, not jus…
brettchien Jul 19, 2026
dde1b34
chore(acp-mcp): log browser tunnel open/register for live-session obs…
brettchien Jul 19, 2026
06165f0
fix(acp-mcp): raise ACP frame cap 1→8 MiB for browser-tool results
brettchien Jul 19, 2026
1f5b0c9
fix(acp-mcp): one browser tunnel per session — fix fan-out overwrite/…
brettchien Jul 19, 2026
bbc9e13
fix(acp-mcp): 0600 mcp.json + strip stale bearer on evict (M-B2)
brettchien Jul 19, 2026
b9bdd08
chore(acp-mcp): lock subtle dependency
brettchien Jul 19, 2026
a6d9654
feat(mcp-proxy): write openab-browser into kiro-cli config too, not j…
brettchien Jul 20, 2026
dc0009a
docs: browser MCP agent setup — per-variant mcp.json how-to (Phase 2 #8)
brettchien Jul 20, 2026
c2bd77d
feat(mcp-proxy): per-pod browser-bridge socket server (Option C, P1)
brettchien Jul 20, 2026
73a777e
feat(cli): openab browser-bridge subcommand — stdio MCP relay to the …
brettchien Jul 20, 2026
8b18c48
feat(acp): inject OPENAB_BROWSER_CHANNEL into the agent env (Option C…
brettchien Jul 20, 2026
2509bde
feat(mcp-proxy): static write-once browser-bridge config (Option C, P4)
brettchien Jul 20, 2026
822fdf9
feat: OPENAB_BROWSER_MODE proxy|bridge toggle wiring (Option C, P5)
brettchien Jul 20, 2026
1d51376
fix(browser-bridge): resolve channel via process-ancestry, not env (O…
brettchien Jul 20, 2026
c83e399
feat(mcp-proxy): revert bridge config to pure {command,args} (Option …
brettchien Jul 20, 2026
3696d13
fix(acp): add acp_mcp_servers to test-only AcpSession initializers
brettchien Jul 24, 2026
0ed644d
docs(acp): split into reverse-MCP mechanism ADR + browser-control ADR…
brettchien Jul 24, 2026
144c232
docs(adr): correct D4 list_changed overclaim in browser ADR
brettchien Jul 24, 2026
1c35aaa
refactor(acp): compound-key (channel_id,server_id) tunnel registry + …
brettchien Jul 24, 2026
c000534
Merge main (post #1448/#1449/#1453): keep browser tunnel wiring along…
chaodu-agent Jul 25, 2026
487a05a
chore: regenerate Cargo.lock for merged deps
chaodu-agent Jul 25, 2026
c2a7c6a
fix(mcp-proxy): register browser server in kiro per-agent configs (--…
chaodu-agent Jul 25, 2026
a492eb5
Merge remote-tracking branch 'origin/main' into pr1447rw
chaodu-agent Jul 25, 2026
bf37d25
feat(mcp): browser capabilities through the session-aware facade (Fac…
chaodu-agent Jul 25, 2026
74e23f0
fix: facade_serving is acp-only — derive it, don't flag it (default-f…
chaodu-agent Jul 25, 2026
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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target/
.git/
data/
*.tgz
4 changes: 4 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ feishu = ["dep:openab-gateway", "dep:axum", "openab-gateway/feishu"]
googlechat = ["dep:openab-gateway", "dep:axum", "openab-gateway/googlechat"]
wecom = ["dep:openab-gateway", "dep:axum", "openab-gateway/wecom"]
teams = ["dep:openab-gateway", "dep:axum", "openab-gateway/teams"]
acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp"]
acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp", "openab-core/acp-mcp"]

[dev-dependencies]
tempfile = "3.27.0"
9 changes: 9 additions & 0 deletions crates/openab-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ aws-credential-types = { version = "1", optional = true }
urlencoding = { version = "2", optional = true }
hex = { version = "0.4", optional = true }
http = { version = "1", optional = true }
# MCP proxy server (browser-control, feature `acp-mcp`): core hosts an in-process
# Streamable-HTTP MCP server the colocated agent connects to (D3). rmcp server + a
# self-owned loopback axum listener; kept optional so non-acp builds don't pull them.
rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true }
axum = { version = "0.8", optional = true }
tokio-util = { version = "0.7", optional = true }
subtle = { version = "2", optional = true } # constant-time bearer compare for the loopback MCP server

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand All @@ -65,3 +72,5 @@ config-s3 = ["dep:aws-sdk-s3", "dep:aws-config"]
pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate2", "dep:tar"]
filestore = ["dep:aws-sdk-s3", "dep:aws-config"]
agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"]
# Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`).
acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util", "dep:subtle"]
48 changes: 47 additions & 1 deletion crates/openab-core/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ pub struct AcpConnection {
pub session_reset: bool,
_reader_handle: JoinHandle<()>,
_stderr_handle: Option<JoinHandle<()>>,
/// Cancels this session's per-session MCP proxy server (D5-a) when the connection is
/// dropped. Held only for its `Drop` side effect (never read).
#[cfg(feature = "acp-mcp")]
#[allow(dead_code)]
mcp_server_guard: Option<tokio_util::sync::DropGuard>,
}

/// Build the final set of env vars for the agent subprocess.
Expand Down Expand Up @@ -331,6 +336,7 @@ impl AcpConnection {
working_dir: &str,
env: &std::collections::HashMap<String, String>,
inherit_env: &[String],
browser_channel: Option<&str>,
) -> Result<Self> {
info!(cmd = command, ?args, cwd = working_dir, "spawning agent");

Expand Down Expand Up @@ -397,6 +403,10 @@ impl AcpConnection {
cmd.env("SystemDrive", v);
}
}
// Option C: hand this session's browser channel to the agent so the `openab
// browser-bridge` stdio shim it later spawns inherits it and routes tool calls to THIS
// session's tunnel. env_clear() above dropped it, so (re)inject explicitly.
set_browser_channel(&mut cmd, browser_channel);
for (k, v) in env {
cmd.env(k, expand_env(v));
}
Expand Down Expand Up @@ -485,9 +495,17 @@ impl AcpConnection {
session_reset: false,
_reader_handle: reader_handle,
_stderr_handle: stderr_handle,
#[cfg(feature = "acp-mcp")]
mcp_server_guard: None,
})
}

/// Attach the guard that stops this session's MCP proxy server when the connection drops.
#[cfg(feature = "acp-mcp")]
pub fn set_mcp_guard(&mut self, guard: Option<tokio_util::sync::DropGuard>) {
self.mcp_server_guard = guard;
}

fn next_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
Expand Down Expand Up @@ -834,11 +852,39 @@ impl Drop for AcpConnection {
}
}

/// Inject the session's browser channel into the agent's env so the `openab browser-bridge`
/// stdio shim the agent later spawns inherits it (Option C) and routes tool calls to THIS
/// session's tunnel. No-op for non-browser sessions (`None`).
fn set_browser_channel(cmd: &mut tokio::process::Command, browser_channel: Option<&str>) {
if let Some(channel) = browser_channel {
cmd.env("OPENAB_BROWSER_CHANNEL", channel);
}
}

#[cfg(test)]
mod tests {
use super::{build_agent_env, build_permission_response, pick_best_option};
use super::{build_agent_env, build_permission_response, pick_best_option, set_browser_channel};
use serde_json::json;

#[test]
fn set_browser_channel_injects_env_when_present() {
let mut cmd = tokio::process::Command::new("true");
set_browser_channel(&mut cmd, Some("acp_win1"));
assert!(cmd.as_std().get_envs().any(|(k, v)| {
k == "OPENAB_BROWSER_CHANNEL" && v == Some(std::ffi::OsStr::new("acp_win1"))
}));
}

#[test]
fn set_browser_channel_is_noop_when_absent() {
let mut cmd = tokio::process::Command::new("true");
set_browser_channel(&mut cmd, None);
assert!(!cmd
.as_std()
.get_envs()
.any(|(k, _)| k == "OPENAB_BROWSER_CHANNEL"));
}

#[test]
fn picks_allow_always_over_other_options() {
let options = vec![
Expand Down
137 changes: 136 additions & 1 deletion crates/openab-core/src/acp/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ pub struct SessionPool {
mapping_path: PathBuf,
meta_path: PathBuf,
default_config_options: HashMap<String, String>,
/// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the
/// root. `None` = no browser wiring (tool calls report not-connected).
#[cfg(feature = "acp-mcp")]
browser_tunnel: Option<Arc<dyn crate::mcp_proxy::AcpMcpTunnel>>,
#[cfg(feature = "acp-mcp")]
session_registrar: Option<Arc<dyn crate::mcp_proxy::SessionTokenRegistrar>>,
#[cfg(feature = "acp-mcp")]
facade_url: Option<String>,
}

type CancelHandle = (Arc<tokio::sync::Mutex<tokio::process::ChildStdin>>, String);
Expand Down Expand Up @@ -197,9 +205,42 @@ impl SessionPool {
mapping_path,
meta_path,
default_config_options,
#[cfg(feature = "acp-mcp")]
browser_tunnel: None,
#[cfg(feature = "acp-mcp")]
session_registrar: None,
#[cfg(feature = "acp-mcp")]
facade_url: None,
}
}

/// Wire the browser tunnel bridge (D6-a', set by the root) so per-session MCP proxies can
/// reach the browser. Call before sharing the pool.
#[cfg(feature = "acp-mcp")]
pub fn with_browser_tunnel(
mut self,
tunnel: Option<Arc<dyn crate::mcp_proxy::AcpMcpTunnel>>,
) -> Self {
self.browser_tunnel = tunnel;
self
}

/// Wire the facade session-token registrar + facade URL (Facade mode,
/// set by the root when `[mcp]` is running). With both present, browser
/// capabilities route through the facade: the pool mints one token per
/// session, injects it as `OPENAB_SESSION_TOKEN` in the agent process
/// env, and writes the static facade MCP entry once per workdir.
#[cfg(feature = "acp-mcp")]
pub fn with_facade_sessions(
mut self,
registrar: Option<Arc<dyn crate::mcp_proxy::SessionTokenRegistrar>>,
facade_url: Option<String>,
) -> Self {
self.session_registrar = registrar;
self.facade_url = facade_url;
self
}

fn load_mapping(path: &Path) -> HashMap<String, String> {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| {
Expand Down Expand Up @@ -347,14 +388,106 @@ impl SessionPool {
self.config.working_dir.clone()
};

// Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP
// server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The
// returned guard cancels that server when this connection is dropped (any evict path).
// Facade mode: mint the per-session token (returned env var rides the
// agent spawn below) and write the static facade entry once. Falls
// back to Proxy mode when the root wired no registrar (no [mcp]).
#[cfg(feature = "acp-mcp")]
let mut session_token: Option<String> = None;
#[cfg(feature = "acp-mcp")]
let mcp_guard: Option<tokio_util::sync::DropGuard> =
if let Some(channel_id) = thread_id.strip_prefix("acp:") {
let mode = match crate::mcp_proxy::browser_mode() {
crate::mcp_proxy::BrowserMode::Facade
if self.session_registrar.is_none() || self.facade_url.is_none() =>
{
crate::mcp_proxy::BrowserMode::Proxy
}
m => m,
};
match mode {
crate::mcp_proxy::BrowserMode::Facade => {
// unwraps guarded by the fallback arm above
let registrar = self.session_registrar.as_ref().unwrap();
let facade_url = self.facade_url.as_ref().unwrap();
if let Err(e) =
crate::mcp_proxy::write_facade_mcp_config(&effective_workdir, facade_url)
.await
{
warn!(thread_id, error = %e, "failed to write facade mcp config");
}
session_token = Some(registrar.mint(channel_id));
info!(thread_id, "session token minted for facade browser capabilities");
// Revoke on evict/replace: piggyback the same DropGuard
// plumbing proxy mode uses for its server teardown.
let ct = tokio_util::sync::CancellationToken::new();
let child = ct.child_token();
let registrar = registrar.clone();
let chan = channel_id.to_string();
tokio::spawn(async move {
child.cancelled().await;
registrar.revoke(&chan);
});
Some(ct.drop_guard())
}
// Bridge mode (Option C): the agent's static mcp.json points at `openab
// browser-bridge`, which dials the pod-wide socket server (started at boot).
// Just ensure the write-once config exists — no per-session server/guard.
crate::mcp_proxy::BrowserMode::Bridge => {
if let Err(e) =
crate::mcp_proxy::write_bridge_mcp_config(&effective_workdir).await
{
warn!(thread_id, error = %e, "failed to write bridge mcp config");
}
None
}
// Proxy mode (default): per-session loopback HTTP MCP server + dynamic config.
crate::mcp_proxy::BrowserMode::Proxy => {
match crate::mcp_proxy::start_session_server(
channel_id,
&effective_workdir,
self.browser_tunnel.clone(),
)
.await
{
Ok((addr, ct)) => {
info!(thread_id, %addr, "started per-session MCP proxy server");
Some(ct.drop_guard())
}
Err(e) => {
warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable");
None
}
}
}
}
} else {
None
};

// Build the replacement connection outside the state lock so one stuck
// initialization does not block all unrelated sessions.
#[cfg(feature = "acp-mcp")]
let spawn_env: std::collections::HashMap<String, String> = {
let mut env = self.config.env.clone();
if let Some(tok) = &session_token {
// The static facade MCP entry references ${OPENAB_SESSION_TOKEN};
// the value lives only in this agent process's environment.
env.insert("OPENAB_SESSION_TOKEN".to_string(), tok.clone());
}
env
};
#[cfg(not(feature = "acp-mcp"))]
let spawn_env = self.config.env.clone();
let mut new_conn = AcpConnection::spawn(
&self.config.command,
&self.config.args,
&effective_workdir,
&self.config.env,
&spawn_env,
&self.config.inherit_env,
thread_id.strip_prefix("acp:"),
)
.await?;

Expand Down Expand Up @@ -423,6 +556,8 @@ impl SessionPool {
let activity_handle = new_conn.activity_handle();
let child_pgid = new_conn.child_pgid();
let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default();
#[cfg(feature = "acp-mcp")]
new_conn.set_mcp_guard(mcp_guard);
let new_conn = Arc::new(Mutex::new(new_conn));

let mut state = self.state.write().await;
Expand Down
2 changes: 2 additions & 0 deletions crates/openab-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pub mod acp;
pub mod adapter;
#[cfg(feature = "acp-mcp")]
pub mod mcp_proxy;
pub mod bot_turns;
pub mod config;
pub mod cron;
Expand Down
Loading
Loading