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
6 changes: 3 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,10 @@ boundary dash, hairlines for the rest. It never goes on a page with a data view

The header scrolls away with the page, so `--header-h` (what sticky content must clear) is 0.

Product surfaces go in `product`. The matrix (`MatrixTable`, `StatusCell`, `ObservationDetail`)
Product surfaces go in `product`. The matrix (`MatrixTable`, `StatusCell`, `ClassIcon`)
keeps its styles there through `<style is:global>` instead of scoped styles, because a scoped
style puts an attribute on each of the table's thousands of elements. Their class names are
prefixed (`.matrix`, `.status-cell`, `.status-label`, `.obs`) so they cannot meet another
prefixed (`.matrix`, `.status-cell`, `.status-label`, `.class-icon`) so they cannot meet another
component's.

## 5. Motion
Expand Down Expand Up @@ -256,7 +256,7 @@ package of the redesign that builds it. Anything not in this table does not anim
| M7 | Explorer token view | On step change, claims that differ from the previous hop get a 2px `--signal` rail that fades to `--rule` over 480ms, starting when the message has drawn (300ms) and 40ms apart | "this is what changed in the token" | rail shown without fade | WP6, R-08 |
| M8 | Explorer toggle pages | Changed and removed steps get their rail in the toggle's ink on page load, from transparent over 480ms (one pass, staggered 40ms per step, max 400ms total) | "the toggle touched these" | shown at once | WP6 |
| M9 | Matrix | Row and column crosshair on hover/focus of a cell (`--signal-soft` background, 120ms) | orientation in a large grid | instant | WP4 |
| M10 | Matrix | Cell dialog: backdrop fades, panel moves up 8px, 220ms, using `@starting-style` | "a detail opened" | appears | WP4 |
| M10 | Matrix | Removed: a cell links to its row on the server page instead of opening a dialog (the inline records were most of the page's weight) | n/a | n/a | WP4 |
| M11 | Matrix filters | Count text changes with no animation; hidden rows collapse instantly (today's behaviour) | n/a | n/a | WP4 |
| M12 | Scanner report | The request ladder prints line by line, 60ms apart, when the section scrolls into view, once; the count of checks appears last | "this is what the scanner did, in order" | all lines shown | WP7 |
| M13 | Breach page, spec page revisions | Timeline rail draws in top to bottom, 480ms; on the breach page the dates fade in behind it | "events in sequence" | shown | WP6 |
Expand Down
4 changes: 2 additions & 2 deletions docs/scanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ The levels are never added up into one number, and nothing on the site sorts ser
There is no report page of its own: the server page is where a scan shows. Its status panel
prints the counts and the date, the checks are rows of its feature tables (marked passive scan,
with the metadata each read), and "What the scanner asked" lists the requests behind them. The
atlas matrix shows the cells themselves and a quiet "scan" link next to each server name that
opens that section. `/mcp/servers/<id>/scan/` forwards to the server page; `scan.json` stays.
atlas matrix shows the cells themselves and a faint trace mark (a link named "scan") next to
each server name that opens that section. `/mcp/servers/<id>/scan/` forwards to the server page; `scan.json` stays.

`unknown` is counted as unknown, never as unsupported. A MUST the server may meet in either of
two ways counts as supported when either way is supported, and as unknown while the other way is
Expand Down
4 changes: 3 additions & 1 deletion specs/001-mcp-atlas/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ and date match the underlying observation and link to its evidence.
it is judged against named at the top.
2. **Given** a cell has an observation, **When** the visitor opens the cell, **Then** they see the
status, the method (manual, vendor claim, passive scan, active scan), the date, who observed it
and the evidence link or note.
and the evidence link or note. *(Amended 2026-10-23: the cell is a link to that feature's row on
the server's page, which shows all of these; the matrix no longer carries an inline copy of
each record, which made up most of its weight.)*
3. **Given** a server has no observation for a feature, **When** the matrix renders, **Then** the
cell reads "unknown" and is visually distinct from "unsupported".
4. **Given** the visitor filters by feature level or category, or searches by server name,
Expand Down
64 changes: 64 additions & 0 deletions src/components/ClassIcon.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
import type { AccessClass, AuthClass } from "@/schema";

/**
* The glyph for a server's access or auth class (009): one stroked path on a 16-unit grid, in
* the ink of its context. Plain line drawings with no colour, so a class reads as a fact about
* the server and never as a verdict. Unknown is the dashed ring of a recorded unknown status.
*/
type Props =
| { kind: "access"; value: AccessClass; class?: string }
| { kind: "auth"; value: AuthClass; class?: string };

const { kind, value, class: className } = Astro.props;

const RING = "M8 3.5a4.5 4.5 0 1 0 0 9a4.5 4.5 0 1 0 0-9Z";
const ACCESS: Record<AccessClass, string> = {
// a globe: anyone can connect
public:
"M8 2.5a5.5 5.5 0 1 0 0 11a5.5 5.5 0 1 0 0-11ZM2.5 8h11M8 2.5c-2.2 2.4-2.2 8.6 0 11M8 2.5c2.2 2.4 2.2 8.6 0 11",
// a padlock: connecting needs an account or an invitation
closed: "M4.5 7.5h7v5.5h-7ZM6 7.5V5.5a2 2 0 0 1 4 0v2",
unknown: RING,
};
const AUTH: Record<AuthClass, string> = {
// two arrows: the redirect and token exchange of an OAuth flow
oauth: "M3 6h9.5M10 3.5 12.5 6 10 8.5M13 10H3.5M6 7.5 3.5 10 6 12.5",
"api-key": "M3 8a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0ZM8 8h5.5M11.5 8v2.2M13.5 8v1.6",
// a dash, like not applicable: there is nothing to authenticate with
none: "M4.5 8h7",
unknown: RING,
};
const d = kind === "access" ? ACCESS[value as AccessClass] : AUTH[value as AuthClass];
---

<svg
class:list={["class-icon", { "class-icon--unknown": value === "unknown" }, className]}
viewBox="0 0 16 16"
aria-hidden="true"
>
<path d={d} />
</svg>

<style is:global>
@layer reset, tokens, base, layout, components, product, utilities, print;

@layer product {
.class-icon {
width: 1em;
height: 1em;
flex: none;
vertical-align: -0.15em;
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}

.class-icon--unknown {
stroke-dasharray: 2 1.55;
stroke-linecap: butt;
}
}
</style>
114 changes: 2 additions & 112 deletions src/components/Matrix.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
<script lang="ts">
/**
* Progressive enhancement over the server-rendered matrix table. The table in the DOM is the
* source of truth: this island only hides rows and columns, syncs the query string and shows a
* cell's <details> content in a dialog with focus return. Without JS, the page is complete.
* source of truth: this island only hides rows and columns and syncs the query string. A cell is a
* link to its row on the server page. Without JS, the page is complete.
*/
import { onMount } from "svelte";

let dialog: HTMLDialogElement;
let heading: HTMLHeadingElement;
let detailHost: HTMLDivElement;
let opener: HTMLElement | null = null;

const PARAMS = ["level", "category", "party", "q", "access", "auth", "group"] as const;
type Param = (typeof PARAMS)[number];
// the width in rem below which a group's label counts as wider than its columns (MatrixTable)
Expand Down Expand Up @@ -236,110 +231,5 @@ onMount(() => {
// let the browser clear the fields first
setTimeout(() => apply(), 0);
});

// cell detail in a dialog, with focus return
table.addEventListener("click", (e) => {
const summary = (e.target as HTMLElement).closest<HTMLElement>("details.cell > summary");
if (!summary) return;
e.preventDefault();
const details = summary.parentElement as HTMLDetailsElement;
const content = details.querySelector("[data-detail]");
if (!content) return;
opener = summary;
detailHost.replaceChildren(content.cloneNode(true));
dialog.showModal();
heading.focus();
});
dialog.addEventListener("close", () => {
opener?.focus();
opener = null;
});
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();
});
});
</script>

