yardmcp 0.2.2 → 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 +536 -152
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d10bb618f1bd998034becf18059f3dee35e41437da51f88c88d2647cd8d119bb
4
- data.tar.gz: 2c14c6fbccd58cac7ec149f401c607ddcbd4c66fbc3f83dada0640b6368d1a04
3
+ metadata.gz: 7f804ad6257e87c3c7a2e9ea805b7fb778f4f026006f5c231ab444906ad18c9a
4
+ data.tar.gz: 90177775b4c69504a1cb6a127137f9bee11b91748815f673a7bf85f0e23af9ae
5
5
  SHA512:
6
- metadata.gz: a872f2d3873ddcd8fe42a9088df79fc5ea939750515c93ddfa40fd49930d57a094b0cb43e0a862a32412918fd577f323450d2fa5291fd059189075bd8c1233ec
7
- data.tar.gz: 1ff7d77c17dde0652bb185481be516ebe757b4ee0aea1b43527ada6707078b47d2a32537d1410a813fc9f3600b7e252c58fec0c68a96328df7317cf11e4d505f
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.2'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/yardmcp.rb CHANGED
@@ -3,6 +3,7 @@
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'
@@ -12,12 +13,27 @@ require_relative 'yardmcp/version'
12
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,24 +47,21 @@ class YardUtils # rubocop:disable Metrics/ClassLength
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)
41
- YARD::Registry.load_all
54
+ YARD::Registry.load!(dir)
42
55
  @last_loaded_gem = gem_name
43
56
  end
44
57
 
45
58
  # Ensures the correct .yardoc is loaded for the given object path
46
59
  def ensure_yardoc_loaded_for_object!(object_path)
47
- # TODO: Handle multiple gems for the same object path, use some heuristic to determine the correct gem
48
- gem_name = @object_to_gem[object_path]&.first
49
- 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
50
63
 
51
- load_yardoc_for_gem(gem_name)
64
+ load_yardoc_for_gem(gem_names.first)
52
65
  end
53
66
 
54
67
  # Lists all installed gems that have a .yardoc file available.
@@ -56,19 +69,29 @@ class YardUtils # rubocop:disable Metrics/ClassLength
56
69
  # @return [Array<String>] An array of gem names with .yardoc files.
57
70
  def list_gems
58
71
  libraries.keys.select do |name|
59
- lib = libraries[name].first
60
- ver = "= #{lib.version}"
61
- dir = YARD::Registry.yardoc_file_for_gem(name, ver)
62
- dir && File.directory?(dir)
72
+ yardoc_exists?(yardoc_path_for(gem_spec!(name)))
63
73
  end.sort
64
74
  end
65
75
 
76
+ def list_installed_gems
77
+ libraries.keys.sort
78
+ end
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
+
66
89
  # Lists all classes and modules in the loaded YARD registry.
67
90
  #
68
91
  # @return [Array<String>] An array of fully qualified class/module paths.
69
92
  def list_classes(gem_name)
70
93
  load_yardoc_for_gem(gem_name)
71
- YARD::Registry.all(:class, :module).map(&:path).sort
94
+ @class_cache[gem_name] ||= YARD::Registry.all(:class, :module).map(&:path).sort
72
95
  end
73
96
 
74
97
  # Fetches documentation and metadata for a YARD object (class/module/method).
@@ -77,14 +100,7 @@ class YardUtils # rubocop:disable Metrics/ClassLength
77
100
  # @return [Hash] A hash containing type, name, namespace, visibility, docstring, parameters, return, and source.
78
101
  # @raise [RuntimeError] if the object is not found in the registry.
79
102
  def get_doc(path, gem_name = nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
80
- if gem_name
81
- # Load the specific gem's yardoc
82
- load_yardoc_for_gem(gem_name)
83
- else
84
- ensure_yardoc_loaded_for_object!(path)
85
- end
86
- obj = YARD::Registry.at(path)
87
- raise 'Object not found' unless obj
103
+ obj = object_for!(path, gem_name)
88
104
 
89
105
  tags = obj.tags.map do |tag|
90
106
  {
@@ -108,7 +124,7 @@ class YardUtils # rubocop:disable Metrics/ClassLength
108
124
  text: obj.tag('return').text
109
125
  }
110
126
  end,
111
- source: obj.respond_to?(:source) ? obj.source : nil,
127
+ source: capped_source(obj.respond_to?(:source) ? obj.source : nil),
112
128
  tags:
113
129
  }
