@a11y-context/mcp-server 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ Quality and robustness polish. No breaking changes to the published stdio tool contract; the three tools (`list_patterns`, `get_pattern`, `get_foundations`) and their success responses are byte-identical to 0.1.0.
6
+
7
+ ### Tools
8
+
9
+ - **Structured, path-safe errors.** Tools now return `{ error_code, message }` with `isError: true` instead of throwing raw `Error`s. Stable codes: `PATTERN_NOT_FOUND`, `STACK_INVALID`, `CORPUS_UNAVAILABLE`, `INTERNAL_ERROR`. Absolute host filesystem paths are stripped from every client-reachable message.
10
+ - **`get_foundations` `scope` filter.** The `scope` argument is now declared on the tool and wired through, so `get_foundations({ scope: ["component"] })` returns only rules matching those scope buckets (it was previously accepted internally but never exposed).
11
+
12
+ ### Server
13
+
14
+ - **Index built once per process.** The per-stack index is now a lazy, process-lifetime singleton (shared across the stdio server and per-request HTTP servers) instead of being rebuilt on a 1-hour TTL. `cache_ttl_seconds` remains a client-facing cache hint only.
15
+ - **HTTP transport hardening.** Runs stateless (no in-memory session map to leak), adds an in-memory per-IP rate limiter, and replaces the hand-rolled origin guard with the SDK's DNS-rebinding protection (`ALLOWED_HOSTS` / `ALLOWED_ORIGINS`) for Host/Origin validation.
16
+
17
+ ### Tooling
18
+
19
+ - **`npm run sync-corpus`.** Refreshes the bundled `corpus/<stack>/` read-slice from a corpus-repo checkout, copying only published components (driven by `patterns.json` membership; `draft`/`deprecated` excluded).
20
+ - **Regression suite + CI.** Regenerated snapshot fixtures against the 0.5.2 corpus; the runner now validates the compiled `dist/` output. Added a GitHub Actions workflow running build + regression + a stdio handshake smoke as a pre-publish gate.
21
+ - Removed unused imports (`node:path` in `http.ts`, `sha256` in `repo/index.ts`).
package/README.md CHANGED
@@ -44,7 +44,16 @@ The server ships a bundled snapshot of the corpus at `corpus/<stack>/`, refreshe
44
44
  ## Transports
45
45
 
46
46
  - **stdio** (default, via `npx`) — runs locally in your MCP client.
