Commit graph

733 commits

Author SHA1 Message Date
Koichi ITO
d71ec506c4
Merge pull request #447 from koic/support_class_based_resource_definitions
Support class-based Resource and ResourceTemplate definitions
2026-07-11 22:00:58 +09:00
Koichi ITO
b6df40b134 Support class-based Resource and ResourceTemplate definitions
## Motivation and Context

Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions
while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block
with manual URI routing.

`MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool`
and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`,
`size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods
and `MCP::Server#define_resource` / `#define_resource_template`.

The default `resources/read` handler now routes requests automatically: an exact URI match calls
the class-based resource's `contents` method, and class-based resource templates match
simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments.
`contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered
and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164.

## How Has This Been Tested?

- Added regression tests covering the class-level DSL, URI template matching, auto-routing,
  `server_context:` opt-in, error responses, and backward compatibility of the no-op default.
- Verified end-to-end with a smoke script exercising exact-match reads, template-match reads,
  unknown URIs, and legacy instance-only setups.

## Breaking Changes

No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for
setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds
the internal URI index.

Closes #98
2026-07-10 22:39:42 +09:00
Koichi ITO
61233ed43b
Merge pull request #438 from koic/server_discover
Add `server/discover` and stateless lifecycle error codes per SEP-2575
2026-07-10 00:26:49 +09:00
Koichi ITO
f013bcbf8c
Merge pull request #436 from koic/list_result_ttl
Add Opt-In `ttlMs` / `cacheScope` Cache Hints to List and Read Results per SEP-2549
2026-07-10 00:26:21 +09:00
Koichi ITO
ab65b919e2
Merge pull request #426 from koic/sse_retry
Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport
2026-07-08 01:09:56 +09:00
Koichi ITO
95feef2dcf
Merge pull request #442 from koic/release_0_23_0 v0.23.0
Release 0.23.0
2026-07-07 22:48:00 +09:00
Koichi ITO
494f43d3a7 Release 0.23.0
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.
2026-07-07 22:17:27 +09:00
Koichi ITO
6cb78eaad8
Merge commit from fork
Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS
2026-07-07 22:10:06 +09:00
Koichi ITO
772e0cb1f9 Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS
## Motivation and Context

Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:

- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
  before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
  in the reporter's measurement. The path runs before session validation,
  so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
  so a peer that never emits a newline grows a single String until the process is OOM-killed.
  This matters when a host pipes an untrusted server's stdout into an unconstrained process.

The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.

- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
  rejects a declared `Content-Length` over the cap before reading, and also bounds
  the actual read to one byte past the cap so a missing or spoofed `Content-Length`
  (chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
  `max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
  The constructor validates the new option as a positive Integer, since `nil` or a non-positive
  value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
  a separate advisory, so this change carries the remaining piece of the reported stdio fix:
  an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
  so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
  fails cleanly instead of parsing a truncated frame.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).

## Breaking Changes

None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
2026-07-07 22:03:41 +09:00
Koichi ITO
0ede5fc146
Merge commit from fork
Add a Session-Ownership Hook to StreamableHTTPTransport
2026-07-07 21:44:50 +09:00
Koichi ITO
3546660531 Add a Session-Ownership Hook to StreamableHTTPTransport
## Motivation and Context

`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates
an incoming request by session existence and idle timeout only, with no binding to a client identity.
An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST
a `tools/call` on that session and the server executes it, silently poisoning the victim's session.
The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path,
so this is not covered by it.

Binding a session to a user is fundamentally the deploying application's responsibility:
the transport hands the server only the JSON-RPC body string and never receives the authenticated principal
(`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as
a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present.
This gives the Ruby SDK the seam to do the same:

- A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }`
  on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects
  with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses,
  not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel
  a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover.
  `initialize` is exempt because it establishes the session. An application that has an authenticated principal
  uses the validator to enforce that the caller owns the session; without one, ownership is not enforced.
- A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header
  is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when
  both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC)
  is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker.
  Enforcing ownership against a determined attacker requires the validator.

Stateless mode retains no sessions, so neither check applies to it.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs
from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed;
a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID;
the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests;
and stateless mode never consults the validator.

## Breaking Changes

None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both
the session and the request carry an `Origin` and they differ, so existing applications are unaffected.
2026-07-07 21:41:29 +09:00
Koichi ITO
4b400be6ed
Merge commit from fork
Bound Stateful Session Retention to Prevent an Initialize-Flood DoS
2026-07-07 21:32:45 +09:00
Koichi ITO
dfdd4efc24 Merge upstream modelcontextprotocol/ruby-sdk main
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
2026-07-07 21:28:11 +09:00
Koichi ITO
cfccb7ca25
Merge commit from fork
Validate `Host` and `Origin` Headers to Prevent DNS Rebinding per MCP 2025-11-25
2026-07-07 21:16:03 +09:00
Koichi ITO
c8930758ad
Merge commit from fork
Bound stdio frame reads with max_line_bytes
2026-07-07 21:10:07 +09:00
Koichi ITO
267b8fa628 Bound stdio frame reads with max_line_bytes
## Motivation and Context

CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.

This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.

Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.

Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.

## How Has This Been Tested?

Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.

Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.

Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.

The full test suite and RuboCop pass.

## Breaking Changes

None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
2026-07-05 21:30:28 +09:00
Koichi ITO
afb968c468 Bound Stateful Session Retention to Prevent an Initialize-Flood DoS
## Motivation and Context

In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored
a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker
could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until
memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client.
The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count,
so the insecure behavior was the default.

Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python,
and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults
in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below):

- `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default
  and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode
  retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from
  an explicit `nil`.
- A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under
  the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions
  the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired),
  and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects:
  established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions
  while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS,
  which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode.
  The value is validated as a positive Integer or `nil`.

## How Has This Been Tested?

New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and
the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep
no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session
is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out,
and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to
the new secure default.

## Breaking Changes

Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions
lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require
the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
2026-07-05 19:39:56 +09:00
Koichi ITO
c145876bb9 Add server/discover and stateless lifecycle error codes per SEP-2575
## Motivation and Context

SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release)
makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of
per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both
the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and
the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration
and are purely additive:

- The `server/discover` method, which servers MUST implement: sessionless capability discovery
  returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`.
  The new `Server#discover` handler reuses the same data `init` already builds, but is state-free
  and idempotent: it stores no client info, never marks a session initialized, and responds regardless
  of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`).
  `serverInfo` is returned unfiltered because discovery precedes version negotiation;
  the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work.
  On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement
  and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with
  a bare POST in both stateful and stateless modes.
- The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003`
  and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet
  (that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of
  `URLElicitationRequiredError` is intentionally left untouched.

The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is
deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25,
where those methods are required.

Part of #389.

## How Has This Been Tested?

- `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities,
  server info, and instructions; it responds before `initialize` and with empty capabilities;
  it does not mark the session initialized (a subsequent `initialize` still succeeds);
  `instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")`
  now raises `MethodAlreadyDefinedError`.
- `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID
  returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`.
- New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None at the wire level for existing clients. One narrow API consequence: servers that previously registered
their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because
the method is built in.
2026-07-04 20:24:37 +09:00
Koichi ITO
8b1a41aedd
Merge pull request #437 from yuki3738/fix_tool_name_in_http_server_example
[Doc] Use registered callable names in examples/README.md
2026-07-03 02:09:59 +09:00
Yuki Minamiya
ac392cba55 [Doc] Use registered callable names in examples/README.md
The `tools/call` cURL example in `examples/README.md` uses `"name": "ExampleTool"`, but a tool registered by class without an explicit `tool_name` gets a snake_case name derived from its class name (via `StringUtils.handle_from_class_name`), so the example fails with `Tool not found: ExampleTool`. The Tools/Prompts lists in the same file had the same mismatch (`ExampleTool`, `ExamplePrompt`, `NotificationTool`). All entries now use the registered callable names.

How Tested: Ran `ruby examples/http_server.rb` and exercised the cURL flow. `tools/list` returned `example_tool` and `echo`; `tools/call` with `"name": "ExampleTool"` returned `Tool not found: ExampleTool`; with `"name": "example_tool"` returned `The sum of 5 and 3 is 8`. `prompts/list` returned `example_prompt`. The full unit suite and RuboCop pass locally.

Breaking Changes: None (documentation only).
2026-07-03 01:08:45 +09:00
Koichi ITO
3339ceb8c1 Add Opt-In ttlMs / cacheScope Cache Hints to List and Read Results per SEP-2549
## Motivation and Context

SEP-2549 (modelcontextprotocol/modelcontextprotocol#2549, merged for the 2026-07-28 spec release) adds a `CacheableResult`
shape with `ttlMs` (freshness lifetime in milliseconds, max-age semantics, 0 = do not cache) and `cacheScope`
(`"public"` or `"private"`, whether shared intermediaries may cache) to `ListToolsResult`, `ListPromptsResult`,
`ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult`, complementing the `listChanged` notifications.

The field shape follows the Python SDK's implementation (python-sdk#2824, `ttlMs` + `cacheScope` with `ttlMs: 0` /
`cacheScope: "public"` defaults; the TypeScript SDK's earlier `ttl`-seconds prototype #2070 was superseded by this shape).
Because this SDK negotiates protocol versions up to 2025-11-25, where the fields do not exist, emission is opt-in:
when neither `ttl_ms` nor `cache_scope` is configured, responses are serialized exactly as before. The fields become REQUIRED
in 2026-07-28; a follow-up can auto-emit defaults once that version enters `SUPPORTED_STABLE_PROTOCOL_VERSIONS`.

- `MCP::Server.new` gains `ttl_ms:` and `cache_scope:` keywords with validating writers modeled on `page_size=`
  (`ttl_ms` must be nil or a non-negative Integer; `cache_scope` must be nil, `"public"`, or `"private"`).
- A private `apply_cache_metadata` wraps the five emission points. When the user sets only one of the two values,
  the other is filled with the spec defaults (`ttlMs: 0`, the only universally safe freshness value, and `cacheScope: "public"`,
  the spec and Python default).
- A `resources_read_handler` may return a full `{ contents:, ttlMs:, cacheScope: }` hash to override the server-level hints per result;
  the documented bare-contents return shape keeps working unchanged.
- The client's `ListToolsResult` / `ListPromptsResult` / `ListResourcesResult` / `ListResourceTemplatesResult` structs gain
  `ttl_ms` and `cache_scope` members populated from the response fields.

Resolves #387.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb`:

- with `ttl_ms: 5000`, all four list results carry `ttlMs: 5000` and the filled `cacheScope: "public"` default
- with only `cache_scope: "private"`, `resources/read` carries `ttlMs: 0`
- with neither configured, list and read results omit both fields (wire-format regression)
- the hints appear alongside `nextCursor` when paginating
- a `resources_read_handler` returning `{ contents:, ttlMs: 60_000 }` overrides the server-level value while the missing `cacheScope` is filled
- invalid writer values (`-1`, `1.5`, `"internal"`) raise `ArgumentError`

New tests in `test/mcp/client_test.rb` assert all four list result structs expose `ttl_ms` / `cache_scope`,
and that both are nil when the server omits the fields.

## Breaking Changes

None. Emission is opt-in, the new constructor keywords default to nil, and the client structs gain additive members.
2026-07-02 17:51:01 +09:00
Koichi ITO
49d6d7f254
Merge pull request #435 from koic/fix_result_null_for_id_bearing_notification
Fix an incorrect `result: null` response to an id-bearing notification message
2026-07-02 07:40:40 +09:00
Koichi ITO
6d42cf7826 Fix an incorrect result: null response to an id-bearing notification message
## Motivation and Context

A JSON-RPC notification carries no id and must not receive any response.
When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id,
the server replied with a successful `result: null`, which is neither a valid MCP result nor
a legal reply to a notification.

Such a message is now treated as a request for a method that has no request handler and rejected with
Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification
that omits the id continues to receive no response.

Fixes #434.

## How Has This Been Tested?

Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`,
and `notifications/progress`, a batch that mixes an id-bearing notification with other messages,
custom `notifications/*` methods, and the same case over the HTTP transport.

## Breaking Changes

None for spec-conformant clients, which never put an id on a notification.
The only behavioral change is that a malformed id-bearing notification now receives a Method not found error
instead of a spurious `result: null`.
2026-07-01 01:55:15 +09:00
Koichi ITO
ba543083a7 Validate Host and Origin Headers to Prevent DNS Rebinding per MCP 2025-11-25
## Motivation and Context

MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate
the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost,
to prevent DNS rebinding attacks against locally bound MCP servers.
`StreamableHTTPTransport` previously validated neither `Host` nor `Origin`,
so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin,
invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m.

The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of
external middleware) and the Python SDK (`transport_security.py` disables protection by default)
both ship this off for backwards compatibility, a constraint they cannot escape after 1.0.
Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline:

- `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`,
  compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`.
  This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`.
  A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one.
- `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port)
  case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches
  `Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected.
- `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers.

A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:

- a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed
- a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values
  (`127.0.0.1`, `localhost`, `[::1]`) are allowed
- an upper-cased loopback `Host` is accepted (case-insensitive matching)
- a non-loopback `Host` listed in `allowed_hosts:` is allowed
- a request with no `Host` header, and one with no `Origin` header, are allowed
- a default-port `Origin` (`http://localhost`) matches `Host: localhost:80`
- opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected
- a `host:port` `allowed_hosts:` entry matches that port only
- `Host` validation also applies to GET requests
- `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through

## Breaking Changes

`StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default.
A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`,
or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients
are unaffected.

Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
2026-06-28 12:06:13 +09:00
Koichi ITO
cb0eb3892b Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport
## Motivation and Context

Resolves the `sse-retry` client conformance scenario (SEP-1699,
https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699).

Per SEP-1699, a server may close a request's SSE stream right after a priming event
(an event carrying an `id:`) without delivering the response, expecting the client
to treat the graceful close like a network failure: wait the server-specified
`retry:` interval, then reconnect with an HTTP GET carrying `Last-Event-ID`
so the server can replay the pending response on the resumed stream.

The Ruby client previously read the entire SSE body to EOF and parsed it afterward,
so it had no notion of per-event state (`id:`, `retry:`) or reconnection,
and the scenario failed all three checks (graceful reconnect, retry timing, `Last-Event-ID`).

`MCP::Client::HTTP#send_request` now consumes responses incrementally
via Faraday's `on_data` streaming callback, feeding SSE chunks to `event_stream_parser`
as they arrive while buffering plain JSON bodies as before. A new internal `SSEStream`
tracks the last received event id, the `retry:` reconnection delay,
and the awaited JSON-RPC response; once the response arrives, a held-open stream is
abandoned via an internal control-flow exception since servers may never close it.
When a stream closes gracefully after a priming event but before the response,
the client sleeps for the `retry:` interval (default 1000ms when the server sent none),
then issues a GET with `Accept: text/event-stream`, the session headers, and `Last-Event-ID`,
for up to 2 attempts. The defaults match the Python SDK's `DEFAULT_RECONNECTION_DELAY_MS` and
`MAX_RECONNECTION_ATTEMPTS` (`streamable_http.py`); the TypeScript SDK's `StreamableHTTPClientTransport`
(`streamableHttp.ts`) likewise lets the `retry:` field override its backoff and reconnects only when
a priming event was received and no response has arrived.

The public behavior of `send_request` is unchanged: same return values, same error mapping,
and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error
without reconnecting. Because the previous implementation read `response.body` and therefore worked with any Faraday adapter,
a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read from `response.body`
(adapters without `on_data` support, e.g. the Faraday test adapter) or from the buffered chunks
(Faraday < 2.1, which invokes `on_data` without `env`).

The conformance client gains an `sse-retry` branch that calls the harness's `test_reconnection` tool,
and the scenario is removed from the expected failures baseline.

## How Has This Been Tested?

New unit tests covering: reconnection with `Last-Event-ID` after a primed graceful close including the `retry:` wait,
the default 1000ms delay when `retry:` is absent, raising after the reconnection attempt cap with `Last-Event-ID`
advancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks
(JSON and SSE responses through the Faraday test adapter, which ignores `on_data`, plus buffered SSE chunks
when `on_data` receives no `env`)

## Breaking Changes

None. The `send_request` contract is unchanged; SSE bodies are now parsed incrementally instead of after EOF,
and reconnection only activates when the server opts in by sending a priming event.
2026-06-28 11:56:02 +09:00
Koichi ITO
5518e8db03
Merge pull request #429 from atesgoral/issue-390-deprecation-annotations
docs: annotate Roots, Sampling, and Logging as deprecated per SEP-2577
2026-06-28 01:46:01 +09:00
Koichi ITO
b7bd9bb556
Merge pull request #427 from koic/require_connect_stdio
Require calling `MCP::Client#connect` before sending requests on stdio transport
2026-06-28 01:43:07 +09:00
Ates Goral
69bfe436b7 docs: annotate Roots, Sampling, and Logging as deprecated per SEP-2577
Split the advisory YARD @deprecated annotations out of #406 so the
documentation can land ahead of the 2026-07-28 protocol release.

Per maintainer feedback, this drops the protocol-version-gated runtime
Kernel.warn behavior and the new 2026-07-28 supported protocol version,
leaving only the @deprecated notes for the Roots, Sampling, and Logging
APIs deprecated by SEP-2577.

Refs #390

Co-authored-by: 🦞 Qlaw <noreply@qlaw.quick.shopify.io>
2026-06-27 14:02:27 +02:00
Koichi ITO
58d5175364
Merge pull request #428 from koic/release_0_22_0 v0.22.0
Release 0.22.0
2026-06-27 08:23:46 +09:00
Koichi ITO
f739a4b8e8 Release 0.22.0
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.
2026-06-27 02:54:30 +09:00
Koichi ITO
d6a368a54f Require calling MCP::Client#connect before sending requests on stdio transport
## Motivation and Context

The MCP specification requires an `initialize` request followed by `notifications/initialized`
before any other interaction. Issue #334 makes that handshake explicit on the stdio client,
matching the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`).

The change was staged to avoid a hard break. Phase 1 (#336) added `MCP::Client::Stdio#connect`
while keeping an implicit-init compatibility shim on the first `send_request`.
Phase 2 (#338) emitted a deprecation warning when that shim ran. The warning has shipped since 0.16.0,
so this final phase removes the shim.

## How Has This Been Tested?

`send_request` now raises a `RuntimeError` when `connect` has not been called, and no longer
auto-starts the subprocess. `connect` is the sole entry point that spawns the process and
performs the handshake, which avoids leaving an orphaned subprocess behind.

The stdio test suite was reworked to call `connect` explicitly, a regression test was added for
the new guard, and `rake test` passes (1145 runs, 0 failures).

## Breaking Changes

Stdio clients that send requests without calling `MCP::Client#connect` now raise a `RuntimeError`
instead of silently initializing. Call `client.connect` before the first request.

Closes #334
2026-06-26 13:34:47 +09:00
Koichi ITO
0694e386db
Merge pull request #423 from koic/sep_2243_mcp_method_name_headers
Send SEP-2243 `Mcp-Method` and `Mcp-Name` headers per MCP specification
2026-06-25 01:27:33 +09:00
Koichi ITO
a284bf7314 Send SEP-2243 Mcp-Method and Mcp-Name headers per MCP specification
## Motivation and Context

The MCP specification (SEP-2243) requires the Streamable HTTP transport to mirror
selected JSON-RPC body fields into HTTP headers so that intermediaries such as load balancers,
gateways, and observability tooling can route and inspect requests without parsing the body.
`Mcp-Method` carries the JSON-RPC `method` and is required on all requests and notifications,
while `Mcp-Name` carries `params.name` (`tools/call`, `prompts/get`) or `params.uri` (`resources/read`).

The Ruby SDK client did not send these headers, so servers enforcing SEP-2243 rejected
its requests with `400 Bad Request`, breaking interoperability. This aligns the Ruby SDK with
the TypeScript and Python SDKs, both of which send these headers from their Streamable HTTP clients.

Header values that cannot be transmitted safely as-is, such as non-ASCII text, control characters
(including CR/LF, which would otherwise allow header injection), or significant leading or
trailing whitespace, are wrapped per SEP-2243 as `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
The encoding uses `Array#pack("m0")` to avoid depending on the `base64` gem.

Resolves #366

## How Has This Been Tested?

Client tests cover deriving `Mcp-Name` from `params.name` (`tools/call`, `prompts/get`) and
falling back to `params.uri` (`resources/read`), both symbol- and string-keyed params,
requests and notifications without a name or uri sending only `Mcp-Method`, and non-Hash params sending
only `Mcp-Method`. Value encoding is covered for non-ASCII names, surrounding whitespace, CR/LF
(verifying no header injection), and values that already match the `=?base64?...?=` sentinel.

## Breaking Changes

None. The change is purely additive: existing requests now carry the required `Mcp-Method` and `Mcp-Name` headers
alongside the headers already sent. The `x-mcp-header` / `Mcp-Param-*` custom headers remain out of scope,
matching the current state of the TypeScript and Python SDKs.
2026-06-24 14:04:36 +09:00
Koichi ITO
7e878827ae
Merge pull request #417 from koic/json_schema_2020_12_tool_schemas
Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106
2026-06-24 13:39:16 +09:00
Koichi ITO
817b8f4d83
Merge pull request #425 from koic/feature_cancellation_for_client
Support client-side `notifications/cancelled` per MCP specification
2026-06-24 13:38:18 +09:00
Koichi ITO
b1a31ee143
Merge pull request #424 from modelcontextprotocol/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 6 to 7
2026-06-23 12:43:27 +09:00
Koichi ITO
a4cd7c8cde Support client-side notifications/cancelled per MCP specification
## Motivation and Context

The server-side half of MCP `notifications/cancelled` shipped earlier and lets handlers
observe cancellation via `server_context.cancelled?` / `raise_if_cancelled!`.
This PR delivers the client-side equivalent that was deferred at the time:
an `MCP::Client` user can now cancel a request they have already issued and have
the calling thread wake up, matching the Python SDK
(`anyio.CancelScope`) and TypeScript SDK (`AbortSignal`) ergonomics.

The recommended pattern is to pass an `MCP::Cancellation` token into the request method,
run the request on a worker thread, and call `cancellation.cancel(reason:)` from another thread.
The cancelling thread sends `notifications/cancelled` to the server, and the calling thread is
woken up with `MCP::CancelledError`:

```ruby
cancellation = MCP::Cancellation.new
Thread.new do
  client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
rescue MCP::CancelledError
  # cleanup
end
cancellation.cancel(reason: "user pressed cancel")
```

For low-level use, `Client#cancel(request_id:, reason:)` sends the notification without managing
the calling thread, and `Client#generate_request_id` lets callers pre-allocate an id before kicking off
a request on another thread. The `request_id:` and `cancellation:` keywords are accepted by
every request method (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`,
`list_resource_templates`, `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`,
`complete`, `ping`).

When `cancellation:` is supplied, the actual blocking `transport.send_request` runs on a worker thread;
the calling thread waits on a `Queue` woken either by the response or by the cancel signal
(whichever arrives first - the same race contract as the server-side `StreamableHTTPTransport#cancel_pending_request`).
On a normal completion the `on_cancel` hook is deregistered so a late cancel does not emit a stray `notifications/cancelled`.
The worker thread is *not* force-killed when a cancel wins the race; it stays blocked on the underlying I/O until
the transport actually returns (or the user closes it). For `Client::HTTP` the leak resolves as soon as
the server sends any response; for `Client::Stdio` the user may need to call `client.transport.close`
if the server stops responding entirely.

`Client::Stdio` and `Client::HTTP` gain `send_notification(notification:)` so `Client#cancel` can deliver
the JSON-RPC notification through the existing transport plumbing. Custom transports that do not implement
`send_notification` cause `Client#cancel` to raise `NoMethodError`.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation

## How Has This Been Tested?

`test/mcp/client_test.rb` covers `Client#cancel` payload (with and without `reason`),
`Client#generate_request_id` returning distinct UUIDs, every request method threading `request_id:` through,
and the full `cancellation:` flow:

- Returns the response when no cancel happens (no behaviour regression for callers that do not pass a token).
- Pre-cancelled token raises `MCP::CancelledError` immediately and never reaches the transport
  (no stray request, no stray notification).
- Mid-flight cancel raises `MCP::CancelledError` carrying the supplied reason and sends `notifications/cancelled`
  through the transport.
- Late cancel after a normal completion does not emit a stray notification
  (the `on_cancel` hook is deregistered in the `ensure` block).

`test/mcp/client/stdio_test.rb` covers `send_notification` writing the JSON line through the spawned process
and returning `nil` without waiting for a response.

`test/mcp/client/http_test.rb` covers `send_notification` POSTing the body, tolerating HTTP 202 Accepted,
and surfacing Faraday errors as `RequestHandlerError`.

## Breaking Change

None. All new keywords (`request_id:`, `cancellation:`) on request methods are optional and default to `nil`.
Existing callers that do not pass them get the original synchronous behaviour. Custom transports that already
implement `send_request(request:)` continue to work; only callers of `Client#cancel` need a transport
that responds to `send_notification`, and the failure mode is a clear `NoMethodError` rather than silent breakage.
2026-06-23 12:39:53 +09:00
dependabot[bot]
da8e7e5363
Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 20:13:56 +00:00
Koichi ITO
6bf0a069b5
Merge pull request #422 from yuki3738/add_audience_validation_to_annotations
Add `audience` role validation for `MCP::Annotations` per MCP specification
2026-06-22 02:37:17 +09:00
Yuki Minamiya
4c3268ccb1 Add audience role validation for MCP::Annotations per MCP specification
The MCP specification constrains `Annotations.audience` to an array of
`Role` values (`"user"` or `"assistant"`), but the Ruby SDK accepts any
value. The other official SDKs already enforce this (TypeScript, Python,
and PHP).

This follows the existing pattern in `MCP::Icon`, which validates its
constrained `theme` value in the constructor and raises `ArgumentError`.

Added tests in `test/mcp/annotations_test.rb`:

- valid `audience` (`["user"]`, `["assistant"]`, `["user", "assistant"]`,
  and an empty array) is accepted
- an unknown role, a mixed valid/invalid array, or a non-array `audience`
  raises `ArgumentError`

Existing tests used `["developers"]`, which is not a valid `Role`, and are
updated to `["user"]`.

The full unit suite and RuboCop pass locally.

Strictly speaking this is a breaking change: a caller that currently passes
an invalid `audience` (e.g. `["developers"]`) will now get an
`ArgumentError`. Such values already violate the MCP specification, and
`nil` / valid-role values are unaffected.
2026-06-22 01:13:17 +09:00
Koichi ITO
dc26613a07
Merge pull request #419 from koic/fix_syntax_error_on_ruby_2_7_0
Fix a `SyntaxError` on Ruby 2.7.0 caused by arguments forwarding syntax
2026-06-21 01:07:18 +09:00
Koichi ITO
db460e5f47
Merge pull request #420 from koic/release_0_21_0 v0.21.0
Release 0.21.0
2026-06-20 22:15:29 +09:00
Koichi ITO
c4c73e05f5 Fix a SyntaxError on Ruby 2.7.0 caused by arguments forwarding syntax
## Motivation and Context

The gemspec already declares `required_ruby_version >= 2.7.0`, but `ServerContext` used
the `...` arguments forwarding syntax in `method_missing`. That syntax was added in Ruby 2.7.3,
so requiring the gem raised a `SyntaxError` on Ruby 2.7.0 through 2.7.2, and the library could not
be loaded at all.

RuboCop did not catch this because its Parser backend runs on Ruby 2.7.8, where the syntax is valid.
CI missed it too because the test matrix tested `2.7`, which resolves to the latest 2.7.x rather than
the declared minimum.

## How Has This Been Tested?

Forward arguments explicitly with `*args, **kwargs, &block` so the method loads on Ruby 2.7.0,
and pin the CI test matrix to 2.7.0 so the suite runs against the declared minimum instead of
the latest 2.7.x.

Running the suite on 2.7.0 also requires a `Hash.ruby2_keywords_hash?` shim in the test helper,
since mocha would otherwise raise a `NoMethodError` on that version.

## Breaking Changes

None.

Closes #418
2026-06-20 01:01:07 +09:00
Koichi ITO
49a08bf3be Release 0.21.0
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.
2026-06-20 01:00:18 +09:00
Koichi ITO
5ca8b6ebbb Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106
## Motivation and Context

SEP-2106 (modelcontextprotocol/modelcontextprotocol#2106, merged for the 2026-07-28 spec release)
makes tool `inputSchema` and `outputSchema` conform to the full JSON Schema 2020-12 vocabulary:
an input schema keeps `type: "object"` at the root but may use any 2020-12 keyword below it;
an output schema may be ANY valid schema (object, array, primitive, or a root-level composition);
and `CallToolResult.structuredContent` widens from an object to any JSON value. The SEP also adds
resource bounds: `$ref` resolution is restricted (same-document only in the reference implementation) and
composition-heavy documents must be bounded to avoid excessive validation cost.

This follows the TypeScript SDK's reference implementation (typescript-sdk#2249; the Python SDK tracks
the same work in python-sdk#2792):

- `Tool::Schema` now validates against the JSON Schema 2020-12 metaschema rather than the draft-04 metaschema.
  The draft-04 pin was a stopgap from when the SDK used the `json-schema` gem, which did not support 2020-12;
  `json_schemer` does, so `$defs`/`$ref` and the rest of the 2020-12 vocabulary now resolve natively.
  This matches the dialect the SDK already advertises in emitted schemas and the Python SDK's behavior,
   whose `jsonschema.validate` selects the validator from the schema's `$schema`.
- `Tool::Schema` moves root-type defaulting into an overridable `apply_default_root_type!` hook.
  `InputSchema` keeps the historical `type: "object"` default; `OutputSchema` now applies it only
  when no root schema keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`) is present.
  The previous unconditional default merged `type: "object"` into root combinators such as `{ oneOf: [...] }`,
  producing a wrong schema, so that case is a bug fix.
- `Tool::Schema` enforces the TypeScript SDK's schema bounds at construction time: only same-document
  `$ref`/`$dynamicRef`s (starting with `#`, so schema handling can never trigger network or file access),
  `MAX_SCHEMA_DEPTH = 64` nesting levels, and `MAX_SUBSCHEMA_COUNT = 10_000` subschema objects,
  all raising `ArgumentError` on violation.
- `Server#call_tool` mirrors non-object `structuredContent` into `content` as serialized JSON text
  when the tool provided no content blocks, so pre-SEP clients that only read `content` still receive the data.
  Object results and explicit content are untouched.

Resolves #377.

## How Has This Been Tested?

- `test/mcp/tool/output_schema_test.rb`: root-level `oneOf`, `$ref`+`$defs`, primitive, and `enum` schemas serialize
  without an injected `type` and validate results correctly; the `properties`-only shorthand still serializes
  with `type: "object"` (wire-format regression); explicit `type: "array"` keeps working.
- `test/mcp/tool/input_schema_test.rb`: an input schema using `$defs`, `$ref`, `oneOf`, `if`/`then`, and `allOf`
  keeps its object root and round-trips all keywords; a draft-04-only boolean `exclusiveMinimum` is rejected
  under the 2020-12 dialect while the numeric form is accepted.
- `test/mcp/tool/schema_test.rb`: depth and subschema-count bound violations raise `ArgumentError`;
  non-same-document `$ref`s (remote URI, sibling file) are rejected while `#/$defs/...` is accepted.
  The previous unbounded-depth caching test is replaced, since the depth bound now rejects such documents by design.
- `test/mcp/server_test.rb`: `tools/call` with array `structuredContent` and no content gains
  the serialized TextContent fallback; explicit content is not overwritten; object `structuredContent` gets no fallback.

`bundle exec rake` (tests, RuboCop, and conformance baseline, including the `json-schema-2020-12` server scenario) passes.

## Breaking Changes

Three narrow behavior changes, all intentional per the SEP:

- Runtime validation now uses the JSON Schema 2020-12 metaschema instead of draft-04.
  Schemas that rely on draft-04-only syntax are rejected at construction time. The practical case is the boolean
  `exclusiveMinimum`/`exclusiveMaximum` form (deprecated since draft-06), which must now be the numeric form;
  the Python SDK rejects it the same way. Other draft-04 spellings (`definitions`, `id`, `dependencies`) still validate,
  since 2020-12 tolerates unknown keywords.
- Schemas that exceed the new resource bounds (nesting deeper than 64, more than 10,000 subschema objects) or
  use a non-same-document `$ref`/`$dynamicRef` now raise `ArgumentError` at construction time.
  Previously such documents were accepted (external references were already never fetched, only ignored).
- An `OutputSchema` whose root declares a schema keyword other than `type` (e.g. `oneOf`) no longer has  `type: "object"`
  merged into it. The old output was an invalid hybrid schema, so no conforming consumer could have relied on it.
2026-06-19 21:36:55 +09:00
Koichi ITO
217542f812
Merge pull request #416 from koic/fix_a_build_error
Fix a build error in the Ruby 3.1 CI matrix
2026-06-19 21:30:36 +09:00
Koichi ITO
cfc9a699d6 Fix a build error in the Ruby 3.1 CI matrix
## Motivation and Context

This resolves the following build failure that occurs when using Ruby 3.1 with I18n 1.15.0:

```console
$ bundle exec rake
(snip)

/Users/koic/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/i18n-1.15.0/lib/i18n.rb:58:
in `config': undefined method `[]' for Fiber:Class (NoMethodError)

      current = Fiber[:i18n_config] || self.config = I18n::Config.new
                     ^^^^^^^^^^^^^^
        from /Users/koic/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/i18n-1.15.0/lib/i18n.rb:88:in `load_path'
```

https://github.com/modelcontextprotocol/ruby-sdk/actions/runs/27724973112/job/82025136923

According to the report in https://github.com/ruby-i18n/i18n/issues/735,
this issue has been fixed in I18n 1.15.1 and later.
However, the already released I18n 1.15.0 still triggers the build error.

While this could potentially be addressed in a better way on the I18n side,
this change updates the Gemfile to avoid using I18n 1.15.0, which is known to cause the issue.

## How Has This Been Tested?

The build error described above has been resolved.

## Breaking Changes

None.
2026-06-18 09:20:59 +09:00
Koichi ITO
c8e0cd9562
Merge pull request #415 from koic/stateless_session_isolation
Isolate Stateless Requests in Ephemeral Sessions per SEP-2567
2026-06-18 07:55:01 +09:00
Koichi ITO
aac41cdff8
Merge pull request #408 from koic/dcr_application_type
Set OIDC `application_type` on Dynamic Client Registration per SEP-837
2026-06-17 10:57:19 +09:00
Koichi ITO
60f0088877
Merge pull request #414 from koic/2025_03_26_backcompat
Fall Back to Legacy 2025-03-26 OAuth Discovery for Servers Without PRM
2026-06-17 10:49:00 +09:00