ask-mcp 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8719aff67887e47eec36f7e8b8b7c822261cbf6e1120380580747691c4d2ef2b
4
- data.tar.gz: a5c2006a33be93158838aa8fc0f0ae69c8f3a1ec2aa07fd1478cf88c50f2aa62
3
+ metadata.gz: 207c163681c8ee59bc9c869627cb6c053ca5e3824bb2e7eafd4669152c0a86cf
4
+ data.tar.gz: '029f07e479189995bb8b9aa7b4e09eb7e70308851789a4a9e05ab09a80e56568'
5
5
  SHA512:
6
- metadata.gz: d5a909234ffdc34be3ee993118ae8145b971f120516e7eef66be8c1bf95b5a9b18e767a366e0abca51f5fd1d573186e60496404afeebcc2d66cc3d371a907a74
7
- data.tar.gz: 8c058d280dd5da3aac82fcebe7aacddb3a7c5e8861375e987835294d99cfd6f4f79e12589f47b53773397267fbf7992a44f9548321b65cc6d6a108175fe800af
6
+ metadata.gz: 823681d47e782fa73574987dfdb65f7a0ef53ff68d8dfb46d3537995d1bfa04a231bac6ec53b6b4fa03f7c34276ee6181d339644b821a391dffbcc9642a42303
7
+ data.tar.gz: 4118870bcb52e14efccb0d1fec411c01af5d68e5b62111a5c6818d952004f63a8198c35f7264997f7922a41a7418fb6851a31bd3a20e182ead6fbd685011ef8f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,72 @@
1
+ ## [0.5.0] - 2026-09-17
2
+
3
+ ### Added
4
+
5
+ - **`Ask::MCP::Server::HTTP`, a Rack application serving MCP over the
6
+ stateless Streamable HTTP transport (2026-07-28)** — mount it wherever Rack
7
+ runs: `mount Ask::MCP::Server.rack_app(name: "anychat", tools: [...]) => "/mcp"`.
8
+ Every POST carries one JSON-RPC message and is answered with a single
9
+ `application/json` object; there are no sessions, no GET stream and no SSE
10
+ resumability, which is what the revision requires. The transport enforces
11
+ what the spec makes a server's job: `Origin` validation (403 for an origin
12
+ the host has not allowed), the required `MCP-Protocol-Version`, `Mcp-Method`
13
+ and `Mcp-Name` request headers validated against the body (400 with
14
+ `HeaderMismatch` -32020), the base64 sentinel form for header values that are
15
+ not plain ASCII, `UnsupportedProtocolVersionError` (-32022) listing the
16
+ versions it does support, 404 with -32601 for a method it does not implement,
17
+ 202 for a notification, and 401 with `WWW-Authenticate` when `authenticate`
18
+ rejects a caller. `tools` may be a callable of the request context, which is
19
+ what lets a host expose a different tool set per caller.
20
+
21
+ - **`Ask::MCP::Server::Core`, transport-agnostic message handling** — the
22
+ dispatch that used to live inside `Server::Stdio`, extracted so more than one
23
+ transport can share it. A Core turns one parsed message into the messages the
24
+ server wants to send and collects them in `#outbox`; `Stdio` overrides
25
+ `#deliver` to write to stdout as they are produced. Protocol state is
26
+ per-instance, so a stateless transport builds a Core per request.
27
+
28
+ - **`serverInfo` in every stateless result's `_meta`** — servers SHOULD
29
+ identify themselves on each result so a client can attribute an answer
30
+ without a separate discovery round trip.
31
+
32
+ ### Changed
33
+
34
+ - **`Ask::MCP::Server::Stdio` is now a thin subclass of `Core`** — the read
35
+ loop, the stdout writer and the `notify_*` methods remain. Wire behaviour is
36
+ unchanged; the init gate is now stated once instead of on each gated method.
37
+
38
+ - **The client negotiates stateless servers correctly** — `server/discover` is
39
+ now sent as a *modern* request, carrying `MCP-Protocol-Version` and `_meta`,
40
+ because a compliant 2026-07-28 server refuses anything else. A version the
41
+ server does not implement comes back as `UnsupportedProtocolVersionError`;
42
+ the client retries once with the best version the server advertises instead
43
+ of falling back to the `initialize` handshake the revision removed.
44
+
45
+ - **A JSON-RPC error on a non-200 response is delivered as a response, not
46
+ raised as a transport failure** — the spec has servers carry errors on 400
47
+ (header mismatch, unsupported version) and 404 (unknown method), so
48
+ `Transport::StreamableHTTP` surfaces those to the message handlers where the
49
+ pending request can pick them up. `ConnectionError` is reserved for responses
50
+ that are not JSON-RPC at all.
51
+
52
+ ### Fixed
53
+
54
+ - **A server rejection now echoes the request id** — request-level errors
55
+ (header mismatch, unsupported version) carry the id of the request that
56
+ caused them, so a client can correlate a failure with what it sent. Errors
57
+ raised before the body is understood, and the 403 for an untrusted `Origin`,
58
+ carry no id, which the spec allows.
59
+
60
+ - **A base64 `Mcp-Name` decodes as UTF-8** — base64 carries bytes and knows
61
+ nothing of encodings, so a decoded name was compared as ASCII-8BIT and never
62
+ matched the UTF-8 value in the body. The spec defines the payload as the
63
+ UTF-8 representation, so the decoded string is tagged as such.
64
+
65
+ - **A stateless request naming an unspoken revision is refused** — version
66
+ mismatches are answered with `UnsupportedProtocolVersionError` (-32022) and
67
+ the list of versions the server does speak, replacing the generic
68
+ "not initialized" the legacy gate would have produced.
69
+
1
70
  ## [0.4.5] - 2026-08-12
2
71
 
3
72
  ### Added
@@ -161,8 +161,23 @@ module Ask
161
161
  @transport.protocol_version = @protocol_version if @transport.respond_to?(:protocol_version=)
162
162
  end
163
163
 
164
+ # Probe the server as a *modern* request. A compliant 2026-07-28 server
165
+ # requires MCP-Protocol-Version and `_meta` on every request and refuses
166
+ # anything else, so the probe has to name a version before negotiation
167
+ # has happened. A version the server does not implement comes back as an
168
+ # UnsupportedProtocolVersionError naming the ones it does; retry once
169
+ # with the best of those. Answers false for a server that is not modern,
170
+ # which is the caller's cue to fall back to `initialize`.
164
171
  def discover_server
165
- response = send_request_raw("server/discover", {}, timeout: 5)
172
+ response = probe_discover(Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.last)
173
+
174
+ if unsupported_version?(response)
175
+ chosen = advertised_protocol_version(response)
176
+ return false unless chosen
177
+
178
+ response = probe_discover(chosen)
179
+ end
180
+
166
181
  return false unless response.success?
167
182
 
168
183
  result = response.result
@@ -179,15 +194,43 @@ module Ask
179
194
  false
180
195
  end
181
196
 
