@expressivecss/mcp-server 0.3.1 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +3 -0
- package/capability-roadmap.json +262 -35
- package/component-decisions.json +1059 -441
- package/component-guides.json +87 -71
- package/contract.json +7 -4
- package/package.json +1 -1
- package/scripts/resolve-version.mjs +3 -2
- package/semantics-data.json +78 -1
- package/server.js +52 -33
- package/smoke.mjs +66 -1
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.
|
|
5
|
-
"skillVersion": "0.
|
|
6
|
-
"matchingTag": "v0.
|
|
4
|
+
"frameworkVersion": "0.15.0",
|
|
5
|
+
"skillVersion": "0.11.0",
|
|
6
|
+
"matchingTag": "v0.15.0",
|
|
7
7
|
"releaseTags": [
|
|
8
8
|
"v0.10.0",
|
|
9
9
|
"v0.10.1",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"v0.12.0",
|
|
12
12
|
"v0.13.0",
|
|
13
13
|
"v0.13.1",
|
|
14
|
+
"v0.14.0",
|
|
15
|
+
"v0.14.1",
|
|
16
|
+
"v0.15.0",
|
|
14
17
|
"v0.5.0",
|
|
15
18
|
"v0.6.0",
|
|
16
19
|
"v0.7.0",
|
|
@@ -27,5 +30,5 @@
|
|
|
27
30
|
"package.json",
|
|
28
31
|
"CHANGELOG.md"
|
|
29
32
|
],
|
|
30
|
-
"sourceHash": "
|
|
33
|
+
"sourceHash": "e1c777a1bcd53d5a6ed4615075a7a500fb6f488050e727487804f8cc421b5902"
|
|
31
34
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
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);
|
package/semantics-data.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedBy": "mcp/expressivecss/scripts/sync-guides.mjs",
|
|
4
|
-
"frameworkVersion": "0.
|
|
4
|
+
"frameworkVersion": "0.15.0",
|
|
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,63 @@
|
|
|
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
|
+
},
|
|
1267
|
+
"keypad": {
|
|
1268
|
+
"status": "enforced",
|
|
1269
|
+
"note": "Added with the keypad and keyboard component. The container is a named group, and every key is a native button that must not submit the form around it.",
|
|
1270
|
+
"rules": [
|
|
1271
|
+
{
|
|
1272
|
+
"id": "keypad-is-group",
|
|
1273
|
+
"kind": "require-attr",
|
|
1274
|
+
"selector": ":is(.keypad, .keyboard)",
|
|
1275
|
+
"attr": "role",
|
|
1276
|
+
"equals": "group",
|
|
1277
|
+
"message": "A keypad or keyboard is a set of related keys. Give it role=\"group\" and an aria-label so its keys are announced together."
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
"id": "keypad-is-labelled",
|
|
1281
|
+
"kind": "require-attr",
|
|
1282
|
+
"selector": ":is(.keypad, .keyboard)",
|
|
1283
|
+
"attr": "aria-label",
|
|
1284
|
+
"message": "Name the keypad or keyboard with aria-label, for example \"PIN keypad\"."
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
"id": "keypad-key-type",
|
|
1288
|
+
"kind": "require-attr",
|
|
1289
|
+
"selector": ":is(.keypad, .keyboard) button",
|
|
1290
|
+
"attr": "type",
|
|
1291
|
+
"equals": "button",
|
|
1292
|
+
"message": "A <button> inside a form defaults to type=submit, so every key would submit it. Give each key type=\"button\"."
|
|
1293
|
+
}
|
|
1294
|
+
]
|
|
1295
|
+
},
|
|
1219
1296
|
"tree": {
|
|
1220
1297
|
"status": "enforced",
|
|
1221
1298
|
"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 ?
|
|
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',
|
|
@@ -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
|
|
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
|
-
|
|
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+.-]
|
|
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
|
-
|
|
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
|
|
1636
|
-
const chunk = await handle.read(bytes, 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 >
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 :
|
|
2371
|
+
const projectRoot = parsed.projectRoot === undefined ? undefined : authorizedProjectRoot(parsed.projectRoot);
|
|
2352
2372
|
const catalog = await loadGuideCatalog(projectRoot);
|
|
2353
|
-
const version = projectRoot === undefined ? null :
|
|
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
|
|
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
|
@@ -299,10 +299,13 @@ async function verifyResponseBudgets(matchingDir, versionedDir) {
|
|
|
299
299
|
assert.equal(result.structuredContent.skipped, true);
|
|
300
300
|
assert.equal(result.structuredContent.contractCompatibility, 'unknown');
|
|
301
301
|
}
|
|
302
|
-
await assert.rejects(peer.callTool(request('component_syntax_expert', { components: Array(400).fill(1) })), (error) => error.code === -32001);
|
|
303
302
|
}, { env: { SKIP_SETUP_EXPERT: 'true', SKIP_CREATIVE_DIRECTOR: 'true', SKIP_PAGE_ARCHITECT: 'true', SKIP_COMPONENT_SYNTAX_EXPERT: 'true' } });
|
|
304
303
|
await withServer(1, async ({ peer }) => {
|
|
305
304
|
await assert.rejects(peer.callTool(request('component_syntax_expert', { components: ['cards'] })), (error) => error.code === -32001);
|
|
305
|
+
// A validation error is budgeted like any result. Its size is zod's to
|
|
306
|
+
// decide (4.6.6 stops at the array's maxItems), so only a 1-byte budget
|
|
307
|
+
// overflows it on every version.
|
|
308
|
+
await assert.rejects(peer.callTool(request('component_syntax_expert', { components: Array(400).fill(1) })), (error) => error.code === -32001);
|
|
306
309
|
}, { env: { SKIP_COMPONENT_SYNTAX_EXPERT: 'true' } });
|
|
307
310
|
for (const invalid of ['', '0', '-1', '+1', '1.5', 'NaN', 'Infinity', '1e4', ' 64', '9007199254740992']) {
|
|
308
311
|
const child = spawnSync(process.execPath, [path.join(packageDir, 'server.js')], { env: { ...process.env, [setting]: invalid }, encoding: 'utf8', timeout: 10_000 });
|
|
@@ -996,6 +999,21 @@ const transport = new StdioClientTransport({
|
|
|
996
999
|
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
1000
|
}
|
|
998
1001
|
|
|
1002
|
+
{
|
|
1003
|
+
const { redactSensitiveText, readInspectionFile } = await import('./server.js');
|
|
1004
|
+
// Command output is captured up to 160K characters. Redaction must stay linear on long token runs.
|
|
1005
|
+
const startedAt = Date.now();
|
|
1006
|
+
for (const output of ['ab12'.repeat(40_000), 'a-'.repeat(80_000), 'a'.repeat(160_000)]) redactSensitiveText(output);
|
|
1007
|
+
assert.ok(Date.now() - startedAt < 2_000, `redaction took ${Date.now() - startedAt} ms`);
|
|
1008
|
+
const redacted = redactSensitiveText('token eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM0.SflKxwRJSMeKKF2QT git+https://user:pass@example.com/x');
|
|
1009
|
+
assert.doesNotMatch(redacted, /eyJhbGci|user:pass/u);
|
|
1010
|
+
assert.match(redacted, /git\+https:\/\/\[REDACTED\]@example\.com/u);
|
|
1011
|
+
if (process.platform === 'linux') {
|
|
1012
|
+
// procfs reports size 0 for files with content. A size-bound read must fail, not return a truncated file.
|
|
1013
|
+
await assert.rejects(readInspectionFile('/proc/self/status', '/proc', 1024 * 1024), /changed while reading/u);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
999
1017
|
const client = new Client({ name: 'expressivecss-mcp-smoke', version: '0.1.0' });
|
|
1000
1018
|
|
|
1001
1019
|
try {
|
|
@@ -1304,6 +1322,7 @@ try {
|
|
|
1304
1322
|
},
|
|
1305
1323
|
});
|
|
1306
1324
|
assert.ok(boundedRules.structuredContent.issueCount <= 200);
|
|
1325
|
+
assert.equal(boundedRules.content[0].text.includes('\n '), false, 'tool results are compact JSON');
|
|
1307
1326
|
assert.ok(boundedRules.structuredContent.blockedChecks.includes('static inspection limit reached'));
|
|
1308
1327
|
|
|
1309
1328
|
const nestedMarkupStartedAt = Date.now();
|
|
@@ -2290,6 +2309,52 @@ try {
|
|
|
2290
2309
|
await timeoutClient.close();
|
|
2291
2310
|
}
|
|
2292
2311
|
|
|
2312
|
+
const rootsClient = new Client({ name: 'expressivecss-mcp-project-roots-smoke', version: '0.1.0' });
|
|
2313
|
+
try {
|
|
2314
|
+
await rootsClient.connect(new StdioClientTransport({
|
|
2315
|
+
command: process.execPath,
|
|
2316
|
+
args: [path.join(packageDir, 'server.js')],
|
|
2317
|
+
cwd: packageDir,
|
|
2318
|
+
stderr: 'pipe',
|
|
2319
|
+
env: { ...process.env, EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS: outsideDir },
|
|
2320
|
+
}));
|
|
2321
|
+
// A root outside the allowlist must not become a file-existence or hash oracle.
|
|
2322
|
+
for (const [name, args] of [
|
|
2323
|
+
['quality_inspector', { projectRoot: os.tmpdir(), files: [outsideFile] }],
|
|
2324
|
+
['component_catalog', { projectRoot: os.tmpdir() }],
|
|
2325
|
+
['setup_expert', { projectRoot: path.join(outsideDir, 'missing') }],
|
|
2326
|
+
]) {
|
|
2327
|
+
const denied = await rootsClient.callTool({ name, arguments: args });
|
|
2328
|
+
assert.equal(denied.isError, true, `${name} accepted a root outside the allowlist`);
|
|
2329
|
+
assert.match(denied.content[0].text, /EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS/u);
|
|
2330
|
+
}
|
|
2331
|
+
const allowed = await rootsClient.callTool({ name: 'quality_inspector', arguments: { projectRoot: outsideDir, files: [] } });
|
|
2332
|
+
assert.notEqual(allowed.isError, true, allowed.content[0].text);
|
|
2333
|
+
const defaultRoot = await rootsClient.callTool({ name: 'component_catalog', arguments: {} });
|
|
2334
|
+
assert.notEqual(defaultRoot.isError, true, 'the operator default root needs no allowlist entry');
|
|
2335
|
+
} finally {
|
|
2336
|
+
await rootsClient.close();
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// Set but empty or invalid allowlists fail closed instead of allowing every root.
|
|
2340
|
+
for (const setting of ['', '[]', '[broken', '[42]']) {
|
|
2341
|
+
const closedClient = new Client({ name: 'expressivecss-mcp-closed-roots-smoke', version: '0.1.0' });
|
|
2342
|
+
try {
|
|
2343
|
+
await closedClient.connect(new StdioClientTransport({
|
|
2344
|
+
command: process.execPath,
|
|
2345
|
+
args: [path.join(packageDir, 'server.js')],
|
|
2346
|
+
cwd: packageDir,
|
|
2347
|
+
stderr: 'pipe',
|
|
2348
|
+
env: { ...process.env, EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS: setting },
|
|
2349
|
+
}));
|
|
2350
|
+
const denied = await closedClient.callTool({ name: 'quality_inspector', arguments: { projectRoot: outsideDir, files: [outsideFile] } });
|
|
2351
|
+
assert.equal(denied.isError, true, `allowlist ${JSON.stringify(setting)} allowed a client root`);
|
|
2352
|
+
assert.equal(JSON.stringify(denied).includes('sha256'), false);
|
|
2353
|
+
} finally {
|
|
2354
|
+
await closedClient.close();
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2293
2358
|
const trapTransport = new StdioClientTransport({
|
|
2294
2359
|
command: process.execPath,
|
|
2295
2360
|
args: [path.join(packageDir, 'server.js')],
|