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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e99b2f4d4d5727123d1ae167586134b1f06498f975ed2b16cb2f7e1c413d1271
4
- data.tar.gz: 9ea2939b0f5fd94cad620376561def6cc0db832f0f62f990cdf66799c91c3bb6
3
+ metadata.gz: 95dcc461ef4ba74c3128e82441b81587135488d8c9e4d17f5ea91ba45867f080
4
+ data.tar.gz: 2d0afc689585aad6ffaf37c4ca5528497e30bf81e733514a05faf724cfb3e2f4
5
5
  SHA512:
6
- metadata.gz: acc1d2aa4fa8c76b2028cf2cc74b806b08ffc3e42a1e41d48326c53145fc6d3c95e6e724db9960e808b5c2f80330d4973caec143d97bf644f0f800348782476c
7
- data.tar.gz: ddf8af09d31cb72577ceb3b4f2ef7fb90f0e813790966d532cc82690d9e9f56847cf8f391c36e860c6f51747dd795394f943941d6ecb44045e9ba424819b5854
6
+ metadata.gz: 68e755e7b252d6680ca82ce073b3e541e0b4202ac18ce5b73a5175c326aa655e7cbdf4d0685cdb06d4007cc35696b0e7ad60c7a50820567b63b84e4cce5d7c7c
7
+ data.tar.gz: 68a614ac492dfcfa34a1429efc57e031089dfa65b10db2f6b18b9d0bbffe6ebbcb03c13eacbac9cb3dea178fda603b279825b2cb67ccb81081523db809e27e98
data/README.md CHANGED
@@ -93,6 +93,8 @@ see [Server Transports](https://ruby.sdk.modelcontextprotocol.io/server/transpor
93
93
  A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools:
94
94
 
95
95
  ```ruby
96
+ require "mcp"
97
+
96
98
  stdio_transport = MCP::Client::Stdio.new(
97
99
  command: "bundle",
98
100
  args: ["exec", "ruby", "path/to/server.rb"],
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ # Base class of the errors the Skills extension (SEP-2640) raises on the client side, so a host can
6
+ # rescue all of them at once. Messages carry request and skill URIs only, never content.
7
+ class SkillError < StandardError; end
8
+
9
+ # A `skills/list` or `skills/get` result the server returned is not a conformant skill entry
10
+ # (wrong shape, a malformed digest, a resource outside the skill root, a duplicate URI within one page,
11
+ # or a `skills/get` result for another URI). Nothing is remembered from such a result.
12
+ class SkillEntryError < SkillError; end
13
+
14
+ # The content a `resources/read` returned does not match the skill's manifest: `reason` is one of
15
+ # `:contents` (not exactly one item for the requested URI with exactly one of `text` or `blob`,
16
+ # an undecodable `blob`, or an item nested too deeply to copy), `:size`, `:digest`, `:parse` (the `SKILL.md`
17
+ # frontmatter cannot be read), or `:frontmatter` (it differs from the entry). The skill is latched for
18
+ # the life of the connection.
19
+ class SkillVerificationError < SkillError
20
+ attr_reader :uri, :skill_uri, :reason
21
+
22
+ def initialize(message, uri:, skill_uri:, reason:)
23
+ super(message)
24
+ @uri = uri
25
+ @skill_uri = skill_uri
26
+ @reason = reason
27
+ end
28
+ end
29
+
30
+ # A Skills call was refused before it was sent, or its completion was discarded because the connection
31
+ # changed. `reason` is one of `:not_advertised`, `:connection_changed`, `:latched`, `:unknown_skill`,
32
+ # `:dynamic`, `:not_in_manifest`, or `:skill_md_unverified`; later releases may add reasons, so treat
33
+ # an unknown one as a refusal too. Nothing is latched by a refusal.
34
+ class SkillReadRefusedError < SkillError
35
+ attr_reader :uri, :skill_uri, :reason
36
+
37
+ def initialize(message, reason:, uri: nil, skill_uri: nil)
38
+ super(message)
39
+ @reason = reason
40
+ @uri = uri
41
+ @skill_uri = skill_uri
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "digest"
5
+
6
+ module MCP
7
+ class Client
8
+ # One page of `skills/list`: `skills` is a frozen Array of `MCP::Skill`; `next_cursor`, `meta`, `ttl_ms`, and
9
+ # `cache_scope` mirror the response members. Frozen on construction.
10
+ ListSkillsResult = Struct.new(:skills, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) do
11
+ def initialize(**kwargs)
12
+ super(**kwargs)
13
+ freeze
14
+ end
15
+ end
16
+
17
+ # The entry `skills/get` returned for exactly the requested URI. Frozen on construction.
18
+ GetSkillResult = Struct.new(:skill, :meta, :ttl_ms, :cache_scope, keyword_init: true) do
19
+ def initialize(**kwargs)
20
+ super(**kwargs)
21
+ freeze
22
+ end
23
+ end
24
+
25
+ # A skill file read and verified by `read_skill_resource`: `bytes` is the verified content as a frozen
26
+ # binary-encoded String (the decoded `blob`, or the bytes of the `text`), `mime_type` is the server's
27
+ # unverified display metadata (never a reason to pick an interpreter), `contents` is the raw frozen
28
+ # `contents` Array, `skill` is the entry object the content was checked against, and `verified` is true:
29
+ # the digest, size, and (for a `SKILL.md`) frontmatter matched. Every value is independent of the objects
30
+ # it was built from, so mutating a source cannot change the result.
31
+ SkillReadResult = Struct.new(:uri, :bytes, :mime_type, :contents, :skill, :verified, keyword_init: true) do
32
+ def initialize(**kwargs)
33
+ super(**kwargs)
34
+ freeze
35
+ end
36
+ end
37
+
38
+ # Client side of the Skills extension (SEP-2640), mixed into `MCP::Client`: `list_skills`, `get_skill`,
39
+ # `read_skill_resource`, and `skills_capability`.
40
+ #
41
+ # Every call is admitted only after a handshake of the current connection advertised both the extension and
42
+ # `resources`, as the SEP asks of clients. Entries the server returned are remembered per client instance,
43
+ # bound to the connection: `connect` starts a new generation and forgets them. `read_skill_resource` checks
44
+ # the content against the entry it resolved (size, SHA-256, and the frontmatter of a `SKILL.md`), withholds
45
+ # content that does not match, and latches the skill so that no later read of it is sent on this connection,
46
+ # which is what keeps a host from loading part of a tampered skill. `read_resource` stays the raw,
47
+ # unverified way to fetch a skill file; `MCP::Skill.verify_resource` and `MCP::Skill.verify_frontmatter`
48
+ # exist for hosts that go that way and then own the latching and ordering obligations themselves.
49
+ module Skills
50
+ # Fetches one page of the skills the server serves.
51
+ #
52
+ # @raise [SkillReadRefusedError] `:not_advertised` before any request is sent, `:connection_changed` when
53
+ # `connect` ran while the request was in flight (the page is discarded).
54
+ # @raise [SkillEntryError] When the page is not a conformant `skills/list` result.
55
+ def list_skills(cursor: nil, meta: nil, cancellation: nil)
56
+ generation = @skills_state.admit(connected: connected?)
57
+ params = cursor ? { cursor: cursor } : nil
58
+ response = request(method: Methods::SKILLS_LIST, params: params, meta: meta, cancellation: cancellation)
59
+ @skills_state.ensure_current!(generation)
60
+
61
+ result = response.is_a?(Hash) ? response["result"] : nil
62
+ raise SkillEntryError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
63
+
64
+ entries = result["skills"]
65
+ raise SkillEntryError, "Response validation failed: `skills` is not an Array" unless entries.is_a?(Array)
66
+
67
+ skills = entries.map { |entry| parse_skill_entry(entry) }
68
+ uris = skills.map(&:uri)
69
+ raise SkillEntryError, "Response validation failed: a skill URI is listed twice in one page" unless uris.uniq.size == uris.size
70
+
71
+ @skills_state.install(generation, skills)
72
+ ListSkillsResult.new(
73
+ skills: skills.freeze,
74
+ next_cursor: result["nextCursor"],
75
+ meta: result["_meta"],
76
+ ttl_ms: result["ttlMs"],
77
+ cache_scope: result["cacheScope"],
78
+ )
79
+ end
80
+
81
+ # Fetches the entry of one skill by the URI of its `SKILL.md`, listed or not.
82
+ #
83
+ # @raise [SkillReadRefusedError] `:not_advertised` or `:connection_changed`, as for `list_skills`.
84
+ # @raise [SkillEntryError] When the result is not a conformant entry for exactly `uri`.
85
+ def get_skill(uri:, meta: nil, cancellation: nil)
86
+ check_skill_uri!(uri)
87
+
88
+ generation = @skills_state.admit(connected: connected?)
89
+ response = request(method: Methods::SKILLS_GET, params: { uri: uri }, meta: meta, cancellation: cancellation)
90
+ @skills_state.ensure_current!(generation)
91
+
92
+ result = response.is_a?(Hash) ? response["result"] : nil
93
+ raise SkillEntryError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
94
+
95
+ skill = parse_skill_entry(result["skill"])
96
+ raise SkillEntryError, "Response validation failed: skills/get answered for #{skill.uri.dump} instead of #{uri.dump}" unless skill.uri == uri
97
+
98
+ @skills_state.install(generation, [skill])
99
+ GetSkillResult.new(skill: skill, meta: result["_meta"], ttl_ms: result["ttlMs"], cache_scope: result["cacheScope"])
100
+ end
101
+
102
+ # Reads one file of a skill through `resources/read` and verifies it against the skill's entry before
103
+ # returning it. The entry is `skill:` when given (for this read only), else the most specific remembered
104
+ # skill that contains `uri`, never an enclosing one: when an enclosing skill lists `uri` as well the read is
105
+ # refused as `:ambiguous`, since only the host knows which skill it is acting on, and a `SKILL.md` URI
106
+ # resolves only to its own entry. A supporting file is read only after the skill's `SKILL.md` verified on
107
+ # this instance.
108
+ #
109
+ # @raise [SkillReadRefusedError] Before anything is sent: `:not_advertised`, `:latched`, `:unknown_skill` (also
110
+ # for a URI longer than 8 KiB or deeper than 128 segments, an SDK limit), `:ambiguous` (an enclosing
111
+ # remembered skill lists `uri` too; pass `skill:` for the skill being acted on), `:dynamic` (no digests; use
112
+ # `read_resource` with `MCP::Skill.verify_frontmatter`), `:not_in_manifest`, `:skill_md_unverified`; on
113
+ # completion, `:connection_changed` or `:latched`.
114
+ # @raise [SkillVerificationError] When the content does not match; the skill is latched for the connection.
115
+ def read_skill_resource(uri:, skill: nil, meta: nil, cancellation: nil)
116
+ check_skill_uri!(uri)
117
+ raise ArgumentError, "The value of skill must be an MCP::Skill or nil (got #{skill.class})." unless skill.nil? || skill.is_a?(Skill)
118
+
119
+ # A frozen copy, so a caller mutating its String during the read cannot change the URI that was verified.
120
+ uri = -uri
121
+ unless Skill::Uri.resolvable?(uri)
122
+ # Refused before the ancestor roots are computed, which would cost time and memory quadratic in
123
+ # the length of a URI that may come from model output; the message leaves the URI out for that reason.
124
+ @skills_state.admit(connected: connected?)
125
+ raise SkillReadRefusedError.new(
126
+ "Skills call refused (unknown_skill): the URI is longer than #{Skill::Uri::MAX_RESOLVED_BYTES} bytes or deeper " \
127
+ "than #{Skill::Uri::MAX_RESOLVED_SEGMENTS} segments, beyond what the client resolves",
128
+ reason: :unknown_skill,
129
+ uri: uri,
130
+ )
131
+ end
132
+
133
+ candidates = Skill::Uri.candidate_skill_uris(uri)
134
+ generation, entry, resource_entry = @skills_state.admit_read(uri, candidates, skill, connected: connected?)
135
+ response = request(method: Methods::RESOURCES_READ, params: { uri: uri }, meta: meta, cancellation: cancellation)
136
+ outcome = verify_skill_contents(entry, resource_entry, uri, response)
137
+ bytes, mime_type, contents = @skills_state.complete_read(generation, uri, candidates, entry, explicit: !skill.nil?, outcome: outcome)
138
+
139
+ SkillReadResult.new(uri: uri, bytes: bytes, mime_type: mime_type, contents: contents, skill: entry, verified: true)
140
+ end
141
+
142
+ # The Skills settings the current connection's handshake advertised, as a frozen Hash, or nil when
143
+ # a Skills call would be refused with `:not_advertised`: not ready, not connected, or not advertised.
144
+ # nil never means "read skill files raw instead".
145
+ def skills_capability
146
+ @skills_state.skills_capability(connected: connected?)
147
+ end
148
+
149
+ private
150
+
151
+ def check_skill_uri!(uri)
152
+ raise ArgumentError, "The value of uri must be a String (got #{uri.class})." unless uri.is_a?(String)
153
+ return if uri.valid_encoding? && uri.encoding.ascii_compatible?
154
+
155
+ raise ArgumentError, "The value of uri must be validly encoded in an ASCII-compatible encoding."
156
+ end
157
+
158
+ def parse_skill_entry(entry)
159
+ Skill.from_entry(entry)
160
+ rescue ArgumentError => e
161
+ raise SkillEntryError, "Skill entry rejected: #{e.message}"
162
+ end
163
+
164
+ # Pure verification, run outside the state lock: exactly one content item for `uri` with exactly one of
165
+ # `text` or `blob`, the size before decoding, the digest, and for the skill's own `SKILL.md` the frontmatter.
166
+ # Returns `[:ok, bytes, mime_type, contents]` or `[:failed, error]`.
167
+ def verify_skill_contents(entry, resource_entry, uri, response)
168
+ result = response.is_a?(Hash) ? response["result"] : nil
169
+ contents = result.is_a?(Hash) ? result["contents"] : nil
170
+ item = contents.is_a?(Array) && contents.size == 1 ? contents.first : nil
171
+ unless item.is_a?(Hash) && item["uri"] == uri
172
+ return skill_verification_failure(:contents, uri, entry, "did not come back as exactly one content item")
173
+ end
174
+
175
+ text = item["text"]
176
+ blob = item["blob"]
177
+ unless (text.is_a?(String) && blob.nil?) || (blob.is_a?(String) && text.nil?)
178
+ return skill_verification_failure(:contents, uri, entry, "did not carry exactly one of text or blob")
179
+ end
180
+
181
+ expected_size = resource_entry.size
182
+ if text
183
+ bytes = text.b
184
+ return skill_verification_failure(:size, uri, entry, "has a different size than listed") unless bytes.bytesize == expected_size
185
+ else
186
+ # Checked before decoding so the manifest's size bounds the work: this is the base64 length of exactly `size` bytes.
187
+ return skill_verification_failure(:size, uri, entry, "has a different size than listed") unless blob.length <= ((expected_size + 2) / 3) * 4
188
+
189
+ bytes = begin
190
+ blob.unpack1("m0")
191
+ rescue ArgumentError
192
+ return skill_verification_failure(:contents, uri, entry, "did not carry valid base64")
193
+ end
194
+ return skill_verification_failure(:size, uri, entry, "has a different size than listed") unless bytes.bytesize == expected_size
195
+ end
196
+
197
+ unless "sha256:#{Digest::SHA256.hexdigest(bytes)}" == resource_entry.digest
198
+ return skill_verification_failure(:digest, uri, entry, "has a different digest than listed")
199
+ end
200
+
201
+ if uri == entry.uri
202
+ begin
203
+ Skill.verify_frontmatter(entry, bytes.dup.force_encoding(Encoding::UTF_8))
204
+ rescue SkillVerificationError => e
205
+ return [:failed, e]
206
+ end
207
+ end
208
+
209
+ copy = begin
210
+ Skill::Json.deep_freeze(Skill::Json.deep_dup(contents))
211
+ rescue ArgumentError
212
+ return skill_verification_failure(:contents, uri, entry, "came back nested too deeply to be the file listed")
213
+ end
214
+
215
+ mime_type = item["mimeType"]
216
+ [:ok, bytes.freeze, mime_type.is_a?(String) ? mime_type.dup.freeze : nil, copy]
217
+ end
218
+
219
+ def skill_verification_failure(reason, uri, entry, detail)
220
+ # URIs a server chose are shown with `dump`, which escapes everything outside printable ASCII (`inspect`
221
+ # leaves bidirectional overrides as they are), so they cannot forge or reorder lines in a host's log.
222
+ message = "Resource #{uri.dump} #{detail} by #{entry.uri.dump}"
223
+ [:failed, SkillVerificationError.new(message, uri: uri, skill_uri: entry.uri, reason: reason)]
224
+ end
225
+
226
+ # Per-instance Skills state, bound to the connection. Every public method takes the one Mutex and none of
227
+ # them calls another inside it (Ruby's Mutex is not reentrant); hashing, decoding, and parsing happen
228
+ # outside, in the client methods above. No lock is ever held across I/O.
229
+ class State
230
+ def initialize
231
+ @mutex = Mutex.new
232
+ @generation = 0
233
+ @ready = false
234
+ @recorded = nil
235
+ @index = {}
236
+ @verified = {}.compare_by_identity
237
+ @latched = Set.new
238
+ end
239
+
240
+ # A new connection is about to be established: forget everything and return the new generation.
241
+ def begin_handshake
242
+ @mutex.synchronize do
243
+ @generation += 1
244
+ @ready = false
245
+ @recorded = nil
246
+ @index = {}
247
+ @verified = {}.compare_by_identity
248
+ @latched = Set.new
249
+ @generation
250
+ end
251
+ end
252
+
253
+ # The handshake that started `generation` completed with `result`, the wire Hash `transport.connect`
254
+ # returned; records a frozen copy of its capabilities, unless a newer handshake started meanwhile.
255
+ # Capabilities nested too deeply to copy are recorded as none, so `connect` succeeds as it does without
256
+ # Skills and every Skills call is refused.
257
+ def handshake_completed(generation, result)
258
+ capabilities = read_key(result, "capabilities")
259
+ copy = begin
260
+ capabilities.is_a?(Hash) ? Skill::Json.deep_freeze(Skill::Json.deep_dup(capabilities)) : nil
261
+ rescue ArgumentError
262
+ nil
263
+ end
264
+ @mutex.synchronize do
265
+ next unless generation == @generation
266
+
267
+ @ready = true
268
+ @recorded = copy
269
+ end
270
+ end
271
+
272
+ # The handshake that started `generation` raised; a stale failure changes nothing.
273
+ def handshake_failed(generation)
274
+ @mutex.synchronize do
275
+ next unless generation == @generation
276
+
277
+ @ready = false
278
+ @recorded = nil
279
+ end
280
+ end
281
+
282
+ def ready?
283
+ @mutex.synchronize { @ready }
284
+ end
285
+
286
+ def admit(connected:)
287
+ @mutex.synchronize do
288
+ admit_or_refuse!(connected)
289
+ @generation
290
+ end
291
+ end
292
+
293
+ def ensure_current!(generation)
294
+ @mutex.synchronize do
295
+ refuse!(:connection_changed, nil, nil, "the connection changed while the request was in flight") unless generation == @generation
296
+ end
297
+ end
298
+
299
+ # Resolves and checks a read before it is sent; returns the generation, the entry, and its resource entry.
300
+ def admit_read(uri, candidates, explicit, connected:)
301
+ @mutex.synchronize do
302
+ admit_or_refuse!(connected)
303
+ latched = candidates.find { |candidate| @latched.include?(candidate) }
304
+ refuse!(:latched, uri, latched, "#{latched.dump} failed verification on this connection") if latched
305
+
306
+ entry = resolve_entry(uri, candidates, explicit)
307
+ refuse!(:dynamic, uri, entry.uri, "#{entry.uri.dump} is dynamic and lists no digests") if entry.dynamic?
308
+
309
+ resource_entry = entry.resources.find { |candidate| candidate.uri == uri }
310
+ refuse!(:not_in_manifest, uri, entry.uri, "#{uri.dump} is not listed by #{entry.uri.dump}") unless resource_entry
311
+ if uri != entry.uri && !@verified.key?(entry)
312
+ refuse!(:skill_md_unverified, uri, entry.uri, "#{entry.uri.dump} has not been read and verified on this connection yet")
313
+ end
314
+
315
+ [@generation, entry, resource_entry]
316
+ end
317
+ end
318
+
319
+ # Remembers entries a catalog call returned; a replaced entry object loses its verified status.
320
+ def install(generation, skills)
321
+ @mutex.synchronize do
322
+ refuse!(:connection_changed, nil, nil, "the connection changed while the request was in flight") unless generation == @generation
323
+
324
+ skills.each do |skill|
325
+ previous = @index[skill.uri]
326
+ @verified.delete(previous) if previous && !previous.equal?(skill)
327
+ @index[skill.uri] = skill
328
+ end
329
+ end
330
+ end
331
+
332
+ # Settles a read: discards a stale or newly latched completion, latches the skill on a failure, and
333
+ # grants the entry object verified status after its own `SKILL.md` verified (an automatic read only
334
+ # while the object is still current; an explicit read for the object the caller supplied).
335
+ def complete_read(generation, uri, candidates, entry, explicit:, outcome:)
336
+ @mutex.synchronize do
337
+ refuse!(:connection_changed, uri, entry.uri, "the connection changed while the request was in flight") unless generation == @generation
338
+ latched = candidates.find { |candidate| @latched.include?(candidate) }
339
+ refuse!(:latched, uri, latched, "#{latched.dump} failed verification on this connection") if latched
340
+
341
+ status, *values = outcome
342
+ if status == :failed
343
+ @latched << entry.uri
344
+ raise values.first
345
+ end
346
+
347
+ if uri == entry.uri && (explicit || @index[entry.uri].equal?(entry))
348
+ @verified[entry] = true
349
+ end
350
+ values
351
+ end
352
+ end
353
+
354
+ def skills_capability(connected:)
355
+ @mutex.synchronize do
356
+ next unless admitted?(connected)
357
+
358
+ read_key(read_key(@recorded, "extensions"), Skill::EXTENSION_ID)
359
+ end
360
+ end
361
+
362
+ private
363
+
364
+ def admit_or_refuse!(connected)
365
+ return if admitted?(connected)
366
+
367
+ refuse!(:not_advertised, nil, nil, "no handshake of this connection advertised the skills extension and resources")
368
+ end
369
+
370
+ def admitted?(connected)
371
+ return false unless @ready && connected && @recorded.is_a?(Hash)
372
+
373
+ settings = read_key(read_key(@recorded, "extensions"), Skill::EXTENSION_ID)
374
+ settings.is_a?(Hash) && read_key(@recorded, "resources").is_a?(Hash)
375
+ end
376
+
377
+ def resolve_entry(uri, candidates, explicit)
378
+ if explicit
379
+ if Skill::Uri.manifest_uri?(uri) && uri != explicit.uri
380
+ refuse!(:unknown_skill, uri, explicit.uri, "a SKILL.md URI resolves only to its own skill")
381
+ end
382
+ return explicit
383
+ end
384
+
385
+ if Skill::Uri.manifest_uri?(uri)
386
+ entry = @index[uri]
387
+ refuse!(:unknown_skill, uri, nil, "#{uri.dump} has not been listed or fetched on this connection; pass skill: or call get_skill") unless entry
388
+ return entry
389
+ end
390
+
391
+ remembered = candidates.reverse_each.map { |candidate| @index[candidate] }.compact
392
+ refuse!(:unknown_skill, uri, nil, "no remembered skill contains #{uri.dump}; pass skill: or call get_skill") if remembered.empty?
393
+
394
+ # Only the most specific remembered skill containing `uri` is resolved automatically. The specification
395
+ # resolves a read against the entry the host loaded the skill from, and only the host knows which skill
396
+ # it is acting on, so an enclosing skill that lists the file (it lists its nested skills' files too) is
397
+ # reached with `skill:` alone: never because the nested entry is dynamic or leaves the file out.
398
+ entry = remembered.first
399
+ return entry if entry.dynamic?
400
+
401
+ enclosing = remembered.drop(1).select do |candidate|
402
+ !candidate.dynamic? && candidate.resources.any? { |resource| resource.uri == uri }
403
+ end
404
+ unless enclosing.empty?
405
+ refuse!(
406
+ :ambiguous,
407
+ uri,
408
+ entry.uri,
409
+ "#{uri.dump} is also listed by #{enclosing.map { |candidate| candidate.uri.dump }.join(" and ")}; " \
410
+ "pass skill: for the skill being acted on",
411
+ )
412
+ end
413
+
414
+ entry
415
+ end
416
+
417
+ def read_key(hash, key)
418
+ return unless hash.is_a?(Hash)
419
+
420
+ value = hash[key]
421
+ value.nil? ? hash[key.to_sym] : value
422
+ end
423
+
424
+ def refuse!(reason, uri, skill_uri, detail)
425
+ raise SkillReadRefusedError.new("Skills call refused (#{reason}): #{detail}", reason: reason, uri: uri, skill_uri: skill_uri)
426
+ end
427
+ end
428
+ end
429
+ end
430
+ end
@@ -2,15 +2,21 @@
2
2
 
3
3
  module MCP
4
4
  class Client
5
+ # A tool as `tools/list` described it. Every member is kept as the server sent it, so what an extension
6
+ # puts under `_meta` (MCP Apps names a tool's app under `_meta.ui`) reaches the host unchanged.
5
7
  class Tool
6
- attr_reader :name, :description, :input_schema, :output_schema, :annotations
8
+ attr_reader :name, :title, :description, :input_schema, :output_schema, :annotations, :icons, :execution, :meta
7
9
 
8
- def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil)
10
+ def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil, title: nil, icons: nil, execution: nil, meta: nil)
9
11
  @name = name
12
+ @title = title
10
13
  @description = description
11
14
  @input_schema = input_schema
12
15
  @output_schema = output_schema
13
16
  @annotations = annotations
17
+ @icons = icons
18
+ @execution = execution
19
+ @meta = meta
14
20
  end
15
21
  end
16
22
  end
data/lib/mcp/client.rb CHANGED
@@ -7,6 +7,8 @@ require_relative "client/oauth"
7
7
  require_relative "client/stdio"
8
8
  require_relative "client/http"
9
9
  require_relative "client/paginated_result"
10
+ require_relative "client/skill_errors"
11
+ require_relative "client/skills"
10
12
  require_relative "client/tool"
11
13
  require_relative "result_type"
12
14
 
@@ -114,6 +116,9 @@ module MCP
114
116
  STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05
115
117
  STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25
116
118
 
119
+ # The Skills extension methods (SEP-2640): `list_skills`, `get_skill`, `read_skill_resource`, `skills_capability`.
120
+ include Skills
121
+
117
122
  # Initializes a new MCP::Client instance.
118
123
  #
119
124
  # @param transport [Object] The transport object to use for communication with the server.
@@ -144,6 +149,8 @@ module MCP
144
149
  # a SEP-2322 `input_required` result.
145
150
  @input_required_handlers = {}
146
151
  @input_required_max_rounds = input_required_max_rounds
152
+ # Skills extension state (SEP-2640), bound to the connection by `connect`; see `Client::Skills`.
153
+ @skills_state = Skills::State.new
147
154
  end
148
155
 
149
156
  # The user may want to access additional transport-specific methods/attributes
@@ -217,7 +224,15 @@ module MCP
217
224
 
218
225
  effective_mode = resolve_connect_mode(mode, protocol_version)
219
226
 
220
- if effective_mode == :legacy
227
+ # Skills state (SEP-2640) follows the connection: a handshake about to run starts a new generation, its
228
+ # result is recorded only if no newer handshake started meanwhile, and a failure clears readiness under
229
+ # the same condition, so a stale completion or failure changes nothing. The bundled transports answer
230
+ # a repeated `connect` with their cached handshake result without any I/O, so when the transport is
231
+ # connected and Skills is ready nothing is reset: the latches of a live connection stay put.
232
+ generation = @skills_state.begin_handshake unless skills_ready_on_connected_transport?
233
+ completed = false
234
+
235
+ result = if effective_mode == :legacy
221
236
  transport.connect(
222
237
  client_info: client_info,
223
238
  protocol_version: protocol_version,
@@ -231,6 +246,11 @@ module MCP
231
246
  mode: effective_mode,
232
247
  )
233
248
  end
249
+ completed = true
250
+ @skills_state.handshake_completed(generation, result) if generation
251
+ result
252
+ ensure
253
+ @skills_state.handshake_failed(generation) if generation && !completed
234
254
  end
235
255
 
236
256
  # Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery
@@ -295,10 +315,14 @@ module MCP
295
315
 
296
316
  Tool.new(
297
317
  name: tool["name"],
318
+ title: tool["title"],
298
319
  description: tool["description"],
299
320
  input_schema: tool["inputSchema"],
300
321
  output_schema: tool["outputSchema"],
301
322
  annotations: tool["annotations"],
323
+ icons: tool["icons"],
324
+ execution: tool["execution"],
325
+ meta: tool["_meta"],
302
326
  )
303
327
  end
304
328
 
@@ -673,10 +697,19 @@ module MCP
673
697
  scan = McpParamHeaders.scan(tool["inputSchema"])
674
698
  return false if scan[:valid]
675
699
 
676
- warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}")
700
+ name = tool["name"]
701
+ # Both pieces are the server's: `dump` keeps control characters and bidirectional overrides out of the log line.
702
+ warn("MCP::Client: excluding tool #{(name.is_a?(String) ? name : name.inspect).dump} from tools/list: #{scan[:reason].dump}")
677
703
  true
678
704
  end
679
705
 
706
+ # Whether a `connect` would only hand back the transport's cached handshake: the transport reports itself
707
+ # connected and Skills already recorded a handshake of this connection. A transport without connection
708
+ # state always starts a new generation.
709
+ def skills_ready_on_connected_transport?
710
+ transport.respond_to?(:connected?) && transport.connected? && @skills_state.ready?
711
+ end
712
+
680
713
  # Resolves the effective SEP-2575 lifecycle mode for `connect`:
681
714
  #
682
715
  # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe,
data/lib/mcp/methods.rb CHANGED
@@ -22,6 +22,11 @@ module MCP
22
22
  RESOURCES_SUBSCRIBE = "resources/subscribe"
23
23
  RESOURCES_UNSUBSCRIBE = "resources/unsubscribe"
24
24
 
25
+ # Skills extension (SEP-2640): registered only when a server configures skills,
26
+ # so an unconfigured server answers `-32601` rather than a capability error.
27
+ SKILLS_LIST = "skills/list"
28
+ SKILLS_GET = "skills/get"
29
+
25
30
  TOOLS_CALL = "tools/call"
26
31
  TOOLS_LIST = "tools/list"
27
32