@expressivecss/mcp-server 0.3.1 → 0.4.0

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/contract.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generatedBy": "scripts/gen-expressivecss-skill.mjs",
4
- "frameworkVersion": "0.13.1",
5
- "skillVersion": "0.9.0",
6
- "matchingTag": "v0.13.1",
4
+ "frameworkVersion": "0.14.1",
5
+ "skillVersion": "0.10.0",
6
+ "matchingTag": "v0.14.1",
7
7
  "releaseTags": [
8
8
  "v0.10.0",
9
9
  "v0.10.1",
@@ -11,6 +11,8 @@
11
11
  "v0.12.0",
12
12
  "v0.13.0",
13
13
  "v0.13.1",
14
+ "v0.14.0",
15
+ "v0.14.1",
14
16
  "v0.5.0",
15
17
  "v0.6.0",
16
18
  "v0.7.0",
@@ -27,5 +29,5 @@
27
29
  "package.json",
28
30
  "CHANGELOG.md"
29
31
  ],
30
- "sourceHash": "18ec4146d300280bbcb74eb3b23f16d1015a0a3db86a8c59fc8348dbdf9d96c2"
32
+ "sourceHash": "80d854b3978953f2491c292c4c9c0fe32f72842a9d9a03e4e0b8aff409314463"
31
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expressivecss/mcp-server",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "ExpressiveCSS MCP server with Design→QA workflow tools.",
5
5
  "homepage": "https://www.expressivecss.com",
6
6
  "repository": {
@@ -33,14 +33,15 @@ async function readBounded(filePath) {
33
33
  if (!before.isFile() || !sameFile(before, pathBefore)) return { error: 'identity-changed' };
34
34
  const limit = readLimit(filePath);
35
35
  if (before.size > BigInt(limit)) return { error: 'oversized' };
36
- const buffer = Buffer.alloc(limit + 1);
36
+ // Size the buffer to the file, not the limit. The spare byte exposes growth.
37
+ const buffer = Buffer.allocUnsafe(Number(before.size) + 1);
37
38
  let offset = 0;
38
39
  while (offset < buffer.length) {
39
40
  const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
40
41
  if (bytesRead === 0) break;
41
42
  offset += bytesRead;
42
43
  }
43
- if (offset > limit) return { error: 'oversized' };
44
+ if (offset > before.size) return { error: 'identity-changed' };
44
45
  const after = await handle.stat({ bigint: true });
45
46
  const pathAfter = await lstat(filePath, { bigint: true });
46
47
  const currentResolved = await realpath(filePath);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generatedBy": "mcp/expressivecss/scripts/sync-guides.mjs",
4
- "frameworkVersion": "0.13.1",
4
+ "frameworkVersion": "0.14.1",
5
5
  "semantics": {
6
6
  "about": "Normative HTML semantics for ExpressiveCSS. This data file is the single source of truth: SEMANTICS.md is generated from it by scripts/gen-semantics.mjs, and tests/semantics.test.js enforces it. One row per roster entry, each stating its kind - component, foundation or behavior, as CONTEXT.md defines them. CONTEXT.md defines the rest of the vocabulary.",
7
7
  "scope": "Element choice, landmark structure, and the ARIA that element choice implies. Keyboard interaction, focus management and colour contrast are deliberately out of scope.",
@@ -977,6 +977,26 @@
977
977
  }
978
978
  ]
979
979
  },
980
+ "map": {
981
+ "status": "enforced",
982
+ "note": "Added with the map component. MapLibre draws the map on a canvas, so the frame needs a name for the region it is, and a marker that opens a popup has to be a button to be reached and activated from the keyboard. Icon-only controls are covered by icon-only-control-is-named.",
983
+ "rules": [
984
+ {
985
+ "id": "map-is-named",
986
+ "kind": "forbid",
987
+ "selector": ".map:not([aria-label], [aria-labelledby])",
988
+ "fragmentSafe": true,
989
+ "message": "Name the map with aria-label or aria-labelledby, and give it role=\"region\". MapLibre's canvas has no useful name of its own."
990
+ },
991
+ {
992
+ "id": "map-popup-marker-is-button",
993
+ "kind": "forbid",
994
+ "selector": ".map-marker[aria-controls]:not(button)",
995
+ "fragmentSafe": true,
996
+ "message": "A marker that opens a popup is a <button type=\"button\"> with aria-label, so the keyboard can reach and open it."
997
+ }
998
+ ]
999
+ },
980
1000
  "accordion": {
981
1001
  "status": "enforced",
982
1002
  "note": "Added with the accordion component. Each item is a native <details>, so the browser exposes <summary> as a button with its expanded state and handles the keyboard. The rules keep authors from re-stating that state or nesting controls in the toggle.",
@@ -1216,6 +1236,34 @@
1216
1236
  }
