yardmcp 0.2.1 → 0.3.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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -2
  3. data/lib/yardmcp/version.rb +1 -1
  4. data/lib/yardmcp.rb +553 -151
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9d5b8dbd42ee1273aeeff0a838eff6163149bb15d919fad96ca2805334e278a
4
- data.tar.gz: 969fb019698cd02d4ee29c6d27c8133ecb9b9c88f804759fda894ff96093acd3
3
+ metadata.gz: 7f804ad6257e87c3c7a2e9ea805b7fb778f4f026006f5c231ab444906ad18c9a
4
+ data.tar.gz: 90177775b4c69504a1cb6a127137f9bee11b91748815f673a7bf85f0e23af9ae
5
5
  SHA512:
6
- metadata.gz: 40134673bfd201aa6e191a963f36d650e63beef01972c0b7c4e86f8d4932660ad6b529bd4755f0914d4a29357c8d80983439c539782d72837ee306e38a386c0e
7
- data.tar.gz: '015892a44b95e688079da5b935d6913df45a634f90a125735e3f644b52872bf067b05a0b2bf4952a153e4db6bb4eef3ab9303ffa4fe8bf191c7dae6fc550e300'
6
+ metadata.gz: ff475a131b82fa0a0292e0409828047089cb647d4ee0ff439420107e29970f468e5ba25394781536e31538fd21ad083548e8b83479af67f6e82bd1330a3ae719
7
+ data.tar.gz: 6b3e94a29e92f4057e327f5df3a42540b79f66977f48b981563f56bc7d66378e780932c1bbf246295ccb56bd3ba15baacc9169b27be570097d3b230e3a2caf41
data/README.md CHANGED
@@ -16,6 +16,7 @@ This is useful for building documentation browsers, code assistants, or integrat
16
16
  ## Features
17
17
 
18
18
  - **List Gems:** See all installed gems with YARD documentation
19
+ - **Build Gem Docs:** Explicitly build a local YARD index for an installed gem
19
20
  - **List Classes/Modules:** Explore all classes/modules in a gem
20
21
  - **Fetch Documentation:** Get docstrings, tags, parameters, return types, and more for any class/module/method
21
22
  - **List Children:** List constants, classes, modules, and methods under a namespace
@@ -26,6 +27,7 @@ This is useful for building documentation browsers, code assistants, or integrat
26
27
  - **Search:** Fuzzy/full-text search across all documentation
27
28
  - **Source Location:** Find the file and line number for any object
28
29
  - **Code Snippet:** Fetch the source code for any object
30
+ - **MCP Resources:** Read YARD docs and source via `yard://` resource URIs
29
31
 
30
32
  ## Installation
31
33
 
@@ -55,10 +57,20 @@ Start the server:
55
57
  yardmcp
56
58
  ```
57
59
 
60
+ Read-only query tools do not build missing YARD indexes. If a gem is installed
61
+ but has no local YARD index yet, run:
62
+
63
+ ```sh
64
+ yard gems <gemname>
65
+ ```
66
+
67
+ or call `BuildGemDocsTool` explicitly.
68
+
58
69
  ### Tool List
59
70
 
60
71
  The following tools are available (use `tools/list` to discover):
61
72
  - ListGemsTool
73
+ - BuildGemDocsTool
62
74
  - ListClassesTool
63
75
  - GetDocTool
64
76
  - ChildrenTool
@@ -70,7 +82,29 @@ The following tools are available (use `tools/list` to discover):
70
82
  - AncestorsTool
71
83
  - RelatedObjectsTool
72
84
 
73
- See the code in `lib/yardmcp.rb` for argument details and return formats.
85
+ Tool results return standard MCP text content and machine-readable data in
86
+ `structuredContent`. Tool execution failures return `isError: true` without
87
+ local stack traces.
88
+
89
+ All tools publish `outputSchema` metadata in `tools/list`.
90
+
91
+ Object-oriented tool results include `resource_uris` when `gem_name` is supplied,
92
+ so clients can pivot from a tool result to the corresponding documentation or
93
+ source resource.
94
+
95
+ ### Resources
96
+
97
+ The server exposes templated MCP resources:
98
+
99
+ - `yard://gem/{gem_name}/object/{+path}` returns JSON documentation for a YARD object.
100
+ - `yard://gem/{gem_name}/source/{+path}` returns source text for a YARD object.
101
+
102
+ Examples:
103
+
104
+ ```text
105
+ yard://gem/yard/object/YARD::Registry
106
+ yard://gem/yard/source/YARD::CodeObjects::Base#name
107
+ ```
74
108
 
75
109
  ## Development
76
110
 
@@ -96,4 +130,4 @@ See the code in `lib/yardmcp.rb` for argument details and return formats.
96
130
 
97
131
  ## License
98
132
 
99
- MIT License. See [LICENSE](LICENSE) for details.
133
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YardMCP
4
- VERSION = '0.2.1'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/yardmcp.rb CHANGED
@@ -3,21 +3,37 @@
3
3
 
4
4
  require 'fast_mcp'
5
5
  require 'json'
6
+ require 'logger'
6
7
  require 'rubygems'
7
8
  require 'yard'
8
9
  require 'singleton'
9
10
  require_relative 'yardmcp/version'
10
11
 
11
12
  # Utility class for YARD operations
12
- class YardUtils
13
+ class YardUtils # rubocop:disable Metrics/ClassLength
13
14
  include Singleton
14
15
 
16
+ MAX_SOURCE_CHARS = 20_000
17
+
18
+ class DocumentationError < StandardError; end
19
+
20
+ class AmbiguousObjectError < DocumentationError
21
+ attr_reader :path, :candidates
22
+
23
+ def initialize(path, candidates)
24
+ @path = path
25
+ @candidates = candidates
26
+ super("Multiple gems contain '#{path}'. Pass gem_name.")
27
+ end
28
+ end
29
+
15
30
  attr_reader :libraries, :logger, :object_to_gem
