mewmew 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +99 -0
- data/Rakefile +10 -0
- data/examples/example_cli.rb +118 -0
- data/examples/mcp_server.sh +3 -0
- data/lib/mewmew/mcp_integration.rb +82 -0
- data/lib/mewmew/thor_introspector.rb +187 -0
- data/lib/mewmew/version.rb +5 -0
- data/lib/mewmew.rb +41 -0
- data/sig/mewmew.rbs +4 -0
- data/test/integration_test.rb +244 -0
- data/test/run_tests.sh +36 -0
- data/test/smoke_test.rb +68 -0
- metadata +86 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 769d8c200467f6133c57ff947521b1a73be13d273c182904b8ba5b51d448f551
|
4
|
+
data.tar.gz: f39f7bceb745991663d71ef9e3e69a924bb978cd2108c06440fdc22f98628c1a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6b1fe4ae16186be03d19c9fb2af9e1ad63284ffb21eb9b68ad51791a54318f99b53c5116760b21ed3803e76a1f1d0704ec8e1ffd711d9c03c9e98deafdb72292
|
7
|
+
data.tar.gz: 61322d6cbffd4aca03bd8c4919b28c643443381d0edb992f686f7d76dae3ad51856073cc39da2c598876f954b3fe000a69558290814c47ef712476336e72aeda
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Martin Emde
|
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.
|
data/README.md
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
# Mewmew
|
2
|
+
|
3
|
+
Just add Mewmew to make any Thor CLI into an MCP server.
|
4
|
+
|
5
|
+
Mewmew automatically converts Thor CLI commands into MCP (Model Context Protocol) tools that can be used by AI assistants.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add to your Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'mewmew'
|
13
|
+
```
|
14
|
+
|
15
|
+
or add to a gemspec for a command that already uses Thor.
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
### 1. Include Mewmew in your Thor class
|
20
|
+
|
21
|
+
Simply add `include Mewmew` to your Thor CLI class:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
require 'thor'
|
25
|
+
require 'mewmew'
|
26
|
+
|
27
|
+
class MyCli < Thor
|
28
|
+
include Mewmew # MCP command is automatically added!
|
29
|
+
|
30
|
+
# Optional: Customize MCP server options
|
31
|
+
# add_mcp_command(name: "my_custom_name", version: "2.0.0")
|
32
|
+
|
33
|
+
desc "hello NAME", "Say hello to someone"
|
34
|
+
def hello(name)
|
35
|
+
puts "Hello, #{name}!"
|
36
|
+
end
|
37
|
+
|
38
|
+
desc "greet", "Greet the world"
|
39
|
+
method_option :style, type: :string, default: "normal", desc: "Greeting style"
|
40
|
+
def greet
|
41
|
+
style = options[:style]
|
42
|
+
case style
|
43
|
+
when "loud"
|
44
|
+
puts "HELLO WORLD!"
|
45
|
+
when "quiet"
|
46
|
+
puts "hello world..."
|
47
|
+
else
|
48
|
+
puts "Hello world!"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
### 2. Start the MCP server
|
55
|
+
|
56
|
+
Run your CLI with the `mcp` command:
|
57
|
+
|
58
|
+
```bash
|
59
|
+
ruby my_cli mcp
|
60
|
+
```
|
61
|
+
|
62
|
+
## Cursor Integration
|
63
|
+
|
64
|
+
To use your CLI with Cursor:
|
65
|
+
|
66
|
+
1. **Configure MCP in Cursor**: Add this to your `.cursor/mcp.json`:
|
67
|
+
|
68
|
+
```json
|
69
|
+
{
|
70
|
+
"mcpServers": {
|
71
|
+
"my_cli": {
|
72
|
+
"command": "/path/to/ruby",
|
73
|
+
"args": ["/path/to/your/my_cli.rb", "mcp"]
|
74
|
+
}
|
75
|
+
}
|
76
|
+
}
|
77
|
+
```
|
78
|
+
|
79
|
+
2. **Restart Cursor** to pick up the MCP configuration
|
80
|
+
|
81
|
+
3. **Use in Cursor**: Your CLI commands will now be available as tools that Cursor can call automatically!
|
82
|
+
|
83
|
+
## Example
|
84
|
+
|
85
|
+
Check out the complete example in `examples/example_cli.rb` which creates a Wikipedia lookup CLI that can be used with Cursor.
|
86
|
+
|
87
|
+
## Features
|
88
|
+
|
89
|
+
- š **Automatic detection**: Just `include Mewmew` in any Thor class
|
90
|
+
- ā” **Zero configuration**: MCP tools are automatically added - no extra steps needed
|
91
|
+
- š ļø **Works with existing Thor commands out of the box**
|
92
|
+
- š **Full option support**: Method options, descriptions, and arguments are preserved
|
93
|
+
- š **Live updates**: Changes to your CLI are immediately available to Cursor
|
94
|
+
|
95
|
+
## Requirements
|
96
|
+
|
97
|
+
- Ruby 3.0+
|
98
|
+
- Thor gem
|
99
|
+
- MCP gem (installed automatically)
|
data/Rakefile
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
begin
|
5
|
+
require "thor"
|
6
|
+
rescue LoadError
|
7
|
+
warn "This example requires the 'thor' gem. Install with: gem install thor"
|
8
|
+
exit 1
|
9
|
+
end
|
10
|
+
|
11
|
+
require "net/http"
|
12
|
+
require "json"
|
13
|
+
require "uri"
|
14
|
+
|
15
|
+
# Load mewmew for MCP integration
|
16
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
17
|
+
require "mewmew"
|
18
|
+
|
19
|
+
class McuCli < Thor
|
20
|
+
include Mewmew
|
21
|
+
|
22
|
+
# MCP command is automatically added when Mewmew is included!
|
23
|
+
# You can optionally customize it: add_mcp_command(name: "mcu_cli", version: "1.0.0")
|
24
|
+
desc "lookup [QUERY]", "Lookup a movie/comic item on Wikipedia and print a summary"
|
25
|
+
method_option :lang, type: :string, default: "en", desc: "Wikipedia language code"
|
26
|
+
def lookup(query = nil)
|
27
|
+
lang = options[:lang]
|
28
|
+
normalized_query = normalize_query(query)
|
29
|
+
title = find_best_title(normalized_query, lang) || normalized_query
|
30
|
+
summary = fetch_summary(title, lang)
|
31
|
+
|
32
|
+
if summary && !summary.strip.empty?
|
33
|
+
say "Title: #{title}", :green
|
34
|
+
say summary
|
35
|
+
else
|
36
|
+
say "No summary found for '#{query}'.", :red
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
desc "hammer", "Show info about Thor's hammer (Mjolnir) a.k.a. 'Mew Mew'"
|
41
|
+
method_option :lang, type: :string, default: "en", desc: "Wikipedia language code"
|
42
|
+
def hammer
|
43
|
+
lookup("Mjolnir")
|
44
|
+
end
|
45
|
+
|
46
|
+
no_commands do
|
47
|
+
def normalize_query(q)
|
48
|
+
return "Mjolnir" if q.nil? || q.strip.empty?
|
49
|
+
key = q.to_s.strip.downcase
|
50
|
+
case key
|
51
|
+
when "mew mew", "meow meow", "mewmew", "meowmeow", "thor's hammer", "thors hammer"
|
52
|
+
"Mjolnir"
|
53
|
+
when "mjolnir", "mjƶlner", "mjƶllnir", "mjolnir (comics)", "mjƶlnir"
|
54
|
+
"Mjolnir"
|
55
|
+
else
|
56
|
+
q
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def find_best_title(query, lang)
|
61
|
+
# Use MediaWiki opensearch to find the closest matching title
|
62
|
+
uri = URI("https://#{lang}.wikipedia.org/w/api.php")
|
63
|
+
uri.query = URI.encode_www_form(
|
64
|
+
action: "opensearch",
|
65
|
+
format: "json",
|
66
|
+
search: query,
|
67
|
+
limit: 5,
|
68
|
+
namespace: 0
|
69
|
+
)
|
70
|
+
data = fetch_json(uri)
|
71
|
+
return nil unless data.is_a?(Array) && data[1].is_a?(Array) && data[1].any?
|
72
|
+
|
73
|
+
titles = data[1]
|
74
|
+
descriptions = data[2] || []
|
75
|
+
|
76
|
+
ranked = titles.each_with_index.sort_by do |title, i|
|
77
|
+
desc = descriptions[i].to_s.downcase
|
78
|
+
score = 0
|
79
|
+
score -= i # earlier results get higher priority
|
80
|
+
score += 3 if title.downcase.include?("mjolnir") || title.downcase.include?("mjƶlnir")
|
81
|
+
score += 2 if desc.include?("marvel")
|
82
|
+
score += 1 if desc.include?("thor")
|
83
|
+
-score
|
84
|
+
end
|
85
|
+
|
86
|
+
ranked.first&.first
|
87
|
+
rescue StandardError
|
88
|
+
nil
|
89
|
+
end
|
90
|
+
|
91
|
+
def fetch_summary(title, lang)
|
92
|
+
# Use REST summary endpoint which follows redirects and returns plaintext extract
|
93
|
+
# IMPORTANT: Title is a PATH segment, not a query param. Spaces should be underscores, not '+'.
|
94
|
+
slug = title.to_s.strip.tr(" ", "_")
|
95
|
+
uri = URI("https://#{lang}.wikipedia.org/api/rest_v1/page/summary/#{slug}")
|
96
|
+
data = fetch_json(uri)
|
97
|
+
data && (data["extract"] || data["description"])
|
98
|
+
rescue StandardError
|
99
|
+
nil
|
100
|
+
end
|
101
|
+
|
102
|
+
def fetch_json(uri)
|
103
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
104
|
+
req = Net::HTTP::Get.new(uri)
|
105
|
+
req["User-Agent"] = "mewmew-example-cli/1.0 (https://github.com/martinemde/mewmew)"
|
106
|
+
http.request(req)
|
107
|
+
end
|
108
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
109
|
+
JSON.parse(response.body)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.exit_on_failure?
|
114
|
+
true
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
McuCli.start(ARGV)
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "mcp"
|
5
|
+
require "mcp/server/transports/stdio_transport"
|
6
|
+
rescue LoadError
|
7
|
+
raise LoadError, "MCP gem is required. Add 'mcp' to your Gemfile."
|
8
|
+
end
|
9
|
+
|
10
|
+
module Mewmew
|
11
|
+
# Integrates Thor CLI with MCP server
|
12
|
+
class McpIntegration
|
13
|
+
attr_reader :thor_class, :server_options
|
14
|
+
|
15
|
+
def initialize(thor_class, **server_options)
|
16
|
+
@thor_class = thor_class
|
17
|
+
@server_options = server_options
|
18
|
+
end
|
19
|
+
|
20
|
+
# Start the MCP stdio server
|
21
|
+
def serve
|
22
|
+
server = create_server
|
23
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
24
|
+
|
25
|
+
# Trap signals for graceful shutdown
|
26
|
+
%w[INT TERM].each do |signal|
|
27
|
+
Signal.trap(signal) do
|
28
|
+
warn "\nShutting down MCP server..."
|
29
|
+
transport.close
|
30
|
+
exit(0)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
transport.open
|
35
|
+
end
|
36
|
+
|
37
|
+
# Create an MCP server with Thor tools
|
38
|
+
def create_server
|
39
|
+
tools = ThorIntrospector.new(thor_class).to_mcp_tools
|
40
|
+
|
41
|
+
MCP::Server.new(
|
42
|
+
name: server_name,
|
43
|
+
version: server_version,
|
44
|
+
tools: tools,
|
45
|
+
**server_options
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def server_name
|
52
|
+
server_options[:name] || thor_class_name.downcase
|
53
|
+
end
|
54
|
+
|
55
|
+
def server_version
|
56
|
+
server_options[:version] || "1.0.0"
|
57
|
+
end
|
58
|
+
|
59
|
+
def thor_class_name
|
60
|
+
thor_class.name&.split("::")&.last || "ThorCLI"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Module to be included in Thor classes to add MCP functionality
|
65
|
+
module ThorMcpExtension
|
66
|
+
def self.included(base)
|
67
|
+
base.extend(ClassMethods)
|
68
|
+
# Automatically add the MCP command with default options
|
69
|
+
base.add_mcp_command
|
70
|
+
end
|
71
|
+
|
72
|
+
module ClassMethods
|
73
|
+
# Add the mcp command to the Thor class
|
74
|
+
def add_mcp_command(**server_options)
|
75
|
+
desc "mcp", "Start MCP (Model Context Protocol) server to expose CLI commands as tools"
|
76
|
+
define_method :mcp do
|
77
|
+
Mewmew.serve_cli(self.class, **server_options)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "stringio"
|
4
|
+
|
5
|
+
begin
|
6
|
+
require "mcp"
|
7
|
+
rescue LoadError
|
8
|
+
raise LoadError, "MCP gem is required. Add 'mcp' to your Gemfile."
|
9
|
+
end
|
10
|
+
|
11
|
+
module Mewmew
|
12
|
+
# Introspects Thor CLI classes and extracts command information
|
13
|
+
class ThorIntrospector
|
14
|
+
attr_reader :thor_class
|
15
|
+
|
16
|
+
def initialize(thor_class)
|
17
|
+
@thor_class = thor_class
|
18
|
+
validate_thor_class!
|
19
|
+
end
|
20
|
+
|
21
|
+
# Get all commands from the Thor class
|
22
|
+
def commands
|
23
|
+
@commands ||= thor_class.commands.reject { |name, _| %w[help mcp].include?(name) }
|
24
|
+
end
|
25
|
+
|
26
|
+
# Convert Thor commands to MCP tools
|
27
|
+
def to_mcp_tools
|
28
|
+
commands.map do |name, command|
|
29
|
+
create_tool_class(name, command, thor_class)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def create_tool_class(command_name, thor_command, thor_class)
|
36
|
+
# Dynamically create a tool class that inherits from MCP::Tool
|
37
|
+
Class.new(MCP::Tool) do
|
38
|
+
# Set the tool metadata
|
39
|
+
tool_name command_name
|
40
|
+
description thor_command.description || "Execute the #{command_name} command"
|
41
|
+
|
42
|
+
# Build input schema from method signature and options
|
43
|
+
schema_properties = {}
|
44
|
+
schema_required = []
|
45
|
+
|
46
|
+
# Handle command arguments from method signature
|
47
|
+
method = thor_class.instance_method(command_name.to_sym)
|
48
|
+
method.parameters.each do |param_type, param_name|
|
49
|
+
case param_type
|
50
|
+
when :req
|
51
|
+
schema_properties[param_name] = {
|
52
|
+
type: "string",
|
53
|
+
description: "#{param_name.to_s.capitalize} argument"
|
54
|
+
}
|
55
|
+
schema_required << param_name.to_s
|
56
|
+
when :opt
|
57
|
+
schema_properties[param_name] = {
|
58
|
+
type: "string",
|
59
|
+
description: "#{param_name.to_s.capitalize} argument (optional)"
|
60
|
+
}
|
61
|
+
when :rest
|
62
|
+
schema_properties[param_name] = {
|
63
|
+
type: "array",
|
64
|
+
items: { type: "string" },
|
65
|
+
description: "Additional #{param_name.to_s.capitalize} arguments"
|
66
|
+
}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Handle command options
|
71
|
+
if thor_command.options
|
72
|
+
thor_command.options.each do |option_name, option|
|
73
|
+
next if option_name == :help
|
74
|
+
|
75
|
+
schema = {
|
76
|
+
description: option.description || "#{option.name.to_s.gsub('_', ' ').capitalize} option"
|
77
|
+
}
|
78
|
+
|
79
|
+
case option.type
|
80
|
+
when :boolean
|
81
|
+
schema[:type] = "boolean"
|
82
|
+
schema[:default] = option.default if !option.default.nil?
|
83
|
+
when :numeric
|
84
|
+
schema[:type] = "number"
|
85
|
+
schema[:default] = option.default if !option.default.nil?
|
86
|
+
when :array
|
87
|
+
schema[:type] = "array"
|
88
|
+
schema[:items] = { type: "string" }
|
89
|
+
schema[:default] = option.default if option.default
|
90
|
+
else
|
91
|
+
schema[:type] = "string"
|
92
|
+
schema[:default] = option.default if option.default
|
93
|
+
end
|
94
|
+
|
95
|
+
schema_properties[option_name] = schema
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
input_schema(properties: schema_properties, required: schema_required)
|
100
|
+
|
101
|
+
# Define the class method to execute the command
|
102
|
+
define_singleton_method :call do |server_context: nil, **args|
|
103
|
+
begin
|
104
|
+
# Create a new instance of the Thor class
|
105
|
+
thor_instance = thor_class.new
|
106
|
+
|
107
|
+
# Extract arguments and options
|
108
|
+
method = thor_class.instance_method(command_name.to_sym)
|
109
|
+
arg_names = method.parameters
|
110
|
+
.select { |type, _| [:req, :opt].include?(type) }
|
111
|
+
.map { |_, name| name }
|
112
|
+
|
113
|
+
command_args = []
|
114
|
+
options = {}
|
115
|
+
|
116
|
+
args.each do |key, value|
|
117
|
+
key_sym = key.to_sym
|
118
|
+
if arg_names.include?(key_sym)
|
119
|
+
arg_index = arg_names.index(key_sym)
|
120
|
+
command_args[arg_index] = value
|
121
|
+
else
|
122
|
+
options[key_sym] = value
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Fill in missing arguments with nil
|
127
|
+
command_args.fill(nil, command_args.length, arg_names.length - command_args.length) if arg_names.any?
|
128
|
+
|
129
|
+
# Set up Thor instance with options
|
130
|
+
thor_instance.options = thor_instance.options.merge(options) if options.any?
|
131
|
+
|
132
|
+
# Capture output
|
133
|
+
original_stdout = $stdout
|
134
|
+
stdout_capture = StringIO.new
|
135
|
+
|
136
|
+
# Only capture stdout, leave stderr for debug output
|
137
|
+
$stdout = stdout_capture
|
138
|
+
|
139
|
+
begin
|
140
|
+
thor_instance.send(command_name, *command_args.compact)
|
141
|
+
|
142
|
+
stdout_content = stdout_capture.string
|
143
|
+
output = stdout_content.empty? ? "Command executed successfully" : stdout_content
|
144
|
+
ensure
|
145
|
+
$stdout = original_stdout
|
146
|
+
end
|
147
|
+
|
148
|
+
MCP::Tool::Response.new([{
|
149
|
+
type: "text",
|
150
|
+
text: output
|
151
|
+
}])
|
152
|
+
rescue => e
|
153
|
+
MCP::Tool::Response.new([{
|
154
|
+
type: "text",
|
155
|
+
text: "Error executing command '#{command_name}': #{e.message}"
|
156
|
+
}], true)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def validate_thor_class!
|
165
|
+
# Check if the class inherits from any Thor-like class
|
166
|
+
unless thor_like_class?(thor_class)
|
167
|
+
raise Error, "Class must inherit from Thor or a Thor-like class. Got: #{thor_class} with superclass: #{thor_class.superclass}"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def thor_like_class?(klass)
|
172
|
+
return false unless klass.is_a?(Class)
|
173
|
+
|
174
|
+
# Check the inheritance chain for Thor-like classes
|
175
|
+
current_class = klass
|
176
|
+
while current_class && current_class != Object
|
177
|
+
parent = current_class.superclass
|
178
|
+
if parent && (parent.name == "Thor" || parent.name&.end_with?("::Thor"))
|
179
|
+
return true
|
180
|
+
end
|
181
|
+
current_class = parent
|
182
|
+
end
|
183
|
+
|
184
|
+
false
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
data/lib/mewmew.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "mewmew/version"
|
4
|
+
require_relative "mewmew/thor_introspector"
|
5
|
+
require_relative "mewmew/mcp_integration"
|
6
|
+
|
7
|
+
module Mewmew
|
8
|
+
class Error < StandardError; end
|
9
|
+
|
10
|
+
# Auto-detect Thor classes and include MCP functionality
|
11
|
+
def self.included(base)
|
12
|
+
# Check if the base class is a Thor class by looking for Thor inheritance
|
13
|
+
if thor_class?(base)
|
14
|
+
base.include(Mewmew::ThorMcpExtension)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class << self
|
19
|
+
# Convert a Thor CLI class to MCP tools and start a server
|
20
|
+
def serve_cli(thor_class, **server_options)
|
21
|
+
McpIntegration.new(thor_class, **server_options).serve
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get MCP tools for a Thor CLI class without starting server
|
25
|
+
def tools_for_cli(thor_class)
|
26
|
+
ThorIntrospector.new(thor_class).to_mcp_tools
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
# Check if a class is a Thor class
|
32
|
+
def thor_class?(klass)
|
33
|
+
# Check if it's a subclass of Thor or includes Thor::Base
|
34
|
+
return true if defined?(::Thor) && klass <= ::Thor
|
35
|
+
return true if defined?(::Thor::Base) && klass.include?(::Thor::Base)
|
36
|
+
false
|
37
|
+
rescue
|
38
|
+
false
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/sig/mewmew.rbs
ADDED
@@ -0,0 +1,244 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "json"
|
5
|
+
require "open3"
|
6
|
+
|
7
|
+
class MCPIntegrationTest
|
8
|
+
attr_reader :test_results, :total_tests, :passed_tests
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@test_results = []
|
12
|
+
@total_tests = 0
|
13
|
+
@passed_tests = 0
|
14
|
+
end
|
15
|
+
|
16
|
+
def run_all_tests
|
17
|
+
puts "š§Ŗ Running MCP Integration Tests"
|
18
|
+
puts "=" * 50
|
19
|
+
|
20
|
+
test_tools_list
|
21
|
+
test_lookup_with_query
|
22
|
+
test_lookup_with_options
|
23
|
+
test_hammer_command
|
24
|
+
test_invalid_tool
|
25
|
+
test_missing_args
|
26
|
+
test_boolean_option
|
27
|
+
|
28
|
+
print_summary
|
29
|
+
exit(@passed_tests == @total_tests ? 0 : 1)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def test_tools_list
|
35
|
+
test_name = "Tools List"
|
36
|
+
puts "\nš Testing: #{test_name}"
|
37
|
+
|
38
|
+
result = run_mcp_command("tools/list")
|
39
|
+
|
40
|
+
if result[:success] && result[:data]
|
41
|
+
tools = result[:data]["tools"]
|
42
|
+
expected_tools = %w[lookup hammer]
|
43
|
+
actual_tools = tools.map { |t| t["name"] }.sort
|
44
|
+
|
45
|
+
if actual_tools == expected_tools.sort
|
46
|
+
assert_pass(test_name, "Found expected tools: #{actual_tools.join(', ')}")
|
47
|
+
|
48
|
+
# Check tool schemas
|
49
|
+
lookup_tool = tools.find { |t| t["name"] == "lookup" }
|
50
|
+
if lookup_tool && lookup_tool["inputSchema"]["properties"]["query"]
|
51
|
+
assert_pass("#{test_name} - Schema", "Lookup tool has query parameter")
|
52
|
+
else
|
53
|
+
assert_fail("#{test_name} - Schema", "Lookup tool missing query parameter")
|
54
|
+
end
|
55
|
+
else
|
56
|
+
assert_fail(test_name, "Expected tools: #{expected_tools}, got: #{actual_tools}")
|
57
|
+
end
|
58
|
+
else
|
59
|
+
assert_fail(test_name, "Failed to get tools list: #{result[:error]}")
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def test_lookup_with_query
|
64
|
+
test_name = "Lookup with Query"
|
65
|
+
puts "\nš Testing: #{test_name}"
|
66
|
+
|
67
|
+
result = run_mcp_command("tools/call", tool_name: "lookup", args: { query: "mjolnir" })
|
68
|
+
|
69
|
+
if result[:success] && result[:data]
|
70
|
+
content = result[:data]["content"]
|
71
|
+
if content && content[0] && content[0]["text"]
|
72
|
+
text = content[0]["text"]
|
73
|
+
# The CLI should return some result (even if it's "No summary found")
|
74
|
+
if text.length > 0
|
75
|
+
assert_pass(test_name, "Returned text: #{text.strip}")
|
76
|
+
else
|
77
|
+
assert_fail(test_name, "Empty response text")
|
78
|
+
end
|
79
|
+
else
|
80
|
+
assert_fail(test_name, "No text content in response")
|
81
|
+
end
|
82
|
+
else
|
83
|
+
assert_fail(test_name, "Tool call failed: #{result[:error]}")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_lookup_with_options
|
88
|
+
test_name = "Lookup with Language Option"
|
89
|
+
puts "\nš Testing: #{test_name}"
|
90
|
+
|
91
|
+
result = run_mcp_command("tools/call", tool_name: "lookup", args: { query: "thor", lang: "en" })
|
92
|
+
|
93
|
+
if result[:success] && result[:data]
|
94
|
+
content = result[:data]["content"]
|
95
|
+
if content && content[0] && content[0]["text"]
|
96
|
+
text = content[0]["text"]
|
97
|
+
# Should execute without error (even if no results found)
|
98
|
+
assert_pass(test_name, "Executed with lang option: #{text.strip}")
|
99
|
+
else
|
100
|
+
assert_fail(test_name, "No text content in response")
|
101
|
+
end
|
102
|
+
else
|
103
|
+
assert_fail(test_name, "Tool call failed: #{result[:error]}")
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def test_hammer_command
|
108
|
+
test_name = "Hammer Command"
|
109
|
+
puts "\nš Testing: #{test_name}"
|
110
|
+
|
111
|
+
result = run_mcp_command("tools/call", tool_name: "hammer")
|
112
|
+
|
113
|
+
if result[:success] && result[:data]
|
114
|
+
content = result[:data]["content"]
|
115
|
+
if content && content[0] && content[0]["text"]
|
116
|
+
text = content[0]["text"]
|
117
|
+
# Hammer command calls lookup("Mjolnir") internally
|
118
|
+
assert_pass(test_name, "Hammer command executed: #{text.strip}")
|
119
|
+
else
|
120
|
+
assert_fail(test_name, "No text content in response")
|
121
|
+
end
|
122
|
+
else
|
123
|
+
assert_fail(test_name, "Tool call failed: #{result[:error]}")
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def test_invalid_tool
|
128
|
+
test_name = "Invalid Tool Name"
|
129
|
+
puts "\nš Testing: #{test_name}"
|
130
|
+
|
131
|
+
result = run_mcp_command("tools/call", tool_name: "nonexistent")
|
132
|
+
|
133
|
+
if result[:success]
|
134
|
+
assert_fail(test_name, "Should have failed for invalid tool name")
|
135
|
+
else
|
136
|
+
# Should get an error for invalid tool
|
137
|
+
assert_pass(test_name, "Correctly rejected invalid tool: #{result[:error]}")
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def test_missing_args
|
142
|
+
test_name = "Missing Arguments"
|
143
|
+
puts "\nš Testing: #{test_name}"
|
144
|
+
|
145
|
+
# Test calling lookup without query (it's optional but let's see what happens)
|
146
|
+
result = run_mcp_command("tools/call", tool_name: "lookup", args: {})
|
147
|
+
|
148
|
+
if result[:success] && result[:data]
|
149
|
+
content = result[:data]["content"]
|
150
|
+
if content && content[0] && content[0]["text"]
|
151
|
+
# Should work with no query (optional parameter)
|
152
|
+
assert_pass(test_name, "Handled missing optional parameter correctly")
|
153
|
+
else
|
154
|
+
assert_fail(test_name, "No content returned")
|
155
|
+
end
|
156
|
+
else
|
157
|
+
assert_fail(test_name, "Unexpected failure: #{result[:error]}")
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def test_boolean_option
|
162
|
+
test_name = "Boolean Option"
|
163
|
+
puts "\nš Testing: #{test_name}"
|
164
|
+
|
165
|
+
# Test with lang option (string) - should work
|
166
|
+
result = run_mcp_command("tools/call", tool_name: "lookup", args: { query: "test", lang: "fr" })
|
167
|
+
|
168
|
+
if result[:success] && result[:data]
|
169
|
+
assert_pass(test_name, "Boolean/string options handled correctly")
|
170
|
+
else
|
171
|
+
assert_fail(test_name, "Failed with options: #{result[:error]}")
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def run_mcp_command(method, tool_name: nil, args: {})
|
176
|
+
cmd_parts = [
|
177
|
+
"npx", "@modelcontextprotocol/inspector",
|
178
|
+
"--cli", "./examples/mcp_server.sh",
|
179
|
+
"--method", method
|
180
|
+
]
|
181
|
+
|
182
|
+
if tool_name
|
183
|
+
cmd_parts += ["--tool-name", tool_name]
|
184
|
+
end
|
185
|
+
|
186
|
+
args.each do |key, value|
|
187
|
+
cmd_parts += ["--tool-arg", "#{key}=#{value}"]
|
188
|
+
end
|
189
|
+
|
190
|
+
puts " š» Running: #{cmd_parts.join(' ')}"
|
191
|
+
|
192
|
+
stdout, stderr, status = Open3.capture3(*cmd_parts)
|
193
|
+
|
194
|
+
if status.success?
|
195
|
+
begin
|
196
|
+
data = JSON.parse(stdout)
|
197
|
+
{ success: true, data: data }
|
198
|
+
rescue JSON::ParserError => e
|
199
|
+
{ success: false, error: "JSON parse error: #{e.message}, output: #{stdout}" }
|
200
|
+
end
|
201
|
+
else
|
202
|
+
{ success: false, error: "Command failed (#{status.exitstatus}): #{stderr}" }
|
203
|
+
end
|
204
|
+
rescue => e
|
205
|
+
{ success: false, error: "Exception: #{e.message}" }
|
206
|
+
end
|
207
|
+
|
208
|
+
def assert_pass(test_name, message)
|
209
|
+
@total_tests += 1
|
210
|
+
@passed_tests += 1
|
211
|
+
@test_results << { name: test_name, status: :pass, message: message }
|
212
|
+
puts " ā
PASS: #{message}"
|
213
|
+
end
|
214
|
+
|
215
|
+
def assert_fail(test_name, message)
|
216
|
+
@total_tests += 1
|
217
|
+
@test_results << { name: test_name, status: :fail, message: message }
|
218
|
+
puts " ā FAIL: #{message}"
|
219
|
+
end
|
220
|
+
|
221
|
+
def print_summary
|
222
|
+
puts "\n" + "=" * 50
|
223
|
+
puts "š TEST SUMMARY"
|
224
|
+
puts "=" * 50
|
225
|
+
|
226
|
+
@test_results.each do |result|
|
227
|
+
icon = result[:status] == :pass ? "ā
" : "ā"
|
228
|
+
puts "#{icon} #{result[:name]}: #{result[:message]}"
|
229
|
+
end
|
230
|
+
|
231
|
+
puts "\nš Results: #{@passed_tests}/#{@total_tests} tests passed"
|
232
|
+
|
233
|
+
if @passed_tests == @total_tests
|
234
|
+
puts "š All tests passed!"
|
235
|
+
else
|
236
|
+
puts "š„ #{@total_tests - @passed_tests} test(s) failed"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# Run the tests if this file is executed directly
|
242
|
+
if __FILE__ == $0
|
243
|
+
MCPIntegrationTest.new.run_all_tests
|
244
|
+
end
|
data/test/run_tests.sh
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
# Test runner for MCP integration tests
|
3
|
+
|
4
|
+
set -e
|
5
|
+
|
6
|
+
echo "š§Ŗ MCP Integration Test Runner"
|
7
|
+
echo "=============================="
|
8
|
+
|
9
|
+
# Change to project root
|
10
|
+
cd "$(dirname "$0")/.."
|
11
|
+
|
12
|
+
echo "š Working directory: $(pwd)"
|
13
|
+
|
14
|
+
# Ensure bundle is available
|
15
|
+
echo "š¦ Checking bundle..."
|
16
|
+
if ! command -v bundle &> /dev/null; then
|
17
|
+
echo "ā Bundle not found. Please install bundler."
|
18
|
+
exit 1
|
19
|
+
fi
|
20
|
+
|
21
|
+
# Install dependencies
|
22
|
+
echo "š„ Installing dependencies..."
|
23
|
+
bundle install --quiet
|
24
|
+
|
25
|
+
# Run smoke test first
|
26
|
+
echo ""
|
27
|
+
echo "š„ Running smoke test..."
|
28
|
+
bundle exec ruby test/smoke_test.rb
|
29
|
+
|
30
|
+
# Run full integration tests
|
31
|
+
echo ""
|
32
|
+
echo "š Running full integration tests..."
|
33
|
+
bundle exec ruby test/integration_test.rb
|
34
|
+
|
35
|
+
echo ""
|
36
|
+
echo "⨠All tests completed successfully!"
|
data/test/smoke_test.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "json"
|
5
|
+
require "open3"
|
6
|
+
|
7
|
+
puts "š„ MCP Smoke Test"
|
8
|
+
puts "=" * 30
|
9
|
+
|
10
|
+
# Test 1: Basic server startup and tools list
|
11
|
+
puts "\n1. Testing basic server startup..."
|
12
|
+
|
13
|
+
stdout, stderr, status = Open3.capture3(
|
14
|
+
"npx", "@modelcontextprotocol/inspector",
|
15
|
+
"--cli", "./examples/mcp_server.sh",
|
16
|
+
"--method", "tools/list"
|
17
|
+
)
|
18
|
+
|
19
|
+
if status.success?
|
20
|
+
begin
|
21
|
+
data = JSON.parse(stdout)
|
22
|
+
tools = data["tools"]
|
23
|
+
puts "ā
Server started successfully"
|
24
|
+
puts "ā
Found #{tools.length} tools: #{tools.map { |t| t['name'] }.join(', ')}"
|
25
|
+
rescue JSON::ParserError => e
|
26
|
+
puts "ā JSON parse error: #{e.message}"
|
27
|
+
puts "Raw output: #{stdout}"
|
28
|
+
exit 1
|
29
|
+
end
|
30
|
+
else
|
31
|
+
puts "ā Server failed to start"
|
32
|
+
puts "stderr: #{stderr}"
|
33
|
+
puts "stdout: #{stdout}"
|
34
|
+
exit 1
|
35
|
+
end
|
36
|
+
|
37
|
+
# Test 2: Simple tool call
|
38
|
+
puts "\n2. Testing simple tool call..."
|
39
|
+
|
40
|
+
stdout, stderr, status = Open3.capture3(
|
41
|
+
"npx", "@modelcontextprotocol/inspector",
|
42
|
+
"--cli", "./examples/mcp_server.sh",
|
43
|
+
"--method", "tools/call",
|
44
|
+
"--tool-name", "hammer"
|
45
|
+
)
|
46
|
+
|
47
|
+
if status.success?
|
48
|
+
begin
|
49
|
+
data = JSON.parse(stdout)
|
50
|
+
content = data["content"]
|
51
|
+
if content && content[0] && content[0]["text"]
|
52
|
+
puts "ā
Tool call successful"
|
53
|
+
puts "ā
Response: #{content[0]['text'].strip[0..100]}..."
|
54
|
+
else
|
55
|
+
puts "ā No content in response"
|
56
|
+
exit 1
|
57
|
+
end
|
58
|
+
rescue JSON::ParserError => e
|
59
|
+
puts "ā JSON parse error: #{e.message}"
|
60
|
+
exit 1
|
61
|
+
end
|
62
|
+
else
|
63
|
+
puts "ā Tool call failed"
|
64
|
+
puts "stderr: #{stderr}"
|
65
|
+
exit 1
|
66
|
+
end
|
67
|
+
|
68
|
+
puts "\nš Smoke test passed! Ready for full integration tests."
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mewmew
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Martin Emde
|
8
|
+
bindir: exe
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: mcp
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - ">="
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - ">="
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: thor
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
description: Load any Thor CLI directly as a StdIO MCP server
|
41
|
+
email:
|
42
|
+
- me@martinemde.com
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- CHANGELOG.md
|
48
|
+
- LICENSE.txt
|
49
|
+
- README.md
|
50
|
+
- Rakefile
|
51
|
+
- examples/example_cli.rb
|
52
|
+
- examples/mcp_server.sh
|
53
|
+
- lib/mewmew.rb
|
54
|
+
- lib/mewmew/mcp_integration.rb
|
55
|
+
- lib/mewmew/thor_introspector.rb
|
56
|
+
- lib/mewmew/version.rb
|
57
|
+
- sig/mewmew.rbs
|
58
|
+
- test/integration_test.rb
|
59
|
+
- test/run_tests.sh
|
60
|
+
- test/smoke_test.rb
|
61
|
+
homepage: https://github.com/martinemde/mewmew
|
62
|
+
licenses:
|
63
|
+
- MIT
|
64
|
+
metadata:
|
65
|
+
allowed_push_host: https://rubygems.org
|
66
|
+
homepage_uri: https://github.com/martinemde/mewmew
|
67
|
+
source_code_uri: https://github.com/martinemde/mewmew
|
68
|
+
changelog_uri: https://github.com/martinemde/mewmew/blob/main/CHANGELOG.md
|
69
|
+
rdoc_options: []
|
70
|
+
require_paths:
|
71
|
+
- lib
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: 3.2.0
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
82
|
+
requirements: []
|
83
|
+
rubygems_version: 3.6.9
|
84
|
+
specification_version: 4
|
85
|
+
summary: Make any thor command an MCP
|
86
|
+
test_files: []
|