197
+ def probe_discover(version)
198
+ @transport.protocol_version = version if @transport.respond_to?(:protocol_version=)
199
+ request = Native::Messages::Request.new(
200
+ method: "server/discover", params: meta_params(version), id: next_id
201
+ )
202
+ wait_for_response(request, timeout: 5)
203
+ end
204
+
205
+ def unsupported_version?(response)
206
+ return false if response.success?
207
+
208
+ error_of(response)[:code] == Native::Messages::ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION
209
+ end
210
+
211
+ def advertised_protocol_version(response)
212
+ data = error_of(response)[:data]
213
+ supported = Array(data[:supported] || data["supported"])
214
+ Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.reverse.find { |version| supported.include?(version) }
215
+ end
216
+
217
+ def error_of(response)
218
+ error = response.error
219
+ { code: error[:code] || error["code"], data: error[:data] || error["data"] || {} }
220
+ end
221
+
182
222
  def reset_caches
183
223
  @tools_cache = nil
184
224
  @resources_cache = nil
185
225
  @prompts_cache = nil
186
226
  end
187
227
 
188
- def meta_params
228
+ # The `_meta` envelope every stateless request carries. `version` is
229
+ # overridable so the discover probe can name a version before any
230
+ # negotiation has happened.
231
+ def meta_params(version = @protocol_version)
189
232
  meta = {
190
- Native::Messages::Meta::PROTOCOL_VERSION_KEY => @protocol_version,
233
+ Native::Messages::Meta::PROTOCOL_VERSION_KEY => version,
191
234
  Native::Messages::Meta::CLIENT_CAPABILITIES_KEY => @options[:client_capabilities] || {},
192
235
  Native::Messages::Meta::CLIENT_INFO_KEY => { name: "ask-mcp", version: Ask::MCP::VERSION }
193
236
  }
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Ask
7
+ module MCP
8
+ class Server
9
+ # Transport-agnostic MCP request processing.
10
+ #
11
+ # A Core owns one server's tools, resources and prompts and turns a
12
+ # single parsed JSON-RPC message into the messages the server wants to
13
+ # send back. Transports differ only in how those messages reach the
14
+ # client: Stdio writes each one to $stdout as it is produced, the HTTP
15
+ # transport collects them in #outbox and renders a response.
16
+ #
17
+ # Negotiated protocol state (`@protocol_version`) lives on the instance,
18
+ # so a stateless transport builds a Core per request rather than sharing
19
+ # one across callers.
20
+ class Core
21
+ MAX_RESULT_CACHE = 100
22
+
23
+ attr_reader :name, :tools, :capabilities, :resources, :prompts,
24
+ :resource_templates, :outbox
25
+
26
+ # The protocol version a request declares in params `_meta`, or nil for
27
+ # a legacy request that expects an `initialize` handshake. Handles both
28
+ # symbol and string key forms (the JSON parser symbolizes all keys).
29
+ def self.declared_protocol_version(params)
30
+ meta = params[:meta] || params[:_meta] || {}
31
+ key = Native::Messages::Meta::PROTOCOL_VERSION_KEY
32
+ meta[key] || meta[key.to_sym] || meta[key.to_s]
33
+ end
34
+
35
+ def initialize(name:, version: nil, tools: [], capabilities: { tools: {} },
36
+ resources: {}, prompts: {}, resource_templates: {},
37
+ debug: false, tool_timeout: nil, cache_ttl_ms: 60_000,
38
+ cache_scope: "private")
39
+ @name = name
40
+ @server_version = version || Ask::MCP::VERSION
41
+ @tools = tools || []
42
+ @capabilities = capabilities
43
+ @resources = resources
44
+ @prompts = prompts
45
+ @resource_templates = resource_templates
46
+ @debug = debug
47
+ @tool_timeout = tool_timeout
48
+ @cache_ttl_ms = cache_ttl_ms
49
+ @cache_scope = cache_scope
50
+
51
+ @adapter = Adapters::ToolServer.new(@tools)
52
+ @outbox = []
53
+ @initialized = false
54
+ @result_cache = {}
55
+ # Negotiated protocol version. nil until the client tells us which
56
+ # revision it speaks (legacy `initialize` or stateless `_meta`).
57
+ @protocol_version = nil
58
+ end
59
+
60
+ # Methods held behind the legacy gate. A stateless (2026-07-28) request
61
+ # opens them by declaring its version; a legacy client opens them with
62
+ # `initialize`.
63
+ GATED_METHODS = %w[
64
+ tools/list tools/call
65
+ resources/list resources/read resources/templates/list
66
+ prompts/list prompts/get
67
+ ].freeze
68
+
69
+ # Process one JSON-RPC message (symbolized keys). Every message the
70
+ # server produces is handed to #deliver, in order.
71
+ def handle_message(msg)
72
+ method = msg[:method]
73
+ id = msg[:id]
74
+ params = msg[:params] || {}
75
+ has_id = msg.key?(:id)
76
+
77
+ # A stateless (2026-07-28) request carries its protocol version in
78
+ # `_meta` instead of an `initialize` handshake; a version this server
79
+ # does not speak is answered here and the message stops.
80
+ version = meta_protocol_version(params)
81
+ if version && !supported_version?(version)
82
+ return send_error(id, Native::Messages::ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
83
+ "Unsupported protocol version: #{version}")
84
+ end
85
+
86
+ adopt_version(version)
87
+ return send_error(id, -32_000, "Server not initialized") if gated?(method) && !@initialized
88
+
89
+ case method
90
+ when "initialize"
91
+ handle_initialize(id, params)
92
+ when "server/discover"
93
+ handle_discover(id)
94
+ when "notifications/initialized"
95
+ @initialized = true
96
+ debug_log "Client initialized"
97
+ when "tools/list"
98
+ handle_tools_list(id)
99
+ when "tools/call"
100
+ handle_tool_call(id, params)
101
+ when "resources/list"
102
+ handle_resources_list(id)
103
+ when "resources/read"
104
+ handle_resource_read(id, params)
105
+ when "resources/templates/list"
106
+ handle_resources_templates_list(id)
107
+ when "prompts/list"
108
+ handle_prompts_list(id)
109
+ when "prompts/get"
110
+ handle_prompt_get(id, params)
111
+ when "ping"
112
+ # ping was removed in 2026-07-28; legacy clients still use it.
113
+ if stateless_mode?
114
+ send_error(id, Native::Messages::ErrorCodes::METHOD_NOT_FOUND, "Method not found: ping") if has_id
115
+ elsif has_id
116
+ send_result(id, {})
117
+ end
118
+ else
119
+ debug_log "Unknown method: #{method}"
120
+ send_error(id, Native::Messages::ErrorCodes::METHOD_NOT_FOUND, "Method not found: #{method}") if has_id
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ # Hand an outbound message to the transport. The default collects into
127
+ # #outbox; Stdio overrides this to write to $stdout as messages appear.
128
+ def deliver(message)
129
+ @outbox << message
130
+ end
131
+
132
+ # Adopt the protocol version a stateless (2026-07-28) request declares
133
+ # in `_meta`; that declaration is what unlocks the handlers without the
134
+ # legacy gate. A legacy request declares nothing, so the gate stands.
135
+ def adopt_version(version)
136
+ return if version.nil?
137
+
138
+ @protocol_version = version
139
+ @initialized = true
140
+ debug_log "Stateless request (protocol #{version})"
141
+ end
142
+
143
+ # server/discover (2026-07-28): advertise supported protocol versions,
144
+ # capabilities, and identity. Clients call it before anything else to
145
+ # select a version (or as a backward-compat probe on stdio).
146
+ def handle_discover(id)
147
+ send_result(id, {
148
+ protocolVersions: Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS,
149
+ capabilities: @capabilities,
150
+ serverInfo: { name: @name, version: @server_version }
151
+ })
152
+ debug_log "server/discover answered"
153
+ end
154
+
155
+ def handle_initialize(id, params)
156
+ @initialized = true
157
+ @protocol_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
158
+ client_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
159
+ debug_log "Handling initialize (id=#{id.inspect}, version=#{client_version})"
160
+ send_result(id, {
161
+ protocolVersion: client_version,
162
+ capabilities: @capabilities,
163
+ serverInfo: {
164
+ name: @name,
165
+ version: @server_version
166
+ }
167
+ })
168
+ debug_log "Initialize complete"
169
+ end
170
+
171
+ def handle_tools_list(id)
172
+ defs = @adapter.definitions
173
+ debug_log "tools/list returning #{defs.length} tool definitions"
174
+ send_result(id, cacheable({ tools: defs }))
175
+ end
176
+
177
+ def handle_resources_list(id)
178
+ defs = @resources.values.map { |r| resource_to_h(r) }
179
+ debug_log "resources/list returning #{defs.length} resources"
180
+ send_result(id, cacheable({ resources: defs }))
181
+ end
182
+
183
+ def handle_resources_templates_list(id)
184
+ defs = @resource_templates.values.map { |t| template_to_h(t) }
185
+ debug_log "resources/templates/list returning #{defs.length} templates"
186
+ send_result(id, cacheable({ resourceTemplates: defs }))
187
+ end
188
+
189
+ def handle_resource_read(id, params)
190
+ uri = params[:uri].to_s
191
+ resource = @resources[uri]
192
+ if resource.nil?
193
+ code = stateless_mode? ? Native::Messages::ErrorCodes::INVALID_PARAMS : Native::Messages::ErrorCodes::RESOURCE_NOT_FOUND
194
+ return send_error(id, code, "Resource not found: #{uri}")
195
+ end
196
+
197
+ contents = if resource.respond_to?(:content)
198
+ resource.content
199
+ elsif resource.respond_to?(:read)
200
+ resource.read
201
+ else
202
+ [{ uri: uri, text: "" }]
203
+ end
204
+ send_result(id, cacheable({ contents: contents }))
205
+ end
206
+
207
+ def handle_prompts_list(id)
208
+ defs = @prompts.values.map { |p| prompt_to_h(p) }
209
+ debug_log "prompts/list returning #{defs.length} prompts"
210
+ send_result(id, cacheable({ prompts: defs }))
211
+ end
212
+
213
+ def handle_prompt_get(id, params)
214
+ name = params[:name].to_s
215
+ prompt = @prompts[name]
216
+ if prompt.nil?
217
+ return send_error(id, Native::Messages::ErrorCodes::PROMPT_NOT_FOUND, "Prompt not found: #{name}")
218
+ end
219
+
220
+ messages = prompt.respond_to?(:messages) ? prompt.messages : []
221
+ send_result(id, { messages: messages })
222
+ end
223
+
224
+ def handle_tool_call(id, params)
225
+ cache_key = id.to_s
226
+
227
+ # Return cached result for retried requests (same ID, already processed)
228
+ if @result_cache.key?(cache_key)
229
+ debug_log "Returning cached result for id=#{id}"
230
+ return send_result(id, @result_cache[cache_key])
231
+ end
232
+
233
+ tool_name = params[:name].to_s
234
+ arguments = params[:arguments] || {}
235
+
236
+ debug_log "Handling tools/call: #{tool_name} (id=#{id.inspect})"
237
+
238
+ result = if @tool_timeout
239
+ Timeout.timeout(@tool_timeout) { @adapter.call(tool_name, arguments) }
240
+ else
241
+ @adapter.call(tool_name, arguments)
242
+ end
243
+
244
+ @result_cache[cache_key] = result
245
+ trim_cache
246
+
247
+ send_result(id, result)
248
+ rescue Timeout::Error
249
+ debug_log "Tool call timed out: #{tool_name}"
250
+ send_result(id, {
251
+ content: [{ type: "text", text: "Tool call timed out: #{tool_name}" }],
252
+ isError: true
253
+ })
254
+ end
255
+
256
+ # Serialize a resource object for resources/list. Prefers to_h (the
257
+ # Resource value object emits title/icons/description/mimeType);
258
+ # otherwise builds the shape from duck-typed accessors.
259
+ def resource_to_h(resource)
260
+ return resource.to_h if resource.respond_to?(:to_h)
261
+
262
+ h = { uri: resource.uri, name: resource.name }
263
+ h[:title] = resource.title if resource.respond_to?(:title) && resource.title
264
+ h[:description] = resource.description if resource.respond_to?(:description) && resource.description
265
+ h[:mimeType] = resource.mime_type if resource.respond_to?(:mime_type) && resource.mime_type
266
+ h[:icons] = resource.icons if resource.respond_to?(:icons) && resource.icons&.any?
267
+ h
268
+ end
269
+
270
+ def template_to_h(template)
271
+ return template.to_h if template.respond_to?(:to_h)
272
+
273
+ h = { uriTemplate: template.uri_template, name: template.name }
274
+ h[:title] = template.title if template.respond_to?(:title) && template.title
275
+ h[:mimeType] = template.mime_type if template.respond_to?(:mime_type) && template.mime_type
276
+ h[:icons] = template.icons if template.respond_to?(:icons) && template.icons&.any?
277
+ h
278
+ end
279
+
280
+ def prompt_to_h(prompt)
281
+ return prompt.to_h if prompt.respond_to?(:to_h)
282
+
283
+ h = { name: prompt.name }
284
+ h[:title] = prompt.title if prompt.respond_to?(:title) && prompt.title
285
+ h[:description] = prompt.description if prompt.respond_to?(:description) && prompt.description
286
+ h[:arguments] = prompt.arguments if prompt.respond_to?(:arguments) && prompt.arguments&.any?
287
+ h[:icons] = prompt.icons if prompt.respond_to?(:icons) && prompt.icons&.any?
288
+ h
289
+ end
290
+
291
+ def send_result(id, result)
292
+ result = result.merge(stateless_result_fields) if stateless_mode?
293
+ deliver({ jsonrpc: "2.0", id: id, result: result })
294
+ end
295
+
296
+ # 2026-07-28: every result carries `resultType`, and servers SHOULD
297
+ # identify themselves in `_meta` so a client can attribute an answer
298
+ # without a separate discovery round trip. Legacy peers tolerate both
299
+ # fields, but we only emit them for stateless peers so the legacy wire
300
+ # output is unchanged.
301
+ def stateless_result_fields
302
+ {
303
+ resultType: "complete",
304
+ _meta: { Native::Messages::Meta::SERVER_INFO_KEY => server_info }
305
+ }
306
+ end
307
+
308
+ def server_info
309
+ { name: @name, version: @server_version }
310
+ end
311
+
312
+ # Build a server→client notification (no id).
313
+ def send_notification(method, params = {})
314
+ msg = { jsonrpc: "2.0", method: method }
315
+ msg[:params] = params unless params.empty?
316
+ deliver(msg)
317
+ end
318
+
319
+ # 2026-07-28 CacheableResult: freshness hints (ttlMs) and scope
320
+ # (public/private) on list/read results so clients and shared
321
+ # intermediaries may cache them. Only emitted for stateless peers.
322
+ def cacheable(result)
323
+ return result unless stateless_mode?
324
+
325
+ result.merge(ttlMs: @cache_ttl_ms, cacheScope: @cache_scope)
326
+ end
327
+
328
+ # True once a 2026-07-28 stateless peer has been detected.
329
+ def stateless_mode?
330
+ @protocol_version == Ask::MCP::LATEST_PROTOCOL_VERSION
331
+ end
332
+
333
+ def supported_version?(version)
334
+ Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
335
+ end
336
+
337
+ def gated?(method)
338
+ GATED_METHODS.include?(method)
339
+ end
340
+
341
+ # Read the protocol version a stateless client advertises in params
342
+ # `_meta`. Returns nil for legacy requests. Handles both symbol and
343
+ # string key forms (the JSON parser symbolizes all keys).
344
+ def meta_protocol_version(params)
345
+ meta = params[:meta] || params[:_meta] || {}
346
+ meta_value(meta, Native::Messages::Meta::PROTOCOL_VERSION_KEY)
347
+ end
348
+
349
+ def meta_value(meta, key)
350
+ meta[key] || meta[key.to_sym] || meta[key.to_s]
351
+ end
352
+
353
+ def send_error(id, code, message)
354
+ deliver({ jsonrpc: "2.0", id: id, error: { code: code, message: message } })
355
+ end
356
+
357
+ def debug_log(msg)
358
+ return unless @debug
359
+
360
+ ts = Time.now.strftime("%H:%M:%S.%L")
361
+ warn "[#{ts}] [#{@name}] #{msg}"
362
+ end
363
+
364
+ def trim_cache
365
+ return if @result_cache.size <= MAX_RESULT_CACHE
366
+
367
+ @result_cache.shift(@result_cache.size - MAX_RESULT_CACHE)
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module Ask
7
+ module MCP
8
+ class Server
9
+ # MCP server over the stateless Streamable HTTP transport (2026-07-28).
10
+ #
11
+ # A Rack application. Every JSON-RPC message is one HTTP POST to the
12
+ # endpoint and the server answers with a single application/json object.
13
+ # The 2026-07-28 revision removed protocol-level sessions, the GET
14
+ # stream endpoint and SSE resumability, so nothing here is stateful —
15
+ # each request is dispatched by a fresh Core.
16
+ #
17
+ # mount Ask::MCP::Server.rack_app(name: "anychat", tools: [...]) => "/mcp"
18
+ #
19
+ # Multi-tenant servers resolve tools per request. `authenticate`
20
+ # receives the Rack env on every POST and returns whatever the host uses
21
+ # to identify the caller; a nil return rejects the request with 401.
22
+ # `tools` then receives that value:
23
+ #
24
+ # HTTP.new(
25
+ # name: "anychat",
26
+ # authenticate: ->(env) { User.find_by_token(bearer_token(env)) },
27
+ # tools: ->(user) { user ? Tools.for(user) : [] }
28
+ # )
29
+ #
30
+ # Two spec obligations are deliberately left to the host or to a later
31
+ # revision, and are called out here so nobody assumes otherwise:
32
+ #
33
+ # * Requests that carry `Mcp-Param-{Name}` headers are checked for legal
34
+ # field values, but not matched against the call arguments. Matching
35
+ # needs the calling tool's `x-mcp-header` annotations; no tool in this
36
+ # ecosystem declares them yet, so the condition cannot arise.
37
+ # * Notifications raised while handling a request are dropped rather than
38
+ # streamed ahead of the response. This transport answers with
39
+ # application/json rather than text/event-stream, and the 2026-07-28
40
+ # channel for server-initiated changes is the opt-in
41
+ # subscriptions/listen stream, which is not implemented.
42
+ class HTTP
43
+ ERROR_CODES = Native::Messages::ErrorCodes
44
+
45
+ NAME_BEARING_METHODS = %w[tools/call resources/read prompts/get].freeze
46
+ JSON_CONTENT_TYPE = "application/json"
47
+ MAX_BODY_BYTES = 1_048_576
48
+ # Values that are not plain visible ASCII travel base64-encoded inside
49
+ # this sentinel form (2026-07-28, SEP-2243). The markers are
50
+ # case-sensitive.
51
+ BASE64_SENTINEL = "=?base64?"
52
+ BASE64_SENTINEL_END = "?="
53
+ PARAM_HEADER_PREFIX = "HTTP_MCP_PARAM_"
54
+
55
+ # @param allowed_origins [Array<String>, :any, nil] origins permitted to
56
+ # call this endpoint. nil — the default — trusts no browser origin,
57
+ # which still admits server-to-server clients because they send no
58
+ # Origin header at all.
59
+ def initialize(name:, version: nil, tools: [], capabilities: { tools: {} },
60
+ resources: {}, prompts: {}, resource_templates: {},
61
+ debug: false, tool_timeout: nil, cache_ttl_ms: 60_000,
62
+ cache_scope: "private", authenticate: nil, context: nil,
63
+ allowed_origins: nil)
64
+ @name = name
65
+ @tools = tools
66
+ @authenticate = authenticate
67
+ @context = context
68
+ @debug = debug
69
+ @allowed_origins = allowed_origins
70
+ @core_options = {
71
+ name: name,
72
+ version: version,
73
+ capabilities: capabilities,
74
+ resources: resources,
75
+ prompts: prompts,
76
+ resource_templates: resource_templates,
77
+ debug: debug,
78
+ tool_timeout: tool_timeout,
79
+ cache_ttl_ms: cache_ttl_ms,
80
+ cache_scope: cache_scope
81
+ }
82
+ end
83
+
84
+ # Rack interface.
85
+ def call(env)
86
+ return forbidden unless origin_allowed?(env)
87
+ return method_not_allowed(env) unless post?(env)
88
+
89
+ if @authenticate
90
+ context = @authenticate.call(env)
91
+ return unauthorized unless context
92
+
93
+ return dispatch(env, context)
94
+ end
95
+
96
+ dispatch(env, context_for(env))
97
+ end
98
+
99
+ private
100
+
101
+ def dispatch(env, context)
102
+ raw = read_body(env)
103
+ return error_response(400, ERROR_CODES::PARSE_ERROR, "Empty request body") if raw.strip.empty?
104
+
105
+ message = JSON.parse(raw, symbolize_names: true)
106
+ unless message.is_a?(Hash)
107
+ return error_response(400, ERROR_CODES::INVALID_REQUEST,
108
+ "Expected a single JSON-RPC object (batching is not part of this revision)")
109
+ end
110
+
111
+ # A revision this server cannot speak is a transport-level failure
112
+ # with its own error and status, so it is settled before validation.
113
+ declared_version = env["HTTP_MCP_PROTOCOL_VERSION"].to_s
114
+ if !declared_version.empty? && !Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.include?(declared_version)
115
+ return unsupported_version_response(declared_version, message[:id])
116
+ end
117
+
118
+ # The header requirements are specified for requests; a notification
119
+ # is not one, and this revision defines no client-to-server
120
+ # notifications over this transport, so notifications are accepted
121
+ # without them.
122
+ if message.key?(:id)
123
+ violation = transport_violation(env, message)
124
+ return error_response(400, ERROR_CODES::HEADER_MISMATCH, violation, id: message[:id]) if violation
125
+ end
126
+
127
+ core = build_core(context)
128
+ core.handle_message(message)
129
+ render(core.outbox)
130
+ rescue JSON::ParserError => e
131
+ error_response(400, ERROR_CODES::PARSE_ERROR, "Parse error: #{e.message}")
132
+ end
133
+
134
+ # Servers MUST validate the Origin header and refuse an Origin they do
135
+ # not trust (2026-07-28): without this, a remote page can reach a
136
+ # local MCP endpoint through DNS rebinding.
137
+ def origin_allowed?(env)
138
+ origin = env["HTTP_ORIGIN"].to_s
139
+ return true if origin.empty?
140
+ return false if @allowed_origins.nil?
141
+
142
+ @allowed_origins == :any || Array(@allowed_origins).include?(origin)
143
+ end
144
+
145
+ def transport_violation(env, message)
146
+ protocol_violation(env, message) ||
147
+ method_violation(env, message) ||
148
+ name_violation(env, message) ||
149
+ param_value_violation(env)
150
+ end
151
+
152
+ # MCP-Protocol-Version is required on every POST and must agree with
153
+ # the version the body declares in `_meta`: the body is authoritative,
154
+ # the header exists so intermediaries can route without parsing it.
155
+ # A request with no `_meta` version is a client expecting the removed
156
+ # `initialize` handshake, which this stateless endpoint cannot honour.
157
+ def protocol_violation(env, message)
158
+ declared = env["HTTP_MCP_PROTOCOL_VERSION"].to_s
159
+ return "Missing required MCP-Protocol-Version header" if declared.empty?
160
+
161
+ body_version = Core.declared_protocol_version(message[:params] || {})
162
+ if body_version.nil?
163
+ return "MCP-Protocol-Version #{declared.inspect} has no matching " \
164
+ "#{Native::Messages::Meta::PROTOCOL_VERSION_KEY} in params._meta"
165
+ end
166
+ return nil if declared == body_version
167
+
168
+ "MCP-Protocol-Version #{declared.inspect} does not match body value #{body_version.inspect}"
169
+ end
170
+
171
+ # Mcp-Method is required for all requests (2026-07-28, SEP-2243).
172
+ def method_violation(env, message)
173
+ method = message[:method].to_s
174
+ declared = env["HTTP_MCP_METHOD"].to_s
175
+ return "Missing required Mcp-Method header" if declared.empty?
176
+ return nil if declared == method
177
+
178
+ "Mcp-Method header #{declared.inspect} does not match method #{method.inspect}"
179
+ end
180
+
181
+ # Mcp-Name is required for the methods whose body carries a name.
182
+ def name_violation(env, message)
183
+ method = message[:method].to_s
184
+ return nil unless NAME_BEARING_METHODS.include?(method)
185
+
186
+ params = message[:params] || {}
187
+ expected = (params[:name] || params[:uri]).to_s
188
+ # A call missing its name is reported by the tool layer, with the
189
+ # error shape that layer already owns.
190
+ return nil if expected.empty?
191
+
192
+ declared = decode_header_value(env["HTTP_MCP_NAME"])
193
+ return "Missing required Mcp-Name header for #{method}" if declared.nil? || declared.empty?
194
+ return nil if declared == expected
195
+
196
+ "Mcp-Name header #{declared.inspect} does not match name #{expected.inspect}"
197
+ end
198
+
199
+ # Mirrored tool-parameter headers must carry legal field values, in
200
+ # base64 sentinel form when they cannot (2026-07-28, SEP-2243).
201
+ def param_value_violation(env)
202
+ env.each do |key, value|
203
+ next unless key.start_with?(PARAM_HEADER_PREFIX)
204
+ next if header_value_legal?(value)
205
+
206
+ return "#{header_name_for(key)} contains characters that require base64 encoding"
207
+ end
208
+ nil
209
+ end
210
+
211
+ def header_value_legal?(value)
212
+ return base64_encoded?(value) if value.start_with?(BASE64_SENTINEL)
213
+
214
+ value.each_char.all? do |char|
215
+ char.ord == 0x09 || char.ord.between?(0x20, 0x7e)
216
+ end
217
+ end
218
+
219
+ def base64_encoded?(value)
220
+ return false unless value.end_with?(BASE64_SENTINEL_END)
221
+
222
+ Base64.strict_decode64(sentinel_payload(value))
223
+ true
224
+ rescue ArgumentError
225
+ false
226
+ end
227
+
228
+ # Header values that are not plain visible ASCII travel base64-encoded
229
+ # in the =?base64?...?= sentinel form; servers MUST decode before
230
+ # comparing them with the body. Base64 carries bytes and knows nothing
231
+ # of encodings, and the spec defines the payload as the UTF-8
232
+ # representation, so the decoded string is tagged as such — otherwise
233
+ # it compares unequal to the same text from the body.
234
+ def decode_header_value(value)
235
+ return nil if value.nil?
236
+ return value unless value.start_with?(BASE64_SENTINEL)
237
+
238
+ Base64.strict_decode64(sentinel_payload(value)).force_encoding(Encoding::UTF_8)
239
+ rescue ArgumentError
240
+ value
241
+ end
242
+
243
+ def sentinel_payload(value)
244
+ value[BASE64_SENTINEL.length..-(BASE64_SENTINEL_END.length + 1)]
245
+ end
246
+
247
+ # Rack folds header names into HTTP_MCP_PARAM_REGION; fold it back for
248
+ # a message a human can act on.
249
+ def header_name_for(key)
250
+ key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
251
+ end
252
+
253
+ def build_core(context)
254
+ Core.new(**@core_options, tools: resolve_tools(context))
255
+ end
256
+
257
+ def resolve_tools(context)
258
+ return @tools unless @tools.respond_to?(:call)
259
+
260
+ @tools.call(context) || []
261
+ end
262
+
263
+ def context_for(env)
264
+ @context ? @context.call(env) : env
265
+ end
266
+
267
+ def render(outbox)
268
+ response = outbox.reverse.find { |message| message.key?(:id) }
269
+ dropped = outbox.count { |message| !message.key?(:id) }
270
+ debug_log "Dropped #{dropped} notification(s): no channel for them on this transport" if dropped.positive?
271
+
272
+ # A message with no id is a notification: accepted, nothing to say.
273
+ return [202, json_headers, []] if response.nil?
274
+
275
+ [status_for(response), json_headers, [JSON.generate(response)]]
276
+ end
277
+
278
+ # An RPC the server does not implement is a transport-level 404 with a
279
+ # JSON-RPC -32601 body (2026-07-28); every other outcome is 200, with
280
+ # failures inside the JSON-RPC envelope where they belong.
281
+ def status_for(response)
282
+ response.dig(:error, :code) == ERROR_CODES::METHOD_NOT_FOUND ? 404 : 200
283
+ end
284
+
285
+ def read_body(env)
286
+ input = env["rack.input"]
287
+ return "" unless input
288
+
289
+ input.read(MAX_BODY_BYTES).to_s
290
+ end
291
+
292
+ def post?(env)
293
+ env["REQUEST_METHOD"] == "POST"
294
+ end
295
+
296
+ def json_headers
297
+ { "Content-Type" => JSON_CONTENT_TYPE }
298
+ end
299
+
300
+ def method_not_allowed(env)
301
+ rpc_error(405, ERROR_CODES::METHOD_NOT_FOUND,
302
+ "Method not allowed: #{env['REQUEST_METHOD']} (this endpoint accepts POST)")
303
+ .tap { |triple| triple[1] = triple[1].merge("Allow" => "POST") }
304
+ end
305
+
306
+ def unsupported_version_response(version, id)
307
+ rpc_error(400, ERROR_CODES::UNSUPPORTED_PROTOCOL_VERSION,
308
+ "Unsupported protocol version: #{version}",
309
+ id: id, data: { supported: Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS })
310
+ end
311
+
312
+ def forbidden
313
+ rpc_error(403, ERROR_CODES::INVALID_REQUEST, "Origin not allowed")
314
+ end
315
+
316
+ def unauthorized
317
+ rpc_error(401, ERROR_CODES::AUTH_ERROR, "Unauthorized")
318
+ .tap { |triple| triple[1] = triple[1].merge("WWW-Authenticate" => "Bearer") }
319
+ end
320
+
321
+ def error_response(status, code, message, id: nil)
322
+ rpc_error(status, code, message, id: id)
323
+ end
324
+
325
+ # A rejection that the server could tie to a request echoes that
326
+ # request's id, so a JSON-RPC client can correlate the failure with
327
+ # what it sent. Failures raised before the body is understood — and the
328
+ # 403 for an untrusted Origin — carry no id, which the spec allows.
329
+ def rpc_error(status, code, message, id: nil, data: nil)
330
+ error = { code: code, message: message }
331
+ error[:data] = data if data
332
+ body = { jsonrpc: "2.0", id: id, error: error }
333
+ [status, json_headers, [JSON.generate(body)]]
334
+ end
335
+
336
+ def debug_log(message)
337
+ return unless @debug
338
+
339
+ warn "[ask-mcp] [#{@name}] #{message}"
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
@@ -1,42 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "timeout"
5
-
6
3
  module Ask