16
31
 
17
32
  def initialize
18
33
  @libraries = {}
19
34
  @object_to_gem = {}
20
35
  @last_loaded_gem = nil
36
+ @class_cache = {}
21
37
  @logger = Logger.new($stderr)
22
38
  @logger.level = Logger::INFO unless ENV['DEBUG']
23
39
  build_index
@@ -31,38 +47,51 @@ class YardUtils
31
47
  def load_yardoc_for_gem(gem_name)
32
48
  return if @last_loaded_gem == gem_name
33
49
 
34
- spec = libraries[gem_name].first
35
- ver = "= #{spec.version}"
36
- dir = YARD::Registry.yardoc_file_for_gem(spec.name, ver)
37
- build_docs(gem_name) unless yardoc_exists?(dir)
38
- raise "Yardoc not found for #{gem_name}" unless yardoc_exists?(dir)
50
+ spec = gem_spec!(gem_name)
51
+ dir = yardoc_path_for(spec)
52
+ raise DocumentationError, "YARD documentation is not indexed for gem '#{gem_name}'" unless yardoc_exists?(dir)
39
53
 
40
- YARD::Registry.load_yardoc(dir)
54
+ YARD::Registry.load!(dir)
41
55
  @last_loaded_gem = gem_name
42
56
  end
43
57
 
44
58
  # Ensures the correct .yardoc is loaded for the given object path
45
59
  def ensure_yardoc_loaded_for_object!(object_path)
46
- # TODO: Handle multiple gems for the same object path, use some heuristic to determine the correct gem
47
- gem_name = @object_to_gem[object_path]&.first
48
- raise "No documentation found for #{object_path}" unless gem_name
60
+ gem_names = @object_to_gem[object_path]
61
+ raise DocumentationError, "No indexed documentation contains '#{object_path}'. Pass gem_name if you know the gem." if gem_names.nil? || gem_names.empty?
62
+ raise AmbiguousObjectError.new(object_path, gem_candidates(gem_names)) if gem_names.uniq.size > 1
49
63
 
50
- load_yardoc_for_gem(gem_name)
64
+ load_yardoc_for_gem(gem_names.first)
51
65
  end
52
66
 
53
67
  # Lists all installed gems that have a .yardoc file available.
54
68
  #
55
69
  # @return [Array<String>] An array of gem names with .yardoc files.
56
70
  def list_gems
71
+ libraries.keys.select do |name|
72
+ yardoc_exists?(yardoc_path_for(gem_spec!(name)))
73
+ end.sort
74
+ end
75
+
76
+ def list_installed_gems
57
77
  libraries.keys.sort
58
78
  end
59
79
 
80
+ def gem_candidates(gem_names)
81
+ gem_names.uniq.sort.map do |gem_name|
82
+ {
83
+ gem_name:,
84
+ versions: Array(libraries[gem_name]).map { |library| library.version.to_s }.uniq.sort
85
+ }
86
+ end
87
+ end
88
+
60
89
  # Lists all classes and modules in the loaded YARD registry.
61
90
  #
62
91
  # @return [Array<String>] An array of fully qualified class/module paths.
63
92
  def list_classes(gem_name)
64
93
  load_yardoc_for_gem(gem_name)
65
- YARD::Registry.all(:class, :module).map(&:path).sort
94
+ @class_cache[gem_name] ||= YARD::Registry.all(:class, :module).map(&:path).sort
66
95
  end
67
96
 
68
97
  # Fetches documentation and metadata for a YARD object (class/module/method).
@@ -71,14 +100,7 @@ class YardUtils
71
100
  # @return [Hash] A hash containing type, name, namespace, visibility, docstring, parameters, return, and source.
72
101
  # @raise [RuntimeError] if the object is not found in the registry.
73
102
  def get_doc(path, gem_name = nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
74
- if gem_name
75
- # Load the specific gem's yardoc
76
- load_yardoc_for_gem(gem_name)
77
- else
78
- ensure_yardoc_loaded_for_object!(path)
79
- end
80
- obj = YARD::Registry.at(path)
81
- raise 'Object not found' unless obj
103
+ obj = object_for!(path, gem_name)
82
104
 
83
105
  tags = obj.tags.map do |tag|
84
106
  {
@@ -102,7 +124,7 @@ class YardUtils
102
124
  text: obj.tag('return').text
103
125
  }
104
126
  end,
105
- source: obj.respond_to?(:source) ? obj.source : nil,
127
+ source: capped_source(obj.respond_to?(:source) ? obj.source : nil),
106
128
  tags:
107
129
  }
108
130
 
@@ -121,13 +143,8 @@ class YardUtils
121
143
  # @param path [String] The YARD path of the namespace.
122
144
  # @return [Array<String>] An array of child object paths.
123
145
  # @raise [RuntimeError] if the object is not found in the registry.
124
- def children(path)
125
- ensure_yardoc_loaded_for_object!(path)
126
- obj = YARD::Registry.at(path)
127
- unless obj
128
- logger.error "Object not found: #{path}"
129
- return []
130
- end
146
+ def children(path, gem_name = nil)
147
+ obj = object_for!(path, gem_name)
131
148
  obj.respond_to?(:children) ? obj.children.map(&:path) : []
132
149
  end
133
150
 
@@ -136,13 +153,8 @@ class YardUtils
136
153
  # @param path [String] The YARD path of the class/module.
137
154
  # @return [Array<String>] An array of method paths.
138
155
  # @raise [RuntimeError] if the object is not found in the registry.
