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.
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
- # so no `type: "object"` default should be merged in.
12
- ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.8.1"
4
+ VERSION = "1.9.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -24,6 +24,7 @@ module MCP
24
24
  autoload :ResultType, "mcp/result_type"
25
25
  autoload :Server, "mcp/server"
26
26
  autoload :ServerSession, "mcp/server_session"
27
+ autoload :Skill, "mcp/skill"
27
28
  autoload :Tool, "mcp/tool"
28
29
  autoload :TraceContext, "mcp/trace_context"
29
30
 
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.8.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.8.1
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