7
4
  module MCP
8
5
  class Server
9
- # MCP server over stdio transport.
10
- class Stdio
11
- MAX_RESULT_CACHE = 100
6
+ # MCP server over the stdio transport.
7
+ #
8
+ # Reads newline-delimited JSON-RPC on stdin and writes replies to
9
+ # stdout as they are produced. All message handling lives in Core;
10
+ # this class adds the read loop and the stdout writer.
11
+ class Stdio < Core
12
12
  # Deprecated: use Ask::MCP::PROTOCOL_VERSION (the canonical constant).
13
13
  PROTOCOL_VERSION = Ask::MCP::PROTOCOL_VERSION
14
14
 
15
- attr_reader :name, :tools, :capabilities, :resources, :prompts
16
-
17
- def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {},
18
- resource_templates: {}, debug: false, tool_timeout: nil,
19
- cache_ttl_ms: 60_000, cache_scope: "private", version: nil)
20
- @name = name
21
- @server_version = version || Ask::MCP::VERSION
22
- @capabilities = capabilities
23
- @resources = resources
24
- @prompts = prompts
25
- @resource_templates = resource_templates
26
- @debug = debug
27
- @tool_timeout = tool_timeout
28
- @cache_ttl_ms = cache_ttl_ms
29
- @cache_scope = cache_scope
30
-
31
- @adapter = Adapters::ToolServer.new(tools || [])
32
- @initialized = false
15
+ def initialize(**options)
16
+ super
33
17
  @running = false
