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
|
@@ -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("&", "&")
|
|
212
|
+
.gsub("<", "<")
|
|
213
|
+
.gsub(">", ">")
|
|
214
|
+
.gsub('"', """)
|
|
215
|
+
.gsub("'", "'")
|
|
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("&", "&")
|
|
304
|
+
.gsub("<", "<")
|
|
305
|
+
.gsub(">", ">")
|
|
306
|
+
.gsub('"', """)
|
|
307
|
+
.gsub("'", "'")
|
|
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 → 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>
|