Skip to content

Commit 4c4c142

Browse files
committed
docs(oauth): disposition CIMD/DCR posture — resource-server role only
Audited the live MCP draft authorization spec against turul-mcp-oauth's actual role (slice stop-condition applied: no server-side CIMD behavior is required of a resource-server-only crate, so this closes as docs/tests-only): - Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata- document-00) are a SHOULD for AUTHORIZATION SERVERS and MCP CLIENTS. Client registration never involves the resource server. This crate implements the RS role only — RFC 9728 Protected Resource Metadata and OAuth 2.1 §5.2 / RFC 8707 token validation, both left untouched — so no CIMD surface belongs here. - Dynamic Client Registration (RFC 7591) is deprecated upstream (MAY, retained for AS back-compat; NOT removed — earliest removal 2027-07-28) and is likewise an AS/client concern. This crate has never implemented a DCR surface; none was invented. Changes: role-posture section in the crate docs; wire-shape test pinning that the published RFC 9728 document carries no client-registration keys (registration_endpoint, client_id*, redirect_uris, ...) while the RS-role MUST fields stay present. Review-doc P1 line and CHANGELOG dispositioned. Client-side CIMD is recorded as belonging to a future full MCP OAuth client flow (turul-mcp-client or app layer).
1 parent f21c4e0 commit 4c4c142

4 files changed

Lines changed: 72 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3737

3838
### Fixed (2026-06-10)
3939