47
- - **HTTP** — `npm run start` serves the same tools over HTTP for clients that connect by URL.
47
+ - **HTTP** — `npm run start` serves the same tools over HTTP for clients that connect by URL. The HTTP endpoint is stateless (no session state to leak) and rate-limited. For any publicly reachable deployment, set `ALLOWED_HOSTS` and/or `ALLOWED_ORIGINS` (comma-separated) to enable Host/Origin validation (DNS-rebinding protection); it stays off with a warning until configured.
48
+
49
+ | Env var | Default | Purpose |
50
+ |---|---|---|
51
+ | `PORT` | `3000` | HTTP listen port. |
52
+ | `ALLOWED_HOSTS` | *(unset)* | Comma-separated allowlist of `Host` header values. |
53
+ | `ALLOWED_ORIGINS` | *(unset)* | Comma-separated allowlist of `Origin` header values. |
54
+ | `TRUST_PROXY` | `false` | Express `trust proxy`. Set to `1` (hop count) behind a single reverse proxy (e.g. Railway) so per-IP rate limiting uses the real client IP. Leave off when directly exposed; never `true`. |
55
+ | `RATE_LIMIT_MAX` | `120` | Max requests per IP per window. |
56
+ | `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window in ms. |
48
57
 
49
58
  ## Development
50
59
 
@@ -57,6 +66,18 @@ npm run inspector # MCP Inspector
57
66
 
58
67
  Override the corpus location with `PATTERN_REPO_PATH` (absolute, or relative to the package root) to develop against a live corpus checkout.
59
68
 
69
+ ### Refreshing the bundled corpus
70
+
71
+ The package ships a snapshot of the corpus under `corpus/<stack>/`. Refresh it from a checkout of the corpus repo with:
72
+
73
+ ```bash
74
+ npm run sync-corpus -- --source /path/to/accessibility-pattern-api
75
+ # options: --stack web/react (default), --dry-run
76
+ # or set A11Y_CORPUS_SOURCE instead of --source
77
+ ```
78
+
79
+ It copies only the published read-slice — `patterns.json`, `global/global_rules.md`, and the components listed in `patterns.json` — into `corpus/<stack>/`. Membership is driven by `patterns.json`, so `status: draft` and `status: deprecated` components are excluded (a draft/deprecated member aborts the sync). The copied `patterns.json` records the source `catalog_revision`. Commit the resulting `corpus/` changes as part of the release.
80
+
60
81
  ## License
61
82
 
62
83
  [Apache-2.0](./LICENSE). The bundled corpus content is also Apache-2.0.
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  // This file compiles to dist/config.js, so its dir is dist/ and the package
7
7
  // root is one level up; in dev (ts-node on src/config.ts) it resolves the same.
8
8
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
9
- const PACKAGE_ROOT = path.resolve(MODULE_DIR, "..");
9
+ export const PACKAGE_ROOT = path.resolve(MODULE_DIR, "..");
10
10
  // Canonical server identity, read once from package.json so both transports
11
11
  // (stdio and HTTP) advertise the same name and version in the MCP handshake.
12
12
  const pkg = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
@@ -23,6 +23,8 @@ export function getConfig() {
23
23
  const patternRepoPath = path.isAbsolute(repoPathFromEnv)
24
24
  ? repoPathFromEnv
25
25
  : path.resolve(PACKAGE_ROOT, repoPathFromEnv);
26
+ // Advertised to clients as `cache_ttl_seconds`; does not drive any server-side
27
+ // rebuild (the index is a process-lifetime singleton).
26
28
  const cacheTtlSeconds = process.env.CACHE_TTL_SECONDS
27
29
  ? Number(process.env.CACHE_TTL_SECONDS)
28
30
  : 60 * 60; // 1 hour default
package/dist/http.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // src/http.ts
2
2
  import "dotenv/config";
3
3
  import express from "express";
4
- import { randomUUID } from "node:crypto";
5
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
7
5
  import { createMcpServer as createSharedMcpServer } from "./mcp/createServer.js";
8
6
  import { getConfig, SERVER_NAME, SERVER_VERSION } from "./config.js";
9
7
  function createHttpMcpServer() {
10
8
  // Use the same corpus resolution and identity as the stdio transport, so both
11
9
  // read the bundled package corpus and advertise the same server name/version.
10
+ // The index itself is a process-level singleton (see repo/cache.ts), so making
11
+ // a fresh server per request only re-registers tools — it does not re-read the
12
+ // corpus.
12
13
  const config = getConfig();
13
14
  return createSharedMcpServer({
14
15
  name: SERVER_NAME,
@@ -17,81 +18,145 @@ function createHttpMcpServer() {
17
18
  cacheTtlSeconds: config.cacheTtlSeconds,
18
19
  });
19
20
  }
20
- function originGuard() {
21
- // MCP spec warns about Origin validation for HTTP transports. In practice,
22
- // Cursor/Claude Code are not browsers and often send no Origin header.
23
- // So: only enforce when Origin is present.
24
- const allowed = (process.env.ALLOWED_ORIGINS ?? "")
21
+ function parseList(value) {
22
+ return (value ?? "")
25
23
  .split(",")
26
24
  .map((s) => s.trim())
27
25
  .filter(Boolean);
26
+ }
27
+ /**
28
+ * Express `trust proxy` setting. Default OFF (false) so `req.ip` is the
29
+ * unspoofable socket address. Behind a single reverse proxy (e.g. Railway) set
30
+ * TRUST_PROXY=1 to derive the real client IP from the proxy-appended
31
+ * X-Forwarded-For; a hop count / subnet list is also accepted. Never `true`:
32
+ * that trusts the entire client-supplied XFF chain, letting any client forge
33
+ * `req.ip` and bypass the rate limiter.
34
+ */
35
+ function parseTrustProxy(value) {
36
+ const v = (value ?? "").trim();
37
+ if (v === "" || v === "false")
38
+ return false;
39
+ if (v === "true")
40
+ return true; // discouraged; documented footgun
41
+ const n = Number(v);
42
+ if (Number.isInteger(n) && String(n) === v)
43
+ return n;
44
+ return v; // e.g. "loopback" or a comma-separated subnet list
45
+ }
46
+ /**
47
+ * Minimal in-memory fixed-window rate limiter, keyed by client IP. The open
48
+ * 0.0.0.0 endpoint would otherwise be unbounded. A periodic sweep (unref'd, so
49
+ * it never keeps the process alive) evicts expired windows so the map can't grow
50
+ * without bound.
51
+ */
52
+ function rateLimiter(opts) {
53
+ const hits = new Map();
54
+ const maxKeys = opts.maxKeys ?? 100_000;
55
+ function sweepExpired(now) {
56
+ for (const [key, entry] of hits) {
57
+ if (now >= entry.resetAt)
58
+ hits.delete(key);
59
+ }
60
+ }
61
+ const sweep = setInterval(() => sweepExpired(Date.now()), opts.windowMs);
62
+ sweep.unref();
28
63
  return (req, res, next) => {
29
- const origin = req.headers.origin;
30
- if (!origin)
31
- return next();
32
- if (allowed.length === 0)
33
- return res.status(403).send("Origin not allowed");
34
- if (!allowed.includes(origin))
35
- return res.status(403).send("Origin not allowed");
64
+ const now = Date.now();
65
+ const key = req.ip || req.socket.remoteAddress || "unknown";
66
+ let entry = hits.get(key);
67
+ if (!entry || now >= entry.resetAt) {
68
+ // Hard cap on distinct keys as defense-in-depth against a map-growth DoS
69
+ // (also mitigated by trust-proxy defaulting off). Reclaim expired entries
70
+ // before admitting a new key.
71
+ if (!hits.has(key) && hits.size >= maxKeys)
72
+ sweepExpired(now);
73
+ entry = { count: 0, resetAt: now + opts.windowMs };
74
+ hits.set(key, entry);
75
+ }
76
+ entry.count += 1;
77
+ if (entry.count > opts.max) {
78
+ res.setHeader("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
79
+ res.status(429).json({
80
+ jsonrpc: "2.0",
81
+ error: { code: -32029, message: "Too Many Requests" },
82
+ id: null,
83
+ });
84
+ return;
85
+ }
36
86
  next();
37
87
  };
38
88
  }
39
89
  async function main() {
40
90
  const app = express();
91
+ app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY));
41
92
  app.use(express.json({ limit: "2mb" }));
42
- app.use(originGuard());
43
- // Health endpoint for Railway and humans
93
+ const windowMs = Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000;
94
+ const maxPerWindow = Number(process.env.RATE_LIMIT_MAX) || 120;
95
+ app.use(rateLimiter({ windowMs, max: maxPerWindow }));
96
+ // Health endpoint for Railway and humans.
44
97
  app.get("/health", (_req, res) => {
45
98
  res.status(200).json({
46
99
  ok: true,
100
+ name: SERVER_NAME,
101
+ version: SERVER_VERSION,
47
102
  contract_version: process.env.CONTRACT_VERSION ?? "v1",
48
- patterns_dir: process.env.PATTERNS_DIR ?? "patterns",
49
103
  });
50
104
  });
51
- // Map transports by session ID (official SDK approach).
52
- const transports = {};
105
+ // Host + Origin validation via the SDK's DNS-rebinding protection. Configure
106
+ // ALLOWED_HOSTS / ALLOWED_ORIGINS (comma-separated) for the deployed endpoint;
107
+ // Host validation is what SF17 asks for and it also blocks DNS-rebinding.
108
+ // NOTE: these transport options are marked @deprecated in @modelcontextprotocol/sdk
109
+ // 1.26 in favor of external middleware; a future round may migrate. They remain
110
+ // fully functional today.
111
+ const allowedHosts = parseList(process.env.ALLOWED_HOSTS);
112
+ const allowedOrigins = parseList(process.env.ALLOWED_ORIGINS);
113
+ const enableDnsRebindingProtection = allowedHosts.length > 0 || allowedOrigins.length > 0;
114
+ if (!enableDnsRebindingProtection) {
115
+ console.warn("[http] ALLOWED_HOSTS/ALLOWED_ORIGINS unset — Host/Origin validation is OFF. " +
116
+ "Set them for any publicly reachable deployment.");
117
+ }
118
+ // Stateless transport: a fresh server + transport per request, so there is no
119
+ // in-memory session map to leak on abrupt client disconnects and no sticky
120
+ // routing requirement. The corpus index is shared across requests via the
121
+ // process-level cache, so per-request cost is just tool registration.
53
122
  app.post("/mcp", async (req, res) => {
54
- const sessionId = req.headers["mcp-session-id"];
55
- let transport;
56
- if (sessionId && transports[sessionId]) {
57
- transport = transports[sessionId];
58
- }
59
- else if (!sessionId && isInitializeRequest(req.body)) {
60
- transport = new StreamableHTTPServerTransport({
61
- sessionIdGenerator: () => randomUUID(),
62
- onsessioninitialized: (id) => {
63
- transports[id] = transport;
64
- },
65
- });
66
- transport.onclose = () => {
67
- if (transport?.sessionId)
68
- delete transports[transport.sessionId];
69
- };
70
- const server = createHttpMcpServer();
123
+ const server = createHttpMcpServer();
124
+ const transport = new StreamableHTTPServerTransport({
125
+ sessionIdGenerator: undefined,
126
+ enableDnsRebindingProtection,
127
+ allowedHosts,
128
+ allowedOrigins,
129
+ });
130
+ res.on("close", () => {
131
+ void transport.close();
132
+ void server.close();
133
+ });
134
+ try {
71
135
  await server.connect(transport);
136
+ await transport.handleRequest(req, res, req.body);
72
137
  }
73
- else {
74
- res.status(400).json({
75
- jsonrpc: "2.0",
76
- error: { code: -32000, message: "Bad Request: No valid session ID provided" },
77
- id: null,
78
- });
79
- return;
138
+ catch (err) {
139
+ console.error("[http] request handling failed:", err);
140
+ if (!res.headersSent) {
141
+ res.status(500).json({
142
+ jsonrpc: "2.0",
143
+ error: { code: -32603, message: "Internal server error" },
144
+ id: null,
145
+ });
146
+ }
80
147
  }
81
- await transport.handleRequest(req, res, req.body);
82
148
  });
83
- const handleSessionRequest = async (req, res) => {
84
- const sessionId = req.headers["mcp-session-id"];
85
- if (!sessionId || !transports[sessionId]) {
86
- res.status(400).send("Invalid or missing session ID");
87
- return;
88
- }
89
- await transports[sessionId].handleRequest(req, res);
149
+ // Stateless mode has no sessions, so the SSE GET stream and session DELETE are
150
+ // not supported.
151
+ const methodNotAllowed = (_req, res) => {
152
+ res.status(405).json({
153
+ jsonrpc: "2.0",
154
+ error: { code: -32000, message: "Method not allowed (stateless server)" },
155
+ id: null,
156
+ });
90
157
  };
91
- // GET for server-to-client notifications via SSE (part of Streamable HTTP behavior)
92
- app.get("/mcp", handleSessionRequest);
93
- // DELETE to end a session
94
- app.delete("/mcp", handleSessionRequest);
158
+ app.get("/mcp", methodNotAllowed);
159
+ app.delete("/mcp", methodNotAllowed);
95
160
  const port = Number(process.env.PORT) || 3000;
96
161
  app.listen(port, "0.0.0.0", () => {
97
162
  console.log(`MCP HTTP server listening on :${port} at /mcp`);
@@ -1,16 +1,23 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { createIndexCache } from "../repo/cache.js";
3
+ import { PACKAGE_ROOT } from "../config.js";
4
+ import { getIndexCache } from "../repo/cache.js";
4
5
  import { getGlobalRules } from "../tools/getGlobalRules.js";
5
6
  import { getPattern } from "../tools/getPattern.js";
6
7
  import { listPatterns } from "../tools/listPatterns.js";
7
8
  import { withTelemetry } from "../telemetryWrap.js";
8
9
  import { jsonResult } from "./response.js";
10
+ import { toErrorResult } from "./errors.js";
9
11
  function registerTools(server, opts) {
10
- const cache = createIndexCache({
12
+ const cache = getIndexCache({
11
13
  patternRepoPath: opts.patternsRoot,
12
14
  cacheTtlSeconds: opts.cacheTtlSeconds,
13
15
  });
16
+ // Absolute roots whose prefixes must never leak in a client-facing error.
17
+ // Any throw inside a handler is converted to a structured, client-safe error
18
+ // result ({ error_code, message }, isError: true) via toErrorResult(err, scrubRoots)
19
+ // instead of an SDK error carrying a raw message with absolute host paths.
20
+ const scrubRoots = [opts.patternsRoot, PACKAGE_ROOT];
14
21
  server.registerTool("list_patterns", {
15
22
  description: "List the accessible UI component patterns available for a stack. Call this FIRST whenever you are about to build or modify any UI, to choose which patterns apply. Each entry has an `id`, `summary`, `tags`, `aliases`, and a `selection_excerpt` (its `use_when` / `do_not_use_when` bullets) — use the selection_excerpt to decide which patterns match the components in your task, then call get_pattern for each. Optionally narrow with `tags` or a free-text `query`.",
16
23
  inputSchema: {
@@ -22,31 +29,36 @@ function registerTools(server, opts) {
22
29
  query: z.string().optional(),
23
30
  },
24
31
  }, async (args) => {
25
- const stack = args.stack;
26
- const toolArgs = {
27
- stack,
28
- tags: args.tags,
29
- query: args.query,
30
- };
31
- const payload = await withTelemetry({
32
- tool: "list_patterns",
33
- stack,
34
- args: toolArgs,
35
- handler: async () => {
36
- const index = await cache.getIndex(stack);
37
- return listPatterns(index, {
38
- stack,
39
- tags: args.tags,
40
- query: args.query,
41
- });
42
- },
43
- summarizeResult: (result) => ({
44
- count: result.count,
45
- cache_ttl_seconds: result.cache_ttl_seconds,
46
- catalog_revision: result.catalog_revision,
47
- }),
48
- });
49
- return jsonResult(payload);
32
+ try {
33
+ const stack = args.stack;
34
+ const toolArgs = {
35
+ stack,
36
+ tags: args.tags,
37
+ query: args.query,
38
+ };
39
+ const payload = await withTelemetry({
40
+ tool: "list_patterns",
41
+ stack,
42
+ args: toolArgs,
43
+ handler: async () => {
44
+ const index = await cache.getIndex(stack);
45
+ return listPatterns(index, {
46
+ stack,
47
+ tags: args.tags,
48
+ query: args.query,
49
+ });
50
+ },
51
+ summarizeResult: (result) => ({
52
+ count: result.count,
53
+ cache_ttl_seconds: result.cache_ttl_seconds,
54
+ catalog_revision: result.catalog_revision,
55
+ }),
56
+ });
57
+ return jsonResult(payload);
58
+ }
59
+ catch (err) {
60
+ return toErrorResult(err, scrubRoots);
61
+ }
50
62
  });
51
63
  server.registerTool("get_pattern", {
52
64
  description: "Get the full accessibility specification for one pattern by `id` (ids come from list_patterns). Returns the pattern's sections: `must_haves` (non-negotiable WCAG 2.2 AA requirements — implement all of them), `donts` (anti-patterns — never produce), `golden_pattern` (a reference implementation to model), `customizable` (allowed variations), and `acceptance_checks` (observable pass/fail behaviors). Call this for each pattern you selected before writing UI code.",
@@ -58,58 +70,74 @@ function registerTools(server, opts) {
58
70
  id: z.string(),
59
71
  },
60
72
  }, async (args) => {
61
- const stack = args.stack;
62
- const toolArgs = {
63
- stack,
64
- id: String(args.id),
65
- };
66
- const payload = await withTelemetry({
67
- tool: "get_pattern",
68
- stack,
69
- args: toolArgs,
70
- handler: async () => {
71
- const index = await cache.getIndex(stack);
72
- return getPattern(index, opts.patternsRoot, {
73
- stack,
74
- id: String(args.id),
75
- });
76
- },
77
- summarizeResult: (result) => ({
78
- pattern_id: result.pattern.id,
79
- cache_ttl_seconds: result.cache_ttl_seconds,
80
- catalog_revision: result.catalog_revision,
81
- }),
82
- });
83
- return jsonResult(payload);
73
+ try {
74
+ const stack = args.stack;
75
+ const toolArgs = {
76
+ stack,
77
+ id: String(args.id),
78
+ };
79
+ const payload = await withTelemetry({
80
+ tool: "get_pattern",
81
+ stack,
82
+ args: toolArgs,
83
+ handler: async () => {
84
+ const index = await cache.getIndex(stack);
85
+ return getPattern(index, opts.patternsRoot, {
86
+ stack,
87
+ id: String(args.id),
88
+ });
89
+ },
90
+ summarizeResult: (result) => ({
91
+ pattern_id: result.pattern.id,
92
+ cache_ttl_seconds: result.cache_ttl_seconds,
93
+ catalog_revision: result.catalog_revision,
94
+ }),
95
+ });
96
+ return jsonResult(payload);
97
+ }
98
+ catch (err) {
99
+ return toErrorResult(err, scrubRoots);
100
+ }
84
101
  });
85
102
  server.registerTool("get_foundations", {
86
- description: "Get the cross-cutting Foundations rules for a stack — accessibility requirements not tied to a single component: focus states, landmarks, headings, contrast, page structure, use of color. Retrieve these on every UI task, not just page-level work: each rule carries a `scope` (utility, style, component, layout, page) that determines whether it applies to the current change.",
103
+ description: "Get the cross-cutting Foundations rules for a stack — accessibility requirements not tied to a single component: focus states, landmarks, headings, contrast, page structure, use of color. Retrieve these on every UI task, not just page-level work: each rule carries a `scope` (utility, style, component, layout, page) that determines whether it applies to the current change. Optionally pass `scope` to return only rules matching one or more of those buckets.",
87
104
  inputSchema: {
88
105
  stack: z
89
106
  .enum(["web/react", "android/compose"])
90
107
  .default("web/react")
91
108
  .describe("Target platform and framework. Currently only 'web/react' is populated; defaults to 'web/react'."),
109
+ scope: z
110
+ .array(z.enum(["utility", "style", "component", "layout", "page"]))
111
+ .optional()
112
+ .describe("Optional. Return only rules whose `scope` includes at least one of these buckets. Omit to get all rules."),
92
113
  },
93
114
  }, async (args) => {
94
- const stack = args.stack;
95
- const toolArgs = {
96
- stack,
97
- };
98
- const payload = await withTelemetry({
99
- tool: "get_foundations",
100
- stack,
101
- args: toolArgs,
102
- handler: async () => {
103
- const index = await cache.getIndex(stack);
104
- return getGlobalRules(index, opts.patternsRoot, { stack });
105
- },
106
- summarizeResult: (result) => ({
107
- rules_count: Array.isArray(result.rules.items) ? result.rules.items.length : 0,
108
- cache_ttl_seconds: result.cache_ttl_seconds,
109
- catalog_revision: result.catalog_revision,
110
- }),
111
- });
112
- return jsonResult(payload);
115
+ try {
116
+ const stack = args.stack;
117
+ const scope = args.scope;
118
+ const toolArgs = {
119
+ stack,
120
+ scope,
121
+ };
122
+ const payload = await withTelemetry({
123
+ tool: "get_foundations",
124
+ stack,
125
+ args: toolArgs,
126
+ handler: async () => {
127
+ const index = await cache.getIndex(stack);
128
+ return getGlobalRules(index, opts.patternsRoot, { stack, scope });
129
+ },
130
+ summarizeResult: (result) => ({
131
+ rules_count: Array.isArray(result.rules.items) ? result.rules.items.length : 0,
132
+ cache_ttl_seconds: result.cache_ttl_seconds,
133
+ catalog_revision: result.catalog_revision,
134
+ }),
135
+ });
136
+ return jsonResult(payload);
137
+ }
138
+ catch (err) {
139
+ return toErrorResult(err, scrubRoots);
140
+ }
113
141
  });
114
142
  }
115
143
  export function createMcpServer(opts) {
@@ -0,0 +1,87 @@
1
+ // src/mcp/errors.ts
2
+ //
3
+ // Structured, client-safe tool errors.
4
+ //
5
+ // Tools throw `ToolFailure` with a stable `error_code` (never a raw path in the
6
+ // message). At the MCP boundary, `toErrorResult` turns any throw into a
7
+ // `jsonResult({ error_code, message }, { isError: true })` so clients get a
8
+ // predictable shape and never see absolute host filesystem paths.
9
+ import { jsonResult } from "./response.js";
10
+ /**
11
+ * Stable, client-facing error codes. Clients can branch on these instead of
12
+ * string-matching messages. New codes are additive.
13
+ */
14
+ export const ERROR_CODES = {
15
+ /** Requested pattern id is not in the catalog for the given stack. */
16
+ PATTERN_NOT_FOUND: "PATTERN_NOT_FOUND",
17
+ /** Requested stack is not usable (e.g. index built for a different stack). */
18
+ STACK_INVALID: "STACK_INVALID",
19
+ /** The corpus for the requested stack could not be loaded or parsed. */
20
+ CORPUS_UNAVAILABLE: "CORPUS_UNAVAILABLE",
21
+ /** Catch-all for unexpected failures (message is scrubbed of host paths). */
22
+ INTERNAL_ERROR: "INTERNAL_ERROR",
23
+ };
24
+ /**
25
+ * A tool failure whose message is already safe to return to a client.
26
+ * Throw this from tool/repo code for known, enumerated conditions.
27
+ */
28
+ export class ToolFailure extends Error {
29
+ error_code;
30
+ details;
31
+ constructor(error_code, message, details) {
32
+ super(message);
33
+ this.name = "ToolFailure";
34
+ this.error_code = error_code;
35
+ this.details = details;
36
+ }
37
+ }
38
+ function escapeForRegex(s) {
39
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40
+ }
41
+ /**
42
+ * Remove absolute host paths from a message so nothing client-reachable leaks
43
+ * the server's filesystem layout. Known corpus/package roots are turned into
44
+ * their relative tail; any remaining home-dir absolute prefix is collapsed.
45
+ *
46
+ * Throw sites already compose messages with relative paths — this is a
47
+ * defense-in-depth backstop for unexpected errors.
48
+ */
49
+ export function scrubPaths(message, roots = []) {
50
+ // Work on a slash-normalized copy so a native (backslash) root reliably
51
+ // matches a message whose paths may be POSIX-normalized (fast-glob / toPosix
52
+ // store forward slashes even on Windows) or native. These error messages are
53
+ // path/id-centric, so normalizing stray backslashes is harmless.
54
+ let out = message.replace(/\\/g, "/");
55
+ for (const root of roots) {
56
+ if (!root)
57
+ continue;
58
+ const normalized = root.replace(/\\/g, "/").replace(/\/+$/, "");
59
+ if (!normalized)
60
+ continue;
61
+ // "<root>/a/b" -> "a/b", bare "<root>" -> ""
62
+ out = out.replace(new RegExp(escapeForRegex(normalized) + "/?", "g"), "");
63
+ }
64
+ // Backstop: collapse any lingering absolute home path not under a known root.
65
+ // Covers posix (/Users, /home) and Windows drive paths, which are forward-slash
66
+ // after the normalization above (e.g. "C:/Users/john/").
67
+ out = out
68
+ .replace(/[A-Za-z]:\/Users\/[^/\s"']+\//g, "~/")
69
+ .replace(/\/Users\/[^/\s"']+\//g, "~/")
70
+ .replace(/\/home\/[^/\s"']+\//g, "~/");
71
+ return out;
72
+ }
73
+ /**
74
+ * Convert any thrown value into a structured, client-safe error result.
75
+ * Known `ToolFailure`s keep their code; anything else becomes INTERNAL_ERROR.
76
+ */
77
+ export function toErrorResult(err, roots = []) {
78
+ if (err instanceof ToolFailure) {
79
+ return jsonResult({
80
+ error_code: err.error_code,
81
+ message: scrubPaths(err.message, roots),
82
+ ...(err.details ? { details: err.details } : {}),
83
+ }, { isError: true });
84
+ }
85
+ const rawMessage = err instanceof Error ? err.message : String(err);
86
+ return jsonResult({ error_code: ERROR_CODES.INTERNAL_ERROR, message: scrubPaths(rawMessage, roots) }, { isError: true });
87
+ }
@@ -1,21 +1,25 @@
1
1
  import { buildPatternIndex } from "./index.js";
2
+ /**
3
+ * Lazy singleton index cache, keyed by stack.
4
+ *
5
+ * The bundled corpus is static for the life of the process, so once a stack's
6
+ * index is built we keep it — no TTL, no periodic re-glob/re-parse. We memoize
7
+ * the in-flight Promise (not just the resolved value) so concurrent first-hits
8
+ * dedupe onto a single build instead of racing several.
9
+ */
2
10
  export function createIndexCache(params) {
3
11
  const { patternRepoPath, cacheTtlSeconds } = params;
4
12
  const store = new Map();
5
- async function getIndex(stack) {
6
- const now = Date.now();
7
- const cached = store.get(stack);
8
- // If we have it and it's fresh enough, reuse it.
9
- if (cached) {
10
- const ageSeconds = (now - cached.builtAtMs) / 1000;
11
- if (ageSeconds < cacheTtlSeconds) {
12
- return cached.index;
13
- }
13
+ function getIndex(stack) {
14
+ let inflight = store.get(stack);
15
+ if (!inflight) {
16
+ inflight = buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds);
17
+ // If the build rejects (e.g. an unpopulated stack), evict so a later call
18
+ // can retry rather than caching the rejection for the whole process life.
19
+ inflight.catch(() => store.delete(stack));
20
+ store.set(stack, inflight);
14
21
  }
15
- // Otherwise rebuild
16
- const index = await buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds);
17
- store.set(stack, { index, builtAtMs: now });
18
- return index;
22
+ return inflight;
19
23
  }
20
24
  function clear(stack) {
21
25
  if (stack)
@@ -25,3 +29,17 @@ export function createIndexCache(params) {
25
29
  }
26
30
  return { getIndex, clear };
27
31
  }
32
+ // Process-level memoization keyed by corpus path, so every server instance
33
+ // (the single stdio server, or a per-request HTTP server) shares one cache.
34
+ // The index is built at most once per process per stack — not once per server
35
+ // or per HTTP session.
36
+ const sharedCaches = new Map();
37
+ export function getIndexCache(params) {
38
+ const key = params.patternRepoPath;
39
+ let cache = sharedCaches.get(key);
40
+ if (!cache) {
41
+ cache = createIndexCache(params);
42
+ sharedCaches.set(key, cache);
43
+ }
44
+ return cache;
45
+ }
@@ -3,6 +3,7 @@ import matter from "gray-matter";
3
3
  import path from "node:path";
4
4
  import { getRepoPaths } from "./paths.js";
5
5
  import { readTextFile, fileExists, toPosixPath } from "../utils/fs.js";
6
+ import { ToolFailure, ERROR_CODES } from "../mcp/errors.js";
6
7
  function normalizeBullet(s, maxLen) {
7
8
  if (typeof s !== "string")
8
9
  return null;
@@ -51,7 +52,7 @@ function buildCatalogSelectionMap(catalogText) {
51
52
  parsed = JSON.parse(catalogText);
52
53
  }
53
54
  catch {
54
- throw new Error("patterns.json is not valid JSON.");
55
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, "patterns.json is not valid JSON.");
55
56
  }
56
57
  const parsedObj = parsed;
57
58
  const arr = Array.isArray(parsed)
@@ -83,19 +84,19 @@ function buildCatalogSelectionMap(catalogText) {
83
84
  export async function buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds) {
84
85
  const { baselinePath, catalogPath, componentsGlob } = getRepoPaths(patternRepoPath, stack);
85
86
  if (!(await fileExists(baselinePath))) {
86
- throw new Error(`Missing baseline file: ${baselinePath}`);
87
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Missing baseline file for stack '${stack}': ${makeRelativePath(patternRepoPath, baselinePath)}`);
87
88
  }