139
- def methods_list(path)
140
- ensure_yardoc_loaded_for_object!(path)
141
- obj = YARD::Registry.at(path)
142
- unless obj
143
- logger.error "Object not found: #{path}"
144
- return []
145
- end
156
+ def methods_list(path, gem_name = nil)
157
+ obj = object_for!(path, gem_name)
146
158
  obj.respond_to?(:meths) ? obj.meths.map(&:path) : []
147
159
  end
148
160
 
@@ -151,16 +163,11 @@ class YardUtils
151
163
  # @param path [String] The YARD path of the class/module.
152
164
  # @return [Hash] A hash with :superclass (String or nil), :included_modules (Array<String>), and :mixins (Array<String>).
153
165
  # @raise [RuntimeError] if the object is not found in the registry.
154
- def hierarchy(path) # rubocop:disable Metrics/CyclomaticComplexity
155
- ensure_yardoc_loaded_for_object!(path)
156
- obj = YARD::Registry.at(path)
157
- unless obj
158
- logger.error "Object not found: #{path}"
159
- return []
160
- end
166
+ def hierarchy(path, gem_name = nil)
167
+ obj = object_for!(path, gem_name)
161
168
  {
162
169
  superclass: obj.respond_to?(:superclass) && obj.superclass ? obj.superclass.path : nil,
163
- included_modules: obj.respond_to?(:included_modules) ? obj.included_modules.map(&:path) : [],
170
+ included_modules: obj.respond_to?(:mixins) ? obj.mixins.map(&:path) : [],
164
171
  mixins: obj.respond_to?(:mixins) ? obj.mixins.map(&:path) : []
165
172
  }
166
173
  end
@@ -169,13 +176,8 @@ class YardUtils
169
176
  #
170
177
  # @param path [String] The YARD path of the class/module.
171
178
  # @return [Array<String>] An array of ancestor paths.
172
- def ancestors(path)
173
- ensure_yardoc_loaded_for_object!(path)
174
- obj = YARD::Registry.at(path)
175
- unless obj
176
- logger.error "Object not found: #{path}"
177
- return []
178
- end
179
+ def ancestors(path, gem_name = nil)
180
+ obj = object_for!(path, gem_name)
179
181
  obj.respond_to?(:inheritance_tree) ? obj.inheritance_tree(true).map(&:path) : []
180
182
  end
181
183
 
@@ -183,17 +185,13 @@ class YardUtils
183
185
  #
184
186
  # @param path [String] The YARD path of the class/module.
185
187
  # @return [Hash] A hash with :included_modules, :mixins, :subclasses.
186
- def related_objects(path) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
187
- ensure_yardoc_loaded_for_object!(path)
188
- obj = YARD::Registry.at(path)
189
- unless obj
190
- logger.error "Object not found: #{path}"
191
- return {}
192
- end
188
+ def related_objects(path, gem_name = nil)
189
+ obj = object_for!(path, gem_name)
193
190
  subclasses = YARD::Registry.all(:class).select { |c| c.superclass && c.superclass.path == obj.path }.map(&:path)
191
+ mixins_list = obj.respond_to?(:mixins) ? obj.mixins.map(&:path) : []
194
192
  {
195
- included_modules: obj.respond_to?(:included_modules) ? obj.included_modules.map(&:path) : [],
196
- mixins: obj.respond_to?(:mixins) ? obj.mixins.map(&:path) : [],
193
+ included_modules: mixins_list,
194
+ mixins: mixins_list,
197
195
  subclasses:
198
196
  }
199
197
  end
@@ -202,32 +200,12 @@ class YardUtils
202
200
  #
203
201
  # @param query [String] The search query string.
204
202
  # @return [Array<Hash>] An array of hashes with :path and :score for matching object paths, ranked by relevance.
205
- def search(query) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
203
+ def search(query, gem_name = nil, limit: 25, offset: 0)
206
204
  require 'levenshtein' unless defined?(Levenshtein)
207
- results = []
208
- YARD::Registry.all.each do |obj|
209
- path = obj.path.to_s
210
- doc = obj.docstring.to_s
211
- next if path.empty?
212
-
213
- score = nil
214
- if path == query
215
- score = 100
216
- elsif path.start_with?(query)
217
- score = 90
218
- elsif path.include?(query)
219
- score = 80
220
- elsif doc.include?(query)
221
- score = 60
222
- else
223
- # Fuzzy match: allow up to 2 edits for short queries, 3 for longer
224
- dist = Levenshtein.distance(path.downcase, query.downcase)
225
- score = 70 - dist if dist <= [2, query.length / 3].max
226
- end
227
- results << { path:, score: } if score
228
- end
205
+ candidates = gem_name ? loaded_objects_for_search(gem_name) : indexed_paths_for_search
206
+ results = candidates.filter_map { |candidate| score_search_candidate(candidate, query) }
229
207
  # Sort by score descending, then alphabetically
230
- results.sort_by { |r| [-r[:score], r[:path]] }
208
+ results.sort_by { |r| [-r[:score], r[:path]] }.slice(offset, limit) || []
231
209
  end
232
210
 
233
211
  # Returns the source file and line number for a YARD object (class/module/method).
@@ -235,13 +213,8 @@ class YardUtils
235
213
  # @param path [String] The YARD path (e.g., 'String#upcase').
236
214
  # @return [Hash] A hash with :file (String or nil) and :line (Integer or nil).
237
215
  # @raise [RuntimeError] if the object is not found in the registry.