1217
1237
  ]
1218
1238
  },
1239
+ "confetti": {
1240
+ "status": "enforced",
1241
+ "note": "Added with the confetti component. The pieces are decoration drawn on a canvas, so an authored confetti canvas is hidden from assistive technology. The page-wide canvas the script creates hides itself.",
1242
+ "rules": [
1243
+ {
1244
+ "id": "confetti-canvas-is-hidden",
1245
+ "kind": "require-attr",
1246
+ "selector": "canvas.confetti",
1247
+ "attr": "aria-hidden",
1248
+ "equals": "true",
1249
+ "message": "Confetti is decoration. Mark the canvas aria-hidden=\"true\" and say what happened in text, for example in a snackbar."
1250
+ }
1251
+ ]
1252
+ },
1253
+ "particles": {
1254
+ "status": "enforced",
1255
+ "note": "Added with the particles component. The dots are decoration drawn on a canvas, so the canvas is hidden from assistive technology.",
1256
+ "rules": [
1257
+ {
1258
+ "id": "particles-canvas-is-hidden",
1259
+ "kind": "require-attr",
1260
+ "selector": "canvas.particles",
1261
+ "attr": "aria-hidden",
1262
+ "equals": "true",
1263
+ "message": "Particles are decoration. Mark the canvas aria-hidden=\"true\"."
1264
+ }
1265
+ ]
1266
+ },
1219
1267
  "tree": {
1220
1268
  "status": "enforced",
1221
1269
  "note": "Added with the tree component. Nested lists of native disclosures and links: every row is a Tab stop and the browser opens a branch, so no tree role is promised.",
package/server.js CHANGED
@@ -62,6 +62,7 @@ function configuredCommandRoots(value) {
62
62
  return trimmed.split(path.delimiter).map((entry) => entry.trim()).filter(Boolean);
63
63
  }
64
64
 
65
+ const PROJECT_ROOTS_SETTING = 'EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS';
65
66
  const QUALITY_SCRIPTS = ['typecheck', 'test', 'verify:expressivecss'];
66
67
  // Trusted server configuration can only narrow the built-in list. Invalid input denies all.
67
68
  function configuredScripts(value) {
@@ -82,6 +83,8 @@ const SETTINGS = {
82
83
  commandTimeoutMs: Number(process.env.EXPRESSIVECSS_MCP_COMMAND_TIMEOUT_MS || DEFAULT_COMMAND_TIMEOUT_MS),
83
84
  allowedScripts: configuredScripts(process.env.EXPRESSIVECSS_MCP_ALLOWED_SCRIPTS),
84
85
  allowedCommandRoots: configuredCommandRoots(process.env.EXPRESSIVECSS_MCP_ALLOWED_COMMAND_ROOTS),
86
+ // Unset keeps every root open. A set but empty or invalid list denies every client-supplied root.
87
+ allowedProjectRoots: process.env[PROJECT_ROOTS_SETTING] === undefined ? null : configuredCommandRoots(process.env[PROJECT_ROOTS_SETTING]),
85
88
  };
86
89
 
87
90
  const SKIP_FLAGS = {
@@ -316,7 +319,29 @@ function parseCliProjectRoot() {
316
319
  }
317
320
 
318
321
  function resolveProjectRoot(projectRoot) {
319
- return projectRoot ? path.resolve(projectRoot) : parseCliProjectRoot();
322
+ return projectRoot ? authorizedProjectRoot(projectRoot) : parseCliProjectRoot();
323
+ }
324
+
325
+ // The operator's default root is trusted. When configured, a client-supplied root must sit inside an allowed root.
326
+ function authorizedProjectRoot(projectRoot) {
327
+ const resolved = path.resolve(projectRoot);
328
+ if (!SETTINGS.allowedProjectRoots) return resolved;
329
+ let canonical = null;
330
+ try { canonical = realpathSync(resolved); } catch {}
331
+ if (!canonical || !matchingRoot(SETTINGS.allowedProjectRoots, canonical)) {
332
+ throw new Error(`projectRoot is not inside a root allowed by ${PROJECT_ROOTS_SETTING}.`);
333
+ }
334
+ return resolved;
335
+ }
336
+
337
+ function matchingRoot(configuredRoots, candidate) {
338
+ return configuredRoots.flatMap((configuredRoot) => {
339
+ try {
340
+ return [realpathSync(path.resolve(configuredRoot))];
341
+ } catch {
342
+ return [];
343
+ }
344
+ }).find((allowedRoot) => isPathInside(allowedRoot, candidate)) ?? null;
320
345
  }
321
346
 
322
347
  function resolveRepoRoot(startDir) {
@@ -407,7 +432,7 @@ function serializeToolResult(payload) {
407
432
  return {
408
433
  content: [{
409
434
  type: 'text',
410
- text: JSON.stringify(payload, null, GUIDANCE_TOOLS.has(payload.stage) ? undefined : 2),
435
+ text: JSON.stringify(payload),
411
436
  }],
412
437
  structuredContent: payload,
413
438
  ...(payload.responseBudget?.delivery === 'error' ? { isError: true } : {}),
@@ -833,6 +858,8 @@ async function loadGuideCatalog(projectRoot) {
833
858
  guideSource: 'bundled',
834
859
  count: bundledGuideCache.count,
835
860
  components: bundledGuideCache.components,
861
+ // Handlers reuse this resolution instead of reading the lockfiles again.
862
+ version: projectResolution,
836
863
  };
837
864
  }
838
865
 
@@ -965,9 +992,9 @@ async function projectSummary(projectRoot) {
965
992
  return summary;
966
993
  }
967
994
 
968
- async function resolveAgainstContract(projectRoot, contractVersion) {
969
- const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
970
- if (contractVersion) return version;
995
+ function resolveAgainstContract(catalog) {
996
+ const version = catalog.version;
997
+ if (catalog.frameworkVersion) return version;
971
998
  return {
972
999
  ...version,
973
1000
  status: 'unresolved',
@@ -1534,14 +1561,7 @@ function isPathInside(root, candidate) {
1534
1561
 
1535
1562
  function commandExecutionPolicy(projectRoot) {
1536
1563
  const resolvedProjectRoot = realpathSync(path.resolve(projectRoot));
1537
- const allowedRoots = SETTINGS.allowedCommandRoots.flatMap((configuredRoot) => {
1538
- try {
1539
- return [realpathSync(path.resolve(configuredRoot))];
1540
- } catch {
1541
- return [];
1542
- }
1543
- });
1544
- const matchedRoot = allowedRoots.find((allowedRoot) => isPathInside(allowedRoot, resolvedProjectRoot)) ?? null;
1564
+ const matchedRoot = matchingRoot(SETTINGS.allowedCommandRoots, resolvedProjectRoot);
1545
1565
  return {
1546
1566
  configured: SETTINGS.allowedCommandRoots.length > 0,
1547
1567
  allowed: Boolean(matchedRoot),
@@ -1591,7 +1611,7 @@ function stopProcessTree(proc, signal) {
1591
1611
  }
1592
1612
  }
1593
1613
 
1594
- function redactSensitiveText(value) {
1614
+ export function redactSensitiveText(value) {
1595
1615
  return String(value)
1596
1616
  .replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/giu, '$1[REDACTED]')
1597
1617
  .replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{16,}/giu, '$1[REDACTED]')
@@ -1602,11 +1622,12 @@ function redactSensitiveText(value) {
1602
1622
  .replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/gu, '[REDACTED]')
1603
1623
  .replace(/\bsk-[A-Za-z0-9_-]{20,}\b/gu, '[REDACTED]')
1604
1624
  .replace(/\bAKIA[0-9A-Z]{16}\b/gu, '[REDACTED]')
1605
- .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, '[REDACTED]')
1625
+ // Start only where a token run starts; a bare `\b` retries inside `a-b-c` runs in quadratic time.
1626
+ .replace(/(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, '[REDACTED]')
1606
1627
  .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, '[REDACTED]')
1607
1628
  .replace(/(["'])(API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET|CLIENT[_-]?SECRET|CLIENTSECRET|CONNECTION[_-]?STRING|ACCESS_TOKEN|REFRESH_TOKEN|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\1\s*:\s*(["'])[^"'\r\n]*\3/giu, '$1$2$1:$3[REDACTED]$3')
1608
1629
  .replace(/\b(API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET|CLIENT[_-]?SECRET|CLIENTSECRET|CONNECTION[_-]?STRING|ACCESS_TOKEN|REFRESH_TOKEN|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*[:=]\s*[^\s,;]+/giu, '$1=[REDACTED]')
1609
- .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/giu, '$1[REDACTED]@')
1630
+ .replace(/([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/@:]+:[^\s/@]+@/giu, '$1[REDACTED]@') // Bounded scheme keeps long runs linear.
1610
1631
  .replace(/\/(?:home|Users)\/[^/\s"'<>]+(?:\/[^\s"'<>),;\]}]*)?/gu, '[LOCAL_PATH]')
1611
1632
  .replace(/\/(?:private\/)?tmp\/[^\s"'<>),;\]}]+/gu, '[LOCAL_PATH]')
1612
1633
  .replace(/[A-Z]:\\+Users\\+[^\\\s]+(?:\\+[^\s]*)?/giu, '[LOCAL_PATH]');
@@ -1630,18 +1651,17 @@ export async function readInspectionFile(filePath, projectRoot, byteLimit) {
1630
1651
  throw error;
1631
1652
  }
1632
1653
 
1633
- const bytes = Buffer.allocUnsafe(byteLimit + 1);
1654
+ // Size the buffer to the file, not the limit. The spare byte exposes growth
1655
+ // and files such as /proc entries that report a smaller size than they hold.
1656
+ const capacity = Number(before.size) + 1;
1657
+ const bytes = Buffer.allocUnsafe(capacity);
1634
1658
  let total = 0;
1635
- while (total <= byteLimit) {
1636
- const chunk = await handle.read(bytes, total, byteLimit + 1 - total, total);
1659
+ while (total < capacity) {
1660
+ const chunk = await handle.read(bytes, total, capacity - total, total);
1637
1661
  if (chunk.bytesRead === 0) break;
1638
1662
  total += chunk.bytesRead;
1639
1663
  }
1640
- if (total > byteLimit) {
1641
- const error = new Error(`file exceeds ${byteLimit} byte read limit`);
1642
- error.code = 'INSPECTION_FILE_TOO_LARGE';
1643
- throw error;
1644
- }
1664
+ if (total > before.size) throw new Error('file changed while reading');
1645
1665
 
1646
1666
  const after = await handle.stat({ bigint: true });
1647
1667
  const pathAfter = await lstat(filePath, { bigint: true });
@@ -1897,7 +1917,7 @@ async function setupExpertHandler(args) {
1897
1917
  const snapshot = await projectSummary(projectRoot);
1898
1918
  const catalog = await loadGuideCatalog(projectRoot);
1899
1919
  const contractVersion = catalog.frameworkVersion;
1900
- const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
1920
+ const version = catalog.version;
1901
1921
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
1902
1922
  const checks = [
1903
1923
  {
@@ -2015,7 +2035,7 @@ async function rulesEnforcerHandler(args) {
2015
2035
  const workflowId = parsed.workflowId || randomUUID();
2016
2036
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2017
2037
  const catalog = await loadGuideCatalog(projectRoot);
2018
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2038
+ const version = resolveAgainstContract(catalog);
2019
2039
  const hasSnippet = parsed.snippet.trim().length > 0;
2020
2040
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2021
2041
 
@@ -2137,7 +2157,7 @@ async function creativeDirectorHandler(args) {
2137
2157
  const parsed = creativeSchemaParsed.parse(args);
2138
2158
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2139
2159
  const catalog = await loadGuideCatalog(projectRoot);
2140
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2160
+ const version = resolveAgainstContract(catalog);
2141
2161
  return toToolResult(buildCreativePayload(catalog, version, parsed), { ...parsed, projectRoot }, { catalog, version });
2142
2162
  }
2143
2163
 
@@ -2201,7 +2221,7 @@ async function pageArchitectHandler(args, stage = 'page_architect') {
2201
2221
  const workflowId = parsed.workflowId || randomUUID();
2202
2222
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2203
2223
  const catalog = await loadGuideCatalog(projectRoot);
2204
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2224
+ const version = resolveAgainstContract(catalog);
2205
2225
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2206
2226
  const contractSafe = version.status === 'match' && !provenanceBlock;
2207
2227
 
@@ -2274,7 +2294,7 @@ async function componentSyntaxExpertHandler(args) {
2274
2294
  const parsed = syntaxSchemaParsed.parse(args);
2275
2295
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2276
2296
  const catalog = await loadGuideCatalog(projectRoot);
2277
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2297
+ const version = resolveAgainstContract(catalog);
2278
2298
  return toToolResult(buildSyntaxPayload(catalog, version, parsed), { ...parsed, projectRoot }, { catalog, version });
2279
2299
  }
2280
2300
 
@@ -2348,9 +2368,9 @@ function buildSyntaxPayload(catalog, version, parsed) {
2348
2368
 
2349
2369
  async function componentCatalogHandler(args) {
2350
2370
  const parsed = catalogSchemaParsed.parse(args);
2351
- const projectRoot = parsed.projectRoot === undefined ? undefined : path.resolve(parsed.projectRoot);
2371
+ const projectRoot = parsed.projectRoot === undefined ? undefined : authorizedProjectRoot(parsed.projectRoot);
2352
2372
  const catalog = await loadGuideCatalog(projectRoot);
2353
- const version = projectRoot === undefined ? null : await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2373
+ const version = projectRoot === undefined ? null : resolveAgainstContract(catalog);
2354
2374
  return toToolResult(buildCatalogPayload(catalog, version, parsed), parsed, { catalog, version });
2355
2375
  }
2356
2376
 
@@ -2424,8 +2444,7 @@ async function qualityInspectorHandler(args) {
2424
2444
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2425
2445
  const fileSummary = summarizeProjectFiles(parsed.files, projectRoot);
2426
2446
  const catalog = await loadGuideCatalog(projectRoot);
2427
- const contractVersion = catalog.frameworkVersion;
2428
- const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
2447
+ const version = catalog.version;
2429
2448
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2430
2449
 
2431
2450
  const fileInspection = await findFileViolations(fileSummary.existing, projectRoot);
package/smoke.mjs CHANGED
@@ -996,6 +996,21 @@ const transport = new StdioClientTransport({
996
996
  assert.equal(execFileSync(process.execPath, ['--input-type=module', '-e', "import('./server.js').then(() => console.log('imported'))"], { cwd: packageDir, encoding: 'utf8', timeout: 30_000 }).trim(), 'imported');
997
997
  }
998
998
 
999
+ {
1000
+ const { redactSensitiveText, readInspectionFile } = await import('./server.js');
1001
+ // Command output is captured up to 160K characters. Redaction must stay linear on long token runs.
1002
+ const startedAt = Date.now();
1003
+ for (const output of ['ab12'.repeat(40_000), 'a-'.repeat(80_000), 'a'.repeat(160_000)]) redactSensitiveText(output);
1004
+ assert.ok(Date.now() - startedAt < 2_000, `redaction took ${Date.now() - startedAt} ms`);
1005
+ const redacted = redactSensitiveText('token eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM0.SflKxwRJSMeKKF2QT git+https://user:pass@example.com/x');
1006
+ assert.doesNotMatch(redacted, /eyJhbGci|user:pass/u);
1007
+ assert.match(redacted, /git\+https:\/\/\[REDACTED\]@example\.com/u);
1008
+ if (process.platform === 'linux') {
1009
+ // procfs reports size 0 for files with content. A size-bound read must fail, not return a truncated file.
1010
+ await assert.rejects(readInspectionFile('/proc/self/status', '/proc', 1024 * 1024), /changed while reading/u);
1011
+ }
1012
+ }
1013
+
999
1014
  const client = new Client({ name: 'expressivecss-mcp-smoke', version: '0.1.0' });
1000
1015
 
1001
1016
  try {
@@ -1304,6 +1319,7 @@ try {
1304
1319
  },
1305
1320
  });
1306
1321
  assert.ok(boundedRules.structuredContent.issueCount <= 200);
1322
+ assert.equal(boundedRules.content[0].text.includes('\n '), false, 'tool results are compact JSON');
1307
1323
  assert.ok(boundedRules.structuredContent.blockedChecks.includes('static inspection limit reached'));
1308
1324
 
1309
1325
  const nestedMarkupStartedAt = Date.now();
@@ -2290,6 +2306,52 @@ try {
2290
2306
  await timeoutClient.close();
2291
2307
  }
2292
2308
 
2309
+ const rootsClient = new Client({ name: 'expressivecss-mcp-project-roots-smoke', version: '0.1.0' });
2310
+ try {
2311
+ await rootsClient.connect(new StdioClientTransport({
2312
+ command: process.execPath,
2313
+ args: [path.join(packageDir, 'server.js')],
2314
+ cwd: packageDir,
2315
+ stderr: 'pipe',
2316
+ env: { ...process.env, EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS: outsideDir },
2317
+ }));
2318
+ // A root outside the allowlist must not become a file-existence or hash oracle.
2319
+ for (const [name, args] of [
2320
+ ['quality_inspector', { projectRoot: os.tmpdir(), files: [outsideFile] }],
2321
+ ['component_catalog', { projectRoot: os.tmpdir() }],
2322
+ ['setup_expert', { projectRoot: path.join(outsideDir, 'missing') }],
2323
+ ]) {
2324
+ const denied = await rootsClient.callTool({ name, arguments: args });
2325
+ assert.equal(denied.isError, true, `${name} accepted a root outside the allowlist`);
2326
+ assert.match(denied.content[0].text, /EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS/u);
2327
+ }
2328
+ const allowed = await rootsClient.callTool({ name: 'quality_inspector', arguments: { projectRoot: outsideDir, files: [] } });
2329
+ assert.notEqual(allowed.isError, true, allowed.content[0].text);
2330
+ const defaultRoot = await rootsClient.callTool({ name: 'component_catalog', arguments: {} });
2331
+ assert.notEqual(defaultRoot.isError, true, 'the operator default root needs no allowlist entry');
2332
+ } finally {
2333
+ await rootsClient.close();
2334
+ }
2335
+
2336
+ // Set but empty or invalid allowlists fail closed instead of allowing every root.
2337
+ for (const setting of ['', '[]', '[broken', '[42]']) {
2338
+ const closedClient = new Client({ name: 'expressivecss-mcp-closed-roots-smoke', version: '0.1.0' });
2339
+ try {
2340
+ await closedClient.connect(new StdioClientTransport({
2341
+ command: process.execPath,
2342
+ args: [path.join(packageDir, 'server.js')],
2343
+ cwd: packageDir,
2344
+ stderr: 'pipe',
2345
+ env: { ...process.env, EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS: setting },
2346
+ }));
2347
+ const denied = await closedClient.callTool({ name: 'quality_inspector', arguments: { projectRoot: outsideDir, files: [outsideFile] } });
2348
+ assert.equal(denied.isError, true, `allowlist ${JSON.stringify(setting)} allowed a client root`);
2349
+ assert.equal(JSON.stringify(denied).includes('sha256'), false);
2350
+ } finally {
2351
+ await closedClient.close();
2352
+ }
2353
+ }
2354
+
2293
2355
  const trapTransport = new StdioClientTransport({
2294
2356
  command: process.execPath,
2295
2357
  args: [path.join(packageDir, 'server.js')],