rubygems_mcp 0.1.2 → 0.1.3
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 +9 -0
- data/README.md +27 -11
- data/lib/rubygems_mcp/client.rb +498 -52
- data/lib/rubygems_mcp/errors.rb +48 -0
- data/lib/rubygems_mcp/server.rb +63 -1
- data/lib/rubygems_mcp/version.rb +1 -1
- data/lib/rubygems_mcp.rb +1 -0
- data/sig/rubygems_mcp.rbs +68 -13
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 263030b634fd9d87f2a5d3d4ff68574e017438ec57a7caff2ebc0b5729dfccb2
|
|
4
|
+
data.tar.gz: 8c9e6142f7c8c7cbb6695c5265c2a7655eb7606872963ec0db3bf5ffb9a7d140
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f6bc852cf223da1499bf87063b2f92acbc562d40b4bd7d7fdfd8a4af38bf79064616fb8c0275b50e1e1e148a390cf92fcbdd0547cd3d45ca96d7ccf6966c1ec
|
|
7
|
+
data.tar.gz: 3ae00927439ed56b949e3d2a3ffa3830d87e3ee89d34d6e62adad8af51e7df54a33f2af870ee4b657b80300d335528b7627c8a2d986375a9a8aab7a81ce79516
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.1.3 (2025-11-21)
|
|
4
|
+
|
|
5
|
+
- Add Ruby roadmap tools: `get_ruby_roadmap` and `get_ruby_version_roadmap_details` for accessing Ruby version planning information from bugs.ruby-lang.org
|
|
6
|
+
- Add `get_ruby_version_github_changelog` tool to fetch GitHub release changelogs for Ruby versions
|
|
7
|
+
- Improve error handling with new error classes (ValidationError, improved CorruptedDataError with URI information)
|
|
8
|
+
- Add input validation for all API methods (gem names, version strings, pagination parameters, sort orders)
|
|
9
|
+
- Enhance error messages with URI information for better debugging
|
|
10
|
+
- Increase maximum response size limit for Ruby version changelog retrieval
|
|
11
|
+
|
|
3
12
|
## 0.1.2 (2025-11-21)
|
|
4
13
|
|
|
5
14
|
- Enhance Ruby version changelog retrieval and increase maximum response size
|
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://codecov.io/gh/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
|
|
|
@@ -93,7 +93,7 @@ The server will start and communicate via STDIN/STDOUT using the MCP protocol.
|
|
|
93
93
|
|
|
94
94
|
- **RubyGems API Client**: Full-featured client for RubyGems REST API with comprehensive endpoint coverage
|
|
95
95
|
- **Ruby Version Information**: Fetch Ruby release information, changelogs, and maintenance status from ruby-lang.org
|
|
96
|
-
- **MCP Server Integration**: Ready-to-use MCP server with
|
|
96
|
+
- **MCP Server Integration**: Ready-to-use MCP server with 19 tools and 4 resources, compatible with Cursor IDE, Claude Desktop, and other MCP-enabled tools
|
|
97
97
|
- **Pagination & Sorting**: Support for large result sets with customizable pagination and sorting options
|
|
98
98
|
- **Caching**: In-memory caching with configurable TTL for improved performance
|
|
99
99
|
- **Error Handling**: Graceful error handling with custom exceptions and response size limits
|
|
@@ -196,11 +196,15 @@ recently_updated = client.get_recently_updated_gems(limit: 10)
|
|
|
196
196
|
- `get_latest_ruby_version` - Get latest Ruby version with release date
|
|
197
197
|
- `get_ruby_versions(limit: nil, offset: 0, sort: :version_desc)` - Get all Ruby versions with release dates, download URLs, and release notes URLs, sorted by version descending. Supports pagination and sorting.
|
|
198
198
|
- `get_ruby_version_changelog(version)` - Get changelog summary for a specific Ruby version by fetching and parsing the release notes
|
|
199
|
+
- `get_ruby_version_github_changelog(version)` - Get GitHub release changelog for a Ruby version from the ruby/ruby repository
|
|
200
|
+
- `get_ruby_roadmap` - Get Ruby roadmap information from bugs.ruby-lang.org showing planned versions and their issues
|
|
201
|
+
- `get_ruby_version_roadmap_details(version)` - Get detailed roadmap information for a specific Ruby version from bugs.ruby-lang.org, including issues and features planned for that version
|
|
199
202
|
- `get_ruby_maintenance_status` - Get maintenance status for all Ruby versions including EOL dates and maintenance phases
|
|
200
203
|
|
|
201
204
|
### Gem Information
|
|
202
205
|
|
|
203
206
|
- `get_gem_info(gem_name, fields: nil)` - Get detailed information about a gem (summary, homepage, source code, documentation, licenses, authors, dependencies, downloads). Supports GraphQL-like field selection.
|
|
207
|
+
- `get_gem_version_info(gem_name, version, fields: nil)` - Get detailed information for a specific gem version using RubyGems API v2 (version-specific downloads, dependencies, SHA checksums, creation date, built date). Supports GraphQL-like field selection.
|
|
204
208
|
- `get_gem_reverse_dependencies(gem_name)` - Get reverse dependencies - list of gems that depend on the specified gem
|
|
205
209
|
- `get_gem_version_downloads(gem_name, version)` - Get download statistics for a specific gem version
|
|
206
210
|
- `get_gem_changelog(gem_name, version: nil)` - Get changelog summary for a gem by fetching and parsing the changelog from its changelog_uri
|
|
@@ -251,23 +255,35 @@ The MCP server provides the following tools:
|
|
|
251
255
|
6. **get_gem_info** - Get detailed information about a gem (summary, homepage, source code, documentation, licenses, authors, dependencies, downloads). Supports GraphQL-like field selection.
|
|
252
256
|
- Parameters: `gem_name` (string), `fields` (optional array of strings)
|
|
253
257
|
|
|
254
|
-
7. **
|
|
258
|
+
7. **get_gem_version_info** - Get detailed information for a specific gem version using RubyGems API v2 (version-specific downloads, dependencies, SHA checksums, creation date, built date). Supports GraphQL-like field selection.
|
|
259
|
+
- Parameters: `gem_name` (string), `version` (string, e.g., "0.1.0" or "1.15.0-x86_64-linux"), `fields` (optional array of strings)
|
|
260
|
+
|
|
261
|
+
8. **get_gem_reverse_dependencies** - Get reverse dependencies - list of gems that depend on the specified gem
|
|
255
262
|
- Parameters: `gem_name` (string)
|
|
256
263
|
|
|
257
|
-
|
|
264
|
+
9. **get_gem_version_downloads** - Get download statistics for a specific gem version
|
|
258
265
|
- Parameters: `gem_name` (string), `version` (string)
|
|
259
266
|
|
|
260
|
-
|
|
267
|
+
10. **get_latest_gems** - Get latest gems - most recently added gems to RubyGems.org
|
|
268
|
+
- Parameters: `limit` (optional integer, default: 30, max: 50)
|
|
269
|
+
|
|
270
|
+
11. **get_recently_updated_gems** - Get recently updated gems - most recently updated gem versions
|
|
261
271
|
- Parameters: `limit` (optional integer, default: 30, max: 50)
|
|
262
272
|
|
|
263
|
-
|
|
264
|
-
|
|
273
|
+
12. **get_gem_changelog** - Get changelog summary for a gem by fetching and parsing the changelog from its changelog_uri
|
|
274
|
+
- Parameters: `gem_name` (string), `version` (optional string, uses latest if not provided)
|
|
275
|
+
|
|
276
|
+
13. **search_gems** - Search for gems by name on RubyGems
|
|
277
|
+
- Parameters: `query` (string)
|
|
278
|
+
|
|
279
|
+
14. **get_ruby_roadmap** - Get Ruby roadmap information from bugs.ruby-lang.org showing planned versions and their issues
|
|
280
|
+
- Parameters: none
|
|
265
281
|
|
|
266
|
-
|
|
267
|
-
|
|
282
|
+
15. **get_ruby_version_roadmap_details** - Get detailed roadmap information for a specific Ruby version from bugs.ruby-lang.org, including issues and features planned for that version
|
|
283
|
+
- Parameters: `version` (string, e.g., "3.4", "4.0")
|
|
268
284
|
|
|
269
|
-
|
|
270
|
-
|
|
285
|
+
16. **get_ruby_version_github_changelog** - Get GitHub release changelog for a Ruby version from the ruby/ruby repository
|
|
286
|
+
- Parameters: `version` (string, e.g., "3.4.7", "3.4.0")
|
|
271
287
|
|
|
272
288
|
## MCP Resources
|
|
273
289
|
|
data/lib/rubygems_mcp/client.rb
CHANGED
|
@@ -17,30 +17,17 @@ module RubygemsMcp
|
|
|
17
17
|
# Maximum response size (5MB) to protect against crawler protection pages
|
|
18
18
|
MAX_RESPONSE_SIZE = 5 * 1024 * 1024 # 5MB
|
|
19
19
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
# Validation constants
|
|
21
|
+
VALID_SORT_ORDERS = %i[version_desc version_asc date_desc date_asc].freeze
|
|
22
|
+
MAX_LIMIT = 1000 # Reasonable upper bound for pagination
|
|
23
23
|
|
|
24
|
-
def initialize(message, original_error: nil, response_size: nil)
|
|
25
|
-
super(message)
|
|
26
|
-
@original_error = original_error
|
|
27
|
-
@response_size = response_size
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Custom exception for response size exceeded
|
|
32
|
-
class ResponseSizeExceededError < StandardError
|
|
33
|
-
attr_reader :size, :max_size
|
|
34
|
-
|
|
35
|
-
def initialize(size, max_size)
|
|
36
|
-
@size = size
|
|
37
|
-
@max_size = max_size
|
|
38
|
-
super("Response size (#{size} bytes) exceeds maximum allowed size (#{max_size} bytes). This may indicate crawler protection.")
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
24
|
RUBYGEMS_API_BASE = "https://rubygems.org/api/v1"
|
|
25
|
+
RUBYGEMS_API_V2_BASE = "https://rubygems.org/api/v2"
|
|
42
26
|
RUBY_RELEASES_URL = "https://www.ruby-lang.org/en/downloads/releases/"
|
|
43
27
|
RUBY_BRANCHES_URL = "https://www.ruby-lang.org/en/downloads/branches/"
|
|
28
|
+
RUBY_ROADMAP_URL = "https://bugs.ruby-lang.org/projects/ruby-master/roadmap"
|
|
29
|
+
RUBY_BUGS_BASE = "https://bugs.ruby-lang.org"
|
|
30
|
+
GITHUB_RUBY_REPO = "https://api.github.com/repos/ruby/ruby"
|
|
44
31
|
|
|
45
32
|
# Simple in-memory cache with TTL
|
|
46
33
|
class Cache
|
|
@@ -96,6 +83,8 @@ module RubygemsMcp
|
|
|
96
83
|
# ruby_version, rubygems_version, downloads_count, sha, spec_sha, requirements, metadata
|
|
97
84
|
# @return [Array<Hash>] Array of hashes with selected fields
|
|
98
85
|
def get_latest_versions(gem_names, fields: nil)
|
|
86
|
+
raise ValidationError, "gem_names cannot be empty" if gem_names.nil? || gem_names.empty?
|
|
87
|
+
gem_names = gem_names.map { |name| validate_gem_name(name) }
|
|
99
88
|
gem_names.map do |name|
|
|
100
89
|
versions = get_gem_versions(name, limit: 1, fields: fields)
|
|
101
90
|
latest = versions.first # Versions are sorted by version number descending
|
|
@@ -121,6 +110,11 @@ module RubygemsMcp
|
|
|
121
110
|
# ruby_version, rubygems_version, downloads_count, sha, spec_sha, requirements, metadata
|
|
122
111
|
# @return [Array<Hash>] Array of hashes with selected fields
|
|
123
112
|
def get_gem_versions(gem_name, limit: nil, offset: 0, sort: :version_desc, fields: nil)
|
|
113
|
+
# Validate inputs
|
|
114
|
+
gem_name = validate_gem_name(gem_name)
|
|
115
|
+
validate_pagination_params(limit: limit, offset: offset)
|
|
116
|
+
sort = validate_sort_order(sort)
|
|
117
|
+
|
|
124
118
|
cache_key = "gem_versions:#{gem_name}"
|
|
125
119
|
|
|
126
120
|
if @cache_enabled
|
|
@@ -140,7 +134,8 @@ module RubygemsMcp
|
|
|
140
134
|
unless response.is_a?(Array)
|
|
141
135
|
raise CorruptedDataError.new(
|
|
142
136
|
"Invalid JSON structure: expected Array, got #{response.class}",
|
|
143
|
-
response_size: response.to_s.bytesize
|
|
137
|
+
response_size: response.to_s.bytesize,
|
|
138
|
+
uri: uri.to_s
|
|
144
139
|
)
|
|
145
140
|
end
|
|
146
141
|
|
|
@@ -296,6 +291,8 @@ module RubygemsMcp
|
|
|
296
291
|
# @param sort [Symbol] Sort order: :version_desc (default), :version_asc, :date_desc, :date_asc
|
|
297
292
|
# @return [Array<Hash>] Array of hashes with :version and :release_date
|
|
298
293
|
def get_ruby_versions(limit: nil, offset: 0, sort: :version_desc)
|
|
294
|
+
validate_pagination_params(limit: limit, offset: offset)
|
|
295
|
+
sort = validate_sort_order(sort)
|
|
299
296
|
cache_key = "ruby_versions"
|
|
300
297
|
|
|
301
298
|
if @cache_enabled
|
|
@@ -354,6 +351,7 @@ module RubygemsMcp
|
|
|
354
351
|
# @param version [String] Ruby version (e.g., "3.4.7" or "4.0.0-preview2")
|
|
355
352
|
# @return [Hash] Hash with :version, :release_notes_url, and :content (full content)
|
|
356
353
|
def get_ruby_version_changelog(version)
|
|
354
|
+
validate_version_string(version)
|
|
357
355
|
# First get the release notes URL for this version
|
|
358
356
|
versions = get_ruby_versions
|
|
359
357
|
|
|
@@ -389,32 +387,46 @@ module RubygemsMcp
|
|
|
389
387
|
|
|
390
388
|
uri = URI(release_notes_url)
|
|
391
389
|
response = make_request(uri, parse_html: true)
|
|
392
|
-
return {version: version, release_notes_url: release_notes_url, content: nil, error: "Failed to fetch release notes"} unless response
|
|
393
390
|
|
|
394
|
-
|
|
395
|
-
|
|
391
|
+
content = nil
|
|
392
|
+
github_changelog = nil
|
|
396
393
|
|
|
397
|
-
if
|
|
398
|
-
#
|
|
399
|
-
content.css("
|
|
394
|
+
if response
|
|
395
|
+
# Extract the main content - Ruby release notes use div#content
|
|
396
|
+
content_elem = response.css("div#content").first || response.css("div.content, div.entry-content, article, main").first
|
|
400
397
|
|
|
401
|
-
|
|
402
|
-
|
|
398
|
+
if content_elem
|
|
399
|
+
# Remove navigation and metadata elements
|
|
400
|
+
content_elem.css("p.post-info, .post-info, nav, .navigation, header, footer, .sidebar").remove
|
|
403
401
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
text = text.gsub(/[ \t]+/, " ")
|
|
402
|
+
# Get the full text content, preserving structure
|
|
403
|
+
content = content_elem.text.strip
|
|
407
404
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
405
|
+
# Clean up excessive whitespace but preserve paragraph structure
|
|
406
|
+
content = content.gsub(/\n{3,}/, "\n\n")
|
|
407
|
+
content = content.gsub(/[ \t]+/, " ")
|
|
408
|
+
content = content.strip
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# If no content from release notes, try GitHub as fallback
|
|
413
|
+
if content.nil? || content.empty?
|
|
414
|
+
begin
|
|
415
|
+
github_data = get_ruby_version_github_changelog(version)
|
|
416
|
+
if github_data[:body] && !github_data[:body].empty?
|
|
417
|
+
github_changelog = github_data[:body]
|
|
418
|
+
content = github_changelog
|
|
419
|
+
end
|
|
420
|
+
rescue
|
|
421
|
+
# Silently fall through if GitHub fetch fails
|
|
422
|
+
end
|
|
412
423
|
end
|
|
413
424
|
|
|
414
425
|
result = {
|
|
415
426
|
version: version,
|
|
416
427
|
release_notes_url: release_notes_url,
|
|
417
|
-
content:
|
|
428
|
+
content: content,
|
|
429
|
+
github_changelog: github_changelog
|
|
418
430
|
}
|
|
419
431
|
|
|
420
432
|
# Cache for 24 hours
|
|
@@ -423,11 +435,224 @@ module RubygemsMcp
|
|
|
423
435
|
result
|
|
424
436
|
end
|
|
425
437
|
|
|
438
|
+
# Get Ruby roadmap information from bugs.ruby-lang.org
|
|
439
|
+
#
|
|
440
|
+
# @return [Hash] Hash with roadmap data:
|
|
441
|
+
# - :versions (Array<Hash>) - Array of version information with links
|
|
442
|
+
# Each version hash contains: :name, :due_date, :version_url, :issues_count
|
|
443
|
+
def get_ruby_roadmap
|
|
444
|
+
cache_key = "ruby_roadmap"
|
|
445
|
+
|
|
446
|
+
if @cache_enabled
|
|
447
|
+
cached = self.class.cache.get(cache_key)
|
|
448
|
+
return cached if cached
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
uri = URI(RUBY_ROADMAP_URL)
|
|
452
|
+
response = make_request(uri, parse_html: true)
|
|
453
|
+
return {versions: []} unless response
|
|
454
|
+
|
|
455
|
+
versions = []
|
|
456
|
+
|
|
457
|
+
# Find all version links in the roadmap
|
|
458
|
+
response.css('a[href*="/versions/"]').each do |link|
|
|
459
|
+
href = link["href"]
|
|
460
|
+
next unless href&.match?(%r{/versions/\d+})
|
|
461
|
+
|
|
462
|
+
version_name = link.text.strip
|
|
463
|
+
due_date = link["title"]
|
|
464
|
+
version_url = href.start_with?("http") ? href : "#{RUBY_BUGS_BASE}#{href}"
|
|
465
|
+
|
|
466
|
+
# Try to find issue count in nearby elements
|
|
467
|
+
issues_count = nil
|
|
468
|
+
parent = link.parent
|
|
469
|
+
if parent
|
|
470
|
+
# Look for text like "16 issues" or "87%"
|
|
471
|
+
issues_text = parent.text
|
|
472
|
+
issues_match = issues_text.match(/(\d+)\s+issues?/)
|
|
473
|
+
issues_count = issues_match[1].to_i if issues_match
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Avoid duplicates
|
|
477
|
+
next if versions.any? { |v| v[:name] == version_name }
|
|
478
|
+
|
|
479
|
+
versions << {
|
|
480
|
+
name: version_name,
|
|
481
|
+
due_date: due_date,
|
|
482
|
+
version_url: version_url,
|
|
483
|
+
issues_count: issues_count
|
|
484
|
+
}
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
result = {versions: versions}
|
|
488
|
+
|
|
489
|
+
# Cache for 6 hours (roadmap changes infrequently)
|
|
490
|
+
self.class.cache.set(cache_key, result, 21600) if @cache_enabled
|
|
491
|
+
|
|
492
|
+
result
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Get detailed roadmap information for a specific Ruby version from bugs.ruby-lang.org
|
|
496
|
+
#
|
|
497
|
+
# @param version [String] Ruby version (e.g., "3.4", "4.0")
|
|
498
|
+
# @return [Hash] Hash with version details:
|
|
499
|
+
# - :version (String) - Version name
|
|
500
|
+
# - :version_url (String) - URL to version page
|
|
501
|
+
# - :description (String, nil) - Version description
|
|
502
|
+
# - :issues (Array<Hash>) - Array of issues/features for this version
|
|
503
|
+
# Each issue hash contains: :id, :tracker, :subject, :status, :priority, :url
|
|
504
|
+
def get_ruby_version_roadmap_details(version)
|
|
505
|
+
validate_version_string(version)
|
|
506
|
+
|
|
507
|
+
# First get roadmap to find version URL
|
|
508
|
+
roadmap = get_ruby_roadmap
|
|
509
|
+
version_info = roadmap[:versions].find { |v| v[:name] == version || v[:name].start_with?("#{version}.") }
|
|
510
|
+
|
|
511
|
+
return {version: version, version_url: nil, description: nil, issues: [], error: "Version not found in roadmap"} unless version_info
|
|
512
|
+
|
|
513
|
+
version_url = version_info[:version_url]
|
|
514
|
+
cache_key = "ruby_version_roadmap:#{version}"
|
|
515
|
+
|
|
516
|
+
if @cache_enabled
|
|
517
|
+
cached = self.class.cache.get(cache_key)
|
|
518
|
+
return cached if cached
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
uri = URI(version_url)
|
|
522
|
+
response = make_request(uri, parse_html: true)
|
|
523
|
+
return {version: version, version_url: version_url, description: nil, issues: [], error: "Failed to fetch version page"} unless response
|
|
524
|
+
|
|
525
|
+
# Extract description
|
|
526
|
+
description = nil
|
|
527
|
+
desc_elem = response.css(".wiki, .description, .version-description").first
|
|
528
|
+
description = desc_elem.text.strip if desc_elem && !desc_elem.text.strip.empty?
|
|
529
|
+
|
|
530
|
+
# Extract issues
|
|
531
|
+
issues = []
|
|
532
|
+
response.css(".issues tbody tr, .issue-list tr").each do |row|
|
|
533
|
+
cells = row.css("td")
|
|
534
|
+
next if cells.empty?
|
|
535
|
+
|
|
536
|
+
# Try to extract issue information
|
|
537
|
+
issue_link = row.css("a[href*='/issues/']").first
|
|
538
|
+
next unless issue_link
|
|
539
|
+
|
|
540
|
+
issue_id = issue_link["href"].match(%r{/issues/(\d+)})&.[](1)
|
|
541
|
+
next unless issue_id
|
|
542
|
+
|
|
543
|
+
issue_url = issue_link["href"].start_with?("http") ? issue_link["href"] : "#{RUBY_BUGS_BASE}#{issue_link["href"]}"
|
|
544
|
+
subject = issue_link.text.strip
|
|
545
|
+
|
|
546
|
+
# Try to extract tracker, status, priority from cells
|
|
547
|
+
tracker = cells[0]&.text&.strip
|
|
548
|
+
status = cells[1]&.text&.strip || cells.find { |c| c.text.match?(/New|Assigned|Closed|Resolved/i) }&.text&.strip
|
|
549
|
+
priority = cells.find { |c| c.text.match?(/Low|Normal|High|Urgent/i) }&.text&.strip
|
|
550
|
+
|
|
551
|
+
issues << {
|
|
552
|
+
id: issue_id,
|
|
553
|
+
tracker: tracker,
|
|
554
|
+
subject: subject,
|
|
555
|
+
status: status,
|
|
556
|
+
priority: priority,
|
|
557
|
+
url: issue_url
|
|
558
|
+
}
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
result = {
|
|
562
|
+
version: version,
|
|
563
|
+
version_url: version_url,
|
|
564
|
+
description: description,
|
|
565
|
+
issues: issues
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# Cache for 6 hours
|
|
569
|
+
self.class.cache.set(cache_key, result, 21600) if @cache_enabled
|
|
570
|
+
|
|
571
|
+
result
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Get GitHub release changelog for a Ruby version
|
|
575
|
+
#
|
|
576
|
+
# @param version [String] Ruby version (e.g., "3.4.7", "3.4.0")
|
|
577
|
+
# @return [Hash] Hash with release information:
|
|
578
|
+
# - :version (String) - Version string
|
|
579
|
+
# - :tag_name (String) - GitHub tag name (e.g., "v3_4_7")
|
|
580
|
+
# - :name (String, nil) - Release name
|
|
581
|
+
# - :body (String, nil) - Release notes/changelog
|
|
582
|
+
# - :published_at (String, nil) - Publication date as ISO 8601
|
|
583
|
+
# - :url (String, nil) - GitHub release URL
|
|
584
|
+
def get_ruby_version_github_changelog(version)
|
|
585
|
+
validate_version_string(version)
|
|
586
|
+
|
|
587
|
+
# Convert version to GitHub tag format (3.4.7 -> v3_4_7)
|
|
588
|
+
tag_name = "v#{version.tr(".", "_")}"
|
|
589
|
+
cache_key = "ruby_github_changelog:#{version}"
|
|
590
|
+
|
|
591
|
+
if @cache_enabled
|
|
592
|
+
cached = self.class.cache.get(cache_key)
|
|
593
|
+
return cached if cached
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Try GitHub API
|
|
597
|
+
uri = URI("#{GITHUB_RUBY_REPO}/releases/tags/#{tag_name}")
|
|
598
|
+
begin
|
|
599
|
+
http = build_http_client(uri)
|
|
600
|
+
request = Net::HTTP::Get.new(uri)
|
|
601
|
+
request["Accept"] = "application/vnd.github+json"
|
|
602
|
+
request["User-Agent"] = "rubygems_mcp/#{RubygemsMcp::VERSION}"
|
|
603
|
+
|
|
604
|
+
response = http.request(request)
|
|
605
|
+
|
|
606
|
+
case response
|
|
607
|
+
when Net::HTTPSuccess
|
|
608
|
+
# Parse JSON response
|
|
609
|
+
body = response.body
|
|
610
|
+
return {version: version, tag_name: tag_name, name: nil, body: nil, published_at: nil, url: nil, error: "Empty response"} if body.nil? || body.empty?
|
|
611
|
+
|
|
612
|
+
begin
|
|
613
|
+
data = JSON.parse(body.force_encoding("UTF-8"))
|
|
614
|
+
rescue JSON::ParserError => e
|
|
615
|
+
raise CorruptedDataError.new(
|
|
616
|
+
"Failed to parse GitHub API response: #{e.message}",
|
|
617
|
+
original_error: e,
|
|
618
|
+
uri: uri.to_s
|
|
619
|
+
)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
result = {
|
|
623
|
+
version: version,
|
|
624
|
+
tag_name: tag_name,
|
|
625
|
+
name: data["name"],
|
|
626
|
+
body: data["body"],
|
|
627
|
+
published_at: data["published_at"],
|
|
628
|
+
url: data["html_url"]
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
# Cache for 24 hours
|
|
632
|
+
self.class.cache.set(cache_key, result, 86400) if @cache_enabled
|
|
633
|
+
|
|
634
|
+
result
|
|
635
|
+
when Net::HTTPNotFound
|
|
636
|
+
{version: version, tag_name: tag_name, name: nil, body: nil, published_at: nil, url: nil, error: "Release not found on GitHub"}
|
|
637
|
+
else
|
|
638
|
+
handle_http_error(response, uri)
|
|
639
|
+
end
|
|
640
|
+
rescue APIError
|
|
641
|
+
raise
|
|
642
|
+
rescue => e
|
|
643
|
+
raise APIError.new(
|
|
644
|
+
"Request to GitHub API failed: #{e.class} - #{e.message}",
|
|
645
|
+
uri: uri.to_s
|
|
646
|
+
)
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
426
650
|
# Get reverse dependencies (gems that depend on this gem)
|
|
427
651
|
#
|
|
428
652
|
# @param gem_name [String] Gem name
|
|
429
653
|
# @return [Array<String>] Array of gem names that depend on this gem
|
|
430
654
|
def get_gem_reverse_dependencies(gem_name)
|
|
655
|
+
gem_name = validate_gem_name(gem_name)
|
|
431
656
|
cache_key = "gem_reverse_deps:#{gem_name}"
|
|
432
657
|
|
|
433
658
|
if @cache_enabled
|
|
@@ -452,6 +677,8 @@ module RubygemsMcp
|
|
|
452
677
|
# @param version [String] Gem version (e.g., "1.0.0")
|
|
453
678
|
# @return [Hash] Hash with :version_downloads and :total_downloads
|
|
454
679
|
def get_gem_version_downloads(gem_name, version)
|
|
680
|
+
gem_name = validate_gem_name(gem_name)
|
|
681
|
+
validate_version_string(version)
|
|
455
682
|
cache_key = "gem_downloads:#{gem_name}:#{version}"
|
|
456
683
|
|
|
457
684
|
if @cache_enabled
|
|
@@ -482,6 +709,7 @@ module RubygemsMcp
|
|
|
482
709
|
# @param limit [Integer, nil] Maximum number of gems to return (default: 30, max: 50)
|
|
483
710
|
# @return [Array<Hash>] Array of gem information
|
|
484
711
|
def get_latest_gems(limit: 30)
|
|
712
|
+
validate_pagination_params(limit: limit, offset: 0)
|
|
485
713
|
limit = [limit || 30, 50].min # API returns max 50
|
|
486
714
|
cache_key = "latest_gems:#{limit}"
|
|
487
715
|
|
|
@@ -520,6 +748,7 @@ module RubygemsMcp
|
|
|
520
748
|
# @param limit [Integer, nil] Maximum number of gems to return (default: 30, max: 50)
|
|
521
749
|
# @return [Array<Hash>] Array of gem version information
|
|
522
750
|
def get_recently_updated_gems(limit: 30)
|
|
751
|
+
validate_pagination_params(limit: limit, offset: 0)
|
|
523
752
|
limit = [limit || 30, 50].min # API returns max 50
|
|
524
753
|
cache_key = "recently_updated_gems:#{limit}"
|
|
525
754
|
|
|
@@ -561,6 +790,8 @@ module RubygemsMcp
|
|
|
561
790
|
# @param version [String, nil] Gem version (optional, uses latest if not provided)
|
|
562
791
|
# @return [Hash] Hash with :gem_name, :version, :changelog_uri, and :summary
|
|
563
792
|
def get_gem_changelog(gem_name, version: nil)
|
|
793
|
+
gem_name = validate_gem_name(gem_name)
|
|
794
|
+
validate_version_string(version) if version
|
|
564
795
|
# Get gem info to find changelog_uri
|
|
565
796
|
gem_info = get_gem_info(gem_name)
|
|
566
797
|
return {gem_name: gem_name, version: nil, changelog_uri: nil, summary: nil, error: "Gem not found"} if gem_info.empty?
|
|
@@ -704,6 +935,7 @@ module RubygemsMcp
|
|
|
704
935
|
# dependencies, changelog_uri, funding_uri, platform, sha, spec_sha, metadata
|
|
705
936
|
# @return [Hash] Hash with selected gem information
|
|
706
937
|
def get_gem_info(gem_name, fields: nil)
|
|
938
|
+
gem_name = validate_gem_name(gem_name)
|
|
707
939
|
cache_key = "gem_info:#{gem_name}"
|
|
708
940
|
|
|
709
941
|
if @cache_enabled
|
|
@@ -748,6 +980,82 @@ module RubygemsMcp
|
|
|
748
980
|
select_fields([gem_info], fields).first || gem_info
|
|
749
981
|
end
|
|
750
982
|
|
|
983
|
+
# Get detailed information for a specific gem version
|
|
984
|
+
#
|
|
985
|
+
# Uses RubyGems API v2 to get version-specific details including:
|
|
986
|
+
# - Version-specific download counts
|
|
987
|
+
# - Version creation date
|
|
988
|
+
# - Dependencies for that specific version
|
|
989
|
+
# - SHA checksums
|
|
990
|
+
# - Built date
|
|
991
|
+
#
|
|
992
|
+
# @param gem_name [String] Gem name (e.g., 'devise')
|
|
993
|
+
# @param version [String] Version string (e.g., '0.1.0', '4.9.4', '1.15.0-x86_64-linux')
|
|
994
|
+
# @param fields [Array<String>, nil] Optional field selection (GraphQL-like)
|
|
995
|
+
# @return [Hash] Hash with version-specific gem information
|
|
996
|
+
# @raise [ValidationError] If gem name or version is invalid
|
|
997
|
+
# @raise [NotFoundError] If gem or version doesn't exist
|
|
998
|
+
def get_gem_version_info(gem_name, version, fields: nil)
|
|
999
|
+
validate_gem_name(gem_name)
|
|
1000
|
+
# Gem versions can include platform suffixes (e.g., "1.15.0-x86_64-linux")
|
|
1001
|
+
# So we use a more lenient validation for gem versions
|
|
1002
|
+
validate_gem_version_string(version)
|
|
1003
|
+
|
|
1004
|
+
cache_key = "gem_version_info:#{gem_name}:#{version}"
|
|
1005
|
+
if @cache_enabled
|
|
1006
|
+
cached = self.class.cache.get(cache_key)
|
|
1007
|
+
if cached
|
|
1008
|
+
return select_fields([cached], fields).first if fields
|
|
1009
|
+
return cached
|
|
1010
|
+
end
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Use v2 API for version-specific information
|
|
1014
|
+
uri = URI("#{RUBYGEMS_API_V2_BASE}/rubygems/#{gem_name}/versions/#{version}.json")
|
|
1015
|
+
|
|
1016
|
+
response = make_request(uri)
|
|
1017
|
+
return {} unless response.is_a?(Hash)
|
|
1018
|
+
|
|
1019
|
+
version_info = {
|
|
1020
|
+
name: response["name"],
|
|
1021
|
+
version: response["version"] || response["number"],
|
|
1022
|
+
summary: response["summary"] || response["info"],
|
|
1023
|
+
description: response["description"],
|
|
1024
|
+
homepage: response["homepage_uri"],
|
|
1025
|
+
source_code: response["source_code_uri"],
|
|
1026
|
+
documentation: response["documentation_uri"],
|
|
1027
|
+
licenses: response["licenses"] || [],
|
|
1028
|
+
authors: response["authors"],
|
|
1029
|
+
info: response["info"],
|
|
1030
|
+
downloads: response["downloads"], # Total downloads for the gem
|
|
1031
|
+
version_downloads: response["version_downloads"] || response["downloads_count"],
|
|
1032
|
+
yanked: response["yanked"] || false,
|
|
1033
|
+
dependencies: response["dependencies"] || {runtime: [], development: []},
|
|
1034
|
+
changelog_uri: response["changelog_uri"] || response.dig("metadata", "changelog_uri"),
|
|
1035
|
+
funding_uri: response["funding_uri"] || response.dig("metadata", "funding_uri"),
|
|
1036
|
+
platform: response["platform"] || "ruby",
|
|
1037
|
+
sha: response["sha"],
|
|
1038
|
+
spec_sha: response["spec_sha"],
|
|
1039
|
+
metadata: response["metadata"] || {},
|
|
1040
|
+
version_created_at: response["version_created_at"] || response["created_at"],
|
|
1041
|
+
built_at: response["built_at"],
|
|
1042
|
+
prerelease: response["prerelease"] || false,
|
|
1043
|
+
rubygems_version: response["rubygems_version"],
|
|
1044
|
+
ruby_version: response["ruby_version"],
|
|
1045
|
+
requirements: response["requirements"],
|
|
1046
|
+
gem_uri: response["gem_uri"],
|
|
1047
|
+
project_uri: response["project_uri"],
|
|
1048
|
+
wiki_uri: response["wiki_uri"],
|
|
1049
|
+
mailing_list_uri: response["mailing_list_uri"],
|
|
1050
|
+
bug_tracker_uri: response["bug_tracker_uri"]
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
# Cache for 1 hour
|
|
1054
|
+
self.class.cache.set(cache_key, version_info, 3600) if @cache_enabled
|
|
1055
|
+
|
|
1056
|
+
select_fields([version_info], fields).first || version_info
|
|
1057
|
+
end
|
|
1058
|
+
|
|
751
1059
|
# Search for gems by name
|
|
752
1060
|
#
|
|
753
1061
|
# @param query [String] Search query
|
|
@@ -755,6 +1063,8 @@ module RubygemsMcp
|
|
|
755
1063
|
# @param offset [Integer] Number of results to skip (for pagination)
|
|
756
1064
|
# @return [Array<Hash>] Array of hashes with gem information
|
|
757
1065
|
def search_gems(query, limit: nil, offset: 0)
|
|
1066
|
+
raise ValidationError, "Search query cannot be empty" if query.nil? || query.strip.empty?
|
|
1067
|
+
validate_pagination_params(limit: limit, offset: offset)
|
|
758
1068
|
# Don't cache search results as they can change frequently
|
|
759
1069
|
uri = URI("#{RUBYGEMS_API_BASE}/search.json")
|
|
760
1070
|
uri.query = URI.encode_www_form(query: query)
|
|
@@ -779,6 +1089,126 @@ module RubygemsMcp
|
|
|
779
1089
|
results
|
|
780
1090
|
end
|
|
781
1091
|
|
|
1092
|
+
# Validate gem name
|
|
1093
|
+
#
|
|
1094
|
+
# @param gem_name [String] Gem name to validate
|
|
1095
|
+
# @return [String] Validated and normalized gem name
|
|
1096
|
+
# @raise [ValidationError] If gem name is invalid
|
|
1097
|
+
def validate_gem_name(gem_name)
|
|
1098
|
+
raise ValidationError, "Gem name cannot be empty" if gem_name.nil? || gem_name.strip.empty?
|
|
1099
|
+
raise ValidationError, "Gem name contains invalid characters" unless gem_name.match?(/\A[a-z0-9_-]+\z/i)
|
|
1100
|
+
gem_name.strip
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# Validate version string (for Ruby versions)
|
|
1104
|
+
#
|
|
1105
|
+
# @param version [String, nil] Version string to validate
|
|
1106
|
+
# @return [String, nil] Validated version string or nil
|
|
1107
|
+
# @raise [ValidationError] If version format is invalid
|
|
1108
|
+
def validate_version_string(version)
|
|
1109
|
+
return nil if version.nil?
|
|
1110
|
+
# Ruby versions can be: "3.4.7", "4.0.0-preview2", "4.0.0.pre.preview2", etc.
|
|
1111
|
+
# Use Gem::Version to validate as it handles all Ruby version formats
|
|
1112
|
+
begin
|
|
1113
|
+
Gem::Version.new(version)
|
|
1114
|
+
rescue ArgumentError
|
|
1115
|
+
raise ValidationError, "Invalid version format: #{version.inspect}"
|
|
1116
|
+
end
|
|
1117
|
+
version
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# Validate gem version string (more lenient, supports platform suffixes)
|
|
1121
|
+
#
|
|
1122
|
+
# @param version [String, nil] Version string to validate
|
|
1123
|
+
# @return [String, nil] Validated version string or nil
|
|
1124
|
+
# @raise [ValidationError] If version format is invalid
|
|
1125
|
+
def validate_gem_version_string(version)
|
|
1126
|
+
return nil if version.nil?
|
|
1127
|
+
raise ValidationError, "Version cannot be empty" if version.strip.empty?
|
|
1128
|
+
|
|
1129
|
+
# Gem versions can include platform suffixes like "1.15.0-x86_64-linux"
|
|
1130
|
+
# Try to validate the base version part (before any platform suffix)
|
|
1131
|
+
base_version = version.split(/[-+]/).first
|
|
1132
|
+
begin
|
|
1133
|
+
Gem::Version.new(base_version)
|
|
1134
|
+
rescue ArgumentError
|
|
1135
|
+
raise ValidationError, "Invalid version format: #{version.inspect}"
|
|
1136
|
+
end
|
|
1137
|
+
version
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
# Validate sort order
|
|
1141
|
+
#
|
|
1142
|
+
# @param sort [Symbol, String] Sort order to validate
|
|
1143
|
+
# @return [Symbol] Validated sort order symbol
|
|
1144
|
+
# @raise [ValidationError] If sort order is invalid
|
|
1145
|
+
def validate_sort_order(sort)
|
|
1146
|
+
sort_sym = sort.to_sym
|
|
1147
|
+
unless VALID_SORT_ORDERS.include?(sort_sym)
|
|
1148
|
+
raise ValidationError, "Invalid sort order. Must be one of: #{VALID_SORT_ORDERS.join(", ")}"
|
|
1149
|
+
end
|
|
1150
|
+
sort_sym
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
# Validate pagination parameters
|
|
1154
|
+
#
|
|
1155
|
+
# @param limit [Integer, nil] Limit value
|
|
1156
|
+
# @param offset [Integer] Offset value
|
|
1157
|
+
# @raise [ValidationError] If pagination parameters are invalid
|
|
1158
|
+
def validate_pagination_params(limit:, offset:)
|
|
1159
|
+
raise ValidationError, "Limit must be positive" if limit && limit < 0
|
|
1160
|
+
raise ValidationError, "Offset must be non-negative" if offset < 0
|
|
1161
|
+
raise ValidationError, "Limit cannot exceed #{MAX_LIMIT}" if limit && limit > MAX_LIMIT
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Handle HTTP errors and raise appropriate exception types
|
|
1165
|
+
#
|
|
1166
|
+
# @param response [Net::HTTPResponse] HTTP response
|
|
1167
|
+
# @param uri [URI] Request URI for context
|
|
1168
|
+
# @raise [NotFoundError, ServerError, ClientError, APIError] Based on HTTP status code
|
|
1169
|
+
def handle_http_error(response, uri)
|
|
1170
|
+
# Try to parse error response body
|
|
1171
|
+
error_data = begin
|
|
1172
|
+
JSON.parse(response.body) if response.body && !response.body.empty?
|
|
1173
|
+
rescue JSON::ParserError
|
|
1174
|
+
nil
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
error_message = error_data&.dig("error") || error_data&.dig("message") || response.message
|
|
1178
|
+
error_message = "#{response.code} #{error_message}"
|
|
1179
|
+
|
|
1180
|
+
case response
|
|
1181
|
+
when Net::HTTPNotFound
|
|
1182
|
+
raise NotFoundError.new(
|
|
1183
|
+
"Resource not found: #{uri}",
|
|
1184
|
+
status_code: response.code.to_i,
|
|
1185
|
+
response_data: error_data,
|
|
1186
|
+
uri: uri.to_s
|
|
1187
|
+
)
|
|
1188
|
+
when Net::HTTPServerError
|
|
1189
|
+
raise ServerError.new(
|
|
1190
|
+
error_message,
|
|
1191
|
+
status_code: response.code.to_i,
|
|
1192
|
+
response_data: error_data,
|
|
1193
|
+
uri: uri.to_s
|
|
1194
|
+
)
|
|
1195
|
+
when Net::HTTPClientError
|
|
1196
|
+
raise ClientError.new(
|
|
1197
|
+
error_message,
|
|
1198
|
+
status_code: response.code.to_i,
|
|
1199
|
+
response_data: error_data,
|
|
1200
|
+
uri: uri.to_s
|
|
1201
|
+
)
|
|
1202
|
+
else
|
|
1203
|
+
raise APIError.new(
|
|
1204
|
+
error_message,
|
|
1205
|
+
status_code: response.code.to_i,
|
|
1206
|
+
response_data: error_data,
|
|
1207
|
+
uri: uri.to_s
|
|
1208
|
+
)
|
|
1209
|
+
end
|
|
1210
|
+
end
|
|
1211
|
+
|
|
782
1212
|
private
|
|
783
1213
|
|
|
784
1214
|
# Apply pagination and sorting to a version array
|
|
@@ -865,7 +1295,7 @@ module RubygemsMcp
|
|
|
865
1295
|
response_body = response.body || ""
|
|
866
1296
|
response_size = response_body.bytesize
|
|
867
1297
|
if response_size > MAX_RESPONSE_SIZE
|
|
868
|
-
raise ResponseSizeExceededError.new(response_size, MAX_RESPONSE_SIZE)
|
|
1298
|
+
raise ResponseSizeExceededError.new(response_size, MAX_RESPONSE_SIZE, uri: uri.to_s)
|
|
869
1299
|
end
|
|
870
1300
|
|
|
871
1301
|
# Validate and parse response
|
|
@@ -874,18 +1304,25 @@ module RubygemsMcp
|
|
|
874
1304
|
else
|
|
875
1305
|
validate_and_parse_json(response_body, uri)
|
|
876
1306
|
end
|
|
877
|
-
when Net::HTTPNotFound
|
|
878
|
-
raise "Resource not found. Response: #{response.body[0..500]}"
|
|
879
1307
|
else
|
|
880
|
-
|
|
1308
|
+
handle_http_error(response, uri)
|
|
881
1309
|
end
|
|
882
1310
|
rescue ResponseSizeExceededError, CorruptedDataError
|
|
883
1311
|
# Re-raise our custom errors as-is (don't cache corrupted data)
|
|
884
1312
|
raise
|
|
885
1313
|
rescue OpenSSL::SSL::SSLError => e
|
|
886
|
-
raise
|
|
1314
|
+
raise APIError.new(
|
|
1315
|
+
"SSL verification failed: #{e.message}. This may be due to system certificate configuration issues.",
|
|
1316
|
+
uri: uri.to_s
|
|
1317
|
+
)
|
|
1318
|
+
rescue APIError, NotFoundError, ServerError, ClientError
|
|
1319
|
+
# Re-raise API errors as-is
|
|
1320
|
+
raise
|
|
887
1321
|
rescue => e
|
|
888
|
-
raise
|
|
1322
|
+
raise APIError.new(
|
|
1323
|
+
"Request to #{uri} failed: #{e.class} - #{e.message}",
|
|
1324
|
+
uri: uri.to_s
|
|
1325
|
+
)
|
|
889
1326
|
end
|
|
890
1327
|
|
|
891
1328
|
# Validate and parse JSON response
|
|
@@ -899,7 +1336,8 @@ module RubygemsMcp
|
|
|
899
1336
|
if body.strip.start_with?("<") && body.match?(/cloudflare|ddos protection|access denied|blocked|captcha/i)
|
|
900
1337
|
raise CorruptedDataError.new(
|
|
901
1338
|
"Response appears to be a crawler protection page from #{uri}",
|
|
902
|
-
response_size: body.bytesize
|
|
1339
|
+
response_size: body.bytesize,
|
|
1340
|
+
uri: uri.to_s
|
|
903
1341
|
)
|
|
904
1342
|
end
|
|
905
1343
|
|
|
@@ -910,7 +1348,8 @@ module RubygemsMcp
|
|
|
910
1348
|
unless parsed.is_a?(Hash) || parsed.is_a?(Array)
|
|
911
1349
|
raise CorruptedDataError.new(
|
|
912
1350
|
"Invalid JSON structure: expected Hash or Array, got #{parsed.class}",
|
|
913
|
-
response_size: body.bytesize
|
|
1351
|
+
response_size: body.bytesize,
|
|
1352
|
+
uri: uri.to_s
|
|
914
1353
|
)
|
|
915
1354
|
end
|
|
916
1355
|
|
|
@@ -921,14 +1360,16 @@ module RubygemsMcp
|
|
|
921
1360
|
raise CorruptedDataError.new(
|
|
922
1361
|
"Received HTML instead of JSON from #{uri}. This may indicate an error page or crawler protection.",
|
|
923
1362
|
original_error: e,
|
|
924
|
-
response_size: body.bytesize
|
|
1363
|
+
response_size: body.bytesize,
|
|
1364
|
+
uri: uri.to_s
|
|
925
1365
|
)
|
|
926
1366
|
end
|
|
927
1367
|
|
|
928
1368
|
raise CorruptedDataError.new(
|
|
929
1369
|
"Failed to parse JSON response from #{uri}: #{e.message}",
|
|
930
1370
|
original_error: e,
|
|
931
|
-
response_size: body.bytesize
|
|
1371
|
+
response_size: body.bytesize,
|
|
1372
|
+
uri: uri.to_s
|
|
932
1373
|
)
|
|
933
1374
|
end
|
|
934
1375
|
end
|
|
@@ -943,7 +1384,8 @@ module RubygemsMcp
|
|
|
943
1384
|
if body.match?(/cloudflare|ddos protection|access denied|blocked|captcha|rate limit/i)
|
|
944
1385
|
raise CorruptedDataError.new(
|
|
945
1386
|
"Response appears to be a crawler protection page from #{uri}",
|
|
946
|
-
response_size: body.bytesize
|
|
1387
|
+
response_size: body.bytesize,
|
|
1388
|
+
uri: uri.to_s
|
|
947
1389
|
)
|
|
948
1390
|
end
|
|
949
1391
|
|
|
@@ -951,7 +1393,8 @@ module RubygemsMcp
|
|
|
951
1393
|
unless body.strip.start_with?("<!DOCTYPE", "<html", "<HTML") || body.include?("<html")
|
|
952
1394
|
raise CorruptedDataError.new(
|
|
953
1395
|
"Response from #{uri} does not appear to be HTML",
|
|
954
|
-
response_size: body.bytesize
|
|
1396
|
+
response_size: body.bytesize,
|
|
1397
|
+
uri: uri.to_s
|
|
955
1398
|
)
|
|
956
1399
|
end
|
|
957
1400
|
|
|
@@ -962,7 +1405,8 @@ module RubygemsMcp
|
|
|
962
1405
|
if doc.text.strip.length < 50
|
|
963
1406
|
raise CorruptedDataError.new(
|
|
964
1407
|
"HTML response from #{uri} appears to be empty or too short",
|
|
965
|
-
response_size: body.bytesize
|
|
1408
|
+
response_size: body.bytesize,
|
|
1409
|
+
uri: uri.to_s
|
|
966
1410
|
)
|
|
967
1411
|
end
|
|
968
1412
|
|
|
@@ -978,7 +1422,8 @@ module RubygemsMcp
|
|
|
978
1422
|
if error_indicators.any? { |pattern| doc.text.match?(pattern) }
|
|
979
1423
|
raise CorruptedDataError.new(
|
|
980
1424
|
"HTML response from #{uri} appears to be an error page",
|
|
981
|
-
response_size: body.bytesize
|
|
1425
|
+
response_size: body.bytesize,
|
|
1426
|
+
uri: uri.to_s
|
|
982
1427
|
)
|
|
983
1428
|
end
|
|
984
1429
|
|
|
@@ -987,7 +1432,8 @@ module RubygemsMcp
|
|
|
987
1432
|
raise CorruptedDataError.new(
|
|
988
1433
|
"Failed to parse HTML from #{uri}: #{e.message}",
|
|
989
1434
|
original_error: e,
|
|
990
|
-
response_size: body.bytesize
|
|
1435
|
+
response_size: body.bytesize,
|
|
1436
|
+
uri: uri.to_s
|
|
991
1437
|
)
|
|
992
1438
|
end
|
|
993
1439
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubygemsMcp
|
|
4
|
+
# Base error class for all RubygemsMcp errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# HTTP/API errors
|
|
8
|
+
class APIError < Error
|
|
9
|
+
attr_reader :status_code, :response_data, :uri
|
|
10
|
+
|
|
11
|
+
def initialize(message, status_code: nil, response_data: nil, uri: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@status_code = status_code
|
|
14
|
+
@response_data = response_data
|
|
15
|
+
@uri = uri
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class NotFoundError < APIError; end
|
|
20
|
+
class ServerError < APIError; end
|
|
21
|
+
class ClientError < APIError; end
|
|
22
|
+
|
|
23
|
+
# Data validation errors
|
|
24
|
+
class CorruptedDataError < Error
|
|
25
|
+
attr_reader :original_error, :response_size, :uri
|
|
26
|
+
|
|
27
|
+
def initialize(message, original_error: nil, response_size: nil, uri: nil)
|
|
28
|
+
super(message)
|
|
29
|
+
@original_error = original_error
|
|
30
|
+
@response_size = response_size
|
|
31
|
+
@uri = uri
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class ResponseSizeExceededError < Error
|
|
36
|
+
attr_reader :size, :max_size, :uri
|
|
37
|
+
|
|
38
|
+
def initialize(size, max_size, uri: nil)
|
|
39
|
+
@size = size
|
|
40
|
+
@max_size = max_size
|
|
41
|
+
@uri = uri
|
|
42
|
+
super("Response size (#{size} bytes) exceeds maximum allowed size (#{max_size} bytes). This may indicate crawler protection.")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Input validation errors
|
|
47
|
+
class ValidationError < Error; end
|
|
48
|
+
end
|
data/lib/rubygems_mcp/server.rb
CHANGED
|
@@ -130,6 +130,10 @@ module RubygemsMcp
|
|
|
130
130
|
server.register_tool(GetRecentlyUpdatedGemsTool)
|
|
131
131
|
server.register_tool(GetGemChangelogTool)
|
|
132
132
|
server.register_tool(SearchGemsTool)
|
|
133
|
+
server.register_tool(GetRubyRoadmapTool)
|
|
134
|
+
server.register_tool(GetRubyVersionRoadmapDetailsTool)
|
|
135
|
+
server.register_tool(GetRubyVersionGithubChangelogTool)
|
|
136
|
+
server.register_tool(GetGemVersionInfoTool)
|
|
133
137
|
end
|
|
134
138
|
|
|
135
139
|
def self.register_resources(server)
|
|
@@ -282,6 +286,22 @@ module RubygemsMcp
|
|
|
282
286
|
end
|
|
283
287
|
end
|
|
284
288
|
|
|
289
|
+
# Get detailed information for a specific gem version
|
|
290
|
+
class GetGemVersionInfoTool < BaseTool
|
|
291
|
+
tool_name "get_gem_version_info"
|
|
292
|
+
description "Get detailed information for a specific gem version using RubyGems API v2. Returns version-specific details including download counts, dependencies, SHA checksums, and creation date for that exact version."
|
|
293
|
+
|
|
294
|
+
arguments do
|
|
295
|
+
required(:gem_name).filled(:string).description("Gem name (e.g., 'devise')")
|
|
296
|
+
required(:version).filled(:string).description("Version string (e.g., '0.1.0', '4.9.4')")
|
|
297
|
+
optional(:fields).array(:string).description("GraphQL-like field selection. Available: name, version, summary, description, homepage, source_code, documentation, licenses, authors, info, downloads, version_downloads, yanked, dependencies, changelog_uri, funding_uri, platform, sha, spec_sha, metadata, version_created_at, built_at, prerelease, rubygems_version, ruby_version, requirements, gem_uri, project_uri, wiki_uri, mailing_list_uri, bug_tracker_uri")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def call(gem_name:, version:, fields: nil)
|
|
301
|
+
get_client.get_gem_version_info(gem_name, version, fields: fields)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
285
305
|
# Get reverse dependencies (gems that depend on this gem)
|
|
286
306
|
class GetGemReverseDependenciesTool < BaseTool
|
|
287
307
|
tool_name "get_gem_reverse_dependencies"
|
|
@@ -368,6 +388,48 @@ module RubygemsMcp
|
|
|
368
388
|
end
|
|
369
389
|
end
|
|
370
390
|
|
|
391
|
+
# Get Ruby roadmap information
|
|
392
|
+
class GetRubyRoadmapTool < BaseTool
|
|
393
|
+
tool_name "get_ruby_roadmap"
|
|
394
|
+
description "Get Ruby roadmap information from bugs.ruby-lang.org showing planned versions and their issues"
|
|
395
|
+
|
|
396
|
+
arguments do
|
|
397
|
+
# No arguments required
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def call
|
|
401
|
+
get_client.get_ruby_roadmap
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Get detailed roadmap information for a specific Ruby version
|
|
406
|
+
class GetRubyVersionRoadmapDetailsTool < BaseTool
|
|
407
|
+
tool_name "get_ruby_version_roadmap_details"
|
|
408
|
+
description "Get detailed roadmap information for a specific Ruby version from bugs.ruby-lang.org, including issues and features planned for that version"
|
|
409
|
+
|
|
410
|
+
arguments do
|
|
411
|
+
required(:version).filled(:string).description("Ruby version (e.g., '3.4', '4.0')")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def call(version:)
|
|
415
|
+
get_client.get_ruby_version_roadmap_details(version)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Get GitHub release changelog for a Ruby version
|
|
420
|
+
class GetRubyVersionGithubChangelogTool < BaseTool
|
|
421
|
+
tool_name "get_ruby_version_github_changelog"
|
|
422
|
+
description "Get GitHub release changelog for a Ruby version from the ruby/ruby repository"
|
|
423
|
+
|
|
424
|
+
arguments do
|
|
425
|
+
required(:version).filled(:string).description("Ruby version (e.g., '3.4.7', '3.4.0')")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def call(version:)
|
|
429
|
+
get_client.get_ruby_version_github_changelog(version)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
371
433
|
# Resource: Popular Ruby gems list
|
|
372
434
|
class PopularGemsResource < FastMcp::Resource
|
|
373
435
|
uri "rubygems://popular"
|
|
@@ -393,7 +455,7 @@ module RubygemsMcp
|
|
|
393
455
|
else
|
|
394
456
|
{name: gem_name, version: nil, release_date: nil}
|
|
395
457
|
end
|
|
396
|
-
rescue
|
|
458
|
+
rescue ResponseSizeExceededError, CorruptedDataError => e
|
|
397
459
|
# Skip gems that exceed size limit or have corrupted data
|
|
398
460
|
{name: gem_name, version: nil, release_date: nil, error: e.message}
|
|
399
461
|
end
|
data/lib/rubygems_mcp/version.rb
CHANGED
data/lib/rubygems_mcp.rb
CHANGED
data/sig/rubygems_mcp.rbs
CHANGED
|
@@ -1,20 +1,43 @@
|
|
|
1
1
|
module RubygemsMcp
|
|
2
2
|
VERSION: String
|
|
3
3
|
|
|
4
|
-
class
|
|
5
|
-
|
|
4
|
+
# Base error class for all RubygemsMcp errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# HTTP/API errors
|
|
8
|
+
class APIError < Error
|
|
9
|
+
def initialize: (String message, ?status_code: Integer?, ?response_data: untyped?, ?uri: String?) -> void
|
|
10
|
+
attr_reader status_code: Integer?
|
|
11
|
+
attr_reader response_data: untyped?
|
|
12
|
+
attr_reader uri: String?
|
|
13
|
+
end
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
attr_reader max_size: Integer
|
|
11
|
-
end
|
|
15
|
+
class NotFoundError < APIError; end
|
|
16
|
+
class ServerError < APIError; end
|
|
17
|
+
class ClientError < APIError; end
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
# Data validation errors
|
|
20
|
+
class CorruptedDataError < Error
|
|
21
|
+
def initialize: (String message, ?original_error: Exception?, ?response_size: Integer?, ?uri: String?) -> void
|
|
22
|
+
attr_reader original_error: Exception?
|
|
23
|
+
attr_reader response_size: Integer?
|
|
24
|
+
attr_reader uri: String?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class ResponseSizeExceededError < Error
|
|
28
|
+
def initialize: (Integer size, Integer max_size, ?uri: String?) -> void
|
|
29
|
+
attr_reader size: Integer
|
|
30
|
+
attr_reader max_size: Integer
|
|
31
|
+
attr_reader uri: String?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Input validation errors
|
|
35
|
+
class ValidationError < Error; end
|
|
36
|
+
|
|
37
|
+
class Client
|
|
38
|
+
MAX_RESPONSE_SIZE: Integer
|
|
39
|
+
VALID_SORT_ORDERS: Array[Symbol]
|
|
40
|
+
MAX_LIMIT: Integer
|
|
18
41
|
|
|
19
42
|
class Cache
|
|
20
43
|
def initialize: () -> void
|
|
@@ -59,7 +82,16 @@ module RubygemsMcp
|
|
|
59
82
|
) -> Array[Hash[Symbol, (String | nil)]]
|
|
60
83
|
|
|
61
84
|
# Get changelog for a Ruby version
|
|
62
|
-
def get_ruby_version_changelog: (String version) -> Hash[Symbol, (String | nil)] # Returns :version, :release_notes_url, :content
|
|
85
|
+
def get_ruby_version_changelog: (String version) -> Hash[Symbol, (String | nil)] # Returns :version, :release_notes_url, :content, :github_changelog
|
|
86
|
+
|
|
87
|
+
# Get Ruby roadmap information
|
|
88
|
+
def get_ruby_roadmap: () -> Hash[Symbol, Array[Hash[Symbol, (String | Integer | nil)]]] # Returns :versions array
|
|
89
|
+
|
|
90
|
+
# Get detailed roadmap information for a specific Ruby version
|
|
91
|
+
def get_ruby_version_roadmap_details: (String version) -> Hash[Symbol, (String | Array[Hash[Symbol, (String | nil)]] | nil)] # Returns :version, :version_url, :description, :issues
|
|
92
|
+
|
|
93
|
+
# Get GitHub release changelog for a Ruby version
|
|
94
|
+
def get_ruby_version_github_changelog: (String version) -> Hash[Symbol, (String | nil)] # Returns :version, :tag_name, :name, :body, :published_at, :url
|
|
63
95
|
|
|
64
96
|
# Get reverse dependencies (gems that depend on this gem)
|
|
65
97
|
def get_gem_reverse_dependencies: (String gem_name) -> Array[String]
|
|
@@ -88,6 +120,13 @@ module RubygemsMcp
|
|
|
88
120
|
?fields: Array[String]?
|
|
89
121
|
) -> Hash[Symbol, untyped]
|
|
90
122
|
|
|
123
|
+
# Get detailed information for a specific gem version (uses API v2)
|
|
124
|
+
def get_gem_version_info: (
|
|
125
|
+
String gem_name,
|
|
126
|
+
String version,
|
|
127
|
+
?fields: Array[String]?
|
|
128
|
+
) -> Hash[Symbol, untyped]
|
|
129
|
+
|
|
91
130
|
# Search for gems with pagination
|
|
92
131
|
def search_gems: (
|
|
93
132
|
String query,
|
|
@@ -138,6 +177,10 @@ module RubygemsMcp
|
|
|
138
177
|
def call: (String gem_name, ?Array[String]? fields) -> Hash[Symbol, untyped]
|
|
139
178
|
end
|
|
140
179
|
|
|
180
|
+
class GetGemVersionInfoTool < BaseTool
|
|
181
|
+
def call: (String gem_name, String version, ?Array[String]? fields) -> Hash[Symbol, untyped]
|
|
182
|
+
end
|
|
183
|
+
|
|
141
184
|
class GetGemReverseDependenciesTool < BaseTool
|
|
142
185
|
def call: (String gem_name) -> Array[String]
|
|
143
186
|
end
|
|
@@ -162,6 +205,18 @@ module RubygemsMcp
|
|
|
162
205
|
def call: (String query) -> Array[Hash[Symbol, untyped]]
|
|
163
206
|
end
|
|
164
207
|
|
|
208
|
+
class GetRubyRoadmapTool < BaseTool
|
|
209
|
+
def call: () -> Hash[Symbol, Array[Hash[Symbol, (String | Integer | nil)]]]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
class GetRubyVersionRoadmapDetailsTool < BaseTool
|
|
213
|
+
def call: (String version) -> Hash[Symbol, (String | Array[Hash[Symbol, (String | nil)]] | nil)]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
class GetRubyVersionGithubChangelogTool < BaseTool
|
|
217
|
+
def call: (String version) -> Hash[Symbol, (String | nil)]
|
|
218
|
+
end
|
|
219
|
+
|
|
165
220
|
class PopularGemsResource
|
|
166
221
|
def self.uri: () -> String
|
|
167
222
|
def self.resource_name: () -> String
|
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.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -240,6 +240,7 @@ files:
|
|
|
240
240
|
- bin/rubygems_mcp
|
|
241
241
|
- lib/rubygems_mcp.rb
|
|
242
242
|
- lib/rubygems_mcp/client.rb
|
|
243
|
+
- lib/rubygems_mcp/errors.rb
|
|
243
244
|
- lib/rubygems_mcp/server.rb
|
|
244
245
|
- lib/rubygems_mcp/version.rb
|
|
245
246
|
- sig/rubygems_mcp.rbs
|