mcp 1.8.1 → 1.9.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,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module MCP
6
+ class Skill
7
+ # Directory-backed skills (SEP-2640): `from_directory` turns every directory under a root that holds
8
+ # a `SKILL.md` into a skill whose files the server's default `resources/read` serves, with the digests and
9
+ # sizes computed here.
10
+ #
11
+ # Rules fixed for the first release: regular files only (symbolic links, FIFOs, sockets, and devices are
12
+ # skipped, hard links refused), dotfiles skipped, everything else published (editor backups included), every directory and file
13
+ # name limited to the unreserved URI characters, a directory holding its own `SKILL.md` a skill of its own
14
+ # whose files the enclosing skill lists as supporting files too, files read at request time and refused
15
+ # with one generic error when they changed since registration.
16
+ class << self
17
+ # Scans `root` and returns the skills found under it (an Array of `MCP::Skill`, ordered by URI); every
18
+ # problem is an `ArgumentError` naming the path. `root` is resolved once with `File.realpath`, so
19
+ # a symbolic link to the directory is fine; a `SKILL.md` directly under `root`, or no skill at all, is
20
+ # refused, because `root` is the container of skill directories, not a skill.
21
+ #
22
+ # The tree is trusted as the operator's: it must be writable only by the operator while skills are built
23
+ # and served. The checks on each file narrow what a concurrent change to the tree can do; they do not
24
+ # confine a writer who can change it.
25
+ def from_directory(root)
26
+ root = root.to_path if root.respond_to?(:to_path)
27
+ raise ArgumentError, "The skills root must be a path String (got #{root.class})." unless root.is_a?(String)
28
+
29
+ real_root = begin
30
+ File.realpath(root)
31
+ rescue SystemCallError => e
32
+ raise ArgumentError, "The skills root #{root} cannot be resolved (#{e.class})."
33
+ end
34
+ raise ArgumentError, "The skills root #{real_root} is not a directory." unless File.directory?(real_root)
35
+ if File.exist?(File.join(real_root, Uri::MANIFEST))
36
+ raise ArgumentError, "The skills root #{real_root} holds a SKILL.md itself; pass the directory that contains the skill directories."
37
+ end
38
+
39
+ skills = []
40
+ scan_directory(real_root, [], skills)
41
+ raise ArgumentError, "No directory holding a SKILL.md was found under #{real_root}." if skills.empty?
42
+
43
+ skills.sort_by(&:uri)
44
+ end
45
+
46
+ # Serves one file of a directory-backed skill for a custom `resources_read_handler`, which replaces
47
+ # the default reader and with it the serving of skill files: returns the `contents` Array holding one item,
48
+ # or nil when `uri` is not a file of any of `skills`. A file that changed since registration raises
49
+ # the same `-32603` the default reader answers. The files of a nested skill are files of the enclosing skill
50
+ # as well, so a caller given the enclosing skill reads them whether or not the nested one is among `skills`.
51
+ def read_file(skills, uri)
52
+ record = file_map(skills)[uri]
53
+ return unless record
54
+
55
+ [read_record_item(record)]
56
+ rescue FileError => e
57
+ raise file_changed_error(nil, e)
58
+ end
59
+
60
+ # The default reader's path for a file record found in the server's map.
61
+ #
62
+ # @api private
63
+ def read_record(record, request)
64
+ [read_record_item(record)]
65
+ rescue FileError => e
66
+ raise file_changed_error(request, e)
67
+ end
68
+
69
+ private
70
+
71
+ # Internal: a file could not be read as registered; the message names the path and is reported only.
72
+ class FileError < StandardError; end
73
+
74
+ # Frozen through, so a custom reader handed `read_file`'s item cannot alter the URI later responses carry.
75
+ FileRecord = Struct.new(:uri, :path, :size, :digest, :dev, :ino, :text, keyword_init: true) do
76
+ def initialize(**kwargs)
77
+ super(**kwargs)
78
+ each { |member| member.freeze if member.is_a?(String) }
79
+ freeze
80
+ end
81
+ end
82
+
83
+ MIME_TYPES = { ".md" => "text/markdown" }.freeze
84
+
85
+ def scan_directory(directory, segments, skills)
86
+ children_of(directory).each do |name|
87
+ next if name.start_with?(".")
88
+
89
+ path = File.join(directory, name)
90
+ next unless lstat_of(path).directory?
91
+
92
+ check_name!(name, path)
93
+ child_segments = segments + [name]
94
+ if skill_directory?(path)
95
+ skills << collect_skill(path, child_segments, skills)
96
+ else
97
+ scan_directory(path, child_segments, skills)
98
+ end
99
+ end
100
+ end
101
+
102
+ def skill_directory?(path)
103
+ File.lstat(File.join(path, Uri::MANIFEST)).file?
104
+ rescue SystemCallError
105
+ false
106
+ end
107
+
108
+ def check_name!(name, path)
109
+ return if Uri::LOCAL_SEGMENT.match?(name)
110
+
111
+ raise ArgumentError, "The name of #{path} may only use letters, digits, -, ., _, and ~, so that it can be a skill:// URI segment."
112
+ end
113
+
114
+ # Directory listing and `lstat` in a stable order; a filesystem error is reported as an `ArgumentError`
115
+ # like every other construction problem, since the input is the operator's own tree.
116
+ def children_of(directory)
117
+ Dir.children(directory).sort
118
+ rescue SystemCallError => e
119
+ raise ArgumentError, "The directory #{directory} cannot be listed (#{e.class})."
120
+ end
121
+
122
+ def lstat_of(path)
123
+ File.lstat(path)
124
+ rescue SystemCallError => e
125
+ raise ArgumentError, "The entry #{path} cannot be examined (#{e.class})."
126
+ end
127
+
128
+ # Builds the skill rooted at `directory`: walks its files, reads the frontmatter from the same bytes
129
+ # the manifest's digest was computed over, and attaches the file records.
130
+ def collect_skill(directory, segments, skills)
131
+ root_uri = Uri.root(segments)
132
+ manifest_uri = "#{root_uri}/#{Uri::MANIFEST}"
133
+ walk = { records: {}, manifest_uri: manifest_uri, manifest_bytes: nil, budget: MAX_TOTAL_SIZE }
134
+ walk_skill_files(directory, segments, walk, skills)
135
+
136
+ records = walk[:records].sort_by { |uri, _record| uri }.to_h
137
+ manifest_bytes = walk[:manifest_bytes]
138
+ # `skill_directory?` saw the file, so only a change to the tree during the scan leaves it unread.
139
+ raise ArgumentError, "The SKILL.md of #{directory} disappeared while the directory was being scanned." if manifest_bytes.nil?
140
+
141
+ frontmatter = begin
142
+ Frontmatter.load(manifest_bytes)
143
+ rescue Frontmatter::Error => e
144
+ raise ArgumentError, "The frontmatter of #{records.fetch(manifest_uri).path} cannot be read (#{e.reason}); see the frontmatter rules in the Skills documentation."
145
+ end
146
+
147
+ entries = records.values.map { |record| ResourceEntry.new(uri: record.uri, digest: record.digest, size: record.size) }
148
+ begin
149
+ with_file_records(uri: manifest_uri, frontmatter: frontmatter, resources: entries, file_records: records.freeze)
150
+ rescue ArgumentError => e
151
+ raise ArgumentError, "#{directory}: #{e.message}"
152
+ end
153
+ end
154
+
155
+ def walk_skill_files(directory, segments, walk, skills)
156
+ children_of(directory).each do |name|
157
+ next if name.start_with?(".")
158
+
159
+ path = File.join(directory, name)
160
+ stat = lstat_of(path)
161
+ next unless stat.directory? || stat.file?
162
+
163
+ check_name!(name, path)
164
+ child_segments = segments + [name]
165
+ if stat.directory?
166
+ if skill_directory?(path)
167
+ # A nested skill is published on its own, and its files are supporting files of the enclosing
168
+ # skill as well: the records are shared, so both manifests carry the same digests and sizes.
169
+ nested = collect_skill(path, child_segments, skills)
170
+ skills << nested
171
+ adopt_records(nested.__send__(:file_records), walk)
172
+ else
173
+ walk_skill_files(path, child_segments, walk, skills)
174
+ end
175
+ else
176
+ uri = Uri.root(child_segments)
177
+ record, bytes = capture_file(path, uri, walk)
178
+ walk[:records][uri] = record
179
+ walk[:manifest_bytes] = bytes if uri == walk[:manifest_uri]
180
+ end
181
+ end
182
+ end
183
+
184
+ # Adds a nested skill's records to the enclosing skill's walk; they count against its size budget like
185
+ # its own files, since the specification counts the limits over every listed entry.
186
+ def adopt_records(records, walk)
187
+ records.each_value do |record|
188
+ if record.size > walk[:budget]
189
+ raise ArgumentError, "#{record.path} takes the enclosing skill past the #{MAX_TOTAL_SIZE} byte limit of SEP-2640"
190
+ end
191
+
192
+ walk[:budget] -= record.size
193
+ walk[:records][record.uri] = record
194
+ end
195
+ end
196
+
197
+ # Hashes one file and returns its record with the bytes it hashed. The skill's remaining size budget is
198
+ # checked against `fstat` before anything is read, so an oversized file is refused without loading it, and
199
+ # the read itself stops one byte past the budget, so a file growing after that check is never held whole.
200
+ def capture_file(path, uri, walk)
201
+ # The paths come from the resolved root, so `realpath` must return the path unchanged; an intermediate
202
+ # directory swapped for a symbolic link after `lstat` breaks that. Checked before and after the open,
203
+ # so passing takes two swaps inside that window instead of one.
204
+ raise FileError, "#{path} is not the file its path names" unless resolved_path?(path)
205
+
206
+ open_regular_file(path) do |io, stat|
207
+ # A hard link reaches a file outside the tree the way a symbolic link would, so it is refused rather than
208
+ # skipped, which tells the operator that a file is missing from the manifest. The handle must also be
209
+ # the file its path names now.
210
+ raise FileError, "#{path} has more than one hard link" if stat.nlink > 1
211
+
212
+ current = File.stat(path)
213
+ unless resolved_path?(path) && current.dev == stat.dev && current.ino == stat.ino
214
+ raise FileError, "#{path} is not the file its path names"
215
+ end
216
+
217
+ if stat.size > walk[:budget]
218
+ raise FileError, "#{path} takes the skill past the #{MAX_TOTAL_SIZE} byte limit of SEP-2640"
219
+ end
220
+
221
+ bytes = io.read(walk[:budget] + 1) || "".b
222
+ if bytes.bytesize > walk[:budget]
223
+ raise FileError, "#{path} takes the skill past the #{MAX_TOTAL_SIZE} byte limit of SEP-2640"
224
+ end
225
+
226
+ walk[:budget] -= bytes.bytesize
227
+ record = FileRecord.new(
228
+ uri: uri,
229
+ path: path,
230
+ size: bytes.bytesize,
231
+ digest: "sha256:#{Digest::SHA256.hexdigest(bytes)}",
232
+ dev: stat.dev,
233
+ ino: stat.ino,
234
+ text: bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?,
235
+ )
236
+ [record, bytes]
237
+ end
238
+ rescue FileError => e
239
+ raise ArgumentError, e.message
240
+ end
241
+
242
+ def resolved_path?(path)
243
+ File.realpath(path) == path
244
+ rescue SystemCallError
245
+ false
246
+ end
247
+
248
+ # Opens a regular file for reading without following a symbolic link in its last component, and hands
249
+ # the handle and its `fstat` to the block; everything the block reads comes from the handle it checked.
250
+ # Where the platform lacks `NOFOLLOW`, an `lstat` before the open refuses a symbolic link instead;
251
+ # the `fstat` comparison and the digest over the served bytes still close the race.
252
+ def open_regular_file(path)
253
+ if !nofollow_supported? && File.lstat(path).symlink?
254
+ raise FileError, "#{path} is a symbolic link"
255
+ end
256
+
257
+ File.open(path, open_flags) do |io|
258
+ io.binmode
259
+ stat = io.stat
260
+ raise FileError, "#{path} is not a regular file" unless stat.file?
261
+
262
+ yield io, stat
263
+ end
264
+ rescue SystemCallError, IOError => e
265
+ raise FileError, "#{path} cannot be read (#{e.class})"
266
+ end
267
+
268
+ def open_flags
269
+ flags = File::RDONLY | File::NONBLOCK
270
+ flags |= File::NOFOLLOW if nofollow_supported?
271
+ flags
272
+ end
273
+
274
+ def nofollow_supported?
275
+ File::Constants.const_defined?(:NOFOLLOW)
276
+ end
277
+
278
+ # Reads a registered file for serving: the handle must still be the recorded inode with the recorded size
279
+ # and no additional hard link (`nlink` is compared the way registration does, so a filesystem that reports 0
280
+ # is served), and the bytes read (at most one more than recorded, so growth shows) must still have
281
+ # the recorded digest.
282
+ def read_record_item(record)
283
+ open_regular_file(record.path) do |io, stat|
284
+ unless stat.size == record.size && stat.dev == record.dev && stat.ino == record.ino && stat.nlink <= 1
285
+ raise FileError, "#{record.path} changed since registration (inode, size, or link count)"
286
+ end
287
+
288
+ data = io.read(record.size + 1) || "".b
289
+ unless data.bytesize == record.size && "sha256:#{Digest::SHA256.hexdigest(data)}" == record.digest
290
+ raise FileError, "#{record.path} changed since registration (content)"
291
+ end
292
+
293
+ item = { uri: record.uri }
294
+ mime_type = MIME_TYPES[File.extname(record.path)]
295
+ item[:mimeType] = mime_type if mime_type
296
+ if record.text
297
+ item[:text] = data.force_encoding(Encoding::UTF_8)
298
+ else
299
+ item[:blob] = [data].pack("m0")
300
+ end
301
+ item
302
+ end
303
+ end
304
+
305
+ def file_changed_error(request, cause)
306
+ Server::RequestHandlerError.new(
307
+ "Skill file changed since registration",
308
+ request,
309
+ error_type: :internal_error,
310
+ error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
311
+ original_error: cause,
312
+ )
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ module MCP
6
+ class Skill
7
+ # Reads and compares the YAML frontmatter of a `SKILL.md`, the same way on the server (where
8
+ # `from_directory` publishes it) and on the client (where a host compares what it fetched with the entry
9
+ # it holds, a SEP-2640 MUST). The rules are stricter than YAML so that every YAML library reads the block
10
+ # the same way: one document, no directives or document markers, no tags, no aliases or merge keys, keys
11
+ # that are untagged Strings, and no plain scalar whose YAML 1.1 reading (Psych's) differs from its
12
+ # YAML 1.2 core-schema reading (the JavaScript and Go libraries'); such a value is written quoted instead.
13
+ # Values must be JSON-representable because the entry travels as JSON.
14
+ module Frontmatter
15
+ extend self
16
+
17
+ # Raised internally with `reason` `:parse` (the block cannot be read under these rules) or `:frontmatter`
18
+ # (it reads fine but differs from the entry); callers translate it into their own error types.
19
+ class Error < StandardError
20
+ attr_reader :reason
21
+
22
+ def initialize(reason, message = "frontmatter #{reason}")
23
+ super(message)
24
+ @reason = reason
25
+ end
26
+ end
27
+
28
+ BOM = "\xEF\xBB\xBF".b.freeze
29
+ DELIMITER = "---"
30
+ # NEL, LINE SEPARATOR, and PARAGRAPH SEPARATOR: line breaks in YAML 1.1 (Psych) but not in YAML 1.2.
31
+ UNICODE_BREAKS = Regexp.union([0x85, 0x2028, 0x2029].map { |codepoint| codepoint.chr(Encoding::UTF_8) }).freeze
32
+ MERGE_KEY = "<<"
33
+ # An SDK loading limit for the frontmatter block, not a protocol rule: the file itself may be 16 MiB.
34
+ MAX_BLOCK_BYTES = 64 * 1024
35
+ MAX_DEPTH = 64
36
+
37
+ # Resolves plain scalars by the YAML 1.2 core schema, to compare with Psych's YAML 1.1 resolution.
38
+ module CoreSchema
39
+ extend self
40
+
41
+ NULL = /\A(?:null|Null|NULL|~)?\z/.freeze
42
+ TRUE = /\A(?:true|True|TRUE)\z/.freeze
43
+ FALSE = /\A(?:false|False|FALSE)\z/.freeze
44
+ INT10 = /\A[-+]?[0-9]+\z/.freeze
45
+ INT8 = /\A0o[0-7]+\z/.freeze
46
+ INT16 = /\A0x[0-9a-fA-F]+\z/.freeze
47
+ FLOAT = /\A[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?\z/.freeze
48
+ INF = /\A[-+]?\.(?:inf|Inf|INF)\z/.freeze
49
+ NAN = /\A\.(?:nan|NaN|NAN)\z/.freeze
50
+
51
+ def resolve(text)
52
+ case text
53
+ when NULL then nil
54
+ when TRUE then true
55
+ when FALSE then false
56
+ when INT10 then Integer(text, 10)
57
+ when INT8 then text[2..-1].to_i(8)
58
+ when INT16 then text[2..-1].to_i(16)
59
+ when INF then text.start_with?("-") ? -Float::INFINITY : Float::INFINITY
60
+ when NAN then Float::NAN
61
+ # Ruby's Float() rejects a trailing dot, which the core schema allows.
62
+ when FLOAT then Float(text.sub(/\.(?=[eE]|\z)/, ".0"))
63
+ else text
64
+ end
65
+ end
66
+
67
+ # Whether Psych's reading of a plain scalar differs in type or value from the core-schema reading.
68
+ # A scanner failure counts as a difference, because the other side would not fail the same way.
69
+ def divergent?(scanner, text)
70
+ psych = scanner.tokenize(text)
71
+ core = resolve(text)
72
+ return true unless psych.class.equal?(core.class)
73
+ return false if core.is_a?(Float) && core.nan? && psych.nan?
74
+
75
+ psych != core
76
+ rescue StandardError
77
+ true
78
+ end
79
+ end
80
+
81
+ # Builds the AST as `Psych.parse_stream` does and stops the parser at the first collection nested beyond
82
+ # `MAX_DEPTH`. The scan in libyaml costs the square of the flow nesting, so a block within the size limit
83
+ # could otherwise take seconds before `check_nodes!` ever saw its depth.
84
+ class BoundedTreeBuilder < Psych::TreeBuilder
85
+ def initialize
86
+ super
87
+ @depth = 0
88
+ end
89
+
90
+ def start_sequence(*)
91
+ descend
92
+ super
93
+ end
94
+
95
+ def start_mapping(*)
96
+ descend
97
+ super
98
+ end
99
+
100
+ def end_sequence
101
+ @depth -= 1
102
+ super
103
+ end
104
+
105
+ def end_mapping
106
+ @depth -= 1
107
+ super
108
+ end
109
+
110
+ private
111
+
112
+ def descend
113
+ @depth += 1
114
+ raise Error.new(:parse) if @depth > MAX_DEPTH
115
+ end
116
+ end
117
+ private_constant :BoundedTreeBuilder
118
+
119
+ # Extracts and parses the frontmatter of a `SKILL.md` given as bytes; returns a Hash with String keys.
120
+ def load(bytes)
121
+ parse(extract(bytes))
122
+ end
123
+
124
+ # The text between the opening `---` line and the next `---` line. A leading UTF-8 BOM is skipped
125
+ # here only; digests always cover the raw bytes. Lines end with `\n` or `\r\n`.
126
+ def extract(bytes)
127
+ data = bytes.b
128
+ data = data.byteslice(BOM.bytesize..-1) if data.start_with?(BOM)
129
+ text = data.force_encoding(Encoding::UTF_8)
130
+ raise Error.new(:parse) unless text.valid_encoding?
131
+
132
+ lines = text.each_line
133
+ first = begin
134
+ lines.next
135
+ rescue StopIteration
136
+ raise Error.new(:parse)
137
+ end
138
+ raise Error.new(:parse) unless delimiter_line?(first) && first.end_with?("\n") && !foreign_break?(first)
139
+
140
+ block = +""
141
+ closed = false
142
+ # `loop` ends when `next` raises StopIteration at the end of the text; `each` would restart from
143
+ # the first line instead of continuing after it.
144
+ loop do
145
+ line = lines.next
146
+ raise Error.new(:parse) if foreign_break?(line)
147
+
148
+ if delimiter_line?(line)
149
+ closed = true
150
+ break
151
+ end
152
+
153
+ block << line
154
+ raise Error.new(:parse) if block.bytesize > MAX_BLOCK_BYTES
155
+ end
156
+ raise Error.new(:parse) unless closed
157
+
158
+ block
159
+ end
160
+
161
+ # Parses an extracted block under the rules above.
162
+ def parse(block)
163
+ document = single_document(block)
164
+ check_nodes!(document.root)
165
+
166
+ value = begin
167
+ Psych.safe_load(block, permitted_classes: [], permitted_symbols: [], aliases: false)
168
+ rescue Psych::Exception
169
+ raise Error.new(:parse)
170
+ end
171
+ raise Error.new(:parse) unless value.is_a?(Hash)
172
+
173
+ begin
174
+ Json.normalize(value)
175
+ rescue ArgumentError
176
+ raise Error.new(:parse)
177
+ end
178
+ end
179
+
180
+ # Whether two normalized frontmatter Hashes are equal, with Integer and Float kept distinct.
181
+ def equal?(expected, actual)
182
+ Json.equal?(expected, actual)
183
+ end
184
+
185
+ private
186
+
187
+ def delimiter_line?(line)
188
+ line.chomp == DELIMITER
189
+ end
190
+
191
+ # A line break the delimiter scan above does not see but Psych does: a carriage return that is not part of
192
+ # a `\r\n` ending, or one of the YAML 1.1-only separators. The scan and Psych must never disagree, and neither
193
+ # must Psych and a YAML 1.2 reader. Only an exact `\r\n` suffix is taken off before looking (`chomp` would
194
+ # also strip a lone `\r` or the `\r` of `\r\r\n`).
195
+ def foreign_break?(line)
196
+ line.delete_suffix("\r\n").include?("\r") || line.match?(UNICODE_BREAKS)
197
+ end
198
+
199
+ # `Psych.parse` and `safe_load` read only the first document, so a `...` or a second `---` inside
200
+ # the block would hide content from every check; the stream must hold exactly one implicit document.
201
+ def single_document(block)
202
+ stream = begin
203
+ parser = Psych::Parser.new(BoundedTreeBuilder.new)
204
+ parser.parse(block)
205
+ parser.handler.root
206
+ rescue Psych::Exception
207
+ raise Error.new(:parse)
208
+ end
209
+ documents = stream.children
210
+ raise Error.new(:parse) unless documents.size == 1
211
+
212
+ document = documents.first
213
+ version = document.version
214
+ unless (version.nil? || version.empty?) && document.tag_directives.empty? &&
215
+ document.implicit && document.implicit_end && document.root.is_a?(Psych::Nodes::Mapping)
216
+ raise Error.new(:parse)
217
+ end
218
+
219
+ document
220
+ end
221
+
222
+ # Walks the AST iteratively, before `safe_load` ever builds Ruby objects from it.
223
+ def check_nodes!(root)
224
+ scanner = Psych::ScalarScanner.new(Psych::ClassLoader.new)
225
+ stack = [[root, 1]]
226
+ until stack.empty?
227
+ node, depth = stack.pop
228
+ raise Error.new(:parse) if depth > MAX_DEPTH
229
+ raise Error.new(:parse) if node.respond_to?(:tag) && node.tag
230
+
231
+ case node
232
+ when Psych::Nodes::Scalar
233
+ raise Error.new(:parse) if node.plain && CoreSchema.divergent?(scanner, node.value)
234
+ when Psych::Nodes::Sequence
235
+ node.children.each { |child| stack.push([child, depth + 1]) }
236
+ when Psych::Nodes::Mapping
237
+ check_mapping!(node, depth, stack, scanner)
238
+ else
239
+ raise Error.new(:parse)
240
+ end
241
+ end
242
+ end
243
+
244
+ def check_mapping!(node, depth, stack, scanner)
245
+ keys = {}
246
+ node.children.each_slice(2) do |key, value|
247
+ raise Error.new(:parse) unless key.is_a?(Psych::Nodes::Scalar) && key.tag.nil?
248
+
249
+ text = key.value
250
+ if key.plain
251
+ raise Error.new(:parse) if CoreSchema.divergent?(scanner, text)
252
+ raise Error.new(:parse) unless CoreSchema.resolve(text).is_a?(String)
253
+ end
254
+ raise Error.new(:parse) if text == MERGE_KEY || keys.key?(text)
255
+
256
+ keys[text] = true
257
+ stack.push([value, depth + 1])
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module MCP
6
+ class Skill
7
+ # One file of a skill as its manifest lists it: `{ uri, digest, size }` (SEP-2640). The `digest` is
8
+ # `sha256:` followed by 64 lowercase hex characters over the file's raw bytes, `size` is the byte length.
9
+ # Only the shape is checked here; whether the URI lies under the skill's root is checked by `MCP::Skill`,
10
+ # which knows the root.
11
+ class ResourceEntry
12
+ DIGEST_PATTERN = /\Asha256:[0-9a-f]{64}\z/.freeze
13
+
14
+ # The keys `from` accepts in a Hash, as Symbols or Strings.
15
+ HASH_KEYWORDS = { "uri" => :uri, "digest" => :digest, "size" => :size }.freeze
16
+ private_constant :HASH_KEYWORDS
17
+
18
+ class << self
19
+ # Returns `value` when it is already a `ResourceEntry`, builds one from a Hash, and refuses anything else.
20
+ def from(value)
21
+ return value if value.is_a?(ResourceEntry)
22
+
23
+ unless value.is_a?(Hash)
24
+ raise ArgumentError, "A skill resource entry must be an MCP::Skill::ResourceEntry or a Hash (got #{value.class})."
25
+ end
26
+
27
+ keywords = {}
28
+ value.each do |key, member|
29
+ unless key.is_a?(Symbol) || key.is_a?(String)
30
+ raise ArgumentError, "A skill resource entry Hash key must be a Symbol or a String (got #{key.class})."
31
+ end
32
+
33
+ keyword = HASH_KEYWORDS[key.to_s]
34
+ unless keyword
35
+ raise ArgumentError, "A skill resource entry Hash may only hold uri, digest, and size (got #{key.inspect})."
36
+ end
37
+ raise ArgumentError, "A skill resource entry Hash gives #{keyword} twice." if keywords.key?(keyword)
38
+
39
+ keywords[keyword] = member
40
+ end
41
+
42
+ new(**keywords)
43
+ end
44
+
45
+ # Builds an entry from a server's wire Hash (String or Symbol keys), reading `uri`, `digest`, and `size` and
46
+ # ignoring any other member a server may add; `from` refuses unknown members for local definitions instead.
47
+ #
48
+ # @api private
49
+ def from_wire(value)
50
+ return value if value.is_a?(ResourceEntry)
51
+ raise ArgumentError, "A skill resource entry must be a Hash (got #{value.class})." unless value.is_a?(Hash)
52
+
53
+ new(uri: wire_member(value, "uri"), digest: wire_member(value, "digest"), size: wire_member(value, "size"))
54
+ end
55
+
56
+ # Builds the entry for a file from the exact bytes the resource will serve, so an author never formats
57
+ # a digest by hand: pass `File.binread` output for a binary file and UTF-8 text for a text file.
58
+ def from_content(uri:, content:)
59
+ unless content.is_a?(String)
60
+ raise ArgumentError, "The value of content must be a String (got #{content.class})."
61
+ end
62
+
63
+ new(uri: uri, digest: "sha256:#{Digest::SHA256.hexdigest(content.b)}", size: content.bytesize)
64
+ end
65
+
66
+ private
67
+
68
+ def wire_member(hash, key)
69
+ value = hash[key]
70
+ value.nil? ? hash[key.to_sym] : value
71
+ end
72
+ end
73
+
74
+ attr_reader :uri, :digest, :size
75
+
76
+ def initialize(uri:, digest:, size:)
77
+ unless uri.is_a?(String) && Uri.structural?(uri)
78
+ raise ArgumentError, "The value of uri must be a skill:// URI with non-empty path segments."
79
+ end
80
+
81
+ unless digest.is_a?(String) && digest.encoding.ascii_compatible? && DIGEST_PATTERN.match?(digest)
82
+ raise ArgumentError, "The value of digest must be sha256: followed by 64 lowercase hex characters."
83
+ end
84
+
85
+ unless size.is_a?(Integer) && size >= 0
86
+ raise ArgumentError, "The value of size must be a non-negative Integer (got #{size.class})."
87
+ end
88
+
89
+ @uri = -uri
90
+ @digest = -digest
91
+ @size = size
92
+ freeze
93
+ end
94
+
95
+ def to_h
96
+ { uri: uri, digest: digest, size: size }
97
+ end
98
+
99
+ def ==(other)
100
+ other.is_a?(ResourceEntry) && to_h == other.to_h
101
+ end
102
+ alias_method :eql?, :==
103
+
104
+ def hash
105
+ to_h.hash
106
+ end
107
+ end
108
+ end
109
+ end