34
18
  @shutdown_requested = false
35
- @result_cache = {}
36
- # Negotiated protocol version. nil until the client tells us which
37
- # revision it speaks (legacy `initialize` or stateless `_meta`).
38
- @protocol_version = nil
39
- @stateless = false
40
19
  end
41
20
 
42
21
  def start
@@ -54,6 +33,7 @@ module Ask
54
33
  while @running && !@shutdown_requested && (line = $stdin.gets)
55
34
  line = line.strip
56
35
  next if line.empty?
36
+
57
37
  process_line(line)
58
38
  end
59
39
 
@@ -95,6 +75,12 @@ module Ask
95
75
 
96
76
  private
97
77
 
78
+ # Static stdio peers have no request/response framing, so each message
79
+ # is written the moment the Core produces it.
80
+ def deliver(message)
81
+ $stdout.puts(JSON.generate(message))
82
+ end
83
+
98
84
  def graceful_shutdown
99
85
  @shutdown_requested = true
100
86
  # Close stdin to unblock $stdin.gets so the signal handler
@@ -106,269 +92,7 @@ module Ask
106
92
  msg = JSON.parse(line, symbolize_names: true)
107
93
  handle_message(msg)
108
94
  rescue JSON::ParserError => e
109
- send_error(nil, -32700, "Parse error: #{e.message}")
110
- end
111
-
112
- def handle_message(msg)
113
- method = msg[:method]
114
- id = msg[:id]
115
- params = msg[:params] || {}
116
- has_id = msg.key?(:id)
117
-
118
- # Stateless (2026-07-28) requests carry the protocol version in
119
- # `_meta` instead of an `initialize` handshake. Detecting it here
120
- # unlocks all handlers without the legacy @initialized gate.
121
- if (meta_version = meta_protocol_version(params))
122
- @protocol_version = meta_version
123
- @stateless = true
124
- @initialized = true
125
- debug_log "Stateless request (protocol #{meta_version})"
126
- end
127
-
128
- case method
129
- when "initialize"
130
- handle_initialize(id, params)
131
- when "server/discover"
132
- handle_discover(id)
133
- when "notifications/initialized"
134
- @initialized = true
135
- debug_log "Client initialized"
136
- when "tools/list"
137
- return send_error(id, -32000, "Server not initialized") unless @initialized
138
- handle_tools_list(id)
139
- when "tools/call"
140
- return send_error(id, -32000, "Server not initialized") unless @initialized
141
- handle_tool_call(id, params)
142
- when "resources/list"
143
- return send_error(id, -32000, "Server not initialized") unless @initialized
144
- handle_resources_list(id)
145
- when "resources/read"
146
- return send_error(id, -32000, "Server not initialized") unless @initialized
147
- handle_resource_read(id, params)
148
- when "resources/templates/list"
149
- return send_error(id, -32000, "Server not initialized") unless @initialized
150
- handle_resources_templates_list(id)
151
- when "prompts/list"
152
- return send_error(id, -32000, "Server not initialized") unless @initialized
153
- handle_prompts_list(id)
154
- when "prompts/get"
155
- return send_error(id, -32000, "Server not initialized") unless @initialized
156
- handle_prompt_get(id, params)
157
- when "ping"
158
- # ping was removed in 2026-07-28; legacy clients still use it.
159
- if stateless_mode?
160
- send_error(id, -32601, "Method not found: ping") if has_id
161
- else
162
- send_result(id, {}) if has_id
163
- end
164
- else
165
- debug_log "Unknown method: #{method}"
166
- send_error(id, -32601, "Method not found: #{method}") if has_id
167
- end
168
- end
169
-
170
- # server/discover (2026-07-28): advertise supported protocol versions,
171
- # capabilities, and identity. Clients call it before anything else to
172
- # select a version (or as a backward-compat probe on stdio).
173
- def handle_discover(id)
174
- send_result(id, {
175
- protocolVersions: Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS,
176
- capabilities: @capabilities,
177
- serverInfo: { name: @name, version: @server_version }
178
- })
179
- debug_log "server/discover answered"
180
- end
181
-
182
- def handle_initialize(id, params)
183
- @initialized = true
184
- @protocol_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
185
- client_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
186
- debug_log "Handling initialize (id=#{id.inspect}, version=#{client_version})"
187
- send_result(id, {
188
- protocolVersion: client_version,
189
- capabilities: @capabilities,
190
- serverInfo: {
191
- name: @name,
192
- version: @server_version
193
- }
194
- })
195
- debug_log "Initialize complete"
196
- end
197
-
198
- def handle_tools_list(id)
199
- defs = @adapter.definitions
200
- debug_log "tools/list returning #{defs.length} tool definitions"
201
- send_result(id, cacheable({ tools: defs }))
202
- end
203
-
204
- def handle_resources_list(id)
205
- defs = @resources.values.map { |r| resource_to_h(r) }
206
- debug_log "resources/list returning #{defs.length} resources"
207
- send_result(id, cacheable({ resources: defs }))
208
- end
209
-
210
- def handle_resources_templates_list(id)
211
- defs = @resource_templates.values.map { |t| template_to_h(t) }
212
- debug_log "resources/templates/list returning #{defs.length} templates"
213
- send_result(id, cacheable({ resourceTemplates: defs }))
214
- end
215
-
216
- def handle_resource_read(id, params)
217
- uri = params[:uri].to_s
218
- resource = @resources[uri]
219
- if resource.nil?
220
- code = stateless_mode? ? -32_602 : Native::Messages::ErrorCodes::RESOURCE_NOT_FOUND
221
- return send_error(id, code, "Resource not found: #{uri}")
222
- end
223
-
224
- contents = if resource.respond_to?(:content)
225
- resource.content
226
- elsif resource.respond_to?(:read)
227
- resource.read
228
- else
229
- [{ uri: uri, text: "" }]
230
- end
231
- send_result(id, cacheable({ contents: contents }))
232
- end
233
-
234
- def handle_prompts_list(id)
235
- defs = @prompts.values.map { |p| prompt_to_h(p) }
236
- debug_log "prompts/list returning #{defs.length} prompts"
237
- send_result(id, cacheable({ prompts: defs }))
238
- end
239
-
240
- def handle_prompt_get(id, params)
241
- name = params[:name].to_s
242
- prompt = @prompts[name]
243
- if prompt.nil?
244
- return send_error(id, Native::Messages::ErrorCodes::PROMPT_NOT_FOUND, "Prompt not found: #{name}")
245
- end
246
-
247
- messages = prompt.respond_to?(:messages) ? prompt.messages : []
248
- send_result(id, { messages: messages })
249
- end
250
-
251
- def handle_tool_call(id, params)
252
- cache_key = id.to_s
253
-
254
- # Return cached result for retried requests (same ID, already processed)
255
- if @result_cache.key?(cache_key)
256
- debug_log "Returning cached result for id=#{id}"
257
- return send_result(id, @result_cache[cache_key])
258
- end
259
-
260
- tool_name = params[:name].to_s
261
- arguments = params[:arguments] || {}
262
-
263
- debug_log "Handling tools/call: #{tool_name} (id=#{id.inspect})"
264
-
265
- result = if @tool_timeout
266
- Timeout.timeout(@tool_timeout) { @adapter.call(tool_name, arguments) }
267
- else
268
- @adapter.call(tool_name, arguments)
269
- end
270
-
271
- @result_cache[cache_key] = result
272
- trim_cache
273
-
274
- send_result(id, result)
275
- rescue Timeout::Error
276
- debug_log "Tool call timed out: #{tool_name}"
277
- send_result(id, {
278
- content: [{ type: "text", text: "Tool call timed out: #{tool_name}" }],
279
- isError: true
280
- })
281
- end
282
-
283
- # Serialize a resource object for resources/list. Prefers to_h (the
284
- # Resource value object emits title/icons/description/mimeType);
285
- # otherwise builds the shape from duck-typed accessors.
286
- def resource_to_h(resource)
287
- return resource.to_h if resource.respond_to?(:to_h)
288
-
289
- h = { uri: resource.uri, name: resource.name }
290
- h[:title] = resource.title if resource.respond_to?(:title) && resource.title
291
- h[:description] = resource.description if resource.respond_to?(:description) && resource.description
292
- h[:mimeType] = resource.mime_type if resource.respond_to?(:mime_type) && resource.mime_type
293
- h[:icons] = resource.icons if resource.respond_to?(:icons) && resource.icons&.any?
294
- h
295
- end
296
-
297
- def template_to_h(template)
298
- return template.to_h if template.respond_to?(:to_h)
299
-
300
- h = { uriTemplate: template.uri_template, name: template.name }
301
- h[:title] = template.title if template.respond_to?(:title) && template.title
302
- h[:mimeType] = template.mime_type if template.respond_to?(:mime_type) && template.mime_type
303
- h[:icons] = template.icons if template.respond_to?(:icons) && template.icons&.any?
304
- h
305
- end
306
-
307
- def prompt_to_h(prompt)
308
- return prompt.to_h if prompt.respond_to?(:to_h)
309
-
310
- h = { name: prompt.name }
311
- h[:title] = prompt.title if prompt.respond_to?(:title) && prompt.title
312
- h[:description] = prompt.description if prompt.respond_to?(:description) && prompt.description
313
- h[:arguments] = prompt.arguments if prompt.respond_to?(:arguments) && prompt.arguments&.any?
314
- h[:icons] = prompt.icons if prompt.respond_to?(:icons) && prompt.icons&.any?
315
- h
316
- end
317
-
318
- def send_result(id, result)
319
- # 2026-07-28: all results carry `resultType`. Legacy peers tolerate
320
- # the field, but we only add it for stateless peers to keep the
321
- # legacy wire output unchanged.
322
- result = result.merge(resultType: "complete") if stateless_mode?
323
- $stdout.puts({ jsonrpc: "2.0", id: id, result: result }.to_json)
324
- end
325
-
326
- # Write a server→client notification (no id). stdout is sync'd in
327
- # #start, so this is safe to call from any thread.
328
- def send_notification(method, params = {})
329
- msg = { jsonrpc: "2.0", method: method }
330
- msg[:params] = params unless params.empty?
331
- $stdout.puts(msg.to_json)
332
- end
333
-
334
- # 2026-07-28 CacheableResult: freshness hints (ttlMs) and scope
335
- # (public/private) on list/read results so clients and shared
336
- # intermediaries may cache them. Only emitted for stateless peers.
337
- def cacheable(result)
338
- return result unless stateless_mode?
339
- result.merge(ttlMs: @cache_ttl_ms, cacheScope: @cache_scope)
340
- end
341
-
342
- # True once a 2026-07-28 stateless peer has been detected.
343
- def stateless_mode?
344
- @protocol_version == Ask::MCP::LATEST_PROTOCOL_VERSION
345
- end
346
-
347
- # Read the protocol version a stateless client advertises in params
348
- # `_meta`. Returns nil for legacy requests. Handles both symbol and
349
- # string key forms (the JSON parser symbolizes all keys).
350
- def meta_protocol_version(params)
351
- meta = params[:meta] || params[:_meta] || {}
352
- meta_value(meta, Native::Messages::Meta::PROTOCOL_VERSION_KEY)
353
- end
354
-
355
- def meta_value(meta, key)
356
- meta[key] || meta[key.to_sym] || meta[key.to_s]
357
- end
358
-
359
- def send_error(id, code, message)
360
- $stdout.puts({ jsonrpc: "2.0", id: id, error: { code: code, message: message } }.to_json)
361
- end
362
-
363
- def debug_log(msg)
364
- return unless @debug
365
- ts = Time.now.strftime("%H:%M:%S.%L")
366
- $stderr.puts "[#{ts}] [ask-mcp] #{msg}"
367
- end
368
-
369
- def trim_cache
370
- return if @result_cache.size <= MAX_RESULT_CACHE
371
- @result_cache.shift(@result_cache.size - MAX_RESULT_CACHE)
95
+ send_error(nil, Native::Messages::ErrorCodes::PARSE_ERROR, "Parse error: #{e.message}")
372
96
  end
