ask-mcp 0.4.0 → 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.
@@ -9,23 +9,33 @@ module Ask
9
9
  # MCP server over stdio transport.
10
10
  class Stdio
11
11
  MAX_RESULT_CACHE = 100
12
+ # Deprecated: use Ask::MCP::PROTOCOL_VERSION (the canonical constant).
13
+ PROTOCOL_VERSION = Ask::MCP::PROTOCOL_VERSION
12
14
 
13
15
  attr_reader :name, :tools, :capabilities, :resources, :prompts
14
16
 
15
17
  def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {},
16
- debug: false, tool_timeout: nil)
18
+ resource_templates: {}, debug: false, tool_timeout: nil,
19
+ cache_ttl_ms: 60_000, cache_scope: "private")
17
20
  @name = name
18
21
  @capabilities = capabilities
19
22
  @resources = resources
20
23
  @prompts = prompts
24
+ @resource_templates = resource_templates
21
25
  @debug = debug
22
26
  @tool_timeout = tool_timeout
27
+ @cache_ttl_ms = cache_ttl_ms
28
+ @cache_scope = cache_scope
23
29
 
24
30
  @adapter = Adapters::ToolServer.new(tools || [])
25
31
  @initialized = false
26
32
  @running = false
27
33
  @shutdown_requested = false
28
34
  @result_cache = {}
35
+ # Negotiated protocol version. nil until the client tells us which
36
+ # revision it speaks (legacy `initialize` or stateless `_meta`).
37
+ @protocol_version = nil
38
+ @stateless = false
29
39
  end
30
40
 
31
41
  def start
@@ -66,6 +76,22 @@ module Ask
66
76
  @running
67
77
  end
68
78
 
79
+ # Emit notifications/tools/list_changed (2026-07-28: consumed by
80
+ # clients on the shared stdio channel or on a subscriptions/listen
81
+ # stream). Call these after your tool/resource/prompt sets change.
82
+ # Safe to call from any thread; writes are flushed immediately.
83
+ def notify_tools_list_changed
84
+ send_notification("notifications/tools/list_changed")
85
+ end
86
+
87
+ def notify_resources_list_changed
88
+ send_notification("notifications/resources/list_changed")
89
+ end
90
+
91
+ def notify_prompts_list_changed
92
+ send_notification("notifications/prompts/list_changed")
93
+ end
94
+
69
95
  private
70
96
 
71
97
  def graceful_shutdown
@@ -88,9 +114,21 @@ module Ask
88
114
  params = msg[:params] || {}
89
115
  has_id = msg.key?(:id)
90
116
 
117
+ # Stateless (2026-07-28) requests carry the protocol version in
118
+ # `_meta` instead of an `initialize` handshake. Detecting it here
119
+ # unlocks all handlers without the legacy @initialized gate.
120
+ if (meta_version = meta_protocol_version(params))
121
+ @protocol_version = meta_version
122
+ @stateless = true
123
+ @initialized = true
124
+ debug_log "Stateless request (protocol #{meta_version})"
125
+ end
126
+
91
127
  case method
92
128
  when "initialize"
93
129
  handle_initialize(id, params)
130
+ when "server/discover"
131
+ handle_discover(id)
94
132
  when "notifications/initialized"
95
133
  @initialized = true
96
134
  debug_log "Client initialized"
@@ -100,17 +138,50 @@ module Ask
100
138
  when "tools/call"
101
139
  return send_error(id, -32000, "Server not initialized") unless @initialized
102
140
  handle_tool_call(id, params)
141
+ when "resources/list"
142
+ return send_error(id, -32000, "Server not initialized") unless @initialized
143
+ handle_resources_list(id)
144
+ when "resources/read"
145
+ return send_error(id, -32000, "Server not initialized") unless @initialized
146
+ handle_resource_read(id, params)
147
+ when "resources/templates/list"
148
+ return send_error(id, -32000, "Server not initialized") unless @initialized
149
+ handle_resources_templates_list(id)
150
+ when "prompts/list"
151
+ return send_error(id, -32000, "Server not initialized") unless @initialized
152
+ handle_prompts_list(id)
153
+ when "prompts/get"
154
+ return send_error(id, -32000, "Server not initialized") unless @initialized
155
+ handle_prompt_get(id, params)
103
156
  when "ping"
