UNPKG

safari-mcp

Version:

Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.

4,910 lines • 247 kB
#!/usr/bin/env node
// Safari MCP Server — dual engine:
// 1. Safari Web Extension (fast, ~5-20ms, keeps logins) — when extension is connected
// 2. AppleScript + Swift daemon (~5ms, keeps logins) — always available
//
// Extension transport: HTTP polling (Safari — WebSocket blocked by Apple)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { startTransport } from "./transport.js";
import { currentSessionId } from "./session-context.js";
import { z } from "zod";
import * as safari from "./safari.js";
import { textResult, jsonResult, imageResult, errorResult, evalResult } from "./response.js";
import {
  OWNERSHIP_DIR, BLANK_TAB_SENTINEL,
  _openedTabs, _ownedTabURLs,
  _isURLOwned, _markBlankTabOpened, _addOwnedURL, _removeOwnedURL, _trackTab, _untrackTab,
  _sessionTabs, _trackedAtIndex,
  allowUserTabs,
} from "./ownership-state.js";
import { WebSocketServer } from "ws";
import { createServer } from "node:http";
import { randomUUID, randomBytes, timingSafeEqual, createHmac, createHash } from "node:crypto";
import { execFile, execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { jpegSize } from "./image-size.js";
import { diffSnapshots, lastSnapshots as _lastSnapshots } from "./snapshot-diff.js";
import { createSafariSessionHintReader } from "./safari-session-hint.js";
import { createStaleWorkerPolicy, leaseBuildVerdict, LEASE_BUILD_PREFERENCE_MS, HOME_CONTEXT_ALIVE_MS } from "./worker-upgrade.js";
import {
  createLedger as createWorkerLedger,
  noteConnect as ledgerNoteConnect,
  noteVerdict as ledgerNoteVerdict,
  noteVerified as ledgerNoteVerified,
  summarize as summarizeWorkerLedger,
  readProfileIdentities,
  describeProfileIdentities,
} from "./worker-ledger.js";
import { Socket } from "node:net";
import { ignoreTypeOfServiceEinval } from "./node-compat.js";

// Before the first fetch: a secondary's /proxy-check to a primary that just went away
// must reject, not end the process (#140).
ignoreTypeOfServiceEinval(Socket.prototype);

const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS

// Security: local shared-secret for /proxy-command — prevents an unrelated local process
// (e.g. a malicious npm postinstall) from driving the browser through this bridge.
// Read/created once with mode 600; all instances share it.
const PROXY_TOKEN_FILE = join(homedir(), ".safari-mcp-proxy-token");
function _getProxyToken() {
  try { return readFileSync(PROXY_TOKEN_FILE, "utf8").trim(); }
  catch {
    const tok = randomBytes(32).toString("hex");
    try { writeFileSync(PROXY_TOKEN_FILE, tok, { mode: 0o600 }); }
    catch (err) { console.error(`[Safari MCP] Could not persist proxy token: ${err.message} — secondary instances will get 403 on /proxy-command until the file is writable.`); }
    return tok;
  }
}
const PROXY_TOKEN = _getProxyToken();

// The Safari extension bridge uses a separate per-install secret. The signed
// extension bundle receives the same value as a private resource at build time;
// it never travels in a URL or page state. Keeping this separate from the proxy
// token prevents a compromised extension transport from authenticating as a
// secondary MCP process.
const BRIDGE_TOKEN_FILE = join(homedir(), ".safari-mcp-bridge-token");
function _getBridgeToken() {
  try {
    const existing = readFileSync(BRIDGE_TOKEN_FILE, "utf8").trim();
    if (/^[0-9a-f]{64}$/.test(existing)) return existing;
  } catch {}

  const tok = randomBytes(32).toString("hex");
  const temporaryFile = `${BRIDGE_TOKEN_FILE}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
  try {
    writeFileSync(temporaryFile, tok, { mode: 0o600, flag: "wx" });
    renameSync(temporaryFile, BRIDGE_TOKEN_FILE);
    return tok;
  } catch (err) {
    try { unlinkSync(temporaryFile); } catch {}
    // Another starting process may have repaired the file first. Accept only a
    // complete valid value; otherwise refuse startup instead of authenticating
    // an empty or partially written token.
    try {
      const repaired = readFileSync(BRIDGE_TOKEN_FILE, "utf8").trim();
      if (/^[0-9a-f]{64}$/.test(repaired)) return repaired;
    } catch {}
    throw new Error(`Could not create a valid Safari MCP bridge token: ${err.message}`);
  }
}
const BRIDGE_TOKEN = _getBridgeToken();
const CONTENT_WAKE_LABEL = "safari-mcp-content-wakeup-v1";
const CONTENT_WAKE_TOKEN = createHmac("sha256", BRIDGE_TOKEN)
  .update(CONTENT_WAKE_LABEL)
  .digest("hex");

// Security: compare the local shared-secret in constant time. A plain `!==` returns as
// soon as two bytes differ, so the reply latency leaks how many leading bytes matched —
// a byte-at-a-time oracle that recovers the whole token over enough local requests.
// A non-string header (absent, or sent twice so Node yields an array) fails closed.
function _tokenMatches(given) {
  if (typeof given !== "string") return false;
  const a = Buffer.from(given, "utf8");
  const b = Buffer.from(PROXY_TOKEN, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

function _bridgeTokenMatches(given) {
  if (typeof given !== "string" || !/^[0-9a-f]{64}$/.test(given) || !/^[0-9a-f]{64}$/.test(BRIDGE_TOKEN)) return false;
  const a = Buffer.from(given, "utf8");
  const b = Buffer.from(BRIDGE_TOKEN, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

function _contentWakeTokenMatches(given) {
  if (typeof given !== "string" || !/^[0-9a-f]{64}$/.test(given) || !/^[0-9a-f]{64}$/.test(CONTENT_WAKE_TOKEN)) return false;
  const a = Buffer.from(given, "utf8");
  const b = Buffer.from(CONTENT_WAKE_TOKEN, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

// ========== MULTI-INSTANCE: concurrent instances coexist (never kill siblings) ==========
// This block previously SIGTERM'd every other safari-mcp instance running >10s to
// clear "stale" processes from previous sessions. That broke multi-session use:
// each new Claude Code session's instance killed every older session's instance,
// disconnecting them mid-task. Concurrent instances are fully supported by design —
// the first to bind HTTP_PORT becomes the extension host, the rest proxy commands
// through it (see PROXY MODE below). Instances from closed sessions are SIGTERM'd
// by their own MCP client on shutdown, so no cross-instance cleanup is needed here.

// ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
// In HTTP-daemon mode currentSessionId() already isolates each MCP client (see
// session-context.js), so the random half adds no isolation there — it only means a
// daemon restart (launchd KeepAlive fires on every SIGTERM) invalidates every active
// receipt and every tab-ownership record mid-task. Persist it so restarts are
// survivable. Stdio mode keeps the per-process random id: there currentSessionId()
// collapses to "_default", so SESSION_ID is the only thing separating processes.
const SESSION_ID = _daemonSessionId();

function _daemonSessionId() {
  if (!process.env.SAFARI_MCP_HTTP) return randomUUID().slice(0, 8);
  const file = join(OWNERSHIP_DIR, "daemon-session-id");
  try {
    const existing = readFileSync(file, "utf8").trim();
    if (/^[0-9a-f]{8}$/.test(existing)) return existing;
  } catch { /* first run, or unreadable — fall through and mint one */ }
  const minted = randomUUID().slice(0, 8);
  try {
    if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
    writeFileSync(file, minted, { mode: 0o600 });
  } catch { /* unwritable state dir: fall back to per-process id */ }
  return minted;
}

// Persistent tab-ownership state + its helpers now live in ownership-state.js
// (OWNERSHIP_* consts, _ownedTabURLs/_ownedTabTimestamps, _loadOwnershipFile,
// _saveOwnershipFile, _isURLOwned, _addOwnedURL, _trackTab, … — imported at the top).
// OWNERSHIP_DIR is re-used below for the memory-monitor lock file.

// ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
// Daemon log lines carry a wall-clock prefix so focus-restore traces and reaper kills can be
// matched to the operation that was running (stdio mode stays byte-identical).
if (process.env.SAFARI_MCP_HTTP && process.env.SAFARI_MCP_HTTP !== "0") {
  const _rawErr = console.error.bind(console);
  console.error = (...args) => _rawErr(new Date().toISOString().slice(11, 19), ...args);
}
const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000", 10);

// Tab-ownership state (_openedTabs / _ownedTabURLs / _ownedTabTimestamps), the on-disk
// persistence + TTL, and the helpers (_isURLOwned, _markBlankTabOpened, _addOwnedURL,
// _removeOwnedURL, _trackTab, _untrackTab, BLANK_TAB_SENTINEL) are imported from
// ownership-state.js at the top of this file.

// Close one tab this process opened, found by the identity recorded when it was opened:
// the receipt the extension minted for it, or the AppleScript marker. Never by position.
// The tab cap used to close the index recorded at opening, and the close also carried the
// session's CURRENT receipt, so the extension either refused it or closed whatever tab
// sat at that position by then — while the caller was told its oldest tab was gone.
// Returns "closed"; "lost" when the tab is gone or can no longer be shown to be ours (the
// extension forgets a receipt when its tab closes; an unprovable tab is left open, as #112
// leaves it); or "failed" when the close itself failed and the tab may still be open.
async function _closeTrackedTab(info) {
  const receipt = _receiptToken(info?.receipt);
  if (receipt) {
    if (!_extensionConnected || (_preferAppleScript && !_profileExtensionVerified)) return "failed";
    const send = (type, token) =>
      sendToExtension(type, { receipt: token, sessionId: info.sessionId }, _commandTimeouts.close_tab);
    try {
      await send("close_tab", receipt);
      return "closed";
    } catch (err) {
      const msg = String(err?.message || err);
      if (msg.includes("not valid for this origin")) {
        // The tab left the origin its receipt was minted on (a click through, a redirect)
        // and is still this session's tab: rotate the receipt, as a caller would, and close.
        try {
          const fresh = _receiptToken((await send("get_tab_receipt", receipt))?.receipt);
          if (fresh) {
            await send("close_tab", fresh);
            return "closed";
          }
        } catch {}
      }
      return msg.includes("Tab safety:") ? "lost" : "failed";
    }
  }
  // Opened through AppleScript: prove it by its marker, in the script that closes it. Never by
  // URL: the tab navigates after we open it, and the user can have a tab of their own on the URL
  // we recorded, so a URL match closed THEIR tab (#112). Nor by an index an earlier script found:
  // a close in between renumbered the window, and a stray MCP tab left open costs nothing next
  // to a closed user tab. A named profile never closes a tab through AppleScript.
  // A tab blanked instead of closed (its window's last) is open still, but no marker names it any
  // more: it counts as lost, not as closed.
  if (process.env.SAFARI_PROFILE) return "lost";
  try {
    return (await safari.closeTabByMarker(info?.marker)) === "closed" ? "closed" : "lost";
  } catch {
    return "failed";
  }
}

// Per-session tab cap: before a new tab opens, close this session's oldest tab when it
// already has MAX_TABS. Scoped to the caller — in HTTP daemon mode _openedTabs is
// process-wide, and a global cap closed whichever tab was oldest overall, routinely another
// session's live page. A tab that is already gone, or can no longer be proven ours, is
// only dropped from the count. Returns the tab it closed, or null. At most one per call.
async function _evictOldestTab(sessionId) {
  const mine = _sessionTabs(sessionId);
  let open = mine.length;
  for (const [key, info] of mine) {
    if (open < MAX_TABS) break;
    const outcome = await _oneCloseAtATime(() => _closeTrackedTab(info));
    // It may still be open: keep counting it and retry it at the next new tab. Close the next
    // only through the extension, which fails fast: an AppleScript close that failed, a page that
    // never answers the marker check, would cost every tracked tab its own timeout.
    if (outcome === "failed") {
      if (!_receiptToken(info.receipt)) break;
      continue;
    }
    _untrackTab(key);
    open--;
    if (outcome === "closed") {
      console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached for this session — closed its oldest tab (${_safeUrlForOutput(info.url)})`);
      return info;
    }
  }
  return null;
}

// An HTTP session that ended (its DELETE, or the opt-in idle reaper in transport.js) closes the
// tabs it opened. They used to stay open until the daemon restarted, up to MAX_TABS for every
// finished session. A tab whose close failed stays tracked, so shutdown and the memory sweep
// still see it. Idea from PR #202 by @fahadjibransheikh.
async function _closeSessionTabs(sessionId) {
  const key = `${SESSION_ID}:${sessionId}`;
  _activeReceipts.delete(key);
  let closed = 0;
  for (const [tabKey, info] of _sessionTabs(key)) {
    const outcome = await _oneCloseAtATime(() => _closeTrackedTab(info));
    if (outcome === "failed") continue;
    _untrackTab(tabKey);
    if (outcome === "closed") closed++;
  }
  if (closed) console.error(`[Safari MCP] Session ${sessionId.slice(0, 8)} ended — closed its ${closed} tab(s)`);
}

// What safari_new_tab tells the caller about the tab the cap closed. The receipt is how the
// caller knows which of its tabs is gone; the position it had is meaningless by now.
function _evictionReport(info) {
  const receipt = _receiptToken(info.receipt);
  const safeUrl = _safeUrlForOutput(info.url);
  const opened = safeUrl === "unknown" ? "" : ` (opened on ${safeUrl})`;
  return {
    evictedTab: { ...(receipt ? { receipt } : {}), ...(opened ? { safeUrl } : {}) },
    note: `Tab cap ${MAX_TABS}/session reached — your oldest tab${opened} was closed, and its receipt no longer works. Close tabs you are done with (safari_close_tab).`,
  };
}

// Forget the tab a close just removed, by the identity that close used. An index says
// nothing here: every close renumbers the tabs after it.
function _untrackClosedTab({ receipt = "", marker = "" }) {
  const token = _receiptToken(receipt);
  for (const [key, info] of _openedTabs) {
    if ((token && _receiptToken(info.receipt) === token) || (marker && info.marker === marker)) _untrackTab(key);
  }
}

// One tab close at a time in this process. closeTabByMarker() checks a tab's marker and closes the
// tab in one script, but as two AppleEvents, and a close sent in between renumbered the window: the
// index the check found then named the next tab, the user's included. Two tab-cap evictions at once
// could do it (safari_new_tab and run_script newTab, in parallel), as could an eviction next to
// safari_close_tab. An extension close goes by tab id, but it renumbers the window all the same.
// ponytail: this orders the closes of one server process (every session of an HTTP daemon); a close
// by another MCP process can still land between those two events. A slow extension close holds up
// the rest, shutdown cleanup included, which then leaves the tabs open after its 3s.
let _closeQueue = Promise.resolve();
function _oneCloseAtATime(close) {
  const run = _closeQueue.then(() => close());
  _closeQueue = run.catch(() => {});
  return run;
}

// A URL a call takes the session's tab to, and with no scheme its https:// form, which is what
// safari.js navigate() loads. Strings only: run_script step args are not validated. Closing a tab
// opened on the URL releases both forms (_claimedForms in ownership-state.js).
function _claimURL(url) {
  if (typeof url !== "string" || !url) return;
  _addOwnedURL(url);
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
}

// Open a tab through AppleScript as one of the session's own: its URL claimed, and the tab tracked
// by the marker safari.newTab() mints for it, so the session's tab cap, shutdown cleanup and the
// memory sweep count and close it, and a close forgets it. The URL the caller asked for stays the
// session's current one wherever the page redirected: the site chose the redirect's target.
// newTab() names the marker (onMarker) as soon as the tab exists, and can still throw after that,
// when marking or reading the new tab fails; the tab is claimed and tracked then too. Left out, it
// outlived the cap and cleanup, and the next write was refused in it as "not opened by this MCP
// session". A newTab() that threw before the tab existed named no marker. The marker newTab()
// names, not the session's after the call: a parallel call of the same session changes that one,
// and a switch that adopted the user's tab (SAFARI_MCP_ALLOW_USER_TABS) meanwhile got the user's
// tab recorded among the ones the cap and cleanup close.
async function _openTabViaAppleScript(url) {
  const mySession = `${SESSION_ID}:${currentSessionId()}`;
  let minted = null;
  try {
    return await safari.newTab(url, { onMarker: (marker) => { minted = marker; } });
  } finally {
    if (minted) {
      _trackTab(safari.getActiveTabIndex(), url, mySession, minted); // the index is only a hint
      if (!url || url === "about:blank") {
        _markBlankTabOpened();
      } else {
        safari.setActiveTabURL(url);
        _claimURL(url);
      }
    }
  }
}

// Only the extension can tell which tab a receipt names: AppleScript proves a tab by the
// marker stamped on it (#112), and a tab the extension opened carries none. A close or switch
// by receipt whose extension attempt failed fell back to the session's current tab instead,
// closing it, or claiming a tab with no index at all.
function _receiptNeedsExtension(tool) {
  return new Error(
    "Tab safety: this call names its tab by a receipt (the one passed, or the current tab's), and only " +
    "the Safari extension can resolve a receipt. It could not take the call, so AppleScript touched no tab. " +
    `Retry safari_${tool} once safari_doctor shows the extension connected.`
  );
}

// Close all MCP-opened tabs on process exit
async function _cleanupTabs() {
  if (_openedTabs.size === 0) return;
  // A named Safari profile is extension-only: never mutate it through the
  // AppleScript compatibility backend during process shutdown. Explicit tab
  // closes are ownership-checked by the extension while the session is alive.
  if (process.env.SAFARI_PROFILE) {
    console.error(`[Safari MCP] Cleanup: leaving ${_openedTabs.size} profile tabs for extension-safe cleanup`);
    _openedTabs.clear();
    return;
  }
  console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
  // Find each tab by its identity marker in the script that closes it — indices shift after
  // every closure, and the marker is the only coordinate that survives navigation. closeTab()
  // with no index would close only this session's active tab (#68).
  for (const info of [..._openedTabs.values()]) {
    try {
      await _oneCloseAtATime(() => safari.closeTabByMarker(info.marker));
    } catch {}
  }
  _openedTabs.clear();
}

// Periodic memory check — proactive monitoring with warning + action thresholds
const WEBKIT_WARNING_THRESHOLD_MB = Math.round(WEBKIT_MEMORY_LIMIT_MB * 0.7); // 70% = warning
let _memoryCheckTimer = null;
let _lastMemoryWarningTime = 0;

// ── Cross-instance memory-monitor lock ───────────────────────────────────────
// Every MCP instance (one per Claude session) runs this monitor and they ALL
// read the SAME global WebKit memory. Uncoordinated, all of them close tabs at
// the same moment ("thundering herd") and Safari windows flicker shut. This file
// lock lets only ONE instance run a close-sweep per cycle; the rest skip it.
const _MEMORY_LOCK_FILE = join(OWNERSHIP_DIR, "memory-monitor.lock");
const _MEMORY_LOCK_TTL_MS = 25000; // lock held longer than this = crashed owner, reclaim it

function _tryAcquireMemoryLock() {
  try {
    if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
    // Atomic create-if-absent ("wx") — succeeds for exactly one instance.
    writeFileSync(_MEMORY_LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: "wx" });
    return true;
  } catch {
    // Lock exists — reclaim only if its owner is stale (crashed without releasing).
    try {
      const ts = parseInt(readFileSync(_MEMORY_LOCK_FILE, "utf8").split(":")[1], 10) || 0;
      if (Date.now() - ts > _MEMORY_LOCK_TTL_MS) {
        writeFileSync(_MEMORY_LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: "w" });
        return true;
      }
    } catch {}
    return false;
  }
}

function _releaseMemoryLock() {
  try {
    if (readFileSync(_MEMORY_LOCK_FILE, "utf8").split(":")[0] === String(process.pid)) {
      unlinkSync(_MEMORY_LOCK_FILE);
    }
  } catch {}
}

function _getWebKitMemoryMB() {
  try {
    const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
    if (!pids) return 0;
    const pidList = pids.split("\n").join(",");
    const psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
    const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
    return totalKB / 1024;
  } catch { return 0; }
}

async function _closeOldestMCPTab() {
  if (process.env.SAFARI_PROFILE) return;
  let oldestIdx = null, oldestTime = Infinity;
  for (const [idx, info] of _openedTabs) {
    if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
  }
  if (oldestIdx !== null) {
    // By its identity, like the tab cap: an extension-opened tab carries a receipt, not a
    // marker, and a marker-only close forgot it here without freeing anything.
    await _oneCloseAtATime(() => _closeTrackedTab(_openedTabs.get(oldestIdx)));
    _untrackTab(oldestIdx);
  }
}

function _startMemoryMonitor() {
  const checkInterval = Math.min(MEMORY_CHECK_INTERVAL_MS, 30000); // Max 30s between checks
  _memoryCheckTimer = setInterval(async () => {
    try {
      // Every instance may sweep — but only its OWN _openedTabs, and only one
      // per cycle: _tryAcquireMemoryLock() below already serializes sweepers
      // machine-wide. The old `_isExtensionHost` gate here was redundant with
      // that lock and made the guard inert in multi-instance setups — only the
      // port-9224 winner could ever sweep, and it can't reach tabs the other
      // instances opened (#83).
      const webkitMB = _getWebKitMemoryMB();
      if (webkitMB <= 0) return;

      // Warning threshold — log only (once per 5 min)
      if (webkitMB > WEBKIT_WARNING_THRESHOLD_MB && webkitMB <= WEBKIT_MEMORY_LIMIT_MB) {
        if (Date.now() - _lastMemoryWarningTime > 300000) {
          console.error(`[Safari MCP] ⚠️ WebKit memory warning: ${Math.round(webkitMB)}MB (threshold: ${WEBKIT_WARNING_THRESHOLD_MB}MB, limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — ${_openedTabs.size} MCP tabs open`);
          _lastMemoryWarningTime = Date.now();
        }
      }

      // Action threshold — close oldest tabs (up to 2) to recover memory.
      // Guarded by a cross-instance lock: with N instances all watching the same
      // global WebKit memory, only ONE may sweep per cycle — otherwise all N close
      // tabs together and Safari windows flicker shut.
      if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
        if (!_tryAcquireMemoryLock()) return; // another instance is already recovering memory
        try {
          console.error(`[Safari MCP] 🔴 WebKit over limit: ${Math.round(webkitMB)}MB — closing oldest MCP tabs`);
          await _closeOldestMCPTab();
          // If still over limit and have more tabs, close another
          if (_openedTabs.size > 1) {
            const afterMB = _getWebKitMemoryMB();
            if (afterMB > WEBKIT_MEMORY_LIMIT_MB) {
              await _closeOldestMCPTab();
            }
          }
        } finally {
          _releaseMemoryLock();
        }
      }
    } catch {}
  }, checkInterval);
  _memoryCheckTimer.unref();
}

// Cleanup on exit
let _cleaningUp = false;
async function _shutdown() {
  if (_cleaningUp) return;
  _cleaningUp = true;
  // Restore the user's clipboard synchronously FIRST — if a native paste is mid-flight, its
  // 2s restore timer would never fire once we exit, leaving the tool's text on the clipboard.
  try { safari.flushClipboardRestore(); } catch {}
  // Cap tab cleanup — if the daemon/Safari is wedged at shutdown (exactly when a SIGTERM
  // tends to arrive), listTabs/closeTab can each block for their full timeout; never let
  // that hang the exit past 3s.
  await Promise.race([_cleanupTabs(), new Promise(r => setTimeout(r, 3000))]);
  process.exit(0);
}

for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
  process.on(sig, () => {
    // In HTTP-daemon mode this process is shared by every Claude Code session, and
    // launchd KeepAlive restarts it — which silently drops each client's tab ownership
    // mid-task. Name the signal so the cause of a restart is visible in the log
    // instead of appearing as a bare "Cleanup:" line.
    console.error(`[Safari MCP] Shutdown on ${sig} (ppid=${process.ppid}, uptime=${Math.round(process.uptime())}s)`);
    _shutdown();
  });
}
process.on("exit", () => {
  if (_openedTabs.size > 0) {
    console.error(`[Safari MCP] Exit: ${_openedTabs.size} tabs were tracked for cleanup`);
  }
});

// ========== EXTENSION FOCUS SAFETY ==========
// Before the extension proves its Safari profile, AppleScript is the only safe path:
// accepting whichever extension polls first can cross from Automation into Personal.
// Once profile verification succeeds, the extension is both profile-pinned and the only
// backend that still works when macOS temporarily denies Apple Events (-1743).
const _preferAppleScript = !!process.env.SAFARI_PROFILE;
let _profileExtensionVerified = !process.env.SAFARI_PROFILE;
// A same-version .appex replacement leaves Safari running the OLD service worker,
// which this server refuses by design (see EXTENSION_BRIDGE_PROTOCOL). The refusal is
// silent: workers keep POSTing /connect forever and every tool call fails with the
// generic "profile extension unavailable". Count distinct never-verified workers so
// the operator is told the one thing that fixes it instead of debugging the bridge.
const _unverifiedWorkerIds = new Set();
let _staleWorkerAdviceShown = false;
// Every worker that reaches /connect, with what it announced and what the profile probe
// answered it — the evidence the warning below and safari_doctor print (#109).
const _workerLedger = createWorkerLedger();
const _rejectedWorkerIdsLogged = new Set();
function _noteUnverifiedWorker(workerId) {
  if (!process.env.SAFARI_PROFILE || _profileExtensionVerified || !workerId) return;
  _unverifiedWorkerIds.add(workerId);
  if (_staleWorkerAdviceShown || _unverifiedWorkerIds.size < 3) return;
  _staleWorkerAdviceShown = true;
  // 20.9.26 (#109): the old text prescribed a sqlite write for a "poisoned" stored identity.
  // The real defect was in the extension — Safari decodes background.js in the system's
  // legacy encoding, so its canonicalizer could not see its own em dash and never stripped
  // "<profile> — <title>" (fixed in extension 2.10.24) — and nothing here said which
  // workers the counted ones were. Print the ledger instead of one theory.
  console.error(
    `[Safari MCP] ⚠️ ${_unverifiedWorkerIds.size} extension workers connected but none proved profile ` +
    `"${process.env.SAFARI_PROFILE}". Safari runs one worker per profile and each one tries this port. ` +
    `Run safari_doctor: it lists these workers plus what every profile's extension storage holds ` +
    `(identity, heartbeat, badge). A stored identity that still carries a window-title suffix under an ` +
    `extension older than 2.10.24 is the #109 defect — rebuild and reinstall the app. A worker rejected ` +
    `with 426 or 401 is one Safari kept from an older build — disable and re-enable the extension in ` +
    `that profile, or relaunch Safari. Workers seen so far:\n  ` +
    summarizeWorkerLedger(_workerLedger, { activeWorkerId: _activeHttpWorkerId }).join("\n  ")
  );
}

// One line per distinct verdict per worker: "match", "wrong:<window name>", "notfound"
// or "error:<message>". Repeats of the same verdict only refresh the ledger timestamp.
function _logProfileVerdict(workerId, verdict) {
  if (!ledgerNoteVerdict(_workerLedger, workerId, verdict)) return;
  console.error(`[Safari MCP] Profile probe from worker ${workerId.slice(0, 8)} for "${process.env.SAFARI_PROFILE}": ${verdict}`);
}

