mcp 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- data/lib/mcp/client/tool.rb +8 -2
- data/lib/mcp/client.rb +35 -2
- data/lib/mcp/methods.rb +5 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +25 -12
- data/lib/mcp/server.rb +371 -16
- data/lib/mcp/skill/directory.rb +316 -0
- data/lib/mcp/skill/frontmatter.rb +262 -0
- data/lib/mcp/skill/resource_entry.rb +109 -0
- data/lib/mcp/skill.rb +522 -0
- data/lib/mcp/tool/output_schema.rb +4 -3
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95dcc461ef4ba74c3128e82441b81587135488d8c9e4d17f5ea91ba45867f080
|
|
4
|
+
data.tar.gz: 2d0afc689585aad6ffaf37c4ca5528497e30bf81e733514a05faf724cfb3e2f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/mcp/client/tool.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|