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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 255e316335616783063d8a7374f385ff10b99c9ddef24571734e19b6ec6f5f93
4
- data.tar.gz: 07021e227f5b1be05fd2c1e4dc88e0d563bc963b6f1190f998aa632ed6871031
3
+ metadata.gz: 95dcc461ef4ba74c3128e82441b81587135488d8c9e4d17f5ea91ba45867f080
4
+ data.tar.gz: 2d0afc689585aad6ffaf37c4ca5528497e30bf81e733514a05faf724cfb3e2f4
5
5
  SHA512:
6
- metadata.gz: 3d496817c30ae59b7f99704fead73cc9dfe5d926a87455a5c51fcc766bdf299d943ce86c0d43ca859d870a488df191cc0a4bdeba302b95d8ebcf084730743a68
7
- data.tar.gz: 6291785257c054baf3570fb9baef4ecb1db006c920608ad69239460e2494610fa9133341bdf903a4c127f7ab40146f2bf9eecb34266621cba172a08ef52812f0
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"],
@@ -53,7 +53,9 @@ module JsonRpcHandler
53
53
  end
54
54
  end
55
55
 
56
- def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
56
+ # `on_generate_error`, when given, is called with the `JSON::GeneratorError` of a response that cannot be generated,
57
+ # before that response is replaced with Internal error.
58
+ def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, on_generate_error: nil, &method_finder)
57
59
  begin
58
60
  begin
59
61
  request = JSON.parse(request_json, symbolize_names: true)
@@ -77,7 +79,35 @@ module JsonRpcHandler
77
79
  })
78
80
  end
79
81
 
80
- response&.to_json
82
+ begin
83
+ response&.to_json
84
+ rescue JSON::GeneratorError => e
85
+ on_generate_error&.call(e)
86
+ encodable_response(response, id_validation_pattern: id_validation_pattern).to_json
87
+ end
88
+ end
89
+
90
+ # `response` with each part JSON cannot generate replaced with Internal error. A batch is replaced item by item,
91
+ # so the other items keep their results.
92
+ def encodable_response(response, id_validation_pattern: nil)
93
+ return response.map { |item| encodable_response(item, id_validation_pattern: id_validation_pattern) } if response.is_a?(Array)
94
+
95
+ response.to_json
96
+ response
97
+ rescue JSON::GeneratorError
98
+ internal_error_for(response, id_validation_pattern: id_validation_pattern)
99
+ end
100
+
101
+ # The error answering a request whose response cannot be generated: a result or an error holding a string that
102
+ # is not valid UTF-8, typically one the request carried and the SDK or a handler repeated, such as a tool name
103
+ # in its Tool not found error. It carries the request id when the response has one, which was validated before
104
+ # the method ran.
105
+ def internal_error_for(response, id_validation_pattern: nil)
106
+ id = response[:id] if response.is_a?(Hash)
107
+ error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
108
+ code: ErrorCode::INTERNAL_ERROR,
109
+ message: "Internal error",
110
+ })
81
111
  end
82
112
 
83
113
  def process_request(request, id_validation_pattern:, &method_finder)
@@ -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
@@ -434,7 +434,7 @@ module MCP
434
434
  line = read_line(method, params)
435
435
  raise_connection_error!(method, params) if line.nil?
436
436
 
437
- parsed = JSON.parse(line.strip)
437
+ parsed = JSON.parse(strip_frame(line))
438
438
 
439
439
  # A frame carrying `method` is a request or notification, never the awaited response,
440
440
  # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id
@@ -484,8 +484,10 @@ module MCP
484
484
  parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
485
485
  end
486
486
 
487
+ # An id the pong could not write back is skipped too: a String that is not valid UTF-8, or a number
488
+ # such as `1e400`, which the parser reads as an infinite Float that JSON cannot represent.
487
489
  def json_rpc_id?(id)
488
- id.is_a?(String) || id.is_a?(Numeric)
490
+ (id.is_a?(String) && id.valid_encoding?) || id.is_a?(Integer) || (id.is_a?(Float) && id.finite?)
489
491
  end
490
492
 
491
493
  def ensure_running!
@@ -534,6 +536,14 @@ module MCP
534
536
  )
535
537
  end
536
538
 
539
+ # Strips a frame as bytes. The server's stdout is read as UTF-8, and `String#strip` raises for a UTF-8 string
540
+ # holding an invalid byte at either end on every supported Ruby, and anywhere in it from Ruby 3.2 on, which would
541
+ # surface as that exception instead of the client's own error. As bytes, the frame reaches the parser, which
542
+ # refuses what is not JSON with the client's parse failure.
543
+ def strip_frame(line)
544
+ line.b.strip.force_encoding(Encoding::UTF_8)
545
+ end
546
+
537
547
  def raise_connection_error!(method, params)
538
548
  raise RequestHandlerError.new(
539
549
  "Server process closed stdout unexpectedly",
@@ -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