mcp 1.0.0 → 1.2.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 +428 -9
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +338 -63
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +95 -7
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- data/lib/mcp/instrumentation.rb +6 -0
- data/lib/mcp/methods.rb +21 -0
- data/lib/mcp/prompt.rb +8 -1
- data/lib/mcp/protocol_deprecations.rb +61 -0
- data/lib/mcp/request_envelope.rb +117 -0
- data/lib/mcp/server/input_required_result.rb +163 -0
- data/lib/mcp/server/pending_response.rb +62 -0
- data/lib/mcp/server/request_state_security.rb +131 -0
- data/lib/mcp/server/transports/stdio_transport.rb +39 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +710 -20
- data/lib/mcp/server.rb +541 -43
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +77 -15
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +11 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../methods"
|
|
4
|
+
require_relative "../result_type"
|
|
5
|
+
|
|
6
|
+
module MCP
|
|
7
|
+
class Server
|
|
8
|
+
# A multi round-trip `input_required` result (SEP-2322, MCP 2026-07-28).
|
|
9
|
+
# Handlers for `tools/call`, `prompts/get`, and `resources/read` may return one instead of
|
|
10
|
+
# their normal result to ask the client for additional input: `input_requests` maps server-assigned keys
|
|
11
|
+
# to embedded request shapes (`elicitation/create`, `sampling/createMessage`, or `roots/list`),
|
|
12
|
+
# and `request_state` is an opaque continuation string the client echoes back byte-exactly when it retries
|
|
13
|
+
# the original request with `inputResponses` under the same keys.
|
|
14
|
+
#
|
|
15
|
+
# The server holds no memory between rounds: handlers re-run from the start on every retry and read
|
|
16
|
+
# the answers via `server_context.input_responses` / `server_context.request_state`
|
|
17
|
+
# (deterministic replay, matching the Python SDK). At least one of the two fields must be present;
|
|
18
|
+
# a `request_state`-only result is the load-shedding form ("retry later").
|
|
19
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
|
20
|
+
class InputRequiredResult
|
|
21
|
+
EMBEDDABLE_METHODS = [
|
|
22
|
+
Methods::ELICITATION_CREATE,
|
|
23
|
+
Methods::SAMPLING_CREATE_MESSAGE,
|
|
24
|
+
Methods::ROOTS_LIST,
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :input_requests, :request_state
|
|
28
|
+
|
|
29
|
+
def initialize(input_requests: nil, request_state: nil)
|
|
30
|
+
if (input_requests.nil? || input_requests.empty?) && request_state.nil?
|
|
31
|
+
raise ArgumentError, "at least one of input_requests or request_state is required"
|
|
32
|
+
end
|
|
33
|
+
unless request_state.nil? || request_state.is_a?(String)
|
|
34
|
+
raise ArgumentError, "request_state must be a String"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@input_requests = normalize_input_requests(input_requests)
|
|
38
|
+
@request_state = request_state
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
serialized_requests = @input_requests&.transform_values do |entry|
|
|
44
|
+
{ method: entry[:method], params: entry[:params] }.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
resultType: ResultType::INPUT_REQUIRED,
|
|
49
|
+
inputRequests: serialized_requests,
|
|
50
|
+
requestState: @request_state,
|
|
51
|
+
}.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The client capabilities required to fulfill every embedded request, merged into one nested hash.
|
|
55
|
+
# The mapping matches the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`:
|
|
56
|
+
# `elicitation/create` with `mode: "url"` requires `elicitation.url`, any other `elicitation/create`
|
|
57
|
+
# requires `elicitation.form`, `sampling/createMessage` with `tools`/`toolChoice` requires `sampling.tools`
|
|
58
|
+
# (plain sampling otherwise), and `roots/list` requires `roots`.
|
|
59
|
+
def required_client_capabilities
|
|
60
|
+
(@input_requests || {}).values.reduce({}) do |merged, entry|
|
|
61
|
+
deep_merge(merged, entry_capability(entry))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The subset of {#required_client_capabilities} the request did not declare.
|
|
66
|
+
# Per SEP-2575, servers MUST NOT rely on capabilities the client has not declared,
|
|
67
|
+
# so a non-empty return means the result must not be sent (`-32021`).
|
|
68
|
+
def missing_client_capabilities(declared)
|
|
69
|
+
missing = missing_subtree(required_client_capabilities, declared)
|
|
70
|
+
prune_implied_form_elicitation(missing, declared)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def normalize_input_requests(input_requests)
|
|
76
|
+
return if input_requests.nil?
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "input_requests must be a Hash" unless input_requests.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
normalized = input_requests.each_with_object({}) do |(key, entry), result|
|
|
81
|
+
raise ArgumentError, "input_requests entries must be Hashes" unless entry.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
method = entry[:method] || entry["method"]
|
|
84
|
+
unless EMBEDDABLE_METHODS.include?(method)
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"input_requests entry #{key.inspect} must have a method of " \
|
|
87
|
+
"#{EMBEDDABLE_METHODS.join(", ")} (got #{method.inspect})"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
params = entry[:params] || entry["params"]
|
|
91
|
+
raise ArgumentError, "input_requests entry params must be a Hash" unless params.nil? || params.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
result[key.to_s] = { method: method, params: params }.compact.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
normalized.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def entry_capability(entry)
|
|
100
|
+
params = entry[:params]
|
|
101
|
+
|
|
102
|
+
case entry[:method]
|
|
103
|
+
when Methods::ELICITATION_CREATE
|
|
104
|
+
mode = params && (params[:mode] || params["mode"])
|
|
105
|
+
mode == "url" ? { elicitation: { url: {} } } : { elicitation: { form: {} } }
|
|
106
|
+
when Methods::SAMPLING_CREATE_MESSAGE
|
|
107
|
+
with_tools = params && (params.key?(:tools) || params.key?("tools") ||
|
|
108
|
+
params.key?(:toolChoice) || params.key?("toolChoice"))
|
|
109
|
+
with_tools ? { sampling: { tools: {} } } : { sampling: {} }
|
|
110
|
+
when Methods::ROOTS_LIST
|
|
111
|
+
{ roots: {} }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Walks `required` and keeps only the branches absent from `declared`
|
|
116
|
+
# (symbol/string tolerant on the declared side).
|
|
117
|
+
def missing_subtree(required, declared)
|
|
118
|
+
required.each_with_object({}) do |(name, nested), missing|
|
|
119
|
+
declared_value = read_key(declared, name)
|
|
120
|
+
|
|
121
|
+
if declared_value.nil?
|
|
122
|
+
missing[name] = nested
|
|
123
|
+
elsif nested.is_a?(Hash) && !nested.empty?
|
|
124
|
+
nested_missing = missing_subtree(nested, declared_value)
|
|
125
|
+
missing[name] = nested_missing unless nested_missing.empty?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 2025 back-compat implication shared with the TypeScript and Python SDKs:
|
|
131
|
+
# a bare `elicitation: {}` declaration implies form elicitation support, while
|
|
132
|
+
# an explicit url-only declaration does not.
|
|
133
|
+
def prune_implied_form_elicitation(missing, declared)
|
|
134
|
+
elicitation_missing = missing[:elicitation]
|
|
135
|
+
return missing unless elicitation_missing.is_a?(Hash) && elicitation_missing.key?(:form)
|
|
136
|
+
|
|
137
|
+
declared_elicitation = read_key(declared, :elicitation)
|
|
138
|
+
return missing unless declared_elicitation.is_a?(Hash)
|
|
139
|
+
return missing unless read_key(declared_elicitation, :url).nil?
|
|
140
|
+
|
|
141
|
+
pruned = elicitation_missing.reject { |key, _| key == :form }
|
|
142
|
+
pruned.empty? ? missing.reject { |key, _| key == :elicitation } : missing.merge(elicitation: pruned)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def read_key(hash, key)
|
|
146
|
+
return unless hash.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
value = hash[key.to_sym]
|
|
149
|
+
value.nil? ? hash[key.to_s] : value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def deep_merge(left, right)
|
|
153
|
+
left.merge(right) do |_key, left_value, right_value|
|
|
154
|
+
if left_value.is_a?(Hash) && right_value.is_a?(Hash)
|
|
155
|
+
deep_merge(left_value, right_value)
|
|
156
|
+
else
|
|
157
|
+
right_value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Server
|
|
5
|
+
# A one-shot, timeout-aware handoff between the thread awaiting a server-to-client response
|
|
6
|
+
# and whichever thread resolves it (the client's response, a cancellation, or session teardown).
|
|
7
|
+
#
|
|
8
|
+
# `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later, and this gem supports 2.7,
|
|
9
|
+
# so the wait is expressed with a `ConditionVariable`. The `push`/`pop` names mirror the `Queue`
|
|
10
|
+
# this replaces, keeping the resolving call sites unchanged.
|
|
11
|
+
#
|
|
12
|
+
# First writer wins: a second `push` is ignored, so a cancellation that races a real response
|
|
13
|
+
# cannot overwrite it. `pop` returns the pushed value, or the `on_timeout` result when
|
|
14
|
+
# the deadline passes with nothing pushed.
|
|
15
|
+
class PendingResponse
|
|
16
|
+
def initialize
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@condition = ConditionVariable.new
|
|
19
|
+
@delivered = false
|
|
20
|
+
@value = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Resolves the wait. Ignored when a value was already delivered.
|
|
24
|
+
def push(value)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
next if @delivered
|
|
27
|
+
|
|
28
|
+
@delivered = true
|
|
29
|
+
@value = value
|
|
30
|
+
@condition.broadcast
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Blocks until a value is pushed or `timeout` seconds elapse, and yields to the caller
|
|
35
|
+
# on expiry so it can decide what a timeout means. `ConditionVariable#wait` can return spuriously,
|
|
36
|
+
# so the deadline is re-checked against the monotonic clock.
|
|
37
|
+
#
|
|
38
|
+
# The expiry block runs after the lock is released: it typically cancels the request,
|
|
39
|
+
# which resolves this same object, and Ruby's `Mutex` is not reentrant.
|
|
40
|
+
def pop(timeout:)
|
|
41
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
42
|
+
expired = false
|
|
43
|
+
|
|
44
|
+
value = @mutex.synchronize do
|
|
45
|
+
until @delivered
|
|
46
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
47
|
+
if remaining <= 0
|
|
48
|
+
expired = true
|
|
49
|
+
break
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@condition.wait(@mutex, remaining)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
expired ? yield : value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module MCP
|
|
7
|
+
class Server
|
|
8
|
+
# Opt-in protection for the SEP-2322 `requestState` echo. The opaque continuation string leaves the server,
|
|
9
|
+
# sits in the client's hands, and comes back as client-controlled input, so it must be treated like
|
|
10
|
+
# any other untrusted data. Sealing encrypts the state with AES-256-GCM (clients cannot read it) and binds
|
|
11
|
+
# a claims envelope that unsealing verifies fail-closed:
|
|
12
|
+
#
|
|
13
|
+
# - `exp`: a TTL window (re-sealed each round)
|
|
14
|
+
# - `m` / `t`: the originating method and target (tool/prompt name or resource URI)
|
|
15
|
+
# - `a`: a digest of the originating arguments, so the state only resumes the same call with the same inputs
|
|
16
|
+
# - `aud`: an optional audience, so tokens cannot cross servers sharing a key
|
|
17
|
+
#
|
|
18
|
+
# Pass an instance via `Server.new(request_state_security:)` and the seal/unseal happens transparently;
|
|
19
|
+
# handlers keep reading plaintext through `server_context.request_state`. Without it, the state crosses
|
|
20
|
+
# the wire exactly as the handler wrote it (the author's responsibility, matching the Python SDK's low-level Server).
|
|
21
|
+
# The key must be shared across workers in multi-process
|
|
22
|
+
#
|
|
23
|
+
# deployments; a per-process random key makes retries that land on another worker fail with an invalid-state error,
|
|
24
|
+
# forcing clients to restart the flow.
|
|
25
|
+
#
|
|
26
|
+
# The token format is `v1.<base64url(iv || ciphertext || tag)>`, with the version prefix bound as GCM associated data,
|
|
27
|
+
# following the Python SDK's `AESGCMRequestStateCodec`.
|
|
28
|
+
class RequestStateSecurity
|
|
29
|
+
class InvalidStateError < StandardError; end
|
|
30
|
+
|
|
31
|
+
VERSION_PREFIX = "v1."
|
|
32
|
+
KEY_BYTES = 32
|
|
33
|
+
IV_BYTES = 12
|
|
34
|
+
TAG_BYTES = 16
|
|
35
|
+
DEFAULT_TTL = 300
|
|
36
|
+
|
|
37
|
+
def initialize(key:, ttl: DEFAULT_TTL, audience: nil)
|
|
38
|
+
unless key.is_a?(String) && key.bytesize == KEY_BYTES
|
|
39
|
+
raise ArgumentError, "key must be a #{KEY_BYTES}-byte String"
|
|
40
|
+
end
|
|
41
|
+
unless ttl.is_a?(Numeric) && ttl.positive?
|
|
42
|
+
raise ArgumentError, "ttl must be a positive number of seconds"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@key = key.dup.force_encoding(Encoding::BINARY).freeze
|
|
46
|
+
@ttl = ttl
|
|
47
|
+
@audience = audience
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Seals a plaintext state into an opaque token bound to the originating request.
|
|
51
|
+
def seal(state, method:, target:, arguments_digest:)
|
|
52
|
+
claims = {
|
|
53
|
+
v: 1,
|
|
54
|
+
exp: Time.now.to_i + @ttl,
|
|
55
|
+
m: method,
|
|
56
|
+
t: target,
|
|
57
|
+
a: arguments_digest,
|
|
58
|
+
aud: @audience,
|
|
59
|
+
s: state,
|
|
60
|
+
}.compact
|
|
61
|
+
|
|
62
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
|
|
63
|
+
cipher.key = @key
|
|
64
|
+
iv = cipher.random_iv
|
|
65
|
+
cipher.auth_data = VERSION_PREFIX
|
|
66
|
+
ciphertext = cipher.update(JSON.generate(claims)) + cipher.final
|
|
67
|
+
|
|
68
|
+
VERSION_PREFIX + base64url_encode(iv + ciphertext + cipher.auth_tag)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Unseals a client-echoed token and verifies every claim, failing closed with
|
|
72
|
+
# `InvalidStateError` on tampering, expiry, or a claims mismatch.
|
|
73
|
+
def unseal(sealed, method:, target:, arguments_digest:)
|
|
74
|
+
unless sealed.is_a?(String) && sealed.start_with?(VERSION_PREFIX)
|
|
75
|
+
raise InvalidStateError, "malformed token"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
blob = base64url_decode(sealed.delete_prefix(VERSION_PREFIX))
|
|
79
|
+
raise InvalidStateError, "malformed token" if blob.bytesize < IV_BYTES + TAG_BYTES
|
|
80
|
+
|
|
81
|
+
iv = blob.byteslice(0, IV_BYTES)
|
|
82
|
+
tag = blob.byteslice(-TAG_BYTES, TAG_BYTES)
|
|
83
|
+
ciphertext = blob.byteslice(IV_BYTES, blob.bytesize - IV_BYTES - TAG_BYTES)
|
|
84
|
+
|
|
85
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
|
|
86
|
+
cipher.key = @key
|
|
87
|
+
cipher.iv = iv
|
|
88
|
+
cipher.auth_tag = tag
|
|
89
|
+
cipher.auth_data = VERSION_PREFIX
|
|
90
|
+
plaintext = begin
|
|
91
|
+
cipher.update(ciphertext) + cipher.final
|
|
92
|
+
rescue OpenSSL::Cipher::CipherError
|
|
93
|
+
raise InvalidStateError, "authentication failed"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
claims = begin
|
|
97
|
+
JSON.parse(plaintext, symbolize_names: true)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
raise InvalidStateError, "malformed claims"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
verify!(claims, method: method, target: target, arguments_digest: arguments_digest)
|
|
103
|
+
claims[:s]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def verify!(claims, method:, target:, arguments_digest:)
|
|
109
|
+
raise InvalidStateError, "unsupported version" unless claims[:v] == 1
|
|
110
|
+
raise InvalidStateError, "expired" unless claims[:exp].is_a?(Integer) && Time.now.to_i <= claims[:exp]
|
|
111
|
+
raise InvalidStateError, "method mismatch" unless claims[:m] == method
|
|
112
|
+
raise InvalidStateError, "target mismatch" unless claims[:t] == target
|
|
113
|
+
raise InvalidStateError, "arguments mismatch" unless claims[:a] == arguments_digest
|
|
114
|
+
raise InvalidStateError, "audience mismatch" unless claims[:aud] == @audience
|
|
115
|
+
raise InvalidStateError, "missing state" unless claims[:s].is_a?(String)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def base64url_encode(data)
|
|
119
|
+
[data].pack("m0").tr("+/", "-_").delete("=")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def base64url_decode(encoded)
|
|
123
|
+
padded = encoded.tr("-_", "+/")
|
|
124
|
+
padded += "=" * ((4 - padded.length % 4) % 4)
|
|
125
|
+
padded.unpack1("m0")
|
|
126
|
+
rescue ArgumentError
|
|
127
|
+
raise InvalidStateError, "malformed token"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -49,7 +49,9 @@ module MCP
|
|
|
49
49
|
end
|
|
50
50
|
break if line.nil?
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
line = line.strip
|
|
53
|
+
parsed = parse_line(line)
|
|
54
|
+
response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
|
|
53
55
|
send_response(response) if response
|
|
54
56
|
end
|
|
55
57
|
rescue Interrupt
|
|
@@ -90,6 +92,12 @@ module MCP
|
|
|
90
92
|
# cancellation has very limited value here regardless; servers that need cancellation propagation for nested
|
|
91
93
|
# server-to-client requests should use `StreamableHTTPTransport`.
|
|
92
94
|
def send_request(method, params = nil)
|
|
95
|
+
# The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
|
|
96
|
+
# multi round-trip `input_required` results (SEP-2322) replace them.
|
|
97
|
+
if @session && @session.era == :modern
|
|
98
|
+
raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
|
|
99
|
+
end
|
|
100
|
+
|
|
93
101
|
request_id = generate_request_id
|
|
94
102
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
95
103
|
request[:params] = params if params
|
|
@@ -116,7 +124,7 @@ module MCP
|
|
|
116
124
|
|
|
117
125
|
return parsed[:result]
|
|
118
126
|
else
|
|
119
|
-
response = @session ?
|
|
127
|
+
response = @session ? dispatch_with_era(parsed) : @server.handle(parsed)
|
|
120
128
|
send_response(response) if response
|
|
121
129
|
end
|
|
122
130
|
end
|
|
@@ -143,6 +151,35 @@ module MCP
|
|
|
143
151
|
|
|
144
152
|
line
|
|
145
153
|
end
|
|
154
|
+
|
|
155
|
+
# Parses a frame once so era classification can inspect its method and `_meta`.
|
|
156
|
+
# Returns `nil` for frames that are not JSON objects; those fall back to
|
|
157
|
+
# `ServerSession#handle_json` so protocol-level error responses stay identical.
|
|
158
|
+
def parse_line(line)
|
|
159
|
+
parsed = JSON.parse(line, symbolize_names: true)
|
|
160
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Serves one frame under the dual-era model (SEP-2575): the first era-distinctive message to succeed locks
|
|
166
|
+
# the connection era. A successful `initialize` locks `:legacy` inside `Server#init`; a successful `server/discover`
|
|
167
|
+
# or a successful request carrying the full modern `_meta` triple locks `:modern`. Era-violating frames
|
|
168
|
+
# (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock)
|
|
169
|
+
# are rejected in-band by `Server#lift_request_envelope`.
|
|
170
|
+
def dispatch_with_era(parsed)
|
|
171
|
+
response = @session.handle(parsed)
|
|
172
|
+
lock_modern_era_on_success(parsed, response)
|
|
173
|
+
response
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def lock_modern_era_on_success(parsed, response)
|
|
177
|
+
return if @session.era
|
|
178
|
+
return if !response.is_a?(Hash) || response.key?(:error)
|
|
179
|
+
return if parsed[:method] != Methods::SERVER_DISCOVER && !RequestEnvelope.modern?(parsed[:params])
|
|
180
|
+
|
|
181
|
+
@session.lock_era!(:modern)
|
|
182
|
+
end
|
|
146
183
|
end
|
|
147
184
|
end
|
|
148
185
|
end
|