mcp-app-lint 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -54,7 +54,7 @@ npx mcp-app-lint --dir . --format sarif --out mcp-app-lint.sarif --fail-on error
54
54
 
55
55
  | Id | Name | Level | What it means |
56
56
  | --- | --- | --- | --- |
57
- | MCPAPP001 | csp-missing | warning | no `ui.csp`; a compliant host applies `default-src 'none'`, `connect-src 'none'`. For a package: it serves a UI resource and no domain list is found, literal or built at runtime |
57
+ | MCPAPP001 | csp-missing | warning | no `ui.csp`, but the UI loads from or calls an external host (an attribute such as `src`/`href`, or an absolute URL in `fetch`, `WebSocket`, `EventSource`, XHR, `sendBeacon`, `import()`); a compliant host applies `default-src 'none'`, `connect-src 'none'` and blocks it. A self-contained UI needs no CSP and is not reported. For a package: it serves a UI resource, no domain list is found (literal or built at runtime), and one of its HTML documents references a host |
58
58
  | MCPAPP002 | csp-allows-every-host | error | `*` or a scheme-only entry (`https:`) expands to every host |
59
59
  | MCPAPP003 | csp-wildcard-host | warning | `*.example.com` allows every subdomain |
60
60
  | MCPAPP004 | csp-insecure-scheme | warning | `http:` origin other than loopback |
@@ -82,7 +82,9 @@ In a package scan, domain lists are read where they are written: as a literal ar
82
82
  constant or shorthand defined in the same file (`resourceDomains: DOMAINS`, `{ connectDomains }`).
83
83
  Each CSP finding points at that file and line. A list built at runtime counts as declared, so it
84
84
  does not raise MCPAPP001, but its entries cannot be checked. Packages that only mention the MCP
85
- Apps MIME type or `ui://` (hosts, renderers, SDKs) are not treated as serving a UI resource.
85
+ Apps MIME type or `ui://` (hosts, renderers, SDKs) are not treated as serving a UI resource. Hosts are
86
+ looked for in the package's HTML documents only: a `fetch` in server code is not bound by the CSP,
87
+ so it does not count.
86
88
 
87
89
  The rules come out of the [MCP Apps UI census](../../census) in this repository; the analyzer is
88
90
  shared. The HTML sink detection is `eslint-plugin-render-policy`'s `no-unsafe-innerhtml`. The sink
@@ -130,6 +130,7 @@ export declare function analyzeHtml(html: unknown): {
130
130
  formsWithAction: number;
131
131
  metaCsp: boolean;
132
132
  externalHosts: string[];
133
+ networkHosts: string[];
133
134
  sinkHosts: string[];
134
135
  };