104
- send_result(id, {}) if has_id
157
+ # ping was removed in 2026-07-28; legacy clients still use it.
158
+ if stateless_mode?
159
+ send_error(id, -32601, "Method not found: ping") if has_id
160
+ else
161
+ send_result(id, {}) if has_id
162
+ end
105
163
  else
106
164
  debug_log "Unknown method: #{method}"
107
165
  send_error(id, -32601, "Method not found: #{method}") if has_id
108
166
  end
109
167
  end
110
168
 
169
+ # server/discover (2026-07-28): advertise supported protocol versions,
170
+ # capabilities, and identity. Clients call it before anything else to
171
+ # select a version (or as a backward-compat probe on stdio).
172
+ def handle_discover(id)
173
+ send_result(id, {
174
+ protocolVersions: Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS,
175
+ capabilities: @capabilities,
176
+ serverInfo: { name: @name, version: Ask::MCP::VERSION }
177
+ })
178
+ debug_log "server/discover answered"
179
+ end
180
+
111
181
  def handle_initialize(id, params)
112
182
  @initialized = true
113
- client_version = params[:protocolVersion] || PROTOCOL_VERSION
183
+ @protocol_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
184
+ client_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
114
185
  debug_log "Handling initialize (id=#{id.inspect}, version=#{client_version})"
115
186
  send_result(id, {
116
187
  protocolVersion: client_version,
@@ -126,7 +197,54 @@ module Ask
126
197
  def handle_tools_list(id)
127
198
  defs = @adapter.definitions
128
199
  debug_log "tools/list returning #{defs.length} tool definitions"
129
- send_result(id, { tools: defs })
200
+ send_result(id, cacheable({ tools: defs }))
201
+ end
202
+
203
+ def handle_resources_list(id)
204
+ defs = @resources.values.map { |r| resource_to_h(r) }
205
+ debug_log "resources/list returning #{defs.length} resources"
206
+ send_result(id, cacheable({ resources: defs }))
207
+ end
208
+
209
+ def handle_resources_templates_list(id)
210
+ defs = @resource_templates.values.map { |t| template_to_h(t) }
211
+ debug_log "resources/templates/list returning #{defs.length} templates"
212
+ send_result(id, cacheable({ resourceTemplates: defs }))
213
+ end
214
+
215
+ def handle_resource_read(id, params)
216
+ uri = params[:uri].to_s
217
+ resource = @resources[uri]
218
+ if resource.nil?
219
+ code = stateless_mode? ? -32_602 : Native::Messages::ErrorCodes::RESOURCE_NOT_FOUND
220
+ return send_error(id, code, "Resource not found: #{uri}")
221
+ end
222
+
223
+ contents = if resource.respond_to?(:content)
224
+ resource.content
225
+ elsif resource.respond_to?(:read)
226
+ resource.read
227
+ else
228
+ [{ uri: uri, text: "" }]
229
+ end
230
+ send_result(id, cacheable({ contents: contents }))
231
+ end
232
+
233
+ def handle_prompts_list(id)
234
+ defs = @prompts.values.map { |p| prompt_to_h(p) }
235
+ debug_log "prompts/list returning #{defs.length} prompts"
236
+ send_result(id, cacheable({ prompts: defs }))
237
+ end
238
+
239
+ def handle_prompt_get(id, params)
240
+ name = params[:name].to_s
241
+ prompt = @prompts[name]
242
+ if prompt.nil?
243
+ return send_error(id, Native::Messages::ErrorCodes::PROMPT_NOT_FOUND, "Prompt not found: #{name}")
244
+ end
245
+
246
+ messages = prompt.respond_to?(:messages) ? prompt.messages : []
247
+ send_result(id, { messages: messages })
130
248
  end
131
249
 
132
250
  def handle_tool_call(id, params)
@@ -161,10 +279,82 @@ module Ask
161
279
  })
