From 469087de301bf306185cf8376ccad9ea63e8d78c Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Tue, 1 Apr 2025 02:03:08 +0100 Subject: [PATCH 1/6] feat: Server / Prompts This commit introduces four new example prompts demonstrating different use cases for code review, including inline prompt, multi-message prompt with context, and two separate ERB templates. It also includes a `prompt_code_review.rb` file that utilizes ERB templates to construct the assistant and user messages for a code review scenario. --- .rspec_status | 104 ----- CHANGELOG.md | 7 + examples/prompts/inline_prompt.rb | 36 ++ examples/prompts/multi_message_prompt.rb | 46 +++ examples/prompts/prompt_code_review.rb | 28 ++ .../templates/code_review_assistant.erb | 1 + .../prompts/templates/code_review_user.erb | 7 + lib/fast_mcp.rb | 17 + lib/mcp/prompt.rb | 217 ++++++++++ lib/mcp/server.rb | 116 +++++- spec/mcp/prompt_spec.rb | 389 ++++++++++++++++++ 11 files changed, 863 insertions(+), 105 deletions(-) delete mode 100644 .rspec_status create mode 100644 examples/prompts/inline_prompt.rb create mode 100644 examples/prompts/multi_message_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/mcp/prompt.rb create mode 100644 spec/mcp/prompt_spec.rb 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..0f96fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.0] - 2025-04-01 + +### Added +- Server prompts handling with support for templates +- New flexible API for the `messages` method that supports both hash and array inputs +- Updated example prompts to demonstrate the new API capabilities + ## [1.0.0] - 2025-03-30 ### Added 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..5d5ae89 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,78 @@ 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 + 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 + + send_result({ prompts: prompts_list, nextCursor: next_cursor }, 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/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 From 81797098fb3169eaa9e60ab5da4d7ab2b4527d87 Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Thu, 3 Apr 2025 22:05:43 +0100 Subject: [PATCH 2/6] docs: Prompts documentation and examples, reorganize README sections --- README.md | 11 +- docs/prompts.md | 490 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 docs/prompts.md diff --git a/README.md b/README.md index 507188b..ab00b18 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 @@ -284,9 +284,6 @@ 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 | Feature | Status | @@ -294,6 +291,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 +313,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 +322,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 +353,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 +``` From 191a3a047dff305d4da4f2bebc8df5b400819e0a Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Thu, 3 Apr 2025 22:11:11 +0100 Subject: [PATCH 3/6] Bump version to 1.1.0 and update changelog with prompt features --- CHANGELOG.md | 8 ++++---- Gemfile.lock | 2 +- README.md | 4 ++++ lib/mcp/version.rb | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f96fe5..a675449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,12 @@ 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.0] - 2025-04-01 +## [1.1.0] - 2025-04-04 ### Added -- Server prompts handling with support for templates -- New flexible API for the `messages` method that supports both hash and array inputs -- Updated example prompts to demonstrate the new API capabilities +- 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 diff --git a/Gemfile.lock b/Gemfile.lock index 683eb69..1019a44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fast-mcp (1.0.0) + fast-mcp (1.1.0) base64 dry-schema (~> 1.14) json (~> 2.0) diff --git a/README.md b/README.md index ab00b18..515fa23 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,10 @@ 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 | Feature | Status | diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 76e36aa..b617e0f 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.0' end From c4c6d810bf236c82f5b2d50ea7ea9a2b5b9d6557 Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Fri, 4 Apr 2025 14:40:18 +0100 Subject: [PATCH 4/6] Fix: misleading pagination keys and BUMP 1.1.1 --- CHANGELOG.md | 7 ++++++- Gemfile.lock | 2 +- lib/mcp/server.rb | 8 ++++++-- lib/mcp/version.rb | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a675449..824bf7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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.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 @@ -32,7 +37,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 1019a44..06b5394 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fast-mcp (1.1.0) + fast-mcp (1.1.1) base64 dry-schema (~> 1.14) json (~> 2.0) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5d5ae89..60c54ab 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -440,7 +440,9 @@ def handle_resources_unsubscribe(params, id) 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 - next_cursor = params['cursor'] + + # TODO: We don't have pagination utils + # next_cursor = params['cursor'] prompts_list = @prompts.values.map do |prompt| prompt_data = { @@ -469,7 +471,9 @@ def handle_prompts_list(params, id) prompt_data end - send_result({ prompts: prompts_list, nextCursor: next_cursor }, id) + # 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 diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index b617e0f..b972235 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -2,5 +2,5 @@ # Version information module FastMcp - VERSION = '1.1.0' + VERSION = '1.1.1' end From 94e24d2652a21e64532af72cc7a501fa2330cc89 Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Sat, 5 Apr 2025 16:04:36 +0100 Subject: [PATCH 5/6] feat: Improve STDIO transport layer for large JSON-RPC inputs * Non-blocking reads with read_nonblock: Avoids stalling the thread when no input is available. * Custom buffering: Handles partial reads, which can happen when a long message is sent in chunks. * Safer handling of large inputs: Introduces @max_line_size = 1MB to prevent memory abuse (e.g., if a client sends a huge unbroken stream). * Error logging and cleanup on oversized or malformed input. --- .gitignore | 2 + CHANGELOG.md | 7 ++ Gemfile.lock | 2 +- fix-the-lang-input.md | 117 ++++++++++++++++++++++++++ lib/mcp/transports/stdio_transport.rb | 54 +++++++++++- lib/mcp/version.rb | 2 +- 6 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 fix-the-lang-input.md 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/CHANGELOG.md b/CHANGELOG.md index 824bf7e..a31457d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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 diff --git a/Gemfile.lock b/Gemfile.lock index 06b5394..4f7d809 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fast-mcp (1.1.1) + fast-mcp (1.1.2) base64 dry-schema (~> 1.14) json (~> 2.0) diff --git a/fix-the-lang-input.md b/fix-the-lang-input.md new file mode 100644 index 0000000..aa730cc --- /dev/null +++ b/fix-the-lang-input.md @@ -0,0 +1,117 @@ +Perfect β€” here’s the AI coding agent prompt to improve your stdio_transport.rb MCP server’s STDIO layer handling of large JSON-RPC inputs, formatted in Markdown: + +πŸ› οΈ Prompt: Improve STDIO Transport Layer for Ruby MCP Server + +πŸ“ Description + +You are a coding assistant helping to improve a Ruby-based implementation of the Model Context Protocol (MCP) server, specifically its STDIO transport layer as defined in the MCP 2024-11-05 specification. + +The server currently uses STDIO to read JSON-RPC requests, one per line. However, when a large request is sent (e.g. a long prompt with an embedded transcript), it fails to process the message correctly β€” either due to partial reads or incorrect string splitting. + +🎯 Goals + +Improve stdio_transport.rb to: + 1. βœ… Fully support large single-line JSON inputs (even those thousands of characters long). + 2. βœ… Respect the line-delimited JSON-RPC format (one JSON string per line). + 3. βœ… Avoid breaking on escaped newlines \n inside string values. + 4. βœ… Handle partial/buffered STDIN reads and message reassembly correctly. + 5. βœ… Gracefully handle invalid JSON or oversized lines with error logging. + 6. βœ… Stay compliant with initialize, prompts/list, and prompts/get. + +πŸ”§ Tasks + β€’ Rewrite or improve read_line logic to correctly accumulate until full newline is reached. + β€’ Use a read buffer strategy that prevents premature splitting or truncation. + β€’ Ensure output to STDOUT flushes immediately after sending JSON-RPC response. + β€’ Add clear error messages for: + β€’ JSON parse failures + β€’ Message too long + β€’ Missing required fields + β€’ Optionally: add a test mode that simulates large input through pipes or fixtures. + +πŸ“ File to Improve + +stdio_transport.rb + +# Your existing implementation here... +# Insert or paste full file content or work from this file structure. + +πŸ§ͺ Context + β€’ The JSON input can contain long string values (e.g., transcripts). + β€’ Clients like Claude Desktop and Continue use strict STDIO only. + β€’ Escaped newlines (\n) should not be interpreted as message boundaries. + +🧷 Example Input (1-line JSON-RPC with long transcript) + +{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"SummarizeTranscriptPrompt","arguments":{"transcript":"OpenAI just published this paper... ","meta":{"source":"manual test"}}}} + +EXAMPLE - how it can be done: + +```ruby +require 'json' + +class StdioTransport + def initialize + @input = $stdin + @output = $stdout + @buffer = "" + end + + def run(&handle_message) + loop do + begin + # Read a full line (JSON-RPC spec says one JSON per line) + line = @input.gets + break if line.nil? + + # Clean line endings and append to buffer + line.strip! + next if line.empty? + + # Attempt to parse complete JSON message + message = JSON.parse(line) + log_debug("Received JSON-RPC message", message) + + # Handle and respond + response = handle_message.call(message) + send(response) if response + rescue JSON::ParserError => e + log_error("JSON parsing failed", e.message) + send_error(-32700, "Parse error: Invalid JSON", nil) + rescue => e + log_error("Unexpected error", e.message) + send_error(-32000, "Internal server error", nil) + end + end + end + + def send(payload) + json = payload.to_json + @output.puts(json) + @output.flush + rescue => e + log_error("Failed to send response", e.message) + end + + def send_error(code, message, id = nil) + error_payload = { + jsonrpc: "2.0", + id: id, + error: { + code: code, + message: message + } + } + send(error_payload) + end + + private + + def log_debug(label, obj) + $stderr.puts "[DEBUG] #{label}: #{obj.inspect}" + end + + def log_error(label, message) + $stderr.puts "[ERROR] #{label}: #{message}" + end +end +``` 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 b972235..384d4c8 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -2,5 +2,5 @@ # Version information module FastMcp - VERSION = '1.1.1' + VERSION = '1.1.2' end From 49b47cf8134bd4653588914d03023bdf9cc7918f Mon Sep 17 00:00:00 2001 From: Michal Czyz Date: Sat, 5 Apr 2025 16:23:15 +0100 Subject: [PATCH 6/6] docs: Remove outdated documentation for `stdio_transport.rb` --- fix-the-lang-input.md | 117 ------------------------------------------ 1 file changed, 117 deletions(-) delete mode 100644 fix-the-lang-input.md diff --git a/fix-the-lang-input.md b/fix-the-lang-input.md deleted file mode 100644 index aa730cc..0000000 --- a/fix-the-lang-input.md +++ /dev/null @@ -1,117 +0,0 @@ -Perfect β€” here’s the AI coding agent prompt to improve your stdio_transport.rb MCP server’s STDIO layer handling of large JSON-RPC inputs, formatted in Markdown: - -πŸ› οΈ Prompt: Improve STDIO Transport Layer for Ruby MCP Server - -πŸ“ Description - -You are a coding assistant helping to improve a Ruby-based implementation of the Model Context Protocol (MCP) server, specifically its STDIO transport layer as defined in the MCP 2024-11-05 specification. - -The server currently uses STDIO to read JSON-RPC requests, one per line. However, when a large request is sent (e.g. a long prompt with an embedded transcript), it fails to process the message correctly β€” either due to partial reads or incorrect string splitting. - -🎯 Goals - -Improve stdio_transport.rb to: - 1. βœ… Fully support large single-line JSON inputs (even those thousands of characters long). - 2. βœ… Respect the line-delimited JSON-RPC format (one JSON string per line). - 3. βœ… Avoid breaking on escaped newlines \n inside string values. - 4. βœ… Handle partial/buffered STDIN reads and message reassembly correctly. - 5. βœ… Gracefully handle invalid JSON or oversized lines with error logging. - 6. βœ… Stay compliant with initialize, prompts/list, and prompts/get. - -πŸ”§ Tasks - β€’ Rewrite or improve read_line logic to correctly accumulate until full newline is reached. - β€’ Use a read buffer strategy that prevents premature splitting or truncation. - β€’ Ensure output to STDOUT flushes immediately after sending JSON-RPC response. - β€’ Add clear error messages for: - β€’ JSON parse failures - β€’ Message too long - β€’ Missing required fields - β€’ Optionally: add a test mode that simulates large input through pipes or fixtures. - -πŸ“ File to Improve - -stdio_transport.rb - -# Your existing implementation here... -# Insert or paste full file content or work from this file structure. - -πŸ§ͺ Context - β€’ The JSON input can contain long string values (e.g., transcripts). - β€’ Clients like Claude Desktop and Continue use strict STDIO only. - β€’ Escaped newlines (\n) should not be interpreted as message boundaries. - -🧷 Example Input (1-line JSON-RPC with long transcript) - -{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"SummarizeTranscriptPrompt","arguments":{"transcript":"OpenAI just published this paper... ","meta":{"source":"manual test"}}}} - -EXAMPLE - how it can be done: - -```ruby -require 'json' - -class StdioTransport - def initialize - @input = $stdin - @output = $stdout - @buffer = "" - end - - def run(&handle_message) - loop do - begin - # Read a full line (JSON-RPC spec says one JSON per line) - line = @input.gets - break if line.nil? - - # Clean line endings and append to buffer - line.strip! - next if line.empty? - - # Attempt to parse complete JSON message - message = JSON.parse(line) - log_debug("Received JSON-RPC message", message) - - # Handle and respond - response = handle_message.call(message) - send(response) if response - rescue JSON::ParserError => e - log_error("JSON parsing failed", e.message) - send_error(-32700, "Parse error: Invalid JSON", nil) - rescue => e - log_error("Unexpected error", e.message) - send_error(-32000, "Internal server error", nil) - end - end - end - - def send(payload) - json = payload.to_json - @output.puts(json) - @output.flush - rescue => e - log_error("Failed to send response", e.message) - end - - def send_error(code, message, id = nil) - error_payload = { - jsonrpc: "2.0", - id: id, - error: { - code: code, - message: message - } - } - send(error_payload) - end - - private - - def log_debug(label, obj) - $stderr.puts "[DEBUG] #{label}: #{obj.inspect}" - end - - def log_error(label, message) - $stderr.puts "[ERROR] #{label}: #{message}" - end -end -```