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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/Manifest.txt +3 -1
- data/README.md +21 -0
- data/Rakefile +28 -4
- data/exe/cf-mcp +1 -1
- data/lib/cf/mcp/cli.rb +128 -53
- data/lib/cf/mcp/downloader.rb +9 -5
- data/lib/cf/mcp/github_client.rb +2 -1
- data/lib/cf/mcp/index.rb +21 -18
- data/lib/cf/mcp/index_builder.rb +8 -4
- data/lib/cf/mcp/index_cache.rb +152 -0
- data/lib/cf/mcp/models/doc_item.rb +6 -13
- data/lib/cf/mcp/models/enum_doc.rb +3 -6
- data/lib/cf/mcp/models/function_doc.rb +15 -22
- data/lib/cf/mcp/models/struct_doc.rb +3 -6
- data/lib/cf/mcp/models/topic_doc.rb +3 -5
- data/lib/cf/mcp/parser.rb +29 -57
- data/lib/cf/mcp/server.rb +36 -27
- data/lib/cf/mcp/templates/index.erb +1 -1
- data/lib/cf/mcp/templates/script.js +15 -2
- data/lib/cf/mcp/tool_command.rb +108 -0
- data/lib/cf/mcp/tools/category_enum.rb +31 -0
- data/lib/cf/mcp/tools/find_related.rb +6 -3
- data/lib/cf/mcp/tools/get_details.rb +4 -0
- data/lib/cf/mcp/tools/get_topic.rb +4 -0
- data/lib/cf/mcp/tools/list_category.rb +7 -1
- data/lib/cf/mcp/tools/list_topics.rb +4 -0
- data/lib/cf/mcp/tools/member_search.rb +14 -16
- data/lib/cf/mcp/tools/parameter_search.rb +23 -30
- data/lib/cf/mcp/tools/search_tool.rb +7 -1
- data/lib/cf/mcp/topic_parser.rb +30 -77
- data/lib/cf/mcp/version.rb +1 -1
- data/lib/cf/mcp.rb +9 -0
- metadata +10 -22
- data/sig/cf/mcp.rbs +0 -299
|
@@ -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: {
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
55
|
+
struct = result.struct
|
|
58
56
|
lines << "- **#{struct.name}** (#{struct.category}) — #{struct.brief}"
|
|
59
|
-
result
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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",
|
|
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,
|
data/lib/cf/mcp/topic_parser.rb
CHANGED
|
@@ -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")).
|
|
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
|
-
|
|
33
|
-
topic.reading_order = reading_order[topic.name]
|
|
34
|
-
topics << topic
|
|
35
|
-
end
|
|
36
|
-
end
|
|
31
|
+
next unless topic
|
|
37
32
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
111
|
+
[func_refs, struct_refs, []]
|
|
155
112
|
end
|
|
156
113
|
|
|
157
114
|
def extract_topic_references(content)
|
|
158
|
-
|
|
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)
|
data/lib/cf/mcp/version.rb
CHANGED
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
|