162
280
  end
163
281
 
282
+ # Serialize a resource object for resources/list. Prefers to_h (the
283
+ # Resource value object emits title/icons/description/mimeType);
284
+ # otherwise builds the shape from duck-typed accessors.
285
+ def resource_to_h(resource)
286
+ return resource.to_h if resource.respond_to?(:to_h)
287
+
288
+ h = { uri: resource.uri, name: resource.name }
289
+ h[:title] = resource.title if resource.respond_to?(:title) && resource.title
290
+ h[:description] = resource.description if resource.respond_to?(:description) && resource.description
291
+ h[:mimeType] = resource.mime_type if resource.respond_to?(:mime_type) && resource.mime_type
292
+ h[:icons] = resource.icons if resource.respond_to?(:icons) && resource.icons&.any?
293
+ h
294
+ end
295
+
296
+ def template_to_h(template)
297
+ return template.to_h if template.respond_to?(:to_h)
298
+
299
+ h = { uriTemplate: template.uri_template, name: template.name }
300
+ h[:title] = template.title if template.respond_to?(:title) && template.title
301
+ h[:mimeType] = template.mime_type if template.respond_to?(:mime_type) && template.mime_type
302
+ h[:icons] = template.icons if template.respond_to?(:icons) && template.icons&.any?
303
+ h
304
+ end
305
+
306
+ def prompt_to_h(prompt)
307
+ return prompt.to_h if prompt.respond_to?(:to_h)
308
+
309
+ h = { name: prompt.name }
310
+ h[:title] = prompt.title if prompt.respond_to?(:title) && prompt.title
311
+ h[:description] = prompt.description if prompt.respond_to?(:description) && prompt.description
312
+ h[:arguments] = prompt.arguments if prompt.respond_to?(:arguments) && prompt.arguments&.any?
313
+ h[:icons] = prompt.icons if prompt.respond_to?(:icons) && prompt.icons&.any?
314
+ h
315
+ end
316
+
164
317
  def send_result(id, result)
318
+ # 2026-07-28: all results carry `resultType`. Legacy peers tolerate
319
+ # the field, but we only add it for stateless peers to keep the
320
+ # legacy wire output unchanged.
321
+ result = result.merge(resultType: "complete") if stateless_mode?
165
322
  $stdout.puts({ jsonrpc: "2.0", id: id, result: result }.to_json)
166
323
  end
167
324
 
325
+ # Write a server→client notification (no id). stdout is sync'd in
326
+ # #start, so this is safe to call from any thread.
327
+ def send_notification(method, params = {})
328
+ msg = { jsonrpc: "2.0", method: method }
329
+ msg[:params] = params unless params.empty?
330
+ $stdout.puts(msg.to_json)
331
+ end
332
+
333
+ # 2026-07-28 CacheableResult: freshness hints (ttlMs) and scope
334
+ # (public/private) on list/read results so clients and shared
335
+ # intermediaries may cache them. Only emitted for stateless peers.
336
+ def cacheable(result)
337
+ return result unless stateless_mode?
338
+ result.merge(ttlMs: @cache_ttl_ms, cacheScope: @cache_scope)
339
+ end
340
+
341
+ # True once a 2026-07-28 stateless peer has been detected.
342
+ def stateless_mode?
343
+ @protocol_version == Ask::MCP::LATEST_PROTOCOL_VERSION
344
+ end
345
+
346
+ # Read the protocol version a stateless client advertises in params
347
+ # `_meta`. Returns nil for legacy requests. Handles both symbol and
348
+ # string key forms (the JSON parser symbolizes all keys).
349
+ def meta_protocol_version(params)
350
+ meta = params[:meta] || params[:_meta] || {}
351
+ meta_value(meta, Native::Messages::Meta::PROTOCOL_VERSION_KEY)
352
+ end
353
+
354
+ def meta_value(meta, key)
355
+ meta[key] || meta[key.to_sym] || meta[key.to_s]
356
+ end
357
+
168
358
  def send_error(id, code, message)