238
- def source_location(path)
239
- ensure_yardoc_loaded_for_object!(path)
240
- obj = YARD::Registry.at(path)
241
- unless obj
242
- logger.error "Object not found: #{path}"
243
- return []
244
- end
216
+ def source_location(path, gem_name = nil)
217
+ obj = object_for!(path, gem_name)
245
218
  {
246
219
  file: obj.respond_to?(:file) ? obj.file : nil,
247
220
  line: obj.respond_to?(:line) ? obj.line : nil
@@ -253,22 +226,85 @@ class YardUtils
253
226
  # @param path [String] The YARD path (e.g., 'String#upcase').
254
227
  # @return [String, nil] The code snippet if available, otherwise nil.
255
228
  # @raise [RuntimeError] if the object is not found in the registry.
256
- def code_snippet(path)
257
- ensure_yardoc_loaded_for_object!(path)
258
- obj = YARD::Registry.at(path)
259
- unless obj
260
- logger.error "Object not found: #{path}"
261
- return []
262
- end
263
- obj.respond_to?(:source) ? obj.source : nil
229
+ def code_snippet(path, gem_name = nil, max_chars: MAX_SOURCE_CHARS)
230
+ obj = object_for!(path, gem_name)
231
+ capped_source(obj.respond_to?(:source) ? obj.source : nil, max_chars:)
232
+ end
233
+
234
+ def build_docs(gem_name)
235
+ gem_spec!(gem_name)
236
+ logger.info "Building docs for #{gem_name}..."
237
+ YARD::CLI::Gems.new.run(gem_name)
238
+ @class_cache.delete(gem_name)
239
+ @last_loaded_gem = nil
240
+ load_yardoc_for_gem(gem_name)
241
+ merge_gem_results([collect_current_gem_objects(gem_name)])
242
+ true
264
243
  end
265
244
 
266
245
  private
267
246
 
247
+ def gem_spec!(gem_name)
248
+ spec = libraries[gem_name]&.first
249
+ raise DocumentationError, "Gem '#{gem_name}' is not installed" unless spec
250
+
251
+ spec
252
+ end
253
+
254
+ def yardoc_path_for(spec)
255
+ YARD::Registry.yardoc_file_for_gem(spec.name, "= #{spec.version}")
256
+ end
257
+
268
258
  def yardoc_exists?(dir)
269
259
  dir && File.directory?(dir)
270
260
  end
271
261
 
262
+ def object_for!(path, gem_name = nil)
263
+ gem_name ? load_yardoc_for_gem(gem_name) : ensure_yardoc_loaded_for_object!(path)
264
+ obj = YARD::Registry.at(path)
265
+ raise DocumentationError, "Object '#{path}' was not found" unless obj
266
+
267
+ obj
268
+ end
269
+
270
+ def capped_source(source, max_chars: MAX_SOURCE_CHARS)
271
+ return source unless source && source.length > max_chars
272
+
273
+ "#{source.byteslice(0, max_chars)}\n... truncated at #{max_chars} bytes"
274
+ end
275
+
276
+ def loaded_objects_for_search(gem_name)
277
+ load_yardoc_for_gem(gem_name)
278
+ YARD::Registry.all.map do |obj|
279
+ { path: obj.path.to_s, docstring: obj.docstring.to_s }
280
+ end
281
+ end
282
+
283
+ def indexed_paths_for_search
284
+ @object_to_gem.keys.map { |path| { path:, docstring: '' } }
285
+ end
286
+
287
+ def score_search_candidate(candidate, query)
288
+ path = candidate[:path]
289
+ doc = candidate[:docstring]
290
+ return if path.empty?
291
+
292
+ score = search_score(path, doc, query)
293
+ { path:, score: } if score
294
+ end
295
+
296
+ def search_score(path, doc, query)
297
+ query_downcase = query.downcase
298
+ path_downcase = path.downcase
299
+ return 100 if path == query
300
+ return 90 if path_downcase.start_with?(query_downcase)
301
+ return 80 if path_downcase.include?(query_downcase)
302
+ return 60 if doc.downcase.include?(query_downcase)
303
+
304
+ distance = Levenshtein.distance(path_downcase, query_downcase)
305
+ distance <= [2, query.length / 3].max ? 70 - distance : nil
306
+ end
307
+
272
308
  # Build an index mapping object paths to gem names
273
309
  def build_index # rubocop:disable Metrics/AbcSize
274
310
  logger.info 'Building index...'
@@ -296,7 +332,7 @@ class YardUtils
296
332
  def merge_gem_results(results)
297
333
  results.each do |gem_objects|
298
334
  gem_objects.each do |obj_path, gem_names|
299
- (@object_to_gem[obj_path] ||= []).concat(gem_names)
335
+ (@object_to_gem[obj_path] ||= []).concat(gem_names).uniq!
300
336
  end
301
337
  end
302
338
  end
@@ -311,7 +347,10 @@ class YardUtils
311
347
  return {}
312
348
  end
313
349
 
314
- # Collect all objects for this gem
350
+ collect_current_gem_objects(gem_name)
351
+ end
352
+
353
+ def collect_current_gem_objects(gem_name)
315
354
  gem_objects = {}
316
355
  YARD::Registry.all.each do |obj|
317
356
  logger.debug "Adding #{obj.path} to #{gem_name}"
@@ -319,148 +358,496 @@ class YardUtils
319
358
  end
320
359
  gem_objects
321
360
  end
361
+ end
322
362
 
323
- def build_docs(gem_name)
324
- logger.info "Building docs for #{gem_name}..."
325
- YARD::CLI::Gems.new.run(gem_name)
363
+ module YardSchemas
364
+ RESOURCE_URIS_SCHEMA = {
365
+ type: 'object',
366
+ properties: {
367
+ object: { type: 'string' },
368
+ source: { type: 'string' }
369
+ },
370
+ required: %w[object source]
371
+ }.freeze
372
+
373
+ def self.array_schema(name)
374
+ {
375
+ type: 'object',
376
+ properties: {
377
+ name => { type: 'array', items: { type: 'string' } }
378
+ },
379
+ required: [name.to_s]
380
+ }.freeze
381
+ end
382
+
383
+ def self.path_array_schema(name)
384
+ {
385
+ type: 'object',
386
+ properties: {
387
+ path: { type: 'string' },
388
+ gem_name: { type: %w[string null] },
389
+ resource_uris: RESOURCE_URIS_SCHEMA,
390
+ name => { type: 'array', items: { type: 'string' } }
391
+ },
392
+ required: %w[path resource_uris] + [name.to_s]
393
+ }.freeze
394
+ end
395
+
396
+ LIST_GEMS_SCHEMA = array_schema(:gems)
397
+
398
+ LIST_CLASSES_SCHEMA = {
399
+ type: 'object',
400
+ properties: {
401
+ gem_name: { type: 'string' },
402
+ classes: { type: 'array', items: { type: 'string' } }
403
+ },
404
+ required: %w[gem_name classes]
405
+ }.freeze
406
+
407
+ DOC_OBJECT_SCHEMA = {
408
+ type: 'object',
409
+ properties: {
410
+ path: { type: 'string' },
411
+ gem_name: { type: %w[string null] },
412
+ resource_uris: RESOURCE_URIS_SCHEMA,
413
+ document: {
414
+ type: 'object',
415
+ properties: {
416
+ type: { type: 'string' },
417
+ name: { type: 'string' },
418
+ namespace: { type: %w[string null] },
419
+ visibility: { type: %w[string null] },
420
+ docstring: { type: 'string' },
421
+ parameters: { type: %w[array null] },
422
+ return: { type: %w[object null] },
423
+ source: { type: %w[string null] },
424
+ tags: { type: 'array' }
425
+ },
426
+ required: %w[type name docstring tags]
427
+ }
428
+ },
429
+ required: %w[resource_uris document]
430
+ }.freeze
431
+
432
+ CHILDREN_SCHEMA = path_array_schema(:children)
433
+
434
+ METHODS_SCHEMA = path_array_schema(:methods)
435
+
436
+ SOURCE_LOCATION_SCHEMA = {
437
+ type: 'object',
438
+ properties: {
439
+ path: { type: 'string' },
440
+ gem_name: { type: %w[string null] },
441
+ resource_uris: RESOURCE_URIS_SCHEMA,
442
+ source_location: {
443
+ type: 'object',
444
+ properties: {
445
+ file: { type: %w[string null] },
446
+ line: { type: %w[integer null] }
447
+ },
448
+ required: %w[file line]
449
+ }
450
+ },
451
+ required: %w[path resource_uris source_location]
452
+ }.freeze
453
+
454
+ HIERARCHY_SCHEMA = {
455
+ type: 'object',
456
+ properties: {
457
+ path: { type: 'string' },
458
+ gem_name: { type: %w[string null] },
459
+ resource_uris: RESOURCE_URIS_SCHEMA,
460
+ hierarchy: {
461
+ type: 'object',
462
+ properties: {
463
+ superclass: { type: %w[string null] },
464
+ included_modules: { type: 'array', items: { type: 'string' } },
465
+ mixins: { type: 'array', items: { type: 'string' } }
466
+ },
467
+ required: %w[superclass included_modules mixins]
468
+ }
469
+ },
470
+ required: %w[path resource_uris hierarchy]
471
+ }.freeze
472
+
473
+ SEARCH_SCHEMA = {
474
+ type: 'object',
475
+ properties: {
476
+ query: { type: 'string' },
477
+ gem_name: { type: %w[string null] },
478
+ limit: { type: 'integer' },
479
+ offset: { type: 'integer' },
480
+ results: {
481
+ type: 'array',
482
+ items: {
483
+ type: 'object',
484
+ properties: {
485
+ path: { type: 'string' },
486
+ score: { type: 'integer' }
487
+ },
488
+ required: %w[path score]
489
+ }
490
+ }
491
+ },
492
+ required: %w[query limit offset results]
493
+ }.freeze
494
+
495
+ CODE_SNIPPET_SCHEMA = {
496
+ type: 'object',
497
+ properties: {
498
+ path: { type: 'string' },
499
+ gem_name: { type: %w[string null] },
500
+ resource_uris: RESOURCE_URIS_SCHEMA,
501
+ snippet: { type: 'string' }
502
+ },
503
+ required: %w[path resource_uris snippet]
504
+ }.freeze
505
+
506
+ ANCESTORS_SCHEMA = path_array_schema(:ancestors)
507
+
508
+ RELATED_OBJECTS_SCHEMA = {
509
+ type: 'object',
510
+ properties: {
511
+ path: { type: 'string' },
512
+ gem_name: { type: %w[string null] },
513
+ resource_uris: RESOURCE_URIS_SCHEMA,
514
+ related_objects: {
515
+ type: 'object',
516
+ properties: {
517
+ included_modules: { type: 'array', items: { type: 'string' } },
518
+ mixins: { type: 'array', items: { type: 'string' } },
519
+ subclasses: { type: 'array', items: { type: 'string' } }
520
+ },
521
+ required: %w[included_modules mixins subclasses]
522
+ }
523
+ },
524
+ required: %w[path resource_uris related_objects]
525
+ }.freeze
526
+
527
+ BUILD_GEM_DOCS_SCHEMA = {
528
+ type: 'object',
529
+ properties: {
530
+ gem_name: { type: 'string' },
531
+ indexed: { type: 'boolean' }
532
+ },
533
+ required: %w[gem_name indexed]
534
+ }.freeze
535
+ end
536
+
537
+ module YardMcpToolListOutputSchema
538
+ private
539
+
540
+ def handle_tools_list(id)
541
+ tools_list = @tools.values.map do |tool|
542
+ tool_info = {
543
+ name: tool.tool_name,
544
+ description: tool.description || '',
545
+ inputSchema: tool.input_schema_to_json || { type: 'object', properties: {}, required: [] }
546
+ }
547
+ tool_info[:outputSchema] = tool.output_schema if tool.respond_to?(:output_schema) && tool.output_schema
548
+ tool_info[:annotations] = camel_case_annotations(tool.annotations) unless tool.annotations.empty?
549
+ tool_info
550
+ end
551
+
552
+ send_result({ tools: tools_list }, id)
553
+ end
554
+
555
+ def camel_case_annotations(annotations)
556
+ annotations.to_h do |key, value|
557
+ camel_key = key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
558
+ [camel_key, value]
559
+ end
560
+ end
561
+ end
562
+
563
+ FastMcp::Server.prepend(YardMcpToolListOutputSchema)
564
+
565
+ class YardTool < FastMcp::Tool
566
+ class << self
567
+ attr_reader :output_schema
568
+
569
+ def returns(schema)
570
+ @output_schema = schema
571
+ end
572
+ end
573
+
574
+ private
575
+
576
+ def ok(structured_content, text: nil)
577
+ {
578
+ content: [{ type: 'text', text: text || JSON.pretty_generate(structured_content) }],
579
+ structuredContent: structured_content,
580
+ isError: false
581
+ }
582
+ end
583
+
584
+ def with_yard_errors
585
+ yield
586
+ rescue YardUtils::AmbiguousObjectError => e
587
+ {
588
+ content: [{ type: 'text', text: e.message }],
589
+ structuredContent: {
590
+ error: 'ambiguous_object',
591
+ path: e.path,
592
+ candidates: e.candidates
593
+ },
594
+ isError: true
595
+ }
596
+ rescue YardUtils::DocumentationError, ArgumentError => e
597
+ {
598
+ content: [{ type: 'text', text: e.message }],
599
+ structuredContent: {
600
+ error: 'documentation_error',
601
+ message: e.message
602
+ },
603
+ isError: true
604
+ }
605
+ end
606
+
607
+ def resource_uris(gem_name, path)
608
+ return nil unless gem_name
609
+
610
+ {
611
+ object: "yard://gem/#{gem_name}/object/#{path}",
612
+ source: "yard://gem/#{gem_name}/source/#{path}"
613
+ }
614
+ end
615
+ end
616
+
617
+ class YardObjectResource < FastMcp::Resource
618
+ uri 'yard://gem/{gem_name}/object/{+path}'
619
+ resource_name 'YARD object documentation'
620
+ description 'Read structured YARD documentation for a gem object'
621
+ mime_type 'application/json'
622
+
623
+ def content
624
+ JSON.pretty_generate(document: YardUtils.instance.get_doc(params[:path], params[:gem_name]))
625
+ rescue YardUtils::DocumentationError, ArgumentError => e
626
+ JSON.pretty_generate(error: e.message)
627
+ end
628
+ end
629
+
630
+ class YardSourceResource < FastMcp::Resource
631
+ uri 'yard://gem/{gem_name}/source/{+path}'
632
+ resource_name 'YARD object source'
633
+ description 'Read source code for a documented YARD object'
634
+ mime_type 'text/plain'
635
+
636
+ def content
637
+ YardUtils.instance.code_snippet(params[:path], params[:gem_name]).to_s
638
+ rescue YardUtils::DocumentationError, ArgumentError => e
639
+ "Error: #{e.message}"
326
640
  end
327
641
  end
328
642
 
329
643
  # Tool: List all gems with .yardoc files
330
- class ListGemsTool < FastMcp::Tool
644
+ class ListGemsTool < YardTool
331
645
  description 'List all installed gems that have a .yardoc file'
646
+ annotations(title: 'List all installed gems', read_only_hint: true)
647
+ returns YardSchemas::LIST_GEMS_SCHEMA
332
648
 
333
649
  def call
334
650
  gems = YardUtils.instance.list_gems
335
- { content: gems.map { |gem| { text: gem, type: 'gem' } } }
651
+ ok({ gems: }, text: gems.join("\n"))
336
652
  end
337
653
  end
338
654
 
339
655
  # Tool: List all classes and modules in the loaded YARD registry
340
- class ListClassesTool < FastMcp::Tool
656
+ class ListClassesTool < YardTool
341
657
  description 'List all classes and modules in the loaded YARD registry'
658
+ annotations(title: 'List all classes and modules', read_only_hint: true)
659
+ returns YardSchemas::LIST_CLASSES_SCHEMA
342
660
  arguments do
343
661
  required(:gem_name).filled(:string).description('Name of the gem to list classes for')
344
662
  end
345
663
 
346
664
  def call(gem_name:)
347
- classes = YardUtils.instance.list_classes(gem_name)
348
- { content: classes.map { |cls| { text: cls, type: 'class' } } }
665
+ with_yard_errors do
666
+ classes = YardUtils.instance.list_classes(gem_name)
667
+ ok({ gem_name:, classes: }, text: classes.join("\n"))
668
+ end
349
669
  end
350
670
  end
351
671
 
352
672
  # Tool: Fetch documentation for a YARD object
353
- class GetDocTool < FastMcp::Tool
673
+ class GetDocTool < YardTool
354
674
  description 'Fetch documentation and metadata for a class/module/method from YARD'
675
+ annotations(title: 'Fetch documentation', read_only_hint: true)
676
+ returns YardSchemas::DOC_OBJECT_SCHEMA
355
677
  arguments do
356
678
  required(:path).filled(:string).description("YARD path (e.g. 'String#upcase')")
357
679
  optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
358
680
  end
359
681
 
360
682
  def call(path:, gem_name: nil)
361
- { content: [YardUtils.instance.get_doc(path, gem_name)] }
683
+ with_yard_errors do
684
+ doc = YardUtils.instance.get_doc(path, gem_name)
685
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), document: doc }, text: JSON.pretty_generate(doc))
686
+ end
362
687
  end
