cf-mcp 0.19.0 → 0.21.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.
@@ -8,6 +8,8 @@ const TOOLS = TOOL_SCHEMAS_PLACEHOLDER;
8
8
  const CATEGORIES = CATEGORIES_PLACEHOLDER;
9
9
  const TOPICS = TOPICS_PLACEHOLDER;
10
10
  const CHANGELOG = CHANGELOG_PLACEHOLDER;
11
+ const PROTOCOL_VERSION = PROTOCOL_VERSION_PLACEHOLDER;
12
+ const CLIENT_VERSION = CLIENT_VERSION_PLACEHOLDER;
11
13
 
12
14
  // Helper to render topic options (used in multiple places)
13
15
  const renderTopicOptions = () =>
@@ -38,13 +40,24 @@ function useMcpToolCall() {
38
40
  method: 'POST',
39
41
  headers: {
40
42
  'Content-Type': 'application/json',
41
- 'Accept': 'application/json, text/event-stream'
43
+ 'Accept': 'application/json, text/event-stream',
44
+ 'MCP-Protocol-Version': PROTOCOL_VERSION,
45
+ 'Mcp-Method': 'tools/call',
46
+ 'Mcp-Name': toolName
42
47
  },
43
48
  body: JSON.stringify({
44
49
  jsonrpc: '2.0',
45
50
  id: Date.now(),
46
51
  method: 'tools/call',
47
- params: { name: toolName, arguments: args }
52
+ params: {
53
+ name: toolName,
54
+ arguments: args,
55
+ _meta: {
56
+ 'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
57
+ 'io.modelcontextprotocol/clientCapabilities': {},
58
+ 'io.modelcontextprotocol/clientInfo': { name: 'cf-mcp-dashboard', version: CLIENT_VERSION }
59
+ }
60
+ }
48
61
  })
49
62
  });
