Skip to content

v0.7.0 — typed MCP errors + bounded tool output

Choose a tag to compare

@danielkov danielkov released this 22 May 16:51
· 59 commits to main since this release

v0.7.0 — typed MCP errors + bounded tool output

Two host-facing capabilities land in this release: hosts can now inspect typed MCP invocation errors and synthesize tool results in response, and tools can advertise output budgets that the executor enforces centrally — with artifact-backed readback for oversized results. Plus a PR-checks workflow.

What's Changed

Typed MCP invocation errors + responder hook

McpError::Invocation now carries a typed McpInvocationError enum with one variant per JSON-RPC error code rmcp recognises (UrlElicitation, InvalidRequest, MethodNotFound, InvalidParams, InternalError, ParseError, ResourceNotFound) plus an Other { code, message, data } forward-compat arm. For URL elicitation the data payload is best-effort parsed into the new UrlElicitationData struct; the raw serde_json::Value is always preserved alongside.

Hosts can hook into the invocation path with McpErrorResponder and decide per-call whether to pass the error through or synthesize a successful CallToolResult for the agent:

use std::sync::Arc;

use agentkit_mcp::{
    ErrorResponderOutcome, McpErrorContext, McpErrorResponder, McpHandlerConfig,
    McpInvocationError,
};
use async_trait::async_trait;
use rmcp::model::{CallToolResult, Content};

struct UrlElicitationResponder;

#[async_trait]
impl McpErrorResponder for UrlElicitationResponder {
    async fn handle(
        &self,
        error: &McpInvocationError,
        _ctx: McpErrorContext<'_>,
    ) -> ErrorResponderOutcome {
        let McpInvocationError::UrlElicitation { data: Some(data), .. } = error else {
            return ErrorResponderOutcome::PassThrough;
        };

        ErrorResponderOutcome::SynthesizeResult(CallToolResult::success(vec![
            Content::text(format!("Open this URL to continue: {}", data.url)),
        ]))
    }
}

let handler_config = McpHandlerConfig::new()
    .with_error_responder(Arc::new(UrlElicitationResponder));

This is the recommended way to surface authorization URLs, server-side rate-limit notices, or any other "the call failed but the model should see something useful" payload.

Bounded tool output with artifact-backed readback

Long-running tools can now blow the model's context budget without poisoning the transcript. Two pieces fit together:

1. Tools advertise a budget through ToolSpec::with_output_limit:

use agentkit_tools_core::{ToolOutputLimit, ToolSpec};

let spec = my_spec.with_output_limit(ToolOutputLimit::store_for_readback(150_000));

ToolOutputLimit has three modes:

  • fail(max_bytes) — return an execution error rather than place an oversized result in the transcript. Use for readback tools themselves, where silent truncation would reintroduce the unbounded loop.
  • inline_clip(max_bytes) — clip with an explicit truncation marker.
  • store_for_readback(max_bytes) — store the full output in an artifact store and replace the model-facing result with a small pointer envelope.

2. The executor enforces it via ConfigurableToolOutputTruncationStrategy, registered once and applied uniformly to native tools, MCP tools, and any custom ToolSource:

use std::sync::Arc;

use agentkit_tools_core::{
    BasicToolExecutor, ConfigurableToolOutputTruncationStrategy,
    InMemoryToolOutputArtifactStore, ToolOutputLimit, ToolRegistry,
    tool_result_readback_registry,
};

let store = Arc::new(InMemoryToolOutputArtifactStore::new());

let registry = ToolRegistry::new()
    .with(my_large_output_tool.with_output_limit(
        ToolOutputLimit::store_for_readback(150_000),
    ))
    .merge(tool_result_readback_registry(store.clone(), 150_000));

let executor = BasicToolExecutor::from_registry(registry)
    .with_output_truncation_strategy(
        ConfigurableToolOutputTruncationStrategy::new(store),
    );

Oversized outputs are stored as ToolOutputArtifact { id, tool_name, call_id, session_id, turn_id, original_bytes, body }. The model reads them back through the tool_result_read tool (registered via tool_result_readback_registry), which returns bounded ToolOutputArtifactSlice { offset, next_offset, original_bytes, eof, content } chunks.

For hosts that need readback to survive across processes, agentkit-tool-fs ships FileToolOutputArtifactStore:

use std::sync::Arc;

use agentkit_tool_fs::FileToolOutputArtifactStore;
use agentkit_tools_core::{
    ConfigurableToolOutputTruncationStrategy, tool_result_readback_registry,
};

let store = Arc::new(FileToolOutputArtifactStore::new(".agentkit/tool-results"));

let readback_registry = tool_result_readback_registry(store.clone(), 150_000);
let truncation = ConfigurableToolOutputTruncationStrategy::new(store);

Filesystem tools (fs_read, fs_search, etc.) advertise readback-friendly limits by default, so symlink-resolved searches that hit a giant tree no longer wreck the transcript.

PR checks workflow

.github/workflows/pr-checks.yml runs cargo fmt --check, cargo clippy -- -D warnings, and cargo test --workspace against pull requests. No host-facing change, but it locks the bar for incoming contributions.

Migration notes

  • ItemKind is unchanged since 0.5 — no transcript-shape break here.
  • Tools that previously implemented ad-hoc truncation can hand the responsibility to ConfigurableToolOutputTruncationStrategy and remove the per-tool clipping code.
  • Hosts that match on McpError::Invocation payloads need to switch from string parsing to the new McpInvocationError variants. The Other { code, message, data } arm keeps unknown server codes accessible.
  • McpHandlerConfig gains with_error_responder(Arc<dyn McpErrorResponder>); existing handler configs compile unchanged.

Docs

README.md, the mdBook (installation, feature flags, MCP chapter), and per-crate READMEs bump to 0.7.

Commits

  • chore: re-order publish script crates (0babd82)
  • feat: surface MCP invocation errors and bound tool output (#5) (bca24e5)
  • chore: release 0.7.0 (c2bd3d9)

Full Changelog: v0.6.0...v0.7.0