169
359
  $stdout.puts({ jsonrpc: "2.0", id: id, error: { code: code, message: message } }.to_json)
170
360
  end
@@ -43,9 +43,14 @@ module Ask
43
43
  # @param name [String] server name
44
44
  # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose
45
45
  # @param capabilities [Hash] MCP capabilities (default: { tools: {} })
46
+ # @param resources [Hash{String => #to_h, #content}] uri → resource objects
47
+ # @param prompts [Hash{String => #to_h, #messages}] name → prompt objects
46
48
  # @param debug [Boolean] enable stderr debug logging
47
- def self.start_stdio(name:, tools: [], capabilities: { tools: {} }, debug: false)
48
- Stdio.new(name: name, tools: tools, capabilities: capabilities, debug: debug).start
49
+ def self.start_stdio(name:, tools: [], capabilities: { tools: {} }, resources: {},
50
+ prompts: {}, resource_templates: {}, debug: false)
51
+ Stdio.new(name: name, tools: tools, capabilities: capabilities,
52
+ resources: resources, prompts: prompts,
53
+ resource_templates: resource_templates, debug: debug).start
49
54
  end
50
55
  end
51
56
  end
data/lib/ask/mcp/tool.rb CHANGED
@@ -3,12 +3,14 @@
3
3
  module Ask
4
4
  module MCP
5
5
  class Tool
6
- attr_reader :name, :description, :input_schema
6
+ attr_reader :name, :description, :input_schema, :title, :icons
7
7
 
8
- def initialize(name:, description: "", input_schema: {})
8
+ def initialize(name:, description: "", input_schema: {}, title: nil, icons: [])
9
9
  @name = name
10
10
  @description = description
11
11
  @input_schema = input_schema
12
+ @title = title
13
+ @icons = icons
12
14
  end
13
15
 
14
16
  def to_ask_tool
@@ -22,18 +24,23 @@ module Ask
22
24
  end
23
25
 
24
26
  def to_h
