ask-mcp 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ # ask-mcp — MCP Specification Compliance
2
+
3
+ Status of `ask-mcp` against the Model Context Protocol specification.
4
+
5
+ **Advertised versions:** `Ask::MCP::PROTOCOL_VERSION` = `2025-06-18` (legacy
6
+ default), `Ask::MCP::LATEST_PROTOCOL_VERSION` = `2026-07-28` (stateless),
7
+ `Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS` = `[2025-06-18, 2025-11-25,
8
+ 2026-07-28]`. Clients negotiate via `server/discover` (falling back to the
9
+ legacy `initialize` handshake); servers speak both eras on one connection.
10
+
11
+ ## Strategy (applies to every item below)
12
+
13
+ 1. **Single source of truth.** Protocol constants live on `Ask::MCP`; client
14
+ and server reference them. This is what the `0.1.0` bug was about — never
15
+ reintroduce per-class duplicates.
16
+ 2. **Dual-mode, not rewrite.** `2026-07-28` removed the `initialize`
17
+ handshake, but the spec *requires* backward compatibility with older peers
18
+ (missing `resultType` → treat as `"complete"`; `server/discover` as a
19
+ backward-compat probe on stdio; `UnsupportedProtocolVersionError`). The
20
+ gem speaks the old and new protocols at once, negotiated per peer.
21
+ 3. **Tests first, wire-level.** Where a change alters what goes over the
22
+ wire, assert on the wire: `RecordingTransport` (unit, no subprocess) for
23
+ the client, `MCPServerHarness` for the server.
24
+ 4. **Don't build on deprecated features.** Roots, Sampling, Logging, the
25
+ HTTP+SSE transport, and OAuth Dynamic Client Registration are deprecated
26
+ as of `2026-07-28` — keep existing support working, don't add new surface.
27
+
28
+ ---
29
+
30
+ ## 1. Baseline: `2025-06-18` (done)
31
+
32
+ - [x] `initialize` / `notifications/initialized` handshake (client + server)
33
+ - [x] stdio transport
34
+ - [x] Streamable HTTP transport
35
+ - [x] HTTP+SSE transport (legacy, deprecated)
36
+ - [x] `tools/list`, `tools/call` (+ retry dedup, timeouts, graceful shutdown)
37
+ - [x] `resources/list`, `resources/read`, `resources/templates/list`
38
+ - [x] `prompts/list`, `prompts/get`
39
+ - [x] `ping` (legacy only — removed for 2026-07-28 peers)
40
+ - [x] OAuth resource-server authorization (`auth/oauth.rb`)
41
+ - [x] JSON-RPC 2.0 message types, parser, error codes (`native/messages.rb`)
42
+ - [x] Server serves tools, resources, prompts, and resource templates
43
+ (duck-typed serialization; `Server.start_stdio` accepts `resources`,
44
+ `prompts`, `resource_templates`)
45
+
46
+ ## 2. `2025-11-25` gap (done)
47
+
48
+ - [x] **Icons + title display metadata (SEP-973)** — `title` (optional) and
49
+ `icons` (array of `{src, mimeType, sizes}`) on tools, resources, prompts;
50
+ parsed/emitted by the value objects, passed through by
51
+ `Adapters::ToolServer`.
52
+ - [x] **Server serves resources & prompts** — `resources/list`,
53
+ `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`
54
+ handlers in `Server::Stdio`; `resources/read`/`prompts/get` pull contents
55
+ from `content`/`read`/`messages` or return the spec error codes.
56
+ - [x] **OIDC discovery (1.0)** — `Auth::OAuth#discover!` fetches
57
+ `/.well-known/openid-configuration` (derived from `issuer` per RFC 8414 or
58
+ an explicit `discovery_url`) and populates `token_url`, `auth_url`,
59
+ `issuer`; `token_url` is now optional at construction.
60
+ - [x] **Elicitation** — client handles `elicitation/create` via
61
+ `on_elicitation` (schema/params passed through verbatim, so titled/untitled,
62
+ single/multi-select enums and defaults are supported by construction).
63
+ - [x] **Sampling with tool calling (SEP-1577)** — client handles
64
+ `sampling/createMessage` via `on_sampling`, passing `tools`/`toolChoice`
65
+ through untouched.
66
+ - [x] **JSON Schema 2020-12 as default dialect** — `Validator` accepts
67
+ schemas declaring the 2020-12 dialect (the json-schema gem lacks 2020-12,
68
+ so the metaschema is stripped and validation retried; shared keywords
69
+ including `const` are enforced).
70
+ - [x] **Tool-name guidance** — spec is SHOULD-level; ask-mcp does not reject
71
+ tool names.
72
+ - [~] **Experimental Tasks** — explicitly not implemented: deprecated in
73
+ 2026-07-28 in favor of the `io.modelcontextprotocol/tasks` extension.
74
+
75
+ ## 3. `2026-07-28` (stateless revision) — done, dual-mode
76
+
77
+ ### 3.1 Stateless core
78
+
79
+ - [x] **`server/discover`** — server advertises `protocolVersions`,
80
+ `capabilities`, `serverInfo`; client probes it first and falls back to the
81
+ legacy handshake when the method is unknown (`UnsupportedProtocolVersion`
82
+ semantics via negotiated version lists).
83
+ - [x] **Remove `initialize` handshake in stateless mode** — every request
84
+ carries `_meta` (`io.modelcontextprotocol/protocolVersion`,
85
+ `clientCapabilities`, `clientInfo`); server identifies itself in results
86
+ via `serverInfo` (in discover) and `resultType`. Legacy handshake kept for
87
+ older peers; a server that supports ≤ 2025-11-25 is detected and the client
88
+ still handshakes with the negotiated version.
89
+ - [x] **`resultType` on all results** — server emits `"complete"` for
90
+ stateless peers (and `"input_required"` on MRTR); clients treat missing as
91
+ `"complete"` per spec.
92
+ - [x] **MRTR (Multi Round-Trip Requests, SEP-2322)** — client resolves
93
+ `inputRequests` (elicitation/sampling/roots) through the registered
94
+ handlers, retries with `inputResponses`, echoes `requestState` verbatim,
95
+ uses a fresh JSON-RPC id per retry, and caps round trips.
96
+ - [x] **Remove `ping`, `logging/setLevel`, `notifications/roots/list_changed`**
97
+ — `ping` returns Method-not-found for stateless peers (`logging/setLevel`
98
+ and roots notifications were never implemented; nothing to remove).
99
+ - [x] **Error-code allocation policy** — `-32020..-32099` reserved:
100
+ `HeaderMismatch` `-32020`, `MissingRequiredClientCapability` `-32021`,
101
+ `UnsupportedProtocolVersion` `-32022`; resource-not-found renumbered to
102
+ `-32602` for stateless peers.
103
+
104
+ ### 3.2 Transports
105
+
106
+ - [x] **Streamable HTTP: stateless shape** — no `Mcp-Session-Id`, no GET
107
+ stream endpoint, no `Last-Event-ID`; one POST per message; per-response
108
+ Content-Type decides single JSON vs SSE stream; SSE comment (keep-alive)
109
+ lines ignored.
110
+ - [x] **Required request headers** — `MCP-Protocol-Version` (matching body
111
+ `_meta`), `Mcp-Method`, `Mcp-Name` (`params.name`/`params.uri` for
112
+ `tools/call`, `resources/read`, `prompts/get`), with Base64 sentinel value
113
+ encoding for non-ASCII/unsafe values.
114
+ - [x] **x-mcp-header param mirroring (SEP-2243)** — client mirrors annotated
115
+ tool parameters into `Mcp-Param-{Name}` headers with value encoding, and
116
+ validates the annotations (`Ask::MCP::XMcpHeader`): empty names, invalid
117
+ field-name characters, non-primitive types, case-insensitive duplicates,
118
+ and annotations not statically reachable via a `properties`-only chain
119
+ (items/composition/conditionals/$ref/$defs) all make a tool definition
120
+ invalid, and Streamable HTTP clients exclude such tools from `tools/list`
121
+ with a warning. Integer values outside the JavaScript safe range are
122
+ omitted from headers at call time.
123
+ - [x] **`subscriptions/listen`** — transport opens the long-lived POST
124
+ response stream; `Client#listen(notifications)` convenience; delivered
125
+ notifications flow into the client's cache-invalidation handling.
126
+ Server-side emission: `Server::Stdio#notify_tools_list_changed` /
127
+ `notify_resources_list_changed` / `notify_prompts_list_changed` write the
128
+ notifications (deterministically ordered before the triggering response);
129
+ clients invalidate their caches on receipt (verified end-to-end).
130
+ - [x] **HTTP+SSE transport deprecated (SEP-2596)** — marked in code/docs;
131
+ still functional for legacy servers.
132
+
133
+ ### 3.3 Results, caching, errors
134
+
135
+ - [x] **`CacheableResult`** — `ttlMs` + `cacheScope` (`"private"` default,
136
+ configurable) emitted on `tools/list`, `resources/list`,
137
+ `resources/templates/list`, `resources/read`, `prompts/list` for stateless
138
+ peers.
139
+ - [x] **Deterministic tool ordering** — `tools/list` order is stable (locked
140
+ by test) for client caching and prompt-cache hit rates.
141
+ - [x] **`extensions` capability field** — capabilities are caller-provided
142
+ pass-through, so extension capabilities are supported by construction.
143
+
144
+ ### 3.4 Authorization
145
+
146
+ - [x] **Issuer tracking** — `discover!` records the discovered `issuer`.
147
+ - [x] **`iss` validation (RFC 9207)** — `Auth::OAuth#validate_iss!` rejects
148
+ an `iss` that does not match the recorded issuer.
149
+ - [x] **Client ID Metadata Documents (SEP-991)** —
150
+ `Auth::ClientIdMetadataDocument` builds and validates the self-hosted JSON
151
+ document (required `client_id`/`client_name`/`redirect_uris`, https URL
152
+ client_id with a path, `client_id` matching the document URL,
153
+ `application_type` `native`/`web` with `native` default for CLI clients).
154
+ URL-based client IDs are portable across authorization servers, so no
155
+ re-registration is needed when the server changes.
156
+ - [x] **Credential binding by issuer** — satisfied by construction: each
157
+ `OAuth` instance holds credentials for the single authorization server it
158
+ is configured/discovered against (no shared credential store exists).
159
+ - [~] **`application_type` in Dynamic Client Registration** — ask-mcp has no
160
+ DCR implementation (it is deprecated in 2026-07-28); `application_type` is
161
+ supported via the Client ID Metadata Document builder.
162
+
163
+ ### 3.5 Deprecations (keep working, don't extend)
164
+
165
+ - [x] Roots, Sampling, Logging marked deprecated (no new build-out; the
166
+ client still exposes `on_sampling`/elicitation handlers for MRTR).
167
+ - [x] **OTel trace context conventions (SEP-414)** — `Ask::MCP::TraceContext`
168
+ extracts `traceparent`/`tracestate`/`baggage` from HTTP headers (incl.
169
+ Rack's `HTTP_*` convention) and from `_meta`; clients propagate configured
170
+ `_meta` fields (e.g. `meta: TraceContext.from_headers(rack_env)`).
171
+
172
+ ## 4. Conformance / verification
173
+
174
+ - Unit (no subprocess): `bundle exec ruby -Itest test/client_test.rb`
175
+ - Server (harness + subprocess): `bundle exec ruby -Itest test/server/stdio_test.rb`
176
+ - Transports: `bundle exec ruby -Itest test/transport/`
177
+ - Full suite: `bundle exec rake test`
178
+ - Status: 299 runs, 686 assertions, 0 failures (green across repeated runs).
179
+
180
+ Conformance: `test/conformance_test.rb` is the self-contained stand-in for the
181
+ official MCP test suite — it runs the full lifecycle against the real server
182
+ in both the `2025-06-18` handshake era and the `2026-07-28` stateless era, plus
183
+ a stateless client end-to-end. Run the official MCP fixtures here too once the
184
+ 2026-07-28 fixtures are published.
@@ -8,43 +8,36 @@ module Ask
8
8
  # respond to +name+, +description+, +params_schema+, and +call(args)+ and
9
9
  # exposes them over MCP.
10
10
  #
11
- # Usage:
12
- # class MyTool
13
- # def name; "hello" end
14
- # def description; "Says hello" end
15
- # def params_schema; nil end
16
- # def call(args = {}); OpenStruct.new(ok?: true, output: "Hello!") end
17
- # end
18
- #
19
- # adapter = ToolServer.new([MyTool.new])
20
- # adapter.definitions # => [{ name: "hello", ... }]
21
- # adapter.call("hello", {}) # => { content: [...], isError: false }
11
+ # Tools can return:
12
+ # - An object responding to #ok?/#ok and #output/#error_message (Ask::Result style)
13
+ # - A plain String (treated as success)
14
+ # - Any other value (treated as success, .to_s is used for the response)
22
15
  class ToolServer
23
16
  attr_reader :tools
24
17
 
25
- # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose
26
18
  def initialize(tools = [])
27
19
  @tools = tools
28
20
  @tool_map = tools.each_with_object({}) { |t, h| h[t.name] = t }
29
21
  end
30
22
 
31
- # MCP tool definitions for tools/list
32
- # @return [Array<Hash>]
33
23
  def definitions
34
24
  @tools.map do |tool|
35
25
  schema = tool.params_schema || { type: "object", properties: {}, required: [] }
36
- {
26
+ defn = {
37
27
  name: tool.name,
38
28
  description: tool.description || "",
39
29
  inputSchema: schema
40
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
41
38
  end
42
39
  end
43
40
 
44
- # Call a tool and wrap the result in MCP format
45
- # @param name [String] tool name
46
- # @param arguments [Hash] arguments (may have symbol or string keys)
47
- # @return [Hash] { content: [...], isError: true/false }
48
41
  def call(name, arguments = {})
49
42
  tool = @tool_map[name]
50
43
  unless tool
@@ -64,18 +57,31 @@ module Ask
64
57
  private
65
58
 
66
59
  def wrap_result(result)
67
- if result.respond_to?(:ok?) ? result.ok? : result.ok
68
- output = result.respond_to?(:output) ? result.output : result.to_s
69
- text = output.is_a?(Hash) ? (output[:summary] || output.to_s) : output.to_s
70
- { content: [{ type: "text", text: text }], isError: false }
71
- else
72
- msg = result.respond_to?(:error_message) ? result.error_message : result.to_s
73
- { content: [{ type: "text", text: "Error: #{msg}" }], isError: true }
60
+ # Plain strings are always a success
61
+ if result.is_a?(String)
62
+ return { content: [{ type: "text", text: result }], isError: false }
63
+ end
64
+
65
+ # Result-like objects (Ask::Result, OpenStruct, etc.)
66
+ if result.respond_to?(:ok?) || result.respond_to?(:ok)
67
+ ok = result.respond_to?(:ok?) ? result.ok? : result.ok
68
+ return success_result(result, ok) if ok
69
+ return error_result(result.respond_to?(:error_message) ? result.error_message : result.to_s)
74
70
  end
71
+
72
+ # Everything else — treat as success
73
+ text = result.is_a?(Hash) ? (result[:summary] || result.to_s) : result.to_s
74
+ { content: [{ type: "text", text: text }], isError: false }
75
+ end
76
+
77
+ def success_result(result, _ok)
78
+ output = result.respond_to?(:output) ? result.output : result.to_s
79
+ text = output.is_a?(Hash) ? (output[:summary] || output.to_s) : output.to_s
80
+ { content: [{ type: "text", text: text }], isError: false }
75
81
  end
76
82
 
77
83
  def error_result(message)
78
- { content: [{ type: "text", text: message }], isError: true }
84
+ { content: [{ type: "text", text: "Error: #{message}" }], isError: true }
79
85
  end
80
86
 
81
87
  def deep_stringify_keys(obj)
@@ -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
@@ -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:, auth_url: nil,
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