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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/README.md +104 -175
- data/docs/SPEC_COMPLIANCE.md +184 -0
- data/lib/ask/mcp/adapters/tool_server.rb +33 -27
- 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 -4
- 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
|
@@ -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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
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
|