Skip to content

Add support for standard MCP tool annotations

Overview

Add support for standard MCP tool annotations as defined in the Model Context Protocol specification.

Currently, the annotations field in generated MCP tools is always set to None. This feature will automatically populate appropriate annotation hints based on the HTTP verb extracted from OpenAPI operation definitions.

HTTP Verb to MCP Annotation Mapping

The following table defines the semantic mapping from HTTP verbs to MCP annotation hints:

HTTP Verb readOnlyHint destructiveHint idempotentHint openWorldHint Rationale
GET true false true true Safe, idempotent read operation per HTTP semantics
HEAD true false true true Safe, idempotent metadata retrieval (like GET without body)
OPTIONS true false true true Safe, idempotent capability discovery
POST false false false true Creates resources; not idempotent, not destructive to existing data
PUT false true true true Replaces/updates resources; idempotent but destructive
PATCH false true false true Modifies resources; destructive and typically not idempotent
DELETE false true true true Removes resources; idempotent (subsequent deletes are no-ops) but destructive

Notes on Mapping

  1. readOnlyHint: Only GET, HEAD, and OPTIONS are considered read-only per HTTP specifications
  2. destructiveHint: PUT, PATCH, and DELETE modify or remove existing resources
  3. idempotentHint: GET, HEAD, OPTIONS, PUT, and DELETE are idempotent per HTTP specs; POST and PATCH are not
  4. openWorldHint: Always true since OpenAPI tools interact with external HTTP APIs
  5. title: Should continue to be populated from OpenAPI operation summary or generated from operation ID

Implementation Plan

Phase 1: Add Annotation Generation Logic

File: crates/rmcp-openapi/src/tool/metadata.rs

  1. Import ToolAnnotations:

    use rmcp::model::ToolAnnotations;
  2. Add method to ToolMetadata:

    impl ToolMetadata {
        /// Generate MCP annotations based on HTTP method semantics
        pub fn generate_annotations(&self) -> Option<ToolAnnotations> {
            match self.method.to_uppercase().as_str() {
                "GET" | "HEAD" | "OPTIONS" => Some(ToolAnnotations {
                    title: None, // handled separately via metadata.title
                    read_only_hint: Some(true),
                    destructive_hint: Some(false),
                    idempotent_hint: Some(true),
                    open_world_hint: Some(true),
                }),
                "POST" => Some(ToolAnnotations {
                    title: None,
                    read_only_hint: Some(false),
                    destructive_hint: Some(false),
                    idempotent_hint: Some(false),
                    open_world_hint: Some(true),
                }),
                "PUT" => Some(ToolAnnotations {
                    title: None,
                    read_only_hint: Some(false),
                    destructive_hint: Some(true),
                    idempotent_hint: Some(true),
                    open_world_hint: Some(true),
                }),
                "PATCH" => Some(ToolAnnotations {
                    title: None,
                    read_only_hint: Some(false),
                    destructive_hint: Some(true),
                    idempotent_hint: Some(false),
                    open_world_hint: Some(true),
                }),
                "DELETE" => Some(ToolAnnotations {
                    title: None,
                    read_only_hint: Some(false),
                    destructive_hint: Some(true),
                    idempotent_hint: Some(true),
                    open_world_hint: Some(true),
                }),
                _ => None, // Unknown/unsupported HTTP methods
            }
        }
    }
  3. Update From<&ToolMetadata> for Tool implementation:

    impl From<&ToolMetadata> for Tool {
        fn from(metadata: &ToolMetadata) -> Self {
            // ... existing code ...
            
            Tool {
                name: metadata.name.clone().into(),
                description: metadata.description.clone().map(|d| d.into()),
                input_schema,
                output_schema,
                annotations: metadata.generate_annotations(), // ← CHANGED: was None
                title: metadata.title.clone(),
                icons: None,
            }
        }
    }

Phase 2: Add Tests

File: crates/rmcp-openapi/src/tool/metadata.rs (test module)

Add comprehensive unit tests:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_annotations() {
        let metadata = ToolMetadata {
            name: "test".into(),
            title: None,
            description: None,
            parameters: serde_json::json!({}),
            output_schema: None,
            method: "GET".into(),
            path: "/test".into(),
            security: None,
        };
        
        let annotations = metadata.generate_annotations().unwrap();
        assert_eq!(annotations.read_only_hint, Some(true));
        assert_eq!(annotations.destructive_hint, Some(false));
        assert_eq!(annotations.idempotent_hint, Some(true));
        assert_eq!(annotations.open_world_hint, Some(true));
    }
    
    #[test]
    fn test_post_annotations() {
        // ... test POST mapping
    }
    
    #[test]
    fn test_put_annotations() {
        // ... test PUT mapping
    }
    
    #[test]
    fn test_patch_annotations() {
        // ... test PATCH mapping
    }
    
    #[test]
    fn test_delete_annotations() {
        // ... test DELETE mapping
    }
    
    #[test]
    fn test_unknown_method_annotations() {
        let metadata = ToolMetadata {
            method: "UNKNOWN".into(),
            // ... other fields
        };
        assert_eq!(metadata.generate_annotations(), None);
    }
}

Phase 3: Integration Testing

Add snapshot tests or integration tests that verify:

  1. Tools generated from OpenAPI specs include correct annotations
  2. Annotations are properly serialized in MCP protocol responses
  3. Existing behavior remains unchanged (backward compatibility)

Phase 4: Documentation

  1. Update module documentation in tool/metadata.rs to explain annotation generation
  2. Add example in README or documentation showing how annotations are automatically set
  3. Update CHANGELOG.md

Acceptance Criteria

Functional Requirements

  • AC1: Tools generated from GET operations have readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true
  • AC2: Tools generated from POST operations have readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true
  • AC3: Tools generated from PUT operations have readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true
  • AC4: Tools generated from PATCH operations have readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true
  • AC5: Tools generated from DELETE operations have readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true
  • AC6: Tools generated from HEAD and OPTIONS operations have same annotations as GET
  • AC7: Tools generated from unknown/unsupported HTTP methods have annotations: None
  • AC8: The title field in annotations remains None (title is handled separately via Tool.title)

Technical Requirements

  • AC9: HTTP method comparison is case-insensitive
  • AC10: All annotation fields are explicitly set (no reliance on defaults)
  • AC11: Changes are isolated to tool/metadata.rs (no changes to tool generation logic)
  • AC12: Existing tools without annotations continue to work (backward compatibility)

Testing Requirements

  • AC13: Unit tests cover all HTTP verbs (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
  • AC14: Unit test covers unknown HTTP method edge case
  • AC15: Integration test verifies annotations appear in generated tools from OpenAPI spec
  • AC16: Snapshot tests (if present) are updated to reflect new annotations

Documentation Requirements

  • AC17: Code includes inline documentation explaining the mapping rationale
  • AC18: Public API documentation is updated (if applicable)

References

Edited by Jean-Marc Le Roux