Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `OllamaConfig` struct with `tool_use: bool` field (default false) in `LlmConfig`
- `AgentBuilder::with_mcp_shared_tools()` method to wire the shared tool list into the agent
- ACP session modes support: `set_session_mode` method (ask/architect/code), `current_mode_update` notification emission on mode switch, and `availableModes` field in `new_session`/`load_session` responses (#920)
- ACP: `ext_notification` handler logs method name and returns `Ok(())` instead of `method_not_found` (#930)
- ACP: MCP bridge now supports HTTP and SSE server transports — both are mapped to `McpTransport::Http` since rmcp's `StreamableHttpClientTransport` handles both; previously HTTP and SSE servers were silently skipped (#930)

### Changed
- `ToolDef.id` and `ToolDef.description` changed from `&'static str` to `Cow<'static, str>` to support dynamic MCP tool names without memory leaks
- `AgentCapabilities` in `initialize()` now advertises `PromptCapabilities` with `image=true` and `embedded_context=true`, reflecting actual Image and Resource content block support (#917)
- ACP: `AgentCapabilities` in `initialize` response now advertises `config_options` and `ext_methods` support via meta fields (#930)

## [0.12.1] - 2026-02-25

Expand Down
123 changes: 115 additions & 8 deletions crates/zeph-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,29 @@ ACP (Agent Client Protocol) server adapter for embedding Zeph in IDE environment

## Overview

Implements the [Agent Client Protocol](https://agentclientprotocol.org) server side, allowing IDEs and editors to drive the Zeph agent loop over stdio or HTTP transports. The crate wires IDE-proxied capabilities — file system access, terminal execution, and permission gates — into the agent loop via `AcpContext`, exposes `AgentSpawner` as the integration point for the host application, and supports runtime model switching via `ProviderFactory` and MCP server management via `ext_method`.
Implements the [Agent Client Protocol](https://agentclientprotocol.org) server side, allowing IDEs and editors to drive the Zeph agent loop over stdio, HTTP+SSE, or WebSocket transports. The crate wires IDE-proxied capabilities — file system access, terminal execution, and permission gates — into the agent loop via `AcpContext`, exposes `AgentSpawner` as the integration point for the host application, and supports runtime model switching via `ProviderFactory` and MCP server management via `ext_method`.

## Installation

```toml
[dependencies]
zeph-acp = "0.1"

# With HTTP+SSE transport
zeph-acp = { version = "0.1", features = ["acp-http"] }
```

> [!IMPORTANT]
> Requires Rust 1.88 or later.

## Features

| Feature | Description | Default |
|---------|-------------|---------|
| `acp-http` | HTTP+SSE transport via axum (`AcpHttpState`, `acp_router`, `post_handler`, `get_handler`) | No |

> [!TIP]
> Enable `acp-http` only when deploying Zeph as a network-accessible ACP endpoint. The default stdio transport is sufficient for local IDE integrations.

## Key modules

Expand All @@ -20,11 +42,13 @@ Implements the [Agent Client Protocol](https://agentclientprotocol.org) server s
| `fs` | `AcpFileExecutor` — file system executor backed by IDE-proxied ACP file operations |
| `terminal` | `AcpShellExecutor` — shell executor backed by IDE-proxied ACP terminal |
| `permission` | `AcpPermissionGate` — forwards tool permission requests to the IDE for user approval; persists "always allow/deny" decisions to TOML file |
| `mcp_bridge` | `acp_mcp_servers_to_entries` — converts ACP-advertised MCP servers into `McpServerEntry` configs |
| `mcp_bridge` | `acp_mcp_servers_to_entries` — converts ACP-advertised MCP servers (Stdio, Http, Sse) into `McpServerEntry` configs |
| `error` | `AcpError` typed error enum |

**Re-exports:** `AcpContext`, `AgentSpawner`, `ProviderFactory`, `AcpError`, `AcpFileExecutor`, `AcpPermissionGate`, `AcpShellExecutor`, `AcpServerConfig`, `serve_connection`, `serve_stdio`, `acp_mcp_servers_to_entries`

**Re-exports (feature `acp-http`):** `SendAgentSpawner`, `AcpHttpState`, `acp_router`

## AcpContext

`AcpContext` carries per-session IDE capabilities into the agent loop. Each field is `None` when the IDE did not advertise the corresponding capability:
Expand All @@ -41,6 +65,86 @@ pub struct AcpContext {

The `cancel_signal` is shared with the agent's `LoopbackHandle` so that an IDE cancel request immediately interrupts the running inference loop.

## Protocol methods

### AgentCapabilities (G3)

The `initialize` response advertises enriched capabilities:

```rust
acp::AgentCapabilities::new()
.load_session(true)
.meta({
cap_meta.insert("config_options", json!(true));
cap_meta.insert("ext_methods", json!(true));
cap_meta
})
```

This signals to the IDE that the agent supports session config options (`session/configure`) and custom `ext_method` extensions.

### set_session_mode (G2)

`ZephAcpAgent` implements `set_session_mode` to handle IDE-driven mode switches per session:

- Validates that the target session exists; returns `invalid_request` error if not found.
- Logs the `session_id` and `mode_id` at debug level.
- Currently a no-op acknowledgement — mode semantics are handled by the IDE.

### ext_notification (G4)

`ZephAcpAgent` implements `ext_notification` to accept IDE-originated fire-and-forget notifications:

- Logs the notification method name at debug level.
- Returns `Ok(())` for all known and unknown methods — unrecognized notifications are silently accepted.

## MCP transport support (G8)

`acp_mcp_servers_to_entries` converts ACP-advertised MCP servers into `zeph-mcp` `ServerEntry` configs. Three transport types are supported:

| ACP variant | Mapped transport | Notes |
|-------------|-----------------|-------|
| `McpServer::Stdio` | `McpTransport::Stdio` | Env vars forwarded as-is to child process |
| `McpServer::Http` | `McpTransport::Http` | Streamable HTTP via rmcp |
| `McpServer::Sse` | `McpTransport::Http` | Legacy SSE mapped to streamable HTTP (backward-compatible) |

> [!NOTE]
> SSE is a legacy MCP transport. rmcp's `StreamableHttpClientTransport` handles both SSE and streamable HTTP endpoints, so both variants map to `McpTransport::Http`.

```rust
use zeph_acp::acp_mcp_servers_to_entries;

let entries = acp_mcp_servers_to_entries(&initialize_request.mcp_servers);
// entries: Vec<ServerEntry> ready for McpManager::start_all
```

## HTTP+SSE transport (feature `acp-http`)

Enable the `acp-http` feature to expose Zeph over HTTP with Server-Sent Events:

```rust
use zeph_acp::{AcpHttpState, AcpServerConfig, acp_router};

let state = AcpHttpState::new(spawner, AcpServerConfig::default());
state.start_reaper(); // prune idle connections every 60 s

let app = acp_router(state);
// mount app into your axum Router
```

Endpoints:

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/acp` | Send a JSON-RPC request; stream responses as SSE. Creates a new connection when `Acp-Session-Id` header is absent. |
| `GET` | `/acp` | Reconnect to an existing connection's SSE stream. Requires `Acp-Session-Id` header. |
| `GET` | `/acp/ws` | WebSocket upgrade for bidirectional streaming. |

Session IDs are UUIDs returned in the `Acp-Session-Id` response header. Idle connections (beyond `session_idle_timeout_secs`) are reaped by a background task.

> [!TIP]
> Use `SendAgentSpawner` (the `Send`-safe variant of `AgentSpawner`) when constructing `AcpHttpState`. This satisfies axum's `State` requirement for `Send + Sync`.

## Rich content

ACP prompts can carry multi-modal content blocks beyond plain text:
Expand Down Expand Up @@ -105,6 +209,15 @@ pub type AgentSpawner = Arc<

The host constructs an `AgentSpawner` closure that wires `AcpContext` capabilities into `Agent` via `with_cancel_signal()` on the builder, then passes the closure to `serve_stdio` or `serve_connection`.

For HTTP transport, use `SendAgentSpawner` which requires `Send + Sync`:

```rust
pub type SendAgentSpawner = Arc<
dyn Fn(LoopbackChannel, Option<AcpContext>) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>>
+ Send + Sync + 'static,
>;
```

## Custom methods

`ZephAcpAgent` exposes vendor-specific extensions via `ExtRequest` dispatch. The `custom` module matches on `req.method` and routes to the appropriate handler. Unrecognized methods return `None`, allowing the ACP runtime to respond with "method not found".
Expand All @@ -129,12 +242,6 @@ The host constructs an `AgentSpawner` closure that wires `AcpContext` capabiliti

The `initialize` response includes an `auth_hint` key in its metadata map. For stdio transport (trusted local client) this is a generic `"authentication required"` string. IDEs can use this hint to prompt the user for credentials before issuing further requests.

## Installation

```bash
cargo add zeph-acp
```

## License

MIT
45 changes: 44 additions & 1 deletion crates/zeph-acp/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,13 @@ impl acp::Agent for ZephAcpAgent {
acp::PromptCapabilities::new()
.image(true)
.embedded_context(true),
),
)
.meta({
let mut cap_meta = serde_json::Map::new();
cap_meta.insert("config_options".to_owned(), serde_json::json!(true));
cap_meta.insert("ext_methods".to_owned(), serde_json::json!(true));
cap_meta
}),
)
.meta(meta))
}
Expand All @@ -299,6 +305,11 @@ impl acp::Agent for ZephAcpAgent {
self.ext_method_mcp(&args).await
}

async fn ext_notification(&self, args: acp::ExtNotification) -> acp::Result<()> {
tracing::debug!(method = %args.method, "received ext_notification");
Ok(())
}

async fn authenticate(
&self,
_args: acp::AuthenticateRequest,
Expand Down Expand Up @@ -986,6 +997,19 @@ mod tests {
assert!(prompt_caps.image);
assert!(prompt_caps.embedded_context);
assert!(!prompt_caps.audio);
let cap_meta = resp
.agent_capabilities
.meta
.as_ref()
.expect("agent_capabilities.meta should be present");
assert!(
cap_meta.contains_key("config_options"),
"config_options missing from agent_capabilities meta"
);
assert!(
cap_meta.contains_key("ext_methods"),
"ext_methods missing from agent_capabilities meta"
);
let meta = resp.meta.expect("meta should be present");
assert!(
meta.contains_key("auth_hint"),
Expand All @@ -995,6 +1019,25 @@ mod tests {
.await;
}

#[tokio::test]
async fn ext_notification_accepts_unknown_method() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (agent, _rx) = make_agent();
use acp::Agent as _;
let notif = acp::ExtNotification::new(
"custom/ping",
serde_json::value::RawValue::from_string("{}".to_owned())
.unwrap()
.into(),
);
let result = agent.ext_notification(notif).await;
assert!(result.is_ok());
})
.await;
}

#[tokio::test]
async fn new_session_creates_entry() {
let local = tokio::task::LocalSet::new();
Expand Down
74 changes: 59 additions & 15 deletions crates/zeph-acp/src/mcp_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ const DEFAULT_MCP_TIMEOUT_SECS: u64 = 30;

/// Convert ACP `McpServer` list to `zeph-mcp` `ServerEntry` configs.
///
/// Only `Stdio` transport is supported. `Http` and `Sse` variants are skipped
/// with a warning — the agent does not advertise those capabilities.
/// `Stdio`, `Http`, and `Sse` transports are supported. `Sse` is mapped to
/// `McpTransport::Http` since rmcp's `StreamableHttpClientTransport` handles both.
#[must_use]
pub fn acp_mcp_servers_to_entries(servers: &[acp::McpServer]) -> Vec<ServerEntry> {
servers
Expand All @@ -36,13 +36,23 @@ pub fn acp_mcp_servers_to_entries(servers: &[acp::McpServer]) -> Vec<ServerEntry
timeout: Duration::from_secs(DEFAULT_MCP_TIMEOUT_SECS),
})
}
acp::McpServer::Http(http) => {
tracing::warn!(name = %http.name, "skipping HTTP MCP server — not supported");
None
}
acp::McpServer::Http(http) => Some(ServerEntry {
id: http.name.clone(),
transport: McpTransport::Http {
url: http.url.clone(),
},
timeout: Duration::from_secs(DEFAULT_MCP_TIMEOUT_SECS),
}),
acp::McpServer::Sse(sse) => {
tracing::warn!(name = %sse.name, "skipping SSE MCP server — not supported");
None
// SSE is a legacy MCP transport; map to Streamable HTTP which is
// backward-compatible. rmcp's StreamableHttpClientTransport handles both.
Some(ServerEntry {
id: sse.name.clone(),
transport: McpTransport::Http {
url: sse.url.clone(),
},
timeout: Duration::from_secs(DEFAULT_MCP_TIMEOUT_SECS),
})
}
_ => {
tracing::warn!("skipping unknown MCP server transport — not supported");
Expand All @@ -69,13 +79,29 @@ mod tests {
}

#[test]
fn skips_http_server() {
fn converts_http_server() {
let servers = vec![acp::McpServer::Http(acp::McpServerHttp::new(
"http-mcp",
"http://localhost",
))];
let entries = acp_mcp_servers_to_entries(&servers);
assert!(entries.is_empty());
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "http-mcp");
assert!(matches!(entries[0].transport, McpTransport::Http { .. }));
}

#[test]
fn converts_http_server_url() {
let servers = vec![acp::McpServer::Http(acp::McpServerHttp::new(
"http-mcp",
"http://example.com:8080/mcp",
))];
let entries = acp_mcp_servers_to_entries(&servers);
if let McpTransport::Http { url } = &entries[0].transport {
assert_eq!(url, "http://example.com:8080/mcp");
} else {
panic!("expected Http transport");
}
}

#[test]
Expand All @@ -99,26 +125,44 @@ mod tests {
}

#[test]
fn skips_sse_server() {
fn converts_sse_server() {
let servers = vec![acp::McpServer::Sse(acp::McpServerSse::new(
"sse-mcp",
"http://localhost/sse",
))];
let entries = acp_mcp_servers_to_entries(&servers);
assert!(entries.is_empty());
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "sse-mcp");
assert!(matches!(entries[0].transport, McpTransport::Http { .. }));
}

#[test]
fn converts_sse_server_url() {
let servers = vec![acp::McpServer::Sse(acp::McpServerSse::new(
"sse-mcp",
"http://example.com/sse",
))];
let entries = acp_mcp_servers_to_entries(&servers);
if let McpTransport::Http { url } = &entries[0].transport {
assert_eq!(url, "http://example.com/sse");
} else {
panic!("expected Http transport");
}
}

#[test]
fn mixed_list_returns_only_stdio() {
fn mixed_list_returns_all() {
let servers = vec![
acp::McpServer::Stdio(acp::McpServerStdio::new("stdio-1", "/bin/mcp1")),
acp::McpServer::Http(acp::McpServerHttp::new("http-1", "http://localhost")),
acp::McpServer::Stdio(acp::McpServerStdio::new("stdio-2", "/bin/mcp2")),
acp::McpServer::Sse(acp::McpServerSse::new("sse-1", "http://localhost/sse")),
];
let entries = acp_mcp_servers_to_entries(&servers);
assert_eq!(entries.len(), 2);
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].id, "stdio-1");
assert_eq!(entries[1].id, "stdio-2");
assert_eq!(entries[1].id, "http-1");
assert_eq!(entries[2].id, "stdio-2");
assert_eq!(entries[3].id, "sse-1");
}
}
Loading
Loading