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.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/function_doc"
4
+ require_relative "models/struct_doc"
5
+ require_relative "models/enum_doc"
6
+
7
+ module CF
8
+ module MCP
9
+ class Parser
10
+ DOC_BLOCK_PATTERN = %r{/\*\*.*?\*/}m
11
+ TAG_PATTERN = /@(\w+)\s*/
12
+ MEMBER_COMMENT_PATTERN = %r{/\*\s*@member\s+(.*?)\s*\*/}m
13
+ ENTRY_COMMENT_PATTERN = %r{/\*\s*@entry\s+(.*?)\s*\*/}m
14
+ CF_ENUM_PATTERN = /CF_ENUM\s*\(\s*(\w+)\s*,\s*([^)]*)\)/
15
+ SIGNATURE_CLEANUP = /\b(CF_API|CF_CALL|CF_INLINE)\b\s*/
16
+ END_MARKER_PATTERN = %r{//\s*@end|/\*\s*@end\s*\*/}
17
+
18
+ def parse_file(path)
19
+ content = File.read(path)
20
+ source_file = File.basename(path)
21
+ line_offsets = build_line_offsets(content)
22
+ items = []
23
+
24
+ # Find all documentation blocks with their positions
25
+ content.scan(%r{(/\*\*.*?\*/)(.*?)(?=/\*\*|\z)}m) do |doc_block, following_content|
26
+ # Get the position of the match to calculate line number
27
+ match_position = Regexp.last_match.begin(0)
28
+ source_line = line_for_position(match_position, line_offsets)
29
+ item = parse_doc_block(doc_block, following_content.strip, source_file, source_line)
30
+ items << item if item
31
+ end
32
+
33
+ items
34
+ end
35
+
36
+ def parse_directory(path)
37
+ items = []
38
+ Dir.glob(File.join(path, "**/*.h")).each do |header_file|
39
+ items.concat(parse_file(header_file))
40
+ end
41
+ items
42
+ end
43
+
44
+ private
45
+
46
+ # Build an array of byte positions where each line starts
47
+ def build_line_offsets(content)
48
+ offsets = [0]
49
+ content.each_char.with_index do |char, index|
50
+ offsets << index + 1 if char == "\n"
51
+ end
52
+ offsets
53
+ end
54
+
55
+ # Convert a byte position to a 1-based line number
56
+ def line_for_position(position, line_offsets)
57
+ # Binary search to find the line containing this position
58
+ line_offsets.bsearch_index { |offset| offset > position } || line_offsets.size
59
+ end
60
+
61
+ def parse_doc_block(doc_block, following_content, source_file, source_line = nil)
62
+ tags = extract_tags(doc_block)
63
+ return nil if tags.empty?
64
+
65
+ type = determine_type(tags)
66
+ return nil unless type
67
+
68
+ case type
69
+ when :function
70
+ parse_function(tags, following_content, source_file, source_line)
71
+ when :struct
72
+ parse_struct(tags, following_content, source_file, source_line)
73
+ when :enum
74
+ parse_enum(tags, following_content, source_file, source_line)
75
+ end
76
+ end
77
+
78
+ def extract_tags(doc_block)
79
+ tags = {}
80
+
81
+ # Remove comment markers and clean up
82
+ lines = doc_block.lines.map do |line|
83
+ line.gsub(%r{^\s*/?\*+\s?}, "").gsub(%r{\s*\*+/\s*$}, "")
84
+ end
85
+
86
+ current_tag = nil
87
+ current_content = []
88
+
89
+ lines.each do |line|
90
+ if line =~ TAG_PATTERN
91
+ # Save previous tag
92
+ if current_tag
93
+ save_tag(tags, current_tag, current_content.join("\n").strip)
94
+ end
95
+
96
+ current_tag = ::Regexp.last_match(1)
97
+ remaining = line.sub(TAG_PATTERN, "").strip
98
+ current_content = [remaining]
99
+ elsif current_tag
100
+ current_content << line
101
+ end
102
+ end
103
+
104
+ # Save last tag
105
+ if current_tag
106
+ save_tag(tags, current_tag, current_content.join("\n").strip)
107
+ end
108
+
109
+ tags
110
+ end
111
+
112
+ def save_tag(tags, tag, content)
113
+ case tag
114
+ when "param"
115
+ tags[:params] ||= []
116
+ # Parse "param_name description" format
117
+ if content =~ /^(\w+)\s+(.*)$/m
118
+ tags[:params] << {name: ::Regexp.last_match(1), description: ::Regexp.last_match(2).strip}
119
+ end
120
+ when "related"
121
+ # Filter out comment artifacts like "/" or "*/"
122
+ tags[:related] = content.split(/\s+/).reject { |s| s.empty? || s.match?(%r{^[/*]+$}) }
123
+ else
124
+ tags[tag.to_sym] = content
125
+ end
126
+ end
127
+
128
+ def determine_type(tags)
129
+ return :function if tags[:function]
130
+ return :struct if tags[:struct]
131
+ return :enum if tags[:enum]
132
+ nil
133
+ end
134
+
135
+ def parse_function(tags, following_content, source_file, source_line = nil)
136
+ # Extract signature from following content
137
+ signature = extract_signature(following_content)
138
+
139
+ Models::FunctionDoc.new(
140
+ name: tags[:function],
141
+ category: tags[:category],
142
+ brief: tags[:brief],
143
+ remarks: tags[:remarks],
144
+ example: tags[:example],
145
+ related: tags[:related] || [],
146
+ source_file: source_file,
147
+ source_line: source_line,
148
+ signature: signature,
149
+ parameters: (tags[:params] || []).map { |p| Models::FunctionDoc::Parameter.new(p[:name], p[:description]) },
150
+ return_value: tags[:return]
151
+ )
152
+ end
153
+
154
+ def parse_struct(tags, following_content, source_file, source_line = nil)
155
+ # Extract members from the struct body
156
+ members = extract_members(following_content)
157
+
158
+ Models::StructDoc.new(
159
+ name: tags[:struct],
160
+ category: tags[:category],
161
+ brief: tags[:brief],
162
+ remarks: tags[:remarks],
163
+ example: tags[:example],
164
+ related: tags[:related] || [],
165
+ source_file: source_file,
166
+ source_line: source_line,
167
+ members: members
168
+ )
169
+ end
170
+
171
+ def parse_enum(tags, following_content, source_file, source_line = nil)
172
+ # Extract enum entries from the #define macro
173
+ entries = extract_enum_entries(following_content)
174
+
175
+ Models::EnumDoc.new(
176
+ name: tags[:enum],
177
+ category: tags[:category],
178
+ brief: tags[:brief],
179
+ remarks: tags[:remarks],
180
+ example: tags[:example],
181
+ related: tags[:related] || [],
182
+ source_file: source_file,
183
+ source_line: source_line,
184
+ entries: entries
185
+ )
186
+ end
187
+
188
+ def extract_signature(content)
189
+ # Find the first function declaration (ending with ; or {)
190
+ lines = content.lines
191
+ signature_lines = []
192
+
193
+ lines.each do |line|
194
+ # Skip empty lines and comments at the start
195
+ next if line.strip.empty? && signature_lines.empty?
196
+ break if line.strip.empty? && !signature_lines.empty?
197
+
198
+ # Stop at struct/enum definitions
199
+ break if /^typedef\s+(struct|enum)/.match?(line)
200
+ break if /^#define/.match?(line)
201
+
202
+ signature_lines << line
203
+ break if line.include?(";") || line.include?("{")
204
+ end
205
+
206
+ return nil if signature_lines.empty?
207
+
208
+ signature = signature_lines.join.strip
209
+ # Clean up macros and normalize whitespace
210
+ signature = signature.gsub(SIGNATURE_CLEANUP, "")
211
+ signature = signature.gsub(/\s*\{.*$/m, "").strip
212
+ signature = signature.gsub(/;$/, "").strip
213
+ signature.empty? ? nil : signature
214
+ end
215
+
216
+ def extract_members(content)
217
+ members = []
218
+
219
+ # Find /* @member ... */ comments and the following declaration
220
+ # Use [^/]+? to ensure we capture content (non-slash chars) before the next comment
221
+ content.scan(%r{/\*\s*@member\s+(.*?)\s*\*/\s*([^/]+?)(?=/\*|//\s*@end|$)}m) do |description, declaration|
222
+ decl = declaration.strip.lines.first&.strip
223
+ next unless decl && !decl.empty?
224
+
225
+ # Clean up the declaration (remove trailing semicolon for display)
226
+ decl = decl.gsub(/;$/, "").strip
227
+ members << Models::StructDoc::Member.new(decl, description.strip)
228
+ end
229
+
230
+ members
231
+ end
232
+
233
+ def extract_enum_entries(content)
234
+ entries = []
235
+
236
+ # Find the #define block with CF_ENUM macros
237
+ # Pattern: /* @entry description */ followed by CF_ENUM(NAME, VALUE)
238
+ content.scan(%r{/\*\s*@entry\s+(.*?)\s*\*/\s*\\?\s*CF_ENUM\s*\(\s*(\w+)\s*,\s*([^)]*)\)}m) do |description, name, value|
239
+ entries << Models::EnumDoc::Entry.new(name.strip, value.strip, description.strip)
240
+ end
241
+
242
+ entries
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "tools/search_tool"
5
+ require_relative "tools/search_functions"
6
+ require_relative "tools/search_structs"
7
+ require_relative "tools/search_enums"
8
+ require_relative "tools/list_category"
9
+ require_relative "tools/get_details"
10
+ require_relative "tools/find_related"
11
+ require_relative "tools/parameter_search"
12
+ require_relative "tools/member_search"
13
+ require_relative "tools/list_topics"
14
+ require_relative "tools/get_topic"
15
+
16
+ module CF
17
+ module MCP
18
+ class Server
19
+ attr_reader :server, :index
20
+
21
+ TOOLS = [
22
+ Tools::SearchTool,
23
+ Tools::SearchFunctions,
24
+ Tools::SearchStructs,
25
+ Tools::SearchEnums,
26
+ Tools::ListCategory,
27
+ Tools::GetDetails,
28
+ Tools::FindRelated,
29
+ Tools::ParameterSearch,
30
+ Tools::MemberSearch,
31
+ Tools::ListTopics,
32
+ Tools::GetTopic
33
+ ].freeze
34
+
35
+ def initialize(index)
36
+ @index = index
37
+ @server = ::MCP::Server.new(
38
+ name: "cf-mcp",
39
+ version: CF::MCP::VERSION,
40
+ tools: TOOLS,
41
+ resources: build_topic_resources(index)
42
+ )
43
+ @server.server_context = {index: index}
44
+
45
+ # Register handler for reading resource content
46
+ @server.resources_read_handler do |params|
47
+ handle_resource_read(params, index)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def build_topic_resources(index)
54
+ index.topics.map do |topic|
55
+ ::MCP::Resource.new(
56
+ uri: "cf://topics/#{topic.name}",
57
+ name: topic.name,
58
+ title: topic.name.tr("_", " ").split.map(&:capitalize).join(" "),
59
+ description: topic.brief,
60
+ mime_type: "text/markdown"
61
+ )
62
+ end
63
+ end
64
+
65
+ def handle_resource_read(params, index)
66
+ uri = params[:uri]
67
+ return [] unless uri&.start_with?("cf://topics/")
68
+
69
+ topic_name = uri.sub("cf://topics/", "")
70
+ topic = index.find(topic_name)
71
+
72
+ return [] unless topic&.type == :topic
73
+
74
+ [{
75
+ uri: uri,
76
+ mimeType: "text/markdown",
77
+ text: topic.content
78
+ }]
79
+ end
80
+
81
+ public
82
+
83
+ def run_stdio
84
+ transport = ::MCP::Server::Transports::StdioTransport.new(@server)
85
+ transport.open
86
+ end
87
+
88
+ def run_http(port: 9292)
89
+ require "rackup"
90
+
91
+ app = http_app
92
+ warn "Starting HTTP server on port #{port}..."
93
+ warn "Index contains #{@index.size} items"
94
+ Rackup::Server.start(app: app, Port: port, Logger: $stderr)
95
+ end
96
+
97
+ def http_app
98
+ require "rack"
99
+
100
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server, stateless: true)
101
+ @server.transport = transport
102
+
103
+ build_rack_app(transport)
104
+ end
105
+
106
+ def run_sse(port: 9393)
107
+ require "rack"
108
+ require "rackup"
109
+
110
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
111
+ @server.transport = transport
112
+
113
+ app = build_rack_app(transport)
114
+ warn "Starting SSE server on port #{port}..."
115
+ warn "Index contains #{@index.size} items"
116
+ Rackup::Server.start(app: app, Port: port, Logger: $stderr)
117
+ end
118
+
119
+ private
120
+
121
+ def build_rack_app(transport)
122
+ Rack::Builder.new do
123
+ use Rack::CommonLogger
124
+ run ->(env) { transport.handle_request(Rack::Request.new(env)) }
125
+ end
126
+ end
127
+
128
+ def sse_app
129
+ require "rack"
130
+
131
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
132
+ @server.transport = transport
133
+
134
+ build_rack_app(transport)
135
+ end
136
+ end
137
+
138
+ # Combined server that exposes both SSE and HTTP transports under different paths
139
+ class CombinedServer
140
+ def initialize(index)
141
+ @index = index
142
+ end
143
+
144
+ CORS_HEADERS = {
145
+ "access-control-allow-origin" => "*",
146
+ "access-control-allow-methods" => "GET, POST, DELETE, OPTIONS",
147
+ "access-control-allow-headers" => "Content-Type, Accept, Mcp-Session-Id, Last-Event-ID",
148
+ "access-control-expose-headers" => "Mcp-Session-Id"
149
+ }.freeze
150
+
151
+ def rack_app
152
+ require "rack"
153
+
154
+ # Create separate server instances for each transport
155
+ sse_server = create_mcp_server
156
+ http_server = create_mcp_server
157
+
158
+ sse_transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(sse_server)
159
+ sse_server.transport = sse_transport
160
+
161
+ http_transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(http_server, stateless: true)
162
+ http_server.transport = http_transport
163
+
164
+ landing_page = build_landing_page
165
+ index = @index
166
+ tools = Server::TOOLS
167
+ cors_headers = CORS_HEADERS
168
+
169
+ app = ->(env) {
170
+ request = Rack::Request.new(env)
171
+ path = request.path_info
172
+
173
+ # Handle CORS preflight
174
+ if request.options?
175
+ return [204, cors_headers, []]
176
+ end
177
+
178
+ # Route based on path
179
+ status, headers, body = case path
180
+ when %r{^/\.well-known/}
181
+ # OAuth discovery - return 404 to indicate no OAuth required
182
+ [404, {"content-type" => "application/json"}, ['{"error":"Not found"}']]
183
+ when %r{^/sse(/|$)}
184
+ sse_transport.handle_request(request)
185
+ when %r{^/http(/|$)}
186
+ http_transport.handle_request(request)
187
+ else
188
+ # Default route - show landing page for browsers, SSE for MCP clients
189
+ accept = request.get_header("HTTP_ACCEPT") || ""
190
+ if request.get? && accept.include?("text/html")
191
+ [200, {"content-type" => "text/html; charset=utf-8"}, [landing_page.call(index, tools)]]
192
+ else
193
+ sse_transport.handle_request(request)
194
+ end
195
+ end
196
+
197
+ # Add CORS headers to response
198
+ [status, headers.merge(cors_headers), body]
199
+ }
200
+
201
+ Rack::Builder.new do
202
+ use Rack::CommonLogger
203
+ run app
204
+ end
205
+ end
206
+
207
+ private
208
+
209
+ def escape_html(text)
210
+ text.to_s
211
+ .gsub("&", "&amp;")
212
+ .gsub("<", "&lt;")
213
+ .gsub(">", "&gt;")
214
+ .gsub('"', "&quot;")
215
+ .gsub("'", "&#39;")
216
+ end
217
+
218
+ def create_mcp_server
219
+ server = ::MCP::Server.new(
220
+ name: "cf-mcp",
221
+ version: CF::MCP::VERSION,
222
+ tools: Server::TOOLS
223
+ )
224
+ server.server_context = {index: @index}
225
+ server
226
+ end
227
+
228
+ def build_landing_page
229
+ require "erb"
230
+ require "json"
231
+
232
+ template_path = File.join(__dir__, "templates", "index.erb")
233
+ template = ERB.new(File.read(template_path))
234
+
235
+ ->(index, tool_classes) {
236
+ context = TemplateContext.new(
237
+ version: CF::MCP::VERSION,
238
+ stats: index.stats,
239
+ categories: index.categories.sort,
240
+ topics: index.topics_ordered.map { |t| {name: t.name, brief: t.brief} },
241
+ tools: tool_classes.map { |tool|
242
+ name = tool.respond_to?(:tool_name) ? tool.tool_name : tool.name
243
+ desc = tool.respond_to?(:description) ? tool.description : ""
244
+ {name: name, description: desc}
245
+ },
246
+ tool_schemas_json: tool_classes.map { |tool|
247
+ name = tool.respond_to?(:tool_name) ? tool.tool_name : tool.name
248
+ desc = tool.respond_to?(:description) ? tool.description : ""
249
+ schema = tool.input_schema.to_h
250
+ {name: name, description: desc, inputSchema: schema}
251
+ }.to_json
252
+ )
253
+ template.result(context.get_binding)
254
+ }
255
+ end
256
+
257
+ # Helper class to provide a clean binding for ERB templates
258
+ class TemplateContext
259
+ TEMPLATES_DIR = File.join(__dir__, "templates")
260
+
261
+ attr_reader :version, :stats, :categories, :topics, :tools, :tool_schemas_json
262
+
263
+ def initialize(version:, stats:, categories:, topics:, tools:, tool_schemas_json:)
264
+ @version = version
265
+ @stats = stats
266
+ @categories = categories
267
+ @topics = topics
268
+ @tools = tools
269
+ @tool_schemas_json = tool_schemas_json
270
+ end
271
+
272
+ def categories_json
273
+ @categories.to_json
274
+ end
275
+
276
+ def topics_json
277
+ @topics.to_json
278
+ end
279
+
280
+ def css_content
281
+ File.read(File.join(TEMPLATES_DIR, "style.css"))
282
+ end
283
+
284
+ def changelog_content
285
+ changelog_path = CF::MCP.root.join("CHANGELOG.md")
286
+ changelog_path.exist? ? changelog_path.read : ""
287
+ end
288
+
289
+ def changelog_json
290
+ changelog_content.to_json
291
+ end
292
+
293
+ def js_content
294
+ js = File.read(File.join(TEMPLATES_DIR, "script.js"))
295
+ js.sub("TOOL_SCHEMAS_PLACEHOLDER", @tool_schemas_json)
296
+ .sub("CATEGORIES_PLACEHOLDER", categories_json)
297
+ .sub("TOPICS_PLACEHOLDER", topics_json)
298
+ .sub("CHANGELOG_PLACEHOLDER", changelog_json)
299
+ end
300
+
301
+ def h(text)
302
+ text.to_s
303
+ .gsub("&", "&amp;")
304
+ .gsub("<", "&lt;")
305
+ .gsub(">", "&gt;")
306
+ .gsub('"', "&quot;")
307
+ .gsub("'", "&#39;")
308
+ end
309
+
310
+ def get_binding
311
+ binding
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,94 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CF::MCP - Cute Framework MCP Server</title>
7
+ <style>
8
+ <%= css_content %>
9
+ </style>
10
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
11
+ </head>
12
+ <body>
13
+ <h1>CF::MCP <small style="font-size: 0.5em; color: #8b949e;">v<%= version %></small></h1>
14
+ <p>MCP (Model Context Protocol) server for the <a href="https://github.com/RandyGaul/cute_framework">Cute Framework</a>, a C/C++ 2D game framework.</p>
15
+
16
+ <div class="stats">
17
+ <div class="stat">
18
+ <div class="stat-value"><%= stats[:total] %></div>
19
+ <div class="stat-label">Total Items</div>
20
+ </div>
21
+ <div class="stat">
22
+ <div class="stat-value"><%= stats[:functions] %></div>
23
+ <div class="stat-label">Functions</div>
24
+ </div>
25
+ <div class="stat">
26
+ <div class="stat-value"><%= stats[:structs] %></div>
27
+ <div class="stat-label">Structs</div>
28
+ </div>
29
+ <div class="stat">
30
+ <div class="stat-value"><%= stats[:enums] %></div>
31
+ <div class="stat-label">Enums</div>
32
+ </div>
33
+ <div class="stat">
34
+ <div class="stat-value"><%= stats[:topics] %></div>
35
+ <div class="stat-label">Topics</div>
36
+ </div>
37
+ </div>
38
+
39
+ <h2>Endpoints</h2>
40
+ <div class="endpoint">
41
+ <div class="endpoint-path">/ or /sse</div>
42
+ <div class="endpoint-desc">Streamable HTTP (stateful) - for Claude Desktop and Claude Code</div>
43
+ </div>
44
+ <div class="endpoint">
45
+ <div class="endpoint-path">/http</div>
46
+ <div class="endpoint-desc">Streamable HTTP (stateless) - for simple integrations</div>
47
+ </div>
48
+
49
+ <h2>Claude Desktop Setup</h2>
50
+ <p>Remote MCP servers require a <strong>Pro, Max, Team, or Enterprise</strong> plan.</p>
51
+ <ol>
52
+ <li>Open Claude Desktop</li>
53
+ <li>Go to <strong>Settings &rarr; Connectors</strong></li>
54
+ <li>Add this URL as a remote MCP server:</li>
55
+ </ol>
56
+ <pre><code>https://cf-mcp.fly.dev/</code></pre>
57
+
58
+ <h2>Claude Code CLI Setup</h2>
59
+ <pre><code>claude mcp add --transport http cf-mcp https://cf-mcp.fly.dev/http</code></pre>
60
+
61
+ <h2>Available Tools</h2>
62
+ <div class="tools">
63
+ <% tools.each do |tool| %>
64
+ <div class="tool">
65
+ <div class="tool-name"><%= h(tool[:name]) %></div>
66
+ <div class="tool-desc"><%= h(tool[:description]) %></div>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+
71
+ <h2>Tool Explorer</h2>
72
+ <div id="tool-explorer-root"></div>
73
+
74
+ <h2>Topics Explorer</h2>
75
+ <p>Browse tutorial guides and documentation topics for Cute Framework.</p>
76
+ <div id="topics-explorer-root"></div>
77
+
78
+ <h2>Links</h2>
79
+ <ul>
80
+ <li><a href="https://github.com/pusewicz/cf-mcp">CF::MCP on GitHub</a></li>
81
+ <li><a href="https://github.com/RandyGaul/cute_framework">Cute Framework</a></li>
82
+ <li><a href="https://modelcontextprotocol.io">Model Context Protocol</a></li>
83
+ </ul>
84
+
85
+ <details class="changelog">
86
+ <summary><h2 style="display: inline; cursor: pointer;">Changelog</h2></summary>
87
+ <div id="changelog-content"></div>
88
+ </details>
89
+
90
+ <script type="module">
91
+ <%= js_content %>
92
+ </script>
93
+ </body>
94
+ </html>