373
97
  end
374
98
  end
@@ -54,9 +54,27 @@ module Ask
54
54
  resources: resources, prompts: prompts,
55
55
  resource_templates: resource_templates, debug: debug).start
56
56
  end
57
+
58
+ # Build a Rack application serving MCP over the stateless Streamable
59
+ # HTTP transport (2026-07-28). Mount it wherever Rack runs:
60
+ #
61
+ # mount Ask::MCP::Server.rack_app(name: "anychat", tools: [...]) => "/mcp"
62
+ #
63
+ # Accepts the same tool/resource/prompt options as .start_stdio, plus:
64
+ #
65
+ # @param authenticate [#call, nil] receives the Rack env per request and
66
+ # returns the caller's identity; a nil return answers 401
67
+ # @param context [#call, nil] derives the value passed to a callable
68
+ # `tools` when no `authenticate` is given (defaults to the Rack env)
69
+ # @return [Server::HTTP] a Rack application
70
+ def self.rack_app(**options)
71
+ HTTP.new(**options)
72
+ end
57
73
  end
58
74
  end
59
75
  end
60
76
 
61
77
  # Load Server subclasses after the Server class is defined
78
+ require_relative "server/core"
62
79
  require_relative "server/stdio"
80
+ require_relative "server/http"
@@ -138,26 +138,38 @@ module Ask
138
138
 
