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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.

### Added

- `mcp` feature + `loopctl::mcp` module — adapt any MCP server's tools as loopctl `Tool` implementations. New public types: `McpClient` (a connected client handle), `McpToolProvider` (discovers a server's tools and registers them into a `ToolRegistry`), `McpTool` (one server tool as a `Tool`), `McpError`. The adapter is transport-agnostic; `McpClient::in_process` connects an in-process rmcp server for tests and bundled-server use. Real transports (stdio, HTTP/SSE) arrive in a later release. The optional `rmcp` dependency is pulled in only by the `mcp` feature (`default = []` is unchanged). A runnable end-to-end demo ships at `examples/mcp-adapter.rs` (`cargo run --example mcp-adapter --features mcp`).
- `LoopError::ToolRecoveryExhausted { tool, attempts }` — the driver now enforces `MAX_RECOVERY_ATTEMPTS` (5) as a hard ceiling. A recovery strategy that always returns `Retry` is stopped after 5 retries (attempt 6), returning this variant instead of looping forever. Pinned by `recovery_ceiling_stops_retry_forever_strategy`.
- `RunConfig::memory_top_k` — configurable number of memory entries retrieved and injected per turn (default 3; was a hardcoded magic number).
- `MachineStep::CallTools { turn, calls }` — the machine now emits the 0-indexed turn number on `CallTools` (matching `CallLLM`), so both handlers source the turn identically from the machine rather than one reading a field and the other querying a counter.
Expand Down
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ bytes = { version = "1", optional = true }
async-stream = { version = "0.3", optional = true }
httpdate = { version = "1", optional = true }
jsonschema = { version = "0.49", optional = true }
rmcp = { version = "3", optional = true, default-features = false, features = ["client", "server", "macros", "transport-async-rw"] }
Comment thread
bobrykov marked this conversation as resolved.

[dev-dependencies]
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
Expand Down Expand Up @@ -61,6 +62,9 @@ zai = ["providers", "anthropic"]
grammar = ["providers"]
schema_validation = ["dep:jsonschema"]

# MCP client adapter
mcp = ["dep:rmcp"]

[[example]]
name = "hello-cli"
required-features = ["testing"]
Expand All @@ -77,6 +81,10 @@ required-features = ["testing"]
name = "chat"
required-features = ["testing", "providers"]

[[example]]
name = "mcp-adapter"
required-features = ["mcp"]

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ let agent = BareLoop::new(
| `zai` | No | `providers`, `anthropic` | Z.AI API client (Anthropic-compatible) |
| `grammar` | No | `providers` | Tool-call grammar providers for grammar-aware samplers (vLLM `guided_json`); enables the `Grammar` mode of `ToolConstraint` |
| `schema_validation` | No | — | JSON Schema validation of `Correction::modified_input` in `LlmReflector` (pulls `jsonschema`); when off, validation is skipped |
| `mcp` | No | `rmcp` | MCP client adapter (`mcp::McpToolProvider`) — adapt an MCP server's tools as loopctl `Tool` impls (in-process; stdio/HTTP/SSE transports land in a later release) |

### Streaming vs non-streaming

Expand Down
80 changes: 80 additions & 0 deletions examples/mcp-adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! Adapt an in-process MCP server's tools as loopctl `Tool` impls.
//!
//! Builds a tiny rmcp server (with the `#[tool_router]` / `#[tool]` macros),
//! connects an [`McpToolProvider`] to it over a `tokio::io::duplex`, discovers
//! its tools, registers them into a [`ToolRegistry`], and calls one to prove
//! the round-trip works end-to-end.
//!
//! ```sh
//! cargo run --example mcp-adapter --features mcp
//! ```

#![allow(
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
dead_code
)]

use loopctl::mcp::{McpClient, McpToolProvider};
use loopctl::tool::{ToolContext, ToolRegistry};
use rmcp::handler::server::ServerHandler;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::{tool, tool_handler, tool_router};
use serde_json::json;

