rubygems_mcp 0.1.4 → 0.2.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 +4 -4
- data/CHANGELOG.md +18 -1
- data/README.md +17 -8
- data/lib/rubygems_mcp/cache.rb +76 -0
- data/lib/rubygems_mcp/client.rb +200 -185
- data/lib/rubygems_mcp/network_policy.rb +68 -0
- data/lib/rubygems_mcp/server.rb +44 -36
- data/lib/rubygems_mcp/version.rb +1 -1
- data/sig/rubygems_mcp.rbs +28 -9
- metadata +101 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 792f4e1e9055f0daca5acf09de1b71cdaa64a203808f9f71463d5cb7d67544e1
|
|
4
|
+
data.tar.gz: cc8a4b2af63e9b96534aae1aee6aeeb572fee2fd602d1d648d29b50c391fb9e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5383415d5d7963b119273f486dca440c5af64e490065abfeaab9e3f9d21e03297cf3f0931252909f8fa2ba9d7e777c11101dcf483840ad70a9f1a6a3a96db816
|
|
7
|
+
data.tar.gz: ba60b3179ad4956457c4cd22e758e3a93a4f9ce4347697c899459003fc4002555077d8d56c0af525c7f0f8d74010fe5148c1ae4e6fb88d2b611e4f2177d28481
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0 (2026-09-08)
|
|
6
|
+
|
|
7
|
+
- BREAKING: Require Ruby 3.4
|
|
8
|
+
- BREAKING: Raise `fast-mcp` to `>= 1.6` and `nokogiri` to `~> 1.19`
|
|
9
|
+
- BREAKING: `search_gems` paginates with `limit` and `offset` on the first search page
|
|
10
|
+
- BREAKING: Raise when an MCP version tool receives an invalid sort order
|
|
11
|
+
- Fetch gem changelogs only over HTTPS from GitHub, GitLab, RubyGems, and ruby-lang.org hosts
|
|
12
|
+
- Include the changelog source host on fetch results
|
|
13
|
+
- Read latest gem versions from gem info and keep rows whose dates fail to parse
|
|
14
|
+
- Skip caching empty Ruby changelogs after a GitHub fetch failure
|
|
15
|
+
- Stop reading HTTP bodies whose Content-Length exceeds 5MB
|
|
16
|
+
- Cap the in-memory response cache
|
|
17
|
+
- Paginate and cache the Ruby versions list on first fetch
|
|
18
|
+
- Apply the HTTP size limit to GitHub Ruby changelogs
|
|
19
|
+
- Limit latest-version lookups to 20 gem names
|
|
20
|
+
|
|
3
21
|
## 0.1.4 (2025-11-26)
|
|
4
22
|
|
|
5
23
|
- Add `get_news_releases` tool to fetch all new gem releases from RubyGems.org with pagination (fetches from `/news?page=N`)
|
|
@@ -60,4 +78,3 @@
|
|
|
60
78
|
- Comprehensive test suite with RSpec, VCR cassettes, and WebMock
|
|
61
79
|
- Requires Ruby 3.1 or higher
|
|
62
80
|
- All dependencies use latest compatible versions with pessimistic versioning for security
|
|
63
|
-
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# rubygems_mcp
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/rubygems_mcp) [](https://github.com/amkisko/rubygems_mcp.rb/actions/workflows/test.yml) [](https://app.codecov.io/github/amkisko/rubygems_mcp.rb) [](https://sonarcloud.io/project/overview?id=amkisko_rubygems_mcp.rb)
|
|
4
4
|
|
|
5
5
|
Ruby gem providing RubyGems and Ruby version information via MCP (Model Context Protocol) server tools. Integrates with MCP-compatible clients like Cursor IDE, Claude Desktop, and other MCP-enabled tools.
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
|
|
|
14
14
|
|
|
15
15
|
## Requirements
|
|
16
16
|
|
|
17
|
-
- **Ruby 3.
|
|
17
|
+
- **Ruby 3.4 or higher**
|
|
18
18
|
|
|
19
19
|
## Quick Start
|
|
20
20
|
|
|
@@ -103,7 +103,7 @@ The server will start and communicate via STDIN/STDOUT using the MCP protocol.
|
|
|
103
103
|
|
|
104
104
|
- **RubyGems API Client**: Full-featured client for RubyGems REST API with comprehensive endpoint coverage
|
|
105
105
|
- **Ruby Version Information**: Fetch Ruby release information, changelogs, and maintenance status from ruby-lang.org
|
|
106
|
-
- **MCP Server Integration**: Ready-to-use MCP server with
|
|
106
|
+
- **MCP Server Integration**: Ready-to-use MCP server with 18 tools and 4 resources, compatible with Cursor IDE, Claude Desktop, and other MCP-enabled tools
|
|
107
107
|
- **Pagination & Sorting**: Support for large result sets with customizable pagination and sorting options
|
|
108
108
|
- **Caching**: In-memory caching with configurable TTL for improved performance
|
|
109
109
|
- **Error Handling**: Graceful error handling with custom exceptions and response size limits
|
|
@@ -286,7 +286,7 @@ The MCP server provides the following tools:
|
|
|
286
286
|
- Parameters: `gem_name` (string), `version` (optional string, uses latest if not provided)
|
|
287
287
|
|
|
288
288
|
13. **search_gems** - Search for gems by name on RubyGems
|
|
289
|
-
- Parameters: `query` (string)
|
|
289
|
+
- Parameters: `query` (string), `limit` (optional integer), `offset` (optional integer)
|
|
290
290
|
|
|
291
291
|
14. **get_ruby_roadmap** - Get Ruby roadmap information from bugs.ruby-lang.org showing planned versions and their issues
|
|
292
292
|
- Parameters: none
|
|
@@ -327,10 +327,7 @@ The MCP server provides the following resources:
|
|
|
327
327
|
|
|
328
328
|
## Error Handling
|
|
329
329
|
|
|
330
|
-
|
|
331
|
-
- Returns empty arrays for failed requests
|
|
332
|
-
- Returns empty hashes for failed gem info requests
|
|
333
|
-
- Handles network errors and JSON parsing errors
|
|
330
|
+
HTTP failures raise `NotFoundError`, `ServerError`, or `APIError`. Unreadable JSON or HTML raises `CorruptedDataError`. Bodies over 5MB raise `ResponseSizeExceededError`. Invalid input, including more than 20 names on `get_latest_versions`, raises `ValidationError`.
|
|
334
331
|
|
|
335
332
|
## Development
|
|
336
333
|
|
|
@@ -372,6 +369,18 @@ For more information, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
372
369
|
|
|
373
370
|
If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
|
|
374
371
|
|
|
372
|
+
## Links
|
|
373
|
+
|
|
374
|
+
- [GitHub](https://github.com/amkisko/rubygems_mcp.rb)
|
|
375
|
+
- [GitLab](https://gitlab.com/amkisko/rubygems_mcp.rb)
|
|
376
|
+
- [RubyGems](https://rubygems.org/gems/rubygems_mcp)
|
|
377
|
+
- [libraries.io](https://libraries.io/rubygems/rubygems_mcp)
|
|
378
|
+
- [Deps.dev](https://deps.dev/rubygems/rubygems_mcp)
|
|
379
|
+
- [SonarCloud](https://sonarcloud.io/project/overview?id=amkisko_rubygems_mcp.rb)
|
|
380
|
+
- [Snyk](https://snyk.io/test/github/amkisko/rubygems_mcp.rb)
|
|
381
|
+
- [Codecov](https://app.codecov.io/github/amkisko/rubygems_mcp.rb)
|
|
382
|
+
- [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/amkisko/rubygems_mcp.rb)
|
|
383
|
+
|
|
375
384
|
## License
|
|
376
385
|
|
|
377
386
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubygemsMcp
|
|
4
|
+
class Cache
|
|
5
|
+
MAX_ENTRIES = 256
|
|
6
|
+
|
|
7
|
+
def initialize(max_entries: MAX_ENTRIES)
|
|
8
|
+
@cache = {}
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@inflight = {}
|
|
11
|
+
@max_entries = max_entries
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get(key)
|
|
15
|
+
@mutex.synchronize { read_unlocked(key) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set(key, value, ttl_seconds)
|
|
19
|
+
@mutex.synchronize { write_unlocked(key, value, ttl_seconds) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(key, ttl_seconds)
|
|
23
|
+
cached = get(key)
|
|
24
|
+
return cached unless cached.nil?
|
|
25
|
+
|
|
26
|
+
lock = @mutex.synchronize { @inflight[key] ||= Mutex.new }
|
|
27
|
+
lock.synchronize do
|
|
28
|
+
cached = get(key)
|
|
29
|
+
return cached unless cached.nil?
|
|
30
|
+
|
|
31
|
+
value = yield
|
|
32
|
+
set(key, value, ttl_seconds)
|
|
33
|
+
value
|
|
34
|
+
ensure
|
|
35
|
+
@mutex.synchronize { @inflight.delete(key) if @inflight[key].equal?(lock) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@cache.clear
|
|
42
|
+
@inflight.clear
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def size
|
|
47
|
+
@mutex.synchronize { @cache.size }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def read_unlocked(key)
|
|
53
|
+
entry = @cache[key]
|
|
54
|
+
return nil unless entry
|
|
55
|
+
|
|
56
|
+
if entry[:expires_at] < Time.now
|
|
57
|
+
@cache.delete(key)
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
entry[:value]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write_unlocked(key, value, ttl_seconds)
|
|
65
|
+
evict_unlocked if @cache.size >= @max_entries && !@cache.key?(key)
|
|
66
|
+
@cache.delete(key)
|
|
67
|
+
@cache[key] = {value: value, expires_at: Time.now + ttl_seconds}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def evict_unlocked
|
|
71
|
+
now = Time.now
|
|
72
|
+
@cache.delete_if { |_key, entry| entry[:expires_at] < now }
|
|
73
|
+
@cache.shift while @cache.size >= @max_entries
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/rubygems_mcp/client.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "openssl"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "date"
|
|
6
6
|
require "nokogiri"
|
|
7
|
+
require_relative "cache"
|
|
8
|
+
require_relative "network_policy"
|
|
7
9
|
|
|
8
10
|
module RubygemsMcp
|
|
9
11
|
# RubyGems and Ruby version API client
|
|
@@ -20,6 +22,7 @@ module RubygemsMcp
|
|
|
20
22
|
# Validation constants
|
|
21
23
|
VALID_SORT_ORDERS = %i[version_desc version_asc date_desc date_asc].freeze
|
|
22
24
|
MAX_LIMIT = 1000 # Reasonable upper bound for pagination
|
|
25
|
+
MAX_GEM_NAMES = 20
|
|
23
26
|
|
|
24
27
|
RUBYGEMS_API_BASE = "https://rubygems.org/api/v1"
|
|
25
28
|
RUBYGEMS_API_V2_BASE = "https://rubygems.org/api/v2"
|
|
@@ -29,43 +32,8 @@ module RubygemsMcp
|
|
|
29
32
|
RUBY_ROADMAP_URL = "https://bugs.ruby-lang.org/projects/ruby-master/roadmap"
|
|
30
33
|
RUBY_BUGS_BASE = "https://bugs.ruby-lang.org"
|
|
31
34
|
GITHUB_RUBY_REPO = "https://api.github.com/repos/ruby/ruby"
|
|
35
|
+
GITHUB_MISS_TTL = 300
|
|
32
36
|
|
|
33
|
-
# Simple in-memory cache with TTL
|
|
34
|
-
class Cache
|
|
35
|
-
def initialize
|
|
36
|
-
@cache = {}
|
|
37
|
-
@mutex = Mutex.new
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def get(key)
|
|
41
|
-
@mutex.synchronize do
|
|
42
|
-
entry = @cache[key]
|
|
43
|
-
return nil unless entry
|
|
44
|
-
|
|
45
|
-
if entry[:expires_at] < Time.now
|
|
46
|
-
@cache.delete(key)
|
|
47
|
-
return nil
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
entry[:value]
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def set(key, value, ttl_seconds)
|
|
55
|
-
@mutex.synchronize do
|
|
56
|
-
@cache[key] = {
|
|
57
|
-
value: value,
|
|
58
|
-
expires_at: Time.now + ttl_seconds
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def clear
|
|
64
|
-
@mutex.synchronize { @cache.clear }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Shared cache instance
|
|
69
37
|
@cache = Cache.new
|
|
70
38
|
|
|
71
39
|
class << self
|
|
@@ -85,19 +53,11 @@ module RubygemsMcp
|
|
|
85
53
|
# @return [Array<Hash>] Array of hashes with selected fields
|
|
86
54
|
def get_latest_versions(gem_names, fields: nil)
|
|
87
55
|
raise ValidationError, "gem_names cannot be empty" if gem_names.nil? || gem_names.empty?
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
versions = get_gem_versions(name, limit: 1, fields: fields)
|
|
91
|
-
latest = versions.first # Versions are sorted by version number descending
|
|
92
|
-
if latest
|
|
93
|
-
result = latest.dup
|
|
94
|
-
result[:name] = name
|
|
95
|
-
result
|
|
96
|
-
else
|
|
97
|
-
base_result = {name: name, version: nil, release_date: nil, license: nil}
|
|
98
|
-
select_fields([base_result], fields).first || base_result
|
|
99
|
-
end
|
|
56
|
+
if gem_names.length > MAX_GEM_NAMES
|
|
57
|
+
raise ValidationError, "gem_names cannot exceed #{MAX_GEM_NAMES} names"
|
|
100
58
|
end
|
|
59
|
+
gem_names = gem_names.map { |name| validate_gem_name(name) }
|
|
60
|
+
gem_names.map { |name| latest_version_from_gem_info(name, fields: fields) }
|
|
101
61
|
end
|
|
102
62
|
|
|
103
63
|
# Get all versions for a single gem
|
|
@@ -142,34 +102,7 @@ module RubygemsMcp
|
|
|
142
102
|
|
|
143
103
|
return [] if response.empty?
|
|
144
104
|
|
|
145
|
-
versions = response.
|
|
146
|
-
original_version = version_data["number"]
|
|
147
|
-
next unless original_version.match?(/^\d+\.\d+\.\d+$/)
|
|
148
|
-
|
|
149
|
-
version = Gem::Version.new(original_version)
|
|
150
|
-
release_date = version_data["created_at"] ? Date.parse(version_data["created_at"]) : nil
|
|
151
|
-
built_at = version_data["built_at"] ? Date.parse(version_data["built_at"]) : nil
|
|
152
|
-
|
|
153
|
-
version_hash = {
|
|
154
|
-
version: version.to_s,
|
|
155
|
-
release_date: release_date&.iso8601,
|
|
156
|
-
built_at: built_at&.iso8601,
|
|
157
|
-
license: version_data["licenses"]&.first,
|
|
158
|
-
prerelease: version_data["prerelease"] || false,
|
|
159
|
-
platform: version_data["platform"] || "ruby",
|
|
160
|
-
ruby_version: version_data["ruby_version"],
|
|
161
|
-
rubygems_version: version_data["rubygems_version"],
|
|
162
|
-
downloads_count: version_data["downloads_count"],
|
|
163
|
-
sha: version_data["sha"],
|
|
164
|
-
spec_sha: version_data["spec_sha"],
|
|
165
|
-
requirements: version_data["requirements"] || [],
|
|
166
|
-
metadata: version_data["metadata"] || {}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
version_hash
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
versions = versions.compact
|
|
105
|
+
versions = response.filter_map { |version_data| gem_version_row(version_data) }
|
|
173
106
|
|
|
174
107
|
# Cache for 1 hour (gem versions don't change once published)
|
|
175
108
|
self.class.cache.set(cache_key, versions, 3600) if @cache_enabled
|
|
@@ -341,10 +274,13 @@ module RubygemsMcp
|
|
|
341
274
|
end
|
|
342
275
|
|
|
343
276
|
# Sort and convert dates to ISO 8601 strings for JSON serialization
|
|
344
|
-
versions.compact.sort_by { |v| Gem::Version.new(v[:version]) }.reverse.map do |v|
|
|
277
|
+
versions = versions.compact.sort_by { |v| Gem::Version.new(v[:version]) }.reverse.map do |v|
|
|
345
278
|
v[:release_date] = v[:release_date]&.iso8601
|
|
346
279
|
v
|
|
347
280
|
end
|
|
281
|
+
|
|
282
|
+
self.class.cache.set(cache_key, versions, 86400) if @cache_enabled
|
|
283
|
+
apply_pagination_and_sort(versions, limit: limit, offset: offset, sort: sort)
|
|
348
284
|
end
|
|
349
285
|
|
|
350
286
|
# Get full changelog content for a Ruby version from release notes
|
|
@@ -386,8 +322,8 @@ module RubygemsMcp
|
|
|
386
322
|
return cached if cached
|
|
387
323
|
end
|
|
388
324
|
|
|
389
|
-
uri =
|
|
390
|
-
response = make_request(uri, parse_html: true)
|
|
325
|
+
uri, ip_address = NetworkPolicy.validate!(release_notes_url, host_suffixes: %w[ruby-lang.org])
|
|
326
|
+
response = make_request(uri, parse_html: true, ip_address: ip_address)
|
|
391
327
|
|
|
392
328
|
content = nil
|
|
393
329
|
github_changelog = nil
|
|
@@ -430,8 +366,7 @@ module RubygemsMcp
|
|
|
430
366
|
github_changelog: github_changelog
|
|
431
367
|
}
|
|
432
368
|
|
|
433
|
-
|
|
434
|
-
self.class.cache.set(cache_key, result, 86400) if @cache_enabled
|
|
369
|
+
self.class.cache.set(cache_key, result, 86400) if @cache_enabled && !content.to_s.empty?
|
|
435
370
|
|
|
436
371
|
result
|
|
437
372
|
end
|
|
@@ -594,51 +529,34 @@ module RubygemsMcp
|
|
|
594
529
|
return cached if cached
|
|
595
530
|
end
|
|
596
531
|
|
|
597
|
-
# Try GitHub API
|
|
598
532
|
uri = URI("#{GITHUB_RUBY_REPO}/releases/tags/#{tag_name}")
|
|
599
533
|
begin
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
534
|
+
data = make_request(uri, headers: {"Accept" => "application/vnd.github+json"})
|
|
535
|
+
unless data.is_a?(Hash)
|
|
536
|
+
raise CorruptedDataError.new(
|
|
537
|
+
"Invalid JSON structure: expected Hash, got #{data.class}",
|
|
538
|
+
uri: uri.to_s
|
|
539
|
+
)
|
|
540
|
+
end
|
|
606
541
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
542
|
+
result = {
|
|
543
|
+
version: version,
|
|
544
|
+
tag_name: tag_name,
|
|
545
|
+
name: data["name"],
|
|
546
|
+
body: data["body"],
|
|
547
|
+
published_at: data["published_at"],
|
|
548
|
+
url: data["html_url"]
|
|
549
|
+
}
|
|
612
550
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
uri: uri.to_s
|
|
620
|
-
)
|
|
621
|
-
end
|
|
551
|
+
self.class.cache.set(cache_key, result, 86400) if @cache_enabled
|
|
552
|
+
result
|
|
553
|
+
rescue NotFoundError
|
|
554
|
+
github_miss_result(version, tag_name, "Release not found on GitHub")
|
|
555
|
+
rescue ClientError => error
|
|
556
|
+
raise unless error.status_code == 403
|
|
622
557
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
tag_name: tag_name,
|
|
626
|
-
name: data["name"],
|
|
627
|
-
body: data["body"],
|
|
628
|
-
published_at: data["published_at"],
|
|
629
|
-
url: data["html_url"]
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
# Cache for 24 hours
|
|
633
|
-
self.class.cache.set(cache_key, result, 86400) if @cache_enabled
|
|
634
|
-
|
|
635
|
-
result
|
|
636
|
-
when Net::HTTPNotFound
|
|
637
|
-
{version: version, tag_name: tag_name, name: nil, body: nil, published_at: nil, url: nil, error: "Release not found on GitHub"}
|
|
638
|
-
else
|
|
639
|
-
handle_http_error(response, uri)
|
|
640
|
-
end
|
|
641
|
-
rescue APIError
|
|
558
|
+
github_miss_result(version, tag_name, "GitHub denied access to this release")
|
|
559
|
+
rescue ResponseSizeExceededError, CorruptedDataError, APIError
|
|
642
560
|
raise
|
|
643
561
|
rescue => e
|
|
644
562
|
raise APIError.new(
|
|
@@ -795,12 +713,12 @@ module RubygemsMcp
|
|
|
795
713
|
validate_version_string(version) if version
|
|
796
714
|
# Get gem info to find changelog_uri
|
|
797
715
|
gem_info = get_gem_info(gem_name)
|
|
798
|
-
return {gem_name: gem_name, version: nil, changelog_uri: nil, summary: nil, error: "Gem not found"} if gem_info.empty?
|
|
716
|
+
return {gem_name: gem_name, version: nil, changelog_uri: nil, source_host: nil, summary: nil, error: "Gem not found"} if gem_info.empty?
|
|
799
717
|
|
|
800
718
|
version ||= gem_info[:version]
|
|
801
719
|
changelog_uri = gem_info[:changelog_uri]
|
|
802
720
|
|
|
803
|
-
return {gem_name: gem_name, version: version, changelog_uri: nil, summary: nil, error: "No changelog URI available"} unless changelog_uri
|
|
721
|
+
return {gem_name: gem_name, version: version, changelog_uri: nil, source_host: nil, summary: nil, error: "No changelog URI available"} unless changelog_uri
|
|
804
722
|
|
|
805
723
|
cache_key = "gem_changelog:#{gem_name}:#{version}"
|
|
806
724
|
|
|
@@ -809,9 +727,11 @@ module RubygemsMcp
|
|
|
809
727
|
return cached if cached
|
|
810
728
|
end
|
|
811
729
|
|
|
812
|
-
uri =
|
|
813
|
-
response = make_request(uri, parse_html: true)
|
|
814
|
-
|
|
730
|
+
uri, ip_address = NetworkPolicy.validate!(changelog_uri)
|
|
731
|
+
response = make_request(uri, parse_html: true, ip_address: ip_address)
|
|
732
|
+
unless response
|
|
733
|
+
return {gem_name: gem_name, version: version, changelog_uri: changelog_uri, source_host: uri.host, summary: nil, error: "Failed to fetch changelog"}
|
|
734
|
+
end
|
|
815
735
|
|
|
816
736
|
# Extract the main content - try GitHub release page first, then generic selectors
|
|
817
737
|
content = if changelog_uri.include?("github.com") && changelog_uri.include?("/releases/")
|
|
@@ -918,6 +838,7 @@ module RubygemsMcp
|
|
|
918
838
|
gem_name: gem_name,
|
|
919
839
|
version: version,
|
|
920
840
|
changelog_uri: changelog_uri,
|
|
841
|
+
source_host: uri.host,
|
|
921
842
|
summary: summary
|
|
922
843
|
}
|
|
923
844
|
|
|
@@ -972,7 +893,8 @@ module RubygemsMcp
|
|
|
972
893
|
platform: response["platform"] || "ruby",
|
|
973
894
|
sha: response["sha"],
|
|
974
895
|
spec_sha: response["spec_sha"],
|
|
975
|
-
metadata: response["metadata"] || {}
|
|
896
|
+
metadata: response["metadata"] || {},
|
|
897
|
+
version_created_at: response["version_created_at"] || response["created_at"]
|
|
976
898
|
}
|
|
977
899
|
|
|
978
900
|
# Cache for 1 hour
|
|
@@ -1061,20 +983,12 @@ module RubygemsMcp
|
|
|
1061
983
|
#
|
|
1062
984
|
# @param query [String] Search query
|
|
1063
985
|
# @param limit [Integer, nil] Maximum number of results to return (nil = all)
|
|
1064
|
-
# @param offset [Integer] Number of results to skip
|
|
1065
|
-
# @param page [Integer, nil] Page number (1-based). If provided, overrides offset (page 1 = offset 0, page 2 = offset 30, etc.)
|
|
986
|
+
# @param offset [Integer] Number of results to skip within the first search.json page
|
|
1066
987
|
# @return [Array<Hash>] Array of hashes with gem information
|
|
1067
|
-
def search_gems(query, limit: nil, offset: 0
|
|
988
|
+
def search_gems(query, limit: nil, offset: 0)
|
|
1068
989
|
raise ValidationError, "Search query cannot be empty" if query.nil? || query.strip.empty?
|
|
1069
990
|
|
|
1070
|
-
# Convert page to offset if provided (assuming 30 items per page, which is RubyGems default)
|
|
1071
|
-
if page
|
|
1072
|
-
raise ValidationError, "Page must be positive" if page < 1
|
|
1073
|
-
offset = (page - 1) * 30
|
|
1074
|
-
end
|
|
1075
|
-
|
|
1076
991
|
validate_pagination_params(limit: limit, offset: offset)
|
|
1077
|
-
# Don't cache search results as they can change frequently
|
|
1078
992
|
uri = URI("#{RUBYGEMS_API_BASE}/search.json")
|
|
1079
993
|
uri.query = URI.encode_www_form(query: query)
|
|
1080
994
|
|
|
@@ -1092,7 +1006,10 @@ module RubygemsMcp
|
|
|
1092
1006
|
}
|
|
1093
1007
|
end
|
|
1094
1008
|
|
|
1095
|
-
|
|
1009
|
+
if offset > 0 && offset >= results.length
|
|
1010
|
+
raise ValidationError, "Offset is past the returned results"
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1096
1013
|
results = results[offset..] if offset > 0
|
|
1097
1014
|
results = results.first(limit) if limit
|
|
1098
1015
|
results
|
|
@@ -1308,7 +1225,7 @@ module RubygemsMcp
|
|
|
1308
1225
|
# @param offset [Integer] Offset value
|
|
1309
1226
|
# @raise [ValidationError] If pagination parameters are invalid
|
|
1310
1227
|
def validate_pagination_params(limit:, offset:)
|
|
1311
|
-
raise ValidationError, "Limit must be positive" if limit && limit <
|
|
1228
|
+
raise ValidationError, "Limit must be positive" if limit && limit < 1
|
|
1312
1229
|
raise ValidationError, "Offset must be non-negative" if offset < 0
|
|
1313
1230
|
raise ValidationError, "Limit cannot exceed #{MAX_LIMIT}" if limit && limit > MAX_LIMIT
|
|
1314
1231
|
end
|
|
@@ -1363,6 +1280,135 @@ module RubygemsMcp
|
|
|
1363
1280
|
|
|
1364
1281
|
private
|
|
1365
1282
|
|
|
1283
|
+
def latest_version_from_gem_info(name, fields: nil)
|
|
1284
|
+
latest_from_info(name, get_gem_info(name), fields: fields)
|
|
1285
|
+
rescue NotFoundError
|
|
1286
|
+
latest_from_info(name, {}, fields: fields)
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
def latest_from_info(name, info, fields: nil)
|
|
1290
|
+
if info.empty?
|
|
1291
|
+
base = {
|
|
1292
|
+
name: name,
|
|
1293
|
+
version: nil,
|
|
1294
|
+
release_date: nil,
|
|
1295
|
+
license: nil,
|
|
1296
|
+
built_at: nil,
|
|
1297
|
+
prerelease: nil,
|
|
1298
|
+
platform: nil,
|
|
1299
|
+
ruby_version: nil,
|
|
1300
|
+
rubygems_version: nil,
|
|
1301
|
+
downloads_count: nil,
|
|
1302
|
+
sha: nil,
|
|
1303
|
+
spec_sha: nil,
|
|
1304
|
+
requirements: nil,
|
|
1305
|
+
metadata: nil
|
|
1306
|
+
}
|
|
1307
|
+
return select_fields([base], fields).first || base
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
version = info[:version]
|
|
1311
|
+
prerelease = begin
|
|
1312
|
+
Gem::Version.new(version).prerelease?
|
|
1313
|
+
rescue ArgumentError, TypeError
|
|
1314
|
+
false
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
result = {
|
|
1318
|
+
name: name,
|
|
1319
|
+
version: version,
|
|
1320
|
+
release_date: parse_optional_date(info[:version_created_at]),
|
|
1321
|
+
license: Array(info[:licenses]).first,
|
|
1322
|
+
built_at: nil,
|
|
1323
|
+
prerelease: prerelease,
|
|
1324
|
+
platform: info[:platform],
|
|
1325
|
+
ruby_version: nil,
|
|
1326
|
+
rubygems_version: nil,
|
|
1327
|
+
downloads_count: info[:version_downloads] || info[:downloads],
|
|
1328
|
+
sha: info[:sha],
|
|
1329
|
+
spec_sha: info[:spec_sha],
|
|
1330
|
+
requirements: nil,
|
|
1331
|
+
metadata: info[:metadata]
|
|
1332
|
+
}
|
|
1333
|
+
select_fields([result], fields).first || result
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
def gem_version_row(version_data)
|
|
1337
|
+
version = Gem::Version.new(version_data["number"])
|
|
1338
|
+
|
|
1339
|
+
{
|
|
1340
|
+
version: version.to_s,
|
|
1341
|
+
release_date: parse_optional_date(version_data["created_at"]),
|
|
1342
|
+
built_at: parse_optional_date(version_data["built_at"]),
|
|
1343
|
+
license: version_data["licenses"]&.first,
|
|
1344
|
+
prerelease: version.prerelease? || version_data["prerelease"] || false,
|
|
1345
|
+
platform: version_data["platform"] || "ruby",
|
|
1346
|
+
ruby_version: version_data["ruby_version"],
|
|
1347
|
+
rubygems_version: version_data["rubygems_version"],
|
|
1348
|
+
downloads_count: version_data["downloads_count"],
|
|
1349
|
+
sha: version_data["sha"],
|
|
1350
|
+
spec_sha: version_data["spec_sha"],
|
|
1351
|
+
requirements: version_data["requirements"] || [],
|
|
1352
|
+
metadata: version_data["metadata"] || {}
|
|
1353
|
+
}
|
|
1354
|
+
rescue ArgumentError
|
|
1355
|
+
nil
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
def parse_optional_date(value)
|
|
1359
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
1360
|
+
|
|
1361
|
+
Date.parse(value.to_s).iso8601
|
|
1362
|
+
rescue Date::Error
|
|
1363
|
+
nil
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
def github_miss_result(version, tag_name, error)
|
|
1367
|
+
result = {
|
|
1368
|
+
version: version,
|
|
1369
|
+
tag_name: tag_name,
|
|
1370
|
+
name: nil,
|
|
1371
|
+
body: nil,
|
|
1372
|
+
published_at: nil,
|
|
1373
|
+
url: nil,
|
|
1374
|
+
error: error
|
|
1375
|
+
}
|
|
1376
|
+
self.class.cache.set("ruby_github_changelog:#{version}", result, GITHUB_MISS_TTL) if @cache_enabled
|
|
1377
|
+
result
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def read_limited_body(response, uri, parse_html:)
|
|
1381
|
+
content_length = response["Content-Length"]&.to_i
|
|
1382
|
+
if content_length && content_length > MAX_RESPONSE_SIZE
|
|
1383
|
+
raise ResponseSizeExceededError.new(content_length, MAX_RESPONSE_SIZE, uri: uri.to_s)
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
chunks = +""
|
|
1387
|
+
response.read_body do |chunk|
|
|
1388
|
+
chunks << chunk
|
|
1389
|
+
if chunks.bytesize > MAX_RESPONSE_SIZE
|
|
1390
|
+
raise ResponseSizeExceededError.new(chunks.bytesize, MAX_RESPONSE_SIZE, uri: uri.to_s)
|
|
1391
|
+
end
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
if parse_html
|
|
1395
|
+
validate_and_parse_html(chunks, uri)
|
|
1396
|
+
else
|
|
1397
|
+
validate_and_parse_json(chunks, uri)
|
|
1398
|
+
end
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
def html_title(body)
|
|
1402
|
+
match = body.match(%r{<title[^>]*>(.*?)</title>}im)
|
|
1403
|
+
match && match[1]
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
def crawler_protection_title?(title)
|
|
1407
|
+
return false if title.nil? || title.strip.empty?
|
|
1408
|
+
|
|
1409
|
+
title.match?(/cloudflare|ddos protection|access denied|captcha/i)
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1366
1412
|
# Apply pagination and sorting to a version array
|
|
1367
1413
|
#
|
|
1368
1414
|
# @param versions [Array<Hash>] Array of version hashes
|
|
@@ -1408,8 +1454,9 @@ module RubygemsMcp
|
|
|
1408
1454
|
#
|
|
1409
1455
|
# @param uri [URI] URI object for the request
|
|
1410
1456
|
# @return [Net::HTTP] Configured HTTP client
|
|
1411
|
-
def build_http_client(uri)
|
|
1457
|
+
def build_http_client(uri, ip_address: nil)
|
|
1412
1458
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
1459
|
+
http.ipaddr = ip_address if ip_address
|
|
1413
1460
|
http.read_timeout = 10
|
|
1414
1461
|
http.open_timeout = 10
|
|
1415
1462
|
|
|
@@ -1431,36 +1478,27 @@ module RubygemsMcp
|
|
|
1431
1478
|
http
|
|
1432
1479
|
end
|
|
1433
1480
|
|
|
1434
|
-
def make_request(uri, parse_html: false)
|
|
1435
|
-
http = build_http_client(uri)
|
|
1481
|
+
def make_request(uri, parse_html: false, ip_address: nil, headers: {})
|
|
1482
|
+
http = build_http_client(uri, ip_address: ip_address)
|
|
1436
1483
|
|
|
1437
1484
|
request = Net::HTTP::Get.new(uri)
|
|
1438
1485
|
request["Accept"] = parse_html ? "text/html" : "application/json"
|
|
1439
1486
|
request["User-Agent"] = "rubygems_mcp/#{RubygemsMcp::VERSION}"
|
|
1487
|
+
headers.each { |name, value| request[name] = value }
|
|
1440
1488
|
|
|
1441
|
-
|
|
1489
|
+
parsed = nil
|
|
1490
|
+
response = http.request(request) do |http_response|
|
|
1491
|
+
next unless http_response.is_a?(Net::HTTPSuccess)
|
|
1442
1492
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
# Check response size before processing
|
|
1446
|
-
# Note: response.body may be nil for some responses, so check first
|
|
1447
|
-
response_body = response.body || ""
|
|
1448
|
-
response_size = response_body.bytesize
|
|
1449
|
-
if response_size > MAX_RESPONSE_SIZE
|
|
1450
|
-
raise ResponseSizeExceededError.new(response_size, MAX_RESPONSE_SIZE, uri: uri.to_s)
|
|
1451
|
-
end
|
|
1493
|
+
parsed = read_limited_body(http_response, uri, parse_html: parse_html)
|
|
1494
|
+
end
|
|
1452
1495
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
validate_and_parse_html(response_body, uri)
|
|
1456
|
-
else
|
|
1457
|
-
validate_and_parse_json(response_body, uri)
|
|
1458
|
-
end
|
|
1496
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
1497
|
+
parsed
|
|
1459
1498
|
else
|
|
1460
1499
|
handle_http_error(response, uri)
|
|
1461
1500
|
end
|
|
1462
1501
|
rescue ResponseSizeExceededError, CorruptedDataError
|
|
1463
|
-
# Re-raise our custom errors as-is (don't cache corrupted data)
|
|
1464
1502
|
raise
|
|
1465
1503
|
rescue OpenSSL::SSL::SSLError => e
|
|
1466
1504
|
raise APIError.new(
|
|
@@ -1468,7 +1506,6 @@ module RubygemsMcp
|
|
|
1468
1506
|
uri: uri.to_s
|
|
1469
1507
|
)
|
|
1470
1508
|
rescue APIError, NotFoundError, ServerError, ClientError
|
|
1471
|
-
# Re-raise API errors as-is
|
|
1472
1509
|
raise
|
|
1473
1510
|
rescue => e
|
|
1474
1511
|
raise APIError.new(
|
|
@@ -1483,9 +1520,7 @@ module RubygemsMcp
|
|
|
1483
1520
|
# @return [Hash, Array] Parsed JSON data
|
|
1484
1521
|
# @raise [CorruptedDataError] If JSON is invalid or corrupted
|
|
1485
1522
|
def validate_and_parse_json(body, uri)
|
|
1486
|
-
|
|
1487
|
-
# Only check if body looks like HTML (starts with <) to avoid false positives
|
|
1488
|
-
if body.strip.start_with?("<") && body.match?(/cloudflare|ddos protection|access denied|blocked|captcha/i)
|
|
1523
|
+
if body.strip.start_with?("<") && crawler_protection_title?(html_title(body))
|
|
1489
1524
|
raise CorruptedDataError.new(
|
|
1490
1525
|
"Response appears to be a crawler protection page from #{uri}",
|
|
1491
1526
|
response_size: body.bytesize,
|
|
@@ -1532,16 +1567,6 @@ module RubygemsMcp
|
|
|
1532
1567
|
# @return [Nokogiri::HTML::Document] Parsed HTML document
|
|
1533
1568
|
# @raise [CorruptedDataError] If HTML is invalid or appears to be an error page
|
|
1534
1569
|
def validate_and_parse_html(body, uri)
|
|
1535
|
-
# Check for common crawler protection patterns
|
|
1536
|
-
if body.match?(/cloudflare|ddos protection|access denied|blocked|captcha|rate limit/i)
|
|
1537
|
-
raise CorruptedDataError.new(
|
|
1538
|
-
"Response appears to be a crawler protection page from #{uri}",
|
|
1539
|
-
response_size: body.bytesize,
|
|
1540
|
-
uri: uri.to_s
|
|
1541
|
-
)
|
|
1542
|
-
end
|
|
1543
|
-
|
|
1544
|
-
# Check if response is actually HTML
|
|
1545
1570
|
unless body.strip.start_with?("<!DOCTYPE", "<html", "<HTML") || body.include?("<html")
|
|
1546
1571
|
raise CorruptedDataError.new(
|
|
1547
1572
|
"Response from #{uri} does not appear to be HTML",
|
|
@@ -1553,7 +1578,6 @@ module RubygemsMcp
|
|
|
1553
1578
|
begin
|
|
1554
1579
|
doc = Nokogiri::HTML(body)
|
|
1555
1580
|
|
|
1556
|
-
# Check if HTML is empty or appears to be an error page
|
|
1557
1581
|
if doc.text.strip.length < 50
|
|
1558
1582
|
raise CorruptedDataError.new(
|
|
1559
1583
|
"HTML response from #{uri} appears to be empty or too short",
|
|
@@ -1562,18 +1586,9 @@ module RubygemsMcp
|
|
|
1562
1586
|
)
|
|
1563
1587
|
end
|
|
1564
1588
|
|
|
1565
|
-
|
|
1566
|
-
error_indicators = [
|
|
1567
|
-
/error 404/i,
|
|
1568
|
-
/page not found/i,
|
|
1569
|
-
/access denied/i,
|
|
1570
|
-
/forbidden/i,
|
|
1571
|
-
/internal server error/i
|
|
1572
|
-
]
|
|
1573
|
-
|
|
1574
|
-
if error_indicators.any? { |pattern| doc.text.match?(pattern) }
|
|
1589
|
+
if crawler_protection_title?(doc.at_css("title")&.text)
|
|
1575
1590
|
raise CorruptedDataError.new(
|
|
1576
|
-
"
|
|
1591
|
+
"Response appears to be a crawler protection page from #{uri}",
|
|
1577
1592
|
response_size: body.bytesize,
|
|
1578
1593
|
uri: uri.to_s
|
|
1579
1594
|
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RubygemsMcp
|
|
8
|
+
class NetworkPolicy
|
|
9
|
+
ALLOWED_SCHEMES = %w[https].freeze
|
|
10
|
+
ALLOWED_HOST_SUFFIXES = %w[
|
|
11
|
+
github.com
|
|
12
|
+
githubusercontent.com
|
|
13
|
+
gitlab.com
|
|
14
|
+
rubygems.org
|
|
15
|
+
ruby-lang.org
|
|
16
|
+
].freeze
|
|
17
|
+
FORBIDDEN_RANGES = %w[
|
|
18
|
+
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8
|
|
19
|
+
169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24
|
|
20
|
+
192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24
|
|
21
|
+
203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
|
|
22
|
+
::/128 ::1/128 64:ff9b::/96 100::/64 2001::/23 2002::/16
|
|
23
|
+
fc00::/7 fe80::/10 ff00::/8
|
|
24
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
25
|
+
|
|
26
|
+
def self.validate!(url, host_suffixes: ALLOWED_HOST_SUFFIXES)
|
|
27
|
+
uri = URI.parse(url.to_s)
|
|
28
|
+
reject! unless ALLOWED_SCHEMES.include?(uri.scheme&.downcase)
|
|
29
|
+
reject! if uri.host.to_s.empty? || uri.userinfo
|
|
30
|
+
reject! unless allowed_host?(uri.host, host_suffixes)
|
|
31
|
+
|
|
32
|
+
[uri, allowed_ip_address(uri)]
|
|
33
|
+
rescue URI::InvalidURIError
|
|
34
|
+
reject!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.allowed_host?(host, suffixes = ALLOWED_HOST_SUFFIXES)
|
|
38
|
+
hostname = host.to_s.downcase
|
|
39
|
+
suffixes.any? { |suffix| hostname == suffix || hostname.end_with?(".#{suffix}") }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.allowed_ip_address(uri)
|
|
43
|
+
addresses = Addrinfo.getaddrinfo(uri.host, uri.port, nil, :STREAM)
|
|
44
|
+
.map(&:ip_address)
|
|
45
|
+
.uniq
|
|
46
|
+
reject! if addresses.empty? || addresses.any? { |address| forbidden?(address) }
|
|
47
|
+
|
|
48
|
+
addresses.first
|
|
49
|
+
rescue SocketError, SystemCallError
|
|
50
|
+
reject!
|
|
51
|
+
end
|
|
52
|
+
private_class_method :allowed_ip_address
|
|
53
|
+
|
|
54
|
+
def self.forbidden?(address)
|
|
55
|
+
parsed = IPAddr.new(address.split("%", 2).first)
|
|
56
|
+
parsed = parsed.native if parsed.ipv4_mapped?
|
|
57
|
+
FORBIDDEN_RANGES.any? { |range| range.include?(parsed) }
|
|
58
|
+
rescue IPAddr::InvalidAddressError
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
private_class_method :forbidden?
|
|
62
|
+
|
|
63
|
+
def self.reject!
|
|
64
|
+
raise ValidationError, "URL destination is not allowed"
|
|
65
|
+
end
|
|
66
|
+
private_class_method :reject!
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/rubygems_mcp/server.rb
CHANGED
|
@@ -124,6 +124,15 @@ module RubygemsMcp
|
|
|
124
124
|
def get_client
|
|
125
125
|
Client.new
|
|
126
126
|
end
|
|
127
|
+
|
|
128
|
+
def parse_sort_order(sort)
|
|
129
|
+
sort_value = sort.to_s
|
|
130
|
+
unless %w[version_desc version_asc date_desc date_asc].include?(sort_value)
|
|
131
|
+
raise ValidationError, "Invalid sort order. Must be one of: version_desc, version_asc, date_desc, date_asc"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sort_value.to_sym
|
|
135
|
+
end
|
|
127
136
|
end
|
|
128
137
|
|
|
129
138
|
# Get latest versions for a list of gems with release dates
|
|
@@ -132,7 +141,7 @@ module RubygemsMcp
|
|
|
132
141
|
description "Get latest versions for a list of gems with release dates and licenses. Supports GraphQL-like field selection."
|
|
133
142
|
|
|
134
143
|
arguments do
|
|
135
|
-
required(:gem_names).array(:string, min_size?: 1).description("Array of gem names (e.g., ['rails', 'nokogiri', 'rack'])")
|
|
144
|
+
required(:gem_names).array(:string, min_size?: 1, max_size?: Client::MAX_GEM_NAMES).description("Array of gem names, at most #{Client::MAX_GEM_NAMES} (e.g., ['rails', 'nokogiri', 'rack'])")
|
|
136
145
|
optional(:fields).array(:string).description("GraphQL-like field selection. Available: name, version, release_date, license, built_at, prerelease, platform, ruby_version, rubygems_version, downloads_count, sha, spec_sha, requirements, metadata")
|
|
137
146
|
end
|
|
138
147
|
|
|
@@ -155,14 +164,7 @@ module RubygemsMcp
|
|
|
155
164
|
end
|
|
156
165
|
|
|
157
166
|
def call(gem_name:, limit: nil, offset: 0, sort: "version_desc", fields: nil)
|
|
158
|
-
|
|
159
|
-
sort_value = sort.to_s
|
|
160
|
-
sort_sym = if valid_sorts.include?(sort_value)
|
|
161
|
-
sort_value.to_sym
|
|
162
|
-
else
|
|
163
|
-
:version_desc
|
|
164
|
-
end
|
|
165
|
-
get_client.get_gem_versions(gem_name, limit: limit, offset: offset, sort: sort_sym, fields: fields)
|
|
167
|
+
get_client.get_gem_versions(gem_name, limit: limit, offset: offset, sort: parse_sort_order(sort), fields: fields)
|
|
166
168
|
end
|
|
167
169
|
end
|
|
168
170
|
|
|
@@ -192,14 +194,7 @@ module RubygemsMcp
|
|
|
192
194
|
end
|
|
193
195
|
|
|
194
196
|
def call(limit: nil, offset: 0, sort: "version_desc")
|
|
195
|
-
|
|
196
|
-
sort_value = sort.to_s
|
|
197
|
-
sort_sym = if valid_sorts.include?(sort_value)
|
|
198
|
-
sort_value.to_sym
|
|
199
|
-
else
|
|
200
|
-
:version_desc
|
|
201
|
-
end
|
|
202
|
-
get_client.get_ruby_versions(limit: limit, offset: offset, sort: sort_sym)
|
|
197
|
+
get_client.get_ruby_versions(limit: limit, offset: offset, sort: parse_sort_order(sort))
|
|
203
198
|
end
|
|
204
199
|
end
|
|
205
200
|
|
|
@@ -350,13 +345,12 @@ module RubygemsMcp
|
|
|
350
345
|
|
|
351
346
|
arguments do
|
|
352
347
|
required(:query).filled(:string).description("Search query (e.g., 'rails')")
|
|
353
|
-
optional(:
|
|
354
|
-
optional(:
|
|
355
|
-
optional(:offset).filled(:integer).description("Number of results to skip (for pagination)")
|
|
348
|
+
optional(:limit).filled(:integer).description("Maximum number of results from the first search.json response")
|
|
349
|
+
optional(:offset).filled(:integer).description("Number of results to skip within the first search.json response")
|
|
356
350
|
end
|
|
357
351
|
|
|
358
|
-
def call(query:,
|
|
359
|
-
get_client.search_gems(query,
|
|
352
|
+
def call(query:, limit: nil, offset: 0)
|
|
353
|
+
get_client.search_gems(query, limit: limit, offset: offset)
|
|
360
354
|
end
|
|
361
355
|
end
|
|
362
356
|
|
|
@@ -490,27 +484,41 @@ module RubygemsMcp
|
|
|
490
484
|
latest = client.get_latest_ruby_version
|
|
491
485
|
maintenance_status = client.get_ruby_maintenance_status
|
|
492
486
|
|
|
493
|
-
# Create a map of version to maintenance status for quick lookup
|
|
494
|
-
maintenance_status.each_with_object({}) do |status, map|
|
|
495
|
-
map[status[:version]] = status
|
|
496
|
-
end
|
|
497
|
-
|
|
498
487
|
data = {
|
|
499
488
|
latest: latest,
|
|
500
489
|
recent_versions: ruby_versions,
|
|
501
|
-
maintenance_status: maintenance_status.first(10),
|
|
502
|
-
compatibility_notes:
|
|
503
|
-
"3.4.x" => "Latest stable series. Normal maintenance. Supports all modern gems.",
|
|
504
|
-
"3.3.x" => "Stable series. Normal maintenance until 2027. Well-supported by most gems.",
|
|
505
|
-
"3.2.x" => "Security maintenance only. EOL expected 2026-03-31.",
|
|
506
|
-
"3.1.x" => "End of life (EOL: 2025-03-26). No longer supported.",
|
|
507
|
-
"3.0.x" => "End of life (EOL: 2024-04-23). No longer supported.",
|
|
508
|
-
"2.7.x" => "End of life. No longer supported."
|
|
509
|
-
}
|
|
490
|
+
maintenance_status: maintenance_status.first(10),
|
|
491
|
+
compatibility_notes: compatibility_notes_from(maintenance_status)
|
|
510
492
|
}
|
|
511
493
|
|
|
512
494
|
JSON.pretty_generate(data)
|
|
513
495
|
end
|
|
496
|
+
|
|
497
|
+
private
|
|
498
|
+
|
|
499
|
+
def compatibility_notes_from(maintenance_status)
|
|
500
|
+
maintenance_status.each_with_object({}) do |status, notes|
|
|
501
|
+
series = status[:version]
|
|
502
|
+
next if series.nil? || series.empty?
|
|
503
|
+
|
|
504
|
+
notes["#{series}.x"] = maintenance_note(status)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def maintenance_note(status)
|
|
509
|
+
case status[:status]
|
|
510
|
+
when "normal maintenance"
|
|
511
|
+
"Normal maintenance until #{status[:normal_maintenance_until]}."
|
|
512
|
+
when "security maintenance"
|
|
513
|
+
"Security maintenance only. EOL expected #{status[:eol]}."
|
|
514
|
+
when "eol"
|
|
515
|
+
"End of life (EOL: #{status[:eol]}). No longer supported."
|
|
516
|
+
when "preview"
|
|
517
|
+
"Preview release."
|
|
518
|
+
else
|
|
519
|
+
status[:status].to_s
|
|
520
|
+
end
|
|
521
|
+
end
|
|
514
522
|
end
|
|
515
523
|
|
|
516
524
|
# Resource: Ruby maintenance status for all versions
|
data/lib/rubygems_mcp/version.rb
CHANGED
data/sig/rubygems_mcp.rbs
CHANGED
|
@@ -34,22 +34,34 @@ module RubygemsMcp
|
|
|
34
34
|
# Input validation errors
|
|
35
35
|
class ValidationError < Error; end
|
|
36
36
|
|
|
37
|
+
class Cache
|
|
38
|
+
MAX_ENTRIES: Integer
|
|
39
|
+
def initialize: (?max_entries: Integer) -> void
|
|
40
|
+
def get: (String key) -> untyped?
|
|
41
|
+
def set: (String key, untyped value, Integer ttl_seconds) -> void
|
|
42
|
+
def fetch: (String key, Integer ttl_seconds) { () -> untyped } -> untyped
|
|
43
|
+
def clear: () -> void
|
|
44
|
+
def size: () -> Integer
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class NetworkPolicy
|
|
48
|
+
ALLOWED_SCHEMES: Array[String]
|
|
49
|
+
ALLOWED_HOST_SUFFIXES: Array[String]
|
|
50
|
+
def self.validate!: (String | URI url, ?host_suffixes: Array[String]) -> [URI, String]
|
|
51
|
+
def self.allowed_host?: (String? host, ?Array[String] suffixes) -> bool
|
|
52
|
+
end
|
|
53
|
+
|
|
37
54
|
class Client
|
|
38
55
|
MAX_RESPONSE_SIZE: Integer
|
|
39
56
|
VALID_SORT_ORDERS: Array[Symbol]
|
|
40
57
|
MAX_LIMIT: Integer
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def initialize: () -> void
|
|
44
|
-
def get: (String key) -> untyped?
|
|
45
|
-
def set: (String key, untyped value, Integer ttl_seconds) -> void
|
|
46
|
-
def clear: () -> void
|
|
47
|
-
end
|
|
58
|
+
MAX_GEM_NAMES: Integer
|
|
59
|
+
GITHUB_MISS_TTL: Integer
|
|
48
60
|
|
|
49
61
|
def self.cache: () -> Cache
|
|
50
62
|
|
|
51
63
|
def initialize: (?cache_enabled: bool) -> void
|
|
52
|
-
def build_http_client: (URI uri) -> Net::HTTP
|
|
64
|
+
def build_http_client: (URI uri, ?ip_address: String?) -> Net::HTTP
|
|
53
65
|
def validate_and_parse_json: (String body, URI uri) -> (Hash[untyped, untyped] | Array[untyped])
|
|
54
66
|
def validate_and_parse_html: (String body, URI uri) -> Nokogiri::HTML::Document
|
|
55
67
|
|
|
@@ -202,7 +214,7 @@ module RubygemsMcp
|
|
|
202
214
|
end
|
|
203
215
|
|
|
204
216
|
class SearchGemsTool < BaseTool
|
|
205
|
-
def call: (String query) -> Array[Hash[Symbol, untyped]]
|
|
217
|
+
def call: (String query, ?Integer? limit, ?Integer offset) -> Array[Hash[Symbol, untyped]]
|
|
206
218
|
end
|
|
207
219
|
|
|
208
220
|
class GetRubyRoadmapTool < BaseTool
|
|
@@ -249,6 +261,13 @@ module RubygemsMcp
|
|
|
249
261
|
def self.register_tools: (untyped server) -> void
|
|
250
262
|
def self.register_resources: (untyped server) -> void
|
|
251
263
|
end
|
|
264
|
+
|
|
265
|
+
class NetworkPolicy
|
|
266
|
+
ALLOWED_SCHEMES: Array[String]
|
|
267
|
+
FORBIDDEN_RANGES: Array[untyped]
|
|
268
|
+
|
|
269
|
+
def self.validate!: (String | URI url) -> [URI, String]
|
|
270
|
+
end
|
|
252
271
|
end
|
|
253
272
|
|
|
254
273
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubygems_mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '1.6'
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '2.0'
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version: '
|
|
28
|
+
version: '1.6'
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '2.0'
|
|
@@ -35,14 +35,14 @@ dependencies:
|
|
|
35
35
|
requirements:
|
|
36
36
|
- - "~>"
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version: '1.
|
|
38
|
+
version: '1.19'
|
|
39
39
|
type: :runtime
|
|
40
40
|
prerelease: false
|
|
41
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
42
42
|
requirements:
|
|
43
43
|
- - "~>"
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
|
-
version: '1.
|
|
45
|
+
version: '1.19'
|
|
46
46
|
- !ruby/object:Gem::Dependency
|
|
47
47
|
name: rack
|
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -85,6 +85,20 @@ dependencies:
|
|
|
85
85
|
- - "~>"
|
|
86
86
|
- !ruby/object:Gem::Version
|
|
87
87
|
version: '3.13'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: polyrun
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: 2.2.0
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: 2.2.0
|
|
88
102
|
- !ruby/object:Gem::Dependency
|
|
89
103
|
name: webmock
|
|
90
104
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -105,14 +119,14 @@ dependencies:
|
|
|
105
119
|
requirements:
|
|
106
120
|
- - "~>"
|
|
107
121
|
- !ruby/object:Gem::Version
|
|
108
|
-
version: '6.
|
|
122
|
+
version: '6.4'
|
|
109
123
|
type: :development
|
|
110
124
|
prerelease: false
|
|
111
125
|
version_requirements: !ruby/object:Gem::Requirement
|
|
112
126
|
requirements:
|
|
113
127
|
- - "~>"
|
|
114
128
|
- !ruby/object:Gem::Version
|
|
115
|
-
version: '6.
|
|
129
|
+
version: '6.4'
|
|
116
130
|
- !ruby/object:Gem::Dependency
|
|
117
131
|
name: rake
|
|
118
132
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -133,14 +147,14 @@ dependencies:
|
|
|
133
147
|
requirements:
|
|
134
148
|
- - "~>"
|
|
135
149
|
- !ruby/object:Gem::Version
|
|
136
|
-
version: '
|
|
150
|
+
version: '1.2'
|
|
137
151
|
type: :development
|
|
138
152
|
prerelease: false
|
|
139
153
|
version_requirements: !ruby/object:Gem::Requirement
|
|
140
154
|
requirements:
|
|
141
155
|
- - "~>"
|
|
142
156
|
- !ruby/object:Gem::Version
|
|
143
|
-
version: '
|
|
157
|
+
version: '1.2'
|
|
144
158
|
- !ruby/object:Gem::Dependency
|
|
145
159
|
name: rspec_junit_formatter
|
|
146
160
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -161,14 +175,14 @@ dependencies:
|
|
|
161
175
|
requirements:
|
|
162
176
|
- - "~>"
|
|
163
177
|
- !ruby/object:Gem::Version
|
|
164
|
-
version: '
|
|
178
|
+
version: '4.0'
|
|
165
179
|
type: :development
|
|
166
180
|
prerelease: false
|
|
167
181
|
version_requirements: !ruby/object:Gem::Requirement
|
|
168
182
|
requirements:
|
|
169
183
|
- - "~>"
|
|
170
184
|
- !ruby/object:Gem::Version
|
|
171
|
-
version: '
|
|
185
|
+
version: '4.0'
|
|
172
186
|
- !ruby/object:Gem::Dependency
|
|
173
187
|
name: standard
|
|
174
188
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -183,6 +197,76 @@ dependencies:
|
|
|
183
197
|
- - "~>"
|
|
184
198
|
- !ruby/object:Gem::Version
|
|
185
199
|
version: '1.52'
|
|
200
|
+
- !ruby/object:Gem::Dependency
|
|
201
|
+
name: standard-custom
|
|
202
|
+
requirement: !ruby/object:Gem::Requirement
|
|
203
|
+
requirements:
|
|
204
|
+
- - "~>"
|
|
205
|
+
- !ruby/object:Gem::Version
|
|
206
|
+
version: '1.0'
|
|
207
|
+
type: :development
|
|
208
|
+
prerelease: false
|
|
209
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
210
|
+
requirements:
|
|
211
|
+
- - "~>"
|
|
212
|
+
- !ruby/object:Gem::Version
|
|
213
|
+
version: '1.0'
|
|
214
|
+
- !ruby/object:Gem::Dependency
|
|
215
|
+
name: standard-performance
|
|
216
|
+
requirement: !ruby/object:Gem::Requirement
|
|
217
|
+
requirements:
|
|
218
|
+
- - "~>"
|
|
219
|
+
- !ruby/object:Gem::Version
|
|
220
|
+
version: '1.8'
|
|
221
|
+
type: :development
|
|
222
|
+
prerelease: false
|
|
223
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
224
|
+
requirements:
|
|
225
|
+
- - "~>"
|
|
226
|
+
- !ruby/object:Gem::Version
|
|
227
|
+
version: '1.8'
|
|
228
|
+
- !ruby/object:Gem::Dependency
|
|
229
|
+
name: standard-rspec
|
|
230
|
+
requirement: !ruby/object:Gem::Requirement
|
|
231
|
+
requirements:
|
|
232
|
+
- - "~>"
|
|
233
|
+
- !ruby/object:Gem::Version
|
|
234
|
+
version: '0.3'
|
|
235
|
+
type: :development
|
|
236
|
+
prerelease: false
|
|
237
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
238
|
+
requirements:
|
|
239
|
+
- - "~>"
|
|
240
|
+
- !ruby/object:Gem::Version
|
|
241
|
+
version: '0.3'
|
|
242
|
+
- !ruby/object:Gem::Dependency
|
|
243
|
+
name: rubocop-rspec
|
|
244
|
+
requirement: !ruby/object:Gem::Requirement
|
|
245
|
+
requirements:
|
|
246
|
+
- - "~>"
|
|
247
|
+
- !ruby/object:Gem::Version
|
|
248
|
+
version: '3.8'
|
|
249
|
+
type: :development
|
|
250
|
+
prerelease: false
|
|
251
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
252
|
+
requirements:
|
|
253
|
+
- - "~>"
|
|
254
|
+
- !ruby/object:Gem::Version
|
|
255
|
+
version: '3.8'
|
|
256
|
+
- !ruby/object:Gem::Dependency
|
|
257
|
+
name: rubocop-thread_safety
|
|
258
|
+
requirement: !ruby/object:Gem::Requirement
|
|
259
|
+
requirements:
|
|
260
|
+
- - "~>"
|
|
261
|
+
- !ruby/object:Gem::Version
|
|
262
|
+
version: '0.7'
|
|
263
|
+
type: :development
|
|
264
|
+
prerelease: false
|
|
265
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
266
|
+
requirements:
|
|
267
|
+
- - "~>"
|
|
268
|
+
- !ruby/object:Gem::Version
|
|
269
|
+
version: '0.7'
|
|
186
270
|
- !ruby/object:Gem::Dependency
|
|
187
271
|
name: appraisal
|
|
188
272
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -217,14 +301,14 @@ dependencies:
|
|
|
217
301
|
requirements:
|
|
218
302
|
- - "~>"
|
|
219
303
|
- !ruby/object:Gem::Version
|
|
220
|
-
version: '
|
|
304
|
+
version: '4.2'
|
|
221
305
|
type: :development
|
|
222
306
|
prerelease: false
|
|
223
307
|
version_requirements: !ruby/object:Gem::Requirement
|
|
224
308
|
requirements:
|
|
225
309
|
- - "~>"
|
|
226
310
|
- !ruby/object:Gem::Version
|
|
227
|
-
version: '
|
|
311
|
+
version: '4.2'
|
|
228
312
|
description: Ruby gem providing RubyGems and Ruby version information via MCP server
|
|
229
313
|
tools. Integrates with Cursor IDE via Model Context Protocol.
|
|
230
314
|
email:
|
|
@@ -239,8 +323,10 @@ files:
|
|
|
239
323
|
- README.md
|
|
240
324
|
- bin/rubygems_mcp
|
|
241
325
|
- lib/rubygems_mcp.rb
|
|
326
|
+
- lib/rubygems_mcp/cache.rb
|
|
242
327
|
- lib/rubygems_mcp/client.rb
|
|
243
328
|
- lib/rubygems_mcp/errors.rb
|
|
329
|
+
- lib/rubygems_mcp/network_policy.rb
|
|
244
330
|
- lib/rubygems_mcp/server.rb
|
|
245
331
|
- lib/rubygems_mcp/version.rb
|
|
246
332
|
- sig/rubygems_mcp.rbs
|
|
@@ -260,14 +346,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
260
346
|
requirements:
|
|
261
347
|
- - ">="
|
|
262
348
|
- !ruby/object:Gem::Version
|
|
263
|
-
version: '3.
|
|
349
|
+
version: '3.4'
|
|
264
350
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
265
351
|
requirements:
|
|
266
352
|
- - ">="
|
|
267
353
|
- !ruby/object:Gem::Version
|
|
268
354
|
version: '0'
|
|
269
355
|
requirements: []
|
|
270
|
-
rubygems_version:
|
|
356
|
+
rubygems_version: 4.0.6
|
|
271
357
|
specification_version: 4
|
|
272
358
|
summary: RubyGems MCP (Model Context Protocol) server for Cursor IDE integration
|
|
273
359
|
test_files: []
|