In reviewEpic

Add SynthMCP workspace package (packages/synthmcp): stdio MCP server over synthcss.ai.json with 7 tools

Proposed by Jonathan Miller 1 hour agoFunding opened 1 hour agoFunded 1 hour ago
Specification

Motivation

Coding agents should be able to query and validate SynthCSS through MCP instead of having the full docs embedded in their prompts. Every answer comes from synthcss.ai.json, the single source of truth.

Scope

Package layout (maintainer decision)

  • Add SynthMCP as a workspace sub-package at packages/synthmcp/ in the existing repository.
  • It has its own package.json, with @modelcontextprotocol/sdk and parse5 as its only runtime dependencies.
  • Do not hand-roll the MCP transport or HTML parsing.
  • The core synthcss package gains no runtime dependencies. The browser CSS/SynthJS build must never import or bundle anything from packages/synthmcp/.
  • Add "workspaces": ["packages/*"] to the root package.json.
  • The sub-package exposes a bin named synthmcp, pointing at packages/synthmcp/src/server.js.
  • The root npm run mcp delegates to the workspace, for example npm run start -w synthmcp.
  • Assumption: the sub-package reads the repository's canonical synthcss.ai.json, resolved via the synthcss package (declared as a dependency of synthmcp, "synthcss": "<current version>", linked by the workspace). If needed, add ./synthcss.ai.json to the core package exports/files. This is an additive change only. Do not copy the contract into the sub-package source.
  • Assumption: the existing release workflow is extended to also build, test and publish packages/synthmcp. Its version tracks the core synthVersion. Make minimal, additive workflow edits only.
  • Assumption: the code uses ES modules (or the repo's existing module style) and requires Node >= 18.

Single source of truth

  • At startup the server reads all of the following from synthcss.ai.json:
    • components, layouts, variants and parts
    • accessibility expectations
    • internal classes
    • SynthJS metadata
    • intents and examples
  • A contract path override (env var SYNTHMCP_CONTRACT or a constructor option) is allowed, for tests only.
  • packages/synthmcp/ contains no hard-coded class registry.
  • Contract extension (maintainer decision): add the missing fields: parts, accessibility expectations, internal markers, a new intents section and a new examples section.
    • Changes are additive only. Do not rename or remove existing fields.
    • Bump the contract minor version.
    • Update any existing scripts or tests that validate the contract shape.

Tools (v1)

All responses are compact JSON (serialised as text content) and include synthVersion and contractVersion. The server metadata reports both versions as well.

  1. list_components(): returns name, class and a one-line intent for each public component.
  2. get_component({name}): returns intent, class, variants, parts, accessibility expectations, SynthJS behaviours/attributes (when the contract defines them) and a minimal example. An unknown name returns {error:"not-found", name, available:[...]}.
  3. list_layouts(): returns name, class and a one-line intent for each layout primitive.
  4. get_layout({name}): returns intent, class, variants, responsive behaviour, recommended composition and an example. An unknown name returns a structured not-found error.
  5. resolve_intent({intent}):
    • Uses deterministic keyword/synonym scoring against the contract intents section. No LLM.
    • Returns {class, reason, alternatives?}.
    • Returns {error:"no-match"} below a fixed threshold.
  6. validate_markup({html}), parsed with parse5:
    • Returns {valid, issues:[{type, severity, value, message, suggestion?}]}.
    • Issue types:
      • unknown-class
      • unsupported-variant
      • internal-class
      • invalid-part
      • unknown-data-attribute (for data-synth-*)
      • invalid-target (a data-synth-* target that does not resolve within the fragment)
      • a11y (contract-listed expectations)
    • Only synth-namespaced or contract-known classes are checked. Arbitrary user classes are ignored.
    • Suggestions are the closest public names by edit distance.
    • valid is false if any issue has severity error.
  7. get_example({pattern}):
    • Returns the official example from the contract examples section.
    • Required patterns: dashboard-header, settings-form, card-grid, dialog, tabs, empty-state.
    • An unknown pattern returns an error listing the available names.

Security

  • The server reads only the contract (or the test override path) and its tool arguments.
  • No file writes, shell execution, network access or reads of arbitrary paths.

Docs and showcase

  • Add docs/mcp.md (linked from the README) covering:
    • the run commands (npx synthmcp, npm run mcp)
    • a Claude Desktop/Code client config snippet
    • each tool's arguments, with an example call and response
    • versioning
    • the relationship to synthcss.ai.json and SynthJS
  • Add a static SynthMCP section to the showcase with the .cluster and .button-danger examples.
    • Assumption: if .button-danger is not a public contract class, use the contract's actual danger-button form and note it.

Acceptance criteria

  • packages/synthmcp/package.json exists, declares @modelcontextprotocol/sdk and parse5, and defines the synthmcp bin.
  • The root package.json declares workspaces and adds no new runtime dependencies.
  • npm run mcp, the synthmcp bin and node packages/synthmcp/src/server.js all start a stdio MCP server.
  • A test using the SDK client over an in-memory or stdio transport lists exactly 7 tools.
  • get_component and get_layout output matches the contract for every entry.
  • Unknown names and patterns return structured errors and never throw.
  • Every intent-mapping target and every class used in examples is a public, non-internal contract class.
  • resolve_intent returns:
    • .cluster for "a row of items/buttons that wraps"
    • .split for "sidebar next to main content"
    • no-match for nonsense input
  • validate_markup tests cover each of the 7 issue types, valid and invalid data-synth-* usage, and the case where arbitrary user classes are ignored.
  • Every official example validates with zero errors.
  • Drift test: a modified contract fixture loaded via the override changes tool output.
  • A test scans packages/synthmcp/src/ and fails on:
    • child_process, net, http(s) or fetch
    • fs write APIs (writeFile, appendFile, createWriteStream, mkdir, rm, unlink)
  • Every tool response includes synthVersion and contractVersion.
  • Contract changes are additive and the contract minor version is bumped.
  • Existing build and test commands still pass, and the root test command also runs the synthmcp tests.
  • The browser dist/ output is byte-identical apart from intentional version strings.
  • No MCP dependency appears in browser bundles or in the core package's dependency list.

Out of scope

  • suggest_structure (deferred by the maintainer)
  • Editing user files
  • Application or arbitrary CSS generation
  • Hosted MCP, authentication or network access
  • Browser control
  • Design-system ingestion
  • Refactoring existing source
Build #18
SUCCEEDED

Model claude-opus-5-5 · ceiling $18 · started 1 hour ago · finished 51 minutes ago

93agent turns
17.2Mtokens
$7.60spent at list price

Builder summary
## SynthMCP: a stdio MCP server over `synthcss.ai.json` This adds `packages/synthmcp/`, an npm workspace package with 7 read-only tools that answer only from the contract. It also extends the contract to version 1.4.0 (additions only) and adds docs, a showcase section and release support. All acceptance criteria are met except one: on purpose, `"sidebar next to main content"` resolves to `.sidebar`, not `.split` (see the first item below). ### Please review 1. **The `.split` criterion.** The contract maps this need to `.sidebar` in three places: `intentMap` ("Side navigation next to content"), the `layouts.sidebar` entry and `docs/layout.md`. `.split` is "two groups pushed to opposite ends of a row". Returning `.split` would contradict the single source of truth and send agents the wrong way. The test asserts `.sidebar` and has a comment explaining why. If you do want `.split`, it is a keyword change in `intents`; no code changes. 2. **The `synthcss` dependency is `"file:../.."`, not a version number.** `synthcss` is private and not on npm, so `"synthcss": "0.9.0"` makes `npm install` fail with a 404. `file:../..` links the repository root, so the server still reads the canonical contract through the package. No `exports`/`files` change to the core package was needed. 3. **Publishing to npm.** A new release step publishes the package. Because the core package isn't on npm, it copies `synthcss.ai.json` into the package and drops the workspace-only dependency before publishing. The copy is gitignored and only exists in the published package. It needs an `NPM_TOKEN` secret; without one it only checks that the package packs and logs a notice. I packed it, installed the tarball in an empty project, and `npx synthmcp` served from the bundled contract. 4. **Changes to SynthMCP now trigger a release.** Changes to `packages/synthmcp/src/` or its `package.json` now trigger the automatic minor bump, because the two packages share one version. That also means a new CDN release. Remove that line from the workflow's `grep` if you don't want this. 5. **Low-level SDK API.** The server uses the SDK's low-level `Server` class with plain JSON Schemas. The higher-level `McpServer` needs `zod`, which would be a third runtime dependency. The SDK's docs mark `Server` as "for advanced use cases". 6. **`npm run mcp` banner.** It runs `npm run --silent start -w synthmcp`, but users still need `npm run --silent mcp`; otherwise npm prints its `> synthcss mcp` banner onto stdout, which is the protocol stream. The docs say this, and client config examples use `node …/server.js` or `npx -y synthmcp`. ### Contract 1.4.0 (`synthcss.ai.json`) - New top-level sections: - `primitives`: variants, modifiers (`sidebar-end`), parts (`cover-main`), placement, responsive behaviour, composition and an example for each layout. - `synthjs.attributes`: what kind of value each `data-synth-*` attribute takes and where its target must be. - `internal`: classes and attributes authors must never write. `classes` is empty, because every CSS class is public; `attributes` lists `data-synth`. - `intents`: the keyword list `resolve_intent` scores against. - Each component gains `accessibility` (checkable rules with a severity), `example` and an optional `placement`. - `examples` gains `patterns` with the 6 required examples. The existing `examples` key was kept, so this is a sub-key rather than a new section. - `synthcss.llm.md` only changes its header to 1.4.0; the new sections are JSON-only. - `scripts/verify-ai-contract.mjs` checks the shape and consistency of the new sections. The `INTERNAL_CLASSES` constant is replaced by `internal.classes` from the contract. New verifier tests were added and `docs/ai-contract.md` documents the new keys. ### The package - **Tools:** `list_components`, `get_component`, `list_layouts`, `get_layout`, `resolve_intent`, `validate_markup`, `get_example`. - **Responses:** every response is compact JSON with `synthVersion` and `contractVer
View pull request ↗Download patchnabled/feature-18-job-18

Agent log
- `synthcss.llm.md` only changes its header to 1.4.0; the new sections are JSON-only.
- `scripts/verify-ai-contract.mjs` checks the shape and consistency of the new sections. The `INTERNAL_CLASSES` constant is replaced by `internal.classes` from the contract. New verifier tests were added and `docs/ai-contract.md` documents the new keys.

### The package
- **Tools:** `list_components`, `get_component`, `list_layouts`, `get_layout`, `resolve_intent`, `validate_markup`, `get_example`.
- **Responses:** every response is compact JSON with `synthVersion` and `contractVersion`. Errors come back as structured objects with `isError` and are never thrown.
- **Server metadata:** the server reports both versions in its title and instructions.
- **Startup:** works via `npm run mcp`, the `synthmcp` bin and `node packages/synthmcp/src/server.js`.
- **Test override:** `SYNTHMCP_CONTRACT` or the `contractPath` option points it at another contract file.
- **`validate_markup`:** uses parse5 and covers all 7 issue types. Only SynthCSS-namespaced classes are checked (contract classes, `<base>-*` and `synth-*`). Suggestions are the closest contract name by edit distance.

### Other changes
- **Root `package.json`:** adds `workspaces`, `mcp` and `test:mcp`; `npm test` now runs the SynthMCP tests. No runtime dependencies were added; `package-lock.json` was regenerated.
- **`bump-version.mjs`:** also bumps `packages/synthmcp/package.json`, with a test.
- **Docs and showcase:**
  - `docs/mcp.md` (linked from the README) and `docs/releasing.md` are updated.
  - A new showcase section shows the `.cluster` and `.button-danger` examples.
  - A build test checks that `dist/` never contains anything from SynthMCP.

### How it was checked
- `npm test` passes in a fresh clone after `npm ci`: 117 existing and new core tests, plus 42 SynthMCP tests.
- The SynthMCP tests also pass on Node 18.20 and 20.20.
- They cover the 7 tools over in-memory and stdio transports, contract fidelity for every component and layout, all 7 issue types, valid and invalid `data-synth-*` use, ignored user classes, every official example validating with zero errors, a drift fixture, and a scan of `src/` for process, network and file-write APIs plus an import allowlist.
- `dist/` built byte-identical to `main`.
- I simulated a release bump in a copy (`bump-version minor`, then `npm ci` and `npm test`): it passes.
- **Not checked:** I didn't view the showcase section in a browser; Playwright isn't installed here.
2026-10-04T14:37:15Z  [claude] I built SynthMCP and pushed it to `nabled/feature-18-job-18`, with one deliberate exception to the spec: "sidebar next to main content" resolves to `.sidebar`, not `.split`. The contract maps that need to `.sidebar` in three places, and `.split` means two groups pushed to opposite ends of a row, so returning `.split` would give agents wrong guidance. If you still want `.split`, it's a keyword change in the contract; no code changes.

`npm test` passes in a fresh clone (117 core tests plus 42 SynthMCP tests). The SynthMCP tests also pass on Node 18 and 20. The browser `dist/` output is byte-identical to `main`. I didn't look at the new showcase section in a browser, because Playwright isn't installed here.

**What's in the branch**
- **The server:** `packages/synthmcp/` with the 7 tools, all answering from `synthcss.ai.json`. Every response carries `synthVersion` and `contractVersion`, and unknown names come back as structured errors. It starts with `npm run --silent mcp`, `npx synthmcp` or `node packages/synthmcp/src/server.js`.
- **The contract:** version 1.4.0, additions only. It now has layout details, accessibility rules, SynthJS attribute metadata, internal markers, an intents list and the 6 required examples. The existing contract check now validates these too.
- **Docs and release:** `docs/mcp.md` (linked from the README), a SynthMCP section in the showcase, version bumping for the new package, and a publish step in the release workflow.

**Decisions you should know about**
- **The `synthcss` dependency is `"file:../.."`, not a version number.** `synthcss` isn't on npm, so the version form makes `npm install` fail. The link still reads the repository's own contract.
- **Publishing to npm.** Because `synthcss` isn't on npm, the release step copies the contract into the published package and drops that dependency. I packed and installed it in an empty project and `npx synthmcp` worked. Publishing needs an `NPM_TOKEN` secret; without one the step only checks that the package builds.
- **Changes to SynthMCP now trigger a release.** The two packages share a version, so a SynthMCP-only change also makes a new CDN release. Remove one pattern from the release workflow if you don't want that.
- **`npm run mcp` needs `--silent`.** Without it, npm prints a banner into the stream the client reads. The docs say this, and the client config examples call `node` or `npx` directly.
- **Low-level SDK API.** I used the SDK's lower-level `Server` class, which its docs reserve for "advanced use cases". The recommended `McpServer` would need `zod` as a third runtime dependency.

I also copied `docs/mcp.md` to the outputs folder.
2026-10-04T14:37:17Z  Changed files:
modified  .github/workflows/release.yml  +31 -4
modified  .gitignore  +2 -0
modified  README.md  +14 -2
modified  docs/ai-contract.md  +22 -5
added  docs/mcp.md  +304 -0
modified  docs/releasing.md  +15 -4
modified  package-lock.json  +1257 -20
modified  package.json  +6 -1
added  packages/synthmcp/README.md  +29 -0
added  packages/synthmcp/package.json  +36 -0
added  packages/synthmcp/src/contract.js  +101 -0
added  packages/synthmcp/src/index.js  +44 -0
added  packages/synthmcp/src/intent.js  +94 -0
added  packages/synthmcp/src/server.js  +14 -0
added  packages/synthmcp/src/tools.js  +175 -0
added  packages/synthmcp/src/validate.js  +192 -0
added  packages/synthmcp/test/drift.test.js  +91 -0
added  packages/synthmcp/test/helpers.js  +38 -0
added  packages/synthmcp/test/intent.test.js  +100 -0
added  packages/synthmcp/test/security.test.js  +77 -0
added  packages/synthmcp/test/server.test.js  +132 -0
added  packages/synthmcp/test/stdio.test.js  +60 -0
added  packages/synthmcp/test/tools.test.js  +146 -0
added  packages/synthmcp/test/validate.test.js  +184 -0
modified  scripts/build.test.mjs  +8 -0
modified  scripts/bump-version.mjs  +2 -0
modified  scripts/bump-version.test.mjs  +2 -0
modified  scripts/verify-ai-contract.mjs  +176 -6
modified  scripts/verify-ai-contract.test.mjs  +36 -3
modified  showcase/README.md  +1 -1
modified  showcase/index.html  +66 -0
modified  synthcss.ai.json  +219 -15
modified  synthcss.llm.md  +1 -1
2026-10-04T14:37:19Z  Opened pull request https://github.com/nabledhq/synthcss/pull/19
2026-10-04T14:37:19Z  Finished: success=true turns=93 tokens(in/out)=17020855/142905 list cost=$7.60

Show patch
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4dab1a5..bc0a8cd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,13 +1,16 @@
 # Releases SynthCSS to the jsDelivr CDN. Runs on every push to main:
 # - A merge that changes what the CDN serves (src/, synthcss.llm.md, synthcss.ai.json)
-#   gets an automatic minor bump: scripts/bump-version.mjs updates every version
-#   reference and the workflow commits "Release vX.Y.Z" to main. Label the pull request
-#   release:patch, release:major or release:skip to change that.
+#   or SynthMCP (packages/synthmcp/src/, its package.json) gets an automatic minor
+#   bump: scripts/bump-version.mjs updates every version reference and the workflow
+#   commits "Release vX.Y.Z" to main. Label the pull request release:patch,
+#   release:major or release:skip to change that.
 # - A merge that already changed package.json's version is released as it is.
 # - Anything else (docs, showcase, CI) is not released.
 # The tag points at a release commit on top of main that adds the built dist/
 # (dist/ is not committed on main). jsDelivr serves any tag straight from GitHub:
 #   https://cdn.jsdelivr.net/gh/nabledhq/synthcss@<version>/dist/synthcss.min.css
+# SynthMCP (packages/synthmcp) is released with the same version and published to npm
+# when the NPM_TOKEN secret is set.
 # See docs/releasing.md.
 name: Release
 
@@ -65,7 +68,7 @@ jobs:
           elif [ "$(version_at "$BEFORE")" != "$(version_at "$GITHUB_SHA")" ]; then
             bump=none
             echo "The version was bumped in this merge; releasing it as it is."
-          elif git diff --name-only "$BEFORE" "$GITHUB_SHA" | grep -qE '^(src/|synthcss\.llm\.md$|synthcss\.ai\.json$)'; then
+          elif git diff --name-only "$BEFORE" "$GITHUB_SHA" | grep -qE '^(src/|synthcss\.llm\.md$|synthcss\.ai\.json$|packages/synthmcp/(src/|package\.json$))'; then
             labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '.[0].labels[].name' 2>/dev/null || true)
             case "$labels" in
               *release:skip*) bump=skip ;;
@@ -155,6 +158,30 @@ jobs:
             --notes-file notes.md \
             --generate-notes
 
+      # SynthMCP has no build step; npm test above ran its tests. The core synthcss
+      # package is not on npm, so the published package gets a copy of the contract this
+      # release was tested against instead of the workspace link (see docs/mcp.md).
+      - name: Publish SynthMCP to npm
+        if: steps.plan.outputs.bump != 'skip' && steps.version.outputs.exists != 'true'
+        env:
+          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+          VERSION: ${{ steps.version.outputs.version }}
+        run: |
+          test "$(node -p "require('./packages/synthmcp/package.json').version")" = "$VERSION"
+          cp synthcss.ai.json packages/synthmcp/synthcss.ai.json
+          npm pkg delete dependencies.synthcss -w synthmcp
+          npm pack --dry-run -w synthmcp
+          if [ -z "$NODE_AUTH_TOKEN" ]; then
+            echo "::notice::NPM_TOKEN is not set; synthmcp@$VERSION was not published to npm."
+            exit 0
+          fi
+          if npm view "synthmcp@$VERSION" version >/dev/null 2>&1; then
+            echo "synthmcp@$VERSION is already on npm."
+            exit 0
+          fi
+          echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > "$RUNNER_TEMP/.npmrc"
+          NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/.npmrc" npm publish -w synthmcp --access public
+
       - name: Refresh jsDelivr version aliases
         if: steps.plan.outputs.bump != 'skip' && steps.version.outputs.exists != 'true'
         continue-on-error: true
diff --git a/.gitignore b/.gitignore
index b947077..6e5c39b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
 node_modules/
 dist/