135
136
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"analyze.d.mts","sourceRoot":"","sources":["../src/analyze.mjs"],"names":[],"mappings":"AAgBG,YAA+B,MAAM,GAA3B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAQ;AAEpC,YAAoF,MAAM,GAAhF,gBAAgB,GAAG,iBAAiB,GAAG,cAAc,GAAG,gBAAgB,CAAQ;AAK9F,eAAO,MAAM,OAAO,8BAA8B,CAAC;AAEnD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,OAGkB,GAFhB,MAAM,GAAG,IAAI,CAWzB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,OAEuB;;;;;;;;;;;;;;;;;;;;;;EAoBjC;AAeD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAH7B,OAG6B,EAAE,QAAQ,EAFvC,OAEuC;;;;;;;;;;;;EAajD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAFvB,OAEuB;;;;;;EAkBjC;AAED,+FAA+F;AAC/F,eAAO,MAAM,gBAAgB,UAA0G,CAAC;AAyDrI,YAA+D,eAAe,GAApE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAiB;AAI9E,YAAkB,QAAQ,GAC1B;IAAmB,IAAI,EAAZ,MAAM,CACjB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAoB,WAAW,EAApB,OAAO,CAClB;;;;IAAoB,OAAO,EAAhB,OAAO,CAClB;IAAmB,OAAO,EAAf,MAAM,CACnB;CAAA,CAAA;AAZD;;;GAGG;AACH;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAH9B,MAG8B,GAF5B,eAAe,EAAE,CAc7B;AAGD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAFrB,OAEqB;;;;qBAMA,MAAM,EAAE;;;;;;;kBAOX,KAAK,CAAC,eAAe,GAAG;QAAE,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC;;;;WAIxD,QAAQ,EAAE;;;;;;mBAMF,MAAM,EAAE;eACZ,MAAM,EAAE;EA0DjC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,MAEuB,WAKjC;AAEG,YAA0C,YAAY,GAA5C,UAAU,CAAC,OAAO,WAAW,CAAC,CAAc;AAA1D,6DAA6D;AAE7D;;;;;GAKG;AACH,wBAAgB,aAAa,CAJc,CAAC,SAA9B;IAAE,aAAa,EAAE,MAAM,EAAE,CAAA;CAAK,EAId,QAAQ,EAH3B,CAG2B,GAFzB,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAIvC"}
1
+ {"version":3,"file":"analyze.d.mts","sourceRoot":"","sources":["../src/analyze.mjs"],"names":[],"mappings":"AAgBG,YAA+B,MAAM,GAA3B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAQ;AAEpC,YAAoF,MAAM,GAAhF,gBAAgB,GAAG,iBAAiB,GAAG,cAAc,GAAG,gBAAgB,CAAQ;AAK9F,eAAO,MAAM,OAAO,8BAA8B,CAAC;AAEnD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,OAGkB,GAFhB,MAAM,GAAG,IAAI,CAWzB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,OAEuB;;;;;;;;;;;;;;;;;;;;;;EAoBjC;AAeD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAH7B,OAG6B,EAAE,QAAQ,EAFvC,OAEuC;;;;;;;;;;;;EAajD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAFvB,OAEuB;;;;;;EAkBjC;AAED,+FAA+F;AAC/F,eAAO,MAAM,gBAAgB,UAA0G,CAAC;AAyDrI,YAA+D,eAAe,GAApE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAiB;AAI9E,YAAkB,QAAQ,GAC1B;IAAmB,IAAI,EAAZ,MAAM,CACjB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAoB,WAAW,EAApB,OAAO,CAClB;;;;IAAoB,OAAO,EAAhB,OAAO,CAClB;IAAmB,OAAO,EAAf,MAAM,CACnB;CAAA,CAAA;AAZD;;;GAGG;AACH;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAH9B,MAG8B,GAF5B,eAAe,EAAE,CAc7B;AAqBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAFrB,OAEqB;;;;qBAMA,MAAM,EAAE;;;;;;;kBAOX,KAAK,CAAC,eAAe,GAAG;QAAE,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC;;;;WAIxD,QAAQ,EAAE;;;;;;mBAMF,MAAM,EAAE;kBAET,MAAM,EAAE;eACX,MAAM,EAAE;EAiDjC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,MAEuB,WAKjC;AAEG,YAA0C,YAAY,GAA5C,UAAU,CAAC,OAAO,WAAW,CAAC,CAAc;AAA1D,6DAA6D;AAE7D;;;;;GAKG;AACH,wBAAgB,aAAa,CAJc,CAAC,SAA9B;IAAE,aAAa,EAAE,MAAM,EAAE,CAAA;CAAK,EAId,QAAQ,EAH3B,CAG2B,GAFzB,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAIvC"}
package/dist/lint.d.mts CHANGED
@@ -31,10 +31,13 @@ export type ResourceInput = {
31
31
  };
32
32
  /**
33
33
  * Findings about one `_meta` object of a UI resource (the resolved one: read, falling back to list).
34
+ * `hosts` are the hosts the UI references (see lintResource); without a CSP the host blocks them,
35
+ * and that is MCPAPP001. A UI that references none needs no CSP: the restrictive default fits it.
34
36
  * @param {unknown} meta
35
37
  * @param {string} uri
38
+ * @param {readonly string[]} [hosts]
36
39
  */
