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.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/json_rpc_handler.rb +32 -2
- data/lib/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- data/lib/mcp/client/stdio.rb +12 -2
- 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 +54 -22
- data/lib/mcp/server.rb +403 -17
- data/lib/mcp/server_session.rb +4 -4
- 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
data/lib/mcp/skill.rb
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "skill/resource_entry"
|
|
5
|
+
require_relative "skill/frontmatter"
|
|
6
|
+
require_relative "skill/directory"
|
|
7
|
+
|
|
8
|
+
module MCP
|
|
9
|
+
# A skill as the Skills extension describes it (SEP-2640, `io.modelcontextprotocol/skills`): a `skill://`
|
|
10
|
+
# URI naming its `SKILL.md`, the frontmatter of that file kept verbatim, either the list of its files
|
|
11
|
+
# with their digests and sizes or the String `"dynamic"` for content generated per request, and the `_meta`
|
|
12
|
+
# of the entry when it has one, passed through under the JSON rules of the frontmatter with no key acted on
|
|
13
|
+
# (keys of the extension itself use the `io.modelcontextprotocol.skills/` prefix).
|
|
14
|
+
#
|
|
15
|
+
# Every check runs at construction, as `MCP::Icon` does, so a manifest a host may reject fails on
|
|
16
|
+
# the server at definition time: the URI grammar, the frontmatter (`name` and `description`, `name` equal to
|
|
17
|
+
# the last segment of the skill path, JSON-representable values), and the resource entries (under
|
|
18
|
+
# the skill root, unique, the skill's own `SKILL.md` among them). A skill defined locally is also held to
|
|
19
|
+
# the unreserved URI character set, the Agent Skills rules for `name` and `description`, and the SEP limits
|
|
20
|
+
# (512 entries, 16 MiB); entries a client receives from a server are checked structurally only, because
|
|
21
|
+
# a host MUST accept skills up to the limits and MAY accept larger ones, and the Agent Skills guidance for
|
|
22
|
+
# clients is to load a skill whose name breaks a rule anyway. The files of a nested skill, its `SKILL.md`
|
|
23
|
+
# included, are supporting files of the enclosing skill and appear in both manifests, as the specification
|
|
24
|
+
# requires of a complete manifest.
|
|
25
|
+
#
|
|
26
|
+
# Instances are frozen value objects. Equality is identity: the client binds verified state to the exact
|
|
27
|
+
# entry object it checked.
|
|
28
|
+
#
|
|
29
|
+
# https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/beta/skills.mdx
|
|
30
|
+
class Skill
|
|
31
|
+
EXTENSION_ID = "io.modelcontextprotocol/skills"
|
|
32
|
+
# SEP-2640 limits per skill: servers SHOULD NOT serve a skill beyond them, hosts MUST accept up to them.
|
|
33
|
+
MAX_RESOURCES = 512
|
|
34
|
+
MAX_TOTAL_SIZE = 16 * 1024 * 1024
|
|
35
|
+
# The Agent Skills rules SEP-2640 requires of the last segment of a skill path, its `name`: lowercase
|
|
36
|
+
# letters, digits, and single hyphens that neither lead nor trail, 64 characters at most. `description`
|
|
37
|
+
# holds at most 1024 characters.
|
|
38
|
+
NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/.freeze
|
|
39
|
+
MAX_NAME_LENGTH = 64
|
|
40
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
41
|
+
# The `resources` value of a skill whose files are generated per request and carry no digests.
|
|
42
|
+
DYNAMIC = "dynamic"
|
|
43
|
+
|
|
44
|
+
# `skill://` URI rules shared by the server and the client. URIs are plain Strings: nothing is decoded,
|
|
45
|
+
# normalized, or joined, and `URI.parse` is never used.
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
module Uri
|
|
49
|
+
extend self
|
|
50
|
+
|
|
51
|
+
SCHEME = "skill://"
|
|
52
|
+
MANIFEST = "SKILL.md"
|
|
53
|
+
# RFC 3986 unreserved characters, the only ones a locally defined URI may use in a segment.
|
|
54
|
+
LOCAL_SEGMENT = /\A[A-Za-z0-9._~-]+\z/.freeze
|
|
55
|
+
# SDK limits (not protocol rules) on a URI the client resolves against its remembered skills. Resolution
|
|
56
|
+
# considers every ancestor root, so without a bound one read would cost time and memory quadratic in
|
|
57
|
+
# the length of a URI that may come from untrusted model output.
|
|
58
|
+
MAX_RESOLVED_BYTES = 8 * 1024
|
|
59
|
+
MAX_RESOLVED_SEGMENTS = 128
|
|
60
|
+
|
|
61
|
+
# Whether `uri` is within the limits above; counts separators instead of splitting, so the check is linear.
|
|
62
|
+
def resolvable?(uri)
|
|
63
|
+
uri.bytesize <= MAX_RESOLVED_BYTES && uri.count("/") <= MAX_RESOLVED_SEGMENTS + 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The path segments of a `skill://` URI, or nil when it is not structurally valid: a validly encoded String
|
|
67
|
+
# in an ASCII-compatible encoding with at least one segment, none empty, none `.` or `..`.
|
|
68
|
+
def segments(uri)
|
|
69
|
+
return unless uri.is_a?(String) && uri.encoding.ascii_compatible? && uri.valid_encoding? && uri.start_with?(SCHEME)
|
|
70
|
+
|
|
71
|
+
parts = uri[SCHEME.length..-1].split("/", -1)
|
|
72
|
+
return if parts.empty? || parts.any? { |part| part.empty? || part == "." || part == ".." }
|
|
73
|
+
|
|
74
|
+
parts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def structural?(uri)
|
|
78
|
+
!segments(uri).nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The segments before `SKILL.md`, or nil when the URI does not name a `SKILL.md`.
|
|
82
|
+
def skill_path(uri)
|
|
83
|
+
parts = segments(uri)
|
|
84
|
+
return unless parts && parts.length >= 2 && parts.last == MANIFEST
|
|
85
|
+
|
|
86
|
+
parts[0...-1]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def manifest_uri?(uri)
|
|
90
|
+
!skill_path(uri).nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def local_segments?(parts)
|
|
94
|
+
parts.all? { |part| LOCAL_SEGMENT.match?(part) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def root(skill_path)
|
|
98
|
+
"#{SCHEME}#{skill_path.join("/")}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether `uri` lies under `root` at a segment boundary: `skill://ab/x` is not under `skill://a`.
|
|
102
|
+
def under?(uri, root)
|
|
103
|
+
uri.start_with?("#{root}/")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The `SKILL.md` URIs of every skill whose root could contain `uri`, least specific first; for
|
|
107
|
+
# a `SKILL.md` URI the last candidate is the URI itself.
|
|
108
|
+
def candidate_skill_uris(uri)
|
|
109
|
+
parts = segments(uri)
|
|
110
|
+
return [] unless parts && parts.length >= 2
|
|
111
|
+
|
|
112
|
+
directories = parts[0...-1]
|
|
113
|
+
(1..directories.length).map { |count| "#{root(directories.first(count))}/#{MANIFEST}" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# JSON-value helpers for frontmatter and capability copies.
|
|
118
|
+
#
|
|
119
|
+
# @api private
|
|
120
|
+
module Json
|
|
121
|
+
extend self
|
|
122
|
+
|
|
123
|
+
# The largest integer every JSON reader represents exactly (RFC 7493); beyond it JavaScript loses digits,
|
|
124
|
+
# so two hosts would disagree about one frontmatter.
|
|
125
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
126
|
+
# The default nesting limit of `JSON.parse`, which the SDK's transports apply to a whole message. Restated
|
|
127
|
+
# here so that a value from a transport parsing without one is refused instead of exhausting the stack.
|
|
128
|
+
MAX_DEPTH = 100
|
|
129
|
+
|
|
130
|
+
# A deep copy with String keys holding only JSON-representable values (String that JSON can encode, Integer within
|
|
131
|
+
# the safe range, finite Float, true, false, nil, Array, Hash); refuses cycles, nesting beyond `MAX_DEPTH`, and keys
|
|
132
|
+
# that collide once stringified.
|
|
133
|
+
def normalize(value, path = {}.compare_by_identity)
|
|
134
|
+
case value
|
|
135
|
+
when String then normalize_string(value)
|
|
136
|
+
when TrueClass, FalseClass, NilClass then value
|
|
137
|
+
when Integer
|
|
138
|
+
raise ArgumentError, "must hold integers within 2**53" unless value.abs <= MAX_SAFE_INTEGER
|
|
139
|
+
|
|
140
|
+
value
|
|
141
|
+
when Float
|
|
142
|
+
raise ArgumentError, "must hold finite numbers" unless value.finite?
|
|
143
|
+
|
|
144
|
+
value
|
|
145
|
+
when Array
|
|
146
|
+
raise ArgumentError, "must not be cyclic" if path.key?(value)
|
|
147
|
+
|
|
148
|
+
refuse_depth!(path.size)
|
|
149
|
+
path[value] = true
|
|
150
|
+
result = value.map { |member| normalize(member, path) }
|
|
151
|
+
path.delete(value)
|
|
152
|
+
result
|
|
153
|
+
when Hash
|
|
154
|
+
raise ArgumentError, "must not be cyclic" if path.key?(value)
|
|
155
|
+
|
|
156
|
+
refuse_depth!(path.size)
|
|
157
|
+
path[value] = true
|
|
158
|
+
result = {}
|
|
159
|
+
value.each do |key, member|
|
|
160
|
+
name = case key
|
|
161
|
+
when String then normalize_string(key)
|
|
162
|
+
when Symbol then normalize_string(key.to_s)
|
|
163
|
+
else raise ArgumentError, "keys must be Strings or Symbols (got #{key.class})"
|
|
164
|
+
end
|
|
165
|
+
raise ArgumentError, "keys collide after stringification (#{name.dump})" if result.key?(name)
|
|
166
|
+
|
|
167
|
+
result[name] = normalize(member, path)
|
|
168
|
+
end
|
|
169
|
+
path.delete(value)
|
|
170
|
+
result
|
|
171
|
+
else
|
|
172
|
+
raise ArgumentError, "must be JSON-representable (got #{value.class})"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def deep_freeze(value)
|
|
177
|
+
case value
|
|
178
|
+
when Hash
|
|
179
|
+
value.each_value { |member| deep_freeze(member) }
|
|
180
|
+
when Array
|
|
181
|
+
value.each { |member| deep_freeze(member) }
|
|
182
|
+
end
|
|
183
|
+
value.freeze
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A deep copy of a value a transport returned; refuses nesting beyond `MAX_DEPTH` with an `ArgumentError`.
|
|
187
|
+
def deep_dup(value, depth = 0)
|
|
188
|
+
case value
|
|
189
|
+
when Hash
|
|
190
|
+
refuse_depth!(depth)
|
|
191
|
+
value.each_with_object({}) { |(key, member), copy| copy[deep_dup(key, depth + 1)] = deep_dup(member, depth + 1) }
|
|
192
|
+
when Array
|
|
193
|
+
refuse_depth!(depth)
|
|
194
|
+
value.map { |member| deep_dup(member, depth + 1) }
|
|
195
|
+
when String then value.dup
|
|
196
|
+
else value
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Structural equality with Integer and Float kept distinct, Hash keys compared as sets, and Arrays in order.
|
|
201
|
+
def equal?(expected, actual)
|
|
202
|
+
case expected
|
|
203
|
+
when Hash
|
|
204
|
+
return false unless actual.is_a?(Hash) && expected.size == actual.size
|
|
205
|
+
|
|
206
|
+
expected.all? { |key, member| actual.key?(key) && equal?(member, actual[key]) }
|
|
207
|
+
when Array
|
|
208
|
+
return false unless actual.is_a?(Array) && expected.size == actual.size
|
|
209
|
+
|
|
210
|
+
expected.each_with_index.all? { |member, index| equal?(member, actual[index]) }
|
|
211
|
+
when Float
|
|
212
|
+
actual.is_a?(Float) && (expected == actual || (expected.nan? && actual.nan?))
|
|
213
|
+
when Integer
|
|
214
|
+
actual.is_a?(Integer) && expected == actual
|
|
215
|
+
else
|
|
216
|
+
expected.class.equal?(actual.class) && expected == actual
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
def refuse_depth!(depth)
|
|
223
|
+
raise ArgumentError, "must not nest deeper than #{MAX_DEPTH} levels" if depth >= MAX_DEPTH
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def normalize_string(value)
|
|
227
|
+
raise ArgumentError, "must hold strings JSON can encode" unless utf8_encodable?(value)
|
|
228
|
+
|
|
229
|
+
value.dup
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# JSON carries UTF-8. `encode` hands a String already labeled UTF-8 back unchecked, so that case asks
|
|
233
|
+
# `valid_encoding?` instead; a binary String with a byte beyond ASCII fails the conversion.
|
|
234
|
+
def utf8_encodable?(value)
|
|
235
|
+
return value.valid_encoding? if value.encoding == Encoding::UTF_8
|
|
236
|
+
|
|
237
|
+
value.encode(Encoding::UTF_8)
|
|
238
|
+
true
|
|
239
|
+
rescue EncodingError
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private_constant :Frontmatter
|
|
245
|
+
|
|
246
|
+
class << self
|
|
247
|
+
# The `capabilities.extensions` fragment advertising the extension, for a hand-built
|
|
248
|
+
# `MCP::Server::Capabilities`; a server configured with `skills:` adds it itself.
|
|
249
|
+
def capability
|
|
250
|
+
{ EXTENSION_ID => {} }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Builds a skill from an entry a server returned (String or Symbol keys), under the structural rules only.
|
|
254
|
+
#
|
|
255
|
+
# @api private
|
|
256
|
+
def from_entry(entry)
|
|
257
|
+
raise ArgumentError, "A skill entry must be a Hash (got #{entry.class})." unless entry.is_a?(Hash)
|
|
258
|
+
|
|
259
|
+
skill = allocate
|
|
260
|
+
skill.__send__(
|
|
261
|
+
:initialize_members,
|
|
262
|
+
read_key(entry, "uri"),
|
|
263
|
+
read_key(entry, "frontmatter"),
|
|
264
|
+
read_key(entry, "resources"),
|
|
265
|
+
meta: read_key(entry, "_meta"),
|
|
266
|
+
local: false,
|
|
267
|
+
file_records: nil,
|
|
268
|
+
)
|
|
269
|
+
skill
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Builds a locally defined skill that also carries the records `from_directory` serves files from.
|
|
273
|
+
#
|
|
274
|
+
# @api private
|
|
275
|
+
def with_file_records(uri:, frontmatter:, resources:, file_records:, meta: nil)
|
|
276
|
+
skill = allocate
|
|
277
|
+
skill.__send__(:initialize_members, uri, frontmatter, resources, meta: meta, local: true, file_records: file_records)
|
|
278
|
+
skill
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Checks fetched bytes against the entry for `uri` in `skill`'s manifest (size, then SHA-256), for hosts
|
|
282
|
+
# that read skill files through `MCP::Client#read_resource` rather than `#read_skill_resource`. Pass
|
|
283
|
+
# exactly the bytes received: `text.b` for a `text` item, the decoded `blob` otherwise. Raises
|
|
284
|
+
# `MCP::Client::SkillReadRefusedError` when the skill is dynamic or does not list `uri`, and
|
|
285
|
+
# `MCP::Client::SkillVerificationError` on a mismatch. Does not compare frontmatter: a `SKILL.md`
|
|
286
|
+
# also needs `verify_frontmatter`.
|
|
287
|
+
def verify_resource(skill, uri, bytes)
|
|
288
|
+
check_skill_and_uri!(skill, uri)
|
|
289
|
+
raise ArgumentError, "The value of bytes must be a String (got #{bytes.class})." unless bytes.is_a?(String)
|
|
290
|
+
|
|
291
|
+
if skill.dynamic?
|
|
292
|
+
raise Client::SkillReadRefusedError.new(
|
|
293
|
+
"Skill #{skill.uri.dump} is dynamic and lists no digests to verify #{uri.dump} against",
|
|
294
|
+
reason: :dynamic, uri: uri, skill_uri: skill.uri,
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
entry = skill.resources.find { |candidate| candidate.uri == uri }
|
|
299
|
+
unless entry
|
|
300
|
+
raise Client::SkillReadRefusedError.new(
|
|
301
|
+
"Resource #{uri.dump} is not listed by #{skill.uri.dump}",
|
|
302
|
+
reason: :not_in_manifest, uri: uri, skill_uri: skill.uri,
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
unless bytes.bytesize == entry.size
|
|
307
|
+
raise Client::SkillVerificationError.new(
|
|
308
|
+
"Resource #{uri.dump} does not have the size listed by #{skill.uri.dump}",
|
|
309
|
+
uri: uri, skill_uri: skill.uri, reason: :size,
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
unless "sha256:#{Digest::SHA256.hexdigest(bytes)}" == entry.digest
|
|
314
|
+
raise Client::SkillVerificationError.new(
|
|
315
|
+
"Resource #{uri.dump} does not have the digest listed by #{skill.uri.dump}",
|
|
316
|
+
uri: uri, skill_uri: skill.uri, reason: :digest,
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
true
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Compares the frontmatter of a fetched `SKILL.md` (its text) with `skill`'s entry field by field,
|
|
324
|
+
# the host-side MUST of SEP-2640, which `verify_resource` does not cover. Raises
|
|
325
|
+
# `MCP::Client::SkillVerificationError` with reason `:parse` or `:frontmatter`.
|
|
326
|
+
def verify_frontmatter(skill, text)
|
|
327
|
+
raise ArgumentError, "The value of skill must be an MCP::Skill (got #{skill.class})." unless skill.is_a?(Skill)
|
|
328
|
+
raise ArgumentError, "The value of text must be a String (got #{text.class})." unless text.is_a?(String)
|
|
329
|
+
|
|
330
|
+
actual = begin
|
|
331
|
+
Frontmatter.load(text)
|
|
332
|
+
rescue Frontmatter::Error => e
|
|
333
|
+
raise Client::SkillVerificationError.new(
|
|
334
|
+
"The frontmatter of #{skill.uri.dump} cannot be read",
|
|
335
|
+
uri: skill.uri, skill_uri: skill.uri, reason: e.reason,
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
unless Json.equal?(skill.frontmatter, actual)
|
|
340
|
+
raise Client::SkillVerificationError.new(
|
|
341
|
+
"The frontmatter of #{skill.uri.dump} differs from its entry",
|
|
342
|
+
uri: skill.uri, skill_uri: skill.uri, reason: :frontmatter,
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The file records of directory-backed skills keyed by URI, for the server's default resource reader.
|
|
350
|
+
# A nested skill and its enclosing skill share the records of the nested files, so the same record
|
|
351
|
+
# under the same URI is fine; two different records for one URI are not.
|
|
352
|
+
#
|
|
353
|
+
# @api private
|
|
354
|
+
def file_map(skills)
|
|
355
|
+
map = {}
|
|
356
|
+
skills.each do |skill|
|
|
357
|
+
records = skill.__send__(:file_records)
|
|
358
|
+
next unless records
|
|
359
|
+
|
|
360
|
+
records.each do |uri, record|
|
|
361
|
+
existing = map[uri]
|
|
362
|
+
next if existing == record
|
|
363
|
+
raise ArgumentError, "The skill file #{uri} is served by more than one skill from different files." if existing
|
|
364
|
+
|
|
365
|
+
map[uri] = record
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
map.freeze
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The roots (`skill://<path>`) of the directory-backed skills among `skills`, for the server to tell
|
|
372
|
+
# a read of an unserved file under one of them from an ordinary unknown resource.
|
|
373
|
+
#
|
|
374
|
+
# @api private
|
|
375
|
+
def file_roots(skills)
|
|
376
|
+
skills.select { |skill| skill.__send__(:file_records) }.map { |skill| Uri.root(skill.skill_path) }.uniq.freeze
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
private
|
|
380
|
+
|
|
381
|
+
def read_key(hash, key)
|
|
382
|
+
value = hash[key]
|
|
383
|
+
value.nil? ? hash[key.to_sym] : value
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def check_skill_and_uri!(skill, uri)
|
|
387
|
+
raise ArgumentError, "The value of skill must be an MCP::Skill (got #{skill.class})." unless skill.is_a?(Skill)
|
|
388
|
+
raise ArgumentError, "The value of uri must be a String (got #{uri.class})." unless uri.is_a?(String)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
attr_reader :uri, :skill_path, :name, :frontmatter, :resources
|
|
393
|
+
|
|
394
|
+
# The `_meta` of the entry as a frozen Hash with String keys, or nil when the entry carries none.
|
|
395
|
+
attr_reader :meta
|
|
396
|
+
|
|
397
|
+
def initialize(uri:, frontmatter:, resources:, meta: nil)
|
|
398
|
+
initialize_members(uri, frontmatter, resources, meta: meta, local: true, file_records: nil)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def dynamic?
|
|
402
|
+
@resources == DYNAMIC
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The sum of the listed sizes, or nil for a dynamic skill; the hook for a host that declines large skills.
|
|
406
|
+
def total_size
|
|
407
|
+
@total_size
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def to_h
|
|
411
|
+
entry = { uri: uri, frontmatter: frontmatter, resources: dynamic? ? DYNAMIC : resources.map(&:to_h) }
|
|
412
|
+
entry[:_meta] = meta if meta
|
|
413
|
+
entry
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
private
|
|
417
|
+
|
|
418
|
+
def file_records
|
|
419
|
+
@file_records
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def initialize_members(uri, frontmatter, resources, meta:, local:, file_records:)
|
|
423
|
+
path = Uri.skill_path(uri)
|
|
424
|
+
unless path
|
|
425
|
+
raise ArgumentError, "The value of uri must be a skill://<path>/SKILL.md URI with non-empty path segments."
|
|
426
|
+
end
|
|
427
|
+
if local && !Uri.local_segments?(path)
|
|
428
|
+
raise ArgumentError, "The value of uri may only use letters, digits, -, ., _, and ~ in its segments."
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
normalized = begin
|
|
432
|
+
Json.normalize(frontmatter)
|
|
433
|
+
rescue ArgumentError => e
|
|
434
|
+
raise ArgumentError, "The value of frontmatter #{e.message}."
|
|
435
|
+
end
|
|
436
|
+
unless normalized.is_a?(Hash)
|
|
437
|
+
raise ArgumentError, "The value of frontmatter must be a Hash (got #{frontmatter.class})."
|
|
438
|
+
end
|
|
439
|
+
["name", "description"].each do |field|
|
|
440
|
+
unless normalized[field].is_a?(String) && !normalized[field].empty?
|
|
441
|
+
raise ArgumentError, "The value of frontmatter must hold a non-empty String under #{field}."
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
unless normalized["name"] == path.last
|
|
445
|
+
raise ArgumentError, "The frontmatter name must equal the last segment of the skill path."
|
|
446
|
+
end
|
|
447
|
+
check_agent_skills_rules!(normalized) if local
|
|
448
|
+
|
|
449
|
+
@uri = -uri
|
|
450
|
+
@skill_path = path.map { |segment| -segment }.freeze
|
|
451
|
+
@frontmatter = Json.deep_freeze(normalized)
|
|
452
|
+
@name = @frontmatter["name"]
|
|
453
|
+
@resources = normalize_resources(resources, local)
|
|
454
|
+
@total_size = dynamic? ? nil : @resources.sum(&:size)
|
|
455
|
+
@meta = normalize_meta(meta)
|
|
456
|
+
@file_records = file_records
|
|
457
|
+
freeze
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# The length is looked at first, so the pattern never runs over a long name.
|
|
461
|
+
def check_agent_skills_rules!(frontmatter)
|
|
462
|
+
name = frontmatter["name"]
|
|
463
|
+
unless name.length <= MAX_NAME_LENGTH && NAME_PATTERN.match?(name)
|
|
464
|
+
raise ArgumentError, "The frontmatter name must be 1 to #{MAX_NAME_LENGTH} lowercase letters, digits, " \
|
|
465
|
+
"and single hyphens between them."
|
|
466
|
+
end
|
|
467
|
+
return if frontmatter["description"].length <= MAX_DESCRIPTION_LENGTH
|
|
468
|
+
|
|
469
|
+
raise ArgumentError, "The frontmatter description must hold at most #{MAX_DESCRIPTION_LENGTH} characters."
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def normalize_meta(meta)
|
|
473
|
+
return if meta.nil?
|
|
474
|
+
|
|
475
|
+
normalized = begin
|
|
476
|
+
Json.normalize(meta)
|
|
477
|
+
rescue ArgumentError => e
|
|
478
|
+
raise ArgumentError, "The value of meta #{e.message}."
|
|
479
|
+
end
|
|
480
|
+
raise ArgumentError, "The value of meta must be a Hash (got #{meta.class})." unless normalized.is_a?(Hash)
|
|
481
|
+
|
|
482
|
+
Json.deep_freeze(normalized)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def normalize_resources(resources, local)
|
|
486
|
+
return DYNAMIC if resources == DYNAMIC
|
|
487
|
+
|
|
488
|
+
unless resources.is_a?(Array)
|
|
489
|
+
raise ArgumentError, "The value of resources must be \"dynamic\" or an Array of entries (got #{resources.class})."
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
root = Uri.root(@skill_path)
|
|
493
|
+
seen = {}
|
|
494
|
+
entries = resources.each_with_index.map do |value, index|
|
|
495
|
+
entry = begin
|
|
496
|
+
# A remote entry may carry members this SDK does not know; a local definition may not.
|
|
497
|
+
local ? ResourceEntry.from(value) : ResourceEntry.from_wire(value)
|
|
498
|
+
rescue ArgumentError => e
|
|
499
|
+
raise ArgumentError, "resources[#{index}]: #{e.message}"
|
|
500
|
+
end
|
|
501
|
+
raise ArgumentError, "resources[#{index}]: The entry uri must lie under #{root.dump}." unless Uri.under?(entry.uri, root)
|
|
502
|
+
if local && !Uri.local_segments?(Uri.segments(entry.uri))
|
|
503
|
+
raise ArgumentError, "resources[#{index}]: The entry uri may only use letters, digits, -, ., _, and ~ in its segments."
|
|
504
|
+
end
|
|
505
|
+
raise ArgumentError, "resources[#{index}]: The entry uri is listed twice." if seen.key?(entry.uri)
|
|
506
|
+
|
|
507
|
+
seen[entry.uri] = true
|
|
508
|
+
entry
|
|
509
|
+
end
|
|
510
|
+
raise ArgumentError, "The value of resources must list the skill's own SKILL.md." unless seen.key?(@uri)
|
|
511
|
+
|
|
512
|
+
if local
|
|
513
|
+
raise ArgumentError, "The value of resources must hold at most #{MAX_RESOURCES} entries." if entries.size > MAX_RESOURCES
|
|
514
|
+
if entries.sum(&:size) > MAX_TOTAL_SIZE
|
|
515
|
+
raise ArgumentError, "The value of resources must list at most #{MAX_TOTAL_SIZE} bytes in total."
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
entries.freeze
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
@@ -7,9 +7,10 @@ module MCP
|
|
|
7
7
|
class OutputSchema < Schema
|
|
8
8
|
class ValidationError < StandardError; end
|
|
9
9
|
|
|
10
|
-
# Root-level keywords whose presence means the user already chose a root schema shape,
|
|
11
|
-
#
|
|
12
|
-
|
|
10
|
+
# Root-level keywords whose presence means the user already chose a root schema shape, so no `type: "object"`
|
|
11
|
+
# default should be merged in. Both reference keywords count, since a root `$dynamicRef` names the shape as
|
|
12
|
+
# a root `$ref` does; they come from `Schema` so that this list cannot drift from the same-document check.
|
|
13
|
+
ROOT_SCHEMA_KEYWORDS = [:type, *REFERENCE_KEYWORDS, :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze
|
|
13
14
|
|
|
14
15
|
def validate_result(result)
|
|
15
16
|
errors = fully_validate(result)
|
data/lib/mcp/version.rb
CHANGED
data/lib/mcp.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -59,6 +59,8 @@ files:
|
|
|
59
59
|
- lib/mcp/client/oauth/provider.rb
|
|
60
60
|
- lib/mcp/client/oauth/storage_backed_provider.rb
|
|
61
61
|
- lib/mcp/client/paginated_result.rb
|
|
62
|
+
- lib/mcp/client/skill_errors.rb
|
|
63
|
+
- lib/mcp/client/skills.rb
|
|
62
64
|
- lib/mcp/client/stdio.rb
|
|
63
65
|
- lib/mcp/client/tool.rb
|
|
64
66
|
- lib/mcp/configuration.rb
|
|
@@ -104,6 +106,10 @@ files:
|
|
|
104
106
|
- lib/mcp/server/transports/streamable_http_transport.rb
|
|
105
107
|
- lib/mcp/server_context.rb
|
|
106
108
|
- lib/mcp/server_session.rb
|
|
109
|
+
- lib/mcp/skill.rb
|
|
110
|
+
- lib/mcp/skill/directory.rb
|
|
111
|
+
- lib/mcp/skill/frontmatter.rb
|
|
112
|
+
- lib/mcp/skill/resource_entry.rb
|
|
107
113
|
- lib/mcp/string_utils.rb
|
|
108
114
|
- lib/mcp/tool.rb
|
|
109
115
|
- lib/mcp/tool/annotations.rb
|
|
@@ -119,7 +125,7 @@ licenses:
|
|
|
119
125
|
- Apache-2.0
|
|
120
126
|
metadata:
|
|
121
127
|
allowed_push_host: https://rubygems.org
|
|
122
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.
|
|
128
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.9.0
|
|
123
129
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
124
130
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
125
131
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|