Skip to content

v0.5.0

Choose a tag to compare

@danielkov danielkov released this 03 May 22:23

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:

  1. Tool result items were appended to the transcript without firing any event.
  2. 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 matching ToolCallRequested via call_id.
  • TranscriptObserver fires once per Item appended 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 mcpsMcpManager now supports clean removal of registered servers
  • fix: resolve canonical path for perm check — symlinked workspace roots no longer bypass permission checks
  • Provider fixes across the adapter stack

Migration notes

  • ItemKind gains a Notification variant. Any exhaustive match on ItemKind in downstream code needs a new arm.
  • Replace McpToolDescriptor / McpResourceDescriptor / McpPromptDescriptor field access with the rmcp types (aliases retained where possible).
  • Remove legacy-sse / reqwest-client feature flags and any McpTransportBinding::Sse / Custom usage; switch to stdio or Streamable HTTP, or supply a RunningService directly via McpConnection::from_running_service{,_with_events}.
  • Tools that read MCP MetadataMap for 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