114
130
 
@@ -127,13 +143,8 @@ class YardUtils # rubocop:disable Metrics/ClassLength
127
143
  # @param path [String] The YARD path of the namespace.
128
144
  # @return [Array<String>] An array of child object paths.
129
145
  # @raise [RuntimeError] if the object is not found in the registry.
130
- def children(path)
131
- ensure_yardoc_loaded_for_object!(path)
132
- obj = YARD::Registry.at(path)
133
- unless obj
134
- logger.error "Object not found: #{path}"
135
- return []
136
- end
146
+ def children(path, gem_name = nil)
147
+ obj = object_for!(path, gem_name)
137
148
  obj.respond_to?(:children) ? obj.children.map(&:path) : []
138
149
  end
139
150
 
@@ -142,13 +153,8 @@ class YardUtils # rubocop:disable Metrics/ClassLength
142
153
  # @param path [String] The YARD path of the class/module.
143
154
  # @return [Array<String>] An array of method paths.
144
155
  # @raise [RuntimeError] if the object is not found in the registry.
145
- def methods_list(path)
146
- ensure_yardoc_loaded_for_object!(path)
147
- obj = YARD::Registry.at(path)
148
- unless obj
149
- logger.error "Object not found: #{path}"
150
- return []
151
- end
156
+ def methods_list(path, gem_name = nil)
157
+ obj = object_for!(path, gem_name)
152
158
  obj.respond_to?(:meths) ? obj.meths.map(&:path) : []
153
159
  end
154
160
 
@@ -157,13 +163,8 @@ class YardUtils # rubocop:disable Metrics/ClassLength
157
163
  # @param path [String] The YARD path of the class/module.
158
164
  # @return [Hash] A hash with :superclass (String or nil), :included_modules (Array<String>), and :mixins (Array<String>).
159
165
  # @raise [RuntimeError] if the object is not found in the registry.
160
- def hierarchy(path) # rubocop:disable Metrics/CyclomaticComplexity
161
- ensure_yardoc_loaded_for_object!(path)
162
- obj = YARD::Registry.at(path)
163
- unless obj
164
- logger.error "Object not found: #{path}"
165
- return []
166
- end
166
+ def hierarchy(path, gem_name = nil)
167
+ obj = object_for!(path, gem_name)
167
168
  {
168
169
  superclass: obj.respond_to?(:superclass) && obj.superclass ? obj.superclass.path : nil,
169
170
  included_modules: obj.respond_to?(:mixins) ? obj.mixins.map(&:path) : [],
@@ -175,13 +176,8 @@ class YardUtils # rubocop:disable Metrics/ClassLength
175
176
  #
176
177
  # @param path [String] The YARD path of the class/module.
177
178
  # @return [Array<String>] An array of ancestor paths.
178
- def ancestors(path)
179
- ensure_yardoc_loaded_for_object!(path)
180
- obj = YARD::Registry.at(path)
181
- unless obj
182
- logger.error "Object not found: #{path}"
183
- return []
184
- end
179
+ def ancestors(path, gem_name = nil)
180
+ obj = object_for!(path, gem_name)
185
181
  obj.respond_to?(:inheritance_tree) ? obj.inheritance_tree(true).map(&:path) : []
186
182
  end
187
183
 
@@ -189,13 +185,8 @@ class YardUtils # rubocop:disable Metrics/ClassLength
189
185
  #
190
186
  # @param path [String] The YARD path of the class/module.
191
187
  # @return [Hash] A hash with :included_modules, :mixins, :subclasses.
192
- def related_objects(path)
193
- ensure_yardoc_loaded_for_object!(path)
194
- obj = YARD::Registry.at(path)
195
- unless obj
196
- logger.error "Object not found: #{path}"
197
- return {}
198
- end
188
+ def related_objects(path, gem_name = nil)
189
+ obj = object_for!(path, gem_name)
199
190
  subclasses = YARD::Registry.all(:class).select { |c| c.superclass && c.superclass.path == obj.path }.map(&:path)
200
191
  mixins_list = obj.respond_to?(:mixins) ? obj.mixins.map(&:path) : []
201
192
  {
@@ -209,32 +200,12 @@ class YardUtils # rubocop:disable Metrics/ClassLength
209
200
  #
210
201
  # @param query [String] The search query string.
211
202
  # @return [Array<Hash>] An array of hashes with :path and :score for matching object paths, ranked by relevance.
212
- def search(query) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
203
+ def search(query, gem_name = nil, limit: 25, offset: 0)
213
204
  require 'levenshtein' unless defined?(Levenshtein)
214
- results = []
215
- YARD::Registry.all.each do |obj|
216
- path = obj.path.to_s
217
- doc = obj.docstring.to_s
218
- next if path.empty?
219
-
220
- score = nil
221
- if path == query
222
- score = 100
223
- elsif path.start_with?(query)
224
- score = 90
225
- elsif path.include?(query)
226
- score = 80
227
- elsif doc.include?(query)
228
- score = 60
229
- else
230
- # Fuzzy match: allow up to 2 edits for short queries, 3 for longer
231
- dist = Levenshtein.distance(path.downcase, query.downcase)
232
- score = 70 - dist if dist <= [2, query.length / 3].max
233
- end
234
- results << { path:, score: } if score
235
- 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) }
236
207
  # Sort by score descending, then alphabetically
