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.
@@ -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: nil,
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&.downcase || ""
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 source_file
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
- lines = []
59
+ build_parameters_lines + build_return_value_lines
60
+ end
62
61
 
63
- if parameters && !parameters.empty?
64
- lines << "## Parameters"
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
- if return_value && !return_value.empty?
75
- lines << "## Return Value"
76
- lines << return_value
77
- lines << ""
78
- end
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
- lines
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
- match_position = Regexp.last_match.begin(0)
24
- source_line = line_for_position(match_position, line_offsets)
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
- items = []
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
- items.concat(parse_file(header_file))
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
- current_tag = nil
84
- current_content = []
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
- # Save last tag
102
- if current_tag
103
- save_tag(tags, current_tag, current_content.join("\n").strip)
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 content =~ /^(\w+)\s+(.*)$/m
115
- tags[:params] << {name: ::Regexp.last_match(1), description: ::Regexp.last_match(2).strip}
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
- lines = content.lines
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
- next if line.strip.empty? && signature_lines.empty?
193
- break if line.strip.empty? && !signature_lines.empty?
194
-
195
- # Stop at struct/enum definitions
196
- break if /^typedef\s+(struct|enum)/.match?(line)
197
- break if /^#define/.match?(line)
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
- signature_lines << line
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
- members << Models::StructDoc::Member.new(decl, description.strip)
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
- entries << Models::EnumDoc::Entry.new("CF_#{name.strip}", value.strip, description.strip)
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
- PROTOCOL_VERSION = "2025-03-26"
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
- def initialize(index, revision: nil)
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
- configuration:,
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
- return [204, cors_headers, []]
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&.type == :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
- protocol_version: PROTOCOL_VERSION,
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, :protocol_version, :revision, :revision_url, :stats, :categories, :topics, :tools, :tool_schemas_json
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:, protocol_version:, revision:, revision_url:, stats:, categories:, topics:, tools:, tool_schemas_json:)
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
- @protocol_version = protocol_version
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
- js.sub("TOOL_SCHEMAS_PLACEHOLDER", @tool_schemas_json)
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)
@@ -53,7 +53,7 @@
53
53
  </tr>
54
54
  <tr>
55
55
  <th>Protocol Version</th>
56
- <td><%= protocol_version %></td>
56
+ <td><%= modern_protocol_version %> <small>(legacy handshake up to <%= legacy_protocol_version %>)</small></td>
57
57
  </tr>
58
58
  <% if revision %>
59
59
  <tr>