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.
@@ -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
- response = @session.handle_json(line.strip)
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 ? @session.handle(parsed) : @server.handle(parsed)
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