open_gemdocs 0.2.3 → 0.3.1

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.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "json"
5
+
6
+ module OpenGemdocs
7
+ module MCP
8
+ class Server
9
+ attr_reader :port
10
+
11
+ def initialize(port: 6789)
12
+ @port = port
13
+ @handlers = Handlers.new
14
+ end
15
+
16
+ def start
17
+ server = WEBrick::HTTPServer.new(
18
+ Port: @port,
19
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
20
+ AccessLog: []
21
+ )
22
+
23
+ server.mount_proc "/" do |req, res|
24
+ handle_request(req, res)
25
+ end
26
+
27
+ trap("INT") do
28
+ puts "\nShutting down MCP server..."
29
+ OpenGemdocs::Yard.stop_server
30
+ server.shutdown
31
+ end
32
+
33
+ puts "MCP server started on port #{@port}"
34
+ puts "Press Ctrl+C to stop"
35
+ server.start
36
+ end
37
+
38
+ private
39
+
40
+ def handle_request(req, res)
41
+ if req.request_method == "POST"
42
+ handle_post_request(req, res)
43
+ else
44
+ res.status = 405
45
+ res.body = "Method not allowed"
46
+ end
47
+ end
48
+
49
+ def handle_post_request(req, res)
50
+ body = JSON.parse(req.body)
51
+ response = @handlers.handle(body)
52
+ send_json_response(res, response)
53
+ rescue JSON::ParserError => e
54
+ send_json_error(res, -32_700, "Parse error", e.message)
55
+ rescue StandardError => e
56
+ send_json_error(res, -32_603, "Internal error", e.message)
57
+ end
58
+
59
+ def send_json_response(res, response)
60
+ res.content_type = "application/json"
61
+ res.body = JSON.generate(response)
62
+ res.status = 200
63
+ end
64
+
65
+ def send_json_error(res, code, message, data)
66
+ send_json_response(res, {
67
+ jsonrpc: "2.0",
68
+ error: {
69
+ code: code,
70
+ message: message,
71
+ data: data
72
+ }
73
+ })
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module OpenGemdocs
8
+ module MCP
9
+ class Tools
10
+ def initialize
11
+ # Ensure we can access the Yard module
12
+ require_relative "../yard"
13
+ require_relative "../yard_json_formatter"
14
+ end
15
+
16
+ def list
17
+ [
18
+ {
19
+ "name" => "search_gems",
20
+ "description" => "Search for installed Ruby gems",
21
+ "inputSchema" => {
22
+ "type" => "object",
23
+ "properties" => {
24
+ "query" => {
25
+ "type" => "string",
26
+ "description" => "Search query for gem names (partial match supported)"
27
+ }
28
+ },
29
+ "required" => ["query"]
30
+ }
31
+ },
32
+ {
33
+ "name" => "get_gem_info",
34
+ "description" => "Get information about a specific gem including version and summary",
35
+ "inputSchema" => {
36
+ "type" => "object",
37
+ "properties" => {
38
+ "gem_name" => {
39
+ "type" => "string",
40
+ "description" => "Exact name of the gem"
41
+ }
42
+ },
43
+ "required" => ["gem_name"]
44
+ }
45
+ },
46
+ {
47
+ "name" => "start_yard_server",
48
+ "description" => "Start the Yard documentation server",
49
+ "inputSchema" => {
50
+ "type" => "object",
51
+ "properties" => {}
52
+ }
53
+ },
54
+ {
55
+ "name" => "stop_yard_server",
56
+ "description" => "Stop the Yard documentation server",
57
+ "inputSchema" => {
58
+ "type" => "object",
59
+ "properties" => {}
60
+ }
61
+ },
62
+ {
63
+ "name" => "get_yard_server_status",
64
+ "description" => "Check if the Yard documentation server is running",
65
+ "inputSchema" => {
66
+ "type" => "object",
67
+ "properties" => {}
68
+ }
69
+ },
70
+ {
71
+ "name" => "get_gem_documentation_url",
72
+ "description" => "Get the local documentation URL for a gem (Note: Use fetch_gem_docs instead to get actual documentation content)",
73
+ "inputSchema" => {
74
+ "type" => "object",
75
+ "properties" => {
76
+ "gem_name" => {
77
+ "type" => "string",
78
+ "description" => "Name of the gem"
79
+ },
80
+ "class_name" => {
81
+ "type" => "string",
82
+ "description" => "Optional: specific class or module name"
83
+ }
84
+ },
85
+ "required" => ["gem_name"]
86
+ }
87
+ },
88
+ {
89
+ "name" => "fetch_gem_docs",
90
+ "description" => "Fetch structured documentation content for a gem or specific class/module. Returns formatted documentation with methods, attributes, parameters, and examples. Use this instead of fetching URLs directly.",
91
+ "inputSchema" => {
92
+ "type" => "object",
93
+ "properties" => {
94
+ "gem_name" => {
95
+ "type" => "string",
96
+ "description" => "Name of the gem"
97
+ },
98
+ "path" => {
99
+ "type" => "string",
100
+ "description" => 'Optional: specific class or module path (e.g., "FactoryBot::Trait" or "ActiveRecord::Base")'
101
+ }
102
+ },
103
+ "required" => ["gem_name"]
104
+ }
105
+ }
106
+ ]
107
+ end
108
+
109
+ def call(tool_name, arguments)
110
+ case tool_name
111
+ when "search_gems"
112
+ search_gems(arguments["query"])
113
+ when "get_gem_info"
114
+ get_gem_info(arguments["gem_name"])
115
+ when "start_yard_server"
116
+ start_yard_server
117
+ when "stop_yard_server"
118
+ stop_yard_server
119
+ when "get_yard_server_status"
120
+ get_yard_server_status
121
+ when "get_gem_documentation_url"
122
+ get_gem_documentation_url(arguments["gem_name"], arguments["class_name"])
123
+ when "fetch_gem_docs"
124
+ fetch_gem_docs(arguments["gem_name"], arguments["path"])
125
+ else
126
+ {
127
+ "content" => [{
128
+ "type" => "text",
129
+ "text" => "Unknown tool: #{tool_name}"
130
+ }],
131
+ "isError" => true
132
+ }
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def search_gems(query)
139
+ if query.nil? || query.empty?
140
+ return { "content" => [{ "type" => "text", "text" => "Query cannot be empty" }],
141
+ "isError" => true }
142
+ end
143
+
144
+ gems = `gem list --local`.lines
145
+ .map do |line|
146
+ parts = line.strip.match(/^([^\s]+)\s+\(([^)]+)\)/)
147
+ next unless parts
148
+
149
+ { name: parts[1], versions: parts[2] }
150
+ end
151
+ .compact
152
+ .select { |gem| gem[:name].downcase.include?(query.downcase) }
153
+
154
+ if gems.empty?
155
+ {
156
+ "content" => [{
157
+ "type" => "text",
158
+ "text" => "No gems found matching '#{query}'"
159
+ }]
160
+ }
161
+ else
162
+ gem_list = gems.map { |g| "• #{g[:name]} (#{g[:versions]})" }.join("\n")
163
+ {
164
+ "content" => [{
165
+ "type" => "text",
166
+ "text" => "Found #{gems.count} gem(s):\n#{gem_list}"
167
+ }]
168
+ }
169
+ end
170
+ end
171
+
172
+ def get_gem_info(gem_name)
173
+ spec = Gem::Specification.find_by_name(gem_name)
174
+
175
+ info = []
176
+ info << "**#{spec.name}** v#{spec.version}"
177
+ info << ""
178
+ info << "**Summary:** #{spec.summary}" if spec.summary
179
+ info << "**Description:** #{spec.description}" if spec.description && spec.description != spec.summary
180
+ info << "**Homepage:** #{spec.homepage}" if spec.homepage
181
+ info << "**License:** #{spec.license || spec.licenses.join(", ")}" if spec.license || spec.licenses.any?
182
+ info << "**Authors:** #{spec.authors.join(", ")}" if spec.authors.any?
183
+
184
+ {
185
+ "content" => [{
186
+ "type" => "text",
187
+ "text" => info.join("\n")
188
+ }]
189
+ }
190
+ rescue Gem::LoadError => e
191
+ {
192
+ "content" => [{
193
+ "type" => "text",
194
+ "text" => "Gem '#{gem_name}' not found: #{e.message}"
195
+ }],
196
+ "isError" => true
197
+ }
198
+ end
199
+
200
+ def start_yard_server
201
+ if OpenGemdocs::Yard.server_running?
202
+ current_dir = Dir.pwd
203
+ yard_dir = OpenGemdocs::Yard.yard_server_directory
204
+
205
+ if yard_dir && yard_dir != current_dir
206
+ # Yard is running in a different directory
207
+ {
208
+ "content" => [{
209
+ "type" => "text",
210
+ "text" => "Yard server is running in a different directory:\n" \
211
+ "Running in: #{yard_dir}\n" \
212
+ "Current dir: #{current_dir}\n\n" \
213
+ "The server is serving gems from '#{yard_dir}'.\n" \
214
+ "To serve gems from the current directory, stop the server first with 'stop_yard_server' tool."
215
+ }]
216
+ }
217
+ else
218
+ {
219
+ "content" => [{
220
+ "type" => "text",
221
+ "text" => "Yard server is already running on port 8808"
222
+ }]
223
+ }
224
+ end
225
+ else
226
+ OpenGemdocs::Yard.start_yard_server
227
+ sleep 2 # Give server time to start
228
+
229
+ if OpenGemdocs::Yard.server_running?
230
+ {
231
+ "content" => [{
232
+ "type" => "text",
233
+ "text" => "Yard server started successfully on port 8808"
234
+ }]
235
+ }
236
+ else
237
+ {
238
+ "content" => [{
239
+ "type" => "text",
240
+ "text" => "Failed to start Yard server"
241
+ }],
242
+ "isError" => true
243
+ }
244
+ end
245
+ end
246
+ end
247
+
248
+ def stop_yard_server
249
+ if OpenGemdocs::Yard.server_running?
250
+ OpenGemdocs::Yard.stop_server
251
+ {
252
+ "content" => [{
253
+ "type" => "text",
254
+ "text" => "Yard server stopped successfully"
255
+ }]
256
+ }
257
+ else
258
+ {
259
+ "content" => [{
260
+ "type" => "text",
261
+ "text" => "Yard server is not running"
262
+ }]
263
+ }
264
+ end
265
+ end
266
+
267
+ def get_yard_server_status
268
+ if OpenGemdocs::Yard.server_running?
269
+ pids = OpenGemdocs::Yard.find_yard_pids
270
+ yard_dir = OpenGemdocs::Yard.yard_server_directory
271
+ current_dir = Dir.pwd
272
+
273
+ status = "Yard server is running (PID: #{pids.join(", ")}) on port 8808"
274
+ status += "\nServing from: #{yard_dir}" if yard_dir
275
+
276
+ if yard_dir && yard_dir != current_dir
277
+ status += "\nCurrent directory: #{current_dir}"
278
+ status += "\n\nNote: The server is serving gems from a different directory."
279
+ end
280
+
281
+ {
282
+ "content" => [{
283
+ "type" => "text",
284
+ "text" => status
285
+ }]
286
+ }
287
+ else
288
+ {
289
+ "content" => [{
290
+ "type" => "text",
291
+ "text" => "Yard server is not running"
292
+ }]
293
+ }
294
+ end
295
+ end
296
+
297
+ def get_gem_documentation_url(gem_name, class_name = nil)
298
+ # Ensure server is running
299
+ unless OpenGemdocs::Yard.server_running?
300
+ OpenGemdocs::Yard.start_yard_server
301
+ sleep 2
302
+ end
303
+
304
+ url = "http://localhost:8808/docs/#{gem_name}"
305
+ url += "/#{class_name.gsub("::", "/")}" if class_name
306
+
307
+ path_hint = class_name || "the gem overview"
308
+
309
+ {
310
+ "content" => [{
311
+ "type" => "text",
312
+ "text" => "Documentation URL: #{url}\n\n" \
313
+ "**Tip:** Instead of fetching this URL directly, use the `fetch_gem_docs` tool with:\n" \
314
+ "- gem_name: \"#{gem_name}\"\n" \
315
+ "- path: \"#{class_name}\" (if you want specific class documentation)\n\n" \
316
+ "This will give you structured documentation for #{path_hint}."
317
+ }]
318
+ }
319
+ end
320
+
321
+ def fetch_gem_docs(gem_name, path = nil)
322
+ # Use the YardJsonFormatter to get structured documentation
323
+ result = OpenGemdocs::YardJsonFormatter.format_gem_docs(gem_name, path)
324
+
325
+ if result[:error]
326
+ {
327
+ "content" => [{
328
+ "type" => "text",
329
+ "text" => result[:error]
330
+ }],
331
+ "isError" => true
332
+ }
333
+ else
334
+ # Format the JSON result into readable text
335
+ formatted_text = format_json_docs(result, gem_name, path)
336
+
337
+ {
338
+ "content" => [{
339
+ "type" => "text",
340
+ "text" => formatted_text
341
+ }]
342
+ }
343
+ end
344
+ end
345
+
346
+ def format_json_docs(data, gem_name, path)
347
+ lines = []
348
+
349
+ if path
350
+ # Specific object documentation
351
+ obj = data
352
+ lines << "# #{obj[:path]}"
353
+ lines << ""
354
+ lines << "**Type:** #{obj[:type]}"
355
+ lines << "**Namespace:** #{obj[:namespace]}" if obj[:namespace]
356
+ lines << ""
357
+
358
+ if obj[:docstring]
359
+ lines << "## Description"
360
+ lines << obj[:docstring]
361
+ lines << ""
362
+ end
363
+
364
+ if obj[:superclass]
365
+ lines << "**Inherits from:** #{obj[:superclass]}"
366
+ lines << ""
367
+ end
368
+
369
+ if obj[:includes] && obj[:includes].any?
370
+ lines << "**Includes:** #{obj[:includes].join(", ")}"
371
+ lines << ""
372
+ end
373
+
374
+ if obj[:parameters] && obj[:parameters].any?
375
+ lines << "## Parameters"
376
+ obj[:parameters].each do |param|
377
+ default = param[:default] ? " = #{param[:default]}" : ""
378
+ lines << "- `#{param[:name]}#{default}`"
379
+ end
380
+ lines << ""
381
+ end
382
+
383
+ if obj[:methods] && obj[:methods].any?
384
+ lines << "## Methods"
385
+
386
+ # Group methods by visibility
387
+ %w[public protected private].each do |visibility|
388
+ visible_methods = obj[:methods].select { |m| m[:visibility] == visibility }
389
+ next if visible_methods.empty?
390
+
391
+ lines << ""
392
+ lines << "### #{visibility.capitalize} Methods"
393
+ visible_methods.each do |method|
394
+ return_info = method[:return_type] ? " → #{method[:return_type].join(", ")}" : ""
395
+ lines << "- `#{method[:signature]}`#{return_info}"
396
+ lines << " #{method[:docstring]}" if method[:docstring]
397
+ end
398
+ end
399
+ lines << ""
400
+ end
401
+
402
+ if obj[:attributes] && obj[:attributes].any?
403
+ lines << "## Attributes"
404
+ obj[:attributes].each do |attr|
405
+ access = []
406
+ access << "read" if attr[:read]
407
+ access << "write" if attr[:write]
408
+ lines << "- `#{attr[:name]}` (#{access.join("/")})"
409
+ lines << " #{attr[:docstring]}" if attr[:docstring] && !attr[:docstring].empty?
410
+ end
411
+ lines << ""
412
+ end
413
+
414
+ if obj[:tags] && obj[:tags].any?
415
+ examples = obj[:tags].select { |t| t[:tag_name] == "example" }
416
+ if examples.any?
417
+ lines << "## Examples"
418
+ examples.each do |example|
419
+ lines << "```ruby"
420
+ lines << example[:text]
421
+ lines << "```"
422
+ end
423
+ lines << ""
424
+ end
425
+ end
426
+ else
427
+ # Gem overview documentation
428
+ lines << "# #{gem_name}"
429
+
430
+ if data[:summary]
431
+ lines << ""
432
+ lines << "**Version:** #{data[:summary][:version]}" if data[:summary][:version]
433
+ lines << "**Homepage:** #{data[:summary][:homepage]}" if data[:summary][:homepage]
434
+ lines << ""
435
+ lines << data[:summary][:description] if data[:summary][:description]
436
+ lines << ""
437
+ end
438
+
439
+ if data[:namespaces] && data[:namespaces].any?
440
+ lines << "## Top-level Namespaces"
441
+ data[:namespaces].each do |ns|
442
+ lines << "- `#{ns[:path]}` (#{ns[:type]})"
443
+ end
444
+ lines << ""
445
+ end
446
+
447
+ if data[:classes] && data[:classes].any?
448
+ lines << "## Classes"
449
+ data[:classes].each do |cls|
450
+ super_info = cls[:superclass] ? " < #{cls[:superclass]}" : ""
451
+ lines << "- `#{cls[:path]}#{super_info}` (#{cls[:methods_count]} methods)"
452
+ lines << " #{cls[:docstring][0..100]}..." if cls[:docstring]
453
+ end
454
+ lines << ""
455
+ end
456
+
457
+ if data[:modules] && data[:modules].any?
458
+ lines << "## Modules"
459
+ data[:modules].each do |mod|
460
+ lines << "- `#{mod[:path]}` (#{mod[:methods_count]} methods)"
461
+ lines << " #{mod[:docstring][0..100]}..." if mod[:docstring]
462
+ end
463
+ lines << ""
464
+ end
465
+ end
466
+
467
+ lines.join("\n")
468
+ end
469
+ end
470
+ end
471
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenGemdocs
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -2,24 +2,24 @@
2
2
 