25
- {
27
+ h = {
26
28
  name: @name,
27
29
  description: @description,
28
30
  inputSchema: @input_schema
29
31
  }
32
+ h[:title] = @title if @title
33
+ h[:icons] = @icons if @icons.any?
34
+ h
30
35
  end
31
36
 
32
37
  def self.from_h(hash)
33
38
  new(
34
39
  name: hash[:name] || hash["name"],
35
40
  description: hash[:description] || hash["description"] || "",
36
- input_schema: hash[:inputSchema] || hash["input_schema"] || hash[:input_schema] || hash["inputSchema"] || {}
41
+ input_schema: hash[:inputSchema] || hash["input_schema"] || hash[:input_schema] || hash["inputSchema"] || {},
42
+ title: hash[:title] || hash["title"],
43
+ icons: hash[:icons] || hash["icons"] || []
37
44
  )
38
45
  end
39
46
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ # OpenTelemetry trace context propagation via MCP `_meta` (2026-07-28,
6
+ # SEP-414). The convention reserves three `_meta` keys — `traceparent`,
7
+ # `tracestate`, `baggage` — so traces can span the client/server boundary.
8
+ module TraceContext
9
+ KEYS = %w[traceparent tracestate baggage].freeze
10
+
11
+ module_function
12
+
13
+ # Extract trace context from arbitrary HTTP-style headers, matching
14
+ # names case-insensitively and accepting Rack's HTTP_* env convention.
15
+ #
16
+ # TraceContext.from_headers(rack_env)
17
+ # # => { "traceparent" => "00-...", "tracestate" => "..." }
18
+ def from_headers(headers)
19
+ headers.each_with_object({}) do |(name, value), out|
20
+ key = name.to_s.downcase
21
+ key = key.delete_prefix("http_") if key.start_with?("http_")
22
+ next unless KEYS.include?(key)
23
+
24
+ out[key] = value.to_s
25
+ end
26
+ end
27
+
28
+ # Extract trace context from an MCP request's `_meta` hash (keys may be
29
+ # symbols or strings, as the JSON parser symbolizes keys).
30
+ def from_meta(meta)
31
+ meta = meta || {}
32
+ KEYS.each_with_object({}) do |key, out|
33
+ value = meta[key] || meta[key.to_sym]
34
+ out[key] = value.to_s if value
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -3,6 +3,10 @@
3
3
  module Ask
4
4
  module MCP
5
5
  module Transport
6
+ # HTTP+SSE transport (2024-11-05). DEPRECATED since 2025-03-26 and
7
+ # reclassified as Deprecated under the MCP feature lifecycle in
8
+ # 2026-07-28 (SEP-2596). Keep working for legacy servers; new
9
+ # integrations should use StreamableHTTP instead.
6
10
  class SSE
7
11
  attr_reader :url
8
12
 
@@ -42,7 +46,7 @@ module Ask
42
46
  @http&.close
43
47
  end
44
48
 
45
- def send(message)
49
+ def send(message, _headers = {})
46
50
  require "httpx"
47
51
 
48
52
  data = message.is_a?(String) ? message : message.to_json
@@ -50,7 +50,7 @@ module Ask
50
50
  # Process already exited
51
51
  end
52
52
 
53
- def send(message)
53
+ def send(message, _headers = {})
54
54
  data = message.is_a?(String) ? message : message.to_json
55
55
  @mutex.synchronize do
56
56
  @stdin&.puts(data)
@@ -1,10 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
4
+
3
5
  module Ask
4
6
  module MCP
5
7
  module Transport
8
+ # Streamable HTTP transport (2026-07-28 shape).
9
+ #
10
+ # Every JSON-RPC message is its own HTTP POST to the single MCP
11
+ # endpoint. Each POST carries the mirrored request-metadata headers:
12
+ # - MCP-Protocol-Version (matching the body's `_meta` protocolVersion)
13
+ # - Mcp-Method (the JSON-RPC method)
14
+ # - Mcp-Name (params.name / params.uri for tools/call, resources/read,
15
+ # prompts/get)
16
+ # The server answers with either a single `application/json` object or
17
+ # an SSE stream scoped to the request; the client must handle both,
18
+ # chosen per response by Content-Type.
19
+ #
20
+ # Protocol-level sessions, the GET stream endpoint, and Last-Event-ID
21
+ # resumability were removed in 2026-07-28 and are not implemented.
6
22
  class StreamableHTTP
7
23
  attr_reader :url
24
+ # Set by the client after negotiation; sent as MCP-Protocol-Version.
25
+ attr_accessor :protocol_version
8
26
 
9
27
  def initialize(url, options = {})
10
28
  @url = url
@@ -12,7 +30,8 @@ module Ask
12
30
  @running = false
13
31
  @message_handlers = []
14
32
  @http = nil
15
- @session_id = nil
33
+ @protocol_version = nil
34
+ @listen_thread = nil
16
35
  end
17
36
 
18
37
  def on_message(&block)
@@ -23,7 +42,7 @@ module Ask
23
42
  require "httpx"
24
43
 
25
44
  headers = { "Content-Type" => "application/json" }
26
- headers["Accept"] = "text/event-stream" if @options[:stream]
45
+ headers["Accept"] = "application/json, text/event-stream"
27
46
  headers.merge!(@options[:headers]) if @options[:headers]
28
47
 
29
48
  @http = HTTPX.with(
@@ -36,21 +55,52 @@ module Ask
36
55
 
37
56
  def stop
38
57
  @running = false
58
+ @listen_thread&.kill
39
59
  @http&.close
40
60
  end
41
61
 
42
- def send(message)
62
+ # Send a JSON-RPC message. `extra_headers` (e.g. Mcp-Param-* mirrored
63
+ # from tool parameters) are merged into the request metadata headers.
64
+ def send(message, extra_headers = {})
43
65
  data = message.is_a?(String) ? message : message.to_json
44
-
45
- if @options[:stream]
46
- send_streaming(data)
47
- else
48
- send_request_response(data)
49
- end
66
+ headers = request_headers(message).merge(extra_headers)
67
+ response = @http.post(@url, body: data, headers: headers)
68
+ handle_response(response)
50
69
  rescue HTTPX::Error => e
51
70
  raise ConnectionError, "HTTP error: #{e.message}"
52
71
  end
53
72
 
73
+ # Open a long-lived notification stream via subscriptions/listen
74
+ # (2026-07-28). The response SSE stream stays open; delivered
75
+ # notifications (e.g. notifications/tools/list_changed,
76
+ # notifications/resources/updated) are passed to on_message handlers.
77
+ # The notifications filter is a Hash like
78
+ # { toolsListChanged: true, resourceSubscriptions: ["uri"] }
79
+ # Close the stream by calling #close_listen or #stop.
80
+ def listen(notifications)
81
+ require "httpx"
82
+
83
+ request = Native::Messages::Request.new(
84
+ method: "subscriptions/listen",
85
+ params: { notifications: notifications },
86
+ id: @options[:listen_id] || 1
87
+ )
88
+ headers = request_headers(request)
89
+ response = @http.post(@url, body: request.to_json, headers: headers)
90
+
91
+ unless response.status == 200
92
+ raise ConnectionError, "HTTP #{response.status}: #{response.body.to_s[0..200]}"
93
+ end
94
+
95
+ @listen_thread = Thread.new { read_sse_stream(response) }
96
+ self
97
+ end
98
+
99
+ def close_listen
100
+ @listen_thread&.kill
101
+ @listen_thread = nil
102
+ end
103
+
54
104
  def running?
55
105
  @running
56
106
  end
@@ -59,45 +109,71 @@ module Ask
59
109
  stop
60
110
  end
61
111
 
112
+ # Encode a value for use as an HTTP header value per the spec: plain
113
+ # visible ASCII passes through; anything else (non-ASCII, control
114
+ # characters, leading/trailing whitespace, or a value matching the
115
+ # Base64 sentinel pattern) is encoded as =?base64?...?=.
116
+ def encode_header_value(value)
117
+ str = value.to_s
118
+ header_safe?(str) ? str : "=?base64?#{Base64.strict_encode64(str)}?="
119
+ end
120
+
62
121
  private
63
122
 
64
- def send_request_response(data)
65
- response = @http.post(@url, body: data)
123
+ def request_headers(message)
124
+ headers = { "Accept" => "application/json, text/event-stream" }
125
+
126
+ if message.is_a?(Native::Messages::Request) || message.is_a?(Native::Messages::Notification)
127
+ headers["Mcp-Method"] = message.method
128
+ headers["MCP-Protocol-Version"] = @protocol_version if @protocol_version
129
+ if %w[tools/call resources/read prompts/get].include?(message.method) && message.params
130
+ value = message.params[:name] || message.params[:uri] ||
131
+ message.params["name"] || message.params["uri"]
132
+ headers["Mcp-Name"] = encode_header_value(value) if value
133
+ end
134
+ end
135
+
136
+ headers
137
+ end
138
+
139
+ def handle_response(response)
66
140
  status = response.status
141
+ if status == 202
142
+ # Notification accepted, no body.
143
+ return response
144
+ end
145
+ unless status == 200
146
+ raise ConnectionError, "HTTP #{status}: #{response.body.to_s[0..200]}"
147
+ end
67
148
 
68
- if status == 200 || status == 202
149
+ content_type = response.headers["content-type"].to_s
150
+ if content_type.include?("text/event-stream")
151
+ read_sse_stream(response)
152
+ else
69
153
  body = response.body.to_s
70
154
  if body && !body.empty?
71
155
  message = Native::Messages::Parser.parse(body)
72
156
  @message_handlers.each { |handler| handler.call(message) }
73
157
  end
74
- elsif status == 204
75
- # No content — nothing to process
76
- else
77
- raise ConnectionError, "HTTP #{status}: #{response.body.to_s[0..200]}"
78
158
  end
79
159
 
80
160
  response
81
161
  end
82
162
 
83
- def send_streaming(data)
84
- response = @http.post(@url, body: data)
85
-
86
- unless response.status == 200
87
- raise ConnectionError, "HTTP #{response.status}: #{response.body.to_s[0..200]}"
88
- end
89
-
163
+ # Read an SSE stream, delivering each `data:` payload as a parsed
164
+ # message. Lines beginning with a colon are SSE comments (keep-alive)
165
+ # and are ignored. Returns when the server closes the stream.
166
+ def read_sse_stream(response)
90
167
  buffer = +""
91
168
  response.body.each do |chunk|
92
169
  buffer << chunk
93
170
  while (line = buffer.slice!(/\A.*\n/))
94
171
  line = line.strip
95
- next if line.empty?
172
+ next if line.empty? || line.start_with?(":")
96
173
 
97
174
  if line.start_with?("data: ")
98
- data_line = line[6..]
99
175
  begin
100
- message = Native::Messages::Parser.parse(data_line)
176
+ message = Native::Messages::Parser.parse(line[6..])
101
177
  @message_handlers.each { |handler| handler.call(message) }
102
178
  rescue JSON::ParserError
103
179
  # Skip non-JSON data lines
@@ -106,6 +182,12 @@ module Ask
106
182
  end
107
183
  end
108
184
  end
185
+
186
+ def header_safe?(str)
187
+ return false if str.start_with?("=?base64?") && str.end_with?("?=")
188
+ return false unless str == str.strip
189
+ str.each_char.all? { |c| c.ord >= 0x20 && c.ord <= 0x7E }
190
+ end
109
191
  end
110
192
  end
111
193
  end
@@ -19,7 +19,7 @@ module Ask
19
19
  string_schema = deep_stringify_keys(@schema)
20
20
  data = arguments.is_a?(Hash) ? deep_stringify_keys(arguments) : arguments
21
21
 
22
- errors = JSON::Validator.fully_validate(string_schema, data)
22
+ errors = validate_with_dialect_fallback(string_schema, data)
23
23
  if errors.any?
24
24
  raise ValidationError, "Validation failed: #{errors.join(", ")}"
25
25
  end
@@ -36,6 +36,24 @@ module Ask
36
36
 
37
37
  private
38
38
 
39
+ # The json-schema gem implements drafts 1-7 but not JSON Schema 2020-12,
40
+ # which MCP 2025-11-25 declares as the default dialect. Most 2020-12
41
+ # schemas only use keywords shared with earlier drafts, so validating
42
+ # them still works — but the gem rejects the 2020-12 metaschema itself
43
+ # ("Schema not found: .../draft/2020-12/schema"). When a schema declares
44
+ # the 2020-12 dialect, strip the $schema key and retry.
45
+ def validate_with_dialect_fallback(schema, data)
46
+ JSON::Validator.fully_validate(schema, data)
47
+ rescue JSON::Schema::SchemaError, JSON::Schema::JsonParseError => e
48
+ if schema["$schema"].to_s.include?("2020-12")
49
+ cleaned = schema.dup
50
+ cleaned.delete("$schema")
51
+ JSON::Validator.fully_validate(cleaned, data)
52
+ else
53
+ raise e
54
+ end
55
+ end
56
+
39
57
  def deep_stringify_keys(obj)
40
58
  case obj
41
59
  when Hash
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module MCP
3
- VERSION = "0.4.0"
3
+ VERSION = "0.4.2"
4
4
  end
5
5
  end