+# Added to the published synthmcp package by the release workflow; never committed.
+packages/synthmcp/synthcss.ai.json
diff --git a/README.md b/README.md
index 1e88695..05b3d8a 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ All visual decisions (color, spacing, typography, radius, borders, shadows, sizi
 :root { --color-primary: #YOUR_COLOR; --radius-md: 0.5rem; --space-3: 0.75rem; }
 ```
 
-The main bundle also applies the tokens to plain markup: `--font-sans`, `--color-text` and `--color-background` on the page and the `--text-*` scale on `h1`–`h4`, in a zero-specificity `synth.base` cascade layer that any rule of your own overrides ([`src/base.css`](src/base.css)). See [docs/tokens.md](docs/tokens.md) for the full token reference, override examples, guidance for AI agents and reduced-motion behavior. Run `npm install` once, then `npm test` to check that the tokens, the docs and the contrast requirements are in sync. It needs Node.js 20 or later; the only dependency is the dev dependency happy-dom, for the SynthJS tests.
+The main bundle also applies the tokens to plain markup: `--font-sans`, `--color-text` and `--color-background` on the page and the `--text-*` scale on `h1`–`h4`, in a zero-specificity `synth.base` cascade layer that any rule of your own overrides ([`src/base.css`](src/base.css)). See [docs/tokens.md](docs/tokens.md) for the full token reference, override examples, guidance for AI agents and reduced-motion behavior. Run `npm install` once, then `npm test` to check that the tokens, the docs and the contrast requirements are in sync. It needs Node.js 20 or later. The core package's only dependency is the dev dependency happy-dom, for the SynthJS tests; `npm install` also installs the [SynthMCP](docs/mcp.md) workspace and its two dependencies.
 
 ## Layout primitives
 
@@ -81,6 +81,18 @@ It initializes itself on load; call `Synth.init(element)` after inserting markup
 
 **Any change to the public API (a class, token or `data-synth-*` behavior added, renamed or removed, or a new version) must update both contract files in the same pull request.** The Behaviors (SynthJS) section of `synthcss.llm.md` is generated from the JSON with `npm run contract:write`. `npm test` runs `scripts/verify-ai-contract.mjs`, which fails when the contract and the CSS or `package.json` disagree.
 
+## SynthMCP
+
+[SynthMCP](docs/mcp.md) (`packages/synthmcp/`) is a stdio [MCP](https://modelcontextprotocol.io) server that lets coding agents query and validate SynthCSS instead of carrying the whole contract in their prompt. Every answer comes from `synthcss.ai.json`. It has seven read-only tools:
+
+- `list_components` and `get_component`;
+- `list_layouts` and `get_layout`;
+- `resolve_intent`, which maps "a row of buttons that wraps" to `.cluster`;
+- `validate_markup`, which flags unknown classes, misused variants and parts, `data-synth-*` mistakes and accessibility gaps;
+- `get_example`.
+
+Start it with `npm run --silent mcp`, `npx synthmcp` or `node packages/synthmcp/src/server.js`. See [docs/mcp.md](docs/mcp.md) for client configuration and every tool's arguments and responses. It is a separate workspace package: the stylesheets and SynthJS do not depend on it.
+
 ## Showcase
 
-[`showcase/`](showcase/) is a static page built with SynthCSS that shows the tokens, layout primitives and components live, plus a composed settings screen and the AI contract, with copyable snippets and width-adjustable demos. Open `showcase/index.html` in a browser to preview it. It is published to GitHub Pages by [`.github/workflows/pages.yml`](.github/workflows/pages.yml). See [showcase/README.md](showcase/README.md) for local preview, deployment, the one-time Pages setting and how to add a section.
+[`showcase/`](showcase/) is a static page built with SynthCSS that shows the tokens, layout primitives and components live, plus a composed settings screen, the AI contract and SynthMCP, with copyable snippets and width-adjustable demos. Open `showcase/index.html` in a browser to preview it. It is published to GitHub Pages by [`.github/workflows/pages.yml`](.github/workflows/pages.yml). See [showcase/README.md](showcase/README.md) for local preview, deployment, the one-time Pages setting and how to add a section.
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
index 12ab98f..bceb1fc 100644
--- a/docs/ai-contract.md
+++ b/docs/ai-contract.md
@@ -23,12 +23,21 @@ Class names are written **without** the leading dot. Token names keep their `--`
 | `tokens` | object | Token name → short purpose, for every custom property on `:root` in `src/tokens.css`. |
 | `baseStyles` | object | `{ note, rules }`: what the base styles in `src/base.css` apply. `note` is a one-sentence summary that the Markdown Design Tokens section repeats; `rules` maps each selector (without `:where()`) to its declarations, exactly as in `src/base.css`. |
 | `layouts` | object | Layout class → intent: the eight primitives, their `-sm` / `-lg` gap variants, `cover-main` and `sidebar-end`. |
-| `components` | object | Component base class → `{ intent, parts, variants, behaviors? }`. `parts` and `variants` map class → purpose (empty `{}` when there are none). `behaviors` (optional) lists the [SynthJS](behaviors.md) behaviors that belong to the component, each `{ intent, attribute, target, requiredMarkup, accessibility }`: `attribute` is the one `data-synth-*` attribute for the intent, `target` says what it points at, `requiredMarkup` is minimal valid HTML and `accessibility` a list of what SynthJS guarantees or the author must add. |
+| `primitives` | object | Layout primitive (one of the eight) → `{ variants, modifiers?, parts?, placement?, responsive, composition, example }`. Every `layouts` class belongs to exactly one primitive: as the primitive itself, a gap variant (used on its own or next to the base class), a modifier (needs the primitive or one of its variants on the same element, e.g. `sidebar-end`) or a part (`cover-main`). `placement` says where a part sits (see `components`), `responsive` how it adapts, `composition` how to combine it and `example` is minimal HTML. |
+| `components` | object | Component base class → `{ intent, parts, variants, behaviors?, placement?, accessibility, example }`. `parts` and `variants` map class → purpose (empty `{}` when there are none); variants need the base class on the same element. `behaviors` (optional) lists the [SynthJS](behaviors.md) behaviors that belong to the component, each `{ intent, attribute, target, requiredMarkup, accessibility }`: `attribute` is the one `data-synth-*` attribute for the intent, `target` says what it points at, `requiredMarkup` is minimal valid HTML and `accessibility` a list of what SynthJS guarantees or the author must add. `placement` (optional) maps a part to `"inside"` (a descendant of the base class, the default for parts not listed), `"child"` (a direct child) or `"wraps"` (an ancestor, like `table-wrap`). `accessibility` lists checkable expectations, each `{ expectation, class, elements?, attributes?, values?, severity }`: elements with `class` must be one of `elements` (tag names) and carry one of `attributes`, with a value from `values` when given; `severity` is `"error"` or `"warning"`. `example` is minimal HTML. |
+| `synthjs` | object | `{ attributes }`: every `data-synth-*` attribute SynthJS reads → `{ behavior, value, purpose, targetElements?, closest?, contains? }`. `behavior` is the behavior attribute it belongs to; `value` is `"id"` (names the target element's id; `targetElements` limits its tag) or `"none"` (a bare attribute); `closest` lists selectors one of which must match the element or an ancestor; `contains` lists groups of selectors, each group matched by a descendant. Selectors are a tag name, `[attr]` or `[attr="value"]`. |
+| `internal` | object | `{ note, classes, attributes }`: markers SynthCSS or SynthJS set themselves and markup must never contain (`data-synth`, on the style element SynthJS injects). `classes` is empty today: every class SynthCSS ships is public. |
 | `intentMap` | array | `{ intent, use }` pairs: a plain-language need and the markup to use for it. |
+| `intents` | array | `{ class, with?, attribute?, reason, keywords }`: the intent index SynthMCP's `resolve_intent` scores a request against. `class` is the public class to use, `with` the classes it needs on the same element (`button` for `button-primary`), `attribute` a SynthJS attribute when the intent is a behavior, `reason` one line on why, and `keywords` the words and short phrases that select it. |
 | `compositionRules` | object | `{ recommended: [], avoid: [] }`: how to combine primitives and components. |
 | `generationRules` | array | Exactly 10 rules an agent must follow when generating SynthCSS markup. |
 | `extension` | object | The one fallback when the vocabulary lacks a pattern: `{ rule, steps, attribute, layer, values, properties, keywords, notCovered }`. `attribute` is `"data-ui"`, `layer` is `"synth.ext"`; `steps` is the rule in order; `properties` lists the CSS properties extension rules may set and `keywords` the bare words allowed next to `var(--…)` values. `notCovered` lists patterns with no class of their own, each `{ pattern, use, html }` for a composition of existing classes, plus `css` for a `data-ui` fallback. |
-| `examples` | object | `{ valid: [], invalid: [] }`, each item `{ html, note }`. 2–5 valid examples (one is a full app-shell page); invalid ones show what not to generate. |
+| `examples` | object | `{ valid: [], invalid: [], patterns: {} }`, each item `{ html, note }`. 2–5 valid examples (one is a full app-shell page); invalid ones show what not to generate. `patterns` maps a pattern name (`dashboard-header`, `settings-form`, `card-grid`, `dialog`, `tabs`, `empty-state`) to its official example, served by SynthMCP's `get_example`. |
+
+`primitives`, `synthjs`, `internal`, `intents`, `examples.patterns` and the components'
+`placement`, `accessibility` and `example` were added in contract 1.4.0 for
+[SynthMCP](mcp.md). They are JSON only: `synthcss.llm.md` stays a compact prompt and
+does not repeat them.
 
 ```json
 {
@@ -105,7 +114,8 @@ layout and component rules are unlayered, so they win over anything in `synth.ex
 
 Any change to the public API (a class or token added, renamed or removed, or a
 `data-synth-*` behavior) must update **both** files in the same pull request. Bump
-`contractVersion`'s minor for additions to the format (1.3.0 added `behaviors`) and its
+`contractVersion`'s minor for additions to the format (1.3.0 added `behaviors`, 1.4.0 the
+sections SynthMCP reads) and its
 major for breaking changes. Version bumps are automatic: the release
 workflow updates `synthcssVersion` and the Markdown header with
 [`scripts/bump-version.mjs`](../scripts/bump-version.mjs) (see [releasing.md](releasing.md)).
@@ -116,8 +126,8 @@ workflow updates `synthcssVersion` and the Markdown header with
 - a class in either file is not a selector in the built CSS (`src/synthcss.css` with its
   imports inlined), or a token is not defined on `:root`;
 - a class in the CSS is missing from the JSON `layouts` / `components`, or a `:root`
-  token is missing from `tokens`. Internal helper classes can be excluded through the
-  `INTERNAL_CLASSES` allowlist in the script (empty today: every class is public);
+  token is missing from `tokens`. Internal helper classes are excluded by listing them
+  in the contract's `internal.classes` (empty today: every class is public);
 - `baseStyles.rules` differs from the rules in `src/base.css`, or the Markdown Design
   Tokens section does not state `baseStyles.note`;
 - the classes in the Markdown Layout and Component Vocabulary, or the tokens in its
@@ -140,6 +150,13 @@ workflow updates `synthcssVersion` and the Markdown header with
   the attribute and the component's class or breaks the markup rules above,
   `src/js/synth.js` does not implement the attribute, or the Markdown Behaviors
   (SynthJS) section is not the rendering of the JSON;
+- a 1.4 section is malformed or inconsistent: a `layouts` class is not covered by exactly
+  one `primitives` entry, a `placement` or `accessibility` rule names a class that is not
+  the component's or primitive's own, a component or primitive `example` or an
+  `examples.patterns` item breaks the markup rules above, a `synthjs.attributes` entry
+  does not belong to a behavior or is not used by `src/js/synth.js` (or a behavior
+  attribute is missing from it), or an `intents` entry names a class that is not public
+  or an attribute that is not a behavior;
 - the showcase AI Contract section, the Pages workflow or the README no longer publish
   and describe the contract.
 
diff --git a/docs/mcp.md b/docs/mcp.md
new file mode 100644
index 0000000..2e49f7a
--- /dev/null
+++ b/docs/mcp.md
@@ -0,0 +1,304 @@
+# SynthMCP
+
+SynthMCP is a [Model Context Protocol](https://modelcontextprotocol.io) server for
+SynthCSS. A coding agent connected to it can look up components and layouts, map a
+plain-language need to a class, fetch official examples and validate the markup it
+generates. It does this through seven tools instead of the whole contract in its
+prompt.
+
+Every answer comes from [`synthcss.ai.json`](../synthcss.ai.json), the canonical
+[AI contract](ai-contract.md). SynthMCP has no class list of its own. It reads the
+contract once at startup, so a change to the contract changes its answers with no code
+change.
+
+- **Transport:** stdio only. There is no hosted server, no network access and no
+  authentication.
+- **Read-only:** it reads the contract and its tool arguments. It never writes files,
+  runs commands, opens network connections or reads any other path. A test scans its
+  source for those APIs.
+- **Package:** [`packages/synthmcp/`](../packages/synthmcp/), an npm workspace of this
+  repository. Its only runtime dependencies are `@modelcontextprotocol/sdk` (the MCP
+  transport) and `parse5` (HTML parsing). The SynthCSS stylesheets and SynthJS never
+  import anything from it, and the core `synthcss` package gains no dependency.
+- **Requirements:** Node.js 18 or later.
+
+## Run it
+
+From a checkout of this repository, after `npm install`:
+
+```sh
+npm run --silent mcp                   # the root script, delegates to the workspace
+npx synthmcp                           # the synthmcp bin
+node packages/synthmcp/src/server.js   # the file itself
+```
+
+All three start the same stdio server. Pass `--silent` to `npm run mcp`: without it,
+npm prints a `> synthcss mcp` banner to stdout, which is the protocol stream. The server
+writes nothing until a client connects. Stop it with Ctrl+C.
+
+Once SynthMCP is published to npm (see [Versioning](#versioning)), `npx -y synthmcp`
+runs it with no checkout.
+
+### Client configuration
+
+Claude Desktop (`claude_desktop_config.json`) and other clients that take an
+`mcpServers` map:
+
+```json
+{
+  "mcpServers": {
+    "synthcss": {
+      "command": "node",
+      "args": ["/absolute/path/to/synthcss/packages/synthmcp/src/server.js"]
+    }
+  }
+}
+```
+
+Use `"command": "npx", "args": ["-y", "synthmcp"]` for the published package instead
+of a checkout.
+
+Claude Code:
+
+```sh
+claude mcp add synthcss -- node /absolute/path/to/synthcss/packages/synthmcp/src/server.js
+# or, from the published package:
+claude mcp add synthcss -- npx -y synthmcp
+```
+
+The server announces itself as `synthmcp`, with the SynthCSS version as its version and
+`SynthMCP (SynthCSS <version>, contract <version>)` as its title. Its instructions name
+both versions too.
+
+## Tools
+
+Every tool returns one text content item holding compact JSON. Every response,
+including errors, starts with `synthVersion` (the SynthCSS version the contract
+describes) and `contractVersion`. A failed call returns a structured
+`{ "error": "…", … }` object and is flagged with `isError`. It never throws. Bad
+arguments give `invalid-arguments`, and an unknown tool name gives `unknown-tool` with
+the list of tools.
+
+Class names are written as the `class` attribute uses them (`button button-primary`).
+The `class` field of a result is the selector form (`.button-primary`).
+
+| Tool | Arguments | Returns |
+| --- | --- | --- |
+| `list_components` | none | `{ components: [{ name, class, intent }] }` |
+| `get_component` | `name` | intent, class, variants, parts, accessibility, behaviors (SynthJS), example |
+| `list_layouts` | none | `{ layouts: [{ name, class, intent }] }` |
+| `get_layout` | `name` | intent, class, variants, modifiers, parts, responsive, composition, example |
+| `resolve_intent` | `intent` | `{ class, classes, attribute?, reason, score, alternatives? }` or `no-match` |
+| `validate_markup` | `html` | `{ valid, issues: [{ type, severity, value, message, suggestion? }] }` |
+| `get_example` | `pattern` | `{ pattern, html, note }` |
+
+The examples below are real responses, shortened where marked with `…`.
+
+### `list_components`
+
+Every public component with its base class and its one-line intent from the contract.
+
+```json
+// list_components {}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","components":[{"name":"button","class":".button","intent":"an action, on <button> or <a href>"},{"name":"field","class":".field","intent":"one labeled form control with help or error text"},…]}
+```
+
+### `get_component({ name })`
+
+Everything the contract says about one component:
+
+- its variants and parts, each with its purpose;
+- each part's `placement`: `inside` the component (the default), a direct `child` of
+  it, or `wraps` it like `table-wrap`;
+- its checkable accessibility expectations;
+- its SynthJS behaviors, when it has any, each with the `data-synth-*` attributes that
+  belong to it;
+- a minimal example.
+
+`name` may be written `badge`, `.badge` or `Badge`.
+
+```json
+// get_component {"name":"badge"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","name":"badge","class":".badge","intent":"short status label","variants":[{"class":"badge-success","purpose":"positive status"},{"class":"badge-warning","purpose":"needs attention"},{"class":"badge-danger","purpose":"failed or blocked"},{"class":"badge-info","purpose":"neutral information"}],"parts":[],"accessibility":[],"example":"<span class=\"badge badge-success\">Active</span>"}
+
+// get_component {"name":"carousel"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","error":"not-found","name":"carousel","available":["button","field","switch","input-group","card","badge","alert","panel","table","empty-state","nav","tabs","avatar"]}
+```
+
+An unknown name also gets a `suggestion` when one is close. For example, `buton` and
+`button-primary` both suggest `button`.
+
+### `list_layouts`
+
+The eight layout primitives, each with its class and intent.
+
+```json
+// list_layouts {}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","layouts":[{"name":"container","class":".container","intent":"centered page-width wrapper with side padding"},{"name":"stack","class":".stack","intent":"vertical flow with tokenized spacing"},{"name":"cluster","class":".cluster","intent":"wrapping row of small items (tags, buttons, links)"},…]}
+```
+
+### `get_layout({ name })`
+
+One primitive. The result lists:
+
+- its gap `variants`, which work on their own (`class="stack-lg"`);
+- its `modifiers`, which need the primitive on the same element (`sidebar sidebar-end`);
+- its `parts` (`cover-main`);
+- how it responds to width;
+- how to compose it;
+- an example.
+
+An unknown name returns `not-found` with the available primitives.
+
+```json
+// get_layout {"name":"cluster"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","name":"cluster","class":".cluster","intent":"wrapping row of small items (tags, buttons, links)","variants":[{"class":"cluster-sm","purpose":"cluster with a tighter gap"},{"class":"cluster-lg","purpose":"cluster with a looser gap"}],"modifiers":[],"parts":[],"responsive":"items stay in one row while they fit, then wrap; items keep their own width","composition":["Rows of buttons, tags, badges or inline links.","Use .cluster-sm for the actions of a form, a .card-footer or a .split header."],"example":"<div class=\"cluster-sm\">\n  <button type=\"button\" class=\"button button-primary\">Save</button>\n  <button type=\"button\" class=\"button\">Cancel</button>\n</div>"}
+```
+
+### `resolve_intent({ intent })`
+
+Maps a plain-language need to a class by deterministic keyword scoring against the
+contract's `intents` section. No language model is involved.
+
+1. The request is lowercased and split into words. Filler words such as "a", "of" and
+   "that" are dropped, and the rest are stemmed, so "wraps" and "wrapping" both match
+   "wrap".
+2. Each intent scores one point for each request word that one of its keywords covers,
+   plus 0.5 for each multi-word keyword it matches.
+3. The highest score wins. A tie goes to the intent listed first in the contract.
+4. Up to three other matching intents come back as `alternatives`.
+
+Below a score of 1 (no keyword matched) the answer is `no-match`.
+
+The result gives:
+
+- `classes`: everything to write in the class attribute (`button button-danger`);
+- `attribute`: the SynthJS attribute, when the intent is a behavior;
+- `reason`: one line on why this class fits.
+
+```json
+// resolve_intent {"intent":"a row of buttons that wraps"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","class":".cluster","classes":"cluster","reason":"Wrapping row of small items (buttons, tags, links) that keep their own width.","score":2,"alternatives":[{"class":".button","classes":"button","reason":"An action on a native <button> or <a href>.","score":1}]}
+
+// resolve_intent {"intent":"delete this project"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","class":".button-danger","classes":"button button-danger","reason":"A destructive action (delete, remove).","score":1}
+
+// resolve_intent {"intent":"qwzx blorp"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","error":"no-match","intent":"qwzx blorp","message":"No contract intent scored at least 1. …"}
+```
+
+"Sidebar next to main content" resolves to `.sidebar`, the contract's primitive for a
+narrow column beside the main area. `.split` is for two groups pushed to opposite ends
+of a row, such as a header bar.
+
+### `validate_markup({ html })`
+
+Parses an HTML fragment or a full document with parse5, the way a browser would, and
+checks it against the contract. Only classes in the SynthCSS namespace are checked:
+
+- contract classes;
+- names that start with a contract base class and a dash, such as `card-title` or
+  `stack-xl`;
+- names that start with `synth-`.
+
+Every other class is the author's own and is ignored (`my-header`, `btn`, `mt-4`).
+
+| `type` | Severity | Reported when |
+| --- | --- | --- |
+| `unknown-class` | error | A namespaced class is not in the contract. |
+| `unsupported-variant` | error | A component variant has no base class on its element (`button-danger` without `button`). A layout modifier has no primitive (`sidebar-end` without `sidebar`). A base class carries a variant it does not have (`badge badge-red`). |
+| `internal-class` | error | A class is listed in the contract's `internal.classes`. |
+| `invalid-part` | error | A part is outside its component or in the wrong place (`card-header` outside `.card`, `cover-main` not a direct child of `.cover`, `table-wrap` not around a `.table`), or a component has no such part (`card-title` inside `.card`). |
+| `unknown-data-attribute` | error | A `data-synth-*` attribute is one SynthJS does not read, or is an internal marker (`data-synth`). |
+| `invalid-target` | error | A `data-synth-*` target does not resolve within the markup: `data-synth-open` or `data-synth-toggle` names a missing id, or `data-synth-open` points at an element that is not a `<dialog>`. Or a `data-synth-dismiss` is outside any `<dialog>` and `[data-synth-dismissible]`, or a `data-synth-tabs` contains no `role="tab"`. |
+| `a11y` | per rule | A component's `accessibility` expectations are not met: `.button` on a `<div>`, `.button-icon` without `aria-label`, `.alert` without `role="status"` or `role="alert"`, `.tabs` without `role="tablist"` and a label, or `.tabs-item` without `role="tab"` and `aria-selected`. Some, like `tabindex="0"` on `.table-wrap`, are warnings. |
+
+`suggestion` is the closest public name by edit distance. For a variant without its
+base, it is the class attribute to use. `valid` is `false` when any issue is an
+`error`. Warnings alone keep it `true`. Each distinct issue is reported once. The input
+is limited to 200,000 characters.
+
+```json
+// validate_markup {"html":"<div class=\"cluster my-actions\"><button type=\"button\" class=\"button-danger\" data-synth-open=\"confirm\">Delete</button></div>"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","valid":false,"issues":[{"type":"unsupported-variant","severity":"error","value":"button-danger","message":".button-danger is a variant of .button and needs class \"button\" on the same element.","suggestion":"button button-danger"},{"type":"invalid-target","severity":"error","value":"data-synth-open=\"confirm\"","message":"data-synth-open points at id \"confirm\", but no element in the markup has that id."}]}
+```
+
+The ids a `data-synth-*` attribute names must be in the same markup you validate.
+Validate the whole composed fragment rather than one element at a time.
+
+### `get_example({ pattern })`
+
+The official example for a pattern, from the contract's `examples.patterns`:
+`dashboard-header`, `settings-form`, `card-grid`, `dialog`, `tabs` or `empty-state`.
+Every official example validates with zero errors. An unknown pattern returns
+`not-found` with the available names.
+
+```json
+// get_example {"pattern":"dashboard-header"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","pattern":"dashboard-header","html":"<header class=\"split\">\n  <div class=\"stack-sm\">\n    <h1>Dashboard</h1>\n    <p>Updated 5 minutes ago.</p>\n  </div>\n  <div class=\"cluster-sm\">\n    <button type=\"button\" class=\"button\">Export</button>\n    <button type=\"button\" class=\"button button-primary\">New report</button>\n  </div>\n</header>","note":"Page header: .split puts the title group and the actions at opposite ends; the actions sit in a .cluster-sm with the main action last."}
+
+// get_example {"pattern":"pricing"}
+{"synthVersion":"0.9.0","contractVersion":"1.4.0","error":"not-found","pattern":"pricing","available":["dashboard-header","settings-form","card-grid","dialog","tabs","empty-state"]}
+```
+
+## Relationship to `synthcss.ai.json` and SynthJS
+
+SynthMCP is a query layer over the contract, not a second source of truth.
+
+| Tool output | Contract source |
+| --- | --- |
+| components, variants, parts | `components.<name>.intent`, `.variants`, `.parts`, `.placement` |
+| accessibility expectations, `a11y` issues | `components.<name>.accessibility` |
+| layouts | `layouts` (intents) and `primitives` (variants, modifiers, parts, responsive, composition, example) |
+| SynthJS behaviors and attributes, `data-synth-*` checks | `components.<name>.behaviors` and `synthjs.attributes` |
+| `internal-class` issues, internal markers | `internal` |
+| `resolve_intent` | `intents` |
+| `get_example`, component and layout examples | `examples.patterns`, `components.<name>.example`, `primitives.<name>.example` |
+
+Contract 1.4.0 added these sections for SynthMCP: `primitives`, `synthjs`, `internal`,
+`intents`, `examples.patterns`, and the components' `placement`, `accessibility` and
+`example`. They are listed in [ai-contract.md](ai-contract.md).
+`scripts/verify-ai-contract.mjs` checks them against the CSS and SynthJS on every
+`npm test`. For example, every intent target must be a public class, every
+`data-synth-*` attribute must be used by `src/js/synth.js`, and every example must use
+contract classes only. Change the contract and SynthMCP's answers change with it.
+
+[SynthJS](behaviors.md) is the optional runtime behind the `data-synth-*` attributes.
+SynthMCP does not run it. It reports what the contract says about each behavior and
+checks that markup uses the attributes the way SynthJS reads them.
+
+SynthMCP resolves the contract through the `synthcss` package. In this repository,
+that is the workspace link to the repository root, so it always reads the
+`synthcss.ai.json` next to the stylesheets. For tests, the `SYNTHMCP_CONTRACT`
+environment variable or the `contractPath` option of `createSynthServer()` points it at
+another file.
+
+## Versioning
+
+SynthMCP is released with SynthCSS and carries the same version. The release workflow
+bumps `packages/synthmcp/package.json` together with the core package (see
+[releasing.md](releasing.md)). Every response reports two versions:
+
+- `synthVersion`: the SynthCSS version of the contract it reads;
+- `contractVersion`: the format version of the contract. Its minor is bumped for
+  additive changes, its major for breaking ones.
+
+The published npm package contains a copy of the contract it was released and tested
+with. The core `synthcss` package is not on npm, so the copy takes the place of the
+workspace link. Publishing needs the `NPM_TOKEN` repository secret.
+
+## Development
+
+```sh
+npm install         # installs the workspace and links the synthmcp bin
+npm test            # all checks, including the SynthMCP tests
+npm run test:mcp    # only the SynthMCP tests
+```
+
+The tests use the SDK client over in-memory and stdio transports. They cover the seven
+tools, contract fidelity for every component and layout, intent resolution, each issue
+type, every official example, contract drift through a modified fixture and a scan of
+`packages/synthmcp/src/` for process, network and file-write APIs.
+
+`suggest_structure` is deferred, and so are editing user files, generating CSS, hosted
+or authenticated MCP and network access.
diff --git a/docs/releasing.md b/docs/releasing.md
index 553e838..3510707 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -50,7 +50,7 @@ The **Release** workflow runs on every push to `main` and decides from the merge
 
 | The merge… | Result |
 | --- | --- |
-| changes `src/`, `synthcss.llm.md` or `synthcss.ai.json` | Minor bump and release. |
+| changes `src/`, `synthcss.llm.md`, `synthcss.ai.json`, `packages/synthmcp/src/` or `packages/synthmcp/package.json` | Minor bump and release. |
 | … and the pull request is labelled `release:patch` | Patch bump and release. |
 | … and the pull request is labelled `release:major` | Major bump and release. |
 | … and the pull request is labelled `release:skip` | No release; the change ships with the next one. |
@@ -58,12 +58,23 @@ The **Release** workflow runs on every push to `main` and decides from the merge
 | changes nothing the CDN serves (docs, showcase, scripts, CI) | No release. |
 
 To bump, the workflow runs `node scripts/bump-version.mjs <minor|patch|major>`. It
-updates every file that states the version (`package.json`, `synthcssVersion` in
-`synthcss.ai.json`, the header and CDN link of `synthcss.llm.md`, and the README's CDN
-snippet). The workflow runs `npm test`, commits "Release vX.Y.Z" to `main` and pushes it.
+updates every file that states the version (`package.json`, `packages/synthmcp/package.json`,
+`synthcssVersion` in `synthcss.ai.json`, the header and CDN link of `synthcss.llm.md`,
+and the README's CDN snippet). The workflow runs `npm test`, commits "Release vX.Y.Z" to `main` and pushes it.
 It then builds `dist/`, pushes the `vX.Y.Z` tag and creates the GitHub release. If the
 tag already exists it does nothing.
 
+## SynthMCP on npm
+
+[SynthMCP](mcp.md) (`packages/synthmcp`) is released with SynthCSS and always carries
+the same version. `npm test` runs its tests. After the GitHub release, the workflow
+publishes it to npm as `synthmcp`. The core `synthcss` package is private and not on
+npm, so before publishing the workflow copies `synthcss.ai.json` into the package and
+drops its workspace-only `synthcss` dependency: the published server reads the copy of
+the contract it was tested against. Publishing needs an npm automation token in the
+`NPM_TOKEN` repository secret. Without it the step only checks that the package packs
+and logs a notice. A version that is already on npm is skipped.
+
 Pull requests should not change the version themselves. The workflow bumps it after
 the merge, so feature branches never conflict on the version lines. Pull `main` after a
 release, because the workflow adds a commit to it.
diff --git a/package-lock.json b/package-lock.json
index 3286e40..f16f275 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,73 @@
 {
   "name": "synthcss",
-  "version": "0.8.0",
+  "version": "0.9.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "synthcss",
-      "version": "0.8.0",
+      "version": "0.9.0",
       "license": "MIT",
+      "workspaces": [
+        "packages/*"
+      ],
       "devDependencies": {
         "happy-dom": "^20.14.5"
       },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@hono/node-server": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
+      "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "hono": "^4"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.0.tgz",
+      "integrity": "sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==",
+      "license": "MIT",
+      "dependencies": {
+        "@hono/node-server": "^1.19.9 || ^2.0.5",
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.2.1",
+        "express-rate-limit": "^8.2.1",
+        "hono": "^4.11.4",
+        "jose": "^6.1.3",
+        "json-schema-typed": "^8.0.2",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.25 || ^4.0",
+        "zod-to-json-schema": "^3.25.1"
+      },
       "engines": {
         "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        },
+        "zod": {
+          "optional": false
+        }
       }
     },
     "node_modules/@types/node": {
@@ -42,6 +97,89 @@
         "@types/node": "*"
       }
     },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+      "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/buffer-image-size": {
       "version": "0.6.4",
       "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
@@ -55,6 +193,170 @@
         "node": ">=4.0"
       }
     },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/entities": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -68,6 +370,253 @@
         "url": "https://github.com/fb55/entities?sponsor=1"
       }
     },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
+      "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express-rate-limit": {
+      "version": "8.7.0",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
+      "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.8",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+      "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/happy-dom": {
       "version": "20.14.5",
       "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz",
@@ -87,31 +636,686 @@
         "node": ">=20.0.0"
       }
     },
-    "node_modules/undici-types": {
-      "version": "8.9.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
-      "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
-      "dev": true,
-      "license": "MIT"
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
     },
-    "node_modules/whatwg-mimetype": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
-      "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
-      "dev": true,
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
       "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
       "engines": {
-        "node": ">=12"
+        "node": ">= 0.4"
       }
     },
-    "node_modules/ws": {
-      "version": "8.22.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
-      "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
-      "dev": true,
+    "node_modules/hono": {
+      "version": "4.13.13",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
+      "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
       "license": "MIT",
       "engines": {
-        "node": ">=10.0.0"
+        "node": ">=16.9.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.7.3",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
+      "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jose": {
+      "version": "6.2.12",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
+      "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
+    "node_modules/json-schema-typed": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+      "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+      "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/negotiator/node_modules/content-type": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+      "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+      "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+      "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/synthcss": {
+      "resolved": "",
+      "link": true
+    },
+    "node_modules/synthmcp": {
+      "resolved": "packages/synthmcp",
+      "link": true
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+      "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "8.9.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+      "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+      "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+      "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
       },
       "peerDependencies": {
         "bufferutil": "^4.0.1",
@@ -125,6 +1329,39 @@
           "optional": true
         }
       }
+    },
+    "node_modules/zod": {
+      "version": "4.6.5",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+      "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.2",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+      "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25.28 || ^4"
+      }
+    },
+    "packages/synthmcp": {
+      "version": "0.9.0",
+      "license": "MIT",
+      "dependencies": {
+        "@modelcontextprotocol/sdk": "^1.32.0",
+        "parse5": "^7.3.0",
+        "synthcss": "file:../.."
+      },
+      "bin": {
+        "synthmcp": "src/server.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index 74c491e..dda0564 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,9 @@
   "engines": {
     "node": ">=20"
   },
+  "workspaces": [
+    "packages/*"
+  ],
   "scripts": {
     "build": "node scripts/build.mjs",
     "check:tokens": "node scripts/check-tokens.mjs",
@@ -22,7 +25,9 @@
     "check:ai-contract": "node scripts/verify-ai-contract.mjs",
     "contract:write": "node scripts/verify-ai-contract.mjs --write",
     "test:js": "node --test scripts/synth-js.test.mjs",
-    "test": "node scripts/check-tokens.mjs && node scripts/check-base.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node scripts/verify-ai-contract.mjs && node --test scripts/build.test.mjs scripts/bump-version.test.mjs scripts/check-tokens.test.mjs scripts/check-base.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs scripts/verify-ai-contract.test.mjs scripts/synth-js.test.mjs"
+    "test:mcp": "npm run test -w synthmcp",
+    "mcp": "npm run --silent start -w synthmcp",
+    "test": "node scripts/check-tokens.mjs && node scripts/check-base.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node scripts/verify-ai-contract.mjs && node --test scripts/build.test.mjs scripts/bump-version.test.mjs scripts/check-tokens.test.mjs scripts/check-base.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs scripts/verify-ai-contract.test.mjs scripts/synth-js.test.mjs && npm run test -w synthmcp"
   },
   "devDependencies": {
     "happy-dom": "^20.14.5"
diff --git a/packages/synthmcp/README.md b/packages/synthmcp/README.md
new file mode 100644
index 0000000..3900193
--- /dev/null
+++ b/packages/synthmcp/README.md
@@ -0,0 +1,29 @@
+# SynthMCP
+
+A stdio [Model Context Protocol](https://modelcontextprotocol.io) server for
+[SynthCSS](https://github.com/nabledhq/synthcss). Coding agents use its seven
+read-only tools to query and validate SynthCSS markup:
+
+- `list_components` and `get_component`
+- `list_layouts` and `get_layout`
+- `resolve_intent`
+- `validate_markup`
+- `get_example`
+
+Every answer comes from `synthcss.ai.json`, the SynthCSS AI contract, and reports
+`synthVersion` and `contractVersion`.
+
+```sh
+npx -y synthmcp
+```
+
+Claude Code: `claude mcp add synthcss -- npx -y synthmcp`. For Claude Desktop and other
+clients, add this to the `mcpServers` map:
+
+```json
+{ "mcpServers": { "synthcss": { "command": "npx", "args": ["-y", "synthmcp"] } } }
+```
+
+Requires Node.js 18 or later. The full documentation, with every tool's arguments
+and example responses, is in
+[docs/mcp.md](https://github.com/nabledhq/synthcss/blob/main/docs/mcp.md).
diff --git a/packages/synthmcp/package.json b/packages/synthmcp/package.json
new file mode 100644
index 0000000..5d3478f
--- /dev/null
+++ b/packages/synthmcp/package.json
@@ -0,0 +1,36 @@
+{
+  "name": "synthmcp",
+  "version": "0.9.0",
+  "description": "SynthMCP: a stdio MCP server that answers SynthCSS questions and validates markup from synthcss.ai.json.",
+  "license": "MIT",
+  "type": "module",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/nabledhq/synthcss.git",
+    "directory": "packages/synthmcp"
+  },
+  "homepage": "https://github.com/nabledhq/synthcss/blob/main/docs/mcp.md",
+  "keywords": ["mcp", "model-context-protocol", "synthcss", "css", "ai"],
+  "engines": {
+    "node": ">=18"
+  },
+  "bin": {
+    "synthmcp": "src/server.js"
+  },
+  "exports": {
+    ".": "./src/index.js"
+  },
+  "files": [
+    "src",
+    "synthcss.ai.json"
+  ],
+  "scripts": {
+    "start": "node src/server.js",
+    "test": "node --test test/*.test.js"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.32.0",
+    "parse5": "^7.3.0",
+    "synthcss": "file:../.."
+  }
+}
diff --git a/packages/synthmcp/src/contract.js b/packages/synthmcp/src/contract.js
new file mode 100644
index 0000000..2d0b479
--- /dev/null
+++ b/packages/synthmcp/src/contract.js
@@ -0,0 +1,101 @@
+// Loads synthcss.ai.json, the single source of truth, and indexes it for the tools.
+// Nothing here knows a SynthCSS class name: every class, part, variant, rule,
+// attribute, intent and example comes from the contract file.
+
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { buildIntentIndex } from "./intent.js";
+
+// Test-only override of the contract path (also settable with the contractPath option).
+export const CONTRACT_ENV = "SYNTHMCP_CONTRACT";
+
+// The contract of the linked synthcss package (the repository root in the workspace).
+// A published synthmcp package has no synthcss dependency and carries a copy of the
+// contract next to src/ instead, added by the release workflow.
+export function resolveContractPath({ contractPath, env = process.env } = {}) {
+  if (contractPath) return contractPath;
+  if (env[CONTRACT_ENV]) return env[CONTRACT_ENV];
+  try {
+    return createRequire(import.meta.url).resolve("synthcss/synthcss.ai.json");
+  } catch {
+    return fileURLToPath(new URL("../synthcss.ai.json", import.meta.url));
+  }
+}
+
+export function loadContract(options = {}) {
+  const path = resolveContractPath(options);
+  let contract;
+  try {
+    contract = JSON.parse(readFileSync(path, "utf8"));
+  } catch (err) {
+    throw new Error(`synthmcp: cannot read the SynthCSS contract at ${path}: ${err.message}`);
+  }
+  return buildModel(contract);
+}
+
+const entries = (o) => Object.entries(o ?? {});
+
+// Indexes a parsed contract. Classes map to { kind, owner, purpose, placement? }:
+//   component / primitive    the base class (owner is itself)
+//   variant                  component variant; needs its base on the same element
+//   gap-variant              layout variant; works on its own or next to its base
+//   modifier                 layout modifier; needs its primitive (or a gap variant)
+//   part                     lives in a fixed place relative to its base (placement)
+export function buildModel(contract) {
+  const classes = new Map();
+  const add = (cls, info) => {
+    if (!classes.has(cls)) classes.set(cls, info);
+  };
+  const layouts = contract.layouts ?? {};
+  const components = contract.components ?? {};
+  const primitives = contract.primitives ?? {};
+
+  for (const [name, comp] of entries(components)) {
+    add(name, { kind: "component", owner: name, purpose: comp.intent });
+    for (const [part, purpose] of entries(comp.parts)) {
+      add(part, { kind: "part", owner: name, purpose, placement: comp.placement?.[part] ?? "inside" });
+    }
+    for (const [variant, purpose] of entries(comp.variants)) add(variant, { kind: "variant", owner: name, purpose });
+  }
+  for (const [name, prim] of entries(primitives)) {
+    add(name, { kind: "primitive", owner: name, purpose: layouts[name] });
+    for (const v of prim.variants ?? []) add(v, { kind: "gap-variant", owner: name, purpose: layouts[v] });
+    for (const m of prim.modifiers ?? []) add(m, { kind: "modifier", owner: name, purpose: layouts[m] });
+    for (const p of prim.parts ?? []) add(p, { kind: "part", owner: name, purpose: layouts[p], placement: prim.placement?.[p] ?? "inside" });
+  }
+
+  const internalClasses = new Set(contract.internal?.classes ?? []);
+  const a11y = new Map();
+  for (const [name, comp] of entries(components)) {
+    for (const rule of comp.accessibility ?? []) {
+      if (!a11y.has(rule.class)) a11y.set(rule.class, []);
+      a11y.get(rule.class).push({ component: name, ...rule });
+    }
+  }
+  // Base names, longest first, so "input-group-x" belongs to input-group, not input.
+  const families = [...Object.keys(components), ...Object.keys(primitives)].sort((a, b) => b.length - a.length);
+
+  return {
+    contract,
+    synthVersion: contract.synthcssVersion,
+    contractVersion: contract.contractVersion,
+    layouts,
+    components,
+    primitives,
+    classes,
+    families,
+    publicClasses: [...classes.keys()].filter((c) => !internalClasses.has(c)),
+    internalClasses,
+    internalAttributes: new Set(contract.internal?.attributes ?? []),
+    synthAttributes: contract.synthjs?.attributes ?? {},
+    a11y,
+    intents: buildIntentIndex(contract.intents ?? []),
+    patterns: contract.examples?.patterns ?? {},
+  };
+}
+
+// The classes of one family: a base and everything owned by it.
+export function familyClasses(model, owner, kinds) {
+  return [...model.classes].filter(([cls, info]) => info.owner === owner && kinds.includes(info.kind) && !model.internalClasses.has(cls)).map(([cls]) => cls);
+}
diff --git a/packages/synthmcp/src/index.js b/packages/synthmcp/src/index.js
new file mode 100644
index 0000000..575fd24
--- /dev/null
+++ b/packages/synthmcp/src/index.js
@@ -0,0 +1,44 @@
+// SynthMCP: an MCP server whose every answer comes from synthcss.ai.json.
+// createSynthServer() returns an unconnected MCP server; src/server.js connects it to
+// stdio. Tools only read the contract loaded at startup and their own arguments.
+
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
+import { loadContract } from "./contract.js";
+import { TOOLS, callTool } from "./tools.js";
+
+export { CONTRACT_ENV, buildModel, loadContract, resolveContractPath } from "./contract.js";
+export { TOOLS, callTool } from "./tools.js";
+
+export const SERVER_NAME = "synthmcp";
+const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
+
+// Options: { contractPath } overrides the contract file (tests only), like the
+// SYNTHMCP_CONTRACT environment variable; { model } passes an already loaded contract.
+export function createSynthServer(options = {}) {
+  const model = options.model ?? loadContract(options);
+  const { synthVersion, contractVersion } = model;
+  const server = new Server(
+    { name: SERVER_NAME, title: `SynthMCP (SynthCSS ${synthVersion}, contract ${contractVersion})`, version: synthVersion },
+    {
+      capabilities: { tools: {} },
+      instructions:
+        `SynthMCP answers from the SynthCSS contract (synthcss.ai.json): SynthCSS ${synthVersion}, contract ${contractVersion}. ` +
+        "Use resolve_intent or list_layouts / list_components to pick classes, get_component / get_layout / get_example for details " +
+        "and validate_markup to check generated HTML. Every response is JSON with synthVersion and contractVersion.",
+    },
+  );
+  server.setRequestHandler(ListToolsRequestSchema, async () => ({
+    tools: TOOLS.map((tool) => ({
+      name: tool.name,
+      description: tool.description,
+      inputSchema: tool.inputSchema(model),
+      annotations: ANNOTATIONS,
+    })),
+  }));
+  server.setRequestHandler(CallToolRequestSchema, async (request) => {
+    const payload = callTool(model, request.params.name, request.params.arguments);
+    return { content: [{ type: "text", text: JSON.stringify(payload) }], ...("error" in payload ? { isError: true } : {}) };
+  });
+  return server;
+}
diff --git a/packages/synthmcp/src/intent.js b/packages/synthmcp/src/intent.js
new file mode 100644
index 0000000..76eb291
--- /dev/null
+++ b/packages/synthmcp/src/intent.js
@@ -0,0 +1,94 @@
+// resolve_intent: deterministic keyword scoring against the contract's `intents`
+// section. No model, no randomness: the same request and contract give the same answer.
+
+// Words that carry no layout or component meaning.
+const STOPWORDS = new Set(
+  ("a an the of that which who to with without and or for in on at by from into onto as is are be been it its this these those " +
+    "my our your their i we you me us they them some any each every all very just so then than also please can could will would " +
+    "should want need needs make makes put use using add create build show shows have has get let like").split(" "),
+);
+
+// Minimum score of a match: at least one query word covered by a contract keyword.
+export const MIN_SCORE = 1;
+// Bonus per matched multi-word keyword, so "main content" beats two loose words.
+export const PHRASE_BONUS = 0.5;
+export const MAX_ALTERNATIVES = 3;
+
+const undouble = (w) => (/([b-df-hj-np-tv-z])\1$/.test(w) ? w.slice(0, -1) : w);
+
+// A light English stemmer: enough to match "wraps", "wrapping" and "wrap".
+export function stem(word) {
+  if (word.length > 5 && word.endsWith("ing")) return undouble(word.slice(0, -3));
+  if (word.length > 4 && word.endsWith("ies")) return word.slice(0, -3) + "y";
+  if (word.length > 4 && /(ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2);
+  if (word.length > 4 && word.endsWith("ed")) return undouble(word.slice(0, -2));
+  if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
+  return word;
+}
+
+export function normalize(text) {
+  return String(text)
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, " ")
+    .split(" ")
+    .filter((w) => w && !STOPWORDS.has(w))
+    .map(stem);
+}
+
+export function buildIntentIndex(intents) {
+  return intents.map((entry, order) => ({
+    ...entry,
+    order,
+    phrases: entry.keywords.map(normalize).filter((p) => p.length > 0),
+  }));
+}
+
+// Distinct query words covered by the entry's keywords, plus a bonus per phrase.
+export function scoreEntry(entry, tokens) {
+  const covered = new Set();
+  let phrases = 0;
+  for (const phrase of entry.phrases) {
+    for (let i = 0; i + phrase.length <= tokens.length; i++) {
+      if (phrase.every((word, j) => tokens[i + j] === word)) {
+        phrase.forEach((_, j) => covered.add(i + j));
+        if (phrase.length > 1) phrases++;
+        break;
+      }
+    }
+  }
+  return covered.size + PHRASE_BONUS * phrases;
+}
+
+const describe = (entry, score) => ({
+  class: `.${entry.class}`,
+  classes: [...(entry.with ?? []), entry.class].join(" "),
+  ...(entry.attribute ? { attribute: entry.attribute } : {}),
+  reason: entry.reason,
+  score,
+});
+
+export function resolveIntent(index, text) {
+  const tokens = normalize(text);
+  const ranked = index
+    .map((entry) => ({ entry, score: scoreEntry(entry, tokens) }))
+    .filter((r) => r.score >= MIN_SCORE)
+    .sort((a, b) => b.score - a.score || a.entry.order - b.entry.order);
+  if (!ranked.length) {
+    return {
+      error: "no-match",
+      intent: text,
+      message: `No contract intent scored at least ${MIN_SCORE}. Describe the layout or component in plain words (e.g. "row of buttons that wraps"), or call list_components / list_layouts.`,
+    };
+  }
+  const [best, ...rest] = ranked;
+  const key = (e) => `${e.class}|${e.attribute ?? ""}`;
+  const seen = new Set([key(best.entry)]);
+  const alternatives = [];
+  for (const r of rest) {
+    if (alternatives.length === MAX_ALTERNATIVES) break;
+    if (seen.has(key(r.entry))) continue;
+    seen.add(key(r.entry));
+    alternatives.push(describe(r.entry, r.score));
+  }
+  return { ...describe(best.entry, best.score), ...(alternatives.length ? { alternatives } : {}) };
+}
diff --git a/packages/synthmcp/src/server.js b/packages/synthmcp/src/server.js
new file mode 100755
index 0000000..e86e239
--- /dev/null
+++ b/packages/synthmcp/src/server.js
@@ -0,0 +1,14 @@
+#!/usr/bin/env node
+// The synthmcp bin: a SynthMCP server on stdio. stdout carries the protocol, so
+// diagnostics go to stderr.
+
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createSynthServer } from "./index.js";
+
+try {
+  const server = createSynthServer();
+  await server.connect(new StdioServerTransport());
+} catch (err) {
+  console.error(err.message);
+  process.exit(1);
+}
diff --git a/packages/synthmcp/src/tools.js b/packages/synthmcp/src/tools.js
new file mode 100644
index 0000000..9088a0e
--- /dev/null
+++ b/packages/synthmcp/src/tools.js
@@ -0,0 +1,175 @@
+// The seven SynthMCP tools. Each handler takes the contract model and the tool
+// arguments and returns a plain object; a structured { error, … } object stands for
+// a failed call. Handlers never throw for bad input.
+
+import { resolveIntent } from "./intent.js";
+import { closest, MAX_HTML_LENGTH, validateMarkup } from "./validate.js";
+
+const noArgs = { type: "object", properties: {}, additionalProperties: false };
+const oneString = (key, description) => ({
+  type: "object",
+  properties: { [key]: { type: "string", description } },
+  required: [key],
+  additionalProperties: false,
+});
+
+const purposes = (map) => Object.entries(map ?? {}).map(([cls, purpose]) => ({ class: cls, purpose }));
+// Accepts "button", ".button" or " Button ".
+const cleanName = (name) => name.trim().replace(/^\./, "").toLowerCase();
+
+function stringArg(args, key) {
+  const value = args?.[key];
+  if (typeof value !== "string" || !value.trim()) {
+    return { error: "invalid-arguments", message: `"${key}" must be a non-empty string.` };
+  }
+  return null;
+}
+
+function notFound(model, key, value, available) {
+  const name = cleanName(value);
+  const owner = model.classes.get(name)?.owner;
+  const suggestion = available.includes(owner) ? owner : closest(name, available);
+  return { error: "not-found", [key]: value, available, ...(suggestion ? { suggestion } : {}) };
+}
+
+function listComponents(model) {
+  return {
+    components: Object.entries(model.components).map(([name, comp]) => ({ name, class: `.${name}`, intent: comp.intent })),
+  };
+}
+
+function getComponent(model, args) {
+  const bad = stringArg(args, "name");
+  if (bad) return bad;
+  const name = cleanName(args.name);
+  const comp = Object.hasOwn(model.components, name) ? model.components[name] : null;
+  if (!comp) return notFound(model, "name", args.name, Object.keys(model.components));
+  const attributes = Object.entries(model.synthAttributes);
+  return {
+    name,
+    class: `.${name}`,
+    intent: comp.intent,
+    variants: purposes(comp.variants),
+    parts: purposes(comp.parts).map((p) => ({ ...p, placement: comp.placement?.[p.class] ?? "inside" })),
+    accessibility: comp.accessibility ?? [],
+    ...(comp.behaviors
+      ? {
+          behaviors: comp.behaviors.map((b) => ({
+            ...b,
+            attributes: attributes.filter(([, meta]) => meta.behavior === b.attribute).map(([attr, meta]) => ({ name: attr, ...meta })),
+          })),
+        }
+      : {}),
+    example: comp.example,
+  };
+}
+
+function listLayouts(model) {
+  return {
+    layouts: Object.keys(model.primitives).map((name) => ({ name, class: `.${name}`, intent: model.layouts[name] })),
+  };
+}
+
+function getLayout(model, args) {
+  const bad = stringArg(args, "name");
+  if (bad) return bad;
+  const name = cleanName(args.name);
+  const prim = Object.hasOwn(model.primitives, name) ? model.primitives[name] : null;
+  if (!prim) return notFound(model, "name", args.name, Object.keys(model.primitives));
+  const described = (list) => (list ?? []).map((cls) => ({ class: cls, purpose: model.layouts[cls] }));
+  return {
+    name,
+    class: `.${name}`,
+    intent: model.layouts[name],
+    variants: described(prim.variants),
+    modifiers: described(prim.modifiers),
+    parts: described(prim.parts).map((p) => ({ ...p, placement: prim.placement?.[p.class] ?? "inside" })),
+    responsive: prim.responsive,
+    composition: prim.composition,
+    example: prim.example,
+  };
+}
+
+function resolve(model, args) {
+  return stringArg(args, "intent") ?? resolveIntent(model.intents, args.intent);
+}
+
+function validate(model, args) {
+  if (typeof args?.html !== "string") return { error: "invalid-arguments", message: '"html" must be a string.' };
+  if (args.html.length > MAX_HTML_LENGTH) {
+    return { error: "too-large", message: `"html" is ${args.html.length} characters; the limit is ${MAX_HTML_LENGTH}.` };
+  }
+  return validateMarkup(model, args.html);
+}
+
+function getExample(model, args) {
+  const bad = stringArg(args, "pattern");
+  if (bad) return bad;
+  const pattern = cleanName(args.pattern);
+  const example = Object.hasOwn(model.patterns, pattern) ? model.patterns[pattern] : null;
+  if (!example) return notFound(model, "pattern", args.pattern, Object.keys(model.patterns));
+  return { pattern, html: example.html, note: example.note };
+}
+
+export const TOOLS = [
+  {
+    name: "list_components",
+    description: "List every public SynthCSS component: name, class and a one-line intent.",
+    inputSchema: () => noArgs,
+    run: listComponents,
+  },
+  {
+    name: "get_component",
+    description:
+      "One SynthCSS component: intent, class, variants, parts (with placement), accessibility expectations, SynthJS behaviors and data-synth-* attributes when it has any, and a minimal example.",
+    inputSchema: (model) => oneString("name", `Component name from list_components: ${Object.keys(model.components).join(", ")}.`),
+    run: getComponent,
+  },
+  {
+    name: "list_layouts",
+    description: "List the SynthCSS layout primitives: name, class and a one-line intent.",
+    inputSchema: () => noArgs,
+    run: listLayouts,
+  },
+  {
+    name: "get_layout",
+    description: "One SynthCSS layout primitive: intent, class, gap variants, modifiers, parts, responsive behavior, recommended composition and an example.",
+    inputSchema: (model) => oneString("name", `Layout primitive from list_layouts: ${Object.keys(model.primitives).join(", ")}.`),
+    run: getLayout,
+  },
+  {
+    name: "resolve_intent",
+    description:
+      'Map a plain-language UI need to the SynthCSS class to use, by deterministic keyword scoring against the contract. Returns { class, classes, reason, alternatives? } or { error: "no-match" }.',
+    inputSchema: () => oneString("intent", 'What the markup should do, e.g. "a row of buttons that wraps".'),
+    run: resolve,
+  },
+  {
+    name: "validate_markup",
+    description:
+      "Check HTML against the SynthCSS contract: unknown or internal classes, unsupported variants, misplaced parts, unknown data-synth-* attributes, unresolved targets and accessibility expectations. Classes outside the SynthCSS namespace are ignored. valid is false when any issue is an error.",
+    inputSchema: () => oneString("html", `An HTML fragment or a full document, at most ${MAX_HTML_LENGTH} characters.`),
+    run: validate,
+  },
+  {
+    name: "get_example",
+    description: "The official SynthCSS example (HTML and a note) for a UI pattern.",
+    inputSchema: (model) => oneString("pattern", `Pattern name: ${Object.keys(model.patterns).join(", ")}.`),
+    run: getExample,
+  },
+];
+
+// Runs a tool and wraps its answer with the versions. Never throws.
+export function callTool(model, name, args) {
+  const tool = TOOLS.find((t) => t.name === name);
+  let payload;
+  if (!tool) payload = { error: "unknown-tool", name, available: TOOLS.map((t) => t.name) };
+  else {
+    try {
+      payload = tool.run(model, args ?? {});
+    } catch (err) {
+      payload = { error: "internal-error", message: err.message };
+    }
+  }
+  return { synthVersion: model.synthVersion, contractVersion: model.contractVersion, ...payload };
+}
diff --git a/packages/synthmcp/src/validate.js b/packages/synthmcp/src/validate.js
new file mode 100644
index 0000000..993835c
--- /dev/null
+++ b/packages/synthmcp/src/validate.js
@@ -0,0 +1,192 @@
+// validate_markup: parses HTML with parse5 and checks it against the contract.
+// Only classes in the SynthCSS namespace are checked (contract classes, names that
+// start with a contract base class plus "-", and synth-*); any other class is the
+// author's own and is ignored.
+
+import { parse, parseFragment } from "parse5";
+import { familyClasses } from "./contract.js";
+
+export const MAX_HTML_LENGTH = 200000;
+const DOCUMENT = /^\s*(?:<!--[\s\S]*?-->\s*)*<(?:!doctype\b|html[\s>])/i;
+const SYNTH_ATTRIBUTE = /^data-synth(?:-|$)/;
+
+export function editDistance(a, b) {
+  let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
+  for (let i = 1; i <= a.length; i++) {
+    const row = [i];
+    for (let j = 1; j <= b.length; j++) {
+      row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
+    }
+    prev = row;
+  }
+  return prev[b.length];
+}
+
+// The closest candidate by edit distance (first in contract order on a tie), if any
+// is close enough to be a plausible typo or near-miss.
+export function closest(name, candidates) {
+  let best = null;
+  let bestDistance = Infinity;
+  for (const c of candidates) {
+    const d = editDistance(name, c);
+    if (d < bestDistance) [best, bestDistance] = [c, d];
+  }
+  return best !== null && bestDistance <= Math.max(3, Math.ceil(name.length / 2)) ? best : undefined;
+}
+
+// Elements in document order, each { node, tag, attrs, classes, parent }.
+function elementsOf(root) {
+  const out = [];
+  const visit = (node, parent) => {
+    let el = parent;
+    if (node.tagName) {
+      const attrs = new Map(node.attrs.map((a) => [a.name, a.value]));
+      el = { node, tag: node.tagName, attrs, classes: (attrs.get("class") ?? "").split(/\s+/).filter(Boolean), parent, children: [] };
+      if (parent) parent.children.push(el);
+      out.push(el);
+    }
+    for (const child of node.childNodes ?? []) visit(child, el);
+    if (node.content) for (const child of node.content.childNodes ?? []) visit(child, el);
+  };
+  visit(root, null);
+  return out;
+}
+
+const ancestors = function* (el) {
+  for (let p = el.parent; p; p = p.parent) yield p;
+};
+const descendants = function* (el) {
+  for (const child of el.children) {
+    yield child;
+    yield* descendants(child);
+  }
+};
+
+// Contract selectors: a tag name, [attr] or [attr="value"].
+function matches(el, selector) {
+  const attr = /^\[([a-z][a-z0-9-]*)(?:="([^"]*)")?\]$/.exec(selector);
+  if (attr) return el.attrs.has(attr[1]) && (attr[2] === undefined || el.attrs.get(attr[1]) === attr[2]);
+  return el.tag === selector;
+}
+const anyMatch = (els, selectors) => [...els].some((el) => selectors.some((s) => matches(el, s)));
+
+const PLACEMENT_TEXT = { inside: "inside", child: "a direct child of", wraps: "around" };
+
+export function validateMarkup(model, html) {
+  const root = DOCUMENT.test(html) ? parse(html) : parseFragment(html);
+  const elements = elementsOf(root);
+  const ids = new Map();
+  for (const el of elements) if (el.attrs.has("id") && !ids.has(el.attrs.get("id"))) ids.set(el.attrs.get("id"), el);
+
+  const issues = [];
+  const seen = new Set();
+  const report = (type, severity, value, message, suggestion) => {
+    const key = `${type}|${value}|${message}`;
+    if (seen.has(key)) return;
+    seen.add(key);
+    issues.push({ type, severity, value, message, ...(suggestion ? { suggestion } : {}) });
+  };
+  const has = (el, cls) => el.classes.includes(cls);
+  const familyOf = (cls) => model.families.find((f) => cls.startsWith(`${f}-`));
+  // The element carries the family's base class (or, for a layout, a gap variant).
+  const BASE_KINDS = ["component", "primitive", "gap-variant"];
+  const hasBase = (el, owner) => el.classes.some((c) => model.classes.get(c)?.owner === owner && BASE_KINDS.includes(model.classes.get(c).kind));
+
+  for (const el of elements) {
+    for (const cls of new Set(el.classes)) {
+      if (model.internalClasses.has(cls)) {
+        report("internal-class", "error", cls, `.${cls} is internal to SynthCSS; never write it in markup.`, closest(cls, model.publicClasses));
+        continue;
+      }
+      const info = model.classes.get(cls);
+      if (info) {
+        checkKnownClass(el, cls, info);
+        checkA11y(el, cls);
+        continue;
+      }
+      const family = familyOf(cls);
+      if (!family && !cls.startsWith("synth-")) continue;
+      if (family && hasBase(el, family)) {
+        const pool = familyClasses(model, family, ["variant", "gap-variant", "modifier"]);
+        report(
+          "unsupported-variant",
+          "error",
+          cls,
+          `.${family} has no variant .${cls}.${pool.length ? ` Its variants: ${pool.map((c) => `.${c}`).join(", ")}.` : ""}`,
+          closest(cls, pool.length ? pool : model.publicClasses),
+        );
+      } else if (family && [...ancestors(el)].some((a) => has(a, family)) && familyClasses(model, family, ["part"]).length) {
+        const pool = familyClasses(model, family, ["part"]);
+        report("invalid-part", "error", cls, `.${family} has no part .${cls}. Its parts: ${pool.map((c) => `.${c}`).join(", ")}.`, closest(cls, pool));
+      } else {
+        report("unknown-class", "error", cls, `.${cls} is not a SynthCSS class; use only classes from the contract.`, closest(cls.replace(/^synth-/, ""), model.publicClasses));
+      }
+    }
+    for (const [name, value] of el.attrs) if (SYNTH_ATTRIBUTE.test(name)) checkSynthAttribute(el, name, value);
+  }
+
+  function checkKnownClass(el, cls, info) {
+    const { kind, owner } = info;
+    if (kind === "variant" && !has(el, owner)) {
+      report("unsupported-variant", "error", cls, `.${cls} is a variant of .${owner} and needs class "${owner}" on the same element.`, `${owner} ${cls}`);
+    } else if (kind === "modifier" && !hasBase(el, owner)) {
+      const bases = familyClasses(model, owner, ["primitive", "gap-variant"]);
+      report("unsupported-variant", "error", cls, `.${cls} modifies .${owner}; put it next to ${bases.map((c) => `.${c}`).join(" or ")} on the same element.`, `${owner} ${cls}`);
+    } else if (kind === "part") {
+      const ok =
+        info.placement === "child"
+          ? Boolean(el.parent && has(el.parent, owner))
+          : info.placement === "wraps"
+            ? [...descendants(el)].some((d) => has(d, owner))
+            : [...ancestors(el)].some((a) => has(a, owner));
+      if (!ok) {
+        report("invalid-part", "error", cls, `.${cls} is a part of .${owner}; it must be ${PLACEMENT_TEXT[info.placement]} an element with class "${owner}".`);
+      }
+    }
+  }
+
+  function checkA11y(el, cls) {
+    for (const rule of model.a11y.get(cls) ?? []) {
+      if (rule.elements && !rule.elements.includes(el.tag)) {
+        report("a11y", rule.severity, cls, `${rule.expectation} Found <${el.tag}>.`);
+      }
+      if (rule.attributes) {
+        const present = rule.attributes.filter((a) => el.attrs.has(a));
+        if (!present.length) report("a11y", rule.severity, cls, `${rule.expectation} Missing ${rule.attributes.join(" or ")}.`);
+        else if (rule.values && !present.some((a) => rule.values.includes(el.attrs.get(a).trim()))) {
+          report("a11y", rule.severity, cls, `${rule.expectation} Found ${present.map((a) => `${a}="${el.attrs.get(a)}"`).join(" ")}.`);
+        }
+      }
+    }
+  }
+
+  function checkSynthAttribute(el, name, value) {
+    if (model.internalAttributes.has(name)) {
+      report("unknown-data-attribute", "error", name, `${name} is an internal marker SynthJS sets itself; never write it in markup.`);
+      return;
+    }
+    const meta = model.synthAttributes[name];
+    if (!meta) {
+      const known = Object.keys(model.synthAttributes);
+      report("unknown-data-attribute", "error", name, `${name} is not a SynthJS attribute. Known: ${known.join(", ")}.`, closest(name, known));
+      return;
+    }
+    if (meta.value === "id") {
+      const id = value.trim();
+      const target = id && ids.get(id);
+      if (!id) report("invalid-target", "error", name, `${name} needs the id of its target element (${meta.purpose}).`);
+      else if (!target) report("invalid-target", "error", `${name}="${id}"`, `${name} points at id "${id}", but no element in the markup has that id.`);
+      else if (meta.targetElements && !meta.targetElements.includes(target.tag)) {
+        report("invalid-target", "error", `${name}="${id}"`, `${name} must point at ${meta.targetElements.map((t) => `<${t}>`).join(" or ")}, found <${target.tag} id="${id}">.`);
+      }
+    }
+    if (meta.closest && !anyMatch([el, ...ancestors(el)], meta.closest)) {
+      report("invalid-target", "error", name, `${name} must be on or inside ${meta.closest.join(" or ")}; nothing in the markup encloses it.`);
+    }
+    for (const group of meta.contains ?? []) {
+      if (!anyMatch(descendants(el), group)) report("invalid-target", "error", name, `${name} must contain ${group.join(" or ")}.`);
+    }
+  }
+
+  return { valid: !issues.some((i) => i.severity === "error"), issues };
+}
diff --git a/packages/synthmcp/test/drift.test.js b/packages/synthmcp/test/drift.test.js
new file mode 100644
index 0000000..b536f88
--- /dev/null
+++ b/packages/synthmcp/test/drift.test.js
@@ -0,0 +1,91 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { CONTRACT_ENV, loadContract, resolveContractPath } from "../src/index.js";
+import { REPO_CONTRACT, connect, readContract } from "./helpers.js";
+
+// A modified copy of the repository contract, written to a temporary file.
+function fixture(edit) {
+  const contract = readContract();
+  edit(contract);
+  const dir = mkdtempSync(join(tmpdir(), "synthmcp-"));
+  const path = join(dir, "synthcss.ai.json");
+  writeFileSync(path, JSON.stringify(contract));
+  return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
+}
+
+function drifted(contract) {
+  contract.synthcssVersion = "9.9.9";
+  contract.contractVersion = "9.0.0";
+  contract.components.badge.intent = "tiny status chip";
+  contract.components.badge.variants["badge-neutral"] = "no status meaning";
+  contract.primitives.stack.responsive = "always a column";
+  contract.intents.unshift({ class: "badge", reason: "Drifted intent.", keywords: ["flibbertigibbet"] });
+  contract.examples.patterns["pricing-table"] = { html: '<div class="grid">…</div>', note: "Plans side by side." };
+  delete contract.examples.patterns["empty-state"];
+  contract.synthjs.attributes["data-synth-collapse"] = { behavior: "data-synth-toggle", value: "id", purpose: "drift" };
+}
+
+test("by default the server reads the repository's synthcss.ai.json through the synthcss package", () => {
+  const path = resolveContractPath({ env: {} });
+  assert.equal(path, new URL(REPO_CONTRACT).pathname);
+  assert.equal(loadContract({ env: {} }).contractVersion, readContract().contractVersion);
+});
+
+test("drift: a modified contract loaded through the override changes every tool's output", async () => {
+  const { path, cleanup } = fixture(drifted);
+  const { client, call, close } = await connect({ contractPath: path });
+  try {
+    assert.equal(client.getServerVersion().version, "9.9.9");
+    const list = await call("list_components");
+    assert.equal(list.synthVersion, "9.9.9");
+    assert.equal(list.contractVersion, "9.0.0");
+    assert.equal(list.components.find((c) => c.name === "badge").intent, "tiny status chip");
+    const badge = await call("get_component", { name: "badge" });
+    assert.ok(badge.variants.some((v) => v.class === "badge-neutral"));
+    assert.equal((await call("get_layout", { name: "stack" })).responsive, "always a column");
+    assert.equal((await call("resolve_intent", { intent: "flibbertigibbet" })).class, ".badge");
+    assert.equal((await call("get_example", { pattern: "pricing-table" })).note, "Plans side by side.");
+    assert.equal((await call("get_example", { pattern: "empty-state" })).error, "not-found");
+    const markup = await call("validate_markup", {
+      html: '<span class="badge badge-neutral">x</span><button type="button" class="button" data-synth-collapse="x">x</button><div id="x"></div>',
+    });
+    assert.deepEqual(markup.issues, []);
+  } finally {
+    await close();
+    cleanup();
+  }
+  // The same markup is invalid against the repository contract.
+  const repo = await connect();
+  try {
+    const markup = await repo.call("validate_markup", { html: '<span class="badge badge-neutral">x</span>' });
+    assert.equal(markup.valid, false);
+  } finally {
+    await repo.close();
+  }
+});
+
+test(`the ${CONTRACT_ENV} environment variable overrides the contract path`, () => {
+  const { path, cleanup } = fixture(drifted);
+  try {
+    assert.equal(resolveContractPath({ env: { [CONTRACT_ENV]: path } }), path);
+    assert.equal(loadContract({ env: { [CONTRACT_ENV]: path } }).synthVersion, "9.9.9");
+    assert.equal(resolveContractPath({ contractPath: "/x.json", env: { [CONTRACT_ENV]: path } }), "/x.json", "the option wins");
+  } finally {
+    cleanup();
+  }
+});
+
+test("a missing or broken contract fails at startup with a clear message", () => {
+  assert.throws(() => loadContract({ contractPath: "/nonexistent/synthcss.ai.json" }), /cannot read the SynthCSS contract at \/nonexistent/);
+  const dir = mkdtempSync(join(tmpdir(), "synthmcp-"));
+  try {
+    const broken = join(dir, "broken.json");
+    writeFileSync(broken, "{ nope");
+    assert.throws(() => loadContract({ contractPath: broken }), /cannot read the SynthCSS contract/);
+  } finally {
+    rmSync(dir, { recursive: true, force: true });
+  }
+});
diff --git a/packages/synthmcp/test/helpers.js b/packages/synthmcp/test/helpers.js
new file mode 100644
index 0000000..f8f941d
--- /dev/null
+++ b/packages/synthmcp/test/helpers.js
@@ -0,0 +1,38 @@
+// Test helpers: an SDK client connected to SynthMCP over an in-memory transport.
+
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { readFileSync } from "node:fs";
+import { createSynthServer } from "../src/index.js";
+
+export const REPO_CONTRACT = new URL("../../../synthcss.ai.json", import.meta.url);
+export const readContract = () => JSON.parse(readFileSync(REPO_CONTRACT, "utf8"));
+
+export async function connect(options = {}) {
+  const server = createSynthServer(options);
+  const client = new Client({ name: "synthmcp-test", version: "0.0.0" });
+  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+  await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
+  const call = async (name, args = {}) => {
+    const result = await client.callTool({ name, arguments: args });
+    assertTextResult(result);
+    return { ...JSON.parse(result.content[0].text), isError: result.isError === true };
+  };
+  return { client, server, call, close: () => client.close() };
+}
+
+function assertTextResult(result) {
+  if (result.content.length !== 1 || result.content[0].type !== "text") {
+    throw new Error(`expected one text content item, got ${JSON.stringify(result.content)}`);
+  }
+}
+
+// Every public contract class: layouts, components, parts and variants, minus internal ones.
+export function publicClasses(contract) {
+  const all = [...Object.keys(contract.layouts)];
+  for (const [name, comp] of Object.entries(contract.components)) all.push(name, ...Object.keys(comp.parts), ...Object.keys(comp.variants));
+  const internal = new Set(contract.internal.classes);
+  return new Set(all.filter((c) => !internal.has(c)));
+}
+
+export const classAttrs = (html) => [...html.matchAll(/\bclass="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/).filter(Boolean));
diff --git a/packages/synthmcp/test/intent.test.js b/packages/synthmcp/test/intent.test.js
new file mode 100644
index 0000000..1d82008
--- /dev/null
+++ b/packages/synthmcp/test/intent.test.js
@@ -0,0 +1,100 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { connect, readContract } from "./helpers.js";
+import { MIN_SCORE, normalize, stem } from "../src/intent.js";
+
+const contract = readContract();
+
+test("normalizes and stems deterministically", () => {
+  assert.deepEqual(normalize("A row of items/buttons that WRAPS"), ["row", "item", "button", "wrap"]);
+  assert.equal(stem("wrapping"), "wrap");
+  assert.equal(stem("wrapped"), "wrap");
+  assert.equal(stem("entries"), "entry");
+  assert.equal(stem("boxes"), "box");
+  assert.equal(stem("class"), "class");
+});
+
+test("resolves a wrapping row of items to .cluster", async () => {
+  const { call, close } = await connect();
+  try {
+    const res = await call("resolve_intent", { intent: "a row of items/buttons that wraps" });
+    assert.equal(res.class, ".cluster");
+    assert.equal(res.classes, "cluster");
+    assert.equal(res.reason, contract.intents.find((i) => i.class === "cluster").reason);
+    assert.ok(res.score >= MIN_SCORE);
+    for (const alt of res.alternatives ?? []) assert.ok(alt.score <= res.score);
+  } finally {
+    await close();
+  }
+});
+
+// The specification asked for .split here. The contract maps this need to .sidebar
+// ("Side navigation next to content" in intentMap; .split is "two groups pushed to
+// opposite ends of a row"), and every answer must come from the contract.
+test("resolves a sidebar next to main content to .sidebar, the contract's primitive for it", async () => {
+  const { call, close } = await connect();
+  try {
+    const res = await call("resolve_intent", { intent: "sidebar next to main content" });
+    assert.equal(res.class, ".sidebar");
+    assert.equal(contract.intentMap.find((e) => e.intent === "Side navigation next to content").use, ".sidebar");
+  } finally {
+    await close();
+  }
+});
+
+test("returns no-match below the threshold for nonsense", async () => {
+  const { call, close } = await connect();
+  try {
+    for (const intent of ["qwzx blorptastic frumious", "the of and a", "1234 5678", "!!!"]) {
+      const res = await call("resolve_intent", { intent });
+      assert.equal(res.error, "no-match", intent);
+      assert.equal(res.intent, intent);
+      assert.equal(res.isError, true);
+    }
+  } finally {
+    await close();
+  }
+});
+
+test("maps common needs to the contract's classes, with companion classes and SynthJS attributes", async () => {
+  const { call, close } = await connect();
+  try {
+    const cases = [
+      ["page wrapper with max width", ".container", "container"],
+      ["stack the form fields vertically", ".stack", "stack"],
+      ["responsive cards, as many columns as fit", ".grid", "grid"],
+      ["header with the title on the left and actions on the right", ".split", "split"],
+      ["delete button", ".button-danger", "button button-danger"],
+      ["main call to action", ".button-primary", "button button-primary"],
+      ["show an error message under the email input", ".field", "field"],
+      ["on/off toggle switch for a setting", ".switch", "switch"],
+      ["status pill", ".badge", "badge"],
+      ["nothing to show yet, no results", ".empty-state", "empty-state"],
+      ["full screen sign in page", ".cover", "cover"],
+      ["data table with rows and columns", ".table", "table"],
+    ];
+    for (const [intent, cls, classes] of cases) {
+      const res = await call("resolve_intent", { intent });
+      assert.equal(res.class, cls, intent);
+      assert.equal(res.classes, classes, intent);
+    }
+    const modal = await call("resolve_intent", { intent: "open a confirmation modal" });
+    assert.equal(modal.class, ".button");
+    assert.equal(modal.attribute, "data-synth-open");
+    const tabs = await call("resolve_intent", { intent: "switch between panels" });
+    assert.equal(tabs.attribute, "data-synth-tabs");
+  } finally {
+    await close();
+  }
+});
+
+test("is deterministic", async () => {
+  const { call, close } = await connect();
+  try {
+    const a = await call("resolve_intent", { intent: "row of tags" });
+    const b = await call("resolve_intent", { intent: "row of tags" });
+    assert.deepEqual(a, b);
+  } finally {
+    await close();
+  }
+});
diff --git a/packages/synthmcp/test/security.test.js b/packages/synthmcp/test/security.test.js
new file mode 100644
index 0000000..4124efc
--- /dev/null
+++ b/packages/synthmcp/test/security.test.js
@@ -0,0 +1,77 @@
+// SynthMCP only reads the contract and its tool arguments: no process spawning, no
+// network, no file writes. This scans its source for the APIs that would break that.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SRC = fileURLToPath(new URL("../src/", import.meta.url));
+const files = readdirSync(SRC)
+  .filter((f) => f.endsWith(".js") || f.endsWith(".mjs") || f.endsWith(".cjs"))
+  .map((f) => ({ file: f, text: readFileSync(join(SRC, f), "utf8") }));
+
+const FORBIDDEN = [
+  ["child_process", /\bchild_process\b/],
+  ["net", /["'](?:node:)?net["']/],
+  ["http(s)", /["'](?:node:)?https?2?["']|\bhttps?\s*\.\s*(?:request|get|createServer)\b/],
+  ["fetch", /\bfetch\b/],
+  ["other network APIs", /\b(?:XMLHttpRequest|WebSocket|EventSource)\b|["'](?:node:)?(?:dgram|tls|dns|http2)["']/],
+  ["writeFile", /\bwriteFile(?:Sync)?\b/],
+  ["appendFile", /\bappendFile(?:Sync)?\b/],
+  ["createWriteStream", /\bcreateWriteStream\b/],
+  ["mkdir", /\bmkdir(?:Sync)?\b|\bmkdtemp(?:Sync)?\b/],
+  ["rm", /\brm(?:Sync)?\s*\(|\brmdir(?:Sync)?\b|\.rm\b/],
+  ["unlink", /\bunlink(?:Sync)?\b/],
+  ["other fs writes", /\b(?:rename|copyFile|truncate|ftruncate|symlink|chmod|chown|utimes|cp)(?:Sync)?\s*\(/],
+  ["eval", /\beval\s*\(|\bnew Function\s*\(/],
+];
+// Imports SynthMCP may use; a new one must be added here on purpose.
+const ALLOWED_IMPORTS = new Set([
+  "node:fs",
+  "node:module",
+  "node:url",
+  "parse5",
+  "@modelcontextprotocol/sdk/server/index.js",
+  "@modelcontextprotocol/sdk/server/stdio.js",
+  "@modelcontextprotocol/sdk/types.js",
+]);
+
+test("scans every source file", () => {
+  assert.deepEqual(files.map((f) => f.file).sort(), ["contract.js", "index.js", "intent.js", "server.js", "tools.js", "validate.js"]);
+});
+
+test("src/ uses no process, network or file-write APIs", () => {
+  for (const { file, text } of files) {
+    for (const [what, pattern] of FORBIDDEN) assert.doesNotMatch(text, pattern, `src/${file} uses ${what}`);
+  }
+});
+
+test("src/ imports only the MCP SDK, parse5 and read-only Node modules", () => {
+  for (const { file, text } of files) {
+    assert.doesNotMatch(text, /\brequire\s*\(|\bimport\s*\(/, `src/${file} must not load modules dynamically`);
+    for (const m of text.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) {
+      const spec = m[1];
+      if (spec.startsWith("./")) continue;
+      assert.ok(ALLOWED_IMPORTS.has(spec), `src/${file} imports ${spec}`);
+    }
+    for (const m of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']node:fs["']/g)) {
+      assert.deepEqual(m[1].split(",").map((s) => s.trim()).filter(Boolean), ["readFileSync"], `src/${file} may only read files`);
+    }
+  }
+});
+
+test("the scan catches forbidden APIs", () => {
+  const hits = (text) => FORBIDDEN.filter(([, p]) => p.test(text)).map(([what]) => what);
+  assert.deepEqual(hits('import { exec } from "node:child_process";'), ["child_process"]);
+  assert.deepEqual(hits('import net from "net";'), ["net"]);
+  assert.deepEqual(hits('import https from "node:https";'), ["http(s)"]);
+  assert.deepEqual(hits("await fetch(url)"), ["fetch"]);
+  assert.deepEqual(hits("fs.writeFileSync(p, s)"), ["writeFile"]);
+  assert.deepEqual(hits("appendFile(p, s)"), ["appendFile"]);
+  assert.deepEqual(hits("createWriteStream(p)"), ["createWriteStream"]);
+  assert.deepEqual(hits("mkdirSync(p)"), ["mkdir"]);
+  assert.deepEqual(hits("fs.rm(p)"), ["rm"]);
+  assert.deepEqual(hits("unlinkSync(p)"), ["unlink"]);
+});
diff --git a/packages/synthmcp/test/server.test.js b/packages/synthmcp/test/server.test.js
new file mode 100644
index 0000000..4e5e6d1
--- /dev/null
+++ b/packages/synthmcp/test/server.test.js
@@ -0,0 +1,132 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { connect, readContract } from "./helpers.js";
+
+const contract = readContract();
+const versions = { synthVersion: contract.synthcssVersion, contractVersion: contract.contractVersion };
+const TOOL_NAMES = ["list_components", "get_component", "list_layouts", "get_layout", "resolve_intent", "validate_markup", "get_example"];
+
+test("lists exactly the 7 read-only tools over the SDK client", async () => {
+  const { client, close } = await connect();
+  try {
+    const { tools } = await client.listTools();
+    assert.equal(tools.length, 7);
+    assert.deepEqual(tools.map((t) => t.name), TOOL_NAMES);
+    for (const tool of tools) {
+      assert.equal(tool.inputSchema.type, "object");
+      assert.ok(tool.description.length > 20, tool.name);
+      assert.equal(tool.annotations.readOnlyHint, true);
+      assert.equal(tool.annotations.openWorldHint, false);
+    }
+    const schema = tools.find((t) => t.name === "get_example").inputSchema;
+    assert.deepEqual(schema.required, ["pattern"]);
+    assert.match(schema.properties.pattern.description, /dashboard-header/);
+  } finally {
+    await close();
+  }
+});
+
+test("server metadata reports the SynthCSS and contract versions", async () => {
+  const { client, close } = await connect();
+  try {
+    const info = client.getServerVersion();
+    assert.equal(info.name, "synthmcp");
+    assert.equal(info.version, contract.synthcssVersion);
+    assert.ok(info.title.includes(`SynthCSS ${contract.synthcssVersion}`), info.title);
+    assert.ok(info.title.includes(`contract ${contract.contractVersion}`), info.title);
+    const instructions = client.getInstructions();
+    assert.ok(instructions.includes(contract.synthcssVersion) && instructions.includes(contract.contractVersion));
+  } finally {
+    await close();
+  }
+});
+
+test("every tool response, success or error, includes synthVersion and contractVersion", async () => {
+  const { call, close } = await connect();
+  try {
+    const calls = [
+      ["list_components", {}],
+      ["get_component", { name: "card" }],
+      ["get_component", { name: "nope" }],
+      ["list_layouts", {}],
+      ["get_layout", { name: "grid" }],
+      ["get_layout", { name: "nope" }],
+      ["resolve_intent", { intent: "a row of buttons that wraps" }],
+      ["resolve_intent", { intent: "zzzz qqqq" }],
+      ["validate_markup", { html: '<div class="stack"></div>' }],
+      ["get_example", { pattern: "tabs" }],
+      ["get_example", { pattern: "nope" }],
+      ["no_such_tool", {}],
+    ];
+    for (const [name, args] of calls) {
+      const res = await call(name, args);
+      assert.equal(res.synthVersion, versions.synthVersion, name);
+      assert.equal(res.contractVersion, versions.contractVersion, name);
+    }
+  } finally {
+    await close();
+  }
+});
+
+test("unknown names, patterns, tools and bad arguments return structured errors and never throw", async () => {
+  const { call, close } = await connect();
+  try {
+    const component = await call("get_component", { name: "carousel" });
+    assert.equal(component.isError, true);
+    assert.equal(component.error, "not-found");
+    assert.equal(component.name, "carousel");
+    assert.deepEqual(component.available, Object.keys(contract.components));
+
+    const typo = await call("get_component", { name: "buton" });
+    assert.equal(typo.suggestion, "button");
+    const variant = await call("get_component", { name: "button-primary" });
+    assert.equal(variant.error, "not-found");
+    assert.equal(variant.suggestion, "button", "a variant points at its component");
+
+    const layout = await call("get_layout", { name: "masonry" });
+    assert.equal(layout.error, "not-found");
+    assert.deepEqual(layout.available, Object.keys(contract.primitives));
+    assert.equal((await call("get_layout", { name: "button" })).error, "not-found", "components are not layouts");
+
+    const example = await call("get_example", { pattern: "pricing-table" });
+    assert.equal(example.error, "not-found");
+    assert.equal(example.pattern, "pricing-table");
+    assert.deepEqual(example.available, Object.keys(contract.examples.patterns));
+
+    const tool = await call("suggest_structure", {});
+    assert.equal(tool.error, "unknown-tool");
+    assert.equal(tool.available.length, 7);
+
+    for (const [name, args] of [
+      ["get_component", {}],
+      ["get_component", { name: 42 }],
+      ["get_layout", { name: "  " }],
+      ["resolve_intent", {}],
+      ["validate_markup", { html: null }],
+      ["get_example", { pattern: ["tabs"] }],
+    ]) {
+      const res = await call(name, args);
+      assert.equal(res.error, "invalid-arguments", `${name} ${JSON.stringify(args)}`);
+      assert.equal(res.isError, true);
+    }
+    const huge = await call("validate_markup", { html: "<p>".repeat(100000) });
+    assert.equal(huge.error, "too-large");
+    for (const name of ["__proto__", "constructor", "toString"]) {
+      assert.equal((await call("get_component", { name })).error, "not-found", name);
+      assert.equal((await call("get_layout", { name })).error, "not-found", name);
+      assert.equal((await call("get_example", { pattern: name })).error, "not-found", name);
+    }
+  } finally {
+    await close();
+  }
+});
+
+test("successful calls are not flagged as errors", async () => {
+  const { call, close } = await connect();
+  try {
+    assert.equal((await call("get_component", { name: ".Button " })).isError, false);
+    assert.equal((await call("validate_markup", { html: '<span class="badge-success">x</span>' })).isError, false, "invalid markup is a successful validation");
+  } finally {
+    await close();
+  }
+});
diff --git a/packages/synthmcp/test/stdio.test.js b/packages/synthmcp/test/stdio.test.js
new file mode 100644
index 0000000..3643d02
--- /dev/null
+++ b/packages/synthmcp/test/stdio.test.js
@@ -0,0 +1,60 @@
+// Starts the real server as a child process the three documented ways and talks to
+// it over stdio with the SDK client.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+
+const PACKAGE = fileURLToPath(new URL("..", import.meta.url));
+const REPO = fileURLToPath(new URL("../../..", import.meta.url));
+const BIN = fileURLToPath(new URL("../../../node_modules/.bin/synthmcp", import.meta.url));
+const npm = process.platform === "win32" ? "npm.cmd" : "npm";
+
+async function listOverStdio(params) {
+  const transport = new StdioClientTransport({ ...params, stderr: "pipe" });
+  const client = new Client({ name: "synthmcp-stdio-test", version: "0.0.0" });
+  await client.connect(transport);
+  try {
+    const { tools } = await client.listTools();
+    const result = await client.callTool({ name: "resolve_intent", arguments: { intent: "row of tags that wraps" } });
+    return { tools, answer: JSON.parse(result.content[0].text), info: client.getServerVersion() };
+  } finally {
+    await client.close();
+  }
+}
+
+const check = ({ tools, answer, info }) => {
+  assert.equal(tools.length, 7);
+  assert.equal(answer.class, ".cluster");
+  assert.equal(info.name, "synthmcp");
+};
+
+test("node packages/synthmcp/src/server.js serves MCP over stdio", { timeout: 20000 }, async () => {
+  check(await listOverStdio({ command: process.execPath, args: ["packages/synthmcp/src/server.js"], cwd: REPO }));
+});
+
+test("the synthmcp bin serves MCP over stdio", { timeout: 20000, skip: !existsSync(BIN) && "node_modules/.bin/synthmcp is not linked" }, async () => {
+  check(await listOverStdio({ command: BIN, args: [], cwd: PACKAGE }));
+});
+
+test("npm run mcp serves MCP over stdio", { timeout: 30000 }, async () => {
+  check(await listOverStdio({ command: npm, args: ["run", "--silent", "mcp"], cwd: REPO }));
+});
+
+test("the server exits with a message when the contract cannot be read", { timeout: 20000 }, async () => {
+  const transport = new StdioClientTransport({
+    command: process.execPath,
+    args: ["src/server.js"],
+    cwd: PACKAGE,
+    env: { ...process.env, SYNTHMCP_CONTRACT: "/nonexistent/synthcss.ai.json" },
+    stderr: "pipe",
+  });
+  let stderr = "";
+  transport.stderr.on("data", (d) => (stderr += d));
+  const client = new Client({ name: "synthmcp-stdio-test", version: "0.0.0" });
+  await assert.rejects(client.connect(transport));
+  assert.match(stderr, /cannot read the SynthCSS contract at \/nonexistent/);
+});
diff --git a/packages/synthmcp/test/tools.test.js b/packages/synthmcp/test/tools.test.js
new file mode 100644
index 0000000..d39459e
--- /dev/null
+++ b/packages/synthmcp/test/tools.test.js
@@ -0,0 +1,146 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { classAttrs, connect, publicClasses, readContract } from "./helpers.js";
+
+const contract = readContract();
+const PUBLIC = publicClasses(contract);
+const REQUIRED_PATTERNS = ["dashboard-header", "settings-form", "card-grid", "dialog", "tabs", "empty-state"];
+
+test("list_components returns name, class and the contract intent of every component", async () => {
+  const { call, close } = await connect();
+  try {
+    const { components } = await call("list_components");
+    assert.deepEqual(
+      components,
+      Object.entries(contract.components).map(([name, comp]) => ({ name, class: `.${name}`, intent: comp.intent })),
+    );
+    for (const c of components) assert.ok(!c.intent.includes("\n"), `${c.name} intent is one line`);
+  } finally {
+    await close();
+  }
+});
+
+test("get_component matches the contract for every component", async () => {
+  const { call, close } = await connect();
+  try {
+    for (const [name, comp] of Object.entries(contract.components)) {
+      const res = await call("get_component", { name });
+      assert.equal(res.name, name);
+      assert.equal(res.class, `.${name}`);
+      assert.equal(res.intent, comp.intent);
+      assert.deepEqual(res.variants, Object.entries(comp.variants).map(([cls, purpose]) => ({ class: cls, purpose })));
+      assert.deepEqual(
+        res.parts,
+        Object.entries(comp.parts).map(([cls, purpose]) => ({ class: cls, purpose, placement: comp.placement?.[cls] ?? "inside" })),
+      );
+      assert.deepEqual(res.accessibility, comp.accessibility);
+      assert.equal(res.example, comp.example);
+      if (!comp.behaviors) assert.equal(res.behaviors, undefined, `${name} has no behaviors`);
+      else {
+        assert.equal(res.behaviors.length, comp.behaviors.length);
+        res.behaviors.forEach((b, i) => {
+          const { attributes, ...rest } = b;
+          assert.deepEqual(rest, comp.behaviors[i]);
+          const expected = Object.entries(contract.synthjs.attributes).filter(([, meta]) => meta.behavior === b.attribute);
+          assert.deepEqual(attributes, expected.map(([attr, meta]) => ({ name: attr, ...meta })));
+          assert.ok(attributes.some((a) => a.name === b.attribute), `${b.attribute} lists itself`);
+        });
+      }
+    }
+    const dropdown = (await call("get_component", { name: "nav" })).behaviors[0];
+    assert.deepEqual(dropdown.attributes.map((a) => a.name), ["data-synth-dropdown", "data-synth-dropdown-trigger", "data-synth-dropdown-menu"]);
+  } finally {
+    await close();
+  }
+});
+
+test("list_layouts and get_layout match the contract for every primitive", async () => {
+  const { call, close } = await connect();
+  try {
+    const { layouts } = await call("list_layouts");
+    assert.deepEqual(
+      layouts,
+      Object.keys(contract.primitives).map((name) => ({ name, class: `.${name}`, intent: contract.layouts[name] })),
+    );
+    for (const [name, prim] of Object.entries(contract.primitives)) {
+      const res = await call("get_layout", { name });
+      const described = (list) => (list ?? []).map((cls) => ({ class: cls, purpose: contract.layouts[cls] }));
+      assert.equal(res.name, name);
+      assert.equal(res.class, `.${name}`);
+      assert.equal(res.intent, contract.layouts[name]);
+      assert.deepEqual(res.variants, described(prim.variants));
+      assert.deepEqual(res.modifiers, described(prim.modifiers));
+      assert.deepEqual(res.parts, described(prim.parts).map((p) => ({ ...p, placement: prim.placement?.[p.class] ?? "inside" })));
+      assert.equal(res.responsive, prim.responsive);
+      assert.deepEqual(res.composition, prim.composition);
+      assert.equal(res.example, prim.example);
+      for (const v of [...res.variants, ...res.modifiers, ...res.parts]) assert.ok(v.purpose, `${v.class} has a purpose`);
+    }
+    assert.deepEqual((await call("get_layout", { name: "sidebar" })).modifiers.map((m) => m.class), ["sidebar-end"]);
+    assert.deepEqual((await call("get_layout", { name: "cover" })).parts, [
+      { class: "cover-main", purpose: contract.layouts["cover-main"], placement: "child" },
+    ]);
+  } finally {
+    await close();
+  }
+});
+
+test("get_example returns each required official pattern from the contract", async () => {
+  const { call, close } = await connect();
+  try {
+    for (const pattern of REQUIRED_PATTERNS) {
+      const res = await call("get_example", { pattern });
+      assert.equal(res.pattern, pattern);
+      assert.equal(res.html, contract.examples.patterns[pattern].html);
+      assert.equal(res.note, contract.examples.patterns[pattern].note);
+    }
+    assert.ok(contract.examples.patterns.dialog.html.includes("data-synth-open"));
+    assert.ok(contract.examples.patterns.tabs.html.includes("data-synth-tabs"));
+  } finally {
+    await close();
+  }
+});
+
+// Every piece of HTML the contract offers as correct.
+function officialExamples() {
+  const out = [];
+  for (const [name, p] of Object.entries(contract.examples.patterns)) out.push([`examples.patterns.${name}`, p.html]);
+  contract.examples.valid.forEach((e, i) => out.push([`examples.valid ${i + 1}`, e.html]));
+  for (const [name, comp] of Object.entries(contract.components)) {
+    out.push([`components.${name}.example`, comp.example]);
+    for (const b of comp.behaviors ?? []) out.push([`${b.attribute} requiredMarkup`, b.requiredMarkup]);
+  }
+  for (const [name, prim] of Object.entries(contract.primitives)) out.push([`primitives.${name}.example`, prim.example]);
+  for (const item of contract.extension.notCovered) out.push([`notCovered ${item.pattern}`, item.html]);
+  return out;
+}
+
+test("every official example validates with zero errors", async () => {
+  const { call, close } = await connect();
+  try {
+    for (const [where, html] of officialExamples()) {
+      const res = await call("validate_markup", { html });
+      assert.deepEqual(res.issues.filter((i) => i.severity === "error"), [], where);
+      assert.equal(res.valid, true, where);
+    }
+  } finally {
+    await close();
+  }
+});
+
+test("every class used in examples is a public, non-internal contract class", () => {
+  for (const [where, html] of officialExamples()) {
+    for (const cls of classAttrs(html)) assert.ok(PUBLIC.has(cls), `${where} uses .${cls}`);
+  }
+});
+
+test("every intent target is a public, non-internal contract class", () => {
+  assert.ok(contract.intents.length > 20);
+  const attributes = new Set(Object.keys(contract.synthjs.attributes));
+  for (const intent of contract.intents) {
+    for (const cls of [intent.class, ...(intent.with ?? [])]) assert.ok(PUBLIC.has(cls), `intent .${cls}`);
+    if (intent.attribute) assert.ok(attributes.has(intent.attribute), intent.attribute);
+  }
+  for (const name of Object.keys(contract.primitives)) assert.ok(contract.intents.some((i) => i.class === name), `an intent for .${name}`);
+  for (const name of Object.keys(contract.components)) assert.ok(contract.intents.some((i) => i.class === name || i.with?.includes(name)), `an intent for .${name}`);
+});
diff --git a/packages/synthmcp/test/validate.test.js b/packages/synthmcp/test/validate.test.js
new file mode 100644
index 0000000..b8425e5
--- /dev/null
+++ b/packages/synthmcp/test/validate.test.js
@@ -0,0 +1,184 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildModel } from "../src/index.js";
+import { connect, readContract } from "./helpers.js";
+import { closest, editDistance } from "../src/validate.js";
+
+const contract = readContract();
+
+async function validate(html, options) {
+  const { call, close } = await connect(options);
+  try {
+    return await call("validate_markup", { html });
+  } finally {
+    await close();
+  }
+}
+const ofType = (res, type) => res.issues.filter((i) => i.type === type);
+
+test("edit distance and closest names", () => {
+  assert.equal(editDistance("kitten", "sitting"), 3);
+  assert.equal(editDistance("", "abc"), 3);
+  assert.equal(closest("buton", ["badge", "button", "card"]), "button");
+  assert.equal(closest("zzzzzzzzzzzz", ["badge"]), undefined, "no suggestion when nothing is close");
+});
+
+test("unknown-class: an invented class in the SynthCSS namespace, with the closest public name", async () => {
+  // stak-lg is outside the namespace (no contract base "stak"), so it is the author's own.
+  const res = await validate('<div class="stak-lg">…</div><div class="synth-grid">…</div><p class="button-ghost">…</p>');
+  assert.equal(res.valid, false);
+  const issues = ofType(res, "unknown-class");
+  assert.deepEqual(issues.map((i) => i.value), ["synth-grid", "button-ghost"]);
+  assert.ok(issues.every((i) => i.severity === "error"));
+  assert.equal(issues[0].suggestion, "grid", "synth- is dropped before matching");
+  assert.ok(contract.components.button.variants[issues[1].suggestion], "suggests a real button variant");
+});
+
+test("arbitrary user classes are ignored", async () => {
+  const res = await validate('<div class="my-header hero flex-row-gap-large-center btn btn-primary mt-4 user-avatar"><span class="stackable">x</span></div>');
+  assert.deepEqual(res.issues, []);
+  assert.equal(res.valid, true);
+  const mixed = await validate('<div class="cluster my-row"><button type="button" class="button js-save">Save</button></div>');
+  assert.deepEqual(mixed.issues, []);
+});
+
+test("unsupported-variant: a variant without its base, an unknown variant on a base, a modifier without its primitive", async () => {
+  const res = await validate(
+    '<button type="button" class="button-danger">Delete</button>' +
+      '<span class="badge badge-red">Failed</span>' +
+      '<div class="stack stack-xl">…</div>' +
+      '<div class="sidebar-end"><main>…</main><aside>…</aside></div>',
+  );
+  const issues = ofType(res, "unsupported-variant");
+  assert.deepEqual(issues.map((i) => i.value), ["button-danger", "badge-red", "stack-xl", "sidebar-end"]);
+  assert.equal(issues[0].suggestion, "button button-danger");
+  assert.ok(contract.components.badge.variants[issues[1].suggestion], "suggests a real badge variant");
+  assert.ok(["stack-sm", "stack-lg"].includes(issues[2].suggestion));
+  assert.equal(issues[3].suggestion, "sidebar sidebar-end");
+  assert.equal(res.valid, false);
+
+  const ok = await validate('<div class="sidebar-lg sidebar-end"><main>…</main><aside>…</aside></div><div class="stack-lg">…</div>');
+  assert.deepEqual(ok.issues, [], "gap variants stand alone and carry the modifier");
+});
+
+test("internal-class: classes the contract marks internal", async () => {
+  const fixture = structuredClone(contract);
+  fixture.internal.classes = ["synth-focus-ring"];
+  const res = await validate('<button type="button" class="button synth-focus-ring">Save</button>', { model: buildModel(fixture) });
+  assert.deepEqual(res.issues.map((i) => [i.type, i.severity, i.value]), [["internal-class", "error", "synth-focus-ring"]]);
+  assert.equal(res.valid, false);
+  // The repository contract has no internal classes today.
+  assert.deepEqual(contract.internal.classes, []);
+});
+
+test("invalid-part: a part outside its component, in the wrong place, or one the component lacks", async () => {
+  const res = await validate(
+    '<div class="card-header"><h2>Atlas</h2></div>' +
+      '<article class="card"><h2 class="card-title">Atlas</h2></article>' +
+      '<div class="cover"><div><div class="cover-main">…</div></div></div>' +
+      '<div class="table-wrap" tabindex="0"><table><tr><td class="numeric">1</td></tr></table></div>',
+  );
+  const issues = ofType(res, "invalid-part");
+  assert.deepEqual(issues.map((i) => i.value), ["card-header", "card-title", "cover-main", "table-wrap", "numeric"]);
+  assert.equal(issues[1].suggestion, "card-header");
+  assert.match(issues[2].message, /direct child/);
+  assert.match(issues[3].message, /around/);
+
+  const ok = await validate(
+    '<article class="card"><div class="card-header"><h2>Atlas</h2></div><div class="card-footer split-sm">…</div></article>' +
+      '<main class="cover"><form class="cover-main center stack">…</form></main>' +
+      '<div class="table-wrap" tabindex="0"><table class="table"><tr><td class="numeric">1</td></tr></table></div>',
+  );
+  assert.deepEqual(ok.issues, []);
+});
+
+test("unknown-data-attribute: data-synth-* attributes SynthJS does not read, and internal markers", async () => {
+  const res = await validate('<button type="button" class="button" data-synth-collapse="filters">Filters</button><div id="filters" hidden>…</div><style data-synth></style>');
+  const issues = ofType(res, "unknown-data-attribute");
+  assert.deepEqual(issues.map((i) => i.value), ["data-synth-collapse", "data-synth"]);
+  assert.ok(Object.hasOwn(contract.synthjs.attributes, issues[0].suggestion));
+  assert.match(issues[1].message, /internal/);
+  assert.equal(res.valid, false);
+});
+
+test("invalid-target: data-synth-* targets that do not resolve within the fragment", async () => {
+  const res = await validate(
+    '<button type="button" class="button" data-synth-open="missing">Open</button>' +
+      '<button type="button" class="button" data-synth-open="not-a-dialog">Open</button><div id="not-a-dialog">…</div>' +
+      '<button type="button" class="button" data-synth-toggle="">Toggle</button>' +
+      '<button type="button" class="button" data-synth-dismiss>Close</button>' +
+      '<div data-synth-tabs><p>No tabs here</p></div>' +
+      '<ul data-synth-dropdown-menu hidden><li>…</li></ul>',
+  );
+  const issues = ofType(res, "invalid-target");
+  assert.deepEqual(issues.map((i) => i.value), [
+    'data-synth-open="missing"',
+    'data-synth-open="not-a-dialog"',
+    "data-synth-toggle",
+    "data-synth-dismiss",
+    "data-synth-tabs",
+    "data-synth-dropdown-menu",
+  ]);
+  assert.match(issues[1].message, /<dialog>/);
+  assert.equal(res.valid, false);
+});
+
+test("valid data-synth-* usage passes", async () => {
+  const html = [
+    '<button type="button" class="button" data-synth-open="d1">Open</button>',
+    '<dialog id="d1" aria-labelledby="d1-title"><h2 id="d1-title">Hi</h2><button type="button" class="button" data-synth-dismiss>Close</button></dialog>',
+    '<button type="button" class="button" data-synth-toggle="more">More</button><div id="more" hidden>…</div>',
+    '<div class="alert alert-info" role="status" data-synth-dismissible><p>Saved.</p><button type="button" class="button button-sm" data-synth-dismiss>Dismiss</button></div>',
+    '<div data-synth-dropdown><button type="button" class="button">Account</button><ul class="nav stack-sm" role="list" data-synth-dropdown-menu hidden><li><a class="nav-link" href="/me">Me</a></li></ul></div>',
+    contract.examples.patterns.tabs.html,
+  ].join("\n");
+  const res = await validate(html);
+  assert.deepEqual(res.issues, []);
+  assert.equal(res.valid, true);
+});
+
+test("a11y: the contract's accessibility expectations, with their severity", async () => {
+  const res = await validate(
+    '<div class="button">Save</div>' +
+      '<button type="button" class="button button-icon"><svg aria-hidden="true"></svg></button>' +
+      '<div class="alert alert-danger">Failed</div>' +
+      '<div class="tabs" role="group"><button type="button" class="tabs-item" aria-pressed="true">Week</button></div>' +
+      '<div class="table-wrap"><table class="table"></table></div>',
+  );
+  const issues = ofType(res, "a11y");
+  assert.deepEqual(
+    issues.map((i) => [i.value, i.severity]),
+    [
+      ["button", "error"],
+      ["button-icon", "error"],
+      ["alert", "error"],
+      ["tabs", "error"],
+      ["tabs", "error"],
+      ["tabs-item", "error"],
+      ["tabs-item", "error"],
+      ["table-wrap", "warning"],
+    ],
+  );
+  assert.match(issues[0].message, /Found <div>/);
+  assert.match(issues[3].message, /Found role="group"/);
+  for (const issue of issues) {
+    const rules = Object.values(contract.components).flatMap((c) => c.accessibility);
+    assert.ok(rules.some((r) => r.class === issue.value && issue.message.startsWith(r.expectation)), issue.message);
+  }
+  assert.equal(res.valid, false);
+});
+
+test("warnings alone keep the markup valid", async () => {
+  const res = await validate('<div class="field"><label class="field-label">Name <input type="text"></label></div>');
+  assert.deepEqual(res.issues.map((i) => [i.type, i.severity, i.value]), [["a11y", "warning", "field-label"]]);
+  assert.equal(res.valid, true);
+});
+
+test("parses full documents and template content, and reports each issue once", async () => {
+  const doc = await validate('<!doctype html><html lang="en"><body class="container"><template><span class="badge-info">x</span></template></body></html>');
+  assert.deepEqual(doc.issues.map((i) => i.value), ["badge-info"]);
+  const repeated = await validate('<span class="badge-info">a</span><span class="badge-info">b</span>');
+  assert.equal(repeated.issues.length, 1);
+  assert.deepEqual((await validate("")).issues, []);
+  assert.deepEqual((await validate("<div class=\"stack\"><p>unclosed")).issues, [], "parse5 recovers like a browser");
+});
diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs
index 9940440..854ded5 100644
--- a/scripts/build.test.mjs
+++ b/scripts/build.test.mjs
@@ -66,3 +66,11 @@ test("package.json has no runtime dependencies", () => {
   assert.equal(pkg.dependencies, undefined);
   assert.deepEqual(Object.keys(pkg.devDependencies ?? {}), ["happy-dom"]);
 });
+
+test("the browser build never includes SynthMCP or its dependencies", () => {
+  const src = new URL("../src/", import.meta.url);
+  const outputs = build("0.0.0", (file) => readFileSync(new URL(file, src), "utf8"));
+  for (const [file, text] of Object.entries(outputs)) {
+    assert.doesNotMatch(text, /synthmcp|modelcontextprotocol|parse5|packages\//i, `dist/${file}`);
+  }
+});
diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs
index af689e1..b739ff7 100644
--- a/scripts/bump-version.mjs
+++ b/scripts/bump-version.mjs
@@ -27,6 +27,8 @@ export function nextVersion(current, bump) {
 // being released with a stale version.
 export const TARGETS = {
   "package.json": (from, to) => [[`"version": "${from}"`, `"version": "${to}"`]],
+  // SynthMCP is released with SynthCSS and carries the same version.
+  "packages/synthmcp/package.json": (from, to) => [[`"version": "${from}"`, `"version": "${to}"`]],
   "synthcss.ai.json": (from, to) => [[`"synthcssVersion": "${from}"`, `"synthcssVersion": "${to}"`]],
   "synthcss.llm.md": (from, to) => [
     [`Version: SynthCSS ${from} `, `Version: SynthCSS ${to} `],
diff --git a/scripts/bump-version.test.mjs b/scripts/bump-version.test.mjs
index 1e732fd..84c19ec 100644
--- a/scripts/bump-version.test.mjs
+++ b/scripts/bump-version.test.mjs
@@ -4,6 +4,7 @@ import { bumpFiles, nextVersion } from "./bump-version.mjs";
 
 const files = {
   "package.json": '{\n  "name": "synthcss",\n  "version": "0.2.0",\n  "private": true\n}\n',
+  "packages/synthmcp/package.json": '{\n  "name": "synthmcp",\n  "version": "0.2.0",\n  "dependencies": { "synthcss": "file:../.." }\n}\n',
   "synthcss.ai.json": '{\n  "synthcssVersion": "0.2.0",\n  "contractVersion": "1.0.0"\n}\n',
   "synthcss.llm.md":
     "# SynthCSS\r\n\r\nVersion: SynthCSS 0.2.0 · contract 1.0.0\r\n\r\nLoad: `https://cdn.jsdelivr.net/gh/nabledhq/synthcss@0.2.0/dist/synthcss.min.css`\r\n",
@@ -22,6 +23,7 @@ test("computes the next version", () => {
 test("updates every version reference and keeps line endings", () => {
   const out = bumpFiles(files, "0.2.0", "0.3.0");
   assert.ok(out["package.json"].includes('"version": "0.3.0"'));
+  assert.ok(out["packages/synthmcp/package.json"].includes('"version": "0.3.0"'), "SynthMCP tracks the SynthCSS version");
   assert.ok(out["synthcss.ai.json"].includes('"synthcssVersion": "0.3.0"'));
   assert.ok(out["synthcss.ai.json"].includes('"contractVersion": "1.0.0"'), "contract version is separate");
   assert.ok(out["synthcss.llm.md"].includes("Version: SynthCSS 0.3.0 · contract 1.0.0\r\n"));
diff --git a/scripts/verify-ai-contract.mjs b/scripts/verify-ai-contract.mjs
index 2e59420..07862ac 100644
--- a/scripts/verify-ai-contract.mjs
+++ b/scripts/verify-ai-contract.mjs
@@ -16,18 +16,18 @@ import { buildBundle, classesInCss } from "./check-components.mjs";
 import { parseBase } from "./check-base.mjs";
 
 export const DEFAULT_MAX_TOKENS = 8000;
-// Class selectors in the framework CSS that are internal helpers, not public API,
-// and so are not required in the contract. Empty today: every class SynthCSS ships
-// is public.
-export const INTERNAL_CLASSES = [];
 export const JSON_KEYS = [
   "synthcssVersion",
   "contractVersion",
   "tokens",
   "baseStyles",
   "layouts",
+  "primitives",
   "components",
+  "synthjs",
+  "internal",
   "intentMap",
+  "intents",
   "compositionRules",
   "generationRules",
   "extension",
@@ -70,6 +70,12 @@ export const BEHAVIOR_SECTION = "Behaviors (SynthJS)";
 export const BEHAVIOR_KEYS = ["intent", "attribute", "target", "requiredMarkup", "accessibility"];
 export const SYNTH_JS_FILE = "src/js/synth.js";
 const BEHAVIOR_ATTRIBUTE = /^data-synth-[a-z]+(-[a-z]+)*$/;
+// Contract 1.4 additions, read by SynthMCP (packages/synthmcp). Where a part sits
+// relative to its base class: inside it (default), a direct child, or wrapping it.
+export const PLACEMENTS = ["inside", "child", "wraps"];
+export const SEVERITIES = ["error", "warning"];
+// synthjs.attributes values: "id" names the target element, "none" is a bare attribute.
+export const SYNTH_VALUES = ["id", "none"];
 
 // `.name` not preceded by a word character, dot, slash or dash, so file names
 // (synthcss.ai.json), URLs and "e.g." are not read as classes.
@@ -249,6 +255,166 @@ function checkJsonShape(c) {
     if (ex.valid.length < min || ex.valid.length > max) errors.push(`${JSON_FILE}: needs ${min}–${max} valid examples, found ${ex.valid.length}`);
     if (ex.invalid.length < 2) errors.push(`${JSON_FILE}: needs at least 2 invalid examples`);
   }
+  errors.push(...checkMcpShape(c));
+  return errors;
+}
+
+const isText = (v) => typeof v === "string" && v.trim() !== "";
+const isList = (a) => Array.isArray(a) && a.every(isText);
+const isPlacement = (p) => isObject(p) && Object.values(p).every((v) => PLACEMENTS.includes(v));
+const isA11yRule = (r) =>
+  isObject(r) &&
+  isText(r.expectation) &&
+  isText(r.class) &&
+  SEVERITIES.includes(r.severity) &&
+  (r.elements !== undefined || r.attributes !== undefined) &&
+  (r.elements === undefined || (isList(r.elements) && r.elements.length > 0)) &&
+  (r.attributes === undefined || (isList(r.attributes) && r.attributes.length > 0)) &&
+  (r.values === undefined || (r.attributes !== undefined && isList(r.values) && r.values.length > 0));
+
+// Shape of the sections SynthMCP reads (contract 1.4): layout primitives, component
+// accessibility / example / placement, SynthJS attributes, internal markers, intents
+// and the named example patterns.
+function checkMcpShape(c) {
+  const errors = [];
+  if (!isObject(c.primitives)) errors.push(`${JSON_FILE}: primitives must be an object`);
+  else {
+    for (const [name, p] of Object.entries(c.primitives)) {
+      const ok =
+        isObject(p) &&
+        isList(p.variants) &&
+        (p.modifiers === undefined || isList(p.modifiers)) &&
+        (p.parts === undefined || isList(p.parts)) &&
+        (p.placement === undefined || isPlacement(p.placement)) &&
+        isText(p.responsive) &&
+        isList(p.composition) &&
+        p.composition.length > 0 &&
+        isText(p.example);
+      if (!ok) {
+        errors.push(`${JSON_FILE}: primitives.${name} must be { variants: [], modifiers?: [], parts?: [], placement?: {part: ${PLACEMENTS.join("|")}}, responsive, composition: [], example }`);
+      }
+    }
+  }
+  if (isObject(c.components)) {
+    for (const [name, comp] of Object.entries(c.components)) {
+      if (!isObject(comp)) continue;
+      if (!Array.isArray(comp.accessibility) || !comp.accessibility.every(isA11yRule)) {
+        errors.push(`${JSON_FILE}: components.${name}.accessibility must be a list of { expectation, class, elements?: [], attributes?: [], values?: [], severity: ${SEVERITIES.join("|")} }`);
+      }
+      if (!isText(comp.example)) errors.push(`${JSON_FILE}: components.${name}.example must be a minimal HTML example`);
+      if (comp.placement !== undefined && !isPlacement(comp.placement)) {
+        errors.push(`${JSON_FILE}: components.${name}.placement must map parts to ${PLACEMENTS.join(", ")}`);
+      }
+    }
+  }
+  const isSelectors = (a) => isList(a) && a.length > 0;
+  const isSynthAttr = (a) =>
+    isObject(a) &&
+    isText(a.behavior) &&
+    SYNTH_VALUES.includes(a.value) &&
+    isText(a.purpose) &&
+    (a.targetElements === undefined || (a.value === "id" && isSelectors(a.targetElements))) &&
+    (a.closest === undefined || isSelectors(a.closest)) &&
+    (a.contains === undefined || (Array.isArray(a.contains) && a.contains.length > 0 && a.contains.every(isSelectors)));
+  if (!isObject(c.synthjs) || !isObject(c.synthjs.attributes) || !Object.values(c.synthjs.attributes).every(isSynthAttr)) {
+    errors.push(`${JSON_FILE}: synthjs must be { attributes: { "data-synth-…": { behavior, value: ${SYNTH_VALUES.join("|")}, purpose, targetElements?, closest?, contains? } } }`);
+  }
+  if (!isObject(c.internal) || !isText(c.internal.note) || !isList(c.internal.classes) || !isList(c.internal.attributes)) {
+    errors.push(`${JSON_FILE}: internal must be { note, classes: [], attributes: [] }`);
+  }
+  const isIntent = (e) =>
+    isObject(e) &&
+    isText(e.class) &&
+    isText(e.reason) &&
+    isList(e.keywords) &&
+    e.keywords.length > 0 &&
+    (e.with === undefined || isList(e.with)) &&
+    (e.attribute === undefined || isText(e.attribute));
+  if (!Array.isArray(c.intents) || !c.intents.length || !c.intents.every(isIntent)) {
+    errors.push(`${JSON_FILE}: intents must be a list of { class, with?: [], attribute?, reason, keywords: [] }`);
+  }
+  const patterns = c.examples?.patterns;
+  if (!isObject(patterns) || !Object.values(patterns).every(isExample)) {
+    errors.push(`${JSON_FILE}: examples.patterns must map each pattern name to { html, note }`);
+  }
+  return errors;
+}
+
+// Meaning of the 1.4 sections: every class they name is public, primitives cover the
+// layouts, parts and accessibility rules belong to their component, SynthJS
+// attributes match the behaviors and the runtime, and their HTML follows the markup
+// rules.
+function checkMcpSections(contract, { vocab, cssClasses, synthJs }, markupContext) {
+  const errors = [];
+  const internal = new Set(contract.internal.classes);
+  const isPublic = (cls) => vocab.has(cls) && !internal.has(cls);
+  for (const cls of contract.internal.classes) {
+    if (!cssClasses.has(cls)) errors.push(`${JSON_FILE}: internal class .${cls} is not a selector in the SynthCSS CSS`);
+    if (vocab.has(cls)) errors.push(`${JSON_FILE}: internal class .${cls} is also listed as public vocabulary`);
+  }
+
+  const covered = new Map();
+  const cover = (cls, by) => {
+    if (!(cls in contract.layouts)) errors.push(`${JSON_FILE}: primitives.${by} names .${cls}, which is not in layouts`);
+    else if (covered.has(cls)) errors.push(`${JSON_FILE}: layout .${cls} belongs to both primitives.${covered.get(cls)} and primitives.${by}`);
+    else covered.set(cls, by);
+  };
+  for (const [name, p] of Object.entries(contract.primitives)) {
+    cover(name, name);
+    for (const cls of [...p.variants, ...(p.modifiers ?? []), ...(p.parts ?? [])]) cover(cls, name);
+    for (const part of Object.keys(p.placement ?? {})) {
+      if (!(p.parts ?? []).includes(part)) errors.push(`${JSON_FILE}: primitives.${name}.placement names .${part}, which is not one of its parts`);
+    }
+    errors.push(...checkMarkup(`${JSON_FILE}: primitives.${name}.example`, p.example, undefined, markupContext));
+  }
+  for (const cls of Object.keys(contract.layouts)) {
+    if (!covered.has(cls)) errors.push(`${JSON_FILE}: layout .${cls} is not listed in primitives (as a primitive, variant, modifier or part)`);
+  }
+
+  for (const [name, comp] of Object.entries(contract.components)) {
+    const own = new Set([name, ...Object.keys(comp.parts), ...Object.keys(comp.variants)]);
+    for (const part of Object.keys(comp.placement ?? {})) {
+      if (!(part in comp.parts)) errors.push(`${JSON_FILE}: components.${name}.placement names .${part}, which is not one of its parts`);
+    }
+    for (const rule of comp.accessibility) {
+      if (!own.has(rule.class)) errors.push(`${JSON_FILE}: components.${name}.accessibility rule for .${rule.class} must name the component, a part or a variant`);
+    }
+    errors.push(...checkMarkup(`${JSON_FILE}: components.${name}.example`, comp.example, undefined, markupContext));
+    if (!new RegExp(`class="([^"]*\\s)?${name}(\\s[^"]*)?"`).test(comp.example)) {
+      errors.push(`${JSON_FILE}: components.${name}.example must use .${name}`);
+    }
+  }
+
+  const attributes = contract.synthjs.attributes;
+  const behaviorAttributes = new Set(behaviorsOf(contract).map((b) => b.attribute));
+  for (const attr of behaviorAttributes) {
+    if (attributes[attr]?.behavior !== attr) errors.push(`${JSON_FILE}: synthjs.attributes must list the behavior attribute ${attr} with behavior "${attr}"`);
+  }
+  for (const [attr, meta] of Object.entries(attributes)) {
+    if (!BEHAVIOR_ATTRIBUTE.test(attr)) errors.push(`${JSON_FILE}: synthjs.attributes ${attr} must be data-synth-<name>`);
+    if (!behaviorAttributes.has(meta.behavior)) errors.push(`${JSON_FILE}: synthjs.attributes ${attr} belongs to ${meta.behavior}, which is not a behavior attribute`);
+    if (synthJs !== undefined && !synthJs.includes(`"${attr}`) && !synthJs.includes(`[${attr}`)) {
+      errors.push(`${JSON_FILE}: synthjs.attributes ${attr} is not used by ${SYNTH_JS_FILE}`);
+    }
+  }
+  for (const attr of contract.internal.attributes) {
+    if (attr in attributes) errors.push(`${JSON_FILE}: internal attribute ${attr} is also a public synthjs attribute`);
+  }
+
+  contract.intents.forEach((intent, i) => {
+    const where = `${JSON_FILE}: intents ${i + 1} (${intent.class})`;
+    for (const cls of [intent.class, ...(intent.with ?? [])]) {
+      if (!isPublic(cls)) errors.push(`${where}: .${cls} is not a public contract class`);
+    }
+    if (intent.attribute !== undefined && !behaviorAttributes.has(intent.attribute)) {
+      errors.push(`${where}: ${intent.attribute} is not a behavior attribute`);
+    }
+  });
+
+  for (const [name, p] of Object.entries(contract.examples.patterns)) {
+    if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) errors.push(`${JSON_FILE}: examples.patterns name "${name}" must be lowercase and dashed`);
+    errors.push(...checkMarkup(`${JSON_FILE}: examples.patterns.${name}`, p.html, undefined, markupContext));
+  }
   return errors;
 }
 
@@ -442,7 +608,8 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
 
   const cssClasses = classesInCss(files.builtCss);
   const cssTokens = new Set(parseTokens(files.builtCss).root.keys());
-  const publicClasses = new Set([...cssClasses].filter((c) => !INTERNAL_CLASSES.includes(c)));
+  const internalClasses = new Set(contract.internal.classes);
+  const publicClasses = new Set([...cssClasses].filter((c) => !internalClasses.has(c)));
   const { version } = JSON.parse(files.pkg);
 
   // Versions.
@@ -461,7 +628,7 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
     if (!cssClasses.has(cls)) errors.push(`${JSON_FILE}: class .${cls} is not a selector in the SynthCSS CSS`);
   }
   for (const cls of diff(publicClasses, vocab)) {
-    errors.push(`class .${cls} is in the SynthCSS CSS but missing from ${JSON_FILE} (add it to layouts or components, or to INTERNAL_CLASSES)`);
+    errors.push(`class .${cls} is in the SynthCSS CSS but missing from ${JSON_FILE} (add it to layouts or components, or to internal.classes)`);
   }
   const jsonTokens = new Set(Object.keys(contract.tokens));
   for (const token of sorted(new Set([...jsonTokens, ...jsonMentions.tokens]))) {
@@ -514,6 +681,9 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
     errors.push(...checkMarkup(`${where}: requiredMarkup`, b.requiredMarkup, undefined, markupContext));
   }
 
+  // Sections read by SynthMCP.
+  errors.push(...checkMcpSections(contract, { vocab, cssClasses, synthJs: files.synthJs }, markupContext));
+
   // Markdown.
   const { preamble, sections } = parseMarkdown(md);
   const header = /SynthCSS v?(\d+\.\d+\.\d+\S*)\s*·\s*contract v?(\d+\.\d+\.\d+)/.exec(preamble);
diff --git a/scripts/verify-ai-contract.test.mjs b/scripts/verify-ai-contract.test.mjs
index 9909578..62cda9a 100644
--- a/scripts/verify-ai-contract.test.mjs
+++ b/scripts/verify-ai-contract.test.mjs
@@ -214,7 +214,7 @@ const editAppShell = (find, replace) => {
   };
 };
 
-test("states the one extension rule in both files, at contract 1.3.0", () => {
+test("states the one extension rule in both files, at contract 1.4.0", () => {
   const ext = contract.extension;
   assert.equal(ext.attribute, "data-ui");
   assert.equal(ext.layer, "synth.ext");
@@ -223,8 +223,8 @@ test("states the one extension rule in both files, at contract 1.3.0", () => {
   for (const banned of ["display", "flex", "grid-template-columns", "gap", "margin", "position", "order"]) {
     assert.ok(!ext.properties.includes(banned), `${banned} is not an extension property`);
   }
-  assert.equal(contract.contractVersion, "1.3.0");
-  assert.match(files.md, /· contract 1\.3\.0 ·/);
+  assert.equal(contract.contractVersion, "1.4.0");
+  assert.match(files.md, /· contract 1\.4\.0 ·/);
   assert.match(files.md, /^## When the vocabulary is missing a pattern$/m);
   for (const phrase of ['`data-ui="<name>"`', "`@layer synth.ext", "`var(--…)`"]) assert.ok(files.md.includes(phrase), phrase);
   assertError(errorsWith(withJson((c) => delete c.extension)), 'missing top-level key "extension"');
@@ -373,3 +373,36 @@ test("--write replaces only the behavior section", () => {
   assert.ok(md.includes(`## ${BEHAVIOR_SECTION}\n\nnew body\n\n## Composition Rules\n`));
   assert.equal(replaceSection(md, BEHAVIOR_SECTION, renderBehaviors(contract)), files.md);
 });
+
+test("checks the 1.4 sections SynthMCP reads: primitives, accessibility, synthjs, internal, intents, patterns", () => {
+  for (const key of ["primitives", "synthjs", "internal", "intents"]) {
+    assertError(errorsWith(withJson((c) => delete c[key])), `missing top-level key "${key}"`);
+  }
+  assert.deepEqual(Object.keys(contract.primitives), ["container", "stack", "cluster", "grid", "sidebar", "split", "center", "cover"]);
+  assertError(errorsWith(withJson((c) => c.primitives.stack.variants.pop())), "layout .stack-lg is not listed in primitives");
+  assertError(errorsWith(withJson((c) => c.primitives.split.variants.push("stack-sm"))), "layout .stack-sm belongs to both primitives.stack and primitives.split");
+  assertError(errorsWith(withJson((c) => delete c.primitives.grid.responsive)), "primitives.grid must be");
+  assertError(errorsWith(withJson((c) => (c.primitives.cover.placement = { "cover-main": "beside" }))), "primitives.cover must be");
+
+  assertError(errorsWith(withJson((c) => delete c.components.card.example)), "components.card.example must be a minimal HTML example");
+  assertError(errorsWith(withJson((c) => (c.components.card.example = '<div class="tile">…</div>'))), "components.card.example uses .tile");
+  assertError(errorsWith(withJson((c) => (c.components.badge.example = '<span class="alert">…</span>'))), "components.badge.example must use .badge");
+  assertError(errorsWith(withJson((c) => (c.components.table.placement = { "card-body": "inside" }))), "components.table.placement names .card-body");
+  const foreignRule = withJson((c) => c.components.tabs.accessibility.push({ expectation: "…", class: "nav-link", elements: ["a"], severity: "error" }));
+  assertError(errorsWith(foreignRule), "components.tabs.accessibility rule for .nav-link must name the component");
+  const badRule = withJson((c) => (c.components.alert.accessibility[0].severity = "fatal"));
+  assertError(errorsWith(badRule), "components.alert.accessibility must be a list of");
+
+  assertError(errorsWith(withJson((c) => delete c.synthjs.attributes["data-synth-toggle"])), "synthjs.attributes must list the behavior attribute data-synth-toggle");
+  const unused = withJson((c) => (c.synthjs.attributes["data-synth-collapse"] = { behavior: "data-synth-toggle", value: "none", purpose: "…" }));
+  assertError(errorsWith(unused), "synthjs.attributes data-synth-collapse is not used by src/js/synth.js");
+  assertError(errorsWith(withJson((c) => (c.synthjs.attributes["data-synth-open"].value = "selector"))), "synthjs must be");
+
+  assertError(errorsWith(withJson((c) => c.internal.classes.push("button"))), "internal class .button is also listed as public vocabulary");
+  assertError(errorsWith(withJson((c) => c.intents.push({ class: "hero", reason: "…", keywords: ["hero"] }))), "(hero): .hero is not a public contract class");
+  assertError(errorsWith(withJson((c) => (c.intents[0].attribute = "data-synth-collapse"))), "data-synth-collapse is not a behavior attribute");
+
+  assertError(errorsWith(withJson((c) => delete c.examples.patterns)), "examples.patterns must map each pattern name");
+  const pattern = withJson((c) => (c.examples.patterns.dialog.html = '<div class="modal">…</div>'));
+  assertError(errorsWith(pattern), "examples.patterns.dialog uses .modal");
+});
diff --git a/showcase/README.md b/showcase/README.md
index 7a50bd2..9b5e011 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -2,7 +2,7 @@
 
 A single static page that shows what SynthCSS is, why it is AI-first, and live demos
 of its design tokens, layout primitives and components, plus a composed interface built
-only from SynthCSS classes. It is built with SynthCSS itself and
+only from SynthCSS classes, the AI contract and the SynthMCP server. It is built with SynthCSS itself and
 published to GitHub Pages.
 
 | File | Purpose |
diff --git a/showcase/index.html b/showcase/index.html
index da917e8..1685f96 100644
--- a/showcase/index.html
+++ b/showcase/index.html
@@ -35,6 +35,7 @@
             <li><a href="#composed">Composed</a></li>
             <li><a href="#ai-examples">AI examples</a></li>
             <li><a href="#ai-contract">AI contract</a></li>
+            <li><a href="#synthmcp">SynthMCP</a></li>
           </ul>
         </nav>
       </header>
@@ -1272,6 +1273,71 @@ <h3>Team</h3>
         </article>
       </div>
     </section>
+
+    <section id="synthmcp" class="sc-band" aria-labelledby="synthmcp-title">
+      <div class="container stack-lg">
+        <div class="stack-sm">
+          <h2 id="synthmcp-title">SynthMCP</h2>
+          <p class="sc-muted">
+            A stdio MCP server for coding agents. Instead of the whole contract in the prompt, an
+            agent asks seven read-only tools: <code>list_components</code>,
+            <code>get_component</code>, <code>list_layouts</code>, <code>get_layout</code>,
+            <code>resolve_intent</code>, <code>validate_markup</code> and <code>get_example</code>.
+            Every answer comes from <code>synthcss.ai.json</code> and carries
+            <code>synthVersion</code> and <code>contractVersion</code>. Start it with
+            <code>npx synthmcp</code> or <code>npm run --silent mcp</code>.
+          </p>
+        </div>
+        <div class="cluster">
+          <a class="sc-button" href="https://github.com/nabledhq/synthcss/blob/main/docs/mcp.md">Read the SynthMCP docs</a>
+          <a href="https://github.com/nabledhq/synthcss/tree/main/packages/synthmcp">packages/synthmcp</a>
+        </div>
+        <div class="grid-lg" style="--grid-min: 22rem">
+          <article class="sc-card stack" aria-labelledby="synthmcp-intent">
+            <h3 id="synthmcp-intent">Intent to class: <code>.cluster</code></h3>
+            <p>The agent describes the need; <code>resolve_intent</code> scores it against the contract's intents, with no model involved.</p>
+            <p class="sc-label">Tool call</p>
+            <div class="sc-code"><pre><code>resolve_intent({ "intent": "a row of buttons that wraps" })</code></pre></div>
+            <p class="sc-label">Response</p>
+            <div class="sc-code"><pre><code>{
+  "class": ".cluster",
+  "classes": "cluster",
+  "reason": "Wrapping row of small items (buttons, tags, links) that keep their own width."
+}</code></pre></div>
+            <p class="sc-label">Rendered with SynthCSS</p>
+            <div class="sc-demo">
+              <div class="cluster">
+                <button type="button" class="button">Archive</button>
+                <button type="button" class="button">Duplicate</button>
+                <button type="button" class="button">Share</button>
+                <button type="button" class="button button-primary">Publish</button>
+              </div>
+            </div>
+          </article>
+          <article class="sc-card stack" aria-labelledby="synthmcp-validate">
+            <h3 id="synthmcp-validate">Validation: <code>.button-danger</code></h3>
+            <p><code>validate_markup</code> parses the HTML and catches a variant used without its base class, with the fix as a suggestion.</p>
+            <p class="sc-label">Tool call</p>
+            <div class="sc-code"><pre><code>validate_markup({ "html": "&lt;button class=\"button-danger\"&gt;Delete&lt;/button&gt;" })</code></pre></div>
+            <p class="sc-label">Response</p>
+            <div class="sc-code"><pre><code>{
+  "valid": false,
+  "issues": [{
+    "type": "unsupported-variant",
+    "severity": "error",
+    "value": "button-danger",
+    "message": ".button-danger is a variant of .button and needs class \"button\" on the same element.",
+    "suggestion": "button button-danger"
+  }]
+}</code></pre></div>
+            <p class="sc-label">Fixed, rendered with SynthCSS</p>
+            <div class="sc-demo">
+              <button type="button" class="button button-danger">Delete project</button>
+            </div>
+          </article>
+        </div>
+      </div>
+    </section>
   </main>
 
   <footer class="sc-footer">
diff --git a/synthcss.ai.json b/synthcss.ai.json
index edf0067..a8482f6 100644
--- a/synthcss.ai.json
+++ b/synthcss.ai.json
@@ -1,6 +1,6 @@
 {
   "synthcssVersion": "0.9.0",
-  "contractVersion": "1.3.0",
+  "contractVersion": "1.4.0",
   "tokens": {
     "--color-background": "page background",
     "--color-surface": "subtle background for panels, table heads, footers",
@@ -101,6 +101,83 @@
     "cover": "full-viewport-height section, main child vertically centered",
     "cover-main": "the child of .cover that is vertically centered"
   },
+  "primitives": {
+    "container": {
+      "variants": [],
+      "responsive": "full width of its parent below --container-width, then stops growing and is centered; side padding grows from --space-4 to --space-6",
+      "composition": [
+        "Wrap the page's main content, or each full-width band, and put .stack or .stack-lg on the same element.",
+        "Never nest a .container in another; narrow text with .center instead."
+      ],
+      "example": "<main class=\"container stack-lg\">\n  <h1>Settings</h1>\n  <p>…</p>\n</main>"
+    },
+    "stack": {
+      "variants": ["stack-sm", "stack-lg"],
+      "responsive": "a column at every width; spacing comes from the gap, children keep their normal width",
+      "composition": [
+        "Form fields, card and panel content, page sections.",
+        "Wrap a button in a .cluster inside the stack to keep it at its content width."
+      ],
+      "example": "<section class=\"stack\">\n  <h2>Profile</h2>\n  <p>…</p>\n</section>"
+    },
+    "cluster": {
+      "variants": ["cluster-sm", "cluster-lg"],
+      "responsive": "items stay in one row while they fit, then wrap; items keep their own width",
+      "composition": [
+        "Rows of buttons, tags, badges or inline links.",
+        "Use .cluster-sm for the actions of a form, a .card-footer or a .split header."
+      ],
+      "example": "<div class=\"cluster-sm\">\n  <button type=\"button\" class=\"button button-primary\">Save</button>\n  <button type=\"button\" class=\"button\">Cancel</button>\n</div>"
+    },
+    "grid": {
+      "variants": ["grid-sm", "grid-lg"],
+      "responsive": "as many equal columns as fit, each at least --grid-min wide; one column on a phone, never overflows its parent",
+      "composition": [
+        "A <ul role=\"list\"> of .card items for collections.",
+        "Set the column width per grid with an inline token override: style=\"--grid-min: 12rem\"."
+      ],
+      "example": "<ul class=\"grid\" role=\"list\" style=\"--grid-min: 14rem\">\n  <li class=\"card\">…</li>\n  <li class=\"card\">…</li>\n</ul>"
+    },
+    "sidebar": {
+      "variants": ["sidebar-sm", "sidebar-lg"],
+      "modifiers": ["sidebar-end"],
+      "responsive": "the narrow column keeps --sidebar-width and the main area grows (at least half); both stack when the layout itself gets narrow, with no media queries",
+      "composition": [
+        "Exactly two children: the narrow column (navigation, filters) first, then the main area.",
+        "Add .sidebar-end to put the narrow column last, on the right; override --sidebar-width inline per layout."
+      ],
+      "example": "<div class=\"sidebar\">\n  <nav class=\"stack-sm\" aria-label=\"Settings\">…</nav>\n  <main class=\"stack\">…</main>\n</div>"
+    },
+    "split": {
+      "variants": ["split-sm", "split-lg"],
+      "responsive": "first and last child pushed to opposite ends and vertically centered; the second group wraps under the first when they no longer fit",
+      "composition": [
+        "Page, card and panel headers: a title and a .cluster-sm of actions.",
+        "Toolbars and footers with two groups, e.g. class=\"card-footer split-sm\"."
+      ],
+      "example": "<header class=\"split\">\n  <h1>Projects</h1>\n  <button type=\"button\" class=\"button button-primary\">New project</button>\n</header>"
+    },
+    "center": {
+      "variants": [],
+      "responsive": "fills its parent up to --content-width, then is centered; it centers the box, not the text",
+      "composition": [
+        "Articles, long-form text and narrow forms.",
+        "Add .stack on the same element for vertical rhythm."
+      ],
+      "example": "<article class=\"center stack\">\n  <h1>Release notes</h1>\n  <p>…</p>\n</article>"
+    },
+    "cover": {
+      "variants": [],
+      "parts": ["cover-main"],
+      "placement": { "cover-main": "child" },
+      "responsive": "at least the viewport height (100dvh) and grows with its content; .cover-main is vertically centered, other children sit at the top and bottom",
+      "composition": [
+        "Sign-in, onboarding and error pages.",
+        "Put .center and .stack on the .cover-main child."
+      ],
+      "example": "<main class=\"cover\">\n  <form class=\"cover-main center stack\">…</form>\n</main>"
+    }
+  },
   "components": {
     "button": {
       "intent": "an action, on <button> or <a href>",
@@ -136,7 +213,12 @@
             "the target stays in the DOM; hidden removes it from the accessibility tree"
           ]
         }
-      ]
+      ],
+      "accessibility": [
+        { "expectation": "Put .button on a native <button type> or an <a href>.", "class": "button", "elements": ["button", "a"], "severity": "error" },
+        { "expectation": "An icon-only .button-icon needs an accessible name: aria-label or aria-labelledby.", "class": "button-icon", "attributes": ["aria-label", "aria-labelledby"], "severity": "error" }
+      ],
+      "example": "<button type=\"button\" class=\"button button-primary\">Save</button>"
     },
     "field": {
       "intent": "one labeled form control with help or error text",
@@ -145,17 +227,28 @@
         "field-help": "hint text under the control",
         "field-error": "error message; pair with aria-invalid=\"true\""
       },
-      "variants": {}
+      "variants": {},
+      "accessibility": [
+        { "expectation": ".field-label must be a <label> element.", "class": "field-label", "elements": ["label"], "severity": "error" },
+        { "expectation": ".field-label names its control with for=\"<id>\".", "class": "field-label", "attributes": ["for"], "severity": "warning" }
+      ],
+      "example": "<div class=\"field\">\n  <label class=\"field-label\" for=\"name\">Name</label>\n  <input id=\"name\" type=\"text\" aria-describedby=\"name-help\">\n  <p class=\"field-help\" id=\"name-help\">Shown on your profile.</p>\n</div>"
     },
     "switch": {
       "intent": "on/off toggle: <label class=\"switch\"><input type=\"checkbox\" role=\"switch\"> Text</label>; state from checked / disabled",
       "parts": {},
-      "variants": {}
+      "variants": {},
+      "accessibility": [
+        { "expectation": ".switch must be a <label> around <input type=\"checkbox\" role=\"switch\">.", "class": "switch", "elements": ["label"], "severity": "error" }
+      ],
+      "example": "<label class=\"switch\"><input type=\"checkbox\" role=\"switch\" checked> Email alerts</label>"
     },
     "input-group": {
       "intent": "one <input> joined with .button and/or <select> children; shared borders, outer corners rounded; put in .field for a label",
       "parts": {},
-      "variants": {}
+      "variants": {},
+      "accessibility": [],
+      "example": "<div class=\"input-group\"><input type=\"search\" aria-label=\"Search\"><button type=\"submit\" class=\"button\">Search</button></div>"
     },
     "card": {
       "intent": "raised self-contained item (project, product, user)",
@@ -166,7 +259,9 @@
         "card-media": "full-width image or video",
         "card-actions": "wrapping row of buttons or links"
       },
-      "variants": {}
+      "variants": {},
+      "accessibility": [],
+      "example": "<article class=\"card\">\n  <div class=\"card-header\"><h2>Atlas</h2></div>\n  <div class=\"card-body\">Design system migration.</div>\n  <div class=\"card-footer split-sm\"><span class=\"badge badge-success\">Active</span></div>\n</article>"
     },
     "badge": {
       "intent": "short status label",
@@ -176,7 +271,9 @@
         "badge-warning": "needs attention",
         "badge-danger": "failed or blocked",
         "badge-info": "neutral information"
-      }
+      },
+      "accessibility": [],
+      "example": "<span class=\"badge badge-success\">Active</span>"
     },
     "alert": {
       "intent": "message box; role=\"status\" or role=\"alert\"",
@@ -201,7 +298,13 @@
             "inside a <dialog> it closes the dialog and focus returns to the opener"
           ]
         }
-      ]
+      ],
+      "placement": { "alert-icon": "child" },
+      "accessibility": [
+        { "expectation": ".alert needs role=\"status\" (polite) or role=\"alert\" (urgent).", "class": "alert", "attributes": ["role"], "values": ["status", "alert"], "severity": "error" },
+        { "expectation": ".alert-icon is decorative and needs aria-hidden=\"true\".", "class": "alert-icon", "attributes": ["aria-hidden"], "values": ["true"], "severity": "error" }
+      ],
+      "example": "<div class=\"alert alert-warning\" role=\"status\">\n  <p>Your trial ends in 3 days.</p>\n</div>"
     },
     "panel": {
       "intent": "flat bordered group of secondary content",
@@ -209,7 +312,9 @@
         "panel-header": "title area with a bottom border",
         "panel-body": "content area"
       },
-      "variants": {}
+      "variants": {},
+      "accessibility": [],
+      "example": "<section class=\"panel\">\n  <div class=\"panel-header\"><h2>Activity</h2></div>\n  <div class=\"panel-body\">…</div>\n</section>"
     },
     "table": {
       "intent": "tabular data on a native <table>",
@@ -219,12 +324,20 @@
       },
       "variants": {
         "table-hover": "highlight the hovered row"
-      }
+      },
+      "placement": { "table-wrap": "wraps" },
+      "accessibility": [
+        { "expectation": ".table must be on a native <table>.", "class": "table", "elements": ["table"], "severity": "error" },
+        { "expectation": ".table-wrap needs tabindex=\"0\" so keyboard users can scroll it.", "class": "table-wrap", "attributes": ["tabindex"], "values": ["0"], "severity": "warning" }
+      ],
+      "example": "<div class=\"table-wrap\" tabindex=\"0\">\n  <table class=\"table\">\n    <thead><tr><th>Plan</th><th class=\"numeric\">Price</th></tr></thead>\n    <tbody><tr><td>Team</td><td class=\"numeric\">$12</td></tr></tbody>\n  </table>\n</div>"
     },
     "empty-state": {
       "intent": "nothing to show yet: icon, heading, text, action",
       "parts": {},
-      "variants": {}
+      "variants": {},
+      "accessibility": [],
+      "example": "<div class=\"empty-state\">\n  <h2>No projects yet</h2>\n  <p>Create a project to get started.</p>\n  <button type=\"button\" class=\"button button-primary\">New project</button>\n</div>"
     },
     "nav": {
       "intent": "list reset for navigation links on <ul>; add .stack-sm (vertical) or .cluster-sm (horizontal)",
@@ -244,7 +357,12 @@
             "a disclosure list of links: no role=\"menu\", which promises arrow-key navigation SynthJS does not add"
           ]
         }
