# color-engine MCP Server [![npm version](https://img.shields.io/npm/v/color-engine-mcp)](https://www.npmjs.com/package/color-engine-mcp) [![CI](https://github.com/aimsise/color-engine-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/aimsise/color-engine-mcp/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) A Model Context Protocol (MCP) server providing 6 CSS color utilities: parsing, conversion, gamut mapping, WCAG contrast (with optional APCA), tint/shade ramp generation (with optional design-token output), and contrast-target solving. All tools operate purely in-memory — no network I/O, no filesystem writes. | Tool | Purpose | |------|---------| | [parse_color](#parse_color) | Parse any CSS color string into hex, RGB, OKLCH, and gamut info | | [convert_color](#convert_color) | Convert a color into a canonical hex / rgb / hsl / oklch string | | [contrast](#contrast) | WCAG 2.1 contrast ratio and tier flags, with optional APCA Lc | | [gamut_map](#gamut_map) | Map wide-gamut colors into sRGB via perceptual OKLCH chroma reduction | | [generate_ramp](#generate_ramp) | Tint-to-shade ramps with per-swatch contrast info and design-token output | | [solve_for_contrast](#solve_for_contrast) | Find a foreground color that meets WCAG contrast targets against a background | > All JSON outputs shown in this README are genuine responses captured from the built server via the MCP Inspector CLI (`npx @modelcontextprotocol/inspector --cli`). ## Installation Requires Node.js >= 20. The npm package exposes a `color-engine-mcp` bin, so no clone or build is needed — clients launch it via `npx`. This is a standard stdio MCP server using only the `tools` primitive, so any MCP-compatible client registers it the same way: point the client at the launch command, and the client spawns and manages the process itself. For Claude Code, one command registers the server: ```sh claude mcp add color-engine -- npx -y color-engine-mcp ``` (The default scope is the local project; add `--scope user` to register it for all your projects, or `--scope project` to write the shared `.mcp.json`.) Most other JSON-configured clients accept this shape verbatim: ```json { "mcpServers": { "color-engine": { "command": "npx", "args": ["-y", "color-engine-mcp"] } } } ``` Where the entry lives in a few common clients: | Client | Config file | |--------|-------------| | Claude Code | `.mcp.json` in the project root | | Claude Desktop | `claude_desktop_config.json` (e.g. `~/Library/Application Support/Claude/` on macOS) | | Cursor | `.cursor/mcp.json` (per-project) or `~/.cursor/mcp.json` (global) | | VS Code | `.vscode/mcp.json` — VS Code names the top-level key `servers` instead of `mcpServers` | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | Other clients (Zed, JetBrains, custom agents built on an MCP SDK, …) differ only in the file location and occasionally the top-level key name — see their MCP server registration docs. Listed in the official [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.aimsise/color-engine-mcp`. Once connected, try asking your agent things like: - *"Generate an 11-step Tailwind ramp from #3b82f6"* - *"Find an accessible text color for background #1e293b at 4.5:1 contrast"* To run from a clone instead, see [Development](#development). ## Color input handling Every tool routes color strings through one shared parse boundary, so the rules below apply uniformly. **Accepted formats.** Any CSS color string culori can parse, including named colors, hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`, `hsl()`/`hsla()`, and `oklch()` — plus the wide-gamut / CSS Color 4 formats: - `color(display-p3 r g b)` - `lab(L a b)` / `lch(L C h)` - `oklab(L a b)` - `hwb(h w b)` - `color(rec2020 r g b)` - `color(a98-rgb r g b)` - `color(xyz-d65 x y z)` A wide-gamut input outside sRGB parses fine and reports `inGamut: false`: ```json { "input": "color(display-p3 1 0 0)" } ``` ```json { "hex": "#ff0000", "rgb": { "r": 255, "g": 0, "b": 0 }, "oklch": { "l": 0.6485740751442981, "c": 0.2994852863383699, "h": 28.958132730803953 }, "inGamut": false } ``` **Whitespace.** Leading/trailing whitespace is trimmed before parsing — `" #ff0000 "` parses as `#ff0000`. **CSS Color 4 channel clamping (legacy spaces only).** Out-of-range channels in hex / `rgb()` / `hsl()` inputs are clamped at the parse boundary, per CSS Color 4: `rgb(-50 0 0)` behaves exactly as `rgb(0 0 0)`, and `hsl()` saturation/lightness clamp likewise (hue wraps). Inputs in **other** modes (`oklch()`, `lab()`, `color(display-p3 …)`, …) are **not** clamped — their out-of-gamut values flow through raw, which is what makes `gamut_map` useful. **`none` channels.** CSS Color 4 `none` channels are normalized to `0` in all six tools — `oklch(0.5 none 30)` behaves exactly as `oklch(0.5 0 30)`. **Component magnitude.** A parseable component with an absurd magnitude (above 1e6, e.g. `oklch(0.5 1e30 30)`) is rejected with `COMPONENT_OUT_OF_RANGE` — two tools (`gamut_map`, `solve_for_contrast`) surface it differently; see the [error codes](#error-codes) table. Real out-of-gamut values are many orders of magnitude below this guard and are never affected. **Alpha policy.** `contrast` and `solve_for_contrast` **reject** translucent colors — any explicit alpha `< 1`, including `rgba()`/`hsla()` functional alpha and 4-/8-digit hex (`#00000080`) — with `ALPHA_UNSUPPORTED`, because the effective color of a translucent layer depends on an unknown backdrop; composite over the backdrop first. All **other** tools accept translucent input and simply **ignore** the alpha channel (computations use the opaque color; no output ever carries an alpha component). **Length cap.** Color strings longer than 256 characters (after trimming) are rejected with `INPUT_TOO_LONG`. ## Tools ### parse_color Parse any CSS color string and return hex, RGB, OKLCH, and gamut info. **Input schema** | Field | Type | Required | Description | |-------|--------|----------|-------------| | input | string | yes | Any CSS color string, e.g. `"#ff0000"`, `"red"`, `"oklch(0.6 0.2 30)"` | **Output** for `{ "input": "#ff0000" }` ```json { "hex": "#ff0000", "rgb": { "r": 255, "g": 0, "b": 0 }, "oklch": { "l": 0.6279553639214311, "c": 0.2576833038053608, "h": 29.233880279627854 }, "inGamut": true } ``` The `rgb` channels are the **sRGB-clamped** 0–255 integer projection (consistent with `hex`). For an out-of-gamut input (e.g. a wide-gamut `oklch(...)`), the channels are clamped into `[0, 255]` rather than reporting raw out-of-range values — use the `inGamut` flag to detect that the input fell outside sRGB. The `oklch` block, by contrast, is the **raw (lossless, unrounded)** OKLCH of the input, and `oklch.h` is `0` for achromatic colors. --- ### convert_color Convert a CSS color string into a canonical hex, rgb, hsl, or oklch format string. **Input schema** | Field | Type | Required | Description | |-------|--------|----------|-------------| | input | string | yes | Any CSS color string | | to | string | yes | Target format: `"hex"`, `"rgb"`, `"hsl"`, or `"oklch"` | **Output** for `{ "input": "#ff0000", "to": "oklch" }` ```json { "result": "oklch(0.62796 0.25768 29.23)" } ``` **Raw vs. clamped for out-of-gamut inputs** For a color that lies outside the sRGB gamut, the target formats diverge: - `to: "oklch"` returns the **raw, lossless** OKLCH triple, so an out-of-gamut color round-trips faithfully. E.g. `{ "input": "oklch(0.7 0.4 30)", "to": "oklch" }` → `"oklch(0.70000 0.40000 30.00)"`. - `to: "hex"`, `"rgb"`, and `"hsl"` are all derived from the **sRGB-clamped** projection and report the in-gamut approximation. E.g. `{ "input": "oklch(0.7 0.4 30)", "to": "rgb" }` → `"rgb(255, 0, 0)"`. `rgb` channels are integers in `[0, 255]`. (`L`/`C` are formatted to 5 decimal places and `H` to 2, which guarantees an exact hex round-trip across the full sRGB cube.) --- ### contrast Compute the WCAG 2.1 contrast ratio between two fully opaque CSS color strings and return tier flags. Optionally also computes the APCA Lc value (see [APCA](#apca-optional-perceptual-contrast)). **Input schema** | Field | Type | Required | Description | |-------|---------|----------|-------------| | a | string | yes | First CSS color string (treated as the **text/foreground** for APCA), e.g. `"#000000"` | | b | string | yes | Second CSS color string (treated as the **background** for APCA), e.g. `"#ffffff"` | | apca | boolean | no | When `true`, additionally return the signed APCA-W3 `apcaLc` for text `a` over background `b` | **Output** for `{ "a": "#000000", "b": "#ffffff" }` ```json { "ratio": 21, "aaNormal": true, "aaLarge": true, "aaaNormal": true, "aaaLarge": true } ``` `ratio` is the 2-decimal **display** value. The four tier booleans are derived from the **unrounded raw** ratio (so a near-boundary raw `4.4999`, which displays as `4.50`, still yields `aaNormal: false`). WCAG 2.1 tier thresholds: - `aaNormal` / `aaLarge` require ratio ≥ 4.5 / ≥ 3.0 - `aaaNormal` / `aaaLarge` require ratio ≥ 7.0 / ≥ 4.5 **Errors.** A string that fails to parse yields a parameter-named error — `PARSE_FAILED: could not parse the foreground color` for `a`, `PARSE_FAILED: could not parse the background color` for `b`. Translucent input yields `ALPHA_UNSUPPORTED` (see [Alpha policy](#color-input-handling)). #### APCA (optional perceptual contrast) Pass `apca: true` to additionally get `apcaLc` — the signed APCA-W3 (SAPC-4g) lightness contrast Lc, rounded to 2 decimals, for **text `a` over background `b`** (the argument order matters for APCA, unlike the symmetric WCAG ratio). The sign encodes polarity: **positive** for dark text on a light background, **negative** for light text on a dark background; compare magnitudes with `|Lc|`. ```json { "a": "#1a1a1a", "b": "#ffffff", "apca": true } ``` ```json { "ratio": 17.4, "aaNormal": true, "aaLarge": true, "aaaNormal": true, "aaaLarge": true, "apcaLc": 104.27 } ``` Reversing the pair flips the sign: `{ "a": "#ffffff", "b": "#1a1a1a", "apca": true }` → `"apcaLc": -106.55` (same WCAG `ratio` of `17.4`, since the WCAG ratio is symmetric). Commonly cited APCA guideline thresholds (by `|Lc|`): | \|Lc\| | Common guideline use | |-------|----------------------| | 45 | Minimum for large/bold text | | 60 | Minimum for other content text | | 75 | Body text | | 90 | Preferred body text | > **Disclaimer:** APCA is a candidate method for WCAG 3 and is **not yet a normative WCAG standard** — use the WCAG 2.1 tier flags for conformance claims. --- ### gamut_map Map any CSS color string into the sRGB gamut via perceptual OKLCH chroma reduction. Useful for converting wide-gamut colors (P3, Rec2020, arbitrary OKLCH) to displayable sRGB. **Input schema** | Field | Type | Required | Description | |-------|--------|----------|-------------| | input | string | yes | Any CSS color string, e.g. `"oklch(0.6 0.4 30)"` | **Output** for `{ "input": "oklch(0.6 0.4 30)" }` ```json { "hex": "#f70000", "oklch": { "l": 0.6137129506655941, "c": 0.25183888824211537, "h": 29.233880279628004 }, "clamped": true } ``` `clamped` is `true` when the input was outside the sRGB gamut and had to be mapped. The returned `oklch` is the raw OKLCH of the **mapped** in-gamut result (chroma reduced perceptually), not of the original input. **Identity and idempotency.** An input that is **already inside** the sRGB gamut is returned **identically**: `clamped` is `false` and `hex` is exactly the canonical hex of the input (e.g. `{ "input": "#3b82f6" }` → `{ "hex": "#3b82f6", …, "clamped": false }`). The tool is also **idempotent**: its own output hex is always in-gamut, so feeding a result back in takes the identity path and returns the identical hex. --- ### generate_ramp Generate a tint-to-shade color ramp from a base CSS color. Returns an ordered list of swatches (light to dark) each with hex, OKLCH, WCAG contrast ratios vs white and black, and a gamut flag. Optionally emits the ramp as design tokens (see [Design tokens](#design-tokens)). **Input schema** | Field | Type | Required | Description | |--------------|---------|----------|-------------| | base | string | yes | Any CSS color string, e.g. `"#3b82f6"` | | steps | integer | no | Number of swatches (2–512). Default: 5 | | lightnessMin | number | no | Lower lightness endpoint (OKLCH L, 0–1). Default: 0.05 | | lightnessMax | number | no | Upper lightness endpoint (OKLCH L, 0–1). Default: 0.97 | | deltaL | number | no | **Total** lightness span centered on the base L — endpoints at base L ± deltaL/2 (overrides the fixed range) | | tokenFormat | string | no | `"tailwind"` or `"css-variables"` — when present the output includes a `tokens` string | | tokenName | string | no | Base name for emitted tokens (letters/digits/hyphens, must start with a letter, 1–64 chars). Default: `"color"` | **Output** for `{ "base": "#3b82f6", "steps": 5 }` (first of 5 swatches shown) ```json { "swatches": [ { "step": 0, "hex": "#f5f5f5", "oklch": { "l": 0.97, "c": 0, "h": 259.81 }, "vsWhite": { "ratio": 1.09, "tier": "FAIL" }, "vsBlack": { "ratio": 19.26, "tier": "AAA" }, "inGamut": true } ] } ``` Swatch indices are **zero-based**: swatches are ordered lightest (`step: 0`) to darkest (`step: steps - 1`), with strictly **decreasing** OKLCH lightness. (In the full 5-swatch response above, the in-gamut base `#3b82f6` reappears verbatim at `step: 2` — the ramp anchors the nearest step to the base lightness.) Each swatch carries `vsWhite` / `vsBlack`, each `{ ratio, tier }` where `ratio` is the WCAG contrast against white/black and `tier` is one of `"AAA"` (raw ratio ≥ 7.0), `"AA"` (≥ 4.5), or `"FAIL"`. **Display rounding.** Swatch numbers are display-rounded: contrast `ratio`s to 2 decimals, `oklch.l`/`oklch.c` to 5 decimals, `oklch.h` to 2. The `tier` classifications still derive from the **raw, unrounded** ratios (a raw 6.9999 displays as `7.00` but is classified `"AA"`). **Tier quantization near thresholds.** Each swatch's contrast is computed from its 8-bit `hex` (the authoritative displayed color), so within about `0.01` of a tier threshold (3.0 / 4.5 / 7.0) the quantization can flip the tier relative to a float-precision computation. If you need a guaranteed margin, target a ratio comfortably above the threshold (e.g. solve for 4.6 rather than 4.5). **Validation rules** The numeric constraints are declared in the tool schema, so the SDK rejects out-of-range calls before the handler runs — over MCP those rejections arrive as the SDK's validation error, not the tool-level codes below (see [Schema-layer vs tool-layer enforcement](#schema-layer-vs-tool-layer-enforcement)): - `steps` must be an integer in `[2, 512]` → otherwise `STEPS_OUT_OF_RANGE`. - `deltaL`, when provided, must be a finite number `> 0` → otherwise `INVALID_DELTA_L`. - The resolved lightness range must satisfy `lightnessMin < lightnessMax` (endpoints are clamped into `[0, 1]` first) → otherwise `INVALID_LIGHTNESS_RANGE`. - The base color's OKLCH chroma must be ≤ 100 → otherwise `BASE_CHROMA_OUT_OF_RANGE`. - A `base` that fails to parse forwards the parse error code (`PARSE_FAILED`, `INPUT_TOO_LONG`, `COMPONENT_OUT_OF_RANGE`, or `NON_FINITE_COMPONENTS`). **Payload size.** A `steps: 512` call returns roughly **200 KB** of JSON in the tool result (~280 KB when pretty-printed). Prefer small step counts (5–11) in LLM contexts — they cover virtually every design-system use case at a tiny fraction of the tokens. **Note on `swatch.oklch` vs `swatch.hex`** Each swatch's reported `oklch` is the **requested-L / chroma-clamped** projection (the target lightness with chroma reduced into the sRGB gamut at that lightness), which is what preserves strict L-monotonicity across the ramp. It can therefore differ slightly (up to a ΔL of ≈0.013) from the exact OKLCH you would compute from `swatch.hex`. This is a deliberate trade-off favoring monotonic lightness over an exact hex round-trip; `hex` remains the authoritative displayed color. #### Design tokens Pass `tokenFormat` (and optionally `tokenName`) to additionally receive a `tokens` string alongside `swatches`. - With **exactly 11 steps**, token keys use the canonical Tailwind scale `50, 100, 200, …, 900, 950` (swatch `0` — the lightest — maps to `50`). - Any **other** step count uses the zero-based `step` index as the key. **`tokenFormat: "tailwind"`** — a pretty-printed JSON object string. `tokens` output for `{ "base": "#3b82f6", "steps": 11, "tokenFormat": "tailwind", "tokenName": "blue" }`: ```json { "blue": { "50": "#f5f5f5", "100": "#c1d8fe", "200": "#8fbaff", "300": "#629bfa", "400": "#3b82f6", "500": "#155ecf", "600": "#0043ab", "700": "#002b80", "800": "#001750", "900": "#000721", "950": "#000000" } } ``` **`tokenFormat: "css-variables"`** — a `:root` block. `tokens` output for `{ "base": "#3b82f6", "steps": 5, "tokenFormat": "css-variables", "tokenName": "brand" }`: ```css :root { --brand-0: #f5f5f5; --brand-1: #78abfe; --brand-2: #3b82f6; --brand-3: #002068; --brand-4: #000000; } ``` `tokenName` is validated at the schema boundary (`/^[a-z][a-z0-9-]*$/i`, 1–64 chars) so it always embeds safely in a CSS custom-property name or JSON key. --- ### solve_for_contrast Find a foreground color that meets one or more WCAG 2.1 contrast targets against a background. Binary-searches OKLCH lightness (holding hue/chroma fixed). **Input schema** | Field | Type | Required | Description | |------------|------------------------------|----------|-------------| | background | string | yes | Background CSS color string | | target | number | one of | Single WCAG 2.1 contrast target (e.g. 4.5, 7) | | targets | number[] | one of | Multiple contrast targets (1–50 entries; an empty array is rejected) | | prefer | `"lighter"`,`"darker"`,`"either"` | no | Search direction. Default: `"either"` | | hue | number | no | Fixed OKLCH hue (degrees) held constant during the search | | chroma | number | no | Fixed OKLCH chroma (≥ 0) held constant during the search | **`hue`/`chroma` defaults — pass `chroma` to keep saturation** When omitted, `chroma` defaults to the **background's own chroma** and `hue` defaults to the background's hue (or `0` for an achromatic background). Two consequences worth knowing: - **`hue` without `chroma` does not add saturation.** Against an achromatic background (white/grey/black), the defaulted chroma is `0`, so the result is an achromatic gray regardless of the hue you pass. Against `#ffffff` with `target: 4.5`: `{ "hue": 260 }` → `{ "met": true, "color": "#767676", "ratio": 4.54 }` (the same gray as passing no hue at all), while `{ "hue": 260, "chroma": 0.15 }` → `{ "met": true, "color": "#4075cf", "ratio": 4.5 }` (a real blue). - `chroma` without `hue` fixes the hue to the background's hue (or `0` when achromatic). The fixed chroma may still be reduced per-lightness when the sRGB gamut requires it. **`target` vs `targets` precedence and response shape** Provide `target` (single) **or** `targets` (array). If **both** are given, `targets` takes precedence and the single `target` is ignored. The two modes return **different response shapes**: - **Single `target`** → `{ met, color, ratio }` (plus an optional `nearMiss` flag). - **`targets` array** → `{ results: [ { met, color, ratio, nearMiss? }, ... ] }`, one entry per requested target, in order. The registered output schema is an all-optional **superset** of both shapes; any given response populates exactly one of the two. **Output** for `{ "background": "#ffffff", "target": 4.5, "prefer": "darker" }` ```json { "met": true, "color": "#767676", "ratio": 4.54 } ``` **Output** for `{ "background": "#1e293b", "targets": [4.5, 7] }` ```json { "results": [ { "met": true, "color": "#8290a6", "ratio": 4.52 }, { "met": true, "color": "#a6b5cc", "ratio": 7.04 } ] } ``` `nearMiss: true` may appear (on a single result, or on an entry inside `results`) when `met` was granted via the near-ceiling tolerance: the best achievable **raw** ratio in the searched direction(s) is within `0.03` *below* the target. Under a directional `prefer` (`"lighter"`/`"darker"`) only that one band is searched, so the opposite direction may still strictly meet the target; in the `"either"` mode a strictly-compliant side always wins, so `nearMiss` only surfaces when *neither* direction strictly meets the target. **Errors.** A background that does not parse returns `isError` with `PARSE_FAILED: could not parse the background color` — on **both** the single-`target` and the `targets` paths. A translucent background yields `ALPHA_UNSUPPORTED` (see [Alpha policy](#color-input-handling)). --- ## Error handling Every tool is **total**: malformed or out-of-range input never crashes the server or leaks a stack trace. On failure a tool returns an MCP result with `isError: true` and a single text content block whose text follows a **uniform `: message` format**, where `` is an `UPPER_SNAKE_CASE` value from a closed set. The message is a static, safe string — it never embeds your raw input, file paths, stacks, or library internals. On error the tool never sets `structuredContent`. Example error text: ``` PARSE_FAILED: could not parse the provided color string ``` The catch-all for any unexpected internal fault is always: ``` INTERNAL_ERROR: unexpected internal error ``` ### Schema-layer vs tool-layer enforcement Constraints declared in the tools' zod input schemas are enforced by the MCP SDK **before the tool handler runs** — the handler never executes and the result carries no `structuredContent`. In MCP SDK 1.29 that rejection arrives **in-band** as an error-flagged tool result (`isError: true`) whose text begins `MCP error -32602: Input validation error: …` — that text is SDK-generated and sits outside the uniform `CODE: message` / no-internals guarantee above (which covers tool-layer errors only); future SDK versions may surface a true `-32602` protocol error instead, so don't pattern-match on the exact delivery or wording. The matching tool-level codes are **retained as defense-in-depth** for direct library callers (code that imports the handlers or `src/lib` functions and bypasses SDK validation). Schema-enforced constraints: - `INPUT_TOO_LONG` — every color-string field declares `.max(256)`. - `STEPS_OUT_OF_RANGE` — `steps` declares integer `2..512`. - `INVALID_DELTA_L` — `deltaL` declares finite `> 0`. - `TOO_MANY_TARGETS` — `targets` declares `.max(50)`. - `EMPTY_TARGETS` — `targets` declares `.min(1)` (an empty array is rejected pre-handler). - The finiteness/sign constraints behind `INVALID_TARGET`, `INVALID_CHROMA`, and `INVALID_HUE` are likewise schema-declared (and non-finite numbers are not representable in JSON anyway). ### Error codes | Code | Meaning | |------|---------| | `INPUT_TOO_LONG` | A color string exceeded the 256-character cap (DoS guard, enforced before parsing; schema-enforced). | | `PARSE_FAILED` | The provided color string could not be parsed as any CSS color. In `contrast` and `solve_for_contrast` the static message names the failing **parameter**: `could not parse the foreground color` / `could not parse the background color`. | | `COMPONENT_OUT_OF_RANGE` | A parseable color component had an absurd magnitude (> 1e6), e.g. `oklch(0.5 1e30 30)` — returned by `parse_color`, `convert_color`, `contrast`, and `generate_ramp`. **Exception:** `gamut_map` rejects that same input with `CHROMA_OUT_OF_RANGE` (its chroma guard fires first), and `solve_for_contrast` reports it as parameter-named `PARSE_FAILED`. | | `ALPHA_UNSUPPORTED` | `contrast` / `solve_for_contrast` received a translucent color (explicit alpha < 1, including 4-/8-digit hex). Static message: `contrast requires fully opaque colors (alpha = 1); composite the color over its backdrop first`. | | `NON_FINITE_COMPONENTS` | The color resolved to non-finite RGB/OKLCH components (e.g. an overflowing chroma). | | `NON_FINITE_LUMINANCE` | Contrast computation produced a non-finite luminance. | | `NON_FINITE_OKLCH_COMPONENTS` | OKLCH lightness/chroma were non-finite during gamut mapping. | | `NULL_OKLCH_CHANNELS` | OKLCH channels resolved to null during gamut mapping. | | `NON_FINITE_OKLCH_HUE` | OKLCH hue was non-finite for a chromatic color during gamut mapping. | | `CHROMA_OUT_OF_RANGE` | OKLCH chroma exceeded the gamut mapper's supported maximum (100). This is what `gamut_map` returns for `oklch(0.5 1e30 30)` — not `COMPONENT_OUT_OF_RANGE`. | | `GAMUT_MAP_COLLAPSE` | Gamut mapping collapsed to null/non-finite channels. | | `STEPS_OUT_OF_RANGE` | `generate_ramp` `steps` was not an integer in `[2, 512]` (schema-enforced). | | `INVALID_LIGHTNESS_RANGE` | `generate_ramp` resolved `lightnessMin >= lightnessMax`. | | `INVALID_DELTA_L` | `generate_ramp` `deltaL` was not a finite number `> 0` (schema-enforced). | | `BASE_CHROMA_OUT_OF_RANGE` | `generate_ramp` base OKLCH chroma exceeded 100. | | `MISSING_BACKGROUND` | `solve_for_contrast` was called without a `background`. | | `MISSING_TARGET` | `solve_for_contrast` was called with neither `target` nor `targets`. | | `EMPTY_TARGETS` | `solve_for_contrast` `targets` was an empty array (schema-enforced via `.min(1)`). | | `TOO_MANY_TARGETS` | `solve_for_contrast` `targets` exceeded 50 entries (schema-enforced). | | `INVALID_TARGET` | A `solve_for_contrast` target was not a finite number `>= 0`. | | `INVALID_CHROMA` | `solve_for_contrast` `chroma` was not a finite number `>= 0`. | | `INVALID_HUE` | `solve_for_contrast` `hue` was not a finite number. | | `INVALID_GEOMETRY` | `solve_for_contrast` resolved a non-finite hue/chroma or a negative chroma. Over MCP, explicit `hue`/`chroma` are schema-validated first, so this is effectively a defense-in-depth code for direct library callers. | | `INTERNAL_ERROR` | Catch-all for any unexpected internal error. | ## Tool annotations and server metadata All six tools are registered with MCP [tool annotations](https://modelcontextprotocol.io/) declaring them read-only and side-effect-free (`readOnlyHint: true`, `idempotentHint: true`, `destructiveHint: false`, `openWorldHint: false`), plus a human-readable `title`. The server itself registers a `title` ("Color Engine") and an `instructions` string that summarizes the six tools, the OKLCH-first design, the rounding conventions, and the error format for connecting clients (notably steering agents toward `solve_for_contrast` when they need to *meet* a ratio rather than merely *measure* one). Every tool result carries both `structuredContent` and an equivalent plain-text content block, so clients that don't consume structured output lose nothing. ## Accuracy and performance **Accuracy** (all verified in the test suite — reproduce with `npm test`): - **WCAG ratios** are validated against a dual oracle: a first-principles WCAG 2.1 luminance implementation and the independent `colorjs.io` implementation, with tier classification checked by color pairs straddling each threshold (3.0 / 4.5 / 7.0). - **hex ↔ OKLCH round-trips are byte-exact** across thousands of deterministic grid points and seeded fuzz samples spanning the sRGB cube. - **APCA** matches the independent `colorjs.io` APCA implementation within **0.1 Lc**, in both polarities. - **gamut_map** returns in-gamut colors **bit-identically** and is idempotent on its own output. **Performance** (median ms per call, measured on Node v25.9.0 via `npm run build && npm run bench` — indicative, not contractual): | Call | Median ms | |------|-----------| | `parse_color` | 0.005 | | `convert_color` | 0.002 | | `contrast` | 0.004 | | `gamut_map` (out-of-gamut input) | 0.015 | | `generate_ramp` (steps=5) | 0.037 | | `solve_for_contrast` (single target) | 0.067 | | `generate_ramp` (steps=512, worst case) | 3.3 | | `solve_for_contrast` (50 targets, worst case) | 2.7 | Cold start (process spawn to the `initialize` response) measured ≈ **135 ms** median. ## Development ### Building ```sh npm install npm run build ``` The compiled server is at `dist/server.js`. **Running from source.** To point an MCP client at a local clone instead of the npm package, build first, then use: ```json { "mcpServers": { "color-engine": { "command": "node", "args": ["/absolute/path/to/color-engine-mcp/dist/server.js"] } } } ``` Replace `/absolute/path/to/color-engine-mcp/dist/server.js` with the actual path to `dist/server.js` in your clone. ### Testing ```sh npm test ``` Runs the full test suite (including MCP Inspector CLI integration tests via `npx @modelcontextprotocol/inspector --cli`). The `pretest` script runs `tsc` automatically to ensure `dist/server.js` is current before the Inspector tests run. ### Benchmarking ```sh npm run build npm run bench ``` Runs `scripts/bench.mjs` against the built `dist/` handlers (20 warmup + 200 timed iterations per case, reporting medians) — the source of the numbers in [Accuracy and performance](#accuracy-and-performance). ## Changelog See [CHANGELOG.md](CHANGELOG.md) for release history. ## License MIT — see [LICENSE](LICENSE).