mcp 1.8.2 → 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.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- data/lib/mcp/client/tool.rb +8 -2
- data/lib/mcp/client.rb +35 -2
- data/lib/mcp/methods.rb +5 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +25 -12
- data/lib/mcp/server.rb +371 -16
- data/lib/mcp/skill/directory.rb +316 -0
- data/lib/mcp/skill/frontmatter.rb +262 -0
- data/lib/mcp/skill/resource_entry.rb +109 -0
- data/lib/mcp/skill.rb +522 -0
- data/lib/mcp/tool/output_schema.rb +4 -3
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -2
|
@@ -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
|