8 releases

Uses new Rust 2024

0.0.3-rc.1 Mar 8, 2026
0.0.2-rc.3 Mar 6, 2026
0.0.2-rc.2 Mar 2, 2026
0.0.1-rc.5 Feb 28, 2026
0.0.1-rc.2 Feb 27, 2026

#2234 in Database interfaces

MIT license

195KB
3.5K SLoC

clap-mcp

Enrich your Rust CLI with MCP Capabilities

crates.io docs.rs

Usage

You can take a look at the examples, but this is a VERY early draft. See examples/README.md for detailed instructions on running them.

Development

Contributors should follow these conventions:

  • Format code with cargo fmt. CI runs cargo fmt --all -- --check.
  • Run cargo clippy --all-targets --all-features -- -D warnings before submitting; CI enforces this.
  • Document public API items and add a // SAFETY: comment above any unsafe block explaining invariants.

Run all tests (including feature-gated logging tests):

cargo test --all-features

Code coverage

Coverage is measured with cargo-llvm-cov. Install and run:

cargo install cargo-llvm-cov
cargo llvm-cov test --workspace --all-features --summary-only

For an HTML report (opens in browser):

cargo llvm-cov test --workspace --all-features --html

Coverage focuses on the clap-mcp and clap-mcp-macros crates; the examples crate is excluded from coverage targets.

Release prep includes building and running all example binaries (CI runs each with --help as a smoke test); see examples/README.md.

Design

Compared to a Command Line Interface, I'm not a huge fan of the Model Context Protocol, but my feelings don't represent real world usage patterns. I feel MCP would do better with gRPC and Protobuf as it's "transport." All that being said, I'm not bitter about it, so I'm just letting a model do the development work and deal with it's own self-generated mess.

The intent is generally:

  • Make it easy to add a MCP server to current Rust CLIs that use clap.
  • Have it work well enough and provide enough guardrails to cover the 95% case.
  • If there is structured information available from the CLI as an outcome, we should provide a way to express it naturally via MCP.
  • Provide a way to express structured logging information (if available) as part of the response if requested.

Overall, the more you design your CLI around a service pattern, the more naturally this crate will behave as an MCP server, and modern CLIs often do that. At the same time, we shouldn't force CLIs that don't do that, out of the ecosystem.

Quick start

Add clap-mcp to your Cargo.toml (the default derive feature includes the macro):

[dependencies]
clap-mcp = "0.0.3-rc.1"

For derive usage, use clap_mcp::ClapMcp so you can write #[derive(ClapMcp)].

Imperative (existing clap CLI)

If you already have a clap::Command-based CLI, you can add MCP support in one line. When --mcp is not passed, your CLI works exactly as before:

use clap::Command;

fn main() {
    let cmd = Command::new("myapp")
        .subcommand(Command::new("hello").about("Say hello"));

    let matches = clap_mcp::get_matches_or_serve_mcp(cmd);
    // If we reach here, --mcp was not passed — normal CLI execution continues.
}

Derive (minimal)

With #[derive(ClapMcp)], each subcommand is automatically exposed as an MCP tool. This uses default config (subprocess execution, serialized tool calls):

use clap::Parser;
use clap_mcp::ClapMcp;

#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp_output_from = "run"]
#[command(name = "myapp")]
enum Cli {
    /// Say hello.
    Greet {
        #[arg(long)]
        name: Option<String>,
    },
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Greet { name } => format!("Hello, {}!", name.as_deref().unwrap_or("world")),
    }
}

fn main() {
    let cli = clap_mcp::parse_or_serve_mcp_attr::<Cli>();
    println!("{}", run(cli));
}

Use #[clap_mcp(...)] to declare execution safety, and parse_or_serve_mcp_attr to pick up that config automatically:

use clap::Parser;
use clap_mcp::ClapMcp;

#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe, parallel_safe = false)]
#[clap_mcp_output_from = "run"]
#[command(name = "myapp")]
enum Cli {
    Add { a: i32, b: i32 },
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Add { a, b } => (a + b).to_string(),
    }
}

fn main() {
    let cli = clap_mcp::parse_or_serve_mcp_attr::<Cli>();
    println!("{}", run(cli));
}

Struct root with subcommand