// What safari_doctor appends about the bridge: this host's role, the lease holder, the
// workers seen, and what each profile's extension storage holds (read-only sqlite).
async function _extensionBridgeReport() {
  const lines = ["", "🔌 Extension bridge"];
  const profile = process.env.SAFARI_PROFILE || "";
  if (!_isExtensionHost) {
    lines.push(`   this process is not the extension host (another safari-mcp owns port ${HTTP_PORT}); ask it, or run safari_doctor through the daemon`);
    return lines;
  }
  const pollAge = _extensionLastPollTime ? `${Math.max(0, Math.round((Date.now() - _extensionLastPollTime) / 1000))}s ago` : "never";
  if (_extensionConnected && (!profile || _profileExtensionVerified)) {
    lines.push(`   ${profile ? `profile "${profile}" verified` : "connected"} — worker ${_activeHttpWorkerId.slice(0, 8)}, last poll ${pollAge}`);
  } else {
    lines.push(`   ❌ ${profile ? `no worker has proved profile "${profile}"` : "no worker connected"} — last poll ${pollAge}${_unverifiedWorkerIds.size ? `, ${_unverifiedWorkerIds.size} other worker(s) tried` : ""}`);
  }
  const seen = summarizeWorkerLedger(_workerLedger, { activeWorkerId: _activeHttpWorkerId });
  lines.push(...(seen.length ? seen : ["no worker has reached /connect since this host started"]).map((l) => `   • ${l}`));
  const { promisify: pfy } = await import("node:util");
  const { readdir } = await import("node:fs/promises");
  const execFileAsync = pfy(execFile);
  let identities = [];
  let storageError = null;
  try {
    identities = await readProfileIdentities({
      home: homedir(),
      readdir,
      runSqlite: async (db) => (await execFileAsync("sqlite3", ["-readonly", db,
        "SELECT key, value FROM extension_storage WHERE key IN ('mcpVerifiedProfile','_heartbeat','mcpStatus')"],
        { timeout: 2000 })).stdout,
    });
  } catch (e) { storageError = e; }
  lines.push(...describeProfileIdentities(identities, { profile, error: storageError }).map((l) => `   ◦ ${l}`));
  return lines;
}

// A named profile is driven only by its verified WebExtension worker. The worker
// can create a background window through browser.windows.create() when the last
// profile window was closed, so no AppleScript/UI-scripting recovery is needed.
async function _waitForVerifiedProfileExtension(timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (_extensionConnected && _profileExtensionVerified) return true;
    await new Promise(resolve => setTimeout(resolve, 250));
  }
  return false;
}

// ========== EXTENSION BRIDGE (WebSocket + HTTP polling) ==========
function _bridgePort(name, fallback) {
  const value = Number.parseInt(process.env[name] || "", 10);
  return Number.isInteger(value) && value >= 1024 && value <= 65535 ? value : fallback;
}
const HTTP_PORT = _bridgePort("SAFARI_MCP_BRIDGE_PORT", 9224);
const WS_PORT = _bridgePort("SAFARI_MCP_BRIDGE_WS_PORT", HTTP_PORT - 1);
// A manifest version is not enough to distinguish two development builds: Safari can
// keep an older service worker alive after a same-version appex is replaced. Require a
// code-capability marker before a worker may enter profile verification, so a stale
// worker can never displace the currently installed worker or consume its commands.
const EXTENSION_BRIDGE_PROTOCOL = "popup-opener-lease-v2";
function _isExtensionSchemeOrigin(origin) {
  const value = String(origin || "");
  return value.startsWith("safari-web-extension://") ||
    value.startsWith("moz-extension://") ||
    value.startsWith("chrome-extension://");
}
// Bearer receipts cross this socket, so an extension scheme is not enough: another
// installed extension could claim the connection. WebSocket transport is disabled
// until exact trusted origins are explicitly allowlisted. Safari uses HTTP polling.
const _allowedWebSocketOrigins = new Set(
  String(process.env.SAFARI_MCP_WS_ORIGINS || "")
    .split(",")
    .map((origin) => origin.trim())
    .filter((origin) => _isExtensionSchemeOrigin(origin))
);
function _isAllowedWebSocketOrigin(origin) {
  return _allowedWebSocketOrigins.has(String(origin || ""));
}
let _extensionWs = null;
let _extensionConnected = false;
let _activeHttpWorkerId = "";
// The build the lease holder announced on /connect ("" for a context from an older app).
let _activeHttpWorkerBuild = "";
// Which extension origin the lease holder's requests came from (a short hash), for the log.
let _activeHttpWorkerOrigin = "";
// The identity of the running Safari process, sent on every authenticated bridge response so
// the extension can name its browser session the same way from every worker instance (#143).
const _safariSessionHint = createSafariSessionHintReader();

// Safari keeps the extension context of every app generation it ever loaded alive (measured
// 13.10.26: its process had resources of a dozen builds mapped, the deleted ones included), and
// each wakes every minute and verifies. The ones from before the isolated envelopes announce no
// build, so while a current one has verified recently they may not take the lease, and a current
// one takes it from them at once (see worker-upgrade.js).
let _currentBuildWorkerSeenAt = 0;
function _noteCurrentBuildWorker(build) {
  if (build) _currentBuildWorkerSeenAt = Date.now();
}
function _currentBuildWorkerSeenRecently(now = Date.now()) {
  return _currentBuildWorkerSeenAt > 0 && now - _currentBuildWorkerSeenAt <= LEASE_BUILD_PREFERENCE_MS;
}
// Current contexts can be several as well, each with tab ids of its own: a receipt resolves only in
// the context that minted it (see worker-upgrade.js). The first current context to lead is the home
// context, and no instance of another origin leads while the home context is alive.
let _homeOrigin = "";
let _homeOriginSeenAt = 0;
function _homeContextAlive(now = Date.now()) {
  return !!_homeOrigin && now - _homeOriginSeenAt <= HOME_CONTEXT_ALIVE_MS;
}
function _noteHomeContext(build, origin) {
  if (build && origin && origin === _homeOrigin) _homeOriginSeenAt = Date.now();
}
function _pinHomeContext(build, origin) {
  if (!build || !origin || origin === "none") return;
  if (origin !== _homeOrigin) console.error(`[Safari MCP] home context: ${_homeOrigin || "none"} -> ${origin}`);
  _homeOrigin = origin;
  _homeOriginSeenAt = Date.now();
}
const _contextRefusedLoggedAt = new Map();
function _logContextRefused(workerId, origin) {
  const now = Date.now();
  if (now - (_contextRefusedLoggedAt.get(origin) || 0) < 5 * 60_000) return;
  if (_contextRefusedLoggedAt.size >= 64) _contextRefusedLoggedAt.clear();
  _contextRefusedLoggedAt.set(origin, now);
  console.error(`[Safari MCP] lease: worker ${workerId.slice(0, 8)} of context ${origin} waits, context ${_homeOrigin} leads`);
}
function _extensionOriginId(origin) {
  return typeof origin === "string" && origin ? createHash("sha256").update(origin).digest("hex").slice(0, 6) : "none";
}
function _logLeaseChange(fromWorkerId, toWorkerId, toBuild, toOrigin) {
  const from = fromWorkerId ? `${fromWorkerId.slice(0, 8)} (${_activeHttpWorkerBuild || "no build"}, origin ${_activeHttpWorkerOrigin || "?"})` : "nobody";
  console.error(`[Safari MCP] lease: ${from} -> ${toWorkerId.slice(0, 8)} (${toBuild || "no build"}, origin ${toOrigin || "?"})`);
}

// When no current context is around, a lease holder that announced no build is reloaded
// instead, once (see worker-upgrade.js). SAFARI_MCP_NO_AUTO_UPGRADE=1 turns this off.
const _staleWorkerPolicy = createStaleWorkerPolicy();
let _staleReloadInFlight = false;
async function _reloadStaleWorker(workerId) {
  if (process.env.SAFARI_MCP_NO_AUTO_UPGRADE === "1" || _staleReloadInFlight) return;
  if (workerId !== _activeHttpWorkerId || _activeHttpWorkerBuild) return;
  // A current context is about to replace this one, and reloading restarts the same old one.
  if (_currentBuildWorkerSeenRecently()) return;
  // Asked on every poll of the lease holder until it has been reloaded: a command in flight or a
  // reload a moment ago only delays it, so a context that verified at a busy moment cannot stay
  // the lease holder for good.
  const verdict = _staleWorkerPolicy.check({
    build: _activeHttpWorkerBuild,
    active: true,
    busy: _pendingRequests.size > 0,
  });
  if (!verdict.reload) return;
  _staleReloadInFlight = true;
  _staleWorkerPolicy.commit();
  console.error(`[Safari MCP] worker ${workerId.slice(0, 8)} announced no extension build (a context Safari kept from an older app); reloading it`);
  // The reload needs the same handoff as safari_reload_extension (see /proxy-command): it is
  // what lets the replacement worker take the lease from this one.
  let handoff = null;
  try {
    handoff = _prepareReloadHttpWorkerHandoff();
    await sendToExtension("reload_extension", { reloadHandoff: handoff.token }, 30000);
  } catch (error) {
    if (handoff) _cancelReloadHttpWorkerHandoff(handoff);
    console.error(`[Safari MCP] reloading worker ${workerId.slice(0, 8)} failed: ${error.message}`);
  } finally {
    _staleReloadInFlight = false;
  }
}
const _connectingHttpWorkers = new Map();
const _HTTP_WORKER_TTL_MS = 60 * 1000;
const _HTTP_RELOAD_HANDOFF_TTL_MS = 30 * 1000;
const _HTTP_WORKER_SUCCESSOR_GRACE_MS = 20 * 1000;
const _HTTP_WORKER_DISCONNECT_MS = 30 * 1000;
// Safari parks an idle profile worker every ~30s and its 1-minute alarm wakes it. Declaring
// it gone in between bought nothing (a profile has no fallback) and cost a full
// re-verification per minute — 61 flaps in two hours. Wait one alarm cycle plus margin.
const _HTTP_WORKER_PARK_GRACE_MS = 75 * 1000;
let _extensionLastPollTime = 0;
let _extensionLastHeartbeat = 0;
let _reloadHttpWorkerHandoff = null;

function _httpWorkerId(req) {
  const value = req.headers["x-safari-mcp-worker"];
  return typeof value === "string" && /^[0-9a-f]{32}$/.test(value) ? value : "";
}

function _reloadHandoffToken(req) {
  const value = req.headers["x-safari-mcp-reload-handoff"];
  return typeof value === "string" && /^[A-Za-z0-9_-]{24,}$/.test(value) ? value : "";
}

function _rememberConnectingHttpWorker(workerId, reloadHandoffToken = "", build = "", origin = "") {
  const now = Date.now();
  for (const [id, record] of _connectingHttpWorkers) {
    if (now - record.connectedAt > _HTTP_WORKER_TTL_MS) _connectingHttpWorkers.delete(id);
  }
  _connectingHttpWorkers.set(workerId, { connectedAt: now, reloadHandoffToken, build, origin });
}

function _connectingHttpWorker(workerId) {
  const record = _connectingHttpWorkers.get(workerId);
  return record && typeof record.connectedAt === "number" && Date.now() - record.connectedAt <= _HTTP_WORKER_TTL_MS
    ? record
    : null;
}

function _prepareReloadHttpWorkerHandoff() {
  const now = Date.now();
  if (!_activeHttpWorkerId) {
    throw new Error("Extension reload requires an active HTTP worker");
  }
  // A prepared handoff is attached by object identity to one pending reload command.
  // Preserve it beyond the successor TTL only while that exact command is pending;
  // otherwise an ACK lost after arming (or a failed runtime.reload) would block all
  // future reloads forever on the still-healthy original worker.
  if (_reloadHttpWorkerHandoff) {
    const handoff = _reloadHttpWorkerHandoff;
    const isPending = [..._pendingRequests.values()].some(
      (pending) => pending.reloadHandoff === handoff
    );
    if (handoff.expiresAt < now && !isPending) {
      _reloadHttpWorkerHandoff = null;
    } else {
      throw new Error("Extension reload is already in progress");
    }
  }
  const handoff = {
    fromWorkerId: _activeHttpWorkerId,
    token: randomBytes(18).toString("base64url"),
    armed: false,
    expiresAt: now + _HTTP_RELOAD_HANDOFF_TTL_MS,
  };
  _reloadHttpWorkerHandoff = handoff;
  return handoff;
}

function _armReloadHttpWorkerHandoff(handoff) {
  if (_reloadHttpWorkerHandoff !== handoff) return false;
  handoff.armed = true;
  handoff.expiresAt = Date.now() + _HTTP_RELOAD_HANDOFF_TTL_MS;
  return true;
}

function _cancelReloadHttpWorkerHandoff(handoff) {
  if (_reloadHttpWorkerHandoff === handoff) _reloadHttpWorkerHandoff = null;
}

function _reloadAwaitsDelivery(handoff) {
  return [..._pendingRequests.values()].some(
    (pending) => pending.reloadHandoff === handoff && !pending.dispatchedWorkerId
  );
}

// A new worker won the lease. A reload its predecessor never received moves with the
// lease — that worker is the one that will run it; any other prepared handoff is void.
function _adoptReloadHandoff(workerId) {
  const handoff = _reloadHttpWorkerHandoff;
  if (handoff && !handoff.armed && _reloadAwaitsDelivery(handoff)) handoff.fromWorkerId = workerId;
  else _reloadHttpWorkerHandoff = null;
}

function _activeHttpWorkerIsFresh(now = Date.now()) {
  const lastActivity = Math.max(_extensionLastPollTime, _extensionLastHeartbeat);
  return !!_activeHttpWorkerId && _extensionConnected && lastActivity > 0 && now - lastActivity <= _HTTP_WORKER_SUCCESSOR_GRACE_MS;
}

function _activeHttpWorkerHasDispatchedRequest() {
  return !!_activeHttpWorkerId && [..._pendingRequests.values()].some(
    (pending) => pending.dispatchedWorkerId === _activeHttpWorkerId
  );
}

function _mayReplaceActiveHttpWorker(workerId, presentedToken, now = Date.now(), build = "", origin = "") {
  if (workerId === _activeHttpWorkerId) return true;
  const preference = leaseBuildVerdict({
    candidateBuild: build,
    holderBuild: _activeHttpWorkerBuild,
    hasHolder: !!_activeHttpWorkerId,
    currentSeenRecently: _currentBuildWorkerSeenRecently(now),
    candidateOrigin: origin,
    homeOrigin: _homeOrigin,
    homeAlive: _homeContextAlive(now),
  });
  if (preference === "deny") return false;
  if (!_activeHttpWorkerId) return true;
  // No successor capability may bypass a mutation already delivered to the
  // active worker. A reload result removes its pending fence before arming the
  // exact handoff, so the intended reload path remains available.
  if (_activeHttpWorkerHasDispatchedRequest()) return false;
  const handoff = _reloadHttpWorkerHandoff;
  if (handoff) {
    // While the old worker is preparing a reload, no unrelated worker may use a
    // momentary poll/heartbeat gap to clear the handoff before /result arms it.
    // A reload nobody has received is not being prepared, though: a parked worker can
    // never take it, and pinning its lease deadlocked the reload until it timed out
    // (15.9.26). Then only the ordinary freshness rule applies, and the successor
    // inherits the reload (_adoptReloadHandoff).
    if (!handoff.armed) {
      if (!_reloadAwaitsDelivery(handoff)) return false;
      return !_activeHttpWorkerIsFresh(now);
    }
    if (handoff.expiresAt >= now) {
      return handoff.fromWorkerId === _activeHttpWorkerId && presentedToken === handoff.token;
    }
    // An armed successor token is one-shot and short-lived. Once it expires, return
    // to the ordinary healthy-worker lease instead of leaving a permanent blockade.
    if (_reloadHttpWorkerHandoff === handoff) _reloadHttpWorkerHandoff = null;
  }
  return preference === "take" || !_activeHttpWorkerIsFresh(now);
}

function _requireActiveHttpWorker(req, res) {
  const workerId = _httpWorkerId(req);
  if (!workerId) {
    res.writeHead(401);
    res.end("Worker authentication required");
    return "";
  }
  if (!_activeHttpWorkerId) {
    res.writeHead(409);
    res.end("Worker reconnect required");
    return "";
  }
  if (workerId !== _activeHttpWorkerId) {
    // A previous Safari service worker can survive an extension reload. It must stop
    // polling rather than consuming commands whose receipt state belongs to the newly
    // verified worker.
    res.writeHead(423);
    res.end("Worker superseded");
    return "";
  }
  return workerId;
}
// Monotonic proof that a worker completed a NEW verified connection.  A boolean is
// insufficient for reload_extension: the old worker can still be marked connected
// when its command response arrives, so callers used to return before the replacement
// worker existed.
let _extensionConnectionGeneration = 0;

async function _waitForExtensionGeneration(previousGeneration, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (
      _extensionConnectionGeneration > previousGeneration &&
      _extensionConnected &&
      (!_preferAppleScript || _profileExtensionVerified)
    ) return true;
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  return false;
}

// Pending requests: command sent to extension, waiting for result
const _pendingRequests = new Map();

// Command queue: commands waiting to be picked up by HTTP-polling extension
const _commandQueue = [];

function _dequeueHttpCommand(workerId) {
  while (_commandQueue.length > 0) {
    const cmd = _commandQueue.shift();
    const pending = _pendingRequests.get(cmd.id);
    // A request timer can expire while its command is at the head of the queue.
    // Never deliver an orphaned or already-claimed mutation out of band.
    if (!pending || pending.dispatchedWorkerId) continue;
    pending.dispatchedWorkerId = workerId;
    return cmd;
  }
  return null;
}

// ========== WEBSOCKET SERVER (for Chrome extensions / direct WebSocket) ==========
let wss;
try {
  wss = new WebSocketServer({
    host: "127.0.0.1",
    port: WS_PORT,
    verifyClient: ({ origin }) => _isAllowedWebSocketOrigin(origin),
  });
  wss.on("connection", (ws) => {
    _extensionWs = ws;
    _extensionConnected = true;
    _extensionConnectionGeneration += 1;
    console.error(`[Safari MCP] Extension connected via WebSocket`);
    _setupExtensionListener(ws);
    ws.on("close", () => {
      _extensionConnected = false;
      _extensionWs = null;
      _drainOnDisconnect("WebSocket close");
      console.error("[Safari MCP] Extension disconnected (WebSocket)");
    });
  });
  wss.on("error", (err) => {
    if (err.code === "EADDRINUSE") {
      console.error(`[Safari MCP] WebSocket port ${WS_PORT} in use — WebSocket disabled`);
    }
  });
} catch {}

function _setupExtensionListener(ws) {
  ws.on("message", (data) => {
    let msg;
    try { msg = JSON.parse(data.toString()); } catch { return; }
    _handleExtensionResponse(msg);
  });
}

// ========== HTTP POLLING SERVER (for Safari extensions — WebSocket blocked) ==========
const _contentWakePolls = new Set();
function _releaseContentWakePolls() {
  for (const finish of [..._contentWakePolls]) finish();
}

// Open GET /poll long-polls, each a check() that answers its request if it can. Woken when a
// command is queued or the active worker changes — the 5ms setInterval they replace kept both
// daemons at ~200 wake-ups a second while idle.
const _httpPollWaiters = new Set();
function _wakeHttpPolls() {
  for (const check of [..._httpPollWaiters]) check();
}

function _setContentWakeCors(req, res) {
  const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
  res.setHeader("Access-Control-Allow-Origin", origin || "*");
  if (origin) res.setHeader("Vary", "Origin");
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "X-Safari-MCP-Wakeup");
  res.setHeader("Cache-Control", "no-store");
}

function _isAllowedContentWakeOrigin(origin) {
  if (!origin) return true;
  if (_isExtensionSchemeOrigin(origin)) return true;
  try {
    const parsed = new URL(origin);
    return (parsed.protocol === "http:" || parsed.protocol === "https:") &&
      parsed.origin === origin;
  } catch {
    return false;
  }
}

function _handleContentWakeRequest(req, res) {
  if (req.url !== "/content-wakeup") return false;

  const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
  if (!_isAllowedContentWakeOrigin(origin)) {
    res.writeHead(403);
    res.end("Forbidden origin");
    return true;
  }

  // Safari gives a content-script fetch the webpage's Origin. Permit only this
  // narrow, authority-free wake route before the extension-only CORS gate below.
  // The actual GET still needs the independent derived HMAC capability.
  if (req.method === "OPTIONS") {
    const requestedMethod = String(req.headers["access-control-request-method"] || "").toUpperCase();
    const requestedHeaders = String(req.headers["access-control-request-headers"] || "")
      .split(",")
      .map((header) => header.trim().toLowerCase())
      .filter(Boolean);
    if (requestedMethod !== "GET" ||
        requestedHeaders.length !== 1 ||
        requestedHeaders[0] !== "x-safari-mcp-wakeup") {
      res.writeHead(403);
      res.end("Forbidden preflight");
      return true;
    }
    _setContentWakeCors(req, res);
    res.writeHead(204);
    res.end();
    return true;
  }

  if (req.method !== "GET") {
    res.writeHead(405, { Allow: "GET, OPTIONS" });
    res.end("Method not allowed");
    return true;
  }

  _setContentWakeCors(req, res);
  if (!_contentWakeTokenMatches(req.headers["x-safari-mcp-wakeup"])) {
    res.writeHead(401);
    res.end("Unauthorized");
    return true;
  }

  if (_contentWakePolls.size >= 4) {
    res.writeHead(429, { "Retry-After": "1" });
    res.end("Too many wake polls");
    return true;
  }

  let settled = false;
  let timer = null;
  const finish = () => {
    if (settled) return;
    settled = true;
    if (timer) clearTimeout(timer);
    _contentWakePolls.delete(finish);
    if (!res.writableEnded) {
      res.writeHead(204);
      res.end();
    }
  };
  _contentWakePolls.add(finish);
  timer = setTimeout(finish, 8000);
  req.on("close", () => {
    if (settled) return;
    settled = true;
    if (timer) clearTimeout(timer);
    _contentWakePolls.delete(finish);
  });
  return true;
}

// Binds the extension bridge port; set once the bridge server exists (see PROXY MODE).
let _listenAsExtensionHost = null;

