v0.5.0
v0.5.0 — rmcp rework + DX update
Major reshape of the MCP surface, plus a batch of DX wins. Breaking — hence the 0.5 bump.
MCP: drop the hand-rolled stack, build on rmcp
agentkit-mcp previously shipped its own JSON-RPC transport (framing, SSE parsing, session-id capture, Last-Event-ID resume) and wrapped MCP wire types behind agentkit-flavoured structs that flattened rich fields into a MetadataMap. That's gone.
Typed wire surface
McpTool, McpResource, McpPrompt, Content, CallToolResult, McpToolAnnotations, PromptArgument are re-exported straight from rmcp. Tool annotations, output_schema, prompt argument metadata, resource title / size / icons are now first-class typed fields instead of stringly-typed metadata.
Pluggable client handlers
Implement any subset of McpSamplingResponder, McpElicitationResponder, McpRootsProvider and the corresponding ClientCapabilities is derived automatically. Wire the responders through McpHandlerConfig and pass to McpConnection::connect_with_handler:
use std::sync::Arc;
use agentkit_mcp::{McpConnection, McpError, McpHandlerConfig, McpRoot, McpRootsProvider,
McpServerConfig, McpTransportBinding, StdioTransportConfig};
use async_trait::async_trait;
struct StaticRoots;
#[async_trait]
impl McpRootsProvider for StaticRoots {
async fn list_roots(&self) -> Result<Vec<McpRoot>, McpError> {
// McpRoot is re-exported from rmcp — construct via its public fields.
Ok(vec![/* one or more McpRoot values */])
}
}
let transport = StdioTransportConfig::new("uvx").with_arg("mcp-server-git");
let server = McpServerConfig::new("git-mcp", McpTransportBinding::Stdio(transport));
let handler = McpHandlerConfig::new().with_roots_provider(Arc::new(StaticRoots));
let conn = McpConnection::connect_with_handler(&server, handler).await?;Server-event broadcast
McpServerEvent covers progress, logging, resource updates, cancellation, and every *_list_changed notification. Subscribe once, fan out wherever you like:
use agentkit_mcp::McpServerEvent;
let mut events = conn.subscribe_events();
tokio::spawn(async move {
while let Ok(evt) = events.recv().await {
match evt {
McpServerEvent::Progress(p) => tracing::info!(?p, "mcp progress"),
McpServerEvent::Logging(p) => tracing::info!(?p, "mcp log"),
McpServerEvent::ResourceUpdated(p) => tracing::info!(uri = %p.uri, "resource changed"),
McpServerEvent::ToolListChanged => { /* invalidate tool cache */ }
_ => {}
}
}
});subscribe_resource / unsubscribe_resource and set_logging_level are exposed on McpConnection; cancellation and roots-list-changed flow through the same broadcast channel.
Namespace policy
McpToolNamespace::{Default, None, Custom(closure)} controls the agentkit-side tool name. Default keeps the historical mcp_<server>_<tool> shape; None exposes raw names; Custom lets you implement project conventions.
Adapter unification
The bespoke McpInvocable is gone. MCP tools route through ToolInvocableAdapter like everything else, so the same permission / approval / annotation machinery applies uniformly.
Dynamic auth
McpConnection::resolve_auth rotates credentials in-place without reconnecting. For full per-request rotation, supply your own McpHttpClient to the streamable-HTTP transport:
use std::sync::Arc;
use agentkit_mcp::{McpConnection, McpServerConfig, McpTransportBinding, StreamableHttpTransportConfig};
let dynamic_client = Arc::new(SequentialBearerClient { /* mints token-N per call */ });
let conn = McpConnection::connect(&McpServerConfig::new(
"remote-mcp",
McpTransportBinding::StreamableHttp(
StreamableHttpTransportConfig::new("https://example.com/mcp")
.with_http_client(dynamic_client),
),
))
.await?;End-to-end demo (mock server + sequential-bearer client) lives in examples/mcp-dynamic-auth.
#[tool] macro (new)
Tools used to require a hand-rolled Tool impl plus a manual ToolSpec whose input_schema was either inlined JSON or built up by hand. The new agentkit-tools-derive crate ships a #[tool] attribute macro that turns an async function into a unit struct implementing Tool, with the input_schema derived from the input type via schemars::JsonSchema. The struct's identifier matches the function name, so registration reads naturally:
use agentkit_core::{ToolCallId, ToolOutput, ToolResultPart};
use agentkit_tools_core::{ToolError, ToolRegistry, ToolResult};
use agentkit_tools_derive::tool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(JsonSchema, Deserialize)]
struct WordCountInput {
/// The text whose words should be counted.
text: String,
}
/// Count whitespace-separated words.
#[tool]
async fn word_count(input: WordCountInput) -> Result<ToolResult, ToolError> {
let count = input.text.split_whitespace().count();
Ok(ToolResult::new(ToolResultPart::success(
ToolCallId::default(),
ToolOutput::text(format!("{count} word(s)")),
)))
}
let tools = ToolRegistry::new().with(word_count);Description defaults to the function's first doc-comment line; override with #[tool(description = "...")] or rename with #[tool(name = "...")]. Annotation flags map to ToolAnnotations and approval hints:
/// Drop a database table.
#[tool(destructive, needs_approval)]
async fn drop_table(input: DropTableInput) -> Result<ToolResult, ToolError> {
# unimplemented!()
}Supported flags: read_only, destructive, idempotent, needs_approval, supports_streaming. Each may be bare (= true) or explicit (destructive = false). Works on free functions and impl blocks (one method per impl).
Tool source combinators
agentkit-tools-core also gains Prefixed, Filtered, and Renamed adapters over any ToolSource, so namespacing or selectively exposing a subset of tools no longer requires a custom source.
Loop: lossless observation + background result delivery
TranscriptObserver + AgentEvent::ToolResultReceived
LoopObserver historically had two gaps that made it impossible to reconstruct the transcript from observer events alone:
- Tool result items were appended to the transcript without firing any event.
- Content deltas don't carry their parent-Item identity, so reassembly is lossy.
Both are closed:
AgentEvent::ToolResultReceived(ToolResultPart)fires before every tool-result item lands. All five push sites (foreground ready, waited resolution, background-detach placeholder, auth-cancelled, approval-denied) emit it. Correlate with the matchingToolCallRequestedviacall_id.TranscriptObserverfires once perItemappended to the transcript, in order, with the full Item shape ready for persistence.
use agentkit_core::Item;
use agentkit_loop::{Agent, AgentEvent, LoopObserver, TranscriptObserver};
struct PersistEverything;
impl TranscriptObserver for PersistEverything {
fn on_item_appended(&mut self, item: &Item) {
// write to db, replicate, audit — no reconstruction needed
}
}
struct ToolAuditor;
impl LoopObserver for ToolAuditor {
fn handle_event(&mut self, event: AgentEvent) {
if let AgentEvent::ToolResultReceived(r) = event {
tracing::info!(call_id = %r.call_id, is_error = r.is_error, "tool result");
}
}
}
let agent = Agent::builder()
.model(adapter)
.observer(ToolAuditor)
.transcript_observer(PersistEverything)
.build()?;Transcript observers do not fire for compaction-driven rewrites — those signal via AgentEvent::CompactionFinished instead.
Background tool results delivered as notifications
Background tool execution already existed: when a tool detaches, a synthetic ToolResult is pushed so the model isn't left with an unmatched tool_use. The problem was what happened when the real result arrived later — it was appended as a second ToolResultPart against the same call_id, which Anthropic and OpenRouter reject as an "orphaned tool_result" schema violation.
This release introduces ItemKind::Notification (and Item::notification(text)). When a tool-result item references a call_id that was already paired with a synthetic detach result, the loop converts it to a Notification item before appending. Adapters render Notification items as a user-role message wrapped in <system-reminder>, so the late-arriving result reaches the model on the next turn without violating the tool_use/tool_result pairing.
Observers still see AgentEvent::ToolResultReceived for both the placeholder and the late result, so spinners and task trackers close cleanly.
// From inside the loop's append path:
// Background tool call call-7 completed: <output>
// is wrapped in <system-reminder> by the completions adapter and arrives
// at the model as a user-role message on the next turn.Other DX
Agent builder transcript ↔ input
Round-trip prior conversation state through the builder cleanly:
let agent = Agent::builder()
.model(adapter)
.transcript(saved_history) // preloaded, observers don't fire
.input(vec![Item::text(ItemKind::User, prompt)]) // next user turn
.build()?;.transcript(...) items skip TranscriptObserver::on_item_appended (the host already knows about anything it preloads). .input(...) items move to the transcript on dispatch and do fire observers. When input is preloaded, the first next() call dispatches the model directly instead of yielding AwaitingInput.
Window-based compaction example
examples/openrouter-context-window-compaction shows a CompactionTrigger that fires off provider-reported input_tokens against the model's real context window:
use agentkit_compaction::{
CompactionConfig, CompactionPipeline, DropFailedToolResultsStrategy, DropReasoningStrategy,
SummarizeOlderStrategy,
};
let strategy = CompactionPipeline::new()
.with_strategy(DropReasoningStrategy::new())
.with_strategy(DropFailedToolResultsStrategy::new())
.with_strategy(SummarizeOlderStrategy::new(1));
let agent = Agent::builder()
.model(adapter)
.compaction(
CompactionConfig::new(trigger.clone(), strategy)
.with_backend(NestedLoopCompactionBackend { adapter: adapter.clone() }),
)
.observer(trigger) // also a LoopObserver — feeds itself UsageUpdated
.build()?;The trigger is both a CompactionTrigger (reads last_input_tokens) and a LoopObserver (writes last_input_tokens from AgentEvent::UsageUpdated). The model's context_length is fetched from OpenRouter's /api/v1/models/{model}/endpoints route at startup, so the threshold tracks the pinned model rather than a hardcoded value.
Fixes
fix: allow removing mcps—McpManagernow supports clean removal of registered serversfix: resolve canonical path for perm check— symlinked workspace roots no longer bypass permission checks- Provider fixes across the adapter stack
Migration notes
ItemKindgains aNotificationvariant. Any exhaustivematchonItemKindin downstream code needs a new arm.- Replace
McpToolDescriptor/McpResourceDescriptor/McpPromptDescriptorfield access with the rmcp types (aliases retained where possible). - Remove
legacy-sse/reqwest-clientfeature flags and anyMcpTransportBinding::Sse/Customusage; switch to stdio or Streamable HTTP, or supply aRunningServicedirectly viaMcpConnection::from_running_service{,_with_events}. - Tools that read MCP
MetadataMapfor annotations / output schema / prompt arguments should read the typed fields instead.
Docs
README, agentkit-mcp/README, book/src/ch13-custom-tools.md, book/src/ch17-mcp.md, docs/mcp.md refreshed for the new surface.
Full Changelog: v0.4.0...v0.5.0