37
- export declare function lintUiMeta(meta: unknown, uri: string): Finding[];
40
+ export declare function lintUiMeta(meta: unknown, uri: string, hosts?: readonly string[]): Finding[];
38
41
  /**
39
42
  * Findings about a UI resource as served: listing entry, read result content item, HTML.
40
43
  * @param {ResourceInput} resource
@@ -1 +1 @@
1
- {"version":3,"file":"lint.d.mts","sourceRoot":"","sources":["../src/lint.mjs"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAe,YAAY,EAA2D,MAAM,eAAe,CAAC;AAMhH,OAAQ,KAAA,EAAE,WAAW,EAAE,MAAM,WAAW,CACxC;AAAA,OAAQ,KAAA,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAMpC,YAAkB,OAAO,GACzB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAmB,KAAK,AAAxB,CACA,EADW,KAAK,CAChB;IAAmB,OAAO,EAAf,MAAM,CACjB;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAyD,MAAM,AAA/D,CACA,EADW;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CACtD;IAA4C,OAAO,AAAnD,CACA,EADW;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CACzC;IAAqC,UAAU,AAA/C,CAAgD,EAArC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAc;CAAA,CAAA;AAE/C,YAAuD,YAAY,GAAzD,IAAI,CAAC,OAAO,EAAE,QAAQ,GAAG,SAAS,GAAG,KAAK,CAAC,CAAc;AAIpE,YAAkB,aAAa,GAC/B;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAA2B,IAAI,AAA/B,CAAgC,EAArB,MAAM,GAAG,IAAI,CAAQ;CAAA,CAAA;AAsCnC;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAHpB,OAGoB,EAAE,GAAG,EAFzB,MAEyB,aAgBnC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,QAAe,EAAE,QAAe,EAAE,IAAW,EAAE,EAFxE,aAEwE,aAYlF;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,MAGkB,EAAE,GAAG,EAFvB,MAEuB,aAEjC;AAiCD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAHpB,OAGoB,EAAE,GAAG,AAFjC,CAAqB,EAAb,MAEwC,aAelD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAHzB,WAGyB,EAAE,IAAI,AAFvC,CAAsB,EAAd,MAEqC,aAqB/C;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAHnB,MAGmB,GAFjB,KAAK,CAIjB;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"lint.d.mts","sourceRoot":"","sources":["../src/lint.mjs"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAe,YAAY,EAA2D,MAAM,eAAe,CAAC;AAMhH,OAAQ,KAAA,EAAE,WAAW,EAAE,MAAM,WAAW,CACxC;AAAA,OAAQ,KAAA,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAMpC,YAAkB,OAAO,GACzB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAmB,KAAK,AAAxB,CACA,EADW,KAAK,CAChB;IAAmB,OAAO,EAAf,MAAM,CACjB;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAyD,MAAM,AAA/D,CACA,EADW;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CACtD;IAA4C,OAAO,AAAnD,CACA,EADW;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CACzC;IAAqC,UAAU,AAA/C,CAAgD,EAArC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAc;CAAA,CAAA;AAE/C,YAAuD,YAAY,GAAzD,IAAI,CAAC,OAAO,EAAE,QAAQ,GAAG,SAAS,GAAG,KAAK,CAAC,CAAc;AAIpE,YAAkB,aAAa,GAC/B;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAA2B,IAAI,AAA/B,CAAgC,EAArB,MAAM,GAAG,IAAI,CAAQ;CAAA,CAAA;AAgDnC;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAJpB,OAIoB,EAAE,GAAG,EAHzB,MAGyB,EAAE,KAAK,AAFxC,CAAkC,EAA1B,SAAS,MAAM,EAEsB,aAgB/C;AASD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,QAAe,EAAE,QAAe,EAAE,IAAW,EAAE,EAFxE,aAEwE,aAalF;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,MAGkB,EAAE,GAAG,EAFvB,MAEuB,aAEjC;AAiCD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAHpB,OAGoB,EAAE,GAAG,AAFjC,CAAqB,EAAb,MAEwC,aAelD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAHzB,WAGyB,EAAE,IAAI,AAFvC,CAAsB,EAAd,MAEqC,aAsB/C;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAHnB,MAGmB,GAFjB,KAAK,CAIjB;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-app-lint",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
package/src/analyze.mjs CHANGED
@@ -211,6 +211,24 @@ export function postMessageStarCalls(code) {
211
211
  return calls;
212
212
  }
213
213
  const URL_ATTR_RE = /\b(?:src|href|action|poster|data)\s*=\s*["']([^"']+)["']/gi;
214
+ // A network call with an absolute URL literal: fetch, sendBeacon, import(), WebSocket, EventSource, XHR open.
215
+ const NETWORK_CALL_RE = /(?:\b(?:fetch|sendBeacon|import)\s*\(|\bnew\s+(?:WebSocket|EventSource)\s*\(|\.open\s*\(\s*["'`][A-Za-z]+["'`]\s*,)\s*["'`]((?:https?|wss?):\/\/[^"'`\s]+)/g;
216
+
217
+ /**
218
+ * Hostnames of URLs, skipping the ones that do not parse.
219
+ * @param {Iterable<string>} urls
220
+ */
221
+ function hostnames(urls) {
222
+ const hosts = new Set();
223
+ for (const url of urls) {
224
+ try {
225
+ hosts.add(new URL(url.startsWith('//') ? `https:${url}` : url).hostname.toLowerCase());
226
+ } catch {
227
+ /* ignore */
228
+ }
229
+ }
230
+ return [...hosts].sort();
231
+ }
214
232
 