try {
  const httpServer = createServer((req, res) => {
    if (_handleContentWakeRequest(req, res)) return;

    // CORS headers — restricted to browser extension origin only.
    // Safari extensions use moz-extension:// or safari-web-extension:// origins.
    // "*" was a security risk: any webpage could POST to the localhost bridge and execute MCP commands.
    const origin = req.headers.origin || "";
    const isSafeOrigin = !origin || _isExtensionSchemeOrigin(origin);
    if (isSafeOrigin) {
      res.setHeader("Access-Control-Allow-Origin", origin || "*");
    } else {
      // Block cross-origin requests from web pages
      res.writeHead(403);
      res.end("Forbidden: cross-origin request blocked");
      return;
    }
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Safari-MCP-Token, X-Safari-MCP-Worker, X-Safari-MCP-Reload-Handoff");

    if (req.method === "OPTIONS") {
      res.writeHead(204);
      res.end();
      return;
    }

    // GET /keepalive — a plain page for the keepalive tab (SAFARI_MCP_KEEPALIVE_TAB=1).
    // Any http(s) page of the profile hosts the content script, whose wake long-poll and
    // runtime pings are what keep Safari's MV3 worker from parking; this page exists so
    // there is always one, even after every owned tab was closed or Safari was relaunched.
    if (req.method === "GET" && req.url.split("?")[0].split("#")[0] === "/keepalive") {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
      res.end(KEEPALIVE_HTML);
      return;
    }

    // Every Safari-extension HTTP endpoint is authenticated in addition to its
    // CORS check. Origin alone is not authority: another extension has its own
    // extension-scheme origin, and a local process can forge or omit Origin.
    const extensionBridgeEndpoint =
      (req.method === "GET" && req.url === "/poll") ||
      (req.method === "POST" && (
        req.url === "/result" ||
        req.url.startsWith("/connect") ||
        req.url === "/heartbeat" ||
        req.url === "/extension-verified" ||
        req.url === "/verify-profile"
      ));
    if (extensionBridgeEndpoint && !_bridgeTokenMatches(req.headers["x-safari-mcp-token"])) {
      // Silent until 20.9.26: a worker whose bundled token no longer matches this host's
      // retries forever and never shows up in the ledger. Name it once.
      const rejectedId = _httpWorkerId(req) || "(no worker id)";
      if (!_rejectedWorkerIdsLogged.has(rejectedId)) {
        _rejectedWorkerIdsLogged.add(rejectedId);
        console.error(
          `[Safari MCP] ⚠️ Extension worker ${rejectedId.slice(0, 8)} rejected (401) on ${req.method} ${req.url.split("?")[0]}: ` +
          `its bridge token does not match extension/bridge-auth-token as this host read it — a worker started ` +
          `before the app was rebuilt, or two checkouts with different tokens. Relaunch Safari (or reinstall the app).`
        );
      }
      res.writeHead(401);
      res.end("Unauthorized");
      return;
    }
    if (extensionBridgeEndpoint) {
      const sessionHint = _safariSessionHint();
      if (sessionHint) {
        res.setHeader("X-Safari-MCP-Browser-Epoch", sessionHint);
        res.setHeader("Access-Control-Expose-Headers", "X-Safari-MCP-Browser-Epoch");
      }
    }

    // GET /poll — extension asks for next command (long-poll, up to 5s)
    if (req.method === "GET" && req.url === "/poll") {
      const pollingWorkerId = _requireActiveHttpWorker(req, res);
      if (!pollingWorkerId) return;
      // A profile-scoped server never releases queued commands until an extension has
      // proved it belongs to that profile. This keeps an old/wrong worker from draining
      // commands during reconnect races.
      if (process.env.SAFARI_PROFILE && !_profileExtensionVerified) {
        res.writeHead(409);
        res.end("Profile verification required");
        return;
      }
      _extensionLastPollTime = Date.now(); // Keep connection alive — critical for stale detection
      _noteHomeContext(_activeHttpWorkerBuild, _activeHttpWorkerOrigin);
      _reloadStaleWorker(pollingWorkerId);
      if (!_extensionConnected) {
        _extensionConnected = true;
        console.error("[Safari MCP] Extension reconnected via poll");
      }
      const queuedCommand = _dequeueHttpCommand(pollingWorkerId);
      if (queuedCommand) {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(queuedCommand));
      } else {
        // Long-poll: wait up to 5 seconds for a command; _wakeHttpPolls() runs check().
        const check = () => {
          // Dropped with no successor (the stale timer after the Mac slept, a wedged-worker
          // drop): the worker must reconnect, same as the gate above. A 423 here tells it a
          // replacement verified, so it went dormant for good while its own profile's pages
          // kept it alive (26.10.26: a profile lost its extension overnight).
          if (!_activeHttpWorkerId) {
            done();
            res.writeHead(409);
            res.end("Worker reconnect required");
            return;
          }
          if (pollingWorkerId !== _activeHttpWorkerId) {
            done();
            res.writeHead(423);
            res.end("Worker superseded");
            return;
          }
          const cmd = _dequeueHttpCommand(pollingWorkerId);
          if (cmd) {
            done();
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify(cmd));
          }
        };
        const timer = setTimeout(() => {
          done();
          res.writeHead(204);
          res.end();
        }, 5000);
        const done = () => {
          clearTimeout(timer);
          _httpPollWaiters.delete(check);
        };
        _httpPollWaiters.add(check);
        req.on("close", done); // client went away
      }
      return;
    }

    // POST /result — extension sends command result
    if (req.method === "POST" && req.url === "/result") {
      const respondingWorkerId = _requireActiveHttpWorker(req, res);
      if (!respondingWorkerId) return;
      let body = "";
      let bodyTooLarge = false; // 'end' can still fire after destroy() — never parse/respond then
      req.on("data", (chunk) => { if (bodyTooLarge || res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { bodyTooLarge = true; res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
      req.on("end", () => {
        if (bodyTooLarge) return;
        let accepted = false;
        try {
          const msg = JSON.parse(body);
          accepted = _handleExtensionResponse(msg, respondingWorkerId);
        } catch {}
        res.writeHead(accepted ? 200 : 409);
        res.end(accepted ? "ok" : "Result was not accepted");
      });
      return;
    }

    // POST /connect — extension announces it's alive
    if (req.method === "POST" && req.url.startsWith("/connect")) {
      const workerId = _httpWorkerId(req);
      if (!workerId) {
        res.writeHead(400);
        res.end("Worker identity required");
        return;
      }
      const connectUrl = new URL(req.url, `http://127.0.0.1:${HTTP_PORT}`);
      // Safari can retain old per-profile workers after a development build is replaced.
      // Those workers use the legacy visible-tab verifier. Fail them closed before the
      // server reveals a target profile, while current workers use the no-new-tab verifier.
      if (process.env.SAFARI_PROFILE && (
        connectUrl.searchParams.get("verifier") !== "existing-tab-v1" ||
        connectUrl.searchParams.get("protocol") !== EXTENSION_BRIDGE_PROTOCOL
      )) {
        // A 426 used to be silent: the worker Safari kept from an older build retried
        // forever and the host counted only the accepted workers (#109).
        const rejected = {
          verifier: String(connectUrl.searchParams.get("verifier") || "").slice(0, 40),
          protocol: String(connectUrl.searchParams.get("protocol") || "").slice(0, 40),
        };
        ledgerNoteConnect(_workerLedger, workerId, { rejected });
        if (!_rejectedWorkerIdsLogged.has(workerId)) {
          _rejectedWorkerIdsLogged.add(workerId);
          console.error(
            `[Safari MCP] ⚠️ Extension worker ${workerId.slice(0, 8)} rejected (426 upgrade_required): it announced ` +
            `verifier="${rejected.verifier}" protocol="${rejected.protocol}", this host requires existing-tab-v1 / ` +
            `${EXTENSION_BRIDGE_PROTOCOL}. Safari is still running a worker from an older build in one of its ` +
            `profiles — disable and re-enable the extension there, or relaunch Safari.`
          );
        }
        res.writeHead(426, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ status: "upgrade_required" }));
        return;
      }
      // Only an accepted code generation may proceed to /verify-profile and
      // /extension-verified. Remembering rejected workers would let a stale process
      // skip the protocol gate by posting the second handshake step directly.
      const announcedBuild = String(connectUrl.searchParams.get("build") || "");
      const build = /^[A-Za-z0-9._-]{1,40}$/.test(announcedBuild) ? announcedBuild : "";
      const originId = _extensionOriginId(req.headers.origin);
      _rememberConnectingHttpWorker(workerId, _reloadHandoffToken(req), build, originId);
      ledgerNoteConnect(_workerLedger, workerId);
      _noteUnverifiedWorker(workerId);
      // When SAFARI_PROFILE is set, don't mark as connected until profile is verified.
      // A personal-profile extension connecting first would incorrectly set the flag.
      if (!process.env.SAFARI_PROFILE) {
        const reloadHandoffToken = _reloadHandoffToken(req);
        _noteCurrentBuildWorker(build);
        _noteHomeContext(build, originId);
        if (!_mayReplaceActiveHttpWorker(workerId, reloadHandoffToken, Date.now(), build, originId)) {
          if (build) _logContextRefused(workerId, originId);
          _connectingHttpWorkers.delete(workerId);
          res.writeHead(423, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ status: "worker_lease_held" }));
          return;
        }
        const workerChanged = workerId !== _activeHttpWorkerId;
        if (workerChanged) _logLeaseChange(_activeHttpWorkerId, workerId, build, originId);
        _pinHomeContext(build, originId);
        _activeHttpWorkerId = workerId;
        _activeHttpWorkerBuild = build;
        _activeHttpWorkerOrigin = originId;
        _connectingHttpWorkers.delete(workerId);
        if (workerChanged) {
          _adoptReloadHandoff(workerId);
          _extensionConnectionGeneration += 1;
          _wakeHttpPolls(); // the superseded worker's open poll answers 423 now
        }
        if (!_extensionConnected) {
          _extensionConnected = true;
          console.error("[Safari MCP] Extension connected via HTTP polling");
        }
        _scheduleKeepaliveTab();
      }
      if (!process.env.SAFARI_PROFILE) _extensionLastPollTime = Date.now();
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "connected", profile: process.env.SAFARI_PROFILE || null }));
      return;
    }

    // POST /heartbeat — extension is alive and mid-command (its poll loop is blocked
    // by the running command, so /poll cannot be what proves liveness here).
    if (req.method === "POST" && req.url === "/heartbeat") {
      const heartbeatingWorkerId = _requireActiveHttpWorker(req, res);
      if (!heartbeatingWorkerId) return;
      _extensionLastHeartbeat = Date.now();
      // Re-arm in-flight deadlines: the worker told us it is still on the command.
      // hardDeadline inside armTimer keeps this from extending indefinitely.
      let liveInFlight = false;
      for (const pending of _pendingRequests.values()) {
        if (pending.dispatchedWorkerId !== heartbeatingWorkerId) continue;
        if (!pending.armTimer || Date.now() >= pending.hardDeadline) continue;
        liveInFlight = true;
        clearTimeout(pending.timer);
        pending.timer = pending.armTimer();
      }
      // A beat proves liveness only while the worker is on a command we still wait for.
      // 7.9.26: a worker wedged inside one command (its handleCommand never settled) kept
      // beating every 5s for hours after that command's hard deadline had expired; each
      // beat refreshed the stale clock, so "HTTP poll timeout" never fired, no successor
      // could take the lease, and every tool call died with "Extension timeout". Once
      // nothing dispatched to this worker is in flight, its beats no longer count as polls.
      if (liveInFlight) _extensionLastPollTime = Date.now();
      res.writeHead(204);
      res.end();
      return;
    }

    // POST /extension-verified — extension confirmed it's in the correct profile
    if (req.method === "POST" && req.url === "/extension-verified") {
      const workerId = _httpWorkerId(req);
      const connectingWorker = workerId ? _connectingHttpWorker(workerId) : null;
      if (!workerId || !connectingWorker) {
        res.writeHead(401);
        res.end("Unverified worker");
        return;
      }
      _noteCurrentBuildWorker(connectingWorker.build);
      _noteHomeContext(connectingWorker.build, connectingWorker.origin);
      if (!_mayReplaceActiveHttpWorker(workerId, connectingWorker.reloadHandoffToken, Date.now(), connectingWorker.build || "", connectingWorker.origin || "")) {
        if (connectingWorker.build) _logContextRefused(workerId, connectingWorker.origin);
        _connectingHttpWorkers.delete(workerId);
        res.writeHead(423, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ status: "worker_lease_held" }));
        return;
      }
      const workerChanged = workerId !== _activeHttpWorkerId;
      if (workerChanged) _logLeaseChange(_activeHttpWorkerId, workerId, connectingWorker.build, connectingWorker.origin);
      _pinHomeContext(connectingWorker.build, connectingWorker.origin);
      _activeHttpWorkerId = workerId;
      _activeHttpWorkerBuild = connectingWorker.build || "";
      _activeHttpWorkerOrigin = connectingWorker.origin || "";
      _connectingHttpWorkers.delete(workerId);
      if (workerChanged) {
        _adoptReloadHandoff(workerId);
        _wakeHttpPolls();
      }
      _profileExtensionVerified = true;
      ledgerNoteVerified(_workerLedger, workerId);
      if (workerChanged) _extensionConnectionGeneration += 1;
      if (!_extensionConnected) {
        _extensionConnected = true;
        console.error(`[Safari MCP] Extension connected and profile-verified via HTTP polling (worker ${workerId.slice(0, 8)})`);
      }
      _extensionLastPollTime = Date.now();
      _scheduleKeepaliveTab();
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "verified" }));
      return;
    }

    // POST /verify-profile — extension asks server to check which profile has a nonce tab
    if (req.method === "POST" && req.url === "/verify-profile") {
      const workerId = _httpWorkerId(req);
      if (!workerId || !_connectingHttpWorker(workerId)) {
        res.writeHead(401);
        res.end("Worker must connect before profile verification");
        return;
      }
      let body = "";
      let bodyTooLarge = false; // 'end' can still fire after destroy() — never parse/respond then
      req.on("data", (chunk) => { if (bodyTooLarge || res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { bodyTooLarge = true; res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
      req.on("end", async () => {
        if (bodyTooLarge) return;
        try {
          const { nonce, expectedProfile } = JSON.parse(body);
          // Use AppleScript to find which window contains the nonce in a tab title
          const safeNonce = String(nonce).replace(/[^0-9]/g, '');  // nonce is numeric only
          const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, '');  // whitelist: letters, numbers, spaces, hyphens, underscores
          // Check via AppleScript — look for the nonce in the profile window
          const { execFile: execFileCb } = await import("node:child_process");
          const { promisify: pfy } = await import("node:util");
          const execFileAsync = pfy(execFileCb);
          // Don't launch Safari if it's not running
          try {
            await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 1000 });
          } catch {
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ match: false, error: "Safari is not running" }));
            return;
          }
          const script = `tell application "Safari"
            repeat with w in every window
              repeat with t in every tab of w
                if name of t contains "${safeNonce}" then
                  if name of w starts with "${safeProfile} —" then
                    return "match"
                  else
                    return "wrong:" & name of w
                  end if
                end if
              end repeat
            end repeat
            return "notfound"
          end tell`;
          // Must finish well inside the extension's 5s fetch timeout: if the extension aborts
          // first it never caches the verdict, so it re-probes every few seconds — and each probe
          // opens a *new window* in any profile that has none (the personal profile popping up).
          // 1s pgrep + 2.5s osascript = 3.5s worst case, and the catch below still answers 200.
          const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 2500 });
          const out = stdout.trim();
          _logProfileVerdict(workerId, out);
          if (out === "match") {
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ match: true }));
          } else {
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ match: false, actualProfile: out }));
          }
        } catch (err) {
          // Apple Events not answering (locked screen, a modal Safari dialog, TCC -1743)
          // lands here as a timeout: the worker reads it as inconclusive and keeps retrying.
          _logProfileVerdict(workerId, `error:${String(err.message || err).slice(0, 80)}`);
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ match: false, error: err.message }));
        }
      });
      return;
    }

    // GET /proxy-check — secondary instances check if extension is connected
    if (req.method === "GET" && req.url.startsWith("/proxy-check")) {
      const requestUrl = new URL(req.url, `http://127.0.0.1:${HTTP_PORT}`);
      const requestedProfile = requestUrl.searchParams.get("profile") || "";
      const hostProfile = process.env.SAFARI_PROFILE || "";
      const profileMatch = requestedProfile === hostProfile;
      const extensionUsable = _extensionConnected && profileMatch && (!_preferAppleScript || _profileExtensionVerified);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ extensionConnected: extensionUsable, profileMatch }));
      return;
    }

    // POST /proxy-command — secondary instances send commands through primary
    if (req.method === "POST" && req.url === "/proxy-command") {
      // Security: require the local shared-secret — blocks unrelated local processes
      if (!_tokenMatches(req.headers["x-local-token"])) {
        res.writeHead(403, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "Forbidden: invalid local token" }));
        return;
      }
      let body = "";
      let bodyTooLarge = false; // 'end' can still fire after destroy() — never parse/respond then
      req.on("data", (chunk) => { if (bodyTooLarge || res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { bodyTooLarge = true; res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
      req.on("end", async () => {
        if (bodyTooLarge) return;
        try {
          const { type, payload, profile = "" } = JSON.parse(body);
          const hostProfile = process.env.SAFARI_PROFILE || "";
          if (profile !== hostProfile) {
            res.writeHead(409, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ error: "Refusing proxy command: verified Safari profile does not match" }));
            return;
          }
          // A secondary MCP process proxies extension commands directly and does
          // not execute this host's safari_new_tab tool body. Give a suspended
          // profile worker time to wake before the verification gate; the worker
          // itself will create a background profile window if none exists.
          if (type === "new_tab") await _waitForVerifiedProfileExtension(30000);
          if (_preferAppleScript && !_profileExtensionVerified) {
            res.writeHead(409, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ error: "Refusing proxy command: verified Safari profile does not match" }));
            return;
          }
          const timeout = _commandTimeouts[type] || 30000;
          const reloadHandoff = type === "reload_extension"
            ? _prepareReloadHttpWorkerHandoff()
            : null;
          const commandPayload = reloadHandoff
            ? { ...payload, reloadHandoff: reloadHandoff.token }
            : payload;
          let result;
          try {
            result = await sendToExtension(type, commandPayload, timeout);
          } catch (error) {
            if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
            throw error;
          }
          if (type === "reload_extension") {
            // The worker answers before scheduling runtime.reload(). Capture the
            // baseline only now: a suspended OLD worker may have reconnected merely
            // to receive this command, and that wake must not satisfy the barrier.
            const reloadGeneration = _extensionConnectionGeneration;
            const reconnected = await _waitForExtensionGeneration(reloadGeneration, 30000);
            if (!reconnected) throw new Error("Extension reload did not produce a verified reconnect within 30000ms");
            if (result && typeof result === "object") result = { ...result, reconnected: true };
          }
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ result }));
        } catch (err) {
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ error: err.message, dispatched: err.dispatched }));
        }
      });
      return;
    }

    res.writeHead(404);
    res.end("Not found");
  });

  let binding = false;
  let proxying = false;
  httpServer.on("listening", () => {
    binding = false;
    _isExtensionHost = true;
    console.error(`[Safari MCP] HTTP server listening on port ${HTTP_PORT} (extension host)`);
  });
  httpServer.on("error", (err) => {
    binding = false;
    if (err.code === "EADDRINUSE" && !proxying) {
      proxying = true;
      console.error(`[Safari MCP] HTTP port ${HTTP_PORT} in use — will proxy commands to primary instance`);
      _isExtensionHost = false;
      // Check if primary instance has extension connected
      _checkPrimaryExtension();
    }
  });
  // Callable again after a failed bind; the guard keeps a second call from throwing
  // ERR_SERVER_ALREADY_LISTEN while a bind is still in flight.
  _listenAsExtensionHost = () => {
    if (binding || httpServer.listening) return;
    binding = true;
    httpServer.listen(HTTP_PORT, "127.0.0.1");
  };
  _listenAsExtensionHost();
} catch {}

// ========== PROXY MODE ==========
// When another MCP instance already owns the port, we proxy commands through it
let _isExtensionHost = false;
let _primaryHasExtension = false;

// Delayed check: if after 2 seconds we're not the host, try proxy mode
setTimeout(() => {
  if (!_isExtensionHost && !_primaryHasExtension) {
    console.error("[Safari MCP] Not extension host after startup — checking for primary instance");
    _checkPrimaryExtension();
  }
}, 2000);

async function _checkPrimaryExtension() {
  if (_isExtensionHost) return; // Already hosting — stop polling
  try {
    const profile = encodeURIComponent(process.env.SAFARI_PROFILE || "");
    const res = await fetch(`http://127.0.0.1:${HTTP_PORT}/proxy-check?profile=${profile}`, {
      method: "GET",
      signal: AbortSignal.timeout(2000),
    });
    if (res.ok) {
      const data = await res.json();
      _primaryHasExtension = data.extensionConnected;
      if (_primaryHasExtension) {
        _extensionConnected = true; // Enable extension path in extensionOrFallback
        _profileExtensionVerified = true;
        console.error(`[Safari MCP] Primary instance has extension — proxy mode enabled`);
      } else {
        _extensionConnected = false;
        if (process.env.SAFARI_PROFILE) _profileExtensionVerified = false;
      }
    }
  } catch {
    // Nothing answers on the bridge port: the primary is gone. A secondary that keeps
    // proxying to nobody leaves the extension without a bridge until a restart (seen
    // 15.9.26, after a short-lived process held the port during a daemon restart), so
    // take the port over. If something else still holds it, the bind fails quietly.
    _primaryHasExtension = false;
    _extensionConnected = false;
    if (process.env.SAFARI_PROFILE) _profileExtensionVerified = false;
    _listenAsExtensionHost?.();
  }
  // Re-check every 10s (unref'd — must not keep the Node process alive on its own)
  setTimeout(_checkPrimaryExtension, 10000).unref();
}

// Send command to primary instance's extension via proxy
async function _proxyToExtension(type, payload, timeoutMs = 30000) {
  const res = await fetch(`http://127.0.0.1:${HTTP_PORT}/proxy-command`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-local-token": PROXY_TOKEN },
    body: JSON.stringify({ type, payload, profile: process.env.SAFARI_PROFILE || "" }),
    // new_tab may first wait for a suspended profile worker to reconnect on the
    // host, so its proxy envelope must outlive the command timeout itself.
    // The host also re-arms a command's deadline on every extension heartbeat, up to
    // the same ceiling sendToExtension uses. Without matching that here, a secondary
    // instance abandoned a heavy-DOM command at ~35s while the host was still happily
    // waiting for it — the exact failure this envelope is supposed to prevent.
    signal: AbortSignal.timeout(
      Math.max(timeoutMs * 4, 180000) + (type === "new_tab" ? 35000 : 5000)
    ),
  });
  if (!res.ok) throw new Error(`Proxy error: ${res.status}`);
  const data = await res.json();
  if (data.error) throw Object.assign(new Error(data.error), { dispatched: data.dispatched });
  return data.result;
}

// Beats while the extension is busy executing a command, so a long command does not
// look like a dead worker. See POST /heartbeat.
// Detect stale HTTP connection (no poll in 30s = disconnected)
// Only applies to primary instance (extension host) — not proxy mode
const _staleHttpTimer = setInterval(() => {
  // After a daemon restart the profile worker can sit in its reconnect backoff for minutes
  // (measured 9 min on 4.9.26) while pages of the profile are already long-polling us for
  // wake-ups. Releasing those polls makes each page ping the worker, and a pinged worker
  // retries /connect at once (extension ≥2.10.9) instead of waiting out the backoff.
  if (_isExtensionHost && process.env.SAFARI_PROFILE && !_profileExtensionVerified && _contentWakePolls.size) {
    _releaseContentWakePolls();
  }
  if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
    if (Date.now() - _extensionLastPollTime > _HTTP_WORKER_DISCONNECT_MS) {
      if (process.env.SAFARI_PROFILE && Date.now() - _extensionLastPollTime <= _HTTP_WORKER_PARK_GRACE_MS) return;
      // A command already delivered to this worker is an at-most-once fence.
      // Its own bounded request timer releases the fence; disconnect cleanup must
      // not make an ordinary successor eligible while the old worker may finish it.
      if (_activeHttpWorkerHasDispatchedRequest()) return;
      // The extension runs each command inside its own poll loop, so a command that
      // outlives 30s (heavy DOM: facebook.com, business.facebook.com) starves /poll
      // even though the worker is alive and still working on OUR command. Killing the
      // connection here also drained the very request it was executing. Trust the
      // per-command heartbeat instead, and only declare death when nothing is in flight.
      if (_pendingRequests.size > 0 && Date.now() - _extensionLastHeartbeat < 30000) return;
      // A reload result must arm the exact object captured by its pending request.
      // Let that request's own bounded timer cancel the prepared handoff; clearing it
      // here creates an identity race when /result lands on the same timer tick.
      if (_reloadHttpWorkerHandoff && [..._pendingRequests.values()].some(
        (pending) => pending.reloadHandoff === _reloadHttpWorkerHandoff
      )) return;
      _extensionConnected = false;
      _activeHttpWorkerId = "";
      _connectingHttpWorkers.clear();
      _reloadHttpWorkerHandoff = null;
      _wakeHttpPolls();
      _drainOnDisconnect("HTTP poll timeout");
      console.error("[Safari MCP] Extension disconnected (HTTP poll timeout)");
    }
  }
}, 5000);
_staleHttpTimer.unref();  // stale-detection must not keep the Node process alive on its own

// ========== SHARED EXTENSION LOGIC ==========

// A worker that keeps beating but never finishes a command holds the profile lease
// hostage. Releasing it is the same bookkeeping the stale timer does on poll timeout.
function _dropWedgedHttpWorker(reason) {
  if (!_extensionConnected && !_activeHttpWorkerId) return;
  _extensionConnected = false;
  _activeHttpWorkerId = "";
  _connectingHttpWorkers.clear();
  _reloadHttpWorkerHandoff = null;
  _wakeHttpPolls();
  _drainOnDisconnect(`wedged worker: ${reason}`);
  console.error(`[Safari MCP] Extension worker dropped as wedged (${reason})`);
}

// Drain pending requests and command queue on disconnect — allows fast fallback to AppleScript
function _drainOnDisconnect(reason) {
  if (process.env.SAFARI_PROFILE) {
    _profileExtensionVerified = false;
    // ponytail: in a named profile nothing can run a drained command instead, so rejecting
    // queued work only turns "worker parked for a few seconds" into a hard error (seen live
    // 4.9.26: the first new_tab after idle died with "Extension disconnected: HTTP poll
    // timeout"). Leave the queue alone — the next verified worker polls it, and every request
    // still dies on its own timer, which also removes it from the queue.
    return;
  }
  // Reject all in-flight requests immediately (instead of waiting for timeout)
  const queued = new Set(_commandQueue.map((c) => c.id));
  for (const [id, pending] of _pendingRequests) {
    clearTimeout(pending.timer);
    if (pending.reloadHandoff) _cancelReloadHttpWorkerHandoff(pending.reloadHandoff);
    pending.reject(Object.assign(new Error(`Extension disconnected: ${reason}`), { dispatched: !queued.has(id) }));
  }
  _pendingRequests.clear();
  // Clear queued commands that will never be picked up
  _commandQueue.length = 0;
}

function _handleExtensionResponse(msg, respondingWorkerId = "") {
  if (msg.type === "keepalive") return true;
  if (msg.type === "connected") {
    if (!_extensionConnected) {
      _extensionConnected = true;
      console.error("[Safari MCP] Extension connected");
    }
    return true;
  }
  if (msg.type !== "response" || !msg.id) return false;
  const pending = _pendingRequests.get(msg.id);
  if (!pending) return false;
  if ((pending.dispatchedWorkerId || "") !== respondingWorkerId) return false;
  clearTimeout(pending.timer);
  _pendingRequests.delete(msg.id);
  if (msg.error) {
    if (pending.reloadHandoff) _cancelReloadHttpWorkerHandoff(pending.reloadHandoff);
    pending.reject(new Error(msg.error));
    return true;
  }
  // Arm the successor lease synchronously while handling /result. The HTTP ACK is
  // sent only after this returns true, so the old worker cannot reload before the
  // host is ready to accept the exact replacement token.
  if (pending.reloadHandoff && !_armReloadHttpWorkerHandoff(pending.reloadHandoff)) {
    _cancelReloadHttpWorkerHandoff(pending.reloadHandoff);
    pending.reject(new Error("Extension reload handoff could not be armed"));
    return false;
  }
  pending.resolve(msg.result);
  return true;
}

// Send command to extension (via WebSocket, HTTP command queue, or proxy to primary)
// Thermal backpressure (opt-in). With SAFARI_MCP_THERMAL_FILE naming a JSON file whose `_t` is the chip
// temperature and `_ts` its epoch seconds (e.g. a thermal monitor's latest sample), page-work commands wait
// while the chip is at or above SAFARI_MCP_THERMAL_MAX (default 80) — until it is 2° cooler, at most
// SAFARI_MCP_THERMAL_WAIT_MS (default 20000) — so many agents in many tabs slow down instead of heating the
// Mac. Closing and listing tabs never wait; a stale or missing reading holds nothing.
const _THERMAL_FILE = process.env.SAFARI_MCP_THERMAL_FILE || "";
const _THERMAL_MAX = parseFloat(process.env.SAFARI_MCP_THERMAL_MAX || "80");
const _THERMAL_WAIT_MS = parseInt(process.env.SAFARI_MCP_THERMAL_WAIT_MS || "20000", 10);
const _THERMAL_FREE = new Set(["close_tab", "list_tabs", "get_tab_receipt", "get_tab_locus", "mark_tab", "switch_tab", "reload_extension"]);

function _chipTemp() {
  try {
    const row = JSON.parse(readFileSync(_THERMAL_FILE, "utf8"));
    return Date.now() / 1000 - row._ts <= 15 ? Number(row._t) || 0 : 0;
  } catch {
    return 0;
  }
}

async function _thermalGate(type) {
  if (!_THERMAL_FILE || _THERMAL_FREE.has(type) || _chipTemp() < _THERMAL_MAX) return;
  console.error(`[Safari MCP] chip at ${Math.round(_chipTemp())}°C — holding "${type}" up to ${_THERMAL_WAIT_MS / 1000}s so the Mac can cool`);
  const until = Date.now() + _THERMAL_WAIT_MS;
  while (Date.now() < until && _chipTemp() >= _THERMAL_MAX - 2) {
    await new Promise((r) => setTimeout(r, 1000));
  }
}

function sendToExtension(type, payload = {}, timeoutMs = 30000) {
  if (!_THERMAL_FILE || _THERMAL_FREE.has(type)) return _sendToExtension(type, payload, timeoutMs);
  return _thermalGate(type).then(() => _sendToExtension(type, payload, timeoutMs));
}