363
688
  end
364
689
 
365
690
  # Tool: List children under a namespace
366
- class ChildrenTool < FastMcp::Tool
691
+ class ChildrenTool < YardTool
367
692
  description 'List children under a namespace (class/module) in YARD'
693
+ annotations(title: 'List children under a namespace', read_only_hint: true)
694
+ returns YardSchemas::CHILDREN_SCHEMA
368
695
  arguments do
369
696
  required(:path).filled(:string).description('YARD path of the namespace')
697
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
370
698
  end
371
699
 
372
- def call(path:)
373
- children = YardUtils.instance.children(path)
374
- { content: children.map { |child| { text: child, type: 'child' } } }
700
+ def call(path:, gem_name: nil)
701
+ with_yard_errors do
702
+ children = YardUtils.instance.children(path, gem_name)
703
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), children: }, text: children.join("\n"))
704
+ end
375
705
  end
376
706
  end
377
707
 
378
708
  # Tool: List methods for a class/module
379
- class MethodsListTool < FastMcp::Tool
709
+ class MethodsListTool < YardTool
380
710
  description 'List methods for a class/module in YARD'
711
+ annotations(title: 'List methods for a class/module', read_only_hint: true)
712
+ returns YardSchemas::METHODS_SCHEMA
381
713
  arguments do