215
233
  /**
216
234
  * Static signals in a UI resource's HTML. Heuristic by design; the report says so.
@@ -240,19 +258,12 @@ export function analyzeHtml(html) {
240
258
  formsWithAction: (text.match(/<form\b[^>]*\baction\s*=/gi) ?? []).length,
241
259
  metaCsp: /<meta\b[^>]*http-equiv\s*=\s*["']content-security-policy["']/i.test(text),
242
260
  externalHosts: /** @type {string[]} */ ([]),
261
+ // Hosts that script calls with an absolute URL (fetch, WebSocket, …), bundles included.
262
+ networkHosts: /** @type {string[]} */ ([]),
243
263
  sinkHosts: /** @type {string[]} */ ([]),
244
264
  };
245
- const hosts = new Set();
246
- for (const match of text.matchAll(URL_ATTR_RE)) {
247
- const value = match[1] ?? '';
248
- if (!/^(https?:)?\/\//i.test(value)) continue;
249
- try {
250
- hosts.add(new URL(value.startsWith('//') ? `https:${value}` : value).hostname.toLowerCase());
251
- } catch {
252
- /* ignore */
253
- }
254
- }
255
- result.externalHosts = [...hosts].sort();
265
+ result.externalHosts = hostnames([...text.matchAll(URL_ATTR_RE)].map((m) => m[1] ?? '').filter((v) => /^(https?:)?\/\//i.test(v)));
266
+ result.networkHosts = hostnames([...text.matchAll(NETWORK_CALL_RE)].map((m) => m[1] ?? ''));
256
267
  for (const match of text.matchAll(SCRIPT_RE)) {
257
268
  const attrs = match[1] ?? '';
258
269
  const body = match[2] ?? '';
package/src/lint.mjs CHANGED
@@ -69,18 +69,31 @@ function cspEntryFindings(key, raw, uri, extra) {
69
69
  return findings;
70
70
  }
71
71
 
72
+ /**
73
+ * The hosts a UI document loads from or calls, sorted and without duplicates.
74
+ * @param {Iterable<{ externalHosts?: string[], networkHosts?: string[] }>} docs
75
+ */
76
+ function referencedHosts(docs) {
77
+ const hosts = new Set();
78
+ for (const doc of docs) for (const host of [...(doc.externalHosts ?? []), ...(doc.networkHosts ?? [])]) hosts.add(host);
79
+ return [...hosts].sort();
80
+ }
81
+
72
82
  /**
73
83
  * Findings about one `_meta` object of a UI resource (the resolved one: read, falling back to list).
84
+ * `hosts` are the hosts the UI references (see lintResource); without a CSP the host blocks them,
85
+ * and that is MCPAPP001. A UI that references none needs no CSP: the restrictive default fits it.
74
86
  * @param {unknown} meta
75
87
  * @param {string} uri
88
+ * @param {readonly string[]} [hosts]
76
89
  */
77
- export function lintUiMeta(meta, uri) {
90
+ export function lintUiMeta(meta, uri, hosts = []) {
78
91
  /** @type {Finding[]} */
79
92
  const findings = [];
80
93
  const ui = uiMetaOf(meta);
81
94
  const analysis = analyzeUiMeta(meta);
82
95
  if (!analysis.cspDeclared) {
83
- findings.push(finding('MCPAPP001', 'No ui.csp declared; a compliant host applies default-src \'none\'.', uri));
96
+ if (hosts.length > 0) findings.push(cspMissing(hosts, uri));
84
97
  } else {
85
98
  for (const key of CSP_KEYS) {
86
99
  for (const raw of ui?.csp?.[key] ?? []) findings.push(...cspEntryFindings(key, raw, uri, { logical: { name: `csp.${key}`, kind: 'member' } }));
@@ -92,6 +105,13 @@ export function lintUiMeta(meta, uri) {
92
105
  return findings;
93
106
  }
94
107
 
108
+ /**
109
+ * @param {readonly string[]} hosts
110
+ * @param {string} uri
111
+ */
112
+ const cspMissing = (hosts, uri) =>
113
+ finding('MCPAPP001', `No ui.csp declared, but the UI references ${hosts.join(', ')}; a compliant host applies default-src 'none' and blocks them.`, uri, { properties: { hosts: [...hosts] } });
114
+
95
115
  /**
96
116
  * Findings about a UI resource as served: listing entry, read result content item, HTML.
97
117
  * @param {ResourceInput} resource
@@ -100,13 +120,14 @@ export function lintResource({ uri, listMeta = null, readMeta = null, html = nul
100
120
  /** @type {Finding[]} */
101
121
  const findings = [];
102
122
  const effective = readMeta ?? listMeta;
103
- findings.push(...lintUiMeta(effective, uri));
123
+ const analysis = typeof html === 'string' ? withSinkHosts(analyzeHtml(html)) : null;
124
+ findings.push(...lintUiMeta(effective, uri, analysis ? referencedHosts([analysis]) : []));
104
125
  if (listMeta && readMeta) {
105
126
  const compared = compareListRead(listMeta, readMeta);
106
127
  if (compared.readWider) findings.push(finding('MCPAPP018', 'resources/read declares a wider policy than resources/list.', uri));
107
128
  else if (compared.mismatch) findings.push(finding('MCPAPP017', 'resources/read and resources/list declare different policies.', uri));
108
129
  }
109
- if (typeof html === 'string') findings.push(...lintHtml(html, uri));
130
+ if (analysis) findings.push(...htmlFindings(analysis, uri));
110
131
  return findings;
111
132
  }
112
133
 
@@ -187,11 +208,12 @@ export function lintPackageScan(scan, root = '.') {
187
208
  if (!scan.cspDeclared) delete fake.ui.csp;
188
209
  if (Array.isArray(scan.domainSites)) {
189
210
  // Each entry where it is written, so code scanning can point at the line.
190
- if (!scan.cspDeclared && servesUi) findings.push(...lintUiMeta({ ui: {} }, root));
211
+ const hosts = referencedHosts(scan.html ?? []);
212
+ if (!scan.cspDeclared && servesUi && hosts.length > 0) findings.push(cspMissing(hosts, root));
191
213
  for (const site of scan.domainSites) findings.push(...cspEntryFindings(site.key, site.raw, `${root}/${site.file}`.replace(/^\.\//, ''), { region: { startLine: site.line } }));
192
214
  findings.push(...lintUiMeta({ ui: { csp: {}, permissions: fake.ui.permissions } }, root).filter((f) => f.ruleId === 'MCPAPP007'));
193
215
  } else {
194
- findings.push(...lintUiMeta(fake, root));
216
+ findings.push(...lintUiMeta(fake, root, servesUi ? referencedHosts(scan.html ?? []) : []));
195
217
  }
196
218
  }
197
219
  for (const doc of scan.html ?? []) {
package/src/rules.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * @type {readonly Rule[]}
10
10
  */
11
11
  export const RULES = [
12
- { id: 'MCPAPP001', name: 'csp-missing', level: 'warning', short: 'UI resource declares no CSP domain list', full: "A compliant host applies default-src 'none' and connect-src 'none' to a resource without ui.csp. Declare the domains the app uses, or confirm it needs none. For a package, reported when it serves a UI resource (registers one, or sets the MCP Apps MIME type or a ui:// URI on a resource) and no domain list is found, literal or built at runtime." },
12
+ { id: 'MCPAPP001', name: 'csp-missing', level: 'warning', short: 'UI references external hosts but declares no CSP domain list', full: "A compliant host applies default-src 'none' and connect-src 'none' to a resource without ui.csp, so the hosts the UI loads from or calls are blocked. Reported when the HTML references an external host (src, href and similar attributes) or calls one with an absolute URL (fetch, WebSocket, EventSource, XMLHttpRequest, sendBeacon, import()); a UI that references none needs no CSP. For a package, reported when it serves a UI resource (registers one, or sets the MCP Apps MIME type or a ui:// URI on a resource), no domain list is found, literal or built at runtime, and one of its HTML documents references a host." },
13
13
  { id: 'MCPAPP002', name: 'csp-allows-every-host', level: 'error', short: 'CSP entry allows every host', full: "A bare '*' or a scheme-only entry such as 'https:' is expanded by hosts into a CSP source that allows every host, including request catchers and tunnels. Name the hosts." },
14
14
  { id: 'MCPAPP003', name: 'csp-wildcard-host', level: 'warning', short: 'CSP entry allows every subdomain', full: 'A *.example.com entry allows every subdomain, including user-controlled ones on multi-tenant services. Prefer exact hosts.' },
15
15
  { id: 'MCPAPP004', name: 'csp-insecure-scheme', level: 'warning', short: 'CSP entry uses http:', full: 'An http: origin can be read and modified in transit. Use https:. Loopback origins are reported as development origins (MCPAPP006) instead.' },
package/src/sarif.mjs CHANGED
@@ -68,6 +68,6 @@ export function toSarif(findings, { invocation = {} } = {}) {
68
68
  export function toText(findings) {
69
69
  if (findings.length === 0) return 'no findings\n';
70
70
  return `${findings
71
- .map((f) => `${(f.level ?? levelOf(f.ruleId)).padEnd(7)} ${f.ruleId} ${f.uri}${f.region ? `:${f.region.startLine}:${f.region.startColumn}` : ''} ${f.message}`)
71
+ .map((f) => `${(f.level ?? levelOf(f.ruleId)).padEnd(7)} ${f.ruleId} ${f.uri}${f.region ? `:${f.region.startLine}${f.region.startColumn ? `:${f.region.startColumn}` : ''}` : ''} ${f.message}`)
72
72
  .join('\n')}\n`;
73
73
  }