237
- results.sort_by { |r| [-r[:score], r[:path]] }
208
+ results.sort_by { |r| [-r[:score], r[:path]] }.slice(offset, limit) || []
238
209
  end
239
210
 
240
211
  # Returns the source file and line number for a YARD object (class/module/method).
@@ -242,13 +213,8 @@ class YardUtils # rubocop:disable Metrics/ClassLength
242
213
  # @param path [String] The YARD path (e.g., 'String#upcase').
243
214
  # @return [Hash] A hash with :file (String or nil) and :line (Integer or nil).
244
215
  # @raise [RuntimeError] if the object is not found in the registry.
245
- def source_location(path)
246
- ensure_yardoc_loaded_for_object!(path)
247
- obj = YARD::Registry.at(path)
248
- unless obj
249
- logger.error "Object not found: #{path}"
250
- return []
251
- end
216
+ def source_location(path, gem_name = nil)
217
+ obj = object_for!(path, gem_name)
252
218
  {
253
219
  file: obj.respond_to?(:file) ? obj.file : nil,
254
220
  line: obj.respond_to?(:line) ? obj.line : nil
@@ -260,22 +226,85 @@ class YardUtils # rubocop:disable Metrics/ClassLength
260
226
  # @param path [String] The YARD path (e.g., 'String#upcase').
261
227
  # @return [String, nil] The code snippet if available, otherwise nil.
262
228
  # @raise [RuntimeError] if the object is not found in the registry.
263
- def code_snippet(path)
264
- ensure_yardoc_loaded_for_object!(path)
265
- obj = YARD::Registry.at(path)
266
- unless obj
267
- logger.error "Object not found: #{path}"
268
- return []
269
- end
270
- 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
271
243
  end
272
244
 
273
245
  private
274
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
+
275
258
  def yardoc_exists?(dir)
276
259
  dir && File.directory?(dir)
277
260
  end
278
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
+
279
308
  # Build an index mapping object paths to gem names
280
309
  def build_index # rubocop:disable Metrics/AbcSize
281
310
  logger.info 'Building index...'
@@ -303,7 +332,7 @@ class YardUtils # rubocop:disable Metrics/ClassLength
303
332
  def merge_gem_results(results)
304
333
  results.each do |gem_objects|
305
334
  gem_objects.each do |obj_path, gem_names|
306
- (@object_to_gem[obj_path] ||= []).concat(gem_names)
335
+ (@object_to_gem[obj_path] ||= []).concat(gem_names).uniq!
307
336
  end
308
337
  end
309
338
  end
@@ -318,7 +347,10 @@ class YardUtils # rubocop:disable Metrics/ClassLength
318
347
  return {}
319
348
  end
320
349
 
321
- # Collect all objects for this gem
350
+ collect_current_gem_objects(gem_name)
351
+ end
352
+
353
+ def collect_current_gem_objects(gem_name)
322
354
  gem_objects = {}
323
355
  YARD::Registry.all.each do |obj|
324
356
  logger.debug "Adding #{obj.path} to #{gem_name}"
@@ -326,159 +358,496 @@ class YardUtils # rubocop:disable Metrics/ClassLength
326
358
  end
327
359
  gem_objects
328
360
  end
361
+ end
329
362
 
330
- def build_docs(gem_name)
331
- logger.info "Building docs for #{gem_name}..."
332
- 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}"
333
640
  end
