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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1d6300f5e71b96d4ef7df1909e03c52555440cf794bc8afb46f5b1e6f75310e
4
- data.tar.gz: 1727f6cbf963a35862eec6557da0d7008ea6fac4714614d2305aca1aec93a24c
3
+ metadata.gz: 792f4e1e9055f0daca5acf09de1b71cdaa64a203808f9f71463d5cb7d67544e1
4
+ data.tar.gz: cc8a4b2af63e9b96534aae1aee6aeeb572fee2fd602d1d648d29b50c391fb9e5
5
5
  SHA512:
6
- metadata.gz: b4e6bf57c3302cac5c5c74e99b0060e12b192befec7d7007c36e81d41dd45f2892f12eb73e315a7502c29686ef38abbd0f652012ee9e310ca38eb96729590182
7
- data.tar.gz: a756f8c5da8e2a7d36e4c9e1c07427a102e9ca97fae9ba895ff41ecfa37c4449d14c26e6991b656b74686cfa36ad9bfcfa42145136f9cd2ab72fb40d505ad614
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
- [![Gem Version](https://badge.fury.io/rb/rubygems_mcp.svg?v=0.1.3)](https://badge.fury.io/rb/rubygems_mcp) [![Test Status](https://github.com/amkisko/rubygems_mcp.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/rubygems_mcp.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/rubygems_mcp.rb/graph/badge.svg?token=APQ6AK7EC9)](https://codecov.io/gh/amkisko/rubygems_mcp.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/rubygems_mcp.svg?v=0.2.0)](https://badge.fury.io/rb/rubygems_mcp) [![Test Status](https://github.com/amkisko/rubygems_mcp.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/rubygems_mcp.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/rubygems_mcp.rb/graph/badge.svg?token=APQ6AK7EC9)](https://app.codecov.io/github/amkisko/rubygems_mcp.rb) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=amkisko_rubygems_mcp.rb&metric=alert_status)](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.1 or higher** (Ruby 3.0 and earlier are not supported)
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 19 tools and 4 resources, compatible with Cursor IDE, Claude Desktop, and other MCP-enabled tools
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
- The client handles errors gracefully:
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
@@ -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
- gem_names = gem_names.map { |name| validate_gem_name(name) }
89
- gem_names.map do |name|
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.map do |version_data|
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 = URI(release_notes_url)
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
- # Cache for 24 hours
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
- http = build_http_client(uri)
601
- request = Net::HTTP::Get.new(uri)
602
- request["Accept"] = "application/vnd.github+json"
603
- request["User-Agent"] = "rubygems_mcp/#{RubygemsMcp::VERSION}"
604
-
605
- response = http.request(request)
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
- case response
608
- when Net::HTTPSuccess
609
- # Parse JSON response
610
- body = response.body
611
- return {version: version, tag_name: tag_name, name: nil, body: nil, published_at: nil, url: nil, error: "Empty response"} if body.nil? || body.empty?
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
- begin
614
- data = JSON.parse(body.force_encoding("UTF-8"))
615
- rescue JSON::ParserError => e
616
- raise CorruptedDataError.new(
617
- "Failed to parse GitHub API response: #{e.message}",
618
- original_error: e,
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
- result = {
624
- version: version,
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 = URI(changelog_uri)
813
- response = make_request(uri, parse_html: true)
814
- return {gem_name: gem_name, version: version, changelog_uri: changelog_uri, summary: nil, error: "Failed to fetch changelog"} unless response
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 (for pagination)
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, page: nil)
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
- # Apply pagination
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 < 0
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
- response = http.request(request)
1489
+ parsed = nil
1490
+ response = http.request(request) do |http_response|
1491
+ next unless http_response.is_a?(Net::HTTPSuccess)
1442
1492
 
1443
- case response
1444
- when Net::HTTPSuccess
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
- # Validate and parse response
1454
- if parse_html
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
- # Check for common crawler protection patterns
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
- # Check for common error page indicators
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
- "HTML response from #{uri} appears to be an error page",
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
@@ -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
- valid_sorts = %w[version_desc version_asc date_desc date_asc]
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
- valid_sorts = %w[version_desc version_asc date_desc date_asc]
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(:page).filled(:integer).description("Page number (1-based). If provided, overrides offset")
354
- optional(:limit).filled(:integer).description("Maximum number of results to return")
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:, page: nil, limit: nil, offset: 0)
359
- get_client.search_gems(query, page: page, limit: limit, offset: offset)
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), # Most recent 10 versions
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
@@ -1,3 +1,3 @@
1
1
  module RubygemsMcp
2
- VERSION = "0.1.4"
2
+ VERSION = "0.2.0"
3
3
  end
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
- class Cache
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.1.4
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: '0.1'
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: '0.1'
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.15'
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.15'
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.0'
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.0'
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: '0.22'
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: '0.22'
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: '3.1'
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: '3.1'
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: '3.9'
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: '3.9'
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.1'
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: 3.6.9
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: []