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 +4 -4
- data/CHANGELOG.md +69 -0
- data/lib/ask/mcp/client.rb +46 -3
- data/lib/ask/mcp/server/core.rb +372 -0
- data/lib/ask/mcp/server/http.rb +344 -0
- data/lib/ask/mcp/server/stdio.rb +16 -292
- data/lib/ask/mcp/server.rb +18 -0
- data/lib/ask/mcp/transport/streamable_http.rb +27 -15
- data/lib/ask/mcp/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 207c163681c8ee59bc9c869627cb6c053ca5e3824bb2e7eafd4669152c0a86cf
|
|
4
|
+
data.tar.gz: '029f07e479189995bb8b9aa7b4e09eb7e70308851789a4a9e05ab09a80e56568'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/mcp/client.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =>
|
|
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
|
data/lib/ask/mcp/server/stdio.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/ask/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
data/lib/ask/mcp/version.rb
CHANGED
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
|
+
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
|