ask-mcp 0.4.1 → 0.4.2

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.
@@ -23,11 +23,18 @@ module Ask
23
23
  def definitions
24
24
  @tools.map do |tool|
25
25
  schema = tool.params_schema || { type: "object", properties: {}, required: [] }
26
- {
26
+ defn = {
27
27
  name: tool.name,
28
28
  description: tool.description || "",
29
29
  inputSchema: schema
30
30
  }
31
+ # 2025-11-25: optional display metadata (SEP-973 icons, title).
32
+ # Only included when the tool object provides them.
33
+ defn[:title] = tool.title if tool.respond_to?(:title) && tool.title
34
+ if tool.respond_to?(:icons) && tool.icons&.any?
35
+ defn[:icons] = tool.icons
36
+ end
37
+ defn
31
38
  end
32
39
  end
33
40
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ask
6
+ module MCP
7
+ module Auth
8
+ # OAuth Client ID Metadata Documents (2026-07-28, SEP-991) — the
9
+ # recommended client registration mechanism, replacing the deprecated
10
+ # Dynamic Client Registration Protocol (RFC 7591).
11
+ #
12
+ # A client_id is an HTTPS URL pointing to a JSON document describing the
13
+ # client; the authorization server fetches and validates it on demand.
14
+ # Because the document is self-hosted, URL-based client IDs are portable
15
+ # across authorization servers — no re-registration needed when the
16
+ # server changes.
17
+ module ClientIdMetadataDocument
18
+ # Fields every metadata document MUST include.
19
+ REQUIRED_FIELDS = %w[client_id client_name redirect_uris].freeze
20
+ # OIDC application types (native = desktop/mobile/CLI/localhost web).
21
+ APPLICATION_TYPES = %w[native web].freeze
22
+
23
+ module_function
24
+
25
+ # Build a metadata document hash. `client_id` must be an https URL
26
+ # with a path component; `redirect_uris` must be a non-empty array.
27
+ # Extra keyword args are included verbatim (string keys).
28
+ def build(client_id:, client_name:, redirect_uris:, application_type: "native", **extra)
29
+ {
30
+ "client_id" => client_id,
31
+ "client_name" => client_name,
32
+ "redirect_uris" => redirect_uris,
33
+ "application_type" => application_type
34
+ }.merge(extra.transform_keys(&:to_s))
35
+ end
36
+
37
+ # Whether the client_id has the required URL form: https scheme with
38
+ # a path component (e.g. https://example.com/client.json).
39
+ def valid_client_id_url?(client_id)
40
+ uri = URI.parse(client_id.to_s)
41
+ uri.scheme == "https" && !uri.path.to_s.empty? && uri.path != "/"
42
+ rescue URI::InvalidURIError
43
+ false
44
+ end
45
+
46
+ # Validate a fetched document. Returns nil when valid, or a reason
47
+ # string. When `document_url` is given, the document's client_id MUST
48
+ # match it exactly.
49
+ def invalid_reason(document, document_url: nil)
50
+ return "metadata document must be a JSON object" unless document.is_a?(Hash)
51
+
52
+ missing = REQUIRED_FIELDS.reject { |f| document[f] || document[f.to_sym] }
53
+ return "missing required fields: #{missing.join(', ')}" unless missing.empty?
54
+
55
+ client_id = (document["client_id"] || document[:client_id]).to_s
56
+ if document_url && client_id != document_url
57
+ return "client_id #{client_id.inspect} does not match document URL #{document_url.inspect}"
58
+ end
59
+ return "client_id must be an https URL with a path" unless valid_client_id_url?(client_id)
60
+
61
+ redirect_uris = document["redirect_uris"] || document[:redirect_uris]
62
+ return "redirect_uris must be a non-empty array" unless redirect_uris.is_a?(Array) && !redirect_uris.empty?
63
+
64
+ app_type = document["application_type"] || document[:application_type]
65
+ if app_type && !APPLICATION_TYPES.include?(app_type.to_s)
66
+ return "invalid application_type #{app_type.inspect}"
67
+ end
68
+
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -4,16 +4,18 @@ module Ask
4
4
  module MCP
5
5
  module Auth
6
6
  class OAuth
7
- attr_reader :client_id, :client_secret, :token_url, :auth_url
7
+ attr_reader :client_id, :client_secret, :token_url, :auth_url, :issuer
8
8
 
9
- def initialize(client_id:, client_secret: nil, token_url:, auth_url: nil,
10
- redirect_uri: nil, scopes: [])
9
+ def initialize(client_id:, client_secret: nil, token_url: nil, auth_url: nil,
10
+ redirect_uri: nil, scopes: [], issuer: nil, discovery_url: nil)
11
11
  @client_id = client_id
12
12
  @client_secret = client_secret
13
13
  @token_url = token_url
14
14
  @auth_url = auth_url
15
15
  @redirect_uri = redirect_uri
16
16
  @scopes = scopes
17
+ @issuer = issuer
18
+ @discovery_url = discovery_url
17
19
  @access_token = nil
18
20
  @refresh_token = nil
19
21
  @expires_at = nil
@@ -28,10 +30,12 @@ module Ask
28
30
  end
29
31
 
30
32
  def authenticate!
31
- if @client_secret
33
+ if @client_secret && @token_url
32
34
  authenticate_client_credentials
33
35
  elsif @auth_url
34
36
  authenticate_authorization_code
37
+ elsif @issuer || @discovery_url
38
+ raise AuthError, "Call #discover! before #authenticate! to resolve endpoints"
35
39
  else
36
40
  raise AuthError, "No authentication method available"
37
41
  end
@@ -44,8 +48,52 @@ module Ask
44
48
  self
45
49
  end
46
50
 
51
+ # Validate an `iss` parameter from an authorization response
52
+ # (RFC 9207, 2026-07-28): when an issuer is recorded (via
53
+ # discovery or configuration), a present `iss` MUST match it.
54
+ # Call before redeeming an authorization code.
55
+ def validate_iss!(iss)
56
+ return self if @issuer.nil?
57
+ raise AuthError, "iss mismatch: expected #{@issuer}, got #{iss.inspect}" unless iss.to_s == @issuer.to_s
58
+ self
59
+ end
60
+
61
+ # Discover authorization server endpoints via OpenID Connect Discovery
62
+ # 1.0 (2025-11-25, SEP-797). Fetches the document at discovery_url (or
63
+ # the well-known URL derived from issuer per RFC 8414) and populates
64
+ # token_url, auth_url, and issuer. Returns self.
65
+ def discover!(discovery_url: nil)
66
+ require "httpx"
67
+
68
+ url = discovery_url || @discovery_url || well_known_discovery_url
69
+ data = fetch_json(HTTPX, url)
70
+
71
+ @issuer = data[:issuer] if data[:issuer]
72
+ @token_url = data[:token_endpoint] if data[:token_endpoint]
73
+ @auth_url = data[:authorization_endpoint] if data[:authorization_endpoint]
74
+
75
+ raise AuthError, "Discovery document has no token_endpoint" unless @token_url
76
+ self
77
+ end
78
+
47
79
  private
48
80
 
81
+ def well_known_discovery_url
82
+ return @discovery_url if @discovery_url
83
+ raise AuthError, "OIDC discovery requires an issuer or discovery_url" unless @issuer
84
+ "#{@issuer.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
85
+ end
86
+
87
+ def fetch_json(http, url)
88
+ response = http.get(url)
89
+ unless response.status == 200
90
+ raise AuthError, "Discovery request failed: #{response.status} #{response.body.to_s[0..200]}"
91
+ end
92
+ JSON.parse(response.body.to_s, symbolize_names: true)
93
+ rescue JSON::ParserError => e
94
+ raise AuthError, "Invalid discovery document: #{e.message}"
95
+ end
96
+
49
97
  def expired?
50
98
  @expires_at && Time.now >= @expires_at
51
99
  end
@@ -3,7 +3,9 @@
3
3
  module Ask
4
4
  module MCP
5
5
  class Client
6
- PROTOCOL_VERSION = "0.1.0"
6
+ # Deprecated: use Ask::MCP::PROTOCOL_VERSION (the canonical constant).
7
+ # Kept as an alias so existing consumers don't break.
8
+ PROTOCOL_VERSION = Ask::MCP::PROTOCOL_VERSION
7
9
 
8
10
  attr_reader :transport, :capabilities, :server_info
9
11
 
@@ -18,14 +20,18 @@ module Ask
18
20
  @pending_requests = {}
19
21
  @pending_mutex = Mutex.new
20
22
  @pending_condition = ConditionVariable.new
23
+ @request_handlers = {}
21
24
  @next_id = 0
22
25
  @initialized = false
26
+ # Negotiated protocol version; nil until start() resolves it.
27
+ @protocol_version = nil
28
+ @stateless = false
23
29
  end
24
30
 
25
31
  def start
26
32
  @transport.on_message { |message| handle_message(message) }
27
33
  @transport.start
28
- initialize_session
34
+ negotiate_protocol
29
35
  self
30
36
  end
31
37
 
@@ -39,6 +45,7 @@ module Ask
39
45
 
40
46
  response = send_request("tools/list")
41
47
  tools = (response[:tools] || []).map { |t| Tool.from_h(t) }
48
+ tools = reject_invalid_mcp_header_tools(tools)
42
49
  @tools_cache = index_by_name(tools)
43
50
  end
44
51
 
@@ -65,17 +72,18 @@ module Ask
65
72
  Validator.new(tool.input_schema).validate!(arguments)
66
73
  end
67
74
  end
68
- response = send_request("tools/call", name: name, arguments: arguments)
75
+ headers = mcp_param_headers(name, arguments)
76
+ response = send_request("tools/call", { name: name, arguments: arguments }, headers: headers)
69
77
  response[:content] || response
70
78
  end
71
79
 
72
80
  def read_resource(uri)
73
- response = send_request("resources/read", uri: uri)
81
+ response = send_request("resources/read", { uri: uri })
74
82
  response[:contents] || response
75
83
  end
76
84
 
77
85
  def get_prompt(name, arguments = {})
78
- response = send_request("prompts/get", name: name, arguments: arguments)
86
+ response = send_request("prompts/get", { name: name, arguments: arguments })
79
87
  response[:messages] || response
80
88
  end
81
89
 
@@ -83,8 +91,149 @@ module Ask
83
91
  @initialized
84
92
  end
85
93
 
94
+ # Register a handler for a server-initiated request (a JSON-RPC Request
95
+ # the server sends to the client, such as elicitation/create or
96
+ # sampling/createMessage). The handler receives the request params and
97
+ # must return the result hash to send back. The handler runs on the
98
+ # transport reader thread, so long-blocking handlers block message
99
+ # processing — return promptly.
100
+ def on_request(method, &handler)
101
+ @request_handlers[method] = handler
102
+ end
103
+
104
+ # Handle a server request for user input (client/elicitation, 2025-06-18+).
105
+ # The handler receives the elicitation params (including the Elicitation
106
+ # schema with titled/untitled, single/multi-select enums and default
107
+ # values in 2025-11-25) and returns an ElicitationResponse hash, e.g.
108
+ # { message: "42" }
109
+ # or structured content. Declare client support via
110
+ # Ask::MCP::Client.new(transport, client_capabilities: { elicitation: {} })
111
+ def on_elicitation(&handler)
112
+ on_request("elicitation/create", &handler)
113
+ end
114
+
115
+ # Handle a sampling request (client/sampling). The handler receives the
116
+ # CreateMessageRequest params — including `tools` and `toolChoice` when
117
+ # the server offers tool calling (2025-11-25, SEP-1577) — and returns a
118
+ # CreateMessageResult hash, e.g.
119
+ # { role: "assistant", content: { type: "text", text: "..." } }
120
+ # Declare client support via
121
+ # Ask::MCP::Client.new(transport, client_capabilities: { sampling: {} })
122
+ def on_sampling(&handler)
123
+ on_request("sampling/createMessage", &handler)
124
+ end
125
+
126
+ # Open a long-lived notification stream (2026-07-28 subscriptions/listen).
127
+ # `notifications` is a filter Hash, e.g.
128
+ # { toolsListChanged: true, resourceSubscriptions: ["file:///x"] }
129
+ # Notifications flow into the client's message handling (which resets
130
+ # caches on list_changed). Only supported on transports that implement
131
+ # #listen (StreamableHTTP).
132
+ def listen(notifications)
133
+ @transport.listen(notifications)
134
+ end
135
+
86
136
  private
87
137
 
138
+ # Resolve the protocol version to speak with the server (2026-07-28
139
+ # stateless negotiation). Tries `server/discover` first: a server that
140
+ # answers with a supported version list we intersect goes stateless
141
+ # (no initialize handshake); anything else falls back to the legacy
142
+ # `initialize` handshake.
143
+ def negotiate_protocol
144
+ if discover_server
145
+ if @stateless
146
+ # 2026-07-28 stateless peer: no initialize handshake.
147
+ @initialized = true
148
+ reset_caches
149
+ debug "Stateless mode (protocol #{@protocol_version})"
150
+ else
151
+ # Server is a legacy revision (≤ 2025-11-25): it still requires
152
+ # the initialize handshake. discover already filled in server_info
153
+ # and capabilities; initialize refines them.
154
+ initialize_session
155
+ end
156
+ else
157
+ initialize_session
158
+ end
159
+ # Streamable HTTP mirrors the negotiated version into the
160
+ # MCP-Protocol-Version header on every POST.
161
+ @transport.protocol_version = @protocol_version if @transport.respond_to?(:protocol_version=)
162
+ end
163
+
164
+ def discover_server
165
+ response = send_request_raw("server/discover", {}, timeout: 5)
166
+ return false unless response.success?
167
+
168
+ result = response.result
169
+ supported = result[:protocolVersions] || result["protocolVersions"] || []
170
+ chosen = Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.reverse.find { |v| supported.include?(v) }
171
+ return false unless chosen
172
+
173
+ @protocol_version = chosen
174
+ @stateless = chosen == Ask::MCP::LATEST_PROTOCOL_VERSION
175
+ @server_info = result[:serverInfo] || result["serverInfo"] || {}
176
+ @capabilities = result[:capabilities] || result["capabilities"] || {}
177
+ true
178
+ rescue StandardError
179
+ false
180
+ end
181
+
182
+ def reset_caches
183
+ @tools_cache = nil
184
+ @resources_cache = nil
185
+ @prompts_cache = nil
186
+ end
187
+
188
+ def meta_params
189
+ meta = {
190
+ Native::Messages::Meta::PROTOCOL_VERSION_KEY => @protocol_version,
191
+ Native::Messages::Meta::CLIENT_CAPABILITIES_KEY => @options[:client_capabilities] || {},
192
+ Native::Messages::Meta::CLIENT_INFO_KEY => { name: "ask-mcp", version: Ask::MCP::VERSION }
193
+ }
194
+ # Optional extra `_meta` fields — e.g. OpenTelemetry trace context
195
+ # (traceparent/tracestate/baggage) via
196
+ # Client.new(transport, meta: TraceContext.from_headers(rack_env))
197
+ meta.merge!(@options[:meta]) if @options[:meta]
198
+ { _meta: meta }
199
+ end
200
+
201
+ # Mirror x-mcp-header-annotated tool parameters into Mcp-Param-{Name}
202
+ # HTTP headers (2026-07-28, SEP-2243). Only applies on transports that
203
+ # support it (StreamableHTTP). Values are encoded per the spec; integer
204
+ # values outside the JavaScript safe range cannot be represented safely
205
+ # and their headers are omitted.
206
+ def mcp_param_headers(tool_name, arguments)
207
+ return {} unless @transport.is_a?(Transport::StreamableHTTP)
208
+ tool = @tools_cache && @tools_cache[tool_name]
209
+ return {} unless tool && tool.input_schema
210
+
211
+ props = tool.input_schema[:properties] || tool.input_schema["properties"] || {}
212
+ props.each_with_object({}) do |(prop_name, prop_schema), headers|
213
+ header_name = prop_schema[:'x-mcp-header'] || prop_schema["x-mcp-header"]
214
+ next unless header_name
215
+ value = arguments[prop_name] || arguments[prop_name.to_s]
216
+ next if value.nil?
217
+ next if value.is_a?(Integer) && !XMcpHeader.safe_integer?(value)
218
+ headers["Mcp-Param-#{header_name}"] = @transport.encode_header_value(value)
219
+ end
220
+ end
221
+
222
+ # Streamable HTTP clients MUST reject tool definitions with invalid
223
+ # x-mcp-header annotations, excluding the tool from tools/list
224
+ # (2026-07-28, SEP-2243). Other transports ignore the annotations.
225
+ def reject_invalid_mcp_header_tools(tools)
226
+ return tools unless @transport.is_a?(Transport::StreamableHTTP)
227
+
228
+ tools.reject do |tool|
229
+ reason = XMcpHeader.invalid_reason(tool.input_schema)
230
+ next false unless reason
231
+
232
+ warn "[ask-mcp][client] rejecting tool #{tool.name} from tools/list: #{reason}"
233
+ true
234
+ end
235
+ end
236
+
88
237
  def next_id
89
238
  @pending_mutex.synchronize do
90
239
  @next_id += 1
@@ -93,7 +242,7 @@ module Ask
93
242
 
94
243
  def initialize_session
95
244
  response = send_request_raw("initialize", {
96
- protocolVersion: PROTOCOL_VERSION,
245
+ protocolVersion: @protocol_version || Ask::MCP::PROTOCOL_VERSION,
97
246
  capabilities: @options[:client_capabilities] || {},
98
247
  clientInfo: {
99
248
  name: "ask-mcp",
@@ -108,24 +257,28 @@ module Ask
108
257
  result = response.result
109
258
  @server_info = result[:serverInfo] || {}
110
259
  @capabilities = result[:capabilities] || {}
260
+ @protocol_version = result[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
111
261
 
112
262
  send_notification("notifications/initialized")
113
263
  @initialized = true
114
264
 
115
- @tools_cache = nil
116
- @resources_cache = nil
117
- @prompts_cache = nil
265
+ reset_caches
118
266
  end
119
267
 
120
- def send_request(method, params = {})
121
- response = send_request_raw(method, params)
268
+ def debug(msg)
269
+ warn "[ask-mcp][client] #{msg}" if @options[:debug]
270
+ end
271
+
272
+ def send_request(method, params = {}, headers: {})
273
+ response = send_request_raw(method, params, headers: headers)
122
274
  raise ProtocolError, "Request failed: #{response.error[:message]}" unless response.success?
123
275
  response.result
124
276
  end
125
277
 
126
- def send_request_raw(method, params = {})
278
+ def send_request_raw(method, params = {}, timeout: nil, headers: {})
279
+ params = params.merge(meta_params) if @stateless
127
280
  request = Native::Messages::Request.new(method:, params:, id: next_id)
128
- wait_for_response(request)
281
+ wait_for_response(request, timeout: timeout, headers: headers)
129
282
  end
130
283
 
131
284
  def send_notification(method, params = {})
@@ -133,15 +286,38 @@ module Ask
133
286
  @transport.send(notification)
134
287
  end
135
288
 
136
- def wait_for_response(request)
289
+ # Multi Round-Trip Requests (2026-07-28, SEP-2322): how many times the
290
+ # client may retry a request after an InputRequiredResult before giving up.
291
+ MAX_MRTR_ROUND_TRIPS = 5
292
+
293
+ def wait_for_response(request, timeout: nil, headers: {})
294
+ timeout ||= @options[:timeout] || 60
295
+ round_trips = 0
296
+
297
+ loop do
298
+ response = send_and_wait(request, timeout, headers: headers)
299
+ return response unless input_required?(response)
300
+
301
+ round_trips += 1
302
+ if round_trips > MAX_MRTR_ROUND_TRIPS
303
+ raise ProtocolError, "MRTR exceeded #{MAX_MRTR_ROUND_TRIPS} round trips"
304
+ end
305
+
306
+ result = response.result
307
+ input_responses = resolve_input_requests(result[:inputRequests] || result["inputRequests"] || {})
308
+ request = build_retry_request(request, result, input_responses)
309
+ end
310
+ end
311
+
312
+ # Send a single request and wait for its response.
313
+ def send_and_wait(request, timeout, headers: {})
137
314
  # Register the pending request BEFORE sending to avoid race
138
315
  @pending_mutex.synchronize do
139
316
  @pending_requests[request.id] = true
140
317
  end
141
318
 
142
- @transport.send(request)
319
+ @transport.send(request, headers)
143
320
 
144
- timeout = @options[:timeout] || 60
145
321
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
146
322
 
147
323
  @pending_mutex.synchronize do
@@ -163,6 +339,39 @@ module Ask
163
339
  end
164
340
  end
165
341
 
342
+ # An InputRequiredResult only occurs in the stateless (2026-07-28) mode.
343
+ def input_required?(response)
344
+ @stateless && response.success? && response.result.is_a?(Hash) &&
345
+ response.result[:resultType] == "input_required"
346
+ end
347
+
348
+ # Resolve each server inputRequest (elicitation/create, sampling/
349
+ # createMessage, roots/list) through the registered on_request handlers.
350
+ # Returns the InputResponses map keyed by the server's identifiers.
351
+ def resolve_input_requests(input_requests)
352
+ return {} if input_requests.nil? || input_requests.empty?
353
+
354
+ input_requests.each_with_object({}) do |(key, req), responses|
355
+ method = req[:method] || req["method"]
356
+ handler = @request_handlers[method]
357
+ unless handler
358
+ raise ProtocolError, "MRTR: no handler registered for #{method} (inputRequest #{key})"
359
+ end
360
+ responses[key] = handler.call(req[:params] || req["params"] || {})
361
+ end
362
+ end
363
+
364
+ # Build the MRTR retry: same method, a NEW id, the original params plus
365
+ # inputResponses and — if the server sent one — the opaque requestState
366
+ # echoed verbatim (clients MUST NOT inspect or modify it).
367
+ def build_retry_request(request, result, input_responses)
368
+ retry_params = (request.params || {}).dup
369
+ retry_params[:inputResponses] = input_responses
370
+ state = result[:requestState] || result["requestState"]
371
+ retry_params[:requestState] = state if state
372
+ Native::Messages::Request.new(method: request.method, params: retry_params, id: next_id)
373
+ end
374
+
166
375
  def handle_message(message)
167
376
  case message
168
377
  when Native::Messages::Response
@@ -186,13 +395,18 @@ module Ask
186
395
  end
187
396
 
188
397
  def handle_request(request)
189
- response = Native::Messages::Response.new(
190
- id: request.id,
191
- error: {
192
- code: Native::Messages::ErrorCodes::METHOD_NOT_FOUND,
193
- message: "Method not implemented: #{request.method}"
194
- }
195
- )
398
+ handler = @request_handlers[request.method]
399
+ response = if handler
400
+ Native::Messages::Response.new(id: request.id, result: handler.call(request.params || {}))
401
+ else
402
+ Native::Messages::Response.new(
403
+ id: request.id,
404
+ error: {
405
+ code: Native::Messages::ErrorCodes::METHOD_NOT_FOUND,
406
+ message: "Method not implemented: #{request.method}"
407
+ }
408
+ )
409
+ end
196
410
  @transport.send(response)
197
411
  end
198
412
 
@@ -133,7 +133,7 @@ module Ask
133
133
  INVALID_PARAMS = -32602
134
134
  INTERNAL_ERROR = -32603
135
135
 
136
- # MCP-specific error codes
136
+ # MCP-specific error codes (legacy, 2025-06-18 and earlier)
137
137
  TOOL_NOT_FOUND = -32000
138
138
  RESOURCE_NOT_FOUND = -32001
139
139
  PROMPT_NOT_FOUND = -32002
@@ -141,6 +141,13 @@ module Ask
141
141
  CONNECTION_ERROR = -32004
142
142
  TIMEOUT_ERROR = -32005
143
143
 
144
+ # 2026-07-28: the -32020..-32099 range is reserved for the MCP
145
+ # specification (error code allocation policy); older codes were
146
+ # renumbered.
147
+ HEADER_MISMATCH = -32020
148
+ MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
149
+ UNSUPPORTED_PROTOCOL_VERSION = -32022
150
+
144
151
  ERROR_MESSAGES = {
145
152
  PARSE_ERROR => "Parse error",
146
153
  INVALID_REQUEST => "Invalid request",
@@ -152,9 +159,23 @@ module Ask
152
159
  PROMPT_NOT_FOUND => "Prompt not found",
153
160
  AUTH_ERROR => "Authentication error",
154
161
  CONNECTION_ERROR => "Connection error",
155
- TIMEOUT_ERROR => "Timeout error"
162
+ TIMEOUT_ERROR => "Timeout error",
163
+ HEADER_MISMATCH => "Header mismatch",
164
+ MISSING_REQUIRED_CLIENT_CAPABILITY => "Missing required client capability",
165
+ UNSUPPORTED_PROTOCOL_VERSION => "Unsupported protocol version"
156
166
  }.freeze
157
167
  end
168
+
169
+ # Reserved `_meta` keys for the stateless protocol (2026-07-28).
170
+ # Keys are namespaced strings; note the JSON parser symbolizes all
171
+ # keys, so lookups must try both the string and symbol forms.
172
+ module Meta
173
+ PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
174
+ CLIENT_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities"
175
+ CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
176
+ SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo"
177
+ LOG_LEVEL_KEY = "io.modelcontextprotocol/logLevel"
178
+ end
158
179
  end
159
180
  end
160
181
  end
@@ -3,18 +3,22 @@
3
3
  module Ask
4
4
  module MCP
5
5
  class Prompt
6
- attr_reader :name, :description, :arguments
6
+ attr_reader :name, :description, :arguments, :title, :icons
7
7
 
8
- def initialize(name:, description: nil, arguments: [])
8
+ def initialize(name:, description: nil, arguments: [], title: nil, icons: [])
9
9
  @name = name
10
10
  @description = description
11
11
  @arguments = arguments
12
+ @title = title
13
+ @icons = icons
12
14
  end
13
15
 
14
16
  def to_h
15
17
  h = { name: @name }
18
+ h[:title] = @title if @title
16
19
  h[:description] = @description if @description
17
20
  h[:arguments] = @arguments if @arguments.any?
21
+ h[:icons] = @icons if @icons.any?
18
22
  h
19
23
  end
20
24
 
@@ -22,7 +26,9 @@ module Ask
22
26
  new(
23
27
  name: hash[:name] || hash["name"],
24
28
  description: hash[:description] || hash["description"],
25
- arguments: hash[:arguments] || hash["arguments"] || []
29
+ arguments: hash[:arguments] || hash["arguments"] || [],
30
+ title: hash[:title] || hash["title"],
31
+ icons: hash[:icons] || hash["icons"] || []
26
32
  )
27
33
  end
28
34
  end
@@ -3,19 +3,23 @@
3
3
  module Ask
4
4
  module MCP
5
5
  class Resource
6
- attr_reader :uri, :name, :description, :mime_type
6
+ attr_reader :uri, :name, :description, :mime_type, :title, :icons
7
7
 
8
- def initialize(uri:, name:, description: nil, mime_type: nil)
8
+ def initialize(uri:, name:, description: nil, mime_type: nil, title: nil, icons: [])
9
9
  @uri = uri
10
10
  @name = name
11
11
  @description = description
12
12
  @mime_type = mime_type
13
+ @title = title
14
+ @icons = icons
13
15
  end
14
16
 
15
17
  def to_h
16
18
  h = { uri: @uri, name: @name }
19
+ h[:title] = @title if @title
17
20
  h[:description] = @description if @description
18
21
  h[:mimeType] = @mime_type if @mime_type
22
+ h[:icons] = @icons if @icons.any?
19
23
  h
20
24
  end
21
25
 
@@ -24,7 +28,9 @@ module Ask
24
28
  uri: hash[:uri] || hash["uri"],
25
29
  name: hash[:name] || hash["name"],
26
30
  description: hash[:description] || hash["description"],
27
- mime_type: hash[:mimeType] || hash["mime_type"] || hash[:mime_type]
31
+ mime_type: hash[:mimeType] || hash["mime_type"] || hash[:mime_type],
32
+ title: hash[:title] || hash["title"],
33
+ icons: hash[:icons] || hash["icons"] || []
28
34
  )
29
35
  end
30
36
  end