88
89
  if (!(await fileExists(catalogPath))) {
89
- throw new Error(`Missing catalog file: ${catalogPath}`);
90
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Missing catalog file for stack '${stack}': ${makeRelativePath(patternRepoPath, catalogPath)}`);
90
91
  }
91
- // Deterministic ordering: sort file paths before reading/hashing/parsing
92
+ // Deterministic ordering: sort file paths before reading/parsing
92
93
  const componentPaths = (await fg(componentsGlob, { onlyFiles: true, unique: true }))
93
94
  .map(toPosixPath)
94
95
  .sort((a, b) => a.localeCompare(b));
95
96
  const baselineText = await readTextFile(baselinePath);
96
97
  const catalogText = await readTextFile(catalogPath);
97
98
  const selectionById = buildCatalogSelectionMap(catalogText);
98
- // Read each component once; reuse both for hashing and parsing
99
+ // Read each component once, then parse from the cached text.
99
100
  const fileTextByPath = new Map();
100
101
  for (const p of componentPaths) {
101
102
  // NOTE: componentPaths are POSIX normalized; ensure readTextFile can handle them on Windows.
@@ -118,14 +119,15 @@ export async function buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds)
118
119
  const idToPath = new Map();
119
120
  const all = [];
120
121
  for (const filePath of componentPaths) {
122
+ const relPath = makeRelativePath(patternRepoPath, filePath);
121
123
  const raw = fileTextByPath.get(filePath);
122
124
  if (raw == null)
123
- throw new Error(`Internal error: missing cached text for ${filePath}`);
125
+ throw new Error(`Internal error: missing cached text for ${relPath}`);
124
126
  const parsed = matter(raw);
125
127
  const data = parsed.data;
126
128
  const id = String(data.id ?? "").trim();
127
129
  if (!id) {
128
- throw new Error(`Pattern missing 'id' in frontmatter: ${filePath}`);
130
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern missing 'id' in frontmatter: ${relPath}`);
129
131
  }