382
714
  required(:path).filled(:string).description('YARD path of the class/module')
715
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
383
716
  end
384
717
 
385
- def call(path:)
386
- methods = YardUtils.instance.methods_list(path)
387
- { content: methods.map { |method| { text: method, type: 'method' } } }
718
+ def call(path:, gem_name: nil)
719
+ with_yard_errors do
720
+ methods = YardUtils.instance.methods_list(path, gem_name)
721
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), methods: }, text: methods.join("\n"))
722
+ end
388
723
  end
389
724
  end
390
725
 
391
726
  # Tool: Return inheritance and inclusion info
392
- class HierarchyTool < FastMcp::Tool
727
+ class HierarchyTool < YardTool
393
728
  description 'Return inheritance and inclusion info for a class/module in YARD'
729
+ annotations(title: 'Return inheritance and inclusion info', read_only_hint: true)
730
+ returns YardSchemas::HIERARCHY_SCHEMA
394
731
  arguments do
395
732
  required(:path).filled(:string).description('YARD path of the class/module')
733
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
396
734
  end
397
735
 
398
- def call(path:)
399
- { content: YardUtils.instance.hierarchy(path) }
736
+ def call(path:, gem_name: nil)
737
+ with_yard_errors do
738
+ hierarchy = YardUtils.instance.hierarchy(path, gem_name)
739
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), hierarchy: })
740
+ end
400
741
  end
