@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 +21 -0
- package/README.md +22 -1
- package/dist/config.js +3 -1
- package/dist/http.js +120 -55
- package/dist/mcp/createServer.js +98 -70
- package/dist/mcp/errors.js +87 -0
- package/dist/repo/cache.js +31 -13
- package/dist/repo/index.js +13 -11
- package/dist/tools/getGlobalRules.js +2 -1
- package/dist/tools/getPattern.js +9 -7
- package/dist/tools/listPatterns.js +2 -1
- package/package.json +7 -4
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
|
|
21
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
app.
|
|
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`);
|
package/dist/mcp/createServer.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
package/dist/repo/cache.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/repo/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/dist/tools/getPattern.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
27
|
+
throw new ToolFailure(ERROR_CODES.CORPUS_UNAVAILABLE, `Pattern missing 'id' in frontmatter: ${relPath}`);
|
|
26
28
|
if (patternId !== id) {
|
|
27
|
-
throw new
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
27
|
-
"test:regression
|
|
28
|
-
"test:regression:
|
|
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": [
|