function _sendToExtension(type, payload = {}, timeoutMs = 30000) {
  // If we're a secondary instance, proxy through primary
  if (!_isExtensionHost && _primaryHasExtension) {
    return _proxyToExtension(type, payload, timeoutMs);
  }

  return new Promise((resolve, reject) => {
    if (!_extensionConnected) {
      reject(Object.assign(new Error("Extension not connected"), { dispatched: false }));
      return;
    }
    const reloadHandoff = type === "reload_extension" &&
      _reloadHttpWorkerHandoff?.token === payload.reloadHandoff
      ? _reloadHttpWorkerHandoff
      : null;
    if (type === "reload_extension" && !reloadHandoff) {
      reject(new Error("Extension reload handoff is unavailable"));
      return;
    }
    const id = randomUUID();
    // A heavy DOM (facebook.com, business.facebook.com) can take well past 30s for a
    // single read_page/snapshot. Rather than raise every timeout — which would make a
    // genuinely stuck worker hang for just as long — let the extension's mid-command
    // heartbeat re-arm this deadline. No heartbeat, no extension: it still fails fast.
    // hardDeadline is the ceiling, so a worker that beats forever cannot hang us.
    const hardDeadline = Date.now() + Math.max(timeoutMs * 4, 180000);
    const expire = () => {
      const pending = _pendingRequests.get(id);
      _pendingRequests.delete(id);
      if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
      // Also drop it from the HTTP poll queue — otherwise the extension could poll this command
      // long after the caller gave up and run a stale navigate/click out of band.
      const qi = _commandQueue.findIndex(c => c.id === id);
      if (qi >= 0) _commandQueue.splice(qi, 1);
      // Still queued = never polled, so nothing ran; callers may retry elsewhere only then.
      reject(Object.assign(new Error(`Extension timeout after ${timeoutMs}ms`), { dispatched: qi < 0 }));
      // The worker took this command and never answered, even past the hard ceiling: it is
      // wedged (see /heartbeat). Drop its lease now instead of waiting for the stale timer,
      // so a fresh worker — or Safari re-spawning this one — can connect at once.
      if (
        pending && pending.dispatchedWorkerId && pending.dispatchedWorkerId === _activeHttpWorkerId &&
        Date.now() >= pending.hardDeadline
      ) {
        _dropWedgedHttpWorker(`${type} exceeded its hard deadline`);
      }
    };
    const armTimer = () => setTimeout(expire, Math.min(timeoutMs, Math.max(0, hardDeadline - Date.now())));
    _pendingRequests.set(id, {
      resolve,
      reject,
      timer: armTimer(),
      armTimer,
      hardDeadline,
      reloadHandoff,
      dispatchedWorkerId: "",
    });

    const command = { id, type, payload };

    // If WebSocket is connected, use it (faster)
    if (_extensionWs) {
      _extensionWs.send(JSON.stringify(command));
    } else {
      // Otherwise, queue for HTTP polling
      _commandQueue.push(command);
      _wakeHttpPolls();
      _releaseContentWakePolls();
    }
  });
}

// Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
const _commandTimeouts = {
  // Safari can suspend a profile extension worker for 20–25 seconds even while
  // its window remains open. Short deadlines caused a queued mutating command to
  // be abandoned and then retried through AppleScript, which is both unreliable
  // and unsafe for clicks/typing. Keep every profile command above that wake-up
  // window; fast workers still return immediately.
  click: 30000, click_open_popup: 30000, fill: 30000, read_page: 30000, get_source: 30000, evaluate: 30000,
  type_text: 30000, press_key: 30000, scroll: 30000, scroll_to: 30000, scroll_to_element: 30000,
  hover: 30000, list_tabs: 30000, new_tab: 30000, close_tab: 30000, switch_tab: 30000,
  wait_for: 30000, navigate: 45000, navigate_and_read: 45000, go_back: 30000, go_forward: 30000,
  reload: 30000, screenshot: 30000, snapshot: 30000, click_and_read: 30000,
  double_click: 30000, right_click: 30000, clear_field: 30000, select_option: 30000, fill_form: 30000,
  replace_editor: 30000, get_url: 30000, get_title: 30000,
};
// A parked profile worker wakes on Safari's 1-minute alarm at the latest (sooner when an
// http(s) tab of the profile is open — its content script relays the wake). The two commands
// that typically arrive first after idle must outlive that cycle instead of failing at 30s.
if (process.env.SAFARI_PROFILE) { _commandTimeouts.new_tab = 75000; _commandTimeouts.list_tabs = 75000; }

// ========== KEEPALIVE TAB (opt-in) ==========
// Safari parks an idle extension worker; the first command afterwards then waits for the
// 1-minute alarm (measured: new_tab 18s, or a hard failure before the queue fix). A page of
// the profile that carries the content script relays wake-ups instantly and keeps the worker
// alive — so keep exactly one such page open, served by this daemon itself.
const KEEPALIVE_TAB_ENABLED = process.env.SAFARI_MCP_KEEPALIVE_TAB === "1";
const KEEPALIVE_HTML = '<!doctype html><html lang="he" dir="rtl"><head><meta charset="utf-8"><title>Safari MCP · keepalive</title><meta name="robots" content="noindex"><style>body{font-family:-apple-system,system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;color:#1a2230;background:#f3f5f8;line-height:1.6}code{background:#ddeff2;padding:.1em .4em;border-radius:3px}</style></head><body><h1>Safari MCP</h1><p>הטאב הזה משאיר את ההרחבה ערה ברקע, כדי שהפקודה הראשונה אחרי הפסקה תרוץ מיד. אפשר להשאיר אותו פתוח; אם סוגרים אותו, הוא ייפתח שוב לבד.</p><p>לביטול: <code>SAFARI_MCP_KEEPALIVE_TAB=0</code> בהגדרות הדמון.</p></body></html>';
const KEEPALIVE_URL = `http://127.0.0.1:${HTTP_PORT}/keepalive`;
const KEEPALIVE_SESSION = `${SESSION_ID}:keepalive`;
let _keepaliveBusy = false;
let _keepaliveTimer = null;
async function _ensureKeepaliveTab() {
  if (!KEEPALIVE_TAB_ENABLED || _keepaliveBusy || !_isExtensionHost || !_extensionConnected) return;
  if (_preferAppleScript && !_profileExtensionVerified) return;
  _keepaliveBusy = true;
  try {
    const tabs = await sendToExtension("list_tabs", { sessionId: KEEPALIVE_SESSION }, 30000);
    const list = Array.isArray(tabs) ? tabs : [];
    // No window at all → new_tab would have to create one, and a WebExtension-created
    // window is not guaranteed to land in this profile. Wait for a real window instead.
    if (!list.length) return;
    if (list.some((t) => String(t?.safeUrl || t?.url || "").startsWith(KEEPALIVE_URL))) return;
    await sendToExtension("new_tab", { url: KEEPALIVE_URL, sessionId: KEEPALIVE_SESSION }, 45000);
    console.error(`[Safari MCP] keepalive tab opened (${KEEPALIVE_URL})`);
  } catch (err) {
    console.error(`[Safari MCP] keepalive tab check skipped: ${err.message}`);
  } finally {
    _keepaliveBusy = false;
  }
}
function _scheduleKeepaliveTab(delayMs = 3000) {
  if (!KEEPALIVE_TAB_ENABLED) return;
  if (_keepaliveTimer) clearTimeout(_keepaliveTimer);
  _keepaliveTimer = setTimeout(() => { _keepaliveTimer = null; void _ensureKeepaliveTab(); }, delayMs);
  _keepaliveTimer.unref();
}
if (KEEPALIVE_TAB_ENABLED) setInterval(() => void _ensureKeepaliveTab(), 10 * 60 * 1000).unref();

// Commands where null result means failure (should fall back to AppleScript)
const _nullMeansFailure = new Set([
  "click", "double_click", "right_click", "fill",
  "press_key", "hover", "clear_field", "select_option", "fill_form",
  // NOTE: "type_text" intentionally NOT here — execCommand always returns a string,
  // and double execution would duplicate text in contenteditable editors.
  "read_page", "get_source", "snapshot", "get_element", "query_all",
  "scroll", "scroll_to",
  // NOTE: "evaluate" intentionally NOT here — null is a valid return value.
  // Its CSP fallback is handled separately, by the ladder's terminal marker (hardCspBlock).
]);

// Operations that don't need tab ownership (read-only or tab management)
const _noOwnershipCheck = new Set([
  // Tab management. NOTE: "close_tab" is deliberately NOT here. It sat in this set as
  // "tab management" next to new_tab/list_tabs/switch_tab, but it is the only member that
  // destroys a user tab: new_tab creates one, list_tabs reads, and switch_tab carries its
  // own ownership check at the tool. close_tab had none — exempt from the shared guard and
  // unguarded underneath, which is how it closed a user's tab in #68.
  "new_tab", "list_tabs", "switch_tab",
  // Extension self-management (doesn't touch tabs)
  "reload_extension",
  // Read-only — don't modify the page
  "read_page", "get_source", "snapshot", "accessibility_snapshot",
  "list_frames",
  "get_element", "query_all", "screenshot", "screenshot_element",
  "get_console", "list_console_messages", "start_console",
  "get_network", "list_network_requests", "start_network_capture",
  "network", "network_details", "console_filter",
  "performance_metrics", "css_coverage", "get_computed_style",
  "extract_images", "extract_links", "extract_meta", "extract_tables",
  "get_cookies", "local_storage", "session_storage",
  "get_indexed_db", "list_indexed_dbs", "detect_forms",
  "save_pdf", "analyze_page",
]);

// run_script action names (camelCase) that don't require an owned tab — strictly
// read-only steps, plus newTab/switchTab/listTabs which mirror _noOwnershipCheck
// (switchTab still has to prove its tab: by the session's marker in safari.js switchTab(),
// or through the extension, which knows the tabs it opened by their id).
// Everything that can change page or tab state (navigate, evaluate, reload, goBack,
// closeTab, ...) is asserted PER STEP while the batch runs (see the run_script
// handler): a batch can change the active tab mid-run, so a single pre-flight
// check is not enough. "evaluate" in particular was exempt here while the
// standalone safari_evaluate tool was guarded — an inconsistency that allowed
// arbitrary JS in an unowned tab via batching.
const _RUNSCRIPT_OWNERSHIP_EXEMPT = new Set([
  "newTab", "switchTab", "listTabs",
  "readPage", "getPageSource", "screenshot", "screenshotElement",
  "waitFor", "waitForTime", "getElementInfo", "querySelectorAll",
  "extractTables", "extractMeta", "extractImages", "extractLinks",
  "analyzePage", "detectForms", "getAccessibilityTree", "getPerformanceMetrics",
  "getLocalStorage", "getSessionStorage", "getCookies",
]);

// Origin of a URL, or "" when it has none (about:blank, "missing value", junk).
// An empty result must never compare equal to another empty result at a call site.
const _SCREENSHOT_MAX_WIDTH = parseInt(process.env.SAFARI_MCP_SCREENSHOT_MAX_WIDTH || "0", 10) || 0;
// Retina captures come back at 2× the viewport (3024px wide on a 15" MacBook → ~1,900 tokens
// per image even after the API's own 1568px cap). Resampling with the system's sips keeps the
// tool dependency-free; the original is returned untouched on any failure.
async function _downscaleJpeg(base64, maxWidth) {
  if (!maxWidth || !base64) return base64;
  const buf = Buffer.from(base64, "base64");
  const size = jpegSize(buf);
  if (!size || size.width <= maxWidth) return base64;
  const stem = join(OWNERSHIP_DIR, `shot-${process.pid}-${Date.now()}`);
  try {
    if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
    writeFileSync(`${stem}.jpg`, buf, { mode: 0o600 });
    await new Promise((resolve, reject) => execFile(
      "/usr/bin/sips", ["--resampleWidth", String(maxWidth), `${stem}.jpg`, "--out", `${stem}-small.jpg`],
      { timeout: 10000 }, (err) => (err ? reject(err) : resolve())
    ));
    return readFileSync(`${stem}-small.jpg`).toString("base64");
  } catch (err) {
    console.error(`[Safari MCP] screenshot downscale skipped: ${err.message}`);
    return base64;
  } finally {
    for (const f of [`${stem}.jpg`, `${stem}-small.jpg`]) { try { unlinkSync(f); } catch { /* not written */ } }
  }
}

function _originOf(url) {
  try { return new URL(String(url || "")).origin; } catch { return ""; }
}

function _safeUrlForOutput(rawUrl) {
  const raw = String(rawUrl || "");
  if (!raw) return "unknown";
  if (raw === "about:blank" || raw === "missing value") return raw;
  try {
    const parsed = new URL(raw);
    if (/^https?:$/.test(parsed.protocol)) return parsed.origin + parsed.pathname;
    return parsed.protocol ? parsed.protocol.replace(/:$/, "") + ":" : "unknown";
  } catch {
    return "unknown";
  }
}

// Receipts this daemon rotated on the caller's behalf (a cross-origin safari_navigate) stay
// usable under their old name: callers routinely keep the first receipt they saw and ignore
// the fresh one in the navigate result (nine consecutive "not valid for this origin" failures
// in one session on 4.9.26). Whoever held the old capability held the tab. Every session that
// held it follows the alias, other clients of an HTTP daemon included (_getActiveReceipt), so an
// alias may only join two receipts of one tab: each rotation names the receipt it replaces.
const _receiptAliases = new Map(); // old token → newer token
function _aliasReceipt(oldToken, newToken) {
  if (!oldToken || !newToken || oldToken === newToken) return;
  // Every older name of the tab points straight at its newest receipt. As a chain of one link per
  // rotation, a receipt stopped resolving after eight (_receiptToken's hop limit): a handful of
  // cross-origin navigations in one tab.
  for (const [older, newer] of _receiptAliases) if (newer === oldToken) _receiptAliases.set(older, newToken);
  _receiptAliases.set(oldToken, newToken);
  if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
}

// The origin a receipt is valid on: the page the extension reported with it (_sanitizeTabResult).
// safari_navigate rotates a receipt only when the tab lands off that origin. Judged by the URL
// safari.js holds instead (another tab's when the call names its tab by receipt, unknown after a
// switch the extension served, stale after a navigation it served), a navigate that stayed on the
// receipt's origin counted as a change and retired the receipt the caller kept.
const _receiptOrigins = new Map(); // token → origin
function _noteReceiptOrigin(receipt, url) {
  const token = _receiptToken(receipt);
  const origin = _originOf(url);
  if (!token || !origin) return;
  _receiptOrigins.set(token, origin);
  if (_receiptOrigins.size > 500) _receiptOrigins.delete(_receiptOrigins.keys().next().value);
}