401
742
  end
402
743
 
403
744
  # Tool: Perform fuzzy/full-text search
404
- class SearchTool < FastMcp::Tool
745
+ class SearchTool < YardTool
405
746
  description 'Perform fuzzy/full-text search in YARD registry'
747
+ annotations(title: 'Perform fuzzy/full-text search', read_only_hint: true)
748
+ returns YardSchemas::SEARCH_SCHEMA
406
749
  arguments do
407
750
  required(:query).filled(:string).description('Search query')
751
+ optional(:gem_name).filled(:string).description('Optional gem name to search docstrings and paths within')
752
+ optional(:limit).filled(:integer, gt?: 0, lteq?: 100).description('Maximum number of results to return')
753
+ optional(:offset).filled(:integer, gteq?: 0).description('Number of results to skip')
408
754
  end
409
755
 
410
- def call(query:)
411
- # Enhanced search: ranked, fuzzy, and full-text
412
- results = YardUtils.instance.search(query)
413
- { content: results.map { |result| { text: result[:path], score: result[:score], type: 'search_result' } } }
756
+ def call(query:, gem_name: nil, limit: 25, offset: 0)
757
+ with_yard_errors do
758
+ results = YardUtils.instance.search(query, gem_name, limit:, offset:)
759
+ ok({ query:, gem_name:, limit:, offset:, results: }, text: results.map { |result| "#{result[:score]}\t#{result[:path]}" }.join("\n"))
760
+ end
414
761
  end
415
762
  end
416
763
 
417
764
  # Tool: Fetch source file and line number for a YARD object
418
- class SourceLocationTool < FastMcp::Tool
765
+ class SourceLocationTool < YardTool
419
766
  description 'Fetch the source file and line number for a class/module/method from YARD'
767
+ annotations(title: 'Fetch the source file and line number', read_only_hint: true)
768
+ returns YardSchemas::SOURCE_LOCATION_SCHEMA
420
769
  arguments do
421
770
  required(:path).filled(:string).description("YARD path (e.g. 'String#upcase')")
771
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
422
772
  end
423
773
 
424
- def call(path:)
425
- { content: YardUtils.instance.source_location(path) }
774
+ def call(path:, gem_name: nil)
775
+ with_yard_errors do
776
+ location = YardUtils.instance.source_location(path, gem_name)
777
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), source_location: location })
778
+ end
426
779
  end
427
780
  end
428
781
 
429
782
  # Tool: Fetch code snippet for a YARD object from installed gems
430
- class CodeSnippetTool < FastMcp::Tool
783
+ class CodeSnippetTool < YardTool
431
784
  description 'Fetch the code snippet for a class/module/method from installed gems using YARD'
785
+ annotations(title: 'Fetch the code snippet', read_only_hint: true)
786
+ returns YardSchemas::CODE_SNIPPET_SCHEMA
432
787
  arguments do
433
788
  required(:path).filled(:string).description("YARD path (e.g. 'String#upcase')")
