Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 25 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

<br>

**[letsauth.eu](https://letsauth.eu/)** is an open, vendor-neutral lab for seeing, testing and breaking how AI agents are authorized.
MCP, A2A and agent delegation meet OAuth, OpenID Connect and workload identity, with a European lens.
**[letsauth.eu](https://letsauth.eu/)** is an open, vendor-neutral lab for how AI agents are authorized.
It covers where MCP, A2A and agent delegation meet OAuth, OpenID Connect and workload identity, with EU law in view.

<br>

Expand Down Expand Up @@ -40,25 +40,23 @@ MCP, A2A and agent delegation meet OAuth, OpenID Connect and workload identity,
## Why this lab exists

Vendors explain agent authorization through their own product. Conformance testers run against one
server, by its owner, pinned to an older revision of the spec. Nobody shows, over time and for
everyone, which public MCP servers validate the issuer, bind the audience, or still lean on dynamic
client registration. Nobody draws WIMSE, AAuth, ID-JAG, A2A and the EU acts on one map.

letsauth measures instead of asserting. It keeps one open dataset of what the agent protocols
require and what real servers do:

- **Every claim cites a revision.** Each requirement links to the section of the specification
revision it comes from. When the spec moves, the atlas says what changed and keeps the old
reading intact.
- **Every status cites an observation.** A cell is only ever a dated observation with a method and
evidence a reviewer can open. Nothing is inferred from silence: an empty cell reads *unknown*,
never failure.
- **Open, append-only data.** The dataset is YAML in this repository under CC BY 4.0. Observations
are never edited, so the atlas is also a record of how the ecosystem moved.
- **Neutral and passive.** Rows are alphabetical, nothing is ranked, and the site contacts no server
at build time. The scanner reads public metadata only, never with a token.
- **European by default.** Hosting, storage and analytics run in the EU. eIDAS 2.0, the AI Act and
NIS2 are first-class content wherever they touch agent delegation.
server, by its owner, pinned to an older revision of the spec. Nobody shows which public MCP
servers validate the issuer, bind the audience or still rely on dynamic client registration, for
all of them and over time. Nobody draws WIMSE, AAuth, ID-JAG, A2A and the EU acts on one map.

letsauth keeps one open dataset of what the agent protocols require and what real servers do:

- **Each requirement cites a revision.** It links to the section of the specification revision it
comes from. When the spec changes, the atlas says what changed and keeps the old reading.
- **Each status cites an observation.** A cell is a dated observation with a method and evidence a
reviewer can open. A cell with no observation on record reads *unknown*, and never reads as a
failure.
- **Open, append-only data.** The dataset is YAML in this repository under CC BY 4.0. We never edit
an observation, so the atlas also records how servers changed over time.
- **Neutral and passive.** Rows are in alphabetical order and nothing is ranked. The site build
contacts no server. The scanner reads public metadata only and never sends a token.
- **European.** Hosting, storage and analytics run in the EU. The site covers eIDAS 2.0, the AI
Act and NIS2 wherever they touch agent delegation.

## What is live

Expand All @@ -82,14 +80,14 @@ the data.
</picture>

One shared model, many views. Specifications, features, servers, observations and the relations
between standards live as YAML under [`data/`](data/), validated by a Zod schema at build time and
changed only through reviewed pull requests. The site is pre-rendered from that data; the scanner
is the one backend, and its output flows back into the repository as data. The full picture is in
between standards live as YAML under [`data/`](data/). A Zod schema validates them at build time,
and they change only through reviewed pull requests. The build pre-renders the site from that
data. The scanner is the one backend, and its output comes back into the repository as data. The full picture is in
[`docs/architecture.md`](docs/architecture.md).

## Use the data

Everything the site shows is yours to download under CC BY 4.0:
You can download everything the site shows, under CC BY 4.0:

```sh
curl -s https://letsauth.eu/mcp/data.json | jq '.servers | length'
Expand Down Expand Up @@ -120,5 +118,5 @@ issue.
## Licences

Code is [MIT](LICENSE). Data under `data/` and the JSON exports are
[CC BY 4.0](LICENSE-DATA). Operated by [Jan Brennenstuhl](https://www.janbrennenstuhl.eu/) in
Berlin as Let's Auth Labs; see the [imprint](https://letsauth.eu/imprint/).
[CC BY 4.0](LICENSE-DATA). [Jan Brennenstuhl](https://www.janbrennenstuhl.eu/) runs the site in
Berlin as Let's Auth Labs. The [imprint](https://letsauth.eu/imprint/) has the details.
6 changes: 3 additions & 3 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ Metadata Documents", "RFC 9207 `iss` validation", "Resource indicators (RFC 8707
| `category` | enum | `discovery`, `registration`, `authorization`, `token`, `session`, `transport`, `delegation`, `consent` |
| `description` | string | |
| `supersedes` | string? | Feature id this replaces (e.g. CIMD supersedes DCR as the default) |
| `observable` | enum | `passive`, `active`, `manual` — how support can be verified |
| `party` | enum | `mcp-server`, `authorization-server`, `client` — who the requirement is on |
| `observable` | enum | `passive`, `active`, `manual`: how support can be verified |
| `party` | enum | `mcp-server`, `authorization-server`, `client`: who the requirement is on |
| | | (`levelNote` lives inside each `revisions` entry) |

### Relation
Expand Down Expand Up @@ -91,7 +91,7 @@ A public MCP server (or, later, another agent endpoint) that the MCP Atlas track
| `url` | url | MCP endpoint |
| `homepage` | url? | |
| `transport` | enum | `streamable-http`, `sse`, `stdio-only` |
| `listed` | enum | `opt-in`, `public-directory`, `maintainer-added` — why it is in the atlas |
| `listed` | enum | `opt-in`, `public-directory`, `maintainer-added`: why it is in the atlas |
| `source` | url | Where the listing came from; required unless `opt-in` |
| `access` | Classification? | Who can connect: `public` (anyone; where an account is needed, anyone can create one by self-service), `closed` (an existing contract, a tenant an admin must enable, a sales process or a private beta stands in the way; a catalog of approved clients is not an obstacle to the person and stays in the evidence note), `unknown` |
| `auth` | Classification? | How the endpoint authenticates clients: `oauth` (an OAuth 2.x authorization through the server's authorization server), `api-key` (a static credential the user pastes into the client), `none`, `unknown` |
Expand Down
6 changes: 3 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ set.
| `pnpm typecheck` | `astro sync` and `tsc --noEmit`. |
| `pnpm test` | Unit tests (Vitest) over `src/lib` and `src/scanner`. |
| `pnpm test:e2e` | Playwright against `astro preview`; run `pnpm build` first. |
| `pnpm check:prose [--fail]` | Lists copy in `src/pages`, `src/components`, `README.md` and `docs/*.md` that breaks the mechanical voice rules (banned words, em dash). Warns only until the content pass; `--fail` makes hits fail. |
| `pnpm check:prose` | Lists copy in `src/pages`, `src/components`, `README.md` and `docs/*.md` that breaks the mechanical voice rules (banned words, em dash) and fails on any hit. |
| `pnpm check:budgets [dir]` | Byte budgets over `dist/` (gzip level 6): CSS, JavaScript, HTML, inline SVG and preloaded fonts per page, plus each island. Run `pnpm build` first. |
| `pnpm check:contrast [--markdown] [--hex]` | WCAG contrast of the colour pairs in `src/styles/tokens.css`, light and dark; fails under 4.5:1 for text and 3:1 for glyphs and outlines. `--markdown` prints the table in `docs/design.md`, `--hex` the sRGB value of each token. |
| `pnpm check` | validate:data, lint, check:prose, check:contrast, typecheck, unit tests, build, check:links, check:budgets. Must pass before a push. |
Expand All @@ -58,8 +58,8 @@ set.
`enforce` flag in `LIMITS` at the top of `scripts/check-budgets.ts`, and the package that meets
a target flips it.
- **Prose lint** (`pnpm check:prose`) reports the voice rules a script can check, from section 8
of the same plan. It prints `file:line: rule: text` and exits 0. Set `FAIL` in
`scripts/check-prose.ts` to make it a gate.
of the same plan. It prints `file:line: rule: text` and fails on any hit, so `pnpm check`
fails too. A quotation of a source may use "ecosystem"; nothing else is exempt.
- **Contrast** (`pnpm check:contrast`) recomputes every colour pair in `docs/design.md` from the
OKLCH values in `tokens.css` and fails when one is under its minimum.

Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ features and the review point after each. Each feature gets its own spec under `
| 006 | **Agents under EU rules** | eIDAS 2.0 and EUDI wallet mandates, AI Act oversight, NIS2 duties, as explorer scenarios and long-form pages. | Planned |
| 007 | **Changelog and newsletter** | Generated from merged data and content changes. Runs once there is something to report. | Planned |
| 008 | **Discovery challenge check** | The first nightly run withheld grades for servers that publish no protected resource metadata at a well-known URL (Atlassian, Plaid, Stack Overflow), because the other allowed mechanism, a `resource_metadata` pointer in the 401 `WWW-Authenticate` challenge, needs a request the passive scanner does not make. One unauthenticated request per server to its MCP endpoint, reading only the challenge header, would fill `mcp.prm.www-authenticate` beside `mcp.prm.well-known`, so the atlas shows which mechanism each server offers, or both. It is the first request beyond well-known metadata, so the spec must justify it against the constitution before it runs. | Implemented (pull request open); `specs/008-discovery-challenge/` records why it is still passive |
| 009 | **Server access and auth classes** | Today the atlas mixes servers anyone can connect to with ones that need an account, tenant or enterprise contract, and OAuth servers with API-key and open ones, using only the directory's unverified `authClaimed` label. Make access (public or closed) and auth (OAuth, API key, none) first-class, evidenced fields; let the atlas group and filter by them; and let the scanner and the grade use them; drop the label. | Implemented; `specs/009-access-auth-classes/` |
| 009 | **Server access and auth classes** | Today the atlas mixes servers anyone can connect to with ones that need an account, tenant or enterprise contract, and OAuth servers with API-key and open ones, using only the directory's unverified `authClaimed` label. Make access (public or closed) and auth (OAuth, API key, none) evidenced fields of their own; let the atlas group and filter by them; and let the scanner and the grade use them; drop the label. | Implemented; `specs/009-access-auth-classes/` |
| 010 | **Composable discovery strategies** | After the first nightly runs, several requirements stayed `unknown` for every server for the same few reasons: streamable HTTP servers answer the scanner's `GET` with 405 before authenticating, listed `/sse` endpoints had moved, metadata documents name no scopes, and the `scope` parameter the endpoint already sends was not read. Make each check an ordered list of strategies over the sources the probe found, make the endpoint request a ladder, read the scope challenge, and let the run summary flag stale URLs. | Implemented; `specs/010-discovery-strategies/` |
| 016 | **Visual redesign** | The site works but looks generated: boxes, shadows, glow, a slogan hero. Move it to one calm system shared with the maintainer's blog (flat surfaces, hairlines, two brand accents, Geist), with motion only where it explains a state, tighter byte and Lighthouse budgets, and a copy pass. No change to data, schema or URLs. | Planned; `specs/016-visual-redesign/plan.md` |

Expand Down
8 changes: 5 additions & 3 deletions scripts/check-prose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";

// Mechanical rules from the voice checklist (specs/016-visual-redesign/plan.md section 8, rule 4).
// Warn-only until WP9 rewrites the copy: then set FAIL to true (or pass --fail meanwhile).
const FAIL = false;
// A gate since WP9 rewrote the copy: any hit fails `pnpm check`.
const FAIL = true;

const root = process.cwd();
const fail = FAIL || process.argv.includes("--fail");
Expand All @@ -20,6 +20,8 @@ const WORDS = [
"cutting-edge",
"next level",
"game-changing",
"game-changer",
"holistic",
"in the era of",
"in the age of",
"measured instead of asserted",
Expand Down Expand Up @@ -114,5 +116,5 @@ if (hits.length) {
for (const [name, count] of [...perFile].sort((a, b) => b[1] - a[1]))
log(` ${String(count).padStart(4)} ${name}`);
if (fail) process.exitCode = 1;
else console.log("(warn only; WP9 switches this check to fail)");
else console.log("(warn only)");
} else console.log(`Prose OK: ${files.length} files`);
16 changes: 16 additions & 0 deletions specs/016-visual-redesign/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,21 @@ reveal, the token rail and the toggle rails; breach pages on `.rows`, `.panel` a
- [x] W6-T13 Breach layout rework after review: the entry page puts the account (summary after its first sentence, the missing control, the replay as a rail in the toggle's ink) at reading width beside a sticky facts panel (timeline, affects, sources as short labels); the index is a ledger with the date, class and id in a narrow column beside each entry and its missing control and replay as labelled lines
- [x] W6-T12 Screenshot baselines for `explorer.spec.ts` and `breaches.spec.ts`: refreshed through the `refresh-screenshots` label on the pull request

## WP9: Content pass

The copy that WP3 to WP7 did not rewrite, against plan section 8 and the Plain English layer of
the review (sentences under 20 words on average and none over 30, active voice with a named
actor, "you" and "we", no em dashes or semicolons in body copy, no triads, no "X, not Y").
Taken as review follow-up 9. Legal content keeps its substance; only the wording changes.

- [x] W9-T01 Section-root h1 under 24 characters: `/mcp/` (and the revision pages, which share `MatrixPage.astro`), `/standards/`, `/explorer/`, `/breaches/`, `/mcp/scanner/`; the lede carries what the old headline said
- [x] W9-T02 `src/layouts/Base.astro`: the home `<title>` without the em dash; the footer statement without "in the agent era" and without the triad
- [x] W9-T03 `src/pages/accessibility.astro`: a motion section that lists every animation of plan section 4 in plain words with its reduced-motion form, including Play and auto-advance in the explorer and the cell dialog; the known limits match the matrix on phones (R-05)
- [x] W9-T04 `README.md` and `docs/*.md` pass `check:prose`; `FAIL` is true in `scripts/check-prose.ts`, which also bans "game-changer" and "holistic" from the review's list; `docs/development.md` says the check fails
- [x] W9-T05 `/about/`, `/contribute/`, `/made-in-europe/`: one idea per sentence, no semicolons, no "X, not Y" principle names, no "yours to download"
- [x] W9-T06 `/privacy/`, `/imprint/`, `/disclaimer/`, `/404`: wording only, no semicolons in body copy, headings without the colon-and-triad pattern; legal content unchanged
- [ ] W9-T07 Screenshot baselines for the pages whose head changed, through the `refresh-screenshots` label

## Review follow-ups (21 October 2026)

Items from the redesign review, one pull request each, in order.
Expand All @@ -170,3 +185,4 @@ Items from the redesign review, one pull request each, in order.
- [x] R-06 Matrix on desktop: the table comes straight after the head, and the status key and the live count sit under it. "Filter and group" folds into one quiet button at the end of the head's meta line; opened, the form runs under the whole line. The revision is named once, in the meta line: the lede says "the MCP Authorization specification", the link to its text is "Specification text", and the published date shows only when it differs from the revision id. On wide screens the table fits the column instead of scrolling past its edge (at 1440 px three groups were hidden behind a clipped "Tra…"): its widths come from the group row, a group whose label is wider than its columns (`data-narrow`, from an estimate of the label's length) takes the label's width, and the other columns share the rest down to a 2.25rem floor, below which the frame scrolls. The island redoes the sums when a filter changes the groups. Phones keep R-05. Baselines through the `refresh-screenshots` label
- [x] R-07 Home figures and copy: the facts read Servers, Requirements, Cells observed and Specifications. "Cells observed" counts the current matrix cells that have an observation, beside the total (611 of 897), in place of the 1,664 entries of the append-only history, which counted every scan of the same cell again; `Facts.astro` takes an optional `of` total that stands still while the figure counts up. "Latest observations" shows the six newest observations of the current revision with one row per requirement and per server, so one scan run no longer fills the panel with six rows of the same feature. The seed-data sentence under the list is gone (the server page and `/contribute/` no longer carried one). No baseline changes: the home screenshot stops above the facts
- [x] R-08 The explorer as a scene: one Web Animations API timeline (a target-less scene clock) holds the playhead for Play, Pause, Previous, Next, a scrub bar in the stepper, the arrow keys, a click on a step and the decision log (M6). Auto-advance starts once the chain has sat in view for 1 s with no input, stops for good on hover, touch, focus, scroll or a key, never restarts and never runs under reduced motion; M6 in the plan is amended. Per step the sender and receiver light up in the actor head, the message draws in over 300ms, earlier steps keep a quieter arrow and later ones a hairline (colour, not opacity, so axe passes while stepping) (M5). The changed claims get their rail 40ms apart once the message lands (M7), now through the Web Animations API instead of an `is-fresh` class. A toggle swaps the chain in place through a same-document view transition of 220ms and pushes its URL; back and forward swap again, the analytics count the swap as a pageview (M17). A quiet log under the chain gains one line per step (M18). Durations come from the motion tokens (`--dur-message`, `--stagger` and `--dur-step` are new) through `durationToken()` in `src/lib/motion.ts`. Baselines through the `refresh-screenshots` label
- [x] R-09 Content pass (WP9 above): section-root headlines under 24 characters, the title suffix and footer statement, an accessibility statement that lists every motion with its reduced-motion form, `check:prose` as a gate, and the about, contribute, made-in-Europe, legal and 404 pages in plain English
6 changes: 1 addition & 5 deletions src/components/MatrixPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
<div class="wrap">
<header class="page-head">
<p class="eyebrow">Servers</p>
<h1>Who supports what in <span class="nowrap">MCP Authorization</span></h1>
<h1>Who supports what</h1>
<p class="lede">
The public MCP servers we list, against what the MCP Authorization specification requires
of servers and their authorization servers. Each cell opens the observation behind it.
Expand Down Expand Up @@ -266,10 +266,6 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
</div>

<style>
.nowrap {
white-space: nowrap;
}

/* the revision the matrix is measured against, and the way to the others, as the head's meta line */
.revision-line {
column-gap: var(--space-5);
Expand Down
Loading
Loading