mcp-app-lint 0.1.0 → 0.1.2

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.
@@ -0,0 +1,80 @@
1
+ import type { Finding } from './lint.mjs';
2
+ /** @import { Finding } from './lint.mjs' */
3
+ /**
4
+ * The version of this package, as the SARIF driver reports it. Read from package.json, which every
5
+ * install ships, so it cannot fall behind a release.
6
+ * @type {string}
7
+ */
8
+ export declare const TOOL_VERSION: string;
9
+ /**
10
+ * A SARIF 2.1.0 log with one run.
11
+ * @param {readonly Finding[]} findings
12
+ * @param {{ invocation?: Record<string, unknown> }} [options] extra fields for the run's invocation, such as `commandLine`
13
+ */
14
+ export declare function toSarif(findings: readonly Finding[], { invocation }?: {
15
+ invocation?: Record<string, unknown>;
16
+ }): {
17
+ $schema: string;
18
+ version: string;
19
+ runs: {
20
+ tool: {
21
+ driver: {
22
+ name: string;
23
+ version: string;
24
+ informationUri: string;
25
+ rules: {
26
+ id: string;
27
+ name: string;
28
+ shortDescription: {
29
+ text: string;
30
+ };
31
+ fullDescription: {
32
+ text: string;
33
+ };
34
+ help: {
35
+ text: string;
36
+ };
37
+ defaultConfiguration: {
38
+ level: import("./rules.mjs").Level;
39
+ };
40
+ properties: {
41
+ tags: string[];
42
+ };
43
+ }[];
44
+ };
45
+ };
46
+ invocations: {
47
+ executionSuccessful: boolean;
48
+ }[];
49
+ results: {
50
+ ruleId: string;
51
+ ruleIndex: number | undefined;
52
+ level: import("./rules.mjs").Level;
53
+ message: {
54
+ text: string;
55
+ };
56
+ locations: {
57
+ physicalLocation: {
58
+ artifactLocation: {
59
+ uri: string;
60
+ };
61
+ region?: {
62
+ startLine: number;
63
+ startColumn?: number;
64
+ } | undefined;
65
+ };
66
+ logicalLocations?: {
67
+ name: string;
68
+ kind: string;
69
+ }[] | undefined;
70
+ }[];
71
+ properties?: Record<string, unknown> | undefined;
72
+ }[];
73
+ }[];
74
+ };
75
+ /**
76
+ * One line per finding: level, rule, location, message.
77
+ * @param {readonly Finding[]} findings
78
+ */
79
+ export declare function toText(findings: readonly Finding[]): string;
80
+ //# sourceMappingURL=sarif.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sarif.d.mts","sourceRoot":"","sources":["../src/sarif.mjs"],"names":[],"mappings":"AAII,OAAQ,KAAA,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAAzC,4CAA4C;AAE5C;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAFf,MAEuG,CAAC;AAElH;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAHrB,SAAS,OAAO,EAGK,EAAE,EAAE,UAAe,EAAE,AAFlD,CACF,EADU;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAES;;;;;YAuBhD,MAAM;gBACJ,IAAI;gBACJ,OAAO;gBACP,cAAc;gBACd,KAAK;;;;wBAGiB,IAAI;;;wBACL,IAAI;;;wBACf,IAAI;;;wBACY,KAAK;;;wBACf,IAAI;;;;;;;;;;;;;gBA7BjB,IAAI;;;;;wBAIW,GAAG;;;;;;;;;;;;;;;EAkChC;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAFpB,SAAS,OAAO,EAEI,UAK9B"}
package/package.json CHANGED
@@ -1,30 +1,37 @@
1
1
  {
2
2
  "name": "mcp-app-lint",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "SARIF findings about what an MCP App declares (CSP domain lists, permissions, tool visibility) and what its HTML does (innerHTML sinks, postMessage to '*', external hosts, data sinks).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "mcp-app-lint": "./bin/mcp-app-lint.mjs"
9
9
  },
10
+ "types": "./dist/index.d.mts",
10
11
  "exports": {
11
- ".": "./src/index.mjs"
12
+ ".": {
13
+ "types": "./dist/index.d.mts",
14
+ "default": "./src/index.mjs"
15
+ }
12
16
  },
