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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/README.md +104 -175
- data/docs/SPEC_COMPLIANCE.md +184 -0
- data/lib/ask/mcp/adapters/tool_server.rb +33 -27
- data/lib/ask/mcp/auth/client_id_metadata_document.rb +74 -0
- data/lib/ask/mcp/auth/oauth.rb +52 -4
- data/lib/ask/mcp/client.rb +237 -23
- data/lib/ask/mcp/native/messages.rb +23 -2
- data/lib/ask/mcp/prompt.rb +9 -3
- data/lib/ask/mcp/resource.rb +9 -3
- data/lib/ask/mcp/server/stdio.rb +194 -4
- data/lib/ask/mcp/server.rb +7 -2
- data/lib/ask/mcp/tool.rb +11 -4
- data/lib/ask/mcp/trace_context.rb +39 -0
- data/lib/ask/mcp/transport/sse.rb +5 -1
- data/lib/ask/mcp/transport/stdio.rb +1 -1
- data/lib/ask/mcp/transport/streamable_http.rb +108 -26
- data/lib/ask/mcp/validator.rb +19 -1
- data/lib/ask/mcp/version.rb +1 -1
- data/lib/ask/mcp/x_mcp_header.rb +95 -0
- data/lib/ask/mcp.rb +15 -0
- metadata +19 -1
data/lib/ask/mcp/server/stdio.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/mcp/server.rb
CHANGED
|
@@ -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: {} },
|
|
48
|
-
|
|
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
|
|
@@ -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
|
-
@
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
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
|
data/lib/ask/mcp/validator.rb
CHANGED
|
@@ -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 =
|
|
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
|
data/lib/ask/mcp/version.rb
CHANGED