When your CLI has a struct root with #[command(subcommand)] and an enum of commands, derive ClapMcp on both the root struct and the subcommand enum. Put #[clap_mcp_output_from = "run"] and execution config (#[clap_mcp(...)]) on the subcommand enum. In main, parse the root then call your run logic on the subcommand (e.g. run(cli.command) or match cli.command { ... }).

You can use either subcommand_required = false with command: Option<Commands> (so myapp with no subcommand is valid) or keep subcommand_required = true; in both cases myapp --mcp is valid and starts the MCP server (clap-mcp checks for --mcp before calling clap, so a required subcommand is not demanded when only --mcp is passed).

use clap::{Parser, Subcommand};
use clap_mcp::ClapMcp;

#[derive(Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe, parallel_safe = false)]
#[command(subcommand_required = false)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand, ClapMcp)]
#[clap_mcp_output_from = "run"]
enum Commands {
    Greet { #[arg(long)] name: Option<String> },
    Add { #[arg(long)] a: i32, #[arg(long)] b: i32 },
}

fn run(cmd: Commands) -> String {
    match cmd {
        Commands::Greet { name } => format!("Hello, {}!", name.as_deref().unwrap_or("world")),
        Commands::Add { a, b } => format!("{}", a + b),
    }
}

fn main() {
    let cli = clap_mcp::parse_or_serve_mcp_attr::<Cli>();
    match cli.command {
        None => println!("No subcommand"),
        Some(cmd) => println!("{}", run(cmd)),
    }
}

See Dual derive (root + subcommand) below and the struct_subcommand example in examples/README.md.

Feature flags

Flag Enables
tracing ClapMcpTracingLayer — a tracing_subscriber::Layer that forwards tracing events to MCP clients via notifications/message.
log ClapMcpLogBridge — a log::Log implementation that forwards log crate messages to MCP clients.
output-schema schemars-based JSON schema generation for structured tool output. Enables output_schema_for_type, output_schema_one_of!, and #[clap_mcp_output_type] / #[clap_mcp_output_one_of] to set each tool's output_schema for MCP clients.

Enable features in Cargo.toml:

[dependencies]
clap-mcp = { version = "0.0.3-rc.1", features = ["tracing"] }

Custom resources and prompts

In addition to the built-in clap://schema resource and the optional logging guide prompt, you can expose custom MCP resources and prompts. Add them to ClapMcpServeOptions and pass that into parse_or_serve_mcp_with_config_and_options or serve_schema_json_over_stdio_blocking.

Custom resources

Set custom_resources to a list of CustomResource values. Each has:

  • Identity: uri, name, optional title, description, mime_type. Use a stable URI (e.g. myapp://config) so clients can list and read.
  • Content: Either static (ResourceContent::Static(String)) or dynamic (ResourceContent::Dynamic(Arc<dyn ResourceContentProvider>)). Dynamic content uses the async ResourceContentProvider::read so the handler can await it.

Example (static):

use clap_mcp::content::{CustomResource, ResourceContent};

let mut opts = clap_mcp::ClapMcpServeOptions::default();
opts.custom_resources.push(CustomResource {
    uri: "myapp://readme".into(),
    name: "readme".into(),
    title: Some("Readme".into()),
    description: Some("Project readme".into()),
    mime_type: Some("text/markdown".into()),
    content: ResourceContent::Static("# Hello\n".into()),
});

For dynamic content, implement ResourceContentProvider (async read(uri)).

Custom prompts

Set custom_prompts to a list of CustomPrompt values. Each has:

  • Identity: name, optional title, description, optional arguments (MCP prompt argument descriptors).
  • Content: Either static (PromptContent::Static(Vec<PromptMessage>)) or dynamic (PromptContent::Dynamic(Arc<dyn PromptContentProvider>)). Dynamic uses the async PromptContentProvider::get.

The built-in clap-mcp-logging-guide prompt is only listed when logging is enabled (serve_options.log_rx.is_some()). Custom prompts are always merged into the list.

URI and name conventions

Prefer a stable prefix (e.g. myapp://) for custom resource URIs so they don’t clash with the built-in clap://schema. Prompt names must be unique; avoid clap-mcp-logging-guide for custom prompts.

Exporting agent skills

You can generate Agent Skills (SKILL.md) from the same tools, resources, and prompts that the MCP server exposes. This is useful for documenting your CLI for AI agents.

The --export-skills flag

Add the flag with command_with_export_skills_flag or use command_with_mcp_and_export_skills_flags to add both --mcp and --export-skills:

  • --export-skills — Generate skills into the default directory (see below) and exit.
  • --export-skills=DIR — Generate skills into DIR (e.g. --export-skills=./out) and exit.

When both --mcp and --export-skills are present, --export-skills wins: the process exports and exits without starting the MCP server.

Default output directory

Default directory is .agents/skills/, where each skill gets a subdirectory named after the app or tool. Override with --export-skills=DIR.

What gets generated

  • One skill per tool (from your clap schema), with name/description and usage hints.
  • A combined resources-and-prompts skill when you have custom resources or prompts.

Generated files follow the Agent Skills specification (YAML frontmatter with name, description, and allowed-tools; markdown body with usage instructions). The name field matches the parent directory name as required by the spec. Each tool skill includes allowed-tools listing the MCP tool it describes; note that this field is still experimental in the spec with no defined syntax convention. You can also call content::export_skills programmatically with schema, tools, custom resources, and custom prompts.

Execution safety configuration

CLIs differ in how safely they can be invoked over MCP. Two flags control this:

  • reinvocation_safe (default: false): Controls whether tool calls spawn a fresh subprocess of your binary (false) or run in-process via ClapMcpToolExecutor (true). The name refers to whether the CLI's internal state can survive repeated invocations without a process restart. Most CLIs that don't hold mutable global state can set this to true.

  • parallel_safe (default: false): Controls whether tool calls are serialized behind a tokio Mutex (false) or dispatched concurrently (true). Set to true only if your CLI logic is safe to run concurrently.

  • share_runtime (default: false): When reinvocation_safe is true, controls how async tool execution runs. See Async tools and share_runtime below.

Use #[derive(ClapMcp)] and #[clap_mcp(...)] on your CLI type:

use clap::Parser;
use clap_mcp::ClapMcp;

#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe, parallel_safe = false)]
#[clap_mcp_output_from = "run"]
#[command(...)]
enum Cli {
    Add { a: i32, b: i32 },
    // ...
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Add { a, b } => (a + b).to_string(),
        // ...
    }
}

let cli = clap_mcp::parse_or_serve_mcp_attr::<Cli>();

Schema metadata: skip and requires

Use #[clap_mcp(skip)] to exclude subcommands or arguments from MCP exposure. Use #[clap_mcp(requires)] or #[clap_mcp(requires = "arg_name")] to make an optional argument required in the MCP tool schema (useful for positional args that may trigger stdin behavior when omitted). When the client omits a required arg, a clear error is returned.

For optional positional arguments that might read from stdin when omitted, prefer an explicit #[clap_mcp(requires)] or #[clap_mcp(skip)] so MCP behavior is intentional.

Argument-level (on each field):

#[derive(Parser, ClapMcp)]
#[clap_mcp_output_from = "run"]
enum Cli {
    Read {
        #[clap_mcp(requires)]  // MCP schema makes path required
        #[arg(long)]
        path: Option<String>,
    },
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Read { path } => path.unwrap_or_default(),
    }
}

Variant-level (one or more args; use a single name or comma-separated list — the MCP schema marks each as required):

#[derive(Parser, ClapMcp)]
#[clap_mcp_output_from = "run"]
enum Cli {
    // Single optional positional made required in MCP
    #[clap_mcp(requires = "versions")]
    Sort { versions: Option<String> },

    // Multiple optional args
    #[clap_mcp(requires = "path, input")]  // both become required in MCP
    Process {
        #[arg(long)] path: Option<String>,
        #[arg(long)] input: Option<String>,
    },
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Sort { versions } => format!("{versions:?}"),
        Cli::Process { path, input } => format!("{:?}", (path, input)),
    }
}

Skip: (subcommands or variant-level) use #[clap_mcp(skip)] so a variant is hidden from MCP; pair with #[clap_mcp_output_from = "run"] and a single run for the exposed variants.

#[derive(Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe, parallel_safe = false)]
#[clap_mcp_output_from = "run"]
enum Cli {
    Public,
    #[clap_mcp(skip)]
    Internal,
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Public => "ok".to_string(),
        Cli::Internal => "hidden".to_string(),
    }
}