50
63
 
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module CF
6
+ module MCP
7
+ # Runs an MCP tool as a command, mapping its input schema onto the command
8
+ # line: required properties are positional arguments, the rest are flags.
9
+ class ToolCommand
10
+ Property = Data.define(:name, :type, :description, :values)
11
+
12
+ def initialize(tool)
13
+ @tool = tool
14
+ schema = tool.input_schema.to_h
15
+ required = schema.fetch(:required, []).map(&:to_sym)
16
+ properties = schema.fetch(:properties).map do |name, spec|
17
+ Property.new(name: name, type: spec[:type], description: spec[:description].to_s, values: spec[:enum])
18
+ end
19
+ @required, @flags = properties.partition { |property| required.include?(property.name) }
20
+ # A tool that requires nothing still takes its first property as an optional argument.
21
+ @arguments = @required.empty? ? properties.first(1) : @required
22
+ @parser = build_parser
23
+ end
24
+
25
+ # Returns the process exit status.
26
+ def run(args)
27
+ flags = {} #: Hash[Symbol, untyped]
28
+ positionals = @parser.parse(args, into: flags)
29
+ return print_help if flags.key?(:help)
30
+
31
+ respond(call_tool(bind(positionals).merge(flags)))
32
+ end
33
+
34
+ private
35
+
36
+ def build_parser
37
+ OptionParser.new do |opts|
38
+ opts.banner = ["Usage: cf-mcp", @tool.tool_name, *@arguments.map { |argument| usage(argument) }, "[options]"].join(" ")
39
+ opts.separator ""
40
+ opts.separator @tool.description.to_s
41
+ unless @arguments.empty?
42
+ opts.separator ""
43
+ opts.separator "Arguments:"
44
+ @arguments.each { |argument| opts.separator " #{placeholder(argument).ljust(32)}#{argument.description}" }
45
+ end
46
+ opts.separator ""
47
+ opts.separator "Options:"
48
+ @flags.each { |flag| define_flag(opts, flag) }
49
+ opts.on("-h", "--help", "Show this help message")
50
+ end
51
+ end
52
+
53
+ def define_flag(opts, flag)
54
+ switch = "--#{flag.name} #{placeholder(flag)}"
55
+ if (values = flag.values)
56
+ opts.on(switch, values, "#{flag.description} [#{values.join("|")}]")
57
+ elsif flag.type == "integer"
58
+ opts.on(switch, Integer, flag.description)
59
+ elsif flag.type == "boolean"
60
+ opts.on("--[no-]#{flag.name}", flag.description)
61
+ else
62
+ opts.on(switch, flag.description)
63
+ end
64
+ end
65
+
66
+ def placeholder(property)
67
+ property.name.to_s.upcase
68
+ end
69
+
70
+ # How an argument reads in the usage line: optional ones are bracketed.
71
+ def usage(argument)
72
+ @required.include?(argument) ? placeholder(argument) : "[#{placeholder(argument)}]"
73
+ end
74
+
75
+ def bind(positionals)
76
+ if positionals.size < @required.size
77
+ raise OptionParser::MissingArgument, placeholder(@required.fetch(positionals.size))
78
+ end
79
+ if positionals.size > @arguments.size
80
+ raise OptionParser::NeedlessArgument, positionals.drop(@arguments.size).join(" ")
81
+ end
82
+
83
+ @arguments.first(positionals.size).map(&:name).zip(positionals).to_h
84
+ end
85
+
86
+ def call_tool(arguments)
87
+ # `MCP::Tool.call` is deliberately undeclared (see sig-stubs/mcp.rbs), so it is sent.
88
+ @tool.public_send(:call, **arguments) #: ::MCP::Tool::Response
89
+ end
90
+
91
+ def respond(response)
92
+ text = response.content.filter_map { |item| item[:text] }.join("\n")
93
+ if response.error?
94
+ warn text
95
+ 1
96
+ else
97
+ puts text
98
+ 0
99
+ end
100
+ end
101
+
102
+ def print_help
103
+ puts @parser
104
+ 0
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module CF
6
+ module MCP
7
+ module Tools
8
+ # For a tool whose `category` property takes the index's categories. They are
9
+ # read when the schema is asked for, not when the class loads, so a tool can
10
+ # be loaded before the index is filled. The property stays open while the
11
+ # index is empty, as a JSON Schema enum needs at least one value.
12
+ module CategoryEnum
13
+ def input_schema_value
14
+ categories = Index.instance.categories
15
+ return super if categories.empty?
16
+
17
+ @category_schemas ||= {} #: Hash[Array[String], ::MCP::Tool::InputSchema]
18
+ @category_schemas[categories] ||= with_categories(super.to_h, categories)
19
+ end
20
+
21
+ private
22
+
23
+ def with_categories(schema, categories)
24
+ properties = schema.fetch(:properties)
25
+ category = properties.fetch(:category).merge(enum: categories)
26
+ ::MCP::Tool::InputSchema.new(schema.merge(properties: properties.merge(category: category)))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -9,7 +9,10 @@ module CF
9
9
  class FindRelated < ::MCP::Tool
10
10
  extend ResponseHelpers
11
11
 
12
+ TITLE = "Find Related"
13
+
12
14
  tool_name "find_related"
15
+ title TITLE
13
16
  description "Find all items related to a given Cute Framework item (bidirectional relationship search)"
14
17
 
15
18
  input_schema(
@@ -21,6 +24,7 @@ module CF
21
24
  )
22
25
 
23
26
  annotations(
27
+ title: TITLE,
24
28
  read_only_hint: true,
25
29
  destructive_hint: false,
26
30
  idempotent_hint: true,
@@ -44,12 +48,11 @@ module CF
44
48
  }
45
49
 
46
50
  # Back references: items that reference this item
47
- back_refs = []
48
- index.items.each_value do |other_item|
51
+ back_refs = index.items.each_value.filter_map do |other_item|
49
52
  next if other_item.name == name
50
53
  next unless other_item.related&.include?(name)
51
54
 
52
- back_refs << "- `#{other_item.name}` (#{other_item.type}) — #{other_item.brief}"
55
+ "- `#{other_item.name}` (#{other_item.type}) — #{other_item.brief}"
53
56
  end
54
57
 
55
58
  if forward_refs.empty? && back_refs.empty?