40+
- **`turul-mcp-oauth` CIMD/DCR posture dispositioned (docs/tests-only by design).** Audited against the live draft authorization spec: Client ID Metadata Documents are a SHOULD for *authorization servers and MCP clients*; Dynamic Client Registration is deprecated upstream (MAY, AS back-compat; not removed — earliest removal 2027-07-28). This crate implements the resource-server role only — RFC 9728 Protected Resource Metadata and OAuth 2.1 §5.2/RFC 8707 token validation, both unchanged — so no CIMD or DCR surface belongs in it and none was invented. The role posture is now documented in the crate header, and a wire-shape test pins that the published RFC 9728 document carries no client-registration keys (`registration_endpoint`, `client_id*`, `redirect_uris`, …). Client-side CIMD belongs to a future full MCP OAuth client flow.
4041
- **Builders/derive schema pipeline is lossless on the 2026 path.** Two defects destroyed JSON Schema 2020-12 fidelity between a tool author's types and `tools/list`: (1) `ToolSchema::from_schemars` stripped `$defs`/`definitions` from the root while passing properties through verbatim — every `#/$defs/X` pointer dangled; the 2026 root now RETAINS `$defs`/`definitions`/`$schema` (the 2025 typed lane keeps its inline-resolution and stripping). (2) The derive macros funneled schemars-generated parameter and output schemas through the typed-enum converter, silently collapsing data-bearing unions (`oneOf` + `const` tags → bare `{"type":"object"}`) and other 2020-12 compositions. New lane-aware `turul_mcp_builders::schemars_param_schema`: on 2026 it inlines local `$ref`s (cycle-guarded `resolve_local_refs`; `$ref` siblings compose via `allOf`) and carries the result verbatim via the new transparent `JsonSchema::Raw` variant (untagged escape hatch on the 2026 typed enum — also the deserialize fallback for subschemas the structured variants reject); on 2025 it is the status-quo typed conversion. **Documented limitations with rejection tests** (not silent loss): cyclic `$ref`s cannot be inlined into a property subschema (error names the cycle; restructure the type or use a root `from_schemars` document), and non-local/network `$ref`s are rejected per the spec's no-auto-deref rule. Tests: 7 builders fidelity tests (nested `$defs` inlining with enum/required intact, tagged-union `oneOf` survival, composition-keyword verbatim round-trip, cycle/non-local rejection, `$ref`-sibling `allOf` composition, root `$defs` retention) + 2 real-HTTP e2e (`schema_fidelity_2026.rs`: a derived tool's tagged-union param and schemars output reach `tools/list` undamaged with no dangling `$ref`; `tools/call` `structuredContent` satisfies the ADVERTISED `outputSchema` wrapper field discovered from `tools/list`). Revert-and-fail: with the 2026 arm forced through the old converter, the tagged-union test fails showing the exact loss (`"shape":{"type":"object"}`) — recorded. No public macro/builder API shape changed.
4142
- **Protocol-fidelity sweep, part 2 — `ttlMs` as a schema `number` + SEP-2577 marker absorption.** (a) `CacheableResult.ttlMs` (and its embeddings in the tools/resources/prompts/discover results) is now `f64` per the schema's `number` type: fractional values are accepted on deserialize and survive round trips, negative/non-finite values reject (`@minimum 0`), and whole values keep the compact integer wire form (byte-stable for the common `ttlMs: 0` case). (b) The re-pinned schema's SEP-2577 deprecations are now fully absorbed as `#[deprecated]` markers: `LoggingLevel` (+ `LogLevel` alias), the per-request `_meta` `logLevel` key and `RequestMetaObject.log_level`/`with_log_level`, `ServerCapabilities.logging`, `ModelHint`/`ModelPreferences`/`ToolChoice`, the `ContentBlock::ToolUse`/`ToolResult` variants and constructors, and the sampling trait surface (`HasCreateMessageRequestParams`/`CreateMessageRequest`/`CreateMessageResult`/`HasLevelParam`). The earlier rustdoc claim that `LoggingLevel`/`logLevel` were "the non-deprecated replacement" was wrong against the re-pin and is corrected — the whole Logging surface (including the per-request opt-in this branch implements) is deprecated-but-normative through the migration window. Framework-internal use sites carry scoped `#[allow(deprecated)]` (the framework intentionally serves the surface through the window); downstream consumers now get compiler nudges.
4243
- **Protocol-fidelity sweep, part 1 (wire/type drift vs the pinned schema).** (a) `ToolChoice` no longer carries a non-spec `name` field on the wire (the `specific()` constructor is gone) and `mode` is optional per schema (`{}` parses; absent means `"auto"`; `effective_mode()` helper). (b) `PromptReference` is `BaseMetadata`-shaped: gains `title`, drops the non-spec `description`. (c) `Annotations.audience` is the closed `Role[]` union instead of `Vec<String>` — wire-invalid values like `"system"` are now rejected at parse time; the builders' `annotation_audience` takes `Role` (converted to strings on the frozen 2025 lane). (d) The duplicate `Role` binding is gone — `sampling::Role` re-exports the single `prompts::Role`. (e) `LoggingCapabilities`/`CompletionsCapabilities` match the schema's opaque `JSONObject`: the invented `enabled`/`levels` keys are removed from the bindings and from both server builders' capability advertisements (presence of the object is the signal). 5 new wire-shape contract tests in `compliance.rs`; existing tests migrated with the contract (e.g. the empty `ToolChoice` parse fails against the pre-fix required-`mode` binding).

crates/turul-mcp-oauth/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@
44
//! acting as Resource Servers (RS). It does NOT implement an Authorization
55
//! Server — tokens are validated against an external AS via JWKS.
66
//!
7+
//! # Authorization-role posture (MCP draft, 2026-07-28 era)
8+
//!
9+
//! The MCP authorization spec assigns requirements per OAuth role. This
10+
//! crate implements the **resource-server role only**:
11+
//!
12+
//! - RFC 9728 Protected Resource Metadata (`/.well-known/oauth-protected-resource`)
13+
//! — the RS MUST — via [`ProtectedResourceMetadata`] / [`WellKnownOAuthHandler`].
14+
//! - OAuth 2.1 §5.2 access-token validation with RFC 8707 audience binding
15+
//! — via [`JwtValidator`] / [`OAuthResourceMiddleware`].
16+
//!
17+
//! **Client ID Metadata Documents** (CIMD,
18+
//! draft-ietf-oauth-client-id-metadata-document-00) are a SHOULD for
19+
//! *authorization servers and MCP clients* — client registration never
20+
//! involves the resource server, so no CIMD surface belongs in this crate.
21+
//! Applications embedding an AS or building a full MCP OAuth client implement
22+
//! CIMD there.
23+
//!
24+
//! **Dynamic Client Registration** (RFC 7591) is deprecated upstream (MAY,
25+
//! retained for AS back-compat; not removed — earliest removal 2027-07-28)
26+
//! and is likewise an AS/client concern. This crate has never implemented a
27+
//! DCR surface and does not add one.
28+
//!
729
//! # Architecture
830
//!
931
//! - **`OAuthResourceMiddleware`** — Pre-session middleware that validates

crates/turul-mcp-oauth/src/metadata.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,46 @@ mod tests {
212212
assert!(json.get("resource_documentation").is_none());
213213
}
214214

215+
#[test]
216+
fn protected_resource_metadata_carries_no_client_registration_surface() {
217+
// Role posture: client registration (CIMD / deprecated DCR) is an
218+
// authorization-server + client concern. The RFC 9728 document this
219+
// resource server publishes must not grow registration keys — a
220+
// client discovering this RS gets ONLY resource metadata and the AS
221+
// list, and performs registration against the AS.
222+
let metadata = ProtectedResourceMetadata::new(
223+
"https://example.com/mcp",
224+
vec!["https://auth.example.com".to_string()],
225+
)
226+
.unwrap()
227+
.with_jwks_uri("https://auth.example.com/.well-known/jwks.json")
228+
.with_scopes(vec!["mcp:read".to_string()]);
229+
230+
let json = serde_json::to_value(&metadata).unwrap();
231+
let keys: Vec<&str> = json
232+
.as_object()
233+
.unwrap()
234+
.keys()
235+
.map(|k| k.as_str())
236+
.collect();
237+
for forbidden in [
238+
"registration_endpoint",
239+
"client_id_metadata_document_supported",
240+
"client_id",
241+
"client_secret",
242+
"redirect_uris",
243+
] {
244+
assert!(
245+
!keys.contains(&forbidden),
246+
"RFC 9728 resource metadata must not carry the registration \
247+
key '{forbidden}' (AS/client concern), got keys: {keys:?}"
248+
);
249+
}
250+
// The RS-role MUSTs stay present.
251+
assert!(keys.contains(&"resource"));
252+
assert!(keys.contains(&"authorization_servers"));
253+
}
254+
215255
#[test]
216256
fn test_metadata_url_extracts_origin() {
217257
// Resource with path -> origin-based URL

docs/plans/2026-07-28-release-readiness-review.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,15 @@ P1 = mediums (spec SHOULDs, fidelity drift, ADR/doc contract drift). P2 = lows.
209209
unification trips the alias mutex — empirically verified).
210210
-`docs/plans/2026-07-28-schema-coverage-matrix.md` is stale (STALE banner added 2026-06-10) post Slice A'/A'' and
211211
the 2026-06-07 re-vendor.
212-
-`turul-mcp-oauth`: absorb DCR deprecation / CIMD SHOULD (§1).
212+
- ☑ (2026-06-10, dispositioned as docs/tests-only) `turul-mcp-oauth`: absorb DCR
213+
deprecation / CIMD SHOULD (§1). **Finding:** the live draft assigns the CIMD
214+
SHOULD to *authorization servers and MCP clients*; DCR (deprecated, MAY) is
215+
likewise AS/client-side. `turul-mcp-oauth` implements the resource-server
216+
role only (RFC 9728 PRM + OAuth 2.1 §5.2 token validation, both unchanged) —
217+
no CIMD or DCR surface belongs in it, and none was invented. Posture recorded
218+
in the crate docs; a wire-shape test pins that the RFC 9728 document carries
219+
no client-registration keys. A future full MCP OAuth *client* flow (in
220+
`turul-mcp-client` or an app layer) is where client-side CIMD would land.
213221

214222
### P2 — Lows (64 upheld; do as one sweep slice)
215223

0 commit comments

Comments
 (0)