function _receiptToken(value) {
  const raw = String(value || "");
  let token = "";
  if (/^[A-Za-z0-9_-]{24,}$/.test(raw)) token = raw;
  else {
    const legacy = raw.match(/(?:[?#&])mcp-tab=([A-Za-z0-9_-]{24,})(?:[&#]|$)/);
    token = legacy ? legacy[1] : "";
  }
  for (let hops = 0; token && _receiptAliases.has(token) && hops < 8; hops++) token = _receiptAliases.get(token);
  return token;
}

// A caller-supplied receipt is the documented way for a stateless caller to name its
// own tab ("Stateless callers can present a receipt" — extension/background.js). Only
// switch_tab and get_tab_receipt exposed it, so after an MCP reconnect — which mints a
// fresh session id and empties _activeReceipts — every other tool silently fell back to
// the user's front tab and tripped the ownership guard. Accepting it here restores that
// path, and adopting it as the session's active receipt keeps later calls working too.
function _explicitReceipt(args) {
  const token = _receiptToken(args?.receipt || "");
  if (!token) return {};
  // A receipt that names another tab makes that tab the current one, so safari.js has to follow.
  // Otherwise its AppleScript fallback ran in the tab the session had before, or, for a session
  // re-initialised after a reconnect (no tab of its own yet), in the front document: the tab the
  // user is looking at. The extension marks the receipt's tab when AppleScript first needs it.
  if (token !== _getActiveReceipt()) safari.setActiveTabFromExtension(null, null);
  _setActiveReceipt(token);
  return { receipt: token };
}

// The session's current tab: its receipt, or "" for a tab picked without one (an index
// switch, a tab opened without a receipt), which the extension resolves from its own
// cache. No entry means the session has no current tab — it was closed.
const _activeReceipts = new Map();
function _receiptSessionKey() {
  return `${SESSION_ID}:${currentSessionId()}`;
}
// Resolved on every read: another client of an HTTP daemon can rotate this session's receipt
// after it was stored, and the extension then knows only the new one.
function _getActiveReceipt() {
  return _receiptToken(_activeReceipts.get(_receiptSessionKey()));
}
function _setActiveReceipt(receipt) {
  _activeReceipts.set(_receiptSessionKey(), _receiptToken(receipt));
}
function _clearActiveReceipt() {
  _activeReceipts.delete(_receiptSessionKey());
}

// Commands that name no tab; every other command targets the session's current tab.
const _UNTARGETED_COMMANDS = new Set(["new_tab", "list_tabs", "switch_tab", "reload_extension"]);

// In a named profile, a command with no receipt from a session whose current tab was closed
// went to the extension's guess — its cached tab, else the session's newest — and with
// several tabs open that guess read another query's page (geo-audit, 24.9.26). A current
// tab, or a single open tab, is never ambiguous; only this case is refused.
function _refuseUnnamedTab(extensionType, extensionPayload) {
  if (!_preferAppleScript || _UNTARGETED_COMMANDS.has(extensionType)) return;
  if (_receiptToken(extensionPayload?.receipt) || _activeReceipts.has(_receiptSessionKey())) return;
  const open = _sessionTabs(_receiptSessionKey()).length;
  if (open < 2) return;
  throw new Error(
    `Tab safety: this session's current tab was closed and ${open} of its tabs are still open, ` +
    `so "${extensionType}" does not say which one to use. Pass that tab's receipt ` +
    "(from safari_new_tab or safari_list_tabs), or pick it with safari_switch_tab."
  );
}

function _sanitizeTabResult(value) {
  let normalized = value;
  if (typeof normalized === "string") {
    try { normalized = JSON.parse(normalized); } catch { return normalized; }
  }
  if (Array.isArray(normalized)) return normalized.map(_sanitizeTabResult);
  if (!normalized || typeof normalized !== "object") return normalized;
  const safeUrl = _safeUrlForOutput(normalized.safeUrl || normalized.url || normalized.requestedUrl || "");
  const receipt = _receiptToken(normalized.receipt || normalized.receiptUrl || "");
  // The extension hands every receipt out here, with the page it is valid on.
  _noteReceiptOrigin(receipt, safeUrl);
  return {
    ...(normalized.index !== undefined ? { index: normalized.index } : {}),
    ...(normalized.tabIndex !== undefined ? { tabIndex: normalized.tabIndex } : {}),
    // ponytail: list_tabs echoes every tab of the window; empty titles, "active:false" and
    // 200-char titles were roughly a third of its bytes (p90 5KB, max 30KB per call).
    ...(normalized.title ? { title: String(normalized.title).slice(0, 80) } : {}),
    ...(safeUrl !== "unknown" ? { safeUrl } : {}),
    ...(receipt ? { receipt } : {}),
    ...(normalized.active ? { active: true } : {}),
    ...(normalized.owned !== undefined ? { owned: !!normalized.owned } : {}),
    ...(normalized.clicked !== undefined ? { clicked: !!normalized.clicked } : {}),
    ...(normalized.popupOpened !== undefined ? { popupOpened: !!normalized.popupOpened } : {}),
  };
}

// After a navigation, the session's current page is the URL it asked for. safari.navigate() records
// one for the AppleScript path, but a navigation the extension served left safari.js holding the URL
// the tab had left, which the tool then released: without SAFARI_PROFILE every later write of that
// client was judged by a URL it no longer owned, and refused (#172). The requested URL, not the one
// the page reports: extensionOrFallback has already claimed it, while a redirect target is the
// site's choice (see run_script's newTab). Skipped when a call alongside has made another tab the
// session's current one: the navigated page is not that tab's.
function _pinNavigatedUrl(url, usedReceipt) {
  if ((usedReceipt || "") !== (_getActiveReceipt() || "")) return;
  safari.setActiveTabURL(url);
}

// A receipt is bound to the origin it was minted on, so a cross-origin navigation the
// caller itself asked for used to strand every following call ("not valid for this
// origin", ~80×/week). The caller chose the destination, so rotating here is safe —
// hand back the new receipt instead of letting the caller discover the trap. Every
// navigation a caller asks for goes through here: safari_navigate, safari_navigate_and_read
// and run_script's navigate and navigateAndRead steps. `usedReceipt` is the receipt the
// navigation carried, read before it waited: a call alongside that names another tab makes
// that tab the session's current one, and the rotation has to name the tab that navigated.
// navigate_and_read answers with its page as a JSON string; a rotated result is an object.
async function _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl) {
  let page = result;
  if (typeof page === "string") {
    try { page = JSON.parse(page); } catch { return result; }
  }
  const landed = page && typeof page === "object" ? page.url : "";
  // The origin of the receipt the navigation carried: extensionOrFallback sends the newest one.
  if (!landed || !usedReceipt || _originOf(landed) === (_receiptOrigins.get(_receiptToken(usedReceipt)) || _originOf(oldUrl))) {
    return result;
  }
  try {
    // Named for the alias below, as run_script getReceipt names it (see _receiptAliases).
    const fresh = _sanitizeTabResult(await extensionOrFallback(
      "get_tab_receipt", { ..._explicitReceipt({ receipt: usedReceipt }) }, () => null
    ));
    if (fresh?.receipt) {
      _aliasReceipt(_receiptToken(usedReceipt), fresh.receipt); // the receipt extensionOrFallback sent
      _setActiveReceipt(fresh.receipt);
      return { ...page, receipt: fresh.receipt };
    }
  } catch { /* the navigation itself succeeded — a failed rotation must not fail the tool */ }
  return result;
}

function _isBatchSemanticFailure(result) {
  if (Array.isArray(result)) return result.some(_isBatchSemanticFailure);
  if (result && typeof result === "object") return result.ok === false;
  if (typeof result !== "string") return false;
  return /(?:^|\n)\s*(?:Element not found|Not found|Tab not found|TIMEOUT|No click target|Typed 0 chars)\b/i.test(result) ||
    /(?:^|\n)\s*Selected:\s*(?:\(index -1\))?\s*$/im.test(result);
}

async function _runExtensionBatchAction(action, args = {}) {
  const mySession = `${SESSION_ID}:${currentSessionId()}`;
  const normalize = (value) => {
    if (typeof value !== "string") return value;
    try { return JSON.parse(value); } catch { return value; }
  };
  const syncResultUrl = (value, fallbackUrl = "") => {
    const normalized = normalize(value);
    const landedUrl = normalized && typeof normalized === "object" && normalized.url
      ? normalized.url
      : fallbackUrl;
    if (landedUrl) {
      safari.setActiveTabURL(landedUrl);
      _addOwnedURL(landedUrl);
    }
    return normalized;
  };

  switch (action) {
    case "newTab": {
      const requestedUrl = String(args.url || "");
      let viaAppleScript = false;
      const raw = await extensionOrFallback(
        "new_tab", { url: requestedUrl },
        () => { viaAppleScript = true; return safari.newTab(requestedUrl); }
      );
      const value = _sanitizeTabResult(normalize(raw));
      if (value && typeof value === "object" && value.tabIndex) {
        safari.setActiveTabIndex(value.tabIndex);
        _trackTab(value.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", value.receipt);
        _setActiveReceipt(value.receipt);
      }
      const trackUrl = requestedUrl || "about:blank";
      if (trackUrl) {
        safari.setActiveTabURL(trackUrl);
        _addOwnedURL(trackUrl);
      } else {
        _markBlankTabOpened();
      }
      // safari.js still holds the marker of the last tab AppleScript opened; its fallback
      // would go back there.
      if (!viaAppleScript) safari.setActiveTabFromExtension(value?.tabIndex, trackUrl);
      return value;
    }

    case "switchTab": {
      const index = args.index === undefined ? undefined : Number(args.index);
      const receipt = _receiptToken(args.receipt || args.receiptUrl || args.url || "");
      if ((args.receipt || args.receiptUrl || args.url) && !receipt) {
        throw new Error("Tab safety: switchTab requires an extension-issued receipt");
      }
      if (!receipt && (!Number.isInteger(index) || index < 1)) {
        throw new Error("switchTab requires a positive index or an extension-issued receipt");
      }
      if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
        throw new Error("switchTab index must be a positive integer when provided");
      }
      let viaAppleScript = false;
      const raw = await extensionOrFallback(
        "switch_tab",
        receipt ? { ...(index ? { index } : {}), receipt } : { index },
        () => {
          if (receipt) throw _receiptNeedsExtension("switch_tab");
          viaAppleScript = true;
          return safari.switchTab(index);
        }
      );
      const value = _sanitizeTabResult(raw);
      const resolvedIndex = value?.tabIndex || index;
      if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
      // Point the AppleScript fallback at this tab, not the last one AppleScript claimed. Its
      // URL is left unknown rather than set to the reported one: this path never registered
      // that URL, so the ownership guard would start refusing the steps after it.
      if (!viaAppleScript && value && typeof value === "object") safari.setActiveTabFromExtension(resolvedIndex, null);
      // A switch by index that returns no receipt still makes that tab the current one.
      // Keeping the previous tab's receipt sent every later step back to the previous tab.
      if (value && typeof value === "object") _setActiveReceipt(value.receipt || receipt);
      return value;
    }

    case "listTabs":
      return _sanitizeTabResult(await extensionOrFallback("list_tabs", {}, () => safari.listTabs()));

    case "getReceipt": {
      // A receipt that is not one (a token cut short, the advice's "<old receipt>" left in) fell
      // through to the current tab's receipt, and the step rotated that tab instead.
      if (args.receipt && !_receiptToken(args.receipt)) {
        throw new Error("Tab safety: getReceipt requires an extension-issued receipt");
      }
      const previous = _receiptToken(args.receipt || _getActiveReceipt());
      // The payload names the receipt aliased below. extensionOrFallback would otherwise attach the
      // session's receipt as it stands after its own awaits, which a call running alongside can change.
      const value = normalize(await extensionOrFallback(
        "get_tab_receipt", previous ? { receipt: previous } : {},
        () => {
          throw new Error("getReceipt: the Safari extension did not hand back a new receipt (the server log has its " +
            "reason; safari_doctor shows whether it is connected). AppleScript touched no tab.");
        }
      ));
      const safeValue = _sanitizeTabResult(value);
      // The rotated tab is now the current one. Its URL stays unknown in safari.js: a receipt
      // is rotated because the tab left the origin this session registered.
      if (safeValue?.index) safari.setActiveTabFromExtension(safeValue.index, null);
      if (safeValue?.receipt) {
        // Rotation retires the old token. Keep it resolving to the new one, as navigate does,
        // so the tab stays reachable — and closable by the tab cap — by the name it was opened with.
        _aliasReceipt(_receiptToken(previous), safeValue.receipt); // the receipt extensionOrFallback sent
        _setActiveReceipt(safeValue.receipt);
      }
      return safeValue;
    }

    case "closeTab": {
      const index = args.index == null ? undefined : Number(args.index);
      // An index that is not a tab number (0, -1, 1.5, "abc") was dropped here or ignored by the
      // extension, and the close took the current tab. switchTab refuses one, as closeOwnTab does
      // without a profile.
      if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
        throw new Error("Tab safety: closeTab index must be a positive integer when provided");
      }
      // As in safari_close_tab: a receipt that is not one is refused, not read as the current tab's.
      const supplied = args.receipt || args.receiptUrl || args.url || "";
      const receipt = supplied ? _receiptToken(supplied) : _getActiveReceipt();
      if (supplied && !receipt) throw new Error("Tab safety: closeTab requires an extension-issued receipt");
      const closesCurrent = !receipt || receipt === _getActiveReceipt();
      const raw = await extensionOrFallback(
        "close_tab", { ...(index ? { index } : {}), ...(receipt ? { receipt } : {}) },
        () => {
          if (receipt) throw _receiptNeedsExtension("close_tab");
          return safari.closeOwnTab(index);
        }
      );
      if (/^Tab not found/.test(String(raw))) return normalize(raw);
      // A named profile closes through the extension only: the tab that closed is the receipt's.
      _untrackClosedTab({ receipt });
      if (closesCurrent) {
        safari.setActiveTabIndex(null);
        safari.setActiveTabURL(null);
        _clearActiveReceipt();
      }
      return normalize(raw);
    }

    case "navigate": {
      const url = String(args.url || "");
      if (!url) throw new Error("navigate requires url");
      const oldUrl = safari.getActiveTabURL();
      const usedReceipt = _getActiveReceipt();
      const raw = await extensionOrFallback(
        "navigate", { url, timeout: args.timeout, ..._explicitReceipt({ receipt: usedReceipt }) },
        () => safari.navigate(url)
      );
      return _rotateReceiptAfterNavigate(syncResultUrl(raw, url), usedReceipt, oldUrl);
    }

    case "navigateAndRead": {
      const url = String(args.url || "");
      if (!url) throw new Error("navigateAndRead requires url");
      const oldUrl = safari.getActiveTabURL();
      const usedReceipt = _getActiveReceipt();
      const raw = await extensionOrFallback(
        "navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout, ..._explicitReceipt({ receipt: usedReceipt }) },
        async () => { await safari.navigate(url); return safari.readPage({ maxLength: args.maxLength }); }
      );
      safari.setActiveTabURL(url);
      return _rotateReceiptAfterNavigate(normalize(raw), usedReceipt, oldUrl);
    }

    case "readPage":
      return normalize(await extensionOrFallback(
        "read_page", { selector: args.selector, maxLength: args.maxLength },
        () => safari.readPage(args)
      ));

    case "snapshot": {
      const gen = safari.getNextSnapshotGen();
      return await extensionOrFallback(
        "snapshot", { selector: args.selector, gen },
        () => safari.takeSnapshot({ selector: args.selector, _gen: gen })
      );
    }

    case "getElementInfo":
      return normalize(await extensionOrFallback(
        "get_element", { selector: args.selector },
        () => safari.getElementInfo(args)
      ));

    case "querySelectorAll":
      return normalize(await extensionOrFallback(
        "query_all", { selector: args.selector, limit: args.limit },
        () => safari.querySelectorAll(args)
      ));

    case "waitFor":
      return await extensionOrFallback(
        "wait_for", { selector: args.selector, text: args.text, timeout: args.timeout },
        () => safari.waitFor(args)
      );

    case "waitForTime": {
      const ms = Math.max(0, Math.min(Number(args.ms) || 0, 30000));
      await new Promise((resolve) => setTimeout(resolve, ms));
      return `Waited ${ms}ms`;
    }

    case "click":
      return await extensionOrFallback(
        "click", { ref: args.ref, selector: args.selector, text: args.text, x: args.x, y: args.y },
        () => safari.click(args)
      );

    case "clickAndOpenPopup": {
      const selector = typeof args.selector === "string" && args.selector ? args.selector : "";
      const ref = typeof args.ref === "string" && args.ref ? args.ref : "";
      if ((selector ? 1 : 0) + (ref ? 1 : 0) !== 1) {
        throw new Error("clickAndOpenPopup requires exactly one of selector or ref");
      }
      const raw = await extensionOrFallback(
        "click_open_popup", { ...(selector ? { selector } : { ref }) },
        () => { throw new Error("clickAndOpenPopup requires a verified Safari profile extension"); }
      );
      return _sanitizeTabResult(normalize(raw));
    }

    case "fill":
      return await extensionOrFallback(
        "fill", { selector: args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector, value: args.value },
        () => safari.fill(args)
      );

    case "fillForm":
      return await extensionOrFallback(
        "fill_form", { fields: args.fields },
        () => safari.fillForm(args)
      );

    case "clearField":
      return await extensionOrFallback(
        "clear_field", { selector: args.selector },
        () => safari.clearField(args)
      );

    case "typeText":
      return await extensionOrFallback(
        "type_text", { text: args.text, selector: args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector },
        () => safari.typeText(args)
      );

    case "selectOption":
      return await extensionOrFallback(
        "select_option", { selector: args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector, value: args.value },
        () => safari.selectOption(args)
      );

    case "pressKey":
      return await extensionOrFallback(
        "press_key", { key: args.key, modifiers: args.modifiers },
        () => safari.pressKey(args)
      );

    case "scroll":
      return await extensionOrFallback(
        "scroll", { direction: args.direction, amount: args.amount },
        () => safari.scroll(args)
      );

    case "scrollTo":
      return await extensionOrFallback(
        "scroll_to", { x: args.x, y: args.y },
        () => safari.scrollTo(args)
      );

    case "scrollToElement":
      return await extensionOrFallback(
        "scroll_to_element", { selector: args.selector, text: args.text, block: args.block, timeout: args.timeout },
        () => safari.scrollToElement(args)
      );

    case "hover":
      return await extensionOrFallback(
        "hover", { selector: args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector },
        () => safari.hover(args)
      );

    case "evaluate":
      return normalize(await extensionOrFallback(
        "evaluate", { script: args.script },
        () => safari.evaluate(args)
      ));

    case "reload":
      return syncResultUrl(await extensionOrFallback(
        "reload", { hard: args.hard },
        () => safari.reload(args.hard)
      ));

    case "goBack":
      return syncResultUrl(await extensionOrFallback("go_back", {}, () => safari.goBack()));

    case "goForward":
      return syncResultUrl(await extensionOrFallback("go_forward", {}, () => safari.goForward()));

    default:
      throw new Error(`Unsupported extension batch action: ${action}`);
  }
}

// Tab-ownership assertion — shared by extensionOrFallback AND the tools that bypass it
// (safari_run_script, native_*). Throws if the operation would land on a tab this MCP
// session didn't open. Read-only / tab-management ops (in _noOwnershipCheck) are exempt.
// A session with no tab of its own may only read: without one, AppleScript acts on the
// front document, the tab the user is looking at.
function _assertTabOwnership(opType, extensionPayload = {}) {
  if (_noOwnershipCheck.has(opType)) return;
  // Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
  // user's tab writable, not disposable — a wrong close costs work that cannot be undone
  // (#68). Checked before every early return so the batch action and the tool share it.
  // The session's marker on the tab says it was adopted, not its URL: the tab keeps that
  // marker wherever it navigates, and another tab on the same URL does not carry it.
  if (opType === "close_tab" && safari.isActiveTabAdopted()) {
    const msg = `⚠️ Tab safety: refusing "close_tab" — this tab was adopted from you via SAFARI_MCP_ALLOW_USER_TABS, not opened by this MCP session. Close it yourself, or open your own tab with safari_new_tab.`;
    console.error(`[Safari MCP] ${msg}`);
    throw new Error(msg);
  }
  // In a named profile the extension is the authority. A bearer receipt may be
  // presented after a stateless reconnect; only the extension can validate its exact
  // tab binding, freshness, digest, and origin.
  if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
  // A rotation that names its receipt touches no page and never falls back to AppleScript, so the
  // extension alone judges it, as a profile does every receipt. Without a profile, the URL safari.js
  // holds can be where a redirect took the tab during an AppleScript load, which the session never
  // registered, or, after safari_navigate, the URL the tab left, which the navigation released: this
  // check refused the rotation on both.
  if (opType === "get_tab_receipt" && _receiptToken(extensionPayload.receipt)) return;
  const currentUrl = safari.getActiveTabURL();
  // An adopted tab (#92) is the session's target even though the session opened nothing.
  if (safari.isActiveTabAdopted()) {
    console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
    return;
  }
  // Asked of THIS session: whether it opened, claimed or adopted a tab (safari.js records that per
  // session, for AppleScript's tabs and the extension's alike), or names one by the receipt passed
  // with this call — a session with no state of its own yet, such as a subagent or the same agent
  // after a reconnect. The extension applies the same rule on its own path (handleCommand in
  // extension/background.js). It was asked of _ownedTabURLs and _openedTabs, which hold every
  // session's tabs in this process, and through owned-tabs.json every process's, so a tab opened
  // anywhere on the machine let a session that had none write, into the user's tab.
  if (!_receiptToken(extensionPayload.receipt) && !safari.hasOwnedTab()) {
    const msg = `⚠️ Tab safety: refusing "${opType}" — this MCP session has no tab of its own, so it would act on the tab in front, which is the user's. Open one with safari_new_tab, or pass the receipt of a tab you opened, with the call or to safari_switch_tab (a receipt survives reconnects and restarts).`;
    console.error(`[Safari MCP] ${msg}`);
    throw new Error(msg);
  }
  // A blank tab has no URL to own; the current tab of a session that has one is its own.
  const blank = currentUrl === 'about:blank' || currentUrl === 'missing value';
  if (currentUrl && !blank && !_isURLOwned(currentUrl)) {
    const msg = `⚠️ Tab safety: refusing "${opType}" — current tab (${_safeUrlForOutput(currentUrl)}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
    console.error(`[Safari MCP] ${msg}`);
    throw new Error(msg);
  }
}

// Try the profile-verified extension first. Named profiles are extension-only:
// crossing to AppleScript would lose the extension's profile and tab-id proof.
async function extensionOrFallback(extensionType, extensionPayload, fallbackFn) {
  _refuseUnnamedTab(extensionType, extensionPayload);
  // Tab-ownership guard — extracted to _assertTabOwnership so run_script / native_* share it.
  _assertTabOwnership(extensionType, extensionPayload);
  // A navigation claims its destination before it starts, so a load that throws halfway still
  // leaves the tab reachable (safari_switch_tab recovery). Only once the guard has passed: the
  // callers used to claim it first, and the destination vouched for itself — on a clean machine
  // it was the one owned URL the guard looked for, and a refused call left it in owned-tabs.json.
  if (extensionType === "navigate" || extensionType === "navigate_and_read") _addOwnedURL(extensionPayload.url);

  // Safari may terminate an otherwise healthy profile worker between two tool
  // calls. If no command has been sent yet, waiting for its verified reconnect is
  // safe even for non-idempotent actions (click/fill): there is nothing to repeat.
  if (_preferAppleScript && (!_extensionConnected || !_profileExtensionVerified)) {
    await _waitForVerifiedProfileExtension(30000);
  }
  // Without a named profile a new tab gets a shorter grace. Safari suspends the worker now
  // and then, and an AppleScript tab opened in that gap has no receipt, which the extension
  // refuses once it is back. So when the extension served this daemon in the last two minutes,
  // wait up to ~10 s (one proxy primary check) for it; a daemon that never had it, or lost it
  // long ago, falls back at once. The timestamp lives on this function so the guard stays
  // self-contained. Idea and diagnosis from PR #190 by @kad-xyz.
  if (!_preferAppleScript && extensionType === "new_tab" && !_extensionConnected &&
      Date.now() - (extensionOrFallback.servedAt || 0) < 120000) {
    const deadline = Date.now() + 10500;
    while (!_extensionConnected && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 250));
  }

  // ========== FOCUS PRESERVATION ==========
  // Safari AppleScript/extension can steal focus (bring Safari window to front).
  // Save the frontmost app before the operation and restore it after if Safari stole focus.
  // Set focusGuard flag so inner osascript/runJSLarge calls skip their own focus logic.
  const opStart = Date.now();
  const savedApp = _preferAppleScript ? null : await safari.saveFrontmostApp();
  safari.setFocusGuard(true);

  let result;
  let usedExtension = false;
  // The extension's evaluate ladder has a terminal state: every injection strategy was
  // refused. It announces itself as "Falling back to AppleScript", so the caller must
  // never be handed an empty value as if the script had simply returned nothing (#106).
  let hardCspBlock = false;
  try {
    if (_extensionConnected && (!_preferAppleScript || _profileExtensionVerified)) {
      try {
        const t0 = Date.now();
        const activeReceipt = _getActiveReceipt();
        // Composite id: SESSION_ID alone is process-wide, so in HTTP-daemon mode every
        // MCP client looked like ONE extension session and could target another client's
        // cached tab (#76). currentSessionId() alone would collapse all stdio processes
        // into "_default" — so both parts are needed.
        // A receipt is an opaque capability, not a URL. Keep it out of page state and
        // attach it automatically only to commands that target the current logical tab.
        const attachActiveReceipt = !_UNTARGETED_COMMANDS.has(extensionType);
        const reloadHandoff = extensionType === "reload_extension" && _isExtensionHost
          ? _prepareReloadHttpWorkerHandoff()
          : null;
        const namedReceipt = _receiptToken(extensionPayload.receipt);
        const payload = {
          ...(attachActiveReceipt && activeReceipt ? { receipt: activeReceipt } : {}),
          ...extensionPayload,
          // A receipt named when the call started follows a rotation of its tab made while the call
          // waited above (by another client of an HTTP daemon): an alias joins receipts of one tab.
          ...(namedReceipt ? { receipt: namedReceipt } : {}),
          ...(reloadHandoff ? { reloadHandoff: reloadHandoff.token } : {}),
          sessionId: `${SESSION_ID}:${currentSessionId()}`,
        };
        const timeout = _commandTimeouts[extensionType] || 30000;
        try {
          result = await sendToExtension(extensionType, payload, timeout);
        } catch (error) {
          if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
          throw error;
        }
        // The ladder's own terminal marker. It was not in this list, so the fallback the
        // marker's text promises never actually ran and its text reached the caller as
        // the tool's value instead of as a failure (#106).
        // It is the whole reply of an evaluate, so it is matched as that and nowhere else. A
        // result is page text as often as not: matched anywhere in it, CSP wording read a page
        // that mentions Trusted Types as a blocked command, which a profile session then
        // refused and any other session ran a second time through AppleScript. Every other
        // command's failure arrives as a rejection, never as its result.
        hardCspBlock = extensionType === "evaluate" && typeof result === 'string' && result.startsWith('Error: CSP blocked all strategies');
        const isPermissionDenied = result === '__SCREENSHOT_PERMISSION_DENIED__';
        const isElementMiss = typeof result === 'string' && result.startsWith('Element not found');
        const isFailed = result === null || isElementMiss;
        if (isPermissionDenied) {
          console.error(`[Safari MCP] ${extensionType} permission denied (${Date.now() - t0}ms) — falling back to AppleScript`);
        } else if (hardCspBlock) {
          console.error(`[Safari MCP] ${extensionType} CSP blocked: ${result?.substring(0, 100)} (${Date.now() - t0}ms) — falling back to AppleScript`);
        } else if (isFailed && _nullMeansFailure.has(extensionType)) {
          if (_preferAppleScript && isElementMiss) {
            // In a profile-scoped session AppleScript is intentionally forbidden.  An
            // exhaustive DOM lookup returning no element is a semantic miss from a
            // healthy extension, not evidence that its backend disconnected. Preserve
            // the exact result so callers can correct their selector instead of seeing
            // the misleading generic "profile extension unavailable" error.
            console.error(`[Safari MCP] ${extensionType} semantic miss via extension: ${result} (${Date.now() - t0}ms)`);
            usedExtension = true;
          } else {
            console.error(`[Safari MCP] ${extensionType} extension failed: ${result} (${Date.now() - t0}ms) — falling back to AppleScript`);
          }
        } else {
          console.error(`[Safari MCP] ${extensionType} via extension (${Date.now() - t0}ms)`);
          usedExtension = true;
        }
      } catch (err) {
        // A profile extension ownership refusal is a security boundary, not a
        // transient backend failure. Falling back to AppleScript here would bypass
        // the extension's tab-id proof and could switch to or mutate a user's tab.
        if (String(err?.message || err).includes("Tab safety:")) {
          if (/not valid for this origin/.test(err.message)) {
            // The receipt goes in "args": the tool's schema drops a key beside "action", and
            // getReceipt then rotated the session's current tab, or was refused after a reconnect.
            err.message += " Likely cause: the tab moved to another origin after the receipt was minted (redirect/login). " +
              "Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}], or reopen with safari_new_tab.";
          }
          throw err;
        }
        if (_preferAppleScript) throw new Error(`Safari profile extension unavailable for "${extensionType}": ${err.message}`);
        // evaluate runs arbitrary side effects. Once the extension may have started the
        // script — the command reached it and did not come back with "did not start" —
        // AppleScript would run it a second time: every POST in it repeated.
        if (extensionType === "evaluate" && err?.dispatched !== false && !/evaluate did not start/.test(err.message)) {
          throw new Error(`safari_evaluate: ${err.message} — the script may already have run in the page, so it was not re-run through AppleScript`, { cause: err });
        }
        console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
      }
    }
    if (usedExtension) extensionOrFallback.servedAt = Date.now();
    if (!usedExtension) {
      if (_preferAppleScript) {
        // The extension answered here, so naming it unavailable sent callers after a bridge
        // that worked (copilot.microsoft.com, 4-5.10.26: Trusted Types turn away its policy).
        if (hardCspBlock) {
          throw new Error(
            "safari_evaluate: this page's Content-Security-Policy refused every way to run a script string " +
            "(no eval, no inline script the extension can add), so the script did not run. A profile-scoped " +
            "session never falls back to AppleScript. Tools that run no script string still work here: " +
            "safari_read_page, safari_snapshot, safari_get_element, safari_click, safari_fill."
          );
        }
        throw new Error(`Safari profile extension unavailable for "${extensionType}"; refusing AppleScript fallback`);
      }
      const t0 = Date.now();
      result = await fallbackFn();
      console.error(`[Safari MCP] ${extensionType} via AppleScript (${Date.now() - t0}ms)`);
      // Both paths are now exhausted. An empty value here means the script never ran,
      // which is byte-identical to a script that legitimately returned nothing — and an
      // agent reads that as "no such element" and keeps building on it. Fail instead.
      if (hardCspBlock && (result === undefined || result === null)) {
        throw new Error(
          `safari_${extensionType}: this page refused every JavaScript injection strategy (CSP), ` +
          "and the AppleScript fallback returned nothing — the script did not run. " +
          "Reading the page still works here: safari_read_page, safari_snapshot, safari_get_element, safari_extract_links."
        );
      }
    }
  } finally {
    safari.setFocusGuard(false);
    // Restore focus in finally — even when the operation threw, Safari may have come to
    // the front mid-op and must be sent back, or the user is left staring at the
    // automation window with their keystrokes landing in it.
    //
    // ...BUT only for AppleScript ops. AppleScript raises Safari to the front itself,
    // so a restore correctly returns focus to wherever the user was. Extension ops run
    // as background JS injection and NEVER raise Safari — so if Safari is frontmost
    // after an extension op, it's because the USER switched into it mid-op. Restoring
    // then yanks Safari out from under them (the "VS Code jumps in front while I work
    // in Safari" bug), and the HID-idle guard can't catch it because a user who is
    // reading (not typing) looks idle. So skip restore entirely for extension ops.
    if (!usedExtension && savedApp) await safari.restoreFocusIfStolen(savedApp, opStart);
  }

  return result;
}

// The cookie / localStorage / sessionStorage tools run their page JavaScript through the
// same extension-first ladder as safari_evaluate. They used to go straight to AppleScript,
// which is why a stuck Apple Events channel failed safari_delete_cookies with
// `Safari profile "X" window not found` while list_tabs, run_script and evaluate kept
// working (geo-audit, 2026-09-20). The fallback safari.js hands us is its original runJS.
safari.setPageJSRunner((script, fallback) => extensionOrFallback("evaluate", { script }, fallback));

// safari.js lets AppleScript act only on a tab that carries this session's identity marker. A tab
// the extension opened or picked has none, and its index or URL does not say which tab it is now
// (a tab the user closed or dragged, another front window, their own tab on the same site), so
// the extension, which knows the tab by its id, writes a fresh marker there: safari.js asks when
// AppleScript first needs the tab, and again once a cross-site load has cleared window.name.
// mark_tab writes it from a fixed function, which no page CSP can refuse; an extension built
// before mark_tab can only use evaluate, which a strict CSP (github.com) does refuse.
safari.setExtensionTabMarker(async (marker) => {
  if ((!_extensionConnected && !_primaryHasExtension) || (_preferAppleScript && !_profileExtensionVerified)) return false;
  // No current tab — it was closed — so nothing to mark.
  if (!_activeReceipts.has(_receiptSessionKey())) return false;
  const receipt = _getActiveReceipt();
  const target = { ...(receipt ? { receipt } : {}), sessionId: `${SESSION_ID}:${currentSessionId()}` };
  try {
    return (await sendToExtension("mark_tab", { ...target, marker }, _commandTimeouts.evaluate)) === true;
  } catch (err) {
    if (!/Unknown command/.test(String(err?.message || err))) return false;
  }
  try {
    const script = `(window.name=window.__mcpTabMarker=${JSON.stringify(marker)},'1')`;
    return (await sendToExtension("evaluate", { ...target, script }, _commandTimeouts.evaluate)) === "1";
  } catch {
    return false;
  }
});

// SAFARI_MCP_TOOLS (idea from PR #204 by @fahadjibransheikh): "core" registers the everyday tools
// below, or give a comma list of tool names, "core" allowed among them. Unset or "all" registers
// every tool. A client that loads every definition up front pays for all of them on each turn.
const _CORE_TOOLS = `
  new_tab close_tab list_tabs switch_tab wait_for_new_tab navigate navigate_and_read go_back
  reload snapshot read_page screenshot get_element extract_links click click_and_wait
  fill fill_form type_text press_key select_option scroll hover upload_file
  handle_dialog wait_for evaluate run_script get_console network doctor
`.trim().split(/\s+/).map((name) => `safari_${name}`);
function _toolsToRegister(spec) {
  const names = String(spec || "").split(",").map((name) => name.trim()).filter(Boolean);
  if (!names.length || names.includes("all")) return null;
  return new Set(names.flatMap((name) => (name === "core" ? _CORE_TOOLS : [name])));
}
const _registeredTools = _toolsToRegister(process.env.SAFARI_MCP_TOOLS);

// Read version from package.json to avoid hardcoded mismatch
const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
// Factory so each MCP session (in HTTP mode) gets its own McpServer — McpServer is single-connection.
// stdio mode calls this exactly once, identical to the historical inline server. Tool bodies are
// unchanged; they close over the module-global safari state, which stays shared across sessions
// (correct: one physical Safari window). See docs/http-transport-design.md.
function buildServer() {
const server = new McpServer({
  name: "safari-mcp",
  version: _pkgVersion,
  description: "Safari browser automation - lightweight, keeps logins",
});
if (_registeredTools) {
  const register = server.tool.bind(server);
  server.tool = (name, ...rest) => (_registeredTools.has(name) ? register(name, ...rest) : undefined);
}

// ========== NAVIGATION ==========

server.tool(
  "safari_navigate",
  "Navigate this session's tab to a URL (pass `receipt` to pin the tab). Waits for the page to load. Returns {title, url} — plus a fresh `receipt` when the origin changed; use that one from then on.",
  {
    url: z.string().describe("URL to navigate to"),
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
  },
  async ({ url, receipt }) => {
    // The profile extension can only issue receipts for http(s) pages: after a trip to
    // about:blank or file:// no tool could reach the tab again, not even to close it.
    if (process.env.SAFARI_PROFILE && !/^https?:\/\//i.test(url.trim())) {
      throw new Error(`safari_navigate only opens http(s) URLs in a Safari profile ("${url.slice(0, 40)}" would leave the tab unreachable). To free the tab, use safari_close_tab.`);
    }
    const oldUrl = safari.getActiveTabURL();
    // extensionOrFallback claims the destination for this session once its guard passes.
    // Read once, before anything waits: a call alongside that names another tab makes that tab the
    // session's current one, and the rotation below has to name the tab this call navigated.
    const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
    const result = await extensionOrFallback(
      "navigate", { url, ..._explicitReceipt({ receipt: usedReceipt }) },
      () => safari.navigate(url)
    );
    // Tab kept its identity, just changed URL — drop the stale old URL from ownership.
    if (oldUrl && oldUrl !== url && oldUrl !== 'about:blank') _removeOwnedURL(oldUrl);
    _pinNavigatedUrl(url, usedReceipt);
    return textResult(await _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl));
  }
);

server.tool(
  "safari_go_back",
  "Go back in browser history",
  { receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"), },
  async ({ receipt }) => {
    const result = await extensionOrFallback("go_back", { ..._explicitReceipt({ receipt }) }, () => safari.goBack());
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  }
);

server.tool(
  "safari_go_forward",
  "Go forward in browser history",
  { receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"), },
  async ({ receipt }) => {
    const result = await extensionOrFallback("go_forward", { ..._explicitReceipt({ receipt }) }, () => safari.goForward());
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  }
);

server.tool(
  "safari_reload",
  "Reload the current page",
  { hard: z.boolean().optional().describe("Hard reload (bypass cache)"), receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"), },
  async ({ hard, receipt }) => {
    const result = await extensionOrFallback("reload", { hard, ..._explicitReceipt({ receipt }) }, () => safari.reload(hard));
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  }
);

// ========== PAGE INFO ==========

server.tool(
  "safari_read_page",
  "Read page text content (title, URL, body text). Use for reading article text or page content. For interacting with elements, prefer safari_snapshot (gives ref IDs). Use selector to read specific element. Use maxLength to limit output.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector to read specific element"),
    maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
  },
  async ({ selector, maxLength, receipt }) => {
    const result = await extensionOrFallback(
      "read_page", { selector, maxLength, ..._explicitReceipt({ receipt }) },
      () => safari.readPage({ selector, maxLength })
    );
    return textResult(result);
  }
);

server.tool(
  "safari_get_source",
  "Get HTML source of current page",
  { maxLength: z.coerce.number().optional().describe("Max chars (default: 200000)") },
  async ({ maxLength }) => {
    const result = await extensionOrFallback(
      "get_source", { maxLength },
      () => safari.getPageSource({ maxLength })
    );
    return textResult(result);
  }
);

// ========== SNAPSHOT (ref-based interaction like Chrome DevTools MCP) ==========

server.tool(
  "safari_snapshot",
  "PREFERRED way to see page state. Returns accessibility tree with ref IDs for every interactive element. Use refs with click/fill/type instead of CSS selectors. Workflow: snapshot → see refs → click({ref:'0_5'}). PREFER THIS over safari_screenshot (cheaper, structured text vs heavy image) and over safari_read_page (includes interactive refs). Use safari_screenshot only when you need to see visual layout/styling.",
  {
    selector: z.string().optional().describe("CSS selector for subtree (default: full page)"),
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    diff: z.boolean().optional().describe("Return only the lines that changed since this tab's previous snapshot (+ added, with fresh refs; - removed). Much smaller after a click or a fill"),
  },
  async (args) => {
    const gen = safari.getNextSnapshotGen();
    // Read before the call: a reply can make another tab the session's current one.
    const tab = _receiptToken(args.receipt) || _getActiveReceipt() || "";
    const result = await extensionOrFallback(
      "snapshot", { selector: args.selector, gen, ..._explicitReceipt(args) },
      () => safari.takeSnapshot({ ...args, _gen: gen })
    );
    if (typeof result !== "string") return textResult(result);
    // Every snapshot is remembered, so the first diff:true after a plain snapshot has a base.
    const previous = _lastSnapshots.swap(`${_receiptSessionKey()}|${tab}|${args.selector || ""}`, result);
    if (!args.diff) return textResult(result);
    if (previous === undefined) return textResult(`(No earlier snapshot of this tab to compare with, so this is the full snapshot.)\n${result}`);
    return textResult(diffSnapshots(previous, result));
  }
);

server.tool(
  "safari_navigate_and_read",
  "Navigate to a URL and return the page content in one step — saves 1 full round-trip vs navigate+read_page. Use instead of safari_navigate + safari_read_page. Like safari_navigate, returns a fresh `receipt` when the origin changed; use that one from then on.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    url: z.string().describe("URL to navigate to"),
    maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
    timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
  },
  async ({ url, maxLength, timeout, receipt }) => {
    const oldUrl = safari.getActiveTabURL();
    // Read before anything waits, as safari_navigate does: the rotation names the tab that navigated.
    const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
    const result = await extensionOrFallback(
      "navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt: usedReceipt }) },
      async () => {
        await safari.navigate(url);
        return safari.readPage({ maxLength });
      }
    );
    if (oldUrl && oldUrl !== url && oldUrl !== 'about:blank') _removeOwnedURL(oldUrl);
    _pinNavigatedUrl(url, usedReceipt);
    return textResult(await _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl));
  }
);

// ========== CLICK ==========

server.tool(
  "safari_click",
  "Click element. Use ref (from snapshot), selector, text, or x/y. Works on React/Airtable/virtual DOM apps via full PointerEvent+MouseEvent sequence + React Fiber fallback. Pure JS — never touches user's mouse. When using ref, always take a FRESH safari_snapshot first — refs expire after each new snapshot.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot (e.g. '0_5')"),
    selector: z.string().optional().describe("CSS selector"),
    text: z.string().optional().describe("Visible text to find and click"),
    x: z.coerce.number().optional().describe("X coordinate"),
    y: z.coerce.number().optional().describe("Y coordinate"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "click", { ref: args.ref, selector: args.selector, text: args.text, x: args.x, y: args.y, ..._explicitReceipt(args) },
      () => safari.click(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_click_and_read",
  "Click an element then return the updated page — saves 1 full round-trip vs separate click+read_page. Handles both React Router navigation and full page loads.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    text: z.string().optional().describe("Visible text of the element to click"),
    selector: z.string().optional().describe("CSS selector"),
    x: z.coerce.number().optional().describe("X coordinate"),
    y: z.coerce.number().optional().describe("Y coordinate"),
    wait: z.coerce.number().optional().describe("Ms to wait after click (default: auto-detect navigation)"),
    maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "click_and_read",
      { selector: args.selector, text: args.text, x: args.x, y: args.y, wait: args.wait, maxLength: args.maxLength, ..._explicitReceipt(args) },
      async () => {
        await safari.click(args);
        if (args.wait) {
          await new Promise(r => setTimeout(r, args.wait));
        } else {
          // Smart wait: brief pause then check if page is loading
          await new Promise(r => setTimeout(r, 50));
          const state = await safari.runJSQuick("document.readyState");
          if (state === "loading") {
            // Page is navigating — wait for it to complete
            await safari.runJSQuick("(async function(){for(var i=0;i<50;i++){if(document.readyState==='complete')return 'done';await new Promise(r=>setTimeout(r,200));}return 'timeout';})()");
          } else {
            await new Promise(r => setTimeout(r, 100)); // SPA settle time
          }
        }
        return safari.readPage({ maxLength: args.maxLength });
      }
    );
    return textResult(result);
  }
);

server.tool(
  "safari_double_click",
  "Double-click an element by CSS selector or x/y coordinates (e.g. to select a word in text)",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector"),
    x: z.coerce.number().optional().describe("X coordinate"),
    y: z.coerce.number().optional().describe("Y coordinate"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "double_click", { selector: args.selector, x: args.x, y: args.y, ..._explicitReceipt(args) },
      () => safari.doubleClick(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_right_click",
  "Right-click (context menu) an element by CSS selector or x/y coordinates",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector"),
    x: z.coerce.number().optional().describe("X coordinate"),
    y: z.coerce.number().optional().describe("Y coordinate"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "right_click", { selector: args.selector, x: args.x, y: args.y, ..._explicitReceipt(args) },
      () => safari.rightClick(args)
    );
    return textResult(result);
  }
);

// ========== NATIVE CLICK (OS-level, isTrusted: true) ==========

server.tool(
  "safari_native_click",
  "OS-level mouse click via macOS CGEvent — produces isTrusted: true events that pass WAF/bot detection (G2, Cloudflare, etc.). Use when regular safari_click fails with 405/403 errors or form submissions are blocked. Trade-off: physically moves the mouse cursor and requires Safari window to be visible. Use ref (from snapshot), selector, text, or x/y. When using ref, always take a FRESH safari_snapshot first.",
  {
    ref: z.string().optional().describe("Ref ID from safari_snapshot (e.g. '0_5')"),
    selector: z.string().optional().describe("CSS selector"),
    text: z.string().optional().describe("Visible text to find and click"),
    x: z.coerce.number().optional().describe("Viewport X coordinate"),
    y: z.coerce.number().optional().describe("Viewport Y coordinate"),
    doubleClick: z.boolean().optional().default(false).describe("Double-click instead of single click"),
  },
  async (args) => {
    // Native click always uses AppleScript path (no extension) — it needs OS-level access.
    // Bypasses extensionOrFallback, so assert tab-ownership explicitly — an OS-level click
    // on the user's front window would otherwise slip past the safety guard.
    _assertTabOwnership("native_click");
    // A profile can hold SEVERAL windows (measured: four "אוטומציות" at once), and the
    // AppleScript side picks the first by name — so coordinates were computed against
    // one window and the event delivered to another, a silent no-op. The extension is
    // the only party that knows which window holds this session's tab; ask it, and pin
    // both the geometry read and the click to that window. Falls back to the old
    // behaviour when the extension is unavailable.
    let locus = null;
    try { locus = await _tabLocusFromExtension(); } catch (_e) { /* AppleScript-only mode */ }
    const result = await safari.nativeClick({ ...args, locus });
    return textResult(result);
  }
);

// Resolve THIS session's tab window/index via the extension. Returns null when the
// extension can't answer (disconnected, AppleScript-only session).
async function _tabLocusFromExtension() {
  // Wait out a brief extension reconnect rather than silently falling back to the
  // broken postToPid path — that intermittency is exactly what made pinned clicks
  // land only sometimes. Bounded, so a genuinely absent extension still returns fast.
  try { await _waitForVerifiedProfileExtension(4000); } catch (_e) {}
  if (!_extensionConnected && !_primaryHasExtension) return null;
  // Deliberately NO tabUrl: an SPA rewrites its URL as you move through it, so passing
  // the URL we happened to record earlier makes the extension's URL-based lookup miss
  // and the whole pin silently degrade. Its per-session tab cache already knows which
  // tab this is — that is the authoritative answer here.
  const locus = await sendToExtension("get_tab_locus", {
    sessionId: `${SESSION_ID}:${currentSessionId()}`,
  }, 10000);
  return locus && typeof locus === "object" && locus.windowId ? locus : null;
}

server.tool(
  "safari_native_hover",
  "OS-level mouse hover via macOS CGEvent — moves the real cursor to an element to trigger native :hover / mouseenter handlers. Use for obfuscated UIs where JS-dispatched mouseenter isn't enough, like Discord server sidebars (tooltips only appear on real hover) or portal-rendered tooltips. After hover, call safari_wait_for or safari_evaluate to read the tooltip. Dwells for dwellMs to let tooltips render, then restores the original cursor position by default. Requires Safari window to be visible.",
  {
    ref: z.string().optional().describe("Ref ID from safari_snapshot"),
    selector: z.string().optional().describe("CSS selector"),
    text: z.string().optional().describe("Visible text to find and hover"),
    x: z.coerce.number().optional().describe("Viewport X coordinate"),
    y: z.coerce.number().optional().describe("Viewport Y coordinate"),
    dwellMs: z.coerce.number().optional().default(500).describe("Milliseconds to dwell over the element so tooltips render (clamped 0-5000)"),
    restoreMouse: z.boolean().optional().default(true).describe("Restore cursor to original position after dwell"),
  },
  async (args) => {
    _assertTabOwnership("native_hover");
    const result = await safari.nativeHover(args);
    return textResult(result);
  }
);

server.tool(
  "safari_native_keyboard",
  "OS-level keyboard event via macOS CGEvent — sends a real keypress (with optional modifiers) to the Safari window WITHOUT activating Safari or stealing focus. Use when safari_press_key's JS path doesn't reach React trust-gated handlers (Discord ProseMirror Enter, Slack send, virtualized editors). Keys: enter, return, tab, escape, space, delete, backspace, up/down/left/right, home, end, pageup, pagedown, f1-f6, a-z, 0-9 and common punctuation. Modifiers: cmd, shift, alt, ctrl. Produces isTrusted:true events. Never activates Safari — runs entirely in the background.",
  {
    key: z.string().describe("Key name: enter, escape, tab, space, arrow keys, letters, digits, etc."),
    modifiers: z.array(z.string()).optional().default([]).describe("Modifier keys: cmd, shift, alt, ctrl"),
  },
  async (args) => {
    _assertTabOwnership("native_keyboard");
    const result = await safari.nativeKeyboard(args);
    return textResult(result);
  }
);

server.tool(
  "safari_native_type",
  "Insert text into ANY editor via OS-level clipboard paste (CGEvent Cmd+V targeted to Safari window). Unlike safari_fill which manipulates DOM directly (breaking React/ProseMirror state), this goes through the real paste pipeline — ProseMirror/Slate/Draft.js process the paste event natively and update their internal model. After native_type, pressing Enter (via safari_native_keyboard) will actually submit the form because the framework state matches the DOM. Saves and restores the user's clipboard. No focus stealing. Use for Discord, Slack, and any editor where safari_fill works visually but the content isn't 'really there' when you try to submit.",
  {
    value: z.string().describe("Text to insert via clipboard paste"),
    selector: z.string().optional().describe("CSS selector of the editor element to focus first"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot to focus first"),
  },
  async (args) => {
    _assertTabOwnership("native_type");
    const result = await safari.nativeType(args);
    return textResult(result);
  }
);

// ========== FORM INPUT ==========

server.tool(
  "safari_fill",
  "Fill/replace value in an input, textarea, select, OR contenteditable (rich text). Handles React controlled inputs, ProseMirror, Draft.js, and Google Closure editors automatically. Use for SETTING a value (replaces existing). For code editors (Monaco/CodeMirror/Ace), use safari_replace_editor instead. For character-by-character typing in search boxes, use safari_type_text. IMPORTANT: When using ref, always take a FRESH safari_snapshot first — refs expire after each new snapshot (prefix changes: 5_xx → 6_xx).",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot"),
    selector: z.string().optional().describe("CSS selector"),
    value: z.string().describe("Value to fill"),
  },
  async (args) => {
    const selector = args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector;
    const result = await extensionOrFallback(
      "fill", { selector, value: args.value, ..._explicitReceipt(args) },
      () => safari.fill(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_clear_field",
  "Clear an input field",
  { selector: z.string().describe("CSS selector of the input"), receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"), },
  async (args) => {
    const result = await extensionOrFallback(
      "clear_field", { selector: args.selector, ..._explicitReceipt(args) },
      () => safari.clearField(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_verify_state",
  "Verify the framework-level state of an editor/input matches the expected value. Returns JSON {match, mode, actual, expected, hint?}. Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain state separately from the DOM — `.value` or `.textContent` may show new text while the internal store still holds old data, so a Submit click sends stale data. Call this AFTER safari_fill and BEFORE clicking Submit on critical forms (Featured.com, LinkedIn share, Medium, Reddit).",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().describe("CSS selector of the editor/input to verify"),
    expected: z.string().describe("Expected value or text fragment that should appear in framework state"),
  },
  async (args) => {
    const result = await safari.verifyState(args);
    return textResult(result);
  }
);

server.tool(
  "safari_select_option",
  "Select an option in a native <select> dropdown. Sets .value and dispatches change event. Pass `ref` (from safari_snapshot) for a select inside an iframe or shadow DOM — a plain `selector` only reaches the top document. For custom dropdowns (React/LinkedIn), use safari_click on the dropdown trigger, then safari_click on the option instead.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector of the select (top document only)"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot — required for selects inside iframes/shadow DOM"),
    value: z.string().describe("Option value or visible label to select"),
  },
  async (args) => {
    // The receipt first: for a caller with no state of its own it is what names the tab, and the
    // guard has to see it. The engine calls below can bypass extensionOrFallback — assert
    // ownership here.
    const pin = _explicitReceipt(args);
    _assertTabOwnership("select_option", pin);
    // ref path: resolve via mcpFindRef (reaches iframes/shadow DOM) on the AppleScript
    // engine — the extension's select_option handler is selector-only.
    if (args.ref) {
      const refResult = await safari.selectOption({ ref: args.ref, value: args.value });
      return { content: [{ type: "text", text: typeof refResult === 'string' ? refResult : JSON.stringify(refResult) }] };
    }
    let result = await extensionOrFallback(
      "select_option", { selector: args.selector, value: args.value, ...pin },
      () => safari.selectOption(args)
    );
    // If extension returned "Selected: " with empty/default value, fuzzy match may have failed.
    // Retry via AppleScript path which has the latest fuzzy match code.
    if (typeof result === 'string' && result.match(/^Selected:\s*$/) ) {
      console.error('[Safari MCP] select_option returned empty value — retrying via AppleScript');
      result = await safari.selectOption(args);
    }
    return textResult(result);
  }
);

server.tool(
  "safari_react_select_set",
  "Set a value in a react-select v5 dropdown by walking React fiber to find the Select component and invoking onChange directly — bypasses the menu UI entirely. Use when safari_click on the chevron or option keeps failing (Cloudflare custom token forms after a few rows, portal-rendered selects that intercept synthetic events). Returns JSON {ok, selected} on success, or {ok:false, error, available:[…]} listing up to 30 option labels on miss. Match is by label, value, or case-insensitive label. Either ref or selector required. NOTE: For Permissions-levels combos that are disabled until a Permission is selected, set the Permissions value first — the level combo becomes enabled and its props.options populate.",
  {
    selector: z.string().optional().describe("CSS selector — typically input[name=...] or the .react-select__control container"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot"),
    value: z.string().describe("Option label (or value) to select — case-insensitive fallback"),
  },
  async (args) => {
    _assertTabOwnership("react_select_set");
    const result = await safari.reactSelectSet(args);
    return textResult(result);
  }
);

server.tool(
  "safari_react_select_list_options",
  "List available options of a react-select v5 dropdown without opening the menu. Returns JSON {ok, total, options:[{label,value}…]}. Useful when safari_react_select_set returns 'option not found' and you need to see exact labels (e.g. 'Email Routing Rules' vs 'Email Routing'). Either ref or selector required.",
  {
    selector: z.string().optional().describe("CSS selector"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot"),
  },
  async (args) => {
    const result = await safari.reactSelectListOptions(args);
    return textResult(result);
  }
);

server.tool(
  "safari_fill_form",
  "Fill multiple form fields at once",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    fields: z.array(z.object({
      selector: z.string().describe("CSS selector"),
      value: z.string().describe("Value to fill"),
    })).describe("Array of {selector, value} pairs"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "fill_form", { fields: args.fields, ..._explicitReceipt(args) },
      () => safari.fillForm(args)
    );
    return textResult(result);
  }
);

// ========== KEYBOARD ==========

server.tool(
  "safari_press_key",
  "Press a keyboard key (enter, tab, escape, arrows, etc). Supports modifiers (cmd, shift, alt, ctrl).",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    key: z.string().describe("Key name: enter, tab, escape, space, delete, up, down, left, right, or a single character"),
    modifiers: z.array(z.string()).optional().describe("Modifier keys: cmd, shift, alt, ctrl"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "press_key", { key: args.key, modifiers: args.modifiers, ..._explicitReceipt(args) },
      () => safari.pressKey(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_type_text",
  "Type text character-by-character with realistic key events. Best for: search boxes (triggers autocomplete), chat inputs, and fields that react to each keystroke. For rich text editors (Medium, HackerNoon, LinkedIn), use safari_fill instead — it uses framework-native APIs. For code editors (Monaco/CodeMirror), use safari_replace_editor. When using ref, always take a FRESH safari_snapshot first — refs expire after each new snapshot.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    text: z.string().describe("Text to type"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot"),
    selector: z.string().optional().describe("CSS selector to focus"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "type_text", { text: args.text, selector: args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector, ..._explicitReceipt(args) },
      () => safari.typeText(args)
    );
    return textResult(result);
  }
);

// ========== CODE EDITOR ==========

server.tool(
  "safari_replace_editor",
  "Replace ALL content in a code editor (Monaco, CodeMirror, Ace, ProseMirror). Use ONLY for code editors — Airtable automations, GitHub gists, CodePen, n8n code nodes, etc. NOT for rich text editors like Medium/LinkedIn (use safari_fill for those). Detects ProseMirror/Draft.js/CodeMirror/Monaco/Ace and uses their native API.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    text: z.string().describe("The complete code/text to put in the editor"),
  },
  async ({ text, receipt }) => {
    const result = await extensionOrFallback(
      "replace_editor", { text, ..._explicitReceipt({ receipt }) },
      () => safari.replaceEditorContent({ text })
    );
    return textResult(result);
  }
);

// ========== SCREENSHOT ==========

server.tool(
  "safari_screenshot",
  "Take a visual screenshot (base64 JPEG). EXPENSIVE — use safari_snapshot instead for most tasks. Only use screenshot when you need to verify visual layout, styling, images, or colors that snapshot can't show. Pass maxWidth (e.g. 1024) for layout checks — far fewer tokens.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    fullPage: z.boolean().optional().describe("Capture full page (not just viewport)"),
    maxWidth: z.coerce.number().optional().describe("Downscale to this pixel width before returning (default: env SAFARI_MCP_SCREENSHOT_MAX_WIDTH, else none). Retina captures are 2× the viewport; 1280 keeps text readable at a third fewer tokens, 800 is plenty for layout checks. 0 = never scale."),
  },
  async ({ fullPage, receipt, maxWidth }) => {
    let base64;
    try {
      base64 = await extensionOrFallback(
        "screenshot", { fullPage, ..._explicitReceipt({ receipt }) },
        () => safari.screenshot({ fullPage })
      );
    } catch (err) {
      // If AppleScript screenshot failed (permission lost), retry via extension only
      // But only if we're not in profile mode (extension may be from wrong profile)
      if (_extensionConnected && !_preferAppleScript && err.message && (err.message.includes("permission") || err.message.includes("screencapture") || err.message.includes("empty"))) {
        console.error("[Safari MCP] Screenshot AppleScript failed, retrying via extension only");
        base64 = await sendToExtension("screenshot", { fullPage }, 15000);
      } else {
        throw err;
      }
    }
    base64 = await _downscaleJpeg(base64, maxWidth ?? _SCREENSHOT_MAX_WIDTH);
    return {
      content: [{ type: "image", data: base64, mimeType: "image/jpeg" }],
    };
  }
);

server.tool(
  "safari_screenshot_element",
  "Take a screenshot of a specific element (by CSS selector). Returns a base64 image.",
  { selector: z.string().describe("CSS selector of the element to capture"), receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"), },
  async ({ selector, receipt }) => {
    // The extension crops to JPEG, the AppleScript path to PNG; a miss comes back as text.
    return imageResult(await extensionOrFallback(
      "screenshot_element", { selector, ..._explicitReceipt({ receipt }) },
      () => safari.screenshotElement({ selector })
    ));
  }
);

// ========== SCROLL ==========

server.tool(
  "safari_scroll",
  "Scroll the page up or down by a specified amount",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    direction: z.enum(["up", "down"]).optional().describe("Scroll direction (default: down)"),
    amount: z.coerce.number().optional().describe("Pixels to scroll (default: 500)"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "scroll", { direction: args.direction, amount: args.amount, ..._explicitReceipt(args) },
      () => safari.scroll(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_scroll_to",
  "Scroll to a specific position on the page",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    x: z.coerce.number().optional().describe("X position (default: 0)"),
    y: z.coerce.number().optional().describe("Y position (default: 0)"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "scroll_to", { x: args.x, y: args.y, ..._explicitReceipt(args) },
      () => safari.scrollTo(args)
    );
    return textResult(result);
  }
);

// ========== TAB MANAGEMENT ==========

server.tool(
  "safari_list_tabs",
  "List every tab in this session's Safari window (titles + URLs, including the user's own tabs — verbose and rarely needed). Tabs this session owns carry a receipt. Prefer keeping the receipt from safari_new_tab over listing.",
  {},
  async () => {
    const result = await extensionOrFallback(
      "list_tabs", {},
      () => safari.listTabs()
    );
    const safeResult = _sanitizeTabResult(result);
    return { content: [{ type: "text", text: typeof safeResult === 'string' ? safeResult : JSON.stringify(safeResult, null, 2) }] };
  }
);

server.tool(
  "safari_reload_extension",
  "Hot-reload the Safari MCP Bridge extension — forces it to reload its own code from disk without requiring manual Safari Preferences → Extensions → toggle. Use after editing extension/background.js or extension/content.js in the safari-mcp repo. The extension briefly disconnects during reload and auto-reconnects within ~2 seconds. NOTE: this tool itself requires the extension version already installed to support the `reload_extension` command (added in v2.9.1+). If your extension is older, trigger a manual reload once to pick up this feature.",
  {},
  async () => {
    let result = await extensionOrFallback(
      "reload_extension", {},
      async () => "Extension fallback not available — this command requires the Safari MCP Bridge extension."
    );
    // Proxy mode waits on the extension host inside /proxy-command. A host process
    // performs the same generation barrier here before reporting success locally.
    if (_isExtensionHost) {
      // Baseline after the response, for the same suspended-old-worker reason as the
      // proxy route above. The extension schedules runtime.reload() after responding.
      const reloadGeneration = _extensionConnectionGeneration;
      const reconnected = await _waitForExtensionGeneration(reloadGeneration, 30000);
      if (!reconnected) throw new Error("Extension reload did not produce a verified reconnect within 30000ms");
      if (result && typeof result === "object") result = { ...result, reconnected: true };
    }
    return textResult(result);
  }
);

server.tool(
  "safari_new_tab",
  "Open a new background tab (never steals focus), optionally with a URL. Returns {tabIndex, safeUrl, receipt}. KEEP THE RECEIPT and pass it as `receipt` to every later call on this tab — it survives MCP reconnects, daemon restarts, parallel sessions and subagents, and makes safari_list_tabs / safari_switch_tab unnecessary.",
  { url: z.string().optional().describe("URL to open (empty for blank tab)") },
  async ({ url }) => {
    // Safari may suspend the profile worker between calls. Give it one full wake
    // cycle; once connected, the extension creates a background profile window
    // itself when the previous last window was closed.
    if (process.env.SAFARI_PROFILE) await _waitForVerifiedProfileExtension(30000);

    // Enforce the tab cap: close this session's oldest tab, by its identity, if at max.
    const mySession = `${SESSION_ID}:${currentSessionId()}`;
    const evicted = await _evictOldestTab(mySession);

    const requestedUrl = String(url || "");
    let viaAppleScript = false;
    const rawResult = await extensionOrFallback(
      "new_tab", { url: requestedUrl },
      () => { viaAppleScript = true; return _openTabViaAppleScript(requestedUrl); }
    );
    // AppleScript fallback returns a JSON string; extension returns an object — normalize
    let result = rawResult;
    if (typeof rawResult === 'string') {
      try { result = JSON.parse(rawResult); } catch {}
    }
    const safeResult = _sanitizeTabResult(result);
    // Sync safari.js tracking when extension handled new_tab. The raw navigation URL
    // remains internal and is never copied into the MCP response.
    if (safeResult?.tabIndex) {
      safari.setActiveTabIndex(safeResult.tabIndex);
      // A tab AppleScript opened is tracked by _openTabViaAppleScript, by its marker.
      if (!viaAppleScript) _trackTab(safeResult.tabIndex, requestedUrl, mySession, "", safeResult.receipt);
      // The new tab is the current one, with or without a receipt.
      _setActiveReceipt(safeResult.receipt);
    }
    if (requestedUrl) {
      const trackUrl = requestedUrl;
      safari.setActiveTabURL(trackUrl);
      _addOwnedURL(trackUrl);
    }
    if (!requestedUrl) _markBlankTabOpened();
    // safari.js still holds the marker (and, for a blank tab, the URL) of the previous tab;
    // its AppleScript fallback would go back there.
    if (!viaAppleScript) safari.setActiveTabFromExtension(safeResult?.tabIndex, requestedUrl);
    // The eviction used to be stderr-only, so a session learned about its closed tab from
    // the next "receipt … stale" error. Say it in the response instead.
    let out = evicted && safeResult && typeof safeResult === "object"
      ? { ...safeResult, ..._evictionReport(evicted) }
      : safeResult;
    // Nothing in an AppleScript tab's result said it had no receipt; the caller learned it
    // from the next refusal.
    if (viaAppleScript && out && typeof out === "object" && !out.receipt) {
      out = {
        ...out,
        receipt: null,
        via: "applescript",
        note: "The Safari extension was not connected, so AppleScript opened this tab without a receipt. " +
          "Keep working in it without one, or open a new tab once safari_doctor shows the extension connected.",
      };
    }
    return { content: [{ type: "text", text: typeof out === 'string' ? out : JSON.stringify(out) }] };
  }
);

server.tool(
  "safari_close_tab",
  "Close the current tab. After a daemon/session restart, pass the opaque receipt returned by safari_new_tab or safari_list_tabs.",
  {
    receipt: z.string().optional().describe("Opaque extension-issued tab receipt"),
    url: z.string().optional().describe("Deprecated legacy receipt URL"),
  },
  async ({ receipt, url }) => {
    const supplied = receipt || url || "";
    // Only a close that names no receipt closes the current tab. One the server cannot parse
    // ("<old receipt>", a token cut below 24 characters, a page URL) fell through to the current
    // tab's receipt, and the tab the caller was still working in closed instead of the one it named.
    const token = supplied ? _receiptToken(supplied) : _getActiveReceipt();
    if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
    let closesCurrent = false, marker = null;
    let viaAppleScript = false;
    // One close at a time (_oneCloseAtATime). Which tab is current is read when this close's turn
    // comes: a parallel call can switch tabs while it waits, and AppleScript closes the one current then.
    const result = await _oneCloseAtATime(() => {
      closesCurrent = !token || token === _getActiveReceipt();
      marker = safari.getActiveTabMarker();
      return extensionOrFallback(
        "close_tab",
        token ? { receipt: token } : {},
        () => {
          if (token) throw _receiptNeedsExtension("close_tab");
          viaAppleScript = true;
          return safari.closeTab();
        }
      );
    });
    // The extension closed the receipt's tab; AppleScript, the tab carrying the session's marker.
    _untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
    // Closing another tab by its receipt leaves the current one current.
    if (viaAppleScript || closesCurrent) _clearActiveReceipt();
    // AppleScript's closeTab() forgets the tab it closed. After the extension's close, safari.js
    // still tracked the closed tab by index and URL, and that index now names whichever tab
    // shifted into it.
    if (!viaAppleScript && closesCurrent) {
      safari.setActiveTabIndex(null);
      safari.setActiveTabURL(null);
    }
    return textResult(result);
  }
);

server.tool(
  "safari_switch_tab",
  "Switch the MCP session's logical target without focusing Safari. Pass an index for the current session window, or an opaque receipt to recover the exact owned tab across Safari windows; a valid receipt takes precedence over any supplied index. Rarely needed: passing `receipt` on each tool call already targets that tab.",
  {
    index: z.coerce.number().optional().describe("Tab index (starting from 1); optional when receipt is provided"),
    receipt: z.string().optional().describe("Opaque extension-issued receipt for the exact owned tab"),
    url: z.string().optional().describe("Deprecated legacy receipt URL"),
  },
  async ({ index, receipt, url }) => {
    const supplied = receipt || url || "";
    const token = _receiptToken(supplied);
    if (supplied && !token) {
      const msg = "⚠️ Tab safety: refusing switch_tab — invalid receipt.";
      console.error(`[Safari MCP] ${msg}`);
      return errorResult(msg);
    }
    if (!token && (!Number.isInteger(index) || index < 1)) {
      return errorResult("Tab safety: switch_tab requires a positive index or a valid receipt");
    }
    if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
      return errorResult("Tab safety: switch_tab index must be a positive integer when provided");
    }

    // Tab ownership check: verify target tab is one we opened
    // `_ownedTabURLs.size > 0` alone skipped this whole lookup for a session that had opened
    // nothing — fine while switch_tab could only reach owned tabs, but adoption (#92) has to
    // work from a cold session, which is precisely the "read the article in my current tab"
    // case the opt-in exists for.
    if (!process.env.SAFARI_PROFILE && (_ownedTabURLs.size > 0 || allowUserTabs())) {
      // Get target tab's URL via list_tabs before switching
      try {
        const tabs = await extensionOrFallback(
          "list_tabs", {},
          () => safari.listTabs()
        );
        const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
        const target = parsed.find(t => t.index === index);
        if (target && target.url && !_isURLOwned(target.url)) {
          // about:blank / missing value tabs are owned if tracked in _openedTabs
          const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && (!!_trackedAtIndex(index) || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
          // A tab THIS session opened may have redirected away from the URL we
          // registered (/dashboard -> /login, any 302), so the URL alone stops
          // being proof and the tab became permanently un-switchable. But a
          // recorded index is not proof either: Safari renumbers every index
          // whenever any tab closes, so a stale one can point straight at a
          // user's tab. Require BOTH — the index we opened AND the origin we
          // opened it on. That mirrors the origin
          // boundary the extension keeps on its own receipts, so nothing that
          // would survive the extension's check is lost here. A URL still proves
          // nothing about WHICH tab this is, so the AppleScript fallback also needs
          // the session's marker on the tab (safari.js switchTab()).
          const trackedOrigin = _originOf(_trackedAtIndex(index)?.url);
          const isTrackedRedirect = !!trackedOrigin && trackedOrigin === _originOf(target.url);
          if (!isBlankOwned && !isTrackedRedirect && !allowUserTabs()) {
            const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${_safeUrlForOutput(target.url)}) — not opened by this MCP session. Use safari_new_tab to open your own tab, or set SAFARI_MCP_ALLOW_USER_TABS=1 to let switch_tab adopt a tab you already had open.`;
            console.error(`[Safari MCP] ${msg}`);
            return errorResult(msg);
          }
          // With the opt-in the switch goes ahead, and the AppleScript fallback below adopts the tab (#92).
        }
      } catch {}
    }
    let viaAppleScript = false;
    const result = await extensionOrFallback(
      "switch_tab", token ? { ...(index ? { index } : {}), receipt: token } : { index },
      async () => {
        if (token) throw _receiptNeedsExtension("switch_tab");
        viaAppleScript = true;
        try {
          return await safari.switchTab(index);
        } catch (err) {
          // switchTab() claims only a tab carrying a marker of this session. With the opt-in, any
          // other tab is adopted instead (#92): it gets the session's adoption marker, which it
          // keeps wherever it navigates, and which no close accepts. The URL check above passes
          // the user's tab on a URL the session owns, so only this marker tells it apart.
          if (!err?.unproven || !allowUserTabs()) throw err;
        }
        const claimed = await safari.switchTab(index, { adopt: true });
        console.error(`[Safari MCP] switch_tab adopted ${_safeUrlForOutput(safari.getActiveTabURL())} (user tab, opted-in via SAFARI_MCP_ALLOW_USER_TABS)`);
        return claimed;
      }
    );
    // Adopted just now, or earlier: switchTab() keeps an adopted tab in the adoption family.
    const adopted = viaAppleScript && safari.isActiveTabAdopted();
    const safeResult = _sanitizeTabResult(result);
    // Sync safari.js state so AppleScript fallback targets the correct tab
    const resolvedIndex = safeResult?.tabIndex || index;
    if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
    if (viaAppleScript && safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
    // …including the marker, which still names the last tab AppleScript claimed. The URL is left
    // unknown, as run_script's switchTab leaves it: the extension reports where the tab is now,
    // which after a redirect or a login bounce is a page this session never registered, and the
    // ownership guard refused every write after it, though the extension had proven the tab by its id.
    if (!viaAppleScript && safeResult && typeof safeResult === "object") {
      safari.setActiveTabFromExtension(resolvedIndex, null);
    }
    if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
    // A switch by INDEX gets no receipt back, and the session was still holding the one it
    // minted for the PREVIOUS tab. Every later command auto-attaches that receipt, so the
    // switch reported `owned: true` and then the next evaluate/click died with "receipt is
    // forged, stale, ambiguous, or not valid for this origin" — a dead end with no way out
    // but opening another tab. Drop the stale capability instead: without one the command
    // targets the tab this switch just selected, which is exactly what was asked for.
    else _setActiveReceipt("");
    // Say so in the result, not only in the log: an agent that adopted a user's tab should
    // be able to see that from the answer it got (#92, condition 3).
    const reported = adopted ? { ...safeResult, note: "(user tab, opted-in)" } : safeResult;
    return { content: [{ type: "text", text: JSON.stringify(reported) }] };
  }
);

