@expressivecss/mcp-server 0.3.0 → 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/server.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import { spawn } from 'node:child_process';
8
- import { JSDOM } from 'jsdom';
8
+ import { createRequire } from 'node:module';
9
9
  import * as z from 'zod/v4';
10
10
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -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 = {
@@ -104,7 +107,7 @@ const LegacyPatternList = [
104
107
  id: 'legacy-card-content',
105
108
  severity: 'high',
106
109
  description: '`.card-content` was removed; use the component’s documented child structure.',
107
- pattern: /\bcard-content\b/g,
110
+ pattern: /(?<![-\w])card-content(?![-\w])/g,
108
111
  },
109
112
  {
110
113
  id: 'legacy-nav-wrapper',
@@ -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',
@@ -1060,6 +1087,8 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
1060
1087
  if (markupStructureExceedsLimit(snippet)) return markInspectionTruncated([], 'markup structure limit reached');
1061
1088
  const prefix = '<!doctype html><body>';
1062
1089
  const includeNodeLocations = snippet.length * (snippet.split('<').length - 1) <= MAX_LOCATED_MARKUP_WORK;
1090
+ // Loaded on first use: jsdom takes ~0.3 s to load, and most server starts never inspect markup.
1091
+ const { JSDOM } = createRequire(import.meta.url)('jsdom');
1063
1092
  const dom = new JSDOM(`${prefix}${snippet}</body>`, { includeNodeLocations });
1064
1093
  const { document } = dom.window;
1065
1094
  // Elements the parser creates without a start tag have no source offset, so they report the start.
@@ -1532,14 +1561,7 @@ function isPathInside(root, candidate) {
1532
1561
 
1533
1562
  function commandExecutionPolicy(projectRoot) {
1534
1563
  const resolvedProjectRoot = realpathSync(path.resolve(projectRoot));
1535
- const allowedRoots = SETTINGS.allowedCommandRoots.flatMap((configuredRoot) => {
1536
- try {
1537
- return [realpathSync(path.resolve(configuredRoot))];
1538
- } catch {
1539
- return [];
1540
- }
1541
- });
1542
- const matchedRoot = allowedRoots.find((allowedRoot) => isPathInside(allowedRoot, resolvedProjectRoot)) ?? null;
1564
+ const matchedRoot = matchingRoot(SETTINGS.allowedCommandRoots, resolvedProjectRoot);
1543
1565
  return {
1544
1566
  configured: SETTINGS.allowedCommandRoots.length > 0,
1545
1567
  allowed: Boolean(matchedRoot),
@@ -1589,7 +1611,7 @@ function stopProcessTree(proc, signal) {
1589
1611
  }
1590
1612
  }
1591
1613
 
1592
- function redactSensitiveText(value) {
1614
+ export function redactSensitiveText(value) {
1593
1615
  return String(value)
1594
1616
  .replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/giu, '$1[REDACTED]')
1595
1617
  .replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{16,}/giu, '$1[REDACTED]')
@@ -1600,11 +1622,12 @@ function redactSensitiveText(value) {
1600
1622
  .replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/gu, '[REDACTED]')
1601
1623
  .replace(/\bsk-[A-Za-z0-9_-]{20,}\b/gu, '[REDACTED]')
1602
1624
  .replace(/\bAKIA[0-9A-Z]{16}\b/gu, '[REDACTED]')
1603
- .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]')
1604
1627
  .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, '[REDACTED]')
1605
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')
1606
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]')
1607
- .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.
1608
1631
  .replace(/\/(?:home|Users)\/[^/\s"'<>]+(?:\/[^\s"'<>),;\]}]*)?/gu, '[LOCAL_PATH]')
1609
1632
  .replace(/\/(?:private\/)?tmp\/[^\s"'<>),;\]}]+/gu, '[LOCAL_PATH]')
1610
1633
  .replace(/[A-Z]:\\+Users\\+[^\\\s]+(?:\\+[^\s]*)?/giu, '[LOCAL_PATH]');
@@ -1628,18 +1651,17 @@ export async function readInspectionFile(filePath, projectRoot, byteLimit) {
1628
1651
  throw error;
1629
1652
  }
1630
1653
 
1631
- 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);
1632
1658
  let total = 0;
1633
- while (total <= byteLimit) {
1634
- 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);
1635
1661
  if (chunk.bytesRead === 0) break;
1636
1662
  total += chunk.bytesRead;
1637
1663
  }
1638
- if (total > byteLimit) {
1639
- const error = new Error(`file exceeds ${byteLimit} byte read limit`);
1640
- error.code = 'INSPECTION_FILE_TOO_LARGE';
1641
- throw error;
1642
- }
1664
+ if (total > before.size) throw new Error('file changed while reading');
1643
1665
 
1644
1666
  const after = await handle.stat({ bigint: true });
1645
1667
  const pathAfter = await lstat(filePath, { bigint: true });
@@ -1895,7 +1917,7 @@ async function setupExpertHandler(args) {
1895
1917
  const snapshot = await projectSummary(projectRoot);
1896
1918
  const catalog = await loadGuideCatalog(projectRoot);
1897
1919
  const contractVersion = catalog.frameworkVersion;
1898
- const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
1920
+ const version = catalog.version;
1899
1921
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
1900
1922
  const checks = [
1901
1923
  {
@@ -2013,7 +2035,7 @@ async function rulesEnforcerHandler(args) {
2013
2035
  const workflowId = parsed.workflowId || randomUUID();
2014
2036
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2015
2037
  const catalog = await loadGuideCatalog(projectRoot);
2016
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2038
+ const version = resolveAgainstContract(catalog);
2017
2039
  const hasSnippet = parsed.snippet.trim().length > 0;
2018
2040
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2019
2041
 
@@ -2135,7 +2157,7 @@ async function creativeDirectorHandler(args) {
2135
2157
  const parsed = creativeSchemaParsed.parse(args);
2136
2158
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2137
2159
  const catalog = await loadGuideCatalog(projectRoot);
2138
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2160
+ const version = resolveAgainstContract(catalog);
2139
2161
  return toToolResult(buildCreativePayload(catalog, version, parsed), { ...parsed, projectRoot }, { catalog, version });
2140
2162
  }
2141
2163
 
@@ -2199,7 +2221,7 @@ async function pageArchitectHandler(args, stage = 'page_architect') {
2199
2221
  const workflowId = parsed.workflowId || randomUUID();
2200
2222
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2201
2223
  const catalog = await loadGuideCatalog(projectRoot);
2202
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2224
+ const version = resolveAgainstContract(catalog);
2203
2225
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2204
2226
  const contractSafe = version.status === 'match' && !provenanceBlock;
2205
2227
 
@@ -2272,7 +2294,7 @@ async function componentSyntaxExpertHandler(args) {
2272
2294
  const parsed = syntaxSchemaParsed.parse(args);
2273
2295
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2274
2296
  const catalog = await loadGuideCatalog(projectRoot);
2275
- const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2297
+ const version = resolveAgainstContract(catalog);
2276
2298
  return toToolResult(buildSyntaxPayload(catalog, version, parsed), { ...parsed, projectRoot }, { catalog, version });
2277
2299
  }
2278
2300
 
@@ -2346,9 +2368,9 @@ function buildSyntaxPayload(catalog, version, parsed) {
2346
2368
 
2347
2369
  async function componentCatalogHandler(args) {
2348
2370
  const parsed = catalogSchemaParsed.parse(args);
2349
- const projectRoot = parsed.projectRoot === undefined ? undefined : path.resolve(parsed.projectRoot);
2371
+ const projectRoot = parsed.projectRoot === undefined ? undefined : authorizedProjectRoot(parsed.projectRoot);
2350
2372
  const catalog = await loadGuideCatalog(projectRoot);
2351
- const version = projectRoot === undefined ? null : await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2373
+ const version = projectRoot === undefined ? null : resolveAgainstContract(catalog);
2352
2374
  return toToolResult(buildCatalogPayload(catalog, version, parsed), parsed, { catalog, version });
2353
2375
  }
2354
2376
 
@@ -2422,8 +2444,7 @@ async function qualityInspectorHandler(args) {
2422
2444
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2423
2445
  const fileSummary = summarizeProjectFiles(parsed.files, projectRoot);
2424
2446
  const catalog = await loadGuideCatalog(projectRoot);
2425
- const contractVersion = catalog.frameworkVersion;
2426
- const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
2447
+ const version = catalog.version;
2427
2448
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2428
2449
 
2429
2450
  const fileInspection = await findFileViolations(fileSummary.existing, projectRoot);
package/smoke.mjs CHANGED
@@ -220,9 +220,18 @@ async function verifyResponseBudgets(matchingDir, versionedDir) {
220
220
  const result = await call(catalogRequest);
221
221
  assert.notEqual(result.structuredContent.responseBudget.delivery, 'complete');
222
222
  });
223
- await withServer(20_000, async ({ call }) => {
223
+ // A partial listing needs room for one recovery record per omitted entry, and
224
+ // that accounting grows with the catalogue. Derive the budget from the
225
+ // complete listing so it stays below it while the accounting still fits; a
226
+ // fixed figure turns into the documented error once enough components exist.
227
+ let listingBytes;
228
+ await withServer(1_048_576, async ({ call }) => {
229
+ listingBytes = bytes(await call(request('component_catalog')));
230
+ });
231
+ await withServer(listingBytes - 3_000, async ({ call }) => {
224
232
  const listing = await call(request('component_catalog'));
225
233
  assert.equal(listing.structuredContent.responseBudget.delivery, 'partial');
234
+ assert.ok(listing.structuredContent.responseBudget.omissions.length > 1, 'the listing omits several entries');
226
235
  assert.equal(listing.structuredContent.count + listing.structuredContent.responseBudget.omissions.length, guideData.guides.length);
227
236
  for (const recovery of listing.structuredContent.responseBudget.recoveries) {
228
237
  assert.equal(recovery.action, 'retry');
@@ -230,6 +239,8 @@ async function verifyResponseBudgets(matchingDir, versionedDir) {
230
239
  assert.equal(recovered.structuredContent.entries[0].slug, recovery.request.arguments.query);
231
240
  assert.equal(recovered.structuredContent.responseBudget.delivery, 'complete');
232
241
  }
242
+ });
243
+ await withServer(20_000, async ({ call }) => {
233
244
  const duplicate = await call(request('component_syntax_expert', { projectRoot: matchingDir, components: ['cards', 'cards', 'unknown-name', 'autocomplete'], sections: ['options', 'methods'] }));
234
245
  assert.equal(duplicate.structuredContent.missing[0].requested, 'unknown-name');
235
246
  for (const omitted of duplicate.structuredContent.responseBudget.omissions.filter((row) => row.slug === 'cards')) assert.ok([0, 1].includes(omitted.index));
@@ -958,6 +969,12 @@ const transport = new StdioClientTransport({
958
969
  assert.match(long.stdout, /long\.html:1:1 nav-needs-label/u);
959
970
  assert.doesNotMatch(long.stdout, /inspection-truncated/u);
960
971
  assert.equal(lint([outsideFile]).status, 0);
972
+ // The retired class name matches only as a whole class, not inside documented names that contain it.
973
+ const cardContentMarkup = path.join(outsideDir, 'card-content.html');
974
+ await writeFile(cardContentMarkup, '<style>article { --md-comp-card-content-padding: 24px; }</style>\n<div class="expanding-card-content">Details</div>\n<div class="card-content">Old</div>\n');
975
+ const cardContent = lint([cardContentMarkup]);
976
+ assert.equal(cardContent.stdout.match(/legacy-card-content/gu)?.length, 1, cardContent.stdout);
977
+ assert.match(cardContent.stdout, /card-content\.html:3:\d+ legacy-card-content/u);
961
978
  // Files the bounded reader refuses, and inspections that stop early, fail instead of passing silently.
962
979
  const linkedMarkup = path.join(outsideDir, 'linked.html');
963
980
  const crowdedMarkup = path.join(outsideDir, 'crowded.html');
@@ -979,6 +996,21 @@ const transport = new StdioClientTransport({
979
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');
980
997
  }
981
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
+
982
1014
  const client = new Client({ name: 'expressivecss-mcp-smoke', version: '0.1.0' });
983
1015
 
984
1016
  try {
@@ -1287,6 +1319,7 @@ try {
1287
1319
  },
1288
1320
  });
1289
1321
  assert.ok(boundedRules.structuredContent.issueCount <= 200);
1322
+ assert.equal(boundedRules.content[0].text.includes('\n '), false, 'tool results are compact JSON');
1290
1323
  assert.ok(boundedRules.structuredContent.blockedChecks.includes('static inspection limit reached'));
1291
1324
 
1292
1325
  const nestedMarkupStartedAt = Date.now();
@@ -2273,6 +2306,52 @@ try {
2273
2306
  await timeoutClient.close();
2274
2307
  }
2275
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
+
2276
2355
  const trapTransport = new StdioClientTransport({
2277
2356
  command: process.execPath,
2278
2357
  args: [path.join(packageDir, 'server.js')],