/// An rmcp server exposing one `greet` tool.
#[derive(Clone)]
struct GreetServer {
router: ToolRouter<Self>,
}

#[tool_router]
impl GreetServer {
fn new() -> Self {
Self {
router: Self::tool_router(),
}
}

#[tool(description = "Return a friendly greeting")]
async fn greet(&self) -> String {
"hello, world!".to_string()
}
}

#[tool_handler]
impl ServerHandler for GreetServer {}

#[tokio::main]
async fn main() {
// 1. Connect an rmcp client to the in-process server and run the MCP
// initialize handshake. This is the only constructor L-12 ships; real
// transports (stdio, HTTP/SSE) arrive in a later release.
let client = McpClient::in_process(GreetServer::new())
.await
.expect("client initialize");

// 2. Discover the server's tools and register them.
let provider = McpToolProvider::connect(client, None)
.await
.expect("connect + list_tools");
let mut registry = ToolRegistry::new();
provider.register_into(&mut registry);
println!("discovered {} tool(s):", registry.len());
for schema in registry.all_schemas() {
println!(" - {} : {}", schema.tool, schema.description);
}

// 3. Call the adapted `greet` tool through the registry, exactly as the
// agent loop would call any native loopctl tool.
let greet = registry.get("greet").expect("greet registered");
let ctx = ToolContext::default();
let out = greet.call(json!({}), &ctx).await.expect("greet call");
println!("greet -> {}", out.text_content());
assert!(!out.is_error);
assert_eq!(out.text_content(), "hello, world!");
println!("OK");
}
12 changes: 11 additions & 1 deletion src/engine/bare/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4457,11 +4457,17 @@ fn test_add_contributor_panics_after_session_start() {
// subsequent add_contributor must panic in debug builds (matches
// set_reflector's contract).
// Box the future so we can drop it without awaiting; the session-init
// side effect is the state transition under test.
// side effect is the state transition under test. The turn path uses
// `tokio::select!`/`tokio::time::sleep`, which require a tokio reactor
// context, so enter a runtime guard before block_on polls (the guard only
// makes a reactor available on this thread — we do not drive via the
// runtime's own block_on, which would run the loop to completion).
{
let run_config = RunConfig::default();
let fut = agent.run("seed", &run_config);
let mut fut = std::pin::pin!(fut);
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
let _guard = rt.enter();
let outcome = futures::executor::block_on(fut.as_mut());
drop(outcome);
}
Expand Down Expand Up @@ -4548,10 +4554,14 @@ fn test_set_request_options_panics_after_session_start() {
let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), config);
// The first run() establishes the session and moves the loop out of
// Idle; a subsequent set_request_options must panic in debug builds.
// The turn path uses `tokio::select!`/`tokio::time::sleep`, which require a
// tokio reactor context, so enter a runtime guard before block_on polls.
{
let run_config = RunConfig::default();
let fut = agent.run("seed", &run_config);
let mut fut = std::pin::pin!(fut);
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
let _guard = rt.enter();
let outcome = futures::executor::block_on(fut.as_mut());
drop(outcome);
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
//! - **[`middleware`]** — Tool dispatch middleware pipeline (timeouts, permissions, output limits).
//! - **[`tool`]** — Tool trait, registry, and supporting types.
//! - **`tool::health`** — Per-tool health monitoring, circuit breakers, and self-healing routing. *Requires `tool_health` feature.*
//! - **[`mcp`]** — MCP client adapter ([`McpToolProvider`](mcp::McpToolProvider)) — adapt any MCP server's tools as `Tool` impls. *Requires `mcp` feature.*
//!
//! ## API Layer
//!
Expand Down Expand Up @@ -77,6 +78,8 @@ pub mod fallback;
#[cfg(feature = "hooks")]
pub mod hooks;
pub mod managers;
#[cfg(feature = "mcp")]
pub mod mcp;
pub mod memory;
pub mod message;
pub mod middleware;
Expand Down
Loading