// ========== WAIT ==========

server.tool(
  "safari_wait_for",
  "Wait for an element or text to appear on the page",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector to wait for"),
    text: z.string().optional().describe("Text to wait for"),
    timeout: z.coerce.number().optional().describe("Timeout in ms (default: 10000)"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "wait_for", { selector: args.selector, text: args.text, timeout: args.timeout, ..._explicitReceipt(args) },
      () => safari.waitFor(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_wait_for_new_tab",
  "Wait for a new tab to appear (e.g. after OAuth login click opens popup). Automatically switches to the new tab.",
  {
    timeout: z.coerce.number().optional().describe("Timeout in ms (default: 10000)"),
    urlContains: z.string().optional().describe("Only match new tabs whose URL contains this string"),
  },
  async ({ timeout, urlContains }) => {
    const timeoutMs = timeout || 10000;
    // Every listing says where it came from. AppleScript lists one window by id (`win`): the one
    // this session's own tab is in, proven by its marker when AppleScript first lists, and a claim
    // through AppleScript goes to that window. Listings of whichever window was in front made a
    // tab of the user's other window look new, and the claim stamped the session's own marker on
    // it. A listing from the other source starts the comparison over instead of comparing tabs
    // across them.
    let win = null;
    const list = async () => {
      let via = "extension";
      const raw = await extensionOrFallback("list_tabs", {}, async () => {
        via = "applescript";
        if (!win) win = await safari.sessionTabWindow();
        const listed = await safari.listWindowTabs(win);
        win = listed.win;
        return listed.tabs;
      });
      return { via, tabs: typeof raw === "string" ? JSON.parse(raw) : raw };
    };
    // A listed tab's URL: AppleScript lists `url`, the extension `safeUrl` (origin and path).
    const urlOf = (t) => t.url ?? t.safeUrl;
    // Switch to the new tab and own it for THIS session, else the next interaction trips the
    // tab-safety guard. Its marker names it only if AppleScript made the switch (and stamped
    // it); the extension answers with the tab's receipt instead. A tab AppleScript listed is
    // claimed through AppleScript, in the pinned window, at once: its index is a position there,
    // not in the extension's window, and waiting out the extension's switch_tab first (30 s
    // while its worker is busy) left time for another tab to slide under that index. AppleScript
    // never claims a tab only the extension's listing saw.
    const adopt = async (t, via) => {
      const viaAppleScript = via === "applescript";
      let switched;
      if (viaAppleScript) {
        if (!win) throw new Error("Tab safety: AppleScript did not say which window it listed, so it cannot tell which tab opened, and this session claimed none. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it.");
        switched = _sanitizeTabResult(await safari.switchTab(t.index, { claim: true, win, expectUrl: t.url }));
      } else {
        switched = _sanitizeTabResult(await extensionOrFallback("switch_tab", { index: t.index }, () => {
          throw new Error("Tab safety: a new tab opened, but the Safari extension could not switch to it and AppleScript cannot tell which tab of its own window that is, so this session did not claim it. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it. Check the extension with safari_doctor.");
        }));
        // The extension switches to whatever tab sits at the index when it runs the command, and
        // answers "Tab not found" as if it had switched: a tab that closed or moved in between
        // handed the session another tab of its own, or none, under the new tab's name.
        if (switched?.safeUrl !== _safeUrlForOutput(urlOf(t))) {
          throw Object.assign(new Error("Tab safety: the Safari extension switched to another tab than the one the listing saw (a tab closed or moved in between), so this session did not claim it."), { moved: true });
        }
      }
      safari.setActiveTabIndex(t.index);
      safari.setActiveTabURL(t.url);
      if (!viaAppleScript) safari.setActiveTabFromExtension(t.index, t.url);
      // A "new" tab can be one adopted from the user that moved or navigated while this waited.
      // It stays adopted, and recording it among the tabs this session opened would hand it to
      // the tab cap and the shutdown cleanup, which close those.
      if (!safari.isActiveTabAdopted()) {
        _trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
      }
      _setActiveReceipt(switched?.receipt);
      return { content: [{ type: "text", text: `Found new tab: ${t.title} (${urlOf(t)})` }] };
    };
    // Whether every tab of `was` is still there on its URL, in order, in `is`: only then can a tab
    // `is` added be told from the old ones. The first `index:url` pair not seen before was often a
    // tab of the user's that had navigated, or had moved when another closed, and the claim stamped
    // the session's own marker on it.
    // ponytail: tabs are told apart by URL alone (AppleScript has no tab id and no opener), so any
    // tab the user opens in the window during the wait counts as new, a new tab that opens on the
    // very URL a tab of the user's leaves in the same poll can pass for it (as can a tab of the
    // user's that lands on the URL of a new tab closing next to it, while another tab opens further
    // along, which leaves the same listings as that new tab staying put), and the claim stamps
    // whatever tab sits at the listed index a script later unless switchTab checks `expectUrl`.
    const intact = (was, is) => {
      const seen = new Set(was.map(urlOf));
      const kept = is.filter((t) => seen.has(urlOf(t)));
      return kept.length === was.length && kept.every((t, i) => urlOf(t) === urlOf(was[i]));
    };
    // Where the new tabs `last` showed are in `is`: each still on its URL, shown by no more and no
    // fewer tabs than before, and in its place while the other tabs only navigated (as many tabs as
    // before), or still there while the others only closed (the rest on their URLs, in order) and
    // none next to it. A tab that went next to a new one could instead have landed on its URL as the
    // new one went, and a tab that joins it on its URL could be taken for it; a neighbour counts as
    // still there only while as many tabs as before show its URL, or a twin could stand in for it.
    // Null when unsure.
    const stillNew = (last, urls, is) => {
      const count = (tabs, u) => tabs.filter((t) => urlOf(t) === u).length;
      const at = (tabs, u) => tabs.findIndex((t) => urlOf(t) === u);
      if (urls.some((u) => count(is, u) !== count(last, u))) return null;
      if (is.length === last.length) return urls.some((u) => at(is, u) !== at(last, u)) ? null : urls.map((u) => is[at(is, u)]);
      const there = new Set();
      let j = 0;
      last.forEach((t, k) => { if (j < is.length && urlOf(is[j]) === urlOf(t)) { there.add(k); j++; } });
      const stays = (k) => count(is, urlOf(last[k])) === count(last, urlOf(last[k]));
      const neighbours = (u) => [at(last, u) - 1, at(last, u) + 1].every((k) => k < 0 || k >= last.length || (there.has(k) && stays(k)));
      return j === is.length && urls.every(neighbours) ? urls.map((u) => is[at(is, u)]) : null;
    };
    // Get current tab list
    let before = await list();
    // Every URL a baseline showed. A new tab is one on a URL none of them showed, so a tab that
    // left the window and came back (reopened, or dragged out and in again) is not new either.
    const shown = new Set(before.tabs.map(urlOf));
    // The new tabs not claimed yet (still on about:blank, not matching urlContains, or refused),
    // by URL, and the listing that showed them; and whether any tab opened at all.
    let pending = { last: before.tabs, urls: [] };
    let opened = false;

    // Poll for new tab — detect by count increase + tabs on new URLs
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      await new Promise(r => setTimeout(r, 500));
      const now = await list().catch((err) => {
        // A listing that fails (the window pin refused, AppleScript failed) takes the wait's record
        // of a tab that opened with it: say so, as the timeout would.
        if (opened && err instanceof Error) err.message += " A tab opened during the wait; if it is still open, another safari_wait_for_new_tab will not report it.";
        throw err;
      });
      // A listing where a tab closed or navigated becomes the baseline, so a tab of the user's that
      // navigated meanwhile is not new once one opens; so does one from the other source. A new
      // tab not claimed yet stays out of it while the other tabs only navigated in place or only
      // closed: taken into it, it was never new again, and the wait timed out on a popup that
      // was still loading when another tab changed. Any other change, or one as a tab opened,
      // takes every tab in: held against the older baseline, whichever of two tabs that changed
      // navigated first looked new, the user's too.
      if (now.via !== before.via || !intact(before.tabs, now.tabs)) {
        const same = now.via === before.via;
        const kept = !pending.urls.length ? [] : same ? stillNew(pending.last, pending.urls, now.tabs) : null;
        // Only AppleScript lists one window by id: the extension can list whichever window is in front.
        if (same && now.via === "applescript" && now.tabs.length > before.tabs.length) opened = true;
        before = { ...now, tabs: now.tabs.filter((t) => !kept?.includes(t)) };
        for (const t of before.tabs) shown.add(urlOf(t));
        pending = { last: now.tabs, urls: kept ? pending.urls : [] };
        continue;
      }
      const old = new Set(before.tabs.map(urlOf));
      pending = { last: now.tabs, urls: now.tabs.map(urlOf).filter((u) => !old.has(u)) };
      if (pending.urls.length) opened = true;
      for (const tab of now.tabs) {
        // A new tab still on about:blank (an OAuth popup before it loads) is claimed at a later
        // poll, once it shows its URL: a later listing found it again by index, and by then that
        // could be another tab.
        if (urlOf(tab) === 'about:blank' || shown.has(urlOf(tab))) continue;
        if (urlContains && !String(urlOf(tab)).includes(urlContains)) continue;
        try {
          return await adopt(tab, now.via);
        } catch (err) {
          // A tab another session opened in this window meanwhile, whose marker the claim refuses
          // to overwrite (`otherSession`), or a tab that no longer shows the page the listing saw
          // (`moved`: one closed in between and slid another under the index). Neither is the
          // tab this waits for, and a later listing can still show it.
          if (!err?.otherSession && !err?.moved) throw err;
        }
      }
    }
    return { content: [{ type: "text", text: opened
      ? "TIMEOUT: a tab opened during the wait, but none this call could claim: it could not be told apart from a tab that changed at the same time, urlContains ruled it out, or its claim was refused. If it is still open, another safari_wait_for_new_tab will not report it: open it again during a new wait, or ask the user."
      : "TIMEOUT: no new tab appeared" }] };
  }
);

// ========== FRAMES ==========

server.tool(
  "safari_list_frames",
  "List every document in the tab — the main page plus each iframe — with its frameId, URL and text length. Use when a page's content lives in a cross-origin iframe (an embedded app, a micro-frontend shell, a payment field): safari_read_page and safari_evaluate see only the main document there and come back empty or with a loader. Pass the frameId or a URL substring as safari_evaluate's `frame` to run inside it.",
  {
    receipt: z.string().optional().describe("Opaque extension-issued tab receipt"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "list_frames", { ..._explicitReceipt(args) },
      () => { throw new Error("safari_list_frames needs the extension — AppleScript cannot enumerate frames. Check safari_doctor."); }
    );
    return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] };
  }
);