130
132
  const declaredStack = String(data.stack ?? "").trim();
131
133
  const status = String(data.status ?? "").trim();
@@ -133,14 +135,14 @@ export async function buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds)
133
135
  const tags = Array.isArray(data.tags) ? data.tags.map(String) : [];
134
136
  const aliases = Array.isArray(data.aliases) ? data.aliases.map(String) : [];
135
137
  if (declaredStack && declaredStack !== stack) {
136
- throw new Error(`Pattern ${id} declares stack=${declaredStack} but is located under stack=${stack}`);
138
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern '${id}' declares stack='${declaredStack}' but is located under stack='${stack}' (${relPath}).`);
137
139
  }
138
140
  if (!summary) {
139
- throw new Error(`Pattern missing 'summary' in frontmatter: ${filePath}`);
141
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern missing 'summary' in frontmatter: ${relPath}`);
140
142
  }
141
143
  const allowed = ["alpha", "beta", "stable", "deprecated"];
142
144
  if (!allowed.includes(status)) {
143
- throw new Error(`Invalid status '${status}' in ${filePath}. Allowed: ${allowed.join(", ")}`);
145
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Invalid status '${status}' in ${relPath}. Allowed: ${allowed.join(", ")}`);
144
146
  }
145
147
  const selection_excerpt = selectionById.get(id);
146
148
  const pattern = {
@@ -153,7 +155,7 @@ export async function buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds)
153
155
  ...(selection_excerpt ? { selection_excerpt } : {}),
154
156
  };
155
157
  if (byId.has(id)) {
156
- throw new Error(`Duplicate pattern id '${id}' found. Second copy: ${filePath}`);
158
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Duplicate pattern id '${id}' found. Second copy: ${relPath}`);
157
159
  }