<dialog bind:this={dialog} class="cell-dialog" aria-labelledby="cell-dialog-title">
<div class="cell-dialog__inner">
<div class="cell-dialog__bar">
<h2 id="cell-dialog-title" tabindex="-1" bind:this={heading}>Observation</h2>
<form method="dialog">
<button type="submit" class="button small quiet" aria-label="Close">Close</button>
</form>
</div>
<div bind:this={detailHost}></div>
</div>
</dialog>

<style>
/*
* M10: the backdrop fades in and the panel rises 8px over --dur, from @starting-style. Flat:
* the backdrop veils the page in its own ground instead of a shadow lifting the panel.
*/
.cell-dialog {
border: 1px solid var(--rule-strong);
border-radius: var(--radius);
background: var(--ground-2);
color: var(--ink);
padding: 0;
width: min(34rem, calc(100vw - 2rem));
opacity: 1;
translate: 0 0;
transition:
opacity var(--dur) var(--ease),
translate var(--dur) var(--ease),
overlay var(--dur) allow-discrete,
display var(--dur) allow-discrete;
}
.cell-dialog:not([open]) {
opacity: 0;
translate: 0 8px;
}
.cell-dialog::backdrop {
background: color-mix(in oklch, var(--ground) 78%, transparent);
opacity: 1;
transition:
opacity var(--dur) var(--ease),
overlay var(--dur) allow-discrete,
display var(--dur) allow-discrete;
}
.cell-dialog:not([open])::backdrop {
opacity: 0;
}
@starting-style {
.cell-dialog[open] {
opacity: 0;
translate: 0 8px;
}
.cell-dialog[open]::backdrop {
opacity: 0;
}
}
.cell-dialog__inner {
padding: var(--space-4) var(--space-5) var(--space-5);
}
.cell-dialog__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.cell-dialog__bar h2 {
font-size: var(--small);
font-weight: 500;
color: var(--ink-2);
margin: 0;
}
.cell-dialog__bar h2:focus {
outline: none;
}
.cell-dialog__bar h2:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 3px;
}
.cell-dialog__bar form {
margin: 0;
}
</style>
40 changes: 34 additions & 6 deletions src/components/MatrixPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { getDataset } from "@/lib/data";
import { CATEGORY_LABEL, PARTY_LABEL, STATUS_ORDER } from "@/lib/labels";
import { latestScan, passiveChecks } from "@/lib/scanner";
import ClassIcon from "./ClassIcon.astro";
import Matrix from "./Matrix.svelte";
import MatrixTable from "./MatrixTable.astro";
import StatusCell from "./StatusCell.astro";
Expand Down Expand Up @@ -69,7 +70,8 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
<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.
of servers and their authorization servers. Each cell links to the observation behind it on
the server's page.
</p>
<div class="page-head__meta revision-line">
<span>
Expand Down Expand Up @@ -194,7 +196,9 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
{" and "}
<abbr title="Authorization server">AS</abbr> name the party the feature is observed on. The
<a href="/mcp/scanner/">nightly scanner</a> reads {scannerChecks} of these requirements from
public metadata; "scan" next to a server's name opens what it asked.
public metadata; the trace mark
<svg class="legend__scan" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 8h2l1.5-4 3 8 1.5-4h3" /></svg>
next to a server's name opens what it asked.
Rows are alphabetical. Nothing here is scored or ranked.
</p>
<dl class="legend__classes" aria-label="Access and auth classes">
Expand All @@ -204,7 +208,10 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
{ACCESS_ORDER.map((v, i) => (
<>
{i > 0 && " · "}
<dfn title={ACCESS_DESCRIPTION[v]}>{ACCESS_LABEL[v].toLowerCase()}</dfn>
<dfn title={ACCESS_DESCRIPTION[v]}>
<ClassIcon kind="access" value={v} class="legend__icon" />
{ACCESS_LABEL[v].toLowerCase()}
</dfn>
</>
))}
: who can connect, as the server page's evidence says.
Expand All @@ -216,11 +223,14 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
{AUTH_ORDER.map((v, i) => (
<>
{i > 0 && " · "}
<dfn title={AUTH_DESCRIPTION[v]}>{AUTH_LABEL[v].toLowerCase()}</dfn>
<dfn title={AUTH_DESCRIPTION[v]}>
<ClassIcon kind="auth" value={v} class="legend__icon" />
{AUTH_LABEL[v].toLowerCase()}
</dfn>
</>
))}
: how the endpoint authenticates clients. A dashed chip is a class nobody has stated
yet. Both come from the evidence on the server page. API-key and unauthenticated
: how the endpoint authenticates clients. A faint mark after a server's name is a class
nobody has stated yet. Both come from the evidence on the server page. API-key and unauthenticated
servers are not scanned.
</dd>
</div>
Expand Down Expand Up @@ -382,6 +392,24 @@ const classWords = (counts: { value: string; count: number }[], label: Record<st
font-size: var(--tiny);
line-height: 1.55;
}
/* the levels as the column heads show them: plain text, not in their level colours */
.legend__note .level {
color: var(--ink);
}
.legend__classes :global(.legend__icon) {
margin-right: 0.2em;
color: var(--ink);
}
.legend__scan {
width: 0.9rem;
height: 0.9rem;
vertical-align: -0.15em;
fill: none;
stroke: var(--ink);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.legend__classes {
flex-basis: 100%;
display: grid;
Expand Down
Loading
Loading