You can also use #[clap_mcp(skip)] on root struct fields so options like output format are hidden from MCP (they remain available to the CLI):

#[derive(Parser, ClapMcp)]
#[command(name = "myapp")]
struct Args {
    #[clap_mcp(skip)]
    #[arg(long)]
    out: Option<String>,
    #[command(subcommand)]
    command: Option<Commands>,
}

Imperative: Use schema_from_command_with_metadata and get_matches_or_serve_mcp_with_config_and_metadata with ClapMcpSchemaMetadata:

let mut metadata = ClapMcpSchemaMetadata::default();
metadata.skip_commands.push("internal".into());
metadata.requires_args.insert("read".into(), vec!["path".into()]);
let schema = schema_from_command_with_metadata(&cmd, &metadata);

When the client omits a required argument, the tool returns a clear error: "Missing required argument(s): path. The MCP tool schema marks these as required."

Dual derive (root + subcommand)

When you use a struct root with #[command(subcommand)] (e.g. command: Option<Commands>), derive ClapMcp on both the root struct and the subcommand enum. Put #[clap_mcp_output_from = "run"] and execution config (#[clap_mcp(...)]) on the subcommand enum only. The root's derive provides schema metadata and delegates tool execution to the subcommand's executor. In main, parse the root with parse_or_serve_mcp_attr::<Root>() then run with run(cli.command) or match cli.command { ... }. You can keep subcommand_required = true if you want; myapp --mcp alone is valid and starts the MCP server (clap-mcp handles --mcp before clap's subcommand check). See Struct root with subcommand and the struct_subcommand example in examples/README.md.