// ========== EVALUATE JAVASCRIPT ==========

server.tool(
  "safari_evaluate",
  "Execute JavaScript in the current page (a returned Promise is awaited — fetch/timers work in background tabs; requestAnimationFrame never fires there). Automatically falls back to AppleScript when CSP blocks execution (e.g. Google Search Console, LinkedIn). For reading data, prefer safari_read_page or safari_snapshot. For interactions, prefer safari_click/fill with refs.",
  {
    script: z.string().describe("JavaScript code to execute"),
    frame: z.union([z.string(), z.number()]).optional().describe("Run inside a sub-frame instead of the main document: a frameId from safari_list_frames, or a substring of the frame's URL (must match exactly one). Needed for cross-origin iframes — micro-frontend app shells, embedded checkouts — where the main document only holds a loader."),
    receipt: z.string().optional().describe("Opaque extension-issued tab receipt — pass the one safari_new_tab returned to keep targeting that tab after an MCP reconnect"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "evaluate",
      { script: args.script, ...(args.frame === undefined ? {} : { frame: args.frame }), ..._explicitReceipt(args) },
      () => {
        if (args.frame !== undefined) {
          throw new Error("safari_evaluate: frame targeting needs the extension — AppleScript reaches only the main document. Check the extension is connected (safari_doctor).");
        }
        return safari.evaluate(args);
      }
    );
    return evalResult(result);
  }
);

server.tool(
  "safari_eval_file",
  "Execute JavaScript read from a FILE path (avoids passing huge scripts inline / manual copy). Same engine as safari_evaluate: extension-first (no focus steal), AppleScript fallback. Use to upload binary via a generated .js containing base64.",
  {
    path: z.string().describe("Absolute path to a .js file whose contents are the script to execute"),
    receipt: z.string().optional().describe("Opaque extension-issued tab receipt — pass the one safari_new_tab returned to keep targeting that tab after an MCP reconnect"),
  },
  async (args) => {
    const script = readFileSync(args.path, "utf8");
    const result = await extensionOrFallback(
      "evaluate", { script, ..._explicitReceipt(args) }, () => safari.evaluate({ script })
    );
    return evalResult(result);
  }
);

// ========== ELEMENT INFO ==========

server.tool(
  "safari_get_element",
  "Get detailed info about an element (tag, text, rect, attributes, visibility)",
  { selector: z.string().describe("CSS selector"), receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"), },
  async (args) => {
    const result = await extensionOrFallback(
      "get_element", { selector: args.selector, ..._explicitReceipt(args) },
      () => safari.getElementInfo(args)
    );
    return textResult(result);
  }
);

server.tool(
  "safari_query_all",
  "Find all elements matching a CSS selector (returns tag, text, href, value). x/y are relative to each element's own frame viewport — for matches inside an iframe, click by selector or snapshot ref, not by these coordinates",
  {
    selector: z.string().describe("CSS selector"),
    limit: z.coerce.number().optional().describe("Max results (default: 20)"),
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "query_all", { selector: args.selector, limit: args.limit, ..._explicitReceipt(args) },
      () => safari.querySelectorAll(args)
    );
    // The matches live inside a child frame, so their x/y are that frame's viewport
    // coordinates while safari_click and friends take page coordinates. Warn in the
    // result text, which is the only part of this an agent actually reads.
    if (typeof result === "string" && result.includes('"frameRelative":true')) {
      return textResult(
        result +
          "\n\nNote: these matches are inside a child frame. Their x/y are relative to that frame's viewport (see \"frame\"), not to the page — click them by selector or by a safari_snapshot ref; passing these coordinates to safari_click lands somewhere else."
      );
    }
    return textResult(result);
  }
);

// ========== HOVER ==========

server.tool(
  "safari_hover",
  "Hover over element. Use ref, selector, or x/y",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    ref: z.string().optional().describe("Ref ID from safari_snapshot"),
    selector: z.string().optional().describe("CSS selector"),
    x: z.coerce.number().optional().describe("X coordinate"),
    y: z.coerce.number().optional().describe("Y coordinate"),
  },
  async (args) => {
    const sel = args.ref ? `[data-mcp-ref="${args.ref}"]` : args.selector;
    const result = await extensionOrFallback(
      "hover", { selector: sel, ..._explicitReceipt(args) },
      () => safari.hover(args)
    );
    return textResult(result);
  }
);

// ========== DIALOG HANDLING ==========

server.tool(
  "safari_handle_dialog",
  "Set up handler for the next alert/confirm/prompt dialog",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    action: z.enum(["accept", "dismiss"]).optional().describe("Accept or dismiss (default: accept)"),
    text: z.string().optional().describe("Text to enter for prompt dialogs"),
  },
  async (args) => {
    _assertTabOwnership("handle_dialog");
    const result = await safari.handleDialog(args);
    return textResult(result);
  }
);

// ========== WINDOW ==========

server.tool(
  "safari_resize",
  "Resize the Safari window",
  {
    width: z.coerce.number().describe("Window width"),
    height: z.coerce.number().describe("Window height"),
  },
  async (args) => {
    _assertTabOwnership("resize");
    const result = await safari.resizeWindow(args);
    return textResult(result);
  }
);

// ========== DRAG ==========

server.tool(
  "safari_drag",
  "Drag an element to another element or position. Use CSS selectors or x/y coordinates.",
  {
    sourceSelector: z.string().optional().describe("CSS selector of element to drag"),
    targetSelector: z.string().optional().describe("CSS selector of drop target"),
    sourceX: z.coerce.number().optional().describe("Source X coordinate"),
    sourceY: z.coerce.number().optional().describe("Source Y coordinate"),
    targetX: z.coerce.number().optional().describe("Target X coordinate"),
    targetY: z.coerce.number().optional().describe("Target Y coordinate"),
  },
  async (args) => {
    _assertTabOwnership("drag");
    const result = await safari.drag(args);
    return textResult(result);
  }
);

// ========== UPLOAD FILE ==========

server.tool(
  "safari_upload_file",
  "Upload a file to a <input type='file'> element via JavaScript DataTransfer — NO file dialog, NO UI interaction. IMPORTANT: Do NOT click the file input before calling this tool — just provide the selector and file path. If a file dialog is already open, this tool will close it first. NOTE: 'verified 0 files' may appear even on success if the site uses a custom upload handler — check visually with safari_snapshot. For an IMAGE going into a composer/editor that should show a thumbnail, pass verifyPreview:true — some sites (Google Business Profile) accept the file handle and flip their UI to 'attached' while ingesting nothing, and the post then publishes with no image.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().describe("CSS selector of the file input"),
    filePath: z.string().describe("Absolute path to the file to upload"),
    verifyPreview: z.boolean().optional().describe("Require a visible preview (blob:/data: image) to appear; if none does, the synthetic pickup was a ghost and this escalates to a real OS file dialog. Use for images going into a composer."),
    forceNative: z.boolean().optional().describe("Skip synthetic injection and go straight to the real OS file dialog (isTrusted). Needs an unlocked screen and briefly focuses Safari. Use when the site is known to reject synthetic uploads."),
  },
  async (args) => {
    // The receipt first: for a caller with no state of its own it is what names the tab, and the
    // guard has to see it. The upload bypasses extensionOrFallback, so assert ownership here.
    _assertTabOwnership("upload_file", _explicitReceipt(args));
    // AppleScript carries the file; it finds the tab by its marker in the profile's first window,
    // and the extension can say which of the profile's windows holds it (see _locusWindows).
    const result = await safari.uploadFile({ ...args, locate: _tabLocusFromExtension });
    return textResult(result);
  }
);

// ========== PASTE IMAGE ==========

server.tool(
  "safari_paste_image",
  "Paste an image from a local file into the focused element via JS DataTransfer (no clipboard, no focus steal). Works on Medium, dev.to, HackerNoon, TOI, etc.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    filePath: z.string().describe("Absolute path to the image file (PNG, JPG, WebP)"),
  },
  async ({ filePath, receipt }) => {
    // As safari_upload_file: the receipt before the guard, and the extension's locus for the scan.
    _assertTabOwnership("paste_image", _explicitReceipt({ receipt }));
    const result = await safari.pasteImageFromFile({ filePath, locate: _tabLocusFromExtension });
    return textResult(result);
  }
);

// ========== EMULATE (DEVICE SIMULATION) ==========

server.tool(
  "safari_emulate",
  `Emulate a mobile device: sizes the Safari window to the device's CSS viewport and overrides navigator.userAgent/platform/maxTouchPoints (iOS/iPadOS 27 values) in the current page — JavaScript only: the HTTP User-Agent header is unchanged and a reload or navigation clears it. Devices: ${Object.keys(safari.EMULATION_DEVICES).join(", ")}. Or pass width/height (and userAgent).`,
  {
    device: z.string().optional().describe("Device name from the list in the description, e.g. iphone-18-pro, iphone-air, ipad-pro-11"),
    width: z.coerce.number().optional().describe("Custom viewport width"),
    height: z.coerce.number().optional().describe("Custom viewport height"),
    userAgent: z.string().optional().describe("Custom user agent string"),
  },
  async (args) => {
    _assertTabOwnership("emulate");
    const result = await safari.emulate(args);
    return textResult(result);
  }
);

server.tool(
  "safari_reset_emulation",
  "Reset device emulation: restores the window size from before safari_emulate and removes its navigator overrides (no reload)",
  {},
  async () => {
    _assertTabOwnership("reset_emulation");
    const result = await safari.resetEmulation();
    return textResult(result);
  }
);

// ========== COOKIES & STORAGE ==========

server.tool(
  "safari_get_cookies",
  "Get cookies for the current page",
  {},
  async () => {
    const result = await safari.getCookies();
    return { content: [{ type: "text", text: result || "(no cookies)" }] };
  }
);

server.tool(
  "safari_local_storage",
  "Get localStorage data for the current page",
  { key: z.string().optional().describe("Specific key to get (omit for all)") },
  async ({ key }) => {
    const result = await safari.getLocalStorage({ key });
    return { content: [{ type: "text", text: result || "(empty)" }] };
  }
);

// ========== NETWORK ==========

server.tool(
  "safari_network",
  "Quick network overview via Performance API (no setup needed). Shows URLs and timing for resources loaded by the page. For detailed request/response info (headers, status codes, POST bodies), use safari_start_network_capture + safari_network_details instead.",
  { limit: z.coerce.number().optional().describe("Max requests to return (default: 50)") },
  async ({ limit }) => {
    const result = await safari.getNetworkRequests({ limit });
    return textResult(result);
  }
);

// ========== RUN SCRIPT (multi-step) ==========

