# `ConduitMcp.Client.OAuth.Scopes`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/oauth/scopes.ex#L1)

Scope lists and step-up bookkeeping.

Scopes are lists of strings; on the wire they are one space-delimited
string (RFC 6749 §3.3), see `parse/1` and `join/1`.

## Step-up (OA-10)

When a server answers 403 with `error="insufficient_scope"`, the client
re-authorizes with `union/2` of the scopes it requested before and the
challenged ones (MCP 2026-07-28 *Step-Up Authorization Flow*; applied to
both eras). To stop a server from looping the client through the
authorization server, attempts are counted per `{resource, scope set}` in a
plain map the caller keeps (`record_step_up/3`) and checked with
`step_up_allowed?/4` (default limit 2). The scope set is order- and
duplicate-insensitive, so `["b", "a"]` and `["a", "b", "a"]` share a count.

# `attempts`

```elixir
@type attempts() :: %{optional({String.t(), [String.t()]}) =&gt; non_neg_integer()}
```

Step-up attempt counts, keyed by `step_up_key/2`.

# `join`

```elixir
@spec join([String.t()]) :: String.t() | nil
```

The `scope` parameter value for `scopes`, or `nil` for an empty list (the
parameter is then omitted).

    iex> ConduitMcp.Client.OAuth.Scopes.join(["read", "write"])
    "read write"

# `parse`

```elixir
@spec parse(String.t() | nil) :: [String.t()]
```

Splits a space-delimited scope string; `nil` gives `[]`. Empty items from
repeated spaces are dropped and duplicates removed, first occurrence kept.

    iex> ConduitMcp.Client.OAuth.Scopes.parse("read  write read")
    ["read", "write"]

# `record_step_up`

```elixir
@spec record_step_up(attempts(), String.t(), [String.t()]) :: attempts()
```

Records one step-up attempt for `resource` with `scopes`.

# `step_up_allowed?`

```elixir
@spec step_up_allowed?(attempts(), String.t(), [String.t()], non_neg_integer()) ::
  boolean()
```

Whether another step-up for `resource` with `scopes` is allowed: fewer than
`max` (default 2) attempts are recorded in `attempts`.

    iex> alias ConduitMcp.Client.OAuth.Scopes
    iex> attempts = %{} |> Scopes.record_step_up("https://mcp.example.com", ["a"])
    iex> Scopes.step_up_allowed?(attempts, "https://mcp.example.com", ["a"])
    true
    iex> attempts = Scopes.record_step_up(attempts, "https://mcp.example.com", ["a"])
    iex> Scopes.step_up_allowed?(attempts, "https://mcp.example.com", ["a"])
    false

# `step_up_key`

```elixir
@spec step_up_key(String.t(), [String.t()]) :: {String.t(), [String.t()]}
```

The `t:attempts/0` key for `resource` and the set of `scopes`.

# `union`

```elixir
@spec union([String.t()], [String.t()]) :: [String.t()]
```

`previous` followed by every scope of `challenged` not already present,
without duplicates: the order of first appearance is kept.

    iex> ConduitMcp.Client.OAuth.Scopes.union(["read", "write"], ["admin", "read"])
    ["read", "write", "admin"]

---

*Consult [api-reference.md](api-reference.md) for complete listing*
