ask-mcp 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +105 -0
- data/README.md +104 -175
- data/docs/SPEC_COMPLIANCE.md +184 -0
- data/lib/ask/mcp/adapters/tool_server.rb +8 -1
- data/lib/ask/mcp/auth/client_id_metadata_document.rb +74 -0
- data/lib/ask/mcp/auth/oauth.rb +52 -4
- data/lib/ask/mcp/client.rb +237 -23
- data/lib/ask/mcp/native/messages.rb +23 -2
- data/lib/ask/mcp/prompt.rb +9 -3
- data/lib/ask/mcp/resource.rb +9 -3
- data/lib/ask/mcp/server/stdio.rb +194 -5
- data/lib/ask/mcp/server.rb +7 -2
- data/lib/ask/mcp/tool.rb +11 -4
- data/lib/ask/mcp/trace_context.rb +39 -0
- data/lib/ask/mcp/transport/sse.rb +5 -1
- data/lib/ask/mcp/transport/stdio.rb +1 -1
- data/lib/ask/mcp/transport/streamable_http.rb +108 -26
- data/lib/ask/mcp/validator.rb +19 -1
- data/lib/ask/mcp/version.rb +1 -1
- data/lib/ask/mcp/x_mcp_header.rb +95 -0
- data/lib/ask/mcp.rb +15 -0
- metadata +19 -1
|
@@ -23,11 +23,18 @@ module Ask
|
|
|
23
23
|
def definitions
|
|
24
24
|
@tools.map do |tool|
|
|
25
25
|
schema = tool.params_schema || { type: "object", properties: {}, required: [] }
|
|
26
|
-
{
|
|
26
|
+
defn = {
|
|
27
27
|
name: tool.name,
|
|
28
28
|
description: tool.description || "",
|
|
29
29
|
inputSchema: schema
|
|
30
30
|
}
|
|
31
|
+
# 2025-11-25: optional display metadata (SEP-973 icons, title).
|
|
32
|
+
# Only included when the tool object provides them.
|
|
33
|
+
defn[:title] = tool.title if tool.respond_to?(:title) && tool.title
|
|
34
|
+
if tool.respond_to?(:icons) && tool.icons&.any?
|
|
35
|
+
defn[:icons] = tool.icons
|
|
36
|
+
end
|
|
37
|
+
defn
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module MCP
|
|
7
|
+
module Auth
|
|
8
|
+
# OAuth Client ID Metadata Documents (2026-07-28, SEP-991) — the
|
|
9
|
+
# recommended client registration mechanism, replacing the deprecated
|
|
10
|
+
# Dynamic Client Registration Protocol (RFC 7591).
|
|
11
|
+
#
|
|
12
|
+
# A client_id is an HTTPS URL pointing to a JSON document describing the
|
|
13
|
+
# client; the authorization server fetches and validates it on demand.
|
|
14
|
+
# Because the document is self-hosted, URL-based client IDs are portable
|
|
15
|
+
# across authorization servers — no re-registration needed when the
|
|
16
|
+
# server changes.
|
|
17
|
+
module ClientIdMetadataDocument
|
|
18
|
+
# Fields every metadata document MUST include.
|
|
19
|
+
REQUIRED_FIELDS = %w[client_id client_name redirect_uris].freeze
|
|
20
|
+
# OIDC application types (native = desktop/mobile/CLI/localhost web).
|
|
21
|
+
APPLICATION_TYPES = %w[native web].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Build a metadata document hash. `client_id` must be an https URL
|
|
26
|
+
# with a path component; `redirect_uris` must be a non-empty array.
|
|
27
|
+
# Extra keyword args are included verbatim (string keys).
|
|
28
|
+
def build(client_id:, client_name:, redirect_uris:, application_type: "native", **extra)
|
|
29
|
+
{
|
|
30
|
+
"client_id" => client_id,
|
|
31
|
+
"client_name" => client_name,
|
|
32
|
+
"redirect_uris" => redirect_uris,
|
|
33
|
+
"application_type" => application_type
|
|
34
|
+
}.merge(extra.transform_keys(&:to_s))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether the client_id has the required URL form: https scheme with
|
|
38
|
+
# a path component (e.g. https://example.com/client.json).
|
|
39
|
+
def valid_client_id_url?(client_id)
|
|
40
|
+
uri = URI.parse(client_id.to_s)
|
|
41
|
+
uri.scheme == "https" && !uri.path.to_s.empty? && uri.path != "/"
|
|
42
|
+
rescue URI::InvalidURIError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Validate a fetched document. Returns nil when valid, or a reason
|
|
47
|
+
# string. When `document_url` is given, the document's client_id MUST
|
|
48
|
+
# match it exactly.
|
|
49
|
+
def invalid_reason(document, document_url: nil)
|
|
50
|
+
return "metadata document must be a JSON object" unless document.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
missing = REQUIRED_FIELDS.reject { |f| document[f] || document[f.to_sym] }
|
|
53
|
+
return "missing required fields: #{missing.join(', ')}" unless missing.empty?
|
|
54
|
+
|
|
55
|
+
client_id = (document["client_id"] || document[:client_id]).to_s
|
|
56
|
+
if document_url && client_id != document_url
|
|
57
|
+
return "client_id #{client_id.inspect} does not match document URL #{document_url.inspect}"
|
|
58
|
+
end
|
|
59
|
+
return "client_id must be an https URL with a path" unless valid_client_id_url?(client_id)
|
|
60
|
+
|
|
61
|
+
redirect_uris = document["redirect_uris"] || document[:redirect_uris]
|
|
62
|
+
return "redirect_uris must be a non-empty array" unless redirect_uris.is_a?(Array) && !redirect_uris.empty?
|
|
63
|
+
|
|
64
|
+
app_type = document["application_type"] || document[:application_type]
|
|
65
|
+
if app_type && !APPLICATION_TYPES.include?(app_type.to_s)
|
|
66
|
+
return "invalid application_type #{app_type.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/ask/mcp/auth/oauth.rb
CHANGED
|
@@ -4,16 +4,18 @@ module Ask
|
|
|
4
4
|
module MCP
|
|
5
5
|
module Auth
|
|
6
6
|
class OAuth
|
|
7
|
-
attr_reader :client_id, :client_secret, :token_url, :auth_url
|
|
7
|
+
attr_reader :client_id, :client_secret, :token_url, :auth_url, :issuer
|
|
8
8
|
|
|
9
|
-
def initialize(client_id:, client_secret: nil, token_url
|
|
10
|
-
redirect_uri: nil, scopes: [])
|
|
9
|
+
def initialize(client_id:, client_secret: nil, token_url: nil, auth_url: nil,
|
|
10
|
+
redirect_uri: nil, scopes: [], issuer: nil, discovery_url: nil)
|
|
11
11
|
@client_id = client_id
|
|
12
12
|
@client_secret = client_secret
|
|
13
13
|
@token_url = token_url
|
|
14
14
|
@auth_url = auth_url
|
|
15
15
|
@redirect_uri = redirect_uri
|
|
16
16
|
@scopes = scopes
|
|
17
|
+
@issuer = issuer
|
|
18
|
+
@discovery_url = discovery_url
|
|
17
19
|
@access_token = nil
|
|
18
20
|
@refresh_token = nil
|
|
19
21
|
@expires_at = nil
|
|
@@ -28,10 +30,12 @@ module Ask
|
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def authenticate!
|
|
31
|
-
if @client_secret
|
|
33
|
+
if @client_secret && @token_url
|
|
32
34
|
authenticate_client_credentials
|
|
33
35
|
elsif @auth_url
|
|
34
36
|
authenticate_authorization_code
|
|
37
|
+
elsif @issuer || @discovery_url
|
|
38
|
+
raise AuthError, "Call #discover! before #authenticate! to resolve endpoints"
|
|
35
39
|
else
|
|
36
40
|
raise AuthError, "No authentication method available"
|
|
37
41
|
end
|
|
@@ -44,8 +48,52 @@ module Ask
|
|
|
44
48
|
self
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
# Validate an `iss` parameter from an authorization response
|
|
52
|
+
# (RFC 9207, 2026-07-28): when an issuer is recorded (via
|
|
53
|
+
# discovery or configuration), a present `iss` MUST match it.
|
|
54
|
+
# Call before redeeming an authorization code.
|
|
55
|
+
def validate_iss!(iss)
|
|
56
|
+
return self if @issuer.nil?
|
|
57
|
+
raise AuthError, "iss mismatch: expected #{@issuer}, got #{iss.inspect}" unless iss.to_s == @issuer.to_s
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Discover authorization server endpoints via OpenID Connect Discovery
|
|
62
|
+
# 1.0 (2025-11-25, SEP-797). Fetches the document at discovery_url (or
|
|
63
|
+
# the well-known URL derived from issuer per RFC 8414) and populates
|
|
64
|
+
# token_url, auth_url, and issuer. Returns self.
|
|
65
|
+
def discover!(discovery_url: nil)
|
|
66
|
+
require "httpx"
|
|
67
|
+
|
|
68
|
+
url = discovery_url || @discovery_url || well_known_discovery_url
|
|
69
|
+
data = fetch_json(HTTPX, url)
|
|
70
|
+
|
|
71
|
+
@issuer = data[:issuer] if data[:issuer]
|
|
72
|
+
@token_url = data[:token_endpoint] if data[:token_endpoint]
|
|
73
|
+
@auth_url = data[:authorization_endpoint] if data[:authorization_endpoint]
|
|
74
|
+
|
|
75
|
+
raise AuthError, "Discovery document has no token_endpoint" unless @token_url
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
47
79
|
private
|
|
48
80
|
|
|
81
|
+
def well_known_discovery_url
|
|
82
|
+
return @discovery_url if @discovery_url
|
|
83
|
+
raise AuthError, "OIDC discovery requires an issuer or discovery_url" unless @issuer
|
|
84
|
+
"#{@issuer.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fetch_json(http, url)
|
|
88
|
+
response = http.get(url)
|
|
89
|
+
unless response.status == 200
|
|
90
|
+
raise AuthError, "Discovery request failed: #{response.status} #{response.body.to_s[0..200]}"
|
|
91
|
+
end
|
|
92
|
+
JSON.parse(response.body.to_s, symbolize_names: true)
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
raise AuthError, "Invalid discovery document: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
49
97
|
def expired?
|
|
50
98
|
@expires_at && Time.now >= @expires_at
|
|
51
99
|
end
|
data/lib/ask/mcp/client.rb
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module MCP
|
|
5
5
|
class Client
|
|
6
|
-
PROTOCOL_VERSION
|
|
6
|
+
# Deprecated: use Ask::MCP::PROTOCOL_VERSION (the canonical constant).
|
|
7
|
+
# Kept as an alias so existing consumers don't break.
|
|
8
|
+
PROTOCOL_VERSION = Ask::MCP::PROTOCOL_VERSION
|
|
7
9
|
|
|
8
10
|
attr_reader :transport, :capabilities, :server_info
|
|
9
11
|
|
|
@@ -18,14 +20,18 @@ module Ask
|
|
|
18
20
|
@pending_requests = {}
|
|
19
21
|
@pending_mutex = Mutex.new
|
|
20
22
|
@pending_condition = ConditionVariable.new
|
|
23
|
+
@request_handlers = {}
|
|
21
24
|
@next_id = 0
|
|
22
25
|
@initialized = false
|
|
26
|
+
# Negotiated protocol version; nil until start() resolves it.
|
|
27
|
+
@protocol_version = nil
|
|
28
|
+
@stateless = false
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
def start
|
|
26
32
|
@transport.on_message { |message| handle_message(message) }
|
|
27
33
|
@transport.start
|
|
28
|
-
|
|
34
|
+
negotiate_protocol
|
|
29
35
|
self
|
|
30
36
|
end
|
|
31
37
|
|
|
@@ -39,6 +45,7 @@ module Ask
|
|
|
39
45
|
|
|
40
46
|
response = send_request("tools/list")
|
|
41
47
|
tools = (response[:tools] || []).map { |t| Tool.from_h(t) }
|
|
48
|
+
tools = reject_invalid_mcp_header_tools(tools)
|
|
42
49
|
@tools_cache = index_by_name(tools)
|
|
43
50
|
end
|
|
44
51
|
|
|
@@ -65,17 +72,18 @@ module Ask
|
|
|
65
72
|
Validator.new(tool.input_schema).validate!(arguments)
|
|
66
73
|
end
|
|
67
74
|
end
|
|
68
|
-
|
|
75
|
+
headers = mcp_param_headers(name, arguments)
|
|
76
|
+
response = send_request("tools/call", { name: name, arguments: arguments }, headers: headers)
|
|
69
77
|
response[:content] || response
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
def read_resource(uri)
|
|
73
|
-
response = send_request("resources/read", uri: uri)
|
|
81
|
+
response = send_request("resources/read", { uri: uri })
|
|
74
82
|
response[:contents] || response
|
|
75
83
|
end
|
|
76
84
|
|
|
77
85
|
def get_prompt(name, arguments = {})
|
|
78
|
-
response = send_request("prompts/get", name: name, arguments: arguments)
|
|
86
|
+
response = send_request("prompts/get", { name: name, arguments: arguments })
|
|
79
87
|
response[:messages] || response
|
|
80
88
|
end
|
|
81
89
|
|
|
@@ -83,8 +91,149 @@ module Ask
|
|
|
83
91
|
@initialized
|
|
84
92
|
end
|
|
85
93
|
|
|
94
|
+
# Register a handler for a server-initiated request (a JSON-RPC Request
|
|
95
|
+
# the server sends to the client, such as elicitation/create or
|
|
96
|
+
# sampling/createMessage). The handler receives the request params and
|
|
97
|
+
# must return the result hash to send back. The handler runs on the
|
|
98
|
+
# transport reader thread, so long-blocking handlers block message
|
|
99
|
+
# processing — return promptly.
|
|
100
|
+
def on_request(method, &handler)
|
|
101
|
+
@request_handlers[method] = handler
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Handle a server request for user input (client/elicitation, 2025-06-18+).
|
|
105
|
+
# The handler receives the elicitation params (including the Elicitation
|
|
106
|
+
# schema with titled/untitled, single/multi-select enums and default
|
|
107
|
+
# values in 2025-11-25) and returns an ElicitationResponse hash, e.g.
|
|
108
|
+
# { message: "42" }
|
|
109
|
+
# or structured content. Declare client support via
|
|
110
|
+
# Ask::MCP::Client.new(transport, client_capabilities: { elicitation: {} })
|
|
111
|
+
def on_elicitation(&handler)
|
|
112
|
+
on_request("elicitation/create", &handler)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Handle a sampling request (client/sampling). The handler receives the
|
|
116
|
+
# CreateMessageRequest params — including `tools` and `toolChoice` when
|
|
117
|
+
# the server offers tool calling (2025-11-25, SEP-1577) — and returns a
|
|
118
|
+
# CreateMessageResult hash, e.g.
|
|
119
|
+
# { role: "assistant", content: { type: "text", text: "..." } }
|
|
120
|
+
# Declare client support via
|
|
121
|
+
# Ask::MCP::Client.new(transport, client_capabilities: { sampling: {} })
|
|
122
|
+
def on_sampling(&handler)
|
|
123
|
+
on_request("sampling/createMessage", &handler)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Open a long-lived notification stream (2026-07-28 subscriptions/listen).
|
|
127
|
+
# `notifications` is a filter Hash, e.g.
|
|
128
|
+
# { toolsListChanged: true, resourceSubscriptions: ["file:///x"] }
|
|
129
|
+
# Notifications flow into the client's message handling (which resets
|
|
130
|
+
# caches on list_changed). Only supported on transports that implement
|
|
131
|
+
# #listen (StreamableHTTP).
|
|
132
|
+
def listen(notifications)
|
|
133
|
+
@transport.listen(notifications)
|
|
134
|
+
end
|
|
135
|
+
|
|
86
136
|
private
|
|
87
137
|
|
|
138
|
+
# Resolve the protocol version to speak with the server (2026-07-28
|
|
139
|
+
# stateless negotiation). Tries `server/discover` first: a server that
|
|
140
|
+
# answers with a supported version list we intersect goes stateless
|
|
141
|
+
# (no initialize handshake); anything else falls back to the legacy
|
|
142
|
+
# `initialize` handshake.
|
|
143
|
+
def negotiate_protocol
|
|
144
|
+
if discover_server
|
|
145
|
+
if @stateless
|
|
146
|
+
# 2026-07-28 stateless peer: no initialize handshake.
|
|
147
|
+
@initialized = true
|
|
148
|
+
reset_caches
|
|
149
|
+
debug "Stateless mode (protocol #{@protocol_version})"
|
|
150
|
+
else
|
|
151
|
+
# Server is a legacy revision (≤ 2025-11-25): it still requires
|
|
152
|
+
# the initialize handshake. discover already filled in server_info
|
|
153
|
+
# and capabilities; initialize refines them.
|
|
154
|
+
initialize_session
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
initialize_session
|
|
158
|
+
end
|
|
159
|
+
# Streamable HTTP mirrors the negotiated version into the
|
|
160
|
+
# MCP-Protocol-Version header on every POST.
|
|
161
|
+
@transport.protocol_version = @protocol_version if @transport.respond_to?(:protocol_version=)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def discover_server
|
|
165
|
+
response = send_request_raw("server/discover", {}, timeout: 5)
|
|
166
|
+
return false unless response.success?
|
|
167
|
+
|
|
168
|
+
result = response.result
|
|
169
|
+
supported = result[:protocolVersions] || result["protocolVersions"] || []
|
|
170
|
+
chosen = Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.reverse.find { |v| supported.include?(v) }
|
|
171
|
+
return false unless chosen
|
|
172
|
+
|
|
173
|
+
@protocol_version = chosen
|
|
174
|
+
@stateless = chosen == Ask::MCP::LATEST_PROTOCOL_VERSION
|
|
175
|
+
@server_info = result[:serverInfo] || result["serverInfo"] || {}
|
|
176
|
+
@capabilities = result[:capabilities] || result["capabilities"] || {}
|
|
177
|
+
true
|
|
178
|
+
rescue StandardError
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def reset_caches
|
|
183
|
+
@tools_cache = nil
|
|
184
|
+
@resources_cache = nil
|
|
185
|
+
@prompts_cache = nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def meta_params
|
|
189
|
+
meta = {
|
|
190
|
+
Native::Messages::Meta::PROTOCOL_VERSION_KEY => @protocol_version,
|
|
191
|
+
Native::Messages::Meta::CLIENT_CAPABILITIES_KEY => @options[:client_capabilities] || {},
|
|
192
|
+
Native::Messages::Meta::CLIENT_INFO_KEY => { name: "ask-mcp", version: Ask::MCP::VERSION }
|
|
193
|
+
}
|
|
194
|
+
# Optional extra `_meta` fields — e.g. OpenTelemetry trace context
|
|
195
|
+
# (traceparent/tracestate/baggage) via
|
|
196
|
+
# Client.new(transport, meta: TraceContext.from_headers(rack_env))
|
|
197
|
+
meta.merge!(@options[:meta]) if @options[:meta]
|
|
198
|
+
{ _meta: meta }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Mirror x-mcp-header-annotated tool parameters into Mcp-Param-{Name}
|
|
202
|
+
# HTTP headers (2026-07-28, SEP-2243). Only applies on transports that
|
|
203
|
+
# support it (StreamableHTTP). Values are encoded per the spec; integer
|
|
204
|
+
# values outside the JavaScript safe range cannot be represented safely
|
|
205
|
+
# and their headers are omitted.
|
|
206
|
+
def mcp_param_headers(tool_name, arguments)
|
|
207
|
+
return {} unless @transport.is_a?(Transport::StreamableHTTP)
|
|
208
|
+
tool = @tools_cache && @tools_cache[tool_name]
|
|
209
|
+
return {} unless tool && tool.input_schema
|
|
210
|
+
|
|
211
|
+
props = tool.input_schema[:properties] || tool.input_schema["properties"] || {}
|
|
212
|
+
props.each_with_object({}) do |(prop_name, prop_schema), headers|
|
|
213
|
+
header_name = prop_schema[:'x-mcp-header'] || prop_schema["x-mcp-header"]
|
|
214
|
+
next unless header_name
|
|
215
|
+
value = arguments[prop_name] || arguments[prop_name.to_s]
|
|
216
|
+
next if value.nil?
|
|
217
|
+
next if value.is_a?(Integer) && !XMcpHeader.safe_integer?(value)
|
|
218
|
+
headers["Mcp-Param-#{header_name}"] = @transport.encode_header_value(value)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Streamable HTTP clients MUST reject tool definitions with invalid
|
|
223
|
+
# x-mcp-header annotations, excluding the tool from tools/list
|
|
224
|
+
# (2026-07-28, SEP-2243). Other transports ignore the annotations.
|
|
225
|
+
def reject_invalid_mcp_header_tools(tools)
|
|
226
|
+
return tools unless @transport.is_a?(Transport::StreamableHTTP)
|
|
227
|
+
|
|
228
|
+
tools.reject do |tool|
|
|
229
|
+
reason = XMcpHeader.invalid_reason(tool.input_schema)
|
|
230
|
+
next false unless reason
|
|
231
|
+
|
|
232
|
+
warn "[ask-mcp][client] rejecting tool #{tool.name} from tools/list: #{reason}"
|
|
233
|
+
true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
88
237
|
def next_id
|
|
89
238
|
@pending_mutex.synchronize do
|
|
90
239
|
@next_id += 1
|
|
@@ -93,7 +242,7 @@ module Ask
|
|
|
93
242
|
|
|
94
243
|
def initialize_session
|
|
95
244
|
response = send_request_raw("initialize", {
|
|
96
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
245
|
+
protocolVersion: @protocol_version || Ask::MCP::PROTOCOL_VERSION,
|
|
97
246
|
capabilities: @options[:client_capabilities] || {},
|
|
98
247
|
clientInfo: {
|
|
99
248
|
name: "ask-mcp",
|
|
@@ -108,24 +257,28 @@ module Ask
|
|
|
108
257
|
result = response.result
|
|
109
258
|
@server_info = result[:serverInfo] || {}
|
|
110
259
|
@capabilities = result[:capabilities] || {}
|
|
260
|
+
@protocol_version = result[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
|
|
111
261
|
|
|
112
262
|
send_notification("notifications/initialized")
|
|
113
263
|
@initialized = true
|
|
114
264
|
|
|
115
|
-
|
|
116
|
-
@resources_cache = nil
|
|
117
|
-
@prompts_cache = nil
|
|
265
|
+
reset_caches
|
|
118
266
|
end
|
|
119
267
|
|
|
120
|
-
def
|
|
121
|
-
|
|
268
|
+
def debug(msg)
|
|
269
|
+
warn "[ask-mcp][client] #{msg}" if @options[:debug]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def send_request(method, params = {}, headers: {})
|
|
273
|
+
response = send_request_raw(method, params, headers: headers)
|
|
122
274
|
raise ProtocolError, "Request failed: #{response.error[:message]}" unless response.success?
|
|
123
275
|
response.result
|
|
124
276
|
end
|
|
125
277
|
|
|
126
|
-
def send_request_raw(method, params = {})
|
|
278
|
+
def send_request_raw(method, params = {}, timeout: nil, headers: {})
|
|
279
|
+
params = params.merge(meta_params) if @stateless
|
|
127
280
|
request = Native::Messages::Request.new(method:, params:, id: next_id)
|
|
128
|
-
wait_for_response(request)
|
|
281
|
+
wait_for_response(request, timeout: timeout, headers: headers)
|
|
129
282
|
end
|
|
130
283
|
|
|
131
284
|
def send_notification(method, params = {})
|
|
@@ -133,15 +286,38 @@ module Ask
|
|
|
133
286
|
@transport.send(notification)
|
|
134
287
|
end
|
|
135
288
|
|
|
136
|
-
|
|
289
|
+
# Multi Round-Trip Requests (2026-07-28, SEP-2322): how many times the
|
|
290
|
+
# client may retry a request after an InputRequiredResult before giving up.
|
|
291
|
+
MAX_MRTR_ROUND_TRIPS = 5
|
|
292
|
+
|
|
293
|
+
def wait_for_response(request, timeout: nil, headers: {})
|
|
294
|
+
timeout ||= @options[:timeout] || 60
|
|
295
|
+
round_trips = 0
|
|
296
|
+
|
|
297
|
+
loop do
|
|
298
|
+
response = send_and_wait(request, timeout, headers: headers)
|
|
299
|
+
return response unless input_required?(response)
|
|
300
|
+
|
|
301
|
+
round_trips += 1
|
|
302
|
+
if round_trips > MAX_MRTR_ROUND_TRIPS
|
|
303
|
+
raise ProtocolError, "MRTR exceeded #{MAX_MRTR_ROUND_TRIPS} round trips"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
result = response.result
|
|
307
|
+
input_responses = resolve_input_requests(result[:inputRequests] || result["inputRequests"] || {})
|
|
308
|
+
request = build_retry_request(request, result, input_responses)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Send a single request and wait for its response.
|
|
313
|
+
def send_and_wait(request, timeout, headers: {})
|
|
137
314
|
# Register the pending request BEFORE sending to avoid race
|
|
138
315
|
@pending_mutex.synchronize do
|
|
139
316
|
@pending_requests[request.id] = true
|
|
140
317
|
end
|
|
141
318
|
|
|
142
|
-
@transport.send(request)
|
|
319
|
+
@transport.send(request, headers)
|
|
143
320
|
|
|
144
|
-
timeout = @options[:timeout] || 60
|
|
145
321
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
146
322
|
|
|
147
323
|
@pending_mutex.synchronize do
|
|
@@ -163,6 +339,39 @@ module Ask
|
|
|
163
339
|
end
|
|
164
340
|
end
|
|
165
341
|
|
|
342
|
+
# An InputRequiredResult only occurs in the stateless (2026-07-28) mode.
|
|
343
|
+
def input_required?(response)
|
|
344
|
+
@stateless && response.success? && response.result.is_a?(Hash) &&
|
|
345
|
+
response.result[:resultType] == "input_required"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Resolve each server inputRequest (elicitation/create, sampling/
|
|
349
|
+
# createMessage, roots/list) through the registered on_request handlers.
|
|
350
|
+
# Returns the InputResponses map keyed by the server's identifiers.
|
|
351
|
+
def resolve_input_requests(input_requests)
|
|
352
|
+
return {} if input_requests.nil? || input_requests.empty?
|
|
353
|
+
|
|
354
|
+
input_requests.each_with_object({}) do |(key, req), responses|
|
|
355
|
+
method = req[:method] || req["method"]
|
|
356
|
+
handler = @request_handlers[method]
|
|
357
|
+
unless handler
|
|
358
|
+
raise ProtocolError, "MRTR: no handler registered for #{method} (inputRequest #{key})"
|
|
359
|
+
end
|
|
360
|
+
responses[key] = handler.call(req[:params] || req["params"] || {})
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Build the MRTR retry: same method, a NEW id, the original params plus
|
|
365
|
+
# inputResponses and — if the server sent one — the opaque requestState
|
|
366
|
+
# echoed verbatim (clients MUST NOT inspect or modify it).
|
|
367
|
+
def build_retry_request(request, result, input_responses)
|
|
368
|
+
retry_params = (request.params || {}).dup
|
|
369
|
+
retry_params[:inputResponses] = input_responses
|
|
370
|
+
state = result[:requestState] || result["requestState"]
|
|
371
|
+
retry_params[:requestState] = state if state
|
|
372
|
+
Native::Messages::Request.new(method: request.method, params: retry_params, id: next_id)
|
|
373
|
+
end
|
|
374
|
+
|
|
166
375
|
def handle_message(message)
|
|
167
376
|
case message
|
|
168
377
|
when Native::Messages::Response
|
|
@@ -186,13 +395,18 @@ module Ask
|
|
|
186
395
|
end
|
|
187
396
|
|
|
188
397
|
def handle_request(request)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
398
|
+
handler = @request_handlers[request.method]
|
|
399
|
+
response = if handler
|
|
400
|
+
Native::Messages::Response.new(id: request.id, result: handler.call(request.params || {}))
|
|
401
|
+
else
|
|
402
|
+
Native::Messages::Response.new(
|
|
403
|
+
id: request.id,
|
|
404
|
+
error: {
|
|
405
|
+
code: Native::Messages::ErrorCodes::METHOD_NOT_FOUND,
|
|
406
|
+
message: "Method not implemented: #{request.method}"
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
end
|
|
196
410
|
@transport.send(response)
|
|
197
411
|
end
|
|
198
412
|
|
|
@@ -133,7 +133,7 @@ module Ask
|
|
|
133
133
|
INVALID_PARAMS = -32602
|
|
134
134
|
INTERNAL_ERROR = -32603
|
|
135
135
|
|
|
136
|
-
# MCP-specific error codes
|
|
136
|
+
# MCP-specific error codes (legacy, 2025-06-18 and earlier)
|
|
137
137
|
TOOL_NOT_FOUND = -32000
|
|
138
138
|
RESOURCE_NOT_FOUND = -32001
|
|
139
139
|
PROMPT_NOT_FOUND = -32002
|
|
@@ -141,6 +141,13 @@ module Ask
|
|
|
141
141
|
CONNECTION_ERROR = -32004
|
|
142
142
|
TIMEOUT_ERROR = -32005
|
|
143
143
|
|
|
144
|
+
# 2026-07-28: the -32020..-32099 range is reserved for the MCP
|
|
145
|
+
# specification (error code allocation policy); older codes were
|
|
146
|
+
# renumbered.
|
|
147
|
+
HEADER_MISMATCH = -32020
|
|
148
|
+
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
|
|
149
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
150
|
+
|
|
144
151
|
ERROR_MESSAGES = {
|
|
145
152
|
PARSE_ERROR => "Parse error",
|
|
146
153
|
INVALID_REQUEST => "Invalid request",
|
|
@@ -152,9 +159,23 @@ module Ask
|
|
|
152
159
|
PROMPT_NOT_FOUND => "Prompt not found",
|
|
153
160
|
AUTH_ERROR => "Authentication error",
|
|
154
161
|
CONNECTION_ERROR => "Connection error",
|
|
155
|
-
TIMEOUT_ERROR => "Timeout error"
|
|
162
|
+
TIMEOUT_ERROR => "Timeout error",
|
|
163
|
+
HEADER_MISMATCH => "Header mismatch",
|
|
164
|
+
MISSING_REQUIRED_CLIENT_CAPABILITY => "Missing required client capability",
|
|
165
|
+
UNSUPPORTED_PROTOCOL_VERSION => "Unsupported protocol version"
|
|
156
166
|
}.freeze
|
|
157
167
|
end
|
|
168
|
+
|
|
169
|
+
# Reserved `_meta` keys for the stateless protocol (2026-07-28).
|
|
170
|
+
# Keys are namespaced strings; note the JSON parser symbolizes all
|
|
171
|
+
# keys, so lookups must try both the string and symbol forms.
|
|
172
|
+
module Meta
|
|
173
|
+
PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
|
|
174
|
+
CLIENT_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities"
|
|
175
|
+
CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
|
|
176
|
+
SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo"
|
|
177
|
+
LOG_LEVEL_KEY = "io.modelcontextprotocol/logLevel"
|
|
178
|
+
end
|
|
158
179
|
end
|
|
159
180
|
end
|
|
160
181
|
end
|
data/lib/ask/mcp/prompt.rb
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module MCP
|
|
5
5
|
class Prompt
|
|
6
|
-
attr_reader :name, :description, :arguments
|
|
6
|
+
attr_reader :name, :description, :arguments, :title, :icons
|
|
7
7
|
|
|
8
|
-
def initialize(name:, description: nil, arguments: [])
|
|
8
|
+
def initialize(name:, description: nil, arguments: [], title: nil, icons: [])
|
|
9
9
|
@name = name
|
|
10
10
|
@description = description
|
|
11
11
|
@arguments = arguments
|
|
12
|
+
@title = title
|
|
13
|
+
@icons = icons
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def to_h
|
|
15
17
|
h = { name: @name }
|
|
18
|
+
h[:title] = @title if @title
|
|
16
19
|
h[:description] = @description if @description
|
|
17
20
|
h[:arguments] = @arguments if @arguments.any?
|
|
21
|
+
h[:icons] = @icons if @icons.any?
|
|
18
22
|
h
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -22,7 +26,9 @@ module Ask
|
|
|
22
26
|
new(
|
|
23
27
|
name: hash[:name] || hash["name"],
|
|
24
28
|
description: hash[:description] || hash["description"],
|
|
25
|
-
arguments: hash[:arguments] || hash["arguments"] || []
|
|
29
|
+
arguments: hash[:arguments] || hash["arguments"] || [],
|
|
30
|
+
title: hash[:title] || hash["title"],
|
|
31
|
+
icons: hash[:icons] || hash["icons"] || []
|
|
26
32
|
)
|
|
27
33
|
end
|
|
28
34
|
end
|
data/lib/ask/mcp/resource.rb
CHANGED
|
@@ -3,19 +3,23 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module MCP
|
|
5
5
|
class Resource
|
|
6
|
-
attr_reader :uri, :name, :description, :mime_type
|
|
6
|
+
attr_reader :uri, :name, :description, :mime_type, :title, :icons
|
|
7
7
|
|
|
8
|
-
def initialize(uri:, name:, description: nil, mime_type: nil)
|
|
8
|
+
def initialize(uri:, name:, description: nil, mime_type: nil, title: nil, icons: [])
|
|
9
9
|
@uri = uri
|
|
10
10
|
@name = name
|
|
11
11
|
@description = description
|
|
12
12
|
@mime_type = mime_type
|
|
13
|
+
@title = title
|
|
14
|
+
@icons = icons
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def to_h
|
|
16
18
|
h = { uri: @uri, name: @name }
|
|
19
|
+
h[:title] = @title if @title
|
|
17
20
|
h[:description] = @description if @description
|
|
18
21
|
h[:mimeType] = @mime_type if @mime_type
|
|
22
|
+
h[:icons] = @icons if @icons.any?
|
|
19
23
|
h
|
|
20
24
|
end
|
|
21
25
|
|
|
@@ -24,7 +28,9 @@ module Ask
|
|
|
24
28
|
uri: hash[:uri] || hash["uri"],
|
|
25
29
|
name: hash[:name] || hash["name"],
|
|
26
30
|
description: hash[:description] || hash["description"],
|
|
27
|
-
mime_type: hash[:mimeType] || hash["mime_type"] || hash[:mime_type]
|
|
31
|
+
mime_type: hash[:mimeType] || hash["mime_type"] || hash[:mime_type],
|
|
32
|
+
title: hash[:title] || hash["title"],
|
|
33
|
+
icons: hash[:icons] || hash["icons"] || []
|
|
28
34
|
)
|
|
29
35
|
end
|
|
30
36
|
end
|