pmcp-macros 0.5.0

Procedural macros for PMCP SDK - Model Context Protocol
Documentation

pmcp-macros

Procedural macros that eliminate boilerplate for MCP tools, prompts, resources, and server routers. Shipped as part of pmcp via the macros feature flag. All four macros plug into pmcp's compile-time schema generation, State<T> injection, and handler registry, so your code focuses on behavior instead of protocol plumbing.

Installation

Add pmcp with the macros feature. This pulls pmcp-macros transitively — you do not need to add it as a direct dependency.

[dependencies]
pmcp = { version = "2.3", features = ["macros"] }
schemars = "1.0"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.46", features = ["full"] }

Overview

pmcp-macros provides four attribute macros:

Macro Applied to Purpose
#[mcp_tool] async fn or fn Define a tool handler with a typed arg struct and compile-time schema
#[mcp_server] impl block Collect tools and prompts on a type into a single registerable McpServer
#[mcp_prompt] async fn or fn Define a prompt template with typed arguments and auto-generated argument schema
#[mcp_resource] async fn or fn Define a resource handler with URI template matching and parameter extraction

Each macro generates the glue code and schema wiring that MCP servers need. Schemas come from schemars::JsonSchema derives on your argument and result types, so the wire protocol stays in sync with your Rust types automatically.

#[mcp_tool]

Purpose

#[mcp_tool] turns a plain async or sync function into a full ToolHandler with a compile-time JSON Schema derived from its argument struct. The macro eliminates the Box::pin(async move { ... }) boilerplate required by manual ToolHandler impls, enforces a description at compile time, and supports shared state via State<T>.

Attributes

  • description = "..."required. Human-readable description exposed via the MCP tools/list response.
  • name = "..." — optional. Defaults to the function name.
  • annotations(...) — optional. MCP standard annotations: read_only = bool, destructive = bool, idempotent = bool, open_world = bool.
  • ui = "..." — optional. Widget resource URI for MCP Apps integrations.

Example

use pmcp::{mcp_tool, ServerBuilder, ServerCapabilities};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, JsonSchema)]
struct AddArgs {
    /// First addend
    a: f64,
    /// Second addend
    b: f64,
}

#[derive(Debug, Serialize, JsonSchema)]
struct AddResult {
    /// The sum of `a` and `b`
    sum: f64,
}

#[mcp_tool(description = "Add two numbers")]
async fn add(args: AddArgs) -> pmcp::Result<AddResult> {
    Ok(AddResult { sum: args.a + args.b })
}

#[tokio::main]
async fn main() -> pmcp::Result<()> {
    let _server = ServerBuilder::new()
        .name("calculator")
        .version("1.0.0")
        .capabilities(ServerCapabilities::tools_only())
        .tool("add", add())
        .build()?;
    Ok(())
}

The macro expands the annotated function into a zero-arg constructor (add()) that returns a generated AddTool struct implementing ToolHandler. Register it with ServerBuilder::tool(name, handler).

Shared state with State<T>

Tools that need access to shared data (a database handle, a config struct, a client pool) can take a State<T> parameter. The macro detects it automatically and the builder wires it in at registration time via .with_state(...). No Arc::clone, no move captures — just declare the dependency in the function signature.

use pmcp::{mcp_tool, ServerBuilder, ServerCapabilities, State};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;

struct AppConfig {
    greeting_prefix: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct GreetArgs {
    /// Name to greet
    name: String,
}

#[mcp_tool(description = "Greet with prefix from config")]
async fn greet(args: GreetArgs, config: State<AppConfig>) -> pmcp::Result<Value> {
    Ok(json!({
        "greeting": format!("{}, {}!", config.greeting_prefix, args.name),
    }))
}

#[tokio::main]
async fn main() -> pmcp::Result<()> {
    let config = Arc::new(AppConfig {
        greeting_prefix: "Hello".into(),
    });

    let _server = ServerBuilder::new()
        .name("greeter")
        .version("1.0.0")
        .capabilities(ServerCapabilities::tools_only())
        .tool("greet", greet().with_state(config))
        .build()?;
    Ok(())
}

Sync tools work the same way — declare a plain fn instead of async fn. The macro auto-detects and wraps it accordingly.

Full runnable example

See the complete showcase covering standalone tools, state injection, annotations, and impl-block tools: https://github.com/paiml/pmcp/blob/main/examples/s23_mcp_tool_macro.rs

#[mcp_server]

Purpose

#[mcp_server] promotes an impl block into a server bundle. Every method annotated with #[mcp_tool] or #[mcp_prompt] inside the block is collected, ToolHandler/PromptHandler structs are generated for each, and an impl McpServer for YourType is added so ServerBuilder::mcp_server(...) can register them all at once. A single Arc<YourType> is shared across every handler, so &self state is accessible for free.

Example

use pmcp::{mcp_server, mcp_tool, ServerBuilder, ServerCapabilities};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

struct Calculator;

#[derive(Debug, Deserialize, JsonSchema)]
struct AddArgs { a: f64, b: f64 }

#[derive(Debug, Serialize, JsonSchema)]
struct AddResult { sum: f64 }

#[mcp_server]
impl Calculator {
    #[mcp_tool(description = "Add two numbers")]
    async fn add(&self, args: AddArgs) -> pmcp::Result<AddResult> {
        Ok(AddResult { sum: args.a + args.b })
    }

