diff --git a/.gitignore b/.gitignore index a6596e9..c000cda 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ # Ignore gem files *.gem + +# Ignore .DS_Store files diff --git a/.rspec_status b/.rspec_status deleted file mode 100644 index c0b33c4..0000000 --- a/.rspec_status +++ /dev/null @@ -1,104 +0,0 @@ -example_id | status | run_time | -------------------------------------------------------------------- | ------ | --------------- | -./spec/integration/server_integration_spec.rb[1:1:1] | passed | 0.01296 seconds | -./spec/integration/server_integration_spec.rb[1:1:2] | passed | 0.00056 seconds | -./spec/integration/server_integration_spec.rb[1:1:3] | passed | 0.00113 seconds | -./spec/integration/server_integration_spec.rb[1:1:4] | passed | 0.0018 seconds | -./spec/integration/server_integration_spec.rb[1:1:5] | passed | 0.00249 seconds | -./spec/integration/server_integration_spec.rb[1:1:6] | passed | 0.00219 seconds | -./spec/integration/server_integration_spec.rb[1:1:7] | passed | 0.00288 seconds | -./spec/integration/server_integration_spec.rb[1:1:8] | passed | 0.00272 seconds | -./spec/integration/server_integration_spec.rb[1:1:9] | passed | 0.00179 seconds | -./spec/integration/server_integration_spec.rb[1:1:10] | passed | 0.00138 seconds | -./spec/mcp/resource_spec.rb[1:1:1] | passed | 0.00004 seconds | -./spec/mcp/resource_spec.rb[1:1:2] | passed | 0.00003 seconds | -./spec/mcp/resource_spec.rb[1:1:3] | passed | 0.00002 seconds | -./spec/mcp/resource_spec.rb[1:1:4] | passed | 0.00006 seconds | -./spec/mcp/resource_spec.rb[1:1:5] | passed | 0.00005 seconds | -./spec/mcp/resource_spec.rb[1:2:1] | passed | 0.00074 seconds | -./spec/mcp/resource_spec.rb[1:2:2] | passed | 0.00028 seconds | -./spec/mcp/resource_spec.rb[1:2:3] | passed | 0.00004 seconds | -./spec/mcp/resource_spec.rb[1:2:4] | passed | 0.00075 seconds | -./spec/mcp/resource_spec.rb[1:2:5] | passed | 0.00005 seconds | -./spec/mcp/resource_spec.rb[1:3:1] | passed | 0.00161 seconds | -./spec/mcp/resource_spec.rb[1:3:2] | passed | 0.00083 seconds | -./spec/mcp/resource_spec.rb[1:3:3] | passed | 0.00006 seconds | -./spec/mcp/resource_spec.rb[1:4:1] | passed | 0.00007 seconds | -./spec/mcp/resource_spec.rb[1:5:1] | passed | 0.00004 seconds | -./spec/mcp/resource_spec.rb[1:6:1] | passed | 0.00409 seconds | -./spec/mcp/resource_spec.rb[1:6:2] | passed | 0.0003 seconds | -./spec/mcp/resource_spec.rb[1:6:3] | passed | 0.00012 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:1:1] | passed | 0.00099 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:2:1] | passed | 0.00072 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:3:1] | passed | 0.00104 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:4:1] | passed | 0.00127 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:5:1] | passed | 0.00062 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:6:1] | passed | 0.00026 seconds | -./spec/mcp/schema_compiler_spec.rb[1:1:7:1] | passed | 0.00221 seconds | -./spec/mcp/schema_compiler_spec.rb[1:2:1] | passed | 0.00046 seconds | -./spec/mcp/server_spec.rb[1:1:1] | passed | 0.00006 seconds | -./spec/mcp/server_spec.rb[1:2:1] | passed | 0.00004 seconds | -./spec/mcp/server_spec.rb[1:3:1:1] | passed | 0.00089 seconds | -./spec/mcp/server_spec.rb[1:3:2:1] | passed | 0.00075 seconds | -./spec/mcp/server_spec.rb[1:3:3:1] | passed | 0.00085 seconds | -./spec/mcp/server_spec.rb[1:3:4:1] | passed | 0.00098 seconds | -./spec/mcp/server_spec.rb[1:3:4:2] | passed | 0.00087 seconds | -./spec/mcp/server_spec.rb[1:3:4:3] | passed | 0.00081 seconds | -./spec/mcp/server_spec.rb[1:3:4:4] | passed | 0.00079 seconds | -./spec/mcp/server_spec.rb[1:3:5:1] | passed | 0.0008 seconds | -./spec/mcp/server_spec.rb[1:3:5:2] | passed | 0.00082 seconds | -./spec/mcp/server_spec.rb[1:3:5:3] | passed | 0.00074 seconds | -./spec/mcp/tool_spec.rb[1:1:1] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:1:2] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:1:3] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:1:4] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:2:1] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:2:2] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:3:1] | passed | 0.0003 seconds | -./spec/mcp/tool_spec.rb[1:4:1] | passed | 0.00003 seconds | -./spec/mcp/tool_spec.rb[1:4:2] | passed | 0.00032 seconds | -./spec/mcp/tool_spec.rb[1:5:1] | passed | 0.00005 seconds | -./spec/mcp/tool_spec.rb[1:6:1] | passed | 0.0003 seconds | -./spec/mcp/tool_spec.rb[1:6:2] | passed | 0.00211 seconds | -./spec/mcp/tool_spec.rb[1:7:1:1] | passed | 0.00039 seconds | -./spec/mcp/tool_spec.rb[1:7:1:2] | passed | 0.0004 seconds | -./spec/mcp/tool_spec.rb[1:7:1:3] | passed | 0.00065 seconds | -./spec/mcp/tool_spec.rb[1:7:1:4] | passed | 0.00025 seconds | -./spec/mcp/tool_spec.rb[1:7:1:5] | passed | 0.00215 seconds | -./spec/mcp/tool_spec.rb[1:7:1:6] | passed | 0.00081 seconds | -./spec/mcp/tool_spec.rb[1:7:1:7] | passed | 0.00069 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:1:1] | passed | 0.00019 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:1:2] | passed | 0.00006 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:1:3] | passed | 0.00005 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:1:4] | passed | 0.00005 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:1:1] | passed | 0.02322 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:1:2] | passed | 0.00039 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:1:3] | passed | 0.00039 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:2:1] | passed | 0.0001 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:2:2] | passed | 0.00007 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:3:1] | passed | 0.00012 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:3:2] | passed | 0.0001 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:3:3] | passed | 0.0002 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:4:1] | passed | 0.00011 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:5:1] | passed | 0.0001 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:5:2] | passed | 0.00009 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:5:3] | passed | 0.0001 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:6:1] | passed | 0.00006 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:6:2] | passed | 0.00006 seconds | -./spec/mcp/transports/authenticated_rack_transport_spec.rb[1:2:7:1] | passed | 0.0001 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:1:1] | passed | 0.00012 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:1:2] | passed | 0.00005 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:2:1] | passed | 0.00011 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:3:1] | passed | 0.0002 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:3:2] | passed | 0.00022 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:4:1:1] | passed | 0.00039 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:4:2:1] | passed | 0.00022 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:4:3:1] | passed | 0.00025 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:1] | passed | 0.00011 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:2] | passed | 0.00017 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:3:1] | passed | 0.00011 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:4:1] | passed | 0.00076 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:4:2] | passed | 0.00023 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:5:1] | passed | 0.00014 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:5:2] | passed | 0.00016 seconds | -./spec/mcp/transports/rack_transport_spec.rb[1:5:5:3] | passed | 0.00011 seconds | diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ca376..a31457d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.2] - 2025-04-04 + +### Changed +- Improved STDIO transport to better handle large JSON-RPC inputs +- Added proper buffering and size limits for input processing +- Enhanced error handling for JSON parsing and IO operations + +## [1.1.1] - 2025-04-04 + +### Changed +- Fix: Removed unused pagination keys (as we don't have this feature yet) from API responses to align with MCP specification + +## [1.1.0] - 2025-04-04 + +### Added +- Prompts Feature +- New flexible API for the `messages` method supporting both hash and array inputs +- Prompt ERB template support for JSON/XML structured data + ## [1.0.0] - 2025-03-30 ### Added @@ -25,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Namespace consistency correction (FastMCP -> FastMcp) throughout the codebase ### Improved -- ⚠️ [Breaking] Resource content declaration changes +- [Breaking] Resource content declaration changes - Now resources implement `content` over `default_content` - `content` is dynamically called when calling a resource, this implies we can declare dynamic resource contents like: ```ruby diff --git a/Gemfile.lock b/Gemfile.lock index 683eb69..4f7d809 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fast-mcp (1.0.0) + fast-mcp (1.1.2) base64 dry-schema (~> 1.14) json (~> 2.0) diff --git a/README.md b/README.md index 507188b..515fa23 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,12 @@ 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 - ## 💎 What Makes FastMCP Great ```ruby # Define tools for AI models to use @@ -285,6 +285,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 @@ -294,6 +295,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 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 | @@ -315,6 +317,7 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md) - [🌐 Sinatra Integration](docs/sinatra_integration.md) - [📚 Resources](docs/resources.md) - [🛠️ Tools](docs/tools.md) +- [💬 Prompts](docs/prompts.md) ## 💻 Examples @@ -323,6 +326,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) @@ -353,3 +357,6 @@ 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 + +## How to add a MCP server to Claude, Cursor, or other MCP clients? +Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md) diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 0000000..ce846b2 --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,490 @@ +# 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) + - [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) +- [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 SimplePrompt < FastMcp::Prompt + prompt_name 'simple_example' + description 'A simple example prompt' + + def call(**_args) + 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 +- Set a description using the `description` class method +- Define arguments using the `arguments` class method with Dry::Schema +- Implement the message creation in the `call` instance method + +### 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).description("The user's question") + optional(:context).filled(:string).description("Additional context") + end + + def call(query:, context: nil) + messages( + assistant: "I'll help answer your question.", + user: context ? "Question: #{query}\nContext: #{context}" : "Question: #{query}" + ) + end +end +``` + +The `arguments` method works the same way as in tools, allowing you to define: + +- Required arguments using the `required` method +- Optional arguments using the `optional` method +- Types and validations for each argument +- Descriptions for each argument + +### 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 call(query:) + 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 hashes, each containing a single role-content pair: + +```ruby +def call(query:) + messages( + { assistant: "I'll help you with your question." }, + { user: "My question is: #{query}" } + ) +end +``` + +This format is particularly useful when you need to maintain a specific order of messages. + +### Multiple Messages with Same Role + +One of the key advantages of the array format is the ability to have multiple messages with the same role: + +```ruby +def call(query:, examples: []) + message_array = [ + { assistant: "I'll help you with your question." } + ] + + # Add example messages if provided + examples.each do |example| + message_array << { user: "Example: #{example}" } + end + + # Add the main query + message_array << { user: "My question is: #{query}" } + + messages(*message_array) +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).description("Code to review") + optional(:language).filled(:string).description("Programming language") + end + + def 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')) + + 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).description("API endpoint") + required(:method).filled(:string).description("HTTP method") + optional(:params).hash.description("Request parameters") + end + + def 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 + + 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).description("Type of XML document") + required(:elements).array.description("Elements to include") + optional(:attributes).hash.description("Document attributes") + end + + def 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 + + 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).description("The user query") + optional(:context).filled(:string).description("Additional context") + 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 + + messages( + assistant: assistant_message, + user: user_message + ) + end +end +``` + +## Advanced Prompt Features + +### Message Content Types + +Fast MCP supports different content types for messages: + +```ruby +# Text content (default) +message(role: :user, content: text_content("Hello")) + +# Image content +message(role: :user, content: image_content(image_data, "image/png")) + +# Resource content +message(role: :user, content: resource_content(resource_id)) +``` + +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).description("Location for forecast") + optional(:days).filled(:integer).description("Number of days") + end + + def 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") + + 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 +``` + +## 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).description("The question to ask") + end + + def call(question:) + 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).description("The topic to discuss") + optional(:user_background).filled(:string).description("User background info") + end + + def call(topic:, user_background: nil) + message_array = [] + + # First message - assistant introduction + message_array << { assistant: "I'm going to help you understand #{topic}." } + + # Second message - user background if provided + if user_background + message_array << { user: "My background: #{user_background}" } + end + + # Third message - assistant acknowledgment + message_array << { assistant: "I'll tailor my explanation based on your background." } + + # Fourth message - main user query + message_array << { user: "Please explain #{topic} to me." } + + messages(*message_array) + 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).description("Code to review") + optional(:programming_language).filled(:string).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 +``` 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/multi_message_prompt.rb b/examples/prompts/multi_message_prompt.rb new file mode 100644 index 0000000..d5b192e --- /dev/null +++ b/examples/prompts/multi_message_prompt.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative '../../lib/fast_mcp' + +module FastMcp + module Prompts + # Example prompt that demonstrates multiple messages in a specific order + class MultiMessagePrompt < FastMcp::Prompt + prompt_name 'multi_message_example' + description 'An example prompt that uses multiple messages in a specific order' + + arguments do + required(:topic).description('The topic to discuss') + optional(:user_background).description('Background information about the user') + optional(:additional_context).description('Any additional context for the conversation') + end + + def call(topic:, user_background: nil, additional_context: nil) + # Create an array to store our messages in the desired order + message_array = [] + + # First message - system context (represented as assistant) + message_array << { assistant: "I'm going to help you understand #{topic}." } + + # Second message - user background if provided + if user_background + message_array << { user: "My background: #{user_background}" } + end + + # Third message - assistant acknowledgment + message_array << { assistant: "I'll tailor my explanation based on your background." } + + # Fourth message - main user query + message_array << { user: "Please explain #{topic} to me." } + + # Fifth message - additional context if provided + if additional_context + message_array << { user: "Additional context: #{additional_context}" } + end + + # Use the messages method with the array of message hashes + messages(*message_array) + 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/lib/fast_mcp.rb b/lib/fast_mcp.rb index 92b490e..27f4df0 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 @@ -112,6 +113,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/mcp/prompt.rb b/lib/mcp/prompt.rb new file mode 100644 index 0000000..eca076a --- /dev/null +++ b/lib/mcp/prompt.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require 'dry-schema' +require 'erb' + +module FastMcp + # Main Prompt class that represents an MCP Prompt + class Prompt + class InvalidArgumentsError < StandardError; end + + # Define roles as a hash with keys and text + ROLES = { + user: 'user', + assistant: 'assistant' + }.freeze + + CONTENT_TYPE_TEXT = 'text' + CONTENT_TYPE_IMAGE = 'image' + CONTENT_TYPE_RESOURCE = 'resource' + + class << self + attr_accessor :server + + def arguments(&block) + @input_schema = Dry::Schema.JSON(&block) + end + + def input_schema + @input_schema ||= Dry::Schema.JSON + end + + 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 + + def description(description = nil) + return @description if description.nil? + + @description = description + end + + def call(**args) + raise NotImplementedError, 'Subclasses must implement the call method' + end + + def input_schema_to_json + return nil unless @input_schema + + compiler = SchemaCompiler.new + compiler.process(@input_schema) + end + end + + 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 + + # Create a message with the given role and content + def message(role:, content:) + validate_role(role) + validate_content(content) + + { + role: role, + content: content + } + end + + # Create multiple messages from a hash of role => content pairs or an array of role-keyed hashes + # @param args [Hash, Array] Either a hash of role => content pairs or an array of single-key hashes with role as key + # @return [Array] An array of messages + def messages(*args) + result = [] + + # Handle both hash and array arguments for backward compatibility + if args.length == 1 && args.first.is_a?(Hash) + # Original API: hash of role => content pairs + messages_hash = args.first + raise ArgumentError, 'At least one message must be provided' if messages_hash.empty? + + messages_hash.each do |role_key, content| + # Extract the base role (without any suffix like _2) + base_role = role_key.to_s.gsub(/_\d+$/, '') + + # Validate the role + validate_role(base_role) + + # Add the message to the result + if content.is_a?(Hash) + result << message(role: base_role, content: content) + else + result << message(role: base_role, content: text_content(content)) + end + end + else + # New API: array of single-key hashes with role as key + raise ArgumentError, 'At least one message must be provided' if args.empty? + + args.each do |msg_hash| + raise ArgumentError, 'Each message must be a hash' unless msg_hash.is_a?(Hash) + + # Each hash should have a single key (the role) + msg_hash.each do |role_key, content| + # Extract the base role (without any suffix like _2) + base_role = role_key.to_s.gsub(/_\d+$/, '') + + # Validate the role + validate_role(base_role) + + # Add the message to the result + if content.is_a?(Hash) + result << message(role: base_role, content: content) + else + result << message(role: base_role, content: text_content(content)) + end + end + end + end + + result + end + + # Helper method to extract content from a hash + def content_from_hash(hash) + if hash.key?(:text) + text_content(hash[:text]) + elsif hash.key?(:data) && hash.key?(:mimeType) + image_content(hash[:data], hash[:mimeType]) + elsif hash.key?(:resource) + # It's already a resource content + hash + else + # Default to text content with empty string + text_content('') + end + end + + # Create a text content object + def text_content(text) + { + type: CONTENT_TYPE_TEXT, + text: text + } + end + + # Create an image content object + def image_content(data, mime_type) + { + type: CONTENT_TYPE_IMAGE, + data: data, + mimeType: mime_type + } + end + + # Create a resource content object + 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 + + 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 + + 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] + when CONTENT_TYPE_RESOURCE + validate_resource_content(content[:resource]) + else + raise ArgumentError, "Invalid content type: #{content[:type]}" + end + end + + 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/server.rb b/lib/mcp/server.rb index 0e62c0b..60c54ab 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -11,7 +11,7 @@ module FastMcp class Server - attr_reader :name, :version, :tools, :resources, :capabilities + attr_reader :name, :version, :tools, :resources, :capabilities, :prompts DEFAULT_CAPABILITIES = { resources: { @@ -20,6 +20,9 @@ class Server }, tools: { listChanged: true + }, + prompts: { + listChanged: true } }.freeze @@ -28,6 +31,7 @@ def initialize(name:, version:, logger: FastMcp::Logger.new, capabilities: {}) @version = version @tools = {} @resources = {} + @prompts = {} @resource_subscriptions = {} @logger = logger @logger.level = Logger::INFO @@ -90,12 +94,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.name] = prompt + @logger.info("Registered 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.keys.join(', ')}") + @logger.info("Available prompts: #{@prompts.keys.join(', ')}") # Use STDIO transport by default @transport_klass = FastMcp::Transports::StdioTransport @@ -108,6 +132,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.keys.join(', ')}") + @logger.info("Available prompts: #{@prompts.keys.join(', ')}") # Use Rack transport transport_klass = FastMcp::Transports::RackTransport @@ -122,6 +147,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.keys.join(', ')}") + @logger.info("Available prompts: #{@prompts.keys.join(', ')}") # Use Rack transport transport_klass = FastMcp::Transports::AuthenticatedRackTransport @@ -162,6 +188,10 @@ def handle_request(json_str) # rubocop:disable Metrics/MethodLength handle_tools_list(id) when 'tools/call' handle_tools_call(params, 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/read' @@ -215,6 +245,18 @@ def notify_resource_updated(uri) @transport.send_message(notification) 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' @@ -394,6 +436,82 @@ 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'] + + prompts_list = @prompts.values.map do |prompt| + prompt_data = { + name: 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/transports/stdio_transport.rb b/lib/mcp/transports/stdio_transport.rb index 2941169..9214bec 100644 --- a/lib/mcp/transports/stdio_transport.rb +++ b/lib/mcp/transports/stdio_transport.rb @@ -10,6 +10,8 @@ class StdioTransport < BaseTransport def initialize(server, logger: nil) super @running = false + @buffer = '' + @max_line_size = 1024 * 1024 # 1MB max line size for safety end # Start the transport @@ -18,9 +20,16 @@ def start @running = true # Process input from stdin - while @running && (line = $stdin.gets) + while @running begin - process_message(line.strip) + line = read_line + break if line.nil? + next if line.empty? + + process_message(line) + rescue JSON::ParserError => e + @logger.error("JSON parsing error: #{e.message}") + send_error(-32_700, "Parse error: Invalid JSON") rescue StandardError => e @logger.error("Error processing message: #{e.message}") @logger.error(e.backtrace.join("\n")) @@ -45,6 +54,47 @@ def send_message(message) private + # Read a complete line from stdin, handling large inputs correctly + def read_line + while @running + chunk = $stdin.read_nonblock(8192, exception: false) + + case chunk + when :wait_readable + $stdin.wait_readable + next + when nil + return nil + else + @buffer += chunk + end + + # Process complete lines + if @buffer.include?("\n") + line, @buffer = @buffer.split("\n", 2) + + if line.bytesize > @max_line_size + @logger.error("Message exceeds maximum size of #{@max_line_size} bytes") + send_error(-32_001, "Message too large") + @buffer = '' + next + end + + return line.strip + end + + # Safety check for buffer size + if @buffer.bytesize > @max_line_size + @logger.error("Input buffer exceeds maximum size of #{@max_line_size} bytes") + send_error(-32_001, "Message too large") + @buffer = '' + end + end + rescue StandardError => e + @logger.error("IO error while reading: #{e.message}") + nil + end + # Send a JSON-RPC error response def send_error(code, message, id = nil) response = { diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 76e36aa..384d4c8 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -2,5 +2,5 @@ # Version information module FastMcp - VERSION = '1.0.0' + VERSION = '1.1.2' end diff --git a/spec/mcp/prompt_spec.rb b/spec/mcp/prompt_spec.rb new file mode 100644 index 0000000..0697e6f --- /dev/null +++ b/spec/mcp/prompt_spec.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true +require 'spec_helper' + +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 + message = instance.message( + role: 'user', + content: { + type: 'image', + data: 'base64data', + mimeType: 'image/png' + } + ) + + expect(message[:role]).to eq('user') + expect(message[:content][:type]).to eq('image') + expect(message[:content][:data]).to eq('base64data') + 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: 'base64data' + } + ) + end.to raise_error(ArgumentError, /Missing :mimeType/) + end + end + + describe '#messages' do + let(:instance) { described_class.new } + + 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: 'First message'}, + {assistant: 'Second message'}, + {user: '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(ArgumentError, /Invalid role/) + 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 + content = instance.image_content('base64data', 'image/png') + expect(content[:type]).to eq('image') + expect(content[:data]).to eq('base64data') + 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 + + # 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 +end