From f519045037a823817ff96a9c994a76a2f35436aa Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Sun, 17 Aug 2025 23:09:29 +0100 Subject: [PATCH] feat: Add comprehensive MCP prompts implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full Model Context Protocol (MCP) prompts support per specification. Core Features: - New Prompt class with message creation and validation - Dry::Schema-based argument validation (same as tools) - ERB template support for structured prompt messages - Multiple message formats (hash, array, MessageBuilder pattern) - Support for text, image, and resource content types API Design: - MessageBuilder for fluent message construction - Flexible messages() method supporting multiple same-role messages - Base64 validation for image content compliance - Auto-naming from class names (CodeReviewPrompt โ†’ code_review) Feature Parity with Tools: - Authorization blocks for access control - Tags, metadata, and annotations support - Integration with ServerFiltering for dynamic filtering - Headers support for authorization context Server Integration: - Added prompts/list and prompts/get endpoints - Prompt registration methods (register_prompt, register_prompts) - Notification support for prompts list changes Rails Support: - Generator for creating new prompts (rails g fast_mcp:install) - ApplicationPrompt base class - Auto-loading from app/prompts directory - Sample prompt template with best practices Testing & Documentation: - 900+ lines of comprehensive tests - Example prompts (few-shot, inline, code review with templates) - Full documentation in docs/prompts.md - Rails integration guide updates Breaking Changes: None - purely additive feature --- .gitignore | 1 - CHANGELOG.md | 11 + Gemfile.lock | 2 +- README.md | 29 +- docs/prompts.md | 683 +++++++++++++ docs/rails_integration.md | 428 ++++++++- examples/authenticated_rack_middleware.rb | 32 + examples/prompts/few_shot_prompt.rb | 108 +++ examples/prompts/inline_prompt.rb | 36 + examples/prompts/prompt_code_review.rb | 28 + .../templates/code_review_assistant.erb | 1 + .../prompts/templates/code_review_user.erb | 7 + examples/rack_middleware.rb | 32 + examples/server_with_stdio_transport.rb | 30 + lib/fast_mcp.rb | 17 + .../fast_mcp/install/install_generator.rb | 10 + .../install/templates/application_prompt.rb | 8 + .../install/templates/fast_mcp_initializer.rb | 9 +- .../install/templates/sample_prompt.rb | 25 + lib/mcp/prompt.rb | 655 +++++++++++++ lib/mcp/railtie.rb | 14 +- lib/mcp/server.rb | 138 ++- lib/mcp/server_filtering.rb | 27 +- lib/mcp/version.rb | 2 +- spec/mcp/prompt_spec.rb | 900 ++++++++++++++++++ spec/mcp/resource_spec.rb | 125 +++ spec/mcp/server_spec.rb | 199 ++++ spec/templates/prompt_templates_spec.rb | 127 +++ 28 files changed, 3669 insertions(+), 15 deletions(-) create mode 100644 docs/prompts.md create mode 100644 examples/prompts/few_shot_prompt.rb create mode 100644 examples/prompts/inline_prompt.rb create mode 100644 examples/prompts/prompt_code_review.rb create mode 100644 examples/prompts/templates/code_review_assistant.erb create mode 100644 examples/prompts/templates/code_review_user.erb create mode 100644 lib/generators/fast_mcp/install/templates/application_prompt.rb create mode 100644 lib/generators/fast_mcp/install/templates/sample_prompt.rb create mode 100644 lib/mcp/prompt.rb create mode 100644 spec/mcp/prompt_spec.rb create mode 100644 spec/templates/prompt_templates_spec.rb diff --git a/.gitignore b/.gitignore index 5199e01..65ef190 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - # Ignore RSpec status file /.rspec_status diff --git a/CHANGELOG.md b/CHANGELOG.md index 78efdb6..a24286f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Tool annotations support for providing hints about tool behavior (readOnlyHint, destructiveHint, etc.) +- Comprehensive Prompts Feature implementation following MCP specification + - Full MCP prompts protocol support + - Prompt templates with ERB support + - Prompt argument validation using Dry::Schema + - Prompt filtering support with ServerFiltering architecture + - MessageBuilder for fluent message construction API + - Auto-naming from class names (e.g., `CodeReviewPrompt` โ†’ `code_review`) + - Support for authorization, tags, metadata, and prompt annotations + - Base64 validation for image content to ensure MCP compliance + - Flexible API for the `messages` method supporting hash, array, and block formats + - Rails generator templates for creating prompts ## [1.5.0] - 2025-06-01 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 1c08f44..8ef1012 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fast-mcp (1.5.0) + fast-mcp (1.6.0) addressable (~> 2.8) base64 dry-schema (~> 1.14) diff --git a/README.md b/README.md index d46bc35..9fa17f6 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ Fast MCP solves all these problems by providing a clean, Ruby-focused implementa - ๐Ÿ› ๏ธ **Tools API** - Let AI models call your Ruby functions securely, with in-depth argument validation through [Dry-Schema](https://github.com/dry-rb/dry-schema). - ๐Ÿ“š **Resources API** - Share data between your app and AI models +- ๐Ÿ’ฌ **Prompt Handling** - Create structured message templates for LLM interactions - ๐Ÿ”„ **Multiple Transports** - Choose from STDIO, HTTP, or SSE based on your needs - ๐Ÿงฉ **Framework Integration** - Works seamlessly with Rails, Sinatra or any Rack app. - ๐Ÿ”’ **Authentication Support** - Secure your AI-powered endpoints with ease - ๐Ÿš€ **Real-time Updates** - Subscribe to changes for interactive applications - ๐ŸŽฏ **Dynamic Filtering** - Control tool/resource access based on request context (permissions, API versions, etc.) - ## ๐Ÿ’Ž What Makes FastMCP Great ```ruby # Define tools for AI models to use @@ -269,6 +269,27 @@ end # Register the resource with the server server.register_resource(StatisticsResource) +# Define prompts for structured AI interactions +class CodeReviewPrompt < FastMcp::Prompt + # prompt_name is automatically generated as "code_review" from class name + description "Review code for best practices" + + arguments do + required(:code).filled(:string) + required(:language).filled(:string) + end + + def call(code:, language:) + messages( + assistant: "I'll review your #{language} code for best practices and potential improvements.", + user: "Please review this #{language} code:\n\n```#{language}\n#{code}\n```\n\nFocus on readability, performance, and maintainability." + ) + end +end + +# Register the prompt with the server +server.register_prompt(CodeReviewPrompt) + # Start the server server.start ``` @@ -338,6 +359,7 @@ Add your server to your Claude Desktop configuration at: ``` ## How to add a MCP server to Claude, Cursor, or other MCP clients? + Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md) ## ๐Ÿ“Š Supported Specifications @@ -347,6 +369,7 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md) | โœ… **JSON-RPC 2.0** | Full implementation for communication | | โœ… **Tool Definition & Calling** | Define and call tools with rich argument types | | โœ… **Resource & Resource Templates Management** | Create, read, update, and subscribe to resources | +| โœ… **Prompt Handling** | Create structured message templates for LLM interactions | | โœ… **Transport Options** | STDIO, HTTP, and SSE for flexible integration | | โœ… **Framework Integration** | Rails, Sinatra, Hanami, and any Rack-compatible framework | | โœ… **Authentication** | Secure your AI endpoints with token authentication | @@ -398,6 +421,7 @@ FastMcp.authenticated_rack_middleware(app, - [๐ŸŒ Sinatra Integration](docs/sinatra_integration.md) - [๐Ÿ“š Resources](docs/resources.md) - [๐Ÿ› ๏ธ Tools](docs/tools.md) +- [๐Ÿ’ฌ Prompts](docs/prompts.md) - [๐Ÿ”’ Security](docs/security.md) - [๐ŸŽฏ Dynamic Filtering](docs/filtering.md) @@ -408,6 +432,7 @@ Check out the [examples directory](examples) for more detailed examples: - **๐Ÿ”จ Basic Examples**: - [Simple Server](examples/server_with_stdio_transport.rb) - [Tool Examples](examples/tool_examples.rb) + - [Prompt Examples](examples/prompts) - **๐ŸŒ Web Integration**: - [Rack Middleware](examples/rack_middleware.rb) @@ -437,4 +462,4 @@ This project is available as open source under the terms of the [MIT License](ht - The [Model Context Protocol](https://github.com/modelcontextprotocol) team for creating the specification - The [Dry-Schema](https://github.com/dry-rb/dry-schema) team for the argument validation. -- All contributors to this project +- All contributors to this project \ No newline at end of file diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 0000000..ca3fe71 --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,683 @@ +# Working with MCP Prompts + +Prompts are a powerful feature in Fast MCP that allow you to define structured message templates for interacting with Large Language Models (LLMs). This guide covers everything you need to know about creating, using, and extending prompts in Fast MCP. + +## Table of Contents + +- [What are MCP Prompts?](#what-are-mcp-prompts) +- [Defining Prompts](#defining-prompts) + - [Basic Prompt Definition](#basic-prompt-definition) + - [Prompt Arguments](#prompt-arguments) + - [Message Structure](#message-structure) +- [Creating Messages](#creating-messages) + - [Hash Format API](#hash-format-api) + - [Array Format API](#array-format-api) + - [MessageBuilder API](#messagebuilder-api) + - [Multiple Messages with Same Role](#multiple-messages-with-same-role) +- [Using Templates](#using-templates) + - [ERB Templates](#erb-templates) + - [Inline Templates](#inline-templates) +- [Advanced Prompt Features](#advanced-prompt-features) + - [Message Content Types](#message-content-types) + - [Dynamic Content](#dynamic-content) + - [Prompt Filtering](#prompt-filtering) + - [Prompt Annotations](#prompt-annotations) + - [Authorization](#authorization) +- [Best Practices](#best-practices) +- [Examples](#examples) + +## What are MCP Prompts? + +MCP Prompts are structured message templates that define how to interact with Large Language Models (LLMs). They provide a consistent way to: + +- Define the structure of messages sent to LLMs +- Validate and process input arguments +- Create complex multi-message conversations +- Support different message roles (user, assistant) +- Include dynamic content based on input parameters + +> **Note on Message Roles**: The MCP specification only supports "user" and "assistant" roles, unlike some LLM APIs (such as OpenAI) that also support a "system" role. If you need system-like instructions in your prompts, you'll need to include them as part of a user or assistant message. + +Prompts are particularly useful for maintaining consistent interactions with LLMs across your application. + +## Defining Prompts + +### Basic Prompt Definition + +To define a prompt, create a class that inherits from `FastMcp::Prompt`: + +```ruby +class SimpleExamplePrompt < FastMcp::Prompt + # prompt_name is auto-generated as 'simple_example' from the class name + description 'A simple example prompt' + + def self.call(**_args) + new.messages( + assistant: "I'm an AI assistant. How can I help you?", + user: "Tell me about Ruby." + ) + end +end +``` + +When defining a prompt class, you can: + +- Set a name using the `prompt_name` class method (optional - auto-generated from class name if not specified) +- Set a description using the `description` class method +- Define arguments using the `arguments` class method with Dry::Schema +- Implement the message creation in the `self.call` class method + +> **Important**: The `call` method should be defined as a class method (`self.call`) that creates a new instance and calls the `messages` method on it. This is the standard pattern for FastMCP prompts. + +#### Automatic Naming + +If you don't specify a `prompt_name`, FastMCP will automatically generate one from your class name: +- The class name is converted to snake_case +- Any "Prompt" suffix is removed +- For example: `CodeReviewPrompt` โ†’ `code_review`, `DataAnalysisPrompt` โ†’ `data_analysis` + +```ruby +class CodeReviewPrompt < FastMcp::Prompt + # No need to specify prompt_name - it will be "code_review" + description 'Reviews code for best practices' +end + +class CustomNamePrompt < FastMcp::Prompt + prompt_name 'my_custom_name' # Override auto-naming when needed + description 'Uses a custom name instead of auto-generated' +end +``` + +### Prompt Arguments + +To define arguments for a prompt, use the `arguments` class method with a block using Dry::Schema syntax: + +```ruby +class QueryPrompt < FastMcp::Prompt + prompt_name 'query_example' + description 'A prompt for answering user queries' + + arguments do + required(:query).filled(:string) + optional(:context).filled(:string) + end + + def self.call(query:, context: nil) + new.messages( + assistant: "I'll help answer your question.", + user: context ? "Question: #{query}\nContext: #{context}" : "Question: #{query}" + ) + end +end +``` + +The `arguments` method works similarly to tools, allowing you to define: + +- Required arguments using the `required` method +- Optional arguments using the `optional` method +- Types and validations for each argument + +> **Note**: Unlike tools, prompts currently don't support the `.description()` method on schema fields. If you need to document your arguments, use comments in your code or add them to the prompt's main description. + +### Message Structure + +Messages in Fast MCP follow a specific structure that aligns with the MCP specification: + +```ruby +{ + role: "user", # or "assistant" (system role is not supported by MCP) + content: { + type: "text", + text: "The actual message content" + } +} +``` + +The `messages` method in the `Prompt` class handles creating this structure for you. + +## Creating Messages + +Fast MCP provides flexible ways to create messages through the `messages` method. + +### Hash Format API + +The traditional way to create messages is using a hash with roles as keys: + +```ruby +def self.call(query:) + new.messages( + assistant: "I'll help you with your question.", + user: "My question is: #{query}" + ) +end +``` + +This creates an array of messages with the specified roles and content. Note that only `user` and `assistant` roles are supported by the MCP specification. + +### Array Format API + +You can also use an array of message hashes with `:role` and `:content` keys: + +```ruby +def self.call(query:) + new.messages([ + { role: 'assistant', content: "I'll help you with your question." }, + { role: 'user', content: "My question is: #{query}" } + ]) +end +``` + +This format is particularly useful when you need to maintain a specific order of messages. + +### MessageBuilder API + +For more complex message construction, you can use the MessageBuilder class directly: + +```ruby +def self.call(query:, examples: []) + new.messages do + assistant "I'll help you with your question." + + # Add example messages if provided + examples.each do |example| + user "Example: #{example}" + end + + # Add the main query + user "My question is: #{query}" + end +end +``` + +The MessageBuilder provides a fluent API with these methods: +- `user(content)` - Add a user message +- `assistant(content)` - Add an assistant message +- `add_message(role:, content:)` - Add a message with a specific role + +### Multiple Messages with Same Role + +Both the array format and MessageBuilder support multiple messages with the same role: + +```ruby +# Using array format +def self.call(query:, examples: []) + message_array = [ + { role: 'assistant', content: "I'll help you with your question." } + ] + + # Add example messages if provided + examples.each do |example| + message_array << { role: 'user', content: "Example: #{example}" } + end + + # Add the main query + message_array << { role: 'user', content: "My question is: #{query}" } + + new.messages(message_array) +end + +# Using MessageBuilder +def self.call(query:, examples: []) + new.messages do + assistant "I'll help you with your question." + + # Add multiple user messages + examples.each do |example| + user "Example: #{example}" + end + + user "My question is: #{query}" + end +end +``` + +This allows for more complex conversation structures where you might need multiple consecutive messages from the same role. + +## Using Templates + +### ERB Templates + +For more complex prompts, you can use ERB templates: + +```ruby +class CodeReviewPrompt < FastMcp::Prompt + prompt_name 'code_review' + description 'A prompt for code review' + + arguments do + required(:code).filled(:string) + optional(:language).filled(:string) + end + + def self.call(code:, language: nil) + assistant_template = File.read(File.join(File.dirname(__FILE__), 'templates/code_review_assistant.erb')) + user_template = File.read(File.join(File.dirname(__FILE__), 'templates/code_review_user.erb')) + + new.messages( + assistant: ERB.new(assistant_template).result(binding), + user: ERB.new(user_template).result(binding) + ) + end +end +``` + +The ERB templates can access the arguments passed to the `call` method: + +```erb + +I'll help you review your code. I'll analyze it for quality, best practices, and potential improvements. + + +<% if language %> +Please review this <%= language %> code: +<%= code %> +<% else %> +Please review this code: +<%= code %> +<% end %> +``` + +#### JSON Templates with ERB + +For structured data like JSON, ERB templates are particularly useful: + +```ruby +class ApiPrompt < FastMcp::Prompt + prompt_name 'api_request' + description 'A prompt for generating API requests' + + arguments do + required(:endpoint).filled(:string) + required(:method).filled(:string) + optional(:params).hash + end + + def self.call(endpoint:, method:, params: {}) + json_template = <<-ERB +{ + "request": { + "endpoint": "<%= endpoint %>", + "method": "<%= method %>", + "parameters": <%= params.to_json %> + }, + "instructions": "Please generate a valid API request for the above endpoint" +} + ERB + + new.messages( + assistant: "I'll help you generate an API request.", + user: ERB.new(json_template).result(binding) + ) + end +end +``` + +The embedded JSON template would render like this: + +```json +{ + "request": { + "endpoint": "https://api.example.com/users", + "method": "POST", + "parameters": {"name": "John Doe", "email": "john@example.com"} + }, + "instructions": "Please generate a valid API request for the above endpoint" +} +``` + +#### XML Templates with ERB + +Similarly, for XML-based content: + +```ruby +class XmlPrompt < FastMcp::Prompt + prompt_name 'xml_generator' + description 'A prompt for generating XML documents' + + arguments do + required(:document_type).filled(:string) + required(:elements).array + optional(:attributes).hash + end + + def self.call(document_type:, elements:, attributes: {}) + xml_template = <<-ERB + +<<%= document_type %><% attributes.each do |key, value| %> <%= key %>="<%= value %>"<% end %>> +<% elements.each do |element| %> + <<%= element[:name] %>> + <%= element[:content] %> + > +<% end %> +> + ERB + + new.messages( + assistant: "I'll help you generate an XML document.", + user: ERB.new(xml_template).result(binding) + ) + end +end +``` + +The embedded XML template would render like this (with appropriate arguments): + +```xml + + + + Ruby Programming + + + Jane Smith + + + 2025 + + +``` + +### Inline Templates + +For simpler cases, you can define templates inline: + +```ruby +class InlinePrompt < FastMcp::Prompt + prompt_name 'inline_example' + description 'An example prompt that uses inline text' + + arguments do + required(:query).filled(:string) + optional(:context).filled(:string) + end + + def self.call(query:, context: nil) + # Create assistant message + assistant_message = "I'll help you answer your question about: #{query}" + + # Create user message + user_message = if context + "My question is: #{query}\nHere's some additional context: #{context}" + else + "My question is: #{query}" + end + + new.messages( + assistant: assistant_message, + user: user_message + ) + end +end +``` + +## Advanced Prompt Features + +### Message Content Types + +Fast MCP supports different content types for messages. You can create content objects using the built-in helper methods: + +#### Text Content + +```ruby +class TextPrompt < FastMcp::Prompt + def self.call(message:) + prompt = new + text_content = prompt.text_content(message) + prompt.messages([ + { role: 'user', content: text_content } + ]) + end +end +``` + +#### Image Content + +```ruby +class ImagePrompt < FastMcp::Prompt + def self.call(base64_data:, mime_type: 'image/png') + prompt = new + image_content = prompt.image_content(base64_data, mime_type) + prompt.messages([ + { role: 'user', content: image_content } + ]) + end +end +``` + +#### Resource Content + +```ruby +class ResourcePrompt < FastMcp::Prompt + def self.call(uri:, mime_type:, text: nil, blob: nil) + prompt = new + resource_content = prompt.resource_content(uri, mime_type, text: text, blob: blob) + prompt.messages([ + { role: 'user', content: resource_content } + ]) + end +end +``` + +#### Content Helper Methods + +The following helper methods are available for creating properly formatted content: + +- `text_content(text)` - Creates text content with type 'text' +- `image_content(data, mime_type)` - Creates image content with base64 data and MIME type +- `resource_content(uri, mime_type, text: nil, blob: nil)` - Creates resource content +- `content_from(content)` - Automatically detects and creates appropriate content type + +#### Content Validation + +All content is automatically validated to ensure it meets MCP specification requirements: + +- Text content must have a `:text` field +- Image content must have `:data` (valid base64) and `:mimeType` fields +- Resource content must have `:uri`, `:mimeType`, and either `:text` or `:blob` fields + +Remember that only "user" and "assistant" are valid roles according to the MCP specification. + +### Dynamic Content + +You can create prompts with dynamic content based on application state: + +```ruby +class WeatherPrompt < FastMcp::Prompt + prompt_name 'weather_forecast' + description 'A prompt for weather forecasts' + + arguments do + required(:location).filled(:string) + optional(:days).filled(:integer) + end + + def self.call(location:, days: 3) + # Fetch weather data (example) + weather_data = WeatherService.forecast(location, days) + + # Create a detailed context + weather_context = weather_data.map do |day| + "#{day[:date]}: #{day[:condition]}, High: #{day[:high]}ยฐC, Low: #{day[:low]}ยฐC" + end.join("\n") + + new.messages( + assistant: "I'll provide a weather forecast for #{location}.", + user: "What's the weather forecast for #{location} for the next #{days} days?", + assistant: "Here's the raw weather data:\n#{weather_context}", + user: "Can you summarize this forecast in a friendly way?" + ) + end +end +``` + +### Individual Message Creation + +For more control over message creation, you can use the `message` method to create individual messages: + +```ruby +class CustomMessagePrompt < FastMcp::Prompt + def self.call(text:) + prompt = new + + # Create individual messages + intro_message = prompt.message( + role: 'assistant', + content: prompt.text_content("I'll help you with that.") + ) + + user_message = prompt.message( + role: 'user', + content: prompt.text_content(text) + ) + + [intro_message, user_message] + end +end +``` + +### Prompt Filtering + +Filter prompts dynamically based on request context: + +```ruby +server.filter_prompts do |request, prompts| + # Filter by user permissions, tags, etc. + prompts.select { |p| p.authorized?(user: request.user) } +end +``` + +This allows you to control which prompts are available to clients based on the current request context, user permissions, or other criteria. + +### Prompt Annotations + +Add metadata to prompts: + +```ruby +class ReviewPrompt < FastMcp::Prompt + tags :code_review, :ai_assisted + metadata :version, "2.0" + annotations experimental: false +end +``` + +Annotations provide additional information about prompts that can be used by clients for better organization and presentation. + +### Authorization + +Control access to prompts: + +```ruby +class SecurePrompt < FastMcp::Prompt + prompt_name 'secure_prompt' + description 'A prompt that requires authorization' + + arguments do + required(:message).filled(:string) + end + + # Authorization based on headers + authorize { headers['role'] == 'admin' } + + # Authorization based on arguments + authorize { |message:| message != 'forbidden' } + + def self.call(message:) + new.messages( + assistant: "This is a secure prompt.", + user: message + ) + end +end +``` + +Authorization blocks allow you to implement fine-grained access control for prompts, ensuring only authorized users can access sensitive or privileged prompt templates. + +## Best Practices + +When working with prompts: + +1. **Keep prompts modular**: Create separate prompt classes for different tasks +2. **Use descriptive names**: Choose clear, descriptive names for your prompts +3. **Validate inputs**: Use the arguments schema to validate inputs +4. **Use templates for complex prompts**: Separate template files for better organization +5. **Consider message order**: The order of messages can significantly impact LLM responses +6. **Document your prompts**: Add clear descriptions to your prompts and arguments +7. **Test with different inputs**: Ensure your prompts work with various inputs +8. **System instructions as user messages**: Since the MCP specification doesn't support system roles, include system-like instructions as part of your first user or assistant message + +## Examples + +### Simple Question-Answer Prompt + +```ruby +class QAPrompt < FastMcp::Prompt + prompt_name 'qa_prompt' + description 'A simple question-answer prompt' + + arguments do + required(:question).filled(:string) + end + + def self.call(question:) + new.messages( + assistant: "I'll answer your questions to the best of my ability.", + user: question + ) + end +end +``` + +### Multi-Message Conversation Prompt + +```ruby +class ConversationPrompt < FastMcp::Prompt + prompt_name 'conversation_prompt' + description 'A multi-message conversation prompt' + + arguments do + required(:topic).filled(:string) + optional(:user_background).filled(:string) + end + + def self.call(topic:, user_background: nil) + new.messages do + # First message - assistant introduction + assistant "I'm going to help you understand #{topic}." + + # Second message - user background if provided + if user_background + user "My background: #{user_background}" + # Third message - assistant acknowledgment + assistant "I'll tailor my explanation based on your background." + end + + # Final message - main user query + user "Please explain #{topic} to me." + end + end +end +``` + +### Code Review Prompt with Templates + +```ruby +class CodeReviewPrompt < FastMcp::Prompt + prompt_name 'code_review' + description 'A prompt for code review' + + arguments do + required(:code).filled(:string) + optional(:programming_language).filled(:string) + end + + def self.call(code:, programming_language: nil) + assistant_template = File.read(File.join(File.dirname(__FILE__), 'templates/code_review_assistant.erb')) + user_template = File.read(File.join(File.dirname(__FILE__), 'templates/code_review_user.erb')) + + new.messages( + assistant: ERB.new(assistant_template).result(binding), + user: ERB.new(user_template).result(binding) + ) + end +end +``` diff --git a/docs/rails_integration.md b/docs/rails_integration.md index 923ace5..bfd7a3e 100644 --- a/docs/rails_integration.md +++ b/docs/rails_integration.md @@ -1,2 +1,426 @@ -# TODO -In the meantime, check the README or the [demo rails app](../examples/rails-demo-app/) \ No newline at end of file +# Rails Integration Guide + +Fast MCP provides seamless integration with Ruby on Rails applications, including automatic discovery of tools, resources, and prompts. This guide walks you through setting up and using Fast MCP in a Rails application. + +## Installation + +Add the Fast MCP gem to your Rails application's Gemfile: + +```ruby +gem 'fast-mcp' +``` + +Then run: + +```bash +bundle install +``` + +## Generator Setup + +Fast MCP includes a Rails generator that sets up the basic structure for your MCP integration: + +```bash +bin/rails generate fast_mcp:install +``` + +This generator creates: + +- `app/tools/` directory for MCP tools +- `app/resources/` directory for MCP resources +- `app/prompts/` directory for MCP prompts +- `app/tools/application_tool.rb` base class +- `app/resources/application_resource.rb` base class +- `app/prompts/application_prompt.rb` base class +- Basic configuration in `config/initializers/fast_mcp.rb` + +## Configuration + +After running the generator, configure Fast MCP in `config/initializers/fast_mcp.rb`: + +```ruby +# config/initializers/fast_mcp.rb +FastMcp.configure do |config| + config.server_name = 'my-rails-app' + config.server_version = '1.0.0' + + # Configure transport options + config.transport = :rack # or :stdio + config.allowed_origins = ['http://localhost:3000'] + + # Enable authentication if needed + config.authentication_token = ENV['MCP_AUTH_TOKEN'] +end +``` + +## Creating Tools + +Create tools in the `app/tools/` directory. They automatically inherit from `ApplicationTool`: + +```ruby +# app/tools/user_search_tool.rb +class UserSearchTool < ApplicationTool + description "Search for users in the database" + + arguments do + required(:query).filled(:string).description("Search query") + optional(:limit).filled(:integer).description("Maximum number of results") + end + + def call(query:, limit: 10) + users = User.where("name ILIKE ?", "%#{query}%").limit(limit) + users.map { |user| { id: user.id, name: user.name, email: user.email } } + end +end +``` + +Tools have access to all Rails helpers and can interact with your models directly. + +## Creating Resources + +Create resources in the `app/resources/` directory: + +```ruby +# app/resources/user_stats_resource.rb +class UserStatsResource < ApplicationResource + uri "stats/users" + resource_name "User Statistics" + description "Current user statistics" + mime_type "application/json" + + def content + { + total_users: User.count, + active_users: User.where(active: true).count, + new_users_today: User.where(created_at: Date.current.all_day).count + }.to_json + end +end +``` + +## Creating Prompts + +Create prompts in the `app/prompts/` directory: + +```ruby +# app/prompts/user_analysis_prompt.rb +class UserAnalysisPrompt < ApplicationPrompt + prompt_name 'user_analysis' + description 'Analyze user behavior patterns' + + arguments do + required(:user_id).filled(:integer).description("User ID to analyze") + optional(:timeframe).filled(:string).description("Analysis timeframe (week, month, year)") + end + + def call(user_id:, timeframe: 'month') + user = User.find(user_id) + + # Get user activity data + activities = case timeframe + when 'week' + user.activities.where(created_at: 1.week.ago..Time.current) + when 'year' + user.activities.where(created_at: 1.year.ago..Time.current) + else # month + user.activities.where(created_at: 1.month.ago..Time.current) + end + + activity_summary = activities.group(:activity_type).count + + messages( + assistant: "I'll analyze the user behavior for #{user.name} over the past #{timeframe}.", + user: "User: #{user.name} (ID: #{user.id})\nActivity Summary:\n#{activity_summary.map { |type, count| "#{type}: #{count}" }.join('\n')}\n\nPlease provide insights about this user's behavior patterns." + ) + end +end +``` + +## Automatic Registration + +Rails integration automatically discovers and registers your tools, resources, and prompts: + +```ruby +# config/initializers/fast_mcp.rb +FastMcp.configure do |config| + # ... other configuration ... +end + +# Automatic registration happens via the Railtie +# All descendants of ApplicationTool, ApplicationResource, and ApplicationPrompt +# are automatically registered with the server +``` + +## Manual Registration + +If you need more control over registration: + +```ruby +# config/initializers/fast_mcp.rb +FastMcp.configure do |config| + config.auto_register = false # Disable automatic registration +end + +# Then manually register in your initializer +FastMcp.server.tap do |server| + server.register_tool(UserSearchTool) + server.register_resource(UserStatsResource) + server.register_prompt(UserAnalysisPrompt) +end +``` + +## Mounting the MCP Server + +### Option 1: Rack Middleware (Recommended) + +Mount the MCP server as Rack middleware in `config/application.rb`: + +```ruby +# config/application.rb +class Application < Rails::Application + # ... other configuration ... + + config.middleware.use FastMcp::RackMiddleware, { + name: 'my-rails-app', + version: '1.0.0' + } +end +``` + +### Option 2: Routes-based Mounting + +Mount MCP endpoints in your routes: + +```ruby +# config/routes.rb +Rails.application.routes.draw do + mount FastMcp::Engine => '/mcp' + # ... your other routes ... +end +``` + +## Authentication Integration + +Integrate with Rails authentication systems: + +```ruby +# app/tools/authenticated_tool.rb +class AuthenticatedTool < ApplicationTool + authorize do |**args| + # Access headers for authentication + token = headers['Authorization']&.sub(/^Bearer /, '') + + # Validate token using your authentication system + user = User.find_by(api_token: token) + user&.active? + end + + private + + def current_user + @current_user ||= begin + token = headers['Authorization']&.sub(/^Bearer /, '') + User.find_by(api_token: token) + end + end +end +``` + +Use this as a base class for tools that require authentication: + +```ruby +# app/tools/secure_user_tool.rb +class SecureUserTool < AuthenticatedTool + description "Get current user information" + + def call + { + id: current_user.id, + name: current_user.name, + role: current_user.role + } + end +end +``` + +## Filtering and Authorization + +Implement filtering for prompts and tools: + +```ruby +# config/initializers/fast_mcp.rb +FastMcp.configure do |config| + # ... other configuration ... +end + +FastMcp.server.tap do |server| + # Filter tools based on user permissions + server.filter_tools do |request, tools| + user = authenticate_user(request.headers['Authorization']) + tools.select { |tool| tool.authorized?(user: user) } + end + + # Filter prompts based on user role + server.filter_prompts do |request, prompts| + user = authenticate_user(request.headers['Authorization']) + return prompts if user&.admin? + + prompts.reject { |prompt| prompt.tags.include?(:admin_only) } + end +end + +def authenticate_user(auth_header) + return nil unless auth_header + + token = auth_header.sub(/^Bearer /, '') + User.find_by(api_token: token) +end +``` + +## Working with ActiveRecord + +Tools and resources can interact with ActiveRecord models: + +```ruby +# app/tools/user_management_tool.rb +class UserManagementTool < ApplicationTool + description "Manage user accounts" + + arguments do + required(:action).filled(:string).description("Action to perform: create, update, delete") + required(:user_data).hash.description("User data") + end + + def call(action:, user_data:) + case action + when 'create' + user = User.create!(user_data) + { success: true, user_id: user.id } + when 'update' + user = User.find(user_data[:id]) + user.update!(user_data.except(:id)) + { success: true, user_id: user.id } + when 'delete' + User.find(user_data[:id]).destroy! + { success: true } + else + raise "Unknown action: #{action}" + end + rescue ActiveRecord::RecordInvalid => e + { success: false, errors: e.record.errors.full_messages } + end +end +``` + +## Testing + +Test your MCP components using RSpec: + +```ruby +# spec/tools/user_search_tool_spec.rb +RSpec.describe UserSearchTool do + let(:tool) { described_class.new } + + before do + create(:user, name: "John Doe") + create(:user, name: "Jane Smith") + end + + it "searches users by name" do + result = tool.call(query: "John") + expect(result).to have(1).items + expect(result.first[:name]).to eq("John Doe") + end +end +``` + +```ruby +# spec/prompts/user_analysis_prompt_spec.rb +RSpec.describe UserAnalysisPrompt do + let(:prompt) { described_class.new } + let(:user) { create(:user, name: "Test User") } + + it "creates analysis messages" do + result = prompt.call(user_id: user.id) + expect(result).to be_an(Array) + expect(result.first[:role]).to eq("assistant") + expect(result.last[:content][:text]).to include("Test User") + end +end +``` + +## Development and Debugging + +Enable detailed logging in development: + +```ruby +# config/environments/development.rb +Rails.application.configure do + # ... other configuration ... + + config.after_initialize do + FastMcp.logger.level = Logger::DEBUG + end +end +``` + +Use the MCP Inspector to test your server: + +```bash +# Test your Rails MCP server +npx @modelcontextprotocol/inspector http://localhost:3000/mcp +``` + +## Production Considerations + +### Performance + +- Use background jobs for long-running tool operations +- Cache resource content when appropriate +- Consider using read replicas for read-heavy resources + +### Security + +- Always validate and sanitize inputs +- Use Rails parameter filtering for sensitive data +- Implement proper authorization checks +- Use HTTPS in production + +### Monitoring + +Monitor MCP usage and performance: + +```ruby +# config/initializers/fast_mcp.rb +FastMcp.configure do |config| + config.before_tool_call = ->(tool_name, args) { + Rails.logger.info "MCP Tool called: #{tool_name} with #{args.keys}" + } + + config.after_tool_call = ->(tool_name, result, duration) { + Rails.logger.info "MCP Tool completed: #{tool_name} in #{duration}ms" + } +end +``` + +## Example Application + +Check out the complete example Rails application in the [examples directory](../examples/rails-demo-app/) for a working implementation of all these concepts. + +## Troubleshooting + +### Common Issues + +**Issue**: Tools not being auto-registered +- **Solution**: Ensure your tool classes inherit from `ApplicationTool` and are in the `app/tools/` directory + +**Issue**: Routes conflicts +- **Solution**: Mount MCP endpoints on a specific path like `/mcp` + +**Issue**: Authentication not working +- **Solution**: Verify headers are being passed correctly and your authentication logic is sound + +**Issue**: Resources showing stale data +- **Solution**: Ensure you're calling `notify_resource_updated` after data changes + +For more help, see the [main documentation](./integration_guide.md) or check the [examples](../examples/). \ No newline at end of file diff --git a/examples/authenticated_rack_middleware.rb b/examples/authenticated_rack_middleware.rb index 4bd8be9..c2e9d0d 100755 --- a/examples/authenticated_rack_middleware.rb +++ b/examples/authenticated_rack_middleware.rb @@ -59,6 +59,35 @@ def content end end +# Example prompt that uses inline text instead of ERB templates +class InlinePrompt < FastMcp::Prompt + prompt_name 'inline_example' + description 'An example prompt that uses inline text instead of ERB templates' + + arguments do + required(:query).description('The user query to respond to') + optional(:context).description('Additional context for the response') + end + + def call(query:, context: nil) + # Create assistant message + assistant_message = "I'll help you answer your question about: #{query}" + + # Create user message + user_message = if context + "My question is: #{query}\nHere's some additional context: #{context}" + else + "My question is: #{query}" + end + + # Using the messages method with a hash + messages( + assistant: assistant_message, + user: user_message + ) + end +end + # Create a simple Rack application app = lambda do |_env| [200, { 'Content-Type' => 'text/html' }, @@ -73,6 +102,9 @@ def content # Register a sample resource server.register_resource(HelloWorldResource) + + # Register the inline prompt + server.register_prompt(InlinePrompt) end # Run the Rack application with Puma diff --git a/examples/prompts/few_shot_prompt.rb b/examples/prompts/few_shot_prompt.rb new file mode 100644 index 0000000..0dc128f --- /dev/null +++ b/examples/prompts/few_shot_prompt.rb @@ -0,0 +1,108 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../../lib/fast_mcp' + +# Example prompt demonstrating the enhanced messages helper +# with support for multiple same-role messages and array input +class FewShotTranslationPrompt < FastMcp::Prompt + prompt_name "few_shot_translation" + description "A few-shot learning prompt for translation tasks using array format" + + arguments do + required(:word).filled(:string) + optional(:target_language).filled(:string) + end + + def call(word:, target_language: "Spanish") + # Example using array format to support multiple same-role messages + messages([ + { role: "user", content: "Translate 'hello' to #{target_language}" }, + { role: "assistant", content: "hola" }, + { role: "user", content: "Translate 'goodbye' to #{target_language}" }, + { role: "assistant", content: "adiรณs" }, + { role: "user", content: "Translate 'thank you' to #{target_language}" }, + { role: "assistant", content: "gracias" }, + { role: "user", content: "Translate '#{word}' to #{target_language}" } + ]) + end +end + +# Example using the builder pattern for complex conversation flows +class ConversationFlowPrompt < FastMcp::Prompt + prompt_name "conversation_flow" + description "Demonstrates builder pattern for complex conversation flows" + + arguments do + required(:topic).filled(:string) + optional(:expertise_level).filled(:string) + end + + def call(topic:, expertise_level: "beginner") + # Using builder pattern with block syntax + messages do + user "I'm interested in learning about #{topic}" + assistant "That's great! What's your current level of knowledge about #{topic}?" + user "I'm a #{expertise_level}" + assistant "Perfect! Let me provide some #{expertise_level}-friendly information about #{topic}." + + # Add multiple follow-up user questions + add_message(role: "user", content: "Can you give me some practical examples?") + add_message(role: "user", content: "What are the most important concepts to understand first?") + + assistant "I'd be happy to provide examples and key concepts for #{topic}." + end + end +end + +# Example maintaining backward compatibility with hash input +class BackwardCompatiblePrompt < FastMcp::Prompt + prompt_name "backward_compatible" + description "Shows that the original hash format still works" + + arguments do + required(:code).filled(:string) + optional(:language).filled(:string) + end + + def call(code:, language: "code") + # Original hash format still works + messages( + assistant: "I'll help you review your #{language}.", + user: "Please review this #{language}: #{code}" + ) + end +end + +# Demonstration script +if __FILE__ == $0 + puts "=== Few-Shot Translation Prompt ===" + + few_shot = FewShotTranslationPrompt.new + result = few_shot.call_with_schema_validation!(word: "computer", target_language: "French") + + puts "Generated #{result.size} messages:" + result.each_with_index do |message, index| + puts "#{index + 1}. #{message[:role]}: #{message[:content][:text]}" + end + + puts "\n=== Conversation Flow Prompt ===" + + conversation = ConversationFlowPrompt.new + result = conversation.call_with_schema_validation!(topic: "machine learning", expertise_level: "intermediate") + + puts "Generated #{result.size} messages:" + result.each_with_index do |message, index| + puts "#{index + 1}. #{message[:role]}: #{message[:content]}" + end + + puts "\n=== Backward Compatible Prompt ===" + + compatible = BackwardCompatiblePrompt.new + result = compatible.call_with_schema_validation!(code: "def hello; puts 'hi'; end", language: "Ruby") + + puts "Generated #{result.size} messages:" + result.each_with_index do |message, index| + puts "#{index + 1}. #{message[:role]}: #{message[:content][:text]}" + end +end \ No newline at end of file diff --git a/examples/prompts/inline_prompt.rb b/examples/prompts/inline_prompt.rb new file mode 100644 index 0000000..7e3eb46 --- /dev/null +++ b/examples/prompts/inline_prompt.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative '../../lib/fast_mcp' + +module FastMcp + module Prompts + # Example prompt that uses inline text instead of ERB templates + class InlinePrompt < FastMcp::Prompt + prompt_name 'inline_example' + description 'An example prompt that uses inline text instead of ERB templates' + + arguments do + required(:query).description('The user query to respond to') + optional(:context).description('Additional context for the response') + end + + def call(query:, context: nil) + # Create assistant message + assistant_message = "I'll help you answer your question about: #{query}" + + # Create user message + user_message = if context + "My question is: #{query}\nHere's some additional context: #{context}" + else + "My question is: #{query}" + end + + # Using the messages method with a hash + messages( + assistant: assistant_message, + user: user_message + ) + end + end + end +end diff --git a/examples/prompts/prompt_code_review.rb b/examples/prompts/prompt_code_review.rb new file mode 100644 index 0000000..337467e --- /dev/null +++ b/examples/prompts/prompt_code_review.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative '../../lib/fast_mcp' + +module FastMcp + module Prompts + # Example prompt for code review + class CodeReviewPrompt < FastMcp::Prompt + prompt_name 'code_review' + description 'Asks the LLM to analyze code quality and suggest improvements' + + arguments do + required(:code).description('Code to analyze') + optional(:programming_language).description('Language the code is written in') + end + + def call(code:, programming_language: nil) + assistant_template = File.read(File.join(File.dirname(__FILE__), 'templates/code_review_assistant.erb')) + user_template = File.read(File.join(File.dirname(__FILE__), 'templates/code_review_user.erb')) + + messages( + assistant: ERB.new(assistant_template).result(binding), + user: ERB.new(user_template).result(binding) + ) + end + end + end +end diff --git a/examples/prompts/templates/code_review_assistant.erb b/examples/prompts/templates/code_review_assistant.erb new file mode 100644 index 0000000..70f59c2 --- /dev/null +++ b/examples/prompts/templates/code_review_assistant.erb @@ -0,0 +1 @@ +I'll help you review your code. I'll analyze it for quality, best practices, and potential improvements. diff --git a/examples/prompts/templates/code_review_user.erb b/examples/prompts/templates/code_review_user.erb new file mode 100644 index 0000000..45a118c --- /dev/null +++ b/examples/prompts/templates/code_review_user.erb @@ -0,0 +1,7 @@ +<% if programming_language %> +Please review this <%= programming_language %> code: +<%= code %> +<% else %> +Please review this code: +<%= code %> +<% end %> diff --git a/examples/rack_middleware.rb b/examples/rack_middleware.rb index 490be41..ab92bf7 100755 --- a/examples/rack_middleware.rb +++ b/examples/rack_middleware.rb @@ -59,6 +59,35 @@ def content end end +# Example prompt that uses inline text instead of ERB templates +class InlinePrompt < FastMcp::Prompt + prompt_name 'inline_example' + description 'An example prompt that uses inline text instead of ERB templates' + + arguments do + required(:query).description('The user query to respond to') + optional(:context).description('Additional context for the response') + end + + def call(query:, context: nil) + # Create assistant message + assistant_message = "I'll help you answer your question about: #{query}" + + # Create user message + user_message = if context + "My question is: #{query}\nHere's some additional context: #{context}" + else + "My question is: #{query}" + end + + # Using the messages method with a hash + messages( + assistant: assistant_message, + user: user_message + ) + end +end + # Create a simple Rack application app = lambda do |_env| [200, { 'Content-Type' => 'text/html' }, @@ -76,6 +105,9 @@ def content # Register a sample resource server.register_resource(HelloWorldResource) + + # Register a sample prompt + server.register_prompt(InlinePrompt) end # Run the Rack application with Puma diff --git a/examples/server_with_stdio_transport.rb b/examples/server_with_stdio_transport.rb index c44904d..4e4988e 100755 --- a/examples/server_with_stdio_transport.rb +++ b/examples/server_with_stdio_transport.rb @@ -86,7 +86,37 @@ def content end end +# Example prompt that uses inline text instead of ERB templates +class InlinePrompt < FastMcp::Prompt + prompt_name 'inline_example' + description 'An example prompt that uses inline text instead of ERB templates' + + arguments do + required(:query).description('The user query to respond to') + optional(:context).description('Additional context for the response') + end + + def call(query:, context: nil) + # Create assistant message + assistant_message = "I'll help you answer your question about: #{query}" + + # Create user message + user_message = if context + "My question is: #{query}\nHere's some additional context: #{context}" + else + "My question is: #{query}" + end + + # Using the messages method with a hash + messages( + assistant: assistant_message, + user: user_message + ) + end +end + server.register_resources(CounterResource, UsersResource, UserResource, WeatherResource) +server.register_prompts(InlinePrompt) # Class-based tool for incrementing the counter class IncrementCounterTool < FastMcp::Tool diff --git a/lib/fast_mcp.rb b/lib/fast_mcp.rb index de75bf1..cac64e6 100644 --- a/lib/fast_mcp.rb +++ b/lib/fast_mcp.rb @@ -14,6 +14,7 @@ class << self require_relative 'mcp/tool' require_relative 'mcp/server' require_relative 'mcp/resource' +require_relative 'mcp/prompt' require_relative 'mcp/railtie' if defined?(Rails::Railtie) # Load generators if Rails is available @@ -116,6 +117,22 @@ def self.register_resources(*resources) self.server.register_resources(*resources) end + # Register a prompt with the MCP server + # @param prompt [FastMcp::Prompt] The prompt to register + # @return [FastMcp::Prompt] The registered prompt + def self.register_prompt(prompt) + self.server ||= FastMcp::Server.new(name: 'mcp-server', version: '1.0.0') + self.server.register_prompt(prompt) + end + + # Register multiple prompts at once + # @param prompts [Array] The prompts to register + # @return [Array] The registered prompts + def self.register_prompts(*prompts) + self.server ||= FastMcp::Server.new(name: 'mcp-server', version: '1.0.0') + self.server.register_prompts(*prompts) + end + # Mount the MCP middleware in a Rails application # @param app [Rails::Application] The Rails application # @param options [Hash] Options for the middleware diff --git a/lib/generators/fast_mcp/install/install_generator.rb b/lib/generators/fast_mcp/install/install_generator.rb index 8ca4a56..24cc749 100644 --- a/lib/generators/fast_mcp/install/install_generator.rb +++ b/lib/generators/fast_mcp/install/install_generator.rb @@ -16,6 +16,7 @@ def copy_initializer def create_directories empty_directory 'app/tools' empty_directory 'app/resources' + empty_directory 'app/prompts' end def copy_application_tool @@ -34,6 +35,14 @@ def copy_sample_resource template 'sample_resource.rb', 'app/resources/sample_resource.rb' end + def copy_application_prompt + template 'application_prompt.rb', 'app/prompts/application_prompt.rb' + end + + def copy_sample_prompt + template 'sample_prompt.rb', 'app/prompts/sample_prompt.rb' + end + def display_post_install_message say "\n=========================================================" say 'FastMcp was successfully installed! ๐ŸŽ‰' @@ -41,6 +50,7 @@ def display_post_install_message say 'You can now create:' say ' โ€ข Tools in app/tools/' say ' โ€ข Resources in app/resources/' + say ' โ€ข Prompts in app/prompts/' say "\n" say 'Check config/initializers/fast_mcp.rb to configure the middleware.' say "=========================================================\n" diff --git a/lib/generators/fast_mcp/install/templates/application_prompt.rb b/lib/generators/fast_mcp/install/templates/application_prompt.rb new file mode 100644 index 0000000..24288f4 --- /dev/null +++ b/lib/generators/fast_mcp/install/templates/application_prompt.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ApplicationPrompt < ActionPrompt::Base + # Base class for all prompts in this application + # ActionPrompt::Base is an alias for FastMcp::Prompt + + # Add common functionality for all prompts here +end \ No newline at end of file diff --git a/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb b/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb index e2eac52..b804d69 100644 --- a/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +++ b/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb @@ -6,9 +6,11 @@ # In Rails applications, you can use: # - ActionTool::Base as an alias for FastMcp::Tool # - ActionResource::Base as an alias for FastMcp::Resource +# - ActionPrompt::Base as an alias for FastMcp::Prompt # # All your tools should inherit from ApplicationTool which already uses ActionTool::Base, -# and all your resources should inherit from ApplicationResource which uses ActionResource::Base. +# all your resources should inherit from ApplicationResource which uses ActionResource::Base, +# and all your prompts should inherit from ApplicationPrompt which uses ActionPrompt::Base. # Mount the MCP middleware in your Rails application # You can customize the options below to fit your needs. @@ -33,10 +35,13 @@ # FastMcp will automatically discover and register: # - All classes that inherit from ApplicationTool (which uses ActionTool::Base) # - All classes that inherit from ApplicationResource (which uses ActionResource::Base) + # - All classes that inherit from ApplicationPrompt (which uses ActionPrompt::Base) server.register_tools(*ApplicationTool.descendants) server.register_resources(*ApplicationResource.descendants) - # alternatively, you can register tools and resources manually: + server.register_prompts(*ApplicationPrompt.descendants) + # alternatively, you can register tools, resources, and prompts manually: # server.register_tool(MyTool) # server.register_resource(MyResource) + # server.register_prompt(MyPrompt) end end diff --git a/lib/generators/fast_mcp/install/templates/sample_prompt.rb b/lib/generators/fast_mcp/install/templates/sample_prompt.rb new file mode 100644 index 0000000..a39940e --- /dev/null +++ b/lib/generators/fast_mcp/install/templates/sample_prompt.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class SamplePrompt < ApplicationPrompt + # prompt_name is auto-generated as "sample" from class name + description "A sample prompt to demonstrate functionality" + + # Define arguments using Dry::Schema syntax + arguments do + required(:input).filled(:string).description("The input to process") + optional(:context).filled(:string).description("Additional context") + end + + # Implement the call method to generate messages + def call(input:, context: nil) + # Build the user message + user_message = "Process this input: #{input}" + user_message += "\n\nAdditional context: #{context}" if context + + # Using the messages helper to create properly formatted messages + messages( + assistant: "I'm here to help you process your input.", + user: user_message + ) + end +end \ No newline at end of file diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb new file mode 100644 index 0000000..407bf99 --- /dev/null +++ b/lib/mcp/prompt.rb @@ -0,0 +1,655 @@ +# frozen_string_literal: true + +require 'dry-schema' +require 'erb' +require 'base64' + +module FastMcp + # Builder class for creating messages with a fluent API + # + # The MessageBuilder provides a convenient way to construct arrays of messages + # using a fluent interface. It supports method chaining and provides convenience + # methods for common message roles. + # + # @example Basic usage + # builder = MessageBuilder.new + # builder.user("Hello").assistant("Hi there!") + # messages = builder.messages + # + # @example Block-style usage + # messages = MessageBuilder.new.tap do |b| + # b.user("What's the weather?") + # b.assistant("It's sunny today!") + # end.messages + # + # @since 1.6.0 + class MessageBuilder + # Initialize a new MessageBuilder + # + # @since 1.6.0 + def initialize + @messages = [] + end + + # Array of messages built by this builder + # + # @return [Array] Array of message hashes with :role and :content keys + # @since 1.6.0 + attr_reader :messages + + # Add a message with specified role and content + # + # @param role [String, Symbol] The role of the message (e.g., 'user', 'assistant') + # @param content [String, Hash] The content of the message + # @return [MessageBuilder] Returns self for method chaining + # @example + # builder.add_message(role: 'user', content: 'Hello world') + # @since 1.6.0 + def add_message(role:, content:) + @messages << { role: role.to_s, content: content } + self + end + + # Convenience method for adding user messages + # + # @param content [String, Hash] The content of the user message + # @return [MessageBuilder] Returns self for method chaining + # @example + # builder.user("What's the weather like today?") + # @since 1.6.0 + def user(content) + add_message(role: 'user', content: content) + end + + # Convenience method for adding assistant messages + # + # @param content [String, Hash] The content of the assistant message + # @return [MessageBuilder] Returns self for method chaining + # @example + # builder.assistant("It's sunny and 75 degrees!") + # @since 1.6.0 + def assistant(content) + add_message(role: 'assistant', content: content) + end + end + + # Main Prompt class that represents an MCP Prompt + # + # The Prompt class provides a framework for creating structured message templates + # for Language Model interactions following the Model Context Protocol (MCP) specification. + # It supports argument validation, authorization, message creation with multiple content + # types, and flexible template patterns. + # + # Key features: + # - Dry::Schema-based argument validation + # - Authorization blocks for access control + # - Support for text, image, and resource content types + # - Fluent message building API + # - Template interpolation and rendering + # - Metadata, tags, and annotations support + # + # @example Basic prompt implementation + # class GreetingPrompt < FastMcp::Prompt + # arguments do + # required(:name).filled(:string) + # end + # + # def self.call(name:) + # new.messages(user: "Hello #{name}!") + # end + # end + # + # @example Advanced prompt with authorization + # class AdminPrompt < FastMcp::Prompt + # authorize { headers['role'] == 'admin' } + # + # def self.call + # new.messages(user: "Admin-only content") + # end + # end + # + # @see MessageBuilder + # @see FastMcp::Resource + # @see FastMcp::Tool + # @since 1.6.0 + class Prompt + # Exception raised when prompt arguments fail validation + # + # @since 1.6.0 + class InvalidArgumentsError < StandardError; end + + # Valid message roles supported by the MCP protocol + # + # @return [Hash] Mapping of role symbols to string values + # @since 1.6.0 + ROLES = { + user: 'user', + assistant: 'assistant' + }.freeze + + # Content type constant for text content + # @since 1.6.0 + CONTENT_TYPE_TEXT = 'text' + + # Content type constant for image content + # @since 1.6.0 + CONTENT_TYPE_IMAGE = 'image' + + # Content type constant for resource content + # @since 1.6.0 + CONTENT_TYPE_RESOURCE = 'resource' + + class << self + # Server instance associated with this prompt class + # + # @return [FastMcp::Server, nil] The server instance + # @since 1.6.0 + attr_accessor :server + + # Authorization blocks defined for this prompt class + # + # @return [Array, nil] Array of authorization blocks + # @since 1.6.0 + attr_reader :authorization_blocks + + # Define the input schema for prompt arguments using Dry::Schema + # + # @param block [Proc] Block containing Dry::Schema definition + # @return [Dry::Schema::JSON] The configured schema + # @example + # arguments do + # required(:name).filled(:string) + # optional(:age).filled(:integer) + # end + # @since 1.6.0 + def arguments(&block) + @input_schema = Dry::Schema.JSON(&block) + end + + # Get or set tags for this prompt + # + # @param tag_list [Array] Tags to assign to the prompt + # @return [Array] Current tags when called without arguments + # @example Setting tags + # tags :utility, :text_processing + # @example Getting tags + # tags # => [:utility, :text_processing] + # @since 1.6.0 + def tags(*tag_list) + if tag_list.empty? + @tags || [] + else + @tags = tag_list.flatten.map(&:to_sym) + end + end + + # Get or set metadata for this prompt + # + # @param key [String, Symbol, nil] Metadata key + # @param value [Object, nil] Metadata value + # @return [Hash, Object] Full metadata hash, specific value, or nil + # @example Setting metadata + # metadata(:author, "John Doe") + # metadata("version", "1.0.0") + # @example Getting all metadata + # metadata # => { author: "John Doe", version: "1.0.0" } + # @example Getting specific metadata + # metadata(:author) # => "John Doe" + # @since 1.6.0 + def metadata(key = nil, value = nil) + @metadata ||= {} + if key.nil? + @metadata + elsif value.nil? + @metadata[key] + else + @metadata[key] = value + end + end + + # Get or set annotations for this prompt + # + # @param annotations_hash [Hash, nil] Hash of annotations to set + # @return [Hash] Current annotations + # @example Setting annotations + # annotations({ + # audience: "developers", + # level: "intermediate" + # }) + # @example Getting annotations + # annotations # => { audience: "developers", level: "intermediate" } + # @since 1.6.0 + def annotations(annotations_hash = nil) + return @annotations || {} if annotations_hash.nil? + + @annotations = annotations_hash + end + + # Add an authorization block for this prompt + # + # Authorization blocks are executed to determine if a user is authorized + # to use this prompt. All blocks must return truthy values for authorization. + # + # @param block [Proc] Authorization logic block + # @return [Array] Current authorization blocks + # @example Simple authorization + # authorize { headers['role'] == 'admin' } + # @example Authorization with arguments + # authorize { |name:| name != 'blocked_user' } + # @since 1.6.0 + def authorize(&block) + @authorization_blocks ||= [] + @authorization_blocks.push block + end + + # Get the input schema for this prompt + # + # @return [Dry::Schema::JSON] The input schema, or empty schema if none defined + # @since 1.6.0 + def input_schema + @input_schema ||= Dry::Schema.JSON + end + + # Get or set the prompt name + # + # When no name is provided, auto-generates a name from the class name + # by removing "Prompt" suffix and converting to snake_case. + # + # @param name [String, Symbol, nil] Name to set for the prompt + # @return [String] The prompt name + # @example Setting custom name + # prompt_name "my_custom_prompt" + # @example Auto-generated name + # # For class "GreetingPrompt" + # prompt_name # => "greeting" + # @since 1.6.0 + def prompt_name(name = nil) + if name.nil? + return @name if @name + + # Get the actual class name without namespace + class_name = self.name.to_s.split('::').last + # Remove "Prompt" suffix and convert to snake_case + return class_name.gsub(/Prompt$/, '').gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '') + end + + @name = name + end + + # Get or set the prompt description + # + # @param description [String, nil] Description to set + # @return [String, nil] Current description + # @example + # description "A prompt for generating greetings" + # @since 1.6.0 + def description(description = nil) + return @description if description.nil? + + @description = description + end + + # Execute the prompt with the given arguments + # + # This is an abstract method that must be implemented by subclasses. + # It should return an array of messages or use the messages helper. + # + # @param args [Hash] Arguments passed to the prompt + # @return [Array] Array of message hashes + # @raise [NotImplementedError] Always raised as this is abstract + # @abstract Subclasses must implement this method + # @example + # def self.call(name:) + # new.messages(user: "Hello #{name}!") + # end + # @since 1.6.0 + def call(**args) + raise NotImplementedError, 'Subclasses must implement the call method' + end + + # Convert the input schema to JSON Schema format + # + # @return [Hash, nil] JSON Schema representation, or nil if no schema defined + # @since 1.6.0 + def input_schema_to_json + return nil unless @input_schema + + compiler = SchemaCompiler.new + compiler.process(@input_schema) + end + end + + # Initialize a new Prompt instance + # + # @param headers [Hash] Request headers, typically used for authorization + # @example + # prompt = MyPrompt.new(headers: { 'role' => 'admin' }) + # @since 1.6.0 + def initialize(headers: {}) + @headers = headers + end + + # Request headers for this prompt instance + # + # @return [Hash] The headers passed during initialization + # @since 1.6.0 + attr_reader :headers + + # Execute the prompt with automatic schema validation + # + # This method validates the provided arguments against the defined schema + # before executing the prompt. If validation fails, an exception is raised. + # + # @param args [Hash] Arguments to validate and pass to the prompt + # @return [Array] Array of message hashes from the prompt execution + # @raise [InvalidArgumentsError] When arguments fail schema validation + # @example + # prompt = GreetingPrompt.new + # messages = prompt.call_with_schema_validation!(name: "Alice") + # @since 1.6.0 + def call_with_schema_validation!(**args) + arg_validation = self.class.input_schema.call(args) + raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any? + + call(**args) + end + + # Check if the current request is authorized to use this prompt + # + # Evaluates all authorization blocks defined for this prompt class hierarchy. + # All authorization blocks must return truthy values for the request to be authorized. + # + # @param args [Hash] Arguments to pass to authorization blocks that accept parameters + # @return [Boolean] true if authorized, false otherwise + # @raise [InvalidArgumentsError] When arguments fail schema validation + # @example + # prompt = AdminPrompt.new(headers: { 'role' => 'admin' }) + # if prompt.authorized?(user_id: 123) + # # Execute admin-only prompt + # end + # @since 1.6.0 + def authorized?(**args) + auth_checks = self.class.ancestors.filter_map do |ancestor| + ancestor.ancestors.include?(FastMcp::Prompt) && + ancestor.instance_variable_get(:@authorization_blocks) + end.flatten + + return true if auth_checks.empty? + + arg_validation = self.class.input_schema.call(args) + raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any? + + auth_checks.all? do |auth_check| + if auth_check.parameters.empty? + instance_exec(&auth_check) + else + instance_exec(**args, &auth_check) + end + end + end + + # Create a single message with the given role and content + # + # @param role [String, Symbol] The role of the message ('user' or 'assistant') + # @param content [String, Hash] The content of the message + # @return [Hash] A message hash with :role and :content keys + # @raise [ArgumentError] When role or content is invalid + # @example + # message = prompt.message(role: 'user', content: 'Hello!') + # # => { role: 'user', content: { type: 'text', text: 'Hello!' } } + # @since 1.6.0 + def message(role:, content:) + validate_role(role) + validate_content(content) + + { + role: role, + content: content + } + end + + # Create multiple messages from various input formats + # + # This method supports three different ways to create messages: + # 1. Hash of role => content pairs (backward compatibility) + # 2. Array of message hashes with :role and :content keys + # 3. Block-based builder pattern using MessageBuilder + # + # @param messages_input [Hash, Array, nil] Input messages in hash or array format + # @param block [Proc] Optional block for builder pattern + # @return [Array] Array of message hashes with :role and :content keys + # @raise [ArgumentError] When input format is invalid or no messages provided + # @example Hash format (backward compatibility) + # messages(user: "Hello", assistant: "Hi there!") + # @example Array format + # messages([ + # { role: 'user', content: 'Hello' }, + # { role: 'assistant', content: 'Hi there!' } + # ]) + # @example Block format + # messages do + # user "Hello" + # assistant "Hi there!" + # end + # @since 1.6.0 + def messages(messages_input = nil, &block) + if block_given? + builder = MessageBuilder.new + builder.instance_eval(&block) + return builder.messages + end + + raise ArgumentError, 'At least one message must be provided' if messages_input.nil? || messages_input.empty? + + case messages_input + when Array + process_array_messages(messages_input) + when Hash + process_hash_messages(messages_input) + else + raise ArgumentError, 'Messages input must be an Array or Hash' + end + end + + private + + # Process array of message hashes + def process_array_messages(messages_array) + messages_array.map do |message_hash| + unless message_hash.is_a?(Hash) && message_hash[:role] && message_hash[:content] + raise ArgumentError, 'Each message must be a hash with :role and :content keys' + end + + role = message_hash[:role].to_s + content = message_hash[:content] + + validate_role(role) + processed_content = content.is_a?(Hash) && content[:type] ? content : content_from(content) + validate_content(processed_content) + + { + role: role, + content: processed_content + } + end + end + + # Process hash of role => content pairs (backward compatibility) + def process_hash_messages(messages_hash) + messages_hash.map do |role_key, content| + role = role_key.to_s.gsub(/_\d+$/, '').to_sym + { role: ROLES.fetch(role), content: content_from(content) } + end + end + + public + + # Extract and normalize content from various input formats + # + # This helper method converts different content formats into standardized + # content objects with proper type information. + # + # @param content [String, Hash] Content in various formats + # @return [Hash] Normalized content hash with :type key + # @example String content + # content_from("Hello") # => { type: 'text', text: 'Hello' } + # @example Hash with text + # content_from(text: "Hello") # => { type: 'text', text: 'Hello' } + # @example Hash with image data + # content_from(data: "base64data", mimeType: "image/png") + # # => { type: 'image', data: 'base64data', mimeType: 'image/png' } + # @since 1.6.0 + def content_from(content) + if content.is_a?(String) + text_content(content) + elsif content.key?(:text) + text_content(content[:text]) + elsif content.key?(:data) && content.key?(:mimeType) + image_content(content[:data], content[:mimeType]) + elsif content.key?(:resource) + hash + else + text_content('unsupported content') + end + end + + # Create a text content object + # + # @param text [String] The text content + # @return [Hash] Text content hash with type and text keys + # @example + # text_content("Hello world") + # # => { type: 'text', text: 'Hello world' } + # @since 1.6.0 + def text_content(text) + { + type: CONTENT_TYPE_TEXT, + text: text + } + end + + # Create an image content object + # + # @param data [String] Base64-encoded image data + # @param mime_type [String] MIME type of the image (e.g., 'image/png', 'image/jpeg') + # @return [Hash] Image content hash with type, data, and mimeType keys + # @example + # image_content("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", "image/png") + # # => { type: 'image', data: '...', mimeType: 'image/png' } + # @since 1.6.0 + def image_content(data, mime_type) + { + type: CONTENT_TYPE_IMAGE, + data: data, + mimeType: mime_type + } + end + + # Create a resource content object + # + # @param uri [String] URI of the resource + # @param mime_type [String] MIME type of the resource + # @param text [String, nil] Optional text content of the resource + # @param blob [String, nil] Optional binary blob content (base64-encoded) + # @return [Hash] Resource content hash with type and resource keys + # @raise [ArgumentError] When neither text nor blob is provided + # @example With text content + # resource_content("file://readme.txt", "text/plain", text: "Hello world") + # @example With blob content + # resource_content("file://image.png", "image/png", blob: "base64data...") + # @since 1.6.0 + def resource_content(uri, mime_type, text: nil, blob: nil) + resource = { + uri: uri, + mimeType: mime_type + } + + resource[:text] = text if text + resource[:blob] = blob if blob + + { + type: CONTENT_TYPE_RESOURCE, + resource: resource + } + end + + # Validate that a role is one of the supported values + # + # @param role [String, Symbol] The role to validate + # @return [String] The validated role string + # @raise [ArgumentError] When role is not supported + # @example + # validate_role('user') # => 'user' + # validate_role(:assistant) # => 'assistant' + # @since 1.6.0 + def validate_role(role) + # Convert role to symbol if it's a string + role_key = role.is_a?(String) ? role.to_sym : role + + # Use fetch with a block for better error handling + ROLES.fetch(role_key) do + raise ArgumentError, "Invalid role: #{role}. Must be one of: #{ROLES.keys.join(', ')}" + end + end + + # Validate that content has the correct structure and required fields + # + # @param content [Hash] The content object to validate + # @return [Hash] The validated content (same as input if valid) + # @raise [ArgumentError] When content structure or data is invalid + # @example Valid text content + # validate_content({ type: 'text', text: 'Hello' }) + # @example Valid image content + # validate_content({ type: 'image', data: 'base64...', mimeType: 'image/png' }) + # @since 1.6.0 + def validate_content(content) + unless content.is_a?(Hash) && content[:type] + raise ArgumentError, "Invalid content: #{content}. Must be a hash with a :type key" + end + + case content[:type] + when CONTENT_TYPE_TEXT + raise ArgumentError, 'Missing :text in text content' unless content[:text] + when CONTENT_TYPE_IMAGE + raise ArgumentError, 'Missing :data in image content' unless content[:data] + raise ArgumentError, 'Missing :mimeType in image content' unless content[:mimeType] + + # Validate that data is a string + unless content[:data].is_a?(String) + raise ArgumentError, 'Image :data must be a string containing base64-encoded data' + end + + # Validate that data is valid base64 + begin + # Try to decode the base64 data + Base64.strict_decode64(content[:data]) + rescue ArgumentError + raise ArgumentError, 'Image :data must be valid base64-encoded data' + end + when CONTENT_TYPE_RESOURCE + validate_resource_content(content[:resource]) + else + raise ArgumentError, "Invalid content type: #{content[:type]}" + end + end + + # Validate resource content structure and required fields + # + # @param resource [Hash] The resource object to validate + # @return [Hash] The validated resource (same as input if valid) + # @raise [ArgumentError] When resource structure is invalid or required fields are missing + # @example Valid resource + # validate_resource_content({ + # uri: 'file://readme.txt', + # mimeType: 'text/plain', + # text: 'Hello world' + # }) + # @since 1.6.0 + def validate_resource_content(resource) + raise ArgumentError, 'Missing :resource in resource content' unless resource + raise ArgumentError, 'Missing :uri in resource content' unless resource[:uri] + raise ArgumentError, 'Missing :mimeType in resource content' unless resource[:mimeType] + raise ArgumentError, 'Resource must have either :text or :blob' unless resource[:text] || resource[:blob] + end + end +end diff --git a/lib/mcp/railtie.rb b/lib/mcp/railtie.rb index 6938b89..afccf15 100644 --- a/lib/mcp/railtie.rb +++ b/lib/mcp/railtie.rb @@ -17,22 +17,30 @@ module ::ActionResource end end +unless defined?(ActionPrompt) + module ::ActionPrompt + Base = FastMcp::Prompt + end +end + module FastMcp # Railtie for integrating Fast MCP with Rails applications class Railtie < Rails::Railtie - # Add tools and resources directories to autoload paths + # Add tools, resources, and prompts directories to autoload paths initializer 'fast_mcp.setup_autoload_paths' do |app| app.config.autoload_paths += %W[ #{app.root}/app/tools #{app.root}/app/resources + #{app.root}/app/prompts ] end - # Auto-register all tools and resources after the application is fully loaded + # Auto-register all tools, resources, and prompts after the application is fully loaded config.after_initialize do - # Load all files in app/tools and app/resources directories + # Load all files in app/tools, app/resources, and app/prompts directories Dir[Rails.root.join('app', 'tools', '**', '*.rb')].each { |f| require f } Dir[Rails.root.join('app', 'resources', '**', '*.rb')].each { |f| require f } + Dir[Rails.root.join('app', 'prompts', '**', '*.rb')].each { |f| require f } end # Add rake tasks diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 8b62e1b..ae22f73 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -14,7 +14,7 @@ module FastMcp class Server include ServerFiltering - attr_reader :name, :version, :tools, :resources, :capabilities + attr_reader :name, :version, :tools, :resources, :capabilities, :prompts DEFAULT_CAPABILITIES = { resources: { @@ -23,6 +23,9 @@ class Server }, tools: { listChanged: true + }, + prompts: { + listChanged: true } }.freeze @@ -31,6 +34,7 @@ def initialize(name:, version:, logger: FastMcp::Logger.new, capabilities: {}) @version = version @tools = {} @resources = [] + @prompts = {} @resource_subscriptions = {} @logger = logger @request_id = 0 @@ -39,6 +43,7 @@ def initialize(name:, version:, logger: FastMcp::Logger.new, capabilities: {}) @capabilities = DEFAULT_CAPABILITIES.dup @tool_filters = [] @resource_filters = [] + @prompt_filters = [] # Merge with provided capabilities @capabilities.merge!(capabilities) if capabilities.is_a?(Hash) @@ -97,12 +102,32 @@ def remove_resource(uri) end end + # Register multiple prompts at once + # @param prompts [Array] Prompts to register + def register_prompts(*prompts) + prompts.each do |prompt| + register_prompt(prompt) + end + end + + # Register a prompt with the server + def register_prompt(prompt) + @prompts[prompt.prompt_name] = prompt + @logger.info("Registered prompt: #{prompt.prompt_name}") + prompt.server = self + # Notify subscribers about the list change + notify_prompt_list_changed if @transport + + prompt + end + # Start the server using stdio transport def start @logger.transport = :stdio @logger.info("Starting MCP server: #{@name} v#{@version}") @logger.info("Available tools: #{@tools.keys.join(', ')}") @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}") + @logger.info("Available prompts: #{@prompts.keys.join(', ')}") # Use STDIO transport by default @transport_klass = FastMcp::Transports::StdioTransport @@ -115,6 +140,7 @@ def start_rack(app, options = {}) @logger.info("Starting MCP server as Rack middleware: #{@name} v#{@version}") @logger.info("Available tools: #{@tools.keys.join(', ')}") @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}") + @logger.info("Available prompts: #{@prompts.keys.join(', ')}") # Use Rack transport transport_klass = FastMcp::Transports::RackTransport @@ -129,6 +155,7 @@ def start_authenticated_rack(app, options = {}) @logger.info("Starting MCP server as Authenticated Rack middleware: #{@name} v#{@version}") @logger.info("Available tools: #{@tools.keys.join(', ')}") @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}") + @logger.info("Available prompts: #{@prompts.keys.join(', ')}") # Use Rack transport transport_klass = FastMcp::Transports::AuthenticatedRackTransport @@ -174,6 +201,10 @@ def handle_request(json_str, headers: {}) # rubocop:disable Metrics/MethodLength handle_tools_list(id) when 'tools/call' handle_tools_call(params, headers, id) + when 'prompts/list' + handle_prompts_list(params, id) + when 'prompts/get' + handle_prompts_get(params, id) when 'resources/list' handle_resources_list(id) when 'resources/templates/list' @@ -200,7 +231,9 @@ def notify_resource_updated(uri) @logger.warn("Notifying subscribers about resource update: #{uri}, #{@resource_subscriptions.inspect}") return unless @client_initialized && @resource_subscriptions.key?(uri) - resource = @resources[uri] + resource = @resources.find { |r| r.uri == uri } + return unless resource + notification = { jsonrpc: '2.0', method: 'notifications/resources/updated', @@ -218,6 +251,18 @@ def read_resource(uri) @resources.find { |r| r.match(uri) } end + # Notify clients about prompt list changes + def notify_prompt_list_changed + return unless @client_initialized + + notification = { + jsonrpc: '2.0', + method: 'notifications/prompts/listChanged' + } + + @transport.send_message(notification) + end + private PROTOCOL_VERSION = '2024-11-05' @@ -433,6 +478,95 @@ def handle_resources_unsubscribe(params, id) send_result({ unsubscribed: true }, id) end + # Handle prompts/list request + def handle_prompts_list(params, id) + # We acknowledge the cursor parameter but don't use it for pagination in this implementation + # The cursor is included in the response for compatibility with the spec + + # TODO: We don't have pagination utils + # next_cursor = params['cursor'] + + # Apply filtering if prompt filters are configured + prompts_to_list = if @prompt_filters.any? + apply_prompt_filters( + { + 'method' => 'prompts/list', + 'params' => params, + 'id' => id + } + ) + else + @prompts.values + end + + prompts_list = prompts_to_list.map do |prompt| + prompt_data = { + name: prompt.prompt_name, + description: prompt.description || '' + } + + # Add arguments if the prompt has an input schema + if prompt.input_schema_to_json + arguments = [] + properties = prompt.input_schema_to_json[:properties] || {} + required = prompt.input_schema_to_json[:required] || [] + + properties.each do |name, property| + arg = { + name: name.to_s, + description: property[:description] || '', + required: required.include?(name.to_s) + } + arguments << arg + end + + prompt_data[:arguments] = arguments unless arguments.empty? + end + + prompt_data + end + + # TODO: we don't pagination utils + # send_result({ prompts: prompts_list, nextCursor: next_cursor }, id) + send_result({ prompts: prompts_list }, id) + end + + # Handle prompts/get request + def handle_prompts_get(params, id) + prompt_name = params['name'] + arguments = params['arguments'] || {} + + return send_error(-32_602, 'Invalid params: missing prompt name', id) unless prompt_name + + prompt = @prompts[prompt_name] + return send_error(-32_602, "Prompt not found: #{prompt_name}", id) unless prompt + + begin + # Convert string keys to symbols for Ruby + symbolized_args = symbolize_keys(arguments) + result = prompt.new.call_with_schema_validation!(**symbolized_args) + + # Ensure the result has the expected structure + unless result.is_a?(Array) && result.all? { |msg| msg[:role] && msg[:content] } + raise "Invalid prompt result format: #{result.inspect}" + end + + # Format the response according to the MCP specification + formatted_result = { + description: prompt.description || '', + messages: result + } + + send_result(formatted_result, id) + rescue FastMcp::Prompt::InvalidArgumentsError => e + @logger.error("Invalid arguments for prompt #{prompt_name}: #{e.message}") + send_error(-32_602, e.message, id) + rescue StandardError => e + @logger.error("Error executing prompt #{prompt_name}: #{e.message}") + send_error(-32_603, "Internal error: #{e.message}", id) + end + end + # Notify clients about resource list changes def notify_resource_list_changed return unless @client_initialized diff --git a/lib/mcp/server_filtering.rb b/lib/mcp/server_filtering.rb index 34d2b34..da9b710 100644 --- a/lib/mcp/server_filtering.rb +++ b/lib/mcp/server_filtering.rb @@ -13,9 +13,14 @@ def filter_resources(&block) @resource_filters << block if block_given? end + # Add filter for prompts + def filter_prompts(&block) + @prompt_filters << block if block_given? + end + # Check if filters are configured def contains_filters? - @tool_filters.any? || @resource_filters.any? + @tool_filters.any? || @resource_filters.any? || @prompt_filters.any? end # Create a filtered copy for a specific request @@ -33,6 +38,7 @@ def create_filtered_copy(request) # Apply filters and register items register_filtered_tools(filtered_server, request) register_filtered_resources(filtered_server, request) + register_filtered_prompts(filtered_server, request) filtered_server end @@ -59,6 +65,16 @@ def register_filtered_resources(filtered_server, request) end end + # Apply prompt filters and register filtered prompts + def register_filtered_prompts(filtered_server, request) + filtered_prompts = apply_prompt_filters(request) + + # Register filtered prompts + filtered_prompts.each do |prompt| + filtered_server.register_prompt(prompt) + end + end + # Apply all tool filters to the tools collection def apply_tool_filters(request) filtered_tools = @tools.values @@ -76,5 +92,14 @@ def apply_resource_filters(request) end filtered_resources end + + # Apply all prompt filters to the prompts collection + def apply_prompt_filters(request) + filtered_prompts = @prompts.values + @prompt_filters.each do |filter| + filtered_prompts = filter.call(request, filtered_prompts) + end + filtered_prompts + end end end diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 178b110..7db3a86 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FastMcp - VERSION = '1.5.0' + VERSION = '1.6.0' end diff --git a/spec/mcp/prompt_spec.rb b/spec/mcp/prompt_spec.rb new file mode 100644 index 0000000..5a03c52 --- /dev/null +++ b/spec/mcp/prompt_spec.rb @@ -0,0 +1,900 @@ +# frozen_string_literal: true +require 'spec_helper' +require 'base64' + +RSpec.describe FastMcp::MessageBuilder do + let(:builder) { described_class.new } + + describe '#initialize' do + it 'initializes with empty messages array' do + expect(builder.messages).to eq([]) + end + end + + describe '#add_message' do + it 'adds a message with specified role and content' do + builder.add_message(role: 'user', content: 'Hello!') + expect(builder.messages).to eq([{ role: 'user', content: 'Hello!' }]) + end + + it 'returns self for method chaining' do + result = builder.add_message(role: 'user', content: 'Hello!') + expect(result).to eq(builder) + end + + it 'supports multiple messages' do + builder.add_message(role: 'user', content: 'First') + .add_message(role: 'assistant', content: 'Second') + + expect(builder.messages).to eq([ + { role: 'user', content: 'First' }, + { role: 'assistant', content: 'Second' } + ]) + end + end + + describe '#user' do + it 'adds a user message' do + builder.user('Hello from user') + expect(builder.messages).to eq([{ role: 'user', content: 'Hello from user' }]) + end + end + + describe '#assistant' do + it 'adds an assistant message' do + builder.assistant('Hello from assistant') + expect(builder.messages).to eq([{ role: 'assistant', content: 'Hello from assistant' }]) + end + end + + describe 'multiple same-role messages' do + it 'supports multiple user messages' do + builder.user('First user message') + .user('Second user message') + + expect(builder.messages).to eq([ + { role: 'user', content: 'First user message' }, + { role: 'user', content: 'Second user message' } + ]) + end + + it 'supports complex conversation patterns' do + builder.user('Example 1') + .assistant('Response 1') + .user('Example 2') + .assistant('Response 2') + .user('Follow-up question') + + expect(builder.messages.size).to eq(5) + expect(builder.messages[0]).to eq({ role: 'user', content: 'Example 1' }) + expect(builder.messages[1]).to eq({ role: 'assistant', content: 'Response 1' }) + expect(builder.messages[4]).to eq({ role: 'user', content: 'Follow-up question' }) + end + end +end + +RSpec.describe FastMcp::Prompt do + let(:roles) { { assistant: 'assistant', user: 'user' } } + + describe '.prompt_name' do + it 'sets and returns the name' do + test_class = Class.new(described_class) + test_class.prompt_name('custom_prompt') + + expect(test_class.prompt_name).to eq('custom_prompt') + end + + it 'returns the current name when called with nil' do + test_class = Class.new(described_class) + test_class.prompt_name('custom_prompt') + + expect(test_class.prompt_name(nil)).to eq('custom_prompt') + end + + it 'returns a snake_cased version of the class name for named classes when name is not set' do + # Create a class with a known name in the FastMcp namespace + module FastMcp + class ExampleTestPrompt < Prompt; end + end + + expect(FastMcp::ExampleTestPrompt.prompt_name).to eq('example_test') + + # Clean up + FastMcp.send(:remove_const, :ExampleTestPrompt) + end + end + + describe '.description' do + it 'sets and returns the description' do + test_class = Class.new(described_class) + test_class.description('A test prompt') + + expect(test_class.description).to eq('A test prompt') + end + + it 'returns the current description when called with nil' do + test_class = Class.new(described_class) + test_class.description('A test prompt') + + expect(test_class.description(nil)).to eq('A test prompt') + end + end + + describe '.arguments' do + it 'sets up the input schema using Dry::Schema' do + test_class = Class.new(described_class) do + arguments do + required(:code).filled(:string) + optional(:programming_language).filled(:string) + end + end + + expect(test_class.input_schema).to be_a(Dry::Schema::JSON) + end + end + + describe '.input_schema_to_json' do + it 'returns nil when no input schema is defined' do + test_class = Class.new(described_class) + expect(test_class.input_schema_to_json).to be_nil + end + + it 'converts the schema to JSON format using SchemaCompiler' do + test_class = Class.new(described_class) do + arguments do + required(:code).filled(:string).description('Code to analyze') + optional(:programming_language).filled(:string).description('Language the code is written in') + end + end + + json_schema = test_class.input_schema_to_json + expect(json_schema[:type]).to eq('object') + expect(json_schema[:properties][:code][:type]).to eq('string') + expect(json_schema[:properties][:code][:description]).to eq('Code to analyze') + expect(json_schema[:properties][:programming_language][:type]).to eq('string') + expect(json_schema[:properties][:programming_language][:description]).to eq('Language the code is written in') + expect(json_schema[:required]).to include('code') + expect(json_schema[:required]).not_to include('programming_language') + end + end + + describe '.call' do + it 'raises NotImplementedError by default' do + test_class = Class.new(described_class) + expect { test_class.call }.to raise_error(NotImplementedError, 'Subclasses must implement the call method') + end + end + + describe '#call_with_schema_validation!' do + let(:test_class) do + Class.new(described_class) do + arguments do + required(:code).filled(:string) + optional(:programming_language).filled(:string) + end + + def call(code:, programming_language: nil) + messages( + assistant: "I'll review your #{programming_language || 'code'}.", + user: "Please review: #{code}" + ) + end + end + end + + let(:instance) { test_class.new } + + + it 'validates arguments against the schema and calls the method' do + result = instance.call_with_schema_validation!(code: 'def hello(): pass', programming_language: 'Python') + expect(result).to be_an(Array) + expect(result.size).to eq(2) + expect(result[0][:role]).to eq('assistant') + expect(result[0][:content][:text]).to eq("I'll review your Python.") + expect(result[1][:role]).to eq('user') + expect(result[1][:content][:text]).to eq('Please review: def hello(): pass') + end + + it 'works with optional parameters omitted' do + result = instance.call_with_schema_validation!(code: 'def hello(): pass') + expect(result).to be_an(Array) + expect(result.size).to eq(2) + expect(result[0][:role]).to eq('assistant') + expect(result[0][:content][:text]).to eq("I'll review your code.") + expect(result[1][:role]).to eq('user') + expect(result[1][:content][:text]).to eq('Please review: def hello(): pass') + end + + it 'raises InvalidArgumentsError when validation fails' do + expect do + instance.call_with_schema_validation!(programming_language: 'Python') + end.to raise_error(FastMcp::Prompt::InvalidArgumentsError) + end + end + + describe '#message' do + let(:instance) { described_class.new } + + it 'creates a valid message with text content' do + message = instance.message( + role: 'user', + content: { + type: 'text', + text: 'Hello, world!' + } + ) + + expect(message[:role]).to eq('user') + expect(message[:content][:type]).to eq('text') + expect(message[:content][:text]).to eq('Hello, world!') + end + + it 'creates a valid message with image content' do + # Using valid base64 data for testing + valid_base64 = Base64.strict_encode64('test image data') + + message = instance.message( + role: 'user', + content: { + type: 'image', + data: valid_base64, + mimeType: 'image/png' + } + ) + + expect(message[:role]).to eq('user') + expect(message[:content][:type]).to eq('image') + expect(message[:content][:data]).to eq(valid_base64) + expect(message[:content][:mimeType]).to eq('image/png') + end + + it 'creates a valid message with resource content' do + message = instance.message( + role: 'assistant', + content: { + type: 'resource', + resource: { + uri: 'resource://example', + mimeType: 'text/plain', + text: 'Resource content' + } + } + ) + + expect(message[:role]).to eq('assistant') + expect(message[:content][:type]).to eq('resource') + expect(message[:content][:resource][:uri]).to eq('resource://example') + expect(message[:content][:resource][:mimeType]).to eq('text/plain') + expect(message[:content][:resource][:text]).to eq('Resource content') + end + + it 'raises an error for invalid role' do + expect do + instance.message( + role: 'invalid_role', + content: { + type: 'text', + text: 'Hello, world!' + } + ) + end.to raise_error(ArgumentError, /Invalid role/) + end + + it 'raises an error for invalid content type' do + expect do + instance.message( + role: 'user', + content: { + type: 'invalid_type', + text: 'Hello, world!' + } + ) + end.to raise_error(ArgumentError, /Invalid content type/) + end + + it 'raises an error for missing text in text content' do + expect do + instance.message( + role: 'user', + content: { + type: 'text' + } + ) + end.to raise_error(ArgumentError, /Missing :text/) + end + + it 'raises an error for missing data in image content' do + expect do + instance.message( + role: 'user', + content: { + type: 'image', + mimeType: 'image/png' + } + ) + end.to raise_error(ArgumentError, /Missing :data/) + end + + it 'raises an error for missing mimeType in image content' do + expect do + instance.message( + role: 'user', + content: { + type: 'image', + data: 'base64-encoded-image-data' + } + ) + end.to raise_error(ArgumentError, /Missing :mimeType/) + end + end + + describe '#messages' do + let(:instance) { described_class.new } + + describe 'with hash input (backward compatibility)' do + it 'creates multiple messages from a hash' do + result = instance.messages( + assistant: 'Hello!', + user: 'How are you?' + ) + + expect(result).to be_an(Array) + expect(result.size).to eq(2) + expect(result[0][:role]).to eq('assistant') + expect(result[0][:content][:type]).to eq('text') + expect(result[0][:content][:text]).to eq('Hello!') + expect(result[1][:role]).to eq('user') + expect(result[1][:content][:type]).to eq('text') + expect(result[1][:content][:text]).to eq('How are you?') + end + + it 'preserves the order of messages' do + result = instance.messages( + user_1: 'First message', + assistant: 'Second message', + user_2: 'Third message' + ) + + expect(result.size).to eq(3) + expect(result[0][:content][:text]).to eq('First message') + expect(result[1][:content][:text]).to eq('Second message') + expect(result[2][:content][:text]).to eq('Third message') + end + + it 'raises an error for empty messages hash' do + expect do + instance.messages({}) + end.to raise_error(ArgumentError, /At least one message must be provided/) + end + + it 'raises an error for invalid role' do + expect do + instance.messages( + invalid_role: 'Hello!' + ) + end.to raise_error(KeyError, /key not found: :invalid_role/) + end + end + + describe 'with array input' do + it 'creates multiple messages from an array of message hashes' do + result = instance.messages([ + { role: 'user', content: 'Hello!' }, + { role: 'assistant', content: 'Hi there!' }, + { role: 'user', content: 'How are you?' } + ]) + + expect(result).to be_an(Array) + expect(result.size).to eq(3) + expect(result[0][:role]).to eq('user') + expect(result[0][:content][:type]).to eq('text') + expect(result[0][:content][:text]).to eq('Hello!') + expect(result[1][:role]).to eq('assistant') + expect(result[1][:content][:type]).to eq('text') + expect(result[1][:content][:text]).to eq('Hi there!') + expect(result[2][:role]).to eq('user') + expect(result[2][:content][:type]).to eq('text') + expect(result[2][:content][:text]).to eq('How are you?') + end + + it 'supports multiple messages with the same role' do + result = instance.messages([ + { role: 'user', content: 'Example 1' }, + { role: 'assistant', content: 'Response 1' }, + { role: 'user', content: 'Example 2' }, + { role: 'assistant', content: 'Response 2' } + ]) + + expect(result.size).to eq(4) + expect(result[0][:role]).to eq('user') + expect(result[0][:content][:text]).to eq('Example 1') + expect(result[1][:role]).to eq('assistant') + expect(result[1][:content][:text]).to eq('Response 1') + expect(result[2][:role]).to eq('user') + expect(result[2][:content][:text]).to eq('Example 2') + expect(result[3][:role]).to eq('assistant') + expect(result[3][:content][:text]).to eq('Response 2') + end + + it 'handles complex content types in array format' do + valid_base64 = Base64.strict_encode64('test image data') + + result = instance.messages([ + { + role: 'user', + content: { + type: 'image', + data: valid_base64, + mimeType: 'image/png' + } + }, + { + role: 'assistant', + content: { + type: 'resource', + resource: { + uri: 'resource://example', + mimeType: 'text/plain', + text: 'Resource content' + } + } + } + ]) + + expect(result.size).to eq(2) + expect(result[0][:content][:type]).to eq('image') + expect(result[0][:content][:data]).to eq(valid_base64) + expect(result[1][:content][:type]).to eq('resource') + expect(result[1][:content][:resource][:uri]).to eq('resource://example') + end + + it 'raises an error for empty array' do + expect do + instance.messages([]) + end.to raise_error(ArgumentError, /At least one message must be provided/) + end + + it 'raises an error for invalid message structure' do + expect do + instance.messages([ + { role: 'user' } # missing content + ]) + end.to raise_error(ArgumentError, /Each message must be a hash with :role and :content keys/) + end + + it 'raises an error for invalid role in array format' do + expect do + instance.messages([ + { role: 'invalid_role', content: 'Hello!' } + ]) + end.to raise_error(ArgumentError, /Invalid role/) + end + end + + describe 'with builder pattern' do + it 'creates messages using block syntax' do + result = instance.messages do + user 'Hello!' + assistant 'Hi there!' + user 'How are you?' + end + + expect(result).to be_an(Array) + expect(result.size).to eq(3) + expect(result[0][:role]).to eq('user') + expect(result[0][:content]).to eq('Hello!') + expect(result[1][:role]).to eq('assistant') + expect(result[1][:content]).to eq('Hi there!') + expect(result[2][:role]).to eq('user') + expect(result[2][:content]).to eq('How are you?') + end + + it 'supports add_message method for explicit role specification' do + result = instance.messages do + add_message(role: 'user', content: 'Example 1') + add_message(role: 'assistant', content: 'Response 1') + add_message(role: 'user', content: 'Example 2') + add_message(role: 'assistant', content: 'Response 2') + end + + expect(result.size).to eq(4) + expect(result[0][:role]).to eq('user') + expect(result[0][:content]).to eq('Example 1') + expect(result[1][:role]).to eq('assistant') + expect(result[1][:content]).to eq('Response 1') + expect(result[2][:role]).to eq('user') + expect(result[2][:content]).to eq('Example 2') + expect(result[3][:role]).to eq('assistant') + expect(result[3][:content]).to eq('Response 2') + end + end + + describe 'error handling' do + it 'raises an error for nil input' do + expect do + instance.messages(nil) + end.to raise_error(ArgumentError, /At least one message must be provided/) + end + + it 'raises an error for unsupported input types' do + expect do + instance.messages('invalid input') + end.to raise_error(ArgumentError, /Messages input must be an Array or Hash/) + end + end + end + + describe '#text_content' do + let(:instance) { described_class.new } + + it 'creates a valid text content object' do + content = instance.text_content('Hello, world!') + expect(content[:type]).to eq('text') + expect(content[:text]).to eq('Hello, world!') + end + end + + describe '#image_content' do + let(:instance) { described_class.new } + + it 'creates a valid image content object' do + # Using valid base64 data for testing + valid_base64 = Base64.strict_encode64('test image data') + + content = instance.image_content(valid_base64, 'image/png') + expect(content[:type]).to eq('image') + expect(content[:data]).to eq(valid_base64) + expect(content[:mimeType]).to eq('image/png') + end + end + + describe '#resource_content' do + let(:instance) { described_class.new } + + it 'creates a valid resource content object with text' do + content = instance.resource_content('resource://example', 'text/plain', text: 'Resource content') + expect(content[:type]).to eq('resource') + expect(content[:resource][:uri]).to eq('resource://example') + expect(content[:resource][:mimeType]).to eq('text/plain') + expect(content[:resource][:text]).to eq('Resource content') + expect(content[:resource][:blob]).to be_nil + end + + it 'creates a valid resource content object with blob' do + content = instance.resource_content('resource://example', 'application/octet-stream', blob: 'binary_data') + expect(content[:type]).to eq('resource') + expect(content[:resource][:uri]).to eq('resource://example') + expect(content[:resource][:mimeType]).to eq('application/octet-stream') + expect(content[:resource][:blob]).to eq('binary_data') + expect(content[:resource][:text]).to be_nil + end + end + + describe "tags" do + it "supports tag assignment" do + test_class = Class.new(described_class) + test_class.tags :ai, :review, :automated + + expect(test_class.tags).to eq([:ai, :review, :automated]) + end + + it "returns empty array when no tags" do + test_class = Class.new(described_class) + expect(test_class.tags).to eq([]) + end + + it "accepts nested arrays and flattens them" do + test_class = Class.new(described_class) + test_class.tags [:ai, :review], :automated + + expect(test_class.tags).to eq([:ai, :review, :automated]) + end + + it "converts strings to symbols" do + test_class = Class.new(described_class) + test_class.tags 'ai', 'review' + + expect(test_class.tags).to eq([:ai, :review]) + end + end + + describe "metadata" do + it "stores and retrieves metadata" do + test_class = Class.new(described_class) + test_class.metadata :version, "1.0" + test_class.metadata :author, "Test" + + expect(test_class.metadata(:version)).to eq("1.0") + expect(test_class.metadata(:author)).to eq("Test") + end + + it "returns empty hash when no metadata" do + test_class = Class.new(described_class) + expect(test_class.metadata).to eq({}) + end + + it "returns all metadata when called without arguments" do + test_class = Class.new(described_class) + test_class.metadata :version, "1.0" + test_class.metadata :author, "Test" + + expect(test_class.metadata).to eq({ version: "1.0", author: "Test" }) + end + + it "returns nil for non-existent metadata keys" do + test_class = Class.new(described_class) + expect(test_class.metadata(:non_existent)).to be_nil + end + end + + describe "annotations" do + it "supports annotations hash" do + test_class = Class.new(described_class) + test_class.annotations experimental: true, beta: true + + expect(test_class.annotations).to eq(experimental: true, beta: true) + end + + it "returns empty hash when no annotations" do + test_class = Class.new(described_class) + expect(test_class.annotations).to eq({}) + end + + it "overwrites existing annotations" do + test_class = Class.new(described_class) + test_class.annotations experimental: true + test_class.annotations beta: true + + expect(test_class.annotations).to eq(beta: true) + end + end + + describe "authorization" do + it "supports authorization blocks" do + test_class = Class.new(described_class) do + authorize { |user:| user.admin? } + end + + prompt = test_class.new(headers: {}) + admin = double(admin?: true) + user = double(admin?: false) + + expect(prompt.authorized?(user: admin)).to be true + expect(prompt.authorized?(user: user)).to be false + end + + it "allows multiple authorization blocks" do + test_class = Class.new(described_class) do + authorize { |user:| user.logged_in? } + authorize { |user:| user.has_permission?(:prompts) } + end + + prompt = test_class.new(headers: {}) + authorized_user = double(logged_in?: true, has_permission?: true) + unauthorized_user = double(logged_in?: true, has_permission?: false) + not_logged_in_user = double(logged_in?: false, has_permission?: true) + + expect(prompt.authorized?(user: authorized_user)).to be true + expect(prompt.authorized?(user: unauthorized_user)).to be false + expect(prompt.authorized?(user: not_logged_in_user)).to be false + end + + it "returns true when no authorization blocks are defined" do + test_class = Class.new(described_class) + prompt = test_class.new(headers: {}) + + expect(prompt.authorized?).to be true + end + + it "validates arguments before running authorization" do + test_class = Class.new(described_class) do + arguments do + required(:user).filled(:hash) + end + + authorize { |user:| user[:admin] } + end + + prompt = test_class.new(headers: {}) + + expect { + prompt.authorized?(invalid: "data") + }.to raise_error(FastMcp::Prompt::InvalidArgumentsError) + end + + it "supports authorization blocks without parameters" do + test_class = Class.new(described_class) do + authorize { true } + end + + prompt = test_class.new(headers: {}) + expect(prompt.authorized?).to be true + end + end + + describe "headers" do + it "accepts headers on initialization" do + prompt = described_class.new(headers: { "Authorization" => "Bearer token" }) + expect(prompt.headers).to eq({ "Authorization" => "Bearer token" }) + end + + it "defaults to empty hash when no headers provided" do + prompt = described_class.new + expect(prompt.headers).to eq({}) + end + + it "allows access to headers in authorization blocks" do + test_class = Class.new(described_class) do + authorize { headers["Authorization"] == "Bearer valid-token" } + end + + valid_prompt = test_class.new(headers: { "Authorization" => "Bearer valid-token" }) + invalid_prompt = test_class.new(headers: { "Authorization" => "Bearer invalid-token" }) + + expect(valid_prompt.authorized?).to be true + expect(invalid_prompt.authorized?).to be false + end + end + + # Integration test with ERB templates + describe 'integration with ERB templates' do + let(:test_class) do + Class.new(described_class) do + arguments do + required(:code).filled(:string) + optional(:programming_language).filled(:string) + end + + def call(code:, programming_language: nil) + # Create templates inline for testing + assistant_template = "I'll help you review your <%= programming_language || 'code' %>." + user_template = "<% if programming_language %>\nPlease review this <%= programming_language %> code:\n<%= code %>\n<% else %>\nPlease review this code:\n<%= code %>\n<% end %>" + + messages( + assistant: ERB.new(assistant_template).result(binding), + user: ERB.new(user_template).result(binding) + ) + end + end + end + + let(:instance) { test_class.new } + + it 'correctly renders ERB templates with all parameters' do + result = instance.call_with_schema_validation!( + code: 'def hello(): pass', + programming_language: 'Python' + ) + + expect(result[0][:content][:text]).to eq("I'll help you review your Python.") + expect(result[1][:content][:text]).to eq("\nPlease review this Python code:\ndef hello(): pass\n") + end + + it 'correctly renders ERB templates with optional parameters omitted' do + result = instance.call_with_schema_validation!( + code: 'def hello(): pass' + ) + + expect(result[0][:content][:text]).to eq("I'll help you review your code.") + expect(result[1][:content][:text]).to eq("\nPlease review this code:\ndef hello(): pass\n") + end + end + + describe "prompt filtering" do + let(:server) { FastMcp::Server.new(name: "test", version: "1.0.0") } + + before do + # Create some test prompt classes for filtering + @public_prompt = Class.new(described_class) do + prompt_name "public_prompt" + tags :public + description "A public prompt" + def call + messages(user: "Public prompt") + end + end + + @private_prompt = Class.new(described_class) do + prompt_name "private_prompt" + tags :private + description "A private prompt" + def call + messages(user: "Private prompt") + end + end + + @admin_prompt = Class.new(described_class) do + prompt_name "admin_prompt" + tags :admin + description "An admin prompt" + authorize { |user:| user[:admin] } + arguments do + required(:user).filled(:hash) + end + def call(**args) + messages(user: "Admin prompt") + end + end + end + + it "filters prompts by tags" do + server.register_prompt(@public_prompt) + server.register_prompt(@private_prompt) + + server.filter_prompts do |request, prompts| + prompts.select { |p| p.tags.include?(:public) } + end + + # Create a filtered copy + request = double("request") + filtered_server = server.create_filtered_copy(request) + + # Verify only public prompts are included + expect(filtered_server.prompts.size).to eq(1) + expect(filtered_server.prompts.values.first).to eq(@public_prompt) + end + + it "chains multiple filters" do + server.register_prompt(@public_prompt) + server.register_prompt(@private_prompt) + server.register_prompt(@admin_prompt) + + # First filter: only non-admin prompts + server.filter_prompts { |r, p| p.reject { |prompt| prompt.tags.include?(:admin) } } + # Second filter: only prompts with tags + server.filter_prompts { |r, p| p.select { |prompt| prompt.tags.any? } } + + request = double("request") + filtered_server = server.create_filtered_copy(request) + + # Should include public and private, but not admin + expect(filtered_server.prompts.size).to eq(2) + prompt_classes = filtered_server.prompts.values + expect(prompt_classes).to include(@public_prompt, @private_prompt) + expect(prompt_classes).not_to include(@admin_prompt) + end + + it "supports filtering by authorization status" do + server.register_prompt(@public_prompt) + server.register_prompt(@admin_prompt) + + # Filter to only include prompts that don't require authorization or are authorized + server.filter_prompts do |request, prompts| + prompts.select do |prompt_class| + # Check if prompt has authorization requirements + auth_blocks = prompt_class.authorization_blocks + if auth_blocks.nil? || auth_blocks.empty? + true # No authorization required + else + # Check authorization with mock user data + begin + prompt_instance = prompt_class.new(headers: request.headers || {}) + prompt_instance.authorized?(user: { admin: true }) + rescue + false # Authorization failed + end + end + end + end + + # Mock request with headers + request = double("request", headers: { "user" => "admin" }) + filtered_server = server.create_filtered_copy(request) + + # Should include both prompts since we're passing admin user + expect(filtered_server.prompts.size).to eq(2) + end + + it "handles empty filter results" do + server.register_prompt(@public_prompt) + + # Filter that excludes everything + server.filter_prompts { |r, p| [] } + + request = double("request") + filtered_server = server.create_filtered_copy(request) + + expect(filtered_server.prompts).to be_empty + end + end +end diff --git a/spec/mcp/resource_spec.rb b/spec/mcp/resource_spec.rb index dfdcd00..564e469 100644 --- a/spec/mcp/resource_spec.rb +++ b/spec/mcp/resource_spec.rb @@ -184,6 +184,131 @@ def content end end + describe 'stateless architecture' do + let(:file_resource_class) do + Class.new(FastMcp::Resource) do + uri 'file://counter.txt' + resource_name 'Counter' + description 'A file-based counter' + mime_type 'text/plain' + + def content + File.exist?('counter.txt') ? File.read('counter.txt').strip : '0' + end + end + end + + before do + # Clean up any existing file + FileUtils.rm_f('counter.txt') + end + + after do + # Clean up test file + FileUtils.rm_f('counter.txt') + end + + it 'reads content from external source' do + File.write('counter.txt', '42') + resource = file_resource_class.new + expect(resource.content).to eq('42') + end + + it 'handles missing external source' do + FileUtils.rm_f('counter.txt') + resource = file_resource_class.new + expect(resource.content).to eq('0') # default value + end + + it 'does not maintain state between instances' do + resource1 = file_resource_class.new + resource2 = file_resource_class.new + expect(resource1).not_to eq(resource2) + expect(resource1.object_id).not_to eq(resource2.object_id) + end + + it 'reflects external changes immediately' do + # Create initial file + File.write('counter.txt', '10') + resource = file_resource_class.new + expect(resource.content).to eq('10') + + # Update file externally + File.write('counter.txt', '20') + # Same resource instance should reflect the change + expect(resource.content).to eq('20') + end + end + + describe 'integration with tools and external storage' do + let(:storage_resource_class) do + Class.new(FastMcp::Resource) do + uri 'file://data.txt' + resource_name 'Data Storage' + description 'External data storage' + mime_type 'text/plain' + + def content + File.exist?('data.txt') ? File.read('data.txt').strip : 'no data' + end + end + end + + let(:update_tool_class) do + Class.new(FastMcp::Tool) do + description 'Update data storage' + + arguments do + required(:data).filled(:string).description('Data to store') + end + + def call(data:) + File.write('data.txt', data) + notify_resource_updated('file://data.txt') + { success: true, data: data } + end + end + end + + before do + FileUtils.rm_f('data.txt') + server.register_resource(storage_resource_class) + server.register_tool(update_tool_class) + end + + after do + FileUtils.rm_f('data.txt') + end + + it 'integrates tools and stateless resources' do + # Setup - verify initial state + resource = server.read_resource('file://data.txt') + expect(resource.new.content).to eq('no data') + + # Execute tool to update external storage + tool = update_tool_class.new + result = tool.call(data: 'test content') + expect(result[:success]).to be true + + # Verify resource reflects the external change + updated_resource = server.read_resource('file://data.txt') + expect(updated_resource.new.content).to eq('test content') + end + + it 'persists data to external storage' do + tool = update_tool_class.new + tool.call(data: 'persistent data') + + # Create new resource instance to verify persistence + resource = storage_resource_class.new + expect(resource.content).to eq('persistent data') + + # Verify file actually exists + expect(File.exist?('data.txt')).to be true + expect(File.read('data.txt')).to eq('persistent data') + end + end + describe 'creating resources from files' do it 'creates a resource from a file' do allow(File).to receive(:exist?).and_return(true) diff --git a/spec/mcp/server_spec.rb b/spec/mcp/server_spec.rb index cfe6f63..99856a9 100644 --- a/spec/mcp/server_spec.rb +++ b/spec/mcp/server_spec.rb @@ -33,6 +33,84 @@ def call(**_args) end end + describe '#register_prompt' do + it 'registers a prompt with the server' do + test_prompt_class = Class.new(FastMcp::Prompt) do + def self.name + 'TestPrompt' + end + + prompt_name 'test_prompt' + description 'A test prompt' + + def call(**_args) + messages(user: 'Hello, World!') + end + end + + server.register_prompt(test_prompt_class) + + expect(server.instance_variable_get(:@prompts)['test_prompt']).to eq(test_prompt_class) + expect(test_prompt_class.server).to eq(server) + end + + it 'derives prompt name from class name if not explicitly set' do + test_prompt_class = Class.new(FastMcp::Prompt) do + def self.name + 'TestAutoPrompt' + end + + description 'A test prompt with auto-derived name' + + def call(**_args) + messages(user: 'Hello, World!') + end + end + + server.register_prompt(test_prompt_class) + + expect(server.instance_variable_get(:@prompts)['test_auto']).to eq(test_prompt_class) + end + end + + describe '#register_prompts' do + it 'registers multiple prompts at once' do + test_prompt_class1 = Class.new(FastMcp::Prompt) do + def self.name + 'TestPrompt1' + end + + prompt_name 'test_prompt_1' + description 'First test prompt' + + def call(**_args) + messages(user: 'Hello from prompt 1!') + end + end + + test_prompt_class2 = Class.new(FastMcp::Prompt) do + def self.name + 'TestPrompt2' + end + + prompt_name 'test_prompt_2' + description 'Second test prompt' + + def call(**_args) + messages(user: 'Hello from prompt 2!') + end + end + + server.register_prompts(test_prompt_class1, test_prompt_class2) + + prompts = server.instance_variable_get(:@prompts) + expect(prompts['test_prompt_1']).to eq(test_prompt_class1) + expect(prompts['test_prompt_2']).to eq(test_prompt_class2) + expect(test_prompt_class1.server).to eq(server) + expect(test_prompt_class2.server).to eq(server) + end + end + describe '#handle_request' do let(:test_tool_class) do Class.new(FastMcp::Tool) do @@ -315,4 +393,125 @@ def call(**_args) end end end + + describe '#register_resource' do + it 'registers a resource with the server' do + test_resource_class = Class.new(FastMcp::Resource) do + def self.name + 'test-resource' + end + + def self.description + 'A test resource' + end + + def uri + 'file://test.txt' + end + + def name + 'test.txt' + end + + def mime_type + 'text/plain' + end + + def content + 'Hello, World!' + end + end + + server.register_resource(test_resource_class) + + expect(server.instance_variable_get(:@resources)).to include(test_resource_class) + end + end + + describe '#notify_resource_updated' do + let(:test_resource_class) do + Class.new(FastMcp::Resource) do + def self.name + 'test-resource' + end + + def self.description + 'A test resource' + end + + # Use the class method pattern for URI + uri 'file://test.txt' + resource_name 'test.txt' + mime_type 'text/plain' + + def content + 'Hello, World!' + end + end + end + + before do + server.register_resource(test_resource_class) + # Simulate client initialization + server.instance_variable_set(:@client_initialized, true) + end + + it 'finds resource by URI using array search, not hash lookup' do + # Subscribe to the resource + server.instance_variable_get(:@resource_subscriptions)['file://test.txt'] = true + + # Mock the transport's send_message method to verify it's called + transport = double('transport') + server.instance_variable_set(:@transport, transport) + + expect(transport).to receive(:send_message).with(hash_including( + jsonrpc: '2.0', + method: 'notifications/resources/updated', + params: hash_including( + uri: 'file://test.txt', + name: 'test-resource', + mimeType: 'text/plain' + ) + )) + + # This should successfully find the resource using array.find, not hash lookup + server.notify_resource_updated('file://test.txt') + end + + it 'does not send notification if no one is subscribed to the resource' do + # Don't subscribe to the resource + transport = double('transport') + server.instance_variable_set(:@transport, transport) + + expect(transport).not_to receive(:send_message) + + server.notify_resource_updated('file://test.txt') + end + + it 'does not send notification if client is not initialized' do + # Unset client initialization + server.instance_variable_set(:@client_initialized, false) + server.instance_variable_get(:@resource_subscriptions)['file://test.txt'] = true + + transport = double('transport') + server.instance_variable_set(:@transport, transport) + + expect(transport).not_to receive(:send_message) + + server.notify_resource_updated('file://test.txt') + end + + it 'handles non-existent resource URI gracefully' do + # Subscribe to a different resource + server.instance_variable_get(:@resource_subscriptions)['file://nonexistent.txt'] = true + + transport = double('transport') + server.instance_variable_set(:@transport, transport) + + # Mock should not be called since resource doesn't exist + expect(transport).not_to receive(:send_message) + + server.notify_resource_updated('file://nonexistent.txt') + end + end end diff --git a/spec/templates/prompt_templates_spec.rb b/spec/templates/prompt_templates_spec.rb new file mode 100644 index 0000000..e753420 --- /dev/null +++ b/spec/templates/prompt_templates_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +RSpec.describe 'Prompt Generator Templates' do + let(:template_dir) { File.expand_path('../../lib/generators/fast_mcp/install/templates', __dir__) } + + describe 'sample_prompt.rb template' do + let(:template_path) { File.join(template_dir, 'sample_prompt.rb') } + let(:template_content) { File.read(template_path) } + + it 'exists' do + expect(File.exist?(template_path)).to be true + end + + it 'has valid Ruby syntax' do + # Create a test that validates the Ruby syntax + test_code = <<~RUBY + class ApplicationPrompt; end + #{template_content} + RUBY + + # Check syntax with Ruby's parser + expect { RubyVM::InstructionSequence.compile(test_code) }.not_to raise_error + end + + it 'uses the call method, not a non-existent template method' do + # Ensure it uses 'def call' and not 'template do' + expect(template_content).to include('def call') + expect(template_content).not_to include('template do') + expect(template_content).not_to include('template {') + end + + it 'only uses valid roles (user and assistant, not system)' do + # Check that it doesn't use invalid 'system' role + expect(template_content).not_to match(/system:/) + expect(template_content).not_to match(/role:\s*["']system["']/) + expect(template_content).not_to match(/role:\s*:system/) + + # Check that it uses valid roles + expect(template_content).to match(/assistant:|user:/) + end + + it 'demonstrates auto-naming with a comment' do + # Should have a comment about auto-generated name + expect(template_content).to match(/# prompt_name.*(auto|generated)/i) + end + + it 'can be executed with FastMcp::Prompt' do + # Create a working test with actual FastMcp classes + Dir.mktmpdir do |dir| + test_file = File.join(dir, 'test_prompt.rb') + + test_code = <<~RUBY + require '#{File.expand_path('../../lib/fast_mcp', __dir__)}' + + class ApplicationPrompt < FastMcp::Prompt + end + + #{template_content} + + # Test instantiation + prompt = SamplePrompt.new + + # Test auto-generated name + if SamplePrompt.prompt_name != "sample" + raise "Expected auto-generated name 'sample', got '\#{SamplePrompt.prompt_name}'" + end + + # Test calling with required argument + result = prompt.call(input: "test input") + unless result.is_a?(Array) && result.all? { |msg| msg[:role] && msg[:content] } + raise "Invalid prompt result structure" + end + + # Test calling with optional argument + result_with_context = prompt.call(input: "test", context: "additional context") + unless result_with_context.is_a?(Array) + raise "Invalid prompt result with context" + end + + puts "All tests passed!" + RUBY + + File.write(test_file, test_code) + + # Execute the test + output = `ruby #{test_file} 2>&1` + success = $?.success? + + expect(success).to be(true), "Template execution failed:\n#{output}" + expect(output).to include("All tests passed!") + end + end + end + + describe 'application_prompt.rb template' do + let(:template_path) { File.join(template_dir, 'application_prompt.rb') } + let(:template_content) { File.read(template_path) } + + it 'exists' do + expect(File.exist?(template_path)).to be true + end + + it 'has valid Ruby syntax' do + # Mock ActionPrompt::Base for syntax checking + test_code = <<~RUBY + module ActionPrompt + class Base; end + end + #{template_content} + RUBY + + expect { RubyVM::InstructionSequence.compile(test_code) }.not_to raise_error + end + + it 'inherits from ActionPrompt::Base' do + expect(template_content).to include('class ApplicationPrompt < ActionPrompt::Base') + end + + it 'includes helpful comments' do + expect(template_content).to include('ActionPrompt::Base is an alias for FastMcp::Prompt') + end + end +end \ No newline at end of file