    #[mcp_tool(description = "Health check", annotations(read_only = true))]
    async fn health(&self) -> pmcp::Result<Value> {
        Ok(json!({ "status": "ok" }))
    }
}

#[tokio::main]
async fn main() -> pmcp::Result<()> {
    let calculator = Calculator;
    let _server = ServerBuilder::new()
        .name("calculator")
        .version("1.0.0")
        .capabilities(ServerCapabilities::tools_only())
        .mcp_server(calculator)
        .build()?;
    Ok(())
}

Mix #[mcp_tool] and #[mcp_prompt] freely within the same impl block — the macro routes each method to the appropriate handler type. Use standalone .tool(...) / .prompt(...) calls alongside .mcp_server(...) for one-off handlers that do not belong to the type.

Full runnable example

examples/s23_mcp_tool_macro.rs also demonstrates #[mcp_server] alongside standalone #[mcp_tool] registrations: https://github.com/paiml/pmcp/blob/main/examples/s23_mcp_tool_macro.rs

#[mcp_prompt]

Purpose

#[mcp_prompt] defines a prompt template without the HashMap::get("x").ok_or()?.parse()? boilerplate of hand-rolled PromptHandler implementations. Arguments are derived from a struct that implements JsonSchema, so the argument descriptions surface in the MCP prompts/list response without duplication. MCP sends prompt arguments as strings, and the SDK coerces them into numeric, boolean, or optional types automatically based on the struct definition.

Attributes

  • description = "..."required. Human-readable description.
  • name = "..." — optional. Defaults to the function name.

Example

use pmcp::types::{Content, GetPromptResult, PromptMessage};
use pmcp::{mcp_prompt, ServerBuilder, ServerCapabilities};
use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Debug, Deserialize, JsonSchema)]
struct ReviewArgs {
    /// The programming language to review
    language: String,
    /// The code snippet to review
    code: String,
}

#[mcp_prompt(description = "Review code for quality issues")]
async fn code_review(args: ReviewArgs) -> pmcp::Result<GetPromptResult> {
    Ok(GetPromptResult::new(
        vec![PromptMessage::user(Content::text(format!(
            "Please review this {} code for quality issues:\n\n```{}\n{}\n```",
            args.language, args.language, args.code,
        )))],
        Some("Code Review".to_string()),
    ))
}

#[tokio::main]
async fn main() -> pmcp::Result<()> {
    let _server = ServerBuilder::new()
        .name("review-server")
        .version("1.0.0")
        .capabilities(ServerCapabilities::default())
        .prompt("code_review", code_review())
        .build()?;
    Ok(())
}

Like tools, prompts support State<T> injection — just add a State<YourType> parameter to the function signature and wire it at registration with code_review().with_state(shared). Optional prompt arguments use Option<T> on the struct field; the SDK fills in absent values with None automatically.

Full runnable example

https://github.com/paiml/pmcp/blob/main/examples/s24_mcp_prompt_macro.rs

#[mcp_resource]

Note: #[mcp_resource] is currently imported directly from pmcp_macros rather than re-exported via pmcp. A future pmcp release will add it to the re-export alongside the other three macros, at which point the import can become use pmcp::mcp_resource;. Everything else about the macro is the same.

Purpose

#[mcp_resource] defines a resource handler whose URI can carry template variables. The macro generates a struct implementing DynamicResourceProvider, so at request time an incoming URI is pattern-matched against the template and any captured segments are handed to the function as typed parameters. No hand-rolled URI parsing, no string indexing.

Attributes

  • uri = "..."required. URI or URI template. Templates use RFC 6570-style {variable_name} placeholders (for example docs://{topic}).
  • description = "..."required. Human-readable description.
  • name = "..." — optional. Defaults to the function name.
  • mime_type = "..." — optional. Defaults to "text/plain".

URI template variables

The uri attribute accepts a URI template such as docs://{topic} or data://articles/{topic}. Every {variable_name} placeholder inside the template is automatically extracted at request time and passed to the decorated function as a parameter of type String. The function parameter name must match the template variable name exactly — the macro uses the name to route captured substrings into the correct argument slot. You never have to parse the URI yourself.

For example, a resource declared as:

#[mcp_resource(uri = "docs://{topic}", description = "Documentation pages")]
async fn read_doc(topic: String) -> pmcp::Result<String> { ... }

will receive the topic segment of a request URI like docs://rust-macros bound directly to the topic: String parameter. Multiple placeholders are supported — declare one String parameter per {variable_name} in the template.

Example

// Note: direct import until the `mcp_resource` re-export gap is closed.
use pmcp_macros::mcp_resource;

#[mcp_resource(uri = "docs://{topic}", description = "Documentation pages")]
async fn read_doc(topic: String) -> pmcp::Result<String> {
    Ok(format!("# {topic}\n\nDocumentation content for `{topic}`."))
}

fn main() {
    // `read_doc()` is a zero-arg constructor that returns a resource provider.
    // Register it with `ResourceCollection::new().add_dynamic_provider(Arc::new(read_doc()))`
    // and hand the collection to the server via `.resources(collection)`.
    let _provider = read_doc();
}

Resources also support State<T> injection the same way tools and prompts do — declare db: State<MyDatabase> alongside the template-variable parameters and the SDK wires the shared state in at registration time.

Feature flags

The macros feature flag on pmcp exists so users who do not need proc-macro machinery can ship with a smaller compile surface. If you want any of the four macros, enable macros. There is no reason to add pmcp-macros as a direct dependency — always route through pmcp's feature flag.

License

MIT — see https://github.com/paiml/pmcp/blob/main/LICENSE