13
17
  "files": [
14
18
  "bin",
15
19
  "src",
20
+ "dist",
16
21
  "README.md"
17
22
  ],
18
23
  "scripts": {
24
+ "build": "tsc -p tsconfig.build.json && tsc -p test/types/tsconfig.json",
25
+ "typecheck": "tsc -p tsconfig.json",
19
26
  "test": "node --test test/*.test.mjs"
20
27
  },
21
28
  "engines": {
22
29
  "node": ">=22.12"
23
30
  },
24
31
  "dependencies": {
25
- "@render-policy/core": "^0.1.0",
32
+ "@render-policy/core": "^0.1.1",
26
33
  "eslint": "^10.11.0",
27
- "eslint-plugin-render-policy": "^0.1.0"
34
+ "eslint-plugin-render-policy": "^0.1.2"
28
35
  },
29
36
  "publishConfig": {
30
37
  "access": "public"
package/src/analyze.mjs CHANGED
@@ -3,28 +3,50 @@
3
3
  * its tools, and the HTML of the resources themselves. No network here.
4
4
  */
5
5
  import { createRequire } from 'node:module';
6
- import { classifyDomains } from './domains.mjs';
6
+ import { classifyDomains, sinkFor } from './domains.mjs';
7
+
8
+ /**
9
+ * @import { Linter } from 'eslint'
10
+ * @import { ClassifiedDomains } from './domains.mjs'
11
+ */
7
12
 
8
13
  const require = createRequire(import.meta.url);
9
14
 
15
+ /**
16
+ * `_meta.ui` as a server sent it: JSON from the wire or from source, so every field is checked where it is read.
17
+ * @typedef {Record<string, any>} UiMeta
18
+ */
19
+ /** @typedef {'connectDomains' | 'resourceDomains' | 'frameDomains' | 'baseUriDomains'} CspKey */
20
+
21
+ /** @type {readonly CspKey[]} */
10
22
  const CSP_KEYS = ['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'];
11
23
  const PERMISSION_KEYS = ['camera', 'microphone', 'geolocation', 'clipboardWrite'];
12
24
  export const UI_MIME = 'text/html;profile=mcp-app';
13
25
 
14
- /** `_meta.ui` in the modern shape, or the legacy flat `ui/...` keys folded into one object. */
26
+ /**
27
+ * `_meta.ui` in the modern shape, or the legacy flat `ui/...` keys folded into one object.
28
+ * @param {unknown} meta a `_meta` object
29
+ * @returns {UiMeta | null}
30
+ */
15
31
  export function uiMetaOf(meta) {
16
32
  if (!meta || typeof meta !== 'object') return null;
17
- const modern = meta.ui && typeof meta.ui === 'object' ? { ...meta.ui } : {};
33
+ const ui = /** @type {{ ui?: unknown }} */ (meta).ui;
34
+ /** @type {UiMeta} */
35
+ const modern = ui && typeof ui === 'object' ? { ...ui } : {};
18
36
  for (const [key, value] of Object.entries(meta)) {
19
37
  if (key.startsWith('ui/')) modern[key.slice(3)] = value;
20
38
  }
21
39
  return Object.keys(modern).length > 0 ? modern : null;
22
40
  }
23
41
 
42
+ /**
43
+ * What one `_meta` object declares: the CSP domain lists classified, permissions, domain.
44
+ * @param {unknown} meta a `_meta` object
45
+ */
24
46
  export function analyzeUiMeta(meta) {
25
47
  const ui = uiMetaOf(meta);
26
48
  const csp = ui?.csp && typeof ui.csp === 'object' ? ui.csp : null;
27
- const domains = {};
49
+ const domains = /** @type {Record<CspKey, ClassifiedDomains>} */ ({});
28
50
  for (const key of CSP_KEYS) domains[key] = classifyDomains(csp?.[key]);
29
51
  const permissions = ui?.permissions && typeof ui.permissions === 'object' ? PERMISSION_KEYS.filter((k) => k in ui.permissions) : [];
30
52
  return {
@@ -36,35 +58,50 @@ export function analyzeUiMeta(meta) {
36
58
  anyFullWildcard: CSP_KEYS.some((k) => domains[k].fullWildcards > 0),
37
59
  anyInsecure: CSP_KEYS.some((k) => domains[k].insecure > 0),
38
60
  sinks: CSP_KEYS.flatMap((k) => domains[k].sinks),
61
+ tenantSinks: CSP_KEYS.flatMap((k) => domains[k].tenantSinks),
39
62
  permissions,
40
63
  domain: typeof ui?.domain === 'string' ? ui.domain : null,
41
64
  prefersBorder: ui?.prefersBorder === true,
42
65
  };
43
66
  }
44
67
 
68
+ /**
69
+ * @param {unknown} value
70
+ * @returns {unknown}
71
+ */
45
72
  function canonical(value) {
46
73
  if (Array.isArray(value)) return value.map(canonical);
47
74
  if (value && typeof value === 'object') {
48
- return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
75
+ const object = /** @type {Record<string, unknown>} */ (value);
76
+ return Object.fromEntries(Object.keys(object).sort().map((k) => [k, canonical(object[k])]));
49
77
  }
50
78
  return value;
51
79
  }
52
80
 
53
- /** Does `resources/read` declare a different policy than `resources/list`? The host that checked list is then wrong. */
81
+ /**
82
+ * Does `resources/read` declare a different policy than `resources/list`? The host that checked list is then wrong.
83
+ * @param {unknown} listMeta `_meta` of the listing entry
84
+ * @param {unknown} readMeta `_meta` of the read result's content item
85
+ */
54
86
  export function compareListRead(listMeta, readMeta) {
55
87
  const a = uiMetaOf(listMeta);
56
88
  const b = uiMetaOf(readMeta);
57
89
  if (!a && !b) return { comparable: false, mismatch: false };
90
+ /** @param {UiMeta | null} m */
58
91
  const pick = (m) => (m ? { csp: m.csp ?? null, permissions: m.permissions ?? null, domain: m.domain ?? null } : null);
59
92
  const left = JSON.stringify(canonical(pick(a)));
60
93
  const right = JSON.stringify(canonical(pick(b)));
61
94
  const readWider = (() => {
62
95
  if (!a?.csp || !b?.csp) return false;
63
- return CSP_KEYS.some((k) => (b.csp[k]?.length ?? 0) > (a.csp[k]?.length ?? 0) || (b.csp[k] ?? []).some((d) => String(d).includes('*') && !(a.csp[k] ?? []).includes(d)));
96
+ return CSP_KEYS.some((k) => (b.csp[k]?.length ?? 0) > (a.csp[k]?.length ?? 0) || (b.csp[k] ?? []).some((/** @type {unknown} */ d) => String(d).includes('*') && !(a.csp[k] ?? []).includes(d)));
64
97
  })();
65
98
  return { comparable: true, mismatch: left !== right, readWider, listOnly: !!a && !b, readOnly: !a && !!b };
66
99
  }
67
100
 
101
+ /**
102
+ * Counts over the tools of a tools/list result: with a UI, visible to the app, app-only, side-effecting and app-visible.
103
+ * @param {unknown} tools
104
+ */
68
105
  export function analyzeTools(tools) {
69
106
  const list = Array.isArray(tools) ? tools : [];
70
107
  let withUi = 0;
@@ -85,17 +122,25 @@ export function analyzeTools(tools) {
85
122
  return { total: list.length, withUi, visibleToApp, appOnly, sideEffectsVisibleToApp };
86
123
  }
87
124
 
125
+ /** Escaping helpers whose result the census treats as text: by name, not by implementation. */
126
+ export const ESCAPE_FUNCTIONS = ['esc', 'escapeHtml', 'escapeHTML', 'htmlEscape', 'escapeAttr', 'escapeHtmlAttr', 'DOMPurify.sanitize'];
127
+
128
+ /** @type {((source: string) => Linter.LintMessage[]) | null} */
88
129
  let linter = null;
130
+ // The rule runs twice over each script: strict, and with ESCAPE_FUNCTIONS. A sink only the strict
131
+ // run reports gets every dynamic part through an escaping helper.
132
+ /** @param {string} code */
89
133
  function lintInlineScript(code) {
90
134
  if (!linter) {
91
135
  const { Linter } = require('eslint');
92
136
  const plugin = require('eslint-plugin-render-policy').default;
93
137
  const instance = new Linter({ configType: 'flat' });
138
+ /** @type {Linter.Config[]} */
94
139
  const config = [
95
140
  {
96
141
  files: ['**/*.js'],
97
- plugins: { 'render-policy': plugin },
98
- rules: { 'render-policy/no-unsafe-innerhtml': 'error' },
142
+ plugins: { 'render-policy': plugin, escaped: plugin },
143
+ rules: { 'render-policy/no-unsafe-innerhtml': 'error', 'escaped/no-unsafe-innerhtml': ['error', { escapeFunctions: ESCAPE_FUNCTIONS }] },
99
144
  languageOptions: { ecmaVersion: 2024, sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } } },
100
145
  },
101
146
  ];
@@ -105,35 +150,101 @@ function lintInlineScript(code) {
105
150
  }
106
151
 
107
152
  const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
153
+ const POST_MESSAGE_RE = /([\w$.]*)\s*\.\s*postMessage\s*\(/g;
154
+ const PROTOCOL_TARGET_RE = /(^|\.)(parent|top)$/;
155
+ // MCP Apps JSON-RPC, and the message types of the mcp-ui protocol.
156
+ const PROTOCOL_MESSAGE_RE = /\bjsonrpc\s*:|\bmethod\s*:\s*['"`]ui\/|\btype\s*:\s*['"`](ui-[a-z-]+|prompt|link|tool|intent|notify)['"`]/;
157
+
158
+ /**
159
+ * The argument text of a call whose opening parenthesis ends at `start`, or null if unbalanced.
160
+ * @param {string} code
161
+ * @param {number} start
162
+ */
163
+ function callArguments(code, start) {
164
+ let depth = 1;
165
+ let quote = null;
166
+ for (let i = start; i < code.length && i < start + 20000; i += 1) {
167
+ const c = code[i];
168
+ if (quote) {
169
+ if (c === '\\') i += 1;
170
+ else if (c === quote) quote = null;
171
+ } else if (c === '"' || c === "'" || c === '`') quote = c;
172
+ else if (c === '(' || c === '{' || c === '[') depth += 1;
173
+ else if (c === ')' || c === '}' || c === ']') {
174
+ depth -= 1;
175
+ if (depth === 0) return code.slice(start, i);
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * A `postMessage(…, '*')` call: 1-based position, and whether it carries the app protocol to the host.
183
+ * @typedef {{ line: number, column: number, protocol: boolean }} PostMessageCall
184
+ */
185
+ /**
186
+ * A dynamic HTML sink the lint rule reported, positioned in the document.
187
+ * @typedef {object} HtmlSink
188
+ * @property {number} line
189
+ * @property {number} column
190
+ * @property {boolean} handwritten the script it is in is hand-written, not a bundle
191
+ * @property {boolean} escaped every dynamic part of the value goes through an escaping helper
192
+ * @property {string} message
193
+ */
194
+
195
+ /**
196
+ * postMessage calls whose target origin is '*', with position and whether they carry the app protocol to the host.
197
+ * @param {string} code
198
+ * @returns {PostMessageCall[]}
199
+ */
200
+ export function postMessageStarCalls(code) {
201
+ /** @type {PostMessageCall[]} */
202
+ const calls = [];
203
+ for (const match of code.matchAll(POST_MESSAGE_RE)) {
204
+ const args = callArguments(code, match.index + match[0].length);
205
+ if (args === null || !/,\s*['"`]\*['"`]\s*(,[^,]*)?,?\s*$/.test(args)) continue;
206
+ const before = code.slice(0, match.index);
207
+ const line = before.split('\n').length;
208
+ const column = match.index - before.lastIndexOf('\n');
209
+ calls.push({ line, column, protocol: PROTOCOL_TARGET_RE.test(match[1] ?? '') && PROTOCOL_MESSAGE_RE.test(args) });
210
+ }
211
+ return calls;
212
+ }
108
213
  const URL_ATTR_RE = /\b(?:src|href|action|poster|data)\s*=\s*["']([^"']+)["']/gi;
109
214
 
110
- /** Static signals in a UI resource's HTML. Heuristic by design; the report says so. */
215
+ /**
216
+ * Static signals in a UI resource's HTML. Heuristic by design; the report says so.
217
+ * @param {unknown} html
218
+ */
111
219
  export function analyzeHtml(html) {
112
220
  const text = String(html ?? '');
113
221
  const result = {
114
222
  bytes: text.length,
115
223
  inlineScripts: 0,
116
224
  handwrittenScripts: 0,
117
- externalScripts: [],
225
+ externalScripts: /** @type {string[]} */ ([]),
118
226
  unsafeInnerHtml: 0,
119
227
  unsafeInnerHtmlHandwritten: 0,
120
228
  evalLikeHandwritten: 0,
121
229
  postMessageStarHandwritten: 0,
230
+ postMessageStarHandwrittenNonProtocol: 0,
231
+ unsafeInnerHtmlHandwrittenUnescaped: 0,
232
+ postMessages: /** @type {Array<PostMessageCall & { handwritten: boolean }>} */ ([]),
122
233
  lintErrors: 0,
123
234
  unparsedScripts: 0,
124
235
  inlineHandlers: (text.match(/\son[a-z]+\s*=/gi) ?? []).length,
125
- sinks: [],
236
+ sinks: /** @type {HtmlSink[]} */ ([]),
126
237
  evalLike: (text.match(/\beval\s*\(|new\s+Function\s*\(/g) ?? []).length,
127
238
  postMessageStar: (text.match(/postMessage\s*\([^)]*['"]\*['"]/g) ?? []).length,
128
239
  forms: (text.match(/<form\b/gi) ?? []).length,
129
240
  formsWithAction: (text.match(/<form\b[^>]*\baction\s*=/gi) ?? []).length,
130
241
  metaCsp: /<meta\b[^>]*http-equiv\s*=\s*["']content-security-policy["']/i.test(text),
131
- externalHosts: [],
132
- sinkHosts: [],
242
+ externalHosts: /** @type {string[]} */ ([]),
243
+ sinkHosts: /** @type {string[]} */ ([]),
133
244
  };
134
245
  const hosts = new Set();
135
246
  for (const match of text.matchAll(URL_ATTR_RE)) {
136
- const value = match[1];
247
+ const value = match[1] ?? '';
137
248
  if (!/^(https?:)?\/\//i.test(value)) continue;
138
249
  try {
139
250
  hosts.add(new URL(value.startsWith('//') ? `https:${value}` : value).hostname.toLowerCase());
@@ -161,6 +272,11 @@ export function analyzeHtml(html) {
161
272
  result.evalLikeHandwritten += (body.match(/\beval\s*\(|new\s+Function\s*\(/g) ?? []).length;
162
273
  result.postMessageStarHandwritten += (body.match(/postMessage\s*\([^)]*['"]\*['"]/g) ?? []).length;
163
274
  }
275
+ const scriptLine = text.slice(0, match.index).split('\n').length + (match[0].slice(0, match[0].indexOf('>') + 1).split('\n').length - 1);
276
+ for (const call of postMessageStarCalls(body)) {
277
+ result.postMessages.push({ ...call, line: scriptLine + call.line - 1, handwritten });
278
+ if (handwritten && !call.protocol) result.postMessageStarHandwrittenNonProtocol += 1;
279
+ }
164
280
  try {
165
281
  const messages = lintInlineScript(body);
166
282
  const fatal = messages.filter((m) => m.fatal);
@@ -169,10 +285,14 @@ export function analyzeHtml(html) {
169
285
  continue;
170
286
  }
171
287
  const hits = messages.filter((m) => m.ruleId === 'render-policy/no-unsafe-innerhtml');
288
+ const unescaped = new Set(messages.filter((m) => m.ruleId === 'escaped/no-unsafe-innerhtml').map((m) => `${m.line}:${m.column}`));
172
289
  result.unsafeInnerHtml += hits.length;
173
- if (handwritten) result.unsafeInnerHtmlHandwritten += hits.length;
174
- const scriptLine = text.slice(0, match.index).split('\n').length + (match[0].slice(0, match[0].indexOf('>') + 1).split('\n').length - 1);
175
- for (const hit of hits) result.sinks.push({ line: scriptLine + hit.line - 1, column: hit.column, handwritten, message: hit.message });
290
+ for (const hit of hits) {
291
+ const escaped = !unescaped.has(`${hit.line}:${hit.column}`);
292
+ if (handwritten) result.unsafeInnerHtmlHandwritten += 1;
293
+ if (handwritten && !escaped) result.unsafeInnerHtmlHandwrittenUnescaped += 1;
294
+ result.sinks.push({ line: scriptLine + hit.line - 1, column: hit.column, handwritten, escaped, message: hit.message });
295
+ }
176
296
  } catch {
177
297
  result.unparsedScripts += 1;
178
298
  }
@@ -180,7 +300,10 @@ export function analyzeHtml(html) {
180
300
  return result;
181
301
  }
182
302
 
183
- /** Short lines and not too many of them: written by a person, not emitted by a bundler. */
303
+ /**
304
+ * Short lines and not too many of them: written by a person, not emitted by a bundler.
305
+ * @param {string} code
306
+ */
184
307
  export function isHandwritten(code) {
185
308
  const lines = code.split('\n');
186
309
  const longest = Math.max(0, ...lines.map((l) => l.length));
@@ -188,8 +311,14 @@ export function isHandwritten(code) {
188
311
  return longest < 2000 && average < 200 && lines.length < 3000;
189
312
  }
190
313
 
191
- /** Everything the HTML analysis reports about hosts, joined with the sink denylist. */
314
+ /** @typedef {ReturnType<typeof analyzeHtml>} HtmlAnalysis */
315
+
316
+ /**
317
+ * Everything the HTML analysis reports about hosts, joined with the sink denylist.
318
+ * @template {{ externalHosts: string[] }} T
319
+ * @param {T} analysis
320
+ * @returns {T & { sinkHosts: string[] }}
321
+ */
192
322
  export function withSinkHosts(analysis) {
193
- const { sinkFor } = require('./domains.mjs');
194
323
  return { ...analysis, sinkHosts: analysis.externalHosts.filter((h) => sinkFor(h) !== null) };
195
324
  }
package/src/cli.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * mcp-app-lint --html app.html # one UI document
9
9
  * Options: --out file.sarif, --format sarif|text (default sarif), --fail-on error|warning|note|none (default error)
10
10
  */
11
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
11
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { UI_MIME } from './analyze.mjs';
@@ -16,13 +16,38 @@ import { lintHtml, lintPackageScan, lintResource, lintTools, levelOf } from './l
16
16
  import { packageMeta, scanPackage, scanPackageDir } from './npm.mjs';
17
17
  import { toSarif, toText } from './sarif.mjs';
18
18
 
19
+ /**
20
+ * @import { Writable } from 'node:stream'
21
+ * @import { Finding } from './lint.mjs'
22
+ */
23
+ /**
24
+ * @typedef {object} CliOptions
25
+ * @property {string} format
26
+ * @property {string} failOn
27
+ * @property {string} [dir]
28
+ * @property {string} [package]
29
+ * @property {string} [read]
30
+ * @property {string} [list]
31
+ * @property {string} [tools]
32
+ * @property {string} [html]
33
+ * @property {string} [out]
34
+ * @property {boolean} [help]
35
+ */
36
+
37
+ /** @type {Record<string, number>} */
19
38
  const LEVEL_RANK = { error: 3, warning: 2, note: 1, none: 0 };
20
39
 
40
+ /** @param {readonly string[]} argv */
21
41
  function parseArgs(argv) {
42
+ /** @type {CliOptions} */
22
43
  const options = { format: 'sarif', failOn: 'error' };
23
44
  for (let i = 0; i < argv.length; i += 1) {
24
45
  const arg = argv[i];
25
- const next = () => argv[++i];
46
+ const next = () => {
47
+ const value = argv[++i];
48
+ if (value === undefined) throw new Error(`${arg} needs a value`);
49
+ return value;
50
+ };
26
51
  if (arg === '--dir') options.dir = next();
27
52
  else if (arg === '--package') options.package = next();
28
53
  else if (arg === '--read') options.read = next();
@@ -38,22 +63,35 @@ function parseArgs(argv) {
38
63
  return options;
39
64
  }
40
65
 
66
+ /** @type {(file: string) => Promise<any>} */
41
67
  const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'));
68
+ /** @type {(r: any) => boolean} */
42
69
  const isUiResource = (r) => (r?.mimeType ?? '').startsWith('text/html') || String(r?.uri ?? '').startsWith('ui://');
43
70
 
44
- /** A filesystem path under the working directory becomes a forward-slash repo-relative uri; anything else is unchanged. */
71
+ /**
72
+ * A filesystem path under the working directory becomes a forward-slash repo-relative uri; anything else is unchanged.
73
+ * @param {string} uri
74
+ */
45
75
  function relativeUri(uri) {
46
76
  if (typeof uri !== 'string' || !path.isAbsolute(uri)) return uri;
47
77
  const rel = path.relative(process.cwd(), uri);
48
78
  return rel && !rel.startsWith('..') ? rel.split(path.sep).join('/') : uri;
49
79
  }
50
80
 
81
+ /**
82
+ * The command line, without the process exit: resolves to the exit code (0 clean, 1 findings at or
83
+ * above --fail-on, 2 usage error). Throws on unreadable input or a failed download.
84
+ * @param {readonly string[]} argv arguments after the program name
85
+ * @param {{ stdout?: Pick<Writable, 'write'>, stderr?: Pick<Writable, 'write'> }} [io]
86
+ * @returns {Promise<number>}
87
+ */
51
88
  export async function run(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
52
89
  const options = parseArgs(argv);
53
90
  if (options.help || (!options.dir && !options.package && !options.read && !options.html && !options.tools)) {
54
91
  stderr.write('usage: mcp-app-lint (--dir <path> | --package <name> | --read <read.json> [--list <list.json>] | --html <file>) [--tools <tools.json>] [--out file] [--format sarif|text] [--fail-on error|warning|note|none]\n');
55
92
  return 2;
56
93
  }
94
+ /** @type {Finding[]} */
57
95
  const findings = [];
58
96
 
59
97
  if (options.dir) {
@@ -63,8 +101,7 @@ export async function run(argv, { stdout = process.stdout, stderr = process.stde
63
101
  if (options.package) {
64
102
  const meta = await packageMeta(options.package);
65
103
  if (!meta.tarball) throw new Error(`${options.package}: no tarball`);
66
- const work = path.join(os.tmpdir(), 'mcp-app-lint');
67
- await mkdir(work, { recursive: true });
104
+ const work = await mkdtemp(path.join(os.tmpdir(), 'mcp-app-lint-'));
68
105
  try {
69
106
  const scan = await scanPackage(meta, work);
70
107
  findings.push(...lintPackageScan(scan, `npm:${meta.name}@${meta.version}`));
@@ -79,7 +116,7 @@ export async function run(argv, { stdout = process.stdout, stderr = process.stde
79
116
  const listEntries = Array.isArray(listing?.resources) ? listing.resources : Array.isArray(listing) ? listing : listing ? [listing] : [];
80
117
  for (const item of contents) {
81
118
  if (!isUiResource(item) && item.mimeType !== UI_MIME) continue;
82
- const entry = listEntries.find((r) => r.uri === item.uri) ?? null;
119
+ const entry = listEntries.find((/** @type {any} */ r) => r.uri === item.uri) ?? null;
83
120
  findings.push(...lintResource({ uri: item.uri ?? options.read, listMeta: entry?._meta ?? null, readMeta: item._meta ?? null, html: typeof item.text === 'string' ? item.text : null }));
84
121
  }
85
122
  }