789
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
790
+ optional(:max_chars).filled(:integer, gt?: 0, lteq?: 100_000).description('Maximum number of source characters to return')
434
791
  end
435
792
 
436
- def call(path:)
437
- snippet = YardUtils.instance.code_snippet(path)
438
- { content: { text: snippet, type: 'code_snippet' } }
793
+ def call(path:, gem_name: nil, max_chars: YardUtils::MAX_SOURCE_CHARS)
794
+ with_yard_errors do
795
+ snippet = YardUtils.instance.code_snippet(path, gem_name, max_chars:)
796
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), snippet: }, text: snippet.to_s)
797
+ end
439
798
  end
440
799
  end
441
800
 
442
801
  # Tool: Fetch the full ancestor chain (superclasses and included modules) for a class/module in YARD
443
- class AncestorsTool < FastMcp::Tool
802
+ class AncestorsTool < YardTool
444
803
  description 'Fetch the full ancestor chain (superclasses and included modules) for a class/module in YARD'
804
+ annotations(title: 'Fetch the full ancestor chain', read_only_hint: true)
805
+ returns YardSchemas::ANCESTORS_SCHEMA
445
806
  arguments do
446
807
  required(:path).filled(:string).description('YARD path of the class/module')
808
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
447
809
  end
448
810
 
449
- def call(path:)
450
- ancestors = YardUtils.instance.ancestors(path)
451
- { content: ancestors.map { |ancestor| { text: ancestor, type: 'ancestor' } } }
811
+ def call(path:, gem_name: nil)
812
+ with_yard_errors do
813
+ ancestors = YardUtils.instance.ancestors(path, gem_name)
814
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), ancestors: }, text: ancestors.join("\n"))
815
+ end
452
816
  end
453
817
  end
454
818
 
455
819
  # Tool: List related objects: included modules, mixins, and subclasses for a class/module in YARD
456
- class RelatedObjectsTool < FastMcp::Tool
820
+ class RelatedObjectsTool < YardTool
457
821
  description 'List related objects: included modules, mixins, and subclasses for a class/module in YARD'
822
+ annotations(title: 'List related objects', read_only_hint: true)
823
+ returns YardSchemas::RELATED_OBJECTS_SCHEMA
458
824
  arguments do
459
825
  required(:path).filled(:string).description('YARD path of the class/module')
826
+ optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
460
827
  end
461
828
 
462
- def call(path:)
463
- { content: YardUtils.instance.related_objects(path) }
829
+ def call(path:, gem_name: nil)
830
+ with_yard_errors do
831
+ related = YardUtils.instance.related_objects(path, gem_name)
832
+ ok({ path:, gem_name:, resource_uris: resource_uris(gem_name, path), related_objects: related })
833
+ end
834
+ end
835
+ end
836
+
837
+ # Tool: Explicitly build YARD docs for an installed gem
838
+ class BuildGemDocsTool < YardTool
839
+ description 'Build the local YARD documentation index for an installed gem'
840
+ annotations(title: 'Build YARD docs for a gem', read_only_hint: false)
841
+ returns YardSchemas::BUILD_GEM_DOCS_SCHEMA
842
+ arguments do
843
+ required(:gem_name).filled(:string).description('Name of the installed gem to index')
844
+ end
845
+
846
+ def call(gem_name:)
847
+ with_yard_errors do
848
+ YardUtils.instance.build_docs(gem_name)
849
+ ok({ gem_name:, indexed: true }, text: "Indexed #{gem_name}")
850
+ end
464
851
  end
465
852
  end
466
853
 
@@ -468,19 +855,34 @@ module YardMCP
468
855
  def self.start_server(preload: true)
469
856
  YardUtils.instance if preload
470
857
  server = FastMcp::Server.new(name: 'yard-mcp-server', version: YardMCP::VERSION)
471
- server.register_tool(ListGemsTool)
472
- server.register_tool(ListClassesTool)
473
- server.register_tool(GetDocTool)
474
- server.register_tool(ChildrenTool)
475
- server.register_tool(MethodsListTool)
476
- server.register_tool(HierarchyTool)
477
- server.register_tool(SearchTool)
478
- server.register_tool(SourceLocationTool)
479
- server.register_tool(CodeSnippetTool)
480
- server.register_tool(AncestorsTool)
481
- server.register_tool(RelatedObjectsTool)
858
+ server.capabilities.clear
859
+ server.capabilities[:tools] = {}
860
+ server.capabilities[:resources] = {}
861
+ register_tools(server)
862
+ register_resources(server)
482
863
  server.start
483
864
  end
865
+
866
+ def self.register_tools(server)
867
+ server.register_tools(
868
+ ListGemsTool,
869
+ ListClassesTool,
870
+ GetDocTool,
871
+ ChildrenTool,
872
+ MethodsListTool,
873
+ HierarchyTool,
874
+ SearchTool,
875
+ SourceLocationTool,
876
+ CodeSnippetTool,
877
+ AncestorsTool,
878
+ RelatedObjectsTool,
879
+ BuildGemDocsTool
880
+ )
881
+ end
882
+
883
+ def self.register_resources(server)
884
+ server.register_resources(YardObjectResource, YardSourceResource)
885
+ end
484
886
  end
485
887
 
486
888
  YardMCP.start_server(preload: true) if __FILE__ == $PROGRAM_NAME
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yardmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Shterenzon
@@ -82,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
82
  - !ruby/object:Gem::Version
83
83
  version: '0'
84
84
  requirements: []
85
- rubygems_version: 3.6.9
85
+ rubygems_version: 4.0.10
86
86
  specification_version: 4
87
87
  summary: Programmable server for Ruby gem/YARD documentation via FastMCP.
88
88
  test_files: []