3
3
  module OpenGemdocs
4
4
  module Yard
5
- extend self
6
- SERVER_COMMAND = 'yard server --daemon'
5
+ module_function
6
+
7
+ SERVER_COMMAND = "yard server --daemon"
7
8
 
8
9
  def browse_gem(gem_name)
9
10
  if server_running?
10
11
  puts "Yard server is already running. Opening browser..."
11
- system("open http://localhost:8808/docs/#{gem_name}")
12
12
  else
13
13
  puts "Starting Yard server in the background..."
14
14
  start_yard_server
15
15
  sleep 2 # Give the server some time to start
16
- system("open http://localhost:8808/docs/#{gem_name}")
17
16
  end
17
+ system("open http://localhost:8808/docs/#{gem_name}")
18
18
  puts " When you're done, remember to stop the server with `open-gem-docs --stop`"
19
19
  end
20
20
 
21
21
  def start_yard_server
22
- if File.exist?('Gemfile.lock')
22
+ if File.exist?("Gemfile.lock")
23
23
  `#{SERVER_COMMAND} --gemfile`
24
24
  else
25
25
  `#{SERVER_COMMAND} --gems`
@@ -33,11 +33,25 @@ module OpenGemdocs
33
33
  `lsof -i TCP:8808 | grep -E "^ruby.*:8808"`.strip.split("\n").map { |line| line.split(/\s+/)[1] }
34
34
  end
35
35
 
36
+ def yard_server_directory
37
+ pids = find_yard_pids
38
+ return nil if pids.empty?
39
+
40
+ # Get the working directory of the first Yard process
41
+ pid = pids.first
42
+ cwd_line = `lsof -p #{pid} 2>/dev/null | grep cwd`.strip
43
+ return nil if cwd_line.empty?
44
+
45
+ # Extract the directory path from lsof output
46
+ parts = cwd_line.split(/\s+/)
47
+ parts.last if parts.length >= 9
48
+ end
49
+
36
50
  def stop_server
37
51
  yard_pids = find_yard_pids
38
52
  if yard_pids.any?
39
- puts "Stopping Yard server processes: #{yard_pids.join(', ')}"
40
- `kill #{yard_pids.join(' ')}`
53
+ puts "Stopping Yard server processes: #{yard_pids.join(", ")}"
54
+ `kill #{yard_pids.join(" ")}`
41
55
  puts "Yard server processes stopped."
42
56
  else
43
57
  puts "No Yard server processes found to stop"