-      ]
+      ],
+      "accessibility": [
+        { "expectation": ".nav must be on a list, <ul> or <ol>, with role=\"list\".", "class": "nav", "elements": ["ul", "ol"], "severity": "error" },
+        { "expectation": "Each .nav-link must be an <a href>; the current page gets aria-current=\"page\".", "class": "nav-link", "elements": ["a"], "severity": "error" }
+      ],
+      "example": "<ul class=\"nav stack-sm\" role=\"list\">\n  <li><a class=\"nav-link\" href=\"/\" aria-current=\"page\">Home</a></li>\n  <li><a class=\"nav-link\" href=\"/team\">Team</a></li>\n</ul>"
     },
     "tabs": {
       "intent": "tab list or segmented filter; role=\"tablist\" + aria-label; wraps when narrow. Arrow keys and panel switching: data-synth-tabs (SynthJS, see behaviors)",
@@ -264,7 +382,14 @@
             "panels of unselected tabs get hidden; label each panel with aria-labelledby"
           ]
         }
-      ]
+      ],
+      "accessibility": [
+        { "expectation": ".tabs needs role=\"tablist\".", "class": "tabs", "attributes": ["role"], "values": ["tablist"], "severity": "error" },
+        { "expectation": ".tabs needs a label: aria-label or aria-labelledby.", "class": "tabs", "attributes": ["aria-label", "aria-labelledby"], "severity": "error" },
+        { "expectation": "Each .tabs-item needs role=\"tab\".", "class": "tabs-item", "attributes": ["role"], "values": ["tab"], "severity": "error" },
+        { "expectation": "Each .tabs-item needs aria-selected=\"true\" or \"false\"; never aria-pressed.", "class": "tabs-item", "attributes": ["aria-selected"], "values": ["true", "false"], "severity": "error" }
+      ],
+      "example": "<div class=\"tabs\" role=\"tablist\" aria-label=\"Range\">\n  <button type=\"button\" class=\"tabs-item\" role=\"tab\" aria-selected=\"true\">Week</button>\n  <button type=\"button\" class=\"tabs-item\" role=\"tab\" aria-selected=\"false\">Month</button>\n</div>"
     },
     "avatar": {
       "intent": "fixed square for initials, a photo or an icon, on <span>, <div> or <img>",
@@ -279,9 +404,28 @@
         "avatar-danger": "danger tint",
         "avatar-info": "info tint",
         "avatar-accent": "violet tint with no status meaning"
-      }
+      },
+      "accessibility": [],
+      "example": "<span class=\"avatar avatar-round\" aria-hidden=\"true\">AL</span>"
     }
   },