334
641
  end
335
642
 
336
643
  # Tool: List all gems with .yardoc files
337
- class ListGemsTool < FastMcp::Tool
644
+ class ListGemsTool < YardTool
338
645
  description 'List all installed gems that have a .yardoc file'
339
646
  annotations(title: 'List all installed gems', read_only_hint: true)
647
+ returns YardSchemas::LIST_GEMS_SCHEMA
340
648
 
341
649
  def call
342
650
  gems = YardUtils.instance.list_gems
343
- { content: gems.map { |gem| { text: gem, type: 'gem' } } }
651
+ ok({ gems: }, text: gems.join("\n"))
344
652
  end
345
653
  end
346
654
 
347
655
  # Tool: List all classes and modules in the loaded YARD registry
348
- class ListClassesTool < FastMcp::Tool
656
+ class ListClassesTool < YardTool
349
657
  description 'List all classes and modules in the loaded YARD registry'
350
658
  annotations(title: 'List all classes and modules', read_only_hint: true)
659
+ returns YardSchemas::LIST_CLASSES_SCHEMA
351
660
  arguments do
352
661
  required(:gem_name).filled(:string).description('Name of the gem to list classes for')
353
662
  end
354
663
 
355
664
  def call(gem_name:)
356
- classes = YardUtils.instance.list_classes(gem_name)
357
- { 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
358
669
  end
359
670
  end
360
671
 
361
672
  # Tool: Fetch documentation for a YARD object
362
- class GetDocTool < FastMcp::Tool
673
+ class GetDocTool < YardTool
363
674
  description 'Fetch documentation and metadata for a class/module/method from YARD'
364
675
  annotations(title: 'Fetch documentation', read_only_hint: true)
676
+ returns YardSchemas::DOC_OBJECT_SCHEMA
365
677
  arguments do
366
678
  required(:path).filled(:string).description("YARD path (e.g. 'String#upcase')")
367
679
  optional(:gem_name).filled(:string).description("Optional gem name to load specific gem's documentation")
368
680
  end
369
681
 
370
682
  def call(path:, gem_name: nil)
371
- { 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
372
687
  end
373
688
  end
374
689
 
375
690
  # Tool: List children under a namespace
376
- class ChildrenTool < FastMcp::Tool
691
+ class ChildrenTool < YardTool
377
692
  description 'List children under a namespace (class/module) in YARD'
378
693
  annotations(title: 'List children under a namespace', read_only_hint: true)
694
+ returns YardSchemas::CHILDREN_SCHEMA
379
695
  arguments do
380
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")
381
698
  end
382
699
 
383
- def call(path:)
384
- children = YardUtils.instance.children(path)
385
- { 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
386
705
  end
387
706
  end
388
707
 
389
708
  # Tool: List methods for a class/module
390
- class MethodsListTool < FastMcp::Tool
709
+ class MethodsListTool < YardTool
391
710
  description 'List methods for a class/module in YARD'
392
711
  annotations(title: 'List methods for a class/module', read_only_hint: true)
712
+ returns YardSchemas::METHODS_SCHEMA
393
713
  arguments do
394
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")
395
716
  end
396
717
 
397
- def call(path:)
398
- methods = YardUtils.instance.methods_list(path)
399
- { 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
400
723
  end
401
724
  end
402
725
 
403
726
  # Tool: Return inheritance and inclusion info
404
- class HierarchyTool < FastMcp::Tool
727
+ class HierarchyTool < YardTool
405
728
  description 'Return inheritance and inclusion info for a class/module in YARD'
406
729
  annotations(title: 'Return inheritance and inclusion info', read_only_hint: true)
730
+ returns YardSchemas::HIERARCHY_SCHEMA
407
731
  arguments do
408
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")
409
734
  end
410
735
 
411
- def call(path:)
412
- { 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
413
741
  end
414
742
  end
415
743
 
416
744
  # Tool: Perform fuzzy/full-text search
417
- class SearchTool < FastMcp::Tool
745
+ class SearchTool < YardTool
418
746
  description 'Perform fuzzy/full-text search in YARD registry'
419
747
  annotations(title: 'Perform fuzzy/full-text search', read_only_hint: true)
748
+ returns YardSchemas::SEARCH_SCHEMA
420
749
  arguments do
421
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')
422
754
  end
423
755
 
424
- def call(query:)
425
- # Enhanced search: ranked, fuzzy, and full-text
426
- results = YardUtils.instance.search(query)
427
- { 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
428
761
  end
429
762
  end
430
763
 
431
764
  # Tool: Fetch source file and line number for a YARD object
432
- class SourceLocationTool < FastMcp::Tool
765
+ class SourceLocationTool < YardTool
433
766
  description 'Fetch the source file and line number for a class/module/method from YARD'
434
767
  annotations(title: 'Fetch the source file and line number', read_only_hint: true)
768
+ returns YardSchemas::SOURCE_LOCATION_SCHEMA
435
769
  arguments do
436
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")
437
772
  end
438
773
 
439
- def call(path:)
440
- { 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
441
779
  end
442
780
  end
443
781
 
444
782
  # Tool: Fetch code snippet for a YARD object from installed gems
445
- class CodeSnippetTool < FastMcp::Tool
783
+ class CodeSnippetTool < YardTool
446
784
  description 'Fetch the code snippet for a class/module/method from installed gems using YARD'
447
785
  annotations(title: 'Fetch the code snippet', read_only_hint: true)
786
+ returns YardSchemas::CODE_SNIPPET_SCHEMA
448
787
  arguments do
449
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')
450
791
  end
451
792
 
452
- def call(path:)
453
- snippet = YardUtils.instance.code_snippet(path)
454
- { 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
455
798
  end
456
799
  end
457
800
 
458
801
  # Tool: Fetch the full ancestor chain (superclasses and included modules) for a class/module in YARD
459
- class AncestorsTool < FastMcp::Tool
802
+ class AncestorsTool < YardTool
460
803
  description 'Fetch the full ancestor chain (superclasses and included modules) for a class/module in YARD'
461
804
  annotations(title: 'Fetch the full ancestor chain', read_only_hint: true)
805
+ returns YardSchemas::ANCESTORS_SCHEMA
462
806
  arguments do
463
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")
464
809
  end
465
810
 
466
- def call(path:)
467
- ancestors = YardUtils.instance.ancestors(path)
468
- { 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
469
816
  end
470
817
  end
471
818
 
472
819
  # Tool: List related objects: included modules, mixins, and subclasses for a class/module in YARD
473
- class RelatedObjectsTool < FastMcp::Tool
820
+ class RelatedObjectsTool < YardTool
474
821
  description 'List related objects: included modules, mixins, and subclasses for a class/module in YARD'
475
822
  annotations(title: 'List related objects', read_only_hint: true)
823
+ returns YardSchemas::RELATED_OBJECTS_SCHEMA
476
824
  arguments do
477
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")
478
827
  end
479
828
 
480
- def call(path:)
481
- { 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
482
851
  end
483
852
  end
484
853
 
@@ -486,19 +855,34 @@ module YardMCP
486
855
  def self.start_server(preload: true)
487
856
  YardUtils.instance if preload
488
857
  server = FastMcp::Server.new(name: 'yard-mcp-server', version: YardMCP::VERSION)
489
- server.register_tool(ListGemsTool)
490
- server.register_tool(ListClassesTool)
491
- server.register_tool(GetDocTool)
492
- server.register_tool(ChildrenTool)
493
- server.register_tool(MethodsListTool)
494
- server.register_tool(HierarchyTool)
495
- server.register_tool(SearchTool)
496
- server.register_tool(SourceLocationTool)
497
- server.register_tool(CodeSnippetTool)
498
- server.register_tool(AncestorsTool)
499
- server.register_tool(RelatedObjectsTool)
858
+ server.capabilities.clear
859
+ server.capabilities[:tools] = {}
860
+ server.capabilities[:resources] = {}
861
+ register_tools(server)
862
+ register_resources(server)
500
863
  server.start
501
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
502
886
  end
503
887
 
504
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.2
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: []