mcp 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/json_rpc_handler.rb +32 -2
- data/lib/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- data/lib/mcp/client/stdio.rb +12 -2
- data/lib/mcp/client/tool.rb +8 -2
- data/lib/mcp/client.rb +35 -2
- data/lib/mcp/methods.rb +5 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +54 -22
- data/lib/mcp/server.rb +403 -17
- data/lib/mcp/server_session.rb +4 -4
- data/lib/mcp/skill/directory.rb +316 -0
- data/lib/mcp/skill/frontmatter.rb +262 -0
- data/lib/mcp/skill/resource_entry.rb +109 -0
- data/lib/mcp/skill.rb +522 -0
- data/lib/mcp/tool/output_schema.rb +4 -3
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -2
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"],
|
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -53,7 +53,9 @@ module JsonRpcHandler
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -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
|
|
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?(
|
|
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",
|
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
|