@@ -9,7 +9,10 @@ module CF
9
9
  class GetDetails < ::MCP::Tool
10
10
  extend ResponseHelpers
11
11
 
12
+ TITLE = "Get Details"
13
+
12
14
  tool_name "get_details"
15
+ title TITLE
13
16
  description "Get detailed documentation for a specific Cute Framework item by exact name"
14
17
 
15
18
  input_schema(
@@ -21,6 +24,7 @@ module CF
21
24
  )
22
25
 
23
26
  annotations(
27
+ title: TITLE,
24
28
  read_only_hint: true,
25
29
  destructive_hint: false,
26
30
  idempotent_hint: true,
@@ -9,7 +9,10 @@ module CF
9
9
  class GetTopic < ::MCP::Tool
10
10
  extend ResponseHelpers
11
11
 
12
+ TITLE = "Get Topic"
13
+
12
14
  tool_name "get_topic"
15
+ title TITLE
13
16
  description "Get the full content of a Cute Framework topic guide document"
14
17
 
15
18
  input_schema(
@@ -21,6 +24,7 @@ module CF
21
24
  )
22
25
 
23
26
  annotations(
27
+ title: TITLE,
24
28
  read_only_hint: true,
25
29
  destructive_hint: false,
26
30
  idempotent_hint: true,
@@ -1,26 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mcp"
4
+ require_relative "category_enum"
4
5
  require_relative "response_helpers"
5
6
 
6
7
  module CF
7
8
  module MCP
8
9
  module Tools
9
10
  class ListCategory < ::MCP::Tool
11
+ extend CategoryEnum
10
12
  extend ResponseHelpers
11
13
 
14
+ TITLE = "List Category"
15
+
12
16
  tool_name "list_category"
17
+ title TITLE
13
18
  description "List all items in a specific category, or list all available categories"
14
19
 
15
20
  input_schema(
16
21
  type: "object",
17
22
  properties: {
18
- category: {type: "string", enum: Index.instance.categories, description: "Category name. Leave empty to list all categories."},
23
+ category: {type: "string", description: "Category name. Leave empty to list all categories."},
19
24
  type: {type: "string", enum: ["function", "struct", "enum"], description: "Optional: filter by item type"}
20
25
  }
21
26
  )
22
27
 
23
28
  annotations(
29
+ title: TITLE,
24
30
  read_only_hint: true,
25
31
  destructive_hint: false,
26
32
  idempotent_hint: true,
@@ -9,7 +9,10 @@ module CF
9
9
  class ListTopics < ::MCP::Tool
10
10
  extend ResponseHelpers
11
11
 
12
+ TITLE = "List Topics"
13
+
12
14
  tool_name "list_topics"
15
+ title TITLE
13
16
  description "List all Cute Framework topic guides, optionally filtered by category or in recommended reading order"
14
17
 
15
18
  input_schema(
@@ -21,6 +24,7 @@ module CF
21
24
  )
22
25
 
23
26
  annotations(
27
+ title: TITLE,
24
28
  read_only_hint: true,
25
29
  destructive_hint: false,
26
30
  idempotent_hint: true,
@@ -9,7 +9,13 @@ module CF
9
9
  class MemberSearch < ::MCP::Tool
10
10
  extend ResponseHelpers
11
11
 
12
+ TITLE = "Member Search"
13
+
14
+ # A struct together with the members that matched the query.
15
+ Match = Data.define(:struct, :members)
16
+
12
17
  tool_name "member_search"
18
+ title TITLE
13
19
  description "Search Cute Framework structs by member name or type"
14
20
 
15
21
  input_schema(
@@ -22,6 +28,7 @@ module CF
22
28
  )
23
29
 
24
30
  annotations(
31
+ title: TITLE,
25
32
  read_only_hint: true,
26
33
  destructive_hint: false,
27
34
  idempotent_hint: true,
@@ -32,20 +39,11 @@ module CF
32
39
  index = Index.instance
33
40
 
34
41
  pattern = Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
35
- results = []
36
-
37
- index.structs.each do |struct|
38
- next unless struct.members&.any?
39
-
40
- matching_members = struct.members.select { |member|
41
- member.declaration&.match?(pattern)
42
- }
43
-
44
- next if matching_members.empty?
45
-
46
- results << {struct: struct, members: matching_members}
47
- break if results.size >= limit
48
- end
42
+ # A limit below 1 still returns the first match.
43
+ results = index.structs.lazy.filter_map { |struct|
44
+ matching_members = struct.members.select { |member| member.declaration.match?(pattern) }
45
+ Match.new(struct, matching_members) unless matching_members.empty?
46
+ }.first([limit, 1].max)
49
47
 
50
48
  if results.empty?
51
49
  return text_response("No structs found with members matching '#{query}'")
@@ -54,9 +52,9 @@ module CF
54
52
  lines = ["# Structs with members matching '#{query}'", ""]
55
53
 
56
54
  results.each do |result|
57
- struct = result[:struct]
55
+ struct = result.struct
58
56
  lines << "- **#{struct.name}** (#{struct.category}) — #{struct.brief}"
59
- result[:members].each do |member|
57
+ result.members.each do |member|
60
58
  lines << " - `#{member.declaration}` — #{member.description}"
61
59
  end
62
60
  lines << ""
@@ -9,7 +9,10 @@ module CF
9
9
  class ParameterSearch < ::MCP::Tool
10
10
  extend ResponseHelpers
11
11
 
12
+ TITLE = "Parameter Search"
13
+
12
14
  tool_name "parameter_search"
15
+ title TITLE
13
16
  description "Find Cute Framework functions by parameter or return type"
14
17
 
15
18
  input_schema(
@@ -26,6 +29,7 @@ module CF
26
29
  )
27
30
 
28
31
  annotations(
32
+ title: TITLE,
29
33
  read_only_hint: true,
30
34
  destructive_hint: false,
31
35
  idempotent_hint: true,
@@ -36,38 +40,27 @@ module CF
36
40
  index = Index.instance
37
41
 
38
42
  pattern = Regexp.new(Regexp.escape(type), Regexp::IGNORECASE)
39
- input_matches = []
40
- output_matches = []
41
-
42
- index.functions.each do |func|
43
- next unless func.signature
44
-
45
- # Check return type (text before function name in signature)
46
- if direction != "input"
47
- # Extract return type: everything before the function name
48
- if func.signature =~ /^(.+?)\s+#{Regexp.escape(func.name)}\s*\(/
49
- return_type = ::Regexp.last_match(1).strip
50
- if return_type.match?(pattern)
51
- output_matches << func
52
- end
53
- end
54
- end
43
+ functions = index.functions.select(&:signature)
55
44
 
56
- # Check input parameters
57
- if direction != "output"
58
- # Check the signature for parameter types
59
- if func.signature =~ /\(([^)]*)\)/
60
- params_str = ::Regexp.last_match(1)
61
- if params_str.match?(pattern)
62
- input_matches << func unless input_matches.include?(func)
63
- end
64
- end
65
- end
66
- end
45
+ # Check return type (text before function name in signature)
46
+ output_matches = functions.select { |func|
47
+ next false if direction == "input"
48
+
49
+ # Extract return type: everything before the function name
50
+ next false unless func.signature =~ /^(.+?)\s+#{Regexp.escape(func.name)}\s*\(/
51
+
52
+ ::Regexp.last_match(1).to_s.strip.match?(pattern)
53
+ }.uniq
54
+
55
+ # Check input parameters
56
+ input_matches = functions.select { |func|
57
+ next false if direction == "output"
58
+
59
+ # Check the signature for parameter types
60
+ next false unless func.signature =~ /\(([^)]*)\)/
67
61
 
68
- # Remove duplicates between input and output
69
- input_matches.uniq!
70
- output_matches.uniq!
62
+ ::Regexp.last_match(1).to_s.match?(pattern)
63
+ }.uniq
71
64
 
72
65
  if input_matches.empty? && output_matches.empty?
73
66
  return text_response("No functions found using type '#{type}'")
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mcp"
4
+ require_relative "category_enum"
4
5
  require_relative "response_helpers"
5
6
  require_relative "search_result_formatter"
6
7
 
@@ -8,10 +9,14 @@ module CF
8
9
  module MCP
9
10
  module Tools
10
11
  class SearchTool < ::MCP::Tool
12
+ extend CategoryEnum
11
13
  extend ResponseHelpers
12
14
  extend SearchResultFormatter
13
15
 
16
+ TITLE = "Search"
17
+
14
18
  tool_name "search"
19
+ title TITLE
15
20
  description "Search Cute Framework documentation across all types (functions, structs, enums, topics)"
16
21
 
17
22
  input_schema(
@@ -19,13 +24,14 @@ module CF
19
24
  properties: {
20
25
  query: {type: "string", description: "Search query (searches in name, description, and remarks)"},
21
26
  type: {type: "string", enum: ["function", "struct", "enum", "topic"], description: "Optional: filter by item type"},
22
- category: {type: "string", enum: Index.instance.categories, description: "Optional: filter by category"},
27
+ category: {type: "string", description: "Optional: filter by category"},
23
28
  limit: {type: "integer", description: "Maximum number of results to return (default: 20)"}
24
29
  },
25
30
  required: ["query"]
26
31
  )
27
32
 
28
33
  annotations(
34
+ title: TITLE,
29
35
  read_only_hint: true,
30
36
  destructive_hint: false,
31
37
  idempotent_hint: true,
@@ -22,36 +22,28 @@ module CF
22
22
  end
23
23
 
24
24
  def parse_directory(path)
25
- topics = []
26
25
  reading_order = parse_reading_order(File.join(path, "index.md"))
27
26
 
28
- Dir.glob(File.join(path, "*.md")).each do |topic_file|
27
+ Dir.glob(File.join(path, "*.md")).filter_map do |topic_file|
29
28
  next if File.basename(topic_file) == "index.md"
30
29
 
31
30
  topic = parse_file(topic_file)
32
- if topic
33
- topic.reading_order = reading_order[topic.name]
34
- topics << topic
35
- end
36
- end
31
+ next unless topic
37
32
 
38
- topics
33
+ topic.reading_order = reading_order[topic.name]
34
+ topic
35
+ end
39
36
  end
40
37
 
41
38
  def parse_reading_order(index_path)
42
39
  return {} unless File.exist?(index_path)
43
40
 
44
41
  content = File.read(index_path)
45
- order = {}
46
- position = 0
47
42
 
48
- # Match numbered list items with topic links
49
- content.scan(/^\d+\.\s+\[([^\]]+)\]\(\.\/(\w+)\.md\)/) do |_title, slug|
50
- order[slug] = position
51
- position += 1
43
+ # Match numbered list items with topic links; a repeated slug keeps its last position
44
+ content.scan(/^\d+\.\s+\[([^\]]+)\]\(\.\/(\w+)\.md\)/).each_with_index.to_h do |(_title, slug), position|
45
+ [slug, position]
52
46
  end
53
-
54
- order
55
47
  end
56
48
 
57
49
  private
@@ -85,81 +77,42 @@ module CF
85
77
  end
86
78
 
87
79
  def extract_brief(content)
88
- lines = content.lines
89
- in_paragraph = false
90
- paragraph_lines = []
91
-
92
- lines.each do |line|
93
- next if line.start_with?("#")
94
-
95
- if line.strip.empty?
96
- break if in_paragraph
97
- next
98
- end
99
-
100
- in_paragraph = true
101
- paragraph_lines << line.strip
102
- end
80
+ # The first paragraph that isn't a heading
81
+ paragraph_lines = content.lines
82
+ .reject { |line| line.start_with?("#") }
83
+ .drop_while { |line| line.strip.empty? }
84
+ .take_while { |line| !line.strip.empty? }
85
+ .map(&:strip)
103
86
 
104
87
  # Strip markdown links but keep the text
105
88
  paragraph_lines.join(" ").gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
106
89
  end
107
90
 
108
91
  def extract_sections(content)
109
- sections = []
110
- current_title = nil
111
- current_content = []
112
-
113
- content.lines.each do |line|
114
- if line =~ SECTION_PATTERN
115
- if current_title
116
- sections << Models::TopicDoc::Section.new(
117
- title: current_title,
118
- content: current_content.join
119
- )
120
- end
121
- current_title = ::Regexp.last_match(1).strip
122
- current_content = []
123
- elsif current_title
124
- current_content << line
125
- end
92
+ # A section starts on a "## Title" heading and runs until the next one.
93
+ # Text before the first heading belongs to no section.
94
+ content.lines.slice_before { |line| SECTION_PATTERN.match?(line) }.filter_map do |section_lines|
95
+ heading = section_lines.fetch(0)
96
+ title = heading[SECTION_PATTERN, 1]
97
+ next unless title
98
+
99
+ Models::TopicDoc::Section.new(title: title.strip, content: section_lines.drop(1).join)
126
100
  end
127
-
128
- # Add last section
129
- if current_title
130
- sections << Models::TopicDoc::Section.new(
131
- title: current_title,
132
- content: current_content.join
133
- )
134
- end
135
-
136
- sections
137
101
  end
138
102
 
139
103
  def extract_api_references(content)
140
- func_refs = []
141
- struct_refs = []
142
- enum_refs = []
143
-
144
- content.scan(API_LINK_PATTERN) do |_text, _category, name|
145
- if name.start_with?("cf_")
146
- func_refs << name
147
- elsif name.start_with?("CF_") || name.match?(/^[A-Z]/)
148
- # Uppercase names are likely structs or enums
149
- # Will be refined when cross-referenced with index
150
- struct_refs << name
151
- end
152
- end
104
+ names = content.scan(API_LINK_PATTERN).map { |_text, _category, name| name }
105
+
106
+ func_refs, other_refs = names.partition { |name| name.start_with?("cf_") }
107
+ # Uppercase names are likely structs or enums
108
+ # Will be refined when cross-referenced with index
109
+ struct_refs = other_refs.select { |name| name.start_with?("CF_") || name.match?(/^[A-Z]/) }
153
110
 
154
- [func_refs, struct_refs, enum_refs]
111
+ [func_refs, struct_refs, []]
155
112
  end
156
113
 
157
114
  def extract_topic_references(content)
158
- refs = []
159
- content.scan(TOPIC_LINK_PATTERN) do |_text, slug|
160
- refs << slug
161
- end
162
- refs
115
+ content.scan(TOPIC_LINK_PATTERN).map { |_text, slug| slug }
163
116
  end
164
117
 
165
118
  def derive_category(slug)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module CF
4
4
  module MCP
5
- VERSION = "0.19.0"
5
+ VERSION = "0.21.0"
6
6
  end
7
7
  end
data/lib/cf/mcp.rb CHANGED
@@ -10,7 +10,9 @@ module CF
10
10
  autoload :Parser, "cf/mcp/parser"
11
11
  autoload :Index, "cf/mcp/index"
12
12
  autoload :IndexBuilder, "cf/mcp/index_builder"
13
+ autoload :IndexCache, "cf/mcp/index_cache"
13
14
  autoload :TopicParser, "cf/mcp/topic_parser"
15
+ autoload :ToolCommand, "cf/mcp/tool_command"
14
16
  autoload :Server, "cf/mcp/server"
15
17
  autoload :Downloader, "cf/mcp/downloader"
16
18
  autoload :GitHubClient, "cf/mcp/github_client"
@@ -37,6 +39,13 @@ module CF
37
39
  autoload :MemberSearch, "cf/mcp/tools/member_search"
38
40
  autoload :ListTopics, "cf/mcp/tools/list_topics"
39
41
  autoload :GetTopic, "cf/mcp/tools/get_topic"
42
+
43
+ # A method, not a constant: naming a tool loads it, and SearchTool and
44
+ # ListCategory read the index's categories when loaded, so callers must
45
+ # fill the index before asking.
46
+ def self.all
47
+ [SearchTool, ListCategory, GetDetails, FindRelated, ParameterSearch, MemberSearch, ListTopics, GetTopic]
48
+ end
40
49
  end
41
50
  end
42
51
  end