158
160
  byId.set(id, pattern);
159
161
  idToPath.set(id, filePath);
@@ -1,5 +1,6 @@
1
1
  import { readTextFile } from "../utils/fs.js";
2
2
  import { getRepoPaths } from "../repo/paths.js";
3
+ import { ToolFailure, ERROR_CODES } from "../mcp/errors.js";
3
4
  import { parseGlobalRulesMarkdown } from "../repo/globalRules.js";
4
5
  function normalizeScope(scope) {
5
6
  if (!scope)
@@ -9,7 +10,7 @@ function normalizeScope(scope) {
9
10
  export async function getGlobalRules(index, patternRepoPath, args) {
10
11
  const { stack } = args;
11
12
  if (stack !== index.stack) {
12
- throw new Error(`Stack mismatch. Index=${index.stack}, requested=${stack}`);
13
+ throw new ToolFailure(ERROR_CODES.STACK_INVALID, `Stack mismatch. Index='${index.stack}', requested='${stack}'.`);
13
14
  }
14
15
  const scopeFilter = normalizeScope(args.scope);
15
16
  const { baselinePath } = getRepoPaths(patternRepoPath, stack);
@@ -3,15 +3,17 @@ import matter from "gray-matter";
3
3
  import { readTextFile } from "../utils/fs.js";
4
4
  import { extractSections } from "../repo/sections.js";
5
5
  import { makeRelativePath } from "../repo/index.js";
6
+ import { ToolFailure, ERROR_CODES } from "../mcp/errors.js";
6
7
  export async function getPattern(index, patternRepoPath, args) {
7
8
  const { stack, id } = args;
8
9
  if (stack !== index.stack) {
9
- throw new Error(`Stack mismatch. Index=${index.stack}, requested=${stack}`);
10
+ throw new ToolFailure(ERROR_CODES.STACK_INVALID, `Stack mismatch. Index='${index.stack}', requested='${stack}'.`);
10
11
  }
11
12
  const filePath = index.idToPath.get(id);
12
13
  if (!filePath) {
13
- throw new Error(`PATTERN_NOT_FOUND: No pattern with id '${id}' for stack '${stack}'`);
14
+ throw new ToolFailure(ERROR_CODES.PATTERN_NOT_FOUND, `No pattern with id '${id}' for stack '${stack}'.`);
14
15
  }
16
+ const relPath = makeRelativePath(patternRepoPath, filePath);
15
17
  const raw = await readTextFile(filePath);
16
18
  const parsed = matter(raw);
17
19
  const data = parsed.data;
@@ -22,17 +24,17 @@ export async function getPattern(index, patternRepoPath, args) {
22
24
  const tags = Array.isArray(data.tags) ? data.tags.map(String) : [];
23
25
  const aliases = Array.isArray(data.aliases) ? data.aliases.map(String) : [];
24
26
  if (!patternId)
25
- throw new Error(`Pattern missing 'id' in frontmatter: ${filePath}`);
27
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern missing 'id' in frontmatter: ${relPath}`);
26
28
  if (patternId !== id) {
27
- throw new Error(`Pattern id mismatch. Requested '${id}', file declares '${patternId}' (${filePath})`);
29
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern id mismatch. Requested '${id}', file declares '${patternId}' (${relPath}).`);
28
30
  }
29
31
  // Optional but strongly recommended: validate declared stack matches folder stack
30
32
  const declaredStack = String(data.stack ?? "").trim();
31
33
  if (declaredStack && declaredStack !== stack) {
32
- throw new Error(`Pattern '${id}' declares stack='${declaredStack}' but is served under stack='${stack}'. File: ${filePath}`);
34
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern '${id}' declares stack='${declaredStack}' but is served under stack='${stack}' (${relPath}).`);
33
35
  }
34
36
  if (!summary)
35
- throw new Error(`Pattern missing 'summary' in frontmatter: ${filePath}`);
37
+ throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern missing 'summary' in frontmatter: ${relPath}`);
36
38
  const sections = extractSections(parsed.content);
37
39
  const detail = {
38
40
  id,
@@ -43,7 +45,7 @@ export async function getPattern(index, patternRepoPath, args) {
43
45
  aliases,
44
46
  sections,
45
47
  source: {
46
- relative_path: makeRelativePath(patternRepoPath, filePath),
48
+ relative_path: relPath,
47
49
  },
48
50
  };
49
51
  return {
@@ -1,7 +1,8 @@
1
+ import { ToolFailure, ERROR_CODES } from "../mcp/errors.js";
1
2
  export function listPatterns(index, args) {
2
3
  const { stack, tags, query } = args;
3
4
  if (stack !== index.stack) {
4
- throw new Error(`Stack mismatch. Index=${index.stack}, requested=${stack}`);
5
+ throw new ToolFailure(ERROR_CODES.STACK_INVALID, `Stack mismatch. Index='${index.stack}', requested='${stack}'.`);
5
6
  }
6
7
  let results = index.all;
7
8
  // Filter by tags (OR logic): match if pattern has ANY of the requested tags.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11y-context/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Model Context Protocol server that serves the A11y Context accessibility-pattern corpus (WCAG 2.2 AA patterns, Foundations rules) to AI coding agents.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,6 +11,7 @@
11
11
  "dist/",
12
12
  "corpus/",
13
13
  "README.md",
14
+ "CHANGELOG.md",
14
15
  "LICENSE"
15
16
  ],
16
17
  "engines": {
@@ -23,9 +24,11 @@
23
24
  "dev:http": "tsx watch src/http.ts",
24
25
  "inspector": "npm run build && mcp-inspector node dist/index.js",
25
26
  "smoke:remote": "npm run build && node dist/smoke/smoke-remote-mcp.js",
26
- "test:regression": "ts-node scripts/regression-runner.ts",
27
- "test:regression:update": "ts-node scripts/regression-runner.ts --update",
28
- "test:regression:ignore-ttl": "ts-node scripts/regression-runner.ts --ignore-cache-ttl",
27
+ "smoke:stdio": "npm run build && node scripts/smoke-stdio.mjs",
28
+ "test:regression": "npm run build && node scripts/regression-runner.mjs",
29
+ "test:regression:update": "npm run build && node scripts/regression-runner.mjs --update",
30
+ "test:regression:ignore-ttl": "npm run build && node scripts/regression-runner.mjs --ignore-cache-ttl",
31
+ "sync-corpus": "node scripts/sync-corpus.mjs",
29
32
  "prepublishOnly": "npm run build"
30
33
  },
31
34
  "keywords": [