+  "synthjs": {
+    "attributes": {
+      "data-synth-open": { "behavior": "data-synth-open", "value": "id", "purpose": "on a button: the id of the <dialog> it opens", "targetElements": ["dialog"] },
+      "data-synth-dismiss": { "behavior": "data-synth-dismiss", "value": "none", "purpose": "on a button: closes the closest <dialog>, otherwise hides the closest [data-synth-dismissible]", "closest": ["dialog", "[data-synth-dismissible]"] },
+      "data-synth-dismissible": { "behavior": "data-synth-dismiss", "value": "none", "purpose": "marks the element a data-synth-dismiss button inside it hides" },
+      "data-synth-toggle": { "behavior": "data-synth-toggle", "value": "id", "purpose": "on a button: the id of the element whose hidden attribute it toggles" },
+      "data-synth-tabs": { "behavior": "data-synth-tabs", "value": "none", "purpose": "on the container of a role=\"tablist\" and its role=\"tabpanel\" elements", "contains": [["[role=\"tab\"]"]] },
+      "data-synth-dropdown": { "behavior": "data-synth-dropdown", "value": "none", "purpose": "on the wrapper of a trigger button and a menu", "contains": [["[data-synth-dropdown-trigger]", "button"], ["[data-synth-dropdown-menu]", "[role=\"menu\"]"]] },
+      "data-synth-dropdown-trigger": { "behavior": "data-synth-dropdown", "value": "none", "purpose": "marks the trigger button of a dropdown (default: its first <button>)", "closest": ["[data-synth-dropdown]"] },
+      "data-synth-dropdown-menu": { "behavior": "data-synth-dropdown", "value": "none", "purpose": "marks the menu of a dropdown (default: its [role=\"menu\"])", "closest": ["[data-synth-dropdown]"] }
+    }
+  },
+  "internal": {
+    "note": "Markers SynthCSS or SynthJS set themselves. Never write them in markup.",
+    "classes": [],
+    "attributes": ["data-synth"]
+  },
   "intentMap": [
     { "intent": "Page or section wrapper", "use": ".container" },
     { "intent": "Vertical list of blocks", "use": ".stack" },
@@ -314,6 +458,40 @@
     { "intent": "Dropdown of links", "use": "data-synth-dropdown around a .button and a .nav list (SynthJS)" },
     { "intent": "Dismissible message", "use": ".alert with data-synth-dismissible and a data-synth-dismiss .button (SynthJS)" }
   ],
