PMCP supports MCP's top-level outputSchema field on tools (per MCP spec 2025-06-18) to enable full type safety in server composition workflows. While MCP provides type-safe input schemas, tool outputs are typically serde_json::Value - losing type safety at composition boundaries.
PMCP provides:
outputSchema- Top-level JSON Schema onToolInfodescribing the tool's return type (MCP spec 2025-06-18)pmcp:outputTypeName- Annotation extension naming the code-generated output struct
When one MCP server calls another, you lose type information:
// Without output schemas - what shape does result have?
let result: Value = composition_client
.call_tool("sqlite-explorer", "query", json!({"sql": "SELECT * FROM orders"}))
.await?;
// Must guess or parse manually - error prone!
let rows = result["rows"].as_array().ok_or("expected rows")?;
let columns = result["columns"].as_array().ok_or("expected columns")?;With output schemas on ToolInfo, code generators produce typed clients:
// Generated from schema - full type safety!
#[derive(Debug, Deserialize)]
pub struct QueryResult {
pub columns: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
pub row_count: i64,
}
// Type-safe composition
let result: QueryResult = sqlite_client
.query(QueryArgs { sql: "SELECT * FROM orders".into(), ..Default::default() })
.await?;
// Compiler-checked field access
println!("Found {} rows", result.row_count);
for col in &result.columns {
println!("Column: {}", col);
}Set output schema as a top-level field on ToolInfo:
use pmcp::types::{ToolInfo, ToolAnnotations};
use serde_json::json;
let tool = ToolInfo::new(
"query",
Some("Execute SQL query and return results".into()),
json!({
"type": "object",
"properties": {
"sql": { "type": "string", "description": "SQL query to execute" },
"params": { "type": "array", "items": { "type": "string" } }
},
"required": ["sql"]
}),
)
.with_output_schema(json!({
"type": "object",
"properties": {
"columns": { "type": "array", "items": { "type": "string" } },
"rows": { "type": "array" },
"row_count": { "type": "integer" }
},
"required": ["columns", "rows", "row_count"]
}))
.with_annotations(
ToolAnnotations::new()
.with_read_only(true)
.with_output_type_name("QueryResult") // PMCP codegen extension
);For full automation, define both input and output types with schemars:
use pmcp::server::typed_tool::TypedToolWithOutput;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Input arguments for the query tool
#[derive(Debug, Deserialize, JsonSchema)]
pub struct QueryArgs {
/// SQL query to execute
pub sql: String,
/// Optional query parameters
#[serde(default)]
pub params: Vec<String>,
}
/// Query execution result
#[derive(Debug, Serialize, JsonSchema)]
pub struct QueryResult {
/// Column names from the result set
pub columns: Vec<String>,
/// Row data as arrays of values
pub rows: Vec<Vec<serde_json::Value>>,
/// Total number of rows returned
pub row_count: i64,
}
// Both input AND output schemas are generated automatically
let tool = TypedToolWithOutput::new("query", |args: QueryArgs, _extra| {
Box::pin(async move {
let result = execute_query(&args.sql, &args.params).await?;
Ok(QueryResult {
columns: result.columns,
rows: result.rows,
row_count: result.rows.len() as i64,
})
})
})
.with_description("Execute SQL query and return results");# Export all tool schemas including output schemas
cargo pmcp schema export --endpoint https://my-server.pmcp.run/mcp \
--output my-server-schema.jsonThe exported schema includes output schema at the top level per MCP spec 2025-06-18:
{
"version": "1.0",
"servers": [{
"id": "sqlite-explorer",
"tools": [{
"name": "query",
"description": "Execute SQL query",
"inputSchema": { ... },
"outputSchema": {
"type": "object",
"properties": {
"columns": { "type": "array", "items": { "type": "string" } },
"rows": { "type": "array" },
"row_count": { "type": "integer" }
}
},
"annotations": {
"readOnlyHint": true,
"pmcp:outputTypeName": "QueryResult"
}
}]
}]
}# Generate Rust client with full type safety
cargo pmcp generate --schema my-server-schema.json \
--output src/clients/sqlite_explorer.rs//! Auto-generated typed client for sqlite-explorer
//! Generated from my-server-schema.json
use pmcp_composition::{CompositionClient, CompositionError};
use serde::{Deserialize, Serialize};
// ============================================================================
// Input Types (from inputSchema)
// ============================================================================
/// Arguments for query tool
#[derive(Debug, Serialize)]
pub struct QueryArgs {
/// SQL query to execute
pub sql: String,
/// Optional query parameters
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<String>,
}
// ============================================================================
// Output Types (from top-level outputSchema)
// ============================================================================
/// Result from query tool
#[derive(Debug, Deserialize)]
pub struct QueryResult {
/// Column names from the result set
pub columns: Vec<String>,
/// Row data as arrays of values
pub rows: Vec<Vec<serde_json::Value>>,
/// Total number of rows returned
pub row_count: i64,
}
// ============================================================================
// Typed Client
// ============================================================================
/// Typed client for sqlite-explorer server
pub struct SqliteExplorerClient<'a> {
inner: &'a CompositionClient,
}
impl<'a> SqliteExplorerClient<'a> {
pub fn new(client: &'a CompositionClient) -> Self {
Self { inner: client }
}
/// Execute SQL query and return results
pub async fn query(&self, args: QueryArgs) -> Result<QueryResult, CompositionError> {
let result = self.inner
.call_tool("sqlite-explorer", "query", serde_json::to_value(args)?)
.await?;
Ok(serde_json::from_value(result)?)
}
}Per MCP spec 2025-06-18, outputSchema is a top-level field on the Tool object, as a sibling to inputSchema:
{
"name": "query",
"inputSchema": { ... },
"outputSchema": { ... },
"annotations": {
"readOnlyHint": true,
"pmcp:outputTypeName": "QueryResult"
}
}The pmcp:outputTypeName annotation remains in annotations as a PMCP codegen extension. It provides the struct name for code generation. Standard MCP clients ignore pmcp:* annotations per the spec:
"Clients SHOULD ignore annotations they don't understand." - MCP Specification
This follows the established pattern of vendor-prefixed extensions (like x-* in OpenAPI).
In addition to the top-level outputSchema and PMCP extensions, tools can use standard MCP annotations:
| Annotation | Type | Description |
|---|---|---|
title |
string | Human-readable title |
readOnlyHint |
boolean | Tool doesn't modify state |
destructiveHint |
boolean | Tool may perform destructive operations |
idempotentHint |
boolean | Multiple calls with same args have same effect |
openWorldHint |
boolean | Tool interacts with external systems |
Example with annotations and top-level output schema:
let tool = ToolInfo::new("query", Some("Execute SQL".into()), input_schema)
.with_output_schema(output_schema)
.with_annotations(
ToolAnnotations::new()
.with_read_only(true)
.with_output_type_name("QueryResult")
);Or directly in JSON:
{
"name": "delete_record",
"inputSchema": { ... },
"outputSchema": {
"type": "object",
"properties": { "deleted": { "type": "boolean" } }
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"pmcp:outputTypeName": "DeleteResult"
}
}If your server will be called by other servers, add output schemas:
// Good: Output schema enables type-safe composition
let tool = ToolInfo::new("query", Some("Execute SQL".into()), input_schema)
.with_output_schema(result_schema);The pmcp:outputTypeName becomes the generated struct name:
// Good: Clear, descriptive name
ToolAnnotations::new().with_output_type_name("OrderQueryResult")
// Bad: Generic name
ToolAnnotations::new().with_output_type_name("Result")Include descriptions in your JSON Schema:
{
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Number of records matched"
}
}
}These become doc comments in generated code.
Ensure your tool's return value matches the declared schema:
// Schema declares: { "count": integer, "items": array }
// Tool must return matching structure:
Ok(json!({
"count": items.len(),
"items": items
}))When using TypedToolWithOutput, the output schema is generated from your Rust type, guaranteeing they match:
TypedToolWithOutput::new("my_tool", |args: Input, _| {
Box::pin(async move {
Ok(Output { ... }) // Schema generated from Output type
})
})+--------------------------------------------------------------------+
| Type-Safe Composition Flow |
+--------------------------------------------------------------------+
| |
| 1. Define Tool with Output Schema |
| +-------------------------------------+ |
| | TypedToolWithOutput<Input, Output> | |
| | - Auto-generates inputSchema | |
| | - Auto-generates outputSchema | |
| | - outputSchema on ToolInfo | |
| | - outputTypeName in annotations | |
| +-------------------------------------+ |
| | |
| v |
| 2. Export Schema |
| +-------------------------------------+ |
| | cargo pmcp schema export | |
| | --endpoint https://server/mcp | |
| | --output schema.json | |
| +-------------------------------------+ |
| | |
| v |
| 3. Generate Typed Client |
| +-------------------------------------+ |
| | cargo pmcp generate | |
| | --schema schema.json | |
| | --output src/clients/server.rs | |
| | | |
| | Produces: | |
| | - InputArgs structs (from input) | |
| | - OutputResult structs (from output)| |
| | - Typed client methods | |
| +-------------------------------------+ |
| | |
| v |
| 4. Use in Domain Server |
| +-------------------------------------+ |
| | let result: QueryResult = client | |
| | .query(QueryArgs { ... }) | |
| | .await?; | |
| | | |
| | // Full type safety! | |
| | result.columns.len() | |
| | result.rows.iter() | |
| +-------------------------------------+ |
| |
+--------------------------------------------------------------------+
- TYPED_TOOLS_GUIDE.md - Type-safe input schemas
- MCP Protocol Spec - MCP annotations specification
- Composition Architecture - Server composition design