@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/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +3 -0
- package/capability-roadmap.json +2097 -242
- package/component-decisions.json +4873 -615
- package/component-guides.json +170 -42
- package/contract.json +8 -4
- package/package.json +2 -1
- package/scripts/resolve-version.mjs +3 -2
- package/semantics-data.json +649 -3
- package/server.js +56 -35
- package/smoke.mjs +80 -1
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 {
|
|
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:
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
969
|
-
const version =
|
|
970
|
-
if (
|
|
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
|
|
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
|
-
|
|
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+.-]
|
|
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
|
-
|
|
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
|
|
1634
|
-
const chunk = await handle.read(bytes, 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 >
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 :
|
|
2371
|
+
const projectRoot = parsed.projectRoot === undefined ? undefined : authorizedProjectRoot(parsed.projectRoot);
|
|
2350
2372
|
const catalog = await loadGuideCatalog(projectRoot);
|
|
2351
|
-
const version = projectRoot === undefined ? null :
|
|
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
|
|
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
|
-
|
|
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')],
|