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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module CF
|
|
8
|
+
module MCP
|
|
9
|
+
# Keeps the parsed index on disk so a command need not reparse every header.
|
|
10
|
+
#
|
|
11
|
+
# There is one cache file per source, so projects on different Cute Framework
|
|
12
|
+
# versions do not evict each other. Each records where it was built from, so
|
|
13
|
+
# a lookup needs no path resolution (or download) of its own, and rebuilds
|
|
14
|
+
# when any header or topic file changes. The last source used is remembered
|
|
15
|
+
# for commands that name none.
|
|
16
|
+
class IndexCache
|
|
17
|
+
FORMAT = 1
|
|
18
|
+
DEFAULT_SOURCE = {root: nil, download: false} #: source
|
|
19
|
+
|
|
20
|
+
Payload = Data.define(:format, :gem_version, :headers_path, :topics_path,
|
|
21
|
+
:fingerprint, :revision, :items)
|
|
22
|
+
|
|
23
|
+
def self.default_dir
|
|
24
|
+
ENV["CF_MCP_CACHE_DIR"] || File.join(ENV["XDG_CACHE_HOME"] || File.expand_path("~/.cache"), "cf-mcp")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The cache file used by the last #index or #refresh, and the checkout it came from.
|
|
28
|
+
attr_reader :path, :revision
|
|
29
|
+
|
|
30
|
+
def initialize(dir: self.class.default_dir)
|
|
31
|
+
@dir = dir
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def path_for(source)
|
|
35
|
+
File.join(@dir, "index-#{Digest::SHA256.hexdigest("#{source[:root]}\n#{source[:download]}")[0, 12]}.bin")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Loads the cached index if it is fresh, otherwise builds and stores it.
|
|
39
|
+
# `source` names what to build from; nil means the last one used.
|
|
40
|
+
# The block receives the source to build from and returns an IndexBuilder.
|
|
41
|
+
def index(source = nil, &builder_for)
|
|
42
|
+
check_dir!
|
|
43
|
+
source ||= current_source
|
|
44
|
+
payload = load_payload(path_for(source))
|
|
45
|
+
return hydrate(source, payload) if payload && fresh?(payload)
|
|
46
|
+
|
|
47
|
+
warn "Index #{payload ? "is stale" : "not found"}, rebuilding..."
|
|
48
|
+
rebuild(source, &builder_for)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Rebuilds and stores the index, whether or not the cache is fresh.
|
|
52
|
+
def refresh(source = nil, &builder_for)
|
|
53
|
+
check_dir!
|
|
54
|
+
rebuild(source || current_source, &builder_for)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Cache files are read back with Marshal, which can run code, so they are only
|
|
60
|
+
# trusted in a directory no other user could have put a file in.
|
|
61
|
+
def check_dir!
|
|
62
|
+
return unless File.directory?(@dir)
|
|
63
|
+
|
|
64
|
+
stat = File.stat(@dir)
|
|
65
|
+
return if stat.uid == Process.euid && !stat.world_writable?
|
|
66
|
+
|
|
67
|
+
raise Error, "Refusing to load the index from #{@dir}: it belongs to another user or is writable by everyone. Set CF_MCP_CACHE_DIR to a directory of your own."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A checkout that has gone away (a cleaned download directory, say) keeps
|
|
71
|
+
# its cache: only `refresh` should fetch it again.
|
|
72
|
+
def fresh?(payload)
|
|
73
|
+
!File.directory?(payload.headers_path) ||
|
|
74
|
+
fingerprint(payload.headers_path, payload.topics_path) == payload.fingerprint
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rebuild(source)
|
|
78
|
+
builder = yield source
|
|
79
|
+
unless builder.valid?
|
|
80
|
+
raise Error, "Headers directory not found: #{builder.headers_path}. Point --root at a Cute Framework checkout, or use --download."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
index = builder.build
|
|
84
|
+
write_atomically(path_for(source), Marshal.dump(Payload.new(
|
|
85
|
+
format: FORMAT,
|
|
86
|
+
gem_version: VERSION,
|
|
87
|
+
headers_path: builder.headers_path,
|
|
88
|
+
topics_path: builder.topics_path,
|
|
89
|
+
fingerprint: fingerprint(builder.headers_path, builder.topics_path),
|
|
90
|
+
revision: builder.revision,
|
|
91
|
+
items: index.items.values
|
|
92
|
+
)))
|
|
93
|
+
use(source, builder.revision)
|
|
94
|
+
index
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def hydrate(source, payload)
|
|
98
|
+
index = Index.instance
|
|
99
|
+
index.reset!
|
|
100
|
+
payload.items.each { |item| index.add(item) }
|
|
101
|
+
use(source, payload.revision)
|
|
102
|
+
index
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def use(source, revision)
|
|
106
|
+
@path = path_for(source)
|
|
107
|
+
@revision = revision
|
|
108
|
+
write_atomically(current_path, Marshal.dump(source)) unless current_source == source
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def current_path
|
|
112
|
+
File.join(@dir, "current")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def current_source
|
|
116
|
+
Marshal.load(File.binread(current_path)) #: source
|
|
117
|
+
rescue
|
|
118
|
+
DEFAULT_SOURCE
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def load_payload(path)
|
|
122
|
+
payload = Marshal.load(File.binread(path)) #: Payload
|
|
123
|
+
payload if payload.format == FORMAT && payload.gem_version == VERSION
|
|
124
|
+
rescue
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Written beside the file and renamed into place, so a reader never sees half of it.
|
|
129
|
+
def write_atomically(path, data)
|
|
130
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
131
|
+
Tempfile.create(["cf-mcp", ".tmp"], File.dirname(path), binmode: true) do |file|
|
|
132
|
+
file.write(data)
|
|
133
|
+
file.close
|
|
134
|
+
File.rename(file.path, path)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fingerprint(headers_path, topics_path)
|
|
139
|
+
files = Dir.glob(File.join(headers_path, "**/*.h"))
|
|
140
|
+
files += Dir.glob(File.join(topics_path, "*.md")) if topics_path
|
|
141
|
+
stats = files.sort.filter_map do |file|
|
|
142
|
+
stat = File.stat(file)
|
|
143
|
+
[file, stat.mtime.to_f, stat.size].join(":")
|
|
144
|
+
rescue Errno::ENOENT
|
|
145
|
+
# Gone since it was listed: a header being replaced, as during an update.
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
Digest::SHA256.hexdigest(stats.join("\n"))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -11,7 +11,7 @@ module CF
|
|
|
11
11
|
:example_brief, :related, :source_file, :source_line
|
|
12
12
|
|
|
13
13
|
def initialize(
|
|
14
|
-
name
|
|
14
|
+
name:,
|
|
15
15
|
type: nil,
|
|
16
16
|
category: nil,
|
|
17
17
|
brief: nil,
|
|
@@ -63,7 +63,7 @@ module CF
|
|
|
63
63
|
def keyword_score(keyword)
|
|
64
64
|
score = 0
|
|
65
65
|
keyword_downcase = keyword.downcase
|
|
66
|
-
name_downcase = name
|
|
66
|
+
name_downcase = name.downcase
|
|
67
67
|
|
|
68
68
|
# Exact name match (highest priority)
|
|
69
69
|
if name_downcase == keyword_downcase
|
|
@@ -128,9 +128,7 @@ module CF
|
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
def to_text(detailed: false, index: nil)
|
|
131
|
-
lines =
|
|
132
|
-
lines.concat(build_header_lines)
|
|
133
|
-
lines.concat(build_description_lines)
|
|
131
|
+
lines = build_header_lines + build_description_lines
|
|
134
132
|
|
|
135
133
|
if detailed
|
|
136
134
|
lines.concat(build_type_specific_lines)
|
|
@@ -145,13 +143,9 @@ module CF
|
|
|
145
143
|
protected
|
|
146
144
|
|
|
147
145
|
def build_header_lines
|
|
148
|
-
lines = []
|
|
149
|
-
lines << "# #{name}"
|
|
150
|
-
lines << ""
|
|
151
|
-
lines << "- **Type:** #{type}"
|
|
146
|
+
lines = ["# #{name}", "", "- **Type:** #{type}"]
|
|
152
147
|
lines << "- **Category:** #{category}" if category
|
|
153
|
-
if
|
|
154
|
-
urls = source_urls
|
|
148
|
+
if (urls = source_urls)
|
|
155
149
|
lines << "- **Source:** [include/#{source_file}](#{urls[:blob]})"
|
|
156
150
|
lines << "- **Raw:** #{urls[:raw]}"
|
|
157
151
|
lines << "- **Implementation:** #{urls[:impl_raw]}"
|
|
@@ -161,8 +155,7 @@ module CF
|
|
|
161
155
|
end
|
|
162
156
|
|
|
163
157
|
def build_description_lines
|
|
164
|
-
lines = []
|
|
165
|
-
lines << "## Description"
|
|
158
|
+
lines = ["## Description"]
|
|
166
159
|
lines << brief if brief
|
|
167
160
|
lines << ""
|
|
168
161
|
lines
|
|
@@ -9,10 +9,11 @@ module CF
|
|
|
9
9
|
Entry = Data.define(:name, :value, :description)
|
|
10
10
|
|
|
11
11
|
def initialize(
|
|
12
|
+
name:,
|
|
12
13
|
entries: [],
|
|
13
14
|
**kwargs
|
|
14
15
|
)
|
|
15
|
-
super(type: :enum, **kwargs)
|
|
16
|
+
super(name:, type: :enum, **kwargs)
|
|
16
17
|
@entries = entries || []
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -27,11 +28,7 @@ module CF
|
|
|
27
28
|
def build_type_specific_lines
|
|
28
29
|
return [] unless entries && !entries.empty?
|
|
29
30
|
|
|
30
|
-
lines = []
|
|
31
|
-
lines << "## Values"
|
|
32
|
-
lines << ""
|
|
33
|
-
lines << "| Name | Value | Description |"
|
|
34
|
-
lines << "| --- | --- | --- |"
|
|
31
|
+
lines = ["## Values", "", "| Name | Value | Description |", "| --- | --- | --- |"]
|
|
35
32
|
entries.each do |entry|
|
|
36
33
|
lines << "| `#{entry.name}` | #{entry.value} | #{entry.description} |"
|
|
37
34
|
end
|
|
@@ -9,12 +9,13 @@ module CF
|
|
|
9
9
|
Parameter = Data.define(:name, :description)
|
|
10
10
|
|
|
11
11
|
def initialize(
|
|
12
|
+
name:,
|
|
12
13
|
signature: nil,
|
|
13
14
|
parameters: [],
|
|
14
15
|
return_value: nil,
|
|
15
16
|
**kwargs
|
|
16
17
|
)
|
|
17
|
-
super(type: :function, **kwargs)
|
|
18
|
+
super(name:, type: :function, **kwargs)
|
|
18
19
|
@signature = signature
|
|
19
20
|
@parameters = parameters || []
|
|
20
21
|
@return_value = return_value
|
|
@@ -35,10 +36,7 @@ module CF
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def to_text(detailed: false, index: nil)
|
|
38
|
-
lines =
|
|
39
|
-
lines.concat(build_header_lines)
|
|
40
|
-
lines.concat(build_signature_lines)
|
|
41
|
-
lines.concat(build_description_lines)
|
|
39
|
+
lines = build_header_lines + build_signature_lines + build_description_lines
|
|
42
40
|
|
|
43
41
|
if detailed
|
|
44
42
|
lines.concat(build_type_specific_lines)
|
|
@@ -58,26 +56,21 @@ module CF
|
|
|
58
56
|
end
|
|
59
57
|
|
|
60
58
|
def build_type_specific_lines
|
|
61
|
-
|
|
59
|
+
build_parameters_lines + build_return_value_lines
|
|
60
|
+
end
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
lines << ""
|
|
66
|
-
lines << "| Parameter | Description |"
|
|
67
|
-
lines << "| --- | --- |"
|
|
68
|
-
parameters.each do |param|
|
|
69
|
-
lines << "| `#{param.name}` | #{param.description} |"
|
|
70
|
-
end
|
|
71
|
-
lines << ""
|
|
72
|
-
end
|
|
62
|
+
def build_parameters_lines
|
|
63
|
+
return [] unless parameters && !parameters.empty?
|
|
73
64
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
["## Parameters", "", "| Parameter | Description |", "| --- | --- |"] +
|
|
66
|
+
parameters.map { |param| "| `#{param.name}` | #{param.description} |" } +
|
|
67
|
+
[""]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_return_value_lines
|
|
71
|
+
return [] unless return_value && !return_value.empty?
|
|
79
72
|
|
|
80
|
-
|
|
73
|
+
["## Return Value", return_value, ""]
|
|
81
74
|
end
|
|
82
75
|
end
|
|
83
76
|
end
|
|
@@ -9,10 +9,11 @@ module CF
|
|
|
9
9
|
Member = Data.define(:declaration, :description)
|
|
10
10
|
|
|
11
11
|
def initialize(
|
|
12
|
+
name:,
|
|
12
13
|
members: [],
|
|
13
14
|
**kwargs
|
|
14
15
|
)
|
|
15
|
-
super(type: :struct, **kwargs)
|
|
16
|
+
super(name:, type: :struct, **kwargs)
|
|
16
17
|
@members = members || []
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -27,11 +28,7 @@ module CF
|
|
|
27
28
|
def build_type_specific_lines
|
|
28
29
|
return [] unless members && !members.empty?
|
|
29
30
|
|
|
30
|
-
lines = []
|
|
31
|
-
lines << "## Members"
|
|
32
|
-
lines << ""
|
|
33
|
-
lines << "| Member | Description |"
|
|
34
|
-
lines << "| --- | --- |"
|
|
31
|
+
lines = ["## Members", "", "| Member | Description |", "| --- | --- |"]
|
|
35
32
|
members.each do |member|
|
|
36
33
|
lines << "| `#{member.declaration}` | #{member.description} |"
|
|
37
34
|
end
|
|
@@ -10,6 +10,7 @@ module CF
|
|
|
10
10
|
Section = Data.define(:title, :content)
|
|
11
11
|
|
|
12
12
|
def initialize(
|
|
13
|
+
name:,
|
|
13
14
|
content: nil,
|
|
14
15
|
sections: [],
|
|
15
16
|
function_references: [],
|
|
@@ -19,7 +20,7 @@ module CF
|
|
|
19
20
|
reading_order: nil,
|
|
20
21
|
**kwargs
|
|
21
22
|
)
|
|
22
|
-
super(type: :topic, **kwargs)
|
|
23
|
+
super(name:, type: :topic, **kwargs)
|
|
23
24
|
@content = content
|
|
24
25
|
@sections = sections || []
|
|
25
26
|
@function_references = function_references || []
|
|
@@ -50,10 +51,7 @@ module CF
|
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def to_text(detailed: false, index: nil)
|
|
53
|
-
lines = []
|
|
54
|
-
lines << "# #{name}"
|
|
55
|
-
lines << ""
|
|
56
|
-
lines << "**Type:** topic"
|
|
54
|
+
lines = ["# #{name}", "", "**Type:** topic"]
|
|
57
55
|
lines << "**Category:** #{category}" if category
|
|
58
56
|
lines << "**Source:** #{source_file}" if source_file
|
|
59
57
|
lines << ""
|
data/lib/cf/mcp/parser.rb
CHANGED
|
@@ -15,13 +15,13 @@ module CF
|
|
|
15
15
|
content = File.read(path)
|
|
16
16
|
source_file = File.basename(path)
|
|
17
17
|
line_offsets = build_line_offsets(content)
|
|
18
|
-
items = []
|
|
18
|
+
items = [] #: Array[Models::DocItem]
|
|
19
19
|
|
|
20
20
|
# Find all documentation blocks with their positions
|
|
21
21
|
content.scan(%r{(/\*\*.*?\*/)(.*?)(?=/\*\*|\z)}m) do |doc_block, following_content|
|
|
22
22
|
# Get the position of the match to calculate line number
|
|
23
|
-
|
|
24
|
-
source_line = line_for_position(
|
|
23
|
+
match = Regexp.last_match || raise("scan yielded without a match")
|
|
24
|
+
source_line = line_for_position(match.begin(0), line_offsets)
|
|
25
25
|
item = parse_doc_block(doc_block, following_content.strip, source_file, source_line)
|
|
26
26
|
items << item if item
|
|
27
27
|
end
|
|
@@ -30,12 +30,10 @@ module CF
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def parse_directory(path)
|
|
33
|
-
|
|
34
|
-
Dir.glob(File.join(path, "**/*.h")).each do |header_file|
|
|
33
|
+
Dir.glob(File.join(path, "**/*.h")).flat_map do |header_file|
|
|
35
34
|
warn "Parsing #{header_file}" if $stderr.isatty
|
|
36
|
-
|
|
35
|
+
parse_file(header_file)
|
|
37
36
|
end
|
|
38
|
-
items
|
|
39
37
|
end
|
|
40
38
|
|
|
41
39
|
private
|
|
@@ -73,34 +71,23 @@ module CF
|
|
|
73
71
|
end
|
|
74
72
|
|
|
75
73
|
def extract_tags(doc_block)
|
|
76
|
-
tags = {}
|
|
74
|
+
tags = {} #: Hash[Symbol, untyped]
|
|
77
75
|
|
|
78
76
|
# Remove comment markers and clean up
|
|
79
77
|
lines = doc_block.lines.map do |line|
|
|
80
78
|
line.gsub(%r{^\s*/?\*+\s?}, "").gsub(%r{\s*\*+/\s*$}, "")
|
|
81
79
|
end
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
lines.each do |line|
|
|
87
|
-
if line =~ TAG_PATTERN
|
|
88
|
-
# Save previous tag
|
|
89
|
-
if current_tag
|
|
90
|
-
save_tag(tags, current_tag, current_content.join("\n").strip)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
current_tag = ::Regexp.last_match(1)
|
|
94
|
-
remaining = line.sub(TAG_PATTERN, "").strip
|
|
95
|
-
current_content = [remaining]
|
|
96
|
-
elsif current_tag
|
|
97
|
-
current_content << line
|
|
98
|
-
end
|
|
99
|
-
end
|
|
81
|
+
# A tag starts on a line containing @tag and runs until the next one.
|
|
82
|
+
lines.slice_before { |line| TAG_PATTERN.match?(line) }.each do |tag_lines|
|
|
83
|
+
first_line = tag_lines.fetch(0)
|
|
100
84
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
85
|
+
# Text before the first tag has no tag to belong to
|
|
86
|
+
tag = first_line[TAG_PATTERN, 1]
|
|
87
|
+
next unless tag
|
|
88
|
+
|
|
89
|
+
remaining = first_line.sub(TAG_PATTERN, "").strip
|
|
90
|
+
save_tag(tags, tag, [remaining, *tag_lines.drop(1)].join("\n").strip)
|
|
104
91
|
end
|
|
105
92
|
|
|
106
93
|
tags
|
|
@@ -111,8 +98,8 @@ module CF
|
|
|
111
98
|
when "param"
|
|
112
99
|
tags[:params] ||= []
|
|
113
100
|
# Parse "param_name description" format
|
|
114
|
-
if
|
|
115
|
-
tags[:params] << {name:
|
|
101
|
+
if (match = /^(\w+)\s+(.*)$/m.match(content))
|
|
102
|
+
tags[:params] << {name: match[1], description: match[2].strip}
|
|
116
103
|
end
|
|
117
104
|
when "related"
|
|
118
105
|
# Filter out comment artifacts like "/" or "*/"
|
|
@@ -184,23 +171,16 @@ module CF
|
|
|
184
171
|
|
|
185
172
|
def extract_signature(content)
|
|
186
173
|
# Find the first function declaration (ending with ; or {)
|
|
187
|
-
|
|
188
|
-
signature_lines = []
|
|
189
|
-
|
|
190
|
-
lines.each do |line|
|
|
174
|
+
signature_lines = content.lines
|
|
191
175
|
# Skip empty lines and comments at the start
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
176
|
+
.drop_while { |line| line.strip.empty? }
|
|
177
|
+
# Stop at the first empty line, and at struct/enum definitions
|
|
178
|
+
.take_while { |line| !line.strip.empty? && !/^typedef\s+(struct|enum)/.match?(line) && !/^#define/.match?(line) }
|
|
179
|
+
# ...and just after the line that ends the declaration
|
|
180
|
+
.slice_after { |line| line.include?(";") || line.include?("{") }
|
|
181
|
+
.first
|
|
198
182
|
|
|
199
|
-
|
|
200
|
-
break if line.include?(";") || line.include?("{")
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
return nil if signature_lines.empty?
|
|
183
|
+
return nil if signature_lines.nil? || signature_lines.empty?
|
|
204
184
|
|
|
205
185
|
signature = signature_lines.join.strip
|
|
206
186
|
# Clean up macros and normalize whitespace
|
|
@@ -211,33 +191,25 @@ module CF
|
|
|
211
191
|
end
|
|
212
192
|
|
|
213
193
|
def extract_members(content)
|
|
214
|
-
members = []
|
|
215
|
-
|
|
216
194
|
# Find /* @member ... */ comments and the following declaration
|
|
217
195
|
# Use [^/]+? to ensure we capture content (non-slash chars) before the next comment
|
|
218
|
-
content.scan(%r{/\*\s*@member\s+(.*?)\s*\*/\s*([^/]+?)(?=/\*|//\s*@end|$)}m) do |description, declaration|
|
|
196
|
+
content.scan(%r{/\*\s*@member\s+(.*?)\s*\*/\s*([^/]+?)(?=/\*|//\s*@end|$)}m).filter_map do |description, declaration|
|
|
219
197
|
decl = declaration.strip.lines.first&.strip
|
|
220
198
|
next unless decl && !decl.empty?
|
|
221
199
|
|
|
222
200
|
# Clean up the declaration (remove trailing semicolon for display)
|
|
223
201
|
decl = decl.gsub(/;$/, "").strip
|
|
224
|
-
|
|
202
|
+
Models::StructDoc::Member.new(decl, description.strip)
|
|
225
203
|
end
|
|
226
|
-
|
|
227
|
-
members
|
|
228
204
|
end
|
|
229
205
|
|
|
230
206
|
def extract_enum_entries(content)
|
|
231
|
-
entries = []
|
|
232
|
-
|
|
233
207
|
# Find the #define block with CF_ENUM macros
|
|
234
208
|
# Pattern: /* @entry description */ followed by CF_ENUM(NAME, VALUE)
|
|
235
|
-
content.scan(%r{/\*\s*@entry\s+(.*?)\s*\*/\s*\\?\s*CF_ENUM\s*\(\s*(\w+)\s*,\s*([^)]*)\)}m) do |description, name, value|
|
|
209
|
+
content.scan(%r{/\*\s*@entry\s+(.*?)\s*\*/\s*\\?\s*CF_ENUM\s*\(\s*(\w+)\s*,\s*([^)]*)\)}m).map do |description, name, value|
|
|
236
210
|
# CF_ENUM(K, V) expands to CF_##K = V, so add CF_ prefix
|
|
237
|
-
|
|
211
|
+
Models::EnumDoc::Entry.new("CF_#{name.strip}", value.strip, description.strip)
|
|
238
212
|
end
|
|
239
|
-
|
|
240
|
-
entries
|
|
241
213
|
end
|
|
242
214
|
end
|
|
243
215
|
end
|
data/lib/cf/mcp/server.rb
CHANGED
|
@@ -12,7 +12,7 @@ module CF
|
|
|
12
12
|
CORS_HEADERS = {
|
|
13
13
|
"access-control-allow-origin" => "*",
|
|
14
14
|
"access-control-allow-methods" => "GET, POST, DELETE, OPTIONS",
|
|
15
|
-
"access-control-allow-headers" => "Content-Type, Accept, Mcp-Session-Id",
|
|
15
|
+
"access-control-allow-headers" => "Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id",
|
|
16
16
|
"access-control-expose-headers" => "Mcp-Session-Id"
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
@@ -37,33 +37,37 @@ module CF
|
|
|
37
37
|
new(index, revision: builder.revision).rack_app
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
# Modern clients carry their version on every request (no handshake); legacy
|
|
41
|
+
# clients negotiate through `initialize`. Both track the SDK rather than pinning.
|
|
42
|
+
MODERN_PROTOCOL_VERSION = ::MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
|
|
43
|
+
LEGACY_PROTOCOL_VERSION = ::MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
|
44
|
+
DESCRIPTION = "Search and browse Cute Framework API documentation: functions, structs, enums, and topic guides."
|
|
41
45
|
WEBSITE_URL = ENV.fetch("FLY_APP_NAME", nil) ? "https://#{ENV["FLY_APP_NAME"]}.fly.dev" : "https://cf-mcp.fly.dev"
|
|
42
46
|
PUBLIC_DIR = File.join(__dir__, "public")
|
|
47
|
+
ALLOWED_HOSTS_ENV = "CF_MCP_ALLOWED_HOSTS"
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
# Comma-separated list of extra Host header values the HTTP transport accepts
|
|
50
|
+
# (the MCP SDK only allows loopback hosts by default).
|
|
51
|
+
def self.allowed_hosts_from_env(env = ENV)
|
|
52
|
+
env.fetch(ALLOWED_HOSTS_ENV, "").split(",").map(&:strip).reject(&:empty?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(index, revision: nil, allowed_hosts: self.class.allowed_hosts_from_env)
|
|
45
56
|
@index = index
|
|
46
57
|
@revision = revision
|
|
58
|
+
@allowed_hosts = allowed_hosts
|
|
47
59
|
|
|
48
|
-
configuration = ::MCP::Configuration.new(protocol_version: PROTOCOL_VERSION)
|
|
49
60
|
@server = ::MCP::Server.new(
|
|
50
61
|
name: "cf-mcp",
|
|
51
|
-
|
|
62
|
+
title: "Cute Framework MCP",
|
|
63
|
+
description: DESCRIPTION,
|
|
52
64
|
version: CF::MCP::VERSION,
|
|
65
|
+
website_url: WEBSITE_URL,
|
|
53
66
|
icons: [
|
|
54
67
|
::MCP::Icon.new(src: "#{WEBSITE_URL}/favicon.svg", mime_type: "image/svg+xml", sizes: ["any"]),
|
|
55
68
|
::MCP::Icon.new(src: "#{WEBSITE_URL}/favicon-96x96.png", mime_type: "image/png", sizes: ["96x96"])
|
|
56
69
|
],
|
|
57
|
-
tools:
|
|
58
|
-
Tools::SearchTool,
|
|
59
|
-
Tools::ListCategory,
|
|
60
|
-
Tools::GetDetails,
|
|
61
|
-
Tools::FindRelated,
|
|
62
|
-
Tools::ParameterSearch,
|
|
63
|
-
Tools::MemberSearch,
|
|
64
|
-
Tools::ListTopics,
|
|
65
|
-
Tools::GetTopic
|
|
66
|
-
],
|
|
70
|
+
tools: Tools.all,
|
|
67
71
|
resources: build_topic_resources(index)
|
|
68
72
|
)
|
|
69
73
|
@server.server_context = {index: index}
|
|
@@ -82,7 +86,7 @@ module CF
|
|
|
82
86
|
def rack_app
|
|
83
87
|
require "rack"
|
|
84
88
|
|
|
85
|
-
http_transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server, stateless: true)
|
|
89
|
+
http_transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server, stateless: true, allowed_hosts: @allowed_hosts)
|
|
86
90
|
@server.transport = http_transport
|
|
87
91
|
|
|
88
92
|
landing_page = build_landing_page
|
|
@@ -97,7 +101,7 @@ module CF
|
|
|
97
101
|
|
|
98
102
|
# Handle CORS preflight
|
|
99
103
|
if request.options?
|
|
100
|
-
|
|
104
|
+
next [204, cors_headers, []] #: [Integer, Hash[String, String], Array[String]]
|
|
101
105
|
end
|
|
102
106
|
|
|
103
107
|
# Route based on path
|
|
@@ -136,12 +140,12 @@ module CF
|
|
|
136
140
|
end
|
|
137
141
|
|
|
138
142
|
# Add CORS headers to response
|
|
139
|
-
[status, headers.merge(cors_headers), body]
|
|
143
|
+
[status, headers.merge(cors_headers), body] #: ::Rack::response
|
|
140
144
|
}
|
|
141
145
|
|
|
142
|
-
Rack::Builder.new do
|
|
143
|
-
use Rack::CommonLogger
|
|
144
|
-
run app
|
|
146
|
+
Rack::Builder.new do |builder|
|
|
147
|
+
builder.use Rack::CommonLogger
|
|
148
|
+
builder.run app
|
|
145
149
|
end
|
|
146
150
|
end
|
|
147
151
|
|
|
@@ -165,7 +169,7 @@ module CF
|
|
|
165
169
|
topic_name = uri.sub("cf://topics/", "")
|
|
166
170
|
topic = index.find(topic_name)
|
|
167
171
|
|
|
168
|
-
return [] unless topic
|
|
172
|
+
return [] unless topic.is_a?(Models::TopicDoc)
|
|
169
173
|
|
|
170
174
|
[{
|
|
171
175
|
uri: uri,
|
|
@@ -193,7 +197,8 @@ module CF
|
|
|
193
197
|
->(index, tool_classes) {
|
|
194
198
|
context = TemplateContext.new(
|
|
195
199
|
version: CF::MCP::VERSION,
|
|
196
|
-
|
|
200
|
+
modern_protocol_version: MODERN_PROTOCOL_VERSION,
|
|
201
|
+
legacy_protocol_version: LEGACY_PROTOCOL_VERSION,
|
|
197
202
|
revision: @revision,
|
|
198
203
|
revision_url: @revision && format(CUTE_FRAMEWORK_COMMIT_URL_TEMPLATE, revision: @revision),
|
|
199
204
|
stats: index.stats,
|
|
@@ -219,11 +224,12 @@ module CF
|
|
|
219
224
|
class TemplateContext
|
|
220
225
|
TEMPLATES_DIR = File.join(__dir__, "templates")
|
|
221
226
|
|
|
222
|
-
attr_reader :version, :
|
|
227
|
+
attr_reader :version, :modern_protocol_version, :legacy_protocol_version, :revision, :revision_url, :stats, :categories, :topics, :tools, :tool_schemas_json
|
|
223
228
|
|
|
224
|
-
def initialize(version:,
|
|
229
|
+
def initialize(version:, modern_protocol_version:, legacy_protocol_version:, revision:, revision_url:, stats:, categories:, topics:, tools:, tool_schemas_json:)
|
|
225
230
|
@version = version
|
|
226
|
-
@
|
|
231
|
+
@modern_protocol_version = modern_protocol_version
|
|
232
|
+
@legacy_protocol_version = legacy_protocol_version
|
|
227
233
|
@revision = revision
|
|
228
234
|
@revision_url = revision_url
|
|
229
235
|
@stats = stats
|
|
@@ -256,7 +262,10 @@ module CF
|
|
|
256
262
|
|
|
257
263
|
def js_content
|
|
258
264
|
js = File.read(File.join(TEMPLATES_DIR, "script.js"))
|
|
259
|
-
|
|
265
|
+
# Substitute the scalar placeholders first so injected content can't collide with them
|
|
266
|
+
js.sub("PROTOCOL_VERSION_PLACEHOLDER", @modern_protocol_version.to_json)
|
|
267
|
+
.sub("CLIENT_VERSION_PLACEHOLDER", @version.to_json)
|
|
268
|
+
.sub("TOOL_SCHEMAS_PLACEHOLDER", @tool_schemas_json)
|
|
260
269
|
.sub("CATEGORIES_PLACEHOLDER", categories_json)
|
|
261
270
|
.sub("TOPICS_PLACEHOLDER", topics_json)
|
|
262
271
|
.sub("CHANGELOG_PLACEHOLDER", changelog_json)
|