MCP tool list: The tool list includes the root command and all subcommands. If your CLI has subcommand_required = true, the root command still appears as a tool but has no subcommand in the MCP invocation model and is rarely used by clients; the meaningful tools are the subcommands (e.g. explain, compare, sort). To exclude the root from the tool list when it has subcommands, set ClapMcpSchemaMetadata::skip_root_command_when_subcommands to true via the derive with #[clap_mcp(skip_root_when_subcommands)] on the root struct, or imperatively (e.g. implement ClapMcpSchemaMetadataProvider for the root and set the field, or build metadata manually).

Runtime config

Use ClapMcpConfig with parse_or_serve_mcp_with_config or get_matches_or_serve_mcp_with_config:

clap_mcp::parse_or_serve_mcp_with_config::<Cli>(clap_mcp::ClapMcpConfig {
    reinvocation_safe: true,   // in-process execution
    parallel_safe: false,      // serialize tool calls (default)
    ..Default::default()
})

Tools include meta.clapMcp with these hints for clients.

Crash and panic behavior

  • Subprocess (reinvocation_safe = false): If the tool process exits with a non-zero status, the server returns a tool result with is_error: true and a message that includes the exit code (and stderr when non-empty).
  • In-process (reinvocation_safe = true), catch_in_process_panics = false (default): Any panic in tool code (including from run_async_tool) crashes the server.
  • In-process, catch_in_process_panics = true: Panics are caught and returned as an MCP error; the server stays up. After a caught panic, the process may no longer be reinvocation_safe (global state may be corrupted) — consider restarting the server. See ClapMcpConfig::catch_in_process_panics and the panic_catch_opt_in and subprocess_exit_handling examples in examples/README.md.

Async tools and share_runtime

When your CLI has async subcommands (e.g. tokio::sleep, tokio::spawn), do async work inside your run function (e.g. call clap_mcp::run_async_tool or use a runtime handle). Set share_runtime in #[clap_mcp(...)] to share the MCP server's tokio runtime:

share_runtime Behavior When to use
false (default) Dedicated thread with its own tokio runtime per tool call. No nesting. Recommended. Use unless you need deep integration.
true Shares the MCP server's tokio runtime. Requires reinvocation_safe; uses multi-thread runtime. Advanced: share runtime state, spawn long-lived tasks, or integrate with other async code.

Non-shared (default): do async work inside your run function and call clap_mcp::run_async_tool from there:

fn run(cmd: Cli) -> AsStructured<SleepResult> {
    match cmd {
        Cli::SleepDemo => AsStructured(
            clap_mcp::run_async_tool(&Cli::clap_mcp_config(), run_sleep_demo).expect("async tool failed"),
        ),
    }
}

Shared runtime: same pattern; set share_runtime = true in #[clap_mcp(...)].

share_runtime only applies when reinvocation_safe is true. When tools run in subprocesses (reinvocation_safe = false), share_runtime is ignored.

Security

The MCP server does not trust the client for tool or argument discovery. Every tool call is validated against the schema before any execution (in-process or subprocess). Unknown tools and unknown argument names are rejected immediately with an error; execution proceeds only for schema-defined tools and arguments.

When reinvocation_safe is false (the default), each tool call spawns a fresh subprocess of your binary. Consider the following:

Shell injection is not a concern. Arguments are passed via std::process::Command::arg() directly to the executable as argv — no shell is invoked, so metacharacters (;, |, $(), etc.) are not interpreted.

Unknown tools and arguments are rejected. The server validates every tool name and argument name against the schema before execution. Invalid requests fail with CallToolError::unknown_tool or CallToolError::invalid_arguments; no subprocess is spawned and no in-process handler is invoked for invalid calls.

Argument values come from the MCP client. The schema constrains which argument names are accepted, but values are passed through unvalidated. If your CLI uses those values unsafely (e.g., in file paths, system calls, or other sensitive operations), a malicious or compromised MCP client could exploit that. Ensure your CLI validates and sanitizes all inputs.

Environment and working directory are inherited. The subprocess inherits the full environment and CWD of the MCP server. Sensitive env vars (API keys, tokens) are visible to every subprocess; relative paths resolve against the server's CWD.

Resource usage. Each tool call spawns a new process. With parallel_safe = true, many concurrent calls can create many processes. There are no timeouts or resource limits on subprocess execution.

Tool output attributes

When using #[derive(ClapMcp)], you control how each subcommand's output is returned to MCP clients. The idiomatic approach is a single output function (#[clap_mcp_output_from = "run"]): one run implements both CLI and MCP behavior, so you avoid duplicating logic. Per-variant attributes are available for edge cases but are not the default.

Put one function in charge of all tool output. The macro generates execute_for_mcp by calling run(self) and converting the return value. Use the same run in main so CLI and MCP share the same logic.

Supported return types for run:

  • String or &str → text output
  • AsStructured<T> where T: Serialize → structured JSON output
  • A type that implements IntoClapMcpResult (e.g. a custom enum for mixed text/structured)
  • Option<O>None becomes empty text; Some(o)o.into_tool_result()
  • Result<O, E>Ok(o) → output; Err(e) → MCP error. E must implement IntoClapMcpToolError (e.g. String, or your type for structured errors)

Result<AsStructured<T>, E> is fully supported when you want structured success payloads and a separate error type; IntoClapMcpResult is implemented for AsStructured<T: Serialize>.

Recommended pattern for CLIs with multiple subcommands: have run return Result<AsStructured<SubcommandResult>, ApplicationError> and use #[clap_mcp_output_from = "run"]. Implement IntoClapMcpToolError for your application error type and cover all error variants (e.g. InvalidArgument, validation errors, I/O errors) in that single impl so MCP error responses are consistent across tools.

For run() -> Result<O, E>, ensure E: IntoClapMcpToolError and the macro will convert the return value automatically.

Example:

use clap::Parser;
use clap_mcp::{ClapMcp, AsStructured};

