├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── tiny_mcp.rb └── tiny_mcp │ └── version.rb ├── test ├── test_helper.rb └── tiny_mcp_test.rb └── tiny_mcp.gemspec /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.4.1' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.2.0] - 2025-06-07 4 | 5 | - Add support for multi-result responses of any formats 6 | 7 | ## [0.1.0] - 2025-05-26 8 | 9 | - Initial release 10 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Commands 6 | 7 | ### Development 8 | - `bundle install` - Install dependencies 9 | - `bin/setup` - Setup script that installs dependencies 10 | - `bin/console` - Interactive Ruby console with the gem loaded 11 | 12 | ### Testing 13 | - `rake test` or `bundle exec rake test` - Run all tests 14 | - `rake` - Default task (runs tests) 15 | - `ruby -Ilib:test test/test_tiny_mcp.rb` - Run specific test file 16 | 17 | ### Building and Release 18 | - `bundle exec rake install` - Build and install gem locally 19 | - `bundle exec rake release` - Create git tag and push gem to RubyGems 20 | 21 | ## Architecture Overview 22 | 23 | TinyMCP is a Ruby implementation of an MCP (Model Context Protocol) server that enables creating tools callable through JSON-RPC. 24 | 25 | ### Core Components 26 | 27 | 1. **TinyMCP::Server** - JSON-RPC server that: 28 | - Reads requests from STDIN 29 | - Processes tool calls via JSON-RPC protocol 30 | - Writes responses to STDOUT 31 | - Implements MCP server protocol 32 | 33 | 2. **TinyMCP::Tool** - Base class for tools: 34 | - Inherit from this class to create new tools 35 | - Use DSL methods: `name`, `desc`, `arg`, `opt` for metadata 36 | - Implement `call` method with tool logic 37 | - Parameters are automatically validated 38 | 39 | 3. **Data Classes** (using Ruby's Data.define): 40 | - `Definition` - Tool metadata (name, description, parameters) 41 | - `Prop` - Parameter properties (name, description, type, required) 42 | 43 | ### Tool Definition Pattern 44 | 45 | Tools use a declarative DSL: 46 | ```ruby 47 | class MyTool < TinyMCP::Tool 48 | name 'my_tool' 49 | desc 'Tool description' 50 | arg :required_param, 'string', 'Parameter description' 51 | opt :optional_param, 'string', 'Optional parameter' 52 | 53 | def call(required_param:, optional_param: nil) 54 | # Implementation 55 | end 56 | end 57 | ``` 58 | 59 | ### JSON-RPC Protocol 60 | 61 | The server implements standard JSON-RPC 2.0 for MCP: 62 | - Methods: `initialize`, `tools/list`, `tools/call` 63 | - Input/output via STDIN/STDOUT 64 | - Request/response format follows MCP specification 65 | 66 | ## Important Notes 67 | 68 | - Ruby version requirement: >= 3.1.0 69 | - Test framework: Minitest 70 | - No linting configuration currently set up 71 | - Gem is in early development (has TODOs in gemspec) 72 | - CI runs on Ruby 3.4.1 via GitHub Actions 73 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in tiny_mcp.gemspec 6 | gemspec 7 | 8 | gem 'irb' 9 | gem 'rake', '~> 13.0' 10 | 11 | gem 'minitest', '~> 5.16' 12 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | tiny_mcp (0.2.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | date (3.4.1) 10 | erb (5.0.1) 11 | io-console (0.8.0) 12 | irb (1.15.2) 13 | pp (>= 0.6.0) 14 | rdoc (>= 4.0.0) 15 | reline (>= 0.4.2) 16 | minitest (5.25.5) 17 | pp (0.6.2) 18 | prettyprint 19 | prettyprint (0.2.0) 20 | psych (5.2.6) 21 | date 22 | stringio 23 | rake (13.3.0) 24 | rdoc (6.14.0) 25 | erb 26 | psych (>= 4.0.0) 27 | reline (0.6.1) 28 | io-console (~> 0.5) 29 | stringio (3.1.7) 30 | 31 | PLATFORMS 32 | arm64-darwin-24 33 | ruby 34 | 35 | DEPENDENCIES 36 | irb 37 | minitest (~> 5.16) 38 | rake (~> 13.0) 39 | tiny_mcp! 40 | 41 | BUNDLED WITH 42 | 2.6.9 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Max Chernyak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TinyMCP 2 | 3 | A tiny Ruby implementation of the Model Context Protocol (MCP) that makes it easy to create and serve tools locally for AI assistants. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | gem install tiny_mcp 9 | ``` 10 | 11 | ## Usage 12 | 13 | Create tools by inheriting from `TinyMCP::Tool`: 14 | 15 | ```ruby 16 | #!/usr/bin/env ruby 17 | require 'tiny_mcp' 18 | 19 | class WeatherTool < TinyMCP::Tool 20 | name 'get_weather' 21 | desc 'Get current weather for a city' 22 | arg :city, :string, 'City name' # required 23 | opt :units, :string, 'Temperature units (c/f)' # optional 24 | 25 | def call(city:, units: 'c') 26 | # Your implementation here 27 | "Weather in #{city}: 20°C, sunny" 28 | end 29 | end 30 | 31 | class TimeTool < TinyMCP::Tool 32 | name 'get_time' 33 | desc 'Get current time' 34 | opt :timezone, :string, 'Timezone name' 35 | 36 | def call(timezone: 'UTC') 37 | Time.now.getlocal(timezone) 38 | end 39 | end 40 | 41 | # Serve multiple tools 42 | TinyMCP.serve(WeatherTool, TimeTool) 43 | ``` 44 | 45 | You can put this in a `bin/mcp` file for example, and make it executable: 46 | 47 | ```bash 48 | chmod +x bin/mcp 49 | ``` 50 | 51 | Then add it to Claude Code: 52 | 53 | ```bash 54 | claude mcp add my-mcp bin/mcp 55 | ``` 56 | 57 | The server reads JSON-RPC requests from stdin and writes responses to stdout. 58 | 59 | ## Multiple results and different formats 60 | 61 | By default TinyMCP assumes you're returning `text` from your call function. If you want to return image, audio, or a bunch of different results, wrap your return value in an array, and TinyMCP will treat your return value as the whole `content` body. 62 | 63 | Don't forget that binary data such as images and audio needs to be Base64-encoded. 64 | 65 | ```ruby 66 | require 'base64' 67 | 68 | class MultiModalTool < TinyMCP::Tool 69 | name 'get_different_formats' 70 | desc 'Get results in different formats' 71 | 72 | def call 73 | [ 74 | { 75 | type: 'text', 76 | data: 'This is a text response' 77 | }, 78 | { 79 | type: 'image', 80 | mimeType: 'image/png', 81 | data: Base64.strict_encode64(File.read('image.png', 'rb')) 82 | }, 83 | { 84 | type: 'audio', 85 | mimeType: 'audio/mpeg', 86 | data: Base64.strict_encode64(File.read('audio.mp3', 'rb')) 87 | } 88 | ] 89 | end 90 | end 91 | ``` 92 | 93 | ## Development 94 | 95 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 96 | 97 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 98 | 99 | ## Contributing 100 | 101 | Bug reports and pull requests are welcome on GitHub at https://github.com/maxim/tiny_mcp. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/maxim/tiny_mcp/blob/main/CODE_OF_CONDUCT.md). 102 | 103 | ## License 104 | 105 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 106 | 107 | ## Code of Conduct 108 | 109 | Everyone interacting in the TinyMCP project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/maxim/tiny_mcp/blob/main/CODE_OF_CONDUCT.md). 110 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'minitest/test_task' 5 | 6 | Minitest::TestTask.create 7 | 8 | task default: :test 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'tiny_mcp' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'irb' 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/tiny_mcp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'tiny_mcp/version' 4 | require 'json' 5 | require 'shellwords' 6 | 7 | module TinyMCP 8 | Prop = Data.define(:name, :type, :desc, :req) do 9 | def to_h = { type: type, description: desc } 10 | end 11 | 12 | class Definition 13 | attr_accessor :name, :desc, :props 14 | def initialize = @props = [] 15 | 16 | def to_h 17 | { 18 | name:, 19 | description: desc, 20 | inputSchema: { 21 | type: 'object', 22 | properties: props.map { [_1.name, _1.to_h] }.to_h, 23 | required: props.select(&:req).map(&:name) 24 | } 25 | } 26 | end 27 | end 28 | 29 | class Tool 30 | class << self 31 | attr_accessor :mcp 32 | def inherited(base) = base.mcp = Definition.new 33 | def name(string) = mcp.name = string 34 | def desc(string) = mcp.desc = string 35 | def arg(*args) = mcp.props << Prop[*args, true] 36 | def opt(*args) = mcp.props << Prop[*args, false] 37 | end 38 | 39 | def call = raise 'Override in subclass' 40 | end 41 | 42 | class Server 43 | ERROR_TYPES = { 44 | invalid_json: [-32700, 'Invalid JSON'].freeze, 45 | invalid_request: [-32600, 'Invalid request'].freeze, 46 | method_not_found: [-32601, 'Method not found'].freeze, 47 | invalid_params: [-32602, 'Invalid params'].freeze, 48 | internal: [-32603, 'Internal error'].freeze 49 | }.freeze 50 | 51 | def initialize *tools, 52 | protocol_version: '2024-11-05', 53 | server_name: 'ruby-tinymcp-server', 54 | server_version: '1.0.0', 55 | capabilities: { tools: {} } 56 | 57 | 58 | @tool_defs = tools.map { [_1.mcp.name, _1.mcp.to_h] }.to_h 59 | @tools = tools.map(&:new) 60 | 61 | @protocol_version = protocol_version 62 | @server_name = server_name 63 | @server_version = server_version 64 | @capabilities = capabilities 65 | end 66 | 67 | def run 68 | loop do 69 | input = STDIN.gets 70 | break if input.nil? 71 | 72 | request = 73 | begin 74 | JSON.parse(input.strip) 75 | rescue 76 | puts error_for({'id' => nil}, :invalid_json) 77 | STDOUT.flush 78 | next 79 | end 80 | 81 | response = handle_request(request) 82 | 83 | puts JSON.generate(response) 84 | STDOUT.flush 85 | end 86 | end 87 | 88 | private 89 | 90 | def handle_request(request) 91 | case request['method'] 92 | when 'initialize' 93 | response_for request, 94 | protocolVersion: @protocol_version, 95 | capabilities: @capabilities, 96 | serverInfo: { name: @server_name, version: @server_version } 97 | when 'tools/list' 98 | response_for request, tools: @tool_defs.values 99 | when 'tools/call' 100 | handle_tool_call request 101 | else 102 | error_for(request, :method_not_found) 103 | end 104 | end 105 | 106 | def handle_tool_call(request) 107 | name = request.dig('params', 'name') 108 | tool = @tools.find { _1.class.mcp.name == name } 109 | 110 | if !tool 111 | return error_for(request, :invalid_params, "Unknown tool: #{name}") 112 | end 113 | 114 | args = request.dig('params', 'arguments')&.transform_keys(&:to_sym) 115 | 116 | begin 117 | result = tool.call(**args) 118 | 119 | result.is_a?(Array) ? 120 | response_for(request, content: result) : 121 | response_for(request, content: [{ type: 'text', text: result.to_s }]) 122 | rescue => e 123 | error_for(request, :internal, e.full_message(highlight: false)) 124 | end 125 | end 126 | 127 | def error_for(request, type, message = ERROR_TYPES[type][1]) 128 | code = ERROR_TYPES[type][0] 129 | { jsonrpc: '2.0', id: request['id'], error: { code:, message: } } 130 | end 131 | 132 | def response_for(request, **hash) 133 | { jsonrpc: '2.0', id: request['id'], result: hash } 134 | end 135 | end 136 | 137 | def self.serve(*args, **kwargs) = Server.new(*args, **kwargs).run 138 | end 139 | -------------------------------------------------------------------------------- /lib/tiny_mcp/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyMCP 4 | VERSION = '0.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | require 'tiny_mcp' 5 | 6 | require 'minitest/autorun' 7 | -------------------------------------------------------------------------------- /test/tiny_mcp_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'stringio' 5 | 6 | class TinyMCPTest < Minitest::Test 7 | def test_that_it_has_a_version_number 8 | refute_nil ::TinyMCP::VERSION 9 | end 10 | 11 | # Prop Data Class Tests 12 | def test_prop_creation 13 | prop = TinyMCP::Prop.new(:name, :string, 'Name of the item', true) 14 | assert_equal :name, prop.name 15 | assert_equal :string, prop.type 16 | assert_equal 'Name of the item', prop.desc 17 | assert_equal true, prop.req 18 | end 19 | 20 | def test_prop_to_h 21 | prop = TinyMCP::Prop.new(:age, :number, 'Age in years', false) 22 | expected = { type: :number, description: 'Age in years' } 23 | assert_equal expected, prop.to_h 24 | end 25 | 26 | # Definition Class Tests 27 | def test_definition_initialization 28 | definition = TinyMCP::Definition.new 29 | assert_equal [], definition.props 30 | assert_nil definition.name 31 | assert_nil definition.desc 32 | end 33 | 34 | def test_definition_setting_attributes 35 | definition = TinyMCP::Definition.new 36 | definition.name = 'test_tool' 37 | definition.desc = 'A test tool' 38 | 39 | assert_equal 'test_tool', definition.name 40 | assert_equal 'A test tool', definition.desc 41 | end 42 | 43 | def test_definition_adding_props 44 | definition = TinyMCP::Definition.new 45 | definition.name = 'calculator' 46 | definition.desc = 'Basic calculator' 47 | 48 | prop1 = TinyMCP::Prop.new(:x, :number, 'First number', true) 49 | prop2 = TinyMCP::Prop.new(:y, :number, 'Second number', true) 50 | prop3 = TinyMCP::Prop.new(:precision, :number, 'Decimal precision', false) 51 | 52 | definition.props << prop1 53 | definition.props << prop2 54 | definition.props << prop3 55 | 56 | assert_equal 3, definition.props.length 57 | end 58 | 59 | def test_definition_to_h 60 | definition = TinyMCP::Definition.new 61 | definition.name = 'greet' 62 | definition.desc = 'Greets a person' 63 | 64 | definition.props << TinyMCP::Prop[:name, :string, 'Name to greet', true] 65 | definition.props << TinyMCP::Prop[:title, :string, 'Optional title', false] 66 | 67 | expected = { 68 | name: 'greet', 69 | description: 'Greets a person', 70 | inputSchema: { 71 | type: 'object', 72 | properties: { 73 | name: { type: :string, description: 'Name to greet' }, 74 | title: { type: :string, description: 'Optional title' } 75 | }, 76 | required: [:name] 77 | } 78 | } 79 | 80 | assert_equal expected, definition.to_h 81 | end 82 | 83 | # Tool Base Class Tests 84 | def test_tool_inheritance_creates_definition 85 | test_tool_class = Class.new(TinyMCP::Tool) 86 | 87 | assert_instance_of TinyMCP::Definition, test_tool_class.mcp 88 | refute_nil test_tool_class.mcp 89 | end 90 | 91 | def test_tool_dsl_methods 92 | calculator_tool_class = Class.new(TinyMCP::Tool) do 93 | name 'calculator' 94 | desc 'Performs basic calculations' 95 | arg :x, :number, 'First operand' 96 | arg :y, :number, 'Second operand' 97 | opt :operation, :string, 'Operation to perform' 98 | end 99 | 100 | assert_equal 'calculator', calculator_tool_class.mcp.name 101 | assert_equal 'Performs basic calculations', calculator_tool_class.mcp.desc 102 | assert_equal 3, calculator_tool_class.mcp.props.length 103 | 104 | # Check required args 105 | x_prop = calculator_tool_class.mcp.props[0] 106 | assert_equal :x, x_prop.name 107 | assert_equal true, x_prop.req 108 | 109 | # Check optional args 110 | op_prop = calculator_tool_class.mcp.props[2] 111 | assert_equal :operation, op_prop.name 112 | assert_equal false, op_prop.req 113 | end 114 | 115 | def test_tool_call_raises_error_when_not_overridden 116 | abstract_tool_class = Class.new(TinyMCP::Tool) do 117 | name 'abstract' 118 | desc 'Should not be called directly' 119 | end 120 | 121 | tool = abstract_tool_class.new 122 | assert_raises(RuntimeError) { tool.call } 123 | end 124 | 125 | def test_tool_with_implementation 126 | greeter_tool_class = Class.new(TinyMCP::Tool) do 127 | name 'greeter' 128 | desc 'Greets people' 129 | arg :name, :string, 'Name to greet' 130 | 131 | def call(name:) 132 | "Hello, #{name}!" 133 | end 134 | end 135 | 136 | tool = greeter_tool_class.new 137 | assert_equal "Hello, Alice!", tool.call(name: 'Alice') 138 | end 139 | 140 | # Server Initialization Tests 141 | def test_server_initialization_with_defaults 142 | server = TinyMCP::Server.new 143 | 144 | # Test instance variables via handle_request 145 | request = { 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize' } 146 | response = server.send(:handle_request, request) 147 | 148 | assert_equal '2024-11-05', response[:result][:protocolVersion] 149 | assert_equal 'ruby-tinymcp-server', response[:result][:serverInfo][:name] 150 | assert_equal({ tools: {} }, response[:result][:capabilities]) 151 | end 152 | 153 | def test_server_initialization_with_custom_values 154 | custom_tool_class = Class.new(TinyMCP::Tool) do 155 | name 'custom' 156 | desc 'Custom tool' 157 | end 158 | 159 | server = TinyMCP::Server.new( 160 | custom_tool_class, 161 | protocol_version: '2024-12-01', 162 | server_name: 'my-server', 163 | server_version: '2.0.0', 164 | capabilities: { tools: { custom: true } } 165 | ) 166 | 167 | request = { 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize' } 168 | response = server.send(:handle_request, request) 169 | 170 | assert_equal '2024-12-01', response[:result][:protocolVersion] 171 | assert_equal 'my-server', response[:result][:serverInfo][:name] 172 | assert_equal({ tools: { custom: true } }, response[:result][:capabilities]) 173 | end 174 | 175 | def test_server_creates_tool_instances 176 | tool1_class = Class.new(TinyMCP::Tool) do 177 | name 'tool1' 178 | desc 'First tool' 179 | end 180 | 181 | tool2_class = Class.new(TinyMCP::Tool) do 182 | name 'tool2' 183 | desc 'Second tool' 184 | end 185 | 186 | server = TinyMCP::Server.new(tool1_class, tool2_class) 187 | tools = server.instance_variable_get(:@tools) 188 | 189 | assert_equal 2, tools.length 190 | assert_instance_of tool1_class, tools[0] 191 | assert_instance_of tool2_class, tools[1] 192 | end 193 | 194 | # Server Request Handling Tests 195 | def test_initialize_method 196 | server = TinyMCP::Server.new 197 | request = { 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize' } 198 | response = server.send(:handle_request, request) 199 | 200 | assert_equal '2.0', response[:jsonrpc] 201 | assert_equal 1, response[:id] 202 | assert response[:result][:protocolVersion] 203 | assert response[:result][:capabilities] 204 | assert response[:result][:serverInfo] 205 | end 206 | 207 | def test_tools_list_method 208 | list_tool1_class = Class.new(TinyMCP::Tool) do 209 | name 'list_tool1' 210 | desc 'First listing tool' 211 | arg :param1, :string, 'Parameter 1' 212 | end 213 | 214 | list_tool2_class = Class.new(TinyMCP::Tool) do 215 | name 'list_tool2' 216 | desc 'Second listing tool' 217 | end 218 | 219 | server = TinyMCP::Server.new(list_tool1_class, list_tool2_class) 220 | request = { 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list' } 221 | response = server.send(:handle_request, request) 222 | 223 | assert_equal 2, response[:result][:tools].length 224 | 225 | tool1 = response[:result][:tools].find { |t| t[:name] == 'list_tool1' } 226 | assert tool1 227 | assert_equal 'First listing tool', tool1[:description] 228 | assert tool1[:inputSchema] 229 | end 230 | 231 | def test_tools_call_valid_tool 232 | add_tool_class = Class.new(TinyMCP::Tool) do 233 | name 'add' 234 | desc 'Adds two numbers' 235 | arg :x, :number, 'First number' 236 | arg :y, :number, 'Second number' 237 | 238 | def call(x:, y:) 239 | x + y 240 | end 241 | end 242 | 243 | server = TinyMCP::Server.new(add_tool_class) 244 | request = { 245 | 'jsonrpc' => '2.0', 246 | 'id' => 3, 247 | 'method' => 'tools/call', 248 | 'params' => { 249 | 'name' => 'add', 250 | 'arguments' => { 'x' => 5, 'y' => 3 } 251 | } 252 | } 253 | 254 | response = server.send(:handle_request, request) 255 | 256 | assert_equal '2.0', response[:jsonrpc] 257 | assert_equal 3, response[:id] 258 | assert_equal [{ type: 'text', text: '8' }], response[:result][:content] 259 | end 260 | 261 | def test_tools_call_nonexistent_tool 262 | server = TinyMCP::Server.new 263 | request = { 264 | 'jsonrpc' => '2.0', 265 | 'id' => 4, 266 | 'method' => 'tools/call', 267 | 'params' => { 268 | 'name' => 'nonexistent', 269 | 'arguments' => {} 270 | } 271 | } 272 | 273 | response = server.send(:handle_request, request) 274 | 275 | assert response[:error] 276 | assert_equal(-32602, response[:error][:code]) 277 | assert_match(/Unknown tool: nonexistent/, response[:error][:message]) 278 | end 279 | 280 | def test_tools_call_with_error 281 | error_tool_class = Class.new(TinyMCP::Tool) do 282 | name 'error_tool' 283 | desc 'Tool that raises an error' 284 | 285 | def call 286 | raise 'Something went wrong!' 287 | end 288 | end 289 | 290 | server = TinyMCP::Server.new(error_tool_class) 291 | request = { 292 | 'jsonrpc' => '2.0', 293 | 'id' => 5, 294 | 'method' => 'tools/call', 295 | 'params' => { 296 | 'name' => 'error_tool', 297 | 'arguments' => {} 298 | } 299 | } 300 | 301 | response = server.send(:handle_request, request) 302 | 303 | assert response[:error] 304 | assert_equal(-32603, response[:error][:code]) 305 | assert_match(/Something went wrong!/, response[:error][:message]) 306 | end 307 | 308 | def test_unknown_method 309 | server = TinyMCP::Server.new 310 | request = { 'jsonrpc' => '2.0', 'id' => 6, 'method' => 'unknown/method' } 311 | response = server.send(:handle_request, request) 312 | 313 | assert response[:error] 314 | assert_equal(-32601, response[:error][:code]) 315 | assert_equal 'Method not found', response[:error][:message] 316 | end 317 | 318 | # Error Handling Tests 319 | def test_error_types 320 | server = TinyMCP::Server.new 321 | 322 | # Test each error type 323 | error_types = { 324 | invalid_json: [-32700, 'Invalid JSON'], 325 | invalid_request: [-32600, 'Invalid request'], 326 | method_not_found: [-32601, 'Method not found'], 327 | invalid_params: [-32602, 'Invalid params'], 328 | internal: [-32603, 'Internal error'] 329 | } 330 | 331 | error_types.each do |type, (code, message)| 332 | error = server.send(:error_for, { 'id' => 1 }, type) 333 | assert_equal code, error[:error][:code] 334 | assert_equal message, error[:error][:message] 335 | end 336 | end 337 | 338 | def test_error_with_custom_message 339 | server = TinyMCP::Server.new 340 | error = 341 | server.send(:error_for, { 'id' => 1 }, :internal, 'Custom error message') 342 | 343 | assert_equal(-32603, error[:error][:code]) 344 | assert_equal 'Custom error message', error[:error][:message] 345 | end 346 | 347 | # Integration Tests 348 | def test_full_request_response_cycle 349 | echo_tool_class = Class.new(TinyMCP::Tool) do 350 | name 'echo' 351 | desc 'Echoes the input' 352 | arg :message, :string, 'Message to echo' 353 | 354 | def call(message:) 355 | message 356 | end 357 | end 358 | 359 | # Capture STDOUT 360 | old_stdout = $stdout 361 | $stdout = StringIO.new 362 | 363 | # Mock STDIN 364 | input = StringIO.new 365 | input.puts \ 366 | '{"jsonrpc":"2.0","id":1,"method":"tools/call",' \ 367 | '"params":{"name":"echo","arguments":{"message":"Hello"}}}' 368 | 369 | input.rewind 370 | old_stdin = $stdin 371 | $stdin = input 372 | 373 | server = TinyMCP::Server.new(echo_tool_class) 374 | 375 | # Run one iteration 376 | begin 377 | input_line = $stdin.gets 378 | request = JSON.parse(input_line.strip) 379 | response = server.send(:handle_request, request) 380 | $stdout.puts JSON.generate(response) 381 | $stdout.flush 382 | rescue 383 | end 384 | 385 | # Restore IO 386 | $stdin = old_stdin 387 | output = $stdout.string 388 | $stdout = old_stdout 389 | 390 | response = JSON.parse(output.strip) 391 | assert_equal 'Hello', response['result']['content'][0]['text'] 392 | end 393 | 394 | # Edge Cases 395 | def test_tool_with_no_parameters 396 | no_param_tool_class = Class.new(TinyMCP::Tool) do 397 | name 'no_param' 398 | desc 'Tool with no parameters' 399 | 400 | def call 401 | 'No parameters needed' 402 | end 403 | end 404 | 405 | server = TinyMCP::Server.new(no_param_tool_class) 406 | request = { 407 | 'jsonrpc' => '2.0', 408 | 'id' => 1, 409 | 'method' => 'tools/call', 410 | 'params' => { 411 | 'name' => 'no_param', 412 | 'arguments' => {} 413 | } 414 | } 415 | 416 | response = server.send(:handle_request, request) 417 | assert_equal 'No parameters needed', response[:result][:content][0][:text] 418 | end 419 | 420 | def test_tool_with_only_optional_parameters 421 | optional_tool_class = Class.new(TinyMCP::Tool) do 422 | name 'optional' 423 | desc 'Tool with only optional parameters' 424 | opt :greeting, :string, 'Optional greeting' 425 | opt :punctuation, :string, 'Optional punctuation' 426 | 427 | def call(greeting: 'Hello', punctuation: '!') 428 | "#{greeting} World#{punctuation}" 429 | end 430 | end 431 | 432 | server = TinyMCP::Server.new(optional_tool_class) 433 | 434 | # Call with no arguments 435 | request = { 436 | 'jsonrpc' => '2.0', 437 | 'id' => 1, 438 | 'method' => 'tools/call', 439 | 'params' => { 440 | 'name' => 'optional', 441 | 'arguments' => {} 442 | } 443 | } 444 | 445 | response = server.send(:handle_request, request) 446 | assert_equal 'Hello World!', response[:result][:content][0][:text] 447 | 448 | # Call with some arguments 449 | request['params']['arguments'] = { 'greeting' => 'Hi' } 450 | response = server.send(:handle_request, request) 451 | assert_equal 'Hi World!', response[:result][:content][0][:text] 452 | end 453 | 454 | def test_nil_request_id 455 | server = TinyMCP::Server.new 456 | request = { 'jsonrpc' => '2.0', 'id' => nil, 'method' => 'tools/list' } 457 | response = server.send(:handle_request, request) 458 | 459 | assert_nil response[:id] 460 | assert response[:result] 461 | end 462 | 463 | def test_missing_request_id 464 | server = TinyMCP::Server.new 465 | request = { 'jsonrpc' => '2.0', 'method' => 'tools/list' } 466 | response = server.send(:handle_request, request) 467 | 468 | assert_nil response[:id] 469 | assert response[:result] 470 | end 471 | 472 | def test_empty_tool_list 473 | server = TinyMCP::Server.new 474 | request = { 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list' } 475 | response = server.send(:handle_request, request) 476 | 477 | assert_equal [], response[:result][:tools] 478 | end 479 | 480 | def test_string_keys_converted_to_symbols 481 | symbol_tool_class = Class.new(TinyMCP::Tool) do 482 | name 'symbol_test' 483 | desc 'Tests string to symbol conversion' 484 | arg :test_param, :string, 'Test parameter' 485 | 486 | def call(test_param:) 487 | "Received: #{test_param}" 488 | end 489 | end 490 | 491 | server = TinyMCP::Server.new(symbol_tool_class) 492 | request = { 493 | 'jsonrpc' => '2.0', 494 | 'id' => 1, 495 | 'method' => 'tools/call', 496 | 'params' => { 497 | 'name' => 'symbol_test', 498 | 'arguments' => { 'test_param' => 'value' } 499 | } 500 | } 501 | 502 | response = server.send(:handle_request, request) 503 | assert_equal 'Received: value', response[:result][:content][0][:text] 504 | end 505 | 506 | # Multi-modal Content Tests 507 | def test_tool_returning_array_of_content_items 508 | multi_content_tool_class = Class.new(TinyMCP::Tool) do 509 | name 'multi_content' 510 | desc 'Returns multiple content items' 511 | arg :content_type, :string, 'Type of content to return' 512 | 513 | def call(content_type:) 514 | case content_type 515 | when 'multiple_text' 516 | [ 517 | { type: 'text', text: 'First text item' }, 518 | { type: 'text', text: 'Second text item' }, 519 | { type: 'text', text: 'Third text item' } 520 | ] 521 | when 'mixed' 522 | [ 523 | { type: 'text', text: 'Some text content' }, 524 | { type: 'image', 525 | data: 'base64-encoded-image-data', 526 | mimeType: 'image/png' }, 527 | { type: 'text', text: 'More text after image' } 528 | ] 529 | end 530 | end 531 | end 532 | 533 | server = TinyMCP::Server.new(multi_content_tool_class) 534 | 535 | # Test multiple text content 536 | request = { 537 | 'jsonrpc' => '2.0', 538 | 'id' => 1, 539 | 'method' => 'tools/call', 540 | 'params' => { 541 | 'name' => 'multi_content', 542 | 'arguments' => { 'content_type' => 'multiple_text' } 543 | } 544 | } 545 | 546 | response = server.send(:handle_request, request) 547 | content = response[:result][:content] 548 | 549 | assert_equal 3, content.length 550 | assert_equal 'First text item', content[0][:text] 551 | assert_equal 'Second text item', content[1][:text] 552 | assert_equal 'Third text item', content[2][:text] 553 | content.each { |item| assert_equal 'text', item[:type] } 554 | end 555 | 556 | def test_tool_returning_mixed_content_types 557 | mixed_tool_class = Class.new(TinyMCP::Tool) do 558 | name 'mixed_content' 559 | desc 'Returns mixed content types' 560 | 561 | def call 562 | [ 563 | { type: 'text', text: 'Here is some text' }, 564 | { type: 'image', 565 | mimeType: 'image/png', 566 | data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42' \ 567 | 'mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' }, 568 | { type: 'text', text: 'And more text after the image' }, 569 | { type: 'resource', 570 | uri: 'file:///path/to/resource.txt', 571 | text: 'Resource reference' } 572 | ] 573 | end 574 | end 575 | 576 | server = TinyMCP::Server.new(mixed_tool_class) 577 | request = { 578 | 'jsonrpc' => '2.0', 579 | 'id' => 1, 580 | 'method' => 'tools/call', 581 | 'params' => { 582 | 'name' => 'mixed_content', 583 | 'arguments' => {} 584 | } 585 | } 586 | 587 | response = server.send(:handle_request, request) 588 | content = response[:result][:content] 589 | 590 | assert_equal 4, content.length 591 | 592 | # Check text content 593 | assert_equal 'text', content[0][:type] 594 | assert_equal 'Here is some text', content[0][:text] 595 | 596 | # Check image content 597 | assert_equal 'image', content[1][:type] 598 | assert_equal 'image/png', content[1][:mimeType] 599 | assert_equal 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42' \ 600 | 'mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', 601 | content[1][:data] 602 | 603 | # Check second text content 604 | assert_equal 'text', content[2][:type] 605 | assert_equal 'And more text after the image', content[2][:text] 606 | 607 | # Check resource content 608 | assert_equal 'resource', content[3][:type] 609 | assert_equal 'file:///path/to/resource.txt', content[3][:uri] 610 | assert_equal 'Resource reference', content[3][:text] 611 | end 612 | 613 | def test_tool_returning_empty_array 614 | empty_tool_class = Class.new(TinyMCP::Tool) do 615 | name 'empty_content' 616 | desc 'Returns empty content array' 617 | 618 | def call 619 | [] 620 | end 621 | end 622 | 623 | server = TinyMCP::Server.new(empty_tool_class) 624 | request = { 625 | 'jsonrpc' => '2.0', 626 | 'id' => 1, 627 | 'method' => 'tools/call', 628 | 'params' => { 629 | 'name' => 'empty_content', 630 | 'arguments' => {} 631 | } 632 | } 633 | 634 | response = server.send(:handle_request, request) 635 | content = response[:result][:content] 636 | 637 | assert_equal [], content 638 | assert_equal 0, content.length 639 | end 640 | 641 | def test_tool_returning_single_item_array 642 | single_tool_class = Class.new(TinyMCP::Tool) do 643 | name 'single_content' 644 | desc 'Returns single content item in array' 645 | arg :message, :string, 'Message to return' 646 | 647 | def call(message:) 648 | [{ type: 'text', text: message }] 649 | end 650 | end 651 | 652 | server = TinyMCP::Server.new(single_tool_class) 653 | request = { 654 | 'jsonrpc' => '2.0', 655 | 'id' => 1, 656 | 'method' => 'tools/call', 657 | 'params' => { 658 | 'name' => 'single_content', 659 | 'arguments' => { 'message' => 'Single item message' } 660 | } 661 | } 662 | 663 | response = server.send(:handle_request, request) 664 | content = response[:result][:content] 665 | 666 | assert_equal 1, content.length 667 | assert_equal 'text', content[0][:type] 668 | assert_equal 'Single item message', content[0][:text] 669 | end 670 | end 671 | -------------------------------------------------------------------------------- /tiny_mcp.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/tiny_mcp/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'tiny_mcp' 7 | spec.version = TinyMCP::VERSION 8 | spec.authors = ['Max Chernyak'] 9 | spec.email = ['hello@max.engineer'] 10 | 11 | spec.summary = 'Tiny Ruby-based MCP server' 12 | spec.description = 'Make local MCP tools in Ruby and easily serve them.' 13 | spec.homepage = 'https://github.com/maxim/tiny_mcp' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '>= 3.1.0' 16 | 17 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = spec.homepage 20 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 21 | 22 | gemspec = File.basename(__FILE__) 23 | spec.files = 24 | IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) { |ls| 25 | ls.readlines("\x0", chomp: true).reject { |f| 26 | (f == gemspec) || f.start_with?(*%w[bin/ test/ .git .github Gemfile]) 27 | } 28 | } 29 | 30 | spec.require_paths = ['lib'] 31 | end 32 | --------------------------------------------------------------------------------