ask-mcp 0.4.6 → 0.6.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 +78 -0
- data/lib/ask/mcp/auth/connect.rb +241 -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 +3 -1
- data/lib/ask/mcp.rb +1 -0
- metadata +4 -1
|
@@ -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
|