server.tool(
  "safari_run_script",
  "Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set plus getReceipt; there, closeTab and switchTab take a tab index, not a receipt, and getReceipt needs the tab's receipt in args. After a cross-origin redirect, rotate the tab's receipt with {\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}.",
  {
    steps: z.array(z.object({
      action: z.string().describe("Action name (e.g. 'navigate', 'click', 'fill')"),
      args: z.record(z.string(), z.unknown()).optional().describe("Arguments for the action"),
    })).describe("Array of steps to execute sequentially"),
  },
  async ({ steps }) => {
    // Preserve the full historical action surface for non-profile installations.
    // Named-profile sessions cannot use it because safari.runScript drives Safari via
    // AppleScript and loses the verified profile/tab-id boundary; they use the
    // extension-only dispatcher below.
    if (!process.env.SAFARI_PROFILE) {
      const mySession = `${SESSION_ID}:${currentSessionId()}`;
      const onStep = (action, stepArgs) => {
        // getReceipt is guarded in extensionOrFallback, which sees the receipt the step names and leaves
        // a rotation that names one to the extension. The check below is given no payload, so it would not
        // see that receipt and would judge the URL safari.js holds: after a redirect during an AppleScript
        // load, one the session never registered.
        if (action === "getReceipt") return;
        if (action === "navigate" || action === "navigateAndRead") {
          _assertTabOwnership(`run_script:${action}`);
          _claimURL(stepArgs?.url);
          return;
        }
        // These steps run through AppleScript here, which cannot tell which tab a receipt names:
        // closeTab closed the session's current tab in its place, and switchTab claimed a tab
        // with no index at all.
        if ((action === "closeTab" || action === "switchTab") &&
            (stepArgs?.receipt || stepArgs?.receiptUrl || stepArgs?.url)) {
          throw new Error(
            `Tab safety: without SAFARI_PROFILE, run_script's ${action} runs through AppleScript, which ` +
            `cannot tell which tab a receipt names. Pass the receipt to safari_${action === "closeTab" ? "close_tab" : "switch_tab"} instead.`
          );
        }
        // A switch that cannot happen stops the batch: the steps after it were meant for the tab
        // it names, and would run in the current one instead.
        if (action === "switchTab") {
          const idx = Number(stepArgs?.index);
          if (!Number.isInteger(idx) || idx < 1) {
            throw new Error("Tab safety: run_script's switchTab needs the tab's index (a positive integer).");
          }
        }
        // A closeTab step is a close_tab, so every refusal that belongs to closing applies to it,
        // the one for a tab adopted from the user included (#92, condition 2).
        if (!_RUNSCRIPT_OWNERSHIP_EXEMPT.has(action)) {
          _assertTabOwnership(action === "closeTab" ? "close_tab" : `run_script:${action}`);
        }
      };
      // A tab the extension opened carries a marker only once AppleScript has needed it as the
      // current tab, so switchTab() cannot prove the others. The extension can, by the tab's id.
      const switchTab = async ({ index }) => {
        try {
          return await safari.switchTab(index);
        } catch (err) {
          if (!err?.unproven || !_extensionConnected) throw err;
          return _runExtensionBatchAction("switchTab", { index: Number(index) });
        }
      };
      // A newTab step opens its tab as safari_new_tab does: under the session's tab cap, which first
      // closes the session's oldest tab when it already has MAX_TABS, and as one of the session's
      // own tabs, claimed and tracked (_openTabViaAppleScript). It used to claim nothing, so every
      // write step after newTab{url} was refused on the session's own tab, and to track nothing, so
      // a batch's tabs escaped the cap, shutdown cleanup and the memory sweep.
      const newTab = async ({ url }) => {
        const evicted = await _evictOldestTab(mySession);
        const result = await _openTabViaAppleScript(String(url || ""));
        if (!evicted) return result;
        // Name the tab the cap closed, as safari_new_tab does.
        let tab = {};
        try { tab = JSON.parse(result); } catch {}
        return { ...tab, ..._evictionReport(evicted) };
      };
      // A closeTab step forgets the tab it closed, as safari_close_tab does after an AppleScript
      // close: closeOwnTab() closes only the tab that carries the session's marker. One close at a
      // time (_oneCloseAtATime).
      const closeTab = ({ index }) => _oneCloseAtATime(async () => {
        const marker = safari.getActiveTabMarker();
        const result = await safari.closeOwnTab(index);
        _untrackClosedTab({ marker });
        return result;
      });
      // Only the extension can rotate a receipt, and the legacy action table had no getReceipt, so
      // the step the origin refusal advises came back "Unknown action" and the tab stayed stranded.
      // It has to name its tab here: this batch's AppleScript steps (newTab, switchTab) move to a
      // tab without touching the session's receipt, which then names a tab the batch has left.
      const getReceipt = (stepArgs) => {
        if (!stepArgs?.receipt) {
          throw new Error("Tab safety: without SAFARI_PROFILE, getReceipt needs the receipt of the tab it rotates, in args.receipt.");
        }
        return _runExtensionBatchAction("getReceipt", stepArgs);
      };
      return textResult(await safari.runScript({ steps, onStep, actions: { switchTab, newTab, closeTab, getReceipt } }));
    }

    // Keep every step in one MCP request/session and route it through
    // extensionOrFallback. In a named Safari profile this is extension-only; the old
    // safari.runScript path invoked AppleScript directly and also passed object args to
    // positional functions such as newTab(url), producing `u.split is not a function`.
    const results = [];
    const stopOnSemanticMiss = new Set([
      "newTab", "switchTab", "closeTab", "navigate", "navigateAndRead", "waitFor",
      "click", "clickAndOpenPopup", "fill", "fillForm", "clearField", "typeText", "selectOption",
      "pressKey", "scroll", "scrollTo", "scrollToElement", "hover", "evaluate",
      "reload", "goBack", "goForward",
    ]);
    for (const step of steps) {
      try {
        const result = await _runExtensionBatchAction(step.action, step.args || {});
        const semanticFailure = _isBatchSemanticFailure(result);
        if (semanticFailure && stopOnSemanticMiss.has(step.action)) {
          results.push({ action: step.action, error: result });
          break;
        }
        results.push({ action: step.action, result });
      } catch (err) {
        results.push({ action: step.action, error: err?.message || String(err) });
        break;
      }
    }
    return textResult(JSON.stringify(results));
  }
);

// ========== CONSOLE ==========

server.tool(
  "safari_start_console",
  "Start capturing console messages (log, warn, error, info). Call once per page.",
  {},
  async () => {
    const result = await safari.startConsoleCapture();
    return textResult(result);
  }
);

server.tool(
  "safari_get_console",
  "Get captured console messages (must call safari_start_console first)",
  {},
  async () => {
    const result = await safari.getConsoleMessages();
    return textResult(result);
  }
);

server.tool(
  "safari_clear_console",
  "Clear all captured console messages",
  {},
  async () => {
    const result = await safari.clearConsoleCapture();
    return textResult(result);
  }
);

// ========== PDF SAVE ==========

server.tool(
  "safari_save_pdf",
  "Save the current page as a PDF file. Uses screencapture + PDF rendering (no Safari UI interaction needed).",
  { path: z.string().describe("Absolute file path to save the PDF (e.g. /Users/am/Downloads/page.pdf)") },
  async (args) => {
    const result = await safari.savePDF(args);
    return textResult(result);
  }
);

// ========== ACCESSIBILITY SNAPSHOT ==========

server.tool(
  "safari_accessibility_snapshot",
  "Get the accessibility tree of the page (roles, ARIA labels, focusable elements, form states). Essential for a11y auditing.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector for subtree (default: full page)"),
    maxDepth: z.coerce.number().optional().describe("Max tree depth (default: 5)"),
  },
  async (args) => {
    const result = await safari.getAccessibilityTree(args);
    return textResult(result);
  }
);

// ========== COOKIE CRUD ==========

server.tool(
  "safari_set_cookie",
  "Set a cookie on the current page",
  {
    name: z.string().describe("Cookie name"),
    value: z.string().describe("Cookie value"),
    domain: z.string().optional().describe("Cookie domain"),
    path: z.string().optional().describe("Cookie path (default: /)"),
    expires: z.string().optional().describe("Expiry date (e.g. 'Thu, 01 Jan 2030 00:00:00 GMT')"),
    secure: z.boolean().optional().describe("Secure flag"),
    sameSite: z.enum(["Strict", "Lax", "None"]).optional().describe("SameSite attribute"),
  },
  async (args) => {
    _assertTabOwnership("set_cookie");
    const result = await safari.setCookie(args);
    return textResult(result);
  }
);

server.tool(
  "safari_delete_cookies",
  "Delete a specific cookie or all cookies for the current page",
  {
    name: z.string().optional().describe("Cookie name to delete"),
    all: z.boolean().optional().describe("Delete all cookies"),
  },
  async (args) => {
    _assertTabOwnership("delete_cookies");
    const result = await safari.deleteCookies(args);
    return textResult(result);
  }
);

// ========== SESSION STORAGE ==========

server.tool(
  "safari_session_storage",
  "Get sessionStorage data for the current page",
  { key: z.string().optional().describe("Specific key (omit for all)") },
  async ({ key }) => {
    const result = await safari.getSessionStorage({ key });
    return { content: [{ type: "text", text: result || "(empty)" }] };
  }
);

server.tool(
  "safari_set_session_storage",
  "Set a value in sessionStorage",
  {
    key: z.string().describe("Storage key"),
    value: z.string().describe("Value to store"),
  },
  async (args) => {
    _assertTabOwnership("set_session_storage");
    const result = await safari.setSessionStorage(args);
    return textResult(result);
  }
);

server.tool(
  "safari_set_local_storage",
  "Set a value in localStorage",
  {
    key: z.string().describe("Storage key"),
    value: z.string().describe("Value to store"),
  },
  async (args) => {
    _assertTabOwnership("set_local_storage");
    const result = await safari.setLocalStorage(args);
    return textResult(result);
  }
);

// ========== STORAGE DELETE / CLEAR ==========

server.tool(
  "safari_delete_local_storage",
  "Delete a localStorage key, or clear all localStorage (omit key to clear all)",
  { key: z.string().optional().describe("Key to delete (omit to clear ALL)") },
  async ({ key }) => {
    _assertTabOwnership("delete_local_storage");
    const result = await safari.deleteLocalStorage({ key });
    return textResult(result);
  }
);

server.tool(
  "safari_delete_session_storage",
  "Delete a sessionStorage key, or clear all sessionStorage (omit key to clear all)",
  { key: z.string().optional().describe("Key to delete (omit to clear ALL)") },
  async ({ key }) => {
    _assertTabOwnership("delete_session_storage");
    const result = await safari.deleteSessionStorage({ key });
    return textResult(result);
  }
);

// ========== STORAGE STATE EXPORT/IMPORT ==========

server.tool(
  "safari_export_storage",
  "Export all storage state (cookies + localStorage + sessionStorage) as JSON — useful for saving and restoring login sessions",
  {},
  async () => {
    const result = await safari.exportStorageState();
    return textResult(result);
  }
);

server.tool(
  "safari_import_storage",
  "Import storage state from JSON (as exported by safari_export_storage) — restores cookies, localStorage, sessionStorage",
  { state: z.string().describe("JSON string from safari_export_storage") },
  async ({ state }) => {
    _assertTabOwnership("import_storage");
    const result = await safari.importStorageState({ state });
    return textResult(result);
  }
);

// ========== CLIPBOARD ==========

server.tool(
  "safari_clipboard_read",
  "Read the current clipboard content (text)",
  {},
  async () => {
    const result = await safari.clipboardRead();
    return textResult(result);
  }
);

server.tool(
  "safari_clipboard_write",
  "Write text to the system clipboard",
  { text: z.string().describe("Text to copy to clipboard") },
  async (args) => {
    const result = await safari.clipboardWrite(args);
    return textResult(result);
  }
);

// ========== NETWORK MOCKING ==========

server.tool(
  "safari_mock_route",
  "Intercept network requests matching a URL pattern and return a mock response. Works with both fetch and XHR. Useful for testing API error states, offline behavior, or replacing API responses.",
  {
    urlPattern: z.string().describe("URL substring or regex pattern to match (e.g. '/api/users' or 'example\\.com')"),
    response: z.object({
      status: z.coerce.number().optional().describe("HTTP status code (default: 200)"),
      body: z.string().optional().describe("Response body string (JSON, HTML, text)"),
      contentType: z.string().optional().describe("Content-Type header (default: application/json)"),
    }).describe("Mock response to return"),
  },
  async ({ urlPattern, response }) => {
    _assertTabOwnership("mock_route");
    const result = await safari.mockNetworkRoute({ urlPattern, response });
    return textResult(result);
  }
);

server.tool(
  "safari_clear_mocks",
  "Remove all network route mocks (restore real network behavior)",
  {},
  async () => {
    const result = await safari.clearNetworkMocks();
    return textResult(result);
  }
);

// ========== WAIT FOR TIME ==========

server.tool(
  "safari_wait",
  "Wait for a fixed time in milliseconds. Use only when you need a brief pause between actions. PREFER safari_wait_for (waits for element/text to appear) — it's smarter and doesn't waste time.",
  { ms: z.coerce.number().describe("Milliseconds to wait") },
  async ({ ms }) => {
    const result = await safari.waitForTime({ ms });
    return textResult(result);
  }
);

// ========== NETWORK CAPTURE (Detailed) ==========

server.tool(
  "safari_start_network_capture",
  "Start capturing detailed network requests (fetch + XHR) with headers, status, timing. Call once per page. Intercepts fetch/XHR — captures requests AFTER this call only. For quick overview of already-loaded resources, use safari_network instead.",
  {},
  async () => {
    const result = await safari.startNetworkCapture();
    return textResult(result);
  }
);

server.tool(
  "safari_network_details",
  "Get captured network requests with full details (must call safari_start_network_capture first)",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    limit: z.coerce.number().optional().describe("Max requests (default: 50)"),
    filter: z.string().optional().describe("Filter by URL substring"),
  },
  async (args) => {
    const result = await safari.getNetworkDetails(args);
    return textResult(result);
  }
);

server.tool(
  "safari_clear_network",
  "Clear all captured network requests",
  {},
  async () => {
    const result = await safari.clearNetworkCapture();
    return textResult(result);
  }
);

// ========== PERFORMANCE METRICS ==========

server.tool(
  "safari_performance_metrics",
  "Get detailed performance metrics: navigation timing, Web Vitals (FCP, LCP, CLS), resource breakdown, memory usage",
  {},
  async () => {
    const result = await safari.getPerformanceMetrics();
    return textResult(result);
  }
);

// ========== NETWORK THROTTLING ==========

server.tool(
  "safari_throttle_network",
  "Simulate slow network conditions. Profiles: slow-3g, fast-3g, 4g, offline. Or custom latency/speed. Call with no args to reset.",
  {
    profile: z.string().optional().describe("Preset: slow-3g, fast-3g, 4g, offline"),
    latency: z.coerce.number().optional().describe("Custom latency in ms"),
    downloadKbps: z.coerce.number().optional().describe("Custom download speed in Kbps"),
  },
  async (args) => {
    _assertTabOwnership("throttle");
    const result = await safari.throttleNetwork(args);
    return textResult(result);
  }
);

// ========== CONSOLE FILTER ==========

server.tool(
  "safari_console_filter",
  "Get console messages filtered by level (must call safari_start_console first)",
  { level: z.enum(["log", "warn", "error", "info"]).describe("Console level to filter") },
  async (args) => {
    const result = await safari.getConsoleByLevel(args);
    return textResult(result);
  }
);

// ========== DATA EXTRACTION ==========

server.tool(
  "safari_extract_tables",
  "Extract HTML tables as structured JSON (headers + rows). Perfect for scraping data tables.",
  {
    selector: z.string().optional().describe("CSS selector (default: 'table')"),
    limit: z.coerce.number().optional().describe("Max tables (default: 10)"),
  },
  async (args) => {
    const result = await safari.extractTables(args);
    return textResult(result);
  }
);

server.tool(
  "safari_extract_meta",
  "Extract all meta tags: title, description, canonical, OG tags, Twitter cards, JSON-LD, alternate languages, RSS feeds",
  {},
  async () => {
    const result = await safari.extractMeta();
    return textResult(result);
  }
);

server.tool(
  "safari_extract_images",
  "Extract all images with src, alt, dimensions, loading strategy, viewport visibility",
  { limit: z.coerce.number().optional().describe("Max images (default: 50)") },
  async (args) => {
    const result = await safari.extractImages(args);
    return textResult(result);
  }
);

server.tool(
  "safari_extract_links",
  "Extract all links with href, text, rel, target, external/nofollow detection",
  {
    limit: z.coerce.number().optional().describe("Max links (default: 100)"),
    filter: z.string().optional().describe("Filter by URL or text substring"),
  },
  async (args) => {
    const result = await safari.extractLinks(args);
    return textResult(result);
  }
);

// ========== GEOLOCATION OVERRIDE ==========

server.tool(
  "safari_override_geolocation",
  "Override the browser's geolocation API to return custom coordinates",
  {
    latitude: z.coerce.number().describe("Latitude (-90 to 90)"),
    longitude: z.coerce.number().describe("Longitude (-180 to 180)"),
    accuracy: z.coerce.number().optional().describe("Accuracy in meters (default: 100)"),
  },
  async (args) => {
    _assertTabOwnership("override_geolocation");
    const result = await safari.overrideGeolocation(args);
    return textResult(result);
  }
);

// ========== COMPUTED STYLES ==========

server.tool(
  "safari_get_computed_style",
  "Get computed CSS styles for an element. Optionally filter specific properties.",
  {
    selector: z.string().describe("CSS selector"),
    properties: z.array(z.string()).optional().describe("Specific CSS properties to get (e.g. ['color', 'font-size'])"),
  },
  async (args) => {
    const result = await safari.getComputedStyles(args);
    return textResult(result);
  }
);

// ========== INDEXEDDB ==========

server.tool(
  "safari_list_indexed_dbs",
  "List all IndexedDB databases on the current page",
  {},
  async () => {
    const result = await safari.listIndexedDBs();
    return textResult(result);
  }
);

server.tool(
  "safari_get_indexed_db",
  "Read records from an IndexedDB database store",
  {
    dbName: z.string().describe("Database name"),
    storeName: z.string().describe("Object store name"),
    limit: z.coerce.number().optional().describe("Max records (default: 20)"),
  },
  async (args) => {
    const result = await safari.getIndexedDB(args);
    return textResult(result);
  }
);

// ========== CSS COVERAGE ==========

server.tool(
  "safari_css_coverage",
  "Analyze CSS coverage: find unused CSS rules across all stylesheets. Shows coverage percentage per stylesheet.",
  {},
  async () => {
    const result = await safari.getCSSCoverage();
    return textResult(result);
  }
);

// ========== WEBKIT / iOS WEB-DEV VALIDATION ==========

server.tool(
  "safari_inspect_viewport",
  "Validate the page's <meta name=viewport> against iOS Safari best practices: width=device-width, initial-scale, disabled-zoom (WCAG 1.4.4), viewport-fit=cover. Returns parsed attributes + severity-tagged issues (error/warning/info).",
  {},
  async () => {
    const result = await safari.inspectViewport();
    return textResult(result);
  }
);

server.tool(
  "safari_safe_area_insets",
  "Read the live CSS safe-area-inset values (top/right/bottom/left) as the page sees them, whether viewport-fit=cover is set, and whether env(safe-area-inset-*) is used in any stylesheet. For notch / Dynamic Island layout debugging.",
  {},
  async () => {
    const result = await safari.getSafeAreaInsets();
    return textResult(result);
  }
);

server.tool(
  "safari_check_pwa",
  "Audit the page for iOS 'Add to Home Screen' / PWA readiness: apple-mobile-web-app-capable, apple-touch-icon (incl. 180x180), theme-color, status-bar style, web app manifest, splash screens. Returns a pass/total checklist.",
  {},
  async () => {
    const result = await safari.checkPWA();
    return textResult(result);
  }
);

server.tool(
  "safari_webkit_compat",
  "Check every CSS property used on the page against THIS Safari via CSS.supports() — reports unsupported properties, properties that need a -webkit- prefix, and known Safari rendering quirks (e.g. position:sticky inside overflow ancestors). Tested in the live engine, so no false positives.",
  {},
  async () => {
    const result = await safari.checkWebKitCompat();
    return textResult(result);
  }
);

// ========== DIAGNOSTICS ==========

server.tool(
  "safari_doctor",
  "Diagnose the macOS permission + daemon chain in one shot: Safari running, Apple Events/Automation, native helper daemon, Accessibility (native clicks), Screen Recording, and the helper's codesign identity. Returns a pass/fail checklist with the exact System Settings fix per failure, then the extension bridge: which worker holds the profile lease, every worker that tried and what the profile probe answered it, and what each Safari profile's extension storage holds. Run this FIRST when clicks/screenshots/startup 'don't work even with permissions granted' or when tools answer 'profile extension unavailable'.",
  {},
  async () => {
    const result = await safari.doctor();
    // Best-effort: the permission checklist above must never be lost to a bridge error.
    const bridge = await _extensionBridgeReport().catch((e) => ["", `🔌 Extension bridge: report failed (${String(e.message || e).slice(0, 80)})`]);
    return textResult([result, ...bridge].join("\n"));
  }
);

// ========== FORM AUTO-DETECT ==========

server.tool(
  "safari_detect_forms",
  "Auto-detect all forms on the page with their fields, types, selectors, and submit buttons. Great for automated form filling.",
  {},
  async () => {
    const result = await safari.detectForms();
    return textResult(result);
  }
);

// ========== SCROLL TO ELEMENT ==========

server.tool(
  "safari_scroll_to_element",
  "Scroll to element by CSS selector OR text. For virtual DOM (Airtable) use text — scrolls down until text appears in DOM.",
  {
    receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
    selector: z.string().optional().describe("CSS selector of target element"),
    text: z.string().optional().describe("Text to find — scrolls down until it appears (for virtual DOM/lazy loading)"),
    block: z.enum(["start", "center", "end", "nearest"]).optional().describe("Scroll alignment (default: center)"),
    timeout: z.coerce.number().optional().describe("Max time to scroll in ms (default: 10000)"),
  },
  async (args) => {
    const result = await extensionOrFallback(
      "scroll_to_element",
      { selector: args.selector, text: args.text, block: args.block, ..._explicitReceipt(args) },
      () => safari.scrollToElement(args)
    );
    return textResult(result);
  }
);

// ========== COMBO TOOLS (fast multi-step operations) ==========

server.tool(
  "safari_click_and_wait",
  "Click an element AND wait for the result (page load or element). Use instead of click + wait_for separately.",
  {
    selector: z.string().optional().describe("CSS selector to click"),
    text: z.string().optional().describe("Visible text to click"),
    waitFor: z.string().optional().describe("CSS selector to wait for after click"),
    timeout: z.coerce.number().optional().describe("Wait timeout in ms (default: 10000)"),
  },
  async (args) => {
    _assertTabOwnership("click_and_wait");
    const result = await safari.clickAndWait(args);
    return textResult(result);
  }
);

server.tool(
  "safari_fill_and_submit",
  "Fill a form AND submit it in one operation. Finds submit button automatically if not specified.",
  {
    fields: z.array(z.object({
      selector: z.string().describe("CSS selector"),
      value: z.string().describe("Value to fill"),
    })).describe("Fields to fill"),
    submitSelector: z.string().optional().describe("Submit button selector (auto-detected if omitted)"),
  },
  async (args) => {
    _assertTabOwnership("fill_and_submit");
    const result = await safari.fillAndSubmit(args);
    return textResult(result);
  }
);

server.tool(
  "safari_analyze_page",
  "Full page analysis in ONE call: title, URL, meta tags, OG, headings, link stats, image stats, forms, and text preview. Perfect for SEO/audit.",
  {},
  async () => {
    const result = await safari.analyzePage();
    return textResult(result);
  }
);

  // HTTP mode builds one server per session; the daily state file keeps the banner
  // to once a day anyway, and this flag keeps it to once a process.
  if (!_bannerShown) { _bannerShown = true; _showStartupBanner(server); }
  return server;
}
let _bannerShown = false;

// ========== START SERVER ==========

// One-time-per-day startup banner — visible CTA without spamming MCP logs.
// Stderr only (stdout is reserved for MCP protocol). Skipped if SAFARI_MCP_QUIET=1.
//
// Takes the server it should count tools from: `server` is a local of buildServer(),
// so reading it at module scope threw a ReferenceError that this function's own
// best-effort catch swallowed — the banner printed nothing at all from v2.16.2 until
// this was fixed. Called from buildServer(), which is the first point an instance exists.
function _showStartupBanner(server) {
try {
  if (process.env.SAFARI_MCP_QUIET !== "1") {
    const bannerStateFile = join(homedir(), ".safari-mcp", "last-banner");
    if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
    let lastShown = 0;
    try { lastShown = parseInt(readFileSync(bannerStateFile, "utf8"), 10) || 0; } catch {}
    const ONE_DAY_MS = 24 * 60 * 60 * 1000;
    if (Date.now() - lastShown > ONE_DAY_MS) {
      const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "package.json");
      let version = "?";
      try { version = JSON.parse(readFileSync(pkgPath, "utf8")).version; } catch {}
      console.error("");
      // Count tools at runtime — a hardcoded number here drifted to 96 while the code had 97.
      const toolCount = Object.keys(server._registeredTools ?? {}).length;
      const toolsPhrase = toolCount ? `${toolCount} tools, ` : "";
      console.error(`[Safari MCP] 🦁 v${version} ready — ${toolsPhrase}native WebKit, zero Chrome.`);
      console.error(`[Safari MCP] ⭐ Like it? Star: https://github.com/achiya-automation/safari-mcp`);
      console.error("");
      try { writeFileSync(bannerStateFile, String(Date.now()), { mode: 0o600 }); } catch {}
    }
  }
} catch { /* banner is best-effort, never block startup */ }
}

// Under a profile the monitor can only log: _closeOldestMCPTab() refuses to touch profile tabs, and
// its RSS reading hides compressed/swapped pages (433MB of RSS against 6.8GB of footprint, 27.9).
if (!process.env.SAFARI_PROFILE) _startMemoryMonitor();

// Runtime guard for the silent stale-identity regression (#29): postinstall re-signs the
// helper to a stable id so the Accessibility grant persists, but `npm ci`, `--ignore-scripts`,
// Docker and Smithery installs skip postinstall entirely — leaving the swiftc one-off id and
// a grant that breaks invisibly. Warn once on stderr (non-blocking) so it's diagnosable.
(function warnIfHelperIdentityDrifted() {
  try {
    if (process.platform !== "darwin") return;
    const helper = join(dirname(fileURLToPath(import.meta.url)), "safari-helper");
    if (!existsSync(helper)) return;
    execFile("codesign", ["-d", "--verbose=2", helper], { timeout: 4000 }, (_err, stdout, stderr) => {
      const text = (stdout || "") + (stderr || "");
      if (text && !/Identifier=com\.achiya-automation\.safari-mcp/.test(text)) {
        console.error("[Safari MCP] ⚠ safari-helper codesign identity is not the stable id — native clicks may silently fail. Run the safari_doctor tool to diagnose.");
      }
    });
  } catch { /* best-effort, never block startup */ }
})();

// Transport is chosen at runtime: default stdio (unchanged), or a shared HTTP instance when
// SAFARI_MCP_HTTP=1 (many Claude sessions → one process). buildServer is the per-session factory.
const transportHandle = await startTransport(buildServer, process.env, { onSessionClosed: _closeSessionTabs });

if (transportHandle.kind === "stdio") {
  // A client can disappear without delivering a signal. Treat its pipe closing as the stdio
  // server's lifecycle boundary, while leaving the opt-in HTTP daemon independent of stdin.
  process.stdin.once("end", _shutdown);
  process.stdin.once("close", _shutdown);
  if (process.stdin.readableEnded || process.stdin.destroyed) void _shutdown();
}