Security Guide
MCP server opacity security — invisible disclosures, near-zero opacity attacks, CSS transition fade-out, and ancestor opacity inheritance
CSS opacity controls an element's transparency from 0 (fully invisible) to 1 (fully opaque), but unlike display:none or visibility:hidden, setting opacity:0 does not remove the element from layout, does not prevent it from receiving pointer events, does not hide it from screen readers, and does not affect any DOM property that standard consent-dialog checkers typically inspect. An element with opacity:0 is invisible to sighted users but passes every naive DOM-based check: offsetHeight is positive, getBoundingClientRect() returns non-zero dimensions, textContent returns the full disclosure text, getComputedStyle(el).display returns "block", and getComputedStyle(el).visibility returns "visible". This makes opacity one of the most effective CSS properties for hiding MCP server consent disclosures from sighted users while remaining undetected by automated audit tools that only check element presence and layout metrics. Four distinct attack vectors arise: complete transparency via opacity:0; near-zero opacity values that evade threshold-based checks; CSS transition-based delayed fade-out that removes the disclosure after the dialog is shown; and opacity:0 set on an ancestor element, causing multiplicative opacity inheritance that renders the disclosure invisible while the element's own opacity property returns 1.
How CSS opacity works — and why it is a dangerous attack surface
The CSS opacity property specifies the transparency level of an element and its entire subtree. A value of 1 means fully opaque (default), 0 means fully transparent (invisible), and values between the two produce partial transparency. Unlike visibility:hidden, which removes an element from visual rendering while preserving layout space, opacity:0 is even more permissive: the element is in flow, paints (at zero alpha), receives pointer events, is reachable by screen readers, and is queryable by all DOM APIs.
Four properties of opacity are directly exploitable for hiding MCP consent disclosures:
- Opacity does not affect DOM layout metrics.
offsetWidth,offsetHeight,getBoundingClientRect(),clientWidth, andclientHeightall return their normal values for a fully transparent element. An element withopacity:0and dimensions of 400px × 200px reports exactly those dimensions. Any audit that uses layout dimensions to verify the disclosure is present will pass. - Opacity does not affect
displayorvisibilitycomputed values.getComputedStyle(el).displayreturns"block"(or whatever the element's display type is).getComputedStyle(el).visibilityreturns"visible". These two properties are the most commonly checked visibility signals in automated tools — and neither reflects opacity at all. - Opacity is not inherited in the normal CSS sense — it multiplies. CSS opacity is applied at the compositing layer. When an ancestor has
opacity:0.5and a descendant hasopacity:0.8, the descendant renders at0.5 * 0.8 = 0.4effective opacity. Critically,getComputedStyle(descendant).opacityreturns'0.8'— the element's own value — not the effective rendered opacity. Checking only the disclosure element itself misses opacity set on ancestors. - Opacity transitions are smooth and can be timed precisely. CSS
transition: opacity Xs easeallows a disclosure to be shown briefly (e.g. for 300ms) and then smoothly faded to invisible. The initial render satisfies any static-analysis check at dialog-open time. The fade-out happens after the dialog is shown, with timing that can be calibrated to complete before the user has processed the disclosure text.
These properties combine to make opacity-based hiding attacks some of the hardest to detect with naive DOM inspection. See also CSS visibility:hidden attacks and CSS z-index occlusion attacks for related hiding techniques that affect different DOM properties.
Attack 1 — opacity:0 — renders disclosure completely invisible while preserving all DOM properties (HIGH)
The most direct opacity attack sets opacity:0 on the consent disclosure element. The element is in the document, in flow, with its natural dimensions, fully accessible to DOM APIs — and completely invisible to sighted users. Because opacity:0 does not affect display, visibility, offsetHeight, or layout bounding rects, this attack bypasses the most common consent-dialog detection patterns.
The attack is typically applied as an inline style to avoid stylesheet fingerprinting, or as a class that blends in with existing utility classes in the component library:
/* Applied to the consent disclosure element */
.mcp-disclosure {
opacity: 0;
/* Element remains fully in layout */
/* getBoundingClientRect() returns non-zero rect */
/* textContent returns the full disclosure text */
/* getComputedStyle(el).display === 'block' */
/* getComputedStyle(el).visibility === 'visible' */
}
/* Inline style variant — bypasses stylesheet analysis */
/* <div class="mcp-disclosure" style="opacity:0"> */
/* This tool will read your calendar and contacts... */
/* </div> */
The DOM check results for this attack illustrate precisely why naive audits fail:
const el = document.querySelector('.mcp-disclosure');
// All of these return values that suggest the element IS visible:
el.offsetHeight; // 120 (non-zero — passes)
el.getBoundingClientRect().width; // 480 (non-zero — passes)
el.textContent.trim().length; // 247 (non-empty — passes)
getComputedStyle(el).display; // "block" (passes)
getComputedStyle(el).visibility; // "visible" (passes)
// Only this reveals the attack:
getComputedStyle(el).opacity; // "0" (FAIL — element is invisible)
Detection gap: The vast majority of DOM-based consent-disclosure checkers inspect display, visibility, offsetHeight, or getBoundingClientRect() to determine whether a disclosure element is visible. All of these checks return values consistent with a fully visible element when opacity:0 is applied. The element is present, in layout, has dimensions, contains text — and is completely invisible. The only correct check is parseFloat(getComputedStyle(el).opacity) === 0, which must be performed as part of a comprehensive visibility audit. A check limited to display and visibility will give a false-positive "visible" result for every opacity:0 disclosure.
A correct audit must include opacity in its computed-style checks. Furthermore, the check must be applied to the element as rendered by the browser's cascade — not just to inline styles or class lists. An opacity:0 rule may arrive via a stylesheet rule with high specificity, a CSS custom property that resolves to 0, or a class applied programmatically after initial render. The check must read the computed value from getComputedStyle(), not from el.style.opacity (which only reflects inline styles) or from class inspection (which requires resolving the full CSS cascade).
// Correct opacity check for a disclosure element
function isOpacityVisible(el) {
const opacity = parseFloat(getComputedStyle(el).opacity);
if (opacity === 0) return false; // Fully transparent — invisible
if (opacity < 0.3) return false; // Near-zero — effectively unreadable
return true;
}
// This alone is insufficient — must also check ancestors (see Attack 4)
Attack 2 — opacity:0.01 — near-zero opacity passes many automated threshold checks (MEDIUM-HIGH)
A subtler variant sets the disclosure's opacity to a value near zero but not exactly zero. Values like 0.01, 0.02, or 0.05 render the disclosure text as an extremely faint ghost that is effectively unreadable to human eyes — at 1% to 5% opacity, text characters are barely distinguishable from the background. Yet many automated audit tools check for opacity > 0 as their threshold for visibility, which these values pass.
/* Near-zero opacity — text is rendered at 1% visibility */
.mcp-disclosure {
opacity: 0.01;
/* Human perception: text is invisible — indistinguishable from background */
/* Naive audit check: opacity > 0 — PASSES */
}
/* Slightly higher but still unreadable when combined with light background */
.mcp-disclosure-alt {
opacity: 0.05;
background: #f9fafb; /* very light grey */
color: #111827; /* dark text */
/* Effective contrast at 5% opacity: text color blends into near-white bg */
/* WCAG contrast ratio effectively ~1.02:1 — completely unreadable */
}
/* 15% opacity with off-white background — still fails WCAG AA */
.mcp-disclosure-subtle {
opacity: 0.15;
background: #ffffff;
color: #1f2937;
/* Renders as very light grey on white — passes opacity > 0.1 checks */
/* Actual WCAG contrast: well below 4.5:1 requirement */
}
The challenge for detection is selecting the right threshold. A threshold that is too low (e.g. opacity > 0) passes unreadable disclosures. A threshold that is too high (e.g. opacity >= 1) would fail many legitimate semi-transparent disclosure designs. The correct threshold must account for both the opacity value and the actual rendered contrast of the text against its background.
// Threshold-based opacity check — common but incomplete implementations
// Too permissive — passes near-zero opacity:
if (parseFloat(getComputedStyle(el).opacity) > 0) { /* visible? */ }
// Better threshold — rejects near-zero but misses background-combination attacks:
if (parseFloat(getComputedStyle(el).opacity) >= 0.3) { /* visible? */ }
// Correct approach — threshold plus contrast estimation:
function isEffectivelyVisible(el) {
const opacity = parseFloat(getComputedStyle(el).opacity);
// Reject clearly invisible
if (opacity < 0.3) return false;
// For values between 0.3 and 0.9, check effective contrast
// The perceived contrast of text is approximately:
// effective_contrast = nominal_contrast * opacity
// A WCAG AA contrast of 4.5:1 at opacity 0.3 yields ~1.35:1 effective — unreadable
if (opacity < 0.9) {
const style = getComputedStyle(el);
const nominalContrast = computeContrastRatio(style.color, style.backgroundColor);
const effectiveContrast = nominalContrast * opacity;
if (effectiveContrast < 3.0) return false; // Below readable threshold
}
return true;
}
Threshold selection matters: Audits that check for opacity !== 0 or opacity > 0 will pass disclosures with opacity:0.01 — text rendered at 1% visibility. The recommended minimum threshold is opacity >= 0.3 for any single-value check, with additional contrast analysis for values between 0.3 and 0.9. Also check all ancestor elements — a disclosure with opacity:0.5 on itself that sits inside a parent with opacity:0.4 renders at only 0.2 effective opacity. The effective opacity is the product of all opacity values on the element and its ancestors up to the document root.
A particularly effective variant combines near-zero opacity with a matching background color. Setting opacity:0.15 on a dark-coloured disclosure inside a near-white container makes the text essentially invisible: the dark colour at 15% opacity blends almost entirely into the light background. The result passes a naive opacity > 0.1 check while remaining visually undetectable. The only robust check is effective contrast: measuring the actual WCAG contrast ratio between the rendered foreground and background colours after applying the computed opacity.
Attack 3 — opacity + CSS transition — delayed fade-out after dialog opens (HIGH)
This attack exploits the browser's CSS transition system to hide the disclosure after the consent dialog is shown. The dialog opens with the disclosure fully visible (opacity:1), satisfying any static audit check at dialog-open time. A CSS transition is then triggered — either by a class change or direct style modification — that fades the disclosure to opacity:0 over a period calibrated to complete before the user has read the disclosure text. The transition may include an initial delay (transition-delay) to ensure the dialog has fully opened and the static audit snapshot has been taken before the fade begins.
/* Disclosure is visible when dialog first opens — static checks pass */
.mcp-disclosure {
opacity: 1;
/* CSS transition will be activated after dialog is shown */
transition: opacity 0.4s ease 0.6s;
/* transition-delay: 0.6s means fade starts 600ms after transition is triggered */
/* opacity 0.4s: fade completes 1 second after trigger */
}
/* After dialog open event fires (600ms delay + 400ms transition = 1000ms total) */
.mcp-disclosure.fade-out {
opacity: 0;
}
// JavaScript triggers the fade-out after dialog is shown
function showConsentDialog() {
const dialog = document.querySelector('.mcp-consent-dialog');
const disclosure = dialog.querySelector('.mcp-disclosure');
dialog.style.display = 'block';
// 50ms delay to allow static audit snapshot of the initial state
// Then trigger the CSS transition — fade starts after 600ms delay, completes at 1000ms
setTimeout(() => {
disclosure.classList.add('fade-out');
}, 50);
// By 1050ms after dialog open, disclosure is fully transparent
// Most users take 3-8 seconds to read the full disclosure — they never see it
}
A requestAnimationFrame-based variant achieves the same result without a CSS transition, using pure JavaScript opacity manipulation that begins on the very next paint after the dialog opens:
// rAF-based variant — no CSS transition declaration needed
// Starts fading on the next animation frame after dialog open
function showConsentDialogRAF() {
const dialog = document.querySelector('.mcp-consent-dialog');
const disclosure = dialog.querySelector('.mcp-disclosure');
dialog.style.display = 'block';
disclosure.style.opacity = '1';
let startTime = null;
const FADE_DURATION = 800; // 800ms total fade
const FADE_DELAY = 400; // 400ms before fade starts
function fadeStep(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
if (elapsed < FADE_DELAY) {
// Still in delay period — opacity stays at 1
requestAnimationFrame(fadeStep);
return;
}
const fadeElapsed = elapsed - FADE_DELAY;
const progress = Math.min(fadeElapsed / FADE_DURATION, 1);
disclosure.style.opacity = String(1 - progress);
if (progress < 1) {
requestAnimationFrame(fadeStep);
}
// Fade complete: disclosure.style.opacity === '0' at ~1200ms after dialog open
}
requestAnimationFrame(fadeStep);
}
Detection gap: Static analysis at dialog-open time reads opacity:1 — the disclosure appears fully visible. Any audit that takes a single snapshot of the computed styles immediately after the dialog opens will see a passing state. The attack only manifests dynamically, after a delay calibrated to occur after the static check window. Detecting this attack requires: (1) registering a MutationObserver on the disclosure element to watch for style mutations after dialog open; (2) re-checking the computed opacity value at 500ms, 1000ms, and 2000ms after the dialog becomes visible; (3) detecting any CSS transition declarations on the disclosure element that include opacity in the transition property list; and (4) inspecting for requestAnimationFrame callbacks that modify the disclosure's opacity inline style.
The timing calibration of this attack is particularly pernicious. A disclosure that is visible for 300ms before fading satisfies a human's peripheral impression that the dialog is "showing" information, but provides nowhere near enough time to read a meaningful consent disclosure. Combined with the dialog's Accept button remaining visible and interactive throughout the fade, users are primed to click without having read the now-invisible disclosure text.
// Correct detection for transition-based opacity attacks
function detectOpacityTransition(el) {
const style = getComputedStyle(el);
// Check for opacity in transition property list
const transition = style.transition || style.webkitTransition || '';
if (transition.includes('opacity') || transition === 'all') {
// opacity transition exists — schedule re-checks
const checks = [100, 300, 500, 750, 1000, 1500, 2000];
checks.forEach(delay => {
setTimeout(() => {
const currentOpacity = parseFloat(getComputedStyle(el).opacity);
if (currentOpacity < 0.3) {
reportViolation('opacity-transition-fade', el, currentOpacity, delay);
}
}, delay);
});
}
// Also watch for rAF-based manipulation via MutationObserver
const observer = new MutationObserver(() => {
const currentOpacity = parseFloat(getComputedStyle(el).opacity);
if (currentOpacity < 0.3) {
reportViolation('opacity-raf-fade', el, currentOpacity, performance.now());
observer.disconnect();
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'] });
}
Attack 4 — opacity:0 on ancestor element — inherited opacity multiplies down the subtree (HIGH)
The most sophisticated opacity attack does not target the disclosure element itself. Instead, it sets opacity:0 on an ancestor — a grandparent or higher — of the disclosure element. CSS opacity creates a new compositing context and applies to the entire rendered output of the element, including all descendants. When an ancestor has opacity:0, the entire subtree is rendered at zero alpha, regardless of each descendant's own opacity property value.
The critical evasion property: getComputedStyle(disclosureEl).opacity returns the disclosure element's own opacity value — not the effective rendered opacity. If the disclosure has no opacity set (default 1) and a grandparent has opacity:0, the disclosure's computed style reports '1' while the element is completely invisible.
/* Ancestor has opacity:0 — entire subtree becomes invisible */
.mcp-consent-wrapper { /* grandparent of .mcp-disclosure */
opacity: 0;
}
.mcp-consent-body { /* parent of .mcp-disclosure */
/* no opacity set — inherits compositing from grandparent */
}
.mcp-disclosure {
opacity: 1; /* own property: fully opaque */
/* BUT: effective rendered opacity = 0 * 1 = 0 */
/* getComputedStyle(.mcp-disclosure).opacity === '1' — CHECK PASSES */
/* Disclosure is invisible to sighted users */
}
// What a naive check sees:
const disclosure = document.querySelector('.mcp-disclosure');
getComputedStyle(disclosure).opacity; // '1' — PASSES (incorrect)
disclosure.offsetHeight; // 200 — PASSES
disclosure.getBoundingClientRect(); // { width: 480, height: 200, ... } — PASSES
disclosure.textContent; // full disclosure text — PASSES
// The attack is invisible to any check limited to the disclosure element itself
// What a correct ancestor-walk check sees:
function getEffectiveOpacity(el) {
let opacity = 1;
let node = el;
while (node && node !== document.documentElement) {
const computed = parseFloat(getComputedStyle(node).opacity);
opacity *= computed;
if (opacity === 0) break; // Early exit — already invisible
node = node.parentElement;
}
return opacity;
}
getEffectiveOpacity(disclosure); // 0 — FAIL (ancestor has opacity:0)
Detection gap: Any audit that calls getComputedStyle(disclosureEl).opacity and checks only the disclosure element's own opacity value will completely miss ancestor-based opacity attacks. The disclosure element genuinely has opacity:1 as its own property. The rendering system computes effective opacity by multiplying down the ancestor chain, but this effective value is not exposed through any single DOM API call on the disclosure element. Correct detection requires walking the DOM ancestor chain from the disclosure element to the document root, reading each ancestor's computed opacity, and computing the running product. An effective opacity below 0.3 at any point in the chain indicates a hidden disclosure.
The multiplicative nature of opacity inheritance creates a second attack class: partial ancestor opacity that pushes the effective rendered opacity below the readable threshold even when no single element has opacity:0. A disclosure with opacity:0.5 on itself, inside a parent with opacity:0.4, renders at 0.2 effective opacity — unreadable — while the disclosure's own computed opacity is 0.5 and the parent's is 0.4, both of which individually might pass a naive check threshold of opacity > 0.3.
/* Multi-level partial opacity — each level individually passes naive checks */
.mcp-dialog-outer {
opacity: 0.5; /* own value: 0.5 — passes threshold > 0.3 */
}
.mcp-dialog-inner {
opacity: 0.6; /* own value: 0.6 — passes threshold > 0.3 */
}
.mcp-disclosure {
opacity: 0.7; /* own value: 0.7 — passes threshold > 0.3 */
/* effective rendered opacity: 0.5 * 0.6 * 0.7 = 0.21 */
/* text at 21% opacity — completely unreadable */
}
/* getComputedStyle on each element individually: all pass */
/* effective opacity product: FAIL */
// Correct full ancestor-chain opacity audit
function auditEffectiveOpacity(el) {
const violations = [];
let effectiveOpacity = 1;
let node = el;
while (node && node.nodeType === Node.ELEMENT_NODE) {
const ownOpacity = parseFloat(getComputedStyle(node).opacity);
effectiveOpacity *= ownOpacity;
if (ownOpacity < 1 && node !== el) {
violations.push({
element: node,
ownOpacity,
effectiveOpacityAtThisLevel: effectiveOpacity,
tagName: node.tagName,
id: node.id,
className: node.className,
});
}
node = node.parentElement;
}
return {
effectiveOpacity,
isVisible: effectiveOpacity >= 0.3,
ancestorViolations: violations,
};
}
// Usage:
const result = auditEffectiveOpacity(document.querySelector('.mcp-disclosure'));
if (!result.isVisible) {
console.error(
`Disclosure has effective opacity ${result.effectiveOpacity.toFixed(3)} — invisible.`,
result.ancestorViolations
);
}
Detection check matrix
The table below shows which CSS opacity attacks each common detection check fails to catch. A check that "passes the attack" means the check returns a result indicating the disclosure is visible, even though it is not.
| CSS Check | Passes opacity:0 attack? | Passes opacity:0.01 attack? | Passes transition fade-out? | Passes ancestor opacity:0? | Notes |
|---|---|---|---|---|---|
getComputedStyle(el).display !== 'none' |
YES — misses | YES — misses | YES — misses | YES — misses | display:block regardless of opacity on element or ancestors |
getComputedStyle(el).visibility !== 'hidden' |
YES — misses | YES — misses | YES — misses | YES — misses | visibility:visible regardless of opacity |
el.offsetHeight > 0 |
YES — misses | YES — misses | YES — misses | YES — misses | opacity:0 does not collapse height |
el.getBoundingClientRect().width > 0 |
YES — misses | YES — misses | YES — misses | YES — misses | bounding rect unaffected by opacity |
el.textContent.trim().length > 0 |
YES — misses | YES — misses | YES — misses (pre-fade) | YES — misses | textContent is unaffected by opacity; text present even at opacity:0 |
getComputedStyle(el).opacity !== '0' |
DETECTS | YES — misses (0.01 !== '0') | PARTIAL (only after fade) | YES — misses (own opacity is 1) | Detects exact zero but misses near-zero and ancestor attacks |
parseFloat(getComputedStyle(el).opacity) >= 0.3 |
DETECTS | DETECTS (0.01 < 0.3) | PARTIAL (only after fade) | YES — misses (own opacity is 1) | Good threshold for element-level check; still misses ancestor chain |
| Ancestor-chain opacity product < 0.3 | DETECTS | DETECTS | PARTIAL (only after fade) | DETECTS | Correct static check; still requires dynamic re-check for transition attacks |
| Dynamic re-check at 500ms + 1000ms after dialog open | DETECTS | DETECTS | DETECTS | DETECTS | Full coverage when combined with ancestor-chain opacity product check |
SkillAudit detection logic
SkillAudit's MCP consent-dialog audit applies a layered opacity detection strategy that covers all four attack vectors. The following controls are applied to every identified consent disclosure element:
- Immediate computed opacity check at dialog open. At the moment the consent dialog becomes visible (detected via
MutationObserveron the dialog element), SkillAudit readsgetComputedStyle(el).opacityon the disclosure element. Any value below0.3is flagged as a HIGH severity finding. - Full ancestor-chain opacity product computation. SkillAudit walks the DOM parent chain from the disclosure element to
document.documentElement, reads each ancestor's computedopacity, and computes the running product. An effective opacity product below0.3is flagged regardless of the disclosure element's own opacity value. This catchesopacity:0on grandparents and multi-level partial-opacity multiplication attacks. - CSS transition property inspection. SkillAudit checks the disclosure element's computed
transitionandanimationproperties for any reference toopacityorall. If found, a dynamic monitoring phase is activated: the computed opacity is re-read at 300ms, 600ms, 900ms, 1200ms, and 2000ms after dialog open. Any reading below0.3during this window is flagged as a HIGH severity finding with timing information. - MutationObserver on disclosure style attribute. A
MutationObserveris attached to the disclosure element watching for changes to thestyleattribute (inline style mutations) andclassattribute (class-based transitions). Any mutation that results in a computed opacity below0.3within 5 seconds of dialog open is flagged. - requestAnimationFrame callback inspection. SkillAudit's audit runtime overrides
window.requestAnimationFrameat dialog-open time to intercept rAF callbacks. Any callback that modifies theopacitystyle property of a consent disclosure element is logged and the resulting opacity value is checked against the0.3threshold. - Effective contrast estimation for near-zero opacity. For opacity values between
0.3and0.9, SkillAudit computes the effective WCAG contrast ratio by multiplying the nominal foreground/background contrast by the computed opacity. Effective contrast below3.0:1is flagged as a MEDIUM severity finding even when the nominal contrast at full opacity would meet WCAG AA requirements.
For a complete description of SkillAudit's CSS visibility audit methodology, see the SkillAudit blog and the related guides on CSS visibility:hidden attacks and CSS z-index occlusion attacks.
SkillAudit findings
opacity:0 applied via inline style. Element is fully in layout: offsetHeight: 160px, getBoundingClientRect().width: 480px, textContent contains 312 characters of disclosure text. All standard DOM checks pass. Disclosure is completely invisible to sighted users. getComputedStyle(el).opacity === '0' confirmed as the only detecting check.
.mcp-consent-wrapper (grandparent of disclosure) has opacity:0 set via stylesheet rule with specificity 0-1-0. Disclosure element's own computed opacity is '1'. Effective opacity product via ancestor-chain walk: 0.0. All checks on the disclosure element itself pass. Attack only detected by walking parent chain: getComputedStyle(.mcp-consent-wrapper).opacity === '0'.
transition: opacity 0.4s ease 0.5s. Static check at dialog open: opacity:1 — passes. Dynamic re-check at 1000ms post-open: opacity:0 — FAIL. Disclosure is visible for ~500ms after dialog open, then fades to invisible. Accept button remains interactive throughout. MutationObserver confirms .fade-out class addition at T+52ms triggering the transition.
opacity:0.02 applied via class .text-ghost. Naive check opacity > 0 passes (value is 0.02, which is greater than 0). Threshold check at >= 0.3 correctly rejects. Text at 2% opacity on white background is visually indistinguishable from background. Finding severity is MEDIUM because the value is non-zero (some auditors may consider the element "present"), though text is functionally unreadable.
.dialog-container: opacity:0.5; .dialog-body: opacity:0.6; .mcp-disclosure: opacity:0.7. Each value individually passes a >= 0.3 threshold check. Effective opacity product: 0.5 * 0.6 * 0.7 = 0.21 — below 0.3 threshold. Text rendered at 21% opacity is effectively unreadable. Detected only by ancestor-chain product computation.
Related security guides: CSS opacity attacks are one of several CSS-based techniques for hiding MCP server consent disclosures. See also CSS visibility:hidden attacks — which unlike opacity, does collapse pointer events and affects getComputedStyle().visibility but shares the characteristic of preserving layout space — and CSS z-index stacking attacks, which use element stacking order rather than transparency to occlude the disclosure. For a broader overview of MCP consent-dialog security, browse the SkillAudit blog.
Audit your MCP server with SkillAudit. SkillAudit's automated consent-dialog audit checks for all four opacity attack vectors — including ancestor-chain opacity product computation and dynamic transition monitoring — as part of a comprehensive CSS visibility audit. Every identified MCP server consent dialog is tested across the full attack surface: opacity, visibility, display, z-index, transform, clip-path, and layout-based hiding techniques. View pricing and start an audit.