+  "intents": [
+    { "class": "container", "reason": "Centered page-width wrapper with side padding.", "keywords": ["container", "wrapper", "page wrapper", "page width", "max width", "centered page", "section wrapper"] },
+    { "class": "stack", "reason": "Vertical flow of blocks with an even gap.", "keywords": ["stack", "vertical", "vertically", "one below another", "below each other", "on top of each other", "vertical list", "vertical spacing", "list of blocks", "form fields"] },
+    { "class": "cluster", "reason": "Wrapping row of small items (buttons, tags, links) that keep their own width.", "keywords": ["cluster", "row", "wrap", "wraps", "wrapping", "item", "inline", "horizontal", "tag", "chip", "button group", "button row", "row of links"] },
+    { "class": "grid", "reason": "Responsive equal columns, as many as fit; set --grid-min per grid.", "keywords": ["grid", "tile", "gallery", "column", "as many as fit", "responsive card", "card grid", "grid of card", "collection"] },
+    { "class": "sidebar", "reason": "Narrow side column next to the main area; stacks when narrow.", "keywords": ["sidebar", "side nav", "side navigation", "side panel", "next to", "beside", "main content", "main area", "aside", "two column", "filter panel"] },
+    { "class": "sidebar-end", "with": ["sidebar"], "reason": "Main area first, narrow column on the right.", "keywords": ["right column", "right sidebar", "sidebar on the right", "narrow column on the right", "side column on the right", "secondary column"] },
+    { "class": "split", "reason": "Two groups pushed to opposite ends of a row; wraps when tight.", "keywords": ["split", "two ends", "opposite ends", "opposite sides", "space between", "left and right", "header", "header bar", "toolbar", "title and actions", "push apart"] },
+    { "class": "center", "reason": "Readable, horizontally centered column of text.", "keywords": ["center", "centered", "readable", "text column", "article", "prose", "long text", "long form", "reading width"] },
+    { "class": "cover", "reason": "Full-viewport-height section with its .cover-main child vertically centered.", "keywords": ["cover", "full screen", "fullscreen", "full height", "full page", "viewport height", "hero", "vertically centered", "sign in page", "login page", "splash"] },
+    { "class": "button", "reason": "An action on a native <button> or <a href>.", "keywords": ["button", "action", "click"] },
+    { "class": "button-primary", "with": ["button"], "reason": "The main action of a form or page.", "keywords": ["primary", "main action", "call to action", "cta", "submit", "save button", "primary button"] },
+    { "class": "button-secondary", "with": ["button"], "reason": "An alternative or cancel action.", "keywords": ["secondary", "cancel", "secondary button", "alternative action"] },
+    { "class": "button-danger", "with": ["button"], "reason": "A destructive action (delete, remove).", "keywords": ["danger", "destructive", "delete", "remove", "delete button", "red button"] },
+    { "class": "button-icon", "with": ["button"], "reason": "Square icon-only button; give it aria-label.", "keywords": ["icon button", "icon only", "close icon", "icon-only button"] },
+    { "class": "field", "reason": "One labeled form control with .field-label, .field-help and .field-error.", "keywords": ["field", "form field", "input", "text field", "label", "help text", "error message", "validation", "form control"] },
+    { "class": "switch", "reason": "On/off setting: a <label> around <input type=\"checkbox\" role=\"switch\">.", "keywords": ["switch", "toggle switch", "on off", "setting toggle", "checkbox toggle"] },
+    { "class": "input-group", "reason": "An input joined with a .button or <select>.", "keywords": ["input group", "attached button", "search box", "input with button", "copy link", "inline form"] },
+    { "class": "card", "reason": "Raised self-contained item with .card-header, .card-body and .card-footer.", "keywords": ["card", "self contained", "product card", "project card", "profile card"] },
+    { "class": "badge", "reason": "Short status label; pick .badge-success / -warning / -danger / -info by meaning.", "keywords": ["badge", "pill", "status", "status label", "count"] },
+    { "class": "alert", "reason": "Message box with role=\"status\" or role=\"alert\".", "keywords": ["alert", "message", "notification", "banner", "warning", "info box", "callout", "toast"] },
+    { "class": "panel", "reason": "Flat bordered group of secondary content.", "keywords": ["panel", "box", "secondary content", "group of content", "bordered section"] },
+    { "class": "table", "reason": "Tabular data on a native <table>, wrapped in .table-wrap.", "keywords": ["table", "tabular", "data table", "rows and columns", "spreadsheet", "list of records"] },
+    { "class": "empty-state", "reason": "Nothing to show yet: icon, heading, text and an action.", "keywords": ["empty", "empty state", "no data", "nothing yet", "no results", "blank slate", "zero state"] },
+    { "class": "nav", "reason": "Navigation links: .nav on a list with .nav-link anchors.", "keywords": ["nav", "navigation", "nav links", "menu links", "site navigation", "current page"] },
+    { "class": "tabs", "reason": "Tab list or segmented filter with .tabs-item buttons.", "keywords": ["tabs", "tab", "segmented", "segmented control", "filter tabs", "tab list"] },
+    { "class": "tabs", "attribute": "data-synth-tabs", "reason": "Tabs that switch panels: data-synth-tabs around .tabs and role=\"tabpanel\" panels (SynthJS).", "keywords": ["switch between", "tab panel", "switch panel", "tabbed content"] },
+    { "class": "avatar", "reason": "Square for initials or a photo; add .avatar-round for people.", "keywords": ["avatar", "initials", "profile picture", "user photo", "person"] },
+    { "class": "avatar-accent", "with": ["avatar"], "reason": "Icon on a tinted tile; pick the tint by meaning.", "keywords": ["icon tile", "tinted icon", "icon background"] },
+    { "class": "button", "attribute": "data-synth-open", "reason": "A .button with data-synth-open opens a <dialog> (SynthJS).", "keywords": ["modal", "dialog", "popup", "confirm", "confirmation", "lightbox", "overlay"] },
+    { "class": "button", "attribute": "data-synth-toggle", "reason": "A .button with data-synth-toggle shows or hides a section (SynthJS).", "keywords": ["collapse", "expand", "collapsible", "disclosure", "accordion", "hide", "reveal", "toggle section"] },
+    { "class": "nav", "attribute": "data-synth-dropdown", "reason": "data-synth-dropdown around a .button and a .nav list of links (SynthJS).", "keywords": ["dropdown", "drop down", "menu", "account menu", "user menu", "overflow menu"] },
+    { "class": "alert", "attribute": "data-synth-dismiss", "reason": "An .alert with data-synth-dismissible and a data-synth-dismiss .button (SynthJS).", "keywords": ["dismiss", "dismissible", "dismissable", "closable", "close message"] }
+  ],
   "compositionRules": {
     "recommended": [
       "Wrap each page in .container and give it a .stack or .stack-lg.",
@@ -457,6 +635,32 @@
         "html": "<span class=\"user-avatar rounded-full\">AL</span>",
         "note": "Invented classes. Use .avatar and .avatar-round."
       }
-    ]
+    ],
+    "patterns": {
+      "dashboard-header": {
+        "html": "<header class=\"split\">\n  <div class=\"stack-sm\">\n    <h1>Dashboard</h1>\n    <p>Updated 5 minutes ago.</p>\n  </div>\n  <div class=\"cluster-sm\">\n    <button type=\"button\" class=\"button\">Export</button>\n    <button type=\"button\" class=\"button button-primary\">New report</button>\n  </div>\n</header>",
+        "note": "Page header: .split puts the title group and the actions at opposite ends; the actions sit in a .cluster-sm with the main action last."
+      },
+      "settings-form": {
+        "html": "<form class=\"stack\">\n  <div class=\"field\">\n    <label class=\"field-label\" for=\"workspace\">Workspace name</label>\n    <input id=\"workspace\" type=\"text\" aria-describedby=\"workspace-help\">\n    <p class=\"field-help\" id=\"workspace-help\">Shown to everyone you invite.</p>\n  </div>\n  <div class=\"field\">\n    <label class=\"field-label\" for=\"timezone\">Time zone</label>\n    <select id=\"timezone\"><option>UTC</option><option>Europe/Berlin</option></select>\n  </div>\n  <label class=\"switch\"><input type=\"checkbox\" role=\"switch\" checked> Email notifications</label>\n  <div class=\"cluster-sm\">\n    <button type=\"submit\" class=\"button button-primary\">Save changes</button>\n    <button type=\"button\" class=\"button\">Cancel</button>\n  </div>\n</form>",
+        "note": "Settings form: a .stack of .field controls with labels and help text, a .switch, and the actions in a .cluster-sm."
+      },
+      "card-grid": {
+        "html": "<ul class=\"grid\" role=\"list\" style=\"--grid-min: 16rem\">\n  <li class=\"card\">\n    <div class=\"card-header\"><h2>Atlas</h2></div>\n    <div class=\"card-body\">Design system migration.</div>\n    <div class=\"card-footer split-sm\"><span class=\"badge badge-success\">Active</span><a href=\"/projects/atlas\">Open</a></div>\n  </li>\n  <li class=\"card\">\n    <div class=\"card-header\"><h2>Beacon</h2></div>\n    <div class=\"card-body\">Status page rewrite.</div>\n    <div class=\"card-footer split-sm\"><span class=\"badge badge-warning\">Paused</span><a href=\"/projects/beacon\">Open</a></div>\n  </li>\n</ul>",
+        "note": "Collection: a .grid list of .card items with header, body and a .split-sm footer; --grid-min sets the column width."
+      },
+      "dialog": {
+        "html": "<button type=\"button\" class=\"button button-danger\" data-synth-open=\"delete-dialog\">Delete project</button>\n<dialog id=\"delete-dialog\" aria-labelledby=\"delete-dialog-title\">\n  <div class=\"stack\">\n    <h2 id=\"delete-dialog-title\">Delete project?</h2>\n    <p>This removes the project and its history for everyone.</p>\n    <div class=\"cluster-sm\">\n      <button type=\"button\" class=\"button button-danger\">Delete</button>\n      <button type=\"button\" class=\"button\" data-synth-dismiss>Cancel</button>\n    </div>\n  </div>\n</dialog>",
+        "note": "Modal dialog (SynthJS): data-synth-open names the <dialog> id, data-synth-dismiss closes it, aria-labelledby labels it."
+      },
+      "tabs": {
+        "html": "<div class=\"stack\" data-synth-tabs>\n  <div class=\"tabs\" role=\"tablist\" aria-label=\"Report range\">\n    <button type=\"button\" class=\"tabs-item\" role=\"tab\" id=\"tab-week\" aria-controls=\"panel-week\" aria-selected=\"true\">Week</button>\n    <button type=\"button\" class=\"tabs-item\" role=\"tab\" id=\"tab-month\" aria-controls=\"panel-month\" aria-selected=\"false\">Month</button>\n  </div>\n  <div role=\"tabpanel\" id=\"panel-week\" aria-labelledby=\"tab-week\">…</div>\n  <div role=\"tabpanel\" id=\"panel-month\" aria-labelledby=\"tab-month\" hidden>…</div>\n</div>",
+        "note": "Tabs that switch panels (SynthJS): data-synth-tabs around a labeled .tabs list; each .tabs-item names its panel with aria-controls."
+      },
+      "empty-state": {
+        "html": "<div class=\"empty-state\">\n  <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\">…</svg>\n  <h2>No projects yet</h2>\n  <p>Projects group your reports and your team.</p>\n  <button type=\"button\" class=\"button button-primary\">Create project</button>\n</div>",
+        "note": "Nothing to show yet: an .empty-state with a decorative icon, a heading, one sentence and the main action."
+      }
+    }
   }
 }