139
139
  def handle_response(response)
140
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
141
+ # Notification accepted, no body.
142
+ return response if status == 202
148
143
 
149
144
  content_type = response.headers["content-type"].to_s
150
- if content_type.include?("text/event-stream")
145
+ if status == 200 && content_type.include?("text/event-stream")
151
146
  read_sse_stream(response)
152
- else
153
- body = response.body.to_s
154
- if body && !body.empty?
155
- message = Native::Messages::Parser.parse(body)
156
- @message_handlers.each { |handler| handler.call(message) }
157
- end
147
+ return response
148
+ end
149
+
150
+ body = response.body.to_s
151
+ # A server may carry a JSON-RPC error on a non-200 status — 400 for a
152
+ # header mismatch or an unsupported protocol version, 404 for a
153
+ # method it does not implement. That is a response, not a transport
154
+ # failure, so it goes to the message handlers where the pending
155
+ # request can pick it up.
156
+ message = parse_message(body)
157
+ if message
158
+ @message_handlers.each { |handler| handler.call(message) }
159
+ return response
158
160
  end
159
161
 
160
- response
162
+ return response if status == 200
163
+
164
+ raise ConnectionError, "HTTP #{status}: #{body[0..200]}"
165
+ end
166
+
167
+ def parse_message(body)
168
+ return nil if body.empty?
169
+
170
+ Native::Messages::Parser.parse(body)
171
+ rescue JSON::ParserError
172
+ nil
161
173
  end
162
174
 
163
175
  # Read an SSE stream, delivering each `data:` payload as a parsed
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module MCP
3
- VERSION = "0.4.6"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.6
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -119,6 +119,8 @@ files:
119
119
  - lib/ask/mcp/prompt.rb
120
120
  - lib/ask/mcp/resource.rb
121
121
  - lib/ask/mcp/server.rb
122
+ - lib/ask/mcp/server/core.rb
123
+ - lib/ask/mcp/server/http.rb
122
124
  - lib/ask/mcp/server/stdio.rb
123
125
  - lib/ask/mcp/tool.rb
124
126
  - lib/ask/mcp/trace_context.rb