#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe, parallel_safe = false)]
#[clap_mcp_output_from = "run"]
#[command(name = "myapp", subcommand_required = false)]
enum Cli {
    Greet { #[arg(long)] name: Option<String> },
    Add { a: i32, b: i32 },
}

fn run(cmd: Cli) -> String {
    match cmd {
        Cli::Greet { name } => format!("Hello, {}!", name.as_deref().unwrap_or("world")),
        Cli::Add { a, b } => format!("{}", a + b),
    }
}

fn main() {
    let cli = clap_mcp::parse_or_serve_mcp_attr::<Cli>();
    // Same logic: run(cli) for CLI, run(self) for MCP
    println!("{}", run(cli));
}

Tool output is defined only via #[clap_mcp_output_from = "run"] and a single run function; there are no per-variant output attributes. Use run(Cli) -> T where T implements IntoClapMcpResult (e.g. String, AsStructured<T>, Result<O, E>).

ClapMcpServeOptions::capture_stdout

When true and running in-process, captures stdout written during tool execution and merges it with Text output. Only has effect when reinvocation_safe = true (in-process execution). Unix only — the field is not present on Windows, so code that sets capture_stdout will not compile on Windows. Subprocess mode already captures stdout via Command::output().

Output schema (oneOf) for MCP tool discovery

With the output-schema feature enabled, you can attach a JSON schema to each tool's outputSchema field so MCP clients know the shape of the tool's output.

#[clap_mcp_output_type = "TypeName"]

Use when your tool output is a single type (e.g. an enum or struct). The type must implement schemars::JsonSchema. For enums, schemars typically produces a oneOf schema.

// Requires: features = ["output-schema"], and schemars + JsonSchema on the type
#[derive(Serialize, schemars::JsonSchema)]
struct SubcommandResult { result: String }

#[derive(Parser, ClapMcp)]
#[clap_mcp_output_from = "run"]
#[clap_mcp_output_type = "SubcommandResult"]
enum Cli { ... }

#[clap_mcp_output_one_of = "T1, T2, T3"]

Use when you want to list multiple types explicitly for a oneOf schema without defining a wrapper enum. Each type must implement schemars::JsonSchema.

#[derive(Serialize, schemars::JsonSchema)]
struct AddResult { sum: i32 }
#[derive(Serialize, schemars::JsonSchema)]
struct SubResult { difference: i32 }

#[derive(Parser, ClapMcp)]
#[clap_mcp_output_one_of = "AddResult, SubResult"]
enum Cli { ... }

When either attribute is set, ClapMcpSchemaMetadata::output_schema is populated (by the derive) and tools_from_schema_with_config_and_metadata attaches it to each tool. The high-level serve path (parse_or_serve_mcp_attr, etc.) uses metadata automatically, so tools get output_schema when you use the derive and these attributes.

Logging and observability

clap-mcp can forward application log messages to MCP clients as notifications/message. Two feature-gated paths are available depending on your logging ecosystem.

tracing feature

Enable with features = ["tracing"]. ClapMcpTracingLayer is a standard tracing_subscriber::Layer and composes with any other layers in your subscriber stack — fmt, tracing-opentelemetry, file appenders, etc. Adding it does not interfere with your existing tracing pipeline:

use clap_mcp::logging::{log_channel, ClapMcpTracingLayer};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

let (log_tx, log_rx) = log_channel(32);

tracing_subscriber::registry()
    .with(ClapMcpTracingLayer::new(log_tx))
    .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
    // .with(tracing_opentelemetry::layer().with_tracer(tracer))  // works alongside
    .init();

let mut opts = clap_mcp::ClapMcpServeOptions::default();
opts.log_rx = Some(log_rx);

Current limitations:

  • Only the message field of each tracing event is forwarded. Other structured fields (e.g. tracing::info!(count = 42, "done")count is dropped) are not yet included.
  • Span lifecycle events (on_new_span, on_enter, on_close) are not captured.

log feature

Enable with features = ["log"]. ClapMcpLogBridge implements log::Log and is installed as the global logger:

use clap_mcp::logging::{log_channel, ClapMcpLogBridge};

let (log_tx, log_rx) = log_channel(32);
let bridge = ClapMcpLogBridge::new(log_tx);
log::set_logger(Box::leak(Box::new(bridge))).unwrap();
log::set_max_level(log::LevelFilter::Info);

let mut opts = clap_mcp::ClapMcpServeOptions::default();
opts.log_rx = Some(log_rx);

Trade-off: The log crate supports exactly one global logger. Installing ClapMcpLogBridge replaces any existing logger (e.g. env_logger, simplelog). If you need to log to both disk and MCP simultaneously, you'll need a multiplexing wrapper — either a custom Log impl that fans out to multiple sinks, or a crate like multi_log.

Dependencies

~14–19MB
~238K SLoC