diff --git a/synthcss.llm.md b/synthcss.llm.md
index 086abc0..a0e95f4 100644
--- a/synthcss.llm.md
+++ b/synthcss.llm.md
@@ -1,6 +1,6 @@
 # SynthCSS AI Contract
 
-Version: SynthCSS 0.9.0 · contract 1.3.0 · machine-readable twin: synthcss.ai.json
+Version: SynthCSS 0.9.0 · contract 1.4.0 · machine-readable twin: synthcss.ai.json
 
 The complete public vocabulary of SynthCSS. Use only the classes and tokens listed here; anything else does not exist.
 Load: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@0.9.0/dist/synthcss.min.css">`
Acceptance · round 1
Voting open
CINo checks
Automated reviewPass with concerns

This is a thorough, well-tested SynthMCP package: 7 read-only tools over the contract using the SDK and parse5, strong tests (in-memory and stdio, fidelity, drift, security scan, every issue type) and good docs. One acceptance criterion is deliberately unmet: "sidebar next to main content" resolves to `.sidebar` rather than `.split`, a defensible contract-based choice that the backers or maintainer still need to accept. No CI ran and the diff is truncated, so the contract JSON, showcase and dist byte-identity could not be verified directly. The release workflow is also broadened beyond a minimal edit.

Acceptance criteria · 14 of 19 met
  • YESpackages/synthmcp/package.json exists, declares @modelcontextprotocol/sdk and parse5, and defines the synthmcp binThe new `packages/synthmcp/package.json` lists both deps (plus `synthcss: file:../..`) and has `bin.synthmcp: src/server.js`.
  • YESRoot package.json declares workspaces and adds no new runtime dependencies`package.json` adds `"workspaces": ["packages/*"]` and only scripts, and the existing `build.test.mjs` check that it has no `dependencies` remains.
  • PARTIALnpm run mcp, the synthmcp bin and node packages/synthmcp/src/server.js all start a stdio MCP server`stdio.test.js` spawns all three and lists 7 tools, though `npm run mcp` is tested as `npm run --silent mcp` and the bin test is skipped if not linked.
  • YESA test using the SDK client over an in-memory or stdio transport lists exactly 7 tools`server.test.js` asserts `tools.length === 7` with exact names over `InMemoryTransport`, and `stdio.test.js` does the same over stdio.
  • YESget_component and get_layout output matches the contract for every entry`tools.test.js` iterates every component and primitive and deep-equals the variants, parts, accessibility, behaviors and example against the contract.
  • YESUnknown names and patterns return structured errors and never throw`server.test.js` covers not-found, unknown-tool, invalid-arguments, too-large and prototype-key names, and `callTool` wraps handlers in try/catch.
  • YESEvery intent-mapping target and every class used in examples is a public, non-internal contract class`tools.test.js` checks intent class/with and example classes against `publicClasses`, and the verifier's `checkMcpSections` enforces the same.
  • YESresolve_intent returns .cluster for 'a row of items/buttons that wraps'`intent.test.js` asserts `.cluster` for that exact input.
  • NOresolve_intent returns .split for 'sidebar next to main content'`intent.test.js` intentionally asserts `.sidebar` instead, with a comment citing the contract's intentMap.
  • YESresolve_intent returns no-match for nonsense input`intent.test.js` checks four nonsense inputs return `error: "no-match"`.
  • YESvalidate_markup tests cover all 7 issue types, valid/invalid data-synth-* usage, and ignoring arbitrary user classes`validate.test.js` has dedicated tests for unknown-class, unsupported-variant, internal-class (via fixture), invalid-part, unknown-data-attribute, invalid-target and a11y, plus valid data-synth usage and ignored user classes.
  • YESEvery official example validates with zero errors`tools.test.js` 'every official example validates with zero errors' covers patterns, valid examples, component/primitive examples, requiredMarkup and notCovered.
  • YESDrift test: a modified contract fixture loaded via the override changes tool output`drift.test.js` writes a modified contract to a temp file, loads it via `contractPath`, and checks changed output from every tool; the env override is also tested.
  • YESA test scans packages/synthmcp/src/ and fails on child_process, net, http(s), fetch and fs write APIs`security.test.js` has the FORBIDDEN regex list, an import allowlist, a readFileSync-only fs import check and self-tests of the scanner.
  • YESEvery tool response includes synthVersion and contractVersion`callTool` spreads both versions into every payload, and `server.test.js` checks success, error and unknown-tool responses.
  • UNCLEARContract changes are additive and contract minor version bumpedThe verifier tests now assert `contractVersion` 1.4.0 and the new keys are added to JSON_KEYS, but the `synthcss.ai.json` diff itself is truncated so additivity cannot be inspected.
  • UNCLEARExisting build and test commands still pass, and the root test command runs the synthmcp testsThe root `test` script appends `npm run test -w synthmcp`, but no CI ran, so passing is unverified.
  • UNCLEARBrowser dist/ output is byte-identical apart from intentional version stringsNo `src/` CSS/JS changes are visible and a new `build.test.mjs` asserts outputs contain no synthmcp/parse5 references, but byte-identity was not shown or run.
  • YESNo MCP dependency appears in browser bundles or in the core package's dependency listRoot `package.json` dependencies are unchanged and `build.test.mjs` scans build outputs for MCP strings.
