cf-mcp 0.9.2
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 +7 -0
- data/LICENSE.txt +21 -0
- data/Manifest.txt +34 -0
- data/README.md +52 -0
- data/Rakefile +53 -0
- data/config.ru +26 -0
- data/exe/cf-mcp +6 -0
- data/lib/cf/mcp/cli.rb +211 -0
- data/lib/cf/mcp/downloader.rb +112 -0
- data/lib/cf/mcp/index.rb +128 -0
- data/lib/cf/mcp/models/doc_item.rb +171 -0
- data/lib/cf/mcp/models/enum_doc.rb +83 -0
- data/lib/cf/mcp/models/function_doc.rb +110 -0
- data/lib/cf/mcp/models/struct_doc.rb +83 -0
- data/lib/cf/mcp/models/topic_doc.rb +113 -0
- data/lib/cf/mcp/parser.rb +246 -0
- data/lib/cf/mcp/server.rb +316 -0
- data/lib/cf/mcp/templates/index.erb +94 -0
- data/lib/cf/mcp/templates/script.js +292 -0
- data/lib/cf/mcp/templates/style.css +165 -0
- data/lib/cf/mcp/tools/find_related.rb +77 -0
- data/lib/cf/mcp/tools/get_details.rb +64 -0
- data/lib/cf/mcp/tools/get_topic.rb +53 -0
- data/lib/cf/mcp/tools/list_category.rb +77 -0
- data/lib/cf/mcp/tools/list_topics.rb +64 -0
- data/lib/cf/mcp/tools/member_search.rb +76 -0
- data/lib/cf/mcp/tools/parameter_search.rb +102 -0
- data/lib/cf/mcp/tools/search_enums.rb +57 -0
- data/lib/cf/mcp/tools/search_functions.rb +57 -0
- data/lib/cf/mcp/tools/search_structs.rb +57 -0
- data/lib/cf/mcp/tools/search_tool.rb +58 -0
- data/lib/cf/mcp/topic_parser.rb +199 -0
- data/lib/cf/mcp/version.rb +7 -0
- data/lib/cf/mcp.rb +23 -0
- data/sig/cf/mcp.rbs +84 -0
- metadata +150 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4fdaf411a4f3224a85c8399b9bd6964688ad3a6e8ce68ab61abbd76c32833c8e
|
|
4
|
+
data.tar.gz: b23b96e53d98c56b9fc3364ec76a3a67c50df807b152d8b0e43f1f07db626d2e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 23b5cfa8ed27a9f71d8c031cefabad9b12c83ae5e109f9306b2456fcc0979c305287bcfde0e0dd2581009f84683b78b0e16630a52e8c665aba15c0dc26639453
|
|
7
|
+
data.tar.gz: 1e1785c0bac762cf2b77dfa4bf26faa4e9093124ccf2be74972cc34552e315ca20dbe9a530136b8b34f485d6b1ae727bd617028e5ef3bdad3feb6684787d1fb6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Piotr Usewicz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/Manifest.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
Manifest.txt
|
|
3
|
+
README.md
|
|
4
|
+
Rakefile
|
|
5
|
+
config.ru
|
|
6
|
+
exe/cf-mcp
|
|
7
|
+
lib/cf/mcp.rb
|
|
8
|
+
lib/cf/mcp/cli.rb
|
|
9
|
+
lib/cf/mcp/downloader.rb
|
|
10
|
+
lib/cf/mcp/index.rb
|
|
11
|
+
lib/cf/mcp/models/doc_item.rb
|
|
12
|
+
lib/cf/mcp/models/enum_doc.rb
|
|
13
|
+
lib/cf/mcp/models/function_doc.rb
|
|
14
|
+
lib/cf/mcp/models/struct_doc.rb
|
|
15
|
+
lib/cf/mcp/models/topic_doc.rb
|
|
16
|
+
lib/cf/mcp/parser.rb
|
|
17
|
+
lib/cf/mcp/server.rb
|
|
18
|
+
lib/cf/mcp/templates/index.erb
|
|
19
|
+
lib/cf/mcp/templates/script.js
|
|
20
|
+
lib/cf/mcp/templates/style.css
|
|
21
|
+
lib/cf/mcp/tools/find_related.rb
|
|
22
|
+
lib/cf/mcp/tools/get_details.rb
|
|
23
|
+
lib/cf/mcp/tools/get_topic.rb
|
|
24
|
+
lib/cf/mcp/tools/list_category.rb
|
|
25
|
+
lib/cf/mcp/tools/list_topics.rb
|
|
26
|
+
lib/cf/mcp/tools/member_search.rb
|
|
27
|
+
lib/cf/mcp/tools/parameter_search.rb
|
|
28
|
+
lib/cf/mcp/tools/search_enums.rb
|
|
29
|
+
lib/cf/mcp/tools/search_functions.rb
|
|
30
|
+
lib/cf/mcp/tools/search_structs.rb
|
|
31
|
+
lib/cf/mcp/tools/search_tool.rb
|
|
32
|
+
lib/cf/mcp/topic_parser.rb
|
|
33
|
+
lib/cf/mcp/version.rb
|
|
34
|
+
sig/cf/mcp.rbs
|
data/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# CF::MCP
|
|
2
|
+
|
|
3
|
+
CF::MCP is an MCP server providing documentation tools for the [Cute Framework](https://github.com/RandyGaul/cute_framework), a C/C++ 2D game framework.
|
|
4
|
+
|
|
5
|
+
The MCP server supports three modes of operation:
|
|
6
|
+
|
|
7
|
+
- **STDIO Mode**: Communicates via standard input and output streams, suitable for integration with CLI tools and desktop applications.
|
|
8
|
+
- **HTTP Mode**: Operates as a stateless HTTP server, suitable for simple request/response interactions and multi-node deployments.
|
|
9
|
+
- **SSE Mode**: Operates as a stateful HTTP server with Server-Sent Events support, enabling real-time notifications and streaming responses.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Documentation Generation**: Automatically generates documentation for Cute Framework projects by indexing the the header files and extracting the documentation from the comments.
|
|
14
|
+
- **Search Functionality**: Provides a search feature to quickly find structs, classes, functions, and other elements within the documentation.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle add cf-mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gem install cf-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
To start the MCP server, run the following command in your terminal:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cf-mcp stdio --root /path/to/cute_framework_project # STDIO mode
|
|
36
|
+
cf-mcp http --root /path/to/cute_framework_project # HTTP mode (stateless)
|
|
37
|
+
cf-mcp sse --root /path/to/cute_framework_project # SSE mode (stateful, real-time)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
43
|
+
|
|
44
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
45
|
+
|
|
46
|
+
## Contributing
|
|
47
|
+
|
|
48
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/pusewicz/cf-mcp.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
require "standard/rake"
|
|
9
|
+
|
|
10
|
+
desc "Generate Manifest.txt from git ls-files"
|
|
11
|
+
task :manifest do
|
|
12
|
+
ignore_patterns = %w[bin/ Gemfile .gitignore test/ .github/ .standard.yml cf-mcp.gemspec .ruby-version CLAUDE.md fly.toml Procfile Dockerfile .dockerignore .claude/]
|
|
13
|
+
|
|
14
|
+
files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
|
|
15
|
+
ls.readlines("\x0", chomp: true)
|
|
16
|
+
end.reject { |f| f.start_with?(*ignore_patterns) }.sort
|
|
17
|
+
|
|
18
|
+
# Add Manifest.txt itself so it's included in the gem
|
|
19
|
+
files << "Manifest.txt" unless files.include?("Manifest.txt")
|
|
20
|
+
files.sort!
|
|
21
|
+
|
|
22
|
+
File.write("Manifest.txt", files.join("\n") + "\n")
|
|
23
|
+
puts "Generated Manifest.txt with #{files.size} files"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
task default: %i[test standard manifest]
|
|
27
|
+
|
|
28
|
+
desc "Deploy to Fly.io (runs tests and linting first)"
|
|
29
|
+
task deploy: %i[test standard] do
|
|
30
|
+
sh "fly deploy"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "Create a git tag for the current version"
|
|
34
|
+
task :tag do
|
|
35
|
+
require_relative "lib/cf/mcp/version"
|
|
36
|
+
version = CF::MCP::VERSION
|
|
37
|
+
tag = "v#{version}"
|
|
38
|
+
|
|
39
|
+
if system("git", "rev-parse", tag, out: File::NULL, err: File::NULL)
|
|
40
|
+
puts "Tag #{tag} already exists"
|
|
41
|
+
else
|
|
42
|
+
sh "git", "tag", "-a", tag, "-m", "Release #{version}"
|
|
43
|
+
puts "Created tag #{tag}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "Create and push git tag for current version"
|
|
48
|
+
task "release:tag" => %i[test standard tag] do
|
|
49
|
+
require_relative "lib/cf/mcp/version"
|
|
50
|
+
tag = "v#{CF::MCP::VERSION}"
|
|
51
|
+
sh "git", "push", "origin", tag
|
|
52
|
+
puts "Pushed #{tag} to origin"
|
|
53
|
+
end
|
data/config.ru
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/cf/mcp"
|
|
4
|
+
|
|
5
|
+
# Download headers from GitHub if not already present
|
|
6
|
+
warn "Initializing CF::MCP server..."
|
|
7
|
+
downloader = CF::MCP::Downloader.new
|
|
8
|
+
headers_path = downloader.download_and_extract
|
|
9
|
+
warn "Using headers from: #{headers_path}"
|
|
10
|
+
|
|
11
|
+
# Build the index
|
|
12
|
+
warn "Parsing headers..."
|
|
13
|
+
parser = CF::MCP::Parser.new
|
|
14
|
+
index = CF::MCP::Index.new
|
|
15
|
+
|
|
16
|
+
parser.parse_directory(headers_path).each do |item|
|
|
17
|
+
index.add(item)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
warn "Indexed #{index.stats[:total]} items (#{index.stats[:functions]} functions, #{index.stats[:structs]} structs, #{index.stats[:enums]} enums)"
|
|
21
|
+
|
|
22
|
+
# Create and run the combined server with both SSE and HTTP transports
|
|
23
|
+
# - / and /sse - SSE transport (stateful, for Claude Desktop)
|
|
24
|
+
# - /http - HTTP transport (stateless, for simple integrations)
|
|
25
|
+
server = CF::MCP::CombinedServer.new(index)
|
|
26
|
+
run server.rack_app
|
data/exe/cf-mcp
ADDED
data/lib/cf/mcp/cli.rb
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
require_relative "topic_parser"
|
|
6
|
+
require_relative "index"
|
|
7
|
+
require_relative "server"
|
|
8
|
+
require_relative "downloader"
|
|
9
|
+
|
|
10
|
+
module CF
|
|
11
|
+
module MCP
|
|
12
|
+
class CLI
|
|
13
|
+
DEFAULT_HEADERS_PATH = File.expand_path("~/Work/GitHub/pusewicz/cute_framework/include")
|
|
14
|
+
|
|
15
|
+
def initialize(args)
|
|
16
|
+
@args = args
|
|
17
|
+
@options = parse_args
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
case @options[:command]
|
|
22
|
+
when :stdio
|
|
23
|
+
run_server(:stdio)
|
|
24
|
+
when :http
|
|
25
|
+
run_server(:http)
|
|
26
|
+
when :sse
|
|
27
|
+
run_server(:sse)
|
|
28
|
+
when :combined
|
|
29
|
+
run_combined_server
|
|
30
|
+
when :help
|
|
31
|
+
puts @option_parser
|
|
32
|
+
else
|
|
33
|
+
warn "Unknown command. Use --help for usage information."
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse_args
|
|
41
|
+
options = {
|
|
42
|
+
command: nil,
|
|
43
|
+
port: nil,
|
|
44
|
+
root: nil,
|
|
45
|
+
download: false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@option_parser = OptionParser.new do |opts|
|
|
49
|
+
opts.banner = "Usage: cf-mcp <command> [options]"
|
|
50
|
+
opts.separator ""
|
|
51
|
+
opts.separator "Commands:"
|
|
52
|
+
opts.separator " stdio Run in STDIO mode (for CLI integration)"
|
|
53
|
+
opts.separator " http Run as HTTP server (stateless)"
|
|
54
|
+
opts.separator " sse Run as SSE server (stateful with real-time updates)"
|
|
55
|
+
opts.separator " combined Run as combined server (SSE + HTTP with web interface)"
|
|
56
|
+
opts.separator ""
|
|
57
|
+
opts.separator "Options:"
|
|
58
|
+
|
|
59
|
+
opts.on("-r", "--root PATH", "Path to Cute Framework headers directory") do |path|
|
|
60
|
+
options[:root] = path
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("-p", "--port PORT", Integer, "Port for HTTP/SSE server (default: 9292 for HTTP, 9393 for SSE)") do |port|
|
|
64
|
+
options[:port] = port
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
opts.on("-d", "--download", "Download Cute Framework headers from GitHub") do
|
|
68
|
+
options[:download] = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
72
|
+
options[:command] = :help
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts.on("-v", "--version", "Show version") do
|
|
76
|
+
puts "cf-mcp #{CF::MCP::VERSION}"
|
|
77
|
+
exit 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@option_parser.parse!(@args)
|
|
82
|
+
|
|
83
|
+
# Parse command from remaining args
|
|
84
|
+
if options[:command].nil? && !@args.empty?
|
|
85
|
+
command = @args.shift.to_sym
|
|
86
|
+
options[:command] = command if [:stdio, :http, :sse, :combined].include?(command)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
options[:command] ||= :help
|
|
90
|
+
options
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run_server(mode)
|
|
94
|
+
headers_path = resolve_headers_path
|
|
95
|
+
|
|
96
|
+
unless File.directory?(headers_path)
|
|
97
|
+
warn "Error: Headers directory not found: #{headers_path}"
|
|
98
|
+
warn "Use --root to specify the path to Cute Framework headers"
|
|
99
|
+
warn "Or use --download to fetch headers from GitHub"
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
warn "Parsing headers from: #{headers_path}"
|
|
104
|
+
index = build_index(headers_path)
|
|
105
|
+
warn "Indexed #{index.stats[:total]} items (#{index.stats[:functions]} functions, #{index.stats[:structs]} structs, #{index.stats[:enums]} enums)"
|
|
106
|
+
|
|
107
|
+
server = Server.new(index)
|
|
108
|
+
|
|
109
|
+
case mode
|
|
110
|
+
when :stdio
|
|
111
|
+
server.run_stdio
|
|
112
|
+
when :http
|
|
113
|
+
port = @options[:port] || 9292
|
|
114
|
+
server.run_http(port: port)
|
|
115
|
+
when :sse
|
|
116
|
+
port = @options[:port] || 9393
|
|
117
|
+
server.run_sse(port: port)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_combined_server
|
|
122
|
+
require "rack"
|
|
123
|
+
require "rackup"
|
|
124
|
+
|
|
125
|
+
headers_path = resolve_headers_path
|
|
126
|
+
|
|
127
|
+
unless File.directory?(headers_path)
|
|
128
|
+
warn "Error: Headers directory not found: #{headers_path}"
|
|
129
|
+
warn "Use --root to specify the path to Cute Framework headers"
|
|
130
|
+
warn "Or use --download to fetch headers from GitHub"
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
warn "Parsing headers from: #{headers_path}"
|
|
135
|
+
index = build_index(headers_path)
|
|
136
|
+
warn "Indexed #{index.stats[:total]} items (#{index.stats[:functions]} functions, #{index.stats[:structs]} structs, #{index.stats[:enums]} enums)"
|
|
137
|
+
|
|
138
|
+
port = @options[:port] || 9292
|
|
139
|
+
server = CombinedServer.new(index)
|
|
140
|
+
app = server.rack_app
|
|
141
|
+
|
|
142
|
+
warn "Starting combined server on port #{port}..."
|
|
143
|
+
warn "Web interface available at http://localhost:#{port}/"
|
|
144
|
+
Rackup::Server.start(app: app, Port: port, Logger: $stderr)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_headers_path
|
|
148
|
+
return @options[:root] if @options[:root]
|
|
149
|
+
return ENV["CF_HEADERS_PATH"] if ENV["CF_HEADERS_PATH"]
|
|
150
|
+
|
|
151
|
+
if @options[:download]
|
|
152
|
+
warn "Downloading Cute Framework headers from GitHub..."
|
|
153
|
+
downloader = Downloader.new
|
|
154
|
+
path = downloader.download_and_extract
|
|
155
|
+
warn "Downloaded headers to: #{path}"
|
|
156
|
+
return path
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
DEFAULT_HEADERS_PATH
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_index(headers_path)
|
|
163
|
+
parser = Parser.new
|
|
164
|
+
index = Index.new
|
|
165
|
+
|
|
166
|
+
parser.parse_directory(headers_path).each do |item|
|
|
167
|
+
index.add(item)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Parse topics if available
|
|
171
|
+
topics_path = find_topics_path(headers_path)
|
|
172
|
+
if topics_path && File.directory?(topics_path)
|
|
173
|
+
topic_parser = TopicParser.new
|
|
174
|
+
topic_parser.parse_directory(topics_path).each do |topic|
|
|
175
|
+
refine_topic_references(topic, index)
|
|
176
|
+
index.add(topic)
|
|
177
|
+
end
|
|
178
|
+
warn "Indexed #{index.stats[:topics]} topics from: #{topics_path}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
index
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def find_topics_path(headers_path)
|
|
185
|
+
# If headers_path is .../cute_framework/include, topics is at .../cute_framework/docs/topics
|
|
186
|
+
base = File.dirname(headers_path)
|
|
187
|
+
topics_path = File.join(base, "docs", "topics")
|
|
188
|
+
return topics_path if File.directory?(topics_path)
|
|
189
|
+
|
|
190
|
+
# Alternative: topics directly under headers parent
|
|
191
|
+
topics_path = File.join(base, "topics")
|
|
192
|
+
return topics_path if File.directory?(topics_path)
|
|
193
|
+
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def refine_topic_references(topic, index)
|
|
198
|
+
# Move items from struct_references to enum_references if they're actually enums
|
|
199
|
+
topic.struct_references.dup.each do |ref|
|
|
200
|
+
item = index.find(ref)
|
|
201
|
+
next unless item
|
|
202
|
+
|
|
203
|
+
if item.type == :enum
|
|
204
|
+
topic.struct_references.delete(ref)
|
|
205
|
+
topic.enum_references << ref unless topic.enum_references.include?(ref)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "zip"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module CF
|
|
10
|
+
module MCP
|
|
11
|
+
class Downloader
|
|
12
|
+
CUTE_FRAMEWORK_ZIP_URL = "https://github.com/RandyGaul/cute_framework/archive/refs/heads/master.zip"
|
|
13
|
+
DEFAULT_DOWNLOAD_DIR = File.join(Dir.tmpdir, "cf-mcp-#{VERSION}")
|
|
14
|
+
|
|
15
|
+
class DownloadError < StandardError; end
|
|
16
|
+
|
|
17
|
+
def initialize(download_dir: DEFAULT_DOWNLOAD_DIR)
|
|
18
|
+
@download_dir = download_dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def download_and_extract
|
|
22
|
+
FileUtils.mkdir_p(@download_dir)
|
|
23
|
+
|
|
24
|
+
zip_path = File.join(@download_dir, "cute_framework.zip")
|
|
25
|
+
base_path = File.join(@download_dir, "cute_framework")
|
|
26
|
+
include_path = File.join(base_path, "include")
|
|
27
|
+
File.join(base_path, "docs", "topics")
|
|
28
|
+
|
|
29
|
+
# Return existing path if already downloaded
|
|
30
|
+
if File.directory?(include_path) && !Dir.empty?(include_path)
|
|
31
|
+
return include_path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
download_zip(zip_path)
|
|
35
|
+
extract_directories(zip_path, base_path)
|
|
36
|
+
|
|
37
|
+
include_path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def download_zip(destination)
|
|
43
|
+
uri = URI.parse(CUTE_FRAMEWORK_ZIP_URL)
|
|
44
|
+
|
|
45
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
46
|
+
request = Net::HTTP::Get.new(uri)
|
|
47
|
+
response = http.request(request)
|
|
48
|
+
|
|
49
|
+
# Handle redirects (GitHub redirects to codeload.github.com)
|
|
50
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
51
|
+
redirect_uri = URI.parse(response["location"])
|
|
52
|
+
Net::HTTP.start(redirect_uri.host, redirect_uri.port, use_ssl: true) do |redirect_http|
|
|
53
|
+
redirect_request = Net::HTTP::Get.new(redirect_uri)
|
|
54
|
+
response = redirect_http.request(redirect_request)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
59
|
+
raise DownloadError, "Failed to download Cute Framework: #{response.code} #{response.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
File.binwrite(destination, response.body)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_directories(zip_path, base_path)
|
|
67
|
+
FileUtils.rm_rf(base_path)
|
|
68
|
+
FileUtils.mkdir_p(base_path)
|
|
69
|
+
|
|
70
|
+
Zip::File.open(zip_path) do |zip_file|
|
|
71
|
+
# The zip contains a top-level directory like "cute_framework-master/"
|
|
72
|
+
# We want to extract "include/" and "docs/topics/" subdirectories
|
|
73
|
+
top_level_prefix = nil
|
|
74
|
+
|
|
75
|
+
zip_file.each do |entry|
|
|
76
|
+
# Find the top-level directory prefix (e.g., "cute_framework-master/")
|
|
77
|
+
if top_level_prefix.nil? && entry.name.match?(%r{^[^/]+/include/})
|
|
78
|
+
top_level_prefix = entry.name.match(%r{^([^/]+/)})[1]
|
|
79
|
+
break
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
raise DownloadError, "Could not find include directory in zip" unless top_level_prefix
|
|
84
|
+
|
|
85
|
+
# Directories to extract
|
|
86
|
+
extract_prefixes = [
|
|
87
|
+
"#{top_level_prefix}include/",
|
|
88
|
+
"#{top_level_prefix}docs/topics/"
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
zip_file.each do |entry|
|
|
92
|
+
extract_prefix = extract_prefixes.find { |p| entry.name.start_with?(p) }
|
|
93
|
+
next unless extract_prefix
|
|
94
|
+
|
|
95
|
+
# Calculate the relative path from the top-level directory
|
|
96
|
+
relative_path = entry.name.sub(top_level_prefix, "")
|
|
97
|
+
next if relative_path.empty?
|
|
98
|
+
|
|
99
|
+
target_path = File.join(base_path, relative_path)
|
|
100
|
+
|
|
101
|
+
if entry.directory?
|
|
102
|
+
FileUtils.mkdir_p(target_path)
|
|
103
|
+
else
|
|
104
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
105
|
+
entry.extract(target_path)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/cf/mcp/index.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CF
|
|
4
|
+
module MCP
|
|
5
|
+
class Index
|
|
6
|
+
attr_reader :items, :by_type, :by_category, :topic_references
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@items = {}
|
|
10
|
+
@by_type = {
|
|
11
|
+
function: [],
|
|
12
|
+
struct: [],
|
|
13
|
+
enum: [],
|
|
14
|
+
topic: []
|
|
15
|
+
}
|
|
16
|
+
@by_category = {}
|
|
17
|
+
@topic_references = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add(item)
|
|
21
|
+
@items[item.name] = item
|
|
22
|
+
@by_type[item.type] << item if @by_type.key?(item.type)
|
|
23
|
+
|
|
24
|
+
if item.category
|
|
25
|
+
@by_category[item.category] ||= []
|
|
26
|
+
@by_category[item.category] << item
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build reverse reference index for topics
|
|
30
|
+
build_topic_reverse_index(item) if item.type == :topic
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def find(name)
|
|
34
|
+
@items[name]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def brief_for(name)
|
|
38
|
+
item = find(name)
|
|
39
|
+
return nil unless item
|
|
40
|
+
{name: item.name, type: item.type, brief: item.brief}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def search(query, type: nil, category: nil, limit: 20)
|
|
44
|
+
results = all_items
|
|
45
|
+
|
|
46
|
+
# Filter by type
|
|
47
|
+
if type
|
|
48
|
+
type_sym = type.to_sym
|
|
49
|
+
results = results.select { |item| item.type == type_sym }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Filter by category
|
|
53
|
+
if category
|
|
54
|
+
results = results.select { |item| item.category == category }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Filter by query and sort by relevance
|
|
58
|
+
if query && !query.empty?
|
|
59
|
+
results = results
|
|
60
|
+
.select { |item| item.matches?(query) }
|
|
61
|
+
.sort_by { |item| -item.relevance_score(query) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
results.take(limit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def functions
|
|
68
|
+
@by_type[:function]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def structs
|
|
72
|
+
@by_type[:struct]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def enums
|
|
76
|
+
@by_type[:enum]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def topics
|
|
80
|
+
@by_type[:topic]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def topics_ordered
|
|
84
|
+
topics.sort_by { |t| t.reading_order || Float::INFINITY }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def topics_for(api_name)
|
|
88
|
+
(@topic_references[api_name] || []).map { |name| find(name) }.compact
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def categories
|
|
92
|
+
@by_category.keys.sort
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def items_in_category(category)
|
|
96
|
+
@by_category[category] || []
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def size
|
|
100
|
+
@items.size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stats
|
|
104
|
+
{
|
|
105
|
+
total: @items.size,
|
|
106
|
+
functions: @by_type[:function].size,
|
|
107
|
+
structs: @by_type[:struct].size,
|
|
108
|
+
enums: @by_type[:enum].size,
|
|
109
|
+
topics: @by_type[:topic].size,
|
|
110
|
+
categories: @by_category.size
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def all_items
|
|
117
|
+
@items.values
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_topic_reverse_index(topic)
|
|
121
|
+
topic.all_api_references.each do |ref_name|
|
|
122
|
+
@topic_references[ref_name] ||= []
|
|
123
|
+
@topic_references[ref_name] << topic.name unless @topic_references[ref_name].include?(topic.name)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|