Concerns
  • Acceptance criterion deliberately unmet: `resolve_intent("sidebar next to main content")` returns `.sidebar`, not `.split`. The builder's reasoning is grounded in the contract's existing intentMap and is well argued, but backers or the maintainer must explicitly accept this deviation. Getting `.split` would mean changing intent keywords in the contract.
  • No CI ran on this commit, so none of the claims (`npm test` including the synthmcp tests, the stdio and npm-run tests, the contract verifier and byte-identical `dist/`) are verified by an actual run.
  • The diff is truncated: the `synthcss.ai.json` changes (the 1.4.0 bump, new sections, additive-only guarantee), the `synthcss.llm.md` header and the showcase SynthMCP section (`.cluster`/`.button-danger`) cannot be inspected. Those criteria are judged only from the tests and docs that reference them.
  • The release workflow now triggers an automatic minor bump and CDN release whenever `packages/synthmcp/src/` or its `package.json` changes. The spec asked for minimal, additive workflow edits; this widens what produces CDN releases. A new npm publish step needing an `NPM_TOKEN` secret is also added.
  • `synthcss` is declared as `"file:../.."` rather than a version, contrary to the spec's assumption (justified because the core package is private), and the published package ships a copied contract. Separately, `npm run mcp` without `--silent` writes npm's banner onto the protocol stdout; the tests only exercise `npm run --silent mcp`.
CI details
No CI checks ran on this commit.

BackersWaiting for votes
0 accept · 0 rebuild · 1 not voted · quorum 1 of 1
MaintainerNot decided yet

Both keys are needed: a majority of backers to accept and the maintainer to merge. The window closes in 6 days. Below quorum, the automated verdict decides for the backers. Rebuilds used: 0 of 2.

Automated